Skip to main content

ts_control/
cert.rs

1//! TLS certificate acquisition for a node's MagicDNS name (`host.tailnet.ts.net`).
2//!
3//! # What tsnet does (the real protocol — there is NO control `cert/<domain>` RPC)
4//!
5//! In upstream Tailscale, `tsnet`'s `GetCertificate` mints a *real* publicly
6//! trusted certificate for the node's MagicDNS name. Contrary to a common
7//! misreading, control does **not** run the ACME order on the node's behalf and
8//! there is **no** `POST /machine/<machineKey>/cert/<domain>` endpoint. Instead
9//! **the node itself is the ACME client** and talks **directly to Let's
10//! Encrypt**; control's *only* role is to publish the DNS-01 challenge TXT record
11//! into the `ts.net` zone it controls (the node has no authority over that zone).
12//! The real flow upstream is:
13//!
14//! 1. node generates/loads an ACME account key (ECDSA P-256) and a fresh cert
15//!    key, and opens an ACME order for `<name>` directly against Let's Encrypt,
16//! 2. for the **DNS-01** challenge, the node computes the challenge digest and
17//!    asks control to publish it by sending, over the **Noise (ts2021)** channel,
18//!    `POST /machine/set-dns` with body
19//!    `tailcfg.SetDNSRequest{ Version: <current cap>, NodeKey: <node pub>,
20//!    Name: "_acme-challenge.<name>", Type: "TXT", Value: <digest> }`
21//!    (note: `NodeKey` travels in the BODY, not the URL; the response is an empty
22//!    `SetDNSResponse{}` with HTTP 200 on success),
23//! 3. node tells Let's Encrypt the challenge is ready; LE validates the TXT,
24//! 4. node finalizes the order and downloads the signed leaf + chain *from LE*,
25//! 5. node assembles a [`rustls::sign::CertifiedKey`] and serves it, renewing at
26//!    ~2/3 of lifetime (with ARI).
27//!
28//! (DNS-01 is used for `*.ts.net`; TLS-ALPN-01 is used for Funnel/BYO domains;
29//! HTTP-01 is not used.)
30//!
31//! ## Gap verdict for THIS fork (fail-closed seam, no fake cert)
32//!
33//! The control client in this crate (`ts_control::tokio`) implements exactly
34//! these control RPCs and **no others**:
35//!
36//! - `GET /key`            — control/Noise public key fetch ([`crate::tokio::connect`])
37//! - `POST /ts2021`        — Noise (ts2021) handshake upgrade
38//! - `POST /machine/register` — node registration ([`crate::tokio::register`])
39//! - `POST /machine/map`   — netmap stream + endpoint/derp updates
40//! - ping-response callback (`/machine/.../ping`)
41//!
42//! There is **no** `POST /machine/set-dns` client and **no** ACME engine. Neither
43//! the DNS-01 TXT publish RPC nor the LE-facing order/challenge/finalize state
44//! machine exists, so a node cannot obtain a publicly trusted cert for its
45//! `*.ts.net` name here.
46//!
47//! Because issuing a real cert is impossible and self-signing for production is
48//! forbidden (it would not be publicly trusted and would teach callers to expect
49//! a working `ListenTLS`), [`get_certificate`] returns
50//! [`CertError::Unimplemented`] naming exactly what is missing. This is
51//! **fail-closed**: no self-signed fallback, no plaintext downgrade.
52//!
53//! ## What a future implementation needs (so this seam can be filled in place)
54//!
55//! - A **client-side ACME engine** (talks to Let's Encrypt directly, not to
56//!   control): ACME account key + cert key generation (ECDSA P-256 via `rcgen`,
57//!   ring-only), JWS-signed order/authz/challenge/finalize, and leaf+chain
58//!   download. Renew at ~2/3 lifetime.
59//! - A `POST /machine/set-dns` Noise RPC client to publish the
60//!   `_acme-challenge.<name>` TXT record (body carries `NodeKey`; see step 2
61//!   above). Add it alongside the existing RPCs in [`crate::tokio`]
62//!   (`register.rs` is the template; the Noise transport is `connect.rs`).
63//! - Local ACME account-key persistence keyed to the node identity.
64//!
65//! **Deployment caveat (why this is currently stubbed, not built):** a
66//! self-hosted control plane target may return **HTTP 501
67//! NotImplemented** for `/machine/set-dns`. A client-side ACME engine therefore
68//! cannot complete a DNS-01 challenge against such a control plane — the issuance path
69//! is non-functional until the control plane grows `set-dns` + a real backing DNS zone
70//! (separate, out-of-repo work). Building the ACME engine here without that would
71//! be dead code against the actual control plane.
72//!
73//! Once both pieces land (and control answers `set-dns`), replace the
74//! [`CertError::Unimplemented`] branch in [`get_certificate`] with: open order ->
75//! publish TXT via `set-dns` -> finalize -> assemble [`CertifiedKey`] from the
76//! LE-returned chain + locally held key via [`certified_key_from_pem`].
77
78use tokio_rustls::rustls::{
79    pki_types::{CertificateDer, PrivateKeyDer},
80    sign::CertifiedKey,
81};
82
83/// The control-plane seam the ACME DNS-01 engine depends on: publish (and later clear) the
84/// `_acme-challenge.<name>` TXT record in the `ts.net` zone control owns, by sending the node's
85/// `POST /machine/set-dns` Noise RPC.
86///
87/// Implemented by the runtime's control-RPC layer (which holds the Noise transport + node keys);
88/// the ACME engine ([`crate::acme`], `acme` feature) calls it without depending on the actor types.
89/// `name` is the FULL record name (`_acme-challenge.<host>.<tailnet>.ts.net`), `value` the
90/// base64url-unpadded DNS-01 digest. Returning `Err` fails the issuance closed (no cert).
91#[cfg(feature = "acme")]
92pub trait PublishTxt {
93    /// Publish the DNS-01 challenge TXT record via `POST /machine/set-dns`. Resolves once control
94    /// has accepted the record (HTTP 200 / empty `SetDnsResponse`).
95    fn publish_txt(
96        &self,
97        name: &str,
98        value: &str,
99    ) -> std::pin::Pin<Box<dyn core::future::Future<Output = Result<(), CertError>> + Send + '_>>;
100}
101
102/// Map a [`crate::tokio::SetDnsError`] into [`CertError::Acme`].
103///
104/// The DNS-01 publish is the one I/O step of issuance the ACME engine reaches through the
105/// [`PublishTxt`] seam; fold the set-dns RPC's own error vocabulary into the cert error surface
106/// (its `Display` carries the coarse cause, e.g. the self-hosted control plane 501 `Internal(Http)`).
107#[cfg(feature = "acme")]
108impl From<crate::tokio::SetDnsError> for CertError {
109    fn from(error: crate::tokio::SetDnsError) -> Self {
110        CertError::Acme(format!("set-dns publish failed: {error}"))
111    }
112}
113
114/// A [`PublishTxt`] backed by the node's `POST /machine/set-dns` Noise RPC.
115///
116/// Borrows the node's [`crate::Config`] (control URL + transport) and [`ts_keys::NodeState`] (node
117/// keys for the Noise channel) and publishes the `_acme-challenge.<name>` `TXT` record through
118/// [`crate::tokio::set_dns`]. SaaS-only: a self-hosted control plane typically 501s on `set-dns`, surfaced as
119/// [`CertError::Acme`].
120#[cfg(feature = "acme")]
121pub struct SetDnsPublisher<'a> {
122    /// Control config (server URL + transport) the set-dns RPC dials.
123    config: &'a crate::Config,
124    /// The node's key state, providing the node/machine keys for the Noise channel.
125    node_keystate: &'a ts_keys::NodeState,
126}
127
128#[cfg(feature = "acme")]
129impl<'a> SetDnsPublisher<'a> {
130    /// Build a publisher borrowing the node's control `config` and `node_keystate`.
131    pub fn new(config: &'a crate::Config, node_keystate: &'a ts_keys::NodeState) -> Self {
132        Self {
133            config,
134            node_keystate,
135        }
136    }
137}
138
139#[cfg(feature = "acme")]
140impl PublishTxt for SetDnsPublisher<'_> {
141    fn publish_txt(
142        &self,
143        name: &str,
144        value: &str,
145    ) -> std::pin::Pin<Box<dyn core::future::Future<Output = Result<(), CertError>> + Send + '_>>
146    {
147        let name = name.to_string();
148        let value = value.to_string();
149        Box::pin(async move {
150            crate::tokio::set_dns(self.config, self.node_keystate, &name, "TXT", &value)
151                .await
152                .map_err(CertError::from)
153        })
154    }
155}
156
157/// Issue a real certificate for `name` via the client-side ACME DNS-01 engine, publishing the
158/// challenge TXT through the node's `POST /machine/set-dns` RPC, returning the full
159/// [`IssuedCert`](crate::acme::IssuedCert) (the [`CertifiedKey`] **plus** the chain + leaf-key PEMs).
160///
161/// This is the single issuance entry point: [`issue_certificate_via_setdns`] (which needs only the
162/// [`CertifiedKey`] for the `get_certificate` / `ListenTLS` path) delegates here and drops the PEMs,
163/// while a caller needing the on-disk `.crt`/`.key` pair (the daemon's `tnet cert`, Go's
164/// `LocalClient.CertPair`) keeps them — one ACME order, two consumers.
165///
166/// `account_key` is the ACME account identity (persist its PKCS#8 DER across renewals — see the
167/// runtime caller); `directory_url` selects the ACME CA (production is
168/// [`crate::acme::LETS_ENCRYPT_PRODUCTION_DIRECTORY`]). Rejects non-tailnet names up front (anti-leak)
169/// before any network I/O. SaaS-only: against a self-hosted control plane the set-dns publish typically 501s, surfaced as
170/// [`CertError::Acme`]. Fail-closed: returns an [`IssuedCert`](crate::acme::IssuedCert) only when the
171/// LE order reached `valid` and the chain assembled. The leaf private key
172/// ([`IssuedCert::key_pem`](crate::acme::IssuedCert::key_pem)) is never logged.
173#[cfg(feature = "acme")]
174pub async fn issue_cert_pair_via_setdns(
175    config: &crate::Config,
176    node_keystate: &ts_keys::NodeState,
177    name: &str,
178    account_key: &crate::acme::AcmeAccountKey,
179    directory_url: &url::Url,
180) -> Result<crate::acme::IssuedCert, CertError> {
181    if !is_tailnet_name(name) {
182        return Err(CertError::NotTailnetName(name.to_string()));
183    }
184    let publisher = SetDnsPublisher::new(config, node_keystate);
185    crate::acme::issue_certificate(name, directory_url, account_key, &publisher).await
186}
187
188/// Issue a real certificate for `name` via the client-side ACME DNS-01 engine, returning just the
189/// ready-to-serve [`CertifiedKey`] (the `get_certificate` / `ListenTLS` path).
190///
191/// Thin wrapper over [`issue_cert_pair_via_setdns`] that discards the raw PEMs — one issuance, the
192/// caller here just doesn't need the on-disk pair. See that function for the full contract
193/// (anti-leak name check, SaaS-only set-dns, fail-closed).
194#[cfg(feature = "acme")]
195pub async fn issue_certificate_via_setdns(
196    config: &crate::Config,
197    node_keystate: &ts_keys::NodeState,
198    name: &str,
199    account_key: &crate::acme::AcmeAccountKey,
200    directory_url: &url::Url,
201) -> Result<CertifiedKey, CertError> {
202    issue_cert_pair_via_setdns(config, node_keystate, name, account_key, directory_url)
203        .await
204        .map(|issued| issued.certified)
205}
206
207/// Names exactly what this fork is missing to issue a real cert, surfaced
208/// verbatim in [`CertError::Unimplemented`] so the gap is self-documenting at
209/// runtime. There is no control `cert/<domain>` RPC in real Tailscale — the node
210/// is the ACME client and only needs control to publish the DNS-01 TXT via
211/// `POST /machine/set-dns` (which a self-hosted control plane typically 501s). See the module docs.
212pub const MISSING_CERT_RPC: &str = "client-side ACME engine (direct to Let's Encrypt) + a POST /machine/set-dns \
213     Noise RPC to publish the _acme-challenge TXT (a self-hosted control plane returns 501 for set-dns)";
214
215/// Errors from certificate acquisition / TLS material assembly.
216///
217/// Fail-closed by construction: there is no variant that yields a usable cert
218/// without a genuine issuance path, and there is deliberately no self-signed
219/// production fallback.
220#[derive(Debug)]
221pub enum CertError {
222    /// The control plane in this fork does not expose the RPC(s) needed to mint
223    /// a real certificate. `detail` names exactly what is missing.
224    Unimplemented {
225        /// Names exactly which control RPC is missing (e.g. [`MISSING_CERT_RPC`]).
226        detail: String,
227    },
228    /// An ACME-protocol-level failure (order/challenge/finalize).
229    Acme(String),
230    /// I/O failure (network, file, etc.).
231    Io(std::io::Error),
232    /// A rustls / crypto-material failure (bad key, mismatched cert, provider).
233    Rustls(tokio_rustls::rustls::Error),
234    /// The requested name is not a tailnet (`*.ts.net`-style) name. Anti-leak:
235    /// we never mint or serve certs for off-tailnet names.
236    NotTailnetName(String),
237}
238
239impl core::fmt::Display for CertError {
240    fn fmt(&self, f: &mut core::fmt::Formatter<'_>) -> core::fmt::Result {
241        match self {
242            CertError::Unimplemented { detail } => {
243                write!(
244                    f,
245                    "certificate acquisition is unimplemented in this fork: {detail}"
246                )
247            }
248            CertError::Acme(e) => write!(f, "ACME error: {e}"),
249            CertError::Io(e) => write!(f, "I/O error: {e}"),
250            CertError::Rustls(e) => write!(f, "rustls error: {e}"),
251            CertError::NotTailnetName(name) => {
252                write!(
253                    f,
254                    "refusing to obtain a certificate for non-tailnet name {name:?}"
255                )
256            }
257        }
258    }
259}
260
261impl std::error::Error for CertError {
262    fn source(&self) -> Option<&(dyn std::error::Error + 'static)> {
263        match self {
264            CertError::Io(e) => Some(e),
265            CertError::Rustls(e) => Some(e),
266            CertError::Unimplemented { .. } | CertError::Acme(_) | CertError::NotTailnetName(_) => {
267                None
268            }
269        }
270    }
271}
272
273impl From<std::io::Error> for CertError {
274    fn from(e: std::io::Error) -> Self {
275        CertError::Io(e)
276    }
277}
278
279impl From<tokio_rustls::rustls::Error> for CertError {
280    fn from(e: tokio_rustls::rustls::Error) -> Self {
281        CertError::Rustls(e)
282    }
283}
284
285/// Returns `true` if `name` looks like a tailnet MagicDNS name we may serve a
286/// cert for. We only ever mint/serve certs for tailnet names — never arbitrary
287/// public hostnames — to avoid being turned into a cert oracle for off-tailnet
288/// origins.
289pub fn is_tailnet_name(name: &str) -> bool {
290    // `host.tailnet.ts.net` (public) or `*.ts.net`. Keep this conservative.
291    let name = name.trim_end_matches('.');
292    !name.is_empty() && name.ends_with(".ts.net") && !name.contains('/')
293}
294
295/// Obtain a [`CertifiedKey`] for a node's MagicDNS `name`.
296///
297/// **Fail-closed.** In this fork the control plane exposes no ACME / DNS-01 cert
298/// RPC (see module docs), so this always returns [`CertError::Unimplemented`]
299/// once the name passes the tailnet-name check. It NEVER self-signs and NEVER
300/// returns a placeholder cert — a caller cannot accidentally serve an untrusted
301/// certificate.
302///
303/// When the control RPC ([`MISSING_CERT_RPC`]) is added, fill in the issuance
304/// branch here.
305pub async fn get_certificate(name: &str) -> Result<CertifiedKey, CertError> {
306    if !is_tailnet_name(name) {
307        return Err(CertError::NotTailnetName(name.to_string()));
308    }
309
310    // No client-side ACME engine and no set-dns RPC exist in this fork, and a
311    // self-hosted control target typically 501s on set-dns. Do NOT self-sign.
312    Err(CertError::Unimplemented {
313        detail: format!(
314            "cannot issue a real certificate for {name:?}; requires: {MISSING_CERT_RPC}"
315        ),
316    })
317}
318
319/// Assemble a [`CertifiedKey`] from a PEM chain + PEM private key, using the
320/// **ring** crypto provider's signing-key loader (matching the rest of the TLS
321/// stack — `ts_tls_util` is `tokio-rustls`/`ring`). This is the assembly helper
322/// a future real issuance path (or a test) feeds the control-returned chain into.
323///
324/// This does NOT fetch or issue anything; it only turns already-trusted material
325/// into the rustls representation. Production callers reach it only via a genuine
326/// issuance path; tests reach it with a clearly-marked self-signed cert.
327pub fn certified_key_from_pem(
328    cert_chain_pem: &[u8],
329    key_pem: &[u8],
330) -> Result<CertifiedKey, CertError> {
331    let certs: Vec<CertificateDer<'static>> =
332        rustls_pemfile::certs(&mut &cert_chain_pem[..]).collect::<Result<_, _>>()?;
333    if certs.is_empty() {
334        return Err(CertError::Acme(
335            "certificate chain PEM contained no certificates".into(),
336        ));
337    }
338
339    let key: PrivateKeyDer<'static> = rustls_pemfile::private_key(&mut &key_pem[..])?
340        .ok_or_else(|| CertError::Acme("private key PEM contained no key".into()))?;
341
342    certified_key_from_der(certs, key)
343}
344
345/// Assemble a [`CertifiedKey`] from DER cert chain + DER private key using the
346/// ring signing-key loader. Verifies the key matches the leaf (fail-closed).
347pub fn certified_key_from_der(
348    cert_chain: Vec<CertificateDer<'static>>,
349    key: PrivateKeyDer<'static>,
350) -> Result<CertifiedKey, CertError> {
351    // Match the rest of the stack: ring provider's signing-key loader, never
352    // auto-detect (which panics under ring+aws-lc feature unification).
353    // `any_supported_type` already yields an `Arc<dyn SigningKey>`; don't re-wrap.
354    let signing_key = tokio_rustls::rustls::crypto::ring::sign::any_supported_type(&key)
355        .map_err(CertError::Rustls)?;
356    let ck = CertifiedKey::new(cert_chain, signing_key);
357    ck.keys_match().map_err(CertError::Rustls)?;
358    Ok(ck)
359}
360
361#[cfg(test)]
362mod tests {
363    use super::*;
364
365    #[test]
366    fn tailnet_name_accepts_magicdns() {
367        assert!(is_tailnet_name("host.tail1234.ts.net"));
368        assert!(is_tailnet_name("host.tail1234.ts.net."));
369    }
370
371    #[test]
372    fn tailnet_name_rejects_offtailnet() {
373        assert!(!is_tailnet_name("example.com"));
374        assert!(!is_tailnet_name("evil.ts.net.attacker.com"));
375        assert!(!is_tailnet_name(""));
376        assert!(!is_tailnet_name("host.ts.net/path"));
377    }
378
379    #[tokio::test]
380    async fn get_certificate_is_fail_closed_unimplemented() {
381        let err = get_certificate("host.tail1234.ts.net")
382            .await
383            .expect_err("must not mint a cert without an ACME RPC");
384        match err {
385            CertError::Unimplemented { detail } => {
386                assert!(
387                    detail.contains("cert"),
388                    "detail should name the missing RPC: {detail}"
389                );
390            }
391            other => panic!("expected Unimplemented, got {other:?}"),
392        }
393    }
394
395    #[tokio::test]
396    async fn get_certificate_rejects_offtailnet_name() {
397        let err = get_certificate("example.com").await.unwrap_err();
398        assert!(matches!(err, CertError::NotTailnetName(_)));
399    }
400
401    #[test]
402    fn cert_error_is_std_error_and_displays() {
403        let e = CertError::Unimplemented { detail: "x".into() };
404        let _: &dyn std::error::Error = &e;
405        assert!(format!("{e}").contains("unimplemented"));
406    }
407
408    /// `issue_certificate_via_setdns` rejects a non-tailnet name with [`CertError::NotTailnetName`]
409    /// BEFORE any network I/O (the `is_tailnet_name` guard fires first). This is the only path
410    /// reachable without a live control plane / ACME CA, and it proves the anti-leak guard.
411    #[cfg(feature = "acme")]
412    #[tokio::test]
413    async fn issue_via_setdns_rejects_offtailnet_before_network() {
414        let config = crate::Config::default();
415        let keystate = ts_keys::NodeState::generate();
416        let (account_key, _der) = crate::acme::AcmeAccountKey::generate().expect("generate");
417        let directory = url::Url::parse(crate::acme::LETS_ENCRYPT_PRODUCTION_DIRECTORY).unwrap();
418
419        let err = issue_certificate_via_setdns(
420            &config,
421            &keystate,
422            "example.com",
423            &account_key,
424            &directory,
425        )
426        .await
427        .expect_err("must refuse a non-tailnet name without touching the network");
428        assert!(matches!(err, CertError::NotTailnetName(_)));
429    }
430
431    /// `SetDnsPublisher` implements [`PublishTxt`] (compile-level assertion).
432    #[cfg(feature = "acme")]
433    #[test]
434    fn set_dns_publisher_is_publish_txt() {
435        fn assert_publish_txt<T: PublishTxt>() {}
436        assert_publish_txt::<SetDnsPublisher<'_>>();
437    }
438
439    /// Offline round-trip of the [`crate::acme::IssuedCert`] PEM contract — the data
440    /// `Device::cert_pair` surfaces — WITHOUT a network/ACME server. `issue_certificate` ends by
441    /// feeding a chain PEM + a leaf-key PEM into [`certified_key_from_pem`] and keeping those same
442    /// two PEMs on the `IssuedCert`; this proves that exact assembly with a known cert+key pair
443    /// (generated here with `rcgen`, as the live engine does), so the plumbing is covered even when
444    /// the Pebble integration test cannot run.
445    ///
446    /// Asserts: the leaf-key PEM parses as a private key (`rustls_pemfile::private_key` → `Some`),
447    /// the cert PEM parses as ≥1 certificate, and the matched pair builds a `CertifiedKey` (which
448    /// runs `keys_match()` internally — the key-matches-leaf verification is exercised, NOT skipped).
449    #[cfg(feature = "acme")]
450    #[test]
451    fn issued_cert_pem_pair_round_trips_and_key_matches_leaf() {
452        // A self-signed cert + its key — the same `(chain_pem, key_pem)` shape `issue_certificate`
453        // holds at its final `certified_key_from_pem` call (there the chain is LE's; here it is a
454        // single self-signed leaf — identical for the parse/match contract under test).
455        let cert = rcgen::generate_simple_self_signed(vec!["host.tail1234.ts.net".into()])
456            .expect("generate self-signed cert");
457        let cert_chain_pem = cert.cert.pem();
458        let key_pem = cert.key_pair.serialize_pem();
459
460        // The leaf-key PEM parses as a private key (the "PEM already in hand, no opaque-key export"
461        // fact the whole change rests on). Never logged.
462        let parsed_key = rustls_pemfile::private_key(&mut key_pem.as_bytes())
463            .expect("key_pem must parse as PEM")
464            .expect("key_pem must contain a private key");
465        assert!(
466            !parsed_key.secret_der().is_empty(),
467            "parsed leaf private key DER is empty"
468        );
469
470        // The cert PEM parses to ≥1 certificate.
471        let chain: Vec<CertificateDer<'static>> =
472            rustls_pemfile::certs(&mut cert_chain_pem.as_bytes())
473                .collect::<Result<_, _>>()
474                .expect("cert_chain_pem must parse as PEM certificates");
475        assert!(
476            !chain.is_empty(),
477            "cert_chain_pem parsed to ZERO certificates"
478        );
479
480        // The matched pair assembles — `certified_key_from_pem` runs `keys_match()` internally, so
481        // this is the key-matches-leaf check (the production verifier, reused, never weakened).
482        let ck = certified_key_from_pem(cert_chain_pem.as_bytes(), key_pem.as_bytes())
483            .expect("matched cert_chain_pem + key_pem must build a CertifiedKey");
484        assert!(
485            !ck.cert.is_empty(),
486            "assembled CertifiedKey has an empty chain"
487        );
488    }
489
490    /// The key-matches-leaf verification is real: a cert paired with a *different* key's PEM must be
491    /// REJECTED by [`certified_key_from_pem`]. This guards against any future weakening of the
492    /// matched-pair guarantee `IssuedCert` (and `Device::cert_pair`) depend on.
493    #[cfg(feature = "acme")]
494    #[test]
495    fn certified_key_from_pem_rejects_mismatched_key() {
496        let cert_a = rcgen::generate_simple_self_signed(vec!["host.tail1234.ts.net".into()])
497            .expect("generate cert A");
498        let cert_b = rcgen::generate_simple_self_signed(vec!["other.tail1234.ts.net".into()])
499            .expect("generate cert B");
500        // Cert A's chain with cert B's (non-matching) private key.
501        let err = certified_key_from_pem(
502            cert_a.cert.pem().as_bytes(),
503            cert_b.key_pair.serialize_pem().as_bytes(),
504        )
505        .expect_err("a cert paired with the wrong key must be rejected (keys_match)");
506        assert!(
507            matches!(err, CertError::Rustls(_)),
508            "mismatch must surface as a rustls error, got {err:?}"
509        );
510    }
511}