Skip to content

mandate: a crate that mints, presents and verifies Plow mandates - #448

Merged
srosro merged 5 commits into
mainfrom
feat/mandate-crate
Nov 8, 2026
Merged

srosro merged 5 commits into
mainfrom
feat/mandate-crate

Conversation

@srosro

@srosro srosro commented Nov 8, 2026

Copy link
Copy Markdown
Contributor

Chunk 1 of the Plow Mandate build (protocol bet layer 1). Spec: plow-pbc/swarm docs/superpowers/specs/2026-11-08-mandate-design.md (https://github.com/plow-pbc/swarm/pull/253).

A new workspace crate, latch/mandate: the mandate as an SD-JWT VC (dc+sd-jwt, ES256) bound to the agent's P-256 key, closed per message by a key-binding JWT. Swarm's issuer (chunk 2), its a2a_call (chunk 3/5) and its a2a-bridge (chunk 4) will build against it, which is why it's a crate of its own and not a latch module. Latch's audit tag (chunk 5) uses it too.

  • Issuer mints from a stored grant. It refuses a principal that isn't a person (localpart without =40) and accounts on another server. sub is a pairwise pseudonym (HMAC of principal and aud). name/email are selective disclosures, only when the owner opts in. It also signs the status list (IETF Token Status List, one bit per mandate) fresh on each request.
  • Holder attaches the mandate plus a key-binding JWT (iat, aud, nonce, sd_hash, msg_hash over the JCS message without the extension's field, actor_chain_hash) to metadata["co.plow.mandate"].
  • Verifier runs §5's checks in order, deterministic and fail-closed. Keys and the status list come only from https://<act.sub's server>/.well-known/plow-mandate/…, through a caller-supplied Fetch (the caller pins https and public addresses). A status list older than 5 minutes, or one that can't be fetched, refuses the mandate. Each key-binding JWT is accepted once until exp.

Tests (chunk 1's Done-when): round trip, then one refusal each for bad signature, expired, wrong aud, cnf/key-binding mismatch, key-binding iat outside ±60 s, replayed key-binding JWT, tampered text/file/actor chain, unknown constraint type, revoked, stale status list, failed status fetch, iss ≠ the agent's server, keys from a token-supplied URL (jku, never fetched), and a non-person principal at both the issuer and the verifier. JCS is checked against RFC 8785's sorting example and ECMAScript number forms.

scripts/test.py now passes --workspace to clippy and nextest, so the gate runs the new crate's tests (they fall in core).

Notes on the spec:

  • Person check at the verifier. sub is a pairwise pseudonym, so a verifier can't read a localpart (the same problem as the re-review's N4). The issuer refuses non-person principals at mint, and the verifier refuses any sub that isn't an issuer pseudonym (urn:plow:pp: + 32 bytes).
  • Status list format. The spec says "Bitstring Status List" (W3C). This uses IETF Token Status List, the status mechanism SD-JWT VC names, signed by the issuer key. The semantics are the same: a list index, one bit.
  • Replay vs. retry (N3). An identical retry is the bridge's (caller, messageId) dedup, which runs before verify; that test lands with chunk 4. A refusal for another reason (a failed status fetch) doesn't spend the key-binding JWT. The replay cache is keyed by the key-binding JWT's signed input, because an ECDSA signature also verifies as s → n−s.
  • Replay memory is per process. One bridge runs per company server; a restart reopens at most the 15-minute exp window.

Gates run (Linux, wakeup): cargo fmt --check and workspace clippy -D warnings are clean. mandate's 6 tests pass 40 runs in a row. test.py, docs site, docs publish, install.sh and dev-login.py pass. Not green here, for host reasons: the browser and scenario suites need xvfb, which needs sudo to install. The bubblewrap-sandboxed cli and worker tests fail with bwrap: loopback: Failed RTM_NEWADDR: Operation not permitted: this host has Ubuntu's userns restriction on and the latch-bwrap AppArmor profile isn't loaded. Neither touches this diff, which adds no latch code. CI's Linux container is the gate for those. The three security/correctness fixes from local review each have a test that fails with that fix reverted.

Expected size: about +1,320 LOC net (crate ~810, tests ~470), net-additive by nature: a new library. It is over the 600-addition altitude bar. It's one PR because issuer, holder and verifier share one token format and chunk 1's Done-when is their round trip: splitting off the verifier would leave nothing to verify against.

🤖 Generated with Claude Code

https://claude.ai/code/session_01QnohWTndy1DUYTP7epAeu3

srosro and others added 3 commits November 8, 2026 06:28
An SD-JWT VC (dc+sd-jwt, ES256) bound to the agent's P-256 key and closed
per message by a key-binding JWT over the JCS message, its audience and its
actor chain. Verification is deterministic and fails closed: keys and the
status list come only from the agent's own server's well-known URLs, a stale
or unreachable status list refuses, and each key-binding JWT is accepted
once. Swarm's issuer and A2A bridge build against it.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01QnohWTndy1DUYTP7epAeu3
The gate ran cargo on the latch package alone, so the mandate crate's tests
never ran. They land in the core suite.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01QnohWTndy1DUYTP7epAeu3
…ssued index

- An ECDSA signature has a second valid form (s -> n-s), so a key-binding
  JWT's bytes aren't unique; the replay cache keys on its signed input.
- The status list is sized by the issued indexes, not the highest revoked
  one, which left every live mandate past index 127 unverifiable.
- Timestamps from a token are compared without overflow.
- grant_hash must be the hash of the constraints it travels with.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01QnohWTndy1DUYTP7epAeu3
@srosro

srosro commented Nov 8, 2026

Copy link
Copy Markdown
Contributor Author

Size ruling (sous, 2026-11-08): this PR stays one PR, untrimmed, despite being over the 600-addition bar. Issuer, holder and verifier share one token format, and chunk 1's Done-when is their round trip. A mint-only or verify-only half would have nothing end to end to test against.

Review triage for this chunk: security gaps found in review are filed as issues, not treated as blockers.

@srosro

srosro commented Nov 8, 2026

Copy link
Copy Markdown
Contributor Author

blindc adversarial security review @ 2541dda: FIX. Critical: 1, Important: 1. Everything else on the verify path holds.

Critical

  1. The server name is never validated, so a mandate from another host can name a Plow company as its issuer.
    • Code: server_of (lib.rs:159-164) takes everything after the first : in the token's own act.sub. That string is put straight into iss (:589), jwks_url and status_url (:150-156).
    • Verified with a scratch test against this crate (not pushed):
      • An issuer on evil.example set its server to acme.swarm.plow.co@evil.example and minted a mandate for @ceo=40acme.com:acme.swarm.plow.co@evil.example, disclosing email: ceo@acme.com.
      • The verifier fetched https://acme.swarm.plow.co@evil.example/.well-known/plow-mandate/jwks.json (host evil.example) and returned Ok, with iss = "https://acme.swarm.plow.co@evil.example".
      • The bridge will put that iss in swarm.mandate with verified: true (spec §4). To an agent or a person reading it, that reads as Acme's issuer.
    • ?, # and / in the name break the URL the same way: for example, server# fetches the host's root page.
    • Fix:
      • Refuse any server name that isn't Matrix server_name grammar (a hostname or IP literal, optional :port), in both server_of and Issuer::new.
      • Add a refusal test for @, /, ? and #.

Important

  1. Missing: there is no trust anchor for the issuer, so any HTTPS host can mint an issuer-attested mandate.
    • The verifier accepts any server whose own JWKS signs the token. An outsider's domain can therefore issue trust: "issuer-attested" mandates with any email/name disclosure.
    • The spec says Plow is the trust root (Trust model; I5), and federation is out of v1. Nothing here enforces that.
    • Fix: Verifier::new takes the accepted issuers, for example the *.swarm.plow.co suffix or a list. A narrower option: return name/email only when the email domain is bound to the issuer.
    • Unsettled: whether a non-Plow issuer is meant to verify in v1.

Minor

  1. Issuer::status_list panics when a revoked index is ≥ issued (:408).
  2. The verifier's document cache (:554) grows without bound, one entry per server name in the tokens it receives. The Important fix bounds it.

Checked and holding

  • Algorithm: alg is pinned to ES256 for both the mandate and the key-binding JWT. Keys must be P-256. jku/jwk/kid headers are never used.
  • Claims: exp is checked. aud is checked on both the mandate and the key-binding JWT.
  • Key binding: the key-binding JWT must be signed by cnf, its iat must be within ±60 s, and sd_hash covers the issuer JWT plus disclosures.
  • Message and actor chain: msg_hash is JCS of the raw message without the extension. The actor chain is hashed too.
  • Disclosures: each must match an _sd digest the issuer signed, at most once, name/email only.
  • Replay: keyed on jti plus the hash of the key-binding JWT's signed input, so it is immune to s→n−s, and recorded only after every check passes.
  • Status list: issuer-signed, its sub pinned, at most 5 minutes old, one bit per mandate. An index past the list or a failed fetch refuses the mandate.
  • Other refusals: unknown constraint types are refused, and so is a sub that isn't an issuer pseudonym.
  • Keys: no key material is logged or printed. Keys appear in no error message, and neither Issuer nor Holder derives Debug.

cargo test -p mandate: 7 passed (6 from the PR plus my scratch test).

The verifier took everything after act.sub's first ':' as the server and
built the issuer's https URLs from it, so `acme.swarm.plow.co@evil.example`
fetched keys from evil.example while `iss` read as Acme's. A server name must
now be Matrix server_name grammar (a DNS name or IP literal, optional port),
in the verifier and in Issuer::new.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01QnohWTndy1DUYTP7epAeu3
@srosro

srosro commented Nov 8, 2026

Copy link
Copy Markdown
Contributor Author

📋 First review of this PR — reviewed 2541dda. 🧪 Tests not run. ✅ Prior-art (KID) checked. 🔍 8 sibling repos not searched (8 allowlist; names withheld on a public repo). 📐 Complexity budget unchecked — the PR body is missing Goal, Outside users today, Minimal solution for that user count, Complexity added, Size: Expected net LOC; fill in those PR-template fields so the next review can check the budget.

It appears @srosro is working towards letting people delegate commitments to their agents with verifiable limits while controlling disclosure of their identity, by adding Issuer, Holder, and Verifier in latch/mandate for signed, message-bound mandates with expiry, revocation, and replay checks. This PR supplies the shared library foundation; the described Swarm integration comes later — reviewing against that goal.

Overview — The 1,330 additions form one cohesive protocol foundation with workspace validation. At the repo’s pre-PMF operating point—one engineer, early internal users, and a handful of hosts—removing the document cache would reduce complexity and fix a concrete issuance failure.

Strengths — Replay detection hashes the signed input to handle ECDSA signature malleability, and records acceptance only after every verification check succeeds.

Probes

  1. [medium] [from: data-integrity] [bug] The document cache rejects newly issued mandates when the status list grows beyond its cached byte capacity. After caching status_list(8, …), a mandate at index 8 fails with "index past the list" until cache expiry, even when the issuer already publishes status_list(9, …). This shares the simplification finding’s root cause: an unnecessary second freshness mechanism. Files: latch/mandate/src/lib.rs:554, latch/mandate/src/lib.rs:707, latch/mandate/src/lib.rs:773. Edit: Cut Verifier.cache and fetched; call self.fetch.get directly from keys and revoked, retaining signature and signed-age checks, and remove the cache-specific test tail — approximately −30 LOC.

    // In keys:
    let body = self.fetch.get(&jwks_url(server)).await.map_err(Refused::Keys)?;
    // In revoked:
    let body = self.fetch.get(&status_url(server)).await.map_err(Refused::Status)?;
  2. [medium] [from: data-integrity] [bug] Integer inputs bypass the documented JCS number-formatting contract. The exactly representable 1000000000000000128 is emitted literally, whereas ECMAScript/JCS emits 1000000000000000100; message hashes therefore disagree with conforming encoders for this input. Files: latch/mandate/src/lib.rs:258, latch/mandate/src/lib.rs:290. Edit: Route every Value::Number through ECMAScript-compatible number formatting, removing the n.is_f64() restriction in write_jcs (approximately 0 net LOC).

Probe dropped: additional retry coverage would fence hypothetical future drift; replay insertion already follows status validation (latch/mandate/src/lib.rs:690)—Anti-Bloat/YAGNI.

Security — None.

Test coverage — Staged just test did not run because the reviewer environment lacks cargo-nextest (exit 127); gate validation remains unconfirmed.

For AI authors — (Codex, Claude Code, Cursor, etc. reading this PR): The Probes above are load-bearing. Treat each [open] probe as a hard requirement — answer it directly in your reply or revisit the structural decision. Do NOT silently absorb a probe by adding more code; that path inverts the cost stance the probe is encoding. This repo's operating point (see its REVIEW.md) prefers cutting LOC over adding it; an [open] [simplification] probe whose answer is "no, this complexity isn't needed" should land as a deletion in your next push, not a new defensive guard.


How to use: auto-reviews every new PR and re-reviews after a period of inactivity. Trigger an incremental re-review with /srosro-update-review, or a whole-PR re-review with /srosro-review.

For humans only: push-access collaborators can post:

  • /srosro-approve — APPROVE the PR.
  • /srosro-props [from: <specialist>] — give props to a specialist's contribution.
  • /srosro-critique [from: <specialist>] — flag a specialist's contribution as a misread.
  • /srosro-memorize <feedback> — teach a calibration lesson (learn-from-replies updates COMMENT_REVIEW_MISTAKES.md from your body, sentiment-aware via LLM).

Props: /srosro-props [from: shape] caught a real layering bug we'd have shipped.
Critique: /srosro-critique [from: architecture-refined] DRY suggestion misread distinct seams.
Calibration: /srosro-memorize the architecture-refined DRY finding was a misread; those helpers serve different contracts.

AI agents must not use /srosro-memorize, /srosro-props, or /srosro-critique — those signals tune shared global state.

Generated by sam's ai review bot.

…doubles

- The verifier's document cache held a status list sized for the mandates
  issued when it was fetched, so a newer mandate read as "index past the
  list" until the cache expired. The status list's signed iat already bounds
  its age; the cache is gone.
- JCS prints every number as the IEEE 754 double ECMAScript reads, so an
  integer past 2^53 hashes as conforming encoders hash it.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01QnohWTndy1DUYTP7epAeu3
@srosro

srosro commented Nov 8, 2026

Copy link
Copy Markdown
Contributor Author

Both probes answered:

  1. [medium] document cache. Applied in 979470e: Verifier.cache and fetched are gone, and keys/revoked call fetch.get directly. The status list's signed iat (≤5 min) is still the freshness check. Regression test: one verifier verifies a mandate, the issuer's list grows, and a mandate at a higher index then verifies. It fails on the previous code with "index past the list". The cache-specific test tail is removed.
  2. [medium] JCS integers. Applied in 979470e: every Value::Number goes through the ECMAScript formatting, so 1000000000000000128 emits 1000000000000000100. That vector is added to jcs_matches_rfc_8785, and it fails on the previous code.

The reviewer couldn't run the tests. On this head, cargo test -p mandate passes 7 of 7, and clippy -D warnings and fmt are clean. CI's full Linux gate (linux (1–4), linux-restricted) was green on 2541dda and is re-running now.

@srosro

srosro commented Nov 8, 2026

Copy link
Copy Markdown
Contributor Author

Critical (server name never validated): fixed in fc63ab0. server_of and Issuer::new now accept only Matrix server_name grammar: a DNS name, an IPv4 address or a bracketed IPv6 one, then an optional :port. A server name is still read only from act.sub, iss must equal https:// + that server, and keys and the status list come only from its fixed well-known URLs. New test a_server_name_outside_matrix_grammar_is_refused_before_any_fetch covers @, /, ? and #: each is refused before any fetch, and Issuer::new refuses each. Run on the previous code, it fetched https://acme.swarm.plow.co@evil.example/.well-known/plow-mandate/jwks.json.

Important (no trust anchor): filed as #449. That one is a policy choice, not this chunk's C2 fix: cross-company verification is the point of the bet. It pulls against the current thrust if built now.

Minor 1 (status list panics on a revoked index ≥ issued): declined. A revoked index the issuer never allocated is a bug in the issuer's own store, and panicking surfaces it (fail fast).

Minor 2 (unbounded document cache): gone. 979470e removes the cache entirely (knightwatch probe 1).

@srosro

srosro commented Nov 8, 2026

Copy link
Copy Markdown
Contributor Author

/srosro-update-review

@srosro

srosro commented Nov 8, 2026

Copy link
Copy Markdown
Contributor Author

📋 Re-review of changes from 2541dda to 979470e (git diff 2541dda..979470e). 🧪 Tests not run. ✅ Prior-art (KID) checked. 🔍 8 sibling repos not searched (8 allowlist; names withheld on a public repo). ⚡ Small-diff lane (145 lines): skipped consumers — screened by aggregator. 📐 Complexity budget unchecked — the PR body is missing Goal, Outside users today, Minimal solution for that user count, Complexity added, Size: Expected net LOC; fill in those PR-template fields so the next review can check the budget.

It appears @srosro is working towards letting people authorize agents to act on their behalf while recipients can verify that each message carries a valid, unrevoked mandate without exposing unnecessary personal details. The commits and PR description frame latch/mandate as the shared foundation; the staged diff tightens that foundation by fetching issuer keys and status on every verification, restricting issuer URL construction to Matrix server names, and aligning message hashing with JCS number canonicalization — reviewing against that goal.

Overview — This update addresses both prior findings and the reported issuer URL confusion. At the repo’s pre-PMF operating point—one engineer, early internal users, and a handful of hosts—removing the cache simplifies verification while fixing the concrete status-list growth failure.

Strengths — Issuer construction and verification share the server-name validator, rejecting URL delimiters before fetching documents. The status-list regression reuses one verifier across list growth, exercising the original failure without duplicating setup.

Probes

None remain after checking the specialist conclusions, current code, prior review, and consumer changes.

Probe resolved: document caching is gone; keys and status are fetched directly while signed freshness checks remain (latch/mandate/src/lib.rs:737, latch/mandate/src/lib.rs:747).

Probe resolved: every JSON number now passes through ECMAScript formatting, including the previously failing integer vector (latch/mandate/src/lib.rs:290, latch/mandate/src/tests.rs:518).

Security — None.

Test coverage — Focused regressions cover both prior bugs and issuer URL confusion; staged just test could not run because the reviewer environment lacks cargo-nextest (exit 127), so full gate validation remains unconfirmed.

For AI authors — (Codex, Claude Code, Cursor, etc. reading this PR): The Probes above are load-bearing. Treat each [open] probe as a hard requirement — answer it directly in your reply or revisit the structural decision. Do NOT silently absorb a probe by adding more code; that path inverts the cost stance the probe is encoding. This repo's operating point (see its REVIEW.md) prefers cutting LOC over adding it; an [open] [simplification] probe whose answer is "no, this complexity isn't needed" should land as a deletion in your next push, not a new defensive guard.


How to use: auto-reviews every new PR and re-reviews after a period of inactivity. Trigger an incremental re-review with /srosro-update-review, or a whole-PR re-review with /srosro-review.

For humans only: push-access collaborators can post:

  • /srosro-approve — APPROVE the PR.
  • /srosro-props [from: <specialist>] — give props to a specialist's contribution.
  • /srosro-critique [from: <specialist>] — flag a specialist's contribution as a misread.
  • /srosro-memorize <feedback> — teach a calibration lesson (learn-from-replies updates COMMENT_REVIEW_MISTAKES.md from your body, sentiment-aware via LLM).

Props: /srosro-props [from: shape] caught a real layering bug we'd have shipped.
Critique: /srosro-critique [from: architecture-refined] DRY suggestion misread distinct seams.
Calibration: /srosro-memorize the architecture-refined DRY finding was a misread; those helpers serve different contracts.

AI agents must not use /srosro-memorize, /srosro-props, or /srosro-critique — those signals tune shared global state.

Generated by sam's ai review bot.

@srosro

srosro commented Nov 8, 2026

Copy link
Copy Markdown
Contributor Author

Acknowledged — no findings raised.

@srosro srosro added the converged knightwatch review converged on the current head label Nov 8, 2026
@srosro
srosro merged commit 9355d92 into main Nov 8, 2026
37 of 39 checks passed
@srosro
srosro deleted the feat/mandate-crate branch November 8, 2026 15:28
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

converged knightwatch review converged on the current head

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant