Skip to main content

cratestack_cose/
replay.rs

1//! Freshness and replay for `nonce` mode (ADR 0006 §5, the P0 mode).
2//!
3//! A request carries `iat` (seconds) and a 16-byte random `cti`. The
4//! opener accepts it only while `|now - iat| <= skew`, and records
5//! `(kid, cti)` in core's [`NonceStore`](cratestack_core::NonceStore) so
6//! that a second delivery inside that window is refused. P2's `window`
7//! mode (device counters, no time limit) replaces this for device keys.
8
9use chrono::{DateTime, Utc};
10use cratestack_core::{CratestackError, REQUEST_NONCE_LEN, RequestNonce};
11
12/// The default clock skew, the same 300 s `HmacEnvelope` and the
13/// signed-request verifier use (`ENVELOPE_DEFAULT_CLOCK_SKEW_SECS`).
14pub const DEFAULT_SKEW_SECS: u64 = 300;
15
16/// The length of the random `cti` the default source produces (§5).
17pub const RANDOM_CTI_LEN: usize = 16;
18
19/// Whether `iat` is within `skew` seconds of `now`, in either direction.
20/// A future `iat` is as suspect as an old one: accepting it would stretch
21/// the window a captured request stays replayable in.
22pub(crate) fn is_fresh(now: i64, iat: u64, skew: u64) -> bool {
23    (i128::from(now) - i128::from(iat)).unsigned_abs() <= u128::from(skew)
24}
25
26/// When the nonce store may forget `(kid, cti)`: `iat + 2·skew + 1`.
27///
28/// The request stops being fresh at `iat + skew` **on the verifier's
29/// clock**, but the store expires entries on **another** clock: the
30/// in-memory store compares against `Utc::now()`, and `cratestack-auth`'s
31/// Redis store turns the expiry into a TTL against the recording replica's
32/// clock. If the clocks disagree, an entry can vanish while some replica
33/// still accepts the request, which is a replay window. The second `skew` is the margin
34/// for that disagreement: the deployment already assumes clocks agree
35/// within `skew` (every sender's does), so its own replicas and store are
36/// held to the same bound. The final second covers the boundary, where
37/// `InMemoryNonceStore` drops an entry whose expiry is not strictly in the
38/// future. The cost is a working set twice the skew window.
39pub(crate) fn nonce_expiry(iat: u64, skew: u64) -> Option<DateTime<Utc>> {
40    let iat = i64::try_from(iat).ok()?;
41    let skew = i64::try_from(skew).ok()?;
42    let last = iat.checked_add(skew.checked_mul(2)?)?.checked_add(1)?;
43    DateTime::from_timestamp(last, 0)
44}
45
46/// The key a `(kid, cti)` pair is recorded under. `NonceStore` takes a
47/// string, and the store may be shared with `HmacEnvelope`, so the key is
48/// namespaced and hex-encoded; the fixed `kid` length keeps the split
49/// between `kid` and `cti` unambiguous.
50pub(crate) fn nonce_key(kid: &[u8], cti: &[u8]) -> String {
51    let mut key = String::with_capacity(6 + 2 * (kid.len() + cti.len()));
52    key.push_str("cose:");
53    push_hex(&mut key, kid);
54    key.push(':');
55    push_hex(&mut key, cti);
56    key
57}
58
59fn push_hex(out: &mut String, bytes: &[u8]) {
60    const DIGITS: &[u8; 16] = b"0123456789abcdef";
61    for byte in bytes {
62        out.push(char::from(DIGITS[usize::from(byte >> 4)]));
63        out.push(char::from(DIGITS[usize::from(byte & 0x0f)]));
64    }
65}
66
67/// The default clock: Unix seconds from `chrono`, which also works on
68/// `wasm32-unknown-unknown`.
69pub(crate) fn system_clock() -> i64 {
70    Utc::now().timestamp()
71}
72
73/// The default `cti` source: 16 bytes from the operating system's CSPRNG.
74pub(crate) fn random_cti() -> Result<Vec<u8>, CratestackError> {
75    let mut cti = vec![0; RANDOM_CTI_LEN];
76    getrandom::fill(&mut cti)
77        .map_err(|error| CratestackError::Internal(format!("cose cti source failed: {error}")))?;
78    Ok(cti)
79}
80
81/// A fresh `Cratestack-Nonce`: 16 bytes from the operating system's CSPRNG.
82/// Fails with `CratestackError::Internal` if it is unavailable.
83///
84/// The default nonce source of [`CoseEnvelope::request_nonce`](crate::CoseEnvelope::request_nonce),
85/// for a client that sends an unsigned request it wants a verifiable
86/// response to. Here and not on `RequestNonce` (which `cratestack-core`
87/// defines) since the second #1006 review (decision B-1): drawing it needs
88/// `getrandom`, and only this crate selects that crate's
89/// `wasm32-unknown-unknown` backend. It replaces 0.13.0's
90/// `RequestNonce::random()`.
91pub fn random_request_nonce() -> Result<RequestNonce, CratestackError> {
92    let mut bytes = [0; REQUEST_NONCE_LEN];
93    getrandom::fill(&mut bytes).map_err(|error| {
94        CratestackError::Internal(format!("cose request nonce source failed: {error}"))
95    })?;
96    Ok(RequestNonce::from_bytes(bytes))
97}