Documentation
¶
Overview ¶
Package exchange holds the RFC 8693 token-exchange logic, as pure functions over already-verified claims.
Pure on purpose: the exchange is where a delegation chain is built, and a chain built the wrong way round validates cleanly while saying the opposite of what happened. That has to be testable without a key, a clock or a socket.
Package dpop verifies the sender-constraint proof of RFC 9449.
What the proof is for ¶
A bearer token is a bearer token: anybody holding the bytes may use them. A delegation token that says "this agent may act for that person" is exactly the kind of bytes worth stealing, and every enforcement point in this estate would honour a stolen one. DPoP binds an issued token to a key the holder proves possession of, so the bytes alone are not enough.
The proof is a small JWS the client signs per request, carrying its public key in the header. This package verifies that proof and returns the RFC 7638 thumbprint of the key, which the issuer then puts in `cnf.jkt` on the token it issues. An enforcement point later compares the thumbprint on the token with the thumbprint of the key that signed the proof in front of it.
What it refuses, and why each one is not paranoia ¶
- **A proof whose header carries no `jwk`.** There is nothing to bind to.
- **A proof signed by a key other than the one in its header.** Otherwise anybody can staple somebody else's public key to their own proof and be issued a token bound to a key they do not hold.
- **A private member in the embedded `jwk`.** RFC 9449 requires the public key. A `d` there is a client leaking its own signing key to us, and accepting it makes this service a place private keys collect.
- **`htm` or `htu` that do not match the request in front of us.** A proof is for ONE request. Without this, a proof captured from a call to a harmless endpoint is replayed against this one.
- **An `iat` outside the window.** A proof with no freshness is a bearer token wearing a proof's clothes.
- **A `jti` already seen inside that window.** The window bounds a replay; this closes it.
Package delegation is the estate's one implementation of the delegation token: how it is signed, how it is verified, and what a verified one means.
Why it lives here and not in the issuer ¶
`vouchryx` mints these tokens; `wardryx`, `idryx`, `scopyx`, `heraldyx` and `mockryx` have to check them. A JOSE implementation in the issuer plus a copy in every verifier is exactly the drift this module exists to prevent (CLAUDE.md invariant 3), and it is the worst place for drift there is: two implementations of "is this signature valid" that disagree is a hole nobody sees until somebody walks through it.
Why a library and not a network call ¶
wardryx decides at a 3.2 ms p50 and audits every decision. Putting signature verification behind an HTTP round trip taxes every decision in the estate and makes the token service a hard dependency of every enforcement point at once, which is the shape `dependency_failed` was cut to record. Enforcement points verify locally, from a key set they already hold, and hand the PDP facts that are already true.
Written on the standard library, and here it is not a preference ¶
CLAUDE.md invariant 1: a dependency added to a library package lands in seven consumers at once. Beyond that, the security-critical part of this is not the signing but the REFUSING -- which algorithms a key may be used with, which key a token is matched to, what a token is bound to -- and each of those is a decision this module would otherwise be trusting rather than making.
The one defence that matters most ¶
Verify derives the permitted algorithms from the KEY TYPE and never from the token header. The header is written by whoever presents the token. Without this, an attacker fetches the public EC key from our own `/.well-known/jwks.json`, signs an HMAC using those public bytes as the secret, sets `alg` to `HS256`, and the token verifies: the classic alg confusion downgrade. tokenfuse's `crates/cloud/src/oidc.rs` already carries this defence for the Rust side and the two must not diverge.
It signs ES256 only. It VERIFIES ES256/ES384 and RS256/384/512, because the tokens it accepts as input come from a customer's own IdP and that is what those issue.
Index ¶
- Constants
- Variables
- func Chain(sub string, act *Act) ([]string, error)
- func Extend(chain []string, actor string) ([]string, error)
- func ReadAct(act *Act) ([]string, error)
- func SignES256(key *ecdsa.PrivateKey, kid string, claims map[string]any) (string, error)
- func Thumbprint(j JWK) (string, error)
- func VerifyToken(token string, set Set) (map[string]any, error)
- func VerifyWith(token string, key JWK) (map[string]any, error)
- type Act
- type JWK
- type Options
- type Set
- type Verified
- type Verifier
Constants ¶
const MaxDepth = 32
MaxDepth is the longest chain this service will build or accept.
agent-passport SPEC section 5.1 already caps the recorded chain at 32, and the two must agree: a token carrying a chain the record cannot hold would be a delegation nothing could audit. It is also what stops a caller walking a self-referential `act` for ever.
const Window = 60 * time.Second
Window is how far an `iat` may be from now, either way.
Either way, and not only into the past: a client whose clock is fast would otherwise be refused every time, which an operator diagnoses as "DPoP is broken" rather than "our clock is wrong". RFC 9449 leaves the value to the server; 60 seconds is short enough that the replay cache stays small and long enough to survive ordinary clock drift.
Variables ¶
var ( ErrNoSubject = errors.New("delegation: the subject token names no subject") ErrTooDeep = fmt.Errorf("delegation: the delegation chain is longer than %d", MaxDepth) ErrSelf = errors.New("delegation: an actor may not delegate to itself") )
var ( ErrNoKey = errors.New("delegation: the proof carries no jwk to bind to") ErrPrivate = errors.New("delegation: the proof's jwk carries a private member") ErrSignature = errors.New("delegation: the proof is not signed by the key it carries") ErrBinding = errors.New("delegation: the proof is not for this request") ErrStale = errors.New("delegation: the proof is outside the freshness window") ErrReplay = errors.New("delegation: this proof has been presented before") )
var ( ErrNoKid = errors.New("delegation: the token carries no kid, so no key can be matched to it") ErrUnknownKid = errors.New("delegation: no key in the set has that kid") ErrBadSignature = errors.New("delegation: the signature did not verify") ErrAlgNotAllowed = errors.New("delegation: that algorithm is not permitted for this key type") )
Errors a caller may want to tell apart. Everything else is a bad token and says so without saying which part was bad, because a verifier that narrates its reasoning to whoever presented the token is an oracle.
var ( ErrExpired = errors.New("delegation: the token has expired") ErrIssuer = errors.New("delegation: the token was not issued by the expected issuer") ErrAudience = errors.New("delegation: the token was not minted for this audience") ErrNotBound = errors.New("delegation: the token carries no cnf.jkt, so it is a bearer token") ErrWrongKey = errors.New("delegation: the presenter does not hold the key this token is bound to") ErrRevoked = errors.New("delegation: this delegation has been revoked") ErrNoProof = errors.New("delegation: a sender-constrained token was presented with no proof") ErrMalformed = errors.New("delegation: the token is not a delegation token") )
Refusals a caller may want to tell apart, because each sends the operator somewhere different. A signature failure is a security event; an expiry is a client that needs to refresh; a revocation is somebody's deliberate act.
Functions ¶
func Chain ¶
Chain is the agent-passport `on_behalf_of` for a token: the subject, then the actors, root first.
The join nobody sees until they look ¶
RFC 8693 keeps them apart. `sub` is who the token is FOR, and `act` is the chain of who is acting; the subject is deliberately not in `act`, because it is not an actor. agent-passport SPEC section 5 does the opposite: its `on_behalf_of` is one ordered list, root first, and the root is the person.
So the two are not the same list with a different order. They are a list and a list-plus-its-head, and a service that handed `ReadAct` straight to the record would write a delegation chain with the human missing from it. Every token would still verify; the trail would say a fleet of agents acted on nobody's behalf.
Found by the end-to-end test rather than by reading either specification, which is the only way this kind of mismatch is ever found.
func Extend ¶
Extend adds one actor to a chain, refusing the shapes that are not delegations at all.
A chain that already names the actor is refused rather than deduplicated: an agent appearing twice in its own delegation chain is either a loop or a confused caller, and quietly collapsing it would hide both while producing a token that looks ordinary.
func ReadAct ¶
ReadAct turns an `act` claim back into an agent-passport chain, root first.
The inverse of BuildAct, and it is a separate function rather than a reversal at the call site because the two are used by different processes: this service builds, and every enforcement point reads. A test holds them against each other in both directions, because an inverse that is not one is how a chain silently reverses on its way through the estate.
func Thumbprint ¶
Thumbprint is the RFC 7638 SHA-256 thumbprint, base64url without padding.
This is what an issued token is BOUND to (`cnf.jkt`, RFC 9449), so what it hashes decides whether a stolen token can be replayed by a different holder. RFC 7638 hashes the required members only, in lexicographic order, with no whitespace: `crv, kty, x, y` for EC and `e, kty, n` for RSA. `kid`, `use` and `alg` are excluded, which is why renaming a key does not change what a live token is bound to.
func VerifyToken ¶
VerifyToken checks a compact JWS against `set` and returns its claims.
Named apart from Verify, which is the whole delegation check: this one answers "were these bytes signed by a key in this set", and a caller that stopped there would have a valid signature over claims it never looked at.
It does NOT check `exp`, `iss` or `aud`: those are the caller's policy and live where the policy is. What this promises is that the bytes were signed by a key in the set, with an algorithm that key is allowed to be used with.
func VerifyWith ¶
VerifyWith checks a compact JWS against ONE key that the caller already has, skipping the `kid` lookup.
It exists for RFC 9449 proofs, which carry their key in the header rather than naming one in a set, and it keeps every other defence Verify has: the permitted algorithms still come from the key type, so a proof cannot downgrade itself to `none` or to an HMAC any more than a token can.
The `kid` requirement is the ONLY thing relaxed, and only because there is no set to match against: the caller has decided which key this is, and for a DPoP proof that decision is "the one the proof carries", which is exactly the binding the scheme is about.
Types ¶
type Act ¶
Act is the RFC 8693 section 4.1 actor claim, nested.
func BuildAct ¶
BuildAct turns an agent-passport delegation chain into an `act` claim.
The direction, which is the whole of this function ¶
RFC 8693 section 4.1: "The outermost 'act' claim represents the current actor while nested 'act' claims represent prior actors." So the OUTERMOST is the immediate actor, and nesting goes back in time.
agent-passport SPEC section 5 orders `on_behalf_of` as "an ordered list, root first", so the immediate actor is at the END.
The mapping is therefore a REVERSAL, and this is the one place in the estate where getting a direction wrong produces something that verifies perfectly and asserts the opposite of what happened: that the root delegated to nobody and the immediate actor authorised the whole chain. A signature over a lie is still a valid signature.
chain (root first): [user://alice, agent://triage, agent://runbook]
act (current first): {runbook, act:{triage, act:{alice}}}
type JWK ¶
type JWK struct {
Kty string `json:"kty"`
Crv string `json:"crv,omitempty"`
X string `json:"x,omitempty"`
Y string `json:"y,omitempty"`
N string `json:"n,omitempty"`
E string `json:"e,omitempty"`
Kid string `json:"kid,omitempty"`
Use string `json:"use,omitempty"`
Alg string `json:"alg,omitempty"`
}
JWK is one key in a set. Only the members this service reads are here: an unknown member is carried through JSON and ignored, which is what RFC 7517 requires and what keeps a stricter issuer from being unusable.
func FromPublic ¶
FromPublic renders an EC public key as a JWK.
Public members only. There is no overload of this that takes a private key, on purpose: the result of this function is published at `/.well-known/jwks.json`, and a `d` member there is the signing key, in public, forever.
type Options ¶
type Options struct {
// Keys is the issuer's public set, held locally.
Keys Set
// Issuer is the exact `iss` required. Not a prefix: a prefix is how a
// service ends up trusting `vouchryx.acme.example.evil.test`.
Issuer string
// Audience is the `aud` required, or empty to accept any. Empty is a real
// choice for a single-tenant deployment and a mistake in a shared one, so
// it is explicit rather than defaulted.
Audience string
// Now is the clock. Injected so an expiry is testable without sleeping.
Now time.Time
// Proof is the RFC 9449 header the caller presented, and Method and URL
// are what THIS server received. Together they prove the caller holds the
// key the token is bound to.
//
// Leaving Proof empty means the caller is NOT checking sender-constraint,
// and [Verify] then refuses any token that carries a `cnf.jkt`: a
// sender-constrained token checked as a bearer token is the failure this
// whole scheme exists to prevent, and silently downgrading would be the
// worst way to meet it.
Proof string
Method, URL string
// Proofs is the replay cache. Optional, and its absence is a real
// weakening: without it a captured proof works as often as it is presented
// inside its window.
Proofs *Verifier
// Revoked is consulted after the signature checks pass. Optional; when it
// is nil, revocation is NOT checked, and a caller that leaves it nil has
// decided that a valid signature is enough.
Revoked func(jti, subject string, issuedAt int64) bool
}
Options is what an enforcement point already holds locally.
There is no URL here for fetching anything. That is the point of A2: every field is something the process has before the request arrives, so a check costs no round trip and the token service is not a hard dependency of every enforcement point at once.
type Verified ¶
type Verified struct {
// Subject is who the token is FOR: the `sub` claim.
Subject string
// Actors is the delegation chain read out of `act`, in the order this
// module records chains.
//
// **Read, not verified.** CLAUDE.md invariant 5: root-first ordering is a
// property of how a chain was BUILT and cannot be checked from the finished
// list. What the signature guarantees is that the issuer put these names in
// this nesting; that the nesting means what the issuer intended is the
// issuer's to get right, and `vouchryx` has a test for it. A verifier that
// claimed to check the order would be claiming something no verifier can.
Actors []string
// Chain is `Subject` followed by `Actors`: the `on_behalf_of` an
// agent-passport event carries. Same caveat as `Actors`.
Chain []string
// JKT is the thumbprint the token is bound to, and it MATCHED the proof.
JKT string
// JTI, IssuedAt and ExpiresAt are what a revocation list is checked against.
JTI string
IssuedAt int64
ExpiresAt int64
// Scope is the `scope` claim, or empty.
Scope string
}
Verified is what an enforcement point may rely on after a successful check.
Every field here is either signed by the issuer or derived from something that is. Nothing on this struct came off an unverified header.
func Verify ¶
Verify checks a delegation token and everything that makes it more than a bearer token.
The order is deliberate and each step is cheaper than the next thing it protects: shape, signature, issuer, audience, expiry, binding, revocation. A revocation lookup on a forged token would be work an attacker chose.
type Verifier ¶
type Verifier struct {
// contains filtered or unexported fields
}
Verifier checks proofs and remembers the ones it has seen.
The seen-set is in memory and bounded by Window, which is a deliberate limit rather than an oversight: a restart forgets, and for the length of one window after a restart a captured proof could be replayed once. Making that durable means a store on the request path of every token issue, and the window is sixty seconds. It is written down in the README rather than left for somebody to discover.
func NewVerifier ¶
func NewVerifier() *Verifier