Skip to main content

broadcast_auth/
server.rs

1//! Server-side challenge + verify — the origin half of RFC 7235
2//! (`WWW-Authenticate`/`Authorization`), RFC 7617 (Basic), RFC 7616 (Digest),
3//! RFC 2326 §14 (RTSP's reuse of the same two schemes), and RFC 6750
4//! (Bearer).
5//!
6//! [`crate::Authenticator`]/[`crate::respond`] are the *client* half: answer
7//! a challenge. [`Verifier`] is the other side of the same handshake: an
8//! origin (multimux's shared output auth gating every `/{stream}/…` route,
9//! issue #663; or any other credentialed origin in this workspace) builds
10//! one from a configured [`Credentials`] + realm, calls [`Verifier::challenge`]
11//! for the `WWW-Authenticate` value to send on a `401`, and
12//! [`Verifier::verify`] to check an incoming `Authorization` header.
13//!
14//! Promoted from `multimux`'s test-only mock auth server
15//! (`multimux::testutil`, issue #663 "Finish client-side multi-scheme
16//! auth"): that module's Digest verification was already a real,
17//! independent RFC 7616 §3.4.1 computation (not a literal-string match)
18//! purely to drive multimux's own client-side tests against something that
19//! genuinely rejects wrong credentials. This module is that same
20//! computation, promoted into the shared crate so it is the *production*
21//! server-side verifier (multimux's output-auth middleware) rather than a
22//! test-only fixture, and so no crate hand-rolls a second copy.
23//!
24//! # Verification per scheme
25//!
26//! - **Basic** (RFC 7617 §2): the header's base64 payload is decoded and
27//!   compared, in constant time, against `"{username}:{password}"`.
28//! - **Bearer** (RFC 6750 §2.1): the token is compared, in constant time,
29//!   against the configured token.
30//! - **Digest** (RFC 7616 §3.4.1): `HA1 = MD5(username:realm:password)`,
31//!   `HA2 = MD5(method:digest-uri-value)`, `response =
32//!   MD5(HA1:nonce:nc:cnonce:qop:HA2)` — `qop=auth`/`algorithm=MD5` only (the
33//!   one shape every client in this workspace answers) — recomputed and
34//!   compared, in constant time, against the client's `response` field.
35//!   `digest-uri-value` is the client's own claimed `uri` field (RFC 7616
36//!   §3.4.1: HA2 is always computed over what the client actually hashed),
37//!   not the server's request URI — the two need not be textually identical,
38//!   only to refer to the same request-target (see below). The client's
39//!   claimed `uri` field must also match the actual request URI (RFC 7616
40//!   §3.4.1: the server "SHOULD check" this), not merely be internally
41//!   consistent with its own `response` — but RFC 7230 §5.3 permits a
42//!   request-target in either origin-form (`/path`) or absolute-form
43//!   (`scheme://authority/path`), and a legitimate client may hash either;
44//!   [`digest_uri_matches`] accepts both representations of the same target
45//!   while still rejecting a genuinely different one.
46//!   The `Authorization` fields are read with `http-auth`'s
47//!   `ChallengeParser` (RFC 7235 `auth-param` list): quoted-pairs are
48//!   unescaped, parameter names are case-insensitive, and a repeated
49//!   parameter or a second challenge is rejected. A non-ASCII user
50//!   authenticates through the RFC 7616 §3.4.4 `username*` extended parameter
51//!   (RFC 8187 `UTF-8''<pct-encoded>`); a raw non-ASCII `username` and
52//!   `userhash=true` are rejected.
53//! - **Forwarded** ([`Self::forwarded`], issue #663 extensibility wave part
54//!   1): not an RFC 7235 challenge scheme at all — trusts that a fronting
55//!   reverse proxy has already authenticated the caller and forwards the
56//!   authenticated username in a configured header (conventionally
57//!   `X-Forwarded-User`). Authenticated iff that header is present and
58//!   non-empty. **Safe ONLY behind a trusted reverse proxy that strips any
59//!   client-supplied copies of that header (and of the forwarded-for header,
60//!   if configured) before forwarding** — this crate performs no such
61//!   stripping and trusts [`crate::RequestContext::headers`] completely; a
62//!   direct or spoofed client could otherwise set the header itself and
63//!   bypass authentication entirely. [`Self::challenge`] returns just the
64//!   bare scheme name for diagnostics (there is no challenge/response
65//!   round-trip a direct client could answer).
66//! - **SignedUrl** ([`Self::signed_url`], issue #747): a CDN-style,
67//!   short-lived, tamper-proof token in the URL's own query string — no
68//!   `Authorization` header at all, so a player can fetch segments without
69//!   carrying a credential. See [`crate::signed_url`] for the full wire
70//!   form, canonical string, and rejection semantics. Like `Forwarded`,
71//!   [`Self::challenge`] returns just the bare scheme name — there is no
72//!   `WWW-Authenticate` round-trip a client could answer for a query-string
73//!   token.
74//!
75//! # Nonce handling (RFC 7616 §3.3 / §5.4)
76//!
77//! A `Digest` [`Verifier`] issues a fresh nonce on every
78//! [`Verifier::challenge`] call: `issue-time ‖ issue-sequence ‖
79//! HMAC-SHA256(secret, issue-time ‖ issue-sequence)`, hex-encoded, where
80//! `secret` is drawn from the OS RNG once per verifier. A nonce is accepted
81//! only if its HMAC checks out and it is younger than
82//! [`DIGEST_NONCE_LIFETIME`]. Expiry is absolute; a correctly answered but
83//! expired nonce is reported via [`Verifier::challenge_for`], whose challenge
84//! then carries `stale=true`, so a compliant client retries with the new
85//! nonce without re-prompting (RFC 7616 §3.3). Callers should answer a `401`
86//! with [`Verifier::challenge_for`] rather than [`Verifier::challenge`].
87//!
88//! Replay: for each `(nonce, cnonce)` pair the verifier keeps the highest
89//! `nc` accepted plus a bitmap of the [`NC_WINDOW`] values below it (the
90//! RFC 4303 §3.4.3 anti-replay window), so requests pipelined on one pair may
91//! arrive out of order, but no `nc` is accepted twice and one more than
92//! [`NC_WINDOW`] below the highest is refused. At most
93//! [`DIGEST_NC_TRACK_CAP`] pairs are tracked by default
94//! ([`Verifier::with_digest_nc_capacity`] changes it); past that the
95//! least-recently-used pair is dropped, and any pair not in the table whose
96//! nonce was issued no later than a dropped live one is answered as stale, so
97//! a dropped pair can never be replayed.
98//!
99//! The verifier's clock defaults to [`SystemTime::now`] and can be replaced
100//! with [`Verifier::with_clock`]; a clock that steps backwards is clamped to
101//! the latest nonce issue time seen.
102
103use std::collections::HashMap;
104use std::sync::Mutex;
105use std::time::{Duration, SystemTime, UNIX_EPOCH};
106
107use headers::authorization::{Basic, Bearer, Credentials as HeaderCredentials};
108use headers::{Authorization, Header, HeaderValue};
109use hmac::digest::Key;
110use hmac::{Hmac, KeyInit, Mac};
111use http_auth::{ChallengeParser, ChallengeRef};
112use lru::LruCache;
113use md5::{Digest as _, Md5};
114use percent_encoding::percent_decode_str;
115use sha2::Sha256;
116use subtle::ConstantTimeEq;
117use url::{Position, Url};
118
119use crate::credentials::Credentials;
120use crate::request::RequestContext;
121use crate::signed_url::{self, SignedUrlKeySet};
122
123/// The outcome of [`Verifier::verify`].
124#[derive(Debug, Clone, Copy, PartialEq, Eq)]
125#[non_exhaustive]
126pub enum AuthResult {
127    /// The `Authorization` header (or absence of one) satisfies the
128    /// verifier's configured credential.
129    Ok,
130    /// Missing, malformed, or wrong-credential `Authorization` — the caller
131    /// should respond `401` with [`Verifier::challenge`].
132    Unauthorized,
133}
134
135/// Per-scheme state a [`Verifier`] holds — mirrors [`Credentials`] but adds
136/// the realm (Basic/Digest) and the nonce secret + `nc` tracker (Digest; see
137/// the module docs' nonce handling).
138enum VerifierScheme {
139    Basic {
140        username: String,
141        password: String,
142        realm: String,
143    },
144    Digest {
145        username: String,
146        password: String,
147        realm: String,
148        nonces: DigestNonces,
149    },
150    Bearer {
151        token: String,
152    },
153    /// Reverse-proxy forwarded-auth (see the module docs) — no
154    /// `Credentials`/realm/nonce at all, since there is no client-answered
155    /// challenge for this scheme.
156    Forwarded {
157        user_header: String,
158        forwarded_for_header: Option<String>,
159    },
160    /// HMAC signed-URL (see the module docs / [`crate::signed_url`]) — no
161    /// `Credentials`/realm/nonce either: the token lives in the request's own
162    /// query string, verified against `keys`.
163    SignedUrl {
164        keys: SignedUrlKeySet,
165    },
166}
167
168/// Wipe the configured password/token on drop (issue #1142). The signed-URL
169/// keys and the Digest nonce secret wipe themselves.
170impl Drop for VerifierScheme {
171    fn drop(&mut self) {
172        use zeroize::Zeroize;
173        match self {
174            VerifierScheme::Basic { password, .. } | VerifierScheme::Digest { password, .. } => {
175                password.zeroize();
176            }
177            VerifierScheme::Bearer { token } => token.zeroize(),
178            VerifierScheme::Forwarded { .. } | VerifierScheme::SignedUrl { .. } => {}
179        }
180    }
181}
182
183/// Challenges + verifies incoming requests against one configured
184/// [`Credentials`] (RFC 7235 origin-side auth) — see the module docs.
185pub struct Verifier {
186    scheme: VerifierScheme,
187    clock: Clock,
188}
189
190/// The time source a [`Verifier`] reads nonce ages from.
191type Clock = Box<dyn Fn() -> SystemTime + Send + Sync>;
192
193/// How long an issued Digest nonce is accepted, measured from issue (RFC 7616
194/// §5.4 leaves the lifetime to the server). An older nonce is refused, and
195/// [`Verifier::challenge_for`] answers it with `stale=true` so the client
196/// retries silently with a fresh nonce.
197pub const DIGEST_NONCE_LIFETIME: Duration = Duration::from_secs(3600);
198
199/// Default number of `(nonce, cnonce)` pairs a Digest [`Verifier`] tracks
200/// (see [`Verifier::with_digest_nc_capacity`]). Each tracked pair costs about
201/// 128 bytes (a 32-byte key hash stored twice, 28 bytes of counters, and
202/// hash-map/B-tree overhead), so the default is roughly 8 MiB at most.
203pub const DIGEST_NC_TRACK_CAP: usize = 65_536;
204
205/// Width of the per-pair `nc` anti-replay window: how far below the highest
206/// accepted `nc` a not-yet-seen `nc` is still accepted (RFC 4303 §3.4.3).
207pub const NC_WINDOW: u32 = u64::BITS;
208
209/// Bytes of per-verifier HMAC key drawn from the OS RNG.
210const NONCE_SECRET_LEN: usize = 32;
211// `DigestNonces::mac` zero-pads the secret into one SHA-256 block (64 bytes).
212const _: () = assert!(NONCE_SECRET_LEN <= 64);
213/// Bytes of the big-endian issue time (seconds since the Unix epoch).
214const NONCE_TIME_LEN: usize = 8;
215/// Bytes of the big-endian per-verifier issue sequence number, so every
216/// challenge gets a distinct nonce and nonces are totally ordered by issue.
217const NONCE_SEQ_LEN: usize = 8;
218/// Bytes of HMAC-SHA256 output.
219const NONCE_MAC_LEN: usize = 32;
220/// Bytes of the decoded nonce.
221const NONCE_LEN: usize = NONCE_TIME_LEN + NONCE_SEQ_LEN + NONCE_MAC_LEN;
222/// RFC 7616 §3.4: `nc-value = 8LHEX`.
223const NC_HEX_LEN: usize = 8;
224
225/// Digest nonce secret plus the per-`(nonce, cnonce)` `nc` table.
226struct DigestNonces {
227    secret: [u8; NONCE_SECRET_LEN],
228    seen: Mutex<NcTable>,
229}
230
231impl Drop for DigestNonces {
232    fn drop(&mut self) {
233        use zeroize::Zeroize;
234        self.secret.zeroize();
235    }
236}
237
238/// SHA-256 of `nonce ‖ cnonce` (the nonce has a fixed length, so this is
239/// unambiguous) — keeps table entries small whatever the client sends.
240type PairKey = [u8; 32];
241
242/// When and in which order a nonce was issued.
243#[derive(Clone, Copy)]
244struct NonceStamp {
245    time: u64,
246    seq: u64,
247}
248
249struct NcEntry {
250    stamp: NonceStamp,
251    /// Highest `nc` accepted.
252    highest: u32,
253    /// Bit `i` set: `highest - 1 - i` has been accepted.
254    window: u64,
255    // No `last_used`: `LruCache` owns the use order.
256}
257
258impl NcEntry {
259    /// Accepts `nc` if it has not been seen and is within the window.
260    fn accept(&mut self, nc: u32) -> bool {
261        if nc > self.highest {
262            let shift = nc - self.highest;
263            let old_highest = 1u64.checked_shl(shift - 1).unwrap_or(0);
264            self.window = self.window.checked_shl(shift).unwrap_or(0) | old_highest;
265            self.highest = nc;
266            return true;
267        }
268        let below = self.highest - nc;
269        if below == 0 || below > NC_WINDOW {
270            return false;
271        }
272        let bit = 1u64 << (below - 1);
273        if self.window & bit != 0 {
274            return false;
275        }
276        self.window |= bit;
277        true
278    }
279}
280
281struct NcTable {
282    /// Per-`(nonce, cnonce)` anti-replay state, in least-recently-used order.
283    /// Unbounded as far as `lru` is concerned: the cap below is enforced by
284    /// `record` so a dropped live pair can raise `floor_seq`.
285    entries: LruCache<PairKey, NcEntry>,
286    capacity: usize,
287    /// Nonces with a lower issue sequence are accepted only for pairs still
288    /// in `entries` — raised when a live pair had to be dropped.
289    floor_seq: u64,
290    next_seq: u64,
291    latest_issue_time: u64,
292}
293
294/// What a Digest `Authorization` header amounts to, before any `nc` is
295/// recorded.
296enum DigestCheck {
297    /// Correct response on a live nonce.
298    Accept {
299        key: PairKey,
300        stamp: NonceStamp,
301        nc: u32,
302    },
303    /// Correct response, but the nonce is past its lifetime or its pair was
304    /// dropped from the table.
305    Stale,
306    /// Anything else.
307    Reject,
308}
309
310fn pair_key(nonce: &str, cnonce: &str) -> PairKey {
311    let mut hash = Sha256::new();
312    hash.update(nonce.as_bytes());
313    hash.update(cnonce.as_bytes());
314    hash.finalize().into()
315}
316
317impl DigestNonces {
318    fn new() -> Self {
319        DigestNonces {
320            secret: rand::random(),
321            seen: Mutex::new(NcTable {
322                entries: LruCache::unbounded(),
323                capacity: DIGEST_NC_TRACK_CAP,
324                floor_seq: 0,
325                next_seq: 0,
326                latest_issue_time: 0,
327            }),
328        }
329    }
330
331    fn table(&self) -> std::sync::MutexGuard<'_, NcTable> {
332        self.seen.lock().unwrap_or_else(|e| e.into_inner())
333    }
334
335    fn set_capacity(&self, capacity: usize) {
336        self.table().capacity = capacity.max(1);
337    }
338
339    /// `clock_secs`, clamped so it never runs behind a nonce already issued.
340    fn now(&self, clock_secs: u64) -> u64 {
341        clock_secs.max(self.table().latest_issue_time)
342    }
343
344    fn mac(&self, signed: &[u8]) -> Hmac<Sha256> {
345        // Infallible keying: HMAC (RFC 2104 §2) zero-pads a key shorter than
346        // the hash block to the block size, so a fixed-size block key built
347        // here is exactly equivalent to `new_from_slice(&self.secret)`
348        // without its (never-taken) error arm.
349        let mut block = Key::<Hmac<Sha256>>::default();
350        block[..NONCE_SECRET_LEN].copy_from_slice(&self.secret);
351        let mut mac = <Hmac<Sha256> as KeyInit>::new(&block);
352        mac.update(signed);
353        mac
354    }
355
356    /// A fresh nonce issued now (`clock_secs`, clamped as in [`Self::now`]).
357    fn issue(&self, clock_secs: u64) -> String {
358        let (time, seq) = {
359            let mut table = self.table();
360            let time = clock_secs.max(table.latest_issue_time);
361            table.latest_issue_time = time;
362            let seq = table.next_seq;
363            table.next_seq = seq.wrapping_add(1);
364            (time, seq)
365        };
366        let mut raw = [0u8; NONCE_LEN];
367        raw[..NONCE_TIME_LEN].copy_from_slice(&time.to_be_bytes());
368        raw[NONCE_TIME_LEN..NONCE_TIME_LEN + NONCE_SEQ_LEN].copy_from_slice(&seq.to_be_bytes());
369        let tag = self
370            .mac(&raw[..NONCE_TIME_LEN + NONCE_SEQ_LEN])
371            .finalize()
372            .into_bytes();
373        raw[NONCE_TIME_LEN + NONCE_SEQ_LEN..].copy_from_slice(&tag);
374        hex::encode(raw)
375    }
376
377    /// The issue stamp of `nonce` if this verifier minted it, else `None`.
378    fn issued_at(&self, nonce: &str) -> Option<NonceStamp> {
379        let raw = unhex::<NONCE_LEN>(nonce)?;
380        let (signed, tag) = raw.split_at(NONCE_TIME_LEN + NONCE_SEQ_LEN);
381        self.mac(signed).verify_slice(tag).ok()?;
382        let mut time = [0u8; NONCE_TIME_LEN];
383        time.copy_from_slice(&raw[..NONCE_TIME_LEN]);
384        let mut seq = [0u8; NONCE_SEQ_LEN];
385        seq.copy_from_slice(&raw[NONCE_TIME_LEN..NONCE_TIME_LEN + NONCE_SEQ_LEN]);
386        Some(NonceStamp {
387            time: u64::from_be_bytes(time),
388            seq: u64::from_be_bytes(seq),
389        })
390    }
391
392    fn is_expired(issued: u64, now_secs: u64) -> bool {
393        now_secs.saturating_sub(issued) >= DIGEST_NONCE_LIFETIME.as_secs()
394    }
395
396    /// Records `nc` for `key`; `false` when it was already seen, falls below
397    /// the window, or the pair can no longer be tracked.
398    fn record(&self, key: PairKey, stamp: NonceStamp, nc: u32, now_secs: u64) -> bool {
399        let mut guard = self.table();
400        let table = &mut *guard;
401        // `peek_mut` does not touch the use order: a rejected replay must not
402        // refresh the pair, only a successful accept does.
403        if let Some(entry) = table.entries.peek_mut(&key) {
404            if !entry.accept(nc) {
405                return false;
406            }
407            table.entries.promote(&key);
408            return true;
409        }
410        while table.entries.len() >= table.capacity {
411            let Some((_, dropped)) = table.entries.pop_lru() else {
412                break;
413            };
414            if !Self::is_expired(dropped.stamp.time, now_secs) {
415                table.floor_seq = table.floor_seq.max(dropped.stamp.seq.saturating_add(1));
416            }
417        }
418        if stamp.seq < table.floor_seq {
419            return false;
420        }
421        table.entries.put(
422            key,
423            NcEntry {
424                stamp,
425                highest: nc,
426                window: 0,
427            },
428        );
429        true
430    }
431
432    /// Whether an untracked pair under a nonce with `stamp` would be refused
433    /// because a live pair issued no earlier was dropped.
434    fn below_floor(&self, key: &PairKey, stamp: NonceStamp) -> bool {
435        let table = self.table();
436        stamp.seq < table.floor_seq && !table.entries.contains(key)
437    }
438}
439
440fn unix_secs(t: SystemTime) -> u64 {
441    t.duration_since(UNIX_EPOCH).map_or(0, |d| d.as_secs())
442}
443
444fn default_clock() -> Clock {
445    Box::new(SystemTime::now)
446}
447
448impl Verifier {
449    /// Builds a verifier for `credentials`, using `realm` for the
450    /// `WWW-Authenticate` challenge (Basic/Digest only — RFC 6750 Bearer has
451    /// no realm parameter in this crate's minimal challenge, see
452    /// [`Self::challenge`]).
453    ///
454    /// For `Credentials::Digest`, a random nonce-signing secret is generated
455    /// now; every [`Self::challenge`] then issues a fresh, time-limited nonce
456    /// (see the module docs' nonce handling).
457    pub fn new(credentials: Credentials, realm: impl Into<String>) -> Self {
458        // Stored in the form it is rendered in, so the realm a client echoes
459        // back (and hashes into HA1) is the realm the server compares.
460        let realm = sanitize_realm(&realm.into());
461        // `Credentials` wipes its secrets on drop, so the secrets are moved
462        // out with `mem::take` (leaving empty strings behind) rather than by
463        // destructuring.
464        let mut credentials = credentials;
465        let scheme = match &mut credentials {
466            Credentials::Basic { username, password } => VerifierScheme::Basic {
467                username: core::mem::take(username),
468                password: core::mem::take(password),
469                realm,
470            },
471            Credentials::Digest { username, password } => VerifierScheme::Digest {
472                username: core::mem::take(username),
473                password: core::mem::take(password),
474                realm,
475                nonces: DigestNonces::new(),
476            },
477            Credentials::Bearer { token } => VerifierScheme::Bearer {
478                token: core::mem::take(token),
479            },
480        };
481        Verifier {
482            scheme,
483            clock: default_clock(),
484        }
485    }
486
487    /// Replaces the clock Digest nonce ages are measured against (default
488    /// [`SystemTime::now`]) — for sans-IO drivers and tests.
489    pub fn with_clock(mut self, clock: impl Fn() -> SystemTime + Send + Sync + 'static) -> Self {
490        self.clock = Box::new(clock);
491        self
492    }
493
494    fn now_secs(&self) -> u64 {
495        unix_secs((self.clock)())
496    }
497
498    /// Sets how many `(nonce, cnonce)` pairs a Digest verifier tracks
499    /// (default [`DIGEST_NC_TRACK_CAP`], minimum 1; see the module docs).
500    /// No effect on other schemes.
501    pub fn with_digest_nc_capacity(self, capacity: usize) -> Self {
502        if let VerifierScheme::Digest { nonces, .. } = &self.scheme {
503            nonces.set_capacity(capacity);
504        }
505        self
506    }
507
508    /// Builds a verifier for the reverse-proxy forwarded-auth scheme (see the
509    /// module docs' trust assumption — read it before using this).
510    ///
511    /// `user_header` (conventionally `X-Forwarded-User`) is the header whose
512    /// presence (non-empty) [`Self::verify`] treats as "the proxy already
513    /// authenticated this caller". `forwarded_for_header` (conventionally
514    /// `Some("X-Forwarded-For".to_string())`), if configured, is read back by
515    /// [`Self::forwarded_for`] for observability only — this crate makes no
516    /// trust decision based on it.
517    pub fn forwarded(user_header: impl Into<String>, forwarded_for_header: Option<String>) -> Self {
518        Verifier {
519            scheme: VerifierScheme::Forwarded {
520                user_header: user_header.into(),
521                forwarded_for_header,
522            },
523            clock: default_clock(),
524        }
525    }
526
527    /// Builds a verifier for the HMAC signed-URL scheme (issue #747) —
528    /// see [`crate::signed_url`] for the wire form, canonical string, and
529    /// rejection semantics.
530    pub fn signed_url(keys: SignedUrlKeySet) -> Self {
531        Verifier {
532            scheme: VerifierScheme::SignedUrl { keys },
533            clock: default_clock(),
534        }
535    }
536
537    /// The `WWW-Authenticate` header value to send on a `401` in response to
538    /// a missing/failed [`Self::verify`] call.
539    ///
540    /// `Forwarded` (built via [`Self::forwarded`]) has no real RFC 7235
541    /// challenge (a direct client cannot answer it — see the module docs);
542    /// this just names the scheme for diagnostics.
543    ///
544    /// For Digest every call issues a fresh nonce; prefer
545    /// [`Self::challenge_for`] when the rejected request is at hand, so an
546    /// expired nonce is flagged `stale=true`.
547    pub fn challenge(&self) -> String {
548        self.render_challenge(false)
549    }
550
551    /// Like [`Self::challenge`], but for the request that [`Self::verify`]
552    /// just rejected: when that request answered a Digest challenge
553    /// correctly and only its nonce had expired, the new challenge carries
554    /// `stale=true` (RFC 7616 §3.3) so the client retries without
555    /// re-prompting for credentials. Identical to [`Self::challenge`] for
556    /// every other scheme and outcome.
557    pub fn challenge_for(&self, ctx: &RequestContext<'_>) -> String {
558        let stale = match &self.scheme {
559            VerifierScheme::Digest {
560                username,
561                password,
562                realm,
563                nonces,
564            } => ctx.header("authorization").is_some_and(|header| {
565                matches!(
566                    check_digest(
567                        header,
568                        username,
569                        password,
570                        realm,
571                        nonces,
572                        ctx.method,
573                        ctx.uri,
574                        nonces.now(self.now_secs()),
575                    ),
576                    DigestCheck::Stale
577                )
578            }),
579            _ => false,
580        };
581        self.render_challenge(stale)
582    }
583
584    fn render_challenge(&self, stale: bool) -> String {
585        match &self.scheme {
586            VerifierScheme::Basic { realm, .. } => format!("Basic realm={}", quoted(realm)),
587            VerifierScheme::Digest { realm, nonces, .. } => {
588                let nonce = nonces.issue(self.now_secs());
589                let stale = if stale { ", stale=true" } else { "" };
590                format!(
591                    "Digest realm={}, nonce=\"{nonce}\", qop=\"auth\", algorithm=MD5{stale}",
592                    quoted(realm)
593                )
594            }
595            VerifierScheme::Bearer { .. } => "Bearer".to_string(),
596            VerifierScheme::Forwarded { .. } => "Forwarded".to_string(),
597            VerifierScheme::SignedUrl { .. } => "SignedUrl".to_string(),
598        }
599    }
600
601    /// Verifies an incoming request against this verifier's configured
602    /// scheme.
603    ///
604    /// Basic/Digest/Bearer read `ctx`'s `Authorization` header
605    /// ([`RequestContext::header`], case-insensitive) — missing entirely is
606    /// `Unauthorized`, same as before this took a full [`RequestContext`].
607    /// `ctx.method` feeds Digest's `HA2` directly; `ctx.uri` is the request
608    /// URI the client's claimed `uri` field is matched against (RFC 7616
609    /// §3.4.1's SHOULD, accepting either origin-form or absolute-form —
610    /// unused for Basic/Bearer.
611    /// Forwarded reads `ctx`'s configured user header instead — see the
612    /// module docs. SignedUrl reads `ctx.uri`'s own query string (`exp`/
613    /// `kid`/`sig`\[/`ip`\]) instead of any header at all, and `ctx.peer_addr`
614    /// when the token is IP-scoped — see [`crate::signed_url`].
615    ///
616    /// A `Digest` `Authorization` header with a pathological parameter count
617    /// is rejected outright (see `MAX_DIGEST_FIELDS`) — this bounds
618    /// the per-request allocation cost, but is not a substitute for a
619    /// transport-level cap on header size, which callers should also enforce.
620    pub fn verify(&self, ctx: &RequestContext<'_>) -> AuthResult {
621        let ok = match &self.scheme {
622            VerifierScheme::Basic {
623                username, password, ..
624            } => ctx
625                .header("authorization")
626                .is_some_and(|header| verify_basic(header, username, password)),
627            VerifierScheme::Bearer { token } => ctx
628                .header("authorization")
629                .is_some_and(|header| verify_bearer(header, token)),
630            VerifierScheme::Digest {
631                username,
632                password,
633                realm,
634                nonces,
635            } => ctx.header("authorization").is_some_and(|header| {
636                let now = nonces.now(self.now_secs());
637                match check_digest(
638                    header, username, password, realm, nonces, ctx.method, ctx.uri, now,
639                ) {
640                    DigestCheck::Accept { key, stamp, nc } => nonces.record(key, stamp, nc, now),
641                    DigestCheck::Stale | DigestCheck::Reject => false,
642                }
643            }),
644            VerifierScheme::Forwarded { user_header, .. } => verify_forwarded(ctx, user_header),
645            VerifierScheme::SignedUrl { keys } => signed_url::verify(ctx, keys),
646        };
647        if ok {
648            AuthResult::Ok
649        } else {
650            AuthResult::Unauthorized
651        }
652    }
653
654    /// For a [`Self::forwarded`] verifier with a configured
655    /// `forwarded_for_header`, returns that header's value from `ctx` — for
656    /// tracing/observability only; this crate makes no trust decision with
657    /// it (the module docs' trust assumption is what actually matters).
658    /// `None` for any other verifier, or when no such header is
659    /// configured/present in `ctx`.
660    pub fn forwarded_for<'a>(&self, ctx: &RequestContext<'a>) -> Option<&'a str> {
661        match &self.scheme {
662            VerifierScheme::Forwarded {
663                forwarded_for_header: Some(header_name),
664                ..
665            } => ctx.header(header_name),
666            _ => None,
667        }
668    }
669}
670
671/// Manual `Debug` (rather than `#[derive(Debug)]`): every scheme carries a
672/// secret (`password`/`token`) that must never render verbatim.
673impl core::fmt::Debug for Verifier {
674    fn fmt(&self, f: &mut core::fmt::Formatter<'_>) -> core::fmt::Result {
675        let scheme = match &self.scheme {
676            VerifierScheme::Basic { .. } => "Basic",
677            VerifierScheme::Digest { .. } => "Digest",
678            VerifierScheme::Bearer { .. } => "Bearer",
679            VerifierScheme::Forwarded { .. } => "Forwarded",
680            VerifierScheme::SignedUrl { .. } => "SignedUrl",
681        };
682        f.debug_struct("Verifier")
683            .field("scheme", &scheme)
684            .finish_non_exhaustive()
685    }
686}
687
688/// A realm as it can appear in a header: ASCII control characters other than
689/// HTAB (CR, LF, NUL, ...) cannot appear in a header value and are dropped
690/// rather than letting a realm split the response. HTAB is legal `qdtext`
691/// (RFC 9110 §5.6.4) and is kept. Applied once, when the [`Verifier`] is built.
692fn sanitize_realm(realm: &str) -> String {
693    realm
694        .chars()
695        .filter(|c| !c.is_ascii_control() || *c == '\t')
696        .collect()
697}
698
699/// RFC 9110 §5.6.4 `quoted-string`: `"` and `\` become quoted-pairs.
700///
701/// Rendering a `WWW-Authenticate` challenge stays a formatter here because no
702/// crate in the workspace can produce one (`http_auth::ChallengeRef`
703/// implements only `Debug`; escalation E1). `tests/challenge_render.rs` parses
704/// every rendering back with `http-auth`'s `ChallengeParser`.
705fn quoted(value: &str) -> String {
706    let mut out = String::with_capacity(value.len() + 2);
707    out.push('"');
708    for c in value
709        .chars()
710        .filter(|c| !c.is_ascii_control() || *c == '\t')
711    {
712        if matches!(c, '"' | '\\') {
713            out.push('\\');
714        }
715        out.push(c);
716    }
717    out.push('"');
718    out
719}
720
721/// RFC 8187 §3.2.1 `value-chars` syntax: `pct-encoded` or `attr-char`.
722fn is_ext_value_syntax(value: &str) -> bool {
723    let b = value.as_bytes();
724    let mut i = 0;
725    while i < b.len() {
726        match b[i] {
727            b'%' => {
728                if !(i + 2 < b.len()
729                    && b[i + 1].is_ascii_hexdigit()
730                    && b[i + 2].is_ascii_hexdigit())
731                {
732                    return false;
733                }
734                i += 3;
735            }
736            c if c.is_ascii_alphanumeric() || b"!#$&+-.^_`|~".contains(&c) => i += 1,
737            _ => return false,
738        }
739    }
740    true
741}
742
743/// RFC 7616 §3.4.4: `username*` is an RFC 8187 ext-value
744/// (`charset "'" [language] "'" value-chars`). Only `UTF-8` is accepted;
745/// syntax and UTF-8 validity are enforced, never "lossy" (`percent_decode_str`
746/// alone passes a malformed `%zz` through as literal text).
747fn decode_ext_value(v: &str) -> Option<String> {
748    let (charset, rest) = v.split_once('\'')?;
749    let (_language, value) = rest.split_once('\'')?;
750    if !charset.eq_ignore_ascii_case("UTF-8") || !is_ext_value_syntax(value) {
751        return None;
752    }
753    percent_decode_str(value)
754        .decode_utf8()
755        .ok()
756        .map(std::borrow::Cow::into_owned)
757}
758
759/// RFC 7235 `credentials` for the Digest scheme are an `auth-param` list — the
760/// same grammar `http-auth` parses for challenges — so its parser reads them
761/// (verified against the RFC 7616 §3.9.1 examples in
762/// `tests/http_auth_rfc7616.rs`). Names are lower-cased (RFC 7235 §2.1),
763/// quoted-pairs are unescaped, and anything ambiguous is refused: more than one
764/// challenge, a scheme other than `Digest`, a repeated parameter, or more than
765/// [`MAX_DIGEST_FIELDS`] parameters.
766fn parse_digest_fields(header: &str) -> Option<HashMap<String, String>> {
767    let mut parser = ChallengeParser::new(header);
768    let challenge: ChallengeRef<'_> = parser.next()?.ok()?;
769    if parser.next().is_some()
770        || !challenge.scheme.eq_ignore_ascii_case("Digest")
771        || challenge.params.len() > MAX_DIGEST_FIELDS
772    {
773        return None;
774    }
775    let mut fields = HashMap::with_capacity(challenge.params.len());
776    for (name, value) in &challenge.params {
777        if fields
778            .insert(name.to_ascii_lowercase(), value.to_unescaped())
779            .is_some()
780        {
781            return None;
782        }
783    }
784    Some(fields)
785}
786
787/// Typed read of an `Authorization` header value for one token68 scheme
788/// (`Basic` or `Bearer`; `http-auth`'s parser cannot read token68). `headers`
789/// matches the scheme case-insensitively and tolerates extra spaces.
790fn decode<C: HeaderCredentials>(header: &str) -> Option<Authorization<C>> {
791    let value = HeaderValue::from_str(header).ok()?;
792    Authorization::<C>::decode(&mut std::iter::once(&value)).ok()
793}
794
795/// RFC 7617 §2: decode the base64 payload and compare, in constant time,
796/// against `username`/`password`. The user-id/password split is at the FIRST
797/// `:` (RFC 7617 §2: a user-id cannot contain one), so a configured username
798/// containing `:` cannot match.
799fn verify_basic(header: &str, username: &str, password: &str) -> bool {
800    let Some(auth) = decode::<Basic>(header) else {
801        return false;
802    };
803    // Both compared unconditionally: no short-circuit on the user-id.
804    let user_ok = constant_time_eq(auth.username().as_bytes(), username.as_bytes());
805    let pass_ok = constant_time_eq(auth.password().as_bytes(), password.as_bytes());
806    user_ok & pass_ok
807}
808
809/// RFC 6750 §2.1: compare the bearer token, in constant time.
810fn verify_bearer(header: &str, token: &str) -> bool {
811    let Some(auth) = decode::<Bearer>(header) else {
812        return false;
813    };
814    constant_time_eq(auth.token().trim().as_bytes(), token.trim().as_bytes())
815}
816
817/// A real Digest `Authorization` response (RFC 7616 §3.4.1) carries under 15
818/// `key=value` fields (`username`, `realm`, `nonce`, `uri`, `response`,
819/// `algorithm`, `cnonce`, `opaque`, `qop`, `nc`, plus a couple of optional
820/// extensions). Capping well above that bounds [`verify_digest`]'s
821/// `HashMap` allocation against a request carrying a pathologically large
822/// `Authorization` header (a huge field count forcing a huge per-request
823/// map) without rejecting any legitimate client.
824const MAX_DIGEST_FIELDS: usize = 64;
825
826/// RFC 7616 §3.4.1: parse the `Digest` `Authorization` header's
827/// `key=value`/`key="value"` fields, check the nonce was minted by `nonces`,
828/// independently recompute the expected `response`, and compare in constant
829/// time — `qop=auth`/`algorithm=MD5` only (the one shape every client in this
830/// workspace answers). Records nothing; the caller records an accepted `nc`.
831///
832/// `HA2` is computed over the client's own claimed `uri` field (the
833/// `digest-uri-value` RFC 7616 §3.4.1 defines HA2 over) — not `request_uri` —
834/// since that is what the client actually hashed into its `response`. The
835/// client's claimed `uri` is separately checked against `request_uri` (RFC
836/// 7616 §3.4.1's SHOULD) via [`digest_uri_matches`], which accepts either
837/// legal RFC 7230 request-target representation of the same target
838/// (origin-form or absolute-form) while still rejecting a genuinely
839/// different `uri`.
840///
841/// Rejects outright a header carrying more than [`MAX_DIGEST_FIELDS`]
842/// parameters — see that constant's docs. The parser still allocates one
843/// entry per parameter, bounded by the header size the transport already caps.
844#[allow(clippy::too_many_arguments)]
845fn check_digest(
846    header: &str,
847    username: &str,
848    password: &str,
849    realm: &str,
850    nonces: &DigestNonces,
851    method: &str,
852    request_uri: &str,
853    now_secs: u64,
854) -> DigestCheck {
855    let Some(fields) = parse_digest_fields(header) else {
856        return DigestCheck::Reject;
857    };
858    let get = |k: &str| fields.get(k).map(String::as_str).unwrap_or_default();
859
860    if get("userhash").eq_ignore_ascii_case("true") {
861        return DigestCheck::Reject;
862    }
863    let client_user = match (fields.get("username"), fields.get("username*")) {
864        (Some(_), Some(_)) => return DigestCheck::Reject, // RFC 7616 §3.4.4
865        (Some(u), None) => u.clone(),
866        (None, Some(ext)) => match decode_ext_value(ext) {
867            Some(u) => u,
868            None => return DigestCheck::Reject,
869        },
870        (None, None) => return DigestCheck::Reject,
871    };
872    if client_user != username || get("realm") != realm {
873        return DigestCheck::Reject;
874    }
875    let nonce = get("nonce");
876    let Some(stamp) = nonces.issued_at(nonce) else {
877        return DigestCheck::Reject;
878    };
879    let client_uri = get("uri");
880    if !digest_uri_matches(client_uri, request_uri) {
881        return DigestCheck::Reject;
882    }
883    let nc_text = get("nc");
884    let cnonce = get("cnonce");
885    let qop = get("qop");
886    let client_response = get("response");
887    if cnonce.is_empty() || client_response.is_empty() {
888        return DigestCheck::Reject;
889    }
890    // This module doc / the rendered challenge only ever offer `qop="auth"`,
891    // `algorithm=MD5` (see the module-level "Verification per scheme" doc) —
892    // reject anything else outright rather than silently hashing whatever
893    // the client sent through the qop=auth formula below (RFC 7616 §3.4.1
894    // also defines `qop=auth-int` and `algorithm=*-sess` variants, which use
895    // a *different* HA1/HA2 construction this crate does not implement).
896    // `algorithm` defaults to `MD5` when absent (RFC 7616 §3.3).
897    if qop != "auth" {
898        return DigestCheck::Reject;
899    }
900    let algorithm = get("algorithm");
901    if !algorithm.is_empty() && !algorithm.eq_ignore_ascii_case("MD5") {
902        return DigestCheck::Reject;
903    }
904    let Some(nc) = parse_nc(nc_text) else {
905        return DigestCheck::Reject;
906    };
907
908    let ha1 = md5_hex(format!("{username}:{realm}:{password}"));
909    let ha2 = md5_hex(format!("{method}:{client_uri}"));
910    let expected_response = md5_hex(format!("{ha1}:{nonce}:{nc_text}:{cnonce}:{qop}:{ha2}"));
911    if !constant_time_eq(expected_response.as_bytes(), client_response.as_bytes()) {
912        return DigestCheck::Reject;
913    }
914    let key = pair_key(nonce, cnonce);
915    if DigestNonces::is_expired(stamp.time, now_secs) || nonces.below_floor(&key, stamp) {
916        return DigestCheck::Stale;
917    }
918    DigestCheck::Accept { key, stamp, nc }
919}
920
921/// RFC 7616 §3.4: `nc` is exactly eight hex digits.
922fn parse_nc(nc: &str) -> Option<u32> {
923    if nc.len() != NC_HEX_LEN || !nc.bytes().all(|b| b.is_ascii_hexdigit()) {
924        return None;
925    }
926    u32::from_str_radix(nc, 16).ok()
927}
928
929/// RFC 7616 §3.4.1's SHOULD-check: does the client's claimed Digest `uri`
930/// field refer to the same request-target as `request_uri` (the actual
931/// request the server is verifying against)?
932///
933/// RFC 7230 §5.3 permits a request-target in either **origin-form**
934/// (`/path[?query]`) or **absolute-form** (`scheme://authority/path[?query]`)
935/// — a legitimate client may hash either, and `request_uri` here is always
936/// whatever form the caller's own request line/context uses (in this
937/// workspace, always origin-form for HTTP). This accepts:
938/// - `client_uri == request_uri` verbatim (the origin-form case), or
939/// - `client_uri` in absolute-form that `url::Url` parses, that is ALREADY in
940///   the parser's normalised spelling (`Url::as_str() == client_uri`: no
941///   dot-segments, no upper-case scheme/host, no default port, a root `/`),
942///   and whose path+query+fragment (`Position::BeforePath..`) is
943///   byte-identical to `request_uri`.
944///
945/// Anything else is rejected. The normalised-spelling requirement is the
946/// substitution guard: `Url::parse` removes `.`/`..` segments (also when
947/// percent-encoded), so a naive "parse, then compare" would authorise `/b`
948/// for a response hashed over `http://h/a/../b`. This is not a prefix/suffix
949/// check either: a `client_uri` that merely contains or is suffixed by
950/// `request_uri` (or vice versa) does NOT match.
951fn digest_uri_matches(client_uri: &str, request_uri: &str) -> bool {
952    if client_uri == request_uri {
953        return true;
954    }
955    let Ok(parsed) = Url::parse(client_uri) else {
956        return false;
957    };
958    parsed.as_str() == client_uri && &parsed[Position::BeforePath..] == request_uri
959}
960
961/// Reverse-proxy forwarded-auth (see the module docs): authenticated iff
962/// `user_header` is present in `ctx` and non-empty (after trimming) — the
963/// proxy having already verified the caller's identity. No credential/secret
964/// is compared here, so no constant-time comparison is needed.
965fn verify_forwarded(ctx: &RequestContext<'_>, user_header: &str) -> bool {
966    ctx.header(user_header)
967        .is_some_and(|v| !v.trim().is_empty())
968}
969
970/// Lowercase-hex MD5 digest of `input`.
971fn md5_hex(input: String) -> String {
972    let mut hasher = Md5::new();
973    hasher.update(input.as_bytes());
974    hex::encode(hasher.finalize())
975}
976
977/// Byte-equality in constant time via [`subtle::ConstantTimeEq`] (the same
978/// primitive `signed_url` uses) — only the *length* check short-circuits (an
979/// equal-length requirement is not itself the secret being protected).
980fn constant_time_eq(a: &[u8], b: &[u8]) -> bool {
981    a.len() == b.len() && bool::from(a.ct_eq(b))
982}
983
984/// Decodes exactly `N` bytes of hex, or `None`.
985fn unhex<const N: usize>(text: &str) -> Option<[u8; N]> {
986    let mut out = [0u8; N];
987    hex::decode_to_slice(text, &mut out).ok()?; // exact length, hex digits only
988    Some(out)
989}
990
991#[cfg(test)]
992mod tests {
993    use super::*;
994    use crate::{Credentials, RequestContext, respond};
995
996    const REALM: &str = "cameras";
997
998    /// Eviction order is least-recently-USED, not least-recently-inserted:
999    /// touching an old pair must protect it.
1000    #[test]
1001    fn recently_used_pair_survives_eviction() {
1002        let nonces = DigestNonces::new();
1003        nonces.set_capacity(2);
1004        let stamp = |seq| NonceStamp { time: 100, seq };
1005        let (a, b, c) = (
1006            pair_key("n1", "c"),
1007            pair_key("n2", "c"),
1008            pair_key("n3", "c"),
1009        );
1010        assert!(nonces.record(a, stamp(1), 1, 100));
1011        assert!(nonces.record(b, stamp(2), 1, 100));
1012        // Use `a` again (nc 2), making `b` the least recently used.
1013        assert!(nonces.record(a, stamp(1), 2, 100));
1014        // Inserting `c` evicts `b`, not `a`.
1015        assert!(nonces.record(c, stamp(3), 1, 100));
1016        assert!(
1017            !nonces.record(a, stamp(1), 2, 100),
1018            "a still tracked: nc 2 is a replay"
1019        );
1020        assert_eq!(nonces.table().entries.len(), 2);
1021        // `b` was evicted while still live, so its issue sequence is now below
1022        // the anti-replay floor and the pair cannot be re-admitted.
1023        assert!(!nonces.record(b, stamp(2), 1, 100));
1024    }
1025
1026    #[test]
1027    fn nonce_is_96_lowercase_hex_and_round_trips() {
1028        let nonces = DigestNonces::new();
1029        let nonce = nonces.issue(1_000);
1030        assert_eq!(nonce.len(), NONCE_LEN * 2);
1031        assert!(
1032            nonce
1033                .bytes()
1034                .all(|b| b.is_ascii_digit() || (b'a'..=b'f').contains(&b))
1035        );
1036        let stamp = nonces.issued_at(&nonce).expect("our own nonce verifies");
1037        assert_eq!(stamp.time, 1_000);
1038        assert!(
1039            nonces.issued_at(&nonce.to_uppercase()).is_some(),
1040            "hex is case-insensitive on read"
1041        );
1042        assert!(nonces.issued_at(&nonce[..nonce.len() - 2]).is_none());
1043        assert!(
1044            nonces
1045                .issued_at(&format!("{}zz", &nonce[..nonce.len() - 2]))
1046                .is_none()
1047        );
1048    }
1049
1050    /// The infallible block-key construction in `DigestNonces::mac` must be
1051    /// byte-identical to the variable-length `new_from_slice` keying it
1052    /// replaced (RFC 2104 zero-pads a short key to the block size), so
1053    /// nonces issued before and after the change verify against each other.
1054    #[test]
1055    fn nonce_mac_matches_variable_length_keying() {
1056        let nonces = DigestNonces::new();
1057        let signed = b"issue-time||issue-seq";
1058        let reference = {
1059            let mut m = <Hmac<Sha256> as KeyInit>::new_from_slice(&nonces.secret).unwrap();
1060            m.update(signed);
1061            m.finalize().into_bytes()
1062        };
1063        assert_eq!(nonces.mac(signed).finalize().into_bytes(), reference);
1064    }
1065
1066    /// Test helper: builds a [`RequestContext`] carrying `authorization` (if
1067    /// any) as the `Authorization` header, then verifies it — stands in for
1068    /// the pre-#663-extensibility-wave-1 `Verifier::verify(Option<&str>,
1069    /// &str, &str)` signature so the tests below read the same as before.
1070    fn verify_auth(
1071        v: &Verifier,
1072        authorization: Option<&str>,
1073        method: &str,
1074        uri: &str,
1075    ) -> AuthResult {
1076        let auth_header = authorization.map(|h| [("authorization", h)]);
1077        let headers: &[(&str, &str)] = match &auth_header {
1078            Some(arr) => arr,
1079            None => &[],
1080        };
1081        let ctx = RequestContext::new(method, uri).with_headers(headers);
1082        v.verify(&ctx)
1083    }
1084
1085    // --- challenge() shape ---
1086
1087    #[test]
1088    fn basic_challenge_names_the_realm() {
1089        let v = Verifier::new(
1090            Credentials::Basic {
1091                username: "admin".into(),
1092                password: "12345".into(),
1093            },
1094            REALM,
1095        );
1096        assert_eq!(v.challenge(), "Basic realm=\"cameras\"");
1097    }
1098
1099    #[test]
1100    fn digest_challenge_carries_realm_nonce_qop_algorithm() {
1101        let v = Verifier::new(
1102            Credentials::Digest {
1103                username: "admin".into(),
1104                password: "12345".into(),
1105            },
1106            REALM,
1107        );
1108        let challenge = v.challenge();
1109        assert!(challenge.starts_with("Digest "), "got: {challenge}");
1110        for needle in [
1111            "realm=\"cameras\"",
1112            "nonce=",
1113            "qop=\"auth\"",
1114            "algorithm=MD5",
1115        ] {
1116            assert!(
1117                challenge.contains(needle),
1118                "missing {needle} in {challenge}"
1119            );
1120        }
1121    }
1122
1123    #[test]
1124    fn bearer_challenge_is_bare_scheme_name() {
1125        let v = Verifier::new(Credentials::bearer("tok"), REALM);
1126        assert_eq!(v.challenge(), "Bearer");
1127    }
1128
1129    #[test]
1130    fn digest_challenges_issue_distinct_nonces() {
1131        let v = Verifier::new(
1132            Credentials::Digest {
1133                username: "admin".into(),
1134                password: "12345".into(),
1135            },
1136            REALM,
1137        );
1138        assert_ne!(v.challenge(), v.challenge());
1139    }
1140
1141    // --- round trip: a client's respond() to challenge() must verify() Ok ---
1142
1143    #[test]
1144    fn basic_respond_to_challenge_verifies_ok() {
1145        let v = Verifier::new(
1146            Credentials::Basic {
1147                username: "admin".into(),
1148                password: "12345".into(),
1149            },
1150            REALM,
1151        );
1152        let header = respond(
1153            &v.challenge(),
1154            &RequestContext::new("GET", "/stream"),
1155            Credentials::new("admin", "12345"),
1156        )
1157        .unwrap();
1158        assert_eq!(
1159            verify_auth(&v, Some(&header), "GET", "/stream"),
1160            AuthResult::Ok
1161        );
1162    }
1163
1164    #[test]
1165    fn digest_respond_to_challenge_verifies_ok() {
1166        let v = Verifier::new(
1167            Credentials::Digest {
1168                username: "admin".into(),
1169                password: "12345".into(),
1170            },
1171            REALM,
1172        );
1173        let ctx = RequestContext::new("DESCRIBE", "rtsp://cam/live");
1174        let header = respond(&v.challenge(), &ctx, Credentials::new("admin", "12345")).unwrap();
1175        assert_eq!(
1176            verify_auth(&v, Some(&header), "DESCRIBE", "rtsp://cam/live"),
1177            AuthResult::Ok
1178        );
1179    }
1180
1181    #[test]
1182    fn bearer_respond_to_challenge_verifies_ok() {
1183        let v = Verifier::new(Credentials::bearer("mytoken123"), REALM);
1184        let header = respond(
1185            &v.challenge(),
1186            &RequestContext::new("GET", "/stream"),
1187            Credentials::bearer("mytoken123"),
1188        )
1189        .unwrap();
1190        assert_eq!(
1191            verify_auth(&v, Some(&header), "GET", "/stream"),
1192            AuthResult::Ok
1193        );
1194    }
1195
1196    // --- wrong credentials -> Unauthorized (must BITE) ---
1197
1198    #[test]
1199    fn basic_wrong_password_is_unauthorized() {
1200        let v = Verifier::new(
1201            Credentials::Basic {
1202                username: "admin".into(),
1203                password: "12345".into(),
1204            },
1205            REALM,
1206        );
1207        let header = respond(
1208            &v.challenge(),
1209            &RequestContext::new("GET", "/stream"),
1210            Credentials::new("admin", "WRONG"),
1211        )
1212        .unwrap();
1213        assert_eq!(
1214            verify_auth(&v, Some(&header), "GET", "/stream"),
1215            AuthResult::Unauthorized
1216        );
1217    }
1218
1219    #[test]
1220    fn digest_wrong_password_is_unauthorized() {
1221        let v = Verifier::new(
1222            Credentials::Digest {
1223                username: "admin".into(),
1224                password: "12345".into(),
1225            },
1226            REALM,
1227        );
1228        let ctx = RequestContext::new("DESCRIBE", "rtsp://cam/live");
1229        let header = respond(&v.challenge(), &ctx, Credentials::new("admin", "WRONG")).unwrap();
1230        assert_eq!(
1231            verify_auth(&v, Some(&header), "DESCRIBE", "rtsp://cam/live"),
1232            AuthResult::Unauthorized
1233        );
1234    }
1235
1236    #[test]
1237    fn digest_mismatched_request_uri_is_unauthorized() {
1238        // A digest response computed for one URI must not verify against a
1239        // different URI the caller passes to `verify` (RFC 7616 SHOULD-check
1240        // that the header's `uri` matches the actual request).
1241        let v = Verifier::new(
1242            Credentials::Digest {
1243                username: "admin".into(),
1244                password: "12345".into(),
1245            },
1246            REALM,
1247        );
1248        let ctx = RequestContext::new("DESCRIBE", "rtsp://cam/live");
1249        let header = respond(&v.challenge(), &ctx, Credentials::new("admin", "12345")).unwrap();
1250        assert_eq!(
1251            verify_auth(&v, Some(&header), "DESCRIBE", "rtsp://cam/OTHER"),
1252            AuthResult::Unauthorized
1253        );
1254    }
1255
1256    /// RFC 7230 §5.3.2: a client may legally answer a Digest challenge using
1257    /// the absolute-form request-target instead of origin-form — e.g.
1258    /// multimux's outbound HTTP client (`source::http_auth::authenticated_get`,
1259    /// issue #724) sends the absolute URL as `uri`. The server here only ever
1260    /// sees the request's path (origin-form) as its own request `uri`; RFC
1261    /// 7616 §3.4.1 permits this because HA2 is computed over the CLIENT's
1262    /// claimed `uri`, and the SHOULD uri-match ([`digest_uri_matches`])
1263    /// accepts either representation of the same target. Built via the real
1264    /// `respond()` round-trip (not a rigged expected string) so this exercises
1265    /// the true client computation.
1266    #[test]
1267    fn digest_accepts_absolute_form_client_uri_matching_request_path() {
1268        let v = Verifier::new(
1269            Credentials::Digest {
1270                username: "admin".into(),
1271                password: "12345".into(),
1272            },
1273            REALM,
1274        );
1275        let client_ctx = RequestContext::new("GET", "http://cam.local/stream/media.m3u8");
1276        let header = respond(
1277            &v.challenge(),
1278            &client_ctx,
1279            Credentials::new("admin", "12345"),
1280        )
1281        .unwrap();
1282        assert!(
1283            header.contains("uri=\"http://cam.local/stream/media.m3u8\""),
1284            "expected the client to hash the absolute-form uri, got: {header}"
1285        );
1286        assert_eq!(
1287            verify_auth(&v, Some(&header), "GET", "/stream/media.m3u8"),
1288            AuthResult::Ok
1289        );
1290    }
1291
1292    /// Regression/mutation guard: an absolute-form `uri` whose PATH is
1293    /// genuinely different from the request must still be rejected — the
1294    /// SHOULD uri-match is a real substitution guard, not a rubber stamp for
1295    /// any absolute-form uri. Note this also exercises the response-mismatch
1296    /// path independently of the match check: because HA2 is computed over
1297    /// the client's own claimed uri, the client here computes a
1298    /// self-consistent (but wrong-target) response, so a neutered
1299    /// `digest_uri_matches` (hardcoded `true`) would let this wrongly verify
1300    /// — this test must fail if that guard is ever dropped.
1301    #[test]
1302    fn digest_rejects_absolute_form_uri_with_wrong_path() {
1303        let v = Verifier::new(
1304            Credentials::Digest {
1305                username: "admin".into(),
1306                password: "12345".into(),
1307            },
1308            REALM,
1309        );
1310        let client_ctx = RequestContext::new("GET", "http://cam.local/other/path");
1311        let header = respond(
1312            &v.challenge(),
1313            &client_ctx,
1314            Credentials::new("admin", "12345"),
1315        )
1316        .unwrap();
1317        assert_eq!(
1318            verify_auth(&v, Some(&header), "GET", "/stream/media.m3u8"),
1319            AuthResult::Unauthorized
1320        );
1321    }
1322
1323    /// Same substitution guard, origin-form vs. origin-form (no scheme at
1324    /// all): a client claiming a different path outright must be rejected.
1325    #[test]
1326    fn digest_rejects_origin_form_uri_with_wrong_path() {
1327        let v = Verifier::new(
1328            Credentials::Digest {
1329                username: "admin".into(),
1330                password: "12345".into(),
1331            },
1332            REALM,
1333        );
1334        let client_ctx = RequestContext::new("GET", "/other/path");
1335        let header = respond(
1336            &v.challenge(),
1337            &client_ctx,
1338            Credentials::new("admin", "12345"),
1339        )
1340        .unwrap();
1341        assert_eq!(
1342            verify_auth(&v, Some(&header), "GET", "/stream/media.m3u8"),
1343            AuthResult::Unauthorized
1344        );
1345    }
1346
1347    #[test]
1348    fn digest_uri_matches_unit_cases() {
1349        // Origin-form, identical.
1350        assert!(digest_uri_matches("/a/b", "/a/b"));
1351        // Absolute-form whose path matches.
1352        assert!(digest_uri_matches("http://host/a/b", "/a/b"));
1353        assert!(digest_uri_matches("https://host:8080/a/b?q=1", "/a/b?q=1"));
1354        // Wrong path in either form.
1355        assert!(!digest_uri_matches("/a/c", "/a/b"));
1356        assert!(!digest_uri_matches("http://host/a/c", "/a/b"));
1357        // Not a suffix/prefix rubber stamp.
1358        assert!(!digest_uri_matches("http://host/x/a/b", "/a/b"));
1359        assert!(!digest_uri_matches("/a/b/extra", "/a/b"));
1360        // Absolute-form with no path at all never matches a non-empty path.
1361        assert!(!digest_uri_matches("http://host", "/a/b"));
1362    }
1363
1364    #[test]
1365    fn digest_uri_match_is_not_loosened_by_url_normalisation() {
1366        // The client hashed a spelling that is NOT the request-target, even
1367        // though the url crate would normalise it to one.
1368        assert!(!digest_uri_matches("http://h/a/../b", "/b"));
1369        assert!(!digest_uri_matches("http://h/%2e%2e/b", "/b"));
1370        assert!(!digest_uri_matches("http://h/./b", "/b"));
1371        assert!(!digest_uri_matches("HTTP://H/b", "/b"));
1372        assert!(!digest_uri_matches("http://h:80/b", "/b"));
1373        assert!(!digest_uri_matches("http://h", "/"));
1374        assert!(!digest_uri_matches("http://h/b#frag", "/b"));
1375        assert!(!digest_uri_matches("http://[::1/b", "/b"));
1376        // Normalised absolute-form still matches.
1377        assert!(digest_uri_matches("http://h/b?x=1&y=2", "/b?x=1&y=2"));
1378        assert!(digest_uri_matches("http://[2001:db8::1]:8080/b", "/b"));
1379        assert!(digest_uri_matches("rtsp://cam:554/live/ch1", "/live/ch1"));
1380        assert!(digest_uri_matches(
1381            "rtsp://cam/live/ch1",
1382            "rtsp://cam/live/ch1"
1383        ));
1384    }
1385
1386    #[test]
1387    fn bearer_wrong_token_is_unauthorized() {
1388        let v = Verifier::new(Credentials::bearer("right-token"), REALM);
1389        let header = respond(
1390            &v.challenge(),
1391            &RequestContext::new("GET", "/stream"),
1392            Credentials::bearer("wrong-token"),
1393        )
1394        .unwrap();
1395        assert_eq!(
1396            verify_auth(&v, Some(&header), "GET", "/stream"),
1397            AuthResult::Unauthorized
1398        );
1399    }
1400
1401    #[test]
1402    fn missing_authorization_header_is_unauthorized() {
1403        let v = Verifier::new(Credentials::bearer("tok"), REALM);
1404        assert_eq!(
1405            verify_auth(&v, None, "GET", "/stream"),
1406            AuthResult::Unauthorized
1407        );
1408    }
1409
1410    #[test]
1411    fn wrong_scheme_header_is_unauthorized() {
1412        // A Basic-configured verifier must reject a Bearer-shaped header
1413        // (and vice versa) rather than mis-parsing it as a match.
1414        let v = Verifier::new(
1415            Credentials::Basic {
1416                username: "admin".into(),
1417                password: "12345".into(),
1418            },
1419            REALM,
1420        );
1421        assert_eq!(
1422            verify_auth(&v, Some("Bearer sometoken"), "GET", "/stream"),
1423            AuthResult::Unauthorized
1424        );
1425    }
1426
1427    // --- Forwarded (reverse-proxy forwarded-auth, issue #663 extensibility
1428    // wave part 1) ---
1429
1430    #[test]
1431    fn forwarded_challenge_is_bare_scheme_name() {
1432        let v = Verifier::forwarded("X-Forwarded-User", Some("X-Forwarded-For".to_string()));
1433        assert_eq!(v.challenge(), "Forwarded");
1434    }
1435
1436    /// Biting test: a request carrying the configured user header (non-empty)
1437    /// must verify `Ok` — this is the whole trust mechanism, no secret is
1438    /// ever compared.
1439    #[test]
1440    fn forwarded_with_user_header_present_is_ok() {
1441        let v = Verifier::forwarded("X-Forwarded-User", Some("X-Forwarded-For".to_string()));
1442        let headers: &[(&str, &str)] = &[("X-Forwarded-User", "alice")];
1443        let ctx = RequestContext::new("GET", "/stream").with_headers(headers);
1444        assert_eq!(v.verify(&ctx), AuthResult::Ok);
1445    }
1446
1447    /// Biting test: a request with no user header at all must `Unauthorized`
1448    /// — the whole point of the scheme is that only a trusted proxy having
1449    /// authenticated the caller sets it.
1450    #[test]
1451    fn forwarded_without_user_header_is_unauthorized() {
1452        let v = Verifier::forwarded("X-Forwarded-User", Some("X-Forwarded-For".to_string()));
1453        let ctx = RequestContext::new("GET", "/stream");
1454        assert_eq!(v.verify(&ctx), AuthResult::Unauthorized);
1455    }
1456
1457    /// An empty (but present) user header must not count as authenticated —
1458    /// otherwise a proxy bug forwarding an empty header would silently grant
1459    /// access.
1460    #[test]
1461    fn forwarded_with_empty_user_header_is_unauthorized() {
1462        let v = Verifier::forwarded("X-Forwarded-User", Some("X-Forwarded-For".to_string()));
1463        let headers: &[(&str, &str)] = &[("X-Forwarded-User", "")];
1464        let ctx = RequestContext::new("GET", "/stream").with_headers(headers);
1465        assert_eq!(v.verify(&ctx), AuthResult::Unauthorized);
1466    }
1467
1468    /// The user-header lookup is case-insensitive, matching real HTTP header
1469    /// semantics (RFC 7230 §3.2) rather than a literal-string match.
1470    #[test]
1471    fn forwarded_user_header_lookup_is_case_insensitive() {
1472        let v = Verifier::forwarded("X-Forwarded-User", None);
1473        let headers: &[(&str, &str)] = &[("x-forwarded-user", "alice")];
1474        let ctx = RequestContext::new("GET", "/stream").with_headers(headers);
1475        assert_eq!(v.verify(&ctx), AuthResult::Ok);
1476    }
1477
1478    /// Biting test: `forwarded_for` reads the configured header's value back
1479    /// out of the request context — the mechanism the origin middleware uses
1480    /// to surface the proxy-forwarded client IP to tracing.
1481    #[test]
1482    fn forwarded_for_reads_configured_header() {
1483        let v = Verifier::forwarded("X-Forwarded-User", Some("X-Forwarded-For".to_string()));
1484        let headers: &[(&str, &str)] = &[
1485            ("X-Forwarded-User", "alice"),
1486            ("X-Forwarded-For", "203.0.113.7"),
1487        ];
1488        let ctx = RequestContext::new("GET", "/stream").with_headers(headers);
1489        assert_eq!(v.forwarded_for(&ctx), Some("203.0.113.7"));
1490    }
1491
1492    /// With no `forwarded_for_header` configured, `forwarded_for` is always
1493    /// `None`, even if an `X-Forwarded-For` header happens to be present.
1494    #[test]
1495    fn forwarded_for_is_none_when_not_configured() {
1496        let v = Verifier::forwarded("X-Forwarded-User", None);
1497        let headers: &[(&str, &str)] = &[("X-Forwarded-For", "203.0.113.7")];
1498        let ctx = RequestContext::new("GET", "/stream").with_headers(headers);
1499        assert_eq!(v.forwarded_for(&ctx), None);
1500    }
1501
1502    /// `forwarded_for` is always `None` for a non-`Forwarded` verifier, even
1503    /// if the request happens to carry an `X-Forwarded-For` header.
1504    #[test]
1505    fn forwarded_for_is_none_for_non_forwarded_verifier() {
1506        let v = Verifier::new(Credentials::bearer("tok"), REALM);
1507        let headers: &[(&str, &str)] = &[("X-Forwarded-For", "203.0.113.7")];
1508        let ctx = RequestContext::new("GET", "/stream").with_headers(headers);
1509        assert_eq!(v.forwarded_for(&ctx), None);
1510    }
1511
1512    /// Debug must never need to redact anything for `Forwarded` (no secret is
1513    /// involved), but must still not panic and must name the scheme.
1514    #[test]
1515    fn forwarded_debug_names_scheme() {
1516        let v = Verifier::forwarded("X-Forwarded-User", Some("X-Forwarded-For".to_string()));
1517        let debug = format!("{v:?}");
1518        assert!(debug.contains("Forwarded"), "debug: {debug}");
1519    }
1520
1521    // --- SignedUrl (issue #747) — the scheme's own biting tests (sign/
1522    // verify round trip, cross-route replay, expiry, key rotation, IP
1523    // scoping, malformed input) live in `crate::signed_url`'s test module;
1524    // these just cover `Verifier`'s own surface (`challenge`/`Debug`).
1525
1526    #[test]
1527    fn signed_url_challenge_is_bare_scheme_name() {
1528        let keys = SignedUrlKeySet::new([("k".to_string(), vec![0u8; 32])]).unwrap();
1529        let v = Verifier::signed_url(keys);
1530        assert_eq!(v.challenge(), "SignedUrl");
1531    }
1532
1533    #[test]
1534    fn signed_url_debug_names_scheme_and_never_leaks_secret() {
1535        let secret = b"super-secret-32-byte-hmac-key!!!".to_vec();
1536        let keys = SignedUrlKeySet::new([("k".to_string(), secret.clone())]).unwrap();
1537        let v = Verifier::signed_url(keys);
1538        let debug = format!("{v:?}");
1539        assert!(debug.contains("SignedUrl"), "debug: {debug}");
1540        assert!(
1541            !debug.contains(std::str::from_utf8(&secret).unwrap()),
1542            "debug: {debug}"
1543        );
1544    }
1545
1546    #[test]
1547    fn constant_time_eq_matches_naive_equality() {
1548        assert!(constant_time_eq(b"same", b"same"));
1549        assert!(!constant_time_eq(b"same", b"diff"));
1550        assert!(!constant_time_eq(b"short", b"longer-string"));
1551        assert!(constant_time_eq(b"", b""));
1552    }
1553
1554    // Regression: an oversized Digest `Authorization` header (way more
1555    // `key=value` fields than any real client sends) must be rejected
1556    // outright rather than parsed into an unbounded `HashMap` — and must
1557    // never panic. Must FAIL if the `MAX_DIGEST_FIELDS` cap in
1558    // `verify_digest` is ever removed.
1559    #[test]
1560    fn oversized_digest_header_is_rejected_not_parsed() {
1561        let v = Verifier::new(
1562            Credentials::Digest {
1563                username: "admin".into(),
1564                password: "12345".into(),
1565            },
1566            REALM,
1567        );
1568        let mut huge = String::from("Digest ");
1569        for i in 0..(MAX_DIGEST_FIELDS + 1) {
1570            if i > 0 {
1571                huge.push(',');
1572            }
1573            huge.push_str(&format!("k{i}=\"v{i}\""));
1574        }
1575        assert_eq!(
1576            verify_auth(&v, Some(&huge), "DESCRIBE", "rtsp://cam/live"),
1577            AuthResult::Unauthorized,
1578            "oversized Digest header must not be accepted"
1579        );
1580    }
1581
1582    fn digest_verifier_at(now: std::sync::Arc<std::sync::atomic::AtomicU64>) -> Verifier {
1583        Verifier::new(
1584            Credentials::Digest {
1585                username: "admin".into(),
1586                password: "12345".into(),
1587            },
1588            REALM,
1589        )
1590        .with_clock(move || {
1591            UNIX_EPOCH + Duration::from_secs(now.load(std::sync::atomic::Ordering::SeqCst))
1592        })
1593    }
1594
1595    fn digest_header(challenge: &str, uri: &str) -> String {
1596        respond(
1597            challenge,
1598            &RequestContext::new("GET", uri),
1599            Credentials::new("admin", "12345"),
1600        )
1601        .unwrap()
1602    }
1603
1604    /// Rewrites the `nc` and `response` fields of a Digest header as a client
1605    /// would for its `nc`-th request under the same nonce + cnonce.
1606    fn with_nc(header: &str, nc: u32, uri: &str) -> String {
1607        let field = |name: &str| {
1608            let start = header.find(&format!("{name}=")).unwrap() + name.len() + 1;
1609            let rest = &header[start..];
1610            let rest = rest.trim_start_matches('"');
1611            let end = rest.find(['"', ',']).unwrap_or(rest.len());
1612            rest[..end].to_string()
1613        };
1614        let (nonce, cnonce, old_nc, old_resp) = (
1615            field("nonce"),
1616            field("cnonce"),
1617            field("nc"),
1618            field("response"),
1619        );
1620        let nc = format!("{nc:08x}");
1621        let ha1 = md5_hex(format!("admin:{REALM}:12345"));
1622        let ha2 = md5_hex(format!("GET:{uri}"));
1623        let resp = md5_hex(format!("{ha1}:{nonce}:{nc}:{cnonce}:auth:{ha2}"));
1624        header
1625            .replace(&format!("nc={old_nc}"), &format!("nc={nc}"))
1626            .replace(&old_resp, &resp)
1627    }
1628
1629    // --- r09-W2: scheme token must be matched case-insensitively (RFC 7235
1630    // §2.1: auth-scheme is a `token`) ---
1631
1632    #[test]
1633    fn basic_scheme_is_matched_case_insensitively() {
1634        let v = Verifier::new(
1635            Credentials::Basic {
1636                username: "admin".into(),
1637                password: "12345".into(),
1638            },
1639            REALM,
1640        );
1641        let header = respond(
1642            &v.challenge(),
1643            &RequestContext::new("GET", "/stream"),
1644            Credentials::new("admin", "12345"),
1645        )
1646        .unwrap();
1647        assert!(header.starts_with("Basic "));
1648        let lower = header.replacen("Basic ", "basic ", 1);
1649        assert_eq!(
1650            verify_auth(&v, Some(&lower), "GET", "/stream"),
1651            AuthResult::Ok,
1652            "lower-case scheme token must verify identically to the canonical case"
1653        );
1654    }
1655
1656    #[test]
1657    fn bearer_scheme_is_matched_case_insensitively() {
1658        let v = Verifier::new(Credentials::bearer("mytoken123"), REALM);
1659        let header = respond(
1660            &v.challenge(),
1661            &RequestContext::new("GET", "/stream"),
1662            Credentials::bearer("mytoken123"),
1663        )
1664        .unwrap();
1665        assert!(header.starts_with("Bearer "));
1666        let mixed = header.replacen("Bearer ", "BEARER ", 1);
1667        assert_eq!(
1668            verify_auth(&v, Some(&mixed), "GET", "/stream"),
1669            AuthResult::Ok,
1670            "mixed-case scheme token must verify identically to the canonical case"
1671        );
1672    }
1673
1674    #[test]
1675    fn digest_scheme_is_matched_case_insensitively() {
1676        let now = std::sync::Arc::new(std::sync::atomic::AtomicU64::new(1_000_000));
1677        let v = digest_verifier_at(now);
1678        let uri = "/stream/index.m3u8";
1679        let header = digest_header(&v.challenge(), uri);
1680        assert!(header.starts_with("Digest "));
1681        let lower = header.replacen("Digest ", "digest ", 1);
1682        assert_eq!(
1683            verify_auth(&v, Some(&lower), "GET", uri),
1684            AuthResult::Ok,
1685            "lower-case scheme token must verify identically to the canonical case"
1686        );
1687    }
1688
1689    /// r09-W2: a quoted field value containing a literal comma must not be
1690    /// split into two bogus fields. Uses the `uri` field specifically (not
1691    /// `realm`) because `uri` is checked for exact equivalence against the
1692    /// request ([`digest_uri_matches`]) — the naive `header.split(',')` this
1693    /// replaced would truncate `uri` at the embedded comma, and the
1694    /// truncated value provably does NOT match the (untruncated)
1695    /// `request_uri`, so the defect bites rather than passing by
1696    /// coincidence.
1697    #[test]
1698    fn digest_field_parser_respects_quoted_commas() {
1699        let now = std::sync::Arc::new(std::sync::atomic::AtomicU64::new(1_000_000));
1700        let v = digest_verifier_at(now);
1701        let uri = "/stream/a,b/index.m3u8";
1702        let header = digest_header(&v.challenge(), uri);
1703        assert!(header.contains(&format!("uri=\"{uri}\"")));
1704        assert_eq!(
1705            verify_auth(&v, Some(&header), "GET", uri),
1706            AuthResult::Ok,
1707            "a comma inside the quoted uri field must not fragment the field list"
1708        );
1709    }
1710
1711    // --- r09-W3: qop/algorithm must be validated, not merely hashed ---
1712
1713    /// Pre-fix, `check_digest` never inspected the `algorithm` field's
1714    /// value — it hashed with plain MD5 regardless, so a request that
1715    /// *claims* an algorithm this server does not implement (and for which
1716    /// the real RFC 7616 HA1 construction differs, e.g. `MD5-sess`) was
1717    /// silently accepted anyway. Splicing a false `algorithm=SHA-256` label
1718    /// onto an otherwise-valid MD5-computed header must now be rejected.
1719    #[test]
1720    fn digest_rejects_unsupported_algorithm_label() {
1721        let now = std::sync::Arc::new(std::sync::atomic::AtomicU64::new(1_000_000));
1722        let v = digest_verifier_at(now);
1723        let uri = "/stream/index.m3u8";
1724        let header = digest_header(&v.challenge(), uri);
1725        assert!(header.contains("algorithm=MD5"));
1726        let relabeled = header.replacen("algorithm=MD5", "algorithm=SHA-256", 1);
1727        assert_eq!(
1728            verify_auth(&v, Some(&relabeled), "GET", uri),
1729            AuthResult::Unauthorized,
1730            "a header claiming an unimplemented algorithm must not verify"
1731        );
1732    }
1733
1734    /// Same defect for `qop`: pre-fix, the `qop` field's value was hashed
1735    /// verbatim into the response formula without checking it was `auth` —
1736    /// this server only ever implements the `qop=auth` construction (no
1737    /// `auth-int` entity-body hashing). A client that labels its request
1738    /// `qop=auth-int` but computes `response` with this server's plain
1739    /// `qop=auth` formula (substituting the literal string `auth-int` for
1740    /// the `qop` slot, since that is all the formula does with the field)
1741    /// must be rejected — recomputing `response` to match is what makes
1742    /// this bite instead of merely observing an incidental hash mismatch.
1743    #[test]
1744    fn digest_rejects_non_auth_qop() {
1745        let now = std::sync::Arc::new(std::sync::atomic::AtomicU64::new(1_000_000));
1746        let v = digest_verifier_at(now);
1747        let uri = "/stream/index.m3u8";
1748        let header = digest_header(&v.challenge(), uri);
1749        let field = |name: &str| {
1750            let start = header.find(&format!("{name}=")).unwrap() + name.len() + 1;
1751            let rest = &header[start..];
1752            let rest = rest.trim_start_matches('"');
1753            let end = rest.find(['"', ',']).unwrap_or(rest.len());
1754            rest[..end].to_string()
1755        };
1756        let (nonce, cnonce, nc, response) = (
1757            field("nonce"),
1758            field("cnonce"),
1759            field("nc"),
1760            field("response"),
1761        );
1762        let ha1 = md5_hex(format!("admin:{REALM}:12345"));
1763        let ha2 = md5_hex(format!("GET:{uri}"));
1764        let recomputed = md5_hex(format!("{ha1}:{nonce}:{nc}:{cnonce}:auth-int:{ha2}"));
1765        let relabeled = header
1766            .replacen("qop=auth", "qop=auth-int", 1)
1767            .replace(&response, &recomputed);
1768        assert_eq!(
1769            verify_auth(&v, Some(&relabeled), "GET", uri),
1770            AuthResult::Unauthorized,
1771            "a header claiming qop=auth-int must not verify even when response \
1772             was (mis)computed with the qop=auth formula"
1773        );
1774    }
1775
1776    #[test]
1777    fn digest_increasing_nc_is_accepted_and_repeated_nc_rejected() {
1778        let now = std::sync::Arc::new(std::sync::atomic::AtomicU64::new(1_000_000));
1779        let v = digest_verifier_at(now);
1780        let uri = "/stream/index.m3u8";
1781        let first = digest_header(&v.challenge(), uri);
1782        assert_eq!(verify_auth(&v, Some(&first), "GET", uri), AuthResult::Ok);
1783        let second = with_nc(&first, 2, uri);
1784        assert_eq!(verify_auth(&v, Some(&second), "GET", uri), AuthResult::Ok);
1785        let third = with_nc(&first, 3, uri);
1786        assert_eq!(verify_auth(&v, Some(&third), "GET", uri), AuthResult::Ok);
1787        assert_eq!(
1788            verify_auth(&v, Some(&second), "GET", uri),
1789            AuthResult::Unauthorized,
1790            "an nc already accepted must be rejected"
1791        );
1792    }
1793
1794    #[test]
1795    fn digest_expired_nonce_is_rejected_with_stale_challenge() {
1796        let now = std::sync::Arc::new(std::sync::atomic::AtomicU64::new(1_000_000));
1797        let v = digest_verifier_at(now.clone());
1798        let uri = "/stream/index.m3u8";
1799        let header = digest_header(&v.challenge(), uri);
1800        now.fetch_add(
1801            DIGEST_NONCE_LIFETIME.as_secs(),
1802            std::sync::atomic::Ordering::SeqCst,
1803        );
1804        assert_eq!(
1805            verify_auth(&v, Some(&header), "GET", uri),
1806            AuthResult::Unauthorized
1807        );
1808        let headers = [("authorization", header.as_str())];
1809        let ctx = RequestContext::new("GET", uri).with_headers(&headers);
1810        let challenge = v.challenge_for(&ctx);
1811        assert!(challenge.ends_with(", stale=true"), "got: {challenge}");
1812        // The fresh nonce from that challenge verifies.
1813        let retry = digest_header(&challenge, uri);
1814        assert_eq!(verify_auth(&v, Some(&retry), "GET", uri), AuthResult::Ok);
1815        // A wrong password on an expired nonce is not stale.
1816        let wrong = respond(
1817            &v.challenge(),
1818            &RequestContext::new("GET", uri),
1819            Credentials::new("admin", "WRONG"),
1820        )
1821        .unwrap();
1822        now.fetch_add(
1823            DIGEST_NONCE_LIFETIME.as_secs(),
1824            std::sync::atomic::Ordering::SeqCst,
1825        );
1826        let headers = [("authorization", wrong.as_str())];
1827        let ctx = RequestContext::new("GET", uri).with_headers(&headers);
1828        assert!(!v.challenge_for(&ctx).contains("stale"));
1829    }
1830
1831    #[test]
1832    fn digest_nonce_with_wrong_mac_is_rejected() {
1833        let v = Verifier::new(
1834            Credentials::Digest {
1835                username: "admin".into(),
1836                password: "12345".into(),
1837            },
1838            REALM,
1839        );
1840        let uri = "/stream/index.m3u8";
1841        let challenge = v.challenge();
1842        let start = challenge.find("nonce=\"").unwrap() + "nonce=\"".len();
1843        let nonce = &challenge[start..start + NONCE_LEN * 2];
1844        // Flip the last hex digit of the MAC, keeping the timestamp + salt.
1845        let last = nonce.as_bytes()[nonce.len() - 1];
1846        let flipped = if last == b'0' { '1' } else { '0' };
1847        let forged_nonce = format!("{}{flipped}", &nonce[..nonce.len() - 1]);
1848        let forged = challenge.replace(nonce, &forged_nonce);
1849        let header = digest_header(&forged, uri);
1850        assert!(header.contains(&forged_nonce));
1851        assert_eq!(
1852            verify_auth(&v, Some(&header), "GET", uri),
1853            AuthResult::Unauthorized
1854        );
1855        // A nonce from a different verifier (different secret) is rejected too.
1856        let other = Verifier::new(
1857            Credentials::Digest {
1858                username: "admin".into(),
1859                password: "12345".into(),
1860            },
1861            REALM,
1862        );
1863        let header = digest_header(&other.challenge(), uri);
1864        assert_eq!(
1865            verify_auth(&v, Some(&header), "GET", uri),
1866            AuthResult::Unauthorized
1867        );
1868    }
1869
1870    #[test]
1871    fn digest_out_of_order_nc_within_window_is_accepted_once() {
1872        let now = std::sync::Arc::new(std::sync::atomic::AtomicU64::new(1_000_000));
1873        let v = digest_verifier_at(now);
1874        let uri = "/stream/part.m4s";
1875        let first = digest_header(&v.challenge(), uri);
1876        assert_eq!(verify_auth(&v, Some(&first), "GET", uri), AuthResult::Ok);
1877        let nc = |n| with_nc(&first, n, uri);
1878        // 6 overtakes 5 in flight: both are fresh.
1879        assert_eq!(verify_auth(&v, Some(&nc(6)), "GET", uri), AuthResult::Ok);
1880        assert_eq!(verify_auth(&v, Some(&nc(5)), "GET", uri), AuthResult::Ok);
1881        assert_eq!(
1882            verify_auth(&v, Some(&nc(5)), "GET", uri),
1883            AuthResult::Unauthorized,
1884            "nc 5 a second time is a replay"
1885        );
1886        assert_eq!(
1887            verify_auth(&v, Some(&nc(6)), "GET", uri),
1888            AuthResult::Unauthorized
1889        );
1890        // Jump ahead; the oldest value the window still holds is accepted,
1891        // one further below is refused.
1892        let top = 200;
1893        assert_eq!(verify_auth(&v, Some(&nc(top)), "GET", uri), AuthResult::Ok);
1894        assert_eq!(
1895            verify_auth(&v, Some(&nc(top - NC_WINDOW - 1)), "GET", uri),
1896            AuthResult::Unauthorized,
1897            "an nc 65 below the highest is outside the window"
1898        );
1899        assert_eq!(
1900            verify_auth(&v, Some(&nc(top - NC_WINDOW)), "GET", uri),
1901            AuthResult::Ok
1902        );
1903    }
1904
1905    #[test]
1906    fn digest_nc_table_evicts_least_recently_used_pair() {
1907        let now = std::sync::Arc::new(std::sync::atomic::AtomicU64::new(1_000_000));
1908        let v = digest_verifier_at(now).with_digest_nc_capacity(4);
1909        let uri = "/s";
1910        let headers: Vec<String> = (0..4).map(|_| digest_header(&v.challenge(), uri)).collect();
1911        for h in &headers {
1912            assert_eq!(verify_auth(&v, Some(h), "GET", uri), AuthResult::Ok);
1913        }
1914        // Pair 0 is the oldest issued but stays in use.
1915        assert_eq!(
1916            verify_auth(&v, Some(&with_nc(&headers[0], 2, uri)), "GET", uri),
1917            AuthResult::Ok
1918        );
1919        let newcomer = digest_header(&v.challenge(), uri);
1920        assert_eq!(verify_auth(&v, Some(&newcomer), "GET", uri), AuthResult::Ok);
1921        let VerifierScheme::Digest { nonces, .. } = &v.scheme else {
1922            unreachable!()
1923        };
1924        assert_eq!(nonces.table().entries.len(), 4);
1925        // Pair 0 was not the one dropped: its replay window is intact.
1926        assert_eq!(
1927            verify_auth(&v, Some(&with_nc(&headers[0], 2, uri)), "GET", uri),
1928            AuthResult::Unauthorized
1929        );
1930        assert_eq!(
1931            verify_auth(&v, Some(&with_nc(&headers[0], 3, uri)), "GET", uri),
1932            AuthResult::Ok
1933        );
1934        // Pair 1 (least recently used) was dropped: its replay is refused and
1935        // answered as stale so the client picks up a fresh nonce.
1936        let replay = with_nc(&headers[1], 2, uri);
1937        assert_eq!(
1938            verify_auth(&v, Some(&replay), "GET", uri),
1939            AuthResult::Unauthorized
1940        );
1941        let hdrs = [("authorization", replay.as_str())];
1942        let ctx = RequestContext::new("GET", uri).with_headers(&hdrs);
1943        let challenge = v.challenge_for(&ctx);
1944        assert!(challenge.ends_with(", stale=true"), "got: {challenge}");
1945        let retry = digest_header(&challenge, uri);
1946        assert_eq!(verify_auth(&v, Some(&retry), "GET", uri), AuthResult::Ok);
1947    }
1948
1949    #[test]
1950    fn digest_clock_stepping_back_does_not_backdate_nonces() {
1951        let start = 1_000_000;
1952        let now = std::sync::Arc::new(std::sync::atomic::AtomicU64::new(start));
1953        let v = digest_verifier_at(now.clone());
1954        let uri = "/s";
1955        let before = digest_header(&v.challenge(), uri);
1956        // Step the clock back by more than a nonce lifetime.
1957        now.store(
1958            start - 2 * DIGEST_NONCE_LIFETIME.as_secs(),
1959            std::sync::atomic::Ordering::SeqCst,
1960        );
1961        let during = digest_header(&v.challenge(), uri);
1962        assert_eq!(verify_auth(&v, Some(&during), "GET", uri), AuthResult::Ok);
1963        assert_eq!(verify_auth(&v, Some(&before), "GET", uri), AuthResult::Ok);
1964        // When the clock returns, the nonce issued meanwhile is still young.
1965        now.store(
1966            start + DIGEST_NONCE_LIFETIME.as_secs() - 1,
1967            std::sync::atomic::Ordering::SeqCst,
1968        );
1969        let again = with_nc(&during, 2, uri);
1970        assert_eq!(verify_auth(&v, Some(&again), "GET", uri), AuthResult::Ok);
1971    }
1972
1973    #[test]
1974    fn digest_replayed_request_is_rejected() {
1975        let v = Verifier::new(
1976            Credentials::Digest {
1977                username: "admin".into(),
1978                password: "12345".into(),
1979            },
1980            REALM,
1981        );
1982        let ctx = RequestContext::new("GET", "/stream/index.m3u8");
1983        let header = respond(&v.challenge(), &ctx, Credentials::new("admin", "12345")).unwrap();
1984        assert_eq!(
1985            verify_auth(&v, Some(&header), "GET", "/stream/index.m3u8"),
1986            AuthResult::Ok
1987        );
1988        assert_eq!(
1989            verify_auth(&v, Some(&header), "GET", "/stream/index.m3u8"),
1990            AuthResult::Unauthorized,
1991            "same nonce + nc + cnonce must not verify twice"
1992        );
1993    }
1994
1995    #[test]
1996    fn debug_never_leaks_password_or_token() {
1997        let v = Verifier::new(
1998            Credentials::Digest {
1999                username: "admin".into(),
2000                password: "supersecret".into(),
2001            },
2002            REALM,
2003        );
2004        let debug = format!("{v:?}");
2005        assert!(!debug.contains("supersecret"), "debug: {debug}");
2006
2007        let v = Verifier::new(Credentials::bearer("topsecrettoken"), REALM);
2008        let debug = format!("{v:?}");
2009        assert!(!debug.contains("topsecrettoken"), "debug: {debug}");
2010    }
2011
2012    /// RFC 2617 §3.5 worked example: pins the Digest MD5 chain.
2013    #[test]
2014    fn digest_md5_matches_rfc2617_worked_example() {
2015        use md5::{Digest as _, Md5};
2016        let ha1 = hex_of(Md5::digest(b"Mufasa:testrealm@host.com:Circle Of Life"));
2017        let ha2 = hex_of(Md5::digest(b"GET:/dir/index.html"));
2018        let resp = hex_of(Md5::digest(
2019            format!("{ha1}:dcd98b7102dd2f0e8b11d0f600bfb0c093:00000001:0a4f113b:auth:{ha2}")
2020                .as_bytes(),
2021        ));
2022        assert_eq!(resp, "6629fae49393a05397450978507c4ef1");
2023    }
2024
2025    fn hex_of(bytes: impl AsRef<[u8]>) -> String {
2026        bytes.as_ref().iter().map(|b| format!("{b:02x}")).collect()
2027    }
2028
2029    /// RFC 7617 §2 example: pins Basic base64 encode/decode incl. padding.
2030    #[test]
2031    fn basic_credentials_base64_matches_rfc7617_example() {
2032        use base64::Engine as _;
2033        let enc = base64::engine::general_purpose::STANDARD.encode(b"Aladdin:open sesame");
2034        assert_eq!(enc, "QWxhZGRpbjpvcGVuIHNlc2FtZQ==");
2035        let dec = base64::engine::general_purpose::STANDARD
2036            .decode("QWxhZGRpbjpvcGVuIHNlc2FtZQ==")
2037            .unwrap();
2038        assert_eq!(dec, b"Aladdin:open sesame");
2039    }
2040
2041    /// Production-path pin: the RFC 2617 §3.5 worked example through the
2042    /// crate's own `md5_hex` (what `check_digest` computes HA1/HA2/response
2043    /// with) and `constant_time_eq` (what it compares with). `check_digest`
2044    /// itself also needs a nonce this `DigestNonces` minted, which the RFC's
2045    /// fixed nonce is not, so the chain is driven through those two
2046    /// production helpers instead.
2047    #[test]
2048    fn production_digest_chain_matches_rfc2617_and_rejects_altered() {
2049        let ha1 = md5_hex("Mufasa:testrealm@host.com:Circle Of Life".to_string());
2050        let ha2 = md5_hex("GET:/dir/index.html".to_string());
2051        let expected = md5_hex(format!(
2052            "{ha1}:dcd98b7102dd2f0e8b11d0f600bfb0c093:00000001:0a4f113b:auth:{ha2}"
2053        ));
2054        let rfc = "6629fae49393a05397450978507c4ef1";
2055        assert!(constant_time_eq(expected.as_bytes(), rfc.as_bytes()));
2056        // One character altered: must not compare equal.
2057        let altered = "6629fae49393a05397450978507c4ef2";
2058        assert!(!constant_time_eq(expected.as_bytes(), altered.as_bytes()));
2059    }
2060
2061    /// Production-path pin: RFC 7617 §2 (`Aladdin:open sesame`) through
2062    /// `verify_basic`, the function `Verifier::verify` dispatches Basic to.
2063    #[test]
2064    fn production_basic_verify_matches_rfc7617_and_rejects_altered() {
2065        let ok = "Basic QWxhZGRpbjpvcGVuIHNlc2FtZQ==";
2066        assert!(verify_basic(ok, "Aladdin", "open sesame"));
2067        // One character altered in the payload (`Q` -> `R`).
2068        let altered = "Basic RWxhZGRpbjpvcGVuIHNlc2FtZQ==";
2069        assert!(!verify_basic(altered, "Aladdin", "open sesame"));
2070        assert!(!verify_basic(ok, "Aladdin", "open sesamf"));
2071    }
2072}