feat(d31): store credentials in the clear (owner decision) + one-time migration

Removes credential encryption at rest so the SMS-gateway logins (and other portal/DB
passwords, module secrets, Gmail token) are usable daily without a held-out
HQ_SECRET_KEY. Keystone: crypto.encrypt() stores tagged plaintext ('plain:'),
decrypt() untags keylessly and still reads legacy AES *with* the key to migrate it.
Drops the six 'refuses/cannot without HQ_SECRET_KEY' guards. Scope is the reversible
credential vault ONLY — login passwords stay one-way scrypt-hashed (@sims/auth).
Secrets still kept out of list payloads + audit rows; reveal still managerial+audited.

migrateLegacyCiphertext + POST /admin/migrate-plaintext (owner) rewrite existing AES
rows as plaintext using the key one final time (idempotent, one audit summary), after
which the key is never needed. SECURITY: a DB dump now exposes these in the clear.

Tests updated to the plaintext policy; new migrate-plaintext.test locks decrypt +
migration + idempotency. Full suite 416 green; typecheck clean.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
feat/client-detail-redesign
Thomas Joise 3 days ago
parent 8ed56a97d5
commit 3e175b8da1

@ -64,6 +64,7 @@ import { renewalsDue, generateModuleRenewalQuote } from './repos-renewals'
import { misCockpit } from './repos-mis' import { misCockpit } from './repos-mis'
import { smsBalances, refreshSmsBalance, refreshAllSmsBalances, DEFAULT_SMS_BALANCE_API } from './repos-sms' import { smsBalances, refreshSmsBalance, refreshAllSmsBalances, DEFAULT_SMS_BALANCE_API } from './repos-sms'
import { globalSearch } from './repos-search' import { globalSearch } from './repos-search'
import { migrateLegacyCiphertext } from './migrate-plaintext'
import { assertSafeGatewayUrl } from './sms-gateway' import { assertSafeGatewayUrl } from './sms-gateway'
import { makeRateLimiter } from './rate-limit' import { makeRateLimiter } from './rate-limit'
import { 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 ---------- // ---------- global quick-search (D28): clients + documents + tickets ----------
r.get('/search', requireAuth, async (req, res) => { r.get('/search', requireAuth, async (req, res) => {
try { try {

@ -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 * Credential storage (D31). Per the owner's decision, credentials are stored in the
* HQ_SECRET_KEY (64 hex chars = 32 bytes); payload format is * CLEAR they are used regularly (e.g. the daily SMS-gateway balance pull) and the
* `iv.tag.ciphertext` hex-joined by `.` so it stores in one TEXT column. * operational cost of a held-out HQ_SECRET_KEY was judged to outweigh the at-rest
* Never log the plaintext or the key. * 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 { function keyFromHex(keyHex: string): Buffer {
const key = Buffer.from(keyHex, 'hex') const key = Buffer.from(keyHex, 'hex')
if (key.length !== 32 || keyHex.length !== 64) { if (key.length !== 32 || keyHex.length !== 64) {
@ -15,14 +25,25 @@ function keyFromHex(keyHex: string): Buffer {
return key return key
} }
export function encrypt(plain: string, keyHex: string): string { /** True for a legacy AES-256-GCM payload (needs the key to read). Used by the migration. */
const iv = randomBytes(12) // 96-bit nonce, the GCM-recommended size export function isLegacyCiphertext(payload: string): boolean {
const cipher = createCipheriv('aes-256-gcm', keyFromHex(keyHex), iv) return !payload.startsWith(PLAIN) && AES_SHAPE.test(payload)
const ciphertext = Buffer.concat([cipher.update(plain, 'utf8'), cipher.final()]) }
return [iv.toString('hex'), cipher.getAuthTag().toString('hex'), ciphertext.toString('hex')].join('.')
/** 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 { 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('.') const [ivHex, tagHex, ctHex] = payload.split('.')
if (ivHex === undefined || tagHex === undefined || ctHex === undefined) { if (ivHex === undefined || tagHex === undefined || ctHex === undefined) {
throw new Error('Malformed encrypted payload (expected iv.tag.ciphertext)') throw new Error('Malformed encrypted payload (expected iv.tag.ciphertext)')

@ -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<MigrateResult> {
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
}

@ -153,9 +153,7 @@ export async function setClientDbPassword(
return db.transaction(async () => { return db.transaction(async () => {
const before = await getClient(db, id) const before = await getClient(db, id)
if (before === null) throw new Error('Client not found') if (before === null) throw new Error('Client not found')
if (plain !== '' && keyHex === '') { // D31: stored in the clear (encrypt() tags plaintext); no key required.
throw new Error(`HQ_SECRET_KEY is not configured — refusing to store a DB password unencrypted`)
}
const enc = plain === '' ? null : encrypt(plain, keyHex) const enc = plain === '' ? null : encrypt(plain, keyHex)
await db.run(`UPDATE client SET db_password_enc=? WHERE id=?`, enc, id) 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 }) 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) `SELECT db_password_enc FROM client WHERE id=?`, id)
if (row === undefined) throw new Error('Client not found') 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 (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) const plain = decrypt(row.db_password_enc, keyHex)
await writeAudit(db, userId, 'reveal_db_password', 'client', id) await writeAudit(db, userId, 'reveal_db_password', 'client', id)
return plain return plain

@ -559,9 +559,7 @@ export async function setModulePassword(
return db.transaction(async () => { return db.transaction(async () => {
const before = await getClientModule(db, id) const before = await getClientModule(db, id)
if (before === null) throw new Error('Client module not found') if (before === null) throw new Error('Client module not found')
if (plain !== '' && keyHex === '') { // D31: stored in the clear (encrypt() tags plaintext); no key required.
throw new Error(`HQ_SECRET_KEY is not configured — refusing to store a portal password unencrypted`)
}
const enc = plain === '' ? null : encrypt(plain, keyHex) const enc = plain === '' ? null : encrypt(plain, keyHex)
await db.run(`UPDATE client_module SET password_enc=? WHERE id=?`, enc, id) 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 }) 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 === undefined) throw new Error('Client module not found')
if (row.password_enc === null) throw new Error('No portal password stored for this module') 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) const plain = decrypt(row.password_enc, keyHex)
await writeAudit(db, userId, 'reveal_module_password', 'client_module', id) await writeAudit(db, userId, 'reveal_module_password', 'client_module', id)
return plain return plain
@ -657,7 +655,7 @@ export async function setModuleSecret(
if (beforeRow === undefined) throw new Error('Client module not found') if (beforeRow === undefined) throw new Error('Client module not found')
const def = await fieldDefFor(db, beforeRow.module_id, key) 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 (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 before = (await getClientModule(db, id))!
const map = unpackSecrets(beforeRow.secrets_enc, keyHex) const map = unpackSecrets(beforeRow.secrets_enc, keyHex)
if (plain.trim() === '') delete map[key] if (plain.trim() === '') delete map[key]
@ -674,7 +672,7 @@ export async function revealModuleSecret(
): Promise<string> { ): Promise<string> {
const row = await db.get<{ secrets_enc: string | null }>(`SELECT secrets_enc FROM client_module WHERE id=?`, id) 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 (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) const map = unpackSecrets(row.secrets_enc, keyHex)
if (!(key in map)) throw new Error(`No value stored for secret '${key}'`) 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 }) await writeAudit(db, userId, 'reveal_module_secret', 'client_module', id, undefined, { key })

@ -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) await recordCheck(db, cmId, 'error', null, 'No gateway credentials stored', now)
return { cmId, ok: false, balance: null, changed: false, message: 'No gateway credentials stored' } 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 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 url = apiUrl ?? await smsBalanceApiUrl(db)
const res = await fetchSmsBalance(url, row.username, pass) const res = await fetchSmsBalance(url, row.username, pass)

@ -44,17 +44,15 @@ describe('client support data (repo)', () => {
expect(await listClients(db, undefined, { district: 'ERNAKULAM' })).toHaveLength(1) 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 db = openDb(':memory:'); await seedIfEmpty(db)
const c = await createClient(db, 'u1', { name: 'Acme', stateCode: '32' }) const c = await createClient(db, 'u1', { name: 'Acme', stateCode: '32' })
expect(c.hasDbPassword).toBe(false) 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))! const after = (await getClient(db, c.id))!
expect(after.hasDbPassword).toBe(true) expect(after.hasDbPassword).toBe(true)
expect(JSON.stringify(after)).not.toContain('oracle#secret') // never in the payload expect(JSON.stringify(after)).not.toContain('oracle#secret') // still never in the list payload
const raw = (await db.get<{ db_password_enc: string }>(`SELECT db_password_enc FROM client WHERE id=?`, c.id))! expect(await revealClientDbPassword(db, 'u1', c.id, '')).toBe('oracle#secret') // reveal, no key
expect(raw.db_password_enc).not.toContain('oracle#secret') // encrypted at rest
expect(await revealClientDbPassword(db, 'u1', c.id, KEY)).toBe('oracle#secret')
const reveals = (await listAudit(db)).filter((a) => a.action === 'reveal_db_password' && a.entity_id === c.id) const reveals = (await listAudit(db)).filter((a) => a.action === 'reveal_db_password' && a.entity_id === c.id)
expect(reveals).toHaveLength(1) expect(reveals).toHaveLength(1)
// The set audit records THAT it was set, never the value. // 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') 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 db = openDb(':memory:'); await seedIfEmpty(db)
const c = await createClient(db, 'u1', { name: 'Acme', stateCode: '32' }) 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', '') // no key → succeeds
await setClientDbPassword(db, 'u1', c.id, 'pw', KEY) expect(await revealClientDbPassword(db, 'u1', c.id, '')).toBe('pw')
await expect(revealClientDbPassword(db, 'u1', c.id, '')).rejects.toThrow(/HQ_SECRET_KEY/) await setClientDbPassword(db, 'u1', c.id, 'pw2', KEY) // a key is accepted but ignored
expect(await revealClientDbPassword(db, 'u1', c.id, KEY)).toBe('pw2')
}) })
}) })

@ -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/)
})
})

@ -72,16 +72,13 @@ describe('client_module field values + secrets (D21)', () => {
expect(cleared.fieldValues['ip']).toBe('192.168.10.8') // others untouched 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() 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/) 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(withSecret.secretKeys).toEqual(['portal_password'])
expect(JSON.stringify(withSecret)).not.toContain('s3cr3t') expect(JSON.stringify(withSecret)).not.toContain('s3cr3t') // still not in the list payload
const stored = (await db.get<{ secrets_enc: string }>(`SELECT secrets_enc FROM client_module WHERE id=?`, cm.id))! expect(await revealModuleSecret(db, 'u1', cm.id, 'portal_password', '')).toBe('s3cr3t') // reveal, no key
expect(stored.secrets_enc).not.toContain('s3cr3t') // encrypted at rest
expect(await revealModuleSecret(db, 'u1', cm.id, 'portal_password', KEY)).toBe('s3cr3t')
// Audit carried only the key + set flag, never the value. // Audit carried only the key + set flag, never the value.
const blob = (await db.all<{ after_json: string | null }>( const blob = (await db.all<{ after_json: string | null }>(
`SELECT after_json FROM audit_log WHERE action='set_module_secret'`, `SELECT after_json FROM audit_log WHERE action='set_module_secret'`,

@ -92,7 +92,7 @@ describe('ticket desk (D20)', () => {
}) })
describe('client_module service data (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 { db, c } = await world()
const m = await createModule(db, 'u1', { code: 'SMS', name: 'Bulk SMS' }) 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' }) 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(upd.hasPassword).toBe(false)
expect(JSON.stringify(upd)).not.toContain('password_enc') 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', '') // D31: no key needed
const withPw = await setModulePassword(db, 'u1', cm.id, 'rfUb9kW0', KEY)
expect(withPw.hasPassword).toBe(true) expect(withPw.hasPassword).toBe(true)
const stored = (await db.get<{ password_enc: string }>( expect(JSON.stringify(withPw)).not.toContain('rfUb9kW0') // still never in the list payload
`SELECT password_enc FROM client_module WHERE id=?`, cm.id, expect(await revealModulePassword(db, 'u1', cm.id, '')).toBe('rfUb9kW0') // reveal, no key
))!
expect(stored.password_enc).not.toContain('rfUb9kW0') // encrypted at rest
expect(await revealModulePassword(db, 'u1', cm.id, KEY)).toBe('rfUb9kW0')
const reveals = (await db.get<{ n: number }>( const reveals = (await db.get<{ n: number }>(
`SELECT COUNT(*) AS n FROM audit_log WHERE action='reveal_module_password'`, `SELECT COUNT(*) AS n FROM audit_log WHERE action='reveal_module_password'`,
))!.n ))!.n

@ -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 `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=`). 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, **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 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`; `http://164.52.195.161/API/BalAlert.aspx`) returns pipe-delimited `code|STATUS|payload`;

Loading…
Cancel
Save