/** * Secrets-decryptable check. * * Walks every encrypted secret in the celilo DB (module secrets, * system secrets, capability secrets) and verifies it decrypts * cleanly with the current master key. Catches: * * - Master key rotated without re-encrypting existing secrets. * - Encryption envelope corrupted by external write. * - Algorithm mismatch (key length / GCM tag failure). * * When *every* secret fails with the same error, we collapse to a * single "master key mismatch" finding instead of N identical ones — * the user-facing signal is "the master key is wrong", not "secret * X is broken". */ import type { DriftFinding } from './types'; export type SecretScope = 'module' | 'system' | 'capability'; export interface SecretCheckResult { scope: SecretScope; /** Module ID for module/capability secrets; 'system' for system secrets. */ subject: string; /** Secret name (e.g. `router_password`, `proxmox_api_token`). */ name: string; /** null on success; error message on failure. */ error: string | null; } export interface SecretsDecryptableAuditDeps { results: SecretCheckResult[]; } export async function auditSecretsDecryptable( deps: SecretsDecryptableAuditDeps, ): Promise { const failed = deps.results.filter((r) => r.error !== null); if (failed.length === 0) return []; // If every secret fails with the same error class, collapse to one // finding pointing at the master key — that's the actual problem, // not N broken records. if (failed.length === deps.results.length && deps.results.length > 1) { const errorBuckets = new Map(); for (const r of failed) { const key = (r.error ?? '').slice(0, 60); errorBuckets.set(key, (errorBuckets.get(key) ?? 0) + 1); } if (errorBuckets.size === 1) { const [reason] = errorBuckets.keys(); return [ { category: 'secrets_decryptable', severity: 'blocked', code: 'master_key_mismatch', message: `All ${failed.length} secrets fail to decrypt — master key may be wrong or rotated`, details: reason, remediation: [ 'Every secret in the celilo DB fails to decrypt with the', 'current master key. Common causes:', '', ' 1. Master key file replaced (e.g. machine restored from', ' a backup that includes a different master key).', ' 2. Master key file corrupted.', ' 3. DB restored from a backup taken under a different', ' master key.', '', 'If you have the previous master key, restore it. If not,', "you'll need to re-add every secret manually.", ].join('\n'), actionable: false, subject: 'system', }, ]; } } // Otherwise, per-secret findings. return failed.map((r) => ({ category: 'secrets_decryptable' as const, severity: 'blocked' as const, code: `secret_decrypt_failed_${r.scope}`, message: `${r.scope} secret "${r.name}" (${r.subject}) failed to decrypt`, details: r.error ?? undefined, remediation: r.scope === 'module' ? `Re-set the secret:\n celilo module secret set ${r.subject} ${r.name} ` : r.scope === 'system' ? `Re-set the secret:\n celilo system secret set ${r.name} ` : 'Re-set the capability secret via the owning module.', // Multi-step (need plaintext value); not a one-keypress fix. actionable: false, subject: r.subject, })); }