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}