diff --git a/packages/gittensory-engine/src/index.ts b/packages/gittensory-engine/src/index.ts index d5b02bdef0..61e28496c2 100644 --- a/packages/gittensory-engine/src/index.ts +++ b/packages/gittensory-engine/src/index.ts @@ -672,3 +672,8 @@ export { SECRET_PATTERNS, redactSecrets, } from "./subprocess-env.js"; + +// Shared telemetry-anonymization primitive (#5680) — one source of truth for the per-instance-secret HMAC +// hashing both Orb's self-host collector and AMS's export path use before repo/PR identifiers leave the +// instance. +export { generateAnonSecret, hmacAnonymize } from "./telemetry/anonymize.js"; diff --git a/packages/gittensory-engine/src/telemetry/anonymize.ts b/packages/gittensory-engine/src/telemetry/anonymize.ts new file mode 100644 index 0000000000..78786186b8 --- /dev/null +++ b/packages/gittensory-engine/src/telemetry/anonymize.ts @@ -0,0 +1,29 @@ +// Shared telemetry-anonymization primitive (#5680): one source of truth for the per-instance-secret HMAC +// hashing every self-hosted product (Orb's `src/selfhost/orb-collector.ts`, and AMS's own export path) uses +// to anonymize repo/PR identifiers before they leave the instance. Extracted out of Orb-only code so a second, +// independently-maintained implementation never drifts from this one -- a weaker hash or a reused secret in +// only one of the two would be a real privacy bug, not style debt. +// +// Deliberately narrow: secret PERSISTENCE (where/how it's stored -- D1 for Orb, local SQLite for AMS) stays in +// each product's own store, since that's genuinely different per product. Only the pure hash/generate math +// lives here. +import { createHmac, randomBytes } from "node:crypto"; + +/** + * Generate a fresh, single-purpose 256-bit anonymization secret (64 hex chars). Each product persists this + * once per instance in its own store and reuses it on every export, so the same raw value always hashes the + * same way. Never derived from, or shared with, any other credential (App private keys, webhook secrets) -- + * key separation means a leaked anonymization secret can't be used to forge or decrypt anything else. + */ +export function generateAnonSecret(): string { + return randomBytes(32).toString("hex"); +} + +/** + * HMAC-SHA256 `value` with the instance's own anonymization secret, truncated to 24 hex chars. The collector + * receiving the output never holds `secret`, so it can never reverse the hash back to the original value -- + * it can only tell that two exports carrying the same hash referred to the same underlying repo/PR. + */ +export function hmacAnonymize(value: string, secret: string): string { + return createHmac("sha256", secret).update(value).digest("hex").slice(0, 24); +} diff --git a/packages/gittensory-engine/test/anonymize.test.ts b/packages/gittensory-engine/test/anonymize.test.ts new file mode 100644 index 0000000000..0fc3ef7562 --- /dev/null +++ b/packages/gittensory-engine/test/anonymize.test.ts @@ -0,0 +1,40 @@ +import { test } from "node:test"; +import assert from "node:assert/strict"; + +import { generateAnonSecret, hmacAnonymize } from "../dist/index.js"; + +test("generateAnonSecret: returns a 64-char hex string (256-bit secret)", () => { + const secret = generateAnonSecret(); + assert.equal(secret.length, 64); + assert.match(secret, /^[0-9a-f]{64}$/); +}); + +test("generateAnonSecret: two calls never collide", () => { + assert.notEqual(generateAnonSecret(), generateAnonSecret()); +}); + +test("hmacAnonymize: deterministic — same value+secret always hashes the same", () => { + const secret = "fixed-secret-for-test"; + assert.equal(hmacAnonymize("acme/widgets", secret), hmacAnonymize("acme/widgets", secret)); +}); + +test("hmacAnonymize: different values under the same secret hash differently", () => { + const secret = "fixed-secret-for-test"; + assert.notEqual(hmacAnonymize("acme/widgets", secret), hmacAnonymize("acme/other", secret)); +}); + +test("hmacAnonymize: the same value under different secrets hashes differently", () => { + assert.notEqual(hmacAnonymize("acme/widgets", "secret-a"), hmacAnonymize("acme/widgets", "secret-b")); +}); + +test("hmacAnonymize: output is truncated to 24 hex chars", () => { + const hash = hmacAnonymize("acme/widgets#42", "fixed-secret-for-test"); + assert.equal(hash.length, 24); + assert.match(hash, /^[0-9a-f]{24}$/); +}); + +test("hmacAnonymize: matches Orb's own pre-extraction output for a known vector (regression)", () => { + // Fixed vector captured from the original inline `hmacField` in orb-collector.ts before extraction — + // guards against the refactor silently changing Orb's live anonymized output. + assert.equal(hmacAnonymize("acme/widgets", "known-fixed-secret"), "7323d8850fac6d7c2c4bdfae"); +}); diff --git a/src/selfhost/orb-collector.ts b/src/selfhost/orb-collector.ts index 7b78885fa4..2ac7320b0f 100644 --- a/src/selfhost/orb-collector.ts +++ b/src/selfhost/orb-collector.ts @@ -15,7 +15,8 @@ // No diffs, no code, no comments, no logins, no commit SHAs — only verdict + outcome + reversal + a bucketed // reason category + cycle time, with repo/PR identifiers HMAC'd by a key the collector never holds (so it // can never de-anonymize). -import { createHash, createHmac, randomBytes } from "node:crypto"; +import { createHash, createHmac } from "node:crypto"; +import { generateAnonSecret, hmacAnonymize } from "../../packages/gittensory-engine/src/telemetry/anonymize.js"; import { incr } from "./metrics"; /** Key under which the per-instance anonymization secret is persisted in system_flags. */ @@ -60,11 +61,6 @@ function instanceId(anonSecret: string): string { return createHash("sha256").update(seed).digest("hex").slice(0, 16); } -/** HMAC a string with the instance's own secret for anonymized export. */ -function hmacField(value: string, secret: string): string { - return createHmac("sha256", secret).update(value).digest("hex").slice(0, 24); -} - /** * The instance's DEDICATED anonymization secret: a 256-bit random key generated once and persisted in * system_flags, then reused on every export. Stable across restarts so a repo/PR always hashes the same @@ -81,7 +77,7 @@ export async function getOrCreateAnonSecret(db: D1Database): Promise { }; const existing = await read(); if (existing) return existing; - const generated = randomBytes(32).toString("hex"); // 256-bit, 64 hex chars + const generated = generateAnonSecret(); // 256-bit, 64 hex chars // Race-safe across instances sharing a Postgres DB: OR IGNORE keeps the first writer's key; the re-read // returns whichever value won, so every instance converges on the same secret. await db @@ -188,8 +184,8 @@ export async function exportOrbBatch(db: D1Database, batchSize = 200, fetchFn: t const payload: OrbExportPayload = { instance_id: instance, events: results.map((r) => ({ - repo_hash: anonymize ? hmacField(r.project, secret) : r.project, - pr_hash: anonymize ? hmacField(r.target_id, secret) : r.target_id, + repo_hash: anonymize ? hmacAnonymize(r.project, secret) : r.project, + pr_hash: anonymize ? hmacAnonymize(r.target_id, secret) : r.target_id, gate_verdict: r.verdict, outcome: r.outcome, reversal_flag: r.reverted ? "reverted" : r.reopened ? "reopened" : "none", diff --git a/test/unit/engine-telemetry-anonymize.test.ts b/test/unit/engine-telemetry-anonymize.test.ts new file mode 100644 index 0000000000..d26e6ad5c5 --- /dev/null +++ b/test/unit/engine-telemetry-anonymize.test.ts @@ -0,0 +1,29 @@ +// App-vitest coverage for the engine telemetry-anonymization primitive (#5680). codecov/patch is computed from +// this app vitest run (vitest.config coverage includes packages/gittensory-engine/src/**), so the changed engine +// lines need a vitest test that imports the SRC directly, in addition to the engine's own node:test suite. +import { describe, expect, it } from "vitest"; +import { generateAnonSecret, hmacAnonymize } from "../../packages/gittensory-engine/src/telemetry/anonymize"; + +describe("engine telemetry-anonymize primitive (#5680)", () => { + it("generateAnonSecret returns a 64-char hex string and never collides across calls", () => { + const a = generateAnonSecret(); + const b = generateAnonSecret(); + expect(a).toMatch(/^[0-9a-f]{64}$/); + expect(b).toMatch(/^[0-9a-f]{64}$/); + expect(a).not.toBe(b); + }); + + it("hmacAnonymize is deterministic per value+secret, differs across values and across secrets, truncated to 24 hex chars", () => { + const secret = "fixed-secret-for-test"; + expect(hmacAnonymize("acme/widgets", secret)).toBe(hmacAnonymize("acme/widgets", secret)); + expect(hmacAnonymize("acme/widgets", secret)).not.toBe(hmacAnonymize("acme/other", secret)); + expect(hmacAnonymize("acme/widgets", "secret-a")).not.toBe(hmacAnonymize("acme/widgets", "secret-b")); + expect(hmacAnonymize("acme/widgets#42", secret)).toMatch(/^[0-9a-f]{24}$/); + }); + + it("matches Orb's own pre-extraction output for a known vector (regression)", () => { + // Fixed vector captured from the original inline `hmacField` in orb-collector.ts before extraction — + // guards against the refactor silently changing Orb's live anonymized output. + expect(hmacAnonymize("acme/widgets", "known-fixed-secret")).toBe("7323d8850fac6d7c2c4bdfae"); + }); +});