feat: zero-config encryption + SimpleFIN Bridge links

- encryptionService.js: getKey() tries TOKEN_ENCRYPTION_KEY env first, then
  auto-generates a random 48-byte key on first startup, persists to settings
  as _auto_encryption_key. assertEncryptionReady() is now a no-op.
- bankSyncConfigService.js: removed encryption_key_set response and
  encryptionKeyReady() helper. No env config required.
- .env.example: TOKEN_ENCRYPTION_KEY removed. Comment says enable from Admin
  panel, no env config required.
- BankSyncSection.jsx: added SimpleFIN Bridge links — 'Open SimpleFIN Bridge'
  for first-time setup, 'Get a SimpleFIN token' for existing connections
This commit is contained in:
null 2026-05-29 00:04:28 -05:00
parent 3fea3931f5
commit b03264ceb1
5 changed files with 91 additions and 41 deletions

View File

@ -38,20 +38,8 @@ NODE_ENV=production
# BACKUP_PATH=/opt/bill-tracker/data/backups
# ── Bank Sync (SimpleFIN) ─────────────────────────────────────────────────────
# Optional. Disabled by default. Requires a SimpleFIN Bridge account.
# Users connect their own SimpleFIN Bridge — BillTracker never stores bank credentials.
#
# BANK_SYNC_ENABLED=false
#
# Required when BANK_SYNC_ENABLED=true. Must be at least 32 characters.
# Used to encrypt the SimpleFIN Access URL at rest.
# TOKEN_ENCRYPTION_KEY=replace-with-a-long-random-secret-at-least-32-chars
#
# How many days back to fetch transactions on first sync (default: 90).
# SIMPLEFIN_SYNC_DAYS=90
#
# How often the background auto-sync worker runs (default: 4 hours, minimum: 0.5).
# SIMPLEFIN_SYNC_INTERVAL_HOURS=4
# Enable/disable bank sync from the Admin panel. Users connect their own
# SimpleFIN Bridge from the Data page. No environment config required.
# ── First-run admin account ────────────────────────────────────────────────────
# Set BOTH on first start to create the admin account automatically.

View File

@ -1,6 +1,6 @@
import React, { useState, useEffect, useCallback } from 'react';
import { toast } from 'sonner';
import { Building2, Link2Off, Loader2, RefreshCw } from 'lucide-react';
import { Building2, Eye, EyeOff, ExternalLink, Link2Off, Loader2, RefreshCw } from 'lucide-react';
import { api } from '@/api';
import { cn } from '@/lib/utils';
import { Button } from '@/components/ui/button';
@ -11,6 +11,41 @@ import {
} from '@/components/ui/alert-dialog';
import { SectionCard } from './dataShared';
function TokenInput({ value, onChange, disabled }) {
const [show, setShow] = useState(false);
const tail = value.slice(-4);
return (
<div className="flex-1 space-y-1">
<div className="relative">
<Input
value={value}
onChange={onChange}
type={show ? 'text' : 'password'}
placeholder="Paste SimpleFIN setup token…"
className="font-mono text-xs pr-8"
disabled={disabled}
autoComplete="off"
/>
{value && (
<button
type="button"
tabIndex={-1}
onClick={() => setShow(v => !v)}
className="absolute right-2 top-1/2 -translate-y-1/2 text-muted-foreground hover:text-foreground transition-colors"
>
{show ? <EyeOff className="h-3.5 w-3.5" /> : <Eye className="h-3.5 w-3.5" />}
</button>
)}
</div>
{value && !show && (
<p className="text-[11px] text-muted-foreground font-mono pl-0.5 select-none">
···{tail}
</p>
)}
</div>
);
}
export default function BankSyncSection({ onConnectionChange }) {
const [enabled, setEnabled] = useState(null);
const [connections, setConnections] = useState([]);
@ -172,13 +207,24 @@ export default function BankSyncSection({ onConnectionChange }) {
<div className="rounded-lg border border-border/60 bg-muted/20 p-4 text-sm text-muted-foreground">
<p className="font-medium text-foreground mb-1">Connect a SimpleFIN Bridge account</p>
<p>Paste your SimpleFIN setup token below. BillTracker only stores an encrypted access URL no bank credentials are saved.</p>
<p className="mt-2">
Need a token?{' '}
<a
href="https://beta-bridge.simplefin.org/"
target="_blank"
rel="noreferrer"
className="inline-flex items-center gap-1 font-medium text-primary underline-offset-4 hover:underline"
>
Open SimpleFIN Bridge
<ExternalLink className="h-3 w-3" />
</a>
</p>
</div>
<div className="flex gap-2">
<Input
<TokenInput
value={setupToken}
onChange={e => setSetupToken(e.target.value)}
placeholder="Paste SimpleFIN setup token…"
className="flex-1 font-mono text-xs"
disabled={connecting}
/>
<Button onClick={handleConnect} disabled={connecting || !setupToken.trim()} className="shrink-0">
{connecting ? <><Loader2 className="h-4 w-4 animate-spin mr-1.5" />Connecting</> : 'Connect'}
@ -189,13 +235,23 @@ export default function BankSyncSection({ onConnectionChange }) {
{connections.length > 0 && (
<div className="px-6 py-4 border-t border-border/50 space-y-2">
<p className="text-xs font-medium text-muted-foreground">Add another connection</p>
<div className="flex flex-col gap-1 sm:flex-row sm:items-center sm:justify-between">
<p className="text-xs font-medium text-muted-foreground">Add another connection</p>
<a
href="https://beta-bridge.simplefin.org/"
target="_blank"
rel="noreferrer"
className="inline-flex items-center gap-1 text-xs font-medium text-primary underline-offset-4 hover:underline"
>
Get a SimpleFIN token
<ExternalLink className="h-3 w-3" />
</a>
</div>
<div className="flex gap-2">
<Input
<TokenInput
value={setupToken}
onChange={e => setSetupToken(e.target.value)}
placeholder="Paste SimpleFIN setup token…"
className="flex-1 font-mono text-xs"
disabled={connecting}
/>
<Button size="sm" onClick={handleConnect} disabled={connecting || !setupToken.trim()} className="shrink-0">
{connecting ? <><Loader2 className="h-3.5 w-3.5 animate-spin mr-1" />Connecting</> : 'Connect'}

View File

@ -1,6 +1,6 @@
{
"name": "bill-tracker",
"version": "0.30.5",
"version": "0.31.0",
"description": "Monthly bill tracking system",
"main": "server.js",
"scripts": {

View File

@ -4,11 +4,6 @@ const { getSetting, setSetting } = require('../db/database');
const SYNC_DAYS_DEFAULT = 90;
function encryptionKeyReady() {
const key = process.env.TOKEN_ENCRYPTION_KEY || '';
return Buffer.from(key, 'utf8').length >= 32;
}
function getBankSyncConfig() {
const dbValue = getSetting('bank_sync_enabled');
const envValue = process.env.BANK_SYNC_ENABLED;
@ -32,7 +27,6 @@ function getBankSyncConfig() {
return {
enabled,
encryption_key_set: encryptionKeyReady(),
sync_days: syncDays,
};
}

View File

@ -6,17 +6,29 @@ const ALGORITHM = 'aes-256-gcm';
const IV_BYTES = 12;
const TAG_BYTES = 16;
// Returns a stable 256-bit key. Prefers TOKEN_ENCRYPTION_KEY env var (power-user
// override); otherwise auto-generates a random key on first startup and persists
// it in the settings table so it survives restarts without any manual config.
function getKey() {
const raw = process.env.TOKEN_ENCRYPTION_KEY || '';
if (!raw) throw new Error('TOKEN_ENCRYPTION_KEY is not set');
const buf = Buffer.from(raw, 'utf8');
if (buf.length < 32) throw new Error('TOKEN_ENCRYPTION_KEY must be at least 32 bytes');
return crypto.createHash('sha256').update(buf).digest();
const envRaw = process.env.TOKEN_ENCRYPTION_KEY || '';
if (envRaw) {
const buf = Buffer.from(envRaw, 'utf8');
if (buf.length < 32) throw new Error('TOKEN_ENCRYPTION_KEY must be at least 32 bytes');
return crypto.createHash('sha256').update(buf).digest();
}
// Lazy-require to avoid circular dependency at module load time
const { getSetting, setSetting } = require('../db/database');
let stored = getSetting('_auto_encryption_key');
if (!stored) {
stored = crypto.randomBytes(48).toString('hex');
setSetting('_auto_encryption_key', stored);
}
return crypto.createHash('sha256').update(stored, 'utf8').digest();
}
function assertEncryptionReady() {
getKey();
}
// No-op now that the key is always available — kept for call-site compatibility
function assertEncryptionReady() {}
function encryptSecret(plaintext) {
const key = getKey();
@ -31,10 +43,10 @@ function decryptSecret(stored) {
const parts = stored.split(':');
if (parts.length !== 3) throw new Error('Invalid encrypted secret format');
const [ivHex, tagHex, ctHex] = parts;
const key = getKey();
const iv = Buffer.from(ivHex, 'hex');
const tag = Buffer.from(tagHex, 'hex');
const ct = Buffer.from(ctHex, 'hex');
const key = getKey();
const iv = Buffer.from(ivHex, 'hex');
const tag = Buffer.from(tagHex, 'hex');
const ct = Buffer.from(ctHex, 'hex');
const decipher = crypto.createDecipheriv(ALGORITHM, key, iv, { authTagLength: TAG_BYTES });
decipher.setAuthTag(tag);
return Buffer.concat([decipher.update(ct), decipher.final()]).toString('utf8');