Skip to main content

dig_stun/credential/
nonce.rs

1//! The stateless, source-bound, time-bucketed nonce (`SPEC.md` §14.4) — how a server proves a
2//! signed Binding request answered ITS challenge, from the SAME source it challenged, within the
3//! last one-to-two minutes, without keeping any per-client state.
4
5use std::net::{IpAddr, SocketAddr};
6
7use ring::hmac;
8
9use crate::credential::wire::base64url_decode;
10use crate::scope::fold_ip;
11
12/// Raw nonce length in bytes: 4 (bucket) + 16 (HMAC tag). The wire `NONCE` attribute carries
13/// `base64url(no padding)` of these bytes — 27 characters (`SPEC.md` §14.4).
14pub const NONCE_LEN: usize = 20;
15/// Width of one nonce time bucket. A nonce is valid for the bucket it was issued in and the one
16/// after, so 60-120 seconds depending on where in its bucket it was issued (`SPEC.md` §14.4).
17pub const NONCE_BUCKET_SECS: u64 = 60;
18
19/// The domain-separated HMAC input prefix (`SPEC.md` §14.4) — distinct from [`crate::credential::signature::SIG_DOMAIN_TAG`]
20/// so a nonce tag can never be mistaken for, or substituted into, the signature preimage.
21const NONCE_DOMAIN_TAG: &[u8] = b"dig:stun:nonce:v1";
22/// Address-family markers inside the nonce HMAC input. Deliberately a fresh, crate-local pair
23/// rather than a reuse of `codec`'s private `FAMILY_IPV4`/`FAMILY_IPV6` (which are not visible
24/// outside `codec.rs`): this byte is part of the HMAC preimage `SPEC.md` §14.4 defines, not the
25/// RFC 5389 wire attribute those constants describe, even though the numeric values coincide.
26const NONCE_FAMILY_IPV4: u8 = 0x01;
27const NONCE_FAMILY_IPV6: u8 = 0x02;
28
29/// The result of checking a `NONCE` attribute against the issuer that (claims to have) minted it
30/// (`SPEC.md` §14.4). Exhaustive: adding a variant is a breaking change.
31#[derive(Debug, Clone, Copy, PartialEq, Eq)]
32pub enum NonceCheck {
33    /// The nonce's HMAC tag matches its own bucket and source, and that bucket is the current one
34    /// or the one before it (0-120s old, depending on issue-time-within-bucket).
35    Fresh,
36    /// The tag matches, but the bucket is older than "current or previous" — this issuer minted
37    /// it, for this source, but too long ago.
38    Stale,
39    /// The tag does not match (wrong secret, wrong source, forged, or corrupt), OR the value does
40    /// not even base64url-decode to [`NONCE_LEN`] bytes. A forged nonce is always `Invalid`, never
41    /// `Stale` — the tag is checked BEFORE the bucket age (`SPEC.md` §14.4: "so a forged nonce is
42    /// never reported as merely stale").
43    Invalid,
44}
45
46/// Issues and checks nonces for one DIG-operated UDP STUN server process (`SPEC.md` §14.4). Holds
47/// nothing per-client: every `issue`/`check` call is a pure function of the secret, the source
48/// address, and the wall-clock second.
49pub struct NonceIssuer {
50    secret: [u8; 32],
51}
52
53impl NonceIssuer {
54    /// Build an issuer with a fresh, randomly generated secret (`ring::rand::SystemRandom`). The
55    /// ordinary choice for a single-process deployment (`SPEC.md` §14.4 "Replicas").
56    ///
57    /// # Panics
58    ///
59    /// Only on catastrophic OS CSPRNG unavailability — the same posture as
60    /// [`crate::new_transaction_id`], for the same reason: there is no safe degraded fallback for
61    /// a secret that must not be predictable.
62    pub fn new_random() -> Self {
63        use ring::rand::{SecureRandom, SystemRandom};
64        let mut secret = [0u8; 32];
65        SystemRandom::new()
66            .fill(&mut secret)
67            .expect("OS CSPRNG must be available to generate a STUN nonce-issuer secret");
68        Self { secret }
69    }
70
71    /// Build an issuer from an explicitly supplied secret — for a deployment running several
72    /// server replicas behind one address that must share one issuer (`SPEC.md` §14.4
73    /// "Replicas"). The caller is responsible for generating and distributing `secret` safely;
74    /// this constructor does no validation beyond the type system's (any 32 bytes are accepted).
75    pub fn from_secret(secret: [u8; 32]) -> Self {
76        Self { secret }
77    }
78
79    /// Mint a nonce for `source` at `now_unix_secs`, valid at THIS issuer for `source` alone,
80    /// during the resulting bucket and the one after it (`SPEC.md` §14.4). Returns the raw 20
81    /// bytes; the caller base64url-encodes them into the wire `NONCE` attribute
82    /// ([`crate::credential::encode_challenge`] does this).
83    pub fn issue(&self, source: SocketAddr, now_unix_secs: u64) -> [u8; NONCE_LEN] {
84        let bucket = (now_unix_secs / NONCE_BUCKET_SECS) as u32;
85        let tag = self.tag_for(bucket, source);
86        let mut nonce = [0u8; NONCE_LEN];
87        nonce[..4].copy_from_slice(&bucket.to_be_bytes());
88        nonce[4..].copy_from_slice(&tag);
89        nonce
90    }
91
92    /// Check a wire `NONCE` attribute value (the base64url text, exactly as carried) against
93    /// `source` at `now_unix_secs` (`SPEC.md` §14.4). Recomputes the tag for the nonce's OWN
94    /// bucket (not `now`'s) before ever looking at freshness, so a forged nonce is `Invalid`
95    /// rather than `Stale` regardless of what bucket number it claims.
96    pub fn check(
97        &self,
98        nonce_attr_value: &[u8],
99        source: SocketAddr,
100        now_unix_secs: u64,
101    ) -> NonceCheck {
102        let decoded = match base64url_decode(nonce_attr_value) {
103            Some(bytes) if bytes.len() == NONCE_LEN => bytes,
104            _ => return NonceCheck::Invalid,
105        };
106        let bucket = u32::from_be_bytes(decoded[..4].try_into().expect("4-byte slice"));
107        let carried_tag = &decoded[4..NONCE_LEN];
108        let expected_tag = self.tag_for(bucket, source);
109        if !constant_time_eq(carried_tag, &expected_tag) {
110            return NonceCheck::Invalid;
111        }
112        let now_bucket = (now_unix_secs / NONCE_BUCKET_SECS) as u32;
113        if bucket == now_bucket || bucket == now_bucket.wrapping_sub(1) {
114            NonceCheck::Fresh
115        } else {
116            NonceCheck::Stale
117        }
118    }
119
120    /// `HMAC-SHA256(secret, "dig:stun:nonce:v1" ‖ bucket_be(4) ‖ family(1) ‖ ip_bytes ‖
121    /// port_be(2))[..16]` (`SPEC.md` §14.4). `source` is folded per §5.3 FIRST, so an IPv4-mapped
122    /// IPv6 source (`::ffff:a.b.c.d`) and the plain IPv4 form yield the identical tag — the same
123    /// source, as the response limiter already sees it.
124    fn tag_for(&self, bucket: u32, source: SocketAddr) -> [u8; 16] {
125        let key = hmac::Key::new(hmac::HMAC_SHA256, &self.secret);
126        let mut message = Vec::with_capacity(NONCE_DOMAIN_TAG.len() + 4 + 1 + 16 + 2);
127        message.extend_from_slice(NONCE_DOMAIN_TAG);
128        message.extend_from_slice(&bucket.to_be_bytes());
129        match fold_ip(source.ip()) {
130            IpAddr::V4(v4) => {
131                message.push(NONCE_FAMILY_IPV4);
132                message.extend_from_slice(&v4.octets());
133            }
134            IpAddr::V6(v6) => {
135                message.push(NONCE_FAMILY_IPV6);
136                message.extend_from_slice(&v6.octets());
137            }
138        }
139        message.extend_from_slice(&source.port().to_be_bytes());
140
141        let full = hmac::sign(&key, &message);
142        let mut tag = [0u8; 16];
143        tag.copy_from_slice(&full.as_ref()[..16]);
144        tag
145    }
146}
147
148/// Constant-time byte-slice equality (XOR-accumulate, no early exit) — a truncated tag can't be
149/// checked with `ring::hmac::verify` (which compares a FULL, untruncated MAC), so this crate
150/// carries its own rather than reaching for `ring`'s own internal `constant_time` helper, which
151/// `ring` itself documents as heading for removal. Returns `false` on any length mismatch.
152fn constant_time_eq(a: &[u8], b: &[u8]) -> bool {
153    if a.len() != b.len() {
154        return false;
155    }
156    let mut diff = 0u8;
157    for (x, y) in a.iter().zip(b.iter()) {
158        diff |= x ^ y;
159    }
160    diff == 0
161}