Skip to main content

magnetar_proto/auth/
tls.rs

1// SPDX-License-Identifier: Apache-2.0
2
3//! mTLS [`AuthProvider`] — surfaces the cert chain + private key bytes.
4//!
5//! Mirrors `org.apache.pulsar.client.impl.auth.AuthenticationTls`. The Pulsar wire protocol
6//! conveys mTLS purely at the TLS handshake layer: the `CommandConnect.auth_data` is left empty,
7//! and the broker derives the client identity from the certificate presented during the rustls
8//! handshake.
9//!
10//! This provider therefore returns empty bytes from [`AuthProvider::initial`] and exposes
11//! [`TlsAuth::cert_chain_pem`] / [`TlsAuth::private_key_pem`] for the runtime engine to load into
12//! a `rustls::ClientConfig`.
13
14use std::fs;
15use std::path::Path;
16
17use bytes::Bytes;
18
19use super::{AuthError, AuthProvider};
20
21/// mTLS auth provider carrying PEM-encoded cert and key material.
22///
23/// `Debug` is implemented manually to redact `private_key_pem` (CWE-532).
24/// A derived `Debug` would print the PEM key body whenever an
25/// `AuthProvider: Debug` is rendered into a tracing span, panic dump,
26/// or support bundle — enabling full client impersonation from a leaked
27/// log line. The cert chain length is shown as a coarse identifier;
28/// callers needing a stable fingerprint should hash the cert themselves.
29#[derive(Clone)]
30pub struct TlsAuth {
31    cert_chain_pem: Bytes,
32    private_key_pem: Bytes,
33}
34
35impl std::fmt::Debug for TlsAuth {
36    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
37        f.debug_struct("TlsAuth")
38            .field("cert_chain_pem_len", &self.cert_chain_pem.len())
39            .field("private_key_pem", &"<redacted>")
40            .finish()
41    }
42}
43
44impl TlsAuth {
45    /// Construct from already-loaded PEM bytes.
46    #[must_use]
47    pub fn from_pem_bytes(cert_chain_pem: Bytes, private_key_pem: Bytes) -> Self {
48        Self {
49            cert_chain_pem,
50            private_key_pem,
51        }
52    }
53
54    /// Read PEM-encoded cert and key from disk.
55    pub fn from_pem_files(
56        cert_path: impl AsRef<Path>,
57        key_path: impl AsRef<Path>,
58    ) -> Result<Self, AuthError> {
59        let cert = fs::read(cert_path.as_ref()).map_err(|err| {
60            AuthError::Io(format!(
61                "reading cert file {}: {err}",
62                cert_path.as_ref().display()
63            ))
64        })?;
65        let key = fs::read(key_path.as_ref()).map_err(|err| {
66            AuthError::Io(format!(
67                "reading key file {}: {err}",
68                key_path.as_ref().display()
69            ))
70        })?;
71        Ok(Self::from_pem_bytes(Bytes::from(cert), Bytes::from(key)))
72    }
73
74    /// PEM-encoded cert chain.
75    #[must_use]
76    pub fn cert_chain_pem(&self) -> &Bytes {
77        &self.cert_chain_pem
78    }
79
80    /// PEM-encoded private key.
81    #[must_use]
82    pub fn private_key_pem(&self) -> &Bytes {
83        &self.private_key_pem
84    }
85}
86
87impl AuthProvider for TlsAuth {
88    #[allow(clippy::unnecessary_literal_bound)]
89    fn method(&self) -> &str {
90        "tls"
91    }
92
93    fn initial(&self) -> Result<Bytes, AuthError> {
94        // mTLS carries the auth at the TLS handshake layer; the protocol `auth_data` is empty.
95        Ok(Bytes::new())
96    }
97}
98
99#[cfg(test)]
100mod tests {
101    use bytes::Bytes;
102
103    use super::{AuthProvider, TlsAuth};
104
105    #[test]
106    fn round_trip_holds_bytes_and_method_is_tls() {
107        let cert =
108            Bytes::from_static(b"-----BEGIN CERTIFICATE-----\nfake\n-----END CERTIFICATE-----\n");
109        let key =
110            Bytes::from_static(b"-----BEGIN PRIVATE KEY-----\nfake\n-----END PRIVATE KEY-----\n");
111        let p = TlsAuth::from_pem_bytes(cert.clone(), key.clone());
112        assert_eq!(p.method(), "tls");
113        assert_eq!(p.cert_chain_pem(), &cert);
114        assert_eq!(p.private_key_pem(), &key);
115        assert!(p.initial().expect("initial").is_empty());
116    }
117
118    #[test]
119    fn from_pem_files_missing_paths() {
120        let err = TlsAuth::from_pem_files(
121            "/this/path/does/not/exist/cert.pem",
122            "/this/path/does/not/exist/key.pem",
123        )
124        .unwrap_err();
125        let msg = err.to_string();
126        assert!(msg.contains("cert file"), "msg={msg}");
127    }
128
129    /// CWE-532 regression: a derived `Debug` would print the PEM private
130    /// key body (enabling full client impersonation from a leaked log
131    /// line). The manual impl must redact it.
132    #[test]
133    fn debug_redacts_private_key() {
134        let cert = Bytes::from_static(
135            b"-----BEGIN CERTIFICATE-----\npublic-cert-bytes\n-----END CERTIFICATE-----\n",
136        );
137        let key = Bytes::from_static(
138            b"-----BEGIN PRIVATE KEY-----\nSECRET-KEY-MATERIAL-1234\n-----END PRIVATE KEY-----\n",
139        );
140        let p = TlsAuth::from_pem_bytes(cert, key);
141        let rendered = format!("{p:?}");
142        assert!(
143            !rendered.contains("SECRET-KEY-MATERIAL"),
144            "private key leaked through Debug: {rendered}",
145        );
146        assert!(
147            !rendered.contains("BEGIN PRIVATE KEY"),
148            "PEM header for key leaked through Debug: {rendered}",
149        );
150        assert!(
151            rendered.contains("<redacted>"),
152            "Debug should mark redaction explicitly: {rendered}",
153        );
154    }
155}