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