Skip to main content

xml_sec/xmldsig/
digest.rs

1//! Digest computation for XMLDSig `<Reference>` processing.
2//!
3//! Implements [XMLDSig §6.1](https://www.w3.org/TR/xmldsig-core1/#sec-DigestMethod):
4//! compute message digests over transform output bytes using the selected algorithm.
5//!
6//! Operation digest computation uses the selected `CryptoProvider`; the built-in
7//! standalone convenience path uses RustCrypto.
8
9use subtle::ConstantTimeEq;
10
11/// Digest algorithms supported by XMLDSig.
12///
13/// SHA-1 is disabled for signing by default and requires explicit policy opt-in.
14/// SHA-256 is the recommended default for new signatures.
15#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
16pub enum DigestAlgorithm {
17    /// MD5 compatibility capability; operation policy must explicitly permit it.
18    #[cfg(feature = "legacy-algorithms")]
19    Md5,
20    /// RIPEMD-160 compatibility capability; operation policy must explicitly permit it.
21    #[cfg(feature = "legacy-algorithms")]
22    Ripemd160,
23    /// SHA-1 (160-bit). Disabled for signing by default.
24    Sha1,
25    /// SHA-224 (224-bit), required by XMLDSig 1.1 interoperability profiles.
26    Sha224,
27    /// SHA-256 (256-bit). Default for SAML.
28    Sha256,
29    /// SHA-384 (384-bit).
30    Sha384,
31    /// SHA-512 (512-bit).
32    Sha512,
33    /// SHA3-224 (224-bit).
34    Sha3_224,
35    /// SHA3-256 (256-bit).
36    Sha3_256,
37    /// SHA3-384 (384-bit).
38    Sha3_384,
39    /// SHA3-512 (512-bit).
40    Sha3_512,
41}
42
43impl DigestAlgorithm {
44    /// Every compiled digest capability; this registry grants no policy permission.
45    pub const ALL: [Self; 9 + if cfg!(feature = "legacy-algorithms") {
46        2
47    } else {
48        0
49    }] = [
50        #[cfg(feature = "legacy-algorithms")]
51        Self::Md5,
52        #[cfg(feature = "legacy-algorithms")]
53        Self::Ripemd160,
54        Self::Sha1,
55        Self::Sha224,
56        Self::Sha256,
57        Self::Sha384,
58        Self::Sha512,
59        Self::Sha3_224,
60        Self::Sha3_256,
61        Self::Sha3_384,
62        Self::Sha3_512,
63    ];
64    /// Parse a digest algorithm from its XML namespace URI.
65    ///
66    /// Returns `None` for unrecognized URIs.
67    /// Recognition is capability, not permission: operation callers must check
68    /// their compiled digest policy before hashing, including X509Digest selectors.
69    ///
70    /// # URIs
71    ///
72    /// | Algorithm | URI |
73    /// |-----------|-----|
74    /// | SHA-1 | `http://www.w3.org/2000/09/xmldsig#sha1` |
75    /// | SHA-224 | `http://www.w3.org/2001/04/xmldsig-more#sha224` |
76    /// | SHA-256 | `http://www.w3.org/2001/04/xmlenc#sha256` |
77    /// | SHA-384 | `http://www.w3.org/2001/04/xmldsig-more#sha384` |
78    /// | SHA-512 | `http://www.w3.org/2001/04/xmlenc#sha512` |
79    pub fn from_uri(uri: &str) -> Option<Self> {
80        match uri {
81            // RFC 9231 §§2.1.1, 2.1.4 assign these exact digest identifiers.
82            // https://www.rfc-editor.org/rfc/rfc9231#section-2.1
83            #[cfg(feature = "legacy-algorithms")]
84            "http://www.w3.org/2001/04/xmldsig-more#md5" => Some(Self::Md5),
85            #[cfg(feature = "legacy-algorithms")]
86            "http://www.w3.org/2001/04/xmlenc#ripemd160" => Some(Self::Ripemd160),
87            "http://www.w3.org/2000/09/xmldsig#sha1" => Some(Self::Sha1),
88            "http://www.w3.org/2001/04/xmldsig-more#sha224" => Some(Self::Sha224),
89            "http://www.w3.org/2001/04/xmlenc#sha256" => Some(Self::Sha256),
90            "http://www.w3.org/2001/04/xmldsig-more#sha384" => Some(Self::Sha384),
91            "http://www.w3.org/2001/04/xmlenc#sha512" => Some(Self::Sha512),
92            // RFC 9231 section 2.1.5 fixes the SHA-3 namespace and digest widths:
93            // https://www.rfc-editor.org/rfc/rfc9231.html#section-2.1.5
94            "http://www.w3.org/2007/05/xmldsig-more#sha3-224" => Some(Self::Sha3_224),
95            "http://www.w3.org/2007/05/xmldsig-more#sha3-256" => Some(Self::Sha3_256),
96            "http://www.w3.org/2007/05/xmldsig-more#sha3-384" => Some(Self::Sha3_384),
97            "http://www.w3.org/2007/05/xmldsig-more#sha3-512" => Some(Self::Sha3_512),
98            _ => None,
99        }
100    }
101
102    /// Return the XML namespace URI for this digest algorithm.
103    pub fn uri(self) -> &'static str {
104        match self {
105            #[cfg(feature = "legacy-algorithms")]
106            Self::Md5 => "http://www.w3.org/2001/04/xmldsig-more#md5",
107            #[cfg(feature = "legacy-algorithms")]
108            Self::Ripemd160 => "http://www.w3.org/2001/04/xmlenc#ripemd160",
109            Self::Sha1 => "http://www.w3.org/2000/09/xmldsig#sha1",
110            Self::Sha224 => "http://www.w3.org/2001/04/xmldsig-more#sha224",
111            Self::Sha256 => "http://www.w3.org/2001/04/xmlenc#sha256",
112            Self::Sha384 => "http://www.w3.org/2001/04/xmldsig-more#sha384",
113            Self::Sha512 => "http://www.w3.org/2001/04/xmlenc#sha512",
114            Self::Sha3_224 => "http://www.w3.org/2007/05/xmldsig-more#sha3-224",
115            Self::Sha3_256 => "http://www.w3.org/2007/05/xmldsig-more#sha3-256",
116            Self::Sha3_384 => "http://www.w3.org/2007/05/xmldsig-more#sha3-384",
117            Self::Sha3_512 => "http://www.w3.org/2007/05/xmldsig-more#sha3-512",
118        }
119    }
120
121    /// Whether this algorithm is allowed for signing (not just verification).
122    ///
123    /// SHA-1 is deprecated and disabled by secure signing defaults. An explicit
124    /// signing policy allowlist can enable it for a trusted compatibility boundary.
125    pub fn signing_allowed(self) -> bool {
126        !matches!(self, Self::Sha1) && !self.requires_explicit_permission()
127    }
128
129    /// Whether reference verification requires an explicit digest allowlist entry.
130    pub const fn requires_explicit_permission(self) -> bool {
131        match self {
132            #[cfg(feature = "legacy-algorithms")]
133            Self::Md5 | Self::Ripemd160 => true,
134            _ => false,
135        }
136    }
137
138    /// The expected output length in bytes.
139    pub fn output_len(self) -> usize {
140        match self {
141            #[cfg(feature = "legacy-algorithms")]
142            Self::Md5 => 16,
143            #[cfg(feature = "legacy-algorithms")]
144            Self::Ripemd160 => 20,
145            Self::Sha1 => 20,
146            Self::Sha224 | Self::Sha3_224 => 28,
147            Self::Sha256 | Self::Sha3_256 => 32,
148            Self::Sha384 | Self::Sha3_384 => 48,
149            Self::Sha512 | Self::Sha3_512 => 64,
150        }
151    }
152}
153
154/// Compute the digest of `data` using the specified algorithm.
155///
156/// Returns the raw digest bytes (not base64-encoded).
157pub fn compute_digest(algorithm: DigestAlgorithm, data: &[u8]) -> Vec<u8> {
158    compute_digest_with_provider(crate::provider::default_provider(), algorithm, data)
159        .expect("default provider advertises every XMLDSig digest")
160}
161
162/// Compute a digest with an explicitly selected provider.
163pub fn compute_digest_with_provider(
164    provider: &dyn crate::provider::CryptoProvider,
165    algorithm: DigestAlgorithm,
166    data: &[u8],
167) -> Result<Vec<u8>, crate::provider::ProviderError> {
168    let mut state = begin_digest(provider, algorithm)?;
169    state.update(data)?;
170    finish_digest(algorithm, state)
171}
172
173/// Start an incremental digest after checking the advertised capability.
174pub(crate) fn begin_digest<'p>(
175    provider: &'p dyn crate::provider::CryptoProvider,
176    algorithm: DigestAlgorithm,
177) -> Result<Box<dyn crate::provider::DigestState + 'p>, crate::provider::ProviderError> {
178    provider.require_capability(crate::provider::ProviderCapability::Digest(algorithm))?;
179    provider.begin_digest(algorithm)
180}
181
182/// Finish an incremental digest. The facade, not an interchangeable provider,
183/// owns the output width of each XMLDSig algorithm.
184pub(crate) fn finish_digest(
185    algorithm: DigestAlgorithm,
186    state: Box<dyn crate::provider::DigestState + '_>,
187) -> Result<Vec<u8>, crate::provider::ProviderError> {
188    let digest = state.finalize()?;
189    let expected = algorithm.output_len();
190    if digest.len() != expected {
191        return Err(crate::provider::ProviderError::InvalidOutputSize {
192            operation: crate::provider::ProviderOperation::Digest,
193            expected,
194            actual: digest.len(),
195        });
196    }
197    Ok(digest)
198}
199
200/// Constant-time comparison of two byte slices.
201///
202/// Returns `true` if and only if `a` and `b` have equal length and identical
203/// content. Execution time depends only on the length of the slices, not on
204/// where they differ — preventing timing side-channel attacks on digest
205/// comparison.
206///
207/// Uses `subtle` constant-time equality.
208pub fn constant_time_eq(a: &[u8], b: &[u8]) -> bool {
209    a.ct_eq(b).into()
210}
211
212#[cfg(test)]
213mod tests {
214    use super::*;
215
216    struct RejectingDigestProvider {
217        output: Option<Vec<u8>>,
218    }
219
220    /// Returns caller-chosen bytes whatever it absorbed.
221    struct FixedDigest(Vec<u8>);
222
223    impl crate::provider::DigestState for FixedDigest {
224        fn update(&mut self, _: &[u8]) -> Result<(), crate::provider::ProviderError> {
225            Ok(())
226        }
227        fn finalize(self: Box<Self>) -> Result<Vec<u8>, crate::provider::ProviderError> {
228            Ok(self.0)
229        }
230    }
231
232    impl crate::provider::CryptoProvider for RejectingDigestProvider {
233        fn name(&self) -> &'static str {
234            "rejecting-digest"
235        }
236
237        fn supports(&self, capability: crate::provider::ProviderCapability<'_>) -> bool {
238            if self.output.is_none()
239                && matches!(capability, crate::provider::ProviderCapability::Digest(_))
240            {
241                return false;
242            }
243            crate::provider::default_provider().supports(capability)
244        }
245
246        fn fill_random(&self, output: &mut [u8]) -> Result<(), crate::provider::ProviderError> {
247            crate::provider::default_provider().fill_random(output)
248        }
249
250        fn derive_key(
251            &self,
252            parameters: &crate::provider::KdfParameters<'_>,
253            secret: &[u8],
254        ) -> Result<Vec<u8>, crate::provider::ProviderError> {
255            crate::provider::default_provider().derive_key(parameters, secret)
256        }
257
258        fn begin_digest(
259            &self,
260            _algorithm: DigestAlgorithm,
261        ) -> Result<Box<dyn crate::provider::DigestState + '_>, crate::provider::ProviderError>
262        {
263            if let Some(output) = &self.output {
264                return Ok(Box::new(FixedDigest(output.clone())));
265            }
266            panic!("an unavailable digest capability must not be dispatched")
267        }
268
269        fn sign(
270            &self,
271            key: &dyn super::super::SigningKey,
272            algorithm: super::super::SignatureAlgorithm,
273            data: &[u8],
274        ) -> Result<Vec<u8>, super::super::SigningKeyError> {
275            crate::provider::default_provider().sign(key, algorithm, data)
276        }
277
278        fn verify(
279            &self,
280            key: &dyn super::super::VerifyingKey,
281            algorithm: super::super::SignatureAlgorithm,
282            data: &[u8],
283            signature: &[u8],
284        ) -> Result<bool, super::super::DsigError> {
285            crate::provider::default_provider().verify(key, algorithm, data, signature)
286        }
287
288        #[cfg(feature = "xmlenc")]
289        fn encrypt_data(
290            &self,
291            algorithm: crate::xmlenc::DataEncryptionAlgorithm,
292            key: &[u8],
293            plaintext: &[u8],
294        ) -> Result<Vec<u8>, crate::provider::ProviderError> {
295            crate::provider::default_provider().encrypt_data(algorithm, key, plaintext)
296        }
297
298        #[cfg(feature = "xmlenc")]
299        fn decrypt_data(
300            &self,
301            algorithm: crate::xmlenc::DataEncryptionAlgorithm,
302            key: &[u8],
303            ciphertext: &[u8],
304        ) -> Result<Vec<u8>, crate::provider::ProviderError> {
305            crate::provider::default_provider().decrypt_data(algorithm, key, ciphertext)
306        }
307
308        #[cfg(feature = "xmlenc")]
309        fn wrap_key(
310            &self,
311            algorithm: crate::xmlenc::KeyWrapAlgorithm,
312            kek: &[u8],
313            key: &[u8],
314        ) -> Result<Vec<u8>, crate::provider::ProviderError> {
315            crate::provider::default_provider().wrap_key(algorithm, kek, key)
316        }
317
318        #[cfg(feature = "xmlenc")]
319        fn unwrap_key(
320            &self,
321            algorithm: crate::xmlenc::KeyWrapAlgorithm,
322            kek: &[u8],
323            wrapped: &[u8],
324        ) -> Result<Vec<u8>, crate::provider::ProviderError> {
325            crate::provider::default_provider().unwrap_key(algorithm, kek, wrapped)
326        }
327
328        #[cfg(feature = "xmlenc")]
329        fn transport_key(
330            &self,
331            key: &dyn crate::provider::KeyTransportKey,
332            parameters: &crate::xmlenc::RsaOaepParameters,
333            plaintext: &[u8],
334        ) -> Result<Vec<u8>, crate::provider::ProviderError> {
335            crate::provider::default_provider().transport_key(key, parameters, plaintext)
336        }
337
338        #[cfg(feature = "xmlenc")]
339        fn recover_key(
340            &self,
341            key: &dyn crate::provider::KeyRecoveryKey,
342            parameters: &crate::xmlenc::RsaOaepParameters,
343            ciphertext: &[u8],
344        ) -> Result<Vec<u8>, crate::provider::ProviderError> {
345            crate::provider::default_provider().recover_key(key, parameters, ciphertext)
346        }
347    }
348
349    #[test]
350    fn explicit_provider_digest_failures_are_returned() {
351        // A restricted provider is caller-controlled and must never turn an
352        // unsupported document-selected digest into a process panic.
353        assert!(matches!(
354            compute_digest_with_provider(
355                &RejectingDigestProvider { output: None },
356                DigestAlgorithm::Sha256,
357                b"x"
358            ),
359            Err(crate::provider::ProviderError::Unsupported { .. })
360        ));
361    }
362
363    #[test]
364    fn explicit_provider_digest_output_must_match_the_algorithm() {
365        // The facade, rather than an interchangeable provider, owns the XMLDSig
366        // algorithm contract and must reject bytes its own parser cannot accept.
367        for actual in [0, 31, 33] {
368            assert!(matches!(
369                compute_digest_with_provider(
370                    &RejectingDigestProvider {
371                        output: Some(vec![0_u8; actual]),
372                    },
373                    DigestAlgorithm::Sha256,
374                    b"x",
375                ),
376                Err(crate::provider::ProviderError::InvalidOutputSize {
377                    operation: crate::provider::ProviderOperation::Digest,
378                    expected: 32,
379                    actual: output_len,
380                }) if output_len == actual
381            ));
382        }
383
384        assert_eq!(
385            compute_digest_with_provider(
386                &RejectingDigestProvider {
387                    output: Some(vec![7_u8; 32]),
388                },
389                DigestAlgorithm::Sha256,
390                b"x",
391            ),
392            Ok(vec![7_u8; 32]),
393        );
394    }
395
396    // ── from_uri / uri round-trip ────────────────────────────────────
397
398    #[test]
399    fn from_uri_sha1() {
400        let algo = DigestAlgorithm::from_uri("http://www.w3.org/2000/09/xmldsig#sha1");
401        assert_eq!(algo, Some(DigestAlgorithm::Sha1));
402    }
403
404    #[test]
405    fn from_uri_sha256() {
406        let algo = DigestAlgorithm::from_uri("http://www.w3.org/2001/04/xmlenc#sha256");
407        assert_eq!(algo, Some(DigestAlgorithm::Sha256));
408    }
409
410    #[test]
411    fn from_uri_sha384() {
412        let algo = DigestAlgorithm::from_uri("http://www.w3.org/2001/04/xmldsig-more#sha384");
413        assert_eq!(algo, Some(DigestAlgorithm::Sha384));
414    }
415
416    #[test]
417    fn from_uri_sha512() {
418        let algo = DigestAlgorithm::from_uri("http://www.w3.org/2001/04/xmlenc#sha512");
419        assert_eq!(algo, Some(DigestAlgorithm::Sha512));
420    }
421
422    #[test]
423    fn from_uri_unknown() {
424        assert_eq!(
425            DigestAlgorithm::from_uri("http://example.com/unknown"),
426            None
427        );
428    }
429
430    #[test]
431    fn uri_round_trip() {
432        for algo in [
433            DigestAlgorithm::Sha1,
434            DigestAlgorithm::Sha256,
435            DigestAlgorithm::Sha384,
436            DigestAlgorithm::Sha512,
437        ] {
438            assert_eq!(
439                DigestAlgorithm::from_uri(algo.uri()),
440                Some(algo),
441                "round-trip failed for {algo:?}"
442            );
443        }
444    }
445
446    // ── signing_allowed ──────────────────────────────────────────────
447
448    #[test]
449    fn sha1_verify_only() {
450        assert!(!DigestAlgorithm::Sha1.signing_allowed());
451    }
452
453    #[test]
454    fn sha256_signing_allowed() {
455        assert!(DigestAlgorithm::Sha256.signing_allowed());
456    }
457
458    #[test]
459    fn sha384_signing_allowed() {
460        assert!(DigestAlgorithm::Sha384.signing_allowed());
461    }
462
463    #[test]
464    fn sha512_signing_allowed() {
465        assert!(DigestAlgorithm::Sha512.signing_allowed());
466    }
467
468    // ── output_len ───────────────────────────────────────────────────
469
470    #[test]
471    fn output_lengths() {
472        assert_eq!(DigestAlgorithm::Sha1.output_len(), 20);
473        assert_eq!(DigestAlgorithm::Sha256.output_len(), 32);
474        assert_eq!(DigestAlgorithm::Sha384.output_len(), 48);
475        assert_eq!(DigestAlgorithm::Sha512.output_len(), 64);
476    }
477
478    #[test]
479    fn sha224_uri_round_trips_and_has_standard_width() {
480        let algorithm = DigestAlgorithm::from_uri("http://www.w3.org/2001/04/xmldsig-more#sha224")
481            .expect("XMLDSig 1.1 SHA-224 must be recognized");
482
483        assert_eq!(
484            algorithm.uri(),
485            "http://www.w3.org/2001/04/xmldsig-more#sha224"
486        );
487        assert_eq!(algorithm.output_len(), 28);
488        assert!(algorithm.signing_allowed());
489    }
490
491    // ── Known-answer tests (KAT) ────────────────────────────────────
492    // Reference values computed with `echo -n "..." | openssl dgst -sha*`
493
494    #[test]
495    fn sha1_empty() {
496        // SHA-1("") = da39a3ee5e6b4b0d3255bfef95601890afd80709
497        let digest = compute_digest(DigestAlgorithm::Sha1, b"");
498        assert_eq!(digest.len(), 20);
499        assert_eq!(hex(&digest), "da39a3ee5e6b4b0d3255bfef95601890afd80709");
500    }
501
502    #[test]
503    fn sha256_empty() {
504        // SHA-256("") = e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855
505        let digest = compute_digest(DigestAlgorithm::Sha256, b"");
506        assert_eq!(digest.len(), 32);
507        assert_eq!(
508            hex(&digest),
509            "e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855"
510        );
511    }
512
513    #[test]
514    fn sha224_empty() {
515        let digest = compute_digest(
516            DigestAlgorithm::from_uri("http://www.w3.org/2001/04/xmldsig-more#sha224")
517                .expect("SHA-224 URI must be recognized"),
518            b"",
519        );
520        assert_eq!(
521            hex(&digest),
522            "d14a028c2a3a2bc9476102bb288234c415a2b01f828ea62ac5b3e42f"
523        );
524    }
525
526    #[test]
527    fn sha384_empty() {
528        // SHA-384("") = 38b060a751ac96384cd9327eb1b1e36a21fdb71114be07434c0cc7bf63f6e1da274edebfe76f65fbd51ad2f14898b95b
529        let digest = compute_digest(DigestAlgorithm::Sha384, b"");
530        assert_eq!(digest.len(), 48);
531        assert_eq!(
532            hex(&digest),
533            "38b060a751ac96384cd9327eb1b1e36a21fdb71114be07434c0cc7bf63f6e1da274edebfe76f65fbd51ad2f14898b95b"
534        );
535    }
536
537    #[test]
538    fn sha512_empty() {
539        // SHA-512("") = cf83e1357eefb8bdf1542850d66d8007d620e4050b5715dc83f4a921d36ce9ce47d0d13c5d85f2b0ff8318d2877eec2f63b931bd47417a81a538327af927da3e
540        let digest = compute_digest(DigestAlgorithm::Sha512, b"");
541        assert_eq!(digest.len(), 64);
542        assert_eq!(
543            hex(&digest),
544            "cf83e1357eefb8bdf1542850d66d8007d620e4050b5715dc83f4a921d36ce9ce47d0d13c5d85f2b0ff8318d2877eec2f63b931bd47417a81a538327af927da3e"
545        );
546    }
547
548    #[test]
549    fn sha256_hello_world() {
550        // SHA-256("hello world") = b94d27b9934d3e08a52e52d7da7dabfac484efe37a5380ee9088f7ace2efcde9
551        let digest = compute_digest(DigestAlgorithm::Sha256, b"hello world");
552        assert_eq!(
553            hex(&digest),
554            "b94d27b9934d3e08a52e52d7da7dabfac484efe37a5380ee9088f7ace2efcde9"
555        );
556    }
557
558    #[test]
559    fn sha1_abc() {
560        // SHA-1("abc") = a9993e364706816aba3e25717850c26c9cd0d89d
561        let digest = compute_digest(DigestAlgorithm::Sha1, b"abc");
562        assert_eq!(hex(&digest), "a9993e364706816aba3e25717850c26c9cd0d89d");
563    }
564
565    // ── constant_time_eq ─────────────────────────────────────────────
566
567    #[test]
568    fn constant_time_eq_identical() {
569        let a = compute_digest(DigestAlgorithm::Sha256, b"test");
570        let b = compute_digest(DigestAlgorithm::Sha256, b"test");
571        assert!(constant_time_eq(&a, &b));
572    }
573
574    #[test]
575    fn constant_time_eq_different_content() {
576        let a = compute_digest(DigestAlgorithm::Sha256, b"test1");
577        let b = compute_digest(DigestAlgorithm::Sha256, b"test2");
578        assert!(!constant_time_eq(&a, &b));
579    }
580
581    #[test]
582    fn constant_time_eq_different_lengths() {
583        assert!(!constant_time_eq(&[1, 2, 3], &[1, 2]));
584    }
585
586    #[test]
587    fn constant_time_eq_empty() {
588        assert!(constant_time_eq(&[], &[]));
589    }
590
591    // ── Digest output matches expected length ────────────────────────
592
593    #[test]
594    fn digest_output_matches_declared_length() {
595        let data = b"test data for length verification";
596        for algo in [
597            DigestAlgorithm::Sha1,
598            DigestAlgorithm::Sha256,
599            DigestAlgorithm::Sha384,
600            DigestAlgorithm::Sha512,
601        ] {
602            let digest = compute_digest(algo, data);
603            assert_eq!(
604                digest.len(),
605                algo.output_len(),
606                "output length mismatch for {algo:?}"
607            );
608        }
609    }
610
611    /// Helper: format bytes as lowercase hex string.
612    fn hex(bytes: &[u8]) -> String {
613        bytes.iter().map(|b| format!("{b:02x}")).collect()
614    }
615}