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