voa_openpgp/import/
mod.rs

1//! Import of OpenPGP certificates as VOA verifiers.
2
3pub mod destructured;
4pub mod error;
5
6use std::{
7    fs::File,
8    io::Read,
9    path::{Path, PathBuf},
10    str::FromStr,
11};
12
13use error::Error;
14use log::debug;
15use pgp::{
16    composed::{ArmorOptions, Deserializable, SignedPublicKey},
17    types::KeyDetails,
18};
19use voa_core::{VerifierWriter, identifiers::Technology};
20
21/// The data needed for importing an [OpenPGP certificate] to a [VOA] hierarchy.
22///
23/// Wraps a [`SignedPublicKey`].
24///
25/// [OpenPGP certificate]: https://openpgp.dev/book/certificates.html
26/// [VOA]: https://uapi-group.org/specifications/specs/file_hierarchy_for_the_verification_of_os_artifacts/
27#[derive(Clone, Debug)]
28pub struct OpenPgpImport(SignedPublicKey);
29
30impl OpenPgpImport {
31    /// Creates an [`OpenPgpImport`] from an OpenPGP certificate file.
32    ///
33    /// Supports both binary and ASCII-armored data.
34    ///
35    /// # Errors
36    ///
37    /// Returns an error if
38    ///
39    /// - `path` is not a regular file,
40    /// - or a [`SignedPublicKey`] cannot be created from `path`.
41    pub fn from_file(path: impl AsRef<Path>) -> Result<Self, crate::Error> {
42        let path = path.as_ref();
43        debug!("Reading an OpenPGP certificate from file: {path:?}");
44
45        if !path.is_file() {
46            return Err(error::Error::PathIsNotAFile {
47                path: path.to_path_buf(),
48            }
49            .into());
50        }
51
52        let file = File::open(path).map_err(|source| crate::Error::IoPath {
53            path: path.to_path_buf(),
54            context: "reading the file",
55            source,
56        })?;
57
58        let (pubkey, _) = SignedPublicKey::from_reader_single(file).map_err(|source| {
59            crate::Error::OpenPgpPath {
60                path: path.to_path_buf(),
61                context: "parsing an OpenPGP certificate from file",
62                source,
63            }
64        })?;
65
66        Ok(Self(pubkey))
67    }
68
69    /// Creates an [`OpenPgpImport`] from a [`Read`] implementation.
70    ///
71    /// Supports binary and ASCII-armored data.
72    ///
73    /// # Errors
74    ///
75    /// Returns an error if a [`SignedPublicKey`] cannot be created from the [`Read`]
76    /// implementation.
77    pub fn from_reader(reader: impl Read) -> Result<Self, crate::Error> {
78        debug!("Reading an OpenPGP certificate from stdin");
79        let (pubkey, _) = SignedPublicKey::from_reader_single(reader).map_err(|source| {
80            crate::Error::OpenPgp {
81                context: "reading an OpenPGP certificate from stdin",
82                source,
83            }
84        })?;
85
86        Ok(Self(pubkey))
87    }
88}
89
90impl TryFrom<&Path> for OpenPgpImport {
91    type Error = crate::Error;
92
93    /// Creates an [`OpenPgpImport`] from a path.
94    ///
95    /// The path must be a regular file.
96    /// Supports binary and ASCII-armored data.
97    ///
98    /// # Note
99    ///
100    /// Delegates to [`OpenPgpImport::from_file`].
101    ///
102    /// # Errors
103    ///
104    /// Returns an error if [`OpenPgpImport::from_file`] fails.
105    fn try_from(value: &Path) -> Result<Self, Self::Error> {
106        Self::from_file(value)
107    }
108}
109
110impl FromStr for OpenPgpImport {
111    type Err = crate::Error;
112
113    /// Creates an [`OpenPgpImport`] from a string slice.
114    ///
115    /// # Note
116    ///
117    /// Delegates to [`TryFrom`] [`Path`].
118    ///
119    /// # Errors
120    ///
121    /// Returns an error if [`TryFrom`] [`Path`] fails.
122    fn from_str(s: &str) -> Result<Self, Self::Err> {
123        Self::try_from(Path::new(s))
124    }
125}
126
127impl VerifierWriter for OpenPgpImport {
128    /// Returns the verifier as bytes.
129    ///
130    /// OpenPGP certificates are stored ASCII-armored and this function applies armor.
131    fn to_bytes(&self) -> Result<Vec<u8>, voa_core::Error> {
132        self.0
133            .to_armored_bytes(ArmorOptions::default())
134            .map_err(|source| {
135                voa_core::Error::WriteError(Box::new(crate::Error::OpenPgp {
136                    context: "creating an ASCII-armored OpenPGP public key",
137                    source,
138                }))
139            })
140    }
141
142    /// Returns the file name of the verifier.
143    ///
144    /// The file name consists of the [OpenPGP fingerprint] and the string representation of the
145    /// [`Technology`] as suffix.
146    ///
147    /// [OpenPGP fingerprint]: https://openpgp.dev/book/certificates.html#fingerprint
148    fn file_name(&self) -> PathBuf {
149        let fingerprint = self.0.fingerprint();
150        let technology = self.technology();
151
152        PathBuf::from(format!("{fingerprint}.{technology}"))
153    }
154
155    /// Returns the technology of the verifier ([`Technology::Openpgp`]).
156    fn technology(&self) -> Technology {
157        Technology::Openpgp
158    }
159}
160
161#[cfg(test)]
162mod tests {
163    use pgp::{
164        composed::{EncryptionCaps, KeyType, SecretKeyParamsBuilder, SubkeyParamsBuilder},
165        ser::Serialize,
166    };
167    use rand::thread_rng;
168    use rstest::{fixture, rstest};
169    use simplelog::{ColorChoice, Config, LevelFilter, TermLogger, TerminalMode};
170    use tempfile::{NamedTempFile, tempdir};
171    use testresult::TestResult;
172
173    use super::*;
174
175    /// The configuration for tests.
176    struct TestConfig {
177        /// Whether to ASCII-armor the input file(s).
178        pub armor: bool,
179    }
180
181    /// Init logger
182    fn init_logger() {
183        if TermLogger::init(
184            LevelFilter::Trace,
185            Config::default(),
186            TerminalMode::Stderr,
187            ColorChoice::Auto,
188        )
189        .is_err()
190        {
191            debug!("Not initializing another logger, as one is initialized already.");
192        }
193    }
194
195    /// Creates an OpenPGP certificate ([`SignedPublicKey`]).
196    #[fixture]
197    fn openpgp_cert() -> TestResult<SignedPublicKey> {
198        let mut signkey = SubkeyParamsBuilder::default();
199        signkey
200            .key_type(KeyType::Ed25519Legacy)
201            .can_sign(true)
202            .can_encrypt(EncryptionCaps::None)
203            .can_authenticate(false);
204        let mut key_params = SecretKeyParamsBuilder::default();
205        key_params
206            .key_type(KeyType::Ed25519Legacy)
207            .can_certify(true)
208            .can_sign(false)
209            .can_encrypt(EncryptionCaps::None)
210            .primary_user_id("John Doe <jdoe@example.org>".to_string())
211            .subkeys(vec![signkey.build()?]);
212
213        let secret_key_params = key_params.build()?;
214        let secret_key = secret_key_params.generate(thread_rng())?;
215        let pubkey = SignedPublicKey::from(secret_key);
216        Ok(pubkey)
217    }
218
219    /// Ensures that [`OpenPgpImport`] can be created from OpenPGP certificate.
220    ///
221    /// The OpenPGP certificate input may be
222    /// Further ensures that the export of the verifier with [`OpenPgpImport::write_to_hierarchy`]
223    /// works.
224    #[rstest]
225    #[case::single_binary(TestConfig{armor: false})]
226    #[case::single_ascii_armor(TestConfig{armor: true})]
227    fn write_to_hierarchy_succeeds(
228        openpgp_cert: TestResult<SignedPublicKey>,
229        #[case] config: TestConfig,
230    ) -> TestResult {
231        init_logger();
232
233        let input_pubkey = openpgp_cert?;
234        let mut temp_file = NamedTempFile::new()?;
235        if config.armor {
236            input_pubkey.to_armored_writer(&mut temp_file, ArmorOptions::default())?;
237        } else {
238            input_pubkey.to_writer(&mut temp_file)?;
239        }
240        let path = temp_file.path();
241
242        let import = OpenPgpImport::try_from(path)?;
243
244        let temp_dir = tempdir()?;
245        let output_dir = temp_dir.path();
246        import.write_to_hierarchy(
247            output_dir,
248            "os".parse()?,
249            "purpose".parse()?,
250            Some("context".parse()?),
251        )?;
252
253        let output_file = output_dir
254            .join("os")
255            .join("purpose")
256            .join("context")
257            .join(import.technology().to_string())
258            .join(import.file_name());
259
260        let (output_pubkey, _) = SignedPublicKey::from_armor_file(&output_file)?;
261
262        assert_eq!(input_pubkey, output_pubkey);
263
264        Ok(())
265    }
266
267    /// Ensures that [`OpenPgpImport::new`] fails if the input path is not a file or a directory.
268    #[test]
269    #[cfg(target_os = "linux")]
270    fn try_from_path_fails_on_path_not_a_file() -> TestResult {
271        match OpenPgpImport::try_from(PathBuf::from("/dev/null").as_path()) {
272            Ok(verifier) => {
273                panic!("Should have failed, but succeeded to create a verifier: {verifier:?}");
274            }
275            Err(error) => match error {
276                crate::Error::Import(error::Error::PathIsNotAFile { .. }) => {}
277                error => {
278                    panic!("Did not return the correct error, got: {error}");
279                }
280            },
281        }
282        Ok(())
283    }
284}