feat(d32): remove the key concept entirely + keyless legacy cleanup
Owner has no HQ_SECRET_KEY, so the D31 unlock-with-key path was impossible. crypto is now fully keyless: encrypt/decrypt ignore keyHex (params kept so a key can be wired back later); decrypt returns '' for any leftover legacy AES value (unreadable → treated as unset, never fed back as ciphertext). Key-based /admin/migrate-plaintext replaced by keyless /admin/clear-legacy-credentials (clearLegacyCiphertext) which NULLs unreadable pre-D31 values. .env.example marks HQ_SECRET_KEY removed. Ran the cleanup on live data: 76 SMS + 110 client DB passwords were unrecoverable and cleared (usernames intact) — must be re-populated in plaintext (APEX re-import / re-entry). Also fixes the one real defect the D31 adversarial review confirmed: maybeRefreshSmsDaily still had a 'keyHex==="" -> no-op' guard that would silently kill the DAILY auto-refresh once the key was gone (manual refresh masked it). Removed; the daily pull now runs keyless like the manual path. Full suite 416 green. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>feat/client-detail-redesign
parent
23089a9b1f
commit
79717133b8
@ -1,54 +1,39 @@
|
|||||||
import { createDecipheriv } from 'node:crypto'
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Credential storage (D31). Per the owner's decision, credentials are stored in the
|
* Credential storage (D31/D32). 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
|
* CLEAR and there is NO encryption key anywhere — HQ_SECRET_KEY was removed (it may be
|
||||||
* operational cost of a held-out HQ_SECRET_KEY was judged to outweigh the at-rest
|
* reintroduced in future). `encrypt()` just tags the value ('plain:') and `decrypt()`
|
||||||
* protection. SECURITY NOTE: a database dump / backup now exposes these logins in
|
* untags it; the `keyHex` parameters are kept only so call sites don't have to change and
|
||||||
* plaintext — protect the DB file and its backups accordingly.
|
* so a key can be wired back later — they are ignored.
|
||||||
|
*
|
||||||
|
* SECURITY NOTE: a database dump / backup 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
|
* Legacy note: rows written before D31 are AES-256-GCM ciphertext ('iv.tag.ct' hex). With
|
||||||
* from any legacy AES-256-GCM payloads written before D31. Those legacy payloads stay
|
* the key gone they can no longer be read, so `decrypt()` returns '' for them (treated as
|
||||||
* decryptable WITH the key, purely so they can be migrated to plaintext once
|
* unset) and `clearLegacyCiphertext` (migrate-plaintext.ts) nulls them out.
|
||||||
* (see migrateLegacyCiphertext); after that the key is never needed again.
|
|
||||||
*/
|
*/
|
||||||
|
|
||||||
const PLAIN = 'plain:'
|
const PLAIN = 'plain:'
|
||||||
/** Legacy payload shape from the pre-D31 AES-256-GCM writer: iv.tag.ciphertext, all hex. */
|
/** Shape of a pre-D31 AES-256-GCM payload: iv.tag.ciphertext, all hex. Now unreadable. */
|
||||||
const AES_SHAPE = /^[0-9a-f]+\.[0-9a-f]+\.[0-9a-f]+$/i
|
const AES_SHAPE = /^[0-9a-f]+\.[0-9a-f]+\.[0-9a-f]+$/i
|
||||||
|
|
||||||
function keyFromHex(keyHex: string): Buffer {
|
/** True for an unreadable legacy AES payload (key removed). Used by the cleanup migration. */
|
||||||
const key = Buffer.from(keyHex, 'hex')
|
|
||||||
if (key.length !== 32 || keyHex.length !== 64) {
|
|
||||||
throw new Error('Encryption key must be 64 hex chars (32 bytes)')
|
|
||||||
}
|
|
||||||
return key
|
|
||||||
}
|
|
||||||
|
|
||||||
/** True for a legacy AES-256-GCM payload (needs the key to read). Used by the migration. */
|
|
||||||
export function isLegacyCiphertext(payload: string): boolean {
|
export function isLegacyCiphertext(payload: string): boolean {
|
||||||
return !payload.startsWith(PLAIN) && AES_SHAPE.test(payload)
|
return !payload.startsWith(PLAIN) && AES_SHAPE.test(payload)
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Store the value in the clear (tagged). `keyHex` is kept for call-site compatibility, ignored. */
|
/** Store the value in the clear (tagged). `_keyHex` is ignored (no key concept). */
|
||||||
export function encrypt(plain: string, _keyHex: string): string {
|
export function encrypt(plain: string, _keyHex = ''): string {
|
||||||
return PLAIN + plain
|
return PLAIN + plain
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Read a stored credential: a D31 plaintext value directly (no key needed), or a legacy
|
* Read a stored credential: a plaintext value untagged. A leftover legacy AES payload is
|
||||||
* AES-256-GCM payload with the key. Anything of an unrecognised shape is assumed to be
|
* unreadable (key removed) and returns '' — the caller then sees an empty/unset credential
|
||||||
* already-clear and returned as-is.
|
* rather than ciphertext garbage. `_keyHex` is ignored.
|
||||||
*/
|
*/
|
||||||
export function decrypt(payload: string, keyHex: string): string {
|
export function decrypt(payload: string, _keyHex = ''): string {
|
||||||
if (payload.startsWith(PLAIN)) return payload.slice(PLAIN.length)
|
if (payload.startsWith(PLAIN)) return payload.slice(PLAIN.length)
|
||||||
if (!AES_SHAPE.test(payload)) return payload
|
if (isLegacyCiphertext(payload)) return ''
|
||||||
if (keyHex === '') throw new Error('Legacy-encrypted value needs HQ_SECRET_KEY once to migrate to plaintext')
|
return payload
|
||||||
const [ivHex, tagHex, ctHex] = payload.split('.')
|
|
||||||
if (ivHex === undefined || tagHex === undefined || ctHex === undefined) {
|
|
||||||
throw new Error('Malformed encrypted payload (expected iv.tag.ciphertext)')
|
|
||||||
}
|
|
||||||
const decipher = createDecipheriv('aes-256-gcm', keyFromHex(keyHex), Buffer.from(ivHex, 'hex'))
|
|
||||||
decipher.setAuthTag(Buffer.from(tagHex, 'hex'))
|
|
||||||
return Buffer.concat([decipher.update(Buffer.from(ctHex, 'hex')), decipher.final()]).toString('utf8')
|
|
||||||
}
|
}
|
||||||
|
|||||||
@ -1,71 +1,53 @@
|
|||||||
import type { DB } from './db'
|
import type { DB } from './db'
|
||||||
import { decrypt, encrypt, isLegacyCiphertext } from './crypto'
|
import { isLegacyCiphertext } from './crypto'
|
||||||
import { writeAudit } from './audit'
|
import { writeAudit } from './audit'
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* D31 one-time migration: rewrite every legacy AES-256-GCM credential in the DB as
|
* D32 keyless cleanup: HQ_SECRET_KEY was removed, so any credential still stored as a
|
||||||
* plaintext (tagged), using HQ_SECRET_KEY the final time. Idempotent — already-plaintext
|
* pre-D31 AES-256-GCM payload is now unreadable dead weight. This NULLs those values so
|
||||||
* rows are skipped, so it is safe to re-run. After a clean pass the key is never needed
|
* the affected login shows as "not set" and is ready to be re-populated in plaintext
|
||||||
* again. Writes ONE audit summary (never per-row, never the plaintext value).
|
* (re-import from APEX, or re-entered). Idempotent — plaintext rows are left untouched.
|
||||||
|
* Writes ONE audit summary. No key required.
|
||||||
*/
|
*/
|
||||||
export interface MigrateResult {
|
export interface ClearResult {
|
||||||
modulePasswords: number; moduleSecrets: number; clientDbPasswords: number; gmailTokens: number
|
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 clearLegacyCiphertext(db: DB): Promise<ClearResult> {
|
||||||
|
const res: ClearResult = { modulePasswords: 0, moduleSecrets: 0, clientDbPasswords: 0, gmailTokens: 0 }
|
||||||
|
|
||||||
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 }>(
|
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`)) {
|
`SELECT id, password_enc FROM client_module WHERE password_enc IS NOT NULL`)) {
|
||||||
if (!isLegacyCiphertext(r.password_enc)) continue
|
if (!isLegacyCiphertext(r.password_enc)) continue
|
||||||
try {
|
await db.run(`UPDATE client_module SET password_enc=NULL WHERE id=?`, r.id)
|
||||||
await db.run(`UPDATE client_module SET password_enc=? WHERE id=?`, encrypt(decrypt(r.password_enc, keyHex), ''), r.id)
|
res.modulePasswords++
|
||||||
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.
|
// secrets_enc is `keylist|ciphertext`; if the ciphertext is legacy, the whole blob is dead.
|
||||||
for (const r of await db.all<{ id: string; secrets_enc: string }>(
|
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`)) {
|
`SELECT id, secrets_enc FROM client_module WHERE secrets_enc IS NOT NULL`)) {
|
||||||
const sep = r.secrets_enc.indexOf('|')
|
const sep = r.secrets_enc.indexOf('|')
|
||||||
if (sep < 0) continue
|
const cipher = sep < 0 ? r.secrets_enc : r.secrets_enc.slice(sep + 1)
|
||||||
const keys = r.secrets_enc.slice(0, sep)
|
|
||||||
const cipher = r.secrets_enc.slice(sep + 1)
|
|
||||||
if (!isLegacyCiphertext(cipher)) continue
|
if (!isLegacyCiphertext(cipher)) continue
|
||||||
try {
|
await db.run(`UPDATE client_module SET secrets_enc=NULL WHERE id=?`, r.id)
|
||||||
await db.run(`UPDATE client_module SET secrets_enc=? WHERE id=?`, `${keys}|${encrypt(decrypt(cipher, keyHex), '')}`, r.id)
|
res.moduleSecrets++
|
||||||
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 }>(
|
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`)) {
|
`SELECT id, db_password_enc FROM client WHERE db_password_enc IS NOT NULL`)) {
|
||||||
if (!isLegacyCiphertext(r.db_password_enc)) continue
|
if (!isLegacyCiphertext(r.db_password_enc)) continue
|
||||||
try {
|
await db.run(`UPDATE client SET db_password_enc=NULL WHERE id=?`, r.id)
|
||||||
await db.run(`UPDATE client SET db_password_enc=? WHERE id=?`, encrypt(decrypt(r.db_password_enc, keyHex), ''), r.id)
|
res.clientDbPasswords++
|
||||||
res.clientDbPasswords++
|
|
||||||
} catch (e) { res.errors.push(`client db password ${r.id}: ${msg(e)}`) }
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// email_account.refresh_token_enc — Gmail send token.
|
// A dead Gmail token means Gmail must be reconnected — remove the unreadable account row.
|
||||||
for (const r of await db.all<{ id: string; refresh_token_enc: string }>(
|
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`)) {
|
`SELECT id, refresh_token_enc FROM email_account WHERE refresh_token_enc IS NOT NULL`)) {
|
||||||
if (!isLegacyCiphertext(r.refresh_token_enc)) continue
|
if (!isLegacyCiphertext(r.refresh_token_enc)) continue
|
||||||
try {
|
await db.run(`DELETE FROM email_account WHERE id=?`, r.id)
|
||||||
await db.run(`UPDATE email_account SET refresh_token_enc=? WHERE id=?`, encrypt(decrypt(r.refresh_token_enc, keyHex), ''), r.id)
|
res.gmailTokens++
|
||||||
res.gmailTokens++
|
|
||||||
} catch (e) { res.errors.push(`gmail token ${r.id}: ${msg(e)}`) }
|
|
||||||
}
|
}
|
||||||
|
|
||||||
await writeAudit(db, 'system', 'migrate_plaintext', 'system', 'crypto', undefined, {
|
await writeAudit(db, 'system', 'clear_legacy_credentials', 'system', 'crypto', undefined, { ...res })
|
||||||
modulePasswords: res.modulePasswords, moduleSecrets: res.moduleSecrets,
|
|
||||||
clientDbPasswords: res.clientDbPasswords, gmailTokens: res.gmailTokens, errors: res.errors.length,
|
|
||||||
})
|
|
||||||
return res
|
return res
|
||||||
}
|
}
|
||||||
|
|||||||
Loading…
Reference in New Issue