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.
159///
160/// `account_key` is the ACME account identity (persist its PKCS#8 DER across renewals — see the
161/// runtime caller); `directory_url` selects the ACME CA (production is
162/// [`crate::acme::LETS_ENCRYPT_PRODUCTION_DIRECTORY`]). Rejects non-tailnet names up front (anti-leak)
163/// before any network I/O. SaaS-only: against a self-hosted control plane the set-dns publish typically 501s, surfaced as
164/// [`CertError::Acme`]. Fail-closed: returns a [`CertifiedKey`] only when the LE order reached
165/// `valid` and the chain assembled.
166#[cfg(feature = "acme")]
167pub async fn issue_certificate_via_setdns(
168 config: &crate::Config,
169 node_keystate: &ts_keys::NodeState,
170 name: &str,
171 account_key: &crate::acme::AcmeAccountKey,
172 directory_url: &url::Url,
173) -> Result<CertifiedKey, CertError> {
174 if !is_tailnet_name(name) {
175 return Err(CertError::NotTailnetName(name.to_string()));
176 }
177 let publisher = SetDnsPublisher::new(config, node_keystate);
178 crate::acme::issue_certificate(name, directory_url, account_key, &publisher).await
179}
180
181/// Names exactly what this fork is missing to issue a real cert, surfaced
182/// verbatim in [`CertError::Unimplemented`] so the gap is self-documenting at
183/// runtime. There is no control `cert/<domain>` RPC in real Tailscale — the node
184/// is the ACME client and only needs control to publish the DNS-01 TXT via
185/// `POST /machine/set-dns` (which a self-hosted control plane typically 501s). See the module docs.
186pub const MISSING_CERT_RPC: &str = "client-side ACME engine (direct to Let's Encrypt) + a POST /machine/set-dns \
187 Noise RPC to publish the _acme-challenge TXT (a self-hosted control plane returns 501 for set-dns)";
188
189/// Errors from certificate acquisition / TLS material assembly.
190///
191/// Fail-closed by construction: there is no variant that yields a usable cert
192/// without a genuine issuance path, and there is deliberately no self-signed
193/// production fallback.
194#[derive(Debug)]
195pub enum CertError {
196 /// The control plane in this fork does not expose the RPC(s) needed to mint
197 /// a real certificate. `detail` names exactly what is missing.
198 Unimplemented {
199 /// Names exactly which control RPC is missing (e.g. [`MISSING_CERT_RPC`]).
200 detail: String,
201 },
202 /// An ACME-protocol-level failure (order/challenge/finalize).
203 Acme(String),
204 /// I/O failure (network, file, etc.).
205 Io(std::io::Error),
206 /// A rustls / crypto-material failure (bad key, mismatched cert, provider).
207 Rustls(tokio_rustls::rustls::Error),
208 /// The requested name is not a tailnet (`*.ts.net`-style) name. Anti-leak:
209 /// we never mint or serve certs for off-tailnet names.
210 NotTailnetName(String),
211}
212
213impl core::fmt::Display for CertError {
214 fn fmt(&self, f: &mut core::fmt::Formatter<'_>) -> core::fmt::Result {
215 match self {
216 CertError::Unimplemented { detail } => {
217 write!(
218 f,
219 "certificate acquisition is unimplemented in this fork: {detail}"
220 )
221 }
222 CertError::Acme(e) => write!(f, "ACME error: {e}"),
223 CertError::Io(e) => write!(f, "I/O error: {e}"),
224 CertError::Rustls(e) => write!(f, "rustls error: {e}"),
225 CertError::NotTailnetName(name) => {
226 write!(
227 f,
228 "refusing to obtain a certificate for non-tailnet name {name:?}"
229 )
230 }
231 }
232 }
233}
234
235impl std::error::Error for CertError {
236 fn source(&self) -> Option<&(dyn std::error::Error + 'static)> {
237 match self {
238 CertError::Io(e) => Some(e),
239 CertError::Rustls(e) => Some(e),
240 CertError::Unimplemented { .. } | CertError::Acme(_) | CertError::NotTailnetName(_) => {
241 None
242 }
243 }
244 }
245}
246
247impl From<std::io::Error> for CertError {
248 fn from(e: std::io::Error) -> Self {
249 CertError::Io(e)
250 }
251}
252
253impl From<tokio_rustls::rustls::Error> for CertError {
254 fn from(e: tokio_rustls::rustls::Error) -> Self {
255 CertError::Rustls(e)
256 }
257}
258
259/// Returns `true` if `name` looks like a tailnet MagicDNS name we may serve a
260/// cert for. We only ever mint/serve certs for tailnet names — never arbitrary
261/// public hostnames — to avoid being turned into a cert oracle for off-tailnet
262/// origins.
263pub fn is_tailnet_name(name: &str) -> bool {
264 // `host.tailnet.ts.net` (public) or `*.ts.net`. Keep this conservative.
265 let name = name.trim_end_matches('.');
266 !name.is_empty() && name.ends_with(".ts.net") && !name.contains('/')
267}
268
269/// Obtain a [`CertifiedKey`] for a node's MagicDNS `name`.
270///
271/// **Fail-closed.** In this fork the control plane exposes no ACME / DNS-01 cert
272/// RPC (see module docs), so this always returns [`CertError::Unimplemented`]
273/// once the name passes the tailnet-name check. It NEVER self-signs and NEVER
274/// returns a placeholder cert — a caller cannot accidentally serve an untrusted
275/// certificate.
276///
277/// When the control RPC ([`MISSING_CERT_RPC`]) is added, fill in the issuance
278/// branch here.
279pub async fn get_certificate(name: &str) -> Result<CertifiedKey, CertError> {
280 if !is_tailnet_name(name) {
281 return Err(CertError::NotTailnetName(name.to_string()));
282 }
283
284 // No client-side ACME engine and no set-dns RPC exist in this fork, and a
285 // self-hosted control target typically 501s on set-dns. Do NOT self-sign.
286 Err(CertError::Unimplemented {
287 detail: format!(
288 "cannot issue a real certificate for {name:?}; requires: {MISSING_CERT_RPC}"
289 ),
290 })
291}
292
293/// Assemble a [`CertifiedKey`] from a PEM chain + PEM private key, using the
294/// **ring** crypto provider's signing-key loader (matching the rest of the TLS
295/// stack — `ts_tls_util` is `tokio-rustls`/`ring`). This is the assembly helper
296/// a future real issuance path (or a test) feeds the control-returned chain into.
297///
298/// This does NOT fetch or issue anything; it only turns already-trusted material
299/// into the rustls representation. Production callers reach it only via a genuine
300/// issuance path; tests reach it with a clearly-marked self-signed cert.
301pub fn certified_key_from_pem(
302 cert_chain_pem: &[u8],
303 key_pem: &[u8],
304) -> Result<CertifiedKey, CertError> {
305 let certs: Vec<CertificateDer<'static>> =
306 rustls_pemfile::certs(&mut &cert_chain_pem[..]).collect::<Result<_, _>>()?;
307 if certs.is_empty() {
308 return Err(CertError::Acme(
309 "certificate chain PEM contained no certificates".into(),
310 ));
311 }
312
313 let key: PrivateKeyDer<'static> = rustls_pemfile::private_key(&mut &key_pem[..])?
314 .ok_or_else(|| CertError::Acme("private key PEM contained no key".into()))?;
315
316 certified_key_from_der(certs, key)
317}
318
319/// Assemble a [`CertifiedKey`] from DER cert chain + DER private key using the
320/// ring signing-key loader. Verifies the key matches the leaf (fail-closed).
321pub fn certified_key_from_der(
322 cert_chain: Vec<CertificateDer<'static>>,
323 key: PrivateKeyDer<'static>,
324) -> Result<CertifiedKey, CertError> {
325 // Match the rest of the stack: ring provider's signing-key loader, never
326 // auto-detect (which panics under ring+aws-lc feature unification).
327 // `any_supported_type` already yields an `Arc<dyn SigningKey>`; don't re-wrap.
328 let signing_key = tokio_rustls::rustls::crypto::ring::sign::any_supported_type(&key)
329 .map_err(CertError::Rustls)?;
330 let ck = CertifiedKey::new(cert_chain, signing_key);
331 ck.keys_match().map_err(CertError::Rustls)?;
332 Ok(ck)
333}
334
335#[cfg(test)]
336mod tests {
337 use super::*;
338
339 #[test]
340 fn tailnet_name_accepts_magicdns() {
341 assert!(is_tailnet_name("host.tail1234.ts.net"));
342 assert!(is_tailnet_name("host.tail1234.ts.net."));
343 }
344
345 #[test]
346 fn tailnet_name_rejects_offtailnet() {
347 assert!(!is_tailnet_name("example.com"));
348 assert!(!is_tailnet_name("evil.ts.net.attacker.com"));
349 assert!(!is_tailnet_name(""));
350 assert!(!is_tailnet_name("host.ts.net/path"));
351 }
352
353 #[tokio::test]
354 async fn get_certificate_is_fail_closed_unimplemented() {
355 let err = get_certificate("host.tail1234.ts.net")
356 .await
357 .expect_err("must not mint a cert without an ACME RPC");
358 match err {
359 CertError::Unimplemented { detail } => {
360 assert!(
361 detail.contains("cert"),
362 "detail should name the missing RPC: {detail}"
363 );
364 }
365 other => panic!("expected Unimplemented, got {other:?}"),
366 }
367 }
368
369 #[tokio::test]
370 async fn get_certificate_rejects_offtailnet_name() {
371 let err = get_certificate("example.com").await.unwrap_err();
372 assert!(matches!(err, CertError::NotTailnetName(_)));
373 }
374
375 #[test]
376 fn cert_error_is_std_error_and_displays() {
377 let e = CertError::Unimplemented { detail: "x".into() };
378 let _: &dyn std::error::Error = &e;
379 assert!(format!("{e}").contains("unimplemented"));
380 }
381
382 /// `issue_certificate_via_setdns` rejects a non-tailnet name with [`CertError::NotTailnetName`]
383 /// BEFORE any network I/O (the `is_tailnet_name` guard fires first). This is the only path
384 /// reachable without a live control plane / ACME CA, and it proves the anti-leak guard.
385 #[cfg(feature = "acme")]
386 #[tokio::test]
387 async fn issue_via_setdns_rejects_offtailnet_before_network() {
388 let config = crate::Config::default();
389 let keystate = ts_keys::NodeState::generate();
390 let (account_key, _der) = crate::acme::AcmeAccountKey::generate().expect("generate");
391 let directory = url::Url::parse(crate::acme::LETS_ENCRYPT_PRODUCTION_DIRECTORY).unwrap();
392
393 let err = issue_certificate_via_setdns(
394 &config,
395 &keystate,
396 "example.com",
397 &account_key,
398 &directory,
399 )
400 .await
401 .expect_err("must refuse a non-tailnet name without touching the network");
402 assert!(matches!(err, CertError::NotTailnetName(_)));
403 }
404
405 /// `SetDnsPublisher` implements [`PublishTxt`] (compile-level assertion).
406 #[cfg(feature = "acme")]
407 #[test]
408 fn set_dns_publisher_is_publish_txt() {
409 fn assert_publish_txt<T: PublishTxt>() {}
410 assert_publish_txt::<SetDnsPublisher<'_>>();
411 }
412}