Skip to main content

dhttp_home/identity/
ssl.rs

1use std::{
2    iter,
3    path::{Path, PathBuf},
4};
5
6use futures::{Stream, StreamExt, stream};
7use rustls::pki_types::{CertificateDer, PrivateKeyDer};
8use snafu::{IntoError, ResultExt, Snafu};
9use tokio::{
10    fs::{self, ReadDir},
11    io::{self, AsyncWriteExt},
12};
13use x509_parser::prelude::Pem;
14
15use dhttp_identity::{identity::Identity, name::DhttpName};
16
17use crate::{DhttpHome, identity::IdentityProfile};
18
19pub const SSL_DIR_NAME: &str = "ssl";
20pub const CERT_FILE_NAME: &str = "fullchain.crt";
21pub const KEY_FILE_NAME: &str = "privkey.pem";
22
23#[derive(Snafu, Debug)]
24#[snafu(module)]
25pub enum ResolveIdentityProfileError {
26    #[snafu(display("failed to inspect exact identity profile path {}", path.display()))]
27    ExactMetadata { path: PathBuf, source: io::Error },
28    #[snafu(display("failed to inspect wildcard identity profile path {}", path.display()))]
29    WildcardMetadata { path: PathBuf, source: io::Error },
30    #[snafu(display("exact identity profile path does not exist: {}", path.display()))]
31    ExactNotFound { path: PathBuf },
32    #[snafu(display("wildcard identity profile path does not exist: {}", path.display()))]
33    WildcardNotFound { path: PathBuf },
34    #[snafu(display(
35        "identity profile does not exist at exact path {} or wildcard path {}",
36        exact.display(),
37        wildcard.display()
38    ))]
39    NotFound { exact: PathBuf, wildcard: PathBuf },
40}
41
42#[derive(Snafu, Debug)]
43#[snafu(module)]
44pub enum LoadCertsError {
45    #[snafu(display("failed to read certificate file {}", path.display()))]
46    Read { path: PathBuf, source: io::Error },
47    #[snafu(display("failed to parse pem block in {}", path.display()))]
48    Pem {
49        path: PathBuf,
50        source: x509_parser::error::PEMError,
51    },
52}
53
54#[derive(Snafu, Debug)]
55#[snafu(module)]
56pub enum LoadKeyError {
57    #[snafu(display("failed to inspect private key file {}", path.display()))]
58    Metadata { path: PathBuf, source: io::Error },
59    #[snafu(display("failed to read private key file {}", path.display()))]
60    Read { path: PathBuf, source: io::Error },
61    #[snafu(display(
62        "private key file permissions are too open at {} (current {current:o}, expected to be 400)",
63        path.display()
64    ))]
65    PermissionsTooOpen { path: PathBuf, current: u32 },
66    #[snafu(display("failed to parse private key file {}", path.display()))]
67    Parse {
68        path: PathBuf,
69        source: rustls::pki_types::pem::Error,
70    },
71}
72
73#[derive(Snafu, Debug)]
74#[snafu(module)]
75pub enum LoadIdentityError {
76    #[snafu(display("failed to load identity certificates at {}", path.display()))]
77    LoadCerts {
78        path: PathBuf,
79        source: LoadCertsError,
80    },
81
82    #[snafu(display("failed to load identity private key at {}", path.display()))]
83    LoadKey { path: PathBuf, source: LoadKeyError },
84}
85
86#[derive(Snafu, Debug)]
87#[snafu(module)]
88pub enum SaveIdentityError {
89    #[snafu(display("failed to create identity directory at {}", path.display()))]
90    CreateIdentityDir { path: PathBuf, source: io::Error },
91    #[snafu(display("failed to get metadata for path {}", path.display()))]
92    Metadata { path: PathBuf, source: io::Error },
93    #[snafu(display("failed to delete old file at {}", path.display()))]
94    Delete { path: PathBuf, source: io::Error },
95    #[snafu(display("failed to create file at {}", path.display()))]
96    Create { path: PathBuf, source: io::Error },
97    #[snafu(display("failed to write to file at {}", path.display()))]
98    Write { path: PathBuf, source: io::Error },
99}
100
101#[derive(Snafu, Debug)]
102#[snafu(module)]
103pub enum ListIdentityProfilesError {
104    #[snafu(display("failed to list identity profiles in directory {}", path.display()))]
105    ReadDir { path: PathBuf, source: io::Error },
106    #[snafu(display("failed to read filetype of {}", path.display()))]
107    ReadFty { path: PathBuf, source: io::Error },
108}
109
110impl IdentityProfile {
111    pub fn ssl_dir(&self) -> PathBuf {
112        self.join(SSL_DIR_NAME)
113    }
114
115    pub async fn load_certs(&self) -> Result<Vec<CertificateDer<'static>>, LoadCertsError> {
116        let certs_path = self.ssl_dir().join(CERT_FILE_NAME);
117        let mut data = std::io::Cursor::new(fs::read(certs_path.as_path()).await.context(
118            load_certs_error::ReadSnafu {
119                path: certs_path.clone(),
120            },
121        )?);
122        let (end_entity_pem, _read) = Pem::read(&mut data).context(load_certs_error::PemSnafu {
123            path: certs_path.clone(),
124        })?;
125        let mut certs = vec![CertificateDer::from(end_entity_pem.contents)];
126        loop {
127            match Pem::read(&mut data) {
128                Ok((pem, _read)) => {
129                    certs.push(CertificateDer::from(pem.contents));
130                }
131                Err(x509_parser::error::PEMError::MissingHeader) => break,
132                result => {
133                    _ = result.context(load_certs_error::PemSnafu {
134                        path: certs_path.clone(),
135                    })?;
136                }
137            }
138        }
139
140        Ok(certs)
141    }
142
143    pub async fn load_key(&self) -> Result<PrivateKeyDer<'static>, LoadKeyError> {
144        let key_path = self.ssl_dir().join(KEY_FILE_NAME);
145        #[cfg(unix)]
146        {
147            use std::os::unix::fs::MetadataExt;
148
149            use snafu::ensure;
150            let metadata =
151                fs::metadata(key_path.as_path())
152                    .await
153                    .context(load_key_error::MetadataSnafu {
154                        path: key_path.clone(),
155                    })?;
156            let permissions = metadata.mode() & 0o777;
157            ensure!(
158                permissions == 0o400,
159                load_key_error::PermissionsTooOpenSnafu {
160                    path: key_path.clone(),
161                    current: permissions
162                }
163            )
164        }
165
166        let data = fs::read(key_path.as_path())
167            .await
168            .context(load_key_error::ReadSnafu {
169                path: key_path.clone(),
170            })?;
171        rustls::pki_types::pem::PemObject::from_pem_slice(&data).context(
172            load_key_error::ParseSnafu {
173                path: key_path.clone(),
174            },
175        )
176    }
177
178    /// Load this profile's identity (certificate chain + private key) from disk.
179    pub async fn load_identity(&self) -> Result<Identity, LoadIdentityError> {
180        let certs_path = self.ssl_dir().join(CERT_FILE_NAME);
181        let certs = self
182            .load_certs()
183            .await
184            .context(load_identity_error::LoadCertsSnafu { path: certs_path })?;
185
186        let key_path = self.ssl_dir().join(KEY_FILE_NAME);
187        let key = self
188            .load_key()
189            .await
190            .context(load_identity_error::LoadKeySnafu { path: key_path })?;
191
192        Ok(Identity::new(self.name.clone().into_name(), certs, key))
193    }
194
195    pub async fn save_identity(&self, cert: &[u8], key: &[u8]) -> Result<(), SaveIdentityError> {
196        let ssl_dir = self.ssl_dir();
197        fs::create_dir_all(ssl_dir.as_path()).await.context(
198            save_identity_error::CreateIdentityDirSnafu {
199                path: ssl_dir.clone(),
200            },
201        )?;
202
203        let mut open_options = fs::OpenOptions::new();
204        open_options.create_new(true).write(true);
205        #[cfg(unix)]
206        open_options.mode(0o400);
207
208        let path = ssl_dir.join(CERT_FILE_NAME);
209        if let Err(error) = fs::remove_file(path.as_path()).await
210            && error.kind() != io::ErrorKind::NotFound
211        {
212            return Err(save_identity_error::DeleteSnafu { path }.into_error(error));
213        }
214        open_options
215            .open(path.as_path())
216            .await
217            .context(save_identity_error::CreateSnafu { path: path.clone() })?
218            .write_all(cert)
219            .await
220            .context(save_identity_error::WriteSnafu { path: path.clone() })?;
221
222        let path = ssl_dir.join(KEY_FILE_NAME);
223        if let Err(error) = fs::remove_file(path.as_path()).await
224            && error.kind() != io::ErrorKind::NotFound
225        {
226            return Err(save_identity_error::DeleteSnafu { path }.into_error(error));
227        }
228        open_options
229            .open(path.as_path())
230            .await
231            .context(save_identity_error::CreateSnafu { path: path.clone() })?
232            .write_all(key)
233            .await
234            .context(save_identity_error::WriteSnafu { path: path.clone() })?;
235
236        Ok(())
237    }
238}
239
240impl DhttpHome {
241    /// Resolve `name` to an `IdentityProfile` by exact match only (no wildcard fallback).
242    pub async fn resolve_identity_profile_exactly(
243        &self,
244        name: DhttpName<'_>,
245    ) -> Result<IdentityProfile, ResolveIdentityProfileError> {
246        let profile_path = self.join_identity_name(name.clone());
247        match fs::metadata(profile_path.as_path()).await {
248            Ok(_) => Ok(IdentityProfile {
249                path: profile_path,
250                name: name.to_owned(),
251            }),
252            Err(error) if error.kind() == io::ErrorKind::NotFound => {
253                resolve_identity_profile_error::ExactNotFoundSnafu { path: profile_path }.fail()
254            }
255            Err(error) => Err(error)
256                .context(resolve_identity_profile_error::ExactMetadataSnafu { path: profile_path }),
257        }
258    }
259
260    /// Resolve `name` to an `IdentityProfile` by wildcard match only (no exact fallback).
261    pub async fn resolve_identity_profile_wildcard(
262        &self,
263        name: DhttpName<'_>,
264    ) -> Result<IdentityProfile, ResolveIdentityProfileError> {
265        let wildcard_name = name.to_wildcard();
266        let profile_path = self.join_identity_name(wildcard_name.clone());
267        match fs::metadata(profile_path.as_path()).await {
268            Ok(_) => Ok(IdentityProfile {
269                path: profile_path,
270                name: wildcard_name,
271            }),
272            Err(error) if error.kind() == io::ErrorKind::NotFound => {
273                resolve_identity_profile_error::WildcardNotFoundSnafu { path: profile_path }.fail()
274            }
275            Err(error) => {
276                Err(error).context(resolve_identity_profile_error::WildcardMetadataSnafu {
277                    path: profile_path,
278                })
279            }
280        }
281    }
282
283    /// Resolve `name` to an `IdentityProfile`, trying exact match then wildcard match.
284    pub async fn resolve_identity_profile(
285        &self,
286        name: DhttpName<'_>,
287    ) -> Result<IdentityProfile, ResolveIdentityProfileError> {
288        match self.resolve_identity_profile_exactly(name.clone()).await {
289            Ok(profile) => Ok(profile),
290            Err(ResolveIdentityProfileError::ExactNotFound { path: exact }) => {
291                match self.resolve_identity_profile_wildcard(name).await {
292                    Ok(profile) => Ok(profile),
293                    Err(ResolveIdentityProfileError::WildcardNotFound { path: wildcard }) => {
294                        resolve_identity_profile_error::NotFoundSnafu { exact, wildcard }.fail()
295                    }
296                    Err(error) => Err(error),
297                }
298            }
299            Err(error) => Err(error),
300        }
301    }
302
303    /// Stream the names of all identity profiles that look like a valid
304    /// `<name>/ssl/` layout under this home directory.
305    pub fn identity_profile_names(
306        &self,
307    ) -> impl Stream<Item = Result<DhttpName<'static>, ListIdentityProfilesError>> {
308        use list_identity_profiles_error::*;
309        async fn next_name(
310            read_dir: &mut ReadDir,
311            path: &Path,
312        ) -> Result<Option<DhttpName<'static>>, ListIdentityProfilesError> {
313            loop {
314                let Some(e) = read_dir.next_entry().await.context(ReadDirSnafu { path })? else {
315                    return Ok(None);
316                };
317                if let (entry_path, name) = (e.path(), e.file_name())
318                    && e.file_type()
319                        .await
320                        .context(ReadFtySnafu {
321                            path: entry_path.clone(),
322                        })?
323                        .is_dir()
324                    && let Ok(name) = name.to_string_lossy().as_ref().parse::<DhttpName>()
325                    && fs::metadata(entry_path.join(SSL_DIR_NAME)).await.is_ok()
326                {
327                    return Ok(Some(name));
328                }
329            }
330        }
331
332        let path = self.as_path();
333        stream::once(fs::read_dir(path)).flat_map(move |result| {
334            match result.context(ReadDirSnafu { path }) {
335                Err(error) => stream::iter(iter::once(Err(error))).right_stream(),
336                Ok(read_dir) => stream::unfold(read_dir, move |mut read_dir| async move {
337                    match next_name(&mut read_dir, path).await {
338                        Ok(Some(name)) => Some((Ok(name), read_dir)),
339                        Ok(None) => None,
340                        Err(e) => Some((Err(e), read_dir)),
341                    }
342                })
343                .left_stream(),
344            }
345        })
346    }
347
348    pub async fn identity_profile_exists_exactly(&self, name: DhttpName<'_>) -> bool {
349        self.resolve_identity_profile_exactly(name).await.is_ok()
350    }
351
352    pub async fn identity_profile_exists_wildcard(&self, name: DhttpName<'_>) -> bool {
353        self.resolve_identity_profile_wildcard(name).await.is_ok()
354    }
355
356    pub async fn identity_profile_exists(&self, name: DhttpName<'_>) -> bool {
357        self.resolve_identity_profile(name).await.is_ok()
358    }
359}
360
361#[cfg(feature = "settings")]
362mod settings_integration {
363    use snafu::{OptionExt, ResultExt, Snafu};
364
365    use super::ResolveIdentityProfileError;
366    use crate::{
367        DhttpHome,
368        identity::{
369            IdentityProfile,
370            settings::{DhttpSettingsFile, FileLineCol, LoadDhttpSettingsError},
371        },
372    };
373
374    #[derive(Snafu, Debug)]
375    #[snafu(module, display(
376        "failed to resolve default identity profile{}",
377        location.as_ref().map_or(String::new(), |loc| format!(" at {loc}"))
378    ))]
379    pub struct ResolveDefaultIdentityFromSettingsError {
380        location: Option<FileLineCol>,
381        source: ResolveIdentityProfileError,
382    }
383
384    #[derive(Debug, Snafu)]
385    #[snafu(module)]
386    pub enum ResolveDefaultIdentityProfileError {
387        #[snafu(transparent)]
388        LoadSettings { source: LoadDhttpSettingsError },
389        #[snafu(display("no default identity configured"))]
390        NoDefaultIdentity,
391        #[snafu(transparent)]
392        Resolve {
393            source: ResolveDefaultIdentityFromSettingsError,
394        },
395    }
396
397    impl DhttpSettingsFile {
398        /// Resolve the default identity profile referenced by `[default].name`,
399        /// or return `None` if no default is configured in this settings file.
400        pub async fn resolve_default_identity_profile(
401            &self,
402            home: &DhttpHome,
403        ) -> Option<Result<IdentityProfile, ResolveDefaultIdentityFromSettingsError>> {
404            let name = self.settings().default.name.as_ref()?;
405
406            Some(
407                home.resolve_identity_profile(name.as_ref().clone())
408                    .await
409                    .context(
410                    resolve_default_identity_from_settings_error::ResolveDefaultIdentityFromSettingsSnafu {
411                        location: self.locate(name.span().start),
412                    },
413                ),
414            )
415        }
416    }
417
418    impl DhttpHome {
419        /// Read the settings file and resolve the default identity profile it points to.
420        pub async fn resolve_default_identity_profile(
421            &self,
422        ) -> Result<IdentityProfile, ResolveDefaultIdentityProfileError> {
423            Ok(self
424                .load_settings()
425                .await?
426                .resolve_default_identity_profile(self)
427                .await
428                .context(resolve_default_identity_profile_error::NoDefaultIdentitySnafu)??)
429        }
430    }
431}
432
433#[cfg(feature = "settings")]
434pub use settings_integration::*;
435
436#[cfg(test)]
437mod tests {
438    use std::{
439        fs,
440        path::PathBuf,
441        time::{SystemTime, UNIX_EPOCH},
442    };
443
444    use super::*;
445
446    struct TempDir {
447        path: PathBuf,
448    }
449
450    impl TempDir {
451        fn new(name: &str) -> Self {
452            let stamp = SystemTime::now()
453                .duration_since(UNIX_EPOCH)
454                .expect("system clock should be after unix epoch")
455                .as_nanos();
456            let path = std::env::temp_dir()
457                .join(format!("dhttp-home-{name}-{}-{stamp}", std::process::id()));
458            fs::create_dir_all(&path).expect("test temp dir should be creatable");
459            Self { path }
460        }
461
462        fn path(&self) -> &std::path::Path {
463            &self.path
464        }
465    }
466
467    impl Drop for TempDir {
468        fn drop(&mut self) {
469            let _ = fs::remove_dir_all(&self.path);
470        }
471    }
472
473    #[tokio::test]
474    async fn missing_certificate_reports_certificate_path() {
475        let temp = TempDir::new("missing-certificate");
476        let profile = IdentityProfile::try_from(temp.path().join("reimu.pilot")).unwrap();
477
478        let error = profile.load_certs().await.unwrap_err();
479
480        match error {
481            LoadCertsError::Read { path, .. } => {
482                assert_eq!(path, profile.ssl_dir().join(CERT_FILE_NAME));
483            }
484            other => panic!("expected certificate read error, got {other:?}"),
485        }
486    }
487
488    #[tokio::test]
489    async fn missing_key_reports_key_metadata_path() {
490        let temp = TempDir::new("missing-key");
491        let profile = IdentityProfile::try_from(temp.path().join("reimu.pilot")).unwrap();
492
493        let error = profile.load_key().await.unwrap_err();
494
495        match error {
496            LoadKeyError::Metadata { path, .. } => {
497                assert_eq!(path, profile.ssl_dir().join(KEY_FILE_NAME));
498            }
499            other => panic!("expected key metadata error, got {other:?}"),
500        }
501    }
502
503    #[tokio::test]
504    async fn missing_identity_profile_reports_exact_and_wildcard_paths() {
505        let temp = TempDir::new("missing-identity-profile");
506        let home = DhttpHome::new(temp.path().to_path_buf());
507        let name = "reimu.pilot".parse().unwrap();
508
509        let error = home.resolve_identity_profile(name).await.unwrap_err();
510
511        match error {
512            ResolveIdentityProfileError::NotFound { exact, wildcard } => {
513                assert_eq!(exact, temp.path().join("reimu.pilot"));
514                assert_eq!(wildcard, temp.path().join("*.pilot"));
515            }
516            other => panic!("expected not-found error, got {other:?}"),
517        }
518    }
519}