Skip to main content

broadcast_auth/
signed_url.rs

1//! HMAC-signed URL access control (issue #747) — CDN-style, short-lived,
2//! tamper-proof query-string tokens that gate a media egress route without
3//! the caller carrying a credential header at all.
4//!
5//! # Wire form
6//!
7//! Query parameters on the media URL:
8//!
9//! ```text
10//! ?exp=<unix-seconds>&kid=<key-id>&sig=<base64url-nopad>[&ip=<addr>]
11//! ```
12//!
13//! The query is `application/x-www-form-urlencoded`: [`SignedUrlKeySet::sign`]
14//! percent-encodes every value (a `kid` with `&`, `=`, `%`, `+` or a space, and
15//! an IPv6 `ip=2001%3Adb8%3A%3A1`), and verification percent-decodes them, so a
16//! value can never smuggle in an extra parameter. The first occurrence of a key
17//! wins; a bare key with no `=` has the empty value. Decoding is lossy
18//! (`form_urlencoded`): different spellings of a value can read the same
19//! (`%FF` and `%EF%BF%BD` both read as U+FFFD, `%65xp` reads as `exp`); only
20//! the signed fields (path, `exp`, `ip`) matter to the signature and the
21//! first-wins rule is unchanged, so this opens no bypass.
22//!
23//! - `exp` — an absolute Unix timestamp (seconds). The token is invalid once
24//!   `now > exp`; there is no clock-skew grace period, by design (the caller
25//!   sets the window when minting the token).
26//! - `kid` — selects which of [`SignedUrlKeySet`]'s configured secrets
27//!   verifies this token, so keys can rotate (multiple stay valid at once)
28//!   without invalidating URLs already handed out under an older key. An
29//!   unrecognised `kid` is a rejection, not a fallback to any other key.
30//! - `sig` — HMAC-SHA256 over the canonical string below, base64url-encoded
31//!   **without padding** (RFC 4648 §5, no trailing `=`).
32//! - `ip` — optional. When present, it must equal the connection's actual
33//!   peer address (compared as an [`core::net::IpAddr`], ignoring any port —
34//!   see [`Verifier::verify`](crate::Verifier::verify)'s signed-URL arm);
35//!   when absent, no IP check is made. Either way it is part of the signed
36//!   string, so an attacker can neither add nor strip it from a token they
37//!   didn't mint.
38//!
39//! # Canonical string
40//!
41//! The exact newline-separated bytes the HMAC is computed over, in this
42//! order:
43//!
44//! ```text
45//! <path>\n<exp>\n<ip-or-empty-string>
46//! ```
47//!
48//! `<path>` is the request path *without* its query string (whatever the
49//! caller passes to [`SignedUrlKeySet::sign`] — for a `broadcast_auth`-gated
50//! origin, the same pre-rewrite request path a
51//! [`crate::RequestContext::uri`] carries). **The path is always signed.**
52//! Without it, a token minted for one route would verify against every other
53//! route the same keyset gates — the entire point of naming the resource in
54//! the signature. `<ip-or-empty-string>` is the canonical [`core::net::IpAddr`]
55//! `Display` form (not the raw query-string bytes — see
56//! [`SignedUrlKeySet::sign`]'s docs for why), or an empty string when no `ip`
57//! is bound.
58//!
59//! # Security properties
60//!
61//! - **Constant-time compare**: the decoded `sig` bytes are compared against
62//!   the freshly recomputed HMAC via [`subtle::ConstantTimeEq`] — never a
63//!   short-circuiting `==` on a MAC (a classic timing oracle).
64//! - **Uniform rejection**: [`crate::Verifier::verify`] folds every failure
65//!   mode of this scheme (missing/unparseable `exp`, unknown `kid`,
66//!   missing/malformed `sig`, expired, wrong signature, wrong `ip`) into the
67//!   same [`crate::AuthResult::Unauthorized`] — nothing here tells a caller
68//!   *which* check failed.
69//! - **Minimum secret length**: [`SignedUrlKeySet::new`] rejects any secret
70//!   shorter than [`SignedUrlKeySet::MIN_SECRET_LEN`] at construction time
71//!   (a config/setup error), not per-request.
72//! - **No secret/signature logging**: neither this module nor
73//!   [`SignedUrlKeySet`]'s `Debug` impl ever renders a secret or a minted
74//!   signature.
75
76use core::net::IpAddr;
77use std::borrow::Cow;
78
79use base64::Engine;
80use base64::engine::general_purpose::URL_SAFE_NO_PAD;
81use hmac::{Hmac, KeyInit, Mac};
82use sha2::Sha256;
83use subtle::ConstantTimeEq;
84
85use crate::error::{Error, Result};
86use crate::request::RequestContext;
87
88/// One or more `(kid, secret)` HMAC keys valid for signed-URL verification —
89/// see the module docs for the wire form and canonical string, and
90/// [`crate::Verifier::signed_url`] for wiring one into a [`crate::Verifier`].
91///
92/// Multiple keys may be active simultaneously (key rotation, issue #747):
93/// verification looks the token's `kid` up in this set and rejects an
94/// unrecognised one rather than falling back to any other configured key.
95pub struct SignedUrlKeySet {
96    keys: Vec<(String, Vec<u8>)>,
97}
98
99impl SignedUrlKeySet {
100    /// The minimum accepted secret length, in bytes (32 — enough entropy that
101    /// brute-forcing the HMAC key is infeasible). Enforced by [`Self::new`]
102    /// at construction time, never per-request.
103    pub const MIN_SECRET_LEN: usize = 32;
104
105    /// Builds a keyset from `(kid, secret)` pairs.
106    ///
107    /// Rejects (before constructing anything) the first key whose `secret`
108    /// is shorter than [`Self::MIN_SECRET_LEN`] bytes, with
109    /// [`Error::SignedUrlKeyTooShort`] naming the offending `kid` and
110    /// lengths — this is a setup/config-time error, not something a request
111    /// can trigger.
112    ///
113    /// Duplicate `kid`s are not rejected; the internal lookup (used by both
114    /// [`Self::sign`] and verification) returns the *first* match, so a
115    /// duplicate is effectively shadowed rather than causing ambiguity.
116    pub fn new(keys: impl IntoIterator<Item = (String, Vec<u8>)>) -> Result<Self> {
117        let keys: Vec<(String, Vec<u8>)> = keys.into_iter().collect();
118        for (kid, secret) in &keys {
119            if secret.len() < Self::MIN_SECRET_LEN {
120                return Err(Error::SignedUrlKeyTooShort {
121                    kid: kid.clone(),
122                    min: Self::MIN_SECRET_LEN,
123                    actual: secret.len(),
124                });
125            }
126        }
127        Ok(SignedUrlKeySet { keys })
128    }
129
130    fn secret_for(&self, kid: &str) -> Option<&[u8]> {
131        self.keys
132            .iter()
133            .find(|(k, _)| k == kid)
134            .map(|(_, s)| s.as_slice())
135    }
136
137    /// Signing helper: mints the query-string portion of a signed URL for
138    /// `kid`/`path`/`exp`/`ip` — `?`-prefix and the resource path are the
139    /// caller's own to assemble (e.g. `format!("{path}?{query}")`), since
140    /// this returns just the `exp=...&kid=...&sig=...[&ip=...]` part.
141    ///
142    /// `path` must be exactly the request path the eventual request's
143    /// [`RequestContext::uri`] carries (sans query string) — see the module
144    /// docs' canonical-string rule. `ip`, if given, is rendered via
145    /// [`IpAddr`]'s canonical `Display` form in both the signed string and
146    /// the `ip` query parameter — not whatever string representation a
147    /// caller might otherwise have on hand — so that
148    /// [`crate::Verifier::verify`]'s re-parse-then-re-render of the `ip` it
149    /// receives back always reproduces the exact bytes that were signed.
150    ///
151    /// Fails with [`Error::UnknownSignedUrlKeyId`] if `kid` is not in this
152    /// keyset. This is the *signing* side (used by tests and by whatever
153    /// mints tokens for real clients) — [`crate::Verifier::verify`] never
154    /// returns this error; an unknown `kid` there is folded into the same
155    /// [`crate::AuthResult::Unauthorized`] as every other rejection reason.
156    pub fn sign(&self, kid: &str, path: &str, exp: u64, ip: Option<IpAddr>) -> Result<String> {
157        let secret = self
158            .secret_for(kid)
159            .ok_or_else(|| Error::UnknownSignedUrlKeyId(kid.to_string()))?;
160        let sig = URL_SAFE_NO_PAD.encode(hmac_sha256(secret, &canonical_string(path, exp, ip)));
161        let mut query = form_urlencoded::Serializer::new(String::new());
162        query.append_pair("exp", &exp.to_string());
163        query.append_pair("kid", kid);
164        query.append_pair("sig", &sig);
165        if let Some(ip) = ip {
166            query.append_pair("ip", &ip.to_string());
167        }
168        Ok(query.finish())
169    }
170}
171
172/// Wipe every HMAC secret on drop (issue #1142).
173impl Drop for SignedUrlKeySet {
174    fn drop(&mut self) {
175        use zeroize::Zeroize;
176        for (_, secret) in &mut self.keys {
177            secret.zeroize();
178        }
179    }
180}
181
182/// Manual `Debug` (rather than `#[derive(Debug)]`): every entry carries a
183/// secret that must never render verbatim — only the configured `kid`s are
184/// shown, which is exactly what's useful for diagnosing a rotation.
185impl core::fmt::Debug for SignedUrlKeySet {
186    fn fmt(&self, f: &mut core::fmt::Formatter<'_>) -> core::fmt::Result {
187        f.debug_struct("SignedUrlKeySet")
188            .field(
189                "kids",
190                &self
191                    .keys
192                    .iter()
193                    .map(|(k, _)| k.as_str())
194                    .collect::<Vec<_>>(),
195            )
196            .finish()
197    }
198}
199
200/// The exact newline-separated bytes the HMAC is computed over — see the
201/// module docs' "Canonical string" section. `ip` is rendered via
202/// [`IpAddr`]'s `Display` (canonical form), never the raw query-string
203/// bytes, so sign and verify always agree on the same representation.
204fn canonical_string(path: &str, exp: u64, ip: Option<IpAddr>) -> String {
205    match ip {
206        Some(ip) => format!("{path}\n{exp}\n{ip}"),
207        None => format!("{path}\n{exp}\n"),
208    }
209}
210
211/// HMAC-SHA256(`secret`, `message`) — `new_from_slice` never fails for HMAC
212/// (it accepts a key of any length, padding/hashing it per RFC 2104 §2 if
213/// need be), so the only way this could panic is a `hmac`/`sha2` internal
214/// bug, not anything caller-controlled.
215fn hmac_sha256(secret: &[u8], message: &str) -> Vec<u8> {
216    let mut mac = <Hmac<Sha256> as KeyInit>::new_from_slice(secret)
217        .expect("HMAC accepts a key of any length");
218    mac.update(message.as_bytes());
219    mac.finalize().into_bytes().to_vec()
220}
221
222/// Splits `uri` (a [`RequestContext::uri`], e.g. `/stream/media.m3u8?exp=…`)
223/// into `(path, query)`; `query` is `""` when there is no `?` at all.
224fn split_path_and_query(uri: &str) -> (&str, &str) {
225    match uri.split_once('?') {
226        Some((path, query)) => (path, query),
227        None => (uri, ""),
228    }
229}
230
231/// The first value for `key` in an `application/x-www-form-urlencoded` query,
232/// percent-decoded; a pair with no `=` has the value `""`.
233fn query_get<'q>(query: &'q str, key: &str) -> Option<Cow<'q, str>> {
234    form_urlencoded::parse(query.as_bytes())
235        .find(|(k, _)| k == key)
236        .map(|(_, v)| v)
237}
238
239/// The current wall-clock time as Unix seconds. `unwrap_or(0)` on a
240/// pre-epoch clock is unreachable on any real system but is not itself a
241/// security-relevant fallback: it can only make [`verify`] *stricter*
242/// (`0` fails every non-zero `exp` as already expired), never laxer.
243fn current_unix_time() -> u64 {
244    std::time::SystemTime::now()
245        .duration_since(std::time::UNIX_EPOCH)
246        .map(|d| d.as_secs())
247        .unwrap_or(0)
248}
249
250/// Verifies a signed-URL token carried in `ctx.uri`'s query string against
251/// `keys` — the implementation behind
252/// [`crate::Verifier::verify`]'s `SignedUrl` arm. See the module docs for
253/// the wire form, canonical string, and rejection semantics.
254///
255/// Every one of `exp`/`kid`/`sig` missing or malformed, `kid` unknown, `sig`
256/// undecodable, or `ip` unparseable is treated identically: `false`. Both the
257/// expiry check and the signature check are always computed (into their own
258/// `bool`s) before being combined — neither is allowed to gate access on its
259/// own; a correctly-signed-but-expired token and a fresh-but-wrongly-signed
260/// token are both rejected.
261pub(crate) fn verify(ctx: &RequestContext<'_>, keys: &SignedUrlKeySet) -> bool {
262    let (path, query) = split_path_and_query(ctx.uri);
263
264    let Some(exp) = query_get(query, "exp").and_then(|s| s.parse::<u64>().ok()) else {
265        return false;
266    };
267    let Some(kid) = query_get(query, "kid").filter(|s| !s.is_empty()) else {
268        return false;
269    };
270    let Some(sig) = query_get(query, "sig").filter(|s| !s.is_empty()) else {
271        return false;
272    };
273    let Some(secret) = keys.secret_for(&kid) else {
274        return false;
275    };
276    let ip: Option<IpAddr> = match query_get(query, "ip") {
277        Some(raw) => match raw.parse::<IpAddr>() {
278            Ok(ip) => Some(ip),
279            Err(_) => return false,
280        },
281        None => None,
282    };
283    let Ok(decoded_sig) = URL_SAFE_NO_PAD.decode(sig.as_bytes()) else {
284        return false;
285    };
286
287    let expected = hmac_sha256(secret, &canonical_string(path, exp, ip));
288    let sig_ok = bool::from(decoded_sig.as_slice().ct_eq(expected.as_slice()));
289    let not_expired = current_unix_time() <= exp;
290    let ip_ok = match ip {
291        Some(bound_ip) => ctx.peer_addr.map(|p| p.ip()) == Some(bound_ip),
292        None => true,
293    };
294
295    sig_ok && not_expired && ip_ok
296}
297
298#[cfg(test)]
299mod tests {
300    use super::*;
301    use crate::{AuthResult, Verifier};
302
303    const SECRET_A: &[u8; 32] = b"01234567890123456789012345678901";
304    const SECRET_B: &[u8; 32] = b"abcdefghijabcdefghijabcdefghij01";
305
306    fn keyset() -> SignedUrlKeySet {
307        SignedUrlKeySet::new([
308            ("key-a".to_string(), SECRET_A.to_vec()),
309            ("key-b".to_string(), SECRET_B.to_vec()),
310        ])
311        .unwrap()
312    }
313
314    fn far_future() -> u64 {
315        current_unix_time() + 3600
316    }
317
318    fn ctx_for(uri: &str) -> RequestContext<'_> {
319        RequestContext::new("GET", uri)
320    }
321
322    // --- construction ---
323
324    #[test]
325    fn new_rejects_secret_shorter_than_min_len() {
326        let err = SignedUrlKeySet::new([("k".to_string(), vec![0u8; 31])]).unwrap_err();
327        assert!(matches!(
328            err,
329            Error::SignedUrlKeyTooShort {
330                min: 32,
331                actual: 31,
332                ..
333            }
334        ));
335    }
336
337    #[test]
338    fn new_accepts_exactly_min_len_secret() {
339        SignedUrlKeySet::new([("k".to_string(), vec![0u8; 32])]).unwrap();
340    }
341
342    // --- sign/verify round trip (1: valid + unexpired -> allowed) ---
343
344    #[test]
345    fn valid_signature_and_unexpired_is_allowed() {
346        let keys = keyset();
347        let verifier = Verifier::signed_url(keyset());
348        let exp = far_future();
349        let query = keys.sign("key-a", "/stream/media.m3u8", exp, None).unwrap();
350        let uri = format!("/stream/media.m3u8?{query}");
351        assert_eq!(verifier.verify(&ctx_for(&uri)), AuthResult::Ok);
352    }
353
354    // --- 2: valid signature + expired -> denied ---
355
356    #[test]
357    fn valid_signature_but_expired_is_denied() {
358        let keys = keyset();
359        let verifier = Verifier::signed_url(keyset());
360        let exp = current_unix_time().saturating_sub(60);
361        let query = keys.sign("key-a", "/stream/media.m3u8", exp, None).unwrap();
362        let uri = format!("/stream/media.m3u8?{query}");
363        assert_eq!(verifier.verify(&ctx_for(&uri)), AuthResult::Unauthorized);
364    }
365
366    // --- 3: cross-route replay (path substitution) -> denied ---
367    // The single most important test: a signature minted for one route must
368    // not verify against a different route on the same origin/keyset.
369
370    #[test]
371    fn signature_minted_for_one_path_is_rejected_on_another_path() {
372        let keys = keyset();
373        let verifier = Verifier::signed_url(keyset());
374        let exp = far_future();
375        let query = keys
376            .sign("key-a", "/route-a/media.m3u8", exp, None)
377            .unwrap();
378        // Same query string, replayed against a different route's path.
379        let uri = format!("/route-b/media.m3u8?{query}");
380        assert_eq!(verifier.verify(&ctx_for(&uri)), AuthResult::Unauthorized);
381    }
382
383    // --- 4: tampered exp (extended without re-signing) -> denied ---
384
385    #[test]
386    fn extending_exp_without_resigning_is_denied() {
387        let keys = keyset();
388        let verifier = Verifier::signed_url(keyset());
389        let original_exp = far_future();
390        let query = keys
391            .sign("key-a", "/stream/media.m3u8", original_exp, None)
392            .unwrap();
393        let tampered = query.replacen(
394            &format!("exp={original_exp}"),
395            &format!("exp={}", original_exp + 1_000_000),
396            1,
397        );
398        assert_ne!(query, tampered, "test setup: exp must actually change");
399        let uri = format!("/stream/media.m3u8?{tampered}");
400        assert_eq!(verifier.verify(&ctx_for(&uri)), AuthResult::Unauthorized);
401    }
402
403    // --- 5: unknown kid -> denied ---
404
405    #[test]
406    fn unknown_kid_is_denied() {
407        let keys = keyset();
408        let verifier = Verifier::signed_url(keyset());
409        let exp = far_future();
410        let query = keys.sign("key-a", "/stream/media.m3u8", exp, None).unwrap();
411        let tampered = query.replacen("kid=key-a", "kid=nonexistent-key", 1);
412        let uri = format!("/stream/media.m3u8?{tampered}");
413        assert_eq!(verifier.verify(&ctx_for(&uri)), AuthResult::Unauthorized);
414    }
415
416    // --- 6/7: IP scoping ---
417
418    #[test]
419    fn ip_scoped_token_from_different_peer_is_denied() {
420        let keys = keyset();
421        let verifier = Verifier::signed_url(keyset());
422        let exp = far_future();
423        let bound_ip: IpAddr = "203.0.113.7".parse().unwrap();
424        let query = keys
425            .sign("key-a", "/stream/media.m3u8", exp, Some(bound_ip))
426            .unwrap();
427        let uri = format!("/stream/media.m3u8?{query}");
428        let other_peer: std::net::SocketAddr = "198.51.100.9:443".parse().unwrap();
429        let ctx = ctx_for(&uri).with_peer_addr(other_peer);
430        assert_eq!(verifier.verify(&ctx), AuthResult::Unauthorized);
431    }
432
433    #[test]
434    fn ip_scoped_token_from_correct_peer_is_allowed() {
435        let keys = keyset();
436        let verifier = Verifier::signed_url(keyset());
437        let exp = far_future();
438        let bound_ip: IpAddr = "203.0.113.7".parse().unwrap();
439        let query = keys
440            .sign("key-a", "/stream/media.m3u8", exp, Some(bound_ip))
441            .unwrap();
442        let uri = format!("/stream/media.m3u8?{query}");
443        // Same IP, different (irrelevant) port — the port must not matter.
444        let same_ip_peer: std::net::SocketAddr = "203.0.113.7:54321".parse().unwrap();
445        let ctx = ctx_for(&uri).with_peer_addr(same_ip_peer);
446        assert_eq!(verifier.verify(&ctx), AuthResult::Ok);
447    }
448
449    #[test]
450    fn unscoped_token_is_allowed_regardless_of_peer() {
451        let keys = keyset();
452        let verifier = Verifier::signed_url(keyset());
453        let exp = far_future();
454        let query = keys.sign("key-a", "/stream/media.m3u8", exp, None).unwrap();
455        let uri = format!("/stream/media.m3u8?{query}");
456        let peer: std::net::SocketAddr = "198.51.100.9:443".parse().unwrap();
457        let ctx = ctx_for(&uri).with_peer_addr(peer);
458        assert_eq!(verifier.verify(&ctx), AuthResult::Ok);
459    }
460
461    // --- 8: missing/empty/malformed sig -> denied, no panic ---
462
463    #[test]
464    fn missing_sig_is_denied() {
465        let verifier = Verifier::signed_url(keyset());
466        let uri = format!("/stream/media.m3u8?exp={}&kid=key-a", far_future());
467        assert_eq!(verifier.verify(&ctx_for(&uri)), AuthResult::Unauthorized);
468    }
469
470    #[test]
471    fn empty_sig_is_denied() {
472        let verifier = Verifier::signed_url(keyset());
473        let uri = format!("/stream/media.m3u8?exp={}&kid=key-a&sig=", far_future());
474        assert_eq!(verifier.verify(&ctx_for(&uri)), AuthResult::Unauthorized);
475    }
476
477    #[test]
478    fn malformed_base64_sig_is_denied_not_panicking() {
479        let verifier = Verifier::signed_url(keyset());
480        let uri = format!(
481            "/stream/media.m3u8?exp={}&kid=key-a&sig=not-valid-base64!!!",
482            far_future()
483        );
484        assert_eq!(verifier.verify(&ctx_for(&uri)), AuthResult::Unauthorized);
485    }
486
487    #[test]
488    fn missing_exp_is_denied() {
489        let verifier = Verifier::signed_url(keyset());
490        let uri = "/stream/media.m3u8?kid=key-a&sig=AAAA".to_string();
491        assert_eq!(verifier.verify(&ctx_for(&uri)), AuthResult::Unauthorized);
492    }
493
494    #[test]
495    fn unparseable_exp_is_denied() {
496        let verifier = Verifier::signed_url(keyset());
497        let uri = "/stream/media.m3u8?exp=not-a-number&kid=key-a&sig=AAAA".to_string();
498        assert_eq!(verifier.verify(&ctx_for(&uri)), AuthResult::Unauthorized);
499    }
500
501    #[test]
502    fn empty_query_string_is_denied_not_panicking() {
503        let verifier = Verifier::signed_url(keyset());
504        assert_eq!(
505            verifier.verify(&ctx_for("/stream/media.m3u8")),
506            AuthResult::Unauthorized
507        );
508        assert_eq!(
509            verifier.verify(&ctx_for("/stream/media.m3u8?")),
510            AuthResult::Unauthorized
511        );
512    }
513
514    // --- 9: key rotation ---
515
516    #[test]
517    fn both_active_keys_are_accepted_until_one_is_retired() {
518        let keys = keyset();
519        let exp = far_future();
520        let query_a = keys.sign("key-a", "/stream/media.m3u8", exp, None).unwrap();
521        let query_b = keys.sign("key-b", "/stream/media.m3u8", exp, None).unwrap();
522
523        let verifier = Verifier::signed_url(keyset());
524        assert_eq!(
525            verifier.verify(&ctx_for(&format!("/stream/media.m3u8?{query_a}"))),
526            AuthResult::Ok,
527            "key-a must verify while both keys are active"
528        );
529        assert_eq!(
530            verifier.verify(&ctx_for(&format!("/stream/media.m3u8?{query_b}"))),
531            AuthResult::Ok,
532            "key-b must verify while both keys are active"
533        );
534
535        // Retire key-b: a fresh keyset/verifier carrying only key-a.
536        let retired_keyset =
537            SignedUrlKeySet::new([("key-a".to_string(), SECRET_A.to_vec())]).unwrap();
538        let verifier_after_rotation = Verifier::signed_url(retired_keyset);
539        assert_eq!(
540            verifier_after_rotation.verify(&ctx_for(&format!("/stream/media.m3u8?{query_a}"))),
541            AuthResult::Ok,
542            "key-a must still verify after key-b is retired"
543        );
544        assert_eq!(
545            verifier_after_rotation.verify(&ctx_for(&format!("/stream/media.m3u8?{query_b}"))),
546            AuthResult::Unauthorized,
547            "a token signed by the retired key-b must now be rejected"
548        );
549    }
550
551    // --- signing helper errors ---
552
553    #[test]
554    fn sign_with_unknown_kid_is_a_structured_error() {
555        let keys = keyset();
556        let err = keys
557            .sign("no-such-key", "/stream/media.m3u8", far_future(), None)
558            .unwrap_err();
559        assert!(matches!(err, Error::UnknownSignedUrlKeyId(k) if k == "no-such-key"));
560    }
561
562    // --- misc unit coverage ---
563
564    #[test]
565    fn split_path_and_query_handles_no_query() {
566        assert_eq!(split_path_and_query("/a/b"), ("/a/b", ""));
567        assert_eq!(split_path_and_query("/a/b?x=1"), ("/a/b", "x=1"));
568    }
569
570    #[test]
571    fn query_get_skips_malformed_pairs_without_panicking() {
572        assert_eq!(query_get("a=1&garbage&b=2", "b").as_deref(), Some("2"));
573        assert_eq!(query_get("a=1&garbage&b=2", "garbage").as_deref(), Some(""));
574        assert_eq!(query_get("", "a"), None);
575    }
576
577    #[test]
578    fn debug_never_leaks_secret_bytes() {
579        let keys = keyset();
580        let debug = format!("{keys:?}");
581        assert!(debug.contains("key-a"), "kid should render: {debug}");
582        assert!(debug.contains("key-b"), "kid should render: {debug}");
583        assert!(
584            !debug.contains(std::str::from_utf8(SECRET_A).unwrap()),
585            "secret leaked: {debug}"
586        );
587    }
588}