Repository navigation
wardryx 2.4.0
Wardryx 2.4.0
2.4.0 closes three gaps in how wardryx checks a caller and records an approval. A bearer key is now compared in constant time. An agent's DPoP proof is good for one decision, so a proof captured within its window cannot be presented a second time. An approval can name the person who gave it, through a statement the console signs with a key set the operator configures.
It is a minor release. The new settings and refusals are additive, and two refusals are breaking only for a deployment that sets the setting each one belongs to, as the Breaking section names. A deployment that sets neither WARDRYX_APPROVER_JWKS nor WARDRYX_AGENT_TOKEN_ISSUER decides as 2.3.0 did. The one visible difference for it is the constant-time key comparison, which a caller cannot see.
Upgrade
Read this section first. It lists what an operator must check, by setting.
- Bearer keys. No action. Keys are configured as before, and
key_fpis the same 16 hex characters. WARDRYX_AGENT_TOKEN_ISSUERset. Each/v1/decidecall needs a DPoP proof made for that call. A proof sent a second time is refused with403 agent_token_proof_invalid. Confirm the enforcement point sends a new proof with each call before you upgrade (Breaking, item 1).WARDRYX_APPROVER_JWKSset. Every grant and every denial needs anapprover_statementfrom the console. Without one it is refused with403 approver_statement_requiredand the hold stays pending. Until the console sends statements, setWARDRYX_ALLOW_KEY_APPROVALS=1(Migration, approver statements, step 4).- Startup. A
WARDRYX_APPROVER_JWKSfile that does not read or parse stopsserve. With the file set, the start output names it.WARDRYX_ALLOW_KEY_APPROVALS=1prints a warning at start, and whenWARDRYX_APPROVER_JWKSis unset it also says the switch has no effect. - Neither setting. Every decision is made as 2.3.0 made it.
Rollback. A 2.3.0 build ignores both settings. A bare key decides again, and a repeated proof is accepted again. No stored state changes: internal/store and its schema are unchanged, and an approval decided with a statement keeps approver:<sub> in decided_by, which 2.3.0 shows as text. The proof record is in memory, so a restart clears it.
Breaking
Two refusals are new. Each applies only to a deployment that sets the setting it belongs to.
- A DPoP proof presented twice (with
WARDRYX_AGENT_TOKEN_ISSUERset). A proof is good for one decision. A caller that sends the sameagent_dpop_proofon a second/v1/decidecall gets403 agent_token_proof_invalid. A caller that sends a fresh proof with each call is not affected, because a DPoP proof is made per request (RFC 9449). - A bare key decision (with
WARDRYX_APPROVER_JWKSset). A grant or a denial with no verifiedapprover_statementis refused with403 approver_statement_required, and the hold stays pending. Nothing is decided and no hold moves.
Migration: single-use DPoP proofs
- Check the enforcement point's release. It must make a new DPoP proof for each
/v1/decidecall, bound to that call's token, and must not reuse one. One agent call can cause several decides, and each needs its own proof. - In a test deployment, send two decides with the same proof. The second must be refused with
403 agent_token_proof_invalid. A decide with a fresh proof must be allowed. - Upgrade wardryx. A refused proof is logged with the reason "already used within its window".
- If the enforcement point cannot send fresh proofs yet, fix that release before you upgrade a deployment that sets the issuer. Until it does, each decide that reuses a proof is refused.
Migration: approver statements
- Put the console's public keys in one file as a JWK set. Every key must be EC P-256. The loader computes each key's
kidas its RFC 7638 thumbprint, and refuses a set with two keys of one thumbprint. - Set
WARDRYX_APPROVER_JWKSto that file's path. A server with no key set refuses every statement, so set the file before the console starts to send statements. Keys are read at start, so a rotation needs a restart. - Make the console send
approver_statementonPOST /v1/approvals/{id}/decide, for each grant and each denial. The statement is a compact JWS signed ES256 by a console key, withtypapprover-statement+jwtand a header that carries onlyalg,typandkid. Its claims arev1,sub(the approver),cred(the passkey credential),aid(the approval),dec(grantordeny),iat,exp(at most 300 seconds afteriat) andjti. The statement'saidmust be the approval in the path and itsdecmust be the decision in the request. Anything else is403 approver_statement_invalid, and nothing is decided. - While the console does not yet send statements, set
WARDRYX_ALLOW_KEY_APPROVALS=1. It is read as exactly1. Each decision it allows is logged with a WARNING line. Remove it once statements flow. - Readers of
decided_bymust accept both forms. A statement decision readsapprover:<sub>withapprover_kindperson. A key decision readskey:<fingerprint>withapprover_kindkey.
Added
WARDRYX_APPROVER_JWKS, the file of console keys, andWARDRYX_ALLOW_KEY_APPROVALS, read as exactly1. Both are optional and unset by default. The README's environment table gives each one's default.approver_statement, an optional request field onPOST /v1/approvals/{id}/decide.approver_kind(personorkey) on each approval inGET /v1/approvals, and on theapproval_grantedandapproval_deniedevents. A statement decision also writesapprover_id,credential_idandstatement_jtion those events.- Two refusals on that route:
403 approver_statement_invalid, which decides nothing and leaves the hold pending, and403 approver_statement_required. The reason strings are the response, not wire fields. - Start output: a line naming the key file when it is set, and the warnings for
WARDRYX_ALLOW_KEY_APPROVALS.
Changed
- Bearer comparison.
authenticatetakes the digest of the presented bearer and compares it with every configured digest in constant time, with no early exit. It no longer looks the digest up. Callers see no difference. The time taken grows with the number of configured keys, which the operator sets. - Proof record. With the issuer set, the
jtiof each accepted proof is remembered until its window closes, which is 60 seconds by default and at most 300. A second presentation is refused. The record is in memory, per process, and bounded at 262,144 entries. When it is full a new proof is refused until an entry's window closes, so it fails closed. - Who approved. With
WARDRYX_APPROVER_JWKSset, a verified statement setsdecided_bytoapprover:<sub>. A decision with no statement is recorded as the key, as before. With a key set, a key decision is refused unlessWARDRYX_ALLOW_KEY_APPROVALS=1is set. The refusal is checked after the org check and before anything is written. - Invariants. CLAUDE.md invariant 13 names the proof record in its still-trusted paragraph, invariant 42 states the constant-time comparison, and invariant 43 is new.
- Components and README.
components.jsondeclares the two new settings as optional. The README gains two environment rows, a paragraph on approval statements, a checklist line, and a note that the approver is the deciding key unless a statement names the person.
Changed since 2.3.0
No other commit lies between the 2.3.0 tag and this release.
- #124,
fix: constant-time bearer compare, single-use DPoP proofs, approvals that name the person. One squashed commit with the three changes described above.
Security fixes, by what they close
- A bearer key matched by a map lookup. The lookup of a presented key's digest did work that depended on the digest. Every configured digest is now compared in constant time, with no early exit, so the time taken does not say which key matched. A gate refuses any index of the key store in
internal/api. - An agent's DPoP proof presented again. A captured proof could be presented at any configured door within its window, which is at most 300 seconds. Each accepted proof is now single use within its window.
- An approval that named a key, not a person. A grant or denial recorded the bearer key that sent it, so the record could not say which person decided. With
WARDRYX_APPROVER_JWKS, the console signs a statement naming the approver, and wardryx verifies it against the console's keys. While a key set is configured, a bare key cannot decide an approval unless the operator allows it, and each such decision is logged.
Documentation and tests
- 645 test functions, the README badge's count, up from 614 in 2.3.0: 31 new.
features/approver-statement.featureis new with 16 scenarios.features/bearer-key-store.featurehas 5 scenarios, up from 4, andfeatures/agent-token-verified-by-wardryx.featurehas 32, up from 30.scripts/scenarios-bind-to-tests.shreports 267 scenarios and 344 bindings, each naming a test that exists.- Invariant 42 is held by
scripts/no-raw-secret-lookup.sh, with five cases inscripts/gates-have-teeth.sh. Invariant 43 has tests and no gate script. - The mutants recorded under invariants 42 and 43 were each caught by a named test.
Not proven: where it says nothing
- No run against a live console or a live enforcement point. The tests make statements and proofs with generated keys.
- The proof record is per process. Behind several wardryx instances, a proof presented once to each is accepted by each. A restart clears the record, so a proof seen before a restart can be presented again within its window.
- A full proof record refuses new proofs until an entry's window closes. The bound is a refusal, not a queue.
- A statement captured before it is used can decide its approval within its validity, at most 300 seconds. A second decision on the same approval is
409. A statement is bound to its approval id, so it cannot decide another approval. - The console is believed about the person, and its key set is the boundary. The credential id is on the event and not on the approval row. A fingerprint names a key, not a person. The key set is read at start.
- The timing of the constant-time walk was not measured. The bearer gate follows one assignment from the
Authorizationheader, so a lookup inside a helper that takes the bearer is not seen. - The Postgres integration suite was not run here, because it needs
DATABASE_URL.internal/storeis unchanged. - The 2.3.0 residuals stand where this release does not change them. The enforcement point can still skip wardryx, and
chain_provenandattestation_methodremain its word.