Skip to content

analytics.ts's hashPublicKey is not a cryptographic hash — it exposes the first 4 raw bytes of a wallet address to third-party analytics under a name that promises privacy #148

Description

@prodbycorne

Overview

src/lib/analytics.ts's trackEvent claims to sanitize wallet addresses before they reach third-party analytics (Google Analytics gtag/GTM dataLayer, both directly wired at the bottom of the function):

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);
}

export function trackEvent(name: string, props: AnalyticsProps = {}): void {
  ...
  const sanitized = { ...props };
  if ("publicKey" in sanitized && typeof sanitized.publicKey === "string") {
    sanitized.publicKey = hashPublicKey(sanitized.publicKey as string);
  }
  ...
  if (typeof w.gtag === "function") { w.gtag("event", name, payload); }
  else if (Array.isArray(w.dataLayer)) { w.dataLayer.push({ event: name, ...payload }); }
  ...

Despite its name, hashPublicKey performs no hashing at all — no SubtleCrypto.digest, no HMAC, nothing cryptographic. It takes the raw UTF-8 bytes of the first four characters of the address string and hex-encodes them. Since every Stellar public key (G...) is a base32-encoded string, encoder.encode(publicKey).slice(0, 4) is simply the ASCII byte values of the literal first four characters typed on screen — the first byte is always 0x47 ('G'), and the remaining three come from a 32-character base32 alphabet, giving on the order of 2^15 possible 4-character prefixes. This is a deterministic, unsalted, trivially low-entropy, reversible-by-lookup-table transform, not a hash: the same address always produces the same 8-hex-character output (so it functions as a stable tracking identifier across sessions, defeating any anonymization intent), and an analytics vendor or anyone with access to the dataLayer/GA export could recover the real address's 4-character prefix directly from the "sanitized" value with a small precomputed table (32^3 = 32,768 possible non-'G' suffixes), narrowing any full address search space by roughly 15 bits for free. A field named hashPublicKey, called specifically to "sanitize" a value before it's sent to a third party, is exactly the kind of code a reviewer or future engineer would trust at face value without re-deriving what it actually does.

Requirements

  • Replace hashPublicKey with an actual one-way, salted or at least full-input cryptographic hash (e.g. crypto.subtle.digest('SHA-256', ...), truncated for display if a short identifier is still desired) so the third-party-analytics payload cannot be correlated back to specific wallet addresses.
  • If a stable per-user pseudonymous identifier is genuinely desired (for cross-session analytics correlation), derive it explicitly as that — a documented, intentional pseudonymous ID — rather than implying "hashed for privacy" via a misleading function name over a broken implementation.
  • Since trackEvent is exported, general-purpose infrastructure (not scoped to any one caller), fix it at the source so every current and future caller that passes a publicKey field automatically gets correct behavior, without each call site needing to know about this gap.

Acceptance Criteria

  • hashPublicKey (or its replacement) uses a real cryptographic digest over the full input, not a truncated raw-byte slice.
  • Two different wallet addresses sharing the same first 4 characters (a realistic case, given the limited entropy of that prefix) produce different sanitized outputs.
  • The sanitized output cannot be trivially reversed to the original address's prefix via a small precomputed lookup table (i.e. it exhibits real avalanche/entropy properties, unlike the current byte-slice).
  • A test asserts the new implementation is deterministic per input (if a stable ID is still desired) while being computationally infeasible to reverse.

Additional Notes

More precise references

  • src/lib/analytics.ts:10-18 (hashPublicKey) — confirmed no cryptographic primitive is used anywhere in the function; confirmed data.slice(0, 4) operates on the TextEncoder-encoded bytes of the original string, i.e. literally the first 4 characters' ASCII codes, hex-joined.
  • src/lib/analytics.ts:20-43 (trackEvent) — confirmed the sanitized.publicKey = hashPublicKey(...) call is the only sanitization applied before the payload reaches w.gtag/w.dataLayer, both real, external, third-party-visible sinks (Google Analytics / Google Tag Manager), not internal logging.
  • Confirmed via grep -rn "trackEvent(" src --include="*.ts*" that current call sites (UnlockModal.tsx, useLockFlow.ts) do not pass a publicKey field today — this specific sanitization branch is not actively exercised by any live caller as of this writing. It is, however, live, exported, general-purpose infrastructure specifically built and named for this purpose, and a wallet-address-tracking event (a natural, likely near-term addition given this app's whole purpose is wallet-driven) would immediately hit this broken path the moment any future caller includes publicKey in a trackEvent payload, trusting the function's name to mean what it says.

Additional edge cases

  • Even setting aside cryptographic correctness, the practical anonymity benefit of hashing only a 4-character, 'G'-prefixed substring is inherently weak compared to hashing the full ~56-character address — any fix should hash the complete publicKey input, not just adopt a real hash function while preserving the "only look at the first few characters" truncation-before-hashing pattern.
  • If GA4/GTM's own IP-anonymization or user-ID features are already relied on elsewhere in this app's analytics setup, note that in the PR, since it affects how much this specific fix actually needs to accomplish versus what's already handled at the platform level — this repository doesn't contain that configuration, so it can't be verified from here.

Implementation sketch

async function hashPublicKey(publicKey: string): Promise<string> {
  if (typeof window === "undefined" || !publicKey || !window.crypto?.subtle) return "";
  const data = new TextEncoder().encode(publicKey);
  const digest = await window.crypto.subtle.digest("SHA-256", data);
  return Array.from(new Uint8Array(digest))
    .map((b) => b.toString(16).padStart(2, "0"))
    .join("")
    .slice(0, 16); // still short for a friendly identifier, but derived from the full, real digest
}

Note trackEvent itself is currently synchronous — switching to SubtleCrypto.digest (async) requires either making trackEvent async (auditing all call sites for void trackEvent(...)-style fire-and-forget usage, which is how it's called today) or pre-computing/caching the digest once per connected publicKey (e.g. in StellarWalletContext, alongside where publicKey is first set) and passing the already-hashed value into trackEvent instead of the raw address.

Test/reproduction plan

  • hashPublicKey("GABCDEFGHIJKLMNOPQRSTUVWXYZ234567ABCDEFGHIJKLMNOPQRSTUVWX") and hashPublicKey("GABCZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZ") (same first 4 chars, "GABC", wildly different remainder) → assert the outputs differ (fails today, since both currently hash-encode only "GABC"'s bytes and produce an identical result).
  • Assert the new function's output length/format doesn't trivially map back to a small enumerable space (e.g. assert output entropy/uniqueness across a batch of random valid-looking addresses sharing a prefix).

Cross-references

  • No existing issue in the repo's 75-issue history covers analytics.ts.

Activity

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Metadata

Metadata

Assignees

Labels

GrantFox OSSIssue tracked in GrantFox OSSMaybe RewardedIssue may be eligible for a GrantFox rewardOfficial CampaignCampaign: Official CampaignOfficial Campaign | FWC26Campaign: Official Campaign | FWC26Third CampaignCampaign: Third CampaignsecuritySecurity, signing safety, or wallet interaction hardeningvery hardExtremely hard — deep expertise, careful design, and significant time required

Type

No type

Projects

No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions