diff --git a/apps/hq/src/api.ts b/apps/hq/src/api.ts index fb5152c..6c52801 100644 --- a/apps/hq/src/api.ts +++ b/apps/hq/src/api.ts @@ -64,6 +64,7 @@ import { renewalsDue, generateModuleRenewalQuote } from './repos-renewals' import { misCockpit } from './repos-mis' import { smsBalances, refreshSmsBalance, refreshAllSmsBalances, DEFAULT_SMS_BALANCE_API } from './repos-sms' import { globalSearch } from './repos-search' +import { migrateLegacyCiphertext } from './migrate-plaintext' import { assertSafeGatewayUrl } from './sms-gateway' import { makeRateLimiter } from './rate-limit' import { @@ -1533,6 +1534,19 @@ export function apiRouter( } }) + // ---------- D31 one-time migration: legacy AES credentials → plaintext (owner) ---------- + // Uses the key from the request body if given, else the server's HQ_SECRET_KEY. After a + // clean run every credential is plaintext and the key is never needed again. + r.post('/admin/migrate-plaintext', requireAuth, requireOwner, async (req, res) => { + try { + const bodyKey = typeof (req.body as { key?: unknown }).key === 'string' ? (req.body as { key: string }).key.trim() : '' + const keyHex = bodyKey !== '' ? bodyKey : gmail().keyHex + res.json({ ok: true, result: await migrateLegacyCiphertext(db, keyHex) }) + } catch (err) { + res.status(400).json({ ok: false, error: err instanceof Error ? err.message : String(err) }) + } + }) + // ---------- global quick-search (D28): clients + documents + tickets ---------- r.get('/search', requireAuth, async (req, res) => { try { diff --git a/apps/hq/src/crypto.ts b/apps/hq/src/crypto.ts index 806e5ed..8409d76 100644 --- a/apps/hq/src/crypto.ts +++ b/apps/hq/src/crypto.ts @@ -1,12 +1,22 @@ -import { createCipheriv, createDecipheriv, randomBytes } from 'node:crypto' +import { createDecipheriv } from 'node:crypto' /** - * AES-256-GCM helpers for the Gmail refresh token at rest. The key is - * HQ_SECRET_KEY (64 hex chars = 32 bytes); payload format is - * `iv.tag.ciphertext` hex-joined by `.` so it stores in one TEXT column. - * Never log the plaintext or the key. + * Credential storage (D31). Per the owner's decision, credentials are stored in the + * CLEAR — they are used regularly (e.g. the daily SMS-gateway balance pull) and the + * operational cost of a held-out HQ_SECRET_KEY was judged to outweigh the at-rest + * protection. SECURITY NOTE: a database dump / backup now exposes these logins in + * plaintext — protect the DB file and its backups accordingly. + * + * `encrypt()` therefore just TAGS the value ('plain:') so `decrypt()` can tell it apart + * from any legacy AES-256-GCM payloads written before D31. Those legacy payloads stay + * decryptable WITH the key, purely so they can be migrated to plaintext once + * (see migrateLegacyCiphertext); after that the key is never needed again. */ +const PLAIN = 'plain:' +/** Legacy payload shape from the pre-D31 AES-256-GCM writer: iv.tag.ciphertext, all hex. */ +const AES_SHAPE = /^[0-9a-f]+\.[0-9a-f]+\.[0-9a-f]+$/i + function keyFromHex(keyHex: string): Buffer { const key = Buffer.from(keyHex, 'hex') if (key.length !== 32 || keyHex.length !== 64) { @@ -15,14 +25,25 @@ function keyFromHex(keyHex: string): Buffer { return key } -export function encrypt(plain: string, keyHex: string): string { - const iv = randomBytes(12) // 96-bit nonce, the GCM-recommended size - const cipher = createCipheriv('aes-256-gcm', keyFromHex(keyHex), iv) - const ciphertext = Buffer.concat([cipher.update(plain, 'utf8'), cipher.final()]) - return [iv.toString('hex'), cipher.getAuthTag().toString('hex'), ciphertext.toString('hex')].join('.') +/** True for a legacy AES-256-GCM payload (needs the key to read). Used by the migration. */ +export function isLegacyCiphertext(payload: string): boolean { + return !payload.startsWith(PLAIN) && AES_SHAPE.test(payload) +} + +/** Store the value in the clear (tagged). `keyHex` is kept for call-site compatibility, ignored. */ +export function encrypt(plain: string, _keyHex: string): string { + return PLAIN + plain } +/** + * Read a stored credential: a D31 plaintext value directly (no key needed), or a legacy + * AES-256-GCM payload with the key. Anything of an unrecognised shape is assumed to be + * already-clear and returned as-is. + */ export function decrypt(payload: string, keyHex: string): string { + if (payload.startsWith(PLAIN)) return payload.slice(PLAIN.length) + if (!AES_SHAPE.test(payload)) return payload + if (keyHex === '') throw new Error('Legacy-encrypted value needs HQ_SECRET_KEY once to migrate to plaintext') const [ivHex, tagHex, ctHex] = payload.split('.') if (ivHex === undefined || tagHex === undefined || ctHex === undefined) { throw new Error('Malformed encrypted payload (expected iv.tag.ciphertext)') diff --git a/apps/hq/src/migrate-plaintext.ts b/apps/hq/src/migrate-plaintext.ts new file mode 100644 index 0000000..201a393 --- /dev/null +++ b/apps/hq/src/migrate-plaintext.ts @@ -0,0 +1,71 @@ +import type { DB } from './db' +import { decrypt, encrypt, isLegacyCiphertext } from './crypto' +import { writeAudit } from './audit' + +/** + * D31 one-time migration: rewrite every legacy AES-256-GCM credential in the DB as + * plaintext (tagged), using HQ_SECRET_KEY the final time. Idempotent — already-plaintext + * rows are skipped, so it is safe to re-run. After a clean pass the key is never needed + * again. Writes ONE audit summary (never per-row, never the plaintext value). + */ +export interface MigrateResult { + modulePasswords: number; moduleSecrets: number; clientDbPasswords: number; gmailTokens: number + errors: string[] +} + +const msg = (e: unknown): string => (e instanceof Error ? e.message : String(e)) + +export async function migrateLegacyCiphertext(db: DB, keyHex: string): Promise { + if (keyHex === '') throw new Error('HQ_SECRET_KEY is required to unlock legacy-encrypted rows (one-time)') + const res: MigrateResult = { modulePasswords: 0, moduleSecrets: 0, clientDbPasswords: 0, gmailTokens: 0, errors: [] } + + // client_module.password_enc — the portal/gateway passwords (incl. SMS). + for (const r of await db.all<{ id: string; password_enc: string }>( + `SELECT id, password_enc FROM client_module WHERE password_enc IS NOT NULL`)) { + if (!isLegacyCiphertext(r.password_enc)) continue + try { + await db.run(`UPDATE client_module SET password_enc=? WHERE id=?`, encrypt(decrypt(r.password_enc, keyHex), ''), r.id) + res.modulePasswords++ + } catch (e) { res.errors.push(`module password ${r.id}: ${msg(e)}`) } + } + + // client_module.secrets_enc — stored as `keylist|ciphertext`; migrate the ciphertext part. + for (const r of await db.all<{ id: string; secrets_enc: string }>( + `SELECT id, secrets_enc FROM client_module WHERE secrets_enc IS NOT NULL`)) { + const sep = r.secrets_enc.indexOf('|') + if (sep < 0) continue + const keys = r.secrets_enc.slice(0, sep) + const cipher = r.secrets_enc.slice(sep + 1) + if (!isLegacyCiphertext(cipher)) continue + try { + await db.run(`UPDATE client_module SET secrets_enc=? WHERE id=?`, `${keys}|${encrypt(decrypt(cipher, keyHex), '')}`, r.id) + res.moduleSecrets++ + } catch (e) { res.errors.push(`module secret ${r.id}: ${msg(e)}`) } + } + + // client.db_password_enc — support DB passwords. + for (const r of await db.all<{ id: string; db_password_enc: string }>( + `SELECT id, db_password_enc FROM client WHERE db_password_enc IS NOT NULL`)) { + if (!isLegacyCiphertext(r.db_password_enc)) continue + try { + await db.run(`UPDATE client SET db_password_enc=? WHERE id=?`, encrypt(decrypt(r.db_password_enc, keyHex), ''), r.id) + res.clientDbPasswords++ + } catch (e) { res.errors.push(`client db password ${r.id}: ${msg(e)}`) } + } + + // email_account.refresh_token_enc — Gmail send token. + for (const r of await db.all<{ id: string; refresh_token_enc: string }>( + `SELECT id, refresh_token_enc FROM email_account WHERE refresh_token_enc IS NOT NULL`)) { + if (!isLegacyCiphertext(r.refresh_token_enc)) continue + try { + await db.run(`UPDATE email_account SET refresh_token_enc=? WHERE id=?`, encrypt(decrypt(r.refresh_token_enc, keyHex), ''), r.id) + res.gmailTokens++ + } catch (e) { res.errors.push(`gmail token ${r.id}: ${msg(e)}`) } + } + + await writeAudit(db, 'system', 'migrate_plaintext', 'system', 'crypto', undefined, { + modulePasswords: res.modulePasswords, moduleSecrets: res.moduleSecrets, + clientDbPasswords: res.clientDbPasswords, gmailTokens: res.gmailTokens, errors: res.errors.length, + }) + return res +} diff --git a/apps/hq/src/repos-clients.ts b/apps/hq/src/repos-clients.ts index 3c3ee20..ee2df2d 100644 --- a/apps/hq/src/repos-clients.ts +++ b/apps/hq/src/repos-clients.ts @@ -153,9 +153,7 @@ export async function setClientDbPassword( return db.transaction(async () => { const before = await getClient(db, id) if (before === null) throw new Error('Client not found') - if (plain !== '' && keyHex === '') { - throw new Error(`HQ_SECRET_KEY is not configured — refusing to store a DB password unencrypted`) - } + // D31: stored in the clear (encrypt() tags plaintext); no key required. const enc = plain === '' ? null : encrypt(plain, keyHex) await db.run(`UPDATE client SET db_password_enc=? WHERE id=?`, enc, id) await writeAudit(db, userId, 'set_db_password', 'client', id, undefined, { set: enc !== null }) @@ -169,7 +167,7 @@ export async function revealClientDbPassword(db: DB, userId: string, id: string, `SELECT db_password_enc FROM client WHERE id=?`, id) if (row === undefined) throw new Error('Client not found') if (row.db_password_enc === null) throw new Error('No DB password stored for this client') - if (keyHex === '') throw new Error(`HQ_SECRET_KEY is not configured — cannot decrypt`) + // D31: plaintext returns without a key; a legacy row still needs the key (decrypt throws). const plain = decrypt(row.db_password_enc, keyHex) await writeAudit(db, userId, 'reveal_db_password', 'client', id) return plain diff --git a/apps/hq/src/repos-modules.ts b/apps/hq/src/repos-modules.ts index d7995e5..a902728 100644 --- a/apps/hq/src/repos-modules.ts +++ b/apps/hq/src/repos-modules.ts @@ -559,9 +559,7 @@ export async function setModulePassword( return db.transaction(async () => { const before = await getClientModule(db, id) if (before === null) throw new Error('Client module not found') - if (plain !== '' && keyHex === '') { - throw new Error(`HQ_SECRET_KEY is not configured — refusing to store a portal password unencrypted`) - } + // D31: stored in the clear (encrypt() tags plaintext); no key required. const enc = plain === '' ? null : encrypt(plain, keyHex) await db.run(`UPDATE client_module SET password_enc=? WHERE id=?`, enc, id) await writeAudit(db, userId, 'set_module_password', 'client_module', id, undefined, { set: enc !== null }) @@ -576,7 +574,7 @@ export async function revealModulePassword(db: DB, userId: string, id: string, k ) if (row === undefined) throw new Error('Client module not found') if (row.password_enc === null) throw new Error('No portal password stored for this module') - if (keyHex === '') throw new Error(`HQ_SECRET_KEY is not configured — cannot decrypt`) + // D31: plaintext returns without a key; a legacy row still needs the key (decrypt throws). const plain = decrypt(row.password_enc, keyHex) await writeAudit(db, userId, 'reveal_module_password', 'client_module', id) return plain @@ -657,7 +655,7 @@ export async function setModuleSecret( if (beforeRow === undefined) throw new Error('Client module not found') const def = await fieldDefFor(db, beforeRow.module_id, key) if (def === undefined || def.type !== 'secret') throw new Error(`Field '${key}' is not a secret field of this module`) - if (keyHex === '') throw new Error(`HQ_SECRET_KEY is not configured — refusing to store a secret unencrypted`) + // D31: stored in the clear; no key required. const before = (await getClientModule(db, id))! const map = unpackSecrets(beforeRow.secrets_enc, keyHex) if (plain.trim() === '') delete map[key] @@ -674,7 +672,7 @@ export async function revealModuleSecret( ): Promise { const row = await db.get<{ secrets_enc: string | null }>(`SELECT secrets_enc FROM client_module WHERE id=?`, id) if (row === undefined) throw new Error('Client module not found') - if (keyHex === '') throw new Error(`HQ_SECRET_KEY is not configured — cannot decrypt`) + // D31: plaintext returns without a key; a legacy row still needs the key (unpack throws). const map = unpackSecrets(row.secrets_enc, keyHex) if (!(key in map)) throw new Error(`No value stored for secret '${key}'`) await writeAudit(db, userId, 'reveal_module_secret', 'client_module', id, undefined, { key }) diff --git a/apps/hq/src/repos-sms.ts b/apps/hq/src/repos-sms.ts index 0666b3b..fd49262 100644 --- a/apps/hq/src/repos-sms.ts +++ b/apps/hq/src/repos-sms.ts @@ -131,9 +131,10 @@ export async function refreshSmsBalance(db: DB, userId: string, cmId: string, ke await recordCheck(db, cmId, 'error', null, 'No gateway credentials stored', now) return { cmId, ok: false, balance: null, changed: false, message: 'No gateway credentials stored' } } - if (keyHex === '') return { cmId, ok: false, balance: null, changed: false, message: 'HQ_SECRET_KEY not configured — cannot decrypt credentials' } + // D31: plaintext credentials read without a key; a legacy (still-encrypted) row needs + // the key once — decrypt throws and this row is reported so it can be migrated. let pass: string - try { pass = decrypt(row.password_enc, keyHex) } catch { return { cmId, ok: false, balance: null, changed: false, message: 'Could not decrypt stored password' } } + try { pass = decrypt(row.password_enc, keyHex) } catch { return { cmId, ok: false, balance: null, changed: false, message: 'Legacy-encrypted password — run the one-time migration with HQ_SECRET_KEY' } } const url = apiUrl ?? await smsBalanceApiUrl(db) const res = await fetchSmsBalance(url, row.username, pass) diff --git a/apps/hq/test/client-support.test.ts b/apps/hq/test/client-support.test.ts index d2d4165..3867e53 100644 --- a/apps/hq/test/client-support.test.ts +++ b/apps/hq/test/client-support.test.ts @@ -44,17 +44,15 @@ describe('client support data (repo)', () => { expect(await listClients(db, undefined, { district: 'ERNAKULAM' })).toHaveLength(1) }) - it('DB password: encrypted at rest, never in payloads, reveal decrypts + audits', async () => { + it('DB password (D31): stored in the clear, never in list payloads, reveal audits', async () => { const db = openDb(':memory:'); await seedIfEmpty(db) const c = await createClient(db, 'u1', { name: 'Acme', stateCode: '32' }) expect(c.hasDbPassword).toBe(false) - await setClientDbPassword(db, 'u1', c.id, 'oracle#secret', KEY) + await setClientDbPassword(db, 'u1', c.id, 'oracle#secret', '') // D31: no key needed const after = (await getClient(db, c.id))! expect(after.hasDbPassword).toBe(true) - expect(JSON.stringify(after)).not.toContain('oracle#secret') // never in the payload - const raw = (await db.get<{ db_password_enc: string }>(`SELECT db_password_enc FROM client WHERE id=?`, c.id))! - expect(raw.db_password_enc).not.toContain('oracle#secret') // encrypted at rest - expect(await revealClientDbPassword(db, 'u1', c.id, KEY)).toBe('oracle#secret') + expect(JSON.stringify(after)).not.toContain('oracle#secret') // still never in the list payload + expect(await revealClientDbPassword(db, 'u1', c.id, '')).toBe('oracle#secret') // reveal, no key const reveals = (await listAudit(db)).filter((a) => a.action === 'reveal_db_password' && a.entity_id === c.id) expect(reveals).toHaveLength(1) // The set audit records THAT it was set, never the value. @@ -63,12 +61,13 @@ describe('client support data (repo)', () => { expect(sets[0]!.after_json).not.toContain('oracle') }) - it('refuses to store or reveal without HQ_SECRET_KEY (loud, never silent)', async () => { + it('D31: stores and reveals with no key (KEY is ignored, not required)', async () => { const db = openDb(':memory:'); await seedIfEmpty(db) const c = await createClient(db, 'u1', { name: 'Acme', stateCode: '32' }) - await expect(setClientDbPassword(db, 'u1', c.id, 'pw', '')).rejects.toThrow(/HQ_SECRET_KEY/) - await setClientDbPassword(db, 'u1', c.id, 'pw', KEY) - await expect(revealClientDbPassword(db, 'u1', c.id, '')).rejects.toThrow(/HQ_SECRET_KEY/) + await setClientDbPassword(db, 'u1', c.id, 'pw', '') // no key → succeeds + expect(await revealClientDbPassword(db, 'u1', c.id, '')).toBe('pw') + await setClientDbPassword(db, 'u1', c.id, 'pw2', KEY) // a key is accepted but ignored + expect(await revealClientDbPassword(db, 'u1', c.id, KEY)).toBe('pw2') }) }) diff --git a/apps/hq/test/migrate-plaintext.test.ts b/apps/hq/test/migrate-plaintext.test.ts new file mode 100644 index 0000000..91b06c7 --- /dev/null +++ b/apps/hq/test/migrate-plaintext.test.ts @@ -0,0 +1,62 @@ +// apps/hq/test/migrate-plaintext.test.ts — D31: plaintext crypto + one-time legacy migration. +import { describe, it, expect } from 'vitest' +import { createCipheriv, randomBytes } from 'node:crypto' +import { openDb } from '../src/db' +import { seedIfEmpty } from '../src/seed' +import { createClient } from '../src/repos-clients' +import { createModule, assignModule, revealModulePassword } from '../src/repos-modules' +import { decrypt, isLegacyCiphertext } from '../src/crypto' +import { migrateLegacyCiphertext } from '../src/migrate-plaintext' + +const KEY = 'ab'.repeat(32) // 64 hex chars + +/** Reproduce the pre-D31 AES-256-GCM writer, to plant legacy ciphertext in the DB. */ +function legacyEncrypt(plain: string, keyHex: string): string { + const iv = randomBytes(12) + const cipher = createCipheriv('aes-256-gcm', Buffer.from(keyHex, 'hex'), iv) + const ct = Buffer.concat([cipher.update(plain, 'utf8'), cipher.final()]) + return [iv.toString('hex'), cipher.getAuthTag().toString('hex'), ct.toString('hex')].join('.') +} + +describe('D31 crypto', () => { + it('reads plaintext with no key, but a legacy AES payload still needs the key (loud)', () => { + const legacy = legacyEncrypt('hunter2', KEY) + expect(isLegacyCiphertext(legacy)).toBe(true) + expect(isLegacyCiphertext('plain:hunter2')).toBe(false) + expect(decrypt('plain:hunter2', '')).toBe('hunter2') // plaintext, no key + expect(decrypt(legacy, KEY)).toBe('hunter2') // legacy, with key + expect(() => decrypt(legacy, '')).toThrow(/HQ_SECRET_KEY/) // legacy, no key → loud + }) +}) + +describe('migrateLegacyCiphertext (D31)', () => { + it('converts a legacy portal password to plaintext, then it reads keylessly; idempotent', async () => { + const db = openDb(':memory:'); await seedIfEmpty(db) + const m = await createModule(db, 'u1', { code: 'SMS', name: 'Bulk SMS' }) + const c = await createClient(db, 'u1', { name: 'Bank', stateCode: '32' }) + const cm = await assignModule(db, 'u1', { clientId: c.id, moduleId: m.id, kind: 'yearly' }) + // Plant a legacy AES password directly (as the old importer wrote it). + await db.run(`UPDATE client_module SET password_enc=? WHERE id=?`, legacyEncrypt('gw-pass', KEY), cm.id) + + // Before migration: no key ⇒ reveal fails loudly. + await expect(revealModulePassword(db, 'u1', cm.id, '')).rejects.toThrow(/HQ_SECRET_KEY/) + + const r = await migrateLegacyCiphertext(db, KEY) + expect(r.modulePasswords).toBe(1) + expect(r.errors).toHaveLength(0) + + // After migration: reads with NO key. + expect(await revealModulePassword(db, 'u1', cm.id, '')).toBe('gw-pass') + // Stored value is now plaintext (tagged), not the AES shape. + const stored = (await db.get<{ password_enc: string }>(`SELECT password_enc FROM client_module WHERE id=?`, cm.id))! + expect(isLegacyCiphertext(stored.password_enc)).toBe(false) + + // Idempotent: a second pass migrates nothing. + expect((await migrateLegacyCiphertext(db, KEY)).modulePasswords).toBe(0) + }) + + it('requires a key to run at all', async () => { + const db = openDb(':memory:'); await seedIfEmpty(db) + await expect(migrateLegacyCiphertext(db, '')).rejects.toThrow(/HQ_SECRET_KEY/) + }) +}) diff --git a/apps/hq/test/module-fields.test.ts b/apps/hq/test/module-fields.test.ts index ab97650..ee558e9 100644 --- a/apps/hq/test/module-fields.test.ts +++ b/apps/hq/test/module-fields.test.ts @@ -72,16 +72,13 @@ describe('client_module field values + secrets (D21)', () => { expect(cleared.fieldValues['ip']).toBe('192.168.10.8') // others untouched }) - it('encrypts secret fields, tracks which are set without decrypting, reveals audited, never leaks plaintext', async () => { + it('secret fields (D31 plaintext): set/reveal without a key, tracked, never in list payloads or audit', async () => { const { db, cm } = await world() - await expect(setModuleSecret(db, 'u1', cm.id, 'portal_password', 'pw', '')).rejects.toThrow(/HQ_SECRET_KEY/) await expect(setModuleSecret(db, 'u1', cm.id, 'ip', 'x', KEY)).rejects.toThrow(/not a secret/) - const withSecret = await setModuleSecret(db, 'u1', cm.id, 'portal_password', 's3cr3t', KEY) + const withSecret = await setModuleSecret(db, 'u1', cm.id, 'portal_password', 's3cr3t', '') // D31: no key expect(withSecret.secretKeys).toEqual(['portal_password']) - expect(JSON.stringify(withSecret)).not.toContain('s3cr3t') - const stored = (await db.get<{ secrets_enc: string }>(`SELECT secrets_enc FROM client_module WHERE id=?`, cm.id))! - expect(stored.secrets_enc).not.toContain('s3cr3t') // encrypted at rest - expect(await revealModuleSecret(db, 'u1', cm.id, 'portal_password', KEY)).toBe('s3cr3t') + expect(JSON.stringify(withSecret)).not.toContain('s3cr3t') // still not in the list payload + expect(await revealModuleSecret(db, 'u1', cm.id, 'portal_password', '')).toBe('s3cr3t') // reveal, no key // Audit carried only the key + set flag, never the value. const blob = (await db.all<{ after_json: string | null }>( `SELECT after_json FROM audit_log WHERE action='set_module_secret'`, diff --git a/apps/hq/test/tickets.test.ts b/apps/hq/test/tickets.test.ts index 21a5e65..128dd63 100644 --- a/apps/hq/test/tickets.test.ts +++ b/apps/hq/test/tickets.test.ts @@ -92,7 +92,7 @@ describe('ticket desk (D20)', () => { }) describe('client_module service data (D20)', () => { - it('patches provider/username/details/remark; portal password encrypts, reveals audited, never in payloads', async () => { + it('patches provider/username/details/remark; portal password (D31 plaintext) reveals audited, never in payloads', async () => { const { db, c } = await world() const m = await createModule(db, 'u1', { code: 'SMS', name: 'Bulk SMS' }) const cm = await assignModule(db, 'u1', { clientId: c.id, moduleId: m.id, kind: 'yearly' }) @@ -105,14 +105,10 @@ describe('client_module service data (D20)', () => { expect(upd.hasPassword).toBe(false) expect(JSON.stringify(upd)).not.toContain('password_enc') - await expect(setModulePassword(db, 'u1', cm.id, 'rfUb9kW0', '')).rejects.toThrow(/HQ_SECRET_KEY/) - const withPw = await setModulePassword(db, 'u1', cm.id, 'rfUb9kW0', KEY) + const withPw = await setModulePassword(db, 'u1', cm.id, 'rfUb9kW0', '') // D31: no key needed expect(withPw.hasPassword).toBe(true) - const stored = (await db.get<{ password_enc: string }>( - `SELECT password_enc FROM client_module WHERE id=?`, cm.id, - ))! - expect(stored.password_enc).not.toContain('rfUb9kW0') // encrypted at rest - expect(await revealModulePassword(db, 'u1', cm.id, KEY)).toBe('rfUb9kW0') + expect(JSON.stringify(withPw)).not.toContain('rfUb9kW0') // still never in the list payload + expect(await revealModulePassword(db, 'u1', cm.id, '')).toBe('rfUb9kW0') // reveal, no key const reveals = (await db.get<{ n: number }>( `SELECT COUNT(*) AS n FROM audit_log WHERE action='reveal_module_password'`, ))!.n diff --git a/docs/06-DECISIONS.md b/docs/06-DECISIONS.md index 3226210..a4666de 100644 --- a/docs/06-DECISIONS.md +++ b/docs/06-DECISIONS.md @@ -494,6 +494,28 @@ and the owner Cockpit + Dashboard carry the low-count alert. **Ticket SLA**: an `ticket.sla_days` (dated) drives an overdue mark + filter, and a "Bill" action opens New Document with the client pre-selected (light ticket→invoice; `/documents/new?client=`). +## D31 — Credentials stored in the clear (owner decision, 2026-07-19) +Founder call: "no encryption for anything — those credentials will be used regularly." The +held-out `HQ_SECRET_KEY` was a daily friction point (the SMS-gateway balance pull needs to +decrypt 78 logins every day), so credential **encryption at rest is removed**. `crypto.ts` +`encrypt()` now stores tagged plaintext (`plain:…`); `decrypt()` untags it with no key, and +still reads legacy AES-256-GCM payloads *with* the key purely to migrate them. The six +"refuses/​cannot without HQ_SECRET_KEY" guards (module password, module secret, client DB +password + their reveals, the SMS refresh) are gone — set/reveal/use all work keyless. + +**Scope:** this is the reversible credential vault only — module portal/gateway passwords, +module secret fields, client DB passwords, the Gmail refresh token. **Login passwords stay +one-way scrypt-hashed** (`@sims/auth`) — those never need to be read back, so nothing there +changes. Secrets are still kept out of list payloads and audit rows (only `{set: true}` / +the key name is audited, never the value), and reveal is still managerial + audited. + +**Migration:** `migrateLegacyCiphertext(db, keyHex)` (POST /admin/migrate-plaintext, owner) +rewrites every existing AES row as plaintext using the key ONE final time — idempotent, one +audit summary. After a clean run the key is never needed again. Existing encrypted rows +cannot be read without the key (or a fresh plaintext source), so the key is required exactly +once. **SECURITY:** a DB dump/backup now exposes these logins in the clear — protect the DB +file and its backups (this raises the priority of the pending DB-secret + backup ops slice). + **Live balance fetch** (founder: "configure the BalAlert API so we see the current count, daily auto + low alert"): the provider panel (`sms.balance_api_url`, default `http://164.52.195.161/API/BalAlert.aspx`) returns pipe-delimited `code|STATUS|payload`;