mirror of
https://github.com/MBombeck/HealthLog.git
synced 2026-11-13 14:32:03 +00:00
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>
This commit is contained in:
1 parent
165a14afda
commit
909c572bfd
2 files changed
+36
-13
No files matched your search
@@ -46,6 +46,23 @@ describe("moodLog secret encrypt-at-rest", () => {
|
||||
expect(isLegacyPlaintext(null)).toBe(false);
|
||||
});
|
||||
|
||||
it("throws when an envelope-shaped row fails to decrypt (tamper or stale key)", () => {
|
||||
// `<keyId>.<payload>` shape but the keyId is unknown to the live
|
||||
// config — must surface as an error, not silently return the
|
||||
// ciphertext as if it were the plaintext secret.
|
||||
expect(() => readMoodLogSecret("v9.AAAAAAAAAAAAAAAAAAAAAAA=")).toThrow();
|
||||
});
|
||||
|
||||
it("treats envelope-shaped tampered ciphertext as a hard failure", () => {
|
||||
// Round-trip an encrypted value, then flip a byte in the payload.
|
||||
// The shape stays envelope-like, so decrypt must throw (AES-GCM
|
||||
// auth-tag mismatch) instead of falling back to plaintext.
|
||||
const enc = encryptMoodLogSecret("mb_test");
|
||||
const dot = enc.indexOf(".");
|
||||
const tampered = `${enc.slice(0, dot + 1)}AA${enc.slice(dot + 3)}`;
|
||||
expect(() => readMoodLogSecret(tampered)).toThrow();
|
||||
});
|
||||
|
||||
it("rotateLegacyMoodLogSecrets encrypts only legacy rows and is idempotent", async () => {
|
||||
const enc = encryptMoodLogSecret("mb_alreadyencrypted");
|
||||
const rows = [
|
||||
|
||||
+19
-13
@@ -18,7 +18,7 @@
|
||||
* per webhook invocation; acceptable for small deployments and easily
|
||||
* upgraded later by adding an HMAC lookup column.
|
||||
*/
|
||||
import { encrypt, decrypt } from "@/lib/crypto";
|
||||
import { encrypt, decrypt, extractKeyId } from "@/lib/crypto";
|
||||
|
||||
export function encryptMoodLogSecret(plaintext: string): string {
|
||||
return encrypt(plaintext);
|
||||
@@ -26,14 +26,21 @@ export function encryptMoodLogSecret(plaintext: string): string {
|
||||
|
||||
export function readMoodLogSecret(stored: string | null): string | null {
|
||||
if (!stored) return null;
|
||||
try {
|
||||
// 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);
|
||||
} catch {
|
||||
// Legacy plaintext — keep returning it so the integration keeps
|
||||
// working until the next write rotates it. Operators can force a
|
||||
// migration by hitting the rotation endpoint.
|
||||
return 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;
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -42,12 +49,11 @@ export function readMoodLogSecret(stored: string | null): string | null {
|
||||
*/
|
||||
export function isLegacyPlaintext(stored: string | null): boolean {
|
||||
if (!stored) return false;
|
||||
try {
|
||||
decrypt(stored);
|
||||
return false;
|
||||
} catch {
|
||||
return true;
|
||||
}
|
||||
// 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 {
|
||||
|
||||
Reference in new issue
Block a user