Skip to content

Security: keys, profiles, and fail-closed behavior

This page collects the security-relevant behavior of the pinned implementation: who holds keys and how, the profile system that keeps non-local operation switched off, the behaviors that fail closed, and the replay/timing caveats an integrator must design around. The trust model these mechanisms live inside is Trust & Limits.

The complete secret inventory is three environment variables, all held by the facilitator operator:

secret used for
SUBETHA_TREASURY_PK burn-address seed derivation; custodial fee-sweep signing (entropy only in split mode)
SUBETHA_RELAYER_PK submitting the proof-gated mints; relaying gasless permit burns
SUBETHA_ADMIN_TOKEN Authorization: Bearer auth for the /admin/* endpoints

The rules around them, as implemented:

  • Environment only, no defaults. Keys are never read from the config file, and the daemon has no default network, contracts, or keys — it cannot silently run against the wrong chain.
  • Dev keys are fenced. The well-known anvil dev keys are accepted (with a warning) only when all four local predicates hold: profile = "local", a loopback listen host, a loopback RPC host, and chain id 31337. Any missing predicate aborts startup with an error naming the failed predicate.
  • Secrets stay out of output. Error messages name the variable, never the value; logs redact known secret values and signature-grade hex; every URL the daemon prints is credential-redacted, and a credential-embedding RPC URL draws a warning from the config checker.
  • This site shows no key material. Every key or token value in these docs is a placeholder (0x…) — supply your own via the environment. The payer and provider hold, at most, their own EOA keys; in the shipped configuration the provider process holds no key at all (see architecture).

Profiles: non-local operation is gated off

Section titled “Profiles: non-local operation is gated off”

The facilitator’s top-level profile key ("local" / "testnet" / "production") classifies a deployment explicitly — a loopback bind or chain 31337 never implies local, and an unset profile is treated as non-local, fail-safe. In this release, payments are local-experiment only. Under any non-local profile:

  • POST /challenge, /verify, and /settle refuse with terminal, profile-specific errors;
  • the finalize loop still runs for liveness but never changes chain state, and journal entries stay frozen until the same local configuration they were accepted under returns;
  • GET /supported advertises no kinds, so the official x402 middleware fails its initialization — a provider pointed at a gated facilitator refuses to boot rather than come up half-working;
  • unset profile with no admin token refuses to start outright.

The unlock condition is code, not configuration: non-local acceptance turns on only when the per-profile confirmation/finality policy is implemented and tested.

  • No confirmed burn, no resource. accepted requires the burn transaction’s successful receipt, its IndexedTransfer covering the quoted amount, and the burn-address balance. If settlement fails, the buffered response is discarded and the client receives a 402 — nothing is served on a failed settle. See Accepted vs finalized.
  • Signatures do not lock funds — so no optimistic serve. Serving on a verified signature alone would let a payer drain its balance before the burn executes; the implementation therefore always waits for the burn to be on-chain. The residual payer misbehavior is griefing (a reverted burn wastes facilitator gas), never a served-but-unpaid resource.
  • Permit mode fails fast. A payer configured for the gasless path errors on offers lacking the helper contract — it never silently degrades to a gas-paying transfer.
  • Admin surface locked by default. /admin/* is always Bearer-authenticated; with no token set (allowed only on local loopback) it answers 401 to everything, and locked versus wrong-token responses share one body so the response does not reveal whether a token is configured.
  • Same payload, safe replay. An accepted payment answers any identical re-send with the same accepted result — no second burn, no double charge. A broadcast gasless permit is reconciled from its on-chain replay ledger even across a daemon restart; the burn is never double-executed.
  • Unknown outcome ⇒ re-send, never re-sign. After a timeout or lost response, every party re-sends the same payload. Neither payer client ships automatic retry, and building a fresh 402/payload for an already-signed payment risks paying twice.
  • Offers bind to one payment. Each 402 carries a fresh challenge nonce and a fresh one-time payTo; the facilitator compares echoed requirements against its own challenge record and rejects any mismatch, so a tampered or replayed offer fails. Chain-level double-spend of a burn is blocked by the official verifier’s cumulative accounting.
  • Verify is point-in-time. The middleware runs verify → handler → settle; a payer can move its balance or consume its permit nonce between verify and settle, making settle fail after the handler ran. Funds are never at risk — nothing is served — but providers of computationally expensive resources should apply ordinary rate limiting for the wasted compute.
  • Expiry is judged by chain inclusion, not broadcast. A burn included after the offer’s expiry is refused on every path, with a manual, operator-judged recovery procedure and no automation. Payer clients refuse to start a self-transfer broadcast inside the last 30 seconds of an offer’s life so they never initiate a burn likely to land late.
  • A receipt is not finality. On the local stack reorgs do not occur in practice; the finalize pass still re-verifies chain evidence before minting and fails the entry rather than minting on vanished evidence.

Report vulnerabilities privately via GitHub Security Advisories — never in a public issue. In scope is everything in the product repository: the TypeScript packages, the Python payer client, and the contracts (PermitBurner.sol, FeeSplit.sol); of particular interest is anything that bypasses a spending policy, leaks a key into logs or errors, breaks the documented hiding of the payer–provider correspondence, or redirects a settlement. Out of scope: the zERC20 protocol, SDK, and contracts themselves (report upstream at kbizikav/zERC20), the demo’s well-known anvil dev keys, and denial-of-service against your own local stack. The authoritative policy is SECURITY.md at the pin.