1use crate::error::{ProxyError, ProxyResult};
4use sqlx::PgPool;
5use std::path::{Path, PathBuf};
6use tokio::sync::RwLock;
7use tracing::{debug, info, warn};
8
9#[derive(Clone)]
14pub struct Certificate {
15 pub domain: String,
17 pub cert_pem: Vec<u8>,
19 pub key_pem: Vec<u8>,
21 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
36pub struct CertificateStore {
38 pool: PgPool,
40 cache_dir: PathBuf,
42 cache: RwLock<std::collections::HashMap<String, Certificate>>,
44}
45
46impl CertificateStore {
47 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 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 {
81 let cache = self.cache.read().await;
82 if let Some(cert) = cache.get(domain) {
83 return Some(cert.clone());
84 }
85 }
86
87 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 if let Ok(Some(cert)) = self.load_from_database(domain).await {
96 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 pub async fn save(&self, cert: Certificate) -> ProxyResult<()> {
111 check_domain(&cert.domain)?;
112
113 self.save_to_database(&cert).await?;
115
116 self.save_to_file(&cert).await?;
118
119 let mut cache = self.cache.write().await;
121 cache.insert(cert.domain.clone(), cert);
122
123 Ok(())
124 }
125
126 pub async fn remove(&self, domain: &str) -> ProxyResult<()> {
131 check_domain(domain)?;
132
133 sqlx::query("DELETE FROM proxy_certificates WHERE domain = $1")
135 .bind(domain)
136 .execute(&self.pool)
137 .await?;
138
139 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 let mut cache = self.cache.write().await;
146 cache.remove(domain);
147
148 info!("Removed certificate for domain: {}", domain);
149 Ok(())
150 }
151
152 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 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, })
188 }
189
190 async fn save_to_file(&self, cert: &Certificate) -> ProxyResult<()> {
191 let (cert_path, key_path) = self.paths(&cert.domain)?;
192
193 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 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
289pub(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
319async 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
343async 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
383async 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 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 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 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 assert_eq!(mode(&other), 0o644);
526
527 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 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}