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::{KeyType, SecretKeyParamsBuilder, SubkeyParamsBuilder},
165        ser::Serialize,
166        types::Password,
167    };
168    use rand::thread_rng;
169    use rstest::{fixture, rstest};
170    use simplelog::{ColorChoice, Config, LevelFilter, TermLogger, TerminalMode};
171    use tempfile::{NamedTempFile, tempdir};
172    use testresult::TestResult;
173
174    use super::*;
175
176    /// The configuration for tests.
177    struct TestConfig {
178        /// Whether to ASCII-armor the input file(s).
179        pub armor: bool,
180    }
181
182    /// Init logger
183    fn init_logger() {
184        if TermLogger::init(
185            LevelFilter::Trace,
186            Config::default(),
187            TerminalMode::Stderr,
188            ColorChoice::Auto,
189        )
190        .is_err()
191        {
192            debug!("Not initializing another logger, as one is initialized already.");
193        }
194    }
195
196    /// Creates an OpenPGP certificate ([`SignedPublicKey`]).
197    #[fixture]
198    fn openpgp_cert() -> TestResult<SignedPublicKey> {
199        let mut signkey = SubkeyParamsBuilder::default();
200        signkey
201            .key_type(KeyType::Ed25519Legacy)
202            .can_sign(true)
203            .can_encrypt(false)
204            .can_authenticate(false);
205        let mut key_params = SecretKeyParamsBuilder::default();
206        key_params
207            .key_type(KeyType::Ed25519Legacy)
208            .can_certify(true)
209            .can_sign(false)
210            .can_encrypt(false)
211            .primary_user_id("John Doe <jdoe@example.org>".to_string())
212            .subkeys(vec![signkey.build()?]);
213
214        let secret_key_params = key_params.build()?;
215        let secret_key = secret_key_params.generate(thread_rng())?;
216
217        // Produce binding self-signatures that link all the components together
218        let signed = secret_key.sign(&mut thread_rng(), &Password::from(""))?;
219
220        let pubkey = SignedPublicKey::from(signed);
221        Ok(pubkey)
222    }
223
224    /// Ensures that [`OpenPgpImport`] can be created from OpenPGP certificate.
225    ///
226    /// The OpenPGP certificate input may be
227    /// Further ensures that the export of the verifier with [`OpenPgpImport::write_to_hierarchy`]
228    /// works.
229    #[rstest]
230    #[case::single_binary(TestConfig{armor: false})]
231    #[case::single_ascii_armor(TestConfig{armor: true})]
232    fn write_to_hierarchy_succeeds(
233        openpgp_cert: TestResult<SignedPublicKey>,
234        #[case] config: TestConfig,
235    ) -> TestResult {
236        init_logger();
237
238        let input_pubkey = openpgp_cert?;
239        let mut temp_file = NamedTempFile::new()?;
240        if config.armor {
241            input_pubkey.to_armored_writer(&mut temp_file, ArmorOptions::default())?;
242        } else {
243            input_pubkey.to_writer(&mut temp_file)?;
244        }
245        let path = temp_file.path();
246
247        let import = OpenPgpImport::try_from(path)?;
248
249        let temp_dir = tempdir()?;
250        let output_dir = temp_dir.path();
251        import.write_to_hierarchy(
252            output_dir,
253            "os".parse()?,
254            "purpose".parse()?,
255            Some("context".parse()?),
256        )?;
257
258        let output_file = output_dir
259            .join("os")
260            .join("purpose")
261            .join("context")
262            .join(import.technology().to_string())
263            .join(import.file_name());
264
265        let (output_pubkey, _) = SignedPublicKey::from_armor_file(&output_file)?;
266
267        assert_eq!(input_pubkey, output_pubkey);
268
269        Ok(())
270    }
271
272    /// Ensures that [`OpenPgpImport::new`] fails if the input path is not a file or a directory.
273    #[test]
274    #[cfg(target_os = "linux")]
275    fn try_from_path_fails_on_path_not_a_file() -> TestResult {
276        match OpenPgpImport::try_from(PathBuf::from("/dev/null").as_path()) {
277            Ok(verifier) => {
278                return Err(format!(
279                    "Should have failed, but succeeded to create a verifier: {verifier:?}"
280                )
281                .into());
282            }
283            Err(error) => match error {
284                crate::Error::Import(error::Error::PathIsNotAFile { .. }) => {}
285                error => {
286                    return Err(format!("Did not return the correct error, got: {error}").into());
287                }
288            },
289        }
290        Ok(())
291    }
292}