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 three modes. agent-zk and user-sig are the two your app verifies, and one verifier call (verify_deed) handles both. admin-sig is a third 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 below).

ModeWhoProvesOn-chain check
agent-zkan enrolled agentZK membership of the tenant's registry, without revealing which memberroot recency (revocation) + tenant status (billing)
user-siga human with a walletcontrol of a wallet-derived, app-scoped keytenant status (billing) only
admin-sigGrantor's own dashboard admincontrol of a wallet addressnone, deliberately

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:

TypeScriptPythonGoRust
user-sig holder
agent-zk holder (ZK proving)
admin-sig holder
Deed verification
Deed guard (challenge store, single-use burn)

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.

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 — SP7 tree-sync; 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 feature (non-default — it pulls in semaphore-rs + chain RPC, so light builds that only verify standard OIDC JWTs stay lean):

# `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"] }

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.

// GET /challenge — the guard mints and remembers it.
#[handler]
fn issue_challenge(Data(state): Data<&Arc<RpState>>) -> Json<serde_json::Value> {
    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<RpState>>) -> PoemResult<Json<serde_json::Value>> {
    // 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>AlloyGate::new(rpc_url, registry_address) 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.

Production note on state.challenges: the example above is a plain HashSet<String> 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.

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 chainRegistry (the on-chain registry address YOU trust, pinned out-of-band) — see Constructing a chainReader below for where these come from and why both are required together:

import { authenticate } from "@grantor/agent";

const deed = await authenticate("https://api.example.com", signMessage, {
  chainReader,
  chainRegistry: REGISTRY,
});
from grantor_agent.authenticate import authenticate

deed = authenticate(
    "https://api.example.com", sign_message,
    chain_reader=chain_reader,
    chain_registry=REGISTRY,
)
deed, err := authenticate.Authenticate("https://api.example.com", sign, &authenticate.Options{
	ChainReader:   chainReader,
	ChainRegistry: REGISTRY,
})

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 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). chainRegistry/chain_registry/ChainRegistry — the registry address chainReader is queried against — is REQUIRED alongside it: the document's own chain.registry is compared against this pin and a mismatch is refused, never used as the eth_call target directly (see Origin provenance for why: a hostile origin can publish any contract it likes as chain.registry, including one whose isOriginVoucher always answers true). Get REGISTRY 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.

Browser (window.ethereum) — a wallet provider already speaks this shape:

// `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):

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):

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):

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 below for why this matters.

For agent-zk (an already-enrolled agent proving ZK membership — see Minting an agent-zk deed 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; const deed = await agent.mintDeed( rpcUrl, registryAddress, d.tenant, d.audience, origin, challenge, exp, v.signature, v.epoch, v.exp, 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 items 3 and 4.

It shipped without that check, in every language at once, which was finding S2: 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 was no authenticate()-style wrapper for agent-zk to hide the gap in — mintDeed is the only agent-zk mint API there has ever been.

⚠️ 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 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): 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), 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.

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.
  1. user-sig pseudonym harvesting: closed. derive_app_key folds origin into its HKDF info (tenant:{t}|origin:{o}, usersig.rsaud left that scope with the S1 fix; 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 was finding S1, and it is fixed separately, by origin-scoping the SEED — see "User login" below.

  1. agent-zk cross-origin linkability: CLOSED by prevention (S2 fixed 2026-07-29). 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 SP7 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 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 now 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. It shipped without one — that was finding S2 — and the gap was in every language at once, because mintDeed is the only agent-zk mint API and no authenticate()-style wrapper exists for it. The ordering, not merely the error, is pinned per language; see "How the ordering is proven" below.

  1. agent-zk membership disclosure: CLOSED by prevention (S2 fixed 2026-07-29). Left un-narrowed, 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 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 now covers both the user-sig holder path (authenticate() and signInWithDeed()) and the agent-zk mint path (ZkAgent.mintDeed), in all four languages. See Origin provenance below for what this closes today, what it still does not, and its cost.

⚠️ CORRECTED 2026-07-30 — this paragraph used to say the agent-zk mint path had no provenance check and both items above remained open; that was true when written and is now stale. S2 closed ZkAgent.mintDeed's provenance gap on 2026-07-29, 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). Since the 2026-07-30 architecture audit, 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 now marshals into rather than duplicating the check per shim. Nothing about what is or is not enforced changed: the check is still holder-side, not RP-enforceable — see "Only as good as a holder that runs the check" below, which stands unmodified.

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.

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_vouchrequired, 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):

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 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) — closed as finding S2, 2026-07-29; see Origin binding items 3-4 above for the ordering proof (each language asserts proving was never entered, not merely that an error came back). Since the 2026-07-30 architecture audit, 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 — the earlier per-shim duplication is exactly the shape of gap that let S2 happen in the first place.

⚠️ CORRECTED 2026-07-30 — the two paragraphs above used to say ZkAgent.mintDeed had "no provenance check at all" and that both agent-zk items "remain open in every language today." That was true when written and is now stale; it is fixed as of the S2 close referenced above. This correction does not change the caveats below — 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) stands exactly as written.

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.

⚠️ CORRECTED 2026-07-29 — Part A is NOT an independent backstop, and the claim that used to sit here was false. It described Part A's origin-scoped key derivation as a backstop for exactly this case, "a property the holder gets automatically, from deriving correctly, with no separate check to skip". That does not hold against a hostile origin, which is the only threat it was invoked against: Part A scopes the per-app KEY, but the origin is an argument the deriving party supplies, and in a browser the attacker's page is the deriving party. "The holder derives correctly" protects only a holder that is doing the deriving. This was finding S1 (full impersonation), reproduced end to end and now fixed — the root seed is origin-scoped and the signed message is prose naming the origin. See "What S1 cost, and what it did not buy" under User login for what the fix does and does not buy, and crates/grantor-verify/tests/usersig_root_seed_scope.rs for the regression pin. The correction to this section stands regardless of that fix: there is no automatic, unskippable holder-side backstop behind the origin provenance check. A holder that skips the check 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 explicitverifyOwnOriginVouchAt (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.

⚠️ Both halves were Rust-only until 2026-07-29 — finding S3. The ceiling and the warning lived in verify_own_origin_vouch, which had no FFI export, so TS/Python/Go reached the vouch only through the document builder and that validated shape alone. An operator in three of four languages booted clean while serving a vouch every holder rejects, and got 100% login failure with no diagnostic — precisely what a self-check exists to prevent. The evidence it was not theoretical: the TypeScript adapter's own test fixture shipped a vouchExp about 317 years in the future, and one Rust e2e fixture passed u64::MAX. Neither is a value any deployment could produce, and a fixture like that stops testing the real path.

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 RustuserRootBinding, 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.

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, which is what this used to advertise. That was finding S1: a
// single fixed message meant any site holding one `personal_sign` derived
// the holder's key at every other relying party. See "What S1 cost" 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.

What S1 cost, and what it did not buy#

S1 was a full-impersonation finding: derive_root_seed signed one fixed opaque constant identical at every origin, so any site that obtained that single personal_sign derived the holder's key at every relying party. The fix scopes the seed per origin and makes the signed message human-readable prose naming that origin, mirroring admin-sig's binding.

Do not describe this 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 the fix does buy, unconditionally: a seed captured at one origin is useless at any other, so compromise is contained to the origin the user actually signed at.

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.

ScenariosubChain validation
Same wallet, 2 audiences, same tenant + origin1 sharedaud is not in the key scope, so a role can name the subjectOne check: that tenant's status
Same wallet, 2 origins, same tenant2 different, unlinkableOne check: that tenant's status
Same wallet, 2 apps, different tenants2 different, unlinkableTwo independent per-tenant checks
1 user or 10M usersIdentical; 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.

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.

What the verifier checks#

This section covers verify_deedagent-zk and user-sig only. admin-sig has its own, shorter checklist in Dashboard login above.

verify_deed(&deed, &policy, &expected_challenge, now, &gate) runs, in order. Steps 1–4 are shared; step 5 dispatches on mode.

  1. Token version + modetoken.v/token.mode must be one this verifier supports (MODE_AGENT_ZK or MODE_USER_SIG).
  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 ceilingexp 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. 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) 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 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 tree depth SP7 pins (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 = revocationgate.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 = billinggate.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 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.

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#

just test-sovereign   # cargo test -p grantor-verify --features sovereign

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.

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