Security model#
Grantor's trust root is a public, on-chain registry (GrantorRegistry), not an operator-run service. This page is the threat model for contributors: what each layer defends against, where the defenses live in code, and which pre-production items are tracked but not yet closed.
See also: docs/llms-full.txt §9 for the user-facing summary, and the design spec's §10 error handling/testing/security (../superpowers/specs/2026-07-21-passport-web3-oauth-design.md#10-error-handling-testing--security, not published).
1. On-chain trust root, fail-closed cache#
GrantorRegistry.isActive(keyId) (contracts/src/GrantorRegistry.sol) is the single boolean every grant checks before minting: is the issuer key registered to a tenant that is Active or Grace? Status is derived from periodEnd / graceWindow / now — never stored stale — so there is no separate "revoke" transaction to forget to send; a tenant that stops paying simply ages out.
The issuer never reads this on every request — CachedIsActive (crates/grantor-issuer/src/cache.rs) wraps it with a short-TTL cache with explicit availability semantics (spec §10, line 180):
- Fresh hit — an entry younger than
ttl_secsis served with no RPC round-trip. - TTL-expiry refresh — an entry older than
ttl_secstriggers a re-read; on success the cache updates and the fresh value is returned (this is how revocation propagates — withinttl_secs). - Serve-stale-within-bound — if the re-read fails (RPC outage) but the cached entry is still within
staleness_secs, the last good value is served rather than erroring out the whole issuer. - Fail-closed past the bound — once an entry is older than
staleness_secsand the source is still failing,is_activereturnsfalse. An outage never fails open.
CachedIsActive is generic over an IsActiveSource trait and an injectable Clock, so all four cases (fresh_hit_serves_cache_without_hitting_source, refreshes_after_ttl_expires, serves_stale_on_transient_failure_within_staleness_bound, fails_closed_past_staleness_bound, plus a cold-start case) are unit-tested without anvil. ChainGate (crates/grantor-issuer/src/gate.rs) is the production TrustGate: isActive goes through this cache; isAgentKey is read live (admin-tx frequency, no caching needed) via a cloned AlloyReader. gate.rs's own test asserts a cold cache + unreachable RPC fails closed.
Net effect: revocation propagates in ≤ cache TTL, and any RPC failure degrades toward "not trusted," never toward "trusted by default."
2. Pairwise pseudonymity#
subis never the raw wallet/agent address.pairwise_sub(incrates/grantor-issuer/src/token.rs) computessha256(lower(address) ‖ tenant_salt), so the same wallet gets a different, unlinkable subject id per tenant. Address comparison is case-insensitive (pairwise_sub_is_per_salt_and_case_insensitive), so0xAbCand0xabccollide to the same pseudonym.IssuerConfig(crates/grantor-issuer/src/config.rs) hand-implementsDebuginstead of deriving it, specifically to redactsigning_key_pemandtenant_saltas<redacted>— the signing key and the salt that pairwise identity depends on are never at risk of leaking into a log line via a careless{:?}.- No secret (signing key, tenant salt, raw wallet address) is logged anywhere in the issuer; the only address-shaped data on the wire is the pairwise
sub.
3. Grant A (SIWE + Authorization Code) defenses#
Implemented in crates/grantor-issuer/src/http/authorize.rs and http/page.rs:
- SIWE signature + domain binding. The signed EIP-4361 message's
domainmust equal the issuer's configured host; the EIP-191 signature must recover the address embedded in the message (msg.verify_eip191). - Single-use nonce, signature-before-nonce-burn. The SIWE nonce is only burned (
st.use_nonce) after the signature check succeeds — a forged signature can't be used to grief the real key holder's nonce (an attacker submitting garbage before the real user can't pre-burn their nonce for free). - PKCE (S256 only).
GET /authorizerejects a missingcode_challengeor anycode_challenge_methodother thanS256before serving the login page; the code exchange at/tokenre-derives and compares the challenge from the caller'scode_verifier(seehttp/token_code.rs). - Single-use, ~60s codes.
CODE_TTL_SECS = 60;AppState::take_coderemoves the code on first use and rejects it ifnow > expires_at. - Exact-match redirect allowlist, checked before any code/302.
ClientStore::is_valid_redirect(crates/grantor-issuer/src/clients.rs) does exact string comparison — no wildcards, no prefix match. BothGET /authorizeandPOST /authorize/callbackvalidate the(client_id, redirect_uri)pair; on failure both return 400 with noLocationheader — the code is never minted and the browser is never redirected to an unvalidated URI. This is verified directly bycallback_rejects_unlisted_redirect_without_redirecting, which asserts nolocationheader is present.
4. Grant C (Client Credentials, agents) defenses#
Implemented in crates/grantor-issuer/src/http/assertion.rs and http/token.rs:
- Signature verified before the nonce is consumed (anti-grief, the same ordering discipline as Grant A):
assertion::verifyrecovers the signer from the EIP-191 signature over the canonicalGrantor client-credentials\ndomain:…\ntenant:…\nnonce:…\nissued-at:…message before callingst.use_nonce, so a garbage signature can't burn a real nonce. - ±120s clock skew.
SKEW_SECS = 120;issued_atmore than 120s from the issuer's clock is rejected asExpired. - Single-use nonce within the skew window (
AppState::use_nonce). - On-chain agent-key check. After the signature recovers an address, the issuer confirms it via
TrustGate::is_agent_key(tenant_id, addr)— the address must be a registered agent key for this tenant on-chain, not just any valid signature. - Registered-audience check (no audience spoofing).
audienceis required and must be inClientStore::is_allowed_audience(exact match); there is no default-to-domain fallback. A valid agent signature targeting an unregisteredaudienceis rejected withinvalid_target, independent of whether the tenant is active.
Order of checks in http/token.rs::client_credentials: audience registered → signature/nonce verify → on-chain agent-key check → on-chain issuer-active check → mint.
5. Contract posture#
contracts/src/GrantorRegistry.sol is the money-handling surface, so its owner powers are deliberately minimal:
- Only
onlyOwnerfunctions:pause/unpause,setGraceWindow,setTierConfig/setTierConfigFull,disableTier. None of these can move a single unit of any tenant'sbalance. - Immutable treasury.
treasuryis set once in the constructor (require(treasury_ != address(0))) and isimmutable— fees always flow to the same address; there is no owner-settable payout destination. - Per-tenant fee snapshots.
drawPeriodchargestenantFee[id]/tenantPeriodLength[id], snapshotted atcreateTenant/setTier— never the live, owner-mutabletierConfigs. An owner raising a tier's fee cannot retroactively drain an existing tenant on that tier (test_owner_cannot_drain_tenant_via_fee_raise). - AutoRenew gate.
autoRenew[id](on by default) gatesdrawPeriod; a tenant closing its account can flip it off and withdraw its remaining balance without a permissionlessdrawPeriodkeeper call front-running the withdrawal. - Heavy invariant/fuzz coverage.
contracts/test/GrantorRegistry.t.sol(unit tests) pluscontracts/test/GrantorRegistry.invariant.t.sol— a two-tenant fuzz handler (topUp/draw/withdraw) driven by Foundry's invariant runner, assertingusdc.balanceOf(registry) == sum(tenant balances)holds under arbitrary call sequences.
6. Known pre-production items / follow-ups (tracked, not shipped)#
These are recorded in .superpowers/sdd/progress.md as accepted, tracked gaps — not silent omissions:
- Client/audience registry is global per issuer process, not per-tenant. Correct for a single-tenant deployment; a future hosted multi-tenant service needs the registry scoped per tenant (see
docs/superpowers/specs/2026-07-22-passport-client-registry-design.md, "Out of scope"). - RFC 6749 JSON error bodies +
Cache-Control: no-store./token400s are currently plain-text strings (e.g."invalid_grant: …"), not the{"error":"invalid_grant"}JSON shape RFC 6749 §5.2 OAuth2 clients expect. Scoped to full OIDC-client conformance work. - SIWE
issued_at/expiration_timevalidation. The SIWE message's own time fields are not checked; the single-use nonce is currently the only replay guard on Grant A. - Per-address nonce scoping (Grant C). The nonce store is global, not scoped per recovered signer. Since
domain/tenantare not secret, a self-signed attacker can still grief a guessed nonce within the ±120s skew window. A deeper fix scopes the nonce store per recovered address or requires pre-registered nonces. - Callback check ordering.
POST /authorize/callbackcurrently runs the on-chainis_activecheck before the (cheap, local) redirect-allowlist check, so a bad-redirect request against an inactive tenant returns 403 instead of 400 — a minor status-code leak of tenant active-state to requests that will be rejected anyway. The fix is to reorder the cheap local check first. - Hosted multi-tenant needs. Key custody (moving from an operator-held PEM toward HSM-backed signing) and a database-backed, tenant-scoped
ClientStoreare both required before a hosted, multi-tenant offering; neither exists yet — v1 is single-tenant deployment only.
None of these gate the current single-tenant deployment; they are the explicit punch list before Grantor faces a hosted, multi-tenant, or fully-adversarial-client-registration environment.
Impersonation & identity ownership#
An address grants nothing; a signature proving key control grants everything. Grantor never trusts an address it has not just seen sign a fresh challenge. Addresses are public identifiers (on-chain, visible to all) — the credential is the private key, and it is exercised on every authentication.
- Grant A (SIWE).
http/authorize.rs::callback→login.rs::recover_login_address(verify_eip191) recovers the signer from the signature and requires it to match the address in the EIP-4361 message. Logging in "as" address X requires a signature that recovers to X — i.e. X's private key. The tokensubispairwise_sub(recovered_address, tenant_salt)(token.rs) — identity derived from the proven address, never an asserted one. - Grant C (agent).
http/assertion.rs::verifyrecovers the signer of the client-assertion, and the handler then requiresisAgentKey(tenant, signer)(chain.rs) — a second, on-chain enrollment gate. A valid signature from an un-enrolled key is rejected.
Address reuse does not weaken this. secp256k1 signatures don't leak the private key, so a previously-used, publicly-known address is exactly as safe as a fresh one — its past signatures are visible and still un-forgeable. Replay of a captured signature is blocked: Grant A by a single-use SIWE nonce + PKCE + a ~60s one-time code; Grant C by a single-use nonce + ±120s skew.
No stored credential. Unlike password auth, Grantor holds no user secret — the user's key never touches it. There is no credential database to breach or phish; impersonation requires compromising the user's own key, one target at a time.
The one real vector: key theft. Obtaining the private key (theft, or tricking the holder into signing) is the only impersonation path — inherent to all cryptographic auth. Bounded today by short token TTL + on-chain revocation (deregisterAgentKey / deregisterIssuerKey; existing tokens expire at exp). Roadmap hardening: DPoP (sender-constrained tokens — a stolen token can't be replayed by a different holder) and EIP-1271 smart wallets (multisig / social recovery, so a single stolen key isn't game-over). See the spec roadmap.