From f334282d52b701138814adac9022c54299add719 Mon Sep 17 00:00:00 2001 From: Artem Goncharov Date: Fri, 13 Nov 2026 12:39:59 +0100 Subject: [PATCH 1/3] docs(adr): Add single-use credential registry Add ADR 0044 for replay protection of external IdP assertions, TOTP passcodes and DPoP proofs. Each credential is accepted at most once inside its validity window. The ADR defines a SingleUseStore trait with a bounded local implementation and a Raft-backed cluster implementation. The cluster store uses durable CreateIfAbsent records in time-bucketed keyspaces that a new replicated DropKeyspace mutation removes. It also sets per-use-case defaults with the CRA and NIS2 rationale, the order of checks, error mapping, rolling upgrade gate and clock skew requirements. Signed-off-by: Artem Goncharov --- doc/src/SUMMARY.md | 1 + .../0044-single-use-credential-registry.md | 584 ++++++++++++++++++ 2 files changed, 585 insertions(+) create mode 100644 doc/src/adr/0044-single-use-credential-registry.md diff --git a/doc/src/SUMMARY.md b/doc/src/SUMMARY.md index 6781c3a43..744eef328 100644 --- a/doc/src/SUMMARY.md +++ b/doc/src/SUMMARY.md @@ -156,3 +156,4 @@ ______________________________________________________________________ - [OAuth2 Provider Session Management](adr/0041-oauth2-session-management.md) - [Provider Event Delivery and Delete Cascades](adr/0042-event-delivery-and-cascades.md) - [OAuth2 Sender-Constrained Tokens with DPoP](adr/0043-oauth2-dpop.md) + - [Single-Use Credential Registry](adr/0044-single-use-credential-registry.md) diff --git a/doc/src/adr/0044-single-use-credential-registry.md b/doc/src/adr/0044-single-use-credential-registry.md new file mode 100644 index 000000000..f4c812a03 --- /dev/null +++ b/doc/src/adr/0044-single-use-credential-registry.md @@ -0,0 +1,584 @@ +# 44. Single-use credential registry (replay cache) + +**Date:** 2026-11-13 + +## Status + +Proposed. + +## Reference + +Extends ADR 0043 (DPoP, `jti` cache per node) and ADR 0026 (token exchange as +STS, issue #945). Related: ADR 0016-v2 (Raft storage, at-rest encryption), ADR +0022 (rate limiting), ADR 0036 §6.4 (shared validation surfaces), ADR 0041 +(session state in Raft). Standards: RFC 9449 §11.1 (DPoP proof replay), RFC 7523 +§3 (`jti`), RFC 6238 §5.2 (a TOTP passcode must not be accepted twice). +Regulation: EU Cyber Resilience Act Annex I Part I (secure by default), NIS2 +Art. 21 (security of network and information systems, including availability). + +## Context + +Several credentials must be accepted at most once inside a time window: + +- **External assertions** presented to `POST /v4/oauth2/{domain_id}/token` for + token exchange (ADR 0026, #945). The JWT from the external IdP is the whole + credential. It carries no second secret, no `ath` and no nonce. The only thing + that stops a second use is a `jti` check, and a second use mints a full + Keystone token. +- **TOTP passcodes**, which must not be accepted twice inside the accepted time + window. +- **DPoP proofs** (ADR 0043), where the cache is defense in depth: a proof is + bound to method and URL, `/token` additionally requires a server nonce that + every node accepts, and an attacker who captured the proof of a resource + request has captured the access token too. + +Today only DPoP has a cache, `DpopReplayCache`, a bounded in-memory map that is +**per process**. With N Keystone nodes a captured credential can be used up to N +times inside its window, and a restart empties the cache. ADR 0043 accepts that +for DPoP. It is not acceptable for external assertions, where the credential is +a bearer token with no further binding. + +Keystone already has cluster state. The Raft storage (`crates/storage`) offers +an atomic `Mutation::CreateIfAbsent`: the leader applies it, and a batch with a +`CONFLICT` violation is not committed. Records are either durable (Fjall, +encrypted, in snapshots) or ephemeral (`Metadata::ephemeral()`: in memory only, +no disk, encryption or snapshot). WebAuthn ceremony state uses ephemeral records +in time-bucketed keyspaces (`crates/webauthn/src/driver/raft.rs`). A shared +external cache (memcached, Redis) is not used anywhere in the Rust code base. + +Ephemeral records are not usable for single-use checks. A snapshot install +clears them (`restore.rs`), and a restarted node resumes from the +`last_applied_log` stored in Fjall without replaying earlier log entries, so its +ephemeral map starts empty. The answer to `CreateIfAbsent` comes from the +leader's apply. A leader that restarted or installed a snapshot inside the +window therefore accepts a replay, and Raft elects leaders by log completeness, +not by ephemeral state. + +`StorageApi::drop_keyspace` acts on the local node only and is not replicated. +WebAuthn calls it from the leader, so followers never drop their bucket +keyspaces. + +## Decision + +### 1. One abstraction, two implementations + +A trait in `crates/core` records that a `(scope, id)` pair was used: + +```rust +#[async_trait] +pub trait SingleUseStore: Send + Sync { + /// Record `(scope, id)`. `expires` is the first unix second at which the + /// entry is dead; it must not be earlier than the last moment the same + /// credential could still pass verification. + async fn consume( + &self, + scope: &str, + id: &str, + expires: i64, + now: i64, + ) -> Result<(), SingleUseError>; + + /// Periodic maintenance. + async fn cleanup(&self, now: i64) -> Result<(), SingleUseError>; +} + +pub enum SingleUseError { + /// Already recorded and not yet expired. + Replayed, + /// Local store bounds, or the cluster store's admission limit + /// (section 3), reached. + Capacity, + /// `expires` is in the past or beyond the use case's maximum lifetime; + /// the caller computed it wrongly. + InvalidLifetime, + /// Cluster store cannot be reached, did not answer, or rejected the write + /// for a reason other than a conflict. + Unavailable(Box), +} +``` + +Callers own the semantics of scope, id and lifetime: + +| Use case | scope | id | `expires` | +| ------------------ | -------------------------------------- | ------------------------ | ------------------------------- | +| External assertion | `(domain_id, issuer)`, length-prefixed | `jti` | `exp + skew + 1` | +| TOTP | `user_id` | `{credential_id}:{step}` | end of the last accepted window | +| DPoP proof | `jkt` | `jti` | `iat + window + 1` | + +DPoP proofs at `/token` and DPoP proofs on resource requests are separate use +cases with separate options, defaults and keyspace prefixes (sections 4 and 8). + +An external assertion without a `jti` (missing, empty or not a string) is +rejected with `invalid_grant` **before** `consume`. It is never accepted without +a check, and no id is derived from other claims: that would need a hash and a +crypto inventory row. SPIFFE JWT-SVIDs may omit `jti`; such an issuer cannot be +used for token exchange until a derived-id option is decided in its own ADR. + +All length prefixes count **bytes**, not characters. The scope is encoded as +`"{len(domain_id)}:{domain_id}{issuer}"` so that `("a","b")` and `("ab","")` +stay apart. No hash is used, so no cryptographic primitive is added and the +crypto inventory does not change. + +For TOTP, `step` is the time step that **matched** the passcode, not the current +step: the verifier accepts the current and the previous step, and the same +passcode must be caught whichever step the second attempt is checked in. The +passcode is accepted while the matched step is the current or the previous one, +so `expires = (step + 2) * period`. The id includes the credential so that a +user with several TOTP devices is not blocked by a login with another device in +the same step. Two parallel logins with the same device in the same step share a +passcode and the second is rejected, as RFC 6238 §5.2 requires. + +Today `verify_totp` (`crates/core/src/credential/totp.rs`) returns only a +`bool`, and `authenticate_by_totp` (`identity/service.rs`) does not record used +passcodes. A passcode can be replayed inside its window, on any node. The +implementation changes both: + +- `verify_totp` returns the matched step, and the identity service returns the + matched credential id with the authentication result. +- `consume` is called by the code that sees every authentication method of the + request, not inside `authenticate_by_totp`: the v3 token flow + (`api/v3/auth/token/common.rs`) and the OAuth2 MFA step + (`api/v4/oauth2/mfa.rs`). Both entry points use the same helper. +- `period` from the credential blob is capped (proposed 300 s) so that + `max_lifetime` is bounded by `2 * 300` s. + +Every implementation enforces the same preconditions before touching its store: + +- `expires > now`, else `InvalidLifetime`. +- `expires - now <= max_lifetime(use case)`, else `InvalidLifetime`. The bound + is the configured maximum credential age plus skew (assertion `exp` cap, TOTP + window, DPoP `iat` window). This keeps a caller bug or a missing `exp` cap + from creating entries, or cluster buckets, far in the future. +- `id` is at most 256 bytes. That is the store's cap; a caller may apply a lower + one first. DPoP keeps its `MAX_JTI_LEN` of 128 bytes + (`oauth2_client/dpop.rs`) and rejects with `invalid_dpop_proof`. An assertion + `jti` over 256 bytes is rejected with `invalid_grant`. In both cases the + credential is rejected before `consume`, never truncated. The lower cap + wins. + +Implementations: + +- **Local**: the bounded in-process cache of #1640 (`ReplayCache`). Its bounds + are configured per use case (section 2). +- **Cluster**: a Raft-backed store (section 3). + +### 2. Local implementation + +Keeps the rules of the existing DPoP cache: bounded per scope, per domain (for +assertions) and in total, and **a live entry is never evicted to make room**. +Once a bound is reached, new credentials are refused (`Capacity`). Evicting +would let an attacker flood the cache with fresh scopes, push out the entry of a +captured credential and replay it. The per-domain bound keeps one domain's +tenants from exhausting the total for every other domain. Dead entries are +purged lazily when a bound is about to be hit and by a periodic task. + +Bounds are options. The existing DPoP options +`[oauth2] dpop_replay_cache_max_entries` and +`dpop_replay_cache_max_entries_per_key` keep their names and meaning (total, and +per `jkt`) and apply to both DPoP use cases; the existing check that the +per-key value does not exceed the total stays. New options follow the same +shape: `[oauth2] assertion_replay_cache_max_entries`, +`assertion_replay_cache_max_entries_per_domain` and +`assertion_replay_cache_max_entries_per_issuer`, and +`[auth] totp_replay_cache_max_entries` and +`totp_replay_cache_max_entries_per_user`. Each per-scope value must not exceed +its total. + +### 3. Cluster implementation + +**Records.** + +- Key `"{len(scope)}:{scope}{id}"` in a keyspace + `single_use_{use_case}_{bucket}` with `bucket = expires / 300` (bucket width + 300 s). +- Written with `Mutation::create_if_absent` as a **durable** record: + `Metadata::new()`, `DataTier::Internal`, empty value. The record goes through + the normal encrypted Fjall path and is part of snapshots, so it survives a + restart, a snapshot install and a leader change like any other state. +- Keys are stored in plaintext, as for every other record. They hold only + identifiers that are not secret (domain id, issuer, `jti`, user id, credential + id, time step). The value is empty, so the encryption adds integrity only. +- Cost per `consume`, on every node: one AES-GCM encryption of an empty value + and two small entries (data, meta) in the batch that `apply()` already writes + and fsyncs once per apply call. No additional fsync. + +**Result mapping.** + +| Storage result | `SingleUseError` | +| ------------------------------------------------ | ---------------- | +| committed, no violation | `Ok` | +| `CONFLICT` violation | `Replayed` | +| any other violation (`WRITE_RATE_EXCEEDED`, ...) | `Unavailable` | +| transport error, timeout, no leader | `Unavailable` | + +Only `CONFLICT` means replay. Mapping any other violation to `Replayed` would +record a replay in the audit trail that did not happen. + +**Bucket from `expires`.** The bucket is derived from `expires`, not from the +current time. Every presentation of the same credential has the same `expires`, +so every node resolves it to the same keyspace and one atomic create decides the +question. Time-based buckets as used for WebAuthn state would need a look-back +across keyspaces, which is not atomic. A credential whose `jti` is reused with a +different `exp` lands in another bucket and is not detected. Accepted: the +supported issuers that set `jti` (GitHub Actions, Keycloak) do not do this, +`exp` is signed so a presenter cannot change it, and it is capped. + +**Replicated keyspace drop.** A new `Mutation::DropKeyspace { keyspace }` is +committed through Raft and applied on every node: + +- Apply drops the keyspace and its data and meta entries if it exists, whether + empty or not. Apply is serialized, so no write can race the drop. A missing + keyspace is a no-op, which makes the mutation idempotent. +- Only keyspaces whose name matches an allow-listed prefix registered in the + storage crate may be dropped. This ADR registers `single_use_`, and the name + must have the form `single_use_{use_case}_{bucket}` with a numeric bucket. Any + other keyspace, including core, reserved and other feature keyspaces such as + `oauth2_keys` or the WebAuthn buckets, is refused as a violation. A bug in + bucket-name computation therefore cannot reach unrelated state. A feature that + adopts the mutation later (WebAuthn) registers its own prefix. +- Storage authorization (`grpc/authz.rs`) covers the new mutation like the other + write mutations. +- A snapshot taken after the drop does not contain the keyspace; a follower that + installs it gets the same state. + +The existing local-only `drop_keyspace` stays for callers that need it. +WebAuthn's bucket cleanup has the same follower leak and can move to the new +mutation; that change is not part of this ADR. + +**Cleanup.** Runs on the Raft leader only (`current_leader() == node_id()`). +Every node holds every bucket keyspace locally, so the leader lists its own +keyspaces by prefix instead of probing computed names. `StorageApi` gains a +read-only `list_keyspaces(prefix)` for this (Fjall can list keyspace names). For +each `single_use_{use_case}_{bucket}` with `(bucket + 2) * 300` in the past it +proposes `DropKeyspace`. This needs no look-back range and no watermark: a +bucket missed during an outage, a leader change or a gap is found on the next +tick, and a bucket is never left behind by a bounded probe. A leader change +between ticks only means the next leader proposes the same idempotent drop. No +per-key drain is needed. + +**Retention.** One anchor: `expires`. With `bucket = floor(expires / 300)`, the +drop point `(bucket + 2) * 300` lies between `expires + 301` and `expires + 600` +s. An entry is therefore kept at least until `expires`, and dropped between +`expires + 300` and `expires + 600` s on the leader's clock, plus one cleanup +tick. `expires` is at most `max_lifetime` after the write, so an entry lives at +most `max_lifetime + 600` s plus one cleanup tick. + +**Clock skew.** Buckets come from the credential's `expires`, but the drop uses +the leader's clock while each verifying node accepts the credential by its own +clock. A credential is replayable after the drop if a verifier's clock is +behind the leader's by more than the 300 s margin (the drop point is at least +`expires + 300` s). The margin is the tolerated skew between any two voters. +Operators must run time synchronization; a node whose clock differs from the +leader's by more than half the margin (150 s) reports it in a metric and a +warning, and the cluster store is documented as unsupported beyond the margin. +`[storage] single_use_cleanup_margin_secs` (default 300) raises the margin for +deployments with poorer synchronization, at the cost of longer retention. + +**Admission limit.** The Raft store has no per-scope counter, and a holder of a +valid grant could otherwise turn requests into replicated durable writes. Every +node applies a per-scope admission rate to `consume` before it proposes: a +limiter in the ADR 0022 style keyed on the scope (`(domain_id, issuer)` for +assertions, `user_id` for TOTP, `jkt` for DPoP) plus a per-node cap on in-flight +proposals. A refused request returns `Capacity` and writes nothing. The bound on +cluster-wide entries is therefore `voters * rate * (max_lifetime + 600)`, not a +hard cap, which the section states instead of implying a quota. A bucket-size +metric and alert remain part of the implementation. + +**Rolling upgrade (feature gate).** Nodes that do not know `DropKeyspace` +cannot apply it, and a replicated log entry they cannot decode would stop them. +A cluster-wide capability gate controls this: + +- Each node advertises a storage feature level in its Raft node information + (a new field next to `node_id` and `rpc_addr`). +- The leader proposes the committed record "`single_use_v1` enabled" only when + every voter and learner reports the level. Until then the gate is closed and + no node proposes `DropKeyspace` or writes `single_use_*` keyspaces. +- With the gate closed, defaults resolve as follows, logged once and exposed in + the metric: external assertion exchange fails closed with `Unavailable` (the + exchange is new in the same release and must not run without its check); + TOTP and DPoP `/token` use `local`, never weaker than before the upgrade; a + configuration that explicitly selects `raft` waits, with a warning, instead of + failing start-up. Rule 1 (no silent fallback) applies to explicit + configuration, not to this transient state. +- The gate opens once and does not close. A node added later must report the + level or the join is refused. Downgrading below the level after the record is + committed is unsupported and needs a restore from a backup. + +### 4. Per-use-case configuration + +The operator chooses the store per use case: + +| Option | Default | +| ----------------------------------------------------- | ---------------------------------------------------- | +| `[oauth2] assertion_replay_store = raft \| local` | `raft` when Raft storage is configured, else `local` | +| `[auth] totp_replay_store = raft \| local` | same rule | +| `[oauth2] dpop_token_replay_store = raft \| local` | same rule | +| `[oauth2] dpop_resource_replay_store = local \| raft` | `local` | + +The reasoning behind each default is in section 9. + +Rules: + +1. **No silent fallback.** An explicit `raft` without Raft storage is a start-up + error. An unset option resolves by the default rule and the result is logged + once. +2. **Fail closed.** With the cluster store selected, `Unavailable` rejects the + request. It never degrades to the local cache at run time. `local` is a + deployment choice, not a fallback. +3. **No "off".** A missing check on assertions or TOTP is a vulnerability, not a + tuning choice. +4. **Weak combination is loud.** + - External assertions: when token exchange with external assertions is + enabled (at least one trusted issuer is configured), `local` is a start-up + error unless + `[oauth2] allow_per_node_assertion_replay = true`, **whether or not Raft is + configured**. A deployment that does not use token exchange is not + affected and needs no flag. Without Raft, Keystone cannot see other + processes behind a load balancer, so it cannot tell a single node from + many. The credential is a bare bearer token, so the operator must state + that per-node is accepted. + - TOTP and DPoP `/token`: `local` logs a warning at start-up naming the use + case and its guarantee (section 7). With Raft, the warning is repeated + while the cluster has more than one voter. It is a warning, not an error: + for TOTP the password is a second factor, and for DPoP `/token` the proof + is useless without the grant it was sent with. + - DPoP resource requests: `local` is the default and logs nothing. Choosing + `raft` logs a warning naming the cost (section 8). +5. The resolved store is visible in telemetry and in the audit event of the + exchange. + +Each use case gets its own keyspace prefix, so cleanup and sizing are separate. + +### 5. When to record + +The order of checks matters more than the store. + +For **external assertions**: + +1. Verify the credential (signature, `iss`, `aud`, `exp`, age cap). +2. Evaluate the mapping ruleset and require that it **matched** (bound claims + such as repository or reference passed). +3. Only then call `consume`. +4. Mint the Keystone token. + +Recording after step 1 would let any holder of a valid signature fill the store. +GitHub Actions issues a validly signed token with an arbitrary `aud` to any +GitHub user, so a stranger could exhaust the `(domain, issuer)` quota, and the +shared total, with tokens the mapping would reject. With the cluster store the +junk is also replicated to every node. Recording after step 2 limits entries to +tokens that were authorized. + +For **TOTP**: call `consume` only after every authentication method in the +request has succeeded (for example password and TOTP), then issue the token. +Recording after the TOTP check alone would let someone who has seen a passcode +but lacks the password burn the step for the real user. + +A credential that was recorded and then failed later (minting, database) is +**burned**: the client has to obtain a new one. This is deliberate. The +alternative, releasing the entry on failure, reopens a replay window. + +A write can commit while its answer is lost (timeout, leader change). The caller +sees `Unavailable` and fails the request; the client's retry with the same +credential sees `Replayed`. This is the burned case above, not an attack, and +the audit event for `Replayed` must not be read as proof of theft on its own. + +Further bounds: + +- The quotas count entries, not bytes; the `id` length cap (section 1) bounds + the bytes. +- The Raft store enforces an admission rate per scope (section 3), in addition + to the ordering above, `max_lifetime` and rate limiting of the token endpoint + (ADR 0022). There is no cluster-wide hard cap; the bound is stated in section + 3. A bucket-size metric with an alert is part of the implementation. + +### 6. Errors, audit and metrics + +| `SingleUseError` | External assertion (`/token`) | TOTP | DPoP (`/token`, resource request) | +| ----------------- | ---------------------------------- | ------------------------- | --------------------------------------------- | +| `Replayed` | 400 `invalid_grant` | 401, same as bad passcode | 400 / 401 `invalid_dpop_proof` (RFC 9449) | +| `Capacity` | 503, `Retry-After` | 503, `Retry-After` | 503, `Retry-After` | +| `Unavailable` | 503, `Retry-After` | 503, `Retry-After` | 503, `Retry-After` | +| `InvalidLifetime` | 400 `invalid_grant`, logged as bug | 500, logged as bug | 400 / 401 `invalid_dpop_proof`, logged as bug | + +A `Replayed` result is a signal that a credential leaked or a client misbehaves. +It emits a CADF audit event with the use case, the scope (domain and issuer, or +user), the `jti` or step and the resolved store, and increments a metric +labelled by use case and store. The response body does not say which check +failed beyond the error code. + +### 7. Guarantees + +| Use case | `local` | `raft` | +| --------------------- | ------------------------------------------ | ---------------------------------------------------------------- | +| External assertion | at most once per node, empty after restart | at most once per cluster while the entry is retained (section 3) | +| TOTP | same | same | +| DPoP `/token` | per node; nonce is cluster-wide | at most once per cluster (default with Raft) | +| DPoP resource request | per node; proof and token travel together | at most once per cluster; not recommended (section 8) | + +The `raft` column also assumes the gate (section 3) is open and the voters' +clocks are within the cleanup margin. + +Operators must read this table as: `local` bounds replay per node, not per +cluster. The `raft` guarantee holds across restarts, snapshot installs and +leader changes. It does not hold across a restore from a backup older than +`max_lifetime`: entries written after the backup are lost and those credentials +can be replayed until they expire. A restore is an operator action and the +runbook says so. + +### 8. DPoP + +DPoP is split into two use cases. + +- **`/token` proofs** default to `raft` when Raft storage is configured. The + cost is one Raft write on a rare request, the same as an assertion exchange. +- **Resource-request proofs** stay on `local` (ADR 0043). `raft` is allowed but + **not recommended**: a Raft write and a durable entry per authenticated API + request scale with traffic, an outage of the cluster store fails every DPoP + request closed, and anyone holding one valid token and key can turn request + volume into consensus writes on every node. + +ADR 0043's wording ("the short window bounds the impact") is amended to state +the actual guarantee: at most once per node for resource requests, at most once +per cluster for `/token` on Raft deployments, and for resource requests an +attacker who holds the proof also holds the access token. + +`htu` covers scheme, host and path only (RFC 9449 §4.2); neither the query nor +the body is bound. A captured resource request can therefore be replayed on +another node within the `iat` window with a different query or body to the same +method and path. The mitigation is the window length (`max_skew_secs`, default +60 s), not consensus. + +### 9. Secure default rationale + +The EU Cyber Resilience Act (Annex I, Part I) asks for a secure-by-default +configuration, and NIS2 (Art. 21) treats availability as part of security. A +secure default is not the strongest mechanism everywhere: it handles the known +risks without operator action and states the residual risk, so an operator can +judge the trade-off. This section is that record for each default. + +| Use case | Default (with Raft / without) | Risk handled | Residual risk | +| --------------------- | ----------------------------- | ---------------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------- | +| External assertion | `raft` / start-up error | replay of a bare bearer credential minting a token | restore from an old backup (section 7); without Raft, per node, only after explicit opt-in | +| TOTP | `raft` / `local` + warning | reuse of an observed passcode across nodes | without Raft, per node; the password still has to be known | +| DPoP `/token` | `raft` / `local` + warning | reuse of a captured proof across nodes | without Raft, per node; a replay also needs the grant (single-use code, rotated refresh token) and a valid nonce | +| DPoP resource request | `local` / `local` | replay on the same node; any replay after the window | replay on another node within the window, to the same method and path, with any query or body; attacker already holds the token | + +Why the DPoP resource-request default is not `raft`: + +- RFC 9449 §11.1 requires only a short acceptance window; a `jti` store is + something a server may add. `local` already exceeds the standard. +- `raft` would make every DPoP-authenticated API request depend on cluster + consensus, which turns a Raft outage into a full authentication outage and + request volume into replicated writes. Both weaken availability more than the + per-node gap weakens integrity. + +Hardening available to operators, documented in the configuration reference: +lower `max_skew_secs`, and set `dpop_resource_replay_store = raft` where the +API traffic and the availability coupling are acceptable. + +## Alternatives considered + +- **Local cache only.** Rejected for assertions and TOTP: with N nodes the + guarantee is N uses; the credential is a bearer token. Kept for DPoP and for + deployments without Raft. +- **Ephemeral Raft records.** Rejected: a snapshot install clears them and a + restarted node does not rebuild them, so a leader that restarted inside the + window accepts a replay (see Context). +- **A new record class kept in memory and included in snapshots.** Rejected for + now. Snapshots alone do not fix restarts, because a node resumes from Fjall's + `last_applied_log` and never reloads a snapshot at start-up. It would need a + start-up recovery step (load the class from the last snapshot, re-apply the + log after it), a copy of the map consistent with the snapshot index, and + upgrade gating. The saving over durable records is one small encryption and a + few memtable entries per write, since `apply()` already fsyncs. Can be + revisited with its own ADR if a benchmark shows Fjall apply is the bottleneck, + e.g. for DPoP on resource requests. +- **An unencrypted storage tier.** Rejected. The saving is microseconds per + write, while "all state at rest is encrypted" (ADR 0016-v2) is a rule that is + easy to audit and an exception would have to be reviewed for every future + record placed in it. It also adds an unencrypted branch to read, DEK rotation, + re-encryption, quarantine and restore. +- **Memcached.** Rejected as the only store. It evicts under memory pressure, + which breaks the no-eviction rule above; a restart or a node loss empties it + silently; it adds a mandatory service that the project avoids by design, with + TLS, authentication and audit work. It would fit behind the trait as an + optional backend. +- **Redis with `noeviction`.** Technically sufficient (`SET NX EX`), but a new + mandatory dependency. Same position as memcached: possible later behind the + trait for high-rate cases such as DPoP on resource requests. +- **Raft by default for all DPoP proofs.** Rejected for resource requests: + write cost scales with API traffic, an outage fails all authenticated + requests, and request volume becomes cluster write load (section 9). Adopted + for `/token` proofs. +- **`local` by default for all DPoP proofs (ADR 0043 as written).** Rejected for + `/token`: the cluster check costs one write on a rare request, so leaving it + off would not be a defensible secure default. +- **Time-based buckets as in WebAuthn.** Rejected for single-use checks because + the atomic create guards only one keyspace. +- **Per-key drain before a local drop (WebAuthn pattern).** Rejected: the drop + does not reach followers, and draining costs one Raft write per entry. The + replicated `DropKeyspace` removes a bucket in one entry on every node. + +## Implementation notes + +1. Trait, error type, preconditions, scope helpers and the shared behavioural + suite (`single_use` in `crates/core`), the replay options and their + validation. No implementation lands with this step. +2. #1640 on top of step 1: `ReplayCache` implements the trait through a thin + async wrapper, gains the per-domain bound and passes the shared suite; + `AssertionReplayCache` builds its scope with `assertion_scope`; + `DpopReplayCache` becomes a wrapper. Its module documentation is corrected + to say per node only and to require recording after the mapping match. Then + the store options and the start-up factory. `raft` is rejected with a clear + message until step 4. +3. Storage: `Mutation::DropKeyspace` with the prefix allow-list (apply, authz, + wire format, snapshot and restore tests), `list_keyspaces(prefix)`, the + feature level in the node information and the capability gate, and a + membership listing (voters and learners with their levels). +4. Raft implementation, cleanup task, admission limiter, tests against + `MockStorage` and the cluster harness, including a leader restart and a + snapshot install inside the window. The same behavioural suite runs against + both implementations through the trait: boundary at `expires`, + `InvalidLifetime`, quota without eviction, scope separation. +5. External assertion exchange (#945 step 5) and TOTP move onto the trait, with + the error mapping, audit event and metric of section 6. The assertion path + rejects a missing `jti`; the TOTP path follows section 1 (matched step, + credential id, shared helper, `period` cap). DPoP gets its two + options; `dpop_replay_store` is not introduced. +6. `require_no_conflict` exists in several Raft drivers; move it to + `storage-api` rather than add another copy, and make it map only `CONFLICT` + to a conflict. + +## Open questions + +- The storage API exposes `current_leader()` and `node_id()` but no membership + listing. The feature gate (section 3) and the repeated warning in section 4 + rule 4 both need it, so it is added to `StorageApi` (implementation step 3). +- A derived id for issuers that omit `jti` (for example SPIFFE JWT-SVIDs) would + need a hash and a crypto inventory row; not decided here. +- Deployments with SQL but without Raft only get `local`. A unique-key table in + the SQL driver could provide a cluster store for them; not evaluated. + +## Consequences + +- External assertion exchange gets a cluster-wide single-use guarantee on Raft + deployments that holds across restarts and leader changes, and an explicit, + acknowledged per-node guarantee elsewhere. An operator cannot get the weaker + one by accident, with or without Raft. +- A cluster store adds one consensus write per exchange or TOTP login, and a few + hundred bytes per entry in Fjall on every node (key with domain, issuer and + `jti`, plus metadata) for at most `max_lifetime + 600` s plus one cleanup + tick. +- The cluster store needs time synchronization between voters within the cleanup + margin, and a one-way capability gate during a rolling upgrade. +- Issuers that do not set `jti` cannot be used for token exchange. +- The storage layer gains a replicated keyspace drop that other bucketed state + (WebAuthn) can adopt. +- Exchange, TOTP and DPoP `/token` availability depend on the cluster store + when it is selected, which is the default on Raft deployments. A Raft outage + fails them closed. DPoP resource requests do not depend on it by default. +- Each default and its residual risk is recorded (section 9) as the + secure-by-default record for CRA and NIS2 assessments. +- A burned credential cannot be retried; clients must obtain a new one. +- The trait lets a Redis-style backend be added later without touching the + callers. From 89247716bd4bdc63554d9e9f93245f3a8cdcb5ad Mon Sep 17 00:00:00 2001 From: Artem Goncharov Date: Fri, 13 Nov 2026 12:40:00 +0100 Subject: [PATCH 2/3] feat(core): Add single-use credential store trait Add the SingleUseStore trait, SingleUseError, the shared preconditions and the assertion scope helpers from ADR 0044, plus a behavioural suite every implementation must pass. No implementation is included: the local store lands with the replay cache and the Raft store later. Signed-off-by: Artem Goncharov --- crates/core/src/lib.rs | 1 + crates/core/src/single_use.rs | 201 ++++++++++++++++++++++ crates/core/src/single_use/conformance.rs | 126 ++++++++++++++ 3 files changed, 328 insertions(+) create mode 100644 crates/core/src/single_use.rs create mode 100644 crates/core/src/single_use/conformance.rs diff --git a/crates/core/src/lib.rs b/crates/core/src/lib.rs index bdec72c49..2d71069a2 100644 --- a/crates/core/src/lib.rs +++ b/crates/core/src/lib.rs @@ -127,6 +127,7 @@ pub mod role; pub mod scim_realm; pub mod scim_resource; pub mod service_grant; +pub mod single_use; pub mod spiffe_key; pub mod token; pub mod trust; diff --git a/crates/core/src/single_use.rs b/crates/core/src/single_use.rs new file mode 100644 index 000000000..b262b7cbe --- /dev/null +++ b/crates/core/src/single_use.rs @@ -0,0 +1,201 @@ +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. +// +// SPDX-License-Identifier: Apache-2.0 +// Crypto inventory: none (bookkeeping of identifiers only). +//! # Single-use credential registry (ADR 0044) +//! +//! [`SingleUseStore`] records that a `(scope, id)` pair was used, so that an +//! external assertion, a TOTP passcode or a DPoP proof is accepted at most once +//! inside its validity window. Callers own the meaning of scope, id and +//! lifetime; the store only enforces the shared preconditions +//! ([`check_preconditions`]). +use std::error::Error; + +use async_trait::async_trait; +use thiserror::Error; + +#[cfg(test)] +pub(crate) mod conformance; + +/// Longest accepted `id`, in bytes. A caller may apply a lower cap first. +pub const MAX_ID_LEN: usize = 256; + +/// Why a credential was not recorded. +#[derive(Debug, Error)] +pub enum SingleUseError { + /// Already recorded and not yet expired. + #[error("credential was already used")] + Replayed, + + /// Local store bounds, or the cluster store's admission limit, reached. + #[error("single-use store capacity reached")] + Capacity, + + /// `expires` is in the past or beyond the use case's maximum lifetime, or + /// `id` is longer than [`MAX_ID_LEN`]; the caller computed it wrongly. + #[error("invalid credential lifetime or identifier")] + InvalidLifetime, + + /// The cluster store cannot be reached, did not answer, or rejected the + /// write for a reason other than a conflict. + #[error("single-use store unavailable: {0}")] + Unavailable(#[source] Box), +} + +/// Records that a `(scope, id)` pair was used. +#[async_trait] +pub trait SingleUseStore: Send + Sync { + /// Record `(scope, id)`. `expires` is the first unix second at which the + /// entry is dead; it must not be earlier than the last moment the same + /// credential could still pass verification. + /// + /// # Errors + /// [`SingleUseError::Replayed`] for a live entry, [`SingleUseError::Capacity`] + /// when a bound is reached (a live entry is never evicted), + /// [`SingleUseError::InvalidLifetime`] for failed preconditions and + /// [`SingleUseError::Unavailable`] for a backend failure. + async fn consume( + &self, + scope: &str, + id: &str, + expires: i64, + now: i64, + ) -> Result<(), SingleUseError>; + + /// Periodic maintenance: drop entries that are dead at `now`. + async fn cleanup(&self, now: i64) -> Result<(), SingleUseError>; +} + +/// Encode the scope of an external assertion: `"{len(domain_id)}:{domain_id}{issuer}"`. +/// +/// Lengths count bytes, so `("a", "b")` and `("ab", "")` stay apart. +pub fn assertion_scope(domain_id: &str, issuer: &str) -> String { + format!("{}:{domain_id}{issuer}", domain_id.len()) +} + +/// Domain part of a scope built by [`assertion_scope`], if well-formed. +pub fn assertion_scope_domain(scope: &str) -> Option<&str> { + let (len, rest) = scope.split_once(':')?; + let len: usize = len.parse().ok()?; + rest.get(..len) +} + +/// Preconditions every implementation enforces before touching its store. +/// +/// - `expires > now`; +/// - `expires - now <= max_lifetime_secs`; +/// - `id` is at most [`MAX_ID_LEN`] bytes. +/// +/// # Errors +/// [`SingleUseError::InvalidLifetime`] when any of them fails. +pub fn check_preconditions( + id: &str, + expires: i64, + now: i64, + max_lifetime_secs: i64, +) -> Result<(), SingleUseError> { + if expires <= now || expires.saturating_sub(now) > max_lifetime_secs || id.len() > MAX_ID_LEN { + return Err(SingleUseError::InvalidLifetime); + } + Ok(()) +} + +#[cfg(test)] +mod tests { + use super::*; + + /// Minimal reference implementation: proves the suite itself is sound. + mod reference { + use std::collections::HashMap; + use std::sync::Mutex; + + use async_trait::async_trait; + + use super::super::conformance::{MAX_LIFETIME_SECS, run_suite}; + use super::super::*; + + struct Reference { + max_total: usize, + max_per_scope: usize, + entries: Mutex>, + } + + #[async_trait] + impl SingleUseStore for Reference { + async fn consume( + &self, + scope: &str, + id: &str, + expires: i64, + now: i64, + ) -> Result<(), SingleUseError> { + check_preconditions(id, expires, now, MAX_LIFETIME_SECS)?; + let mut m = self.entries.lock().unwrap(); + m.retain(|_, e| *e > now); + let key = (scope.to_string(), id.to_string()); + if m.contains_key(&key) { + return Err(SingleUseError::Replayed); + } + if m.len() >= self.max_total + || m.keys().filter(|(s, _)| s == scope).count() >= self.max_per_scope + { + return Err(SingleUseError::Capacity); + } + m.insert(key, expires); + Ok(()) + } + + async fn cleanup(&self, now: i64) -> Result<(), SingleUseError> { + self.entries.lock().unwrap().retain(|_, e| *e > now); + Ok(()) + } + } + + #[tokio::test] + async fn suite_passes_for_reference_store() { + run_suite(|max_total, max_per_scope| Reference { + max_total, + max_per_scope, + entries: Mutex::new(HashMap::new()), + }) + .await; + } + } + + #[test] + fn scope_encoding() { + assert_ne!(assertion_scope("a", "b"), assertion_scope("ab", "")); + let s = assertion_scope("dom", "https://idp"); + assert_eq!(assertion_scope_domain(&s), Some("dom")); + assert_eq!(assertion_scope_domain("x:abc"), None); + assert_eq!(assertion_scope_domain("nolen"), None); + } + + #[test] + fn preconditions() { + assert!(check_preconditions("a", 110, 100, 10).is_ok()); + assert!(matches!( + check_preconditions("a", 100, 100, 10), + Err(SingleUseError::InvalidLifetime) + )); + assert!(matches!( + check_preconditions("a", 111, 100, 10), + Err(SingleUseError::InvalidLifetime) + )); + assert!(matches!( + check_preconditions(&"x".repeat(MAX_ID_LEN + 1), 105, 100, 10), + Err(SingleUseError::InvalidLifetime) + )); + assert!(check_preconditions(&"x".repeat(MAX_ID_LEN), 105, 100, 10).is_ok()); + } +} diff --git a/crates/core/src/single_use/conformance.rs b/crates/core/src/single_use/conformance.rs new file mode 100644 index 000000000..c4a62a60d --- /dev/null +++ b/crates/core/src/single_use/conformance.rs @@ -0,0 +1,126 @@ +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. +// +// Crypto inventory: none (bookkeeping of identifiers only). +//! Behavioural suite every [`SingleUseStore`] implementation must pass +//! (ADR 0044, implementation notes step 4). +//! +//! An implementation calls [`run_suite`] from its own tests with a factory +//! that builds a fresh store with the given bounds and a maximum lifetime of +//! [`MAX_LIFETIME_SECS`]. +use super::{SingleUseError, SingleUseStore}; + +/// Maximum `expires - now` the stores under test are built with. +pub const MAX_LIFETIME_SECS: i64 = 600; + +const NOW: i64 = 1_760_000_000; + +/// Run the whole suite. `make(max_total, max_per_scope)` builds a fresh store. +pub async fn run_suite(make: impl Fn(usize, usize) -> S) { + replay_until_expires(&make(10, 10)).await; + scopes_are_separate(&make(10, 10)).await; + invalid_lifetime(&make(10, 10)).await; + id_length_cap(&make(10, 10)).await; + per_scope_quota_without_eviction(&make(10, 2)).await; + total_quota_without_eviction(&make(2, 2)).await; + cleanup_keeps_live_entries(&make(10, 10)).await; +} + +async fn replay_until_expires(s: &impl SingleUseStore) { + s.consume("s", "a", NOW + 60, NOW).await.unwrap(); + assert!(matches!( + s.consume("s", "a", NOW + 60, NOW + 59).await, + Err(SingleUseError::Replayed) + )); + // Dead at `expires`: the same id is accepted again. + s.consume("s", "a", NOW + 120, NOW + 60).await.unwrap(); +} + +async fn scopes_are_separate(s: &impl SingleUseStore) { + s.consume("s1", "a", NOW + 60, NOW).await.unwrap(); + s.consume("s2", "a", NOW + 60, NOW).await.unwrap(); + assert!(matches!( + s.consume("s1", "a", NOW + 60, NOW).await, + Err(SingleUseError::Replayed) + )); +} + +async fn invalid_lifetime(s: &impl SingleUseStore) { + for expires in [NOW - 1, NOW, NOW + MAX_LIFETIME_SECS + 1] { + assert!( + matches!( + s.consume("s", "a", expires, NOW).await, + Err(SingleUseError::InvalidLifetime) + ), + "expires = now + {}", + expires - NOW + ); + } + // A refused credential is not recorded. + s.consume("s", "a", NOW + MAX_LIFETIME_SECS, NOW) + .await + .unwrap(); +} + +async fn id_length_cap(s: &impl SingleUseStore) { + let at_cap = "x".repeat(super::MAX_ID_LEN); + s.consume("s", &at_cap, NOW + 60, NOW).await.unwrap(); + let over = "x".repeat(super::MAX_ID_LEN + 1); + assert!(matches!( + s.consume("s", &over, NOW + 60, NOW).await, + Err(SingleUseError::InvalidLifetime) + )); +} + +/// Needs `max_per_scope == 2`. +async fn per_scope_quota_without_eviction(s: &impl SingleUseStore) { + s.consume("s", "a", NOW + 60, NOW).await.unwrap(); + s.consume("s", "b", NOW + 60, NOW).await.unwrap(); + assert!(matches!( + s.consume("s", "c", NOW + 60, NOW).await, + Err(SingleUseError::Capacity) + )); + // The flood attempt did not push out a live entry. + assert!(matches!( + s.consume("s", "a", NOW + 60, NOW).await, + Err(SingleUseError::Replayed) + )); + // Another scope is not affected. + s.consume("other", "c", NOW + 60, NOW).await.unwrap(); + // Dead entries free room. + s.consume("s", "c", NOW + 120, NOW + 60).await.unwrap(); +} + +/// Needs `max_total == 2`. +async fn total_quota_without_eviction(s: &impl SingleUseStore) { + s.consume("x", "a", NOW + 60, NOW).await.unwrap(); + s.consume("y", "a", NOW + 60, NOW).await.unwrap(); + assert!(matches!( + s.consume("z", "a", NOW + 60, NOW).await, + Err(SingleUseError::Capacity) + )); + assert!(matches!( + s.consume("x", "a", NOW + 60, NOW).await, + Err(SingleUseError::Replayed) + )); +} + +async fn cleanup_keeps_live_entries(s: &impl SingleUseStore) { + s.consume("s", "live", NOW + 120, NOW).await.unwrap(); + s.consume("s", "dead", NOW + 60, NOW).await.unwrap(); + s.cleanup(NOW + 60).await.unwrap(); + assert!(matches!( + s.consume("s", "live", NOW + 120, NOW + 60).await, + Err(SingleUseError::Replayed) + )); + s.consume("s", "dead", NOW + 180, NOW + 60).await.unwrap(); +} From 4d4ebf1fbc8bda5281383037e63f68777e41056a Mon Sep 17 00:00:00 2001 From: Artem Goncharov Date: Fri, 13 Nov 2026 12:40:02 +0100 Subject: [PATCH 3/3] feat(config): Add replay cache bound options Add the assertion replay cache options to [oauth2] and the TOTP replay cache options to [auth], with validation that a per-scope bound does not exceed its total (ADR 0044 section 2). Signed-off-by: Artem Goncharov --- crates/config/src/auth.rs | 71 +++++++++++++++++++++++++++++- crates/config/src/lib.rs | 9 ++++ crates/config/src/oauth2.rs | 87 +++++++++++++++++++++++++++++++++++++ 3 files changed, 166 insertions(+), 1 deletion(-) diff --git a/crates/config/src/auth.rs b/crates/config/src/auth.rs index d25e08eb7..e6cf14791 100644 --- a/crates/config/src/auth.rs +++ b/crates/config/src/auth.rs @@ -15,13 +15,82 @@ //! //! Parsing of the Keystone configuration file implementation. use serde::Deserialize; +use validator::Validate; use crate::common::csv; /// Authentication configuration. -#[derive(Debug, Default, Deserialize, Clone)] +#[derive(Debug, Deserialize, Clone, Validate)] pub struct AuthProvider { /// Authentication methods to be enabled and used for token validation. #[serde(deserialize_with = "csv")] pub methods: Vec, + + /// Maximum number of live entries of the TOTP passcode replay cache + /// (ADR 0044 §2) of the `local` store. When it is full new passcodes are + /// rejected rather than evicting older entries. + #[serde(default = "default_totp_replay_cache_max_entries")] + #[validate(range(min = 1))] + pub totp_replay_cache_max_entries: u32, + + /// Maximum number of live TOTP replay entries held for one user. + #[serde(default = "default_totp_replay_cache_max_entries_per_user")] + #[validate(range(min = 1))] + pub totp_replay_cache_max_entries_per_user: u32, +} + +fn default_totp_replay_cache_max_entries() -> u32 { + 100_000 +} + +fn default_totp_replay_cache_max_entries_per_user() -> u32 { + 32 +} + +impl Default for AuthProvider { + fn default() -> Self { + Self { + methods: Vec::new(), + totp_replay_cache_max_entries: default_totp_replay_cache_max_entries(), + totp_replay_cache_max_entries_per_user: default_totp_replay_cache_max_entries_per_user( + ), + } + } +} + +impl AuthProvider { + /// Check that the per-user TOTP replay bound does not exceed the total + /// (ADR 0044 §2). + pub fn validate_totp_replay(&self) -> Result<(), String> { + if self.totp_replay_cache_max_entries_per_user > self.totp_replay_cache_max_entries { + return Err( + "[auth] totp_replay_cache_max_entries_per_user must not exceed \ + totp_replay_cache_max_entries" + .to_string(), + ); + } + Ok(()) + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn test_totp_replay_bounds() { + assert!(AuthProvider::default().validate_totp_replay().is_ok()); + assert!(AuthProvider::default().validate().is_ok()); + let bad = AuthProvider { + totp_replay_cache_max_entries: 10, + totp_replay_cache_max_entries_per_user: 11, + ..Default::default() + }; + assert!(bad.validate_totp_replay().is_err()); + let zero = AuthProvider { + totp_replay_cache_max_entries: 0, + ..Default::default() + }; + assert!(zero.validate().is_err()); + } } diff --git a/crates/config/src/lib.rs b/crates/config/src/lib.rs index 5bb476d1e..3ca834d85 100644 --- a/crates/config/src/lib.rs +++ b/crates/config/src/lib.rs @@ -172,6 +172,7 @@ pub struct Config { pub assignment: AssignmentProvider, /// Authentication configuration. + #[validate(nested)] pub auth: AuthProvider, /// Catalog provider configuration. @@ -396,6 +397,14 @@ impl Config { .validate_dpop(cfg.mapping.cluster_salt.is_some()) .map_err(Report::msg) .wrap_err("validating [oauth2] DPoP configuration")?; + cfg.oauth2 + .validate_assertion_replay() + .map_err(Report::msg) + .wrap_err("validating [oauth2] assertion replay configuration")?; + cfg.auth + .validate_totp_replay() + .map_err(Report::msg) + .wrap_err("validating [auth] TOTP replay configuration")?; Ok(cfg) } diff --git a/crates/config/src/oauth2.rs b/crates/config/src/oauth2.rs index 5fad92ae5..a16ebe83b 100644 --- a/crates/config/src/oauth2.rs +++ b/crates/config/src/oauth2.rs @@ -381,6 +381,36 @@ pub struct Oauth2Provider { #[serde(default = "default_dpop_replay_cache_max_entries_per_key")] #[validate(range(min = 1))] pub dpop_replay_cache_max_entries_per_key: u32, + + /// Maximum number of live entries of the external assertion replay cache + /// (ADR 0044 §2) of the `local` store. When it is full new assertions are + /// rejected rather than evicting older entries. + #[serde(default = "default_assertion_replay_cache_max_entries")] + #[validate(range(min = 1))] + pub assertion_replay_cache_max_entries: u32, + + /// Maximum number of live assertion replay entries held for one domain. + #[serde(default = "default_assertion_replay_cache_max_entries_per_domain")] + #[validate(range(min = 1))] + pub assertion_replay_cache_max_entries_per_domain: u32, + + /// Maximum number of live assertion replay entries held for one issuer of + /// one domain. + #[serde(default = "default_assertion_replay_cache_max_entries_per_issuer")] + #[validate(range(min = 1))] + pub assertion_replay_cache_max_entries_per_issuer: u32, +} + +fn default_assertion_replay_cache_max_entries() -> u32 { + 100_000 +} + +fn default_assertion_replay_cache_max_entries_per_domain() -> u32 { + 10_000 +} + +fn default_assertion_replay_cache_max_entries_per_issuer() -> u32 { + 10_000 } fn default_dpop_proof_lifetime_seconds() -> u32 { @@ -547,6 +577,11 @@ impl Default for Oauth2Provider { dpop_nonce_lifetime_seconds: default_dpop_nonce_lifetime_seconds(), dpop_replay_cache_max_entries: default_dpop_replay_cache_max_entries(), dpop_replay_cache_max_entries_per_key: default_dpop_replay_cache_max_entries_per_key(), + assertion_replay_cache_max_entries: default_assertion_replay_cache_max_entries(), + assertion_replay_cache_max_entries_per_domain: + default_assertion_replay_cache_max_entries_per_domain(), + assertion_replay_cache_max_entries_per_issuer: + default_assertion_replay_cache_max_entries_per_issuer(), } } } @@ -592,6 +627,30 @@ impl Oauth2Provider { Ok(()) } + /// Check that the per-scope assertion replay bounds do not exceed their + /// totals (ADR 0044 §2): issuer within domain, domain within total. + pub fn validate_assertion_replay(&self) -> Result<(), String> { + if self.assertion_replay_cache_max_entries_per_domain + > self.assertion_replay_cache_max_entries + { + return Err( + "[oauth2] assertion_replay_cache_max_entries_per_domain must not \ + exceed assertion_replay_cache_max_entries" + .to_string(), + ); + } + if self.assertion_replay_cache_max_entries_per_issuer + > self.assertion_replay_cache_max_entries_per_domain + { + return Err( + "[oauth2] assertion_replay_cache_max_entries_per_issuer must not \ + exceed assertion_replay_cache_max_entries_per_domain" + .to_string(), + ); + } + Ok(()) + } + /// Whether requests for `domain_id` may be served, per `enabled_domains`. pub fn domain_allowed(&self, domain_id: &str) -> bool { self.enabled_domains @@ -727,6 +786,34 @@ mod tests { assert!(cfg.validate_dpop(false).is_err()); } + #[test] + fn test_validate_assertion_replay_bounds() { + assert!( + Oauth2Provider::default() + .validate_assertion_replay() + .is_ok() + ); + let per_domain = Oauth2Provider { + assertion_replay_cache_max_entries: 10, + assertion_replay_cache_max_entries_per_domain: 11, + assertion_replay_cache_max_entries_per_issuer: 5, + ..Default::default() + }; + assert!(per_domain.validate_assertion_replay().is_err()); + let per_issuer = Oauth2Provider { + assertion_replay_cache_max_entries: 10, + assertion_replay_cache_max_entries_per_domain: 5, + assertion_replay_cache_max_entries_per_issuer: 6, + ..Default::default() + }; + assert!(per_issuer.validate_assertion_replay().is_err()); + let zero = Oauth2Provider { + assertion_replay_cache_max_entries: 0, + ..Default::default() + }; + assert!(zero.validate().is_err()); + } + #[test] fn test_validate_rejects_bad_dpop_windows() { for cfg in [