# Grantor — full machine reference > Grantor is OAuth with no authorization server. A caller mints a > **deed** — a self-certifying credential — and the relying app verifies > it with a library call against a public on-chain registry. No auth > server exists in this path. A second compatibility tier runs a real > OIDC issuer for relying parties that only speak OIDC. This file is every documentation page concatenated, in reading order. It is GENERATED by docs/build.py — do not edit it directly; edit the page and rebuild. Each page is also available on its own at the URL shown in its header, as Markdown. Pages: 31. Source of truth: https://chaingrantor.com/docs/ --- # Grantor documentation **OAuth with no authorization server.** A caller presents a **deed** — a self-certifying credential it mints itself — and your app verifies it with a library call against a public on-chain registry. No authorization server exists in this path: Grantor operates no service, and the trust anchor is a contract readable from any RPC endpoint. ## The 30-second model 1. Your app issues a **challenge** (the OIDC `nonce` shape — one value, one use). 2. The caller signs or proves against it and returns a **deed**. No redirect, no token endpoint, no round trip to anyone. 3. Your app calls `verify_deed`, which checks the cryptography *and* reads the on-chain registry: is this tenant paid up, and for agents, is this member still un-revoked? 4. You mint your own session however you already do. Grantor never sees it. Step 3 is a plain `eth_call` — a read. It costs no gas, needs no API key, and works from any RPC provider. ## Pick your path | You are… | Start here | |---|---| | An **app that needs to authenticate callers** | [Getting started](guide/getting-started.md) | | Just **trying it out** without spending anything | [Develop locally](guide/local-devnet.md) — a free, disposable local devnet | | Adding **wallet login** for humans | [Wallet login](guide/wallet-login.md) | | Adding **passkey login** for humans (closes the wallet-login phishing residual) | [Passkey login](guide/user-passkey.md) | | Your users hold **smart-contract wallets** (Safe / EIP-1271) | [Smart-wallet login & admin](guide/smart-wallet.md) | | Building an **AI agent** that needs API access | [Agent tokens](guide/agent-tokens.md) · [Agent integration](agents/README.md) | | Restricting login to **an admin-curated allowlist**, anonymously | [User gating](guide/user-gating.md) | | Gating a **remote MCP server** | [MCP server auth](guide/mcp-server.md) | | Granting **bounded, delegated authority** (not just identity) | [Capabilities](guide/capabilities.md) | | Licensing software / gating a paid feature | [Software licensing](guide/licensing.md) | | Operating a tenant and want to detect a **compromised admin key** | [Chain watcher](guide/chain-watcher.md) | | Wondering **how any of this works** | [Concepts](guide/concepts.md) | | A **contributor** | [Architecture](internal/architecture.md) · [Contributing](internal/contributing.md) | ## Kinds of deed | Mode | Who holds it | Proves | On-chain check | |---|---|---|---| | `user-sig` | a human with a wallet | control of a wallet-derived, app-scoped key; the app sees a **pseudonym**, not an address | tenant billing status | | `user-passkey` | a human with a passkey (WebAuthn) | control of a browser-bound P-256 credential; the app sees a **pseudonym** | tenant billing status | | `user-1271` | a human with a smart-contract wallet (Safe / EIP-1271) | on-chain `isValidSignature` approval; the app sees a **pseudonym**, but one linkable to the wallet's public address — [tradeoff](sovereign-tier.md#the-pseudonymity-tradeoff--read-before-picking-this-mode) | `isValidSignature` (the login itself) + tenant billing status | | `agent-zk` | an enrolled agent | ZK membership of the tenant's registry **without revealing which member** | membership-root recency (revocation) + billing | | `user-zk` | a human enrolled in the tenant's own **user allowlist** | ZK membership of that allowlist **without revealing which member**; the app sees a **pseudonym** | membership-root recency (revocation) + billing | | `admin-sig` | a Grantor dashboard admin (EOA); a Safe / EIP-1271 wallet is supported as a *library* capability, not yet wired into Grantor's own dashboard | control of a wallet address (**identified**, not pseudonymous) | none, deliberately — [why](sovereign-tier.md#dashboard-login-admin-sig) | All five user/agent modes verify through one call, `verify_deed`; `admin-sig` has its own entry point that `verify_deed` refuses. Why each mode exists and when to pick which: [Sovereign tier](sovereign-tier.md#passkey-login-user-passkey), [User gating](guide/user-gating.md) for `user-zk`. Everything ships in TypeScript, Python, Go and Rust — parity is CI-enforced. [The matrix](sovereign-tier.md#every-capability-every-language). ## Reference - **Concepts:** [the mental model](guide/concepts.md) — read this first. - **Machine docs:** [`llms.txt`](llms.txt) (index) · [`llms-full.txt`](llms-full.txt) (everything, one file). - **Every page here is also Markdown** — same URL, `.md` instead of `.html`. - **Live, per RP deployment:** an app that accepts deeds publishes its own `/.well-known/grantor-deed` discovery document and challenge endpoint — see [Sovereign tier § Discovery](sovereign-tier.md#discovery-how-an-agent-finds-all-this). ## Guides - [Concepts](guide/concepts.md) — the mental model - [Getting started](guide/getting-started.md) - [Verify a deed](guide/verify-tokens.md) — the relying-party side - [Wallet login](guide/wallet-login.md) — `user-sig`, for humans - [Passkey login](guide/user-passkey.md) — `user-passkey`, phishing-resistant browser login for humans - [Smart-wallet login & admin](guide/smart-wallet.md) — `user-1271` + Safe/EIP-1271 `admin-sig`, for smart-contract wallets - [Agent tokens](guide/agent-tokens.md) — `agent-zk`, for machines - [User gating](guide/user-gating.md) — `user-zk`, anonymous login restricted to an admin-curated allowlist - [MCP server auth](guide/mcp-server.md) — gate a remote MCP server with a deed, no authorization server - [Capabilities](guide/capabilities.md) — bounded, delegated authority on top of a deed - [Software licensing](guide/licensing.md) — a license is a deed, no license server, tiers via capability grants - [Develop locally](guide/local-devnet.md) — a free, disposable local devnet, no production spend - [Sovereign tier](sovereign-tier.md) — the full deed reference - [Chain watcher](guide/chain-watcher.md) — an operator sidecar that watches the registry for revocations/billing changes - [Errors](guide/errors.md) ## Agents (LLM-first) - [Agent integration](agents/README.md) - [Agent onboarding](agents/onboarding.md) ## Internal (contributors) - [Architecture](internal/architecture.md) - [Code map](internal/crates.md) - [Security model](internal/security-model.md) - [Testing](internal/testing.md) - [Contributing](internal/contributing.md) --- **Status: developer preview.** The contract and the full six-language end-to-end proof run on a local devnet (anvil). It has **not** been deployed to a public testnet or mainnet, and `grantor-verify` is not yet published to any package registry — today it resolves by path inside this workspace. It has not been audited. Do not put it in front of production money. **Licence: proprietary, all rights reserved.** The source is readable — package registries distribute source — but that is not a grant of rights. You may use these libraries to integrate with Grantor; you may not fork, redistribute, or build a competing service with them. Full terms in `LICENSE`; enquiries for broader terms are welcome. --- # Concepts The mental model. If you read one page, read this one. Grantor's premise is that **a credential can certify itself**. A caller mints a **deed** that proves what it needs to prove, and your app checks it against a public on-chain registry — with no authorization server in between, because there is nothing in between at all. > A grantor is the party that grants a right. A **deed** is the artifact that > records it. The word is deliberately plain and lowercase, the way *passkey* > is: Grantor is the brand, a deed is the thing. ## What a deed is A deed is a small JSON envelope carrying a proof, an audience, a challenge and an expiry. It is **self-certifying**: everything needed to check it is either inside it or on a public chain. Nothing issued it, so nothing can forge it, observe it, or link its uses. ``` { "v": 1, "mode": "user-sig", // or "agent-zk" / "admin-sig" "tenant": 42, "aud": "api.example.com", // who it is FOR — a deed for someone else is invalid here "challenge": "9f2c…", // the value YOUR app issued, one use only "exp": 1753700000, … per-mode proof material … } ``` Three properties do the work: - **Audience-bound.** A deed minted for `api.example.com` fails at `admin.example.com`. A malicious RP cannot replay what it receives. - **Challenge-bound.** Your app picks the challenge; the caller cannot. This is the OIDC `nonce` shape and it is the entire replay defence. - **Expiring.** Short-lived by construction. ## The exchange ``` your app caller │ 1. challenge (random, single-use) ──────▶ │ │ │ 2. sign / prove against it │ ◀────────────────────────── 3. the deed │ │ │ 4. verify_deed(deed, policy, challenge, now, gate) │ ├─ cryptography: signature or ZK proof │ ├─ audience, challenge, expiry │ └─ eth_call ─────▶ GrantorRegistry ── is the tenant paid up? │ is the member un-revoked? │ 5. mint your own session, however you already do ``` Step 4 is a **read**. It costs no gas, needs no API key, and any RPC provider serves it. Step 5 is yours — Grantor has no opinion about your session format and never sees it. ## Three kinds of deed | Mode | Who holds it | Proves | On-chain check | |---|---|---|---| | `user-sig` | a human with a wallet | control of a wallet-derived, app-scoped key | tenant billing status | | `agent-zk` | an enrolled agent | ZK membership of the tenant's registry, **without revealing which member** | root recency (revocation) + billing | | `admin-sig` | a Grantor dashboard admin | control of a wallet address | **none, deliberately** | `user-sig` and `agent-zk` are what your app accepts, and one call — `verify_deed` — handles both. `admin-sig` authenticates Grantor's own dashboard admins and verifies through a **separate entry point**; `verify_deed` rejects it outright. That isolation is structural rather than configurable: there is no flag to flip. See [why](../sovereign-tier.md#dashboard-login-admin-sig). ## Pseudonymous by construction Your app receives a **pseudonym**, not a wallet address. For `user-sig`, the wallet signs one fixed domain string to derive a `root_seed`, and per-app keys come from it: ``` app_key = HKDF(root_seed, "tenant:{tenant}|audience:{aud}") sub = derived from the app key's public key ``` Because the tenant and audience are both in the derivation scope, the same wallet yields a **different, unlinkable `sub`** at every app — pairwise subjects computed rather than stored. The verifier **recomputes `sub` from the public key** in the deed rather than trusting a claim, so it cannot be forged. The seed derivation signs twice and compares: a wallet with a non-deterministic signature fails loudly instead of silently re-identifying its owner as somebody new on every login. For `agent-zk` the proof reveals membership without revealing *which* member, so the agent is anonymous even to the tenant that enrolled it. `admin-sig` is the deliberate exception — it reveals the address, because administration is an identified context, not an anonymous one. ## The trust anchor: `GrantorRegistry` A Solidity contract that answers, publicly: - **Is this tenant paid up?** `status(id)` → `Active` / `Grace` / `Inactive`. - **Is this agent still a member?** via the membership tree's current root. Anyone can call it from any RPC provider with no API key and no operator in the loop. Trust is a public fact rather than a row in a company database — which is what lets the verifier be a library instead of a service. ## Tenants, tiers, and billing A **tenant** is a billing and ownership unit, represented on-chain as an ERC-721 "org pass." It has a **tier** — a capacity plan (Free/Pro/Scale/ Enterprise), not a deployment mode — a prepaid **balance**, a set of registered keys, and a derived **status**. Billing is a **flat subscription with prepaid funding**, not usage metering. The tier caps *capacity*, not consumption — and it must, because deed verification is an `eth_call` the contract cannot observe. Metering deeds would require your app to report back, which would reintroduce exactly the server this product removes. - `topUp` credits the tenant's balance (it does not pay the operator). - `drawPeriod` moves one period's fee to the treasury. It is **permissionless** — a keeper, a bot or anyone may call it once the period lapses, which is what makes renewal unattended. The balance is the renewal engine. - `withdrawBalance` returns **undrawn** balance. Only consumed periods are non-refundable; unbilled prepayment is your money for service not rendered. **Grace, never a cliff.** When balance runs out a tenant enters `Grace` before `Inactive`; existing credentials keep working throughout. Even at `Inactive`, deeds already minted stay valid until their own `exp` — going unpaid never invalidates a credential already in the world. A tenant that tops up but never draws keeps its balance and gets nothing: status stays `Inactive` until a period is drawn, so funds are never held while the product is in use. ## The polyglot split **All languages or no go** — a capability that ships in one language has not shipped, enforced by `just capability-matrix` against the *generated binding surfaces*. See [Sovereign tier § Every capability, every language](../sovereign-tier.md#every-capability-every-language) for the full matrix. - **On-chain = Solidity.** `GrantorRegistry` is money-handling code, so it gets the most conservative toolchain: Foundry, heavy invariant and fuzz coverage, minimal owner powers, an immutable treasury. - **Off-chain = Rust**, compiled once and shipped everywhere — wasm-bindgen for TypeScript, UniFFI for Python and Go. One implementation of the crypto, four languages, proven identical by shared conformance vectors. --- ## See also [Wallet login](wallet-login.md), [Agent tokens](agent-tokens.md) and [Verify a deed](verify-tokens.md) walk through minting and verifying each kind of deed. [Sovereign tier](../sovereign-tier.md) is the full reference. --- # Getting started Your app verifies a **deed** — a self-certifying credential the caller mints itself — with a library call and an RPC read. No auth server runs anywhere. TypeScript below; [the same calls in Python, Go and Rust](../sovereign-tier.md#every-capability-every-language). ## The quickstart ```js // Imports are by path — @grantor/verify is not yet on npm. This is exactly // how the runnable example examples/mcp-server/server.mjs does it today. import express from "express"; import { DeedVerifier } from "./sdk/verify/ts/pkg/grantor_verify_wasm.js"; import { grantorExpress } from "./sdk/verify/ts/src/express.js"; import { Registry } from "./sdk/verify/ts/src/registry.js"; const app = express(); const verifier = new DeedVerifier( process.env.RPC_URL, // any RPC endpoint — the registry check is an eth_call, no gas, no API key Registry.canonical(), // the SDK pins the canonical GrantorRegistry for you — no address here Number(process.env.CHAIN_ID), // which chain the canonical map (and every chain read) resolves against Number(process.env.TENANT_ID), // your tenant (step 0) "https://api.example.com", // audience: what deeds must be scoped to "https://api.example.com", // origin: what THIS deployment is — never taken from a request 300, // max deed TTL, seconds 30, // chain-read cache TTL, seconds false, // allowInsecureOrigin: true only for a localhost/dev origin Math.floor(Date.now() / 1000), // construction-time clock ); const g = grantorExpress({ verifier, app, challengeEndpoint: "/auth/challenge", chainId: Number(process.env.CHAIN_ID), modes: ["user-sig"], // add "agent-zk" to admit an enrolled agent fleet vouchSignature: process.env.VOUCH_SIGNATURE, // step 0 — bare hex, no 0x vouchEpoch: Number(process.env.VOUCH_EPOCH ?? 0), vouchExp: Number(process.env.VOUCH_EXP), }); app.get("/auth/challenge", g.challenge); app.get("/api/me", g.protect, (req, res) => res.json({ you: req.deed.sub })); app.listen(8930); ``` That is the whole server side: mounting auto-publishes `/.well-known/grantor-deed`, `g.challenge` issues single-use challenges, and `g.protect` verifies the deed (crypto + on-chain billing check) and puts the claims on `req.deed`. Sessions stay yours — mint whatever you already mint, or use the SDK's [one-call session JWT](verify-tokens.md#or-one-call). > **`Registry.canonical()` is empty until mainnet launch.** The compiled-in > chain map has no entries yet, so a real-chain construction errors, naming > the chain — expected today, not a bug. Develop against `Registry.devnet(...)` > (below) until then. A custom registry address on a real chain requires an > [enterprise license](enterprise-registry.md); the standard SDK cannot > express one otherwise. > **Developing? Don't spend anything.** `just devnet` ([Develop > locally](local-devnet.md)) spins up a free, disposable local chain with the > registry, a funded tenant, and an origin vouch already deployed, and writes an > `rpcUrl`/`registry`/`devTenant`/`adminKey` config the snippet above reads — so > you can skip Step 0 entirely until you move to a real chain. ## Step 0: a tenant and a vouch Two one-time artifacts, neither of which is an account. **A tenant on the registry.** `createTenant` plus funding on `GrantorRegistry` — the [register page](../../register.html) walks any wallet through it. On a local devnet: ```bash PRIVATE_KEY=$DEPLOYER CONTRACT_ADDR=$REG USDC=$USDC TIER=1 \ forge script contracts/script/SetupTenant.s.sol --rpc-url $RPC --broadcast ``` **An origin vouch** — the tenant admin signs your deployment's origin so holders can refuse imposters before signing anything: ```bash MSG=$(node --input-type=module -e " import { originVouchBinding } from './sdk/ts/pkg/grantor_sdk_wasm.js'; process.stdout.write(originVouchBinding(1, 'https://api.example.com', 'https://api.example.com', 0, $(( $(date -u +%s) + 86400 * 30 ))));") cast wallet sign --private-key $ADMIN_KEY "$MSG" # strip the 0x → VOUCH_SIGNATURE ``` ## Point a caller at it - **Humans with a wallet** → [Wallet login](wallet-login.md) (`user-sig`) - **Humans with a passkey** → [Passkey login](user-passkey.md) - **Autonomous agents** → [Agent tokens](agent-tokens.md) (`agent-zk`) The caller fetches `/auth/challenge`, mints a deed against it, and sends `X-Grantor-Deed` + `X-Grantor-Challenge`. Your `/api/me` answers with a pseudonymous `sub`. See it run end to end: `just mcp-e2e`. ## Next - [Concepts](concepts.md) — the mental model behind all of this. - [Verify a deed](verify-tokens.md) — errors, session JWTs, scaling the challenge store. - [Errors](errors.md) — every code a relying party branches on, and which are 401 vs 503. - [Sovereign tier](../sovereign-tier.md) — the full reference: discovery, origin binding, every check the verifier makes. --- # Sovereign tier — deeds The sovereign model is the product: your app authenticates a caller with a **deed** — a self-certifying credential the caller mints itself, checked directly against the public on-chain `GrantorRegistry` (the trust anchor + billing ledger). There is no auth server to run or point at, no key custody on Grantor's side, and no forge risk against Grantor, because nothing runs. The only thing your own service takes on is a verifier library dependency and an RPC connection to read the registry. The sovereign tier has **seven wire modes**. `agent-zk`, `user-sig`, `user-passkey`, `user-1271` and `user-zk` are the five your app verifies, and one verifier call (`verify_deed`/`verify_deed_claims`) handles all five via a mode dispatch — `user-1271` is the one exception within that dispatch: it is offline-unverifiable by construction (see [Smart-wallet login](#smart-wallet-login-user-1271) below), so it runs at the gated layer before the pure claims function ever sees it, rather than inside it. `user-zk` reuses `agent-zk`'s exact ZK-membership crypto and root-recency check, over a separate, admin-curated **user** tree instead of the agent tree — see [User gating](guide/user-gating.md) for the full mode, including admin enrollment and revocation. `admin-sig` is a sixth mode that exists so Grantor's own control-plane dashboard has no account-shaped auth left either — it authenticates Grantor's admins, not your users, and it verifies through a separate, dedicated entry point that `verify_deed` refuses to accept (see [Dashboard login](#dashboard-login-admin-sig) below). `admin-sig` itself has two holder shapes, an EOA and an EIP-1271 smart-contract wallet (e.g. a Safe) — both mint the same `admin-sig` wire mode, just verified through two different entry points. `zk-chain` is the seventh — ⚠️ **preview**, novel cryptography pending external audit — a ZK proof of an anonymous, bounded-depth delegation chain; it authenticates an (effective) authority commitment rather than a principal, and — like `admin-sig` — verifies through its own dedicated entry point that `verify_deed` refuses to accept. See [Capabilities § Structure-hiding delegation](guide/capabilities.md#structure-hiding-delegation-preview) for the full mode. | Mode | Who | Proves | On-chain check | |---|---|---|---| | `agent-zk` | an enrolled agent | ZK membership of the tenant's registry, without revealing *which* member | root recency (**revocation**) + tenant status (**billing**) | | `user-zk` | a human enrolled in the tenant's own **user allowlist** | ZK membership of that allowlist, without revealing *which* member — see [User gating](guide/user-gating.md) | root recency (**revocation**) + tenant status (**billing**) | | `user-sig` | a human with a wallet | control of a wallet-derived, app-scoped key | tenant status (**billing**) only | | `user-passkey` | a human with a passkey (WebAuthn) | control of a browser-bound P-256 credential | tenant status (**billing**) only | | `user-1271` | a human with an EIP-1271 smart-contract wallet (e.g. a Safe) | on-chain `isValidSignature` approval by that wallet CONTRACT | on-chain `isValidSignature` (**the login check itself**) + tenant status (**billing**) | | `admin-sig` | Grantor's own dashboard admin (EOA); an EIP-1271 smart wallet is a verified library capability not yet wired into Grantor's own dashboard | control of a wallet address | **none, deliberately** | | `zk-chain` ⚠️ preview | a delegate at the end of an anonymous, bounded-depth delegation chain rooted at a tenant member | an (effective) authority commitment, hiding hop count and every intermediate key/grant | root recency of the rooting member's tree (**revocation**) + tenant status (**billing**) | `user-passkey` and `user-1271` are both additive alongside `user-sig`, not replacements — see [Passkey login](#passkey-login-user-passkey) and [Smart-wallet login](#smart-wallet-login-user-1271) below for why each exists and when to pick which. ## Every capability, every language **Both sides ship in all four languages.** Minting and verification exist in TypeScript, Python, Go and Rust, and `just capability-matrix` fails the build if any cell is missing: | | TypeScript | Python | Go | Rust | |---|---|---|---|---| | `user-sig` holder | ✅ | ✅ | ✅ | ✅ | | `user-passkey` mint (assembler) | ✅ | ✅ | ✅ | ✅ | | `user-passkey` browser ceremony (`registerPasskey`/`signInWithPasskey`) | ✅ | — | — | — | | `user-1271` mint (assembler) | ✅ | ✅ | ✅ | ✅ | | `agent-zk` holder (ZK proving) | ✅ | ✅ | ✅ | ✅ | | `admin-sig` holder (EOA) | ✅ | ✅ | ✅ | ✅ | | `admin-sig` smart-wallet mint (assembler) | ✅ | ✅ | ✅ | ✅ | | Deed verification (all four `verify_deed` modes) | ✅ | ✅ | ✅ | ✅ | | `admin-sig` smart-wallet verify (`verify_admin_deed_smartwallet`) | ✅ | ✅ | ✅ | ✅ | | Deed guard (challenge store, single-use burn) | ✅ | ✅ | ✅ | ✅ | The browser-ceremony row is deliberately TS-only and is not a matrix violation: there is no non-browser WebAuthn authenticator for any language to wrap, the same shape `just capability-matrix` already accepts for `authenticate()`'s Rust exemption ("compose it yourself"). The mint *assembler* underneath the ceremony — pure bytes-in-deed-out, no live authenticator required — is the four-language capability, and that is what ships in **all four** languages, TypeScript included (`mintUserPasskeyDeed` is re-exported at the TS barrel, the same low-level re-export `mintUserDeed` gets for `user-sig`) — only the live `navigator.credentials` ceremony wrapped around it is TS-only. You do not need to hand-write the glue: the **deed guard** ships in each language with a challenge store, single-use burn and the shared error codes, plus thin adapters (Express, FastAPI, `net/http`). Rust has no adapter on purpose — it has no single dominant web framework, and the guard is framework-agnostic there. **Every rejection the guard raises is funnel-shaped by default** (agent-native GTM, 2026-08-10): a 401 (not authenticated) carries a `WWW-Authenticate: Grantor-Deed realm="", discovery="/.well-known/grantor-deed"` header plus `discovery`/`learn` fields in the JSON body — pointing a rejected caller at the exact document that tells it how to mint a deed, and the global onboarding manifest beyond that. A 503 (`Chain`/`QuorumDivergence`/ `WrongChain`/`LicenseExpired`) gets neither: that class of rejection is an RP/network problem, not the caller's credential, so there is nothing to funnel toward. This is additive on the existing error shape and opt-out per adapter (a single `funnelHints`-style option), never mandatory — see [the funnel design](superpowers/specs/2026-08-10-agent-native-gtm-design.md) and the [MCP server guide](guide/mcp-server.md) for a wired example. **Which registry every recipe on this page talks to** is a separate axis from the mode table above: every construction/mint call below takes a `RegistryRef` (`Registry.canonical()` by default — the SDK pins the canonical shared registry for you, no address anywhere in the standard SDK). A custom registry address is expressible only via `Registry.devnet(address)` (local development, chain-id-gated) or `Registry.dedicated(address, license)` (an operator-signed enterprise license) — see [Enterprise registries](guide/enterprise-registry.md) for the full reference. ## What it is An `agent-zk` deed proves membership without revealing which member. Minting is `mintDeed` on the `ZkAgent` type in every language (`grantor_sdk_core::sovereign::mint_deed` in Rust). > The Semaphore identity secret **never crosses the FFI boundary**. Leaking it > would retroactively deanonymise every deed that agent ever minted, so the API > shape — the agent object owns the secret and only emits proofs — is the > mitigation, not a convention. An enrolled agent holds a Semaphore identity whose commitment is registered in your tenant's on-chain membership tree (the same `GrantorRegistry` used by Grant C's blind-RSA path — see `crates/grantor-issuer/src/http/blind.rs`). To authenticate, the agent: 1. fetches the tree's current event log and rebuilds its Merkle membership path **from chain** (`grantor_sdk_core::treesync::build_membership_proof` — the tree-sync path; the same code an issuer-backed agent uses to catch up), 2. produces a ZK proof of membership bound to your challenge + audience + expiry (`mintDeed`, or `grantor_sdk_core::sovereign::mint_deed` in Rust), 3. hands you that proof. There is no token exchange, no redirect, no issuer round-trip — the proof itself is the credential. ## RP integration recipe Verification ships as `DeedVerifier` in TypeScript, Python and Go, and as `grantor_verify::sovereign::verify_deed` in Rust. **Chain reads stay on the Rust side of the FFI** in every binding: the on-chain tenant check *is* the billing enforcement, so the shim owns the gate and claims cannot be obtained without passing through it. Errors are a structured enum rather than a flat string, so a relying party can tell `TenantInactive` (billing — the customer must top up) from `BadProof` (an attack) from `Chain` (an RPC problem worth retrying, and the one case that should answer 503 rather than 401). For Rust, add `grantor-verify` with the `sovereign-chain` feature (non-default — it pulls in `semaphore-rs` + alloy's chain RPC stack, so light builds that only verify standard OIDC JWTs stay lean). The pure `sovereign` feature underneath it — envelope, claims verification, `DeedGuard`, discovery, session-JWT, and the chain PORT itself — links no RPC stack at all; reach for it directly only if you're supplying your own `DeedChainGate` instead of the built-in `AlloyGate`: ```toml # `grantor-verify` is currently path-only within this workspace, not yet # published to crates.io — this snippet is how another crate in the SAME # checkout depends on it today; publishing is tracked separately. grantor-verify = { path = "...", features = ["sovereign-chain"] } ``` The recipe is: **issue a challenge → verify → mint a local JWT → your existing OIDC stack is unchanged.** This is lifted directly from the working reference RP in `crates/grantor-verify/tests/sovereign_e2e.rs` (the file that proves the whole tier with zero `grantor-issuer` process running): The `DeedGuard` owns the part `verify_deed` deliberately cannot: the anti-replay challenge is state only your app holds, and a verifier that managed it would be a server. It issues challenges, remembers them, burns each exactly once, and stops there — minting a session stays yours. ```rust // GET /challenge — the guard mints and remembers it. #[handler] fn issue_challenge(Data(state): Data<&Arc>) -> Json { let mut raw = [0u8; 16]; rand_core::OsRng.fill_bytes(&mut raw); Json(serde_json::json!({ "challenge": state.guard.issue_challenge(raw) })) } // GET /resource — verify the presented deed, then mint YOUR OWN JWT. #[handler] async fn resource(req: &Request, Data(state): Data<&Arc>) -> PoemResult> { // The deed and the challenge arrive in SEPARATE headers. The challenge is // never read out of the deed — doing so would let the caller choose their // own nonce, and the replay defence would be decorative. let deed_header = req.header(guard::DEED_HEADER) .ok_or_else(|| Error::from_status(StatusCode::UNAUTHORIZED))?; let deed_json = guard::decode_deed(deed_header) .map_err(|_| Error::from_status(StatusCode::UNAUTHORIZED))?; let challenge = req.header(guard::CHALLENGE_HEADER); let now = now_secs(); // Burns the challenge BEFORE verifying, so a flood of bogus deeds cannot // probe which challenges are live. `Chain` maps to 503, everything else // to 401 — an RPC outage is not the caller's fault. let claims = state.guard.verify(Some(&deed_json), challenge, now) .await .map_err(|e| Error::from_status( StatusCode::from_u16(e.status()).unwrap_or(StatusCode::UNAUTHORIZED), ))?; // Your existing OIDC stack, unchanged: sign your own JWT, carrying // sub/aud through. `iss`/`iat` are YOUR OWN — most JOSE/OIDC middleware // expects both (iss especially), so a real integration should set them // even though `grantor_verify::DeedClaims` itself carries neither // (there is no issuer to have asserted an `iss`, and no token mint time // to report as `iat` — both are meaningless upstream of your own mint). let jwt = jsonwebtoken::encode( &jsonwebtoken::Header::default(), &RpClaims { iss: "https://your-rp.example".to_string(), sub: claims.sub, aud: claims.aud, iat: now, exp: claims.exp, }, &jsonwebtoken::EncodingKey::from_secret(&state.jwt_secret), ).map_err(|_| Error::from_status(StatusCode::INTERNAL_SERVER_ERROR))?; Ok(Json(serde_json::json!({ "jwt": jwt }))) } ``` `state.gate` is `CachedGate` — `AlloyGate::new(rpc_url, registry_address, expected_chain_id)` (the RESOLVED address, not a `RegistryRef` — `AlloyGate` is the lower layer `DeedVerifier::new` builds on top of after resolving one; see [Enterprise registries § Enforcement posture](guide/enterprise-registry.md#enforcement-posture-read-this-before-relying-on-it-commercially) for why composing `AlloyGate` directly bypasses the license gate) wrapped in a short-TTL cache (`grantor_verify::sovereign_gate`), constructed once at startup and shared across requests. It is the only network dependency this path has: your own RPC endpoint into the chain the registry lives on. ### Multi-RPC quorum With one provider, that provider's word about the registry IS the verifier's view of it: a compromised or merely lying node can report a lapsed tenant active, or a revoked root recent, and nothing notices. `QuorumGate` composes N independent providers and requires **unanimity** on every chain read — every read goes to every provider, and one honest provider vetoes a lie. `DeedVerifier::new(...)` is the degenerate case, a quorum of one. **The guarantee is exactly as strong as provider independence, no stronger.** "Even your RPC provider can't lie to your verifier" is only true when at least one configured provider is both honest and independent. If every configured provider agrees on the SAME false answer — compromised, colluding, or several endpoints resolving to one shared upstream — quorum sees unanimity and passes it; there is nothing to disagree with. What unanimity actually buys is that ONE honest, independent provider vetoes a lie the others tell; it buys nothing against a lie all N happen to agree on. Pick providers that don't share infrastructure, or this reduces to a single point of failure with extra steps. Two ways a quorum read can fail, and both fail closed to the same 401/503 split every other chain read uses: - **`Chain` (HTTP 503)** — a member errored or was unreachable. Same mapping as the single-provider case. - **`QuorumDivergence` (HTTP 503)** — every member answered, and they disagreed. **This is the attack signal the feature exists to produce — alert on it.** A single occurrence can be innocent: two providers sitting at different block heights right after a state change (a `drawPeriod` flipping a tenant active, say) can diverge on one read and agree again on the next, because `CachedGate` never caches an error — agreement is re-established fresh every TTL window, never remembered stale. A *sustained* or *repeated* divergence is the one worth paging on: a provider that keeps disagreeing with the others is either broken or lying, and a verifier has no way to tell those apart from outside — that judgment call belongs to whoever operates the RP, which is why this stays an error the RP observes rather than something the library resolves for you. **The trade is availability, deliberately.** Any one member down fails the whole read closed, so a quorum verifier is only as available as its *least*-available member, never its best one. That is the point, not a defect: the trust property costs exactly what an independently-failing member costs you. Pick 2-3 providers that are *actually* independent — different infra, different operators. Two endpoints behind the same upstream aren't a quorum; they're one provider with a proxy in front, and they will never produce a `QuorumDivergence` worth trusting. Construction takes the same parameters as the single-URL constructor, with `rpc_url` replaced by a list: ```rust use grantor_verify::verifier::DeedVerifier; let verifier = DeedVerifier::new_quorum( &["https://rpc-a.example".to_string(), "https://rpc-b.example".to_string()], ®istry_ref, chain_id, tenant_id, audience, origin, max_ttl_secs, cache_ttl_secs, allow_insecure_origin, now_unix, )?; ``` ```ts import { DeedVerifier } from "../../sdk/verify/ts/pkg/grantor_verify_wasm.js"; import { Registry } from "../../sdk/verify/ts/src/registry.js"; const verifier = DeedVerifier.newQuorum( ["https://rpc-a.example", "https://rpc-b.example"], Registry.canonical(), CHAIN_ID, TENANT_ID, AUDIENCE, ORIGIN, MAX_TTL_SECS, CACHE_TTL_SECS, ALLOW_INSECURE_ORIGIN, Math.floor(Date.now() / 1000), ); ``` ```python from grantor_verify.grantor_verify_uniffi import DeedVerifier from grantor_verify import registry verifier = DeedVerifier.new_quorum( rpc_urls=["https://rpc-a.example", "https://rpc-b.example"], registry_ref=registry.canonical(), chain_id=CHAIN_ID, tenant=TENANT_ID, audience=AUDIENCE, origin=ORIGIN, max_ttl_secs=MAX_TTL_SECS, cache_ttl_secs=CACHE_TTL_SECS, allow_insecure_origin=ALLOW_INSECURE_ORIGIN, now_unix=NOW_UNIX, ) ``` ```go verifier, err := grantor_verify_uniffi.DeedVerifierNewQuorum( []string{"https://rpc-a.example", "https://rpc-b.example"}, guard.RegistryCanonical(), chainID, tenantID, audience, origin, maxTtlSecs, cacheTtlSecs, allowInsecureOrigin, nowUnix, ) ``` A bare Rust `DeedGuard` that supplies its own gate instead of going through `DeedVerifier` composes the two underlying types directly — `CachedGate` on the outside, so agreement is re-checked every TTL window rather than remembered. **This composition bypasses the enterprise-license gate** (see [Enterprise registries § Enforcement posture](guide/enterprise-registry.md#enforcement-posture-read-this-before-relying-on-it-commercially)): `AlloyGate::new` takes an already-resolved address, not a `RegistryRef`, so nothing here checks a license — resolve a `RegistryRef` yourself first (`grantor_sdk_core::resolve_registry`) if you need that check on this path: ```rust use grantor_verify::sovereign_gate::{AlloyGate, CachedGate, QuorumGate}; let gate = CachedGate::new( QuorumGate::new(vec![ AlloyGate::new(rpc_a, registry, expected_chain_id), AlloyGate::new(rpc_b, registry, expected_chain_id), ]), cache_ttl_secs, ); ``` **Production note on `state.challenges`:** the example above is a plain `HashSet` that only ever grows on issue and shrinks on successful redemption — an abandoned challenge (issued, then never redeemed) stays in the set, and stays redeemable, forever. A production RP needs TTL/eviction on this store (e.g. an expiring cache keyed by challenge, or a periodic sweep) so an unused challenge cannot be redeemed arbitrarily far in the future and memory doesn't grow unbounded under abandoned sessions. ## Discovery — how an agent finds all this An application that accepts deeds publishes `/.well-known/grantor-deed`: { "v": 1, "tenant": 42, "audience": "api.example.com", "modes": ["user-sig", "agent-zk"], "challenge_endpoint": "/auth/challenge", "max_ttl_secs": 300, "chain": { "id": 42161, "registry": "0x…" }, "origin_vouch": { "signature": "…", "epoch": 0, "exp": 1234567890 } } `origin_vouch` is REQUIRED — `parse_discovery` rejects a document without it, and the builder additionally refuses to emit one whose vouch has expired or whose TTL exceeds the 90-day ceiling. See [Origin provenance](#origin-provenance). The guard serves it from the same `DeedPolicy` it verifies against, so what is advertised cannot drift from what is enforced. The TypeScript (`grantorExpress`) and Python (`GrantorDeps`) adapters mount it for you when you pass `app` plus `challengeEndpoint`/`chainId`/`modes`. **Go does not auto-mount** — its adapter deliberately owns no router (unlike Express/FastAPI, Go has no single dominant one to couple to), so it stays a zero-dependency package; call `guard.Discovery(...)` yourself and register the returned `http.HandlerFunc` on whatever mux you use. An agent needs only the origin and a way to sign — `authenticate(origin, signMessage)` (`authenticate` in TS/Python/Go) composes discover → challenge → mint for the `user-sig` mode, the mode an autonomous agent uses (permissionless, no enrolment round trip). It ALSO requires a `chainReader` (an `eth_call` seam) and a `registry`/`rpcUrl` pair (the `RegistryRef` you trust, resolved via a live `eth_chainId` read over `rpcUrl` — defaults to `Registry.canonical()`, the SDK pins the canonical registry for you; a custom address exists only on the licensed [dedicated path](guide/enterprise-registry.md)) — see [Constructing a chainReader](#constructing-a-chainreader) below for where these come from and why both are required together: ```ts import { authenticate } from "@grantor/agent"; import { Registry } from "@grantor/agent/registry"; const deed = await authenticate("https://api.example.com", signMessage, { chainReader, registry: Registry.canonical(), rpcUrl, }); ``` ```python from grantor_agent.authenticate import authenticate from grantor_agent import registry deed = await authenticate( "https://api.example.com", sign_message, chain_reader=chain_reader, registry=registry.canonical(), rpc_url=rpc_url, ) ``` ```go deed, err := authenticate.Authenticate("https://api.example.com", sign, &authenticate.Options{ ChainReader: chainReader, Registry: guard.RegistryCanonical(), RpcURL: rpcURL, }) ``` The `chainRegistry`/`chain_registry`/`ChainRegistry` option each language used to take (a plain, holder-pinned address) is **deleted** — passing it now throws/raises/errors naming the replacement, rather than silently falling through to the canonical default. **Python's `authenticate()` is now `async`** (it used to be synchronous) — call it with `await`, matching TS/Go's already-async shape. `authenticate()` takes **only the origin** — there is no second URL parameter for the challenge endpoint. The challenge is always resolved against the same origin discovery was fetched from, structurally: a caller cannot discover at one origin and authenticate at another, because there is nowhere to pass a different one in. If the application does not accept deeds at all (404 on discovery), or does not accept `user-sig` specifically, it refuses **before** ever asking `signMessage` to sign, naming the modes the application does accept. ### Constructing a `chainReader` `chainReader`/`chain_reader`/`ChainReader` is the ONE `eth_call` seam `authenticate()`/`signInWithDeed()` use to check origin provenance before signing anything — see [Origin provenance](#origin-provenance) below for what it protects against. It is entirely caller-supplied and MUST NEVER be derived from the discovery document (a hostile origin would simply name its own node). `registry`/`rpc_url` — resolved to the registry address `chainReader` is queried against — is REQUIRED alongside it: the document's own `chain.registry` is compared against this resolved pin and a mismatch is refused, never used as the `eth_call` target directly (see [Origin provenance](#origin-provenance) for why: a hostile origin can publish any contract it likes as `chain.registry`, including one whose `isOriginVoucher` always answers `true`). `registry` defaults to `Registry.canonical()` — the SDK pins the canonical registry for you; get a custom address the same way you get the RP's expected `tenantId`/`audience` — out of band, from the RP's own documentation or operator, never from anything the RP's discovery document says about itself — and a custom registry address on a real chain requires an [enterprise license](guide/enterprise-registry.md) (`Registry.dedicated(address, license)`); the standard SDK cannot express one otherwise. **Browser (`window.ethereum`)** — a wallet provider already speaks this shape: ```ts // `data` arrives as a Uint8Array — no `Buffer` here on purpose: Vite and // webpack 5 don't polyfill Node globals, so this is the one recipe on this // page that actually has to run in a browser, not just compile. const toHex = (bytes) => "0x" + Array.from(bytes, (b) => b.toString(16).padStart(2, "0")).join(""); const chainReader = { call: ({ to, data }) => window.ethereum.request({ method: "eth_call", params: [{ to, data: toHex(data) }, "latest"], }), }; ``` **Server-side (TypeScript/Node)** — a minimal hand-rolled `eth_call` over any JSON-RPC endpoint (this is exactly why `chainReader` is a raw `eth_call` seam rather than an `alloy`/`ethers` dependency — it keeps `@grantor/agent` free of a multi-MB chain library): ```ts async function makeJsonRpcChainReader(rpcUrl) { return { call: async ({ to, data }) => { const res = await fetch(rpcUrl, { method: "POST", headers: { "content-type": "application/json" }, body: JSON.stringify({ jsonrpc: "2.0", id: 1, method: "eth_call", params: [{ to, data: "0x" + Buffer.from(data).toString("hex") }, "latest"], }), }); const { result } = await res.json(); return result; }, }; } ``` **Server-side (Python)**: ```python import requests def make_chain_reader(rpc_url): def chain_reader(to: str, data: bytes) -> bytes: resp = requests.post(rpc_url, json={ "jsonrpc": "2.0", "id": 1, "method": "eth_call", "params": [{"to": to, "data": "0x" + data.hex()}, "latest"], }) return bytes.fromhex(resp.json()["result"][2:]) return chain_reader ``` **Server-side (Go)**: ```go type jsonRPCChainReader struct{ rpcURL string } func (r jsonRPCChainReader) Call(to string, data []byte) ([]byte, error) { body, _ := json.Marshal(map[string]any{ "jsonrpc": "2.0", "id": 1, "method": "eth_call", "params": []any{map[string]string{"to": to, "data": "0x" + hex.EncodeToString(data)}, "latest"}, }) resp, err := http.Post(r.rpcURL, "application/json", bytes.NewReader(body)) if err != nil { return nil, err } defer resp.Body.Close() var out struct { Result string `json:"result"` } if err := json.NewDecoder(resp.Body).Decode(&out); err != nil { return nil, err } return hex.DecodeString(strings.TrimPrefix(out.Result, "0x")) } ``` **Rust** uses `AlloyGate` directly — see the Rust composition example below; it already implements the equivalent seam over `alloy`, since a Rust holder is not paying the wasm-bundle cost `chainReader` exists to avoid for the other three languages. That same `origin` argument is also what gets **signed** — never a value read out of the discovery document. `authenticate()` passes it straight through to `mintUserDeedFromWallet` as the `origin` parameter (immediately after `aud` in every language), so the deed is bound to the origin the agent actually talked to, not to anything the document claimed. See [Origin binding](#origin-binding) below for why this matters. For `agent-zk` (an already-enrolled agent proving ZK membership — see [Minting an `agent-zk` deed](#what-it-is) above), `authenticate()` does not apply: compose `discover()` and a challenge fetch by hand, then call `ZkAgent.mintDeed` yourself: const d = await discover("https://api.example.com"); if (!d) return; // does not accept deeds const res = await fetch(new URL(d.challenge_endpoint, origin)); const { challenge } = await res.json(); // registryAddress and rpcUrl are YOUR configuration. Never d.chain.registry: // a document-supplied eth_call target lets a hostile origin point the // provenance check at a contract it controls, which answers `true`. if (d.chain.registry.toLowerCase() !== registryAddress.toLowerCase()) return; const v = d.origin_vouch; // `registryAddress` is wrapped in a RegistryRef only at the mint call — // Registry.canonical()/Registry.dedicated(addr, license) for a real // deployment; Registry.devnet(addr) shown here matches the pinned // comparison above (a devnet address is what most local recipes pin). const deed = await agent.mintDeed( rpcUrl, Registry.devnet(registryAddress), d.tenant, d.audience, origin, challenge, exp, v.signature, v.epoch, v.exp, false, // allowInsecureOrigin — true only for local dev against http/localhost Math.floor(Date.now() / 1000), ); **`mintDeed` verifies origin provenance before it proves.** It takes the tenant admin's vouch and checks it against the registry **you** pinned, and refuses before generating a proof if the origin cannot show one — mirroring what `authenticate()` does for `user-sig`. This is what closes [Origin binding](#origin-binding) items 3 and 4. **`mintDeed` also enforces the fail-closed origin policy before any of that.** `allowInsecureOrigin` (default-secure `false`) is forwarded to `enforce_origin_policy`, checked right after `origin` is canonicalised and before origin provenance or any chain call: a non-HTTPS or non-routable `origin` is refused with `InsecureOrigin` rather than building (and discarding) a real Semaphore membership proof for a deployment that could never be production. Pass `true` only for local dev / a demo against `http://localhost:...`. Without this check, a hostile origin publishing a victim tenant's `tenant`/`audience` could induce a holder composing this recipe to hand over a valid Semaphore membership proof for the victim's registry plus a nullifier comparable across every origin sharing that `(tenant, audience)`. There is no `authenticate()`-style wrapper for `agent-zk` to hide such a gap in — `mintDeed` is the only `agent-zk` mint API there has ever been, so the check lives here. **⚠️ Two things you must still get right, because they are yours, not the SDK's.** `registryAddress` and `rpcUrl` must come from YOUR configuration. Passing `d.chain.registry` makes the pin vacuous and the SDK cannot tell — it never sees the document. Compare the document's value against your pin and refuse on mismatch, as the snippet above does; `authenticate()` is structurally safe here only because it reads the document itself and does that comparison for you. Fetch discovery from the **same origin** you will present the deed to, and pass that SAME origin — never `d.audience` or anything else out of the document — as `mintDeed`'s `origin` argument. See [Origin binding](#origin-binding) below for why: without it, composing this by hand is exactly where a caller could accidentally (or be tricked into) signing the wrong origin, since `authenticate()`'s structural guarantee no longer applies once you're composing the calls yourself. **Rust has no `authenticate()`.** `grantor-sdk-core` is deliberately HTTP-free — that is what keeps it wasm-clean, and `just sdk-wasm-check` guards it in CI — so there is nothing in Rust to fetch a URL with. A Rust holder composes the flow by hand. This is composition, not a missing capability: `just capability-matrix`'s Rust column is marked "compose it yourself" for this one row rather than a bound function, because there is no HTTP client to bind. **But composing it means composing the SECURITY CHECK too, not just the happy path.** What `authenticate()` does for the other three languages, in order: 1. Canonicalise your own origin (`normalize_origin`) — once, before any fetch, so the URL you fetch and the origin you sign can never diverge. 2. Fetch `discovery::DISCOVERY_PATH` from that origin with your own client, and validate the response through `discovery::parse_discovery`. Never hand-parse — that validator is shared with every other language on purpose. 3. **Origin provenance, BEFORE anything is signed** ([Origin provenance](#origin-provenance)): recover the vouch signer with `originvouch::origin_vouch_signer(tenant, audience, YOUR_OWN_ORIGIN, epoch, exp, sig)`, reject `exp <= now` and `exp - now > ORIGIN_VOUCH_MAX_TTL_SECS`, then confirm `isOriginVoucher` on-chain and refuse if it is false or the read fails. **Pass your OWN observed origin, never the document's** — that substitution is the entire attack this defeats. **Read against a registry address YOU pinned out of band, never `chain.registry` from the document**, and over an RPC endpoint of your own: a hostile origin publishes whatever contract it likes there, including one whose `isOriginVoucher` always answers `true`. 4. Only then fetch the challenge endpoint the document names, and mint — `usersig::derive_root_seed(origin, …)` → `derive_app_key(seed, tenant, origin)` → `mint_user_deed(…)` (see [User login](#user-login-user-sig)), or `sovereign::mint_deed` for `agent-zk`. Step 3 is the one that is easy to leave out, and leaving it out is silent — everything still works, against every origin. `crates/grantor-verify/tests/sovereign_e2e.rs` composes exactly this (`holder_check_origin_provenance` / `holder_authenticate_user_sig`) against a live chain and asserts the signing callback is **never invoked** for an unvouched origin; treat it as the reference implementation of this list. ## Fail-closed origin policy Separate from origin *binding* (below) and origin *provenance* (above): this is about whether an origin is even well-formed enough for production, not about who it is cryptographically tied to or vouched for. `enforce_origin_policy(origin, allow_insecure_origin)` (`grantor_sdk_core`) refuses two things by default — a scheme other than `https`, and a non-routable host (`localhost`/`*.localhost`/`*.local`/`0.0.0.0`, and loopback/private/link-local/ULA IPv4 and IPv6 literals). `normalize_origin`'s structural check (a malformed scheme, an embedded `|`, userinfo, a real path) still runs first regardless of the flag and surfaces as `SdkError::Input`/`DeedError::Malformed`; only a well-formed-but-insecure origin gets `SdkError::InsecureOrigin`/ `DeedError::InsecureOrigin`. **The default is secure everywhere the parameter exists.** `allow_insecure_origin`/ `allowInsecureOrigin` defaults to (and should stay) `false` in any real deployment; a service configured with `http://localhost:3000` fails loudly at boot/mint time rather than silently accepting a non-routable, non-HTTPS origin. Set it `true` only for local dev or a demo — every recipe on this page that talks to `http://localhost` passes `true` explicitly, never by omission (see `docs/deploy/demo.md`'s note on `deploy.sh` computing it from `$ORIGIN` for exactly this reason). It is enforced at exactly **four** call sites, independently, because there is no single point in the code path that sees every origin: 1. **The verifier's own configured origin** — `DeedPolicy.allow_insecure_origin` / `DeedVerifier`'s `allowInsecureOrigin` constructor argument (second-to-last, immediately before `nowUnix`). Checked as literal step 0 of `verify_deed_claims`/`verify_user_1271` — before the mode check, before the deed's own origin comparison, before either chain read. See [What the verifier checks](#what-the-verifier-checks) below and [Errors](guide/errors.md). 2. **The discovery document** — `discovery_document` (`grantor-verify`) runs the same check on `policy.origin` before assembling the document at all, so a misconfigured RP never advertises a document a holder could authenticate against. 3. **The origin vouch a tenant admin signs** — `origin_vouch_binding` (`grantor_sdk_core::originvouch`) refuses to build the vouch message for an insecure origin, so an admin cannot sign a vouch for `http://localhost` that ends up attached to a real tenant. 4. **Minting an `agent-zk` deed** — `mint_agent_deed`/`ZkAgent.mintDeed` (`grantor_sdk_core::agent`) checks it right after canonicalising `origin` and before origin provenance or any chain call, refusing before building (and discarding) a real Semaphore proof for a deployment that could never be production. **Deliberately NOT enforced:** the raw `user-sig`/`user-passkey` mint bindings (`mint_user_deed`/`mint_user_passkey_deed` take no `allow_insecure_origin` parameter at all — for `user-sig` the security-relevant enforcement point is `authenticate()`'s own config plus the verifier's policy, not the bare mint call), and `admin-sig` (its binding takes no `origin` argument in the first place — see "`admin-sig` is deliberately exempt" under [Origin binding](#origin-binding) just below). ## Origin binding Every `user-sig`/`agent-zk` mint call and every `DeedVerifier` take an `origin` — the scheme+host+optional-port the holder actually talked to (`https://api.example.com`; a path is rejected, but a bare trailing slash is tolerated and folded — see below). It sits immediately after `aud`/`audience` in every mint call and every verifier constructor, in every language, so a transposition between the two is visually obvious. **It joins the SIGNED material, not the wire envelope.** `user_binding` (`user-sig`) and `sovereign_signal_hash` (`agent-zk`) fold `origin` in alongside `tenant`/`aud`/`challenge`/`exp`; `Deed` itself gains no `origin` field — a value the holder supplied and the verifier trusted would defeat the whole point. `DeedVerifier`/`DeedPolicy` carry `origin` as **your own configuration**, normalised (`normalize_origin` — lowercases the host, strips a default port, tolerates a single bare trailing slash) so two differently-spelled configurations of the same origin cannot silently diverge: `DeedVerifier`/`DeedVerifierJs` normalise once at construction, `grantor_verify::sovereign::verify_deed_claims` normalises `DeedPolicy.origin` on every call (so a bare Rust struct literal gets the same protection), and every mint entry point (`mintUserDeed`/`mint_user_deed`/`MintUserDeed`, `mintDeed`, `mintUserDeedFromWallet`) canonicalises `origin` before signing — **including both functions a Rust holder calls directly**, `grantor_sdk_core::usersig::mint_user_deed` (see the snippet below) and its `agent-zk` twin `grantor_sdk_core::sovereign::mint_deed`. The first is why `mint_user_deed` is fallible rather than the plain `Deed`-returning function it started as. Both were once exceptions to this sentence, which is the reason the sentence now names them: the claim was written before it was true — so a holder's `https://API.example.com/` and an RP's `https://api.example.com` — the same origin, spelled two ways a browser or an operator could equally produce — bind the SAME credential rather than silently failing at the signature. `normalizeOrigin`/`normalize_origin`/ `NormalizeOrigin` is exported from every holder-side binding so a caller can canonicalise once, up front, before either fetch — see `authenticate()` below. Verification rebuilds the binding from the normalised configured origin and the challenge you issued; it never reads an origin back out of the token. **Why this exists.** Before origin joined the signed material, a hostile origin A could publish victim B's `tenant`/`audience` in its own discovery document, relay a challenge it fetched from B, and collect a deed: the holder signs against B's genuine tenant/audience/challenge with its real key for B, so the signature/proof is entirely valid — it just says nothing about WHERE the holder was. A replays that deed to B, whose verifier saw a correct audience, a live challenge it issued, and a valid signature/proof — full impersonation, no cryptographic material forged. Binding the origin closes it: B's verifier only ever rebuilds the binding with B's own origin, so a deed signed while talking to A can never reconstruct that same binding, and fails as `BadProof`. **What this closes, and what still does not.** Four separate claims, kept separate on purpose — conflating them is exactly the overclaim this section existed to correct once already: 1. **Impersonation: closed.** As above — B's verifier only ever rebuilds the binding from its own configured origin, so a deed signed while the holder was talking to A can never reconstruct it and fails as `BadProof`. 2. **`user-sig` pseudonym harvesting: closed.** `derive_app_key` folds `origin` into its HKDF info (`tenant:{t}|origin:{o}`, `usersig.rs`; `aud` is deliberately not part of that scope — see "User login" below), so the app key — and therefore `pubkey`/`sub` — is scoped to the origin the holder actually derived against. A publishing B's `tenant`/`audience` and getting a holder to authenticate no longer collects B's pseudonym; it collects a pseudonym scoped to **A**, unrelated to the one B would see. Since `user-sig` carries no membership proof, that pseudonym is the entirety of what `user-sig` could ever leak to A — so origin-scoping the key closes the harvest completely, not just partially. ⚠️ Note the boundary: this is about what a hostile origin learns from a holder that *derives correctly*. It is **not** a defence against a hostile origin that obtains the root seed, because the origin is an argument the deriving party supplies. That is closed separately, by origin-scoping the SEED itself — see "User login" below. 3. **`agent-zk` cross-origin linkability: closed by prevention.** `sovereign_external_nullifier` deliberately still scopes on `(tenant, audience)` only — origin was considered and NOT added to it, for two reasons. First, audience already separates different relying parties, so adding origin would buy separation only in the narrow case of two origins sharing one tenant *and* audience — essentially just the hostile-origin case this section is about. Second, origin-scoping it would break the property that the sovereign tier's `sub` equals the blind tier's `app_scoped_pseudonym` (`blindclient.rs`) — the mechanism by which an agent recognised on one tier is recognised as the same principal on the other. This leaves a hostile origin A that publishes victim B's `tenant`/`audience` able to collect a nullifier that repeats every time the same agent authenticates to A, comparable against nullifiers seen elsewhere sharing that same `(tenant, audience)`. [Origin provenance](#origin-provenance) below closes this **by prevention** — a holder that checks provenance before minting never authenticates to A at all, so there is no nullifier for A to collect. That check runs on the `agent-zk` mint path: `ZkAgent.mintDeed` takes the vouch and verifies it against the caller's PINNED registry **before generating a proof**, in all four languages — `mintDeed` is the only `agent-zk` mint API and no `authenticate()`-style wrapper exists for it, so the check lives there. The ordering, not merely the error, is pinned per language; see "How the ordering is proven" below. 4. **`agent-zk` membership disclosure: closed by prevention.** Without this check, A would additionally collect a valid Semaphore proof that the holder is enrolled in B's on-chain registry — evidence of membership, not merely a pseudonym. No signed-material binding could ever close this on its own: demonstrating membership is what the proof is *for*, and scoping what a proof is bound to changes who can *use* the proof, not whether an untrusted origin can *extract* the fact of membership by asking a holder to produce one at all. [Origin provenance](#origin-provenance) closes it the only way it could be closed — by stopping the holder from ever producing that proof for A. As with item 3, this rests entirely on `mintDeed` refusing **before** it proves, which is why the tests assert that no membership proof was built rather than merely that an error was raised. ### How the ordering is proven Refusing "at some point" would be worthless here: a proof generated and then discarded has still been built, and building it tells the RPC endpoint which tenant the agent belongs to. So each language asserts that proving was never entered, and each assertion was verified by moving the check after `build_membership_proof` and confirming that specific test fails: * **TypeScript / Python / Go** use a deliberately *unregistered* agent against the hostile origin. If proving ran first the failure would be `NotAMember`; getting `BadOriginVouch` instead is what proves the order. * **Rust** has no `ZkAgent` — a holder composes the recipe by hand — so `hostile_origin_is_refused_before_an_agent_zk_proof_is_built` in `crates/grantor-verify/tests/sovereign_e2e.rs` counts membership fetches against a live anvil and requires **zero**. * Each also mints successfully at the genuinely vouched origin, so none of the above can pass because provenance is simply broken shut. Both `agent-zk` items above close through **Part B (origin provenance)**: the tenant admin vouches offline that an origin speaks for the tenant, and the holder verifies that on-chain *before* signing or proving, so the interaction with a hostile origin never happens in the first place — prevention, not scoping after the fact. **Part B covers both the `user-sig` holder path** (`authenticate()` and `signInWithDeed()`) **and the `agent-zk` mint path** (`ZkAgent.mintDeed`), in all four languages — see items 3 and 4 above for the ordering proof (provenance-before-proving, not merely provenance-before-return, pinned per language). The `agent-zk` half of the check lives once, in `grantor_sdk_core::agent::mint_agent_deed` (`crates/grantor-sdk-core/src/agent.rs`), which every language's `ZkAgent.mintDeed` marshals into rather than duplicating the check per shim. The check is still holder-side, not RP-enforceable — see "Only as good as a holder that runs the check" below. See [Origin provenance](#origin-provenance) below for what this closes, what it still does not, and its cost. **This enforces nothing.** `verify_user_sig` recomputes `sub` from the pubkey alone; it has no way to observe which origin a holder derived against, so origin-scoping is not something a verifier checks or can check — it is a property a holder gets for free by deriving correctly, and loses if it (or a hostile origin's SDK fork) derives against the wrong origin. It is a privacy property for the holder, not an authorization control, and it adds no gate a verifier enforces. **Cost.** A relying party that legitimately serves one audience from two origins (a staging origin and a production origin sharing a tenant and audience, say) now has its users derive two different `user-sig` pseudonyms from the same wallet — one human looks like two accounts. That is the same tradeoff a browser's same-origin policy makes for cookies and storage, not a defect specific to this design. **`authenticate(origin, ...)` makes the fix structural, not just available.** It canonicalises `origin` ONCE, at the top, before EITHER the discovery fetch or the challenge fetch, and uses that SAME canonical value for both fetches AND the mint call — never anything read out of the document. A document cannot influence what origin ends up in the deed, because the document is never consulted for it, and a malformed origin (a real path, userinfo, …) is refused before any network call rather than surfacing as an undiagnosable `BadProof` two fetches later. **Redirects do not change what gets signed.** All three SDKs' discovery and challenge fetches follow redirects by default (plain `fetch`/`requests`/ `net/http` behaviour) and none of them reads the response's final URL for anything — `origin` is signed exactly as the CALLER supplied it (after canonicalisation), never re-derived from wherever the fetch actually landed. This is a deliberate choice, not an oversight: binding to the final URL's origin would mean a redirect an attacker controls (or one an operator adds later for an unrelated reason — a `www.` canonicalisation, a load balancer migration) silently changes what a credential is bound to, with no visibility to the caller. The origin you pass to `authenticate()` MUST be the one your RP is actually configured with (its `DeedVerifier`/`DeedPolicy.origin`) — if discovery or the challenge fetch redirects to a different origin, the deed still signs the ORIGINAL one, and verification then depends on which origin the RP is configured with, not on where the fetch happened to end up. **`admin-sig` is deliberately exempt.** Its binding is human-readable prose that already leads with the requesting domain ("Only sign it if you are on {aud} right now") and it never appears in a discovery document's `modes`, so it is unreachable by the attack origin binding closes — see [Dashboard login](#dashboard-login-admin-sig). ## Origin provenance Origin binding (above) closes impersonation and `user-sig` pseudonym harvesting, but leaves two `agent-zk` exposures open on its own: a hostile origin A that publishes victim tenant B's `tenant`/`audience` can still *solicit* an interaction — collecting a repeating nullifier, and a valid Semaphore proof of membership in B's registry — even though A can never present the resulting deed to B. Signed-material binding cannot close either: both are things A learns by *asking* a holder to sign or prove, not by replaying what the holder produced. **The fix is prevention, not further scoping.** A tenant admin signs an offline vouch — human-readable prose, the same convention as `admin-sig`'s binding — that a specific origin currently speaks for the tenant: {origin} is claiming to act for Grantor tenant {tenant}. Signing this authorises {origin} to ask people and agents for deeds belonging to the tenant below. Only sign it if you administer that tenant AND you control {origin} — anyone holding this signature can make holders believe {origin} speaks for you. Tenant: {tenant} Audience: {aud} Origin: {origin} Epoch: {epoch} Expires (unix seconds): {exp} grantor-origin-v1 The guard publishes that signature in its discovery document (`origin_vouch` — **required**, not optional: an optional vouch is one a hostile origin simply omits, and a holder that tolerates its absence gains no protection at all). Before signing or proving anything, the holder recovers the vouch's signer against the origin **it itself observed** — never a value read out of the document, for the same reason `origin` joins the signed material above — and asks the chain `isOriginVoucher(tenant, signer, epoch)`: ```solidity function isOriginVoucher(uint256 id, address signer, uint64 epoch) external view returns (bool) { return isAdmin[id][signer] && epoch == originEpoch[id]; } ``` A tenant admin revokes a compromised or decommissioned origin's vouch with `bumpOriginEpoch(id)` — every outstanding vouch (all signed against the old epoch) stops verifying immediately, with no expiry to wait out. **What this closes today.** `agent-zk` cross-origin linkability and membership disclosure — the two items [Origin binding](#origin-binding) leaves open above — close **by prevention**: a holder that checks provenance before minting never authenticates to an origin that cannot show a vouch, so the interaction with a hostile origin never happens in the first place, and there is nothing left for A to solicit. The provenance check is wired into every mint-side entry point, in all four languages: `authenticate()` and `signInWithDeed()` (`user-sig`) *and* `ZkAgent.mintDeed` (`agent-zk`); see [Origin binding](#origin-binding) items 3-4 above for the ordering proof (each language asserts proving was never *entered*, not merely that an error came back). The `agent-zk` half of that check lives once, in `grantor_sdk_core::agent::mint_agent_deed` (`crates/grantor-sdk-core/src/agent.rs`), which every language's `ZkAgent.mintDeed` marshals into rather than duplicating the check per shim — a single shared implementation, rather than one copy per shim, is what keeps the check from silently drifting out of sync between languages. The check remains holder-side, not RP-enforceable, and every residual limit documented in this section (a compromised admin key, a holder that skips its own check, a multi-origin RP) applies regardless. **What this does NOT close.** A compromised tenant admin key can vouch for any origin it likes. This is not a new exposure — that same key can already add other admins and change the tenant's tier — and **`bumpOriginEpoch` does not mitigate it**: the compromised key simply signs a fresh vouch at the new epoch, immediately, exactly as a legitimate admin would (proven, not merely asserted, by `origin_vouch_authenticates_then_revoked_by_epoch_bump` in `crates/grantor-verify/tests/sovereign_e2e.rs` — the same key that gets bumped can re-vouch and pass again the moment it does). `bumpOriginEpoch` revokes an origin, not an admin. **Only as good as a holder that runs the check.** This is a holder-side control. A holder that skips it — or a fork of the SDK that strips it out — gains nothing from a vouch existing; it will happily sign for whatever origin asks. **Part A's origin-scoped key derivation is not an independent backstop here.** It scopes the per-app KEY, but the origin is an argument the deriving party supplies, and in a browser a hostile page is the deriving party — "the holder derives correctly" protects only a holder that is actually doing the deriving. The root seed itself is also origin-scoped, with the signed message naming the origin in prose; see [Why the seed is origin-scoped](#why-the-seed-is-origin-scoped) under [User login](#user-login-user-sig) for what that buys and does not buy, and `crates/grantor-verify/tests/usersig_root_seed_scope.rs` for the regression pin. There is no automatic, unskippable holder-side backstop behind the origin provenance check: a holder that skips it is relying on a human reading a wallet prompt. **The guard's own self-check is DX, not enforcement.** It lets an RP catch its OWN misconfigured vouch at boot — one signed for the wrong origin, one that has quietly expired, one whose TTL no holder will accept — instead of discovering it from a stream of failed logins. It is anti-drift tooling for the operator, not a security control: nothing stops a bare RP that never calls it from being perfectly secure, because enforcement lives entirely on the holder side, above. Conversely, calling it buys an RP nothing if its holders don't check provenance either. It comes in two halves, split by whether the check needs a chain read: * **Offline, and unavoidable** — building the discovery document refuses an expired vouch or one whose TTL exceeds the 90-day ceiling (`ORIGIN_VOUCH_MAX_TTL_SECS`). This is in the shared document builder, so every language gets it whether or not the operator remembers to run a self-check, and it fails at the point where the vouch would otherwise be *advertised*. Advertising a vouch that cannot work is worse than not advertising: the holder's resulting failure names nothing, this one names the field. * **Chain-backed, and explicit** — `verifyOwnOriginVouchAt` (Rust: `DeedGuard::verify_own_origin_vouch`) additionally confirms the recovered signer genuinely is an admin for this tenant at this epoch, and returns the seconds remaining when the vouch expires within 14 days so an operator can warn on it. Call it once at boot. It reads through the **uncached** gate on purpose: a cached `true` would keep a `bumpOriginEpoch`-revoked origin passing for a TTL window, and a boot-time check has nothing to amortise. **The cost you accept.** Checking `isOriginVoucher` before minting means login now depends on the holder having a working chain connection: the wallet's own provider being connected, and pointed at the right chain. A signature-only signer with no RPC access of its own — one wired to produce raw signatures and nothing else — cannot perform this lookup and cannot complete a login. This mirrors every other chain read in the sovereign tier (root recency, tenant status): the tier trades "no auth server" for "a working RPC connection is now part of the auth critical path," and origin provenance is one more read on that same path, not a new kind of dependency. **Nothing new is published on-chain.** The vouch rides `isAdmin` — already a public mapping — plus one `uint64` epoch counter per tenant (`originEpoch`). An on-chain *origin registry* (storing which domain a tenant claims, so anyone could look it up directly) was considered and **rejected**: it would publish a permanent, enumerable `tenant → domain` map, which — combined with the `drawPeriod` events the billing model already emits — would disclose who Grantor's customers are and when their subscriptions lapse. `isOriginVoucher` answers only "does this address currently vouch for this tenant at this epoch", never "what does this tenant claim as its origin", so nothing about a tenant's actual domain(s) is ever readable from chain state. ## User login (`user-sig`) A human signs in with their wallet and gets a stable pseudonym scoped to the `(tenant, origin)` deployment — still with no Grantor service involved. **This holder path ships in TypeScript, Python, Go and Rust** — `userRootBinding`, `deriveRootSeed`, `deriveAppKey`, `userSub`, `userBinding` and `mintUserDeed` are exported from `sdk/ts` (wasm-bindgen), `sdk/python` and `sdk/go` (UniFFI), all asserted against the same `sdk/conformance/vectors.json` entries the Rust core defines, so the four bindings are proven identical rather than merely parallel implementations. The double-sign-and-compare determinism check (below) lives once in `grantor-sdk-core` and every binding calls into it — no language reimplements it. The Rust snippet below is the source of truth; see `sdk/README.md` for the equivalent TS/Python/Go calls. ```rust use grantor_sdk_core::usersig::{derive_root_seed, derive_app_key, mint_user_deed}; // The wallet signs a human-readable message NAMING THIS ORIGIN // (`user_root_binding`). `derive_root_seed` signs it TWICE and errors if the // results differ — a wallet that signs non-deterministically would otherwise // mint the user a brand-new identity on every login, silently. Failing // loudly is the point. // // The seed is per-origin, so ONE POPUP PER ORIGIN — not one popup for the // whole web. A single fixed message identical at every origin would let // any site holding one `personal_sign` derive the holder's key at every // other relying party. See "Why the seed is origin-scoped" below. let seed = derive_root_seed(origin, |msg| wallet.personal_sign(msg))?; // The key is derived locally for (tenant, origin), so one popup still covers // every AUDIENCE this deployment serves. `origin` is the origin YOU actually // obtained this challenge from — never a value taken out of a discovery // document. The SAME canonical `origin` feeds every call here; deriving with // one origin and minting with another would produce a deed whose pubkey no // verifier can tie to its binding. let app_key = derive_app_key(&seed, tenant_id, origin)?; // `mint_user_deed` CANONICALISES `origin` before signing (`normalize_origin`) // and is fallible for exactly that reason — a genuinely malformed origin (a // real path, userinfo, …) is refused here, at mint time. let token = mint_user_deed(&app_key, tenant_id, audience, origin, &challenge, exp)?; ``` **`aud` is deliberately NOT in the derivation scope**, so one `(tenant, origin)` yields ONE subject across every audience that deployment serves. That is what makes a role assignable: a role system must be able to say "this subject has this role", and it cannot if the same human is a different opaque `sub` at each of an operator's own APIs. `aud` keeps the job OAuth gives it — it stays in the signed `user_binding`, so a deed minted for one audience still cannot be replayed at another. **`aud` restricts; `sub` identifies.** The accepted cost: two audiences under one `(tenant, origin)` are correlatable by that operator. Across tenants and across origins nothing changed, and `tenant` now carries the whole cross-customer separation burden. ### Why the seed is origin-scoped `derive_root_seed` signs a human-readable message naming the origin, mirroring `admin-sig`'s binding, and the resulting seed is scoped to that origin. A single fixed message identical at every origin would instead let any site that obtained one `personal_sign` derive the holder's key at every relying party — full impersonation. Origin-scoping the seed closes that. **Do not describe `user-sig` itself as structurally closed.** `personal_sign` lets the requesting page choose the message freely, so a hostile page can request the *victim's* origin-scoped message rather than its own, and the wallet — which cannot tell who is asking — will sign it. What stands there is the human reading a prompt that names an origin they are not on. That is the same phishing-resistance ceiling `admin-sig` deliberately accepts. Making it a hard guarantee needs a signing primitive the BROWSER binds to origin (passkeys / WebAuthn), not a better string. Pinned by `residual_risk_a_hostile_page_may_request_another_origins_message` in `crates/grantor-verify/tests/usersig_root_seed_scope.rs`. What origin-scoping the seed buys, unconditionally: a seed captured at one origin is useless at any other, so compromise is contained to the origin the user actually signed at. **A signing primitive the browser binds to origin is built, as [`user-passkey`](#passkey-login-user-passkey).** For holders who use a passkey, this residual is **closed**, and closed **over-determined**, not by one lucky check: WebAuthn binds the ceremony to an origin TWICE, independently, both written by the browser itself rather than application code — `clientDataJSON.origin` (the origin the ceremony actually ran on) and the credential's `rpId`, hashed into `authenticatorData.rpIdHash` (the origin the credential itself is scoped to, fixed at registration) — and the verifier checks both against its OWN configured `cfg.origin`, then recomputes `sub` from the credential public key, all **cfg-anchored, none read from the wire**. A hostile page cannot make either browser-written field lie about the page it is really running on, the way it can make `personal_sign` sign an arbitrary requested string. Mutation-pinned in `crates/grantor-verify/tests/userpasskey_verify.rs` (`passkey_refuses_a_deed_whose_clientdata_origin_is_not_the_rp_origin`): deleting the `clientDataJSON.origin` check does **not** make the mismatched-origin deed verify — it still fails, now via `rpIdHash`, so the mutation observes `DeedError::BadProof` rather than acceptance. That is what "over-determined" means concretely: no single field is *the* reason cross-origin deeds are refused, so removing one does not reopen the hole. **`user-sig` is unchanged and remains the compatibility path** — it is not "fixed" by `user-passkey` existing, it retains exactly the residual documented above. Pick `user-passkey` when you can require a passkey; pick `user-sig` when you need to accept any wallet a human already has (compatibility). See [Passkey login](#passkey-login-user-passkey) for the full mode. **What this proves — and what it does not.** `user-sig` proves *"I control a key"*, not *"I control a wallet"*. Permissionless login is open by definition: anyone can generate a key. The wallet's role here is **portability of identity across devices**, not gatekeeping. Requiring membership is a separate, gated mode (allowlist / token-gate / DAO) that is not built yet. **There is no on-chain user revocation**, because there is no on-chain user state to revoke — no registration, no commitment, no nullifier. An RP that wants to ban someone bans the `sub` on its own side. That is a property of permissionless login, not a gap. ### What the chain sees The only on-chain read on this path is the tenant's billing status. | Scenario | `sub` | Chain validation | |---|---|---| | Same wallet, 2 audiences, same tenant + origin | **1 shared** — `aud` is not in the key scope, so a role can name the subject | One check: that tenant's status | | Same wallet, 2 origins, same tenant | 2 different, unlinkable | One check: that tenant's status | | Same wallet, 2 apps, different tenants | 2 different, unlinkable | Two independent per-tenant checks | | 1 user or 10M users | — | Identical; the chain never sees them | So user logins cost **zero gas**, leave **zero on-chain footprint**, and scale without touching the chain. Everything here is scoped by the on-chain tenant id rather than by any Grantor infrastructure, so a `sub` never breaks because of how or where an operator runs their own relying-party code. ## Passkey login (`user-passkey`) A human logs in with a **passkey** (WebAuthn) instead of a wallet, and your app gets the same shape of stable, app-scoped pseudonym `user-sig` gives — except origin binding is enforced by the browser's own WebAuthn implementation, not by a human reading a signing prompt. Additive alongside `user-sig`: nothing about wallet login changes, and `user-sig` remains the compatibility path for a caller who only has a wallet. See [Why the seed is origin-scoped](#why-the-seed-is-origin-scoped) above for the residual this closes and why it is closed **for passkey holders specifically**, not retroactively for `user-sig`. ### The ceremony (browser, TypeScript) 1. **Registration, once per `(rpId, human)`.** `registerPasskey({ rpId, userName, userId })` calls `navigator.credentials.create()` with user verification required, extracts the raw P-256 credential public key, and returns `{ credentialId, credentialPubkeyHex }` for your app to persist against that human. 2. **Your app issues a challenge** — the same single-use `challenge` endpoint every deed mode uses (the [deed guard](guide/verify-tokens.md)). 3. **Login.** `signInWithPasskey({ credentialId, credentialPubkeyHex, tenant, aud, origin, challenge, exp })` calls `navigator.credentials.get()`, then assembles a `Deed` from the resulting WebAuthn assertion. ```ts import { registerPasskey, signInWithPasskey } from "@grantor/sdk"; // once, at enrollment — persist credentialId/credentialPubkeyHex against this human const { credentialId, credentialPubkeyHex } = await registerPasskey({ rpId: "app.example.com", userName: "alice", userId: "alice-internal-id", }); // each login const deed = await signInWithPasskey({ credentialId, credentialPubkeyHex, tenant: tenantId, aud: audience, origin, // the origin YOU are actually running on challenge, // from your app's own /challenge exp, }); ``` Your app then verifies `deed` exactly like any other — [Verify a deed](guide/verify-tokens.md) — and mints its own session however it already does. ### The assembler — four languages One pure, cross-language primitive underlies all of this: `mint_user_passkey_deed(credential_pubkey, authenticator_data, client_data_json, assertion_sig, tenant, aud, origin, challenge, exp) -> Deed`, in `grantor-sdk-core`. It does no I/O and touches no live authenticator — it assembles a `Deed` from already-captured WebAuthn assertion bytes, which is what lets Python, Go and Rust mint (and their conformance suites test) a `user-passkey` deed with fixed fixture bytes, with no browser anywhere in the process: ```rust use grantor_sdk_core::userpasskey::{mint_user_passkey_deed, passkey_sub}; // credential_pubkey/authenticator_data/assertion_sig are lowercase hex; // client_data_json is base64url of the EXACT bytes the assertion signed — // re-serializing the JSON would break the signature. let deed = mint_user_passkey_deed( &credential_pubkey_hex, &authenticator_data_hex, &client_data_json_b64url, &assertion_sig_hex, tenant_id, audience, origin, &challenge, exp, )?; // the same pseudonym the verifier recomputes — useful for a holder that // wants to know its own sub before minting let sub = passkey_sub(tenant_id, origin, &credential_pubkey_hex); ``` Python and Go expose the same primitive as `mint_user_passkey_deed`/`MintUserPasskeyDeed` and `passkey_sub`/`PasskeySub`. **TS ships it too** — `mintUserPasskeyDeed`/`passkeySub` are re-exported from `@grantor/sdk`'s actual barrel (`sdk/ts/src/index.js`), the same low-level re-export `mintUserDeed` gets for `user-sig` — so all **four** languages carry the bare assembler, proven byte-identical against the same fixed WebAuthn fixture in `sdk/conformance/vectors.json`'s `userpasskey` block: the "`user-passkey` mint (assembler)" row in `sdk/capability_matrix.py` probes the SAME symbol name (`mintUserPasskeyDeed`/`mint_user_passkey_deed`/`MintUserPasskeyDeed`) in every language, all four or it would not have shipped. `registerPasskey`/`signInWithPasskey` (above) are TS/browser **convenience glue built on top of** that assembler — they own the `navigator.credentials` ceremony and then call `mintUserPasskeyDeed` for you — exactly the relationship `mintUserDeed` (raw) has to `signInWithDeed` (wrapper) for `user-sig`. **Only the live ceremony itself is TS-only, by platform reality rather than a capability gap** — there is no non-browser WebAuthn authenticator for any other language to wrap, the same shape `just capability-matrix` already accepts for `authenticate()`'s Rust exemption. The assembler underneath it is not TS-only; it is four-language, like everything else in this tier. ### `sub` — one pseudonym per `(tenant, origin, credential)` ``` sub = hex(SHA256( "grantor-sovereign-user-passkey-sub-v1|tenant:{tenant}|origin:{origin}|pubkey:{credential_pubkey_hex}" )) ``` Verifier-recomputed from its own `cfg.tenant`/`cfg.origin` and the presented `credential_pubkey` — never trusted off the wire, so a forged `sub` is structurally impossible, exactly like `user-sig`. Unlike `user-sig`, `(tenant, origin)` cannot be folded into *key derivation* (a passkey's private key never leaves the authenticator, so there is no seed a holder-side app could scope per-context) — folding them into the `sub` hash instead recovers the same property at the identifier level: one human = one `sub` per `(tenant, origin)`. `aud` is not in scope either, for the same reason it is not in `user-sig`'s: `aud` restricts where a deed is valid, `sub` identifies who holds it. ### The binding contract — what is cryptographic vs. what is policy WebAuthn only lets your app control the **challenge**; the assertion signs `authenticatorData ‖ SHA256(clientDataJSON)`, and `clientDataJSON` carries only `type`/`challenge`/`origin`/`crossOrigin`. So the binding splits in two: - **`origin` and `challenge` are bound CRYPTOGRAPHICALLY** — `origin` is written by the browser itself into `clientDataJSON` (a hostile page cannot forge it), and both `origin` and `challenge` are covered by the assertion signature. This is what closes the cross-origin residual `user-sig` cannot close on its own (see [Why the seed is origin-scoped](#why-the-seed-is-origin-scoped)). - **`tenant`, `aud`, `exp` are bound by VERIFIER POLICY plus the single-use challenge**, not by anything inside the assertion. Your verifier issued *this* challenge for *its own* `(tenant, aud, origin)` policy and burns it exactly once; it rejects any deed whose `tenant`/`aud` do not match its own config. An attacker cannot retarget a captured assertion to a different verifier (that verifier issued a different challenge — `clientDataJSON.challenge` mismatch) or to a different `aud`/`tenant` at the *same* verifier (policy mismatch) — and `sub` is recomputed from `(tenant, origin, pubkey)`, so a mutated envelope cannot keep a valid `sub` either. This is the same trust model the sovereign challenge already provides for every mode; `user-passkey` leans on it for `tenant`/`aud`/`exp`, where `user-sig` additionally folds those into its signed `user_binding` string. `signCount` is deliberately **ignored** — the single-use challenge is the replay defense; a monotonic-counter check would add authenticator-state assumptions without improving on single-use. ### What the verifier checks `verify_user_passkey` runs as the third arm of `verify_deed_claims`'s mode dispatch, after the four checks shared with `user-sig`/`agent-zk` (mode, aud+tenant, challenge, expiry — see [What the verifier checks](#what-the-verifier-checks) below): 1. **Shape** — a `user-passkey` deed must carry `credential_pubkey`/`authenticator_data`/ `client_data_json`/`assertion_sig` and none of `root`/`proof`/`pubkey`/`signature` (rejected symmetrically the other way too: a `user-sig`/`agent-zk` deed carrying any passkey-only field is `Malformed`). 2. **`clientDataJSON`** — `type == "webauthn.get"`; `challenge` matches the challenge you issued; `origin` matches your own configured origin, checked by the browser rather than a human — this is what closes the cross-origin phishing residual `user-sig` cannot close on its own. 3. **`authenticatorData`** — `rpIdHash` (bytes 0–32) equals `SHA256(host(cfg.origin))`; the flags byte has both UP (user present) and UV (user verification) set. 4. **Assertion signature** — ES256 (P-256) verified over `authenticatorData ‖ SHA256(clientDataJSON)` with `credential_pubkey`. 5. **`sub` recompute** — from `cfg.tenant`, `cfg.origin` and the presented `credential_pubkey`; must match exactly. There is no step 7/8 membership or revocation check here, same as `user-sig`: a permissionless login has no membership root. Step 8's tenant-status (billing) check still applies — `user-passkey` costs your app the same **zero gas, zero on-chain footprint** as `user-sig`. ## Smart-wallet login (`user-1271`) A human logs in with an **EIP-1271 smart-contract wallet** — a Safe, or any other contract that implements `isValidSignature(bytes32,bytes)` — instead of an EOA. Additive alongside `user-sig`/`user-passkey`, not a replacement: pick `user-1271` when your users' wallets are multisig/smart-contract accounts; `user-sig`/`user-passkey` remain the maximally-private options for an EOA or a passkey holder. **Why this is NOT "EIP-1271 on the `user-sig` verify path."** An earlier note (now corrected — see the launch runbook) described this as riding `user-sig`. It cannot: `user-sig`'s wallet signature never reaches the verifier at all. The wallet only signs [`user_root_binding`](#user-login-user-sig) once, HOLDER-SIDE, to derive a `root_seed`; from there a brand-new local ECDSA keypair is derived via HKDF, and THAT local key signs the deed the verifier actually checks — pure local ECDSA math with no chain read. A smart wallet has no recoverable private key to derive a local keypair's seed from in the first place, and there is no reproducible raw signature to hash even if it did (a Safe's approval is aggregated on-chain, not a single deterministic `personal_sign` output a holder could double-sign-and-compare). So `user-1271` is not a variant of `user-sig`'s flow — it is a different verification path entirely, built on the SAME `Erc1271Reader` on-chain gate `admin-sig`'s smart-wallet variant uses (below). ### The pseudonymity tradeoff — read before picking this mode **`user-1271`'s `sub` is linkable to the wallet's on-chain address by anyone who knows that address.** This is a real, deliberate difference from `user-sig`/`user-passkey`: - `user-sig`'s `sub` is derived from a locally-held app-scoped key that never appears on-chain anywhere; `user-passkey`'s `sub` is derived from a credential public key that lives only in an authenticator. Neither is discoverable by anyone who merely knows the human's wallet address — there is no on-chain artifact to correlate against. - `user-1271`'s `sub` is `hex(SHA256("...tenant:{t}|origin:{o}|wallet:{addr}"))` — a deterministic hash of the wallet's PUBLIC, on-chain address. Anyone who already knows that address (which is inherently public — every EIP-1271 wallet is a deployed contract) can recompute the same hash for a guessed `(tenant, origin)` pair and confirm whether "this smart wallet" is "that `sub`". The pseudonym hides nothing from an observer who already has the one input smart-contract wallets cannot avoid publishing: their own address. **Pick `user-1271` for its OTHER property, not privacy: social recovery.** A smart-contract wallet's whole point is that no single stolen owner key is game-over — a Safe with a 2-of-3 threshold survives one compromised signer. `user-sig`/`user-passkey` give you the strongest pseudonymity this tier has; `user-1271` trades that away for wallet-level key-loss/theft resilience. Use whichever tradeoff your users actually need — they are not ranked, they answer different questions. ### The assembler — four languages Pure, cross-language, exactly like `user-passkey`'s: `mint_user_1271_deed(signer_addr, wallet_sig, tenant, aud, origin, challenge, exp) -> Deed` in `grantor-sdk-core::user1271` does no I/O and calls no chain — it assembles a `Deed` from a wallet CONTRACT address and whatever opaque signature bytes that contract's own scheme produced (a Safe threshold signature, an ERC-4337 account's own scheme, …). It does not interpret those bytes at all; only the verifier's on-chain `isValidSignature` call does that. ```rust use grantor_sdk_core::user1271::{mint_user_1271_deed, user1271_sub}; // signer_addr_hex/wallet_sig_hex are whatever your wallet SDK (e.g. Safe's) already // produced for you — obtaining them is app glue, not this SDK's job (see below). let deed = mint_user_1271_deed( &signer_addr_hex, &wallet_sig_hex, tenant_id, audience, origin, &challenge, exp, )?; // the same pseudonym the verifier recomputes let sub = user1271_sub(tenant_id, origin, &signer_addr_hex); ``` Ships as `mintUser1271Deed`/`mint_user_1271_deed`/`MintUser1271Deed`/`mint_user_1271_deed` (TS/Python/Go/Rust) — the "`user-1271` mint" row in `sdk/capability_matrix.py`, re-exported at the TS barrel the same way `mintUserPasskeyDeed` is. ### Obtaining the wallet's signature is app glue, not this SDK's job This SDK never talks to a Safe (or any other smart-contract wallet) directly — producing `wallet_sig_hex` is exactly the same kind of glue `user-sig`'s wallet-signing callback is for an EOA, just one layer further out: a Safe's own SDK (`@safe-global/protocol-kit`) or a library like `wagmi`/`viem`'s `signMessage`/`readContract` helpers gets you a signature the wallet contract will accept for a given hash. What this SDK controls, and pins exactly, is the message a genuine wallet must approve: the SAME `user_binding` string `user-sig` signs (`grantor_sdk_core::usersig::user_binding(tenant, aud, origin, challenge, exp)`), hashed with `eip191_hash` — your app-glue code obtains a signature/approval over that exact hash, then hands the raw bytes to `mint_user_1271_deed`. The verifier rebuilds the identical hash from ITS OWN policy and the challenge IT issued (never from the token) before asking the wallet contract, so an app cannot influence what the wallet is actually asked to approve. ### What the verifier checks `user-1271` cannot be checked by the pure `verify_deed_claims` at all — there is no local cryptography to run, only an on-chain read — so it is handled as a special case inside the gated `verify_deed`, BEFORE the pure function ever sees the token: 0. **Fail-closed origin policy** — `enforce_configured_origin(cfg)` runs first, same as `verify_deed_claims`'s own step 0 and for the same reason: `user-1271` reads `cfg.origin` in its own branch rather than calling `verify_deed_claims`, so without a copy of this check here a misconfigured insecure origin would refuse every other mode but silently keep authenticating `user-1271` logins. See [Fail-closed origin policy](#fail-closed-origin-policy). 1. **Policy match + shape** — `tenant`/`aud`/`challenge`/`exp` checked against your own config exactly like every other mode; `signer` + `signature` required, every other mode's fields (`root`/`proof`/`pubkey`/`credential_pubkey`/`authenticator_data`/ `client_data_json`/`assertion_sig`) forbidden — `Malformed` otherwise, before any chain read. 2. **Rebuild the binding YOU issued** — `user_binding(cfg.tenant, cfg.audience, origin, expected_challenge, token.exp)`, hashed with `eip191_hash` — never read from the token. 3. **Ask the wallet contract** — the ONE chain call this mode needs: `gate.is_valid_signature(signer, hash, signature)`. Fail-closed by contract: an RPC error, a revert, or any non-magic return maps to refusal, never acceptance. 4. **`sub` recompute** — from `cfg.tenant`, the normalized origin, and the presented `signer` address; must match exactly, so a forged `sub` is structurally impossible. 5. **Billing** — `gate.tenant_is_active(cfg.tenant)`, exactly like `user-sig`/`user-passkey`/ `agent-zk`. `user-1271` is a login mode, not an administrative one, so unlike `admin-sig` it stays gated on billing. Proven against a REAL, live-deployed `ERC1271WalletMock` (`crates/grantor-verify/tests/eip1271_e2e.rs`), not only a fake `Erc1271Reader` — including a malicious wallet that always returns a non-magic value, and a real signature from the WRONG owner. ## Dashboard login (`admin-sig`) Grantor's own control-plane dashboard used to be the one account-shaped thing left in the product: a SIWE message exchanged for a session cookie, checked separately against on-chain `is_admin`. It now authenticates the same way it tells you to — with a deed. There is no SIWE code left in the controlplane. `admin-sig` is mechanically SIWE repackaged as a deed: the wallet signs a domain-separated, HUMAN-READABLE message (leading with the requesting `aud`, stating in plain words that signing grants administrative access, and warning against signing it anywhere else — the `personal_sign` equivalent of SIWE's wallet-rendered warning, since a compact string gives a wallet nothing to show a signer) that carries labelled `tenant`/`challenge`/`exp` fields and ends with a `grantor-sovereign-admin-v1` discriminator — distinct from `user-sig`'s `grantor-sovereign-user-v1` prefix, so a signature solicited for one mode can never be replayed as the other. The verifier recovers the signer's address from the signature, and `sub` is that address, recomputed and compared rather than trusted from the wire — forging it is structurally impossible, exactly as `user-sig` recomputes `sub` from a public key. Unlike `user-sig`, `admin-sig` does **not** hide who signed: the whole point is to identify the address that will next be checked against on-chain `is_admin`. Administration is an identified context, not an anonymous one — don't describe this mode as anonymous. **It is not reachable through `verify_deed`.** `agent-zk` and `user-sig` share that call; `admin-sig` has its own entry point, `grantor_verify::sovereign::verify_admin_deed`, which takes no chain-gate parameter at all — there is no handle to call even by mistake. `verify_deed` (and `verify_deed_claims`) reject `mode: "admin-sig"` outright. This is not merely a policy an integrator could opt out of: a normal relying party calling the normal entry point structurally cannot accept an admin deed, because the code path that would accept one doesn't exist there. Do not build a shared "verify any deed" wrapper over both entry points — that would recreate exactly the shared-surface risk this split exists to avoid. **Why it makes no chain call, and must not gain one.** `agent-zk` and `user-sig` both check tenant billing status before accepting the deed. `admin-sig` deliberately checks nothing on-chain — not root recency (there is no membership tree to be recent against), and not tenant status either, even though that would be easy to bolt on. The reason is a lockout: if `verify_admin_deed` refused a lapsed tenant's admin, that admin would be locked out of the one page that lets them pay the bill and reactivate the tenant. Access to pay a bill must never require having paid. If you find yourself adding a billing check here later, stop — you are about to reintroduce that lockout. A successful `admin-sig` verify proves control of a wallet address, nothing more — it **authenticates**, it does not **authorize**. The controlplane still runs its own on-chain `is_admin(tenant, address)` check before granting anything; the deed only replaced the authentication step SIWE used to perform, and touches no authorization logic. ### Smart-wallet admin (Safe / EIP-1271) **This is a library capability an RP can adopt for its own admin surface — it is NOT (yet) what Grantor's own dashboard/controlplane does.** `crates/grantor-controlplane/src/http/deed.rs` still calls only the EOA `verify_admin_deed`, which rejects a `signer`-bearing (smart-wallet) deed as `Malformed`; wiring Grantor's own dashboard to also accept a Safe admin is a follow-up, not shipped. Everything below describes what the mint/verify pair does for whoever adopts it, not a claim about Grantor's own deployment. A tenant's administrator does not have to be an EOA. `mint_admin_deed_smartwallet` (`grantor_sdk_core::adminsig`) is the counterpart to `mint_admin_deed` above for an admin whose wallet is a Safe or any other EIP-1271 contract: it takes the wallet CONTRACT's address and whatever opaque approval bytes that contract's own scheme produced, and mints the SAME `admin-sig` wire mode — `sub` is `admin_sub(signer)`, the identical `0x`-prefixed address format `mint_admin_deed` produces for a recovered EOA, so an adopting RP's own `is_admin(tenant, sub)` authorization check needs no separate code path for a smart-wallet admin. Verified by its own entry point, `verify_admin_deed_smartwallet` (exported as `verifyAdminSmartwalletAt`/`verify_admin_smartwallet_at`/`VerifyAdminSmartwalletAt` on `DeedVerifier` in TS/Python/Go, `crate::sovereign::verify_admin_deed_smartwallet` in Rust) — mirroring the EOA path's isolation exactly: **not reachable through `verify_deed` either**, and it makes the SAME deliberate choice to skip a tenant-status check, for the same lockout reason (access to pay a lapsed tenant's bill must never itself require having paid). The one structural difference from the EOA entry point: this one DOES take a chain reader parameter (`&impl Erc1271Reader`) — there is no recoverable address to check locally, so asking the wallet contract's own `isValidSignature` is the entire check. Like `verify_admin_deed`, this **authenticates only**; the calling RP still runs its own `is_admin(tenant, sub)` check before granting anything — for Grantor's own dashboard, that would be the controlplane, but see the note above: it does not call this entry point yet. ## What the verifier checks This section covers the pure `verify_deed_claims` — `agent-zk`, `user-sig` and `user-passkey`. **`user-1271` is deliberately NOT covered here** — it has no local cryptography to check at all, so `verify_deed` (the gated wrapper around `verify_deed_claims`) special-cases it and runs its own, entirely on-chain checklist BEFORE ever calling this pure function; see [Smart-wallet login § What the verifier checks](#smart-wallet-login-user-1271) above. `admin-sig` (both the EOA and the EIP-1271-smart-wallet variant) has its own, shorter checklists in [Dashboard login](#dashboard-login-admin-sig) above; neither is reachable through `verify_deed` at all. `verify_deed(&deed, &policy, &expected_challenge, now, &gate)` runs, in order. Step 0 is a fact about the verifier's OWN configuration, checked before the presented deed is inspected at all; steps 1–4 are shared across modes; step 5 dispatches on mode. 0. **Fail-closed origin policy** — `cfg.origin` must be HTTPS and routable, or `cfg.allow_insecure_origin` must be set. Checked FIRST, before the mode check, before the deed's own origin comparison (steps 2 and 6 below), and before either chain read (steps 7–8) — a misconfigured deployment refuses every token the same way, at zero RPC cost, rather than failing unpredictably partway through. See [Fail-closed origin policy](#fail-closed-origin-policy). 1. **Token version + mode** — `token.v`/`token.mode` must be one this verifier supports (`MODE_AGENT_ZK`, `MODE_USER_SIG` or `MODE_USER_PASSKEY`). 2. **Audience + tenant match** — bound against your `DeedPolicy`, never read back from the token. 3. **Challenge match** — the exact challenge you issued (caller-supplied `expected_challenge`, never trusted from the token alone). 4. **Expiry / TTL ceiling** — `exp` is in the future and within `max_ttl_secs` of now. 5. **Per-mode shape + cryptography.** Each mode requires exactly its own fields: `agent-zk` must carry `root`+`proof` and no `pubkey`/`signature`; `user-sig` must carry `pubkey`+`signature` and no `root`/`proof`; `user-passkey` must carry `credential_pubkey`+`authenticator_data`+ `client_data_json`+`assertion_sig` and none of the other three modes' fields. Field exclusivity is symmetric across all modes — a wrong-shape token is `Malformed`, never quietly accepted. - For **`user-sig`**: `sub` is **recomputed** from the presented public key and must match exactly — so a forged `sub` is structurally impossible, not merely rejected — and the ECDSA signature is checked over a binding rebuilt from *your* config (including `origin` — see [Origin binding](#origin-binding)) and the challenge *you* issued. A tampered `aud`/`origin`/`exp`/`tenant` therefore yields a binding the signature was never made over. There is no step 7 for this mode: a permissionless login has no membership root to check. - For **`user-passkey`**: full checklist in [Passkey login § What the verifier checks](#passkey-login-user-passkey) above — clientDataJSON type/challenge/origin, authenticatorData rpIdHash + UP/UV flags, ES256 assertion signature, then `sub` recomputed from `cfg.tenant`/`cfg.origin`/the presented `credential_pubkey`. Also no step 7: permissionless, no membership root. - For **`agent-zk`**: parse root / sub / proof (malformed fields fail before any cryptography), then: 6. **ZK proof validity** (`agent-zk`) — the Semaphore proof cryptographically verifies against the token's own claimed `root`/`sub` and the binding hashes recomputed from your `DeedPolicy` + `expected_challenge`, at the pinned tree depth (`depth_20`). This is purely local math against values already in hand — it does NOT touch the tenant's on-chain registry; that's steps 7-8, next. Last of the pure claim checks. 7. **On-chain root recency = revocation** — `gate.root_is_recent(tenant, root)`. A revoked agent's old root ages out of the registry's bounded recent-root window, so a stale proof stops verifying with no issuer needed to blocklist anything. 8. **On-chain tenant status = billing** — `gate.tenant_is_active(tenant)`. Accepts `Active` OR `Grace` — the exact gate the issuer itself enforces before minting (`GrantorRegistry.isActive`), so a tenant coasting through its billing grace window keeps its agents authenticating here uninterrupted. Only once a tenant falls all the way to `Inactive` (no funded/drawn period, or grace expired) do its agents' otherwise-valid proofs fail here. This is the tier's entire revenue enforcement — no server-side metering required. Ordering is load-bearing: a misconfigured `cfg.origin` fails at step 0 before the deed is even looked at; a token that's the wrong version, wrong audience/tenant, wrong/replayed challenge, expired, unparseable, or carrying a bad ZK proof fails at step 1-6 and costs **zero** RPC calls, because both chain reads (7-8) come last. Both chain reads are live RPC through `AlloyGate`; `CachedGate` only bounds how often you re-hit your RPC provider, not what gets enforced. ## Minting your session — `session_jwt` (optional) Once `verify_deed` returns claims, minting your own session however you already do stays the first-class path — nothing here changes that, and your OIDC stack carries on unchanged either way. If you would rather not hand-roll a JWT, the same SDK can mint one: a standard ES256 token, signed with **your own key** (never one Grantor holds or sees), decodable by any JOSE library on the checking side. RFC 6979 deterministic ECDSA means the same inputs mint byte-identical tokens across all four languages, pinned by a conformance vector plus a tamper test. It is an RP-side convenience layered on top of verification, not a session dependency — sugar, not infrastructure. Ships as `sessionJwt` (TypeScript), `session_jwt` (Python), `SessionJwt` (Go) and `session_jwt` (Rust, `grantor_verify::session::session_jwt`): the "session-JWT convenience minting (RP-side)" row in `sdk/capability_matrix.py`, all four languages or it would not have shipped. See [Verify a deed § Or one call](guide/verify-tokens.md#or-one-call) for the excerpt, transcribed from the runnable MCP reference. ## Operating it: rate-limit the challenge endpoint The one piece of operational hygiene this tier asks of you, because it is the one thing the library cannot do for itself. The challenge endpoint is public and unauthenticated by design — an agent must be able to obtain a challenge *before* it holds any credential. So the RP owns the flood defence: a per-IP or per-subnet limit in whatever middleware you already run. `MemoryChallengeStore` sweeps expired entries on every issue, so it is bounded by challenges issued within one TTL window — but that bound is your request rate times the TTL, and both are the caller's to move. Shortening the TTL tightens it proportionally and costs nothing: it only has to outlast discover → challenge → mint, which is seconds. **Why the library doesn't just cap the map**, when it does exactly that for the two `(tenant, root)` caches: eviction there is safe by construction — a cache is an optimisation over an `eth_call`, so dropping an entry costs a chain read and never a wrong answer. A challenge is not a cache. It is one half of a live login, and evicting it fails a legitimate in-flight authentication with `ChallengeMismatch` — indistinguishable to the holder from an attack, and triggered by the *attacker's* traffic rather than their own. A cap would trade a memory-pressure problem for an availability one and hand the attacker a cheaper lever than the one they started with. Nothing here is a billing or authentication bypass: a challenge is single-use and TTL-bound whatever the volume, and every deed still faces the same checks. ## Paying, and unpaying Billing is prepay-then-draw, and the money is custodial to nobody: - `topUp(id, amount)` moves USDC from you into the **registry contract** and credits your tenant's balance. It does not pay the operator. - `drawPeriod(id)` debits one period's fee from that balance and transfers it to an **immutable** `treasury` address, fixed at deployment and unchangeable afterwards. - `withdrawBalance(id, amount, to)` returns any **undrawn** balance to an address a tenant admin picks. Only periods you actually consumed are non-refundable. The registry has no owner function that can move tenant balances — no withdraw, sweep or rescue — and `invariant_usdc_equals_sum_of_balances` asserts the contract's USDC holdings always equal the sum of tenant balances. ## Why "deed"? A deed is an **instrument**: you prove entitlement by presenting it, and its force comes from a public register of record rather than from asking an authority in real time. The office that keeps such a register is called the Recorder of Deeds. That is this design line for line — a public on-chain registry, and a credential that proves entitlement against it with no server in the path. Law also has a precise term for a document carrying its own proof of execution, so no witness need appear to authenticate it: a **self-proving instrument**. That is exactly what this is. ### But nobody grants it to you — the holder mints it True, and worth answering directly. The tenant's on-chain registration **is** the conveyance; the deed is the instrument proving that record. The holder mints the instrument, and it is the registry that makes it mean anything. A deed with no corresponding record is refused, which is what the `NotAMember` and `TenantInactive` outcomes are. `deed` is deliberately a generic, lowercase, untrademarked noun — the same choice FIDO made with *passkey*. **Grantor** is the brand; a deed is the thing. ## Running the suite ```bash just test-sovereign # cargo test -p grantor-verify --features sovereign-chain ``` This spins up a local `anvil` node, deploys the registry, and runs the full happy-path + negative-path (revoked agent, unpaid tenant, replayed challenge) e2e — no Postgres, no issuer, nothing but the chain. --- # Verify a deed Install the guard, issue a challenge, verify. No server is involved anywhere in this path. ## Verifying a deed The **deed guard** is the piece you install: it owns the anti-replay challenge, the one stateful gap the verifier alone cannot cover. It ships in TypeScript, Python, Go and Rust, with thin adapters for Express, FastAPI and `net/http` (Rust has none — no single dominant framework to adapt to). Two endpoints and you are done: ``` GET /challenge → { "challenge": "…" } the guard mints and remembers it GET /anything ← X-Grantor-Deed: X-Grantor-Challenge: ``` Three properties are worth knowing, because they are the difference between the guard and a hand-rolled check: - **The challenge travels in its own header** and is never read out of the deed. Taking it from the token would let the caller pick their own nonce, which is the entire replay defence gone. - **The challenge is burned before verification**, not after. Otherwise a flood of bogus deeds becomes a free probe for which challenges are live. The cost is that a legitimate client whose own deed was malformed must fetch a new challenge, which is the right trade. - **The guard mints no session and sets no cookie.** Your session format, expiry and flags are yours; they are not ours to choose. ### Or one call Minting your own session JWT the way the bullet above describes stays the first-class path — nothing below changes it, and your OIDC stack carries on unchanged either way. If you would rather not hand-roll one, the same SDK that verified the deed can mint a standard ES256 session JWT too, signed with **your own key**, decodable by any JOSE library on the checking side. It is sugar layered on top of verification, not a dependency of it. Excerpt from `examples/mcp-server/server.mjs`'s `/auth/token` handler, right after `g.guard.verify` has returned `claims`: ```js import { DeedVerifier, sessionJwt } from "../../sdk/verify/ts/pkg/grantor_verify_wasm.js"; ``` ```js const sessionToken = sessionJwt( claims.sub, AUDIENCE, BigInt(TENANT_ID), SESSION_SIGNING_KEY_PEM, BigInt(Math.floor(Date.now() / 1000)), BigInt(SESSION_JWT_TTL_SECS), { iss: ORIGIN, kid: SESSION_KID }, ); ``` Two artifacts, two honest jobs — the example's own comment puts it exactly this way: the deed authenticated the caller; `sessionToken` is the "take this into the rest of YOUR stack" artifact, verifiable against `SESSION_PUBLIC_JWK` (or a published `jwks_uri`-style endpoint) by anything holding the public key, with no Grantor SDK involved on the checking side. See [MCP server auth § Deed → local bearer](mcp-server.md#2-deed-local-bearer-post-authtoken) for the full route this excerpt comes from. Two things to know if you verify the minted session JWT with your own JOSE stack rather than ours: pin the algorithm to ES256 (e.g. jose's `importJWK(jwk, "ES256")` / `algorithms: ["ES256"]`) so alg-confusion is structurally off the table, and there is no `jti` claim — signing is deterministic, so identical claims minted in the same second yield a byte-identical token, and using the raw token string as a unique session id will collide. Ships as `sessionJwt` (TypeScript), `session_jwt` (Python), `SessionJwt` (Go) and `session_jwt` (Rust, `grantor_verify::session::session_jwt`) — the "session-JWT convenience minting (RP-side)" row in `sdk/capability_matrix.py`, proven against the shared conformance vectors. ### Error codes The same strings in every language, so your handling ports. Full reference, including the operator-facing origin-vouch self-check codes: [Errors](errors.md). | Code | Meaning | HTTP | |---|---|---| | `MissingDeed` / `MissingChallenge` | the caller sent neither header | 401 | | `UnknownChallenge` | unissued, expired, or already spent | 401 | | `BadDeedEncoding` | not base64url JSON | 401 | | `UnsupportedMode` | not a mode this verifier accepts | 401 | | `WrongAudience` | minted for a different app | 401 | | `ChallengeMismatch` | bound to a different challenge | 401 | | `Expired` | past its own `exp` | 401 | | `Malformed` | wrong fields for its claimed mode | 401 | | `BadProof` | signature or ZK proof failed | 401 | | `StaleRoot` | membership root too old — possible revocation | 401 | | `TenantInactive` | **billing** — the tenant has lapsed | 401 | | `Chain` | the RPC read failed | **503** | `Chain` is 503 rather than 401 deliberately. An RPC outage is not the caller's fault, and answering 401 sends clients into a login loop that discards good credentials and cannot succeed. The verifier **fails closed**: if the chain cannot be read, no deed is accepted. That is what makes the on-chain billing check load-bearing rather than advisory. ### The default in-memory challenge store Correct for one process and wrong the moment you run two — a challenge issued by one instance is not found by the other, so every other login fails. Put a shared store (Redis, your database) behind the `ChallengeStore` interface before scaling out. ### Scaling out: a shared challenge store Replay defence is only as strong as the store backing it. `MemoryChallengeStore` is correct for exactly one process; the moment a second instance joins, a challenge issued by one is invisible to the other and that login fails — not a security hole, but an availability one that looks like a broken deploy the first time you scale horizontally. `RedisChallengeStore` is the multi-process complement, shipped in all four languages (`sdk/capability_matrix.py`'s "shared challenge store" row). It implements the same two operations as the in-memory store, just against a shared backend: `issue` is `SET key "1" PX ttl`, `burn` is an atomic `DEL key == 1`. The delete being one atomic command is what makes single-use hold across processes — two instances racing to burn the same challenge cannot both win, because only one `DEL` can return 1. Expiry lives in the backend (the `PX` TTL); there is no sweep to run yourself. **Redis is a choice, not a dependency.** No SDK imports a Redis client — the client is injected by you, in every language, so choosing `RedisChallengeStore` never adds a package your project didn't already ask for. The in-memory store stays the default in all four languages; nothing about this capability changes what you get if you construct a `DeedGuard` with no store argument. Fail-closed semantics differ slightly by language, and it's worth stating honestly rather than papering over: a backend outage must never let a login through, but what error comes back varies. - **Go** reports the backend error as `Chain`/503 — an outage is not the caller's fault, so it gets the same "the network failed" status as an RPC outage, not "you are not authenticated." - **TypeScript and Python** propagate the client's own exception, which surfaces as a 5xx from whatever throws it — there's no `ChallengeStore` error type to translate into, so the failure is whatever your Redis client raises. - **Rust**'s `ChallengeKv` trait is infallible by design (so adding it isn't a breaking change to `ChallengeStore`), so a backend error is swallowed at the store and surfaces one layer up as `UnknownChallenge`/401 — the wrong error *class* (401 instead of 503) but the same safe *direction*: denial, never acceptance. If that distinction matters to your ops tooling, monitor the backend directly rather than inferring its health from the verifier's HTTP status. One construction snippet per language, client already connected: **TypeScript** (node-redis v4 — `ioredis` needs a 3-line wrapper mapping `set(k, v, "PX", ms)`/`del(k)` onto the same shape): ```js import { createClient } from "redis"; import { RedisChallengeStore } from "../../sdk/verify/ts/src/guard.js"; const client = createClient({ url: process.env.REDIS_URL }); await client.connect(); const store = new RedisChallengeStore({ client }); ``` **Python** (`redis.asyncio`): ```python import redis.asyncio as redis from grantor_verify.guard import RedisChallengeStore client = redis.Redis.from_url("redis://localhost:6379") store = RedisChallengeStore(client) ``` **Go** (an ~8-line wrapper over `go-redis` implementing `ChallengeKV`): ```go type goRedisKV struct{ c *redis.Client } func (k goRedisKV) SetPX(ctx context.Context, key, value string, ttl time.Duration) error { return k.c.Set(ctx, key, value, ttl).Err() } func (k goRedisKV) Del(ctx context.Context, key string) (int64, error) { return k.c.Del(ctx, key).Result() } kv := goRedisKV{c: redis.NewClient(&redis.Options{Addr: "localhost:6379"})} g, err := guard.New(v, guard.NewRedisChallengeStore(kv, "", 0)) ``` **Rust** (`ChallengeKv` implemented over a `redis-rs` sync connection behind a `Mutex`, since the trait is sync — and while there's no bundled adapter, wiring the guard into axum, actix or poem is a handful of idiomatic lines either way): ```rust use grantor_verify::guard::{ChallengeKv, DeedGuard, KvChallengeStore}; struct RedisKv(std::sync::Mutex); impl ChallengeKv for RedisKv { fn set_px(&self, key: &str, ttl_ms: u64) -> Result<(), String> { redis::cmd("SET") .arg(key).arg("1").arg("PX").arg(ttl_ms) .query::<()>(&mut *self.0.lock().unwrap()) .map_err(|e| e.to_string()) } fn del(&self, key: &str) -> Result { redis::cmd("DEL") .arg(key) .query::(&mut *self.0.lock().unwrap()) .map_err(|e| e.to_string()) } } let conn = redis::Client::open("redis://127.0.0.1/")?.get_connection()?; let kv = RedisKv(std::sync::Mutex::new(conn)); // policy/gate/vouch: however your app already constructs them (see the // Rust composition example in [Sovereign tier](../sovereign-tier.md) for // `gate`) — unchanged by adding a shared store; only the store argument // is new. let guard = DeedGuard::with_origin_vouch(policy, gate, KvChallengeStore::new(kv), vouch); ``` ## See also - [Getting started](getting-started.md) — the deed path, end to end. - [Wallet login](wallet-login.md) and [Agent tokens](agent-tokens.md) — how these deeds get minted in the first place. - [Sovereign tier](../sovereign-tier.md) — discovery, origin binding, origin provenance, and the exact order `verify_deed` checks things in. - [Errors](errors.md) — the full error reference. - [`../llms-full.txt`](../llms-full.txt) — the entire product in one file. --- # Wallet login (`user-sig`) A human signs in with their wallet and your app receives a stable, app-scoped **pseudonym** — not their address. There is no browser redirect, no issuer, and no session-granting server in between: the wallet signs, the SDK mints a **deed**, and your app verifies it directly against the on-chain registry. If you haven't already, read [Concepts](concepts.md) for the mental model. ## TypeScript: `signInWithDeed` `@grantor/sdk` exports `signInWithDeed`, which composes the wallet-signing steps ([The flow](#the-flow), below) and the origin-provenance check into one call: ```ts import { signInWithDeed } from "@grantor/sdk"; const deed = await signInWithDeed({ signMessage, // wraps the wallet's personal_sign tenantId, audience, origin, // the origin YOU are actually running on challenge, // from your app's own /challenge exp, vouch, // the RP's published origin_vouch chainId, // registry defaults to Registry.canonical(), resolved against this chain id chainReader, }); ``` `registry` defaults to `Registry.canonical()` — the SDK pins it for you; pass `registry: Registry.devnet(addr)` for local development or `Registry.dedicated(addr, license)` for a [licensed enterprise registry](enterprise-registry.md). The old `chainRegistry` option (a plain address) is deleted — passing it throws a `TypeError` naming the replacement, never a silent fall-through to the canonical default. Python, Go and Rust expose the same steps (`deriveRootSeed`/`deriveAppKey`/ `mintUserDeed`, in their language-specific casing) as separate calls rather than one wrapper. The Rust snippet in [Sovereign tier § User login](../sovereign-tier.md#user-login-user-sig) is the source of truth; `sdk/README.md` maps the equivalent calls per language. ## The flow 1. **Your app issues a challenge** — `GET /challenge` on your own [deed guard](verify-tokens.md). 2. **The wallet signs.** The SDK has the wallet sign a human-readable message naming the origin (twice, compared for determinism) to derive a `root_seed`, then derives a per-`(tenant, origin)` app key from it and signs the deed with that key. One wallet popup per origin, not one for the whole web. 3. **Your app verifies** the deed with `DeedGuard`/`DeedVerifier` and mints its own session, however it already does. See [Verify a deed](verify-tokens.md). The full walkthrough — the exact derivation (`derive_root_seed` → `derive_app_key` → `mint_user_deed`), what is and is not in the key-derivation scope (`tenant` + `origin`, deliberately not `aud`), and what a `sub` looks like across audiences/origins/tenants — is documented once, in [Sovereign tier § User login (`user-sig`)](../sovereign-tier.md#user-login-user-sig). Read that rather than a second copy here. ## Any wallet, any chain `user-sig` is wallet-**chain**-agnostic: the wallet only ever signs the origin-binding message (`user_root_binding(origin)`), and the verifier never sees which chain the wallet belongs to — only the secp256k1 app key `deriveAppKey` derives from whatever signature comes back. An EVM wallet's `personal_sign`, or a Solana / Aptos / Sui / Near wallet's `signMessage` — any wallet that can produce a signature over that one message — mints the same shape of deed and yields the same stable `(tenant, origin)` pseudonym `user-sig` gives every other wallet. ```rust use grantor_sdk_core::usersig::{derive_root_seed, derive_app_key, mint_user_deed}; // A non-EVM wallet's `signMessage` in place of `personal_sign` — the SDK // does not care which. `derive_root_seed` signs the message TWICE and // compares the results, so the closure just needs to return the wallet's // raw signature bytes; nothing here assumes an EVM signature's shape. let seed = derive_root_seed(origin, |msg| wallet.sign_message(msg))?; let app_key = derive_app_key(&seed, tenant_id, origin)?; let token = mint_user_deed(&app_key, tenant_id, audience, origin, &challenge, exp)?; ``` This is the same [flow](#the-flow) and the same mint API the EVM path uses — there is no separate non-EVM entry point. Pass the wallet's `signMessage` output where the EVM snippet passes `personal_sign`'s, and everything downstream (`deriveAppKey`, `mintUserDeed`, verification) runs unchanged. TypeScript, Python and Go expose the same `deriveRootSeed`/`deriveAppKey`/ `mintUserDeed` calls, in their own casing. ### Determinism is required The wallet must sign **deterministically** — the same message must always produce the same signature bytes. EVM `personal_sign` already is (RFC 6979); a non-EVM wallet must use RFC 8032 **pure** ed25519 signing, not a randomized ("hedged") variant. `deriveRootSeed` signs the binding message twice and compares the results before deriving anything from it, so a non-deterministic wallet is refused loudly — `SdkError::Crypto("wallet signing is non-deterministic; unsupported")` (or the equivalent typed error in your language) — rather than silently minting a new pseudonym on every login. ## Origin provenance: required before signing `signInWithDeed` (and `authenticate()`, the equivalent agent-side helper) refuses to sign **before** it signs anything — not after — if the origin cannot show a tenant admin's on-chain vouch that it speaks for `(tenant, audience)`. This is what stops a hostile origin that republishes a victim tenant's `tenant`/`audience` from harvesting a pseudonym by simply asking a holder to sign in. `chainReader` is REQUIRED — omitting it is a fail-closed error, not a skipped check. See [Sovereign tier § Origin provenance](../sovereign-tier.md#origin-provenance) and [§ Constructing a `chainReader`](../sovereign-tier.md#constructing-a-chainreader). ## What this proves — and what it does not `user-sig` proves *"I control a key"*, not *"I control a wallet"*. Permissionless login is open by definition — anyone can generate a key. The wallet's role is **portability of identity across devices**, not gatekeeping. There is no on-chain user revocation, because there is no on-chain user state to revoke; an RP that wants to ban someone bans the `sub` on its own side. `personal_sign` also means the wallet cannot tell who is asking: a hostile page can request the *victim's* origin-scoped message, and the only control left is a human reading a prompt that names an origin they are not on. See [Sovereign tier § Why the seed is origin-scoped](../sovereign-tier.md#why-the-seed-is-origin-scoped) for the full residual-risk statement — do not describe this mode as a hard phishing-resistance guarantee. If you can require a passkey instead of a wallet, [Passkey login](user-passkey.md) (`user-passkey`) closes exactly this residual via browser-enforced origin binding. It is additive, not a replacement — this page and `user-sig` remain the compatibility path for a caller who only has a wallet. ## See also - [Verify a deed](verify-tokens.md) — the relying-party side. - [Agent tokens](agent-tokens.md) — the `agent-zk` deed, for enrolled agents. - [Sovereign tier](../sovereign-tier.md) — the full reference. - [Errors](errors.md) — every error code a relying party branches on. --- # Passkey login (`user-passkey`) A human signs in with a **passkey** (WebAuthn) instead of a wallet, and your app gets the same shape of stable, app-scoped **pseudonym** `user-sig` gives — not their identity. The browser's own authenticator signs, the SDK mints a **deed**, and your app verifies it directly against the on-chain registry, no issuer or server in between — see [Concepts](concepts.md). `user-passkey` is **additive alongside [`user-sig`](wallet-login.md)** — pick it when you can require a passkey; keep `user-sig` for a caller who only has a wallet. ## TypeScript: `registerPasskey` / `signInWithPasskey` `@grantor/sdk` exports both ceremony functions: ```ts import { registerPasskey, signInWithPasskey } from "@grantor/sdk"; // once, at enrollment — persist credentialId/credentialPubkeyHex against this human const { credentialId, credentialPubkeyHex } = await registerPasskey({ rpId: "app.example.com", userName: "alice", userId: "alice-internal-id", }); // each login const deed = await signInWithPasskey({ credentialId, credentialPubkeyHex, tenant: tenantId, aud: audience, origin, // the origin YOU are actually running on challenge, // from your app's own /challenge exp, }); ``` `signInWithPasskey` above is convenience glue built on top of one pure, cross-language **assembler**, `mintUserPasskeyDeed`/`mint_user_passkey_deed`/`MintUserPasskeyDeed`/ `mint_user_passkey_deed` (TS/Python/Go/Rust) — pure bytes-in-deed-out, no live authenticator involved, which is what lets a server-side language mint (and test) a `user-passkey` deed from already-captured assertion bytes. It ships in **all four** languages, TypeScript included: `@grantor/sdk` re-exports `mintUserPasskeyDeed` at the top level, the same low-level re-export `mintUserDeed` gets for `user-sig`. Only the **live browser ceremony** (`registerPasskey`/`signInWithPasskey`) is TS-only, by platform reality: there is no non-browser WebAuthn authenticator for another language to wrap. The Rust snippet in [Sovereign tier § Passkey login § The assembler — four languages](../sovereign-tier.md#passkey-login-user-passkey) is the source of truth for the non-browser call shape. ## The flow 1. **Registration, once per `(rpId, human)`.** `registerPasskey()` creates the passkey and returns the credential id and public key for your app to persist against that human. 2. **Your app issues a challenge** — `GET /challenge` on your own [deed guard](verify-tokens.md), the same single-use challenge every deed mode uses. 3. **Login.** `signInWithPasskey()` gets a WebAuthn assertion and assembles it into a `Deed`. 4. **Your app verifies** the deed with `DeedGuard`/`DeedVerifier` and mints its own session, however it already does. See [Verify a deed](verify-tokens.md). ## Why this mode exists `user-sig` scopes a holder's key per origin, but the control that a `personal_sign` was produced *at* the claimed origin is a **human reading a signing prompt** — a hostile page can request the *victim's* origin-scoped message, and the wallet cannot tell who is asking. `user-passkey` replaces that human-read prompt with **browser-enforced, cryptographically signed origin binding**: WebAuthn writes the origin into the assertion itself, twice, independently, and a hostile page cannot make either field lie about the page it is really running on. See [Sovereign tier § Why the seed is origin-scoped](../sovereign-tier.md#why-the-seed-is-origin-scoped) for the full before/after — `user-sig` itself is **not** retroactively fixed by this mode existing; it keeps its documented residual. ## The binding-model caveat — read before relying on this WebAuthn only lets your app control the **challenge**; the assertion signs `authenticatorData ‖ SHA256(clientDataJSON)`, which carries only `type`/`challenge`/`origin`/`crossOrigin`. So: - **`origin` and `challenge` are bound cryptographically** — the browser itself writes `origin`, and both are covered by the assertion signature, checked by the browser rather than a human reading a prompt. - **`tenant`, `aud`, `exp` are bound by your verifier's policy plus the single-use challenge**, not by the assertion itself. Your verifier issued *this* challenge for *its own* `(tenant, aud, origin)` and burns it exactly once, so a captured assertion cannot be retargeted to a different verifier, tenant, or audience. Full detail, including the WebAuthn verification steps your app's verifier runs, in [Sovereign tier § Passkey login (`user-passkey`)](../sovereign-tier.md#passkey-login-user-passkey). ## What this proves — and what it does not Like `user-sig`, `user-passkey` proves *"I control this credential"*, not membership — permissionless login is open by definition; anyone can register a passkey. There is no on-chain user revocation, because there is no on-chain user state to revoke; an RP that wants to ban someone bans the `sub` on its own side. Requiring membership is a separate, gated mode (allowlist / token-gate / DAO) that is not built yet. A human who used `user-sig` and later registers a `user-passkey` is a **different subject** (different key material) — no automatic linking, the same as using two different wallets. ## See also - [Wallet login](wallet-login.md) — `user-sig`, the compatibility path for any wallet. - [Verify a deed](verify-tokens.md) — the relying-party side. - [Agent tokens](agent-tokens.md) — the `agent-zk` deed, for enrolled agents. - [Sovereign tier § Passkey login](../sovereign-tier.md#passkey-login-user-passkey) — the full reference. - [Errors](errors.md) — every error code a relying party branches on. --- # Smart-wallet login & admin (`user-1271` + EIP-1271 `admin-sig`) Two additive capabilities for callers whose wallet is a **smart contract** (a Safe, or any [EIP-1271](https://eips.ethereum.org/EIPS/eip-1271) wallet) rather than an EOA: **`user-1271`** lets a human log in with a smart-contract wallet, the same way `user-sig`/`user-passkey` do; **smart-wallet `admin-sig`** lets a Safe mint and be verified as an `admin-sig` deed holder too (Grantor's own dashboard doesn't accept one yet — see below). Both ride the SAME `Erc1271Reader.isValidSignature` gate rather than `ecrecover`. See [Concepts](concepts.md) and [Wallet login](wallet-login.md) for the EOA baseline these modes extend. ## User login: `user-1271` The assembler is pure, cross-language, does no I/O — it assembles a `Deed` from an address and already-obtained signature bytes: ```ts import { mintUser1271Deed } from "@grantor/sdk"; // signerAddrHex / walletSigHex: whatever your Safe/wagmi glue produced for // the exact hash your app's verifier will independently rebuild (see above). const deed = mintUser1271Deed( signerAddrHex, walletSigHex, tenantId, audience, origin, // the origin YOU are actually running on challenge, // from your app's own /challenge exp, ); ``` Ships as `mintUser1271Deed`/`mint_user_1271_deed`/`MintUser1271Deed`/ `mint_user_1271_deed` (TS/Python/Go/Rust) — the "`user-1271` mint" row in `sdk/capability_matrix.py`. Your app verifies the resulting deed exactly like any other, through the SAME `DeedGuard`/`DeedVerifier.verifyAt` call every other mode uses — `user-1271` needs no special handling on the verify side beyond having a chain reader wired in (which every sovereign verifier already has). See [Verify a deed](verify-tokens.md). ## Why this is not "EIP-1271 on the `user-sig` path" An early note described this work as landing "on the `user-sig` verify path." That was wrong, and worth being explicit about because the reason is structural, not a wording nit: `user-sig`'s wallet signature never reaches the verifier at all. The wallet signs `user_root_binding` **once, holder side**, to derive a `root_seed`; a brand-new local ECDSA keypair is then derived from that seed via HKDF, and *that* local key signs the deed the verifier checks — pure local math, no chain read, no wallet signature in sight. A smart-contract wallet has no recoverable private key to seed a local keypair from, and there is no single reproducible signature to hash even if it did (a Safe's approval is aggregated on-chain, not a deterministic `personal_sign` output you could sign twice and compare). So `user-1271` and smart-wallet `admin-sig` are their own verification paths, built on the on-chain `Erc1271Reader` gate — not a variant of `user-sig`. Full detail: [Sovereign tier § Smart-wallet login](../sovereign-tier.md#smart-wallet-login-user-1271). ## Obtaining the wallet's signature is app glue Neither this SDK nor Grantor talks to a Safe (or any other smart-contract wallet) directly. Producing the opaque `wallet_sig_hex` bytes a wallet contract will accept for a given hash is exactly the same kind of glue `user-sig`'s wallet-signing callback is for an EOA, one layer further out — a Safe's own SDK (`@safe-global/protocol-kit`) or `wagmi`/`viem`'s `signMessage`/`readContract` helpers get you there. What this SDK controls and pins exactly is **the message** a genuine wallet must approve: for `user-1271` that is the identical `user_binding` string `user-sig` signs (`grantor_sdk_core::usersig::user_binding(tenant, aud, origin, challenge, exp)`), EIP-191-hashed; for smart-wallet `admin-sig` it is the identical human-readable `admin_binding` prose the EOA path signs. The verifier rebuilds the same hash from *its own* policy and the challenge *it* issued — never from anything in the token — before ever asking the wallet contract, so your app-glue code cannot influence what the wallet is actually asked to approve. ### Read this before adopting `user-1271`: it is not the private option **Unlike `user-sig`/`user-passkey`, a `user-1271` `sub` is linkable to the wallet's on-chain address by anyone who already knows that address.** `user-sig`'s `sub` comes from a locally-derived key that never touches the chain; `user-passkey`'s comes from a credential public key that lives only in an authenticator. `user-1271`'s `sub` is a deterministic hash of the wallet's address — and every EIP-1271 wallet's address is inherently public (it's a deployed contract), so anyone holding that address can recompute the same hash for a guessed `(tenant, origin)` and confirm the link. Pick `user-1271` for what a smart-contract wallet actually buys you — **social recovery**: a Safe on a 2-of-3 threshold survives one compromised signer, so a single stolen owner key is not game-over — not for privacy. `user-sig`/`user-passkey` remain the maximally-private options; use them when your users hold EOAs or passkeys and privacy matters more than multisig resilience. Full writeup: [Sovereign tier § The pseudonymity tradeoff](../sovereign-tier.md#the-pseudonymity-tradeoff--read-before-picking-this-mode). ## Smart-wallet admin: `admin-sig` with a Safe `admin-sig` now has a smart-wallet variant (see [Sovereign tier § Dashboard login](../sovereign-tier.md#dashboard-login-admin-sig)) for an admin whose wallet is a Safe or any other EIP-1271 contract, minted the same shape as the EOA path: > **This does not (yet) apply to Grantor's own dashboard.** The variant > below is a library capability any relying party can adopt — mint + > verify, both four-language, both proven end to end against a live > `ERC1271WalletMock`. Grantor's own control-plane dashboard handler > (`crates/grantor-controlplane/src/http/deed.rs`) still calls the EOA-only > `verify_admin_deed`; wiring it to also accept a Safe admin is a follow-up, > not part of this work. ```python # signer_addr_hex / wallet_sig_hex: your Safe glue's output over the exact # admin_binding(tenant, aud, challenge, exp) hash the verifier rebuilds. deed = mint_admin_deed_smartwallet( signer_addr_hex, wallet_sig_hex, tenant_id, audience, challenge, exp, ) ``` Ships as `mintAdminDeedSmartwallet`/`mint_admin_deed_smartwallet`/ `MintAdminDeedSmartwallet`/`mint_admin_deed_smartwallet` — the "`admin-sig` smart-wallet mint" row in `sdk/capability_matrix.py`. `sub` is the same `0x`-prefixed address format the EOA path produces, so an app's `is_admin(tenant, sub)` authorization check needs no separate branch for a smart-wallet admin. **Verification is a separate entry point from the login path above** — mirroring how EOA `admin-sig` is already isolated from `verify_deed`: ```js // DeedVerifier's verifyAdminSmartwalletAt — a distinct method from // verifyAt, exactly as EOA admin-sig has its own verify_admin_deed // separate from verify_deed. Import path: the wasm package DeedVerifier // ships from (see docs/guide/verify-tokens.md's session-JWT excerpt for // the same import shape). const sub = await verifier.verifyAdminSmartwalletAt(deedJson, challenge, nowUnix); // authenticates ONLY — your app still runs its own is_admin(tenant, sub) // check before granting anything, same as the EOA path. ``` Same deliberate choices as EOA `admin-sig`: no tenant-status check (a lapsed tenant's admin must still be able to reach the page that lets them pay), and it **authenticates, not authorizes** — a successful verify proves control of the wallet contract at `sub`, nothing about whether that address administers any particular tenant. ## Both sides ship in all four languages `user-1271` mint, `admin-sig` smart-wallet mint, `admin-sig` smart-wallet verify, and `user-1271` deed verification all ship in TypeScript, Python, Go and Rust, enforced by `just capability-matrix`. See [Sovereign tier § Every capability, every language](../sovereign-tier.md#every-capability-every-language) for the full table alongside every other mode. ## See also - [Wallet login](wallet-login.md) — `user-sig`, the EOA baseline. - [Passkey login](user-passkey.md) — `user-passkey`, the other maximally-private option. - [Verify a deed](verify-tokens.md) — the relying-party side. - [Sovereign tier § Smart-wallet login](../sovereign-tier.md#smart-wallet-login-user-1271) and [§ Dashboard login § Smart-wallet admin](../sovereign-tier.md#smart-wallet-admin-safe-eip-1271) — the full reference, including the exact verifier checklists. - [Errors](errors.md) — every error code a relying party branches on. --- # Agent tokens (`agent-zk`) An enrolled agent authenticates with an **`agent-zk` deed** — a zero-knowledge membership proof; `ZkAgent.mintDeed` (every language) is the entire mint API, with no browser, issuer, or client secret involved. See [Concepts](concepts.md) for the mental model. ## Don't need on-chain enrolment? Use `user-sig` instead If your agent authenticates straight to an app's own API and doesn't need per-agent revocation or anonymous membership, permissionless `user-sig` needs no enrolment round trip — any signing key works. See [Wallet login](wallet-login.md); `authenticate()` composes discover → challenge → mint for you: ```ts import { authenticate } from "@grantor/agent"; import { Registry } from "@grantor/agent/registry"; const deed = await authenticate("https://api.example.com", signMessage, { chainReader, registry: Registry.canonical(), // the SDK pins the canonical registry for you rpcUrl, }); ``` `chainReader` (an `eth_call` seam) and `registry` (a `RegistryRef` — the SDK pins the canonical registry for you; a custom address exists only on the licensed [dedicated path](enterprise-registry.md)) are REQUIRED: `authenticate()` uses them to verify the application can show a tenant admin's on-chain vouch for this origin *before* it signs anything. See [Constructing a `chainReader`](../sovereign-tier.md#constructing-a-chainreader). ## The `agent-zk` recipe `authenticate()` does not apply to `agent-zk` — an already-enrolled agent composes `discover()`, a challenge fetch, and `ZkAgent.mintDeed` by hand instead. The full worked example (all four languages), the same-origin rule, and the origin-provenance vouch check `mintDeed` performs before it proves, are documented once, in [Sovereign tier § What it is](../sovereign-tier.md#what-it-is) and [§ Discovery — how an agent finds all this](../sovereign-tier.md#discovery-how-an-agent-finds-all-this). Read those rather than a second copy here. ⚠️ **Read the caveats, not just the recipe.** `mintDeed`'s provenance check closes `agent-zk` cross-origin linkability and membership disclosure *by prevention* — but only when your holder actually runs it, and only against an origin that has published a valid vouch. See [Sovereign tier § Origin binding — what this closes, and what still does not](../sovereign-tier.md#origin-binding) and [§ Origin provenance](../sovereign-tier.md#origin-provenance) for exactly what is and is not closed today. ## Prerequisites (one-time, on-chain, done by the tenant operator) Before an agent can mint an `agent-zk` deed, the tenant operator must, on `GrantorRegistry`: 1. `createTenant` and keep it funded (`Active` or `Grace` — see [Concepts](concepts.md#tenants-tiers-and-billing)). 2. `registerZkAgent` — enrol the agent's Semaphore identity commitment in the tenant's on-chain membership tree (`registerZkAgentBatch` to enrol several agents in one call). 3. Sign and publish an **origin vouch** for the app's origin, so `ZkAgent.mintDeed`'s provenance check can pass — see [Sovereign tier § Origin provenance](../sovereign-tier.md#origin-provenance). A tenant admin revokes a vouch with `bumpOriginEpoch` if an origin is decommissioned or compromised. ## Verifying it Your app verifies an `agent-zk` deed exactly the way it verifies a `user-sig` one — the same `DeedGuard`/`DeedVerifier`, the same `verify_deed` call. See [Verify a deed](verify-tokens.md). ## Full agent-integration walkthrough See [Agent integration](../agents/README.md) and [Agent onboarding](../agents/onboarding.md). --- # User gating (`user-zk`) A human proves membership of your app's own **user allowlist** in zero-knowledge — your app learns *"an enrolled member of tenant T logged in"* plus a stable, app-scoped **pseudonym**, and nothing else. No wallet address, no name, no membership list position: your app cannot tell which enrolled member just signed in, only that one did. See [Concepts](concepts.md) for the mental model. `user-zk` sits beside [`agent-zk`](agent-tokens.md) as a *kind of deed* — same zero-knowledge membership proof, same on-chain revocation, same billing check — proved against a **separate, admin-curated user tree** instead of the agent tree. It is additive alongside `user-sig`/`user-passkey`/`user-1271`: pick it when a human logging in must be on YOUR allowlist first, not merely hold a key. ## Mint + verify, at a glance ```ts // ZkUser ships from the same low-level module ZkAgent does — // pkg/grantor_agent_wasm.js (built by `just agent-ts`), not the // authenticate()/discover() high-level module — same convention // agent-tokens.md documents for ZkAgent. import { Registry } from "../../sdk/agent/ts/src/registry.js"; // once, per member: derive the identity from a wallet signature and hand // the PUBLIC commitment to your admin flow — never the signature itself. const user = ZkUser.fromWalletSignature(walletSignature); const commitment = user.commitment(); // -> tenant admin calls registerZkUser(tenantId, commitment) // each login: sync the user tree from chain, prove membership, mint a // user-zk deed bound to a challenge YOUR app issued. const deed = await user.mintUserDeed( rpcUrl, Registry.canonical(), tenantId, audience, origin, challenge, expUnix, vouchSignature, vouchEpoch, vouchExp, allowInsecureOrigin, nowUnix, ); ``` ```js // your app: the same DeedGuard.verify call every mode goes through — // excerpted from examples/mcp-server/server.mjs's own handler. const claims = await g.guard.verify(deedJson, challenge); // claims.sub is the pseudonym — log ONLY this, never a wallet address. console.log(`an enrolled member of tenant ${claims.tenant} logged in (sub=${claims.sub})`); ``` `mintUserDeed`'s parameters mirror `ZkAgent.mintDeed` exactly (the registry ref, the origin vouch, `allowInsecureOrigin`, `nowUnix`) — see [Agent tokens § The `agent-zk` recipe](agent-tokens.md#the-agent-zk-recipe) for what each one does; the provenance check runs the same way, before proving, on the mint path here too. Your app verifies a `user-zk` deed exactly the way it verifies every other mode — the same `DeedGuard`/`DeedVerifier`, the same `verify_deed` call. See [Verify a deed](verify-tokens.md). ## Admin enrollment (once per member, on-chain) Before a human can mint a `user-zk` deed, the tenant admin enrolls their public commitment in the tenant's on-chain **user tree** — a tree the `GrantorRegistry` keeps entirely separate from the agent tree `agent-zk` proves against, with its own cap and its own counter: 1. The human derives their identity from a wallet signature (`ZkUser.fromWalletSignature`, every language) and reads off the public `commitment()`. The signature itself never leaves their machine; only the commitment crosses to the admin. 2. The admin calls `registerZkUser(tenantId, commitment)` — or `registerZkUserBatch(tenantId, commitments)` to enroll several members in one transaction, atomically checked against the tier's `maxUsers` cap before any of them lands. 3. The admin publishes an **origin vouch** for the app's origin, the same mechanism `agent-zk` uses, so `mintUserDeed`'s provenance check can pass — see [Sovereign tier § Origin provenance](../sovereign-tier.md#origin-provenance). ## Anonymous membership and the pseudonym A `user-zk` deed proves "this signer is one of the tenant's enrolled users" without revealing which one — the same Semaphore zero-knowledge membership proof `agent-zk` uses, over a different tree. The `sub` your app receives is a stable pseudonym, scoped to `(the member's identity, tenant, audience)`: the same member gets the same `sub` on every login to the same app, and a different, uncorrelatable `sub` at a different app. Use it as your primary key for per-member state; do not expect it to reveal, or be derivable from, a wallet address. ## Revocation The admin revokes a member with `revokeZkUser(tenantId, commitment)`. This advances the on-chain user tree's root immediately — the member's prior membership proof stops being valid input to a *new* proof at once, and any `user-zk` deed already minted against the pre-revocation root stops verifying on its next presentation, because your app's `verify_deed` call checks root recency on every request, not just at mint time. There is no grace period and nothing for the revoked member to do; the next login simply fails the same way an expired or never-enrolled one would. ## The wallet-derived-identity requirement `user-zk` identities are derived deterministically from a wallet signature (`ZkUser.fromWalletSignature`), the same derivation `agent-zk` uses for an agent key. A member needs a signing key to enroll and to log in — there is no keyless or browser-only variant of this mode today. ## Not built yet Two related gating shapes are deliberately out of scope for this mode and are not shipped: - **Token-gating / DAO membership** — proving membership via an on-chain token balance or a DAO's own membership set, rather than an admin-curated allowlist. This needs a maintained snapshot tree or storage proofs, which is a materially larger, separate piece of work. - **Passkey-derived anonymous identities** — a `user-zk`-shaped mode identity-derived from a passkey instead of a wallet signature. There is no stable secret a passkey can deterministically re-derive across devices the way a wallet signature can, so this has no clean derivation to build on yet. `user-zk` itself is complete for admin-curated, wallet-derived, anonymous, pseudonymous, revocable human membership — that is its whole scope. ## See also - [Agent tokens](agent-tokens.md) — `agent-zk`, the same proof shape for enrolled machines. - [Wallet login](wallet-login.md) — `user-sig`, permissionless login with no enrollment or revocation. - [Verify a deed](verify-tokens.md) — the relying-party side. - [Concepts](concepts.md) — the mental model. - [Errors](errors.md) — every error code a relying party branches on. --- # MCP server auth (deed-gated) MCP authorization is optional, but when a remote [Model Context Protocol](https://modelcontextprotocol.io) server implements it, the spec's prescribed route is standing behind an OAuth 2.1 authorization server. Grantor is "OAuth with no authorization server": you install `DeedGuard`, publish a discovery document, and agents authenticate with **deeds** instead of an OAuth flow. No authorization-server process exists anywhere in your dependency graph. If you haven't already, read [Concepts](concepts.md) for the mental model (deeds, pseudonymity, the on-chain trust anchor). The runnable reference this guide describes lives at `examples/mcp-server/`, starting with `server.mjs`'s wiring below. ### 1. Discovery + challenge — `grantorExpress` ```js import { DeedVerifier, sessionJwt } from "../../sdk/verify/ts/pkg/grantor_verify_wasm.js"; import { grantorExpress, writeRejection } from "../../sdk/verify/ts/src/express.js"; import { DeedRejected } from "../../sdk/verify/ts/src/guard.js"; import { Registry } from "../../sdk/verify/ts/src/registry.js"; const verifier = new DeedVerifier( RPC_URL, Registry.devnet(REGISTRY), // this example runs against an anvil devnet — see Registry.canonical()/.dedicated() for a real deployment CHAIN_ID, TENANT_ID, AUDIENCE, ORIGIN, MAX_TTL_SECS, CACHE_TTL_SECS, ALLOW_INSECURE_ORIGIN, // false in production; true only for a localhost/dev ORIGIN Math.floor(Date.now() / 1000), ); const g = grantorExpress({ verifier, app, challengeEndpoint: "/auth/challenge", chainId: CHAIN_ID, // Fleet gating: `agent-zk` alongside `user-sig`. Nothing else in this file // changes — the guard dispatches on the deed's own `mode` field, so an // agent-zk proof and a user-sig wallet login are verified by the same // `/auth/token` route below. Membership in the tenant's on-chain registry // IS the authorization for that mode; there is no separate allow/deny list // here. modes: ["user-sig", "agent-zk"], vouchSignature: VOUCH_SIGNATURE, vouchEpoch: VOUCH_EPOCH, vouchExp: VOUCH_EXP, }); app.get("/auth/challenge", g.challenge); ``` (The imports above are relative paths into this workspace, not an npm package — nothing is published yet. `sdk/verify/ts` is the source of truth; swap in your package name once it is.) Passing `app` plus `challengeEndpoint`/`chainId`/`modes`/`vouchSignature`/ `vouchEpoch`/`vouchExp` makes `grantorExpress` auto-register `GET /.well-known/grantor-deed` and self-check the document at startup — the offline half of the origin-vouch check (see [Errors](errors.md)). A misconfigured vouch fails the `grantorExpress(...)` call itself, not the first agent's login. ## Before you run it 1. **A tenant on the registry** — `createTenant` plus funding on `GrantorRegistry`. No signup, no account. See [Getting started](getting-started.md). 2. **A signed origin vouch** for wherever your MCP server runs. It is **required** in the discovery document — `parse_discovery` rejects one without it — and it is what lets an agent's `authenticate()` call refuse a hostile origin *before* signing anything. See [Sovereign tier § Origin provenance](../sovereign-tier.md#origin-provenance). 3. **The deed guard**, in your server's language. This guide shows TypeScript (`sdk/verify/ts`); the guard ships in all four languages — see [Every capability, every language](../sovereign-tier.md#every-capability-every-language). 4. **`ALLOW_INSECURE_ORIGIN` left unset (or `false`) for anything but localhost.** The verifier construction above and `agent-zk.mjs`'s `mintDeed` call both take it, default-secure — set it `true` only while `ORIGIN` is a `localhost`/dev origin; a real deployment must run with it unset so a non-HTTPS or non-routable `ORIGIN` fails loudly instead of quietly serving insecure logins. See [Sovereign tier § Fail-closed origin policy](../sovereign-tier.md#fail-closed-origin-policy). ## The three endpoints `examples/mcp-server/server.mjs` wires exactly three routes around the shipped guard, plus the MCP transport itself. The first — discovery and the challenge endpoint — is shown above, right after the intro; the other two follow below. (Fleet-gating agents with `agent-zk` alongside `user-sig` is covered separately in [The fleet side — `agent-zk`](#the-fleet-side-agent-zk).) ### 2. Deed → local bearer — `POST /auth/token` An MCP client is not a browser fetching a protected resource with `X-Grantor-Deed` on every request — it exchanges a deed once, for a session bearer, the way a token endpoint would. So this route calls the guard's verify step directly instead of using the header-based `g.protect` middleware [Verify a deed](verify-tokens.md) documents. It also reads the deed's own envelope `mode` and, if `REQUIRE_MODE` is set, gates on it before verifying — see [Both modes at once](#both-modes-at-once) below for why that check has to live here, not in the guard: ```js app.post("/auth/token", async (req, res) => { try { const { deed, challenge } = req.body ?? {}; if (!deed || !challenge) { res.status(400).json({ error: "MissingField", error_description: "deed and challenge are required" }); return; } const deedJson = typeof deed === "string" ? deed : JSON.stringify(deed); // The mode THIS deed declares, read from its own wire envelope — the // only place it is available (see REQUIRE_MODE's doc above). A parse // failure here is not fatal: `g.guard.verify` below still runs and gives // the caller a real `BadDeedEncoding`/`BadProof`-style rejection instead // of a misleading mode error. let mode; try { mode = JSON.parse(deedJson)?.mode; } catch { // fall through — verify() below rejects the malformed JSON properly. } if (REQUIRE_MODE && mode !== REQUIRE_MODE) { res.status(403).set("Cache-Control", "no-store").json({ error: "ModeNotAllowed", error_description: `this server only accepts ${REQUIRE_MODE} deeds at /auth/token ` + `(got ${mode ?? "unknown"}) — REQUIRE_MODE=${REQUIRE_MODE} is set`, }); return; } // All crypto/replay/billing verification is the guard's job and covers // whichever mode the envelope declared — the REQUIRE_MODE check above // only decides whether that mode is ALLOWED here, not whether the deed // is VALID. const claims = await g.guard.verify(deedJson, challenge); // Belt-and-braces: the envelope said one thing, verification proved it. // A mismatch here would mean the guard verified a different mode than // the envelope declared — impossible by construction, worth crashing on. if (mode && claims.mode !== mode) throw new Error(`mode mismatch: envelope=${mode} verified=${claims.mode}`); const { token, exp } = mintBearer(claims.sub, claims.mode); // Two artifacts, two honest jobs. `access_token` is THIS demo's own // session: an opaque random bearer, checked against the in-memory // `bearers` map below (`requireBearer`) — real for this process, gone on // restart, meaningless to anything else. `session_jwt` is the "take this // into the rest of YOUR stack" artifact: a real, standard ES256 JWT, // minted with THIS server's own key via the shipped `sessionJwt` // convenience (`sdk/verify/ts`) from the claims the guard just verified. // Nothing here is Grantor-proprietary — any JOSE library can verify it // against `SESSION_PUBLIC_JWK` (or `GET /auth/session-jwks` below) // without ever importing this SDK. This example still gates `/mcp` with // the bearer map, not the JWT, so both patterns are demonstrated side by // side rather than one silently replacing the other. const sessionToken = sessionJwt( claims.sub, AUDIENCE, BigInt(TENANT_ID), SESSION_SIGNING_KEY_PEM, BigInt(Math.floor(Date.now() / 1000)), BigInt(SESSION_JWT_TTL_SECS), { iss: ORIGIN, kid: SESSION_KID }, ); res .set("Cache-Control", "no-store") .json({ access_token: token, token_type: "bearer", expires_at: Math.floor(exp / 1000), sub: claims.sub, mode: claims.mode, session_jwt: sessionToken, }); } catch (e) { if (e instanceof DeedRejected) { res.set("Cache-Control", "no-store"); writeRejection(res, e, { realm: ORIGIN }); return; } console.error("auth/token error:", e); res.status(500).json({ error: "InternalError" }); } }); ``` **Every rejection here goes through the funnel** (`writeRejection`, `sdk/verify/ts/src/express.js` — the same helper `g.protect`/`g.protectCapability` use internally, called by hand here because this route calls `g.guard.verify` directly instead of the header-based middleware). A 401 (not authenticated — the deed failed to verify) gets a `WWW-Authenticate: Grantor-Deed realm="...", discovery="/.well-known/grantor-deed"` header plus `discovery`/`learn` fields in the JSON body, pointing a rejected caller at this server's own discovery document and the global onboarding manifest; a 503 (`Chain`/`QuorumDivergence`/`WrongChain`/`LicenseExpired` — an RP/network problem, not the caller's credential) gets neither, since there is nothing to fix by re-authenticating. This is opt-out, not mandatory: pass `funnelHints: false` to `writeRejection`/`grantorExpress` to keep the pre-funnel `{error, error_description}` shape only. See [the funnel design](../superpowers/specs/2026-08-10-agent-native-gtm-design.md) for the full contract, including why `ModeNotAllowed` above stays hand-rolled (it is this route's own policy, not a rejection the shipped guard raised, so there is no shared error class to fold it into). `mintBearer` (a random 192-bit token in an in-memory map, keyed to `{ sub, mode, exp }` — `server.mjs`'s own code, not part of the SDK) is the RP-owned session step: minting a session is deliberately **not** the guard's job, the same rule [Verify a deed](verify-tokens.md) states for any deed integration. The bearer's lifetime is unrelated to the deed's own `exp` — the deed authenticates a login, the bearer is this server's own session. The response also echoes back `mode` so a caller can tell which credential kind it authenticated with; `REQUIRE_MODE` unset means this route accepts whichever modes the server advertises (see [Both modes at once](#both-modes-at-once)). The response also carries `session_jwt` — the RP's own standard ES256 JWT, minted with the one-call convenience [Verify a deed § Or one call](verify-tokens.md#or-one-call) documents, alongside `access_token` rather than instead of it: this example still gates `/mcp` with the bearer map, so both the "roll your own session" and the "one-call convenience" paths are demonstrated side by side, not one silently replacing the other. ### 3. The MCP transport — bearer-gated Past this point it is standard MCP (the official SDK's `StreamableHTTPServerTransport`); the deed only decides who is allowed to open it — and, since `bearers` now stores `mode` alongside `sub` (the `/auth/token` excerpt above), `mode` rides through to the tool layer too: ```js function requireBearer(req, res, next) { const header = req.headers.authorization ?? ""; const token = header.startsWith("Bearer ") ? header.slice("Bearer ".length) : null; // `Map.get` here is a hash lookup, not a constant-time compare — acceptable // for this token specifically because it is 192 bits of `randomBytes` // (mintBearer above), so there is nothing a timing side-channel narrows // down to a feasible guess. A lower-entropy or structured secret (an API // key with a checkable prefix, say) would need `crypto.timingSafeEqual` // instead; copy that if you copy this file for such a token. const entry = token ? bearers.get(token) : undefined; if (!entry || entry.exp <= Date.now()) { res.status(401).set("Cache-Control", "no-store").json({ error: "invalid_token" }); return; } req.grantorSub = entry.sub; req.grantorMode = entry.mode; next(); } function buildMcpServer(sub, mode) { const server = new McpServer({ name: "grantor-mcp-example", version: "1.0.0" }); server.registerTool( "whoami", { description: "Return the calling agent's verified, pseudonymous Grantor subject " + "and the deed mode it authenticated with (the `sub`/`mode` recomputed " + "by the verifier from the presented deed's own envelope).", }, async () => ({ content: [{ type: "text", text: JSON.stringify({ sub, mode }) }] }), ); return server; } app.post("/mcp", requireBearer, async (req, res) => { // Stateless per the SDK's own terminology (sessionIdGenerator: undefined): // a fresh McpServer/transport pair per request, closed over the sub this // bearer verified to. Good enough for a reference; a stateful deployment // would instead look sessions up by the transport's own session id. try { const server = buildMcpServer(req.grantorSub, req.grantorMode); const transport = new StreamableHTTPServerTransport({ sessionIdGenerator: undefined }); await server.connect(transport); await transport.handleRequest(req, res, req.body); res.on("close", () => { transport.close(); server.close(); }); } catch (e) { console.error("mcp request error:", e); if (!res.headersSent) { res.status(500).json({ jsonrpc: "2.0", error: { code: -32603, message: "internal error" }, id: null }); } } }); ``` The example's one demo tool, `whoami`, returns `{ sub, mode }` — the verified, pseudonymous `sub` the guard recomputed from the deed, and the `mode` (`user-sig`/`agent-zk`) threaded through from the bearer set at `/auth/token` — so the e2e can assert the round trip for both. ## The agent side — `authenticate()` `examples/mcp-server/agent.mjs` is an MCP client that authenticates with a `user-sig` deed instead of an OAuth redirect. Every crypto and verification step is delegated to the shipped `authenticate()` — the caller supplies only a real signing key and a pinned chain reader: ```js import { authenticate } from "../../sdk/agent/ts/src/authenticate.js"; import { Registry } from "../../sdk/agent/ts/src/registry.js"; const chainReader = { call: async ({ to, data }) => { const { data: ret } = await publicClient.call({ to, data: toHex(data) }); return ret ?? "0x"; }, }; let deed; try { deed = await authenticate(ORIGIN, signMessage, { chainReader, registry: Registry.devnet(CHAIN_REGISTRY), rpcUrl: RPC_URL, }); } catch (e) { // The hostile-origin leg lands here. Report the signer-call counter so the // e2e can assert it is STILL ZERO — the S2/Part-B property: a holder must // refuse an unvouched origin BEFORE ever asking its key to sign anything. console.log(`AUTH_FAILED: ${e && e.message ? e.message : e}`); console.log(`SIGN_CALLS: ${signCalls}`); process.exit(2); } ``` `CHAIN_REGISTRY` is the holder-**pinned** registry address, wrapped in `Registry.devnet(...)` (this example runs against an anvil devnet — a real deployment uses `Registry.canonical()` or, for a licensed enterprise registry, `Registry.dedicated(address, license)`) — supplied by the agent's own config, never read out of the discovery document the server publishes. `authenticate()` resolves it (via a live `eth_chainId` read over `RPC_URL`) and uses it to confirm the server can show a tenant admin's on-chain vouch for `ORIGIN` **before** it ever asks `signMessage` to sign anything — see [Sovereign tier § Constructing a `chainReader`](../sovereign-tier.md#constructing-a-chainreader). This is the exact property `e2e.sh`'s third leg proves: against an origin whose vouch does not cover it, `authenticate()` refuses with the signing callback never invoked — `agent.mjs`'s `signCalls` counter (incremented only inside `signMessage`, not shown above) is how that leg asserts it, staying at zero through the refusal. Once minted, the deed is exchanged for the server's bearer and used to open the MCP session: ```js const tokenRes = await fetch(new URL("/auth/token", ORIGIN), { method: "POST", headers: { "content-type": "application/json" }, body: JSON.stringify({ deed: JSON.stringify(deed), challenge: deed.challenge }), }); const { access_token: accessToken, session_jwt: sessionJwt } = await tokenRes.json(); const transport = new StreamableHTTPClientTransport(new URL("/mcp", ORIGIN), { requestInit: { headers: { Authorization: `Bearer ${accessToken}` } }, }); const client = new Client({ name: "grantor-mcp-example-agent", version: "1.0.0" }); await client.connect(transport); ``` `agent.mjs` opens the MCP session with `accessToken`, this server's own bearer — `sessionJwt` (the RP-minted convenience artifact from the previous section) is destructured here too because the response carries it, but this client has no further use for its own server's session token; a real downstream service is what would verify it. ## The fleet side — `agent-zk` `agent.mjs` above answers "is this tenant's bill paid?" — any wallet may mint, because `user-sig` is permissionless by design. `examples/mcp-server/agent-zk.mjs` answers a different question: "is this caller one of *my* enrolled agents?" An `agent-zk` deed is a real Groth16 proof of anonymous membership in the tenant's on-chain agent registry — only a commitment the tenant admin registered with `GrantorRegistry.registerZkAgent` can produce a proof this server accepts, and the proof does not reveal *which* member it is. | | `user-sig` | `agent-zk` | |---|---|---| | Who can mint | any wallet holder — permissionless | only a commitment the tenant admin enrolled via `registerZkAgent` | | Authorization question | "is this tenant's bill paid?" — authorization past that is the RP's own layer | "is this caller one of my enrolled agents?" — membership itself IS the authorization *for who can mint a valid `agent-zk` deed*; see "Both modes at once" below for whether an RP actually enforces that at `/auth/token` | | Pseudonym | stable per `(tenant, origin)` wallet | stable per enrolled identity, anonymous **within** the fleet — the proof attests to membership, not identity | | On-chain cost | zero per login | ~801k gas per `registerZkAgent` enrollment (one-time; `registerZkAgentBatch` amortizes across a batch) | Choose `agent-zk` for a fleet you control and want gated by enrollment — "only my deployed workers may call this tool server," with no separate allow/deny list anywhere in `server.mjs` **for that mode**: the membership tree *is* the list for `agent-zk` minting. Choose `user-sig` when any wallet-holding caller should be let through and billing is the only gate you need. **Advertising `agent-zk` alongside `user-sig` — the default in this example — does not by itself make the server fleet-only**; see the next section before calling a dual-mode deployment "gated." ### Both modes at once `modes: ["user-sig", "agent-zk"]` in `grantorExpress`'s config controls what `GET /.well-known/grantor-deed` *advertises* — nothing more. The shipped guard verifies whichever mode a deed's own envelope declares. Its `DeedClaims` return value now DOES carry a `mode` field back out — crypto-checked, derived from the verified branch, never trusted from the wire — but a field existing enforces nothing by itself: on a server configured only with that `modes` list and nothing reading `claims.mode`, `/auth/token` stays mode-agnostic in effect, EITHER credential works, and any wallet can still mint a permissionless `user-sig` deed and get a bearer indistinguishable from a fleet member's. `examples/mcp-server/README.md`'s "Both modes at once" section has the full recipe, now two-layered: an OPTIONAL pre-verify parse of the deed's own envelope (`JSON.parse(deedJson).mode`), cheap — no signature check, no chain read — so a wrong-mode deed can be refused for free before paying for verification; and the AUTHORITATIVE post-verify check against `claims.mode`, the value `verify` itself derived from the branch it walked. `server.mjs`'s `REQUIRE_MODE=agent-zk` implements the cheap pre-check and asserts the two agree (a mismatch would mean the guard verified a different mode than the envelope declared — impossible by construction, worth crashing on); it is the reference implementation, proven by `e2e.sh`'s `REQUIRE_MODE` leg (a `user-sig` deed refused with a 403 naming the reason; the registered fleet agent still succeeds, with the exchange itself reporting `claims.mode == "agent-zk"`). There is no `authenticate()`-style wrapper for this mode — `ZkAgent.mintDeed` is the only `agent-zk` mint API in any language, so the caller composes discover → pin-check → challenge → mint by hand, exactly as [Sovereign tier § Minting an `agent-zk` deed](../sovereign-tier.md#what-it-is) and its [RP integration recipe](../sovereign-tier.md#rp-integration-recipe) prescribe: ```js const d = await discover(ORIGIN); if (!d) throw new Error(`${ORIGIN} does not publish a deed discovery document`); if (!d.modes.includes("agent-zk")) { throw new Error(`${ORIGIN} does not advertise agent-zk (modes=${JSON.stringify(d.modes)})`); } // registryAddress is YOUR configuration, never `d.chain.registry`: a // document-supplied eth_call target lets a hostile origin point the // provenance check at a contract it controls, which just answers `true`. if (d.chain.registry.toLowerCase() !== CHAIN_REGISTRY.toLowerCase()) { throw new Error( `discovery chain.registry ${d.chain.registry} does not match the pinned registry ${CHAIN_REGISTRY}`, ); } const challengeRes = await fetch(new URL(d.challenge_endpoint, ORIGIN)); if (!challengeRes.ok) throw new Error(`challenge endpoint HTTP ${challengeRes.status}`); const { challenge } = await challengeRes.json(); const now = Math.floor(Date.now() / 1000); const exp = now + 60; const v = d.origin_vouch; // `mintDeed` runs origin provenance BEFORE proving (S2) — an unvouched // origin fails with `BadOriginVouch` here, before any proof is generated. // An unregistered commitment gets past provenance (this origin IS vouched) // and fails at tree reconstruction instead, with `NotAMember` — before // this script ever reaches the /auth/token exchange below. deedJson = await agent.mintDeed( RPC_URL, Registry.devnet(CHAIN_REGISTRY), d.tenant, d.audience, ORIGIN, challenge, exp, v.signature, v.epoch, v.exp, ALLOW_INSECURE_ORIGIN, now, ); ``` `RPC_URL` and `CHAIN_REGISTRY` come from `agent-zk.mjs`'s own environment, never the discovery document — the same holder-pinned-registry rule `authenticate()` enforces structurally for `user-sig`, done by hand here because `mintDeed` never sees the document at all. `mintDeed`'s second argument is a `RegistryRef` (`Registry.devnet(...)` in this anvil example; `Registry.canonical()`/`Registry.dedicated(address, license)` for a real deployment), resolved internally the same way `authenticate()` resolves its own `registry` option. `ALLOW_INSECURE_ORIGIN` (also environment-sourced, default-secure `false`) gates the fail-closed origin policy `mintDeed` checks right after canonicalising `ORIGIN` and before origin provenance or any chain call — a non-HTTPS or non-routable origin is refused with `InsecureOrigin`, so local dev against `http://localhost:...` must opt in explicitly. Past this point, the deed is exchanged for the server's bearer exactly like the `user-sig` path — the guard dispatches on the deed's own `mode`, and nothing else in `server.mjs` branches on which one showed up. To register a fleet agent, derive its identity and print the public commitment with no network access, then enroll it as the tenant admin: ```sh AGENT_PRIVATE_KEY=0x... node agent-zk.mjs --print-commitment # -> registerZkAgent(tenantId, commitment) via GrantorRegistry, as a tenant admin ``` An unregistered commitment is refused with `NotAMember` — before any request reaches `/auth/token`. ### Scale, honestly **Verification does not get more expensive as the fleet grows.** The Groth16 proof verifies against a fixed circuit at a pinned tree depth (`depth_20`) — checking a proof costs the same whether the tenant has one enrolled agent or a thousand — and the on-chain root-recency/tenant-status reads the verifier makes on every login pass through `CachedGate`'s short-TTL cache rather than hitting the chain each time. **Minting is a different story.** A cold mint — nothing cached locally yet — replays the tenant's *entire* on-chain registration event log to reconstruct the Merkle tree (`grantor_sdk_core::treesync::reconstruct_tree`, the tree-sync path); that cost grows with the number of registrations the tenant has ever made, not the number currently live, until the queued tree-sync/checkpointing tooling ships. `depth_20` gives every tenant a ceiling of 2^20 (≈1,048,576) leaf slots — but today's tier caps (Free 2 / Pro 25 / Scale 250 agents) put both of those numbers at a purely theoretical distance. `registerZkAgent` itself is ~801k gas per agent (mostly the Merkle tree's Poseidon hashing) — `registerZkAgentBatch` amortizes that across a batch, and an L2 is a precondition for the ZK tier at production fleet sizes, not an optimization. ## Every language ships the guard This guide shows TypeScript because the example is a Node/Express app, but the deed guard — challenge issuance, single-use burn, the shared error codes — and both holder paths, `user-sig` and `agent-zk`, ship in TypeScript, Python, Go and Rust; there is no Rust-only or TS-only capability here. See [Every capability, every language](../sovereign-tier.md#every-capability-every-language) and, for the agent side specifically, [Agent tokens](agent-tokens.md) and [Wallet login](wallet-login.md). ## Honest scope — read before pitching this at a real MCP host This flow authenticates an MCP client whose code you or your customer controls — an agent you built, or one your customer built against your installed SDK. It does not make a remote MCP server work with Claude Desktop or any other off-the-shelf host that expects to redirect a human through a spec OAuth 2.1 authorization server; nothing here implements that. Nor does the example attempt session scaling, persistence, or multi-process deployment — the bearer store and challenge store are both in-memory and single-process, exactly the caveat `MemoryChallengeStore` carries in [Verify a deed](verify-tokens.md#the-default-in-memory-challenge-store). ## Run the proof Every excerpt in this guide is transcribed from `examples/mcp-server/`'s own files — this runs them for real, live against anvil: ```sh just verify-ts agent-ts # build the wasm packages the example imports (once) just mcp-e2e # spin up anvil + two servers + both agents, all live ``` `just mcp-e2e` runs `examples/mcp-server/e2e.sh`, which deploys `GrantorRegistry`, creates and funds a tenant, signs the tenant admin's origin vouch, and starts two MCP servers — one genuine, one whose advertised vouch does not cover it. It then drives both modes: `agent.mjs` (`user-sig`) twice against the genuine origin (the pseudonym is stable across logins with the same key) and once against the unvouched one (`authenticate()` refuses before it ever asks the agent's key to sign anything); then a fleet key is registered on-chain via `registerZkAgent` and `agent-zk.mjs` (`agent-zk`) runs twice with that key (a stable pseudonym across two real Groth16 proofs, and distinct from the `user-sig` pseudonym above) and once with a different, never-registered key, which is refused with `NotAMember` before any request reaches `/auth/token`. ## See also - `examples/mcp-server/README.md` — the full reference example, including every environment variable and how to run it against your own chain. - [Sovereign tier](../sovereign-tier.md) — discovery, origin binding, origin provenance, and what the verifier checks, in full. - [Verify a deed](verify-tokens.md) — the deed guard, in depth. - [Agent tokens](agent-tokens.md) and [Wallet login](wallet-login.md) — how `authenticate()` and its per-language equivalents work. - [Errors](errors.md) — every error code a relying party branches on. --- # Agent-to-agent cards An **agent card** is metadata a service publishes to let other agents discover its authentication requirements and capabilities. Instead of a human reading a documentation page and configuring their client, an agent reads the card, sees what's needed, and acts — no human in the loop. By the A2A convention, a resource publishes its card at `/.well-known/agent.json`, relative to its own base URL — the same well-known-path pattern RFC 9728 and Grantor's own discovery document use. Publish yours there so a consuming agent (or an off-the-shelf A2A client) can find it without being told the URL out of band; a consuming agent's first step is fetching `https:///.well-known/agent.json`. When a Grantor-gated resource refuses a caller with a 401, the response funnel includes `discovery` — the URL where that specific resource publishes its deed requirements. An agent card is the inverse: a pre-flight contract, published before authentication is attempted, that tells an agent "if you want to call me, here is what I require." A card that declares `securitySchemes` with a Grantor deed requirement means: *this resource authenticates with deeds; fetch this URL to learn what deed mode to mint, what the challenge endpoint is, and which chain and tenant gate this resource uses*. This is how off-the-shelf agents (Claude et al.) discover Grantor-gated resources without a human hand-wiring each one. ## Fragment 1: Agent card deed declaration An agent card's `securitySchemes` field names the deed scheme and links to the resource's discovery document: ```json { "name": "Acme API", "description": "An example resource gated with Grantor deeds", "version": "1.0.0", "baseUrl": "https://api.example.com", "securitySchemes": { "grantor-deed": { "type": "http", "scheme": "Grantor-Deed", "description": "Grantor deed (zero-knowledge or wallet-signed)", "discovery": "/.well-known/grantor-deed", "learn": "https://chaingrantor.com/.well-known/grantor-onboard.json" } }, "security": [ { "grantor-deed": [] } ] } ``` The `discovery` path is **relative to this resource's baseUrl** (so `https://api.example.com/.well-known/grantor-deed` in this example). The `learn` URL is **stable and global** — the same for every Grantor-gated resource anywhere, pointing at the onboarding manifest and its narrative twin, so an agent that has never interacted with Grantor before knows where to start. ## Fragment 2: Consuming-agent recipe An agent fetches the card from `/.well-known/agent.json`, sees the `grantor-deed` scheme, and follows this recipe: ``` 0. Fetch the agent card (if not already fetched): GET https://api.example.com/.well-known/agent.json → { "securitySchemes": { "grantor-deed": { "discovery": "/.well-known/grantor-deed", … } }, … } 1. Fetch the discovery document: GET https://api.example.com/.well-known/grantor-deed → { "tenant": 1, "audience": "api.example.com", "challenge_endpoint": "/auth/challenge", "modes": ["user-sig", "agent-zk"], "chain": { "id": 8453, "registry": "0x…" }, "origin_vouch": { … } } 2. Fetch a challenge from the endpoint named in the discovery document: GET https://api.example.com/auth/challenge → { "challenge": "abc123def…" } 3. Mint a deed. For agent-zk (fleet membership): - Verify the origin vouch against the origin you are talking to (https://api.example.com). - Build a Semaphore membership proof of this tenant's on-chain agent tree. - Mint the deed with the proof and the challenge. Result: { "mode": "agent-zk", "proof": "…", "challenge": "…", … } 4. Present the deed and challenge in separate headers: POST https://api.example.com/auth/token X-Grantor-Deed: X-Grantor-Challenge: abc123def… → { "access_token": "…" } (the resource's own session bearer) ``` The deed (`X-Grantor-Deed` header) and challenge are always separate — never read the challenge from inside the deed, which would let an attacker pick its own nonce and break the single-use property. The `learn` URL (from the card) points an agent to the onboarding flow if it is not yet a tenant: it names the chain, funding, tier fees, and contract calls to become a tenant and enroll an agent key. ## Bridge to RFC 9728 This agent-card scheme serves the same discovery purpose as [RFC 9728 OAuth 2.0 Protected Resource Metadata](https://www.rfc-editor.org/rfc/rfc9728), which a resource also publishes at a well-known path (`/.well-known/oauth-protected-resource`) for off-the-shelf OAuth clients to discover. In that case, the client is shaped by the OAuth 2.0 spec and reads the metadata there. Here, the agent is shaped by the agent card and reads the discovery document — which is identical in both cases (same `tenant`, `audience`, `challenge_endpoint`, `modes`, `chain`, `origin_vouch`). An MCP-spec client that speaks OAuth 2.0 can use the RFC 9728 path; an agent following this pattern uses the agent card. Both point at the same document. ## See also - [Agent onboarding](../agents/onboarding.md) — once enrolled, the full `agent-zk` recipe. - [MCP server auth](mcp-server.md) — deed-gated MCP servers, which publish both a discovery document and an agent-facing card. - [Verify a deed](verify-tokens.md) — the RP-side verification path. - [Self-onboarding](../ONBOARD.md) — becoming a tenant and enrolling an agent key. --- # Capabilities (delegation) A **capability deed** carries a *bounded grant* on top of the identity every deed already proves — not just "who is calling" but "what this specific caller is allowed to do, and until when." One principal delegates a narrowed slice of its own authority to another identity by signing a `Delegation`; the receiving identity attaches that link to its own deed and presents it. Your app verifies the whole chain in one call and gets back the caller's identity plus the grants that actually apply — then enforces each request against them with `CapabilityGuard`. If you haven't already, read [Concepts](concepts.md) for the deed mental model this builds on. The runnable reference this guide describes lives at `examples/mcp-server/` (`capability-principal.mjs`, `capability-principal-zk.mjs`, `capability-worker.mjs`, `capability-chain.mjs` (★ preview — see ["Structure-hiding delegation"](#structure-hiding-delegation-preview) below), and the capability-gated routes in `server.mjs`) — every excerpt below is transcribed from those files. ## The grant grammar ```json { "res": "mcp://acme/tools/*", "act": ["call"], "cav": { "exp": 1786015180, "max_uses": 100, "nb": { "tool": "search|fetch" } } } ``` A `Grant` names a **resource pattern** (`res`), the **actions** allowed on it (`act`), and **caveats** (`cav`) bounding it: - `res` is an exact string, or a prefix ending in one trailing `*` (`mcp://acme/tools/*` matches `mcp://acme/tools/search`, not `mcp://acme/other`). - `act` is the set of actions this grant covers on that resource (`"call"`, `"write"`, whatever your app's own vocabulary is). - `cav.exp` bounds when the grant stops being usable; `cav.max_uses` is carried on every grant your guard inspects but is **not enforced by the guard itself** — counting real uses needs durable state (a DB row, a Redis counter) that only your app can own; `cav.nb` (**n**arrow-**b**y) is a set of named predicates — `{"tool": "search|fetch"}` means the request's `tool` argument must be exactly `search` or `fetch`. A delegated grant can only ever get **narrower**, never wider: a child link in a chain must name a resource pattern, an action set, and caveats that are each a subset of its parent's. A grant with no caveat at all is the widest possible caveat, so it may only appear at the root of a chain. ## Delegating — `delegate` A principal signs a `Delegation` handing another identity a narrowed grant, valid until `exp`, tagged with the revocation cohort it was signed against (`epoch_label`/`epoch` — see [Revocation](#revocation) below): ```js import { delegationBinding, parentRefHashRoot, delegate } from "@grantor/sdk"; const grant = { res: "mcp://acme/tools/*", act: ["call"], cav: { exp, max_uses: 100, nb: { tool: "search|fetch" } }, }; const grantsJson = JSON.stringify([grant]); const parentRefHash = parentRefHashRoot(iss); // the first link in a chain const bindingHex = delegationBinding(tenantId, iss, to, grantsJson, exp, epochLabelHex, epoch, parentRefHash); const signatureHex = await sign(bindingHex); // your own secp160k1/EIP-191 signer const link = delegate( tenantId, iss, to, grantsJson, exp, epochLabelHex, epoch, parentRefHash, undefined, // pubkey — omitted for a recoverable EIP-191/secp256k1 signer signatureHex, ); ``` **A delegation chain's root must currently be a registered agent key** — `GrantorRegistry.registerAgentKey(tenantId, commitment)`, where `commitment` is `keccak256("0x{lowercase 20-byte address}")`, the delegator's own address string. This is the SAME on-chain mapping `admin-sig` deeds check, under a DIFFERENT preimage than `agent-zk`'s CAIP-10 commitment — a key registered for one does not automatically root a chain for the other. There is no permissionless-wallet-root path yet; a chain rooted at an unregistered or revoked key fails verification (`BadDelegation`). A chain can have more than one link — an agent that received a grant can delegate a narrower slice of it onward, the same way — but every link after the root must narrow the immediately preceding link's grants, and the last link's `to` must equal the presenting identity's own proven `sub`. ## Anonymous delegation — `delegateZk` ```js import { ZkAgent } from "@grantor/agent"; import { parentRefHashRoot } from "@grantor/sdk"; const agent = ZkAgent.fromAgentKeySignature(agentKeySignatureBytes); const grantsJson = JSON.stringify([grant]); // A delegation's `iss` is a Semaphore nullifier fixed by (identity, tenant) // alone — computed before the proof, but only ever returned FROM one. Mint // once (any parent hash — even a placeholder `zeroHash` — works to learn // `iss`; that proof is discarded), then mint again with the correct parent // hash now that `iss` is known. const probe = JSON.parse(await agent.delegateZk( rpcUrl, registry, tenantId, to, grantsJson, exp, epochLabelHex, epoch, zeroHash, )); const parentRefHash = parentRefHashRoot(probe.iss); const link = JSON.parse(await agent.delegateZk( rpcUrl, registry, tenantId, to, grantsJson, exp, epochLabelHex, epoch, parentRefHash, )); ``` A member of tenant T can grant another identity a bounded slice of authority **without revealing which member of the tenant it is.** Instead of a recoverable EIP-191 signature, `delegateZk` authenticates the link with a real Semaphore membership proof over the SAME on-chain ZK tree `ZkAgent.mintDeed` proves against — the proof is the authentication, so a zk-kind `Delegation` carries no `sig`/`pubkey` at all. Its `iss` is a delegation nullifier, not an address or a commitment: a per-tenant pseudonym, deterministic for a given identity but unlinkable to the commitment the tenant admin registered. Your app verifies a zk-rooted chain through the exact SAME entry point as a signed one — `verifyWithCapabilityAt`/`verify_deed_with_capability` — no separate code path, no separate route. The claims it gets back name the *delegate's* proven identity as usual; the only thing it ever learns about the anonymous delegator is that nullifier, printed here as the delegator's pseudonym, never a member identity: ``` delegator pseudonym (nullifier) iss=4629071830519134272348858930177220791871450050742923343585703717248052541528 ``` Only the chain's **root** is checked against the registry — the same "root-only" gating a signed chain uses. An intermediate zk link's `root` is authenticated by its own nullifier and proof, not separately checked on-chain; recency is enforced once, where the chain's authority actually originates. **Three independent bounds**, all live against the real registry: - **`exp` bounds issuance**, exactly as it does for a signed link — a link past its own `exp` stops verifying, no on-chain action needed. - **`bumpEpoch` is the instant, bulk kill** — identical mechanism to the signed leg's [Revocation](#revocation) section: one transaction on the link's `epoch_label` invalidates it, and every other link in that cohort, immediately. - **Revoking the member (`GrantorRegistry.revokeZkAgent`) invalidates every already-signed delegation rooted at it, immediately** — it overwrites the on-chain root history for the WHOLE tenant tree, not just that one member, so the next root-recency check on an already-minted deed fails closed with a `BadDelegation` worded distinctly as a stale root (`"not a currently-recent registry member"`), independent of whatever epoch state that link was signed against. ## Unlinkable across chains — `delegateZk(..., salt)` ```js const grantsJson = JSON.stringify([grant]); // Same two-call pattern as above, with one addition: a per-chain `salt` as // the trailing argument to `delegateZk`. const probe = JSON.parse(await agent.delegateZk( rpcUrl, registry, tenantId, to, grantsJson, exp, epochLabelHex, epoch, zeroHash, salt, )); const parentRefHash = parentRefHashRoot(probe.iss); const link = JSON.parse(await agent.delegateZk( rpcUrl, registry, tenantId, to, grantsJson, exp, epochLabelHex, epoch, parentRefHash, salt, )); ``` `salt` is an optional trailing string on `delegateZk`, in every language. It scopes the delegation nullifier further, so the SAME anonymous member delegating twice with two different salts produces two pseudonyms that share nothing — no field on either deed, or anywhere on the wire, ties them back to one delegator: ``` chain A (salt="chain-a"): delegator pseudonym iss=826493158991174290343971509743582095105037072152399465317093330796638885617 chain B (salt="chain-b"): delegator pseudonym iss=14936235482157490080433353871006983321048803913765619935150511037695437371846 ``` Omitting `salt` (or passing `undefined`/`null`, depending on the language) reproduces the one stable, recognizable pseudonym this member already uses for the tenant — the same `iss` `delegateZk` returns with no salt today. Which one you want is a per-chain choice, not a global setting: mint a fresh salt for a chain you want unlinkable from the member's other chains, or leave `salt` off for a chain where staying recognizable — the same delegator every time — is the point. Verification does not change: `verifyWithCapabilityAt` recomputes the nullifier scope from whatever `salt` the deed itself carries, so a salted and an unsalted chain from the same member both verify through the exact same call. A `salt` tampered after minting fails the same way a tampered grant does — the proof was built over the original value, so a verifier recomputing against a different one lands on the wrong nullifier scope and rejects with `BadDelegation`. ## Presenting — `mintCapabilityDeed` The identity receiving a delegation attaches it to its own already-minted deed (a `user-sig` login, an `agent-zk` proof, whatever mode it authenticates with) and presents the result instead of the bare identity deed: ```js import { mintCapabilityDeed } from "@grantor/sdk"; const baseDeed = await authenticate(origin, signMessage, { chainReader, registry, rpcUrl }); const capabilityDeed = mintCapabilityDeed(baseDeed, grantsJson, JSON.stringify([link])); ``` `grantsJson` here is the presenter's own **asserted** grants — normally the same grant it received, sometimes a narrower slice of it. `mintCapabilityDeed` does not itself check that the assertion is a legal narrowing; that check is what verification does next, so a forged or widened assertion is caught at the one place that matters, not trusted at mint time. ## Verifying — `verifyWithCapabilityAt` Your app verifies a capability deed with one call, alongside the ordinary identity check — it walks the chain root to leaf, recovers each link's signer cryptographically (never trusts the claimed `iss`), checks every narrowing step, and confirms the root is still an enrolled, non-revoked agent key: ```js const { claims, grants } = await verifier.verifyWithCapabilityAt(deedJson, challenge, now); ``` `claims` is the same identity `verify_deed` already gives you; `grants` is the chain's **effective** authority — `[]` for an identity-only deed with no delegation attached, so this call is a strict superset of the plain identity check and safe to use everywhere, not just on capability-gated routes. ## Enforcing — `CapabilityGuard` `grantorExpress` ships a matching pair of middleware: `protectCapability` verifies the presented deed and attaches `req.deed`/`req.grants`; `requireCapability` checks one route's own resource/action against those grants, denying with `CapabilityGuard.authorize`'s rules if nothing covers the request: ```js app.post( "/mcp/tools/:tool", g.protectCapability, g.requireCapability("mcp://acme/tools/*", "call", { argsFrom: (req) => ({ tool: req.params.tool }), }), (req, res) => res.json({ tool: req.params.tool, sub: req.deed.sub }), ); ``` A request to `/mcp/tools/search` or `/mcp/tools/fetch` passes — the grant's `nb: {"tool": "search|fetch"}` predicate matches. A request to `/mcp/tools/write` is refused: the deed itself is genuine (verification already succeeded), it just carries no grant that covers `tool=write` — `CapabilityDenied`, HTTP **403**, deliberately distinct from every other code in this table, which means "this deed did not check out" and maps to 401/503 instead. See [Errors](errors.md) for the full table. Calling `CapabilityGuard.authorize` directly (outside Express, or from a non-HTTP call site) looks the same, against grants you already verified: ```js import { CapabilityGuard } from "@grantor/verify"; CapabilityGuard.authorize(grants, { res: "mcp://acme/tools/search", act: "call", args: { tool: "search" }, now, }); ``` ## Revocation Three independent mechanisms bound how much a delegation can do and for how long, at three different scopes: - **Caveats bound the blast radius of one grant.** `res`/`act`/`nb` narrow exactly what a delegated grant covers — a grant scoped to `{tool: "search|fetch"}` never covers a `write` call no matter how it is presented. - **Each link's own `exp` bounds its window.** A delegation link — and the leaf deed it is attached to — simply stops verifying once its own `exp` passes, no on-chain action needed. - **`bumpEpoch` is the instant, bulk kill.** Every `Delegation` is signed against a revocation cohort (`epoch_label`, defaulting to the tenant-wide cohort) at a specific `epoch`. The tenant admin calls `GrantorRegistry.bumpEpoch(tenantId, label)` to increment that counter — one transaction invalidates *every* outstanding link signed against the old value for that cohort, immediately, with no per-link bookkeeping: ```js RESULT: {"search":{"status":401,"body":{"error":"EpochRevoked", …}}, …} ``` Your verifier's own chain-read cache (if you run one) bounds how quickly a `bumpEpoch` is *observed* — a cached `delegation_epoch` read serves the old value until its TTL expires, the same tradeoff the tenant-active and root-recency caches already make. The revocation itself is instant on-chain; your app's cache TTL is the only thing standing between the transaction and your next verification seeing it. ## Structure-hiding delegation (preview) > ⚠️ **PREVIEW.** This mode uses a **single-contributor, deterministic > trusted setup** — not a multi-party ceremony. The toxic waste is not > assumed discarded, and the circuit is **pending external cryptographic > review**. Treat every `zk-chain` proof as unauthenticated for anything > beyond a demo, and **do not use this mode for production authority.** > Every other mode on this page (`delegate`, `delegateZk`) is unaffected. Every mode above still reveals *something* about the chain's shape: a `Delegation` array on the wire, one link per hop, each carrying a real signature or proof. **Structure-hiding delegation collapses an entire chain — however many hops, however it narrows — into ONE Groth16 proof and one opaque effective-grant commitment (`g_eff`).** The deed that reaches your server carries no `delegations` array and no way to recover a hop count from the wire at all. ```js // Holder side (a script that plays every role in the chain — see // examples/mcp-server/capability-chain.mjs): authorize N hops, fetch the // root's real on-chain membership witness, and prove. import { ChainHopAuthorizer, proveDelegationChain } from "@grantor/agent"; const auth = new ChainHopAuthorizer(tenantId, epoch); const hop0 = auth.authorizeHop(member, agentA.publicHex(), grantJson); const hop1 = auth.authorizeHop(agentA, agentB.publicHex(), grantJson); const hop2 = auth.authorizeHop(agentB, worker.publicHex(), grantJson); const deedJson = proveDelegationChain( `[${hop0},${hop1},${hop2}]`, siblingsHex, isLeft, worker, rootHex, challenge, tenantId, audience, deedExp, epoch, ); // RP side: verify the proof for real, then OPEN and enforce g_eff. const claims = await verifier.verifyDelegationChainAt(deedJson, challenge, now); const gEff = arithGrantCommit(JSON.stringify(claimedGrantValues)); if (gEff !== claims.sub) throw new Error("does not open the proven commitment"); CapabilityGuard.authorize([openedGrant], { res, act, args, now }); ``` `verifyDelegationChainAt` is a **dedicated** entry point — it never touches `verify_deed`/`verify_deed_with_capability`, and a non-`zk-chain` deed is refused there either way, the same isolation `admin-sig` gets for a different reason. It does real Groth16 verification against a **committed** verifying key, plus the exact same tenant/root/epoch chain gates every other mode uses — no new contract, no new gate method. ### Opening `g_eff` is your job, not the verifier's The in-circuit narrowing checks each hop's caveat-predicate commitment (`nb_commit`) by **equality**, never by opening it — so a Groth16-verified `zk-chain` deed has proven *what* authority exists (an opaque commitment), not that *this specific request* is covered by it. `verifyDelegationChainAt` resolves `sub` to that commitment and stops there on purpose. Your app must: 1. build the `GrantValues` (`{res_id, act_mask, exp, max_uses, nb_commit}`) it believes the chain committed to — from its **own** interning tables, never from anything an untrusted caller asserts (see the next section); 2. recompute `arithGrantCommit` over those values and require it **equal** `claims.sub` — only a value that opens the real commitment is ever enforced; 3. run the opened grant through `CapabilityGuard.authorize` exactly like a P0/P1 effective grant — same `res`/`act`/`nb` rules, same 403 `CapabilityDenied` on a miss. `crates/grantor-verify/src/capguard.rs`'s `CapabilityGuard::authorize_zk_chain` is the Rust-native version of this same two-step split; `arithGrantCommit` (`@grantor/verify`) is the wasm export that lets TypeScript perform the same Poseidon-hash check without reimplementing it. ### The res-interning contract is yours to keep A circuit sees five field elements, not a resource glob or a JSON caveat map. **Your app assigns the meaning** — which `res_id` is which resource pattern, which bit of `act_mask` is which action, which `nb_commit` is which predicate set — the same way `examples/mcp-server/server.mjs`'s zk-chain route keeps a small fixed table (`res_id: 7` ⇒ `"mcp://acme/tools/*"`, bit 0 ⇒ `"call"`, one `nb_commit` id ⇒ `{tool: "search|fetch"}`) and refuses anything outside it. This module cannot validate that mapping for you — a mis-interning is an integration bug in your app, not a circuit bug. ### The `WireGrant` precision ceiling `exp`/`max_uses` cross the wire as plain JSON numbers, not hex — so they must stay under `2^53 - 1` (`Number.MAX_SAFE_INTEGER`). Both are naturally small (a unix timestamp, a usage cap), so this is not a practical limit, but `WireGrant`'s parser refuses a larger value outright rather than proving over one a JS caller's own runtime already silently corrupted. ### Cost: real proving, every time Structure hiding costs a real Groth16 proof, not a signature check — **~18-22 seconds measured in Node (wasm), ~0.8 seconds native.** This is server-side/agent-side work, not something you'd ask a browser tab to do on every click; the reference exchanges one proof for a short-lived session bearer (`POST /auth/token/zk-chain`) rather than re-proving per tool call, the same "deed is the login, bearer is the session" shape every other mode uses. ## Run the proof ```sh just verify-ts agent-ts sdk-ts # build the wasm packages the example imports (once) just caps-e2e # delegate -> present -> enforce -> revoke, all live ``` `just caps-e2e` runs `examples/mcp-server/caps-e2e.sh`, which deploys `GrantorRegistry`, creates and funds a tenant, and starts the reference server. A worker learns its own stable `sub`; a principal registers itself as an on-chain agent key and delegates it "call tools search|fetch, up to 100 uses, expiring in about an hour"; the worker presents the resulting capability deed and calls all three routes — `search` and `fetch` succeed, `write` is refused with `CapabilityDenied`. The tenant admin then calls `bumpEpoch` on the delegation cohort, and the SAME already-signed delegation is presented again: every call now fails with `EpochRevoked`, proving the bulk revocation without touching a single link. The same script then runs the anonymous leg: a member registers into the tenant's ZK tree, delegates the identical grant via `delegateZk` instead, and the worker presents the resulting deed to the same three routes with the same outcome — the transcript prints the delegator's pseudonym, never its commitment or address. The tenant admin then proves both zk revocation channels live, on that SAME already-signed deed: bumping its cohort's epoch (`EpochRevoked`), and separately revoking the member itself (`BadDelegation`, worded as a stale root) — two independent reasons the same deed stops verifying. Finally, the SAME member delegates to two workers on two salted chains and a third with no salt at all: the two salted deeds print two different pseudonyms — proven to differ, both verifying and enforcing exactly like every other leg above — and the no-salt deed reproduces the one stable pseudonym the anonymous leg already printed earlier in the same run. The same script then proves structure-hiding delegation (★ preview) live: a member registers into the SAME on-chain ZK tree, `capability-chain.mjs` authorizes a 3-hop chain (member → agent A → agent B → worker) entirely in its own process and produces ONE Groth16 proof, and the server verifies it, opens `g_eff` against its own interning table, and enforces the identical `search`/`fetch` allowed, `write` denied outcome through the SAME `CapabilityGuard` — proving is real, ~18-22 seconds measured, so this leg is the slowest in the script. Two revocation legs then each prove a FRESH deed (a `zk-chain` deed's challenge is bound into the proof, so an already-presented one cannot demonstrate a second failure): `bumpEpoch` on the chain's own cohort (`EpochRevoked`) and `revokeZkAgent` on the root member (`StaleRoot`). ## See also - `examples/mcp-server/README.md` — the full MCP reference, including the identity modes capability deeds build on top of. - [Errors](errors.md) — `BadDelegation`, `EpochRevoked`, `CapabilityDenied`. - [Verify a deed](verify-tokens.md) — the plain identity check this extends. - [Concepts](concepts.md) — the deed mental model. --- # Software licensing with Grantor A **license is a deed.** The licensed software verifies it **locally** against the public on-chain registry — no license server, no phone-home, no activation backend to run or keep online. Revocation, tiers and billing all ride primitives that already ship: a license is an enrolled [`user-zk` member](user-gating.md), a paid tier is a delegated [capability grant](capabilities.md), and the license fee **is** the tenant's on-chain billing. If you haven't already, read [Concepts](concepts.md) for the deed mental model this builds on. The runnable reference this guide describes lives at `examples/licensing/` and `crates/grantor-verify/tests/licensing_e2e.rs` (`just licensing-e2e`). ## Check a license, at a glance The licensed software's side — verify, then gate the paid tier: ```js // the licensed software: verify the presented license deed locally against // the public registry — no license server anywhere in this call. const { claims, grants } = await verifier.verifyWithCapabilityAt(deedJson, challenge, now); // pseudonym-only: the one line the software is allowed to log about who is // running it. No wallet address, no Semaphore identity, no commitment. console.log(`valid license for tenant ${claims.tenant} — basic unlocked (sub=${claims.sub})`); ``` ```js import { CapabilityGuard } from "@grantor/verify"; // gate a pro-only feature on the vendor's delegated tier grant. A basic-only // license (no grant attached) is DENIED here with CapabilityDenied — the // license itself is still perfectly valid, it just carries no pro grant. CapabilityGuard.authorize(grants, { res: "app://features/pro", act: "use", args: { tier: "pro" }, now, }); ``` `grants` is `[]` for a `basic`-only license, so the same `verifyWithCapabilityAt` call is safe to run on every check — a feature that needs no tier just never calls `CapabilityGuard.authorize` at all. See [Verify a deed](verify-tokens.md) and [Capabilities](capabilities.md) for what each call does underneath. ## A license is a `user-zk` enrollment Issuing a license is enrolling a licensee's public commitment in the vendor's own on-chain user tree — the exact mechanism [User gating](user-gating.md) documents for anonymous, allowlisted human login, reframed here as "issuing a license": 1. The licensee derives an identity from a wallet signature (`ZkUser.fromWalletSignature`) and hands the vendor the public `commitment()` — the signature itself never leaves their machine. 2. The vendor calls `registerZkUser(tenantId, commitment)` — this **is** issuing the license. 3. Each time the licensed software runs, it syncs the vendor's user tree from chain, proves membership, and mints a `user-zk` deed bound to a challenge the software itself issued (`ZkUser.mintUserDeed`). The software verifies that deed with the exact same `verify_deed` / `verifyWithCapabilityAt` call every other deed mode goes through — there is no license-specific verification path. ## The pro-tier grant A paid tier is a capability the vendor delegates to that specific license, not a second credential. The vendor's registered agent key is the delegation root (the same `registerAgentKey` requirement [every capability chain](capabilities.md#delegating--delegate) has); it signs a `Delegation` naming the tier and handing it to the licensee's own `user-zk` `sub`: ```json { "res": "app://features/*", "act": ["use"], "cav": { "nb": { "tier": "pro" } } } ``` The delegation's last `to` equals the license's own proven `sub` — the capability chain's binding rule — so the tier is cryptographically bound to *this* license, not a transferable second token. The licensee's software attaches it with `mintCapabilityDeed` and presents one deed carrying both the license and the tier; the vendor's software never issues or checks a separate "tier credential." ## Revocation — two independent channels Licensing has two separate revocation levers, each proven live against its own chain deployment: - **Revoking the license itself:** the vendor calls `revokeZkUser(tenantId, commitment)`. The next time the licensed software checks, the deed fails closed — `StaleRoot` — because `verify_deed` checks root recency on every request, not just at mint time. There is no grace period. - **Revoking a tier, in bulk, instantly:** the vendor calls `bumpEpoch` on the tier grant's delegation cohort. Every deed carrying a link signed against the old epoch value is refused — `EpochRevoked` — while the license's own `user-zk` membership is completely untouched: a licensee who loses the `pro` tier this way keeps their `basic` license working. These are two distinct mechanisms, not one revocation flow with two outcomes: revoking a license does not touch any tier cohort, and bumping a tier's epoch does not touch membership. Pick whichever matches what you're actually taking away — the whole license, or just a paid tier. ## Anonymity The vendor learns *"a valid licensee is running this"* plus a stable, app-scoped pseudonym (`claims.sub`) — never a wallet address, never the licensee's underlying Semaphore identity or commitment. The same licensee gets the same pseudonym on every check against the same software, so it is a safe primary key for per-license state, but it reveals nothing about which enrolled member it belongs to. This is the differentiated pitch over a conventional license-key or account-based scheme: the vendor cannot build a profile of *who* is running licensed copies, only *how many* valid ones are active. ## Billing is the license fee There is no separate payment processor and no license-issuing backend to keep funded. The vendor's tenant billing (`topUp`/`drawPeriod` on `GrantorRegistry`) **is** the license revenue: a tenant that is `Active` or in its `Grace` window issues and verifies licenses normally; a tenant that lapses goes `Inactive`, and every license — even one for a genuinely enrolled, never-revoked member — starts failing `TenantInactive` on its next check. Reactivating the tenant (drawing a period) makes every outstanding license start verifying again immediately, with nothing to reissue. ## Also possible: per-deployment licensing with `agent-zk` Everything above uses `user-zk` — an anonymous *per-user* license, one enrollment per human. The same model works with [`agent-zk`](agent-tokens.md) instead, for a *per-deployment* or *per-server* license (one enrollment per install rather than per person) — same revocation, same tier-via-delegation composition, same billing gate. This variant is a straightforward substitution of the membership tree, not a different mechanism, but it is **not** what the reference proves live; only the `user-zk` shape above has a running end-to-end test. ⚠️ **Not to be confused with an [enterprise dedicated-registry license](enterprise-registry.md)** — an unrelated concept that reuses the word "license" for a different thing: this page is about YOUR product's end-user software licenses, built as a composition of Grantor primitives on the shared canonical registry; the enterprise page is about Grantor's own operator-signed grant that lets an SDK point at a *custom* registry address at all. ## See also - [User gating](user-gating.md) — `user-zk`, the enrollment/revocation mechanism a license is built on. - [Capabilities](capabilities.md) — the delegated-grant mechanism a tier is built on, including `bumpEpoch` and the full delegation-chain rules. - [Agent tokens](agent-tokens.md) — `agent-zk`, the per-deployment variant. - [Verify a deed](verify-tokens.md) — the relying-party side in general. - [Errors](errors.md) — every error code a relying party branches on (`StaleRoot`, `EpochRevoked`, `TenantInactive`, `CapabilityDenied`). - [Enterprise registries](enterprise-registry.md) — a different, unrelated "license": Grantor's own operator-signed grant for a custom registry deployment. --- # Enterprise registries (`Registry.dedicated`) The standard path only ever talks to the one canonical, shared `GrantorRegistry` — every SDK surface, both sides (RP verifier and holder/agent), resolves it automatically from a compiled-in `chainId → address` map. There is no address to paste anywhere in the standard SDK. An **enterprise dedicated registry** is a *separate deployment* of `GrantorRegistry` — your own contract instance, your own tenants, your own billing — pointed at from your own SDK configuration instead of the canonical one. Naming a custom registry address anywhere in the SDK requires an **operator-signed license grant**: a credential Grantor issues, verified offline against operator public keys compiled into the SDK. The right to run your own registry is itself a Grantor grant, checked by the same credential machinery the product ships to everyone else. If you have not read [Getting started](getting-started.md) yet, start there — this page assumes you already know what a deed is and how `DeedVerifier` verifies one against *a* registry; this page is only about *which* registry. ## The registry seam: one type, three variants Every surface that takes a registry — `DeedVerifier`'s constructor, `ZkAgent.mintDeed`, `ZkUser.mintUserDeed`, `delegateZk`, `authenticate()` / `signInWithDeed()`'s `registry` option — takes a `RegistryRef`, built with one of three static constructors (`Registry` in TS/Go, module-level functions in Python): | | TypeScript | Python | Go | Rust | |---|---|---|---|---| | Standard | `Registry.canonical()` | `registry.canonical()` | `guard.RegistryCanonical()` | `RegistryRef::Canonical` | | Local devnet | `Registry.devnet(address)` | `registry.devnet(address)` | `guard.RegistryDevnet(address)` | `RegistryRef::Devnet { address }` | | Enterprise | `Registry.dedicated(address, license)` | `registry.dedicated(address, license)` | `guard.RegistryDedicated(address, licenseJSON)` | `RegistryRef::Dedicated { address, license }` | A `RegistryRef` is a JSON string on every FFI boundary (wasm and uniffi alike): `{"kind":"canonical"}` / `{"kind":"devnet","address":"0x…"}` / `{"kind":"dedicated","address":"0x…","license":{…}}`. The three helper functions above just produce that string — they are the ONLY supported way to build one; hand-writing the JSON works too but gains you nothing. - **`Registry.canonical()`** — the standard path. Resolves from a compiled-in map, keyed by chain id. **Until mainnet launch the map is empty**, so a standard-path construction against any real chain errs, naming the chain — this is expected and documented, not a bug; see [Develop locally](local-devnet.md). Devnet chain ids are not map entries either — `Registry.canonical()` on a devnet chain still errors; use `.devnet()` there. - **`Registry.devnet(address)`** — free-form address, hard-gated to chain ids `31337`/`1337`. The first chain interaction self-checks `eth_chainId` and refuses closed on any other chain — structurally unusable in production. This is what every example, the forge suites, and `just devnet` use. - **`Registry.dedicated(address, license)`** — this page. `license` is a `RegistryLicense` — either the parsed object `grantor-license issue` prints (after `JSON.parse`/`json.loads`), or its raw JSON string; both forms work in every language's `dedicated()` helper. ## The license artifact A long-lived grant in the same capability grammar the delegation/capabilities vertical uses — no second credential format: - **verb:** `dedicated` - **resource:** `registry:{chainId}:{address}` — the exact `(chain, address)` pair the grant authorizes. A multi-chain enterprise gets one grant per chain. - **claims:** `licensee` (an opaque display label chosen at issuance), `iat`, `exp`, `grace_secs` - **signature:** the operator's ed25519 license key The grant is a **bearer attestation with no secret inside** — distribute the one JSON file (env var or config path) to every RP and every agent that needs it. There is nothing in it that leaks by being copied around. ### Ceilings Enforced at *verification* (an over-limit grant refuses outright, whatever its signature says), mirroring how the origin-vouch TTL ceiling is checked where the vouch is consumed: | Parameter | Value | |---|---| | max TTL (`exp - iat`) | 400 days | | `grace_secs` default | 30 days | | `grace_secs` ceiling | 90 days | | pre-expiry warning window | 30 days | ⚠️ **A license with a total TTL under 30 days is born `expiring_soon` and never reports `ok`.** The warning threshold is `exp − 30 days`; if the whole license is shorter than that, every valid `now` for its entire life is past the threshold. This is not a bug — a 10-day-TTL license genuinely *is* always within 30 days of expiring — but it surprises anyone issuing a short test grant expecting to see `"ok"`. ### Runtime semantics — grace, then hard refusal Checked at **construction** and on **every** verify/mint call — a pure offline clock comparison, zero added I/O: | Time | Behavior | |---|---| | `now < exp − 30d` | normal (`"ok"`) | | `exp − 30d ≤ now < exp` | works; `"expiring_soon"` | | `exp ≤ now < exp + grace_secs` | works; `"grace"` (surfaced loudly) | | `now ≥ exp + grace_secs` | **hard refusal** — `LicenseExpired` | There is no boot-time-only check (a license that lapses mid-deployment is re-checked on the very next call, not just at startup) and no zero-grace hard cutoff (grace is the negotiated notice-period term, carried *in the grant* itself, not a global policy). There is also no revocation-before-expiry: the lever is expiry plus renewal, exactly like an origin vouch. An enterprise contract that needs instant kill-switch revocation is a different, un-shipped feature — not something you can get out of the license mechanism today. `LicenseExpired` is a distinct, structured error — never confused with `BadProof` (an attack) or `Chain` (an RPC problem worth retrying). See [Errors](errors.md) for its exact ordering relative to every other check (it fires FIRST, before the presented deed is even inspected — a lapsed license means this deployment cannot authenticate *anyone*, independent of what the caller presents). ## Using it ### The verifier (RP side) ```ts // Imports are by path — @grantor/verify is not yet on npm, matching every // other snippet on this site (see Getting started). import { DeedVerifier } from "../../sdk/verify/ts/pkg/grantor_verify_wasm.js"; import { Registry } from "../../sdk/verify/ts/src/registry.js"; const license = JSON.parse(fs.readFileSync("license.json", "utf8")); const verifier = new DeedVerifier( process.env.RPC_URL, Registry.dedicated(process.env.REGISTRY, license), Number(process.env.CHAIN_ID), Number(process.env.TENANT_ID), "https://api.example.com", // audience "https://api.example.com", // origin 300, // max deed TTL, seconds 30, // chain-read cache TTL, seconds false, // allowInsecureOrigin Math.floor(Date.now() / 1000), ); ``` ```python from grantor_verify import DeedVerifier from grantor_verify import registry license = json.load(open("license.json")) verifier = DeedVerifier( rpc_url=os.environ["RPC_URL"], registry_ref=registry.dedicated(os.environ["REGISTRY"], license), chain_id=int(os.environ["CHAIN_ID"]), tenant=int(os.environ["TENANT_ID"]), audience="https://api.example.com", origin="https://api.example.com", max_ttl_secs=300, cache_ttl_secs=30, allow_insecure_origin=False, now_unix=int(time.time()), ) ``` ```go licenseJSON, _ := os.ReadFile("license.json") ref, err := guard.RegistryDedicated(os.Getenv("REGISTRY"), string(licenseJSON)) if err != nil { log.Fatal(err) } verifier, err := grantor_verify_uniffi.NewDeedVerifier( os.Getenv("RPC_URL"), ref, chainID, tenantID, "https://api.example.com", "https://api.example.com", 300, 30, false, uint64(time.Now().Unix()), ) ``` ```rust let license: grantor_sdk_core::RegistryLicense = serde_json::from_str(&license_json)?; let registry = grantor_sdk_core::RegistryRef::Dedicated { address: registry_address, license }; let verifier = grantor_verify::verifier::DeedVerifier::new( &rpc_url, ®istry, chain_id, tenant, audience, origin, max_ttl_secs, cache_ttl_secs, allow_insecure_origin, now_unix, )?; ``` A lapsed license (past `exp + grace_secs`) refuses **construction outright** — "restart after lapse fails." Every subsequent `verifyAt` call re-checks the license at *its own* `now`, independent of what the license looked like at construction time — a license that was fine at boot can still lapse mid deployment, and the very next request after that sees `LicenseExpired`. ⚠️ **These two lapses surface as DIFFERENT codes, and an operator health check must watch both.** A license already lapsed *at construction* throws `"BadConfig"` (the constructor maps every `VerifierConfigError`, including a lapsed license, to `"BadConfig"`; the message starts `"license/registry: "`) — it is NOT `LicenseExpired`, because there is no verifier instance yet to carry that runtime code. A license that lapses *while the verifier keeps running* throws the caller-facing `LicenseExpired`/`503` on the next `verifyAt` (see [Errors](errors.md)). A restart-time health check that only alerts on `LicenseExpired` will silently miss the boot-time case. `newQuorum`/`new_quorum`/`NewQuorum` (the multi-RPC quorum constructor) takes the identical `registryRef`/license shape, with `rpcUrl: string` replaced by `rpcUrls: string[]`. ### The holder (agent/user side) `ZkAgent.mintDeed` / `ZkUser.mintUserDeed` / `delegateZk` all take the same `registryRef` (as their second positional argument, after `rpcUrl`) and a trailing `nowUnix` — the clock used to resolve the license, exactly like the verifier's constructor: ```ts const deedJson = await agent.mintDeed( rpcUrl, Registry.dedicated(registryAddress, license), tenant, audience, origin, challenge, exp, vouchSignature, vouchEpoch, vouchExp, allowInsecureOrigin, nowUnix, ); ``` A malformed `registryRef` (bad JSON, unknown `kind`) is refused offline, before any chain call, as `"BadConfig"`. A resolution failure — an unreachable RPC's `eth_chainId` read, or a lapsed/invalid enterprise license — throws `"License"` (agent shims) or `LicenseExpired`/`BadConfig` (verify shims — see [Errors](errors.md)). ### `resolveRegistry` — same name, two different shapes ⚠️ Two packages export a function with this exact name and **they are not interchangeable**: - `grantor-agent-wasm`/`grantor-agent-uniffi` (the **holder** SDK) export an **async, RPC-based** `resolveRegistry(registryRef, rpcUrl, nowUnix)` — it reads `eth_chainId` over the network to discover the effective chain, then resolves. This is what `authenticate()`/holder code use, because a holder generally does not already know which chain it is on. - `grantor-sdk-wasm` (the **standard/verify-adjacent** SDK) exports a **synchronous, `chainId`-based** `resolveRegistry(registryRef, chainId, nowUnix)` — no network call, because the verifier side already has an explicit `chainId` (it needs one for the discovery document regardless). Importing the wrong one for your use case either gets you a `Promise` where you expected a string, or an unexpected trailing network round-trip. If your code already holds a `chainId`, use the sync one; if it only holds an RPC URL, use the async one. ## `licenseStatus` — introspection, not enforcement `DeedVerifier` exposes `licenseStatus(nowUnix)`, re-evaluated at the `nowUnix` you pass: - **`undefined`/`None`/`nil`** — this verifier resolved `Canonical` or `Devnet`, which carry no license to lapse. - **`"ok" | "expiring_soon" | "grace" | "lapsed"`** — this verifier resolved `Dedicated`. Poll this for your own monitoring/alerting; `DeedGuard` also logs a rate-limited warning (at most once per interval, never per request) once a resolved license enters `"grace"` or `"expiring_soon"`. `licenseStatus` is pure clock arithmetic over the license the verifier already resolved at construction — it never makes a chain call and can report `"lapsed"` without throwing (unlike `verifyAt`, which refuses outright once the license has lapsed). Use it to know *before* your next caller hits `LicenseExpired`, not to gate anything yourself. ### `verifyRegistryLicense` — the standalone check The same license check `Registry.dedicated(...)` resolution runs internally, exposed as a free function so you can check a license file without wiring up a chain endpoint at all — useful for a CI job, a support script, or the `grantor-license verify` subcommand below: ```ts import { verifyRegistryLicense } from "../../sdk/verify/ts/pkg/grantor_verify_wasm.js"; const state = verifyRegistryLicense(licenseJson, chainId, registryAddress, nowUnix); // -> "ok" | "expiring_soon" | "grace", or throws "LicenseExpired" / "BadConfig" ``` Python: `verify_registry_license`. Go: `VerifyRegistryLicense`. Rust: `grantor_sdk_core::verify_registry_license`. ## The `grantor-license` operator tool An operator-only binary (`publish = false`, not published anywhere) that issues, tracks, and checks license grants. It never talks to a chain and never phones home — the operator's whole visibility into outstanding licenses comes from a **local, git-ignored JSONL ledger** it writes to, honoring the product's no-SDK-telemetry rule. ``` grantor-license keygen --out ``` Generates a fresh ed25519 operator license key (see the key-ceremony note below — this is NOT the deploy/treasury ceremony). Writes a `0600` hex seed to `` and prints `pubkey: `. ``` grantor-license issue --key --licensee