Skip to main content

reddb_server/wire/
tls.rs

1/// Wire Protocol TLS support
2///
3/// Provides:
4/// - Auto-generated self-signed certificates for dev mode
5/// - TLS acceptor configuration from cert/key files
6/// - TLS-wrapped TCP listener
7use std::io;
8use std::path::{Path, PathBuf};
9use std::sync::Arc;
10
11use rustls::ServerConfig;
12use tokio_rustls::TlsAcceptor;
13
14/// TLS configuration for the wire protocol.
15#[derive(Debug, Clone)]
16pub struct WireTlsConfig {
17    /// Path to PEM certificate file
18    pub cert_path: PathBuf,
19    /// Path to PEM private key file
20    pub key_path: PathBuf,
21}
22
23/// Generate a self-signed certificate for development.
24/// Returns (cert_pem, key_pem) as strings.
25///
26/// The wire cert carries CN `"RedDB Wire {hostname}"` **and**
27/// `OrganizationName "RedDB"`. The HTTP edge shares this generator but
28/// passes `org = None` (see [`generate_self_signed_dev_cert`]) so its
29/// cert keeps no organization.
30pub fn generate_self_signed_cert(
31    hostname: &str,
32) -> Result<(String, String), Box<dyn std::error::Error>> {
33    generate_self_signed_dev_cert(hostname, "RedDB Wire", Some("RedDB"))
34}
35
36/// Shared self-signed dev-cert generator for the wire and HTTP edges.
37///
38/// Behaviour-preserving parameterization of two formerly near-identical
39/// rcgen blocks (issue #1055): the CN is `"{cn_label} {hostname}"`, and
40/// `org` — when `Some` — sets `OrganizationName`. The wire edge passes
41/// `Some("RedDB")`; the HTTP edge passes `None` so its cert keeps CN
42/// `"RedDB HTTP …"` with **no** organization (do NOT silently add
43/// `O=RedDB` to the HTTP cert). The SAN block (`hostname`, `localhost`
44/// when distinct, and `127.0.0.1`) is identical for both.
45pub(crate) fn generate_self_signed_dev_cert(
46    hostname: &str,
47    cn_label: &str,
48    org: Option<&str>,
49) -> Result<(String, String), Box<dyn std::error::Error>> {
50    use rcgen::{CertificateParams, KeyPair};
51
52    let mut params = CertificateParams::new(vec![hostname.to_string()])?;
53    params.distinguished_name.push(
54        rcgen::DnType::CommonName,
55        rcgen::DnValue::Utf8String(format!("{cn_label} {hostname}")),
56    );
57    if let Some(org) = org {
58        params.distinguished_name.push(
59            rcgen::DnType::OrganizationName,
60            rcgen::DnValue::Utf8String(org.to_string()),
61        );
62    }
63
64    // Add localhost + IP SANs for dev
65    params
66        .subject_alt_names
67        .push(rcgen::SanType::DnsName(hostname.try_into()?));
68    if hostname != "localhost" {
69        params
70            .subject_alt_names
71            .push(rcgen::SanType::DnsName("localhost".try_into()?));
72    }
73    // 127.0.0.1
74    params
75        .subject_alt_names
76        .push(rcgen::SanType::IpAddress(std::net::IpAddr::V4(
77            std::net::Ipv4Addr::LOCALHOST,
78        )));
79
80    let key_pair = KeyPair::generate()?;
81    let cert = params.self_signed(&key_pair)?;
82
83    Ok((cert.pem(), key_pair.serialize_pem()))
84}
85
86/// Generate self-signed cert and write to files in the given directory.
87/// Returns the WireTlsConfig pointing to the written files.
88///
89/// Gated by `RED_WIRE_TLS_DEV=1`; refuses to auto-generate in any other
90/// context (refuses prod by default). This mirrors the HTTP edge's
91/// `RED_HTTP_TLS_DEV` opt-in ([`crate::server::tls::auto_generate_dev_cert`])
92/// — the two edges are independent, so enabling dev certs on one does not
93/// enable them on the other.
94pub fn auto_generate_cert(dir: &Path) -> Result<WireTlsConfig, Box<dyn std::error::Error>> {
95    let dev_flag = std::env::var("RED_WIRE_TLS_DEV").unwrap_or_default();
96    if !matches!(dev_flag.as_str(), "1" | "true" | "yes" | "on") {
97        return Err(
98            "refusing to auto-generate wire TLS cert: set RED_WIRE_TLS_DEV=1 to opt into self-signed dev certs"
99                .into(),
100        );
101    }
102
103    let cert_path = dir.join("wire-tls-cert.pem");
104    let key_path = dir.join("wire-tls-key.pem");
105
106    // If files already exist, reuse them
107    if cert_path.exists() && key_path.exists() {
108        tracing::info!(cert = %cert_path.display(), "wire TLS: reusing existing cert");
109        return Ok(WireTlsConfig {
110            cert_path,
111            key_path,
112        });
113    }
114
115    tracing::info!("wire TLS: generating self-signed certificate");
116    let (cert_pem, key_pem) = generate_self_signed_cert("localhost")?;
117
118    std::fs::create_dir_all(dir)?;
119    std::fs::write(&cert_path, &cert_pem)?;
120    std::fs::write(&key_path, &key_pem)?;
121
122    // Restrict key file permissions on Unix
123    #[cfg(unix)]
124    {
125        use std::os::unix::fs::PermissionsExt;
126        std::fs::set_permissions(&key_path, std::fs::Permissions::from_mode(0o600))?;
127    }
128
129    tracing::info!(
130        cert = %cert_path.display(),
131        key = %key_path.display(),
132        "wire TLS: wrote self-signed cert"
133    );
134
135    Ok(WireTlsConfig {
136        cert_path,
137        key_path,
138    })
139}
140
141/// Build a TLS acceptor from cert and key PEM files.
142pub fn build_tls_acceptor(
143    config: &WireTlsConfig,
144) -> Result<TlsAcceptor, Box<dyn std::error::Error>> {
145    // Ensure the ring crypto provider is installed
146    let _ = rustls::crypto::ring::default_provider().install_default();
147
148    let cert_pem = std::fs::read(&config.cert_path)?;
149    let key_pem = std::fs::read(&config.key_path)?;
150
151    let certs = rustls_pemfile::certs(&mut io::BufReader::new(&cert_pem[..]))
152        .collect::<Result<Vec<_>, _>>()?;
153    let key = rustls_pemfile::private_key(&mut io::BufReader::new(&key_pem[..]))?
154        .ok_or("no private key found in PEM file")?;
155
156    let server_config = ServerConfig::builder()
157        .with_no_client_auth()
158        .with_single_cert(certs, key)?;
159
160    Ok(TlsAcceptor::from(Arc::new(server_config)))
161}
162
163#[cfg(test)]
164mod tests {
165    use super::*;
166
167    /// Parse the subject CN + Organization out of a self-signed PEM cert,
168    /// so the wire-vs-HTTP DN difference is pinned end-to-end (issue #1055).
169    fn subject_cn_and_orgs(cert_pem: &str) -> (Vec<String>, Vec<String>) {
170        let der = rustls_pemfile::certs(&mut io::BufReader::new(cert_pem.as_bytes()))
171            .next()
172            .expect("one certificate in PEM")
173            .expect("valid certificate PEM");
174        let (_, parsed) =
175            x509_parser::parse_x509_certificate(der.as_ref()).expect("parse generated X.509");
176        let subject = parsed.subject();
177        let cns = subject
178            .iter_common_name()
179            .filter_map(|cn| cn.as_str().ok().map(str::to_string))
180            .collect();
181        let orgs = subject
182            .iter_organization()
183            .filter_map(|o| o.as_str().ok().map(str::to_string))
184            .collect();
185        (cns, orgs)
186    }
187
188    #[test]
189    fn wire_cert_keeps_wire_cn_and_reddb_org() {
190        let (cert_pem, key_pem) =
191            generate_self_signed_cert("localhost").expect("generate wire cert");
192        assert!(key_pem.contains("PRIVATE KEY"), "key PEM emitted");
193        let (cns, orgs) = subject_cn_and_orgs(&cert_pem);
194        assert_eq!(cns, vec!["RedDB Wire localhost".to_string()]);
195        assert_eq!(orgs, vec!["RedDB".to_string()]);
196    }
197
198    #[test]
199    fn http_cert_keeps_http_cn_and_no_org() {
200        // The non-obvious diff (issue #1055): the HTTP cert must keep CN
201        // "RedDB HTTP …" and carry NO organization. The shared generator
202        // passes org = None so we never silently add O=RedDB to it.
203        let (cert_pem, _key) = generate_self_signed_dev_cert("localhost", "RedDB HTTP", None)
204            .expect("generate http cert");
205        let (cns, orgs) = subject_cn_and_orgs(&cert_pem);
206        assert_eq!(cns, vec!["RedDB HTTP localhost".to_string()]);
207        assert!(orgs.is_empty(), "HTTP cert must not carry an Organization");
208    }
209
210    /// Tests that mutate the process-global `RED_WIRE_TLS_DEV` env var must
211    /// serialize to avoid trampling each other under cargo's default
212    /// parallel test runner (mirrors the HTTP twin's `env_lock`).
213    fn env_lock() -> &'static std::sync::Mutex<()> {
214        static LOCK: std::sync::OnceLock<std::sync::Mutex<()>> = std::sync::OnceLock::new();
215        LOCK.get_or_init(|| std::sync::Mutex::new(()))
216    }
217
218    #[test]
219    fn auto_generate_refuses_without_dev_flag() {
220        let _g = env_lock().lock();
221        let dir = std::env::temp_dir().join(format!(
222            "reddb-wire-tls-test-{}-{}",
223            std::process::id(),
224            std::time::SystemTime::now()
225                .duration_since(std::time::UNIX_EPOCH)
226                .unwrap()
227                .as_nanos()
228        ));
229        // Make sure flag is unset.
230        unsafe {
231            std::env::remove_var("RED_WIRE_TLS_DEV");
232        }
233        let err = auto_generate_cert(&dir).unwrap_err();
234        assert!(err.to_string().contains("RED_WIRE_TLS_DEV"));
235    }
236
237    #[test]
238    fn auto_generate_with_dev_flag_writes_cert() {
239        let _g = env_lock().lock();
240        let dir = std::env::temp_dir().join(format!(
241            "reddb-wire-tls-dev-{}-{}",
242            std::process::id(),
243            std::time::SystemTime::now()
244                .duration_since(std::time::UNIX_EPOCH)
245                .unwrap()
246                .as_nanos()
247        ));
248        unsafe {
249            std::env::set_var("RED_WIRE_TLS_DEV", "1");
250        }
251        let cfg = auto_generate_cert(&dir).expect("should generate");
252        assert!(cfg.cert_path.exists());
253        assert!(cfg.key_path.exists());
254        unsafe {
255            std::env::remove_var("RED_WIRE_TLS_DEV");
256        }
257        let _ = std::fs::remove_dir_all(&dir);
258    }
259}