Repository navigation
Description
Follow-up to #742. That issue proposed carrying the delegation chain in the border JWT; the review in #750 dropped that part. This issue is about the problem underneath it.
eclipse-biscuit/biscuit-go#186 brings third-party blocks to Go. With them, each agentmesh node can sign its own delegation hop with its libp2p key, offline. That closes a gap in the ban list: today, banning a compromised node doesn't reach the delegations re-exchanged downstream of it.
Problem
Say node O is compromised, for example because its agent is prompt-injected, and drives requests on a user's behalf to node E, which calls another node, P.
- Past the first hop, E can't act as the user. The provider strips
X-Mesh-*, Biscuit included, before the backend (node.go:2531), so E's agent gets the user's identity as plain headers but no credential. Today it either calls P as node E, and the user drops out, or the user's raw JWT has to reach it some other way so its node can re-exchange it atPOST /token/exchange. In the second case, every agent on the path holds a bearer credential that isn't bound to any node. - Banning O stops O's own calls. The control plane and the nodes reject a token whose
node,actor_nodeorclient_peer_idis banned (controlplane/sts.go:646,node/sts.go:96,node/middleware.go:443). - It doesn't reach E's requests, in either case.
- If E calls with its own identity, its token doesn't mention O. Those calls keep working for as long as E is trusted.
- If E re-exchanged the user's JWT, the result is a new root carrying
actor_node(E)andclient_peer_id(E)(mintDelegatedBiscuitFromJWT), which doesn't mention O either. It keeps working until it expires, up toDelegatedBiscuitTTL(one hour by default), and the forwarded JWT can be exchanged again from any other node.
| Tool | What it does here |
|---|---|
| Ban E | Stops E for every user, legitimate traffic included. |
| Revoke the user's tokens | E's token is a new root, so revoking the one O held doesn't reach it. Banning the user's identity stops all of their traffic. |
| Short TTLs | Bound the window but don't close it. |
client_peer_id in the Border Crossing lines (#750) |
Finds the requests afterwards, but can't stop them at request time. |
Proposal: node-signed delegation hops in third-party blocks
Everything happens inside the mesh. The border JWT and the control plane's role don't change.
Biscuit (block 0 from the control plane: the user, roles, bound to O)
+ third-party block signed by O: delegation("<O>", "<E>")
+ third-party block signed by E: delegation("<E>", "<P>")
- Signing. Each node signs with its libp2p Ed25519 key. The peer ID embeds the public key, so a verifier needs no lookup, and a rule
trusting ed25519/<key of E>sees only the facts E signed. - What P checks when E presents the token:
- block 0 is bound to O, and the chain starts at O;
- each hop is signed by the node it starts from;
- the presenting peer signed the last hop, and the last hop names P;
- no signer is banned.
- Consent is built in. O signs the hand-off to E, so a node that merely saw the Biscuit can't extend it. A sealed Biscuit ends the chain.
- The user's JWT is used once, at the entry node. The agent never holds a credential: its node gives it a short-lived, per-request handle and appends the hop for it.
- Banning O stops every request that passed through O, on every hop, while E keeps serving the rest. The delay is the propagation of
/revocations, not the token TTL. - No control-plane round trip. It keeps the control plane off the per-request hot path (
security-architecture.md§6, invariant 2). - Per-node policy. A provider can add its own policy over the chain in its
attenuation, for example a depth limit.
What it needs from biscuit-go
eclipse-biscuit/biscuit-go#186 already has:
- Third-party blocks.
Biscuit.ThirdPartyBlockRequest,ThirdPartyBlockRequest.SignRequest(takes the node's Ed25519 private key; a libp2p key converts withed25519.PrivateKey(raw), whererawcomes fromPrivKey.Raw()) andBiscuit.AppendThirdPartyBlock. The spec's third-party sample (test024) passes. - No extension of a sealed token.
ThirdPartyBlockRequestfails on a sealed token.
Still missing there:
- Public key interning across blocks. Spec sample
test026is still skipped. A token that crosses several hops carries one key per node, so agentmesh needs this.
biscuit-wasm and biscuit-python, which the SDKs use, already support third-party blocks.
What it needs in agentmesh
- Accept third-party blocks that carry exactly one
delegationfact signed by an enrolled node.UnmarshalInboundaccepts onlytar_blocktoday. - Let the request binding follow the chain. It checks only block 0 today.
- Make the ban check walk the signers.
- Give the agent a per-request handle, so it can ask its node to delegate on its behalf.
Alternative considered
The control plane could re-mint the Biscuit at each hop, with the chain in block 0. That works with today's biscuit-go, but it puts the control plane on the request path for every new delegation.
Open questions
- The shape of the per-request handle, and how long it lives.
- The depth cap.
MaxAttenuationBlocks = 8bounds it today.
Related
- controlplane: Border Crossing lines name the origin peer; act.sub is the egress node #750:
client_peer_idin the/sts/tokenBorder Crossinglines. - draft-ietf-oauth-transaction-tokens: a replacement Txn-Token must maintain the call chain, but the draft leaves the mechanism out of scope.
Activity
ignaciojaneiro commentedon Oct 20, 2026
@aojea following up on your review in #750: this is the problem I was trying to get at. How would you contain a compromised node at request time today, without waiting for the tokens to expire? If nothing covers it, does building this on the third-party blocks in eclipse-biscuit/biscuit-go#186 look reasonable to you?