diff --git a/apps/mcp/src/retrieval-receipt.test.ts b/apps/mcp/src/retrieval-receipt.test.ts index c1f06f17..5396130c 100644 --- a/apps/mcp/src/retrieval-receipt.test.ts +++ b/apps/mcp/src/retrieval-receipt.test.ts @@ -32,7 +32,7 @@ describe("retrieval receipts", () => { }, profile: { staticCount: 2, dynamicCount: 1 }, latencyMs: 42, - hashAlgorithm: "sha256-prefix-16", + hashAlgorithm: "hmac-sha256-ephemeral-salt-prefix-16", }) expect(receipt.queryHash).toHaveLength(16) @@ -49,6 +49,35 @@ describe("retrieval receipts", () => { expect(JSON.stringify(receipt)).not.toContain("Patient-specific") }) + it("keeps hashes equal within a receipt but unlinkable across receipts", async () => { + const args = { + query: "repeated query", + containerTag: "same-project", + results: [ + { id: "mem_dup", similarity: 0.5, text: "identical content" }, + { id: "mem_dup", similarity: 0.5, text: "identical content" }, + ], + latencyMs: 10, + } + + const first = await createRetrievalReceipt(args) + const second = await createRetrievalReceipt(args) + + // Within one receipt the same value hashes consistently, so duplicates + // remain detectable for debugging. + expect(first.result.idsHash[0]).toBe(first.result.idsHash[1]) + expect(first.result.contentHashes[0]).toBe(first.result.contentHashes[1]) + + // Across receipts the same private value produces different tokens, so it + // cannot be correlated or dictionary-guessed without the ephemeral salt. + expect(second.queryHash).not.toBe(first.queryHash) + expect(second.projectIdHash).not.toBe(first.projectIdHash) + expect(second.result.idsHash[0]).not.toBe(first.result.idsHash[0]) + expect(second.result.contentHashes[0]).not.toBe( + first.result.contentHashes[0], + ) + }) + it("keeps score buckets bounded at edges", () => { expect(toScoreBucket(1)).toBe("1.0") expect(toScoreBucket(0)).toBe("0.0-0.1") diff --git a/apps/mcp/src/retrieval-receipt.ts b/apps/mcp/src/retrieval-receipt.ts index 54b2639c..c5ce64b4 100644 --- a/apps/mcp/src/retrieval-receipt.ts +++ b/apps/mcp/src/retrieval-receipt.ts @@ -27,7 +27,7 @@ export type RetrievalReceipt = { dynamicCount: number } latencyMs: number - hashAlgorithm: "sha256-prefix-16" + hashAlgorithm: "hmac-sha256-ephemeral-salt-prefix-16" } type CreateRetrievalReceiptArgs = { @@ -44,16 +44,51 @@ type CreateRetrievalReceiptArgs = { } const HASH_PREFIX_LENGTH = 16 +const SALT_BYTES = 32 -async function hashValue(value: string): Promise { - const digest = await crypto.subtle.digest( - "SHA-256", - new TextEncoder().encode(value), +function bytesToHex(bytes: Uint8Array): string { + return Array.from(bytes, (byte) => byte.toString(16).padStart(2, "0")).join( + "", ) - return [...new Uint8Array(digest)] - .map((byte) => byte.toString(16).padStart(2, "0")) - .join("") - .slice(0, HASH_PREFIX_LENGTH) +} + +/** + * Builds a one-time keyed hasher for a single receipt. + * + * Plain deterministic SHA-256 is not privacy-safe for the values we hash here: + * queries, container tags, memory IDs, and (especially short) memory content + * are low-entropy, so a raw digest can be dictionary-guessed offline, and the + * same value would always produce the same digest and stay linkable across + * every receipt forever. + * + * Instead we key an HMAC with a cryptographically random salt that is generated + * per receipt and never emitted. Without the salt an attacker cannot precompute + * or brute-force the inputs, and because the salt is fresh for every receipt the + * same private value produces a different token each time, so receipts cannot be + * correlated against each other. Equality is preserved only within a single + * receipt (e.g. duplicate content in one result set), which is what debugging + * needs. + */ +async function createSaltedHasher(): Promise< + (value: string) => Promise +> { + const salt = crypto.getRandomValues(new Uint8Array(SALT_BYTES)) + const key = await crypto.subtle.importKey( + "raw", + salt, + { name: "HMAC", hash: "SHA-256" }, + false, + ["sign"], + ) + + return async (value: string): Promise => { + const signature = await crypto.subtle.sign( + "HMAC", + key, + new TextEncoder().encode(value), + ) + return bytesToHex(new Uint8Array(signature)).slice(0, HASH_PREFIX_LENGTH) + } } export function toScoreBucket(score: number): string { @@ -74,11 +109,13 @@ export async function createRetrievalReceipt({ latencyMs, profile, }: CreateRetrievalReceiptArgs): Promise { + const hash = await createSaltedHasher() + const [queryHash, projectIdHash, idsHash, contentHashes] = await Promise.all([ - hashValue(query), - containerTag ? hashValue(containerTag) : Promise.resolve(undefined), - Promise.all(results.map((result) => hashValue(result.id))), - Promise.all(results.map((result) => hashValue(result.text))), + hash(query), + containerTag ? hash(containerTag) : Promise.resolve(undefined), + Promise.all(results.map((result) => hash(result.id))), + Promise.all(results.map((result) => hash(result.text))), ]) return { @@ -98,6 +135,6 @@ export async function createRetrievalReceipt({ }, ...(profile ? { profile } : {}), latencyMs, - hashAlgorithm: "sha256-prefix-16", + hashAlgorithm: "hmac-sha256-ephemeral-salt-prefix-16", } }