From 1a957395614da8256804ab2a72aef34a429179bc Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Marc-Andr=C3=A9=20Bombeck?= Date: Thu, 9 Jul 2026 00:21:16 +0200 Subject: [PATCH] fix(crypto): log swallowed decrypt on undecryptable read-path fields MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The cycle/illness/records note DTOs, the biomarker context, and the custom mood/cycle labels each degraded an undecryptable value to a blank/generic fallback with no signal — a key-rotation gap read as 'left blank' and the catch was silent. Keep the fail-soft read posture (one bad row never 500s the list) but emit a wide-event on each swallowed decrypt so a systemic key gap surfaces. The visible per-field 'unreadable' marker in the UI is the deferred remainder. --- src/lib/cycle/custom-symptoms.ts | 11 ++++- src/lib/cycle/dto.ts | 12 ++++- src/lib/illness/dto.ts | 10 +++- src/lib/labs/biomarker-store.ts | 11 ++++- .../custom-label-decrypt-logging.test.ts | 49 +++++++++++++++++++ src/lib/mood/custom-tags.ts | 11 ++++- src/lib/records/dto.ts | 10 +++- 7 files changed, 108 insertions(+), 6 deletions(-) create mode 100644 src/lib/mood/__tests__/custom-label-decrypt-logging.test.ts diff --git a/src/lib/cycle/custom-symptoms.ts b/src/lib/cycle/custom-symptoms.ts index d4fa21123..7435d731a 100644 --- a/src/lib/cycle/custom-symptoms.ts +++ b/src/lib/cycle/custom-symptoms.ts @@ -1,6 +1,7 @@ import { randomUUID } from "node:crypto"; import { encrypt, decrypt } from "@/lib/crypto"; +import { getEvent } from "@/lib/logging/context"; import { CUSTOM_SYMPTOM_KEY_PREFIX } from "@/lib/cycle/custom-symptoms-shared"; @@ -52,7 +53,15 @@ export function decryptCustomLabel(encoded: string | null): string | null { if (!encoded) return null; try { return decrypt(encoded); - } catch { + } catch (err) { + // A custom label WAS stored but is undecryptable — fail soft (the caller + // falls back to the generic i18n label) but log it (F-CRYPTO-5) so the loss + // of a user-renamed symptom is not entirely silent. + getEvent()?.addWarning( + `cycle custom-label decrypt failed: ${ + err instanceof Error ? err.message : String(err) + }`, + ); return null; } } diff --git a/src/lib/cycle/dto.ts b/src/lib/cycle/dto.ts index 5dbc4e83c..59fdd1851 100644 --- a/src/lib/cycle/dto.ts +++ b/src/lib/cycle/dto.ts @@ -9,6 +9,7 @@ * (NULL = a plain presence link). */ import { decrypt } from "@/lib/crypto"; +import { getEvent } from "@/lib/logging/context"; import type { CyclePredictionResult } from "@/lib/cycle/types"; import type { CycleDayLog, @@ -73,7 +74,16 @@ function decryptNote(notesEncrypted: string | null): string | null { if (!notesEncrypted) return null; try { return decrypt(notesEncrypted); - } catch { + } catch (err) { + // A note WAS stored but is undecryptable (key-rotation gap / corruption). + // Fail soft to null so one bad row never throws the day-log read, but log + // it (F-CRYPTO-2) so a systemic key gap surfaces instead of silently + // reading as an empty note. + getEvent()?.addWarning( + `cycle day-log note decrypt failed: ${ + err instanceof Error ? err.message : String(err) + }`, + ); return null; } } diff --git a/src/lib/illness/dto.ts b/src/lib/illness/dto.ts index 02eb7fad2..eb55ce7ec 100644 --- a/src/lib/illness/dto.ts +++ b/src/lib/illness/dto.ts @@ -10,6 +10,7 @@ * Jackson/WURSS intensity (NULL = a plain presence link). */ import { decryptFromBytes } from "@/lib/ai/coach/bytes-codec"; +import { getEvent } from "@/lib/logging/context"; import type { IllnessEpisode, IllnessDayLog, @@ -51,7 +52,14 @@ function decryptNote(noteEncrypted: Uint8Array | null): string | null { if (!noteEncrypted || noteEncrypted.byteLength === 0) return null; try { return decryptFromBytes(noteEncrypted); - } catch { + } catch (err) { + // Undecryptable note (key gap / corruption): fail soft to null but log it + // (F-CRYPTO-2) so a systemic key gap surfaces instead of reading as blank. + getEvent()?.addWarning( + `illness note decrypt failed: ${ + err instanceof Error ? err.message : String(err) + }`, + ); return null; } } diff --git a/src/lib/labs/biomarker-store.ts b/src/lib/labs/biomarker-store.ts index dd2f3cdf6..b6cefe5e1 100644 --- a/src/lib/labs/biomarker-store.ts +++ b/src/lib/labs/biomarker-store.ts @@ -13,6 +13,7 @@ import { Buffer } from "node:buffer"; import { decrypt, encrypt } from "@/lib/crypto"; import { prisma } from "@/lib/db"; +import { getEvent } from "@/lib/logging/context"; /** Encrypt a UTF-8 context note into the `Bytes` payload the schema stores. */ export function encryptContextToBytes( @@ -40,7 +41,15 @@ export function decryptContextSoft(buf: Uint8Array | null): string | null { if (!buf) return null; try { return decryptContextFromBytes(buf); - } catch { + } catch (err) { + // Undecryptable context (key gap / corruption): fail soft to null so one + // bad row never 500s the catalog list, but log it (F-CRYPTO-2) so a + // systemic key gap surfaces instead of masking as an empty context. + getEvent()?.addWarning( + `biomarker context decrypt failed: ${ + err instanceof Error ? err.message : String(err) + }`, + ); return null; } } diff --git a/src/lib/mood/__tests__/custom-label-decrypt-logging.test.ts b/src/lib/mood/__tests__/custom-label-decrypt-logging.test.ts new file mode 100644 index 000000000..58db22f85 --- /dev/null +++ b/src/lib/mood/__tests__/custom-label-decrypt-logging.test.ts @@ -0,0 +1,49 @@ +/** + * F-CRYPTO-5 — an undecryptable custom mood label fails soft (the caller falls + * back to the generic i18n label) but must NOT be entirely silent: a swallowed + * decrypt now emits a wide-event so the loss of a user-renamed tag surfaces. + */ +import { beforeEach, describe, expect, it, vi } from "vitest"; + +const { decryptMock, addWarningMock } = vi.hoisted(() => ({ + decryptMock: vi.fn(), + addWarningMock: vi.fn(), +})); + +vi.mock("@/lib/crypto", () => ({ + decrypt: decryptMock, + encrypt: vi.fn(), +})); +vi.mock("@/lib/logging/context", () => ({ + getEvent: () => ({ addWarning: addWarningMock }), +})); + +import { decryptCustomLabel } from "../custom-tags"; + +beforeEach(() => { + decryptMock.mockReset(); + addWarningMock.mockReset(); +}); + +describe("decryptCustomLabel", () => { + it("returns null without logging for a genuinely-unset label", () => { + expect(decryptCustomLabel(null)).toBeNull(); + expect(addWarningMock).not.toHaveBeenCalled(); + }); + + it("returns the decrypted label on success", () => { + decryptMock.mockReturnValue("Morning walk"); + expect(decryptCustomLabel("cipher")).toBe("Morning walk"); + expect(addWarningMock).not.toHaveBeenCalled(); + }); + + it("fails soft to null AND logs a wide-event when the decrypt throws", () => { + decryptMock.mockImplementation(() => { + throw new Error("bad key"); + }); + expect(decryptCustomLabel("cipher")).toBeNull(); + expect(addWarningMock).toHaveBeenCalledWith( + expect.stringContaining("custom-label decrypt failed"), + ); + }); +}); diff --git a/src/lib/mood/custom-tags.ts b/src/lib/mood/custom-tags.ts index 317435128..efc5d52fc 100644 --- a/src/lib/mood/custom-tags.ts +++ b/src/lib/mood/custom-tags.ts @@ -3,6 +3,7 @@ import { randomUUID } from "node:crypto"; import { z } from "zod/v4"; import { encrypt, decrypt } from "@/lib/crypto"; +import { getEvent } from "@/lib/logging/context"; import { MOOD_TAG_ICON_NAMES } from "@/lib/mood/icon-catalog"; /** @@ -77,7 +78,15 @@ export function decryptCustomLabel(encoded: string | null): string | null { if (!encoded) return null; try { return decrypt(encoded); - } catch { + } catch (err) { + // A custom label WAS stored but is undecryptable — fail soft (the caller + // falls back to the generic i18n label) but log it (F-CRYPTO-5) so the loss + // of a user-renamed tag is not entirely silent. + getEvent()?.addWarning( + `mood custom-label decrypt failed: ${ + err instanceof Error ? err.message : String(err) + }`, + ); return null; } } diff --git a/src/lib/records/dto.ts b/src/lib/records/dto.ts index 6df06b9ae..5178914f2 100644 --- a/src/lib/records/dto.ts +++ b/src/lib/records/dto.ts @@ -8,6 +8,7 @@ * never 500s the whole page) — the IllnessEpisode DTO precedent. */ import { decryptFromBytes } from "@/lib/ai/coach/bytes-codec"; +import { getEvent } from "@/lib/logging/context"; import type { Allergy, FamilyHistoryEntry } from "@/generated/prisma/client"; export interface AllergyDTO { @@ -39,7 +40,14 @@ function decryptOptional(buf: Uint8Array | null): string | null { if (!buf || buf.byteLength === 0) return null; try { return decryptFromBytes(buf); - } catch { + } catch (err) { + // Undecryptable payload (key gap / corruption): fail soft to null but log + // it (F-CRYPTO-2) so a systemic key gap surfaces instead of reading blank. + getEvent()?.addWarning( + `records field decrypt failed: ${ + err instanceof Error ? err.message : String(err) + }`, + ); return null; } }