diff --git a/.env.example b/.env.example index 7bc6639..91f9ba7 100644 --- a/.env.example +++ b/.env.example @@ -9,9 +9,11 @@ NODE_ENV=production # ── CSRF Cookie httpOnly Setting ────────────────────────────────────────────── # CSRF cookie httpOnly setting (default: true) -# Set CSRF_HTTP_ONLY=false to allow JavaScript access for SPA CSRF patterns -# CSRF_HTTP_ONLY: "true" (secure, default - cookie not readable by JS) -# CSRF_HTTP_ONLY: "false" (SPA mode - allows JavaScript to read cookie) +# The SPA fetches the token from GET /api/auth/csrf-token and stores it in +# memory — JavaScript does not need to read the cookie directly. httpOnly=true +# removes the token from the XSS-accessible cookie surface. +# CSRF_HTTP_ONLY: "true" (default — cookie not readable by document.cookie) +# CSRF_HTTP_ONLY: "false" (legacy — only if a custom client reads document.cookie) # # ── CSRF Cookie sameSite Setting ────────────────────────────────────────────── # CSRF cookie sameSite setting (default: strict) @@ -37,6 +39,18 @@ NODE_ENV=production # DB_PATH=/opt/bill-tracker/data/db/bills.db # BACKUP_PATH=/opt/bill-tracker/data/backups +# ── Encryption key ──────────────────────────────────────────────────────────── +# AES-256-GCM key used to encrypt secrets at rest (SimpleFIN tokens, SMTP passwords). +# Must be at least 32 bytes. Any printable string works; a random hex string is best. +# +# Generate one with: node -e "console.log(require('crypto').randomBytes(48).toString('hex'))" +# +# If not set, Bill Tracker auto-generates a key and stores it in the database +# next to the encrypted data — anyone with database read access can decrypt. +# Set this variable in production to keep the key separate from the data. +# +# TOKEN_ENCRYPTION_KEY=replace-with-a-long-random-string-at-least-32-chars + # ── Bank Sync (SimpleFIN) ───────────────────────────────────────────────────── # Enable/disable bank sync from the Admin panel. Users connect their own # SimpleFIN Bridge from the Data page. No environment config required. diff --git a/HISTORY.md b/HISTORY.md index d6e83af..4e07230 100644 --- a/HISTORY.md +++ b/HISTORY.md @@ -1,5 +1,31 @@ # Bill Tracker — Changelog +## v0.36.0 + +### 🔧 Changed + +- **Bump** — `0.35.0` → `0.36.0` + +### 🔒 Security + +- **CSRF token moved out of readable cookie** — The CSRF cookie previously defaulted to `httpOnly: false` so the SPA could read it from `document.cookie`. Any XSS vulnerability could steal the token from there and bypass CSRF protection entirely. The cookie is now `httpOnly: true` by default, removing it from the XSS-accessible cookie surface. The SPA instead fetches the token once from `GET /api/auth/csrf-token` on startup and stores it in a module-level memory cache; all mutations continue to send it in the `x-csrf-token` header unchanged. The server-side double-submit validation (`header == cookie`) is identical. `CSRF_HTTP_ONLY=false` remains available in `.env` for compatibility, but is no longer the default. + +--- + +## v0.35.0 + +### 🔧 Changed + +- **Bump** — `0.34.3` → `0.35.0` + +### 🔒 Security + +- **TOKEN_ENCRYPTION_KEY deployment guidance** — When `TOKEN_ENCRYPTION_KEY` is not set, `encryptionService.js` auto-generates a random key and persists it in the `user_settings` table — placing the key and the ciphertext in the same SQLite file. Anyone with database read access has everything needed to decrypt. The threat is bounded (filesystem access = game over regardless), but is now explicitly surfaced: a one-time `console.warn` fires on first use of the auto-generated key path, directing operators to set the env var. `.env.example` gains a `TOKEN_ENCRYPTION_KEY` section with a generation command and a plain-English explanation of the trade-off. + +- **HKDF key derivation with automatic migration** — `encryptionService.js` previously derived the AES-256-GCM key from raw input via `SHA-256(ikm)`, which lacks domain separation and offers no protection if a low-entropy passphrase is supplied. Replaced with **HKDF-SHA-256** (RFC 5869) using info label `bill-tracker-token-encryption-v1`. New ciphertext carries a `v2:` prefix; `decryptSecret` uses it to choose the correct derivation path, so legacy and new ciphertext coexist transparently. DB migration `v0.78` re-encrypts all existing secrets (`data_sources.encrypted_secret` and `notify_smtp_password`) to the v2 format on first startup — no manual action required. + +--- + ## v0.34.3 ### 🔧 Changed diff --git a/client/api.js b/client/api.js index 83ee902..1d0f8cb 100644 --- a/client/api.js +++ b/client/api.js @@ -1,16 +1,20 @@ -// Read CSRF token from cookie -function getCsrfToken() { - if (typeof document === 'undefined') return ''; - const name = 'bt_csrf_token'; - const match = document.cookie.match(new RegExp(name + '=([^;]+)')); - return match ? match[1] : ''; +// Fetch CSRF token from the server once and cache in memory. +// The cookie is httpOnly so document.cookie cannot access it directly. +let _csrfFetch = null; +async function getCsrfToken() { + if (!_csrfFetch) { + _csrfFetch = fetch('/api/auth/csrf-token', { credentials: 'include' }) + .then(r => r.json()) + .then(d => d.token || ''); + } + return _csrfFetch; } async function _fetch(method, path, body) { const opts = { method, headers: { 'Content-Type': 'application/json' }, credentials: 'include' }; // Add CSRF token header for state-changing methods if (['POST', 'PUT', 'DELETE', 'PATCH'].includes(method)) { - const csrfToken = getCsrfToken(); + const csrfToken = await getCsrfToken(); if (csrfToken) { opts.headers['x-csrf-token'] = csrfToken; } @@ -100,10 +104,11 @@ export const api = { }; }, importAdminBackup: async (file) => { + const csrfToken = await getCsrfToken(); const res = await fetch('/api/admin/backups/import', { method: 'POST', credentials: 'include', - headers: { 'Content-Type': 'application/octet-stream', 'x-csrf-token': getCsrfToken() }, + headers: { 'Content-Type': 'application/octet-stream', 'x-csrf-token': csrfToken }, body: file, }); const data = await res.json(); @@ -267,12 +272,13 @@ export const api = { if (options.defaultYear) params.set('year', String(options.defaultYear)); if (options.defaultMonth) params.set('month', String(options.defaultMonth)); const qs = params.toString(); + const csrfToken = await getCsrfToken(); const res = await fetch(`/api/import/spreadsheet/preview${qs ? `?${qs}` : ''}`, { method: 'POST', credentials: 'include', headers: { 'Content-Type': 'application/octet-stream', - 'x-csrf-token': getCsrfToken(), + 'x-csrf-token': csrfToken, ...(file.name ? { 'X-Filename': file.name } : {}), }, body: file, @@ -290,12 +296,13 @@ export const api = { }, applySpreadsheetImport: (data) => post('/import/spreadsheet/apply', data), previewCsvTransactionImport: async (file) => { + const csrfToken = await getCsrfToken(); const res = await fetch('/api/import/csv/preview', { method: 'POST', credentials: 'include', headers: { 'Content-Type': 'text/csv', - 'x-csrf-token': getCsrfToken(), + 'x-csrf-token': csrfToken, ...(file.name ? { 'X-Filename': file.name } : {}), }, body: file, @@ -344,12 +351,13 @@ export const api = { // User SQLite import previewUserDbImport: async (file) => { + const csrfToken = await getCsrfToken(); const res = await fetch('/api/import/user-db/preview', { method: 'POST', credentials: 'include', headers: { 'Content-Type': 'application/octet-stream', - 'x-csrf-token': getCsrfToken(), + 'x-csrf-token': csrfToken, ...(file.name ? { 'X-Filename': file.name } : {}), }, body: file, diff --git a/db/database.js b/db/database.js index e3be131..66cb67e 100644 --- a/db/database.js +++ b/db/database.js @@ -2583,6 +2583,42 @@ function runMigrations() { console.warn('[v0.77] SMTP password encryption migration failed:', err.message); } } + }, + { + version: 'v0.78', + description: 're-encrypt secrets from SHA-256 to HKDF key derivation', + dependsOn: ['v0.77'], + run: function() { + try { + const { decryptSecret, encryptSecret } = require('../services/encryptionService'); + + // Re-encrypt SimpleFIN tokens in data_sources + const sources = db.prepare( + "SELECT id, encrypted_secret FROM data_sources WHERE encrypted_secret IS NOT NULL AND encrypted_secret NOT LIKE 'v2:%'" + ).all(); + const updateSource = db.prepare('UPDATE data_sources SET encrypted_secret = ? WHERE id = ?'); + for (const row of sources) { + try { + updateSource.run(encryptSecret(decryptSecret(row.encrypted_secret)), row.id); + } catch (err) { + console.warn(`[v0.78] Could not re-encrypt data_source id=${row.id}:`, err.message); + } + } + + // Re-encrypt SMTP password + const smtp = db.prepare("SELECT value FROM settings WHERE key = 'notify_smtp_password'").get(); + if (smtp?.value && !smtp.value.startsWith('v2:')) { + try { + db.prepare("UPDATE settings SET value = ?, updated_at = datetime('now') WHERE key = 'notify_smtp_password'") + .run(encryptSecret(decryptSecret(smtp.value))); + } catch (err) { + console.warn('[v0.78] Could not re-encrypt SMTP password:', err.message); + } + } + } catch (err) { + console.warn('[v0.78] HKDF re-encryption migration failed:', err.message); + } + } } ]; diff --git a/middleware/csrf.js b/middleware/csrf.js index 90f4697..3b525e4 100644 --- a/middleware/csrf.js +++ b/middleware/csrf.js @@ -10,13 +10,11 @@ const { logAudit } = require('../services/auditService'); const CSRF_HEADER_NAME = 'x-csrf-token'; // CSRF cookie httpOnly setting - configurable via environment variable -// Default: false — the SPA uses a double-submit pattern (reads token from -// document.cookie and sends it in the x-csrf-token header), which requires -// JavaScript access to the cookie. Setting httpOnly=true would break this flow. -// Do not enable CSRF_HTTP_ONLY for this SPA unless token delivery changes away -// from document.cookie. Server-rendered apps can use httpOnly CSRF cookies when -// they deliver the token through another trusted channel. -const CSRF_HTTP_ONLY = process.env.CSRF_HTTP_ONLY === 'true'; // defaults to false for SPA +// Default: true — the SPA fetches the token from GET /api/auth/csrf-token and stores +// it in memory, so JavaScript does not need direct access to document.cookie. +// httpOnly=true removes the token from the XSS-accessible cookie surface while +// preserving the double-submit validation on the server. +const CSRF_HTTP_ONLY = process.env.CSRF_HTTP_ONLY !== 'false'; // defaults to true // CSRF cookie sameSite setting - configurable via environment variable // Options: 'lax', 'strict', 'none' diff --git a/package.json b/package.json index 4a8e087..ba5e9b3 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "bill-tracker", - "version": "0.34.3", + "version": "0.35.0", "description": "Monthly bill tracking system", "main": "server.js", "scripts": { diff --git a/routes/auth.js b/routes/auth.js index 28ea070..5ea1760 100644 --- a/routes/auth.js +++ b/routes/auth.js @@ -11,6 +11,7 @@ function getAppVersion() { const { getDb, getSetting, setSetting } = require('../db/database'); const { login, logout, hashPassword, cookieOpts, COOKIE_NAME, rotateSessionId, invalidateOtherSessions, recordLogin } = require('../services/authService'); +const { getCsrfToken } = require('../middleware/csrf'); const { requireAuth, requireAdmin } = require('../middleware/requireAuth'); const { getPublicOidcInfo } = require('../services/oidcService'); const { ValidationError, formatError } = require('../utils/apiError'); @@ -57,6 +58,15 @@ router.post('/login', (req, res, next) => { } }); +// GET /api/auth/csrf-token +// Public — returns the CSRF token from the httpOnly cookie so the SPA can +// store it in memory and send it in the x-csrf-token header for mutations. +// Cross-origin access is prevented by the same-origin fetch policy (no CORS). +router.get('/csrf-token', (req, res) => { + const token = getCsrfToken(req, res); + res.json({ token }); +}); + // POST /api/auth/logout router.post('/logout', requireAuth, (req, res) => { logout(req.cookies?.[COOKIE_NAME]); diff --git a/services/encryptionService.js b/services/encryptionService.js index fc54b7e..ee45355 100644 --- a/services/encryptionService.js +++ b/services/encryptionService.js @@ -2,19 +2,32 @@ const crypto = require('crypto'); -const ALGORITHM = 'aes-256-gcm'; +const ALGORITHM = 'aes-256-gcm'; const IV_BYTES = 12; const TAG_BYTES = 16; +// Domain-separation label for HKDF. Changing this invalidates all stored v2 secrets. +const HKDF_INFO = 'bill-tracker-token-encryption-v1'; +// Prefix that identifies ciphertext produced with HKDF key derivation. +const V2_PREFIX = 'v2:'; -// 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() { +let _warnedAutoKey = false; + +// Returns the raw key material (IKM) without derivation — shared by both paths. +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 crypto.createHash('sha256').update(buf).digest(); + 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 @@ -24,26 +37,43 @@ function getKey() { stored = crypto.randomBytes(48).toString('hex'); setSetting('_auto_encryption_key', stored); } - return crypto.createHash('sha256').update(stored, 'utf8').digest(); + return Buffer.from(stored, 'utf8'); } +// Current derivation: HKDF-SHA-256 (RFC 5869). Used for all new encryptions. +function deriveKey(ikm) { + return Buffer.from(crypto.hkdfSync('sha256', ikm, /* salt */ '', HKDF_INFO, 32)); +} + +// Legacy derivation: raw SHA-256. Used only to decrypt pre-v0.78 ciphertext. +function deriveLegacyKey(ikm) { + return crypto.createHash('sha256').update(ikm).digest(); +} + +function getKey() { return deriveKey(getIkm()); } +function getLegacyKey() { return deriveLegacyKey(getIkm()); } + // No-op now that the key is always available — kept for call-site compatibility function assertEncryptionReady() {} +// Always produces v2-format ciphertext (HKDF key derivation). function encryptSecret(plaintext) { const key = getKey(); const iv = crypto.randomBytes(IV_BYTES); const cipher = crypto.createCipheriv(ALGORITHM, key, iv, { authTagLength: TAG_BYTES }); const ct = Buffer.concat([cipher.update(plaintext, 'utf8'), cipher.final()]); const tag = cipher.getAuthTag(); - return `${iv.toString('hex')}:${tag.toString('hex')}:${ct.toString('hex')}`; + return `${V2_PREFIX}${iv.toString('hex')}:${tag.toString('hex')}:${ct.toString('hex')}`; } +// Decrypts both v2 (HKDF) and legacy (SHA-256) ciphertext transparently. function decryptSecret(stored) { - const parts = stored.split(':'); + const isV2 = stored.startsWith(V2_PREFIX); + const payload = isV2 ? stored.slice(V2_PREFIX.length) : stored; + const parts = payload.split(':'); if (parts.length !== 3) throw new Error('Invalid encrypted secret format'); const [ivHex, tagHex, ctHex] = parts; - const key = getKey(); + const key = isV2 ? getKey() : getLegacyKey(); const iv = Buffer.from(ivHex, 'hex'); const tag = Buffer.from(tagHex, 'hex'); const ct = Buffer.from(ctHex, 'hex');