CA
GHOST

Threat model & privacy limitations

GHOST reduces what a verifier learns. It does not guarantee anonymity or unlinkability. This page lists what we defend against, how, and — just as importantly — what leaks anyway.

Assets

  1. Link between a proof and the user's wallet address.
  2. The user's exact balances and full holdings.
  3. Links between the same user's activity at different apps, or between their ghosts.
  4. Integrity: only users who really met the threshold at the snapshot can pass.
  5. Users' identity secrets and wallet keys.

Adversaries

  • Relying party (verifier) — honest-but-curious or malicious; may collude with other verifiers.
  • Network observer / RPC provider / hosting provider.
  • GHOST operator (registry + attester) — may be curious; compromise considered separately.
  • Malicious prover — wants to pass without holding tokens, or to claim twice.
  • Phishing site — wants the user's GHOST signature or a relayed proof.

Integrity attacks

AttackMitigationTest
Prove a balance below thresholdGreaterEqThan(64) constraint; witness generation failsprotocol.test.ts › circuit itself rejects balance < minBalance
Lie about private balanceBalance is inside the leaf hash; a different balance changes the leaf, Merkle root mismatch› circuit rejects inflating the private balance
Field wrap-around (balance = p − 1)Num2Bits(64) range checks on balance and threshold› circuit rejects field wrap-around balances
Tamper proof pointsGroth16 pairing check› rejects a tampered proof element
Change public inputs (raise threshold, change mint)Public inputs are bound in the pairing equation; verifier also compares them to its stored request› rejects a raised threshold…, › re-labelled for a different mint
Public-input aliasing (x + p)Verifier rejects any signal ≥ p before verification› rejects public signals pushed outside the field
Fabricated snapshot / self-signed rootAttester must be in trustedAttesters; prover also refuses› untrusted attester
Splice a real attestation onto another rootRoot is a public input compared to the attestation's root, and the attestation signature covers the root› genuine attestation spliced…
Replay a proofSingle-use challenge bound into the proof; consumed atomically› rejects the same bundle twice
Use a proof after expiryexpiresAt bound into the proof and checked against verifier clockexpiry › …
Proof made for app A used at app BappScope bound into the proof and nullifier› a proof for app A fails at app B
Double claim with a second ghostWallet-mode nullifier depends only on walletKey; registry enforces one walletKey per wallet› wallet mode: a second identity…
Bind someone else's wallet to my identityRegistration requires that wallet's Ed25519 signature; message rebuilt server-sideregistry.test.ts
Replay a signed registration / revocationNonce store + 10-minute timestamp window + registry host bindingregistry.test.ts
Burn other users' challenges with junk proofsChallenges are only consumed after every cryptographic check passesby construction (verifier.ts)

Residual integrity risks

  • Malicious or compromised attester can include arbitrary balances. Use multiple attesters, publish snapshots, keep the registry auditable by a third party, and protect the key (HSM).
  • Compromised RPC used by the attester can do the same. Use your own node or a trusted provider.
  • Trusted setup. The shipped phase-2 has one local contribution. Anyone who kept that randomness could forge proofs. Run an MPC ceremony before production (see Deployment).
  • Slot-range double counting. Balances are read at slots in [slotMin, slotMax], not one atomic slot. Tokens moved from wallet A to wallet B during a snapshot could be counted for both. The range is published; keep it small and prefer verifiers that reject wide ranges. Roadmap: single-slot getMultipleAccounts batching.
  • Historical, not live. A user can sell immediately after a snapshot and still pass until the snapshot ages out. This is inherent to the snapshot model; tune maxSnapshotAgeSeconds.
  • Per-wallet sybil resistance only. One person with many funded wallets gets many nullifiers.

Privacy: what a verifier can still learn or infer

Anonymity set size. A proof only hides you among identities in the same snapshot that hold the same mint with a balance ≥ the threshold. The attestation publishes leafCount per mint — that is an upper bound. The real set may be much smaller.

Threshold probing. A verifier who knows the balance distribution can pick a threshold only one wallet meets. Worse, in wallet mode a verifier can issue many requests with increasing thresholds; the stable nullifier links the answers and binary-searches your balance. Mitigations: the GHOST UI shows the threshold before you prove; don't answer repeated requests from the same app with different thresholds; apps should use fixed, coarse tiers.

Stable nullifiers. For a given app, your wallet-mode nullifier never changes (identity-mode: per ghost). An app can recognise you across visits. Apps can scope by epoch to avoid this.

Mint choice. Proving you hold an obscure token can narrow you down to its few holders.

Timing correlation.

  • Registration → snapshot → proof. If you register, a snapshot is taken right after, and a proof appears seconds later, an observer who sees the leaf count rise by one can correlate. Scheduled snapshots (cron) and waiting before proving help. The dev-mode “take snapshot now” button makes this worse and is disabled in production.
  • Proof time. The bundle reveals which snapshot you used; using an older snapshot when a newer one exists stands out.

Network metadata.

  • The GHOST server sees your IP address and user agent on every request (registration, snapshot download). Registration also reveals your wallet. Use a VPN/Tor if this matters, and host GHOST behind a proxy that does not log IPs.
  • Relying parties see your IP when you submit a proof. GHOST hides your wallet, not your network identity.
  • The attester's RPC provider sees the list of every registered wallet, queried together, at each snapshot. Use a self-hosted node.
  • The wallet adapter is configured with an RPC endpoint; GHOST makes no browser RPC calls itself, but your wallet extension may make its own.

The operator. The registry knows which wallets use GHOST, how many ghosts each has, and (at snapshot time) their balances. It cannot compute nullifiers, so it cannot tell which proofs a wallet made — unless it colludes with a verifier and uses timing/IP. The registry file is encrypted at rest, but the running server holds the key.

“Not in snapshot” side channel. A verifier cannot directly tell why someone didn't prove, but an app that requests a proof from a known user and gets nothing learns something.

Browser storage. Identity secrets are encrypted with a key derived from your wallet signature. Malware or a malicious extension running in the GHOST origin while unlocked can read them from memory. XSS on the GHOST origin is equivalent to full identity compromise (a CSP restricting sources to self is set, but it currently allows unsafe-inline for Next.js bootstrap scripts and unsafe-eval/wasm-unsafe-eval for snarkjs and the Circom WASM witness generator — move to a nonce-based CSP for production).

Phishing of the key-derivation signature. Any site that gets you to sign the exact GHOST key-derivation message for your wallet can derive your walletKey and storage key. Phantom's SIWS domain check warns when the domain in the message does not match the requesting site, but not every wallet does this. Only sign it on the GHOST domain.

Relay attacks. A malicious site can obtain a genuine request from app X and forward you to GHOST with it, hoping to relay your proof to X. GHOST refuses to deliver a proof when callbackOrigin differs from the origin in appId, and shows the requesting app prominently. Copy/paste flows are not protected — read the app ID before proving.

Unauthorized identity access

  • GHOST never requests, transmits or stores private keys or seed phrases; it only uses signMessage. Only an allowlist of Solana-native wallets is offered; any other Wallet-Standard wallet restored by auto-connect is disconnected. The CSP permits exactly one third-party frame origin, https://connect.solflare.com, for Solflare's connect/web-wallet flow.
  • Secrets are decrypted only after a signature from the connected wallet; switching wallets re-locks immediately; the “lock” button drops keys from memory.
  • Losing the browser storage loses identity secrets (wallet key is recoverable). An encrypted export exists (exportVault) and can only be decrypted by the same wallet on the same domain.

Out of scope

Compromised wallets or operating systems; side channels in snarkjs/WASM; denial of service beyond simple rate limits; legal compulsion of the operator.