mirror of
https://github.com/MBombeck/HealthLog.git
synced 2026-11-09 21:10:59 +00:00
fix(crypto): log swallowed decrypt on undecryptable read-path fields
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.
This commit is contained in:
1 parent
801aa73de0
commit
1a95739561
7 files changed
+108
-6
No files matched your search
@@ -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;
|
||||
}
|
||||
}
|
||||
+11
-1
@@ -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;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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"),
|
||||
);
|
||||
});
|
||||
});
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
|
||||
Reference in new issue
Block a user