Skip to main content

otel_bootstrap/
rotating_mtls.rs

1//! Live-sourced mTLS for the gRPC OTLP exporters.
2//!
3//! [`MtlsMaterial`](crate::MtlsMaterial) is a snapshot: the tonic channel built
4//! from it presents the same client certificate and trusts the same bundle for
5//! the life of the process. A workload identity that rotates every hour, or a
6//! process that starts before its identity provider is reachable, cannot live
7//! with that. A [`CertSource`] is asked for the current material **each time a
8//! new connection is made**, so a reconnect after rotation presents the rotated
9//! certificate and trusts the rotated bundle, and a source that is not ready
10//! yet only delays the first connection instead of silently downgrading it.
11
12use std::error::Error;
13use std::future::Future;
14use std::pin::Pin;
15use std::sync::{Arc, Once};
16use std::task::{Context, Poll};
17use std::time::Duration;
18
19use hyper_util::rt::TokioIo;
20use rustls::RootCertStore;
21use rustls::pki_types::pem::PemObject as _;
22use rustls::pki_types::{CertificateDer, PrivateKeyDer, ServerName};
23use tokio::net::TcpStream;
24use tonic::codegen::http::Uri;
25use tonic::transport::{Channel, Endpoint};
26use x509_cert::Certificate;
27use x509_cert::der::Decode as _;
28use x509_cert::ext::pkix::{SubjectAltName, name::GeneralName};
29
30use crate::MtlsMaterial;
31
32type BoxError = Box<dyn Error + Send + Sync>;
33
34/// A source of the mTLS material the OTLP exporters present and trust.
35///
36/// Implementations return whatever is current *now*. The exporters call
37/// [`current`](CertSource::current) on every new connection, never once at
38/// start-up, so an implementation backed by a rotating identity needs no
39/// restart to be picked up.
40pub trait CertSource: Send + Sync + 'static {
41    /// The material to use for a connection made now, or `None` when the
42    /// source has none yet (for example an identity agent that has not
43    /// answered). A `None` fails that connection attempt; the channel retries
44    /// with backoff, so telemetry starts flowing as soon as the source is ready.
45    fn current(&self) -> Option<MtlsMaterial>;
46}
47
48/// A [`CertSource`] that always returns the same material. Equivalent to
49/// [`TelemetryBuilder::with_mtls`](crate::TelemetryBuilder::with_mtls) for
50/// callers that hold a `CertSource` generically.
51#[derive(Clone)]
52pub struct StaticCertSource(MtlsMaterial);
53
54impl StaticCertSource {
55    /// Wrap fixed material.
56    #[must_use]
57    pub fn new(material: MtlsMaterial) -> Self {
58        Self(material)
59    }
60}
61
62impl CertSource for StaticCertSource {
63    fn current(&self) -> Option<MtlsMaterial> {
64        Some(self.0.clone())
65    }
66}
67
68/// Build the rustls client configuration for one connection from `material`.
69///
70/// The trust bundle may hold several certificates; every one becomes an anchor.
71fn client_config(material: &MtlsMaterial) -> Result<rustls::ClientConfig, BoxError> {
72    let mut roots = RootCertStore::empty();
73    for ca in CertificateDer::pem_slice_iter(&material.trust_bundle_pem) {
74        roots.add(ca?)?;
75    }
76    if roots.is_empty() {
77        return Err("mTLS trust bundle holds no certificates".into());
78    }
79    let chain = CertificateDer::pem_slice_iter(&material.client_cert_chain_pem)
80        .collect::<Result<Vec<_>, _>>()?;
81    let key = PrivateKeyDer::from_pem_slice(&material.client_key_pem)?;
82
83    let mut config = rustls::ClientConfig::builder_with_provider(Arc::new(
84        rustls::crypto::aws_lc_rs::default_provider(),
85    ))
86    .with_safe_default_protocol_versions()?
87    .with_root_certificates(roots)
88    .with_client_auth_cert(chain, key)?;
89    // gRPC requires HTTP/2; a server that does not agree it is refused by the
90    // transport, so say so up front.
91    config.alpn_protocols = vec![b"h2".to_vec()];
92    Ok(config)
93}
94
95/// Dials the endpoint's host and runs mutual TLS with whatever the
96/// [`CertSource`] holds at that moment.
97///
98/// When `expected_id` is set the collector must also present that SPIFFE ID as
99/// a URI SAN; a certificate that chains to the bundle and names the right host
100/// but carries another identity is refused. Without it only the chain and the
101/// DNS name are checked.
102#[derive(Clone)]
103pub(crate) struct RotatingConnector {
104    source: Arc<dyn CertSource>,
105    expected_id: Option<Arc<str>>,
106}
107
108/// Fails unless the peer's leaf certificate carries `expected` as a URI SAN.
109fn require_spiffe_id(peer: Option<&[CertificateDer<'_>]>, expected: &str) -> Result<(), BoxError> {
110    let leaf = peer
111        .and_then(<[_]>::first)
112        .ok_or("collector presented no certificate")?;
113    let cert = Certificate::from_der(leaf.as_ref())
114        .map_err(|e| format!("collector certificate is not parseable: {e}"))?;
115    let mut found = Vec::new();
116    if let Some((_, san)) = cert
117        .tbs_certificate
118        .get::<SubjectAltName>()
119        .map_err(|e| format!("collector certificate has a malformed SAN: {e}"))?
120    {
121        for name in &san.0 {
122            if let GeneralName::UniformResourceIdentifier(uri) = name {
123                if uri.as_str() == expected {
124                    return Ok(());
125                }
126                found.push(uri.as_str().to_owned());
127            }
128        }
129    }
130    Err(format!(
131        "collector identity mismatch: expected {expected}, certificate carries [{}]",
132        found.join(", ")
133    )
134    .into())
135}
136
137type TlsIo = TokioIo<tokio_rustls::client::TlsStream<TcpStream>>;
138
139impl RotatingConnector {
140    async fn connect(
141        source: Arc<dyn CertSource>,
142        expected_id: Option<Arc<str>>,
143        uri: Uri,
144    ) -> Result<TlsIo, BoxError> {
145        let host = uri.host().ok_or("OTLP endpoint has no host")?.to_owned();
146        let port = uri.port_u16().unwrap_or(443);
147        let material = source
148            .current()
149            .ok_or("mTLS material is not available yet")?;
150        let config = client_config(&material)?;
151        let tcp = TcpStream::connect((host.as_str(), port)).await?;
152        tcp.set_nodelay(true)?;
153        let name = ServerName::try_from(host)?;
154        let tls = tokio_rustls::TlsConnector::from(Arc::new(config))
155            .connect(name, tcp)
156            .await?;
157        match expected_id {
158            Some(id) => require_spiffe_id(tls.get_ref().1.peer_certificates(), &id)?,
159            None => {
160                static UNPINNED: Once = Once::new();
161                UNPINNED.call_once(|| {
162                    tracing::warn!(
163                        "OTLP collector identity is not pinned: its certificate is trusted on \
164                         chain and DNS name only"
165                    );
166                });
167            }
168        }
169        Ok(TokioIo::new(tls))
170    }
171}
172
173impl tower::Service<Uri> for RotatingConnector {
174    type Response = TlsIo;
175    type Error = BoxError;
176    type Future = Pin<Box<dyn Future<Output = Result<TlsIo, BoxError>> + Send>>;
177
178    fn poll_ready(&mut self, _cx: &mut Context<'_>) -> Poll<Result<(), Self::Error>> {
179        Poll::Ready(Ok(()))
180    }
181
182    fn call(&mut self, uri: Uri) -> Self::Future {
183        Box::pin(Self::connect(
184            Arc::clone(&self.source),
185            self.expected_id.clone(),
186            uri,
187        ))
188    }
189}
190
191/// A lazily connecting channel to `endpoint` whose every connection takes its
192/// material from `source`. Must be called inside a Tokio runtime.
193///
194/// The endpoint's scheme is ignored: the connector always speaks TLS, and
195/// tonic itself is handed an `http` URI so it does not try to layer its own.
196pub(crate) fn channel(
197    endpoint: &str,
198    source: Arc<dyn CertSource>,
199    timeout: Option<Duration>,
200    expected_id: Option<&str>,
201) -> Result<Channel, BoxError> {
202    let parsed: Uri = endpoint.parse()?;
203    let authority = parsed
204        .authority()
205        .ok_or("OTLP endpoint has no host")?
206        .as_str();
207    let mut ep = Endpoint::from_shared(format!("http://{authority}"))?
208        .connect_timeout(Duration::from_secs(10));
209    if let Some(t) = timeout {
210        ep = ep.timeout(t);
211    }
212    Ok(ep.connect_with_connector_lazy(RotatingConnector {
213        source,
214        expected_id: expected_id.map(Arc::from),
215    }))
216}
217
218#[cfg(test)]
219mod tests {
220    use super::*;
221    use std::sync::Mutex;
222    use tokio::io::{AsyncReadExt as _, AsyncWriteExt as _};
223    use tokio::net::TcpListener;
224    use tower::Service as _;
225
226    // Fixed test PKI under tests/fixtures/mtls (EC P-256, 100-year validity,
227    // throwaway keys that protect nothing): a CA, a server leaf for
228    // `localhost`, two client leaves, and a CA that signed none of them.
229    const CA: &str = include_str!("../tests/fixtures/mtls/ca.pem");
230    const STRANGER_CA: &str = include_str!("../tests/fixtures/mtls/stranger-ca.pem");
231    const SERVER_CERT: &str = include_str!("../tests/fixtures/mtls/server.pem");
232    const SERVER_KEY: &str = include_str!("../tests/fixtures/mtls/server.key");
233    const CLIENT_ONE_CERT: &str = include_str!("../tests/fixtures/mtls/client-one.pem");
234    const CLIENT_ONE_KEY: &str = include_str!("../tests/fixtures/mtls/client-one.key");
235    const CLIENT_TWO_CERT: &str = include_str!("../tests/fixtures/mtls/client-two.pem");
236    const CLIENT_TWO_KEY: &str = include_str!("../tests/fixtures/mtls/client-two.key");
237
238    fn material(cert: &str, key: &str, bundle: &str) -> MtlsMaterial {
239        MtlsMaterial {
240            client_cert_chain_pem: cert.as_bytes().to_vec(),
241            client_key_pem: key.as_bytes().to_vec(),
242            trust_bundle_pem: bundle.as_bytes().to_vec(),
243        }
244    }
245
246    fn client_one() -> MtlsMaterial {
247        material(CLIENT_ONE_CERT, CLIENT_ONE_KEY, CA)
248    }
249
250    fn client_two() -> MtlsMaterial {
251        material(CLIENT_TWO_CERT, CLIENT_TWO_KEY, CA)
252    }
253
254    /// A TLS server that requires a client certificate signed by the CA and
255    /// records the leaf it was shown on every connection.
256    async fn server() -> (u16, Arc<Mutex<Vec<Vec<u8>>>>) {
257        server_with(SERVER_CERT, SERVER_KEY, CA).await
258    }
259
260    async fn server_with(
261        server_cert: &str,
262        server_key: &str,
263        client_ca: &str,
264    ) -> (u16, Arc<Mutex<Vec<Vec<u8>>>>) {
265        let mut roots = RootCertStore::empty();
266        roots
267            .add(CertificateDer::from_pem_slice(client_ca.as_bytes()).unwrap())
268            .unwrap();
269        let verifier = rustls::server::WebPkiClientVerifier::builder_with_provider(
270            Arc::new(roots),
271            Arc::new(rustls::crypto::aws_lc_rs::default_provider()),
272        )
273        .build()
274        .unwrap();
275        let mut config = rustls::ServerConfig::builder_with_provider(Arc::new(
276            rustls::crypto::aws_lc_rs::default_provider(),
277        ))
278        .with_safe_default_protocol_versions()
279        .unwrap()
280        .with_client_cert_verifier(verifier)
281        .with_single_cert(
282            CertificateDer::pem_slice_iter(server_cert.as_bytes())
283                .collect::<Result<Vec<_>, _>>()
284                .unwrap(),
285            PrivateKeyDer::from_pem_slice(server_key.as_bytes()).unwrap(),
286        )
287        .unwrap();
288        config.alpn_protocols = vec![b"h2".to_vec()];
289        let acceptor = tokio_rustls::TlsAcceptor::from(Arc::new(config));
290
291        let listener = TcpListener::bind(("127.0.0.1", 0)).await.unwrap();
292        let port = listener.local_addr().unwrap().port();
293        let seen = Arc::new(Mutex::new(Vec::new()));
294        let seen_task = Arc::clone(&seen);
295        tokio::spawn(async move {
296            loop {
297                let (tcp, _) = listener.accept().await.unwrap();
298                let acceptor = acceptor.clone();
299                let seen = Arc::clone(&seen_task);
300                tokio::spawn(async move {
301                    if let Ok(mut tls) = acceptor.accept(tcp).await {
302                        let leaf = tls
303                            .get_ref()
304                            .1
305                            .peer_certificates()
306                            .and_then(|c| c.first())
307                            .map(|c| c.as_ref().to_vec());
308                        if let Some(leaf) = leaf {
309                            seen.lock().unwrap().push(leaf);
310                        }
311                        let mut b = [0_u8; 1];
312                        let _ = tls.read(&mut b).await;
313                        let _ = tls.shutdown().await;
314                    }
315                });
316            }
317        });
318        (port, seen)
319    }
320
321    struct Swappable(Mutex<Option<MtlsMaterial>>);
322    impl CertSource for Swappable {
323        fn current(&self) -> Option<MtlsMaterial> {
324            self.0.lock().unwrap().clone()
325        }
326    }
327
328    fn uri(port: u16) -> Uri {
329        format!("http://localhost:{port}").parse().unwrap()
330    }
331
332    fn pem_to_der(pem: &[u8]) -> Vec<u8> {
333        CertificateDer::from_pem_slice(pem).unwrap().to_vec()
334    }
335
336    #[tokio::test]
337    async fn each_new_connection_presents_the_material_current_at_that_moment() {
338        let (port, seen) = server().await;
339        let source = Arc::new(Swappable(Mutex::new(Some(client_one()))));
340        let mut connector = RotatingConnector {
341            source: Arc::clone(&source) as Arc<dyn CertSource>,
342            expected_id: None,
343        };
344
345        std::future::poll_fn(|cx| connector.poll_ready(cx))
346            .await
347            .expect("connector is always ready");
348        drop(connector.call(uri(port)).await.expect("first connection"));
349
350        *source.0.lock().unwrap() = Some(client_two());
351        drop(connector.call(uri(port)).await.expect("rotated connection"));
352
353        tokio::time::timeout(Duration::from_secs(5), async {
354            while seen.lock().unwrap().len() < 2 {
355                tokio::time::sleep(Duration::from_millis(10)).await;
356            }
357        })
358        .await
359        .expect("server saw both clients");
360        let seen = seen.lock().unwrap();
361        assert_eq!(seen[0], pem_to_der(CLIENT_ONE_CERT.as_bytes()));
362        assert_eq!(seen[1], pem_to_der(CLIENT_TWO_CERT.as_bytes()));
363    }
364
365    #[tokio::test]
366    async fn a_source_not_ready_at_first_connect_fails_that_attempt_then_connects_once_ready() {
367        let (port, seen) = server().await;
368        let source = Arc::new(Swappable(Mutex::new(None)));
369        let mut connector = RotatingConnector {
370            source: Arc::clone(&source) as Arc<dyn CertSource>,
371            expected_id: None,
372        };
373
374        let err = connector
375            .call(uri(port))
376            .await
377            .err()
378            .expect("an unready source must fail the attempt");
379        assert!(err.to_string().contains("not available yet"), "{err}");
380        assert!(seen.lock().unwrap().is_empty());
381
382        *source.0.lock().unwrap() = Some(client_one());
383        drop(
384            connector
385                .call(uri(port))
386                .await
387                .expect("connects once ready"),
388        );
389    }
390
391    #[tokio::test]
392    async fn a_bundle_of_several_certificates_trusts_each_of_them() {
393        let (port, _seen) = server().await;
394        // The issuing root is the second certificate of the bundle, as it is
395        // when a trust domain holds a root alongside a successor.
396        let bundle = format!("{STRANGER_CA}{CA}");
397        let mut connector = RotatingConnector {
398            source: Arc::new(StaticCertSource::new(material(
399                CLIENT_ONE_CERT,
400                CLIENT_ONE_KEY,
401                &bundle,
402            ))),
403            expected_id: None,
404        };
405        drop(
406            connector
407                .call(uri(port))
408                .await
409                .expect("second anchor trusted"),
410        );
411    }
412
413    #[tokio::test]
414    async fn a_server_the_bundle_does_not_vouch_for_is_refused() {
415        let (port, _seen) = server().await;
416        let mut connector = RotatingConnector {
417            source: Arc::new(StaticCertSource::new(material(
418                CLIENT_ONE_CERT,
419                CLIENT_ONE_KEY,
420                STRANGER_CA,
421            ))),
422            expected_id: None,
423        };
424        assert!(connector.call(uri(port)).await.is_err());
425    }
426
427    #[test]
428    fn malformed_material_is_an_error_not_a_panic() {
429        let bad = MtlsMaterial {
430            client_cert_chain_pem: b"x".to_vec(),
431            client_key_pem: b"x".to_vec(),
432            trust_bundle_pem: Vec::new(),
433        };
434        assert!(
435            client_config(&bad)
436                .unwrap_err()
437                .to_string()
438                .contains("no certificates")
439        );
440    }
441
442    #[test]
443    fn static_source_returns_its_material() {
444        let m = client_one();
445        let got = StaticCertSource::new(m.clone()).current().unwrap();
446        assert_eq!(got.client_cert_chain_pem, m.client_cert_chain_pem);
447    }
448
449    // Throwaway PKI under tests/fixtures/mtls/pinned (EC P-256, 100-year
450    // validity, keys that protect nothing): a CA, a client leaf, and three
451    // collector leaves for `localhost` -- one carrying the collector's SPIFFE ID,
452    // one carrying another, one carrying none.
453    const PINNED_CA: &str = include_str!("../tests/fixtures/mtls/pinned/ca.pem");
454    const PINNED_CLIENT_CERT: &str = include_str!("../tests/fixtures/mtls/pinned/client.pem");
455    const PINNED_CLIENT_KEY: &str = include_str!("../tests/fixtures/mtls/pinned/client.key");
456
457    struct Pki {
458        ca: String,
459        server_cert: String,
460        server_key: String,
461        client: MtlsMaterial,
462    }
463
464    fn pki(server_uri: Option<&str>) -> Pki {
465        let (server_cert, server_key) = match server_uri {
466            Some(COLLECTOR_ID) => (
467                include_str!("../tests/fixtures/mtls/pinned/server-match.pem"),
468                include_str!("../tests/fixtures/mtls/pinned/server-match.key"),
469            ),
470            Some(_) => (
471                include_str!("../tests/fixtures/mtls/pinned/server-other.pem"),
472                include_str!("../tests/fixtures/mtls/pinned/server-other.key"),
473            ),
474            None => (
475                include_str!("../tests/fixtures/mtls/pinned/server-nouri.pem"),
476                include_str!("../tests/fixtures/mtls/pinned/server-nouri.key"),
477            ),
478        };
479        Pki {
480            ca: PINNED_CA.to_owned(),
481            server_cert: server_cert.to_owned(),
482            server_key: server_key.to_owned(),
483            client: material(PINNED_CLIENT_CERT, PINNED_CLIENT_KEY, PINNED_CA),
484        }
485    }
486
487    const COLLECTOR_ID: &str = "spiffe://brefwiz.e2e/otel-collector";
488
489    fn pinned(pki: &Pki, expected: Option<&str>) -> RotatingConnector {
490        RotatingConnector {
491            source: Arc::new(StaticCertSource::new(pki.client.clone())),
492            expected_id: expected.map(Arc::from),
493        }
494    }
495
496    #[tokio::test]
497    async fn a_collector_carrying_the_expected_spiffe_id_is_accepted() {
498        let pki = pki(Some(COLLECTOR_ID));
499        let (port, _seen) = server_with(&pki.server_cert, &pki.server_key, &pki.ca).await;
500        drop(
501            pinned(&pki, Some(COLLECTOR_ID))
502                .call(uri(port))
503                .await
504                .expect("matching identity"),
505        );
506    }
507
508    #[tokio::test]
509    async fn a_collector_with_another_spiffe_id_is_refused_though_chain_and_dns_hold() {
510        let pki = pki(Some("spiffe://brefwiz.e2e/impostor"));
511        let (port, _seen) = server_with(&pki.server_cert, &pki.server_key, &pki.ca).await;
512        // The same certificate is accepted when nothing is pinned, so the
513        // refusal below is the identity check and nothing else.
514        drop(
515            pinned(&pki, None)
516                .call(uri(port))
517                .await
518                .expect("chain and DNS name are valid"),
519        );
520        let err = pinned(&pki, Some(COLLECTOR_ID))
521            .call(uri(port))
522            .await
523            .err()
524            .expect("identity mismatch must be refused");
525        assert!(err.to_string().contains("identity mismatch"), "{err}");
526        assert!(err.to_string().contains("impostor"), "{err}");
527    }
528
529    #[tokio::test]
530    async fn a_collector_with_no_spiffe_id_is_refused_when_one_is_expected() {
531        let pki = pki(None);
532        let (port, _seen) = server_with(&pki.server_cert, &pki.server_key, &pki.ca).await;
533        let err = pinned(&pki, Some(COLLECTOR_ID))
534            .call(uri(port))
535            .await
536            .err()
537            .expect("a certificate without the identity must be refused");
538        assert!(err.to_string().contains("identity mismatch"), "{err}");
539    }
540
541    #[tokio::test]
542    async fn without_an_expected_id_chain_and_dns_name_are_enough() {
543        let pki = pki(None);
544        let (port, _seen) = server_with(&pki.server_cert, &pki.server_key, &pki.ca).await;
545        drop(
546            pinned(&pki, None)
547                .call(uri(port))
548                .await
549                .expect("legacy path is unchanged"),
550        );
551    }
552
553    #[tokio::test]
554    async fn channel_rejects_an_endpoint_without_a_host() {
555        let source: Arc<dyn CertSource> = Arc::new(Swappable(Mutex::new(None)));
556        assert!(channel("not a uri", Arc::clone(&source), None, None).is_err());
557        assert!(
558            channel(
559                "https://collector.example:4320",
560                source,
561                Some(Duration::from_secs(3)),
562                None,
563            )
564            .is_ok()
565        );
566    }
567}