mirror of
https://github.com/supermemoryai/supermemory.git
synced 2026-10-01 02:01:40 +00:00
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:
parent
3ac6575db0
commit
7e89f86b3b
2 changed files with 81 additions and 15 deletions
|
|
@ -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")
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
}
|
||||
}
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue