Skip to main content

ai_memory/
tls.rs

1// Copyright 2026 AlphaOne LLC
2// SPDX-License-Identifier: Apache-2.0
3
4//! TLS / mTLS configuration and verifiers for the HTTP daemon.
5//!
6//! Wave 4 (v0.6.3) — extracted verbatim from `src/main.rs`. Three layers:
7//!
8//! 1. **Layer 1** — server-side TLS via `axum-server` + rustls.
9//!    `load_rustls_config` parses a PEM cert + PEM key (PKCS#8 / RSA / SEC1)
10//!    and surfaces operator-friendly errors instead of letting rustls' wrapped
11//!    IO errors bubble up. TLS misconfiguration is the #1 new-deploy footgun.
12//!
13//! 2. **Layer 2** — mTLS with SHA-256 client-cert fingerprint allowlist.
14//!    `load_mtls_rustls_config` builds a rustls `ServerConfig` that:
15//!      - presents the local cert/key (same as Layer 1),
16//!      - demands a client certificate on every connection,
17//!      - accepts the client cert only if its SHA-256 fingerprint appears on
18//!        the operator-configured allowlist. Any other cert — including ones
19//!        signed by trusted CAs — is rejected. This is the fastest path to
20//!        "only authorised peers can even connect" without depending on a
21//!        PKI/CA ecosystem. Fingerprint pinning is a well-understood primitive
22//!        (HTTP Public Key Pinning, SSH host keys).
23//!
24//!    The allowlist parser tolerates:
25//!      - blank lines and `#` full-line comments,
26//!      - trailing inline comments (issue #358),
27//!      - optional `:` separators in the hex,
28//!      - an optional leading `sha256:` marker (forward-compat).
29//!    It rejects embedded whitespace inside the hex run (issue #338) so
30//!    soft-wrap copy-paste artefacts surface a clear "unexpected character"
31//!    error rather than a misleading length error further down.
32//!
33//! 3. **Layer 2 (client side)** — `build_rustls_client_config` builds a
34//!    `rustls::ClientConfig` with client-cert auth and a "dangerously-accept-
35//!    any-server-cert" verifier. Used by the sync-daemon to present its
36//!    client cert on every outbound request while connecting to peers with
37//!    self-signed server certs. Peer authenticity is established on the
38//!    other direction (they verify us via `--mtls-allowlist`).
39//!
40//! Every public symbol below is move-extracted byte-for-byte from `main.rs`
41//! at the W3 commit, with `pub` added for cross-module visibility. Behaviour
42//! must remain bit-for-bit identical at the call sites.
43
44use anyhow::{Context, Result};
45use std::collections::HashSet;
46use std::path::Path;
47use std::sync::Arc;
48
49/// Load a PEM cert + PEM key (PKCS#8 or RSA) into an `axum-server`
50/// rustls config. Returns an error with a specific message for the
51/// operator rather than letting rustls' wrapped IO error bubble up —
52/// TLS misconfigurations are the #1 new-deploy footgun.
53pub async fn load_rustls_config(
54    cert_path: &Path,
55    key_path: &Path,
56) -> Result<axum_server::tls_rustls::RustlsConfig> {
57    let cert = tokio::fs::read(cert_path)
58        .await
59        .with_context(|| format!("failed to read TLS cert from {}", cert_path.display()))?;
60    let key = tokio::fs::read(key_path)
61        .await
62        .with_context(|| format!("failed to read TLS key from {}", key_path.display()))?;
63    let config = axum_server::tls_rustls::RustlsConfig::from_pem(cert, key)
64        .await
65        .context(
66            "failed to parse TLS cert/key — ensure PEM-encoded (cert may be fullchain; \
67                 key must be PKCS#8 or RSA)",
68        )?;
69    Ok(config)
70}
71
72// ---------------------------------------------------------------------------
73// Layer 2 — mTLS with SHA-256 fingerprint allowlist.
74// ---------------------------------------------------------------------------
75
76/// Load a rustls server config with client-cert-fingerprint verification.
77pub async fn load_mtls_rustls_config(
78    cert_path: &Path,
79    key_path: &Path,
80    allowlist_path: &Path,
81) -> Result<axum_server::tls_rustls::RustlsConfig> {
82    let allowlist = load_fingerprint_allowlist(allowlist_path).await?;
83    if allowlist.is_empty() {
84        anyhow::bail!(
85            "mTLS allowlist at {} is empty — refuse to start rather than silently accept all peers",
86            allowlist_path.display()
87        );
88    }
89
90    let cert_pem = tokio::fs::read(cert_path)
91        .await
92        .with_context(|| format!("failed to read TLS cert from {}", cert_path.display()))?;
93    let key_pem = tokio::fs::read(key_path)
94        .await
95        .with_context(|| format!("failed to read TLS key from {}", key_path.display()))?;
96
97    let certs: Vec<rustls::pki_types::CertificateDer<'static>> =
98        rustls_pki_pem_iter_certs(&cert_pem)?;
99    let key = rustls_pki_pem_parse_private_key(&key_pem)?;
100
101    let verifier = Arc::new(FingerprintAllowlistVerifier { allowlist });
102    let server_config = rustls::ServerConfig::builder()
103        .with_client_cert_verifier(verifier)
104        .with_single_cert(certs, key)
105        .context("failed to build rustls ServerConfig for mTLS")?;
106
107    Ok(axum_server::tls_rustls::RustlsConfig::from_config(
108        Arc::new(server_config),
109    ))
110}
111
112/// Parse the allowlist file: one SHA-256 fingerprint per line, case-insensitive
113/// hex with optional `:` separators. Empty lines and `#` comments are skipped.
114pub async fn load_fingerprint_allowlist(path: &Path) -> Result<HashSet<[u8; 32]>> {
115    let text = tokio::fs::read_to_string(path)
116        .await
117        .with_context(|| format!("failed to read mTLS allowlist from {}", path.display()))?;
118    let mut set = HashSet::new();
119    for (lineno, raw) in text.lines().enumerate() {
120        let line = raw.trim();
121        if line.is_empty() || line.starts_with('#') {
122            continue;
123        }
124        // Issue #358: tolerate inline trailing comments — anything after `#`
125        // on a non-comment line is dropped before the strict hex/colon
126        // validation below. Safe because `#` is not a valid hex/colon char,
127        // so it cannot appear in a legitimate SHA-256 fingerprint.
128        let line = line.split('#').next().unwrap_or("").trim();
129        if line.is_empty() {
130            continue;
131        }
132        // Accept a leading `sha256:` marker for forward-compat with richer formats.
133        let hex_part = line.strip_prefix("sha256:").unwrap_or(line);
134        // Ultrareview #338: reject any non-hex, non-colon character —
135        // including embedded whitespace/tabs. Previously the parser
136        // stripped only `:` and relied on the length check to catch
137        // whitespace, but silent acceptance of copy-paste artefacts
138        // (e.g. soft-wraps producing internal spaces) would produce
139        // misleading parse errors further down rather than a clear
140        // "whitespace not allowed" signal. Keep it strict.
141        if let Some(bad) = hex_part
142            .chars()
143            .find(|c| !c.is_ascii_hexdigit() && *c != ':')
144        {
145            anyhow::bail!(
146                "mTLS allowlist line {}: unexpected character {:?} — \
147                 entries must be 64 hex chars with optional `:` separators",
148                lineno + 1,
149                bad
150            );
151        }
152        let hex_clean: String = hex_part.chars().filter(|c| *c != ':').collect();
153        if hex_clean.len() != 64 {
154            anyhow::bail!(
155                "mTLS allowlist line {}: expected 64 hex chars (optionally with `:` separators), got {}",
156                lineno + 1,
157                hex_clean.len()
158            );
159        }
160        let mut bytes = [0u8; 32];
161        for i in 0..32 {
162            bytes[i] = u8::from_str_radix(&hex_clean[i * 2..i * 2 + 2], 16)
163                .with_context(|| format!("mTLS allowlist line {}: invalid hex", lineno + 1))?;
164        }
165        set.insert(bytes);
166    }
167    Ok(set)
168}
169
170pub fn rustls_pki_pem_iter_certs(
171    pem: &[u8],
172) -> Result<Vec<rustls::pki_types::CertificateDer<'static>>> {
173    use rustls::pki_types::pem::PemObject as _;
174    let mut cursor = std::io::Cursor::new(pem);
175    let certs: Vec<_> = rustls::pki_types::CertificateDer::pem_reader_iter(&mut cursor)
176        .collect::<std::result::Result<Vec<_>, _>>()
177        .context("failed to parse TLS cert PEM")?;
178    if certs.is_empty() {
179        anyhow::bail!("TLS cert PEM contained no certificates");
180    }
181    Ok(certs)
182}
183
184pub fn rustls_pki_pem_parse_private_key(
185    pem: &[u8],
186) -> Result<rustls::pki_types::PrivateKeyDer<'static>> {
187    use rustls::pki_types::pem::PemObject as _;
188    let mut cursor = std::io::Cursor::new(pem);
189    let key = rustls::pki_types::PrivateKeyDer::from_pem_reader(&mut cursor)
190        .context("failed to parse TLS key PEM — expected PKCS#8, RSA, or SEC1")?;
191    Ok(key)
192}
193
194/// Custom `ClientCertVerifier` that accepts only client certs whose SHA-256
195/// DER fingerprint is on the allowlist. Ignores CA chain — fingerprint
196/// pinning is the trust anchor here, same model as SSH `known_hosts`.
197#[derive(Debug)]
198pub struct FingerprintAllowlistVerifier {
199    pub allowlist: HashSet<[u8; 32]>,
200}
201
202impl rustls::server::danger::ClientCertVerifier for FingerprintAllowlistVerifier {
203    fn offer_client_auth(&self) -> bool {
204        true
205    }
206
207    fn client_auth_mandatory(&self) -> bool {
208        true
209    }
210
211    fn root_hint_subjects(&self) -> &[rustls::DistinguishedName] {
212        &[]
213    }
214
215    fn verify_client_cert(
216        &self,
217        end_entity: &rustls::pki_types::CertificateDer<'_>,
218        _intermediates: &[rustls::pki_types::CertificateDer<'_>],
219        _now: rustls::pki_types::UnixTime,
220    ) -> std::result::Result<rustls::server::danger::ClientCertVerified, rustls::Error> {
221        use sha2::{Digest, Sha256};
222        let fp: [u8; 32] = Sha256::digest(end_entity.as_ref()).into();
223        if self.allowlist.contains(&fp) {
224            Ok(rustls::server::danger::ClientCertVerified::assertion())
225        } else {
226            Err(rustls::Error::General(format!(
227                "client cert fingerprint {} not in mTLS allowlist",
228                hex_short(&fp)
229            )))
230        }
231    }
232
233    fn verify_tls12_signature(
234        &self,
235        message: &[u8],
236        cert: &rustls::pki_types::CertificateDer<'_>,
237        dss: &rustls::DigitallySignedStruct,
238    ) -> std::result::Result<rustls::client::danger::HandshakeSignatureValid, rustls::Error> {
239        rustls::crypto::verify_tls12_signature(
240            message,
241            cert,
242            dss,
243            &rustls::crypto::ring::default_provider().signature_verification_algorithms,
244        )
245    }
246
247    fn verify_tls13_signature(
248        &self,
249        message: &[u8],
250        cert: &rustls::pki_types::CertificateDer<'_>,
251        dss: &rustls::DigitallySignedStruct,
252    ) -> std::result::Result<rustls::client::danger::HandshakeSignatureValid, rustls::Error> {
253        rustls::crypto::verify_tls13_signature(
254            message,
255            cert,
256            dss,
257            &rustls::crypto::ring::default_provider().signature_verification_algorithms,
258        )
259    }
260
261    fn supported_verify_schemes(&self) -> Vec<rustls::SignatureScheme> {
262        rustls::crypto::ring::default_provider()
263            .signature_verification_algorithms
264            .supported_schemes()
265    }
266}
267
268pub fn hex_short(fp: &[u8; 32]) -> String {
269    use std::fmt::Write as _;
270    let mut s = String::with_capacity(12);
271    for b in &fp[..6] {
272        let _ = write!(s, "{b:02x}");
273    }
274    s.push('…');
275    s
276}
277
278/// Build a rustls `ClientConfig` with client-cert auth and a
279/// "dangerously-accept-any-server-cert" verifier. Used by the
280/// sync-daemon to present its client cert on every outbound request
281/// while connecting to peers with self-signed server certs. Peer
282/// authenticity is established on the other direction (they verify
283/// us via `--mtls-allowlist`).
284pub async fn build_rustls_client_config(
285    cert_path: &Path,
286    key_path: &Path,
287) -> Result<rustls::ClientConfig> {
288    let cert_pem = tokio::fs::read(cert_path)
289        .await
290        .with_context(|| format!("failed to read client cert from {}", cert_path.display()))?;
291    let key_pem = tokio::fs::read(key_path)
292        .await
293        .with_context(|| format!("failed to read client key from {}", key_path.display()))?;
294
295    let certs = rustls_pki_pem_iter_certs(&cert_pem)?;
296    let key = rustls_pki_pem_parse_private_key(&key_pem)?;
297
298    // SAFETY: we accept any server cert because the server authenticates
299    // US via our client cert fingerprint (Layer 2's trust anchor), not
300    // via server-cert validation. Server-cert pinning is a Layer 2b
301    // refinement tracked in #224.
302    let config = rustls::ClientConfig::builder()
303        .dangerous()
304        .with_custom_certificate_verifier(Arc::new(DangerousAnyServerVerifier))
305        .with_client_auth_cert(certs, key)
306        .context("failed to build rustls ClientConfig with client cert")?;
307    Ok(config)
308}
309
310/// `ServerCertVerifier` that accepts any peer certificate. Safe ONLY when
311/// paired with a strong reverse authentication channel — in our case the
312/// peer's `--mtls-allowlist` fingerprint-pins our client cert.
313#[derive(Debug)]
314pub struct DangerousAnyServerVerifier;
315
316impl rustls::client::danger::ServerCertVerifier for DangerousAnyServerVerifier {
317    fn verify_server_cert(
318        &self,
319        _end_entity: &rustls::pki_types::CertificateDer<'_>,
320        _intermediates: &[rustls::pki_types::CertificateDer<'_>],
321        _server_name: &rustls::pki_types::ServerName<'_>,
322        _ocsp_response: &[u8],
323        _now: rustls::pki_types::UnixTime,
324    ) -> std::result::Result<rustls::client::danger::ServerCertVerified, rustls::Error> {
325        Ok(rustls::client::danger::ServerCertVerified::assertion())
326    }
327
328    fn verify_tls12_signature(
329        &self,
330        message: &[u8],
331        cert: &rustls::pki_types::CertificateDer<'_>,
332        dss: &rustls::DigitallySignedStruct,
333    ) -> std::result::Result<rustls::client::danger::HandshakeSignatureValid, rustls::Error> {
334        rustls::crypto::verify_tls12_signature(
335            message,
336            cert,
337            dss,
338            &rustls::crypto::ring::default_provider().signature_verification_algorithms,
339        )
340    }
341
342    fn verify_tls13_signature(
343        &self,
344        message: &[u8],
345        cert: &rustls::pki_types::CertificateDer<'_>,
346        dss: &rustls::DigitallySignedStruct,
347    ) -> std::result::Result<rustls::client::danger::HandshakeSignatureValid, rustls::Error> {
348        rustls::crypto::verify_tls13_signature(
349            message,
350            cert,
351            dss,
352            &rustls::crypto::ring::default_provider().signature_verification_algorithms,
353        )
354    }
355
356    fn supported_verify_schemes(&self) -> Vec<rustls::SignatureScheme> {
357        rustls::crypto::ring::default_provider()
358            .signature_verification_algorithms
359            .supported_schemes()
360    }
361}
362
363// ---------------------------------------------------------------------------
364// Unit tests — pure-function and verifier coverage. Integration tests
365// (anything requiring on-disk PEM fixtures end-to-end) live in
366// `tests/tls_integration.rs` so the bin's compile time stays small.
367// ---------------------------------------------------------------------------
368#[cfg(test)]
369mod tests {
370    use super::*;
371    use rustls::server::danger::ClientCertVerifier;
372
373    /// Convenience: write `body` to a temp file and return the temp file
374    /// (kept so the caller can `tmp.path()`).
375    fn write_tmp(body: &str) -> tempfile::NamedTempFile {
376        let tmp = tempfile::NamedTempFile::new().unwrap();
377        std::fs::write(tmp.path(), body).unwrap();
378        tmp
379    }
380
381    // -----------------------------------------------------------------------
382    // Allowlist parser
383    // -----------------------------------------------------------------------
384
385    #[tokio::test]
386    async fn test_allowlist_empty_file_errors() {
387        // An empty allowlist file produces an empty set. The "refuse to
388        // start" check lives in `load_mtls_rustls_config`, not the parser
389        // — so the parser succeeds with zero entries.
390        let tmp = write_tmp("");
391        let set = load_fingerprint_allowlist(tmp.path()).await.unwrap();
392        assert!(set.is_empty());
393    }
394
395    #[tokio::test]
396    async fn test_allowlist_only_comments_errors() {
397        // Comment-only file should likewise produce an empty set; the
398        // empty-allowlist guard is enforced one layer up.
399        let tmp = write_tmp("# header\n# more\n  # indented\n");
400        let set = load_fingerprint_allowlist(tmp.path()).await.unwrap();
401        assert!(set.is_empty());
402    }
403
404    #[tokio::test]
405    async fn test_allowlist_single_valid_fp() {
406        let fp = "a".repeat(64);
407        let tmp = write_tmp(&format!("{fp}\n"));
408        let set = load_fingerprint_allowlist(tmp.path()).await.unwrap();
409        assert_eq!(set.len(), 1);
410        assert!(set.contains(&[0xaa; 32]));
411    }
412
413    #[tokio::test]
414    async fn test_allowlist_with_colons() {
415        let fp = format!("{}:{}", "b".repeat(32), "b".repeat(32));
416        let tmp = write_tmp(&format!("{fp}\n"));
417        let set = load_fingerprint_allowlist(tmp.path()).await.unwrap();
418        assert_eq!(set.len(), 1);
419        assert!(set.contains(&[0xbb; 32]));
420    }
421
422    #[tokio::test]
423    async fn test_allowlist_sha256_prefix() {
424        let fp = format!("sha256:{}", "c".repeat(64));
425        let tmp = write_tmp(&format!("{fp}\n"));
426        let set = load_fingerprint_allowlist(tmp.path()).await.unwrap();
427        assert_eq!(set.len(), 1);
428        assert!(set.contains(&[0xcc; 32]));
429    }
430
431    /// Issue #358 — trailing inline comment after a fingerprint must parse.
432    #[tokio::test]
433    async fn test_allowlist_inline_comment() {
434        let fp = "d".repeat(64);
435        let body = format!("{fp}  # node-1 mTLS\n");
436        let tmp = write_tmp(&body);
437        let set = load_fingerprint_allowlist(tmp.path()).await.unwrap();
438        assert_eq!(set.len(), 1);
439        assert!(set.contains(&[0xdd; 32]));
440    }
441
442    #[tokio::test]
443    async fn test_allowlist_too_short_errors() {
444        let tmp = write_tmp(&"a".repeat(63));
445        let err = load_fingerprint_allowlist(tmp.path()).await.unwrap_err();
446        assert!(
447            err.to_string().contains("expected 64 hex chars"),
448            "got: {err}"
449        );
450    }
451
452    #[tokio::test]
453    async fn test_allowlist_too_long_errors() {
454        let tmp = write_tmp(&"a".repeat(65));
455        let err = load_fingerprint_allowlist(tmp.path()).await.unwrap_err();
456        assert!(
457            err.to_string().contains("expected 64 hex chars"),
458            "got: {err}"
459        );
460    }
461
462    #[tokio::test]
463    async fn test_allowlist_invalid_hex_errors() {
464        // 64 chars, but `z` is non-hex → must hit the strict char check.
465        let mut s = "a".repeat(63);
466        s.push('z');
467        let tmp = write_tmp(&s);
468        let err = load_fingerprint_allowlist(tmp.path()).await.unwrap_err();
469        assert!(
470            err.to_string().contains("unexpected character"),
471            "got: {err}"
472        );
473    }
474
475    /// Issue #338 — embedded whitespace inside the hex run must error
476    /// with "unexpected character", not silently get stripped.
477    #[tokio::test]
478    async fn test_allowlist_embedded_whitespace_errors() {
479        let body = format!("{} {}\n", "a".repeat(32), "a".repeat(32));
480        let tmp = write_tmp(&body);
481        let err = load_fingerprint_allowlist(tmp.path()).await.unwrap_err();
482        assert!(
483            err.to_string().contains("unexpected character"),
484            "got: {err}"
485        );
486    }
487
488    #[tokio::test]
489    async fn test_allowlist_tab_in_hex_errors() {
490        let body = format!("{}\t{}\n", "a".repeat(32), "a".repeat(32));
491        let tmp = write_tmp(&body);
492        let err = load_fingerprint_allowlist(tmp.path()).await.unwrap_err();
493        assert!(
494            err.to_string().contains("unexpected character"),
495            "got: {err}"
496        );
497    }
498
499    #[tokio::test]
500    async fn test_allowlist_blank_lines_skipped() {
501        let fp = "a".repeat(64);
502        let body = format!("\n\n  \n{fp}\n\n   \n");
503        let tmp = write_tmp(&body);
504        let set = load_fingerprint_allowlist(tmp.path()).await.unwrap();
505        assert_eq!(set.len(), 1);
506    }
507
508    #[tokio::test]
509    async fn test_allowlist_multiple_entries() {
510        let fp_a = "a".repeat(64);
511        let fp_b = "b".repeat(64);
512        let fp_c = format!("{}:{}", "c".repeat(32), "c".repeat(32));
513        let body = format!(
514            "# header\n\
515             {fp_a}\n\
516             sha256:{fp_b}\n\
517             {fp_c}\n"
518        );
519        let tmp = write_tmp(&body);
520        let set = load_fingerprint_allowlist(tmp.path()).await.unwrap();
521        assert_eq!(set.len(), 3);
522        assert!(set.contains(&[0xaa; 32]));
523        assert!(set.contains(&[0xbb; 32]));
524        assert!(set.contains(&[0xcc; 32]));
525    }
526
527    #[tokio::test]
528    async fn test_allowlist_duplicate_entries_dedup() {
529        let fp = "e".repeat(64);
530        let body = format!("{fp}\n{fp}\n{fp}\n");
531        let tmp = write_tmp(&body);
532        let set = load_fingerprint_allowlist(tmp.path()).await.unwrap();
533        // HashSet collapses dupes — exactly one fingerprint registered.
534        assert_eq!(set.len(), 1);
535        assert!(set.contains(&[0xee; 32]));
536    }
537
538    // -----------------------------------------------------------------------
539    // PEM parsers
540    // -----------------------------------------------------------------------
541
542    #[test]
543    fn test_pem_iter_certs_empty_errors() {
544        let err = rustls_pki_pem_iter_certs(b"").unwrap_err();
545        // No certs at all → either parse-error or "contained no certificates".
546        // The empty input is not a parse failure, it's just zero certs.
547        assert!(
548            err.to_string().contains("no certificates")
549                || err.to_string().contains("failed to parse"),
550            "got: {err}"
551        );
552    }
553
554    #[test]
555    fn test_pem_iter_certs_garbage_errors() {
556        let err = rustls_pki_pem_iter_certs(b"not a pem file\n").unwrap_err();
557        assert!(
558            err.to_string().contains("no certificates")
559                || err.to_string().contains("failed to parse"),
560            "got: {err}"
561        );
562    }
563
564    #[test]
565    fn test_pem_iter_certs_single_cert() {
566        let pem = std::fs::read("tests/fixtures/tls/valid_cert.pem")
567            .expect("regenerate fixtures via tests/fixtures/tls/regenerate.sh");
568        let certs = rustls_pki_pem_iter_certs(&pem).unwrap();
569        assert_eq!(
570            certs.len(),
571            1,
572            "expected exactly one cert in valid_cert.pem"
573        );
574    }
575
576    #[test]
577    fn test_pem_iter_certs_chain() {
578        let pem = std::fs::read("tests/fixtures/tls/cert_chain.pem")
579            .expect("regenerate fixtures via tests/fixtures/tls/regenerate.sh");
580        let certs = rustls_pki_pem_iter_certs(&pem).unwrap();
581        assert!(
582            certs.len() >= 2,
583            "expected leaf + intermediate, got {}",
584            certs.len()
585        );
586    }
587
588    #[test]
589    fn test_pem_parse_pkcs8_key() {
590        let pem = std::fs::read("tests/fixtures/tls/valid_key_pkcs8.pem")
591            .expect("regenerate fixtures via tests/fixtures/tls/regenerate.sh");
592        let key = rustls_pki_pem_parse_private_key(&pem).unwrap();
593        // PKCS#8 envelopes RSA / ECDSA / Ed25519. The discriminant tells us
594        // rustls picked the right branch — any PrivateKeyDer variant is fine.
595        let _ = key;
596    }
597
598    #[test]
599    fn test_pem_parse_rsa_key() {
600        let pem = std::fs::read("tests/fixtures/tls/valid_key_rsa.pem")
601            .expect("regenerate fixtures via tests/fixtures/tls/regenerate.sh");
602        let key = rustls_pki_pem_parse_private_key(&pem).unwrap();
603        let _ = key;
604    }
605
606    #[test]
607    fn test_pem_parse_sec1_key() {
608        let pem = std::fs::read("tests/fixtures/tls/valid_key_sec1.pem")
609            .expect("regenerate fixtures via tests/fixtures/tls/regenerate.sh");
610        let key = rustls_pki_pem_parse_private_key(&pem).unwrap();
611        let _ = key;
612    }
613
614    #[test]
615    fn test_pem_parse_garbage_errors() {
616        let err = rustls_pki_pem_parse_private_key(b"not a pem file\n").unwrap_err();
617        assert!(err.to_string().contains("failed to parse TLS key PEM"));
618    }
619
620    // -----------------------------------------------------------------------
621    // hex_short
622    // -----------------------------------------------------------------------
623
624    #[test]
625    fn test_hex_short_format() {
626        // 6 bytes prefix → 12 hex chars + ellipsis.
627        let mut fp = [0u8; 32];
628        fp[0] = 0xde;
629        fp[1] = 0xad;
630        fp[2] = 0xbe;
631        fp[3] = 0xef;
632        fp[4] = 0x12;
633        fp[5] = 0x34;
634        // Bytes 6..32 must NOT appear in the output.
635        for (i, slot) in fp.iter_mut().enumerate().skip(6) {
636            *slot = (i as u8).wrapping_mul(7);
637        }
638        assert_eq!(hex_short(&fp), "deadbeef1234…");
639    }
640
641    #[test]
642    fn test_hex_short_truncates_to_6_bytes() {
643        let fp = [0xff; 32];
644        let s = hex_short(&fp);
645        // Strip the trailing ellipsis (`…` is 3 bytes in UTF-8).
646        let hex_only = s.trim_end_matches('…');
647        assert_eq!(hex_only.len(), 12, "expected 6 bytes = 12 hex chars");
648        assert_eq!(hex_only, "ffffffffffff");
649    }
650
651    // -----------------------------------------------------------------------
652    // FingerprintAllowlistVerifier
653    // -----------------------------------------------------------------------
654
655    #[test]
656    fn test_verifier_accepts_allowlisted_fp() {
657        use sha2::{Digest, Sha256};
658        // Synthesize a "cert" — the verifier doesn't validate ASN.1 here,
659        // only hashes the DER bytes. Any byte slice works; we just need
660        // the fingerprint and the cert bytes to match.
661        let fake_cert = b"fake certificate DER bytes for fingerprint test";
662        let fp: [u8; 32] = Sha256::digest(fake_cert).into();
663        let mut allowlist = HashSet::new();
664        allowlist.insert(fp);
665        let verifier = FingerprintAllowlistVerifier { allowlist };
666        let cert = rustls::pki_types::CertificateDer::from(fake_cert.to_vec());
667        let now = rustls::pki_types::UnixTime::now();
668        let result = verifier.verify_client_cert(&cert, &[], now);
669        assert!(result.is_ok(), "expected accept, got: {result:?}");
670    }
671
672    #[test]
673    fn test_verifier_rejects_unknown_fp() {
674        let allowlist = HashSet::new();
675        let verifier = FingerprintAllowlistVerifier { allowlist };
676        let cert = rustls::pki_types::CertificateDer::from(b"unknown".to_vec());
677        let now = rustls::pki_types::UnixTime::now();
678        let err = verifier.verify_client_cert(&cert, &[], now).unwrap_err();
679        assert!(
680            err.to_string().contains("not in mTLS allowlist"),
681            "got: {err}"
682        );
683    }
684
685    #[test]
686    fn test_verifier_error_includes_truncated_fp() {
687        let allowlist = HashSet::new();
688        let verifier = FingerprintAllowlistVerifier { allowlist };
689        let cert_bytes = b"some cert that won't be in the allowlist";
690        let cert = rustls::pki_types::CertificateDer::from(cert_bytes.to_vec());
691        let now = rustls::pki_types::UnixTime::now();
692        let err = verifier.verify_client_cert(&cert, &[], now).unwrap_err();
693        let msg = err.to_string();
694        // Compute the expected truncated fp prefix and assert it's present.
695        use sha2::{Digest, Sha256};
696        let fp: [u8; 32] = Sha256::digest(cert_bytes).into();
697        let short = hex_short(&fp);
698        assert!(msg.contains(&short), "expected fp {short} in: {msg}");
699        // And the trailing `…` must be there — the fp must be truncated,
700        // not full-length.
701        assert!(msg.contains('…'), "expected truncation marker in: {msg}");
702    }
703
704    #[test]
705    fn test_verifier_offer_client_auth_returns_true() {
706        let verifier = FingerprintAllowlistVerifier {
707            allowlist: HashSet::new(),
708        };
709        assert!(verifier.offer_client_auth());
710    }
711
712    #[test]
713    fn test_verifier_client_auth_mandatory_returns_true() {
714        let verifier = FingerprintAllowlistVerifier {
715            allowlist: HashSet::new(),
716        };
717        assert!(verifier.client_auth_mandatory());
718        // Also exercise root_hint_subjects — it's a one-line getter that
719        // would otherwise sit at zero coverage.
720        assert_eq!(verifier.root_hint_subjects().len(), 0);
721    }
722
723    /// Build a bogus `DigitallySignedStruct` from the on-the-wire byte
724    /// format: 2-byte big-endian scheme + 2-byte big-endian signature
725    /// length + N signature bytes. `DigitallySignedStruct::new` is
726    /// crate-private in rustls 0.23, but the wire decoder is reachable
727    /// through `rustls::internal::msgs::codec::{Codec, Reader}`.
728    fn bogus_dss() -> rustls::DigitallySignedStruct {
729        use rustls::internal::msgs::codec::{Codec, Reader};
730        // ED25519 = 0x0807. Sig length = 0x0040 (64). Then 64 zero bytes.
731        let mut wire = Vec::with_capacity(4 + 64);
732        wire.extend_from_slice(&[0x08, 0x07]);
733        wire.extend_from_slice(&[0x00, 0x40]);
734        wire.extend_from_slice(&[0u8; 64]);
735        let mut reader = Reader::init(&wire);
736        rustls::DigitallySignedStruct::read(&mut reader)
737            .expect("hand-rolled wire bytes must round-trip the Codec")
738    }
739
740    /// Exercise the rustls `verify_tls{12,13}_signature` + `supported_verify_schemes`
741    /// trait methods on `FingerprintAllowlistVerifier`. We feed them a
742    /// deliberately invalid signature so the underlying ring-backed
743    /// verifier returns Err — that's fine, the test only asserts the
744    /// method runs to completion (covers the body) without panicking.
745    #[test]
746    fn test_verifier_signature_methods_run() {
747        let _ = rustls::crypto::ring::default_provider().install_default();
748        let verifier = FingerprintAllowlistVerifier {
749            allowlist: HashSet::new(),
750        };
751        // supported_verify_schemes is pure — must return non-empty.
752        let schemes = verifier.supported_verify_schemes();
753        assert!(
754            !schemes.is_empty(),
755            "ring provider must expose at least one signature scheme"
756        );
757
758        // verify_tls{12,13}_signature: feed bogus inputs and expect Err.
759        let cert = rustls::pki_types::CertificateDer::from(vec![0u8; 32]);
760        let dss = bogus_dss();
761        let _ = verifier.verify_tls12_signature(b"bogus message", &cert, &dss);
762        let _ = verifier.verify_tls13_signature(b"bogus message", &cert, &dss);
763    }
764
765    // -----------------------------------------------------------------------
766    // DangerousAnyServerVerifier — the sync-daemon's client-side verifier.
767    // verify_server_cert always Ok; the signature methods delegate to the
768    // ring provider exactly like the server-side verifier above.
769    // -----------------------------------------------------------------------
770
771    #[test]
772    fn test_dangerous_any_server_verifier_accepts_any_cert() {
773        use rustls::client::danger::ServerCertVerifier;
774        let _ = rustls::crypto::ring::default_provider().install_default();
775        let verifier = DangerousAnyServerVerifier;
776        let cert = rustls::pki_types::CertificateDer::from(b"any bytes here".to_vec());
777        let server_name = rustls::pki_types::ServerName::try_from("example.com").unwrap();
778        let now = rustls::pki_types::UnixTime::now();
779        let result = verifier.verify_server_cert(&cert, &[], &server_name, &[], now);
780        assert!(
781            result.is_ok(),
782            "DangerousAnyServerVerifier accepts any cert (compensating mTLS control)"
783        );
784    }
785
786    #[test]
787    fn test_dangerous_any_server_verifier_signature_methods_run() {
788        use rustls::client::danger::ServerCertVerifier;
789        let _ = rustls::crypto::ring::default_provider().install_default();
790        let verifier = DangerousAnyServerVerifier;
791        let schemes = verifier.supported_verify_schemes();
792        assert!(!schemes.is_empty());
793
794        let cert = rustls::pki_types::CertificateDer::from(vec![0u8; 32]);
795        let dss = bogus_dss();
796        let _ = verifier.verify_tls12_signature(b"bogus message", &cert, &dss);
797        let _ = verifier.verify_tls13_signature(b"bogus message", &cert, &dss);
798    }
799
800    // -----------------------------------------------------------------------
801    // build_rustls_client_config — exercises the sync-daemon's outbound
802    // TLS config path. Covers both the happy and the missing-cert error
803    // paths so the entire function body is reached by unit tests.
804    // -----------------------------------------------------------------------
805
806    #[tokio::test]
807    async fn test_build_rustls_client_config_happy_path() {
808        let _ = rustls::crypto::ring::default_provider().install_default();
809        let cert = std::path::PathBuf::from(env!("CARGO_MANIFEST_DIR"))
810            .join("tests/fixtures/tls/valid_cert.pem");
811        let key = std::path::PathBuf::from(env!("CARGO_MANIFEST_DIR"))
812            .join("tests/fixtures/tls/valid_key_pkcs8.pem");
813        let config = build_rustls_client_config(&cert, &key)
814            .await
815            .expect("client config build with valid cert+key");
816        // The returned ClientConfig is opaque; if the ?-cascade above
817        // returned Ok, every parser branch and the builder ran.
818        drop(config);
819    }
820
821    #[tokio::test]
822    async fn test_build_rustls_client_config_missing_cert_errors() {
823        let cert = std::path::PathBuf::from("/does/not/exist/cert.pem");
824        let key = std::path::PathBuf::from(env!("CARGO_MANIFEST_DIR"))
825            .join("tests/fixtures/tls/valid_key_pkcs8.pem");
826        let err = build_rustls_client_config(&cert, &key)
827            .await
828            .expect_err("missing client cert must error");
829        assert!(
830            err.to_string().contains("failed to read client cert"),
831            "got: {err}"
832        );
833    }
834}