Files
HealthLog/src/lib/moodlog-secret.ts
T
Marc-André BombeckandClaude Opus 4.7 909c572bfd fix(auth): reject moodLog webhook secrets that fail envelope decryption
`readMoodLogSecret()` previously caught every decrypt failure and
returned the stored bytes verbatim, on the theory that legacy
plaintext rows should keep working through the migration grace
period. That fallback is correct for genuinely unprefixed legacy
values, but it also swallowed two failure modes that should never
silently succeed:

- An envelope-shaped row (`<keyId>.<payload>`) whose key id is no
  longer in the live `ENCRYPTION_KEYS` map. Returning the literal
  `v1.<base64-ciphertext>` as if it were the plaintext secret would
  cause every webhook attempt against that row to compare a known-bad
  literal — and worse, lock the user into a state where rotating the
  key out of the env quietly broke their integration.
- A row whose envelope bytes were tampered with directly in the
  column (SQL injection, restored-from-backup-with-tamper, leaked DB
  credentials). The catch-all returned the attacker-chosen value as
  the active secret; the webhook would then accept whatever the
  attacker sent over the wire.

The fix routes the envelope-shape check through `extractKeyId()` and
only catches the failure for the genuinely legacy path. Envelope
rows that fail to decrypt now throw — operators see the rotation gap
or the tampered row instead of running on a quietly-broken secret.

`isLegacyPlaintext()` simplifies to the same shape check: anything
with an envelope prefix is encrypted (even if currently undecryptable),
so it should not be re-rotated as if it were plaintext.

Two new test cases pin the contract for envelope-shape failures
(unknown key id; tampered ciphertext under a known key).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-27 00:08:56 +02:00

85 lines
3.4 KiB
TypeScript

/**
* moodLog webhook secret — encrypt-at-rest helpers (V3 audit STILL-V2-C-2).
*
* Previously the `mood_log_webhook_secret` column stored the secret in
* plaintext. The webhook handler did a timing-safe `Buffer.equals` against
* every enabled user's plaintext secret to find a match.
*
* The new contract:
* - All writes go through `encryptMoodLogSecret()` → AES-256-GCM
* base64 envelope.
* - Reads go through `readMoodLogSecret()` which decrypts envelopes;
* if a stored value is NOT a valid envelope it is treated as a legacy
* plaintext secret (transitional grace period). Operators can leave
* existing rows in place — the next write rotates them to encrypted.
*
* The webhook lookup still iterates candidates, but each candidate is
* decrypted before the timing-safe compare. Cost is O(n) decrypt calls
* per webhook invocation; acceptable for small deployments and easily
* upgraded later by adding an HMAC lookup column.
*/
import { encrypt, decrypt, extractKeyId } from "@/lib/crypto";
export function encryptMoodLogSecret(plaintext: string): string {
return encrypt(plaintext);
}
export function readMoodLogSecret(stored: string | null): string | null {
if (!stored) return null;
// Anything carrying a `<keyId>.<payload>` envelope must decrypt
// cleanly. A failure here means either the active key map no longer
// includes the version this row was written under (rotation playbook
// not finished) or the ciphertext was tampered with directly in the
// column. Returning the raw envelope bytes as if they were the
// plaintext secret would silently turn a corrupted row into an
// attacker-chosen webhook secret — so surface the failure instead.
if (extractKeyId(stored) !== null) {
return decrypt(stored);
}
// Legacy plaintext path — unprefixed values predate the encrypt-at-
// rest contract. Return as-is so the integration keeps working until
// the next write rotates them; `rotateLegacyMoodLogSecrets()` runs
// at boot to convert them.
return stored;
}
/**
* Whether the stored value still looks like an unencrypted legacy secret.
* Used by the boot-time migration helper + the rotation flow.
*/
export function isLegacyPlaintext(stored: string | null): boolean {
if (!stored) return false;
// A row is legacy plaintext only when it does NOT carry the
// `<keyId>.<payload>` envelope. Envelope-shaped rows are encrypted —
// even ones we currently can't decrypt (which should fail loud, not
// get re-rotated as if they were plaintext).
return extractKeyId(stored) === null;
}
interface MoodLogSecretMigrationDeps {
findLegacy: () => Promise<
Array<{ id: string; moodLogWebhookSecret: string | null }>
>;
rotate: (id: string, encryptedSecret: string) => Promise<void>;
}
/**
* One-shot startup migration: encrypts any leftover plaintext secret rows
* with AES-GCM. Idempotent — encrypted rows are skipped automatically.
* Returns the number of rows rotated.
*/
export async function rotateLegacyMoodLogSecrets(
deps: MoodLogSecretMigrationDeps,
): Promise<number> {
const candidates = await deps.findLegacy();
let rotated = 0;
for (const row of candidates) {
if (!row.moodLogWebhookSecret) continue;
if (!isLegacyPlaintext(row.moodLogWebhookSecret)) continue;
const encrypted = encryptMoodLogSecret(row.moodLogWebhookSecret);
await deps.rotate(row.id, encrypted);
rotated += 1;
}
return rotated;
}