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.
Key handling
Section titled “Key handling”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/settlerefuse 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
localconfiguration they were accepted under returns; GET /supportedadvertises 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.
Fail-closed behaviors
Section titled “Fail-closed behaviors”- No confirmed burn, no resource.
acceptedrequires the burn transaction’s successful receipt, itsIndexedTransfercovering 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.
Replay, idempotency, and double-spend
Section titled “Replay, idempotency, and double-spend”- 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.
Timing caveats (TOCTOU and expiry)
Section titled “Timing caveats (TOCTOU and expiry)”- 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.
Reporting a vulnerability
Section titled “Reporting a vulnerability”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.