Skip to main content

postrust_proxy/tls/
cert_store.rs

1//! Certificate storage with database metadata and file caching.
2
3use crate::error::{ProxyError, ProxyResult};
4use sqlx::PgPool;
5use std::path::{Path, PathBuf};
6use tokio::sync::RwLock;
7use tracing::{debug, info, warn};
8
9/// Certificate and key pair.
10///
11/// `Debug` prints the domain and expiry but never the key or the chain: a
12/// private key that reaches a log is a private key that has to be rotated.
13#[derive(Clone)]
14pub struct Certificate {
15    /// Domain name
16    pub domain: String,
17    /// Certificate chain in PEM format
18    pub cert_pem: Vec<u8>,
19    /// Private key in PEM format
20    pub key_pem: Vec<u8>,
21    /// Expiry timestamp
22    pub expires_at: Option<chrono::DateTime<chrono::Utc>>,
23}
24
25impl std::fmt::Debug for Certificate {
26    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
27        f.debug_struct("Certificate")
28            .field("domain", &self.domain)
29            .field("expires_at", &self.expires_at)
30            .field("cert_pem", &format_args!("<{} bytes>", self.cert_pem.len()))
31            .field("key_pem", &format_args!("<redacted>"))
32            .finish()
33    }
34}
35
36/// Certificate store with database metadata and file caching.
37pub struct CertificateStore {
38    /// Database pool for metadata
39    pool: PgPool,
40    /// Cache directory for certificate files
41    cache_dir: PathBuf,
42    /// In-memory certificate cache
43    cache: RwLock<std::collections::HashMap<String, Certificate>>,
44}
45
46impl CertificateStore {
47    /// Create a new certificate store.
48    ///
49    /// The cache directory holds private keys, so it is created owner-only
50    /// (0700) and every key in it is kept at 0600. A directory that already
51    /// exists is left as it is -- `cert_dir` might be a shared path, and
52    /// tightening it could break whatever else uses it -- but a warning says
53    /// so if others can read it, and key files written by earlier versions,
54    /// which were left at the umask's default (typically world-readable), are
55    /// tightened.
56    pub async fn new(pool: PgPool, cache_dir: impl AsRef<Path>) -> ProxyResult<Self> {
57        let cache_dir = cache_dir.as_ref().to_path_buf();
58
59        create_private_dir(&cache_dir).await?;
60        tighten_existing_keys(&cache_dir).await;
61
62        Ok(Self {
63            pool,
64            cache_dir,
65            cache: RwLock::new(std::collections::HashMap::new()),
66        })
67    }
68
69    /// Get a certificate for a domain.
70    ///
71    /// `None` for a name that is not a valid lowercase DNS name: see
72    /// [`check_domain`].
73    pub async fn get(&self, domain: &str) -> Option<Certificate> {
74        if let Err(e) = check_domain(domain) {
75            debug!("Not looking up a certificate: {}", e);
76            return None;
77        }
78
79        // Check in-memory cache first
80        {
81            let cache = self.cache.read().await;
82            if let Some(cert) = cache.get(domain) {
83                return Some(cert.clone());
84            }
85        }
86
87        // Try to load from file cache
88        if let Ok(cert) = self.load_from_file(domain).await {
89            let mut cache = self.cache.write().await;
90            cache.insert(domain.to_string(), cert.clone());
91            return Some(cert);
92        }
93
94        // Try to load from database
95        if let Ok(Some(cert)) = self.load_from_database(domain).await {
96            // Cache to file and memory
97            let _ = self.save_to_file(&cert).await;
98            let mut cache = self.cache.write().await;
99            cache.insert(domain.to_string(), cert.clone());
100            return Some(cert);
101        }
102
103        None
104    }
105
106    /// Save a certificate.
107    ///
108    /// Refused unless `cert.domain` is a valid lowercase DNS name: see
109    /// [`check_domain`].
110    pub async fn save(&self, cert: Certificate) -> ProxyResult<()> {
111        check_domain(&cert.domain)?;
112
113        // Save to database
114        self.save_to_database(&cert).await?;
115
116        // Save to file cache
117        self.save_to_file(&cert).await?;
118
119        // Update in-memory cache
120        let mut cache = self.cache.write().await;
121        cache.insert(cert.domain.clone(), cert);
122
123        Ok(())
124    }
125
126    /// Remove a certificate.
127    ///
128    /// Refused unless `domain` is a valid lowercase DNS name: see
129    /// [`check_domain`].
130    pub async fn remove(&self, domain: &str) -> ProxyResult<()> {
131        check_domain(domain)?;
132
133        // Remove from database
134        sqlx::query("DELETE FROM proxy_certificates WHERE domain = $1")
135            .bind(domain)
136            .execute(&self.pool)
137            .await?;
138
139        // Remove from file cache
140        let (cert_path, key_path) = self.paths(domain)?;
141        let _ = tokio::fs::remove_file(cert_path).await;
142        let _ = tokio::fs::remove_file(key_path).await;
143
144        // Remove from memory cache
145        let mut cache = self.cache.write().await;
146        cache.remove(domain);
147
148        info!("Removed certificate for domain: {}", domain);
149        Ok(())
150    }
151
152    /// List all stored domains.
153    pub async fn list_domains(&self) -> ProxyResult<Vec<String>> {
154        let rows: Vec<(String,)> = sqlx::query_as("SELECT domain FROM proxy_certificates")
155            .fetch_all(&self.pool)
156            .await?;
157
158        Ok(rows.into_iter().map(|(d,)| d).collect())
159    }
160
161    /// The cache files for `domain`: certificate, then key.
162    ///
163    /// The only place a path is built from a domain, and it refuses any name
164    /// [`check_domain`] does: a domain is a tenant-supplied string, and
165    /// `../../somewhere` joined onto the cache directory is somewhere else.
166    fn paths(&self, domain: &str) -> ProxyResult<(PathBuf, PathBuf)> {
167        check_domain(domain)?;
168        Ok((
169            self.cache_dir.join(format!("{domain}.crt")),
170            self.cache_dir.join(format!("{domain}.key")),
171        ))
172    }
173
174    async fn load_from_file(&self, domain: &str) -> ProxyResult<Certificate> {
175        let (cert_path, key_path) = self.paths(domain)?;
176
177        let cert_pem = tokio::fs::read(&cert_path).await?;
178        let key_pem = tokio::fs::read(&key_path).await?;
179
180        debug!("Loaded certificate from file: {}", domain);
181
182        Ok(Certificate {
183            domain: domain.to_string(),
184            cert_pem,
185            key_pem,
186            expires_at: None, // Would need to parse cert to get expiry
187        })
188    }
189
190    async fn save_to_file(&self, cert: &Certificate) -> ProxyResult<()> {
191        let (cert_path, key_path) = self.paths(&cert.domain)?;
192
193        // The chain is public; the key is not. `tokio::fs::write` created both
194        // with the umask's default, which on most systems left every tenant's
195        // private key readable by every local user.
196        write_replacing(&cert_path, &cert.cert_pem, 0o644).await?;
197        write_replacing(&key_path, &cert.key_pem, 0o600).await?;
198
199        debug!("Saved certificate to file: {}", cert.domain);
200        Ok(())
201    }
202
203    /// Read every certificate from the database, ignoring both caches.
204    ///
205    /// [`get`](Self::get) answers from memory once a domain has been read
206    /// once, which is right for a handshake and wrong for a refresh: a
207    /// certificate renewed by *another* instance would never be noticed, and
208    /// this proxy would go on serving the old one until it was restarted. The
209    /// database is the authority, so a refresh asks it.
210    ///
211    /// The in-memory cache is replaced with what was read, so the two do not
212    /// then disagree.
213    pub async fn load_all(&self) -> ProxyResult<Vec<Certificate>> {
214        let rows: Vec<(
215            String,
216            Vec<u8>,
217            Vec<u8>,
218            Option<chrono::DateTime<chrono::Utc>>,
219        )> = sqlx::query_as("SELECT domain, cert_pem, key_pem, expires_at FROM proxy_certificates")
220            .fetch_all(&self.pool)
221            .await?;
222
223        let certificates: Vec<Certificate> = rows
224            .into_iter()
225            .map(|(domain, cert_pem, key_pem, expires_at)| Certificate {
226                domain,
227                cert_pem,
228                key_pem,
229                expires_at,
230            })
231            .collect();
232
233        let mut cache = self.cache.write().await;
234        *cache = certificates
235            .iter()
236            .map(|c| (c.domain.clone(), c.clone()))
237            .collect();
238
239        debug!(
240            "Loaded {} certificate(s) from the database",
241            certificates.len()
242        );
243        Ok(certificates)
244    }
245
246    async fn load_from_database(&self, domain: &str) -> ProxyResult<Option<Certificate>> {
247        let row: Option<(String, Vec<u8>, Vec<u8>, Option<chrono::DateTime<chrono::Utc>>)> =
248            sqlx::query_as(
249                "SELECT domain, cert_pem, key_pem, expires_at FROM proxy_certificates WHERE domain = $1",
250            )
251            .bind(domain)
252            .fetch_optional(&self.pool)
253            .await?;
254
255        Ok(
256            row.map(|(domain, cert_pem, key_pem, expires_at)| Certificate {
257                domain,
258                cert_pem,
259                key_pem,
260                expires_at,
261            }),
262        )
263    }
264
265    async fn save_to_database(&self, cert: &Certificate) -> ProxyResult<()> {
266        sqlx::query(
267            r#"
268            INSERT INTO proxy_certificates (domain, cert_pem, key_pem, expires_at, updated_at)
269            VALUES ($1, $2, $3, $4, NOW())
270            ON CONFLICT (domain) DO UPDATE SET
271                cert_pem = EXCLUDED.cert_pem,
272                key_pem = EXCLUDED.key_pem,
273                expires_at = EXCLUDED.expires_at,
274                updated_at = NOW()
275            "#,
276        )
277        .bind(&cert.domain)
278        .bind(&cert.cert_pem)
279        .bind(&cert.key_pem)
280        .bind(cert.expires_at)
281        .execute(&self.pool)
282        .await?;
283
284        info!("Saved certificate to database: {}", cert.domain);
285        Ok(())
286    }
287}
288
289/// Refuse anything but a lowercase DNS name, optionally a wildcard.
290///
291/// Labels of `a-z`, `0-9` and `-`, not starting or ending with `-`, at most 63
292/// bytes each and 253 in all, with at most a single leading `*.`. This is what
293/// stands between a tenant-supplied domain and a file path: no `/`, no `..`,
294/// no NUL, nothing a filesystem treats specially.
295///
296/// Lowercase only, rather than lowered here: the store is keyed by the exact
297/// string, and quietly accepting `Example.com` would make it a different
298/// entry from `example.com` in the database while naming the same file.
299pub(crate) fn check_domain(domain: &str) -> ProxyResult<()> {
300    let bare = domain.strip_prefix("*.").unwrap_or(domain);
301    let label_ok = |label: &str| {
302        !label.is_empty()
303            && label.len() <= 63
304            && !label.starts_with('-')
305            && !label.ends_with('-')
306            && label
307                .bytes()
308                .all(|b| b.is_ascii_lowercase() || b.is_ascii_digit() || b == b'-')
309    };
310    if domain.len() <= 253 && !bare.is_empty() && bare.split('.').all(label_ok) {
311        Ok(())
312    } else {
313        Err(ProxyError::Validation(format!(
314            "{domain:?} is not a valid lowercase DNS name"
315        )))
316    }
317}
318
319/// Create the cache directory, owner-only if it is new.
320async fn create_private_dir(dir: &Path) -> std::io::Result<()> {
321    let mut builder = tokio::fs::DirBuilder::new();
322    builder.recursive(true);
323    #[cfg(unix)]
324    builder.mode(0o700);
325    builder.create(dir).await?;
326
327    #[cfg(unix)]
328    {
329        use std::os::unix::fs::PermissionsExt;
330        let mode = tokio::fs::metadata(dir).await?.permissions().mode();
331        if mode & 0o077 != 0 {
332            warn!(
333                "Certificate directory {} is accessible to other users (mode {:o}); \
334                 the keys in it are 0600, but consider making it 0700",
335                dir.display(),
336                mode & 0o777
337            );
338        }
339    }
340    Ok(())
341}
342
343/// Make any key file an earlier version left readable owner-only.
344///
345/// Only files named like ours (`<domain>.key`), so pointing `cert_dir` at a
346/// directory that holds other things does not change their permissions.
347async fn tighten_existing_keys(dir: &Path) {
348    #[cfg(unix)]
349    {
350        use std::os::unix::fs::PermissionsExt;
351        let Ok(mut entries) = tokio::fs::read_dir(dir).await else {
352            return;
353        };
354        while let Ok(Some(entry)) = entries.next_entry().await {
355            let name = entry.file_name();
356            let Some(domain) = name.to_str().and_then(|n| n.strip_suffix(".key")) else {
357                continue;
358            };
359            if check_domain(domain).is_err() {
360                continue;
361            }
362            let Ok(meta) = entry.metadata().await else {
363                continue;
364            };
365            if !meta.is_file() || meta.permissions().mode() & 0o077 == 0 {
366                continue;
367            }
368            let path = entry.path();
369            match tokio::fs::set_permissions(&path, std::fs::Permissions::from_mode(0o600)).await {
370                Ok(()) => info!("Restricted {} to 0600", path.display()),
371                Err(e) => warn!(
372                    "{} is readable by other users and could not be restricted: {}",
373                    path.display(),
374                    e
375                ),
376            }
377        }
378    }
379    #[cfg(not(unix))]
380    let _ = dir;
381}
382
383/// Write `contents` to `path` with `mode`, replacing whatever was there.
384///
385/// Written to a fresh file beside it and renamed into place. The mode applies
386/// at creation, so the key is never readable by others even for a moment, and
387/// a file that already existed with looser permissions is replaced rather
388/// than rewritten with them. The rename also means a reader never sees a
389/// half-written key.
390async fn write_replacing(path: &Path, contents: &[u8], mode: u32) -> std::io::Result<()> {
391    use tokio::io::AsyncWriteExt;
392
393    let mut tmp_name = path.as_os_str().to_owned();
394    tmp_name.push(format!(".tmp-{}", uuid::Uuid::new_v4().simple()));
395    let tmp = PathBuf::from(tmp_name);
396
397    let mut options = tokio::fs::OpenOptions::new();
398    options.write(true).create_new(true);
399    #[cfg(unix)]
400    options.mode(mode);
401    #[cfg(not(unix))]
402    let _ = mode;
403
404    let result = async {
405        let mut file = options.open(&tmp).await?;
406        file.write_all(contents).await?;
407        file.sync_all().await?;
408        drop(file);
409        tokio::fs::rename(&tmp, path).await
410    }
411    .await;
412    if result.is_err() {
413        let _ = tokio::fs::remove_file(&tmp).await;
414    }
415    result
416}
417
418#[cfg(test)]
419mod tests {
420    use super::*;
421
422    fn scratch_dir() -> PathBuf {
423        std::env::temp_dir().join(format!("postrust-cert-store-{}", uuid::Uuid::new_v4()))
424    }
425
426    async fn store(dir: &Path) -> CertificateStore {
427        // Lazy, and never connected: these exercise the file cache only.
428        let pool = sqlx::postgres::PgPoolOptions::new()
429            .connect_lazy("postgres://nobody@127.0.0.1:1/none")
430            .unwrap();
431        CertificateStore::new(pool, dir).await.unwrap()
432    }
433
434    fn cert(domain: &str) -> Certificate {
435        Certificate {
436            domain: domain.into(),
437            cert_pem: b"CERT".to_vec(),
438            key_pem: b"KEY".to_vec(),
439            expires_at: None,
440        }
441    }
442
443    #[test]
444    fn only_lowercase_dns_names_are_accepted() {
445        for ok in [
446            "example.com",
447            "a.b.example.com",
448            "xn--bcher-kva.example",
449            "*.example.com",
450            "a-b.c0.example",
451            "localhost",
452        ] {
453            assert!(check_domain(ok).is_ok(), "{ok} should be accepted");
454        }
455        let long_label = format!("{}.com", "a".repeat(64));
456        let long_name = format!("{}.com", vec!["a".repeat(60); 5].join("."));
457        for bad in [
458            "",
459            ".",
460            "..",
461            "../etc/passwd",
462            "a/b",
463            "a\\b",
464            "example.com/../../x",
465            "Example.com",
466            "example..com",
467            ".example.com",
468            "example.com.",
469            "*.*.example.com",
470            "a.*.example.com",
471            "*",
472            "*.",
473            "-a.example.com",
474            "a-.example.com",
475            "exa mple.com",
476            "example.com\0",
477            "ex_ample.com",
478            long_label.as_str(),
479            long_name.as_str(),
480        ] {
481            assert!(check_domain(bad).is_err(), "{bad:?} should be refused");
482        }
483    }
484
485    #[cfg(unix)]
486    #[tokio::test]
487    async fn keys_are_written_owner_only_in_an_owner_only_directory() {
488        use std::os::unix::fs::PermissionsExt;
489        let dir = scratch_dir().join("nested");
490        let store = store(&dir).await;
491        store.save_to_file(&cert("a.example.com")).await.unwrap();
492
493        let mode = |p: PathBuf| std::fs::metadata(p).unwrap().permissions().mode() & 0o777;
494        assert_eq!(mode(dir.clone()), 0o700);
495        assert_eq!(mode(dir.join("a.example.com.key")), 0o600);
496        // The chain is public, but never more than 0644.
497        assert_eq!(mode(dir.join("a.example.com.crt")) & !0o644, 0);
498        assert_eq!(
499            std::fs::read(dir.join("a.example.com.key")).unwrap(),
500            b"KEY"
501        );
502
503        // No temporary files left behind.
504        assert_eq!(std::fs::read_dir(&dir).unwrap().count(), 2);
505        let _ = std::fs::remove_dir_all(dir.parent().unwrap());
506    }
507
508    #[cfg(unix)]
509    #[tokio::test]
510    async fn a_world_readable_key_from_an_earlier_version_is_tightened() {
511        use std::os::unix::fs::PermissionsExt;
512        let dir = scratch_dir();
513        std::fs::create_dir_all(&dir).unwrap();
514        let key = dir.join("old.example.com.key");
515        let other = dir.join("unrelated.txt");
516        for path in [&key, &other] {
517            std::fs::write(path, b"x").unwrap();
518            std::fs::set_permissions(path, std::fs::Permissions::from_mode(0o644)).unwrap();
519        }
520
521        let store = store(&dir).await;
522        let mode = |p: &Path| std::fs::metadata(p).unwrap().permissions().mode() & 0o777;
523        assert_eq!(mode(&key), 0o600);
524        // Not ours, not touched.
525        assert_eq!(mode(&other), 0o644);
526
527        // And overwriting replaces the file rather than inheriting its mode.
528        std::fs::set_permissions(&key, std::fs::Permissions::from_mode(0o644)).unwrap();
529        store.save_to_file(&cert("old.example.com")).await.unwrap();
530        assert_eq!(mode(&key), 0o600);
531        let _ = std::fs::remove_dir_all(&dir);
532    }
533
534    #[tokio::test]
535    async fn a_domain_cannot_name_a_path_outside_the_cache() {
536        let root = scratch_dir();
537        let dir = root.join("certs");
538        let store = store(&dir).await;
539        // A certificate sitting just outside the cache directory.
540        std::fs::write(root.join("evil.crt"), b"CERT").unwrap();
541        std::fs::write(root.join("evil.key"), b"KEY").unwrap();
542
543        assert!(store.get("../evil").await.is_none());
544        assert!(store.load_from_file("../evil").await.is_err());
545        assert!(store.save_to_file(&cert("../evil")).await.is_err());
546        assert!(store.save(cert("../evil")).await.is_err());
547        assert!(store.remove("../evil").await.is_err());
548        assert!(root.join("evil.key").exists());
549        let _ = std::fs::remove_dir_all(&root);
550    }
551}