fix(coach): log swallowed self-context decrypt failures

The Coach self-context loader failed soft per field (undecryptable
allergies/conditions → null) with no signal, so a key-rotation gap made
the Coach generate guidance without ever seeing the user's recorded
allergies, indistinguishable from an empty profile. Keep the per-field
fail-closed posture but emit a wide-event on each swallowed decrypt and on
the DB-read guard, so a systemic failure surfaces instead of masquerading
as 'the user never wrote anything'.
This commit is contained in:
Marc-André Bombeck committed 2026-07-08 23:59:37 +02:00
1 parent 9eadd0755f
commit 3faa4ee76e
2 files changed
+116 -5

No files matched your search

@@ -0,0 +1,85 @@
/**
* F-CRYPTO-4 — an undecryptable Coach self-context field (allergies /
* conditions) must NOT vanish silently. It still fails closed per field (never
* surfaces ciphertext, never throws into prompt assembly), but a swallowed
* decrypt now emits a wide-event so a key-rotation gap surfaces instead of
* masquerading as "the user never wrote anything".
*/
import { beforeEach, describe, expect, it, vi } from "vitest";
const { decryptMock, addWarningMock, findUniqueMock } = vi.hoisted(() => ({
decryptMock: vi.fn(),
addWarningMock: vi.fn(),
findUniqueMock: vi.fn(),
}));
vi.mock("@/lib/db", () => ({
prisma: { userHealthProfile: { findUnique: findUniqueMock } },
}));
vi.mock("@/lib/ai/coach/bytes-codec", () => ({
decryptFromBytes: decryptMock,
encryptToBytes: vi.fn(),
}));
vi.mock("@/lib/logging/context", () => ({
getEvent: () => ({ addWarning: addWarningMock }),
}));
import { getSelfContextForUser } from "../about-me";
beforeEach(() => {
decryptMock.mockReset();
addWarningMock.mockReset();
findUniqueMock.mockReset();
});
describe("getSelfContextForUser — undecryptable field", () => {
it("fails closed to null AND logs a wide-event on a decrypt failure", async () => {
findUniqueMock.mockResolvedValue({
aboutMeEncrypted: new Uint8Array([1]),
conditionsEncrypted: null,
allergiesEncrypted: new Uint8Array([2]), // safety-relevant, undecryptable
coachFocusEncrypted: null,
});
decryptMock.mockImplementation(() => {
throw new Error("bad ciphertext");
});
const ctx = await getSelfContextForUser("u1");
// Never surfaces ciphertext; the field is null (fail-closed).
expect(ctx.aboutMe).toBeNull();
expect(ctx.allergies).toBeNull();
// But the swallowed decrypt is now visible — one warning per undecryptable
// field (aboutMe + allergies here).
expect(addWarningMock).toHaveBeenCalledTimes(2);
expect(addWarningMock.mock.calls[0][0]).toContain("self-context");
});
it("does not log when the fields are genuinely unset", async () => {
findUniqueMock.mockResolvedValue({
aboutMeEncrypted: null,
conditionsEncrypted: null,
allergiesEncrypted: null,
coachFocusEncrypted: null,
});
const ctx = await getSelfContextForUser("u1");
expect(ctx).toEqual({
aboutMe: null,
conditions: null,
allergies: null,
coachFocus: null,
});
expect(addWarningMock).not.toHaveBeenCalled();
});
it("logs a wide-event when the DB read itself throws (no silent empty profile)", async () => {
findUniqueMock.mockRejectedValue(new Error("db down"));
const ctx = await getSelfContextForUser("u1");
expect(ctx.allergies).toBeNull();
expect(addWarningMock).toHaveBeenCalledWith(
expect.stringContaining("self-context load failed"),
);
});
});
+31 -5
View File
@@ -27,6 +27,7 @@
* Server-only — reads `@/lib/db`.
*/
import { prisma } from "@/lib/db";
import { getEvent } from "@/lib/logging/context";
import { decryptFromBytes, encryptToBytes } from "@/lib/ai/coach/bytes-codec";
import {
SELF_REPORT_FENCE_START,
@@ -48,9 +49,18 @@ function decryptOrNull(payload: Uint8Array | null): string | null {
try {
const text = decryptFromBytes(payload).trim();
return text.length > 0 ? text : null;
} catch {
// Fail closed per field — never surface ciphertext, never throw
// into a prompt-assembly path.
} catch (err) {
// Fail closed per field — never surface ciphertext, never throw into a
// prompt-assembly path. But do NOT swallow silently: a stored-but-
// undecryptable safety-relevant field (allergies / conditions) that
// vanishes from the Coach's self-context with no signal is exactly the
// F-CRYPTO-4 gap. Emit a wide-event so a key-rotation gap surfaces instead
// of masquerading as "the user never wrote anything".
getEvent()?.addWarning(
`coach self-context: field decrypt failed (${
err instanceof Error ? err.message : String(err)
})`,
);
return null;
}
}
@@ -83,7 +93,15 @@ export async function getSelfContextForUser(
allergies: decryptOrNull(row?.allergiesEncrypted ?? null),
coachFocus: decryptOrNull(row?.coachFocusEncrypted ?? null),
};
} catch {
} catch (err) {
// Per-field decrypt already fails closed above; this catch is the DB-read
// guard. Surface it (rather than masquerading as an empty profile) so a
// systemic read/key-config failure is visible in wide-events.
getEvent()?.addWarning(
`coach self-context load failed: ${
err instanceof Error ? err.message : String(err)
}`,
);
return {
aboutMe: null,
conditions: null,
@@ -390,7 +408,15 @@ export async function getSelfContextTextForUser(
);
if (!selfText && !recordsText) return null;
return [selfText, recordsText].filter(Boolean).join("\n");
} catch {
} catch (err) {
// Per-field decrypt fails closed inside the composer; this catch is the
// DB-read guard. Log it so a systemic failure surfaces rather than the
// Coach silently generating guidance without the user's recorded profile.
getEvent()?.addWarning(
`coach self-context text load failed: ${
err instanceof Error ? err.message : String(err)
}`,
);
return null;
}
}