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:
parent
3fea3931f5
commit
b03264ceb1
16
.env.example
16
.env.example
|
|
@ -38,20 +38,8 @@ NODE_ENV=production
|
||||||
# BACKUP_PATH=/opt/bill-tracker/data/backups
|
# BACKUP_PATH=/opt/bill-tracker/data/backups
|
||||||
|
|
||||||
# ── Bank Sync (SimpleFIN) ─────────────────────────────────────────────────────
|
# ── Bank Sync (SimpleFIN) ─────────────────────────────────────────────────────
|
||||||
# Optional. Disabled by default. Requires a SimpleFIN Bridge account.
|
# Enable/disable bank sync from the Admin panel. Users connect their own
|
||||||
# Users connect their own SimpleFIN Bridge — BillTracker never stores bank credentials.
|
# SimpleFIN Bridge from the Data page. No environment config required.
|
||||||
#
|
|
||||||
# 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
|
|
||||||
|
|
||||||
# ── First-run admin account ────────────────────────────────────────────────────
|
# ── First-run admin account ────────────────────────────────────────────────────
|
||||||
# Set BOTH on first start to create the admin account automatically.
|
# Set BOTH on first start to create the admin account automatically.
|
||||||
|
|
|
||||||
|
|
@ -1,6 +1,6 @@
|
||||||
import React, { useState, useEffect, useCallback } from 'react';
|
import React, { useState, useEffect, useCallback } from 'react';
|
||||||
import { toast } from 'sonner';
|
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 { api } from '@/api';
|
||||||
import { cn } from '@/lib/utils';
|
import { cn } from '@/lib/utils';
|
||||||
import { Button } from '@/components/ui/button';
|
import { Button } from '@/components/ui/button';
|
||||||
|
|
@ -11,6 +11,41 @@ import {
|
||||||
} from '@/components/ui/alert-dialog';
|
} from '@/components/ui/alert-dialog';
|
||||||
import { SectionCard } from './dataShared';
|
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 }) {
|
export default function BankSyncSection({ onConnectionChange }) {
|
||||||
const [enabled, setEnabled] = useState(null);
|
const [enabled, setEnabled] = useState(null);
|
||||||
const [connections, setConnections] = useState([]);
|
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">
|
<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 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>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>
|
||||||
<div className="flex gap-2">
|
<div className="flex gap-2">
|
||||||
<Input
|
<TokenInput
|
||||||
value={setupToken}
|
value={setupToken}
|
||||||
onChange={e => setSetupToken(e.target.value)}
|
onChange={e => setSetupToken(e.target.value)}
|
||||||
placeholder="Paste SimpleFIN setup token…"
|
disabled={connecting}
|
||||||
className="flex-1 font-mono text-xs"
|
|
||||||
/>
|
/>
|
||||||
<Button onClick={handleConnect} disabled={connecting || !setupToken.trim()} className="shrink-0">
|
<Button onClick={handleConnect} disabled={connecting || !setupToken.trim()} className="shrink-0">
|
||||||
{connecting ? <><Loader2 className="h-4 w-4 animate-spin mr-1.5" />Connecting…</> : 'Connect'}
|
{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 && (
|
{connections.length > 0 && (
|
||||||
<div className="px-6 py-4 border-t border-border/50 space-y-2">
|
<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">
|
<div className="flex gap-2">
|
||||||
<Input
|
<TokenInput
|
||||||
value={setupToken}
|
value={setupToken}
|
||||||
onChange={e => setSetupToken(e.target.value)}
|
onChange={e => setSetupToken(e.target.value)}
|
||||||
placeholder="Paste SimpleFIN setup token…"
|
disabled={connecting}
|
||||||
className="flex-1 font-mono text-xs"
|
|
||||||
/>
|
/>
|
||||||
<Button size="sm" onClick={handleConnect} disabled={connecting || !setupToken.trim()} className="shrink-0">
|
<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'}
|
{connecting ? <><Loader2 className="h-3.5 w-3.5 animate-spin mr-1" />Connecting…</> : 'Connect'}
|
||||||
|
|
|
||||||
|
|
@ -1,6 +1,6 @@
|
||||||
{
|
{
|
||||||
"name": "bill-tracker",
|
"name": "bill-tracker",
|
||||||
"version": "0.30.5",
|
"version": "0.31.0",
|
||||||
"description": "Monthly bill tracking system",
|
"description": "Monthly bill tracking system",
|
||||||
"main": "server.js",
|
"main": "server.js",
|
||||||
"scripts": {
|
"scripts": {
|
||||||
|
|
|
||||||
|
|
@ -4,11 +4,6 @@ const { getSetting, setSetting } = require('../db/database');
|
||||||
|
|
||||||
const SYNC_DAYS_DEFAULT = 90;
|
const SYNC_DAYS_DEFAULT = 90;
|
||||||
|
|
||||||
function encryptionKeyReady() {
|
|
||||||
const key = process.env.TOKEN_ENCRYPTION_KEY || '';
|
|
||||||
return Buffer.from(key, 'utf8').length >= 32;
|
|
||||||
}
|
|
||||||
|
|
||||||
function getBankSyncConfig() {
|
function getBankSyncConfig() {
|
||||||
const dbValue = getSetting('bank_sync_enabled');
|
const dbValue = getSetting('bank_sync_enabled');
|
||||||
const envValue = process.env.BANK_SYNC_ENABLED;
|
const envValue = process.env.BANK_SYNC_ENABLED;
|
||||||
|
|
@ -32,7 +27,6 @@ function getBankSyncConfig() {
|
||||||
|
|
||||||
return {
|
return {
|
||||||
enabled,
|
enabled,
|
||||||
encryption_key_set: encryptionKeyReady(),
|
|
||||||
sync_days: syncDays,
|
sync_days: syncDays,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -6,17 +6,29 @@ const ALGORITHM = 'aes-256-gcm';
|
||||||
const IV_BYTES = 12;
|
const IV_BYTES = 12;
|
||||||
const TAG_BYTES = 16;
|
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() {
|
function getKey() {
|
||||||
const raw = process.env.TOKEN_ENCRYPTION_KEY || '';
|
const envRaw = process.env.TOKEN_ENCRYPTION_KEY || '';
|
||||||
if (!raw) throw new Error('TOKEN_ENCRYPTION_KEY is not set');
|
if (envRaw) {
|
||||||
const buf = Buffer.from(raw, 'utf8');
|
const buf = Buffer.from(envRaw, 'utf8');
|
||||||
if (buf.length < 32) throw new Error('TOKEN_ENCRYPTION_KEY must be at least 32 bytes');
|
if (buf.length < 32) throw new Error('TOKEN_ENCRYPTION_KEY must be at least 32 bytes');
|
||||||
return crypto.createHash('sha256').update(buf).digest();
|
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() {
|
// No-op now that the key is always available — kept for call-site compatibility
|
||||||
getKey();
|
function assertEncryptionReady() {}
|
||||||
}
|
|
||||||
|
|
||||||
function encryptSecret(plaintext) {
|
function encryptSecret(plaintext) {
|
||||||
const key = getKey();
|
const key = getKey();
|
||||||
|
|
@ -31,10 +43,10 @@ function decryptSecret(stored) {
|
||||||
const parts = stored.split(':');
|
const parts = stored.split(':');
|
||||||
if (parts.length !== 3) throw new Error('Invalid encrypted secret format');
|
if (parts.length !== 3) throw new Error('Invalid encrypted secret format');
|
||||||
const [ivHex, tagHex, ctHex] = parts;
|
const [ivHex, tagHex, ctHex] = parts;
|
||||||
const key = getKey();
|
const key = getKey();
|
||||||
const iv = Buffer.from(ivHex, 'hex');
|
const iv = Buffer.from(ivHex, 'hex');
|
||||||
const tag = Buffer.from(tagHex, 'hex');
|
const tag = Buffer.from(tagHex, 'hex');
|
||||||
const ct = Buffer.from(ctHex, 'hex');
|
const ct = Buffer.from(ctHex, 'hex');
|
||||||
const decipher = crypto.createDecipheriv(ALGORITHM, key, iv, { authTagLength: TAG_BYTES });
|
const decipher = crypto.createDecipheriv(ALGORITHM, key, iv, { authTagLength: TAG_BYTES });
|
||||||
decipher.setAuthTag(tag);
|
decipher.setAuthTag(tag);
|
||||||
return Buffer.concat([decipher.update(ct), decipher.final()]).toString('utf8');
|
return Buffer.concat([decipher.update(ct), decipher.final()]).toString('utf8');
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue