wkd_exporter/
lib.rs

1#![doc = include_str!("../README.md")]
2#![deny(missing_docs)]
3
4use std::{
5    fs::OpenOptions,
6    io::Read,
7    path::Path,
8    process::{ExitCode, Termination},
9    str::FromStr,
10};
11
12use email_address::EmailAddress;
13use pgp::{
14    composed::{Deserializable, SignedPublicKey},
15    ser::Serialize,
16    types::KeyDetails as _,
17};
18use tracing::{debug, info, trace};
19
20#[cfg(feature = "cli")]
21#[doc(hidden)]
22pub mod cli;
23
24/// Error when exporting the keyring.
25#[derive(Debug, thiserror::Error)]
26#[cfg_attr(feature = "gen", derive(enum_values::EnumValues))]
27#[non_exhaustive]
28#[repr(u8)]
29pub enum Error {
30    /// Processing OpenPGP data failed.
31    #[error("PGP processing error occurred: {0}")]
32    Pgp(#[from] pgp::errors::Error) = 2,
33
34    /// Reading or writing files failed.
35    #[error("I/O error occurred: {0}")]
36    Io(#[from] std::io::Error) = 3,
37}
38
39impl Termination for Error {
40    fn report(self) -> ExitCode {
41        // apparently there is no other way to access the discriminant of this enum
42        // see: https://github.com/rust-lang/rust/pull/60732/files#diff-997afb68e691752daef215d61b70169639e899bb04a2a76bcb4c211cd1666d7cR30
43        ExitCode::from(unsafe { *(&raw const self).cast::<u8>() })
44    }
45}
46
47/// WKD variant.
48///
49/// There are two WKD directory structures: direct and advanced.
50/// Direct supports only one domain while advanced can support any number of domains.
51#[derive(Debug, Default)]
52pub enum Variant<'a> {
53    /// Advanced variant supporting multiple domains.
54    #[default]
55    Advanced,
56
57    /// Direct variant supporting just one domain.
58    Direct(&'a str),
59}
60
61/// Exporting options.
62///
63/// This struct can be used to adjust the exporting process.
64///
65/// # Examples
66///
67/// The following code makes the exporting process filter domains to
68/// only these explicitly mentioned. Additionally it supports multiple
69/// certificates for the same e-mail address:
70///
71/// ```
72/// use wkd_exporter::{Options, Variant};
73///
74/// let only_arch = Options::default()
75///     .set_allowed_domains(vec!["archlinux.org"])
76///     .set_variant(Variant::Advanced)
77///     .set_append(true);
78/// ```
79#[derive(Debug, Default)]
80pub struct Options<'a, 'b> {
81    allowed_domains: Option<Vec<&'a str>>,
82
83    append: bool,
84
85    variant: Variant<'b>,
86}
87
88impl<'a, 'b> Options<'a, 'b>
89where
90    'b: 'a,
91{
92    /// Sets a list of allowed domains for the export.
93    ///
94    /// Setting this option to `None` (the default) exports all domains.
95    ///
96    /// # Examples
97    ///
98    /// The following code makes the exporting process filter domains to only
99    /// these explicitly mentioned:
100    ///
101    /// ```
102    /// use wkd_exporter::Options;
103    ///
104    /// let only_arch = Options::default().set_allowed_domains(vec!["archlinux.org"]);
105    /// ```
106    #[must_use]
107    pub fn set_allowed_domains(mut self, allowed_domains: impl Into<Option<Vec<&'a str>>>) -> Self {
108        self.allowed_domains = allowed_domains.into();
109        self
110    }
111
112    /// Check if a given domain is allowed for export.
113    #[must_use]
114    pub fn is_domain_allowed(&self, domain: &str) -> bool {
115        self.allowed_domains
116            .as_ref()
117            .is_none_or(|domains| domains.contains(&domain))
118    }
119
120    /// Set WKD directory variant.
121    ///
122    /// Setting a direct variant implies that the filter for that one
123    /// domain will be applied as well. There is no need to use
124    /// [`Self::set_allowed_domains`] when using [`Variant::Direct`].
125    ///
126    /// # Examples
127    ///
128    /// For small, single domain deployments, direct WKD variant may be
129    /// more appropriate than the default:
130    ///
131    /// ```
132    /// use wkd_exporter::{Options, Variant};
133    ///
134    /// let direct = Options::default().set_variant(Variant::Direct("metacode.biz"));
135    /// ```
136    #[must_use]
137    pub fn set_variant(mut self, variant: Variant<'b>) -> Self {
138        self.variant = variant;
139        if let Variant::Direct(domain) = self.variant {
140            self.set_allowed_domains(vec![domain])
141        } else {
142            self
143        }
144    }
145
146    /// Enables or disables append mode.
147    ///
148    /// When appending is enabled the export will not clear target
149    /// files but will rather concatenate incoming certificates to the
150    /// ones existing in target directory.
151    ///
152    /// This could be used to emit multiple certificates for one
153    /// e-mail address which is useful for certificate rotation or
154    /// storing code-signing certificates along the regular ones.
155    ///
156    /// # Examples
157    ///
158    /// Enables append-mode which creates multiple certificates for
159    /// one e-mail address:
160    ///
161    /// ```
162    /// use wkd_exporter::Options;
163    ///
164    /// let append = Options::default().set_append(true);
165    /// ```
166    #[must_use]
167    pub fn set_append(mut self, append: bool) -> Self {
168        self.append = append;
169        self
170    }
171}
172
173/// Exports a keyring file (`input`) to a given well known directory.
174///
175/// # Errors
176///
177/// If the data is not well-formed OpenPGP packets, then [`Error::Pgp`] is returned.
178///
179/// # Examples
180///
181/// ```
182/// # fn main() -> testresult::TestResult {
183/// use wkd_exporter::{Options, export};
184///
185/// export(
186///     std::fs::File::open("tests/test-cases-default/simple.pgp")?,
187///     "/tmp/well-known",
188///     &Options::default(),
189/// )?;
190/// # Ok(()) }
191/// ```
192#[tracing::instrument(skip(keyring), fields(well_known = ?well_known.as_ref()))]
193pub fn export(
194    keyring: impl Read,
195    well_known: impl AsRef<Path>,
196    options: &Options,
197) -> Result<(), Error> {
198    let openpgpkey = well_known.as_ref().join("openpgpkey");
199    std::fs::create_dir_all(&openpgpkey)?;
200    let iterator = SignedPublicKey::from_reader_many(keyring)?.0;
201    for key in iterator {
202        let key = key?;
203        export_key(options, &openpgpkey, &key)?;
204    }
205
206    Ok(())
207}
208
209#[tracing::instrument(fields(key = format!("{:x}", key.primary_key.fingerprint())))]
210fn export_key(
211    options: &Options<'_, '_>,
212    openpgpkey: &std::path::PathBuf,
213    key: &SignedPublicKey,
214) -> Result<(), Error> {
215    for (encoded_local, domain) in key
216        .details
217        .users
218        .iter()
219        .filter_map(|user| user.id.as_str().map(|id| EmailAddress::from_str(id).ok()))
220        .flatten()
221        .map(|email| {
222            use sha1::Digest;
223            let local_part = email.local_part().to_lowercase();
224            let encoded_local = zbase32::encode(&sha1::Sha1::digest(local_part));
225            debug!(?email, encoded_local, "Encoded local part");
226            (encoded_local, email.domain().to_string())
227        })
228        .filter(|(_, domain)| {
229            let domain_allowed = options.is_domain_allowed(domain);
230            if !domain_allowed {
231                debug!(domain, "Skipping domain");
232            }
233            domain_allowed
234        })
235    {
236        let domain = if let Variant::Advanced = options.variant {
237            &openpgpkey.join(&domain)
238        } else {
239            openpgpkey
240        };
241        let hu = domain.join("hu");
242        std::fs::create_dir_all(&hu)?;
243
244        let policy_file = domain.join("policy");
245
246        trace!(?policy_file, "Making sure the policy file exists");
247        OpenOptions::new()
248            .create(true)
249            .truncate(true)
250            .write(true)
251            .open(&policy_file)?;
252
253        let key_file_path = hu.join(encoded_local);
254
255        let mut key_file = OpenOptions::new()
256            .create(true)
257            .write(true)
258            .append(options.append)
259            .truncate(!options.append)
260            .open(&key_file_path)?;
261
262        info!(?key_file_path, "Writing key to file");
263        key.to_writer(&mut key_file)?;
264    }
265    Ok(())
266}