From bcf2d6a64b5031fdc53b860e05034b4e9e8ef051 Mon Sep 17 00:00:00 2001 From: Okorie Chigozie Jehoshaphat Date: Mon, 17 Aug 2026 18:01:48 +0100 Subject: [PATCH] fix(analytics): replace insecure raw byte slice in hashPublicKey with full-input SHA-256 digest (#148) --- src/lib/analytics.test.ts | 103 ++++++++++++++++++++++++++++++++++++++ src/lib/analytics.ts | 56 +++++++++++++++++---- 2 files changed, 148 insertions(+), 11 deletions(-) create mode 100644 src/lib/analytics.test.ts diff --git a/src/lib/analytics.test.ts b/src/lib/analytics.test.ts new file mode 100644 index 0000000..1b4ab35 --- /dev/null +++ b/src/lib/analytics.test.ts @@ -0,0 +1,103 @@ +import { describe, it, expect, beforeEach, afterEach, vi } from "vitest"; +import { hashPublicKey, trackEvent } from "./analytics"; + +describe("analytics module", () => { + const addr1 = "GABCDEFGHIJKLMNOPQRSTUVWXYZ234567ABCDEFGHIJKLMNOPQRSTUVWX"; + const addr2 = "GABCZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZ"; + const addr3 = "GABCDEFGHIJKLMNOPQRSTUVWXYZ234567ABCDEFGHIJKLMNOPQRSTUVWY"; // 1 char diff at end + + describe("hashPublicKey", () => { + it("returns empty string for invalid or missing inputs", async () => { + expect(await hashPublicKey("")).toBe(""); + expect(await hashPublicKey(null as unknown as string)).toBe(""); + expect(await hashPublicKey(undefined as unknown as string)).toBe(""); + }); + + it("is deterministic (same input produces identical hash)", async () => { + const hashA = await hashPublicKey(addr1); + const hashB = await hashPublicKey(addr1); + expect(hashA).toBe(hashB); + expect(hashA.length).toBe(16); + }); + + it("hashes the full input so addresses sharing the same first 4 chars yield different outputs", async () => { + const hash1 = await hashPublicKey(addr1); + const hash2 = await hashPublicKey(addr2); + + // Both start with 'GABC', but total strings differ drastically + expect(addr1.slice(0, 4)).toBe("GABC"); + expect(addr2.slice(0, 4)).toBe("GABC"); + expect(hash1).not.toBe(hash2); + }); + + it("exhibits avalanche effect for single character variations at the end", async () => { + const hash1 = await hashPublicKey(addr1); + const hash3 = await hashPublicKey(addr3); + expect(hash1).not.toBe(hash3); + }); + + it("does not leak the raw ASCII byte hex representation of the address prefix", async () => { + const hash = await hashPublicKey(addr1); + // Raw ASCII hex of 'GABC' is '47414243' + expect(hash).not.toContain("47414243"); + expect(hash.startsWith("47414243")).toBe(false); + }); + }); + + describe("trackEvent", () => { + const originalWindow = globalThis.window; + + beforeEach(() => { + // Setup a window environment with mock gtag and dataLayer + const mockWindow = { + gtag: vi.fn(), + dataLayer: [], + }; + vi.stubGlobal("window", mockWindow); + }); + + afterEach(() => { + vi.stubGlobal("window", originalWindow); + }); + + it("sanitizes publicKey in props using hashPublicKey before dispatching to gtag", async () => { + await trackEvent("test_event", { publicKey: addr1, amount: 100 }); + + const w = window as unknown as { gtag: ReturnType }; + expect(w.gtag).toHaveBeenCalledTimes(1); + + const [command, eventName, payload] = w.gtag.mock.calls[0]; + expect(command).toBe("event"); + expect(eventName).toBe("test_event"); + expect(payload.amount).toBe(100); + expect(typeof payload.timestamp).toBe("number"); + + // Verify publicKey is sanitized and no longer contains raw address + expect(payload.publicKey).not.toBe(addr1); + expect(payload.publicKey).toBe(await hashPublicKey(addr1)); + }); + + it("pushes to dataLayer if gtag is not present", async () => { + const dataLayer: Record[] = []; + vi.stubGlobal("window", { dataLayer }); + + await trackEvent("data_layer_event", { publicKey: addr2 }); + + expect(dataLayer.length).toBe(1); + expect(dataLayer[0].event).toBe("data_layer_event"); + expect(dataLayer[0].publicKey).toBe(await hashPublicKey(addr2)); + }); + + it("does not throw if window.gtag throws an error", async () => { + vi.stubGlobal("window", { + gtag: () => { + throw new Error("Gtag error"); + }, + }); + + await expect( + trackEvent("error_event", { publicKey: addr1 }) + ).resolves.not.toThrow(); + }); + }); +}); diff --git a/src/lib/analytics.ts b/src/lib/analytics.ts index 6d9be3e..04fdd52 100644 --- a/src/lib/analytics.ts +++ b/src/lib/analytics.ts @@ -1,4 +1,4 @@ -export type AnalyticsProps = Record; +export type AnalyticsProps = Record; type GtagFn = (command: "event", name: string, props?: AnalyticsProps) => void; @@ -7,22 +7,56 @@ type AnalyticsWindow = Window & { dataLayer?: AnalyticsProps[]; }; -function hashPublicKey(publicKey: string): string { - if (typeof window === "undefined" || !publicKey) return ""; - const encoder = new TextEncoder(); - const data = encoder.encode(publicKey); - return Array.from(new Uint8Array(data.slice(0, 4))) - .map((b) => b.toString(16).padStart(2, "0")) - .join("") - .slice(0, 8); +function fallbackHash(input: string): string { + let h1 = 0x811c9dc5; + let h2 = 0x01000193; + for (let i = 0; i < input.length; i++) { + const code = input.charCodeAt(i); + h1 = Math.imul(h1 ^ code, 16777619); + h2 = Math.imul(h2 ^ code, 0x01000193); + } + const part1 = (h1 >>> 0).toString(16).padStart(8, "0"); + const part2 = (h2 >>> 0).toString(16).padStart(8, "0"); + return (part1 + part2).slice(0, 16); +} + +/** + * Hashes a public key using SHA-256 over the entire string digest. + * Returns a 16-character hex pseudonymous identifier. + */ +export async function hashPublicKey(publicKey: string): Promise { + if (!publicKey || typeof publicKey !== "string") return ""; + + const cryptoObj = + typeof window !== "undefined" && window.crypto?.subtle + ? window.crypto + : typeof globalThis !== "undefined" && globalThis.crypto?.subtle + ? globalThis.crypto + : null; + + if (cryptoObj?.subtle) { + try { + const data = new TextEncoder().encode(publicKey); + const hashBuffer = await cryptoObj.subtle.digest("SHA-256", data); + const hashArray = Array.from(new Uint8Array(hashBuffer)); + return hashArray + .map((b) => b.toString(16).padStart(2, "0")) + .join("") + .slice(0, 16); + } catch { + // Fall through to fallback hash if Web Crypto digest fails + } + } + + return fallbackHash(publicKey); } -export function trackEvent(name: string, props: AnalyticsProps = {}): void { +export async function trackEvent(name: string, props: AnalyticsProps = {}): Promise { if (typeof window === "undefined") return; const sanitized = { ...props }; if ("publicKey" in sanitized && typeof sanitized.publicKey === "string") { - sanitized.publicKey = hashPublicKey(sanitized.publicKey as string); + sanitized.publicKey = await hashPublicKey(sanitized.publicKey as string); } const payload = { ...sanitized, timestamp: Date.now() };