Release note — sender-constrained tokens (DPoP)¶
Delegation tokens can now be bound to a key the caller holds, and the two network enforcement surfaces require it. A stolen token is no longer usable on its own: the holder must also prove possession of the key it names, per RFC 9449.
This release does not upgrade cleanly with existing configuration. Four required values have no defaults, and any process missing one refuses to start. Read "Before you upgrade" first.
Before you upgrade¶
1. Set four configuration values¶
Each is required and has no default. A process missing one exits at startup naming every missing key at once, so you will not discover them one restart at a time.
| Variable | Process | Value |
|---|---|---|
STRATIUM_DPOP_FRESHNESS_WINDOW |
stratium-pdp |
Go duration, must be positive. How far a proof's timestamp may be from the PDP's clock in either direction. 30s is a reasonable start. |
STRATIUM_SPENT_PROOF_REAP_INTERVAL |
stratium-pdp |
Go duration, must be positive. How often spent-proof records are deleted. 5m is a reasonable start. |
STRATIUM_PEP_EXTAUTHZ_ALLOWED_HOSTS |
stratium-pep extauthz |
Comma-separated list of the public authorities this gateway serves, e.g. api.example.com,api.eu.example.com. No empty entries. |
STRATIUM_PEP_PROXY_PUBLIC_URI |
stratium-pep proxy |
Scheme and authority only — https://proxy.example:9292. Scheme must be http or https; no userinfo, no path, no query, no fragment. |
Choosing the freshness window. Too short and legitimate calls fail on ordinary clock drift between an agent and the PDP; too long and a captured proof stays replayable for longer against the same method and URI. It bounds only the window, not the number of uses — a proof is single-use regardless.
Why the two address values exist. A proof is bound to the URI the client
addressed. The surface must establish that value itself rather than trusting
what the caller asserts, or the binding proves nothing about where the request
was aimed. ALLOWED_HOSTS is a list because one gateway may legitimately serve
several public names; PROXY_PUBLIC_URI is single because a proxy has one
identity.
Note both are the public address a client dials and signs — not the listen
address. Behind a load balancer these differ, and the listen address is usually
:9292-shaped with no hostname at all.
2. Run migration 000018¶
Adds spent_proofs, which records each proof as it is used so a replay
collides at the database. stratium_app receives INSERT, SELECT, and
DELETE on that table and nothing more.
3. Re-issue tokens for proxy and extauthz callers¶
Every caller of those two surfaces must present a bound token. An unbound token is refused there after this release, whatever the policy would have said.
To mint one, pass the caller's public JWK to IssueToken; the token comes
back carrying a cnf.jkt claim. The caller then sends a DPoP proof on each
request — in the DPoP header for extauthz, in dpop metadata for proxy.
hook and mcp callers need no change. See "What is unaffected".
Breaking changes¶
Unbound tokens are refused on proxy and extauthz. This is the point of
the release. Plan the re-issue in step 3 before upgrading those surfaces.
A proof is single-use. Replaying one is denied, distinctly from a policy denial. A client that caches and resends a proof will break.
A proof is bound to the method and URI it was made for. Presenting it for a different method or a different address is denied, even with a valid token.
proxy no longer holds a call open across a human approval. An escalating
call now returns a denial carrying the approval id, and the caller retries with
a fresh proof once approved. This is not a preference: a proof is single-use
and short-lived, a human approval is neither, and the proxy cannot mint a
replacement because the client holds the key. extauthz already behaved this
way. Callers that relied on the call blocking must handle the denial-and-retry
shape instead.
An escalating call from an unbound token is refused immediately rather than waiting, since its outcome would be refused anyway.
What is unaffected¶
hook and mcp are unchanged. They keep working with ordinary bearer
tokens, and binding is not required there — on those surfaces the caller
already holds the session file containing a real token, so a key adds nothing.
No configuration change, no re-issue.
Existing bearer tokens remain valid everywhere except the two network surfaces.
No policy, delegation, agent, or approval semantics changed. A bound token is still an authenticated reference: the PDP loads the delegation and evaluates all three legs exactly as before. Proof verification is a fourth condition, never a bypass.
Verifying the upgrade¶
A correctly configured deployment shows all of:
- every process starts (a missing value is loud, not silent)
- a bound token with a valid proof reaches the upstream through both surfaces
- the same token with no proof is denied
- an unbound token is denied on both surfaces
- resending an identical proof is denied for replay, not for policy
hookandmcpstill work with your existing unbound tokens
If a process refuses to start, the error names every missing or malformed key
together. If calls are denied after upgrading, check the denial reason before
the policy: "not bound to a key", "proof already spent", and an htu mismatch
are all distinct messages and each points somewhere different.
Known limitations¶
A proof binds the request line, not the body. On proxy this is the gRPC
method; on extauthz, the method and URI. Message contents are not covered.
A stream is authorized once, at open. One decision, one proof, for the stream's whole life. Per-message binding is not available without target schemas.
An escalation that is refused still leaves a pending approval. The PDP creates the row before either surface can see whether the token was bound, so approving it achieves nothing — nothing will retry against it. This was equally true of the previous blocking behaviour when an escalation timed out. If you see an orphaned approval queue, this is why.
The audit chain is not encrypted, so crypto-shredding is unavailable. That subsystem is not built.
mcp is proven at unit level and against the decision path, not against a
live stack. No live harness exists for it and none existed before this
release; it was not modified.