Skip to main content

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}