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}