Skip to content

examples: biscuit-delegation harness (holder binding + theft scenarios) - #7

Closed
KonstantinMirin wants to merge 2 commits into
mainfrom
examples/biscuit-delegation
Closed

examples: biscuit-delegation harness (holder binding + theft scenarios)#7
KonstantinMirin wants to merge 2 commits into
mainfrom
examples/biscuit-delegation

Conversation

@KonstantinMirin

@KonstantinMirin KonstantinMirin commented Jun 17, 2026

Copy link
Copy Markdown
Contributor

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 keeps token_format an 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:

  1. Chain validity — the token verifies offline under the publisher/owner's public key alone (the only trust anchor; the Exchange retrieves it from {domain}/.well-known/ramp.json).
  2. Holder binding — the key that signed the HTTP request (learned by verifying the RFC 9421 signature) equals the holder the token names.

The delegation chain (content owner → principal → agent):

  • The publisher/owner signs an authority grant (scopes, spend cap, expiry) and binds it to the next party's public key.
  • The principal attenuates offline — narrows scope, shortens TTL — with no callback to the issuer, and binds the result to the agent's public key.
  • The agent signs the actual request (RFC 9421) with its own private key.
  • The Exchange verifies the chain under the owner's key, then enforces the binding.

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's cnf.jkt (RFC 7638 thumbprint / RFC 7800 holder-of-key, the same confirmation method DPoP uses).

The attack scenarios, run end to end:

  • Legitimate request — agent signs with its own key → ALLOWED.
  • Theft A (stolen token, attacker's key) — thief copies the token bytes and signs with the thief's key → DENIED: request_key != holder. Has chain validity (1), never has the binding (2).
  • Theft B (forged request signature)DENIED by RFC 9421 before the token is even consulted; there is no authenticated key to bind.
  • (JWT) Theft C (forged delegation) — thief mints its own delegation naming itself as holder → DENIED: the forgery is signed by the thief's key, whose thumbprint ≠ the authority's cnf, so the chain link is rejected (the thief has no principal key).
  • (JWT) Over-delegation — principal tries to grant a scope it was never given → DENIED: a delegation cannot widen beyond its grant (segment-wise scope coverage).
  • (Biscuit) Spend over cap — request above max_spend_centsDENIED by the defense-in-depth cap.

Concrete changes (reading the diff)

New isolated module under examples/biscuit-delegation/:

  • main.go — the Biscuit CLI. demo runs the full scenario; the stateful subcommands keygen, mint, inspect, attenuate, authorize let 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 default token_format: "biscuit-v3".
  • jwt.go — the JWT capability-chain variant (jwt-demo). verifyJWTChain is the whole offline verification an edge/Exchange performs; thumbprint/scopesCovered/scopeCovers implement 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 -datalog payload files carry // comments (preserving // inside string literals such as https:// 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; .gitignore excludes generated *.key / *.pub / *.bc artifacts and the compiled binary.

Reviewer entry points: read README.md for the model, then go run . demo and go run . jwt-demo; main.go's package doc and jwt.go's verifyJWTChain carry the load-bearing logic.

Out of scope

  • No change to the protocol contract — no .proto edits, no new dependency on the protocol module. The harness is standalone and CI-excluded.
  • RFC 9421 request signing is simulated with a direct Ed25519 sign/verify over a canonical request string; the harness demonstrates the binding, not a full HTTP message-signature implementation.
  • No .well-known/ramp.json fetch — the publisher/owner public key is passed in directly; the harness models the trust anchor, not its retrieval.
  • Spec scope-matching semantics (segment-wise coverage) are illustrated, not normatively defined here; the spec remains the source of truth.

…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 legendko left a comment

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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's authentication.mdx verification 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.PublicKeySize in 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/nonce components 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.

@KonstantinMirin

Copy link
Copy Markdown
Contributor Author

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.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants