fix: encryptionService removes env-var key path, always uses auto-generated DB key

This commit is contained in:
null 2026-06-03 23:21:48 -05:00
parent a88d5c4647
commit 0011ade58d
3 changed files with 73 additions and 36 deletions

View File

@ -1043,11 +1043,44 @@ export default function BillModal({ bill, initialBill, categories, onClose, onSa
{/* Bank Matching Rules */}
{!isNew && (
<div className="mt-4 rounded-lg border border-border/60 bg-background/35">
<div className="border-b border-border/50 px-3 py-2.5">
<p className="text-xs font-semibold uppercase tracking-wider text-muted-foreground">Bank matching rules</p>
<p className="mt-0.5 text-[11px] text-muted-foreground/70">
Transactions whose description contains these patterns are automatically imported as payments.
</p>
<div className="flex items-center justify-between gap-3 border-b border-border/50 px-3 py-2.5">
<div>
<p className="text-xs font-semibold uppercase tracking-wider text-muted-foreground">Bank matching rules</p>
<p className="mt-0.5 text-[11px] text-muted-foreground/70">
Transactions whose description contains these patterns are automatically imported as payments.
</p>
</div>
{sourceBill?.has_merchant_rule && (
<Button
type="button"
size="sm"
variant="outline"
className="shrink-0 gap-1.5 text-xs"
disabled={syncingPayments}
title="Scan unmatched bank transactions and import any matching payments for this bill"
onClick={async () => {
setSyncingPayments(true);
try {
const result = await api.syncBillSimplefinPayments(sourceBill.id);
if (result.added > 0) {
toast.success(`${result.added} payment${result.added !== 1 ? 's' : ''} imported from bank history.`);
loadLinkedTransactions?.();
refetch?.();
} else {
toast.info('No new matching transactions found.');
}
} catch (err) {
toast.error(err.message || 'Sync failed.');
} finally {
setSyncingPayments(false);
}
}}
>
{syncingPayments
? <><Loader2 className="h-3.5 w-3.5 animate-spin" />Syncing</>
: <><RefreshCw className="h-3.5 w-3.5" />Sync</>}
</Button>
)}
</div>
<div className="px-3 py-3">
<BillMerchantRules

View File

@ -1,6 +1,7 @@
'use strict';
const { normalizeMerchant } = require('./subscriptionService');
const { computeBalanceDelta } = require('./billsService');
// Persist a merchant→bill rule so future synced transactions auto-match.
function addMerchantRule(db, userId, billId, merchant) {
@ -48,10 +49,12 @@ function applyMerchantRules(db, userId) {
if (txRows.length === 0) return { matched: 0 };
const getBill = db.prepare('SELECT current_balance, interest_rate FROM bills WHERE id = ? AND deleted_at IS NULL');
const insertPayment = db.prepare(`
INSERT OR IGNORE INTO payments (bill_id, amount, paid_date, payment_source, transaction_id)
VALUES (?, ?, ?, 'auto_match', ?)
INSERT OR IGNORE INTO payments (bill_id, amount, paid_date, payment_source, transaction_id, balance_delta)
VALUES (?, ?, ?, 'provider_sync', ?, ?)
`);
const updateBalance = db.prepare("UPDATE bills SET current_balance = ?, updated_at = datetime('now') WHERE id = ?");
const updateTx = db.prepare(`
UPDATE transactions
SET matched_bill_id = ?, match_status = 'matched', updated_at = datetime('now')
@ -73,10 +76,18 @@ function applyMerchantRules(db, userId) {
const paidDate = tx.posted_date || (tx.transacted_at ? String(tx.transacted_at).slice(0, 10) : null);
if (!paidDate) continue;
const amount = Math.round(Math.abs(tx.amount)) / 100;
insertPayment.run(rule.bill_id, amount, paidDate, tx.id);
updateTx.run(rule.bill_id, tx.id, userId);
matched++;
const amount = Math.round(Math.abs(tx.amount)) / 100;
// Read bill fresh so sequential matches for the same bill chain correctly
const bill = getBill.get(rule.bill_id);
const balCalc = bill ? computeBalanceDelta(bill, amount) : null;
const result = insertPayment.run(rule.bill_id, amount, paidDate, tx.id, balCalc?.balance_delta ?? null);
if (result.changes > 0) {
// Only update balance and mark matched if the payment was actually inserted
if (balCalc) updateBalance.run(balCalc.new_balance, rule.bill_id);
updateTx.run(rule.bill_id, tx.id, userId);
matched++;
}
}
})();
@ -126,10 +137,12 @@ function syncBillPaymentsFromSimplefin(db, userId, billId) {
if (txRows.length === 0) return { added: 0 };
const getBill = db.prepare('SELECT current_balance, interest_rate FROM bills WHERE id = ? AND deleted_at IS NULL');
const insertPayment = db.prepare(`
INSERT OR IGNORE INTO payments (bill_id, amount, paid_date, payment_source, transaction_id)
VALUES (?, ?, ?, 'auto_match', ?)
INSERT OR IGNORE INTO payments (bill_id, amount, paid_date, payment_source, transaction_id, balance_delta)
VALUES (?, ?, ?, 'provider_sync', ?, ?)
`);
const updateBalance = db.prepare("UPDATE bills SET current_balance = ?, updated_at = datetime('now') WHERE id = ?");
const updateTx = db.prepare(`
UPDATE transactions
SET matched_bill_id = ?, match_status = 'matched', updated_at = datetime('now')
@ -145,10 +158,16 @@ function syncBillPaymentsFromSimplefin(db, userId, billId) {
if (!matches) continue;
const paidDate = tx.posted_date || (tx.transacted_at ? String(tx.transacted_at).slice(0, 10) : null);
if (!paidDate) continue;
const amount = Math.round(Math.abs(tx.amount)) / 100;
insertPayment.run(billId, amount, paidDate, tx.id);
updateTx.run(billId, tx.id, userId);
added++;
const amount = Math.round(Math.abs(tx.amount)) / 100;
const bill = getBill.get(billId);
const balCalc = bill ? computeBalanceDelta(bill, amount) : null;
const result = insertPayment.run(billId, amount, paidDate, tx.id, balCalc?.balance_delta ?? null);
if (result.changes > 0) {
if (balCalc) updateBalance.run(balCalc.new_balance, billId);
updateTx.run(billId, tx.id, userId);
added++;
}
}
})();

View File

@ -10,26 +10,11 @@ const HKDF_INFO = 'bill-tracker-token-encryption-v1';
// Prefix that identifies ciphertext produced with HKDF key derivation.
const V2_PREFIX = 'v2:';
let _warnedAutoKey = false;
// Returns the raw key material (IKM) without derivation — shared by both paths.
// Returns the raw key material (IKM) without derivation.
// The encryption key is auto-generated on first run and stored in the database
// under `_auto_encryption_key`. No environment variable required — all settings
// live in the app. The key is created once and reused across restarts.
function getIkm() {
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 buf;
}
if (!_warnedAutoKey) {
_warnedAutoKey = true;
console.warn(
'[security] TOKEN_ENCRYPTION_KEY is not set. Using an auto-generated key ' +
'stored in the database alongside the encrypted data. Set TOKEN_ENCRYPTION_KEY ' +
'as an environment variable to keep the key separate from the data it protects.',
);
}
// Lazy-require to avoid circular dependency at module load time
const { getSetting, setSetting } = require('../db/database');
let stored = getSetting('_auto_encryption_key');