cratestack_cose/auth/nonce_bridge.rs
1//! `cratestack_auth::NonceStore` -> `cratestack_core::NonceStore`.
2//!
3//! The two traits are unrelated (ADR 0006, P0 scoping notes on
4//! cratestack#1005). Core's, which the envelope's opener calls, asks
5//! "record this key until `expires_at`; was it new?". Auth's, which its
6//! Redis store implements, is `claim(key_id, nonce, timestamp,
7//! replay_window)`: remember `key_id:nonce` until `timestamp +
8//! replay_window`, `Err(NonceReused)` if it is already there. The Redis
9//! implementation is one atomic `SET key value NX EX ttl`, so two replicas
10//! racing on one `cti` cannot both win.
11
12use std::fmt;
13use std::sync::Arc;
14
15use chrono::{DateTime, TimeDelta, Utc};
16use cratestack_auth::{AuthError, NonceStore as AuthNonceStoreTrait, nonce_store_from_redis_url};
17use cratestack_core::{CratestackError, NonceStore};
18
19/// The `key_id` every bridged nonce is claimed under.
20///
21/// The opener passes one string, `cose:<hex kid>:<hex cti>`
22/// (`replay::nonce_key`); auth's store takes two and stores
23/// `{key_id}:{nonce}` (in Redis, under `cratestack:signature-nonce:`). The
24/// bridge does not split the string. It claims all of it as the `nonce`
25/// under this fixed `key_id`, so two different opener keys never map to
26/// one entry (splitting on `:` would not guarantee that). A COSE entry is
27/// therefore `cose-envelope:cose:<hex kid>:<hex cti>`.
28///
29/// **Shared namespace with signed requests.** Auth's `{key_id}:{nonce}`
30/// join is not injective, and `SignedRequestVerifier` claims its nonces
31/// the same way, so when both point at one store (the same Redis, or one
32/// shared `Arc`) they share a keyspace. A verified signed request whose
33/// `keyId` is `cose-envelope` or *starts with* `cose-envelope:` can
34/// produce the same entry as a COSE message (for example `keyId = "cose-envelope:cose"`,
35/// `nonce = "<hex kid>:<hex cti>"`). The effect is refuse-only: whichever
36/// claim comes second is reported as a replay, never accepted. To block a
37/// COSE request that way, the signer of such a `keyId` would have to know
38/// the victim's random 16-byte `cti` before the victim's request lands.
39/// Operators sharing one store between the two must not issue
40/// signed-request key ids with the `cose-envelope` prefix.
41pub const AUTH_NONCE_KEY_ID: &str = "cose-envelope";
42
43/// `cratestack_auth`'s nonce store (in-memory, or Redis across replicas),
44/// as the `cratestack_core::NonceStore` a server [`CoseEnvelope`] records
45/// `(kid, cti)` in (§5, `nonce` mode).
46///
47/// **Expiry.** An entry must outlive `expires_at`, the instant after which
48/// the opener would refuse the request as stale anyway (`iat + 2·skew + 1`,
49/// see `replay.rs`). Auth's Redis store turns `timestamp + replay_window`
50/// into a whole-second TTL against its own clock and rounds **down**, so
51/// the bridge claims with `timestamp = now` and `replay_window =
52/// (expires_at + 1 s) - now`: the key then lives at least until
53/// `expires_at` and at most one second longer. The deadline is computed
54/// with checked arithmetic: an `expires_at` too close to
55/// `DateTime::<Utc>::MAX_UTC` for `+ 1 s` is `Internal` (a `500`) and no
56/// claim is made, because auth's in-memory store adds `timestamp +
57/// replay_window` unchecked and would panic. An `expires_at` already in
58/// the past is reported new, the same answer core's `InMemoryNonceStore`
59/// gives; what is kept depends on the store: Redis floors the TTL at one
60/// second, so the key exists for a second, while auth's in-memory store
61/// inserts an entry that is already expired and ignores it on the next
62/// claim. The opener never asks, since it checks freshness first.
63///
64/// **Errors.** `NonceReused` is "seen" (`Ok(false)`, the opener's `401`).
65/// Every other error means the store could not answer, so it is
66/// `CratestackError::Internal` (a `500`, detail kept server-side), never
67/// "new". Answering "new" there would make a Redis outage switch replay
68/// protection off.
69///
70/// **Deploying the Redis store.** Replay protection is only as durable as
71/// the key. Run the nonce Redis with `maxmemory-policy noeviction`: an
72/// evicting policy can drop a live nonce under memory pressure and let its
73/// message be replayed. Redis replication is asynchronous, so a failover
74/// can lose a `SET` the old primary acknowledged but had not yet
75/// replicated, reopening the window for that nonce until it would have
76/// expired. Known limitation: auth's Redis store opens a new connection
77/// for every claim (cratestack#1070).
78///
79/// [`CoseEnvelope`]: crate::CoseEnvelope
80#[derive(Clone)]
81pub struct AuthNonceStore {
82 inner: Arc<dyn AuthNonceStoreTrait>,
83}
84
85impl AuthNonceStore {
86 /// Bridge any `cratestack_auth::NonceStore`.
87 pub fn new(inner: Arc<dyn AuthNonceStoreTrait>) -> Self {
88 Self { inner }
89 }
90
91 /// The Redis-backed store at `redis_url`, via
92 /// `cratestack_auth::nonce_store_from_redis_url`.
93 ///
94 /// Unlike that function, an empty URL is an error, not a silent
95 /// in-memory fallback: a multi-replica deployment that lost its Redis
96 /// URL would otherwise get one nonce store per replica, and a request
97 /// replayed to another replica would be accepted. A deployment that
98 /// wants the in-memory store asks for it with [`new`](Self::new).
99 ///
100 /// The URL is only parsed here; the first connection is made on the
101 /// first request, and a failure then is a `500`.
102 pub fn redis(redis_url: &str) -> Result<Self, CratestackError> {
103 if redis_url.trim().is_empty() {
104 return Err(CratestackError::Validation(
105 "the COSE nonce store's Redis URL is empty".to_owned(),
106 ));
107 }
108 nonce_store_from_redis_url(Some(redis_url))
109 .map(Self::new)
110 .map_err(|error| CratestackError::Validation(error.to_string()))
111 }
112}
113
114impl fmt::Debug for AuthNonceStore {
115 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
116 f.debug_struct("AuthNonceStore").finish_non_exhaustive()
117 }
118}
119
120#[async_trait::async_trait]
121impl NonceStore for AuthNonceStore {
122 async fn record_if_unseen(
123 &self,
124 nonce: &str,
125 expires_at: DateTime<Utc>,
126 ) -> Result<bool, CratestackError> {
127 let now = Utc::now();
128 let replay_window = expires_at
129 .checked_add_signed(TimeDelta::seconds(1))
130 .map(|deadline| deadline.signed_duration_since(now))
131 .ok_or_else(|| CratestackError::Internal("nonce expiry out of range".to_owned()))?;
132 match self
133 .inner
134 .claim(AUTH_NONCE_KEY_ID, nonce, now, replay_window)
135 .await
136 {
137 Ok(()) => Ok(true),
138 Err(AuthError::NonceReused) => Ok(false),
139 Err(error) => Err(CratestackError::Internal(format!(
140 "nonce store failed: {error}"
141 ))),
142 }
143 }
144}