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}