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