BLA
BLA (Blind LLM Agents) is the open-source Blue Defense MCP and CLI built to protect software artifacts against automated AI reverse-engineering workflows.
RFC 8693 exchange with nested act, sender-constrained by RFC 9449 DPoP, short-lived tokens, a revocation list enforcement points poll, and public keys anybody can verify offline. Revoking ends authori
SecurityGo · GherkinGitHub @TAIPANBOXOnlineOwner unconfirmed
Have you tried it? One tap is enough. No sign-in needed.
Owners of public repositories under a personal GitHub account can sign in to verify ownership and manage the introduction themselves. No administrator approval is needed.
Sign in, generate a verification file, and commit it to this repository’s default branch within 30 minutes. Files on other branches or forks do not count. Then check the file below to manage the project without administrator approval. Keep the file to retain your access.
If needed, you will go to sign-in. After signing in, return here and press your chosen verification button again.
Run /nomorevibe verify in your project folder to verify ownership and manage the introduction yourself.
Verify in one minuteAsk about other verification options
RFC 8693 delegation tokens for agents, sender-constrained by DPoP. A delegation an agent can prove it holds, and a person can end.
Taken from the repository README
Loading the full README.
Read the full README ↗A delegation an agent can prove it holds, and that a person can end.
RFC 8693 token exchange with nested act, sender-constrained by RFC 9449 DPoP.
Short-lived tokens, a revocation list an enforcement point can poll and act on,
and public keys anybody can verify against offline.
The same service as its room on it-rat.com draws it.
The same service as its room on it-rat.com draws it, lifted from that page so the two cannot drift apart.
Vouchryx is the delegation plane: it issues the proof the Agent Passport spec deliberately does not, and it is the only place in the stack where an authority can be ended without waiting for anything to expire.
flowchart TB
Agent["AI agent (any framework)"] -->|"LLM call (base-URL swap)"| TF["TokenFuse proxy: spend + enforcement"]
TF -->|"POST /v1/decide (PEP)"| WX["Wardryx: policy PDP"]
WX -.->|"allow / deny / hold"| TF
TF -->|"cheapest model, budget OK"| LLM[("LLM provider")]
TF -->|"CallRecords"| CL["TokenFuse Cloud: control plane, incidents, replay, evidence, kill-switch"]
VCX["Vouchryx: delegation proved, and endable"] -->|"short-lived token: act + cnf"| TF
TF -.->|"polls /v1/revocations"| VCX
VCX ==>|"delegation_issued / denied / revoked"| BUS
TF ==>|"agent-event NDJSON"| BUS{{"agent-event bus + Agent Passport"}}
WX ==> BUS
Agent -->|"web fetch"| SCX["Scopyx: governed web egress"]
SCX -->|"POST /v1/decide"| WX
SCX ==>|"web_fetch / web_blocked"| BUS
ENG["Engram: memory"] -->|"reflect via base_url"| TF
ENG ==> BUS
BUS ==> IDX["Idryx: identity graph, detectors, Agent-BOM"]
IDX ==>|"identity_finding"| BUS
BUS ==> QX["Qryx: crypto / PQC, passport + hash-chain scan"]
QX ==>|"crypto events"| BUS
BUS ==> VX["Verdryx: quality / drift"]
VX ==>|"quality events"| BUS
TF -->|"outcome-tagged traces"| VX
MX["Mockryx: pre-prod safety rehearsal"] -->|"hostile scenarios"| TF
MX ==>|"sim events"| BUS
BILL[("cloud, SaaS and model bills")] --> CC["CostCrew: the bill, worked by a crew of agents"]
CC ==>|"spend_spike / budget_threshold / crew moves"| BUS
BUS ==> TRX["Trailryx: the record plane, sealed and packed"]
BUS ==> HX["reads the log, mails you (heraldyx)"]
HX -->|"one mail, a view and never an action"| OPS["your mailbox"]
HX ==>|"alert_sent"| HJ[("heraldyx's own hash-chained journal, not this bus")]
YOU(["you, in a browser over your own tunnel"]) --> GX[["Genaryx: the console over all of it"]]
GX -->|"signed commands: the kill, an approval, a policy"| CL
GX -->|"signed commands"| WX
GX ==>|"console_command"| BUS
GX -.->|"reads it"| IDX
GX -.->|"reads it"| QX
GX -.->|"reads it"| VX
GX -.->|"reads it"| MX
GX -.->|"reads it"| ENG
GX -.->|"reads it"| SCX
GX -.->|"reads it"| CC
TFP["terraform-provider-taipan"] -->|"budgets + passports as code"| CL
ASG[["agent-stack-go: shared Go contract"]] -.->|imported by| IDX
ASG -.->|imported by| WX
ASG -.->|imported by| MX
ASG -.->|imported by| TFP
ASG -.->|imported by| HX
ASG -.->|imported by| QX
SPEC[["agent-passport: the spec"]] -.->|governs| BUS
agent-passport SPEC section 2 disclaims two things deliberately:
Not an authentication protocol. The Passport names an agent; it does not prove possession.
Not a freshness claim. The delegation chain records who acted on behalf of whom, not when.
So the estate holds the record of a delegation and points at a mechanism
that did not exist. This is that mechanism. Nothing here replaces
on_behalf_of; this is what makes it provable and what lets it be ended.
TokenFuse's kill switch stops money: a 402 mid-run, before the provider bills. Revoking a delegation stops authority: the right to act on somebody's behalf ends at every enforcement point at once, whatever the token says. Same switch, different axis, and the second is the one an incident needs.
POST /v1/token | RFC 8693 exchange (grant_type=urn:ietf:params:oauth:grant-type:token-exchange). Input: subject_token and actor_token, plus a DPoP header. Output: a short-lived JWT with nested act and cnf.jkt. Since 2026-09-24, the same route also runs Cross App Access's jwt-bearer grant (grant_type=urn:ietf:params:oauth:grant-type:jwt-bearer, assertion=<ID-JAG>, client_secret_basic); see "Cross App Access" below. And grant_type=client_credentials: an agent's own five-minute credential, for an agent enrolled in VOUCHRYX_AGENTS, with a mandatory DPoP header; see "An agent's own credential" below. |
POST /v1/revoke | By jti for one token, or by subject for every token naming that agent anywhere in its chain: at this door since 2026-09-17, and at the enforcement points from agent-stack-go#61 and tokenfuse#298 on. actor and reason are required. A jti revocation ends one token, not the ones already exchanged from it. With "ban": true (a subject only) it is also a ban: nothing names that party again until the ban is lifted. |
POST /v1/revoke/lift | Ends a ban. Same keys as /v1/revoke; subject, actor and reason required, and the subject must be banned. It never un-revokes: what the party held when it was banned stays revoked. |
GET /v1/revocations | What enforcement points poll, with no credential. Carries as_of, so an empty list and an unreachable service are not the same answer. Each entry carries only what a verifier needs to refuse a token: jti, subject, issued_before, expires. It does not carry the actor or the reason of the revocation: those stay in the operator's record (the event stream and the VOUCHRYX_REVOCATIONS_PATH store), so the public list does not say who revoked a token or why. |
GET /.well-known/jwks.json | Public keys, so verification is offline. |
GET /.well-known/oauth-authorization-server | RFC 8414 metadata: this service's own endpoints and grants. Names no trusted issuer and no configured client. |
There is deliberately no introspection endpoint. It would put this service on the request path of every enforcement point at once.
Pull the published image (ghcr.io/taipanbox/vouchryx:v0.6.1, built for
linux/amd64 and linux/arm64 by .github/workflows/release.yml, signed
keyless and carrying an SBOM and a provenance attestation), or build from
source.
docker pull ghcr.io/taipanbox/vouchryx:v0.6.1
docker run --rm -p 4310:4310 \
-e VOUCHRYX_ADDR=0.0.0.0:4310 \
-e VOUCHRYX_ISSUER=https://vouchryx.example.com \
-e VOUCHRYX_SIGNING_KEY=/keys/signing.pem \
-e VOUCHRYX_TRUSTED_ISSUERS='https://idp.example.com|https://vouchryx.example.com|/keys/idp.jwks.json' \
-v "$(pwd)/keys":/keys:ro \
ghcr.io/taipanbox/vouchryx:v0.6.1
The three required variables are VOUCHRYX_ISSUER, VOUCHRYX_SIGNING_KEY (unless VOUCHRYX_SIGNER=keyryx) and
VOUCHRYX_TRUSTED_ISSUERS (see Configuration, below); a missing one aborts the
process, exit code 2, before it binds a socket. vouchryx-demo ships in the
same image; reach it with docker run --entrypoint vouchryx-demo ... instead.
The image itself binds 127.0.0.1:4310 by default, same as the binary; a
launcher that wants it reachable from outside the container sets
VOUCHRYX_ADDR=0.0.0.0:4310 itself, as the example above does.
Or build from source (Go 1.27+):
go build -o vouchryx ./cmd/vouchryx
go build -o vouchryx-demo ./cmd/vouchryx-demo
The first tagged release is v0.1.0; later tags are listed on the Releases page.
Every image is signed keyless with Sigstore and carries a build-provenance
attestation and an SBOM, from the first tag on. With cosign and gh
installed:
cosign verify ghcr.io/taipanbox/vouchryx:v0.6.1 \
--certificate-identity-regexp '^https://github.com/TAIPANBOX/vouchryx/\.github/workflows/release\.yml@refs/tags/v' \
--certificate-oidc-issuer https://token.actions.githubusercontent.com
gh attestation verify oci://ghcr.io/taipanbox/vouchryx:v0.6.1 -R TAIPANBOX/vouchryx
Three values are required: VOUCHRYX_ISSUER, VOUCHRYX_SIGNING_KEY and
VOUCHRYX_TRUSTED_ISSUERS, the last one unless VOUCHRYX_AGENTS enrols at
least one agent (a service for agents alone has no human identity provider to
trust). A missing or malformed one aborts the start. Every
other value is optional and is listed below with its default. The Cross App
Access grant is closed unless VOUCHRYX_CLIENTS is set, and while
VOUCHRYX_XAA_REQUIRE_DPOP is false (the default) that grant accepts a bearer
token with no DPoP proof.
VOUCHRYX_ADDR where to listen (default 127.0.0.1:4310)
VOUCHRYX_ISSUER the `iss` this service puts on every token it mints,
and the base every DPoP htu is checked against, so
behind a TLS terminator it must be the public URL
VOUCHRYX_SIGNING_KEY path to a PEM EC private key; it issues ES256. Optional
with VOUCHRYX_SIGNER=keyryx, where it is the overlap key
VOUCHRYX_SIGNER `file` (default) or `keyryx`: a working key Keyryx
certifies, generated in memory and renewed, instead of
the key file (CLAUDE.md invariant 27)
VOUCHRYX_KEYRYX_URL the Keyryx custody API origin, for example
http://keyryx:4340; required with keyryx
VOUCHRYX_KEYRYX_CONSUMER the consumer name Keyryx's policy grants the key to;
required with keyryx
VOUCHRYX_KEYRYX_SOCKET the unix socket custody gave this consumer, or
VOUCHRYX_KEYRYX_CREDENTIAL_FILE its key or projected token file: exactly one of
the two with keyryx; any KEYRYX variable with file is refused
VOUCHRYX_KEYRYX_ROOT_PIN the delegation root's kids, one ES384 and one ML-DSA-65,
comma separated; required with keyryx. The working key's
statement must verify under them before the key signs
(CLAUDE.md invariant 28); a malformed pin refuses the start
VOUCHRYX_TRUSTED_ISSUERS `iss|aud|jwks-path`, one per line; add `|unbound`
for an issuer whose subject tokens carry no key
(such a token is refused without the marker)
VOUCHRYX_TTL_SECONDS default 300, capped at 3600
VOUCHRYX_EVENTS_PATH agent-event NDJSON; unset means nothing is recorded
VOUCHRYX_REVOCATIONS_PATH revocations kept on disk across a restart; unset,
a restart forgets them
VOUCHRYX_CLIENTS Cross App Access client table, `client_id|agent://td/path|
sha256:<64 hex>` per line; unset closes the jwt-bearer
grant (unauthorized_client to every call)
VOUCHRYX_RESOURCES comma-separated absolute URLs the jwt-bearer grant may
issue for; required once VOUCHRYX_CLIENTS is set
VOUCHRYX_XAA_REQUIRE_DPOP "true" or "false" (default); requires a DPoP proof on
the jwt-bearer grant when true
VOUCHRYX_ALLOWED_AUDIENCES comma-separated absolute URLs a token exchange may
issue a token for; unset, only VOUCHRYX_ISSUER
VOUCHRYX_AGENTS agents issued their own credential, one per line:
`agent://td/path|unit|org|sha256:<64 hex>` (the digest
of an enrolment key) or
`agent://td/path|unit|org|workload:<issuer>#<subject>`;
unset closes the client_credentials grant
VOUCHRYX_WORKLOAD_ISSUERS `iss|aud|jwks-path` per cluster whose projected
service-account tokens prove which agent is asking;
required for every issuer VOUCHRYX_AGENTS binds to
A missing or malformed value aborts the process and names the variable. A token service that came up trusting nothing would issue nothing and look healthy; one that came up trusting a default would issue everything.
The Surface table above documents seven endpoints, and until 2026-08-27 nothing outside this repository's own tests could call them: an RFC 8693 exchange takes two signed input tokens and a DPoP proof whose public key travels in the JWS header, which is a JOSE client before it is a curl command. The driver that proved the end-to-end path on 2026-08-26 was written in a scratch directory and lost with it.
vouchryx-demo is that client, shipped.
go build -o vouchryx-demo ./cmd/vouchryx-demo
# a demo issuer, this service's own signing key, and the caller's key
./vouchryx-demo keygen -out idp -kid idp-1
./vouchryx-demo keygen -out signing
./vouchryx-demo keygen -out holder
# the demo issuer sends tokens with no key, so it is marked unbound
VOUCHRYX_ISSUER=http://127.0.0.1:4310 \
VOUCHRYX_SIGNING_KEY=signing.pem \
VOUCHRYX_TRUSTED_ISSUERS="https://idp.local|http://127.0.0.1:4310|idp.jwks.json|unbound" \
./vouchryx &
./vouchryx-demo exchange -url http://127.0.0.1:4310 \
-idp-key idp.pem -kid idp-1 \
-iss https://idp.local -aud http://127.0.0.1:4310 \
-subject user://acme/ada -actor agent://acme/triage \
-holder-key holder.pem
which prints a token carrying, measured on 2026-08-27:
{ "iss": "http://127.0.0.1:4310", "sub": "user://acme/ada",
"act": { "sub": "agent://acme/triage" },
"cnf": { "jkt": "97HAPceqERXiSYV7HglWE8AQM2ULJ7Uu_aGryI15Tiw" },
"iat": 1787862197, "exp": 1787862497, "jti": "McIAWz0jmD82xc8uv7TdYQ" }
cnf.jkt is the thumbprint of holder.pem, which is what makes the token
useless to anybody who lifts it: spending it needs a fresh proof from the
same key, per request.
./vouchryx-demo proof -key holder.pem -htm POST \
-htu http://127.0.0.1:4100/v1/messages
The second hop. A token this service issued comes back as the
subject_token of a new exchange, so the delegation grows a hop: the HOLDER
presents it, proves its own key, and names the delegate through an
actor_token the delegate's IdP bound to the delegate's key (cnf.jkt, RFC
9449 section 6). The result keeps the root, appends the delegate to act, and
is bound to the delegate's key. For that this service must trust its own
issuer as an input, which is the operator's explicit line and never a default:
# one issuer per line: the IdP, then this service's own key set
VOUCHRYX_TRUSTED_ISSUERS=$'https://idp.local|http://127.0.0.1:4310|idp.jwks.json|unbound\nhttp://127.0.0.1:4310|http://127.0.0.1:4310|signing.jwks.json'
The token being handed on must name this service's own issuer as its aud,
which is the self-trust entry's audience, or the entry refuses it. The token
this service issues for the hand-off goes to an audience on
VOUCHRYX_ALLOWED_AUDIENCES (see Configuration), or to the issuer when that is
unset. A bound token presented by anyone but its holder issues
nothing, an unbound delegate credential issues nothing (the delegator would be
minting a token in the delegate's name bound to its own key), and a revoked
token issues nothing, whichever party in its chain the revocation names.
It is a client and it verifies nothing. Every check stays here, at the service, which is the only shape in which shipping a minting helper beside a service that refuses for a living is safe: a wrong credential minted there is refused here, loudly. Its own tests assert exactly that, by standing up this server and requiring it to accept, or refuse, what the client produced.
@claude 2026-09-24, a decision taken under delegated authority and open to reversal: this service is also a resource authorization server
for Cross App Access, the pattern an enterprise identity provider uses to let
one app hand another app proof of who a person is without ever sharing a
password or a session: RFC 7523's jwt-bearer grant, redeeming an ID-JAG
(draft-ietf-oauth-identity-assertion-authz-grant-04) for a short-lived
access token scoped to one configured resource. POST /v1/token runs it
beside the existing exchange, on grant_type.
Closed by construction: with no VOUCHRYX_CLIENTS configured, every call
answers unauthorized_client, and GET /.well-known/oauth-authorization-server
does not advertise the grant at all.
# a client's secret, and the digest VOUCHRYX_CLIENTS holds instead of it
printf '%s' 'xaa-demo-secret' | shasum -a 256
VOUCHRYX_ISSUER=http://127.0.0.1:4310 \
VOUCHRYX_SIGNING_KEY=signing.pem \
VOUCHRYX_TRUSTED_ISSUERS="https://idp.local|http://127.0.0.1:4310|idp.jwks.json|unbound" \
VOUCHRYX_CLIENTS="console|agent://acme/console|sha256:ec57e360a7beebe60564bab19e0186a4224a4a44b15461dc139d5130ba6783d7" \
VOUCHRYX_RESOURCES="https://broker.acme.example/mcp" \
./vouchryx &
./vouchryx-demo xaa -url http://127.0.0.1:4310 \
-idp-key idp.pem -kid idp-1 -iss https://idp.local -aud http://127.0.0.1:4310 \
-sub [email protected] -client-id console -client-secret xaa-demo-secret \
-resource https://broker.acme.example/mcp
which prints a token carrying, measured on 2026-09-24:
{ "iss": "http://127.0.0.1:4310",
"sub": "user://idp.local/x-616c6963654061636d652e6578616d706c65",
"act": { "sub": "agent://acme/console" },
"client_id": "console", "idp_iss": "https://idp.local",
"idp_sub": "[email protected]",
"aud": "https://broker.acme.example/mcp",
"iat": 1790221720, "exp": 1790222020,
"jti": "nz0kiLKjhkw7-GE7O6MoRQ" }
sub is the mapped identity, user://<lowercase host of the IdP's iss>/<IdP sub>; the IdP's own [email protected] is hex-encoded behind an x-
prefix because @ and . in that position are outside the safe character
set this service already uses for a chain entry's path, so the raw value is
never embedded where a / or a stray scheme separator could be read as
something it is not. exp - iat is exactly 300 seconds, the five-minute cap
set with this grant, regardless of what VOUCHRYX_TTL_SECONDS allows the token-exchange
grant to run for.
A wrong client secret is refused with 401 {"error":"invalid_client"} and a
WWW-Authenticate: Basic header; the operator's log names the reason
(client_auth_failed), the caller never sees it.
The token exchange turns somebody else's tokens into a delegation; it cannot
give an agent an identity of its own. grant_type=client_credentials (RFC 6749
section 4.4) does: an agent enrolled in VOUCHRYX_AGENTS asks for a credential
that lives five minutes, is bound to a key it proves it holds (RFC 9449), and
names it, verified offline at every door that holds this service's key set.
Closed while VOUCHRYX_AGENTS is unset.
An agent proves who it is to this service, and only to this service, in exactly one of two ways:
client_secret_basic, where this service holds only
the key's SHA-256 digest and compares in constant time. The client id is the
agent's agent:// URI, so it must be form-urlencoded before the Basic
encoding, as RFC 6749 section 2.3.1 says (agent%3A%2F%2Facme...); the colon
in an unencoded URI would be read as the separator;client_assertion_type=urn:ietf:params:oauth:client-assertion-type:jwt-bearer).
It is verified with the cluster's key set from VOUCHRYX_WORKLOAD_ISSUERS,
with the key its kid names, the algorithm from the key type, exactly one
audience, at most an hour of life, and it maps to the one agent bound to its
iss and sub. No static secret exists anywhere.What comes back is the contract every door verifies:
| where | value |
|---|---|
header typ | at+jwt (RFC 9068), exactly, so it is never mistaken for a delegation token or an ID-JAG |
header alg, kid | ES256, this service's key |
iss | VOUCHRYX_ISSUER |
sub, client_id | the agent's agent:// URI, both |
aud | one audience: the resource (or audience) asked for, from VOUCHRYX_ALLOWED_AUDIENCES; this service's issuer when none is asked |
iat, exp | at most five minutes apart, whatever VOUCHRYX_TTL_SECONDS says |
jti | random, for revocation |
cnf.jkt | the thumbprint of the key that signed the proof |
unit, org | from the agent's registry line |
auth_src | enrolment-key, or workload:<issuer> |
No refresh token, and token_type is DPoP. An agent token presented as the
actor_token of an exchange, once the operator lists this service's own
issuer as trusted (invariant 13), carries its unit and org into the
delegation; an identity provider's token never does.
# the credential field VOUCHRYX_AGENTS holds instead of the key
echo "sha256:$(printf %s "$ENROLMENT_KEY" | sha256sum | cut -d' ' -f1)"
Stopping an agent is one call, and so is letting it back:
curl -s -X POST http://127.0.0.1:4310/v1/revoke \
-H "Authorization: Bearer $VOUCHRYX_REVOKE_KEY" \
-d '{"subject":"agent://acme/triage","ban":true,"actor":"user://acme/op","reason":"stopped"}'
curl -s -X POST http://127.0.0.1:4310/v1/revoke/lift \
-H "Authorization: Bearer $VOUCHRYX_REVOKE_KEY" \
-d '{"subject":"agent://acme/triage","actor":"user://acme/op","reason":"fixed"}'
A ban is a subject revocation (every token naming the agent, anywhere in its
chain, issued until now, dies at every door that polls the list) and a
standing refusal of every grant to issue anything naming it again: its own
credential, a delegation to or from it, an ID-JAG redemption. It is kept in
VOUCHRYX_REVOCATIONS_PATH beyond its revocation's own expiry and survives a
restart; the answer says "durable": false when no store is configured. A
lift is written to the store before it takes effect, so a lift the disk
refused leaves the ban standing.
Not here. Verification, the algorithm allowlist, the DPoP proof check and
the act chain are agent-stack-go/delegation, pinned in go.mod (at a merged
keyclient commit, since the wave-2 signing work; the release that carries it is
the owner's to tag). The JWS of a token this service issues is assembled by
internal/signer (CLAUDE.md invariant 27), which signs with the file key, or,
with VOUCHRYX_SIGNER=keyryx, with the working key held by keyclient and
certified by Keyryx. Keyryx's statement for that key is verified against the
delegation root this service pins, in internal/statement, before the key is
used (invariant 28). delegation.SignES256 is no longer called. This
service imports the library, and today it is the only Go service that does: measured
2026-09-16 over every Go repository in the stack, wardryx, idryx,
scopyx, heraldyx and mockryx import nothing from that package. What this
service issues is verified at TokenFuse's two doors (its Rust delegation
crate, offline, from a configured issuer and JWKS) when an operator turns
that on, and nowhere else yet. Wardryx reads a chain_proven fact a door
established; it does not verify a token itself.
Measured 2026-09-27 end to end, on a lab image built from this repository's
main and run in a three-node cluster: with the gateway's delegation door on
(polling GET /v1/revocations once a second) and a wardryx policy denying an
unproven chain, a claimed delegation with no token was refused, and a chain
proved by a token exchanged through vouchryx-demo exchange plus a fresh
vouchryx-demo proof per call answered three real model calls. Revoking the
subject took effect between the two: a call 0.9 seconds after the revocation
still answered, and one 2.2 seconds after, and every one afterward, was
refused. Verifying this needs the gateway configured with the JWKS this
service itself serves at GET /.well-known/jwks.json, never the JWKS a
vouchryx-demo keygen file writes for a demo IdP key: the two are different
keys, and pointing the gateway at the wrong one refuses every token.
That is not tidiness. Two implementations of "is this signature valid" that disagree is a hole nobody sees until somebody walks through it, and the issuer having its own copy is the worst arrangement available: the one process that mints tokens would be the one process nobody else's tests cover.
It lived here for exactly one day, which was the day it took to find out that a proof's key lookup could never match and that the chain lost its root. Both tests moved with the code.
The algorithm comes from the key, never from the token header, and a
token is verified with the key it names and no other. Both now live in
agent-stack-go/delegation with their tests; the second exists because a
planted mutant survived here first, and the test that closed it passed for the
wrong reason before that.
The chain grows at the right end. RFC 8693 nests act current-first;
agent-passport orders on_behalf_of root-first. Getting it backwards produces a
token that verifies perfectly and asserts that the root delegated to nobody.
And the two are not one list reversed: the RFC keeps the subject OUT of act,
the estate puts the root INTO the chain, so the mapping is [sub] + reverse(act).
Found by the end-to-end test, which caught a chain with the human missing from
it.
A refusal is not an oracle. Every rejection is the same OAuth error with no detail about which check failed. Told which of eight failed, an attacker walks them one at a time. The detail goes to the event stream.
delegation_issued (info), delegation_denied (high), delegation_revoked
(high), in the shared taipanbox.dev/agent-event envelope. Severity is fixed
per type here exactly as it is in tokenfuse's own crate, so no call site can
pick one. An agent's own credential is delegation_issued with
grant: "client_credentials", filed under the agent; a ban is
delegation_revoked with ban: true. A lift writes no event, because none of
the three types means "a ban ended"; its record is the store line and the log
line, both written every time.
283 tests. Tier T3: these are authorization decisions where a wrong answer is silent.
The statement check (invariant 28) was planted with 21 faults, one at a time:
19 were caught by a named test. The two that survive each remove one of two
comparisons of the key a statement certifies, its kid and its point, and each is
redundant alone: the claims already make the kid the thumbprint of the point, so
a mismatch in one is a mismatch in the other. Removed together they are caught
(TestAStatementCertifyingAnotherKeyIsRefusedAtUse).
Ten mutants were planted in the security paths while that code lived here;
nine were caught immediately and one survived. Closing it is
TestATokenIsVerifiedWithTheKeyItNamesAndNoOther, which moved to
agent-stack-go with the code it guards. Cross App Access (internal/xaa)
planted its own set, named in CLAUDE.md invariants 18 to 20; the one
survivor there is caught only by the happy-path test, not by the one named
for the credential it breaks, and both are named so the reason is not lost.
Coverage, re-measured 2026-10-13 with go test ./internal/<pkg>/... -cover
after Cross App Access landed: xaa 95.1% (new: pure functions with no HTTP
or process boundary to leave untested), config 95.9% (was 95.2%), revoke
86.8% (86.4% on 2026-09-24), demo 75.0% (was 70.8%), api 84.2% (was 87.5%, the one
that moved down: internal/xaa's own share of the request path is now
counted in xaa, not here, and a few of tokenJWTBearer's internal
server_error paths, a bad random source, a signing failure, are the same
kind of practically unreachable branch the exchange handler already leaves
uncovered). The JOSE, DPoP and chain coverage moved with the code to
agent-stack-go.
Re-measured 2026-10-18 with the same command after an agent's own credential
and the ban landed: agents 95.5% (new), api 85.3% (83.9% before the
change), config 94.4% (93.8%), revoke 87.8% (86.8%), xaa 95.0% and
demo 72.4%, both unchanged. What stays uncovered in the new code is the same
kind of branch: a bad random source and a signing failure in
tokenClientCredentials.
go test ./... -race
go vet ./...
staticcheck ./...
./scripts/features-are-bound.sh
./scripts/readme-numbers.sh
./scripts/every-refusal-reaches-the-operator.sh
./scripts/every-issuance-checks-the-ban.sh
./scripts/every-subject-exchange-asks-the-marker.sh
./scripts/gates-have-teeth.sh # needs a clean tree
Stated here rather than left to be discovered.
An unbound ACTOR credential is still accepted on a first hop. Invariant 26 covers the subject token only. On a first hop with an unbound actor credential, the result is bound to the presenter's own proof key, so a captured identity-provider token that names an agent could be exchanged by whoever holds any key. Not closed by this change; it needs its own decision, because refusing it would break any deployment whose agents present unbound credentials.
The revocation store is a local file: no replication, no cross-instance sharing, compacted only at start (a long-running process under heavy revocation grows it until the next start), and fsync ordering is held by reading, not by a test.
The DPoP replay cache is in memory too, bounded by a 60-second window. For one window after a restart, a captured proof could be replayed once.
One enforcement point verifies these tokens on a request path, and it is
the only one. TokenFuse's gateway has been that caller since 2026-08-26:
crates/gateway/src/revocations.rs polls GET /v1/revocations in a
background task, installs the snapshot under a lock, and both doors read it
synchronously through chainproof::resolve, the LLM proxy in proxy.rs and
the MCP broker in mcpbroker.rs, where a revoked delegation answers 401. It
is off until TOKENFUSE_DELEGATION_REVOCATIONS names a URL; naming one makes
the first fetch a startup condition, the fail mode defaults to closed, and
naming it with no issuer configured is refused with the same exit code as any
other check that could never fire. What is still missing is a second caller:
no Go service in the estate sets delegation.Options.Revoked, so
delegation.Revocations in agent-stack-go is a cache nothing constructs.
The audience list is trusted as written. Whoever sets
VOUCHRYX_ALLOWED_AUDIENCES decides which resources a delegation may be
spent at. Nothing here checks that a listed URL is the resource the operator
meant, and an audience left off the list is refused, not reconciled.
In file mode the signing key is single. The published key set holds one key, so a rotation replaces it at once: tokens issued under the old key stop verifying at every verifier that has polled the new set, and no overlap period exists. In keyryx mode Keyryx publishes the working key's current and next keys, and this service serves that set as it gets it; the overlap is Keyryx's to keep, and the tests here use a fake custody, not Keyryx.
The working key is renewed at half its life. For the 72-hour statement that is every 36 hours. The wave-2 decision names a daily renewal; keyclient renews at half the statement's life, and a 48-hour statement would give a daily cadence. Not changed here.
act, ES256, short-lived by defaultcnf.jkt bound to the caller keyjti or by subject, with a required actor and reasonvouchryx-demo ships the client, so the loop is walkable from a shellstack-up --with-delegation brings it up in front of the gatewayVOUCHRYX_CLIENTS is unset; vouchryx-demo xaa walks it from a shellclient_credentials, DPoP
required), by enrolment key or by a cluster's workload token; closed
while VOUCHRYX_AGENTS is unsetApache-2.0, like the rest of the stack. See LICENSE.
GET /healthz | With no credential. 200 once the process is listening; in keyryx mode (VOUCHRYX_SIGNER=keyryx) 503 until a working key is certified, and while its statement does not verify against the pinned root (the reason is in the log), so it then reads as readiness (see NOT PROVEN). |
The root's keys come over the channel and are bound by kid, not trusted as
read. internal/statement verifies both halves of Keyryx's statement under
the root keys named in VOUCHRYX_KEYRYX_ROOT_PIN. Those keys are read once from
custody at /v1/jwks/keyryx.delegation-root; a key is accepted only if its kid
is the thumbprint of that key and the kid is pinned. The pin is the trust root,
and the channel carries only bytes that the pin then checks. Rotating the root
is a new pin and a restart. Until the owner rules on where the root comes from,
this is this repository's reading (CLAUDE.md invariant 28).
The served key set is not filtered by statement. Verifiers read the set
this service serves: Keyryx's published set, and in the overlap the file key's
public half. Only the working key's own statement is checked. A party on the
channel that puts its own public key into that set is not caught here. Checking
each served kid against GET /v1/statements/{kid} is the larger half of the
wave-2 verification design and is not built.
The statement format is a copy of keyryx's, tested against this repository's
fake custody. internal/statement follows keyryx's internal/statement at
origin/main 43bb8c4. The tests sign with internal/keyryxtest, which follows the
same format. Whether the real keyryx binary's statements verify here is a
separate measurement, not made by the test suite.
A statement that verifies is not a statement that is current. A revoked working key is not learned from the statement; it is learned from Keyryx's published set, which is what the served-set item above leaves unchecked.
A failed certification is retried on keyclient's fixed five-minute wait. A Keyryx that comes back is used within that wait; the service issues nothing until then.
A fresh key is used only after this service's cache holds it. The cache is
refreshed every ten seconds, so a new certification waits up to that long
before the first token, and health answers 503 meanwhile.
/healthz is a readiness signal in keyryx mode. It answers 503 while no
working key is certified. A liveness probe on that path would restart the
process while Keyryx is down, and the restart would not help.
Where a subject_token comes from is out of scope. This accepts one from
a configured issuer; obtaining it from a customer's own IdP is a deployment
shape that does not exist yet.
Nothing here has been run against a real IdP, only against tokens minted by the tests using the same code path. Interoperability with Okta, Entra or Auth0 is untested and unclaimed.
No fuzzing, no load measurement, no TLS. It binds HTTP and warns when the
bind is routable; terminating TLS is the deployment's job and is not
demonstrated here. The DPoP htu it checks is built from VOUCHRYX_ISSUER,
not from the request's own socket, so a terminator rewriting the scheme and
host in front of it does not by itself break the check; this is measured
only through httptest requests carrying a literal URL, never through a
real reverse proxy or TLS terminator.
The hand-off has not been run against a real IdP's bound tokens, only
against credentials the tests bind the same way; and there is no lineage in
a token, so revoking a parent by jti does not cascade to tokens already
exchanged from it (revoke the party by subject for that).
The name is a placeholder and was never confirmed.
scripts/the-algorithm-comes-from-the-key.sh no longer measures anything
here, because the file it reads moved. It is removed rather than left
reporting OK on nothing: a gate whose subject is gone must say so, and the
simplest way to say it is not to have it. The rule it held is an invariant of
agent-stack-go now, with its own gate there.
Cross App Access has not been run against a real identity provider.
internal/xaa and vouchryx-demo xaa are exercised against tokens this
repository's own tests and demo client mint, the same limit the rest of
this section already states for the token-exchange grant; open-source
Keycloak issues an ID-JAG only experimentally as of this file's own
research, and Okta's Cross App Access needs a tenant and an early-access
mail nobody has sent yet.
The jti replay cache is in memory, bounded, and a restart forgets it, the same shape the DPoP replay window already has: for one assertion's lifetime after a restart, a captured ID-JAG could be redeemed once more than it should be.
No real cluster has presented a workload token here. The workload path is exercised against RS256 tokens the tests mint in the shape a kubelet writes; no k3s or Kubernetes issuer's key set has been configured and no pod has asked.
The agent token's door is proven by TokenFuse's tests and one live run,
neither in this repository. TokenFuse's gateway admits an agent token of
this service's shape at its LLM doors and its tool-call check, and relays it
through its MCP broker. It needs a DPoP proof that names the token and is
used once, and it refuses a revoked agent. The tests are
crates/gateway/tests/it/agent_token_is_the_door_credential.rs in TokenFuse,
present since its v1.16.0 release (2026-10-18); the door is off unless the
operator sets TOKENFUSE_AGENT_TOKENS to accept or require. A live run on
2026-10-26, on one host against TokenFuse 1.20.1, admitted ten agent tokens
and refused a replayed proof. The estate's own records hold that measurement.
No test here reproduces it, and TokenFuse's tests mint their tokens with its
own helper, not with this service's binary.
The at+jwt header is signed by a local copy of agent-stack-go's
signer, because that library writes typ: JWT and takes no header. Every
token it mints is verified in the tests by the library's own verifier, so the
two cannot drift without a test going red; the copy goes when the library
grows a typ parameter.
A ban without VOUCHRYX_REVOCATIONS_PATH is forgotten by a restart, like
every revocation there; the answer says so ("durable": false).
An enrolment key is readable by the agent process that holds it, as any bootstrap credential is; what it buys is one credential per agent, usable only here, stopped at runtime by a ban. A workload token avoids the static secret but is just as readable inside its pod.
client_secret_basic is the only client authentication method.
private_key_jwt, which the MCP extension also names, is not implemented;
a client that only supports it cannot use this grant.