Skip to content

wardryx 2.4.0

Choose a tag to compare

@TAIPANBOX TAIPANBOX released this 19 Oct 19:37
· 4 commits to main since this release
811da4a

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_fp is the same 16 hex characters.
  • WARDRYX_AGENT_TOKEN_ISSUER set. Each /v1/decide call needs a DPoP proof made for that call. A proof sent a second time is refused with 403 agent_token_proof_invalid. Confirm the enforcement point sends a new proof with each call before you upgrade (Breaking, item 1).
  • WARDRYX_APPROVER_JWKS set. Every grant and every denial needs an approver_statement from the console. Without one it is refused with 403 approver_statement_required and the hold stays pending. Until the console sends statements, set WARDRYX_ALLOW_KEY_APPROVALS=1 (Migration, approver statements, step 4).
  • Startup. A WARDRYX_APPROVER_JWKS file that does not read or parse stops serve. With the file set, the start output names it. WARDRYX_ALLOW_KEY_APPROVALS=1 prints a warning at start, and when WARDRYX_APPROVER_JWKS is 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.

  1. A DPoP proof presented twice (with WARDRYX_AGENT_TOKEN_ISSUER set). A proof is good for one decision. A caller that sends the same agent_dpop_proof on a second /v1/decide call gets 403 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).
  2. A bare key decision (with WARDRYX_APPROVER_JWKS set). A grant or a denial with no verified approver_statement is refused with 403 approver_statement_required, and the hold stays pending. Nothing is decided and no hold moves.

Migration: single-use DPoP proofs

  1. Check the enforcement point's release. It must make a new DPoP proof for each /v1/decide call, bound to that call's token, and must not reuse one. One agent call can cause several decides, and each needs its own proof.
  2. 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.
  3. Upgrade wardryx. A refused proof is logged with the reason "already used within its window".
  4. 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

  1. 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 kid as its RFC 7638 thumbprint, and refuses a set with two keys of one thumbprint.
  2. Set WARDRYX_APPROVER_JWKS to 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.
  3. Make the console send approver_statement on POST /v1/approvals/{id}/decide, for each grant and each denial. The statement is a compact JWS signed ES256 by a console key, with typ approver-statement+jwt and a header that carries only alg, typ and kid. Its claims are v 1, sub (the approver), cred (the passkey credential), aid (the approval), dec (grant or deny), iat, exp (at most 300 seconds after iat) and jti. The statement's aid must be the approval in the path and its dec must be the decision in the request. Anything else is 403 approver_statement_invalid, and nothing is decided.
  4. While the console does not yet send statements, set WARDRYX_ALLOW_KEY_APPROVALS=1. It is read as exactly 1. Each decision it allows is logged with a WARNING line. Remove it once statements flow.
  5. Readers of decided_by must accept both forms. A statement decision reads approver:<sub> with approver_kind person. A key decision reads key:<fingerprint> with approver_kind key.

Added

  • WARDRYX_APPROVER_JWKS, the file of console keys, and WARDRYX_ALLOW_KEY_APPROVALS, read as exactly 1. Both are optional and unset by default. The README's environment table gives each one's default.
  • approver_statement, an optional request field on POST /v1/approvals/{id}/decide.
  • approver_kind (person or key) on each approval in GET /v1/approvals, and on the approval_granted and approval_denied events. A statement decision also writes approver_id, credential_id and statement_jti on those events.
  • Two refusals on that route: 403 approver_statement_invalid, which decides nothing and leaves the hold pending, and 403 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. authenticate takes 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 jti of 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_JWKS set, a verified statement sets decided_by to approver:<sub>. A decision with no statement is recorded as the key, as before. With a key set, a key decision is refused unless WARDRYX_ALLOW_KEY_APPROVALS=1 is 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.json declares 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.feature is new with 16 scenarios. features/bearer-key-store.feature has 5 scenarios, up from 4, and features/agent-token-verified-by-wardryx.feature has 32, up from 30. scripts/scenarios-bind-to-tests.sh reports 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 in scripts/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 Authorization header, 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/store is unchanged.
  • The 2.3.0 residuals stand where this release does not change them. The enforcement point can still skip wardryx, and chain_proven and attestation_method remain its word.