# 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`](../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).

## 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::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 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.
