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
parent
8ed56a97d5
commit
3e175b8da1
@ -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
|
||||
}
|
||||
@ -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/)
|
||||
})
|
||||
})
|
||||
Loading…
Reference in New Issue