This repository has no description
1use std::convert::Infallible;
2use std::path::PathBuf;
3use std::sync::Arc;
4
5use async_trait::async_trait;
6use futures::StreamExt;
7use knot_types::KnotHostname;
8use rustls::server::ResolvesServerCert;
9use rustls_acme::caches::DirCache;
10use rustls_acme::{AccountCache, AcmeConfig, CertCache};
11use tokio_util::sync::CancellationToken;
12
13#[derive(Debug, thiserror::Error)]
14pub enum AcmeError {
15 #[error("acme cache {path}: {source}")]
16 Cache {
17 path: String,
18 #[source]
19 source: std::io::Error,
20 },
21}
22
23struct RestrictedDirCache {
24 dir: PathBuf,
25 inner: DirCache<PathBuf>,
26}
27
28impl RestrictedDirCache {
29 fn new(dir: PathBuf) -> Self {
30 let inner = DirCache::new(dir.clone());
31 Self { dir, inner }
32 }
33}
34
35#[async_trait]
36impl CertCache for RestrictedDirCache {
37 type EC = std::io::Error;
38
39 async fn load_cert(
40 &self,
41 domains: &[String],
42 directory_url: &str,
43 ) -> Result<Option<Vec<u8>>, Self::EC> {
44 self.inner.load_cert(domains, directory_url).await
45 }
46
47 async fn store_cert(
48 &self,
49 domains: &[String],
50 directory_url: &str,
51 cert: &[u8],
52 ) -> Result<(), Self::EC> {
53 self.inner.store_cert(domains, directory_url, cert).await?;
54 restrict_cache_dir(&self.dir).map_err(std::io::Error::other)
55 }
56}
57
58#[async_trait]
59impl AccountCache for RestrictedDirCache {
60 type EA = std::io::Error;
61
62 async fn load_account(
63 &self,
64 contact: &[String],
65 directory_url: &str,
66 ) -> Result<Option<Vec<u8>>, Self::EA> {
67 self.inner.load_account(contact, directory_url).await
68 }
69
70 async fn store_account(
71 &self,
72 contact: &[String],
73 directory_url: &str,
74 account: &[u8],
75 ) -> Result<(), Self::EA> {
76 self.inner
77 .store_account(contact, directory_url, account)
78 .await?;
79 restrict_cache_dir(&self.dir).map_err(std::io::Error::other)
80 }
81}
82
83#[derive(Debug, thiserror::Error)]
84#[error("acme contact {value:?} isn't a bare email address")]
85pub struct AcmeContactError {
86 value: String,
87}
88
89pub struct AcmeContact(String);
90
91impl AcmeContact {
92 // `mailto()` already puts on the scheme for us,
93 // so someone that came with one
94 // already went out to the ACME account as mailto:mailto:.
95 // Hence no colons.
96 pub fn new(contact: impl Into<String>) -> Result<Self, AcmeContactError> {
97 let contact = contact.into();
98 let mut halves = contact.split('@');
99 let well_formed = matches!((halves.next(), halves.next(), halves.next()), (Some(local), Some(domain), None) if !local.is_empty() && domain.contains('.'))
100 && !contact.contains(':')
101 && !contact.chars().any(|c| c.is_whitespace() || c.is_control());
102 match well_formed {
103 true => Ok(Self(contact)),
104 false => Err(AcmeContactError { value: contact }),
105 }
106 }
107
108 pub fn mailto(&self) -> String {
109 format!("mailto:{}", self.0)
110 }
111}
112
113#[derive(Debug, Clone, PartialEq, Eq)]
114pub struct AcmeCacheDir(PathBuf);
115
116impl AcmeCacheDir {
117 pub fn new(path: impl Into<PathBuf>) -> Self {
118 Self(path.into())
119 }
120
121 pub fn as_path(&self) -> &std::path::Path {
122 &self.0
123 }
124}
125
126pub struct AcmeParams {
127 pub domains: Vec<KnotHostname>,
128 pub contact: AcmeContact,
129 pub cache_dir: AcmeCacheDir,
130 pub production: bool,
131}
132
133pub fn start(
134 params: AcmeParams,
135 shutdown: CancellationToken,
136) -> Result<Arc<dyn ResolvesServerCert>, AcmeError> {
137 std::fs::create_dir_all(params.cache_dir.as_path()).map_err(|source| AcmeError::Cache {
138 path: params.cache_dir.as_path().display().to_string(),
139 source,
140 })?;
141 restrict_cache_dir(params.cache_dir.as_path())?;
142
143 let mut state = AcmeConfig::<Infallible, Infallible>::new(params.domains)
144 .contact_push(params.contact.mailto())
145 .cache(RestrictedDirCache::new(params.cache_dir.0))
146 .directory_lets_encrypt(params.production)
147 .state();
148 let resolver = state.resolver();
149
150 tokio::spawn(async move {
151 loop {
152 tokio::select! {
153 () = shutdown.cancelled() => break,
154 event = state.next() => match event {
155 Some(Ok(ok)) => tracing::info!("acme: {ok:?}"),
156 Some(Err(error)) => tracing::warn!("acme: {error:?}"),
157 None => {
158 tracing::warn!("acme renewal stream ended, certificates will no longer renew");
159 break;
160 }
161 },
162 }
163 }
164 });
165
166 Ok(resolver)
167}
168
169#[cfg(unix)]
170fn restrict_cache_dir(path: &std::path::Path) -> Result<(), AcmeError> {
171 use std::os::unix::fs::PermissionsExt;
172 std::fs::set_permissions(path, std::fs::Permissions::from_mode(0o700)).map_err(|source| {
173 AcmeError::Cache {
174 path: path.display().to_string(),
175 source,
176 }
177 })?;
178 std::fs::read_dir(path)
179 .map_err(|source| AcmeError::Cache {
180 path: path.display().to_string(),
181 source,
182 })?
183 .filter_map(Result::ok)
184 .filter(|entry| {
185 entry
186 .file_type()
187 .map(|kind| kind.is_file())
188 .unwrap_or(false)
189 })
190 .try_for_each(|entry| {
191 std::fs::set_permissions(entry.path(), std::fs::Permissions::from_mode(0o600)).map_err(
192 |source| AcmeError::Cache {
193 path: entry.path().display().to_string(),
194 source,
195 },
196 )
197 })
198}
199
200#[cfg(not(unix))]
201fn restrict_cache_dir(_path: &std::path::Path) -> Result<(), AcmeError> {
202 Ok(())
203}
204
205#[cfg(all(test, unix))]
206mod tests {
207 use super::*;
208 use std::os::unix::fs::PermissionsExt;
209
210 #[test]
211 fn acme_contact_accepts_a_bare_email_and_rejects_everything_else() {
212 assert_eq!(
213 AcmeContact::new("ops@oyster.cafe").unwrap().mailto(),
214 "mailto:ops@oyster.cafe"
215 );
216 assert!(AcmeContact::new("").is_err());
217 assert!(AcmeContact::new("ops").is_err());
218 assert!(AcmeContact::new("ops@localhost").is_err());
219 assert!(AcmeContact::new("mailto:ops@oyster.cafe").is_err());
220 assert!(AcmeContact::new("ops@nel.pet@extra.dev").is_err());
221 assert!(AcmeContact::new("ops @oyster.cafe").is_err());
222 assert!(AcmeContact::new("@oyster.cafe").is_err());
223 }
224
225 #[test]
226 fn the_cache_dir_and_its_files_are_tightened_to_owner_only() {
227 let dir = tempfile::tempdir().unwrap();
228 let key = dir.path().join("account.key");
229 std::fs::write(&key, b"private material").unwrap();
230 std::fs::set_permissions(&key, std::fs::Permissions::from_mode(0o644)).unwrap();
231
232 restrict_cache_dir(dir.path()).unwrap();
233
234 assert_eq!(
235 std::fs::metadata(dir.path()).unwrap().permissions().mode() & 0o777,
236 0o700,
237 "the cache directory must be traversable only by its owner"
238 );
239 assert_eq!(
240 std::fs::metadata(&key).unwrap().permissions().mode() & 0o777,
241 0o600,
242 "a cached private key must be readable only by its owner"
243 );
244 }
245
246 #[tokio::test]
247 async fn a_cert_stored_after_boot_is_tightened_to_owner_only() {
248 let dir = tempfile::tempdir().unwrap();
249 let cache = RestrictedDirCache::new(dir.path().to_path_buf());
250 cache
251 .store_cert(
252 &["anemone.knot".to_string()],
253 "https://acme.test/directory",
254 b"private cert material",
255 )
256 .await
257 .unwrap();
258
259 let modes: Vec<u32> = std::fs::read_dir(dir.path())
260 .unwrap()
261 .filter_map(Result::ok)
262 .map(|entry| {
263 std::fs::metadata(entry.path())
264 .unwrap()
265 .permissions()
266 .mode()
267 & 0o777
268 })
269 .collect();
270 assert_eq!(
271 modes,
272 vec![0o600],
273 "a certificate written after boot must be the only entry and owner-only"
274 );
275 }
276}