docs / internal

view as .md

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_secs is served with no RPC round-trip.
  • TTL-expiry refresh — an entry older than ttl_secs triggers a re-read; on success the cache updates and the fresh value is returned (this is how revocation propagates — within ttl_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_secs and the source is still failing, is_active returns false. 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#

  • sub is never the raw wallet/agent address. pairwise_sub (in crates/grantor-issuer/src/token.rs) computes sha256(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), so 0xAbC and 0xabc collide to the same pseudonym.
  • IssuerConfig (crates/grantor-issuer/src/config.rs) hand-implements Debug instead of deriving it, specifically to redact signing_key_pem and tenant_salt as <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 domain must 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 /authorize rejects a missing code_challenge or any code_challenge_method other than S256 before serving the login page; the code exchange at /token re-derives and compares the challenge from the caller's code_verifier (see http/token_code.rs).
  • Single-use, ~60s codes. CODE_TTL_SECS = 60; AppState::take_code removes the code on first use and rejects it if now > 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. Both GET /authorize and POST /authorize/callback validate the (client_id, redirect_uri) pair; on failure both return 400 with no Location header — the code is never minted and the browser is never redirected to an unvalidated URI. This is verified directly by callback_rejects_unlisted_redirect_without_redirecting, which asserts no location header 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::verify recovers the signer from the EIP-191 signature over the canonical Grantor client-credentials\ndomain:…\ntenant:…\nnonce:…\nissued-at:… message before calling st.use_nonce, so a garbage signature can't burn a real nonce.
  • ±120s clock skew. SKEW_SECS = 120; issued_at more than 120s from the issuer's clock is rejected as Expired.
  • 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). audience is required and must be in ClientStore::is_allowed_audience (exact match); there is no default-to-domain fallback. A valid agent signature targeting an unregistered audience is rejected with invalid_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 onlyOwner functions: pause/unpause, setGraceWindow, setTierConfig/setTierConfigFull, disableTier. None of these can move a single unit of any tenant's balance.
  • Immutable treasury. treasury is set once in the constructor (require(treasury_ != address(0))) and is immutable — fees always flow to the same address; there is no owner-settable payout destination.
  • Per-tenant fee snapshots. drawPeriod charges tenantFee[id] / tenantPeriodLength[id], snapshotted at createTenant/setTier — never the live, owner-mutable tierConfigs. 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) gates drawPeriod; a tenant closing its account can flip it off and withdraw its remaining balance without a permissionless drawPeriod keeper call front-running the withdrawal.
  • Heavy invariant/fuzz coverage. contracts/test/GrantorRegistry.t.sol (unit tests) plus contracts/test/GrantorRegistry.invariant.t.sol — a two-tenant fuzz handler (topUp/draw/withdraw) driven by Foundry's invariant runner, asserting usdc.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. /token 400s 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_time validation. 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/tenant are 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/callback currently runs the on-chain is_active check 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 ClientStore are 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::callbacklogin.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 token sub is pairwise_sub(recovered_address, tenant_salt) (token.rs) — identity derived from the proven address, never an asserted one.
  • Grant C (agent). http/assertion.rs::verify recovers the signer of the client-assertion, and the handler then requires isAgentKey(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.

This page is also served as Markdown — agents should read that. The whole tree is indexed for machines in llms.txt.