fix(mcp): use ephemeral salted HMAC for recall receipt hashes

Plain deterministic sha256 prefixes over low-entropy values (query,
container tags, memory ids, short content) are dictionary-guessable
offline and link the same value across receipts forever. Key an HMAC
with a per-receipt random salt that is never emitted, so tokens cannot
be precomputed or correlated across receipts while equality is still
preserved within a single receipt for debugging.
This commit is contained in:
Rāna(Bass Ver.) 2026-06-03 16:29:30 +08:00
parent 3ac6575db0
commit 7e89f86b3b
2 changed files with 81 additions and 15 deletions

View file

@ -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")

View file

@ -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<string> {
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<string>
> {
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<string> => {
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<RetrievalReceipt> {
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",
}
}