examples: biscuit-delegation harness (holder binding + theft scenarios) - #7
examples: biscuit-delegation harness (holder binding + theft scenarios)#7KonstantinMirin wants to merge 2 commits into
Conversation
…eft) A runnable model of the RAMP delegation token using biscuit-go v2.2.0 (Biscuit v3 format, matching token_format "biscuit-v3"). Isolated Go module — not part of the protocol module, not built by CI. Demonstrates the holder-binding guarantee end to end: a publisher mints a token bound to an agent's public key (holder() fact); the agent may attenuate offline; an Exchange verifies the chain with the publisher's public key and then enforces request_key == holder. The `demo` subcommand shows a legitimate request succeed and two theft attempts fail — one because the thief's request key != holder, one because a forged request signature fails RFC 9421 before Biscuit is consulted. Stateful subcommands (keygen/mint/inspect/attenuate/authorize) plus editable Datalog payloads let you experiment with the grant and the verification policy.
`jwt-demo` builds the same content-owner -> principal -> agent delegation from a chain of cnf (holder-of-key) JWTs (golang-jwt, EdDSA) instead of a Biscuit, to show the two are equivalent for RAMP's use cases. The chain: owner-signed authority JWT names the principal in cnf.jkt; the principal-signed delegation JWT carries the principal key in its JOSE header jwk (linking it to the authority's cnf) and names the agent in cnf.jkt; the agent signs the request (RFC 9421) and the verifier checks thumbprint(request key) == delegation cnf.jkt. Verified offline under the owner key alone — principal and agent keys arrive inside the chain. Five outcomes: legitimate ALLOWED; thief-key request DENIED (holder binding); forged request signature DENIED (RFC 9421); forged delegation DENIED (chain linkage — can't sign as the principal); over-delegation DENIED (scope can't widen). README documents the equivalence and what Biscuit would add that RAMP does not use (in-token Datalog; deep in-place attenuation).
legendko
left a comment
There was a problem hiding this comment.
Overall - good work, can be merged without significant blockers.
There is a list of small issues that could be addressed in this PR. Tell me your decision on them:
MEDIUM
I-1 — Undocumented dependency on the in-flight delegation revision.
The harness models a holder-bound delegation token (holder() / request_key in the Biscuit demo; cnf.jkt in the JWT demo) and its README attributes the "leaked token is not usable" guarantee to the authentication spec. That guarantee lives only in the in-flight PR's version of the docs (authentication.mdx "Verify holder binding … not optional and not skippable"; the rewritten threat-model.mdx T-DEL-1 "Holder binding neutralizes theft"; the proto Delegation comment). On currently-published main, the authentication spec instead says holder/possession binding applies to the retrieval URL — "never … the principal/delegation" — and the threat model says a stolen delegation token is usable, bounded only by scope/time/spend. So the example is correct against the in-flight model and inconsistent against the published one, and its PR description does not state the dependency.
- Validated: confirmed the holder-binding delegation model is absent from the current branch (0 occurrences of
cnf.jkt/not bearer-usable) and present in the in-flight PR. - Origin: this is the item the architectural/consistency/security reviewers raised as "the central premise contradicts the spec." With the in-flight PR in view it is not a contradiction or an invented guarantee — it is a sequencing dependency. The earlier Critical grade is withdrawn.
- Resolution: state the dependency in the example's README/PR description and coordinate merge so the example lands with or after the delegation PR. No code change is required for the model itself.
I-2 — Citations that a reader of this repo cannot resolve.
jwt.go and the example README cite ADR-013, ADR-016 D4 (architecture-decision records that live in the separate reference-implementation repository, not here), and M9 / "delegation-claims profile" (which appear in no RAMP document — neither the current docs nor the in-flight PR). The design these gesture at is real and is what the in-flight PR lands; the identifiers are the problem — cross-repository internal references and two labels that exist nowhere.
- Validated: none of
ADR-013,ADR-016,M9, or "delegation-claims profile" exist anywhere in this repository (current or in-flight). The scope-coverage rule the code calls "M9" corresponds to the in-flight spec's plain "each child's scope is covered by its parent" wording, which carries no such label. - Resolution: remove the cross-repo ADR identifiers and the non-existent
M9/ "delegation-claims profile" labels. Once the delegation PR merges, cite this repo'sauthentication.mdxverification steps instead.
I-3 — Wrong RPC path in the canonical request.
main.go:496 and jwt.go:248 sign /ramp/v1/ramp.v1.ExchangeService/Query. There is no Query RPC (the service exposes DiscoverResources, ExecuteTransaction, ReportUsage, DisputeTransaction, RequestDomainVerification, ConfirmDomainVerification), and Connect paths are /ramp.v1.ExchangeService/<Method> with no /ramp/v1/ prefix (verified against the generated rampv1connect stubs; the published auth doc itself shows /ramp.v1.ExchangeService/DiscoverResources).
- Severity: Medium — a reader-facing factual error, though it sits inside the deliberately-simulated request string and has zero functional impact (the same opaque bytes are signed and verified).
- Resolution: use a real path, e.g.
/ramp.v1.ExchangeService/DiscoverResources.
I-4 — cmdMint panics on a short-but-valid-hex -holder.
main.go:262 (holderHex[:16]) panics with a slice-bounds error when -holder is valid hex but not 32 bytes, because the key parser validates hex-ness but not length. Reproduced. Side effect: a wrong-length holder key would otherwise be minted into the token unchecked.
- Resolution: enforce
len(raw) == ed25519.PublicKeySizein the public-key parser (also closes the unchecked-holder gap).
I-5 — Replay is not addressed; "theft-resistance" is overstated.
The simulated RFC 9421 signature (verifyRFC9421) covers a static canonical string with no created/nonce/timestamp, so a captured signed request (token plus signature) replays verbatim. The binding stops token-copy + attacker's new request, not whole-request replay — a distinction the "theft-resistant" framing doesn't make. Inherent to the acknowledged RFC 9421 simulation, but the limitation should be stated.
- Resolution: qualify the claim; note that real RFC 9421
created/noncecomponents cover replay.
LOW
I-6 — biscuit-v3 is the optional format, not the "default." The value biscuit-v3 is correct against the in-flight PR (which moves token_format to jwt default / biscuit-v3 optional). But the README's "matching RAMP's default token_format: biscuit-v3" mislabels it as the default — the in-flight default is JWT — and contradicts the harness's own "JWT is the leaner default" line elsewhere. (Against currently-published main the value is also ahead of biscuit-v2; that resolves when the PR merges.) Resolution: say "optional format (JWT is the default)."
I-7 — Biscuit-demo role labels diverge from the spec's role chain. The JWT demo's owner → principal → agent matches the in-flight spec's delegation chain exactly. The Biscuit demo labels the same issuer role "publisher," which neither matches the JWT demo nor the spec's principal/owner terminology. Resolution: align the Biscuit demo's role vocabulary with owner/principal/agent.
I-8 — Biscuit holder() / request_key() Datalog is illustrative, not spec-prescribed. The in-flight PR specifies the JWT cnf model precisely (which the JWT demo matches) and does not prescribe a biscuit-v3 Datalog fact set; it also removes the old user() / scope() authority-fact list the current docs carry. The Biscuit demo's holder() fact + authorizer request_key() check is therefore a reasonable illustration of holder binding for the optional Biscuit profile, not a deviation from a prescribed fact set. Resolution: note in the README that the Biscuit Datalog is illustrative and the JWT cnf chain is the specified model.
I-9 — DRY. The demo re-implements mint/attenuate inline rather than calling the subcommand cores (drift risk between the thing people run and the thing it illustrates); report and reportJWT are byte-identical; the step closure, the default-Datalog/file-override block (×3), the fact-injection block (×4), the overlapping public-key helpers, and the canonical-request literal are all duplicated. ~70–95 LOC mechanically removable; the demo↔subcommand duplication and the two free deletions are the ones worth doing. Defensible to leave the rest in a teaching harness.
I-10 — Nested module planted in the protocol namespace. The example's module path sits inside the protocol module's import namespace while being a separate module. Isolation is verified working (the root module does not traverse it; nothing imports the protocol module or generated code). A naming smell, not a break. Resolution: move the module path out of the namespace or document the choice.
I-11 — Licensing & repo-guide drift. examples/ is a new top-level directory outside the documented license split, with no license pointer/headers; the repo guide still states "no Go binaries / no runtime services," now contradicted by a runnable binary and a second module. Resolution: place examples/ under the appropriate license and update the repo guide.
I-12 — gofmt non-compliance. gofmt -l flags both Go files (alignment only). Resolution: gofmt -w.
I-13 — go.mod toolchain over-pin. The module pins a patch-level go 1.26.1 vs the repo-wide go 1.26. Resolution: go 1.26.
I-14 — base64Digest misnamed. It returns hex of the first 8 bytes, not base64; the content-digest: sha-256=:…: framing misrepresents the RFC 9421/9530 format. Harmless (opaque) but a credibility ding. Resolution: rename and mark it a stub.
I-15 — README "two theft attempts" vs three. The quick-start says two; the Biscuit demo runs three (A/B/C). Resolution: say three.
I-16 — Holder representation differs across demos, unexplained. The Biscuit demo binds the raw hex public key; the JWT demo binds the RFC 7638 thumbprint (cnf.jkt). The spec's binding is the thumbprint, so the JWT side matches and the Biscuit side does not. Resolution: note the difference, or bind the Biscuit holder by thumbprint too.
I-17 — JWT exp not required. verifyJWTChain validates exp when present but accepts a token lacking it. Resolution: require expiration.
I-18 — Simulation comment overstates what is proven. The verifyRFC9421 docstring implies full request authentication, but it trusts the key passed in; production resolves the key from {domain}/.well-known/ramp.json by keyid (the key→domain step, scoped out of the harness). Resolution: add a caveat in the comment.
I-19 — "Re-bind a stolen token" explanation is imprecise (mechanism is correct). The narration says an appended holder(thief) is "not trusted by the authorizer check"; more precisely it is invisible — Biscuit evaluates attenuation-block facts only in an isolated per-block scope, so the appended fact can never satisfy the authorizer's holder check. The defense itself is genuine and is a highlight. Resolution: tighten the wording.
|
Closing — this was a demo/experiment harness (biscuit delegation walkthrough), not intended to merge. Superseded by the holder-binding work landing via the protocol/auth PRs. |
A runnable Go harness that turns RAMP's delegation / holder-binding model into something you can mint, attenuate, inspect, and attack — with real cryptography, in both a Biscuit and a JWT realization.
Why this exists / motivation
The authentication spec (
authentication.mdx) asserts that a leaked RAMP delegation token is not bearer-usable — copying the token bytes does not let you use them. That is a cryptographic claim, and so far it lives in prose. This harness makes it concrete: it mints actual tokens, lets you inspect their Datalog/claims, and runs the theft scenarios so the guarantee can be observed failing for an attacker rather than taken on faith.It also exists to answer a standing design question — "do we actually need Biscuit?" — by building the same holder-binding guarantee a second way, from a chain of
cnf(holder-of-key) JWTs, and showing the two are functionally equivalent for RAMP's shallow (owner → principal → agent) delegation. That keepstoken_formatan honest choice: Biscuit buys in-token Datalog and deep offline attenuation; JWT + RFC 9421 are leaner and already ubiquitous.This is an examples PR, not a proto-contract change. The harness is an isolated Go module under
examples/biscuit-delegation/— not part of the protocol module, not built by CI, no dependency added to the contract.What it demonstrates / data flow
Two cryptographic facts must both hold for access, and they are independent:
{domain}/.well-known/ramp.json).The delegation chain (content owner → principal → agent):
In the Biscuit realization the binding is one line of Datalog the Exchange always applies, fail-closed:
check if holder($k), request_key($k). In the JWT realization it is the chain-linkage invariant — each token's signer must be the key its parent pinned (cnf.jkt), and the request key's thumbprint must equal the leaf delegation'scnf.jkt(RFC 7638 thumbprint / RFC 7800 holder-of-key, the same confirmation method DPoP uses).The attack scenarios, run end to end:
request_key != holder. Has chain validity (1), never has the binding (2).cnf, so the chain link is rejected (the thief has no principal key).max_spend_cents→ DENIED by the defense-in-depth cap.Concrete changes (reading the diff)
New isolated module under
examples/biscuit-delegation/:main.go— the Biscuit CLI.demoruns the full scenario; the stateful subcommandskeygen,mint,inspect,attenuate,authorizelet you drive each step by hand against on-disk tokens/keys. Library:biscuit-auth/biscuit-go/v2 v2.2.0, emitting/verifying the Biscuit v3 wire format (version: 3), matching RAMP's defaulttoken_format: "biscuit-v3".jwt.go— the JWT capability-chain variant (jwt-demo).verifyJWTChainis the whole offline verification an edge/Exchange performs;thumbprint/scopesCovered/scopeCoversimplement RFC 7638 thumbprints and the segment-wise scope rule. Library:golang-jwt/jwt/v5(EdDSA).io.go— token (de)serialization and the quote-aware Datalog comment stripper that lets the-datalogpayload files carry//comments (preserving//inside string literals such ashttps://URLs).README.md— the model, the role/key table, a step-through, how to edit the Datalog/claims payload, the field-by-field mapping back to RAMP (Delegation.token,holder()↔ delegation-claims subject,scope()↔Delegation.scopes, caps/expiry as defense-in-depth), and the Biscuit-vs-JWT equivalence argument.go.mod/go.sum— the isolated module manifest;.gitignoreexcludes generated*.key/*.pub/*.bcartifacts and the compiled binary.Reviewer entry points: read
README.mdfor the model, thengo run . demoandgo run . jwt-demo;main.go's package doc andjwt.go'sverifyJWTChaincarry the load-bearing logic.Out of scope
.protoedits, no new dependency on the protocol module. The harness is standalone and CI-excluded..well-known/ramp.jsonfetch — the publisher/owner public key is passed in directly; the harness models the trust anchor, not its retrieval.