voa_openpgp/import/destructured/
mod.rs

1//! Import of destructured [OpenPGP certificates] as [VOA] verifiers.
2//!
3//! Destructured [OpenPGP certificates] are represented by (binary or ASCII-armored) [OpenPGP
4//! packet] data in separate files.
5//!
6//! # Note
7//!
8//! Destructured [OpenPGP certificates] are a non-standardized format.
9//!
10//! # Formats
11//!
12//! This module allows reading [OpenPGP certificates] from the following set of directory
13//! structures.
14//!
15//! # Flat structure
16//!
17//! A flat structure can be created by splitting an OpenPGP certificate using specialised tooling
18//! such as [`rpacket`]:
19//!
20//! ```bash
21//! rpacket split < cert.pgp
22//! ```
23//!
24//! This may create output similar to the following:
25//!
26//! ```text
27//! .
28//! ├── 000000-PublicKey
29//! ├── 000001-UserId
30//! ├── 000002-Signature
31//! ├── 000003-Signature
32//! ├── 000004-Signature
33//! ├── 000005-Signature
34//! ├── 000006-Signature
35//! ├── 000007-Signature
36//! ├── 000008-Signature
37//! ├── 000009-Signature
38//! ├── 000010-PublicSubkey
39//! ├── 000011-Signature
40//! ├── 000012-PublicSubkey
41//! ├── 000013-Signature
42//! ├── 000014-PublicSubkey
43//! └── 000015-Signature
44//! ```
45//!
46//! Here, each file contains raw [OpenPGP packet] data.
47//! The concatenation of all files in sequence represents a valid OpenPGP certificate, e.g.
48//!
49//! ```bash
50//! cat 0000* > cert-concant.pgp
51//! ```
52//!
53//! # Arch Linux keyring structure
54//!
55//! The [archlinux-keyring] project chose a more fine grained approach, that is based on a custom
56//! directory structure. Here, files containing raw ASCII-armored [OpenPGP packet] data are grouped
57//! by their specific use in an OpenPGP certificate.
58//!
59//! A single top-level file contains the primary component key, named after its [OpenPGP
60//! fingerprint] (e.g. `F1D2D2F924E986AC86FDF7B36C94BCDF32BEEC15.asc`).
61//!
62//! The following directories are used to group specific [OpenPGP packet] data:
63//!
64//! - `revocation`: If it exists, contains a file containing [Key Revocation Signature] data, named
65//!   after the [OpenPGP fingerprint] of the primary component key (e.g.
66//!   `revocation/F1D2D2F924E986AC86FDF7B36C94BCDF32BEEC15.asc`).
67//! - `directkey`: If it exists, contains a directory structure in which files containing [Direct
68//!   Key Signature] data reside. Individual files are located in a directory that reflects the
69//!   [OpenPGP fingerprint] of the targeted component key and are named after their specific
70//!   creation time (e.g.
71//!   `directkey/certification/F1D2D2F924E986AC86FDF7B36C94BCDF32BEEC15/2024-06-23_12-55-20.asc`)
72//! - `uid`: If it exists, contains a directory structure for each [User ID] or [User Attribute]
73//!   packet of the certificate. Here, [User ID]s are represented by directories named after their
74//!   string representation, with unusable characters replaced and an additional unique identifier
75//!   appended to prevent collision (e.g. `John Doe <jdoe@example.org>` ->
76//!   `uid/John_Doe__jdoe@example.org_d2ad250f`). Each [User ID] directory contains a top-level
77//!   file, which represents the [User ID] packet (e.g.
78//!   `uid/John_Doe__jdoe@example.org_d2ad250f/John_Doe__jdoe@example.org_d2ad250f.asc`). Further,
79//!   each such directory contains a `certification` and may contain a `revocation` directory. The
80//!   `certification` directory may contain User ID binding signatures and third-party
81//!   certifications (e.g. `uid/John_Doe__jdoe@example.org_d2ad250f/certification/
82//!   F1D2D2F924E986AC86FDF7B36C94BCDF32BEEC15.asc`). The `revocation` directory may contain User ID
83//!   revocation signatures or third-party certification revocation signatures (e.g.
84//!   `uid/John_Doe__jdoe@example.org_d2ad250f/revocation/
85//!   F1D2D2F924E986AC86FDF7B36C94BCDF32BEEC15.asc`).
86//! - `subkey`: If it exists, contains a directory structure for each subkey component key bound to
87//!   the primary component key of the certificate. A top-level directory is named after the
88//!   [OpenPGP fingerprint] of the component key (e.g.
89//!   `subkey/E242ED3BFFCCDF271B7FBAF34ED72D089537B42F/`). Each top-level directory contains a file
90//!   containing [Public Subkey] data (e.g.
91//!   `subkey/E242ED3BFFCCDF271B7FBAF34ED72D089537B42F/E242ED3BFFCCDF271B7FBAF34ED72D089537B42F.
92//!   asc`). Further, each such directory contains a `certification` and may contain a `revocation`
93//!   directory. The `certification` directory contains files containing [Subkey Binding Signature]
94//!   data, named after the [OpenPGP fingerprint] of the issuing key (e.g.
95//!   `subkey/E242ED3BFFCCDF271B7FBAF34ED72D089537B42F/certification/
96//!   F1D2D2F924E986AC86FDF7B36C94BCDF32BEEC15.asc`). The `revocation` directory may contain files
97//!   containing [Subkey Revocation Signature] data, named after the [OpenPGP fingerprint] of the
98//!   issuing key (e.g. `subkey/E242ED3BFFCCDF271B7FBAF34ED72D089537B42F/revocation/
99//!   F1D2D2F924E986AC86FDF7B36C94BCDF32BEEC15.asc`).
100//!
101//! The following example illustrates a destructured OpenPGP certificate using the
102//! [archlinux-keyring] specific directory format:
103//!
104//! ```text
105//! .
106//! ├── F1D2D2F924E986AC86FDF7B36C94BCDF32BEEC15.asc
107//! ├── subkey
108//! │   ├── E242ED3BFFCCDF271B7FBAF34ED72D089537B42F
109//! │   │   ├── E242ED3BFFCCDF271B7FBAF34ED72D089537B42F.asc
110//! │   │   └── certification
111//! │   │       └── F1D2D2F924E986AC86FDF7B36C94BCDF32BEEC15.asc
112//! │   ├── D3B0F7C0B825ECBB0F0D7398072947E7B1537B6F
113//! │   │   ├── D3B0F7C0B825ECBB0F0D7398072947E7B1537B6F.asc
114//! │   │   └── certification
115//! │   │       └── F1D2D2F924E986AC86FDF7B36C94BCDF32BEEC15.asc
116//! │   └── 6EADEAC2DADE6347E87C0D24FD455FEFFA7069F0
117//! │       ├── 6EADEAC2DADE6347E87C0D24FD455FEFFA7069F0.asc
118//! │       └── certification
119//! │           └── F1D2D2F924E986AC86FDF7B36C94BCDF32BEEC15.asc
120//! └── uid
121//!     └── John_Doe__jdoe@example.org_d2ad250f
122//!         ├── John_Doe__jdoe@example.org_d2ad250f.asc
123//!         └── certification
124//!             ├── B787A81C32997FD39A5F4C0188363902D3586E7B.asc
125//!             ├── 2072A695613E5103D9AC03C2885C5E2656CB5FF0.asc
126//!             ├── 68D61AF364B99AD0226A9C8859F18BF95A99BCE9.asc
127//!             ├── 033DB9A2637803F63BDA651106B2C4BEF184C21D.asc
128//!             ├── 868672B9CDB0BF449BF3782CFDA1DBE372838AA3.asc
129//!             ├── F1D2D2F924E986AC86FDF7B36C94BCDF32BEEC15.asc
130//!             ├── 98EECC29ABC53C31B0DA5C85CB26CE720C7FF763.asc
131//!             └── 52428846EFFD79371A81D6C82D00FBFED9C654F3.asc
132//! ```
133//!
134//! [Direct Key Signature]: https://www.rfc-editor.org/rfc/rfc9580#name-direct-key-signature-type-i
135//! [Key Revocation Signature]: https://www.rfc-editor.org/rfc/rfc9580#name-key-revocation-signature-ty
136//! [OpenPGP certificates]: https://openpgp.dev/book/certificates.html
137//! [OpenPGP fingerprint]: https://openpgp.dev/book/certificates.html#fingerprint
138//! [OpenPGP packet]: https://openpgp.dev/book/zoom/certificates.html
139//! [Public Subkey]: https://www.rfc-editor.org/rfc/rfc9580#name-public-subkey-packet-type-i
140//! [Subkey Binding Signature]: https://www.rfc-editor.org/rfc/rfc9580#name-subkey-binding-signature-ty
141//! [Subkey Revocation Signature]: https://www.rfc-editor.org/rfc/rfc9580#name-subkey-revocation-signature
142//! [User ID]: https://www.rfc-editor.org/rfc/rfc9580#uid
143//! [User Attribute]: https://www.rfc-editor.org/rfc/rfc9580#name-user-attribute-packet-type-
144//! [VOA]: https://uapi-group.org/specifications/specs/file_hierarchy_for_the_verification_of_os_artifacts/
145//! [`rpacket`]: https://codeberg.org/heiko/rpacket
146//! [archlinux-keyring]: https://gitlab.archlinux.org/archlinux/archlinux-keyring/
147
148pub mod error;
149
150use std::{
151    fs::File,
152    io::{BufRead, BufReader, Read},
153    path::{Path, PathBuf},
154};
155
156use error::Error;
157use log::{debug, trace};
158use pgp::{
159    armor::Dearmor,
160    composed::{Deserializable, SignedPublicKey},
161    packet::{Packet, PacketParser, PacketTrait},
162    types::KeyDetails,
163};
164
165use crate::import::OpenPgpImport;
166
167/// Recursively collects the paths to all regular files in a directory.
168///
169/// Appends all regular files in the directory (and any subdirectory) to a list of paths and returns
170/// them sorted.
171/// Calls this function on any directory in `path`.
172///
173/// # Errors
174///
175/// Returns an error if
176///
177/// - entries in `path` cannot be retrieved,
178/// - reading an entry in `path` fails,
179/// - or calling `recursively_collect_files` on any subdirectory of `path` fails.
180fn recursively_collect_files(path: impl AsRef<Path>) -> Result<Vec<PathBuf>, crate::Error> {
181    let path = path.as_ref();
182    debug!("Collecting regular files in {path:?}");
183
184    let entries = path.read_dir().map_err(|source| crate::Error::IoPath {
185        path: path.to_path_buf(),
186        context: "reading entries of directory",
187        source,
188    })?;
189    let mut paths = Vec::new();
190
191    for entry in entries {
192        let entry = entry.map_err(|source| crate::Error::IoPath {
193            path: path.to_path_buf(),
194            context: "reading entry in directory",
195            source,
196        })?;
197        let file_path = entry.path();
198
199        if file_path.is_file() {
200            debug!("Found regular file {file_path:?}");
201            paths.push(file_path.clone());
202        } else if file_path.is_dir() {
203            // Call `recursively_collect_files` on each directory.
204            let mut subdir_paths = recursively_collect_files(entry.path())?;
205            paths.append(&mut subdir_paths);
206        }
207    }
208
209    Ok(paths)
210}
211
212/// Collects all regular files in a directory in the order of an [OpenPGP Transferable Public
213/// Key].
214///
215/// All top-level regular files in the directory are considered first.
216/// Afterwards, all regular files located in the following list of directories (if they exist) are
217/// considered, in the following order:
218///
219/// - `revocation`: for Revocation Signature packets
220/// - `directkey`: for Direct Key Signature packets
221/// - `uid`: for User ID or User Attribute packets
222/// - `subkey`: for Subkey packets
223///
224/// # Errors
225///
226/// Returns an error if
227///
228/// - entries in `path` cannot be retrieved,
229/// - reading an entry in `path` fails,
230/// - or recursively collecting regular files in any of the subdirectories (`revocation`,
231///   `directkey`, `uid` or `subkey`) fails.
232///
233/// [OpenPGP Transferable Public Key]: https://www.rfc-editor.org/rfc/rfc9580#name-transferable-public-keys
234fn collect_files_in_dir(path: impl AsRef<Path>) -> Result<Vec<PathBuf>, crate::Error> {
235    let path = path.as_ref();
236    debug!("Collecting regular files in {path:?}");
237
238    let entries = path.read_dir().map_err(|source| crate::Error::IoPath {
239        path: path.to_path_buf(),
240        context: "listing files and directories",
241        source,
242    })?;
243
244    let mut files: Vec<PathBuf> = Vec::new();
245    let mut top_level: Vec<PathBuf> = Vec::new();
246    let mut revocation: Vec<PathBuf> = Vec::new();
247    let mut directkey: Vec<PathBuf> = Vec::new();
248    let mut subkey: Vec<PathBuf> = Vec::new();
249    let mut uid: Vec<PathBuf> = Vec::new();
250
251    for dir_entry in entries {
252        let dir_entry = dir_entry.map_err(|source| crate::Error::IoPath {
253            path: path.to_path_buf(),
254            context: "getting information on a file or directory",
255            source,
256        })?;
257        let file_path = dir_entry.path();
258
259        if file_path.is_file() {
260            debug!("Found regular file {file_path:?}");
261            top_level.push(file_path);
262            continue;
263        }
264
265        if !file_path.is_dir() {
266            continue;
267        }
268
269        if file_path.ends_with("revocation") {
270            trace!("Found a directory for Revocation Signature packets");
271            revocation.append(&mut recursively_collect_files(&file_path)?);
272        } else if file_path.ends_with("directkey") {
273            trace!("Found a directory for Direct Key Signature packets");
274            directkey.append(&mut recursively_collect_files(&file_path)?);
275        } else if file_path.ends_with("uid") {
276            trace!("Found a directory for User ID or User Attribute packets");
277            uid.append(&mut recursively_collect_files(&file_path)?);
278        } else if file_path.ends_with("subkey") {
279            trace!("Found a directory for Subkey packets");
280            subkey.append(&mut recursively_collect_files(&file_path)?);
281        }
282    }
283
284    top_level.sort();
285    revocation.sort();
286    directkey.sort();
287    uid.sort();
288    subkey.sort();
289
290    // If there are no top-level OpenPGP packets, but some in subdirectories, this is not a valid
291    // Arch Linux keyring structure.
292    if top_level.is_empty()
293        && (!revocation.is_empty()
294            || !directkey.is_empty()
295            || !uid.is_empty()
296            || !subkey.is_empty())
297    {
298        return Err(crate::import::Error::DestructuredImport(
299            Error::InvalidArchLinuxKeyringStructure {
300                path: path.to_path_buf(),
301            },
302        )
303        .into());
304    }
305
306    // If there is more than one top-level OpenPGP packet and any OpenPGP packets in one of the Arch
307    // Linux keyring specific subdirectories, this is not a valid flat structure.
308    if top_level.len() > 1
309        && (!revocation.is_empty()
310            || !directkey.is_empty()
311            || !uid.is_empty()
312            || !subkey.is_empty())
313    {
314        return Err(
315            crate::import::Error::DestructuredImport(Error::InvalidFlatStructure {
316                path: path.to_path_buf(),
317            })
318            .into(),
319        );
320    }
321
322    files.append(&mut top_level);
323    files.append(&mut revocation);
324    files.append(&mut directkey);
325    files.append(&mut uid);
326    files.append(&mut subkey);
327
328    Ok(files)
329}
330
331/// Recognizes a _single_ [OpenPGP packet] in a reader.
332///
333/// # Note
334///
335/// The `path` parameter is meant to reflect the file path from which the reader is created.
336/// It is only used for better error reporting.
337///
338/// # Errors
339///
340/// Returns an error if
341///
342/// - an OpenPGP packet cannot be parsed from `reader`,
343/// - there is no OpenPGP packet in `reader`,
344/// - there is at least one additional OpenPGP packet in `reader`,
345/// - or there is unparsable data after an initial `OpenPGP` packet.
346///
347/// [OpenPGP packet]: https://openpgp.dev/book/zoom/certificates.html
348fn parse_packet_from_reader<T: Read>(
349    reader: BufReader<T>,
350    path: &Path,
351) -> Result<Packet, crate::Error> {
352    let mut packet_parser = PacketParser::new(reader);
353
354    let packet = match packet_parser.next() {
355        Some(Ok(packet)) => packet,
356        Some(Err(source)) => {
357            return Err(crate::Error::OpenPgpPath {
358                path: path.to_path_buf(),
359                context: "parsing an OpenPGP packet from a buffer",
360                source,
361            });
362        }
363        None => {
364            return Err(
365                crate::import::Error::DestructuredImport(Error::NoPacketInFile {
366                    path: path.to_path_buf(),
367                })
368                .into(),
369            );
370        }
371    };
372
373    match packet_parser.next() {
374        Some(Ok(packet)) => {
375            return Err(
376                crate::import::Error::DestructuredImport(Error::ExcessPacket {
377                    path: path.to_path_buf(),
378                    tag: packet.tag(),
379                })
380                .into(),
381            );
382        }
383        Some(Err(source)) => {
384            return Err(crate::Error::OpenPgpPath {
385                path: path.to_path_buf(),
386                context: "parsing an excess OpenPGP packet from a buffer",
387                source,
388            });
389        }
390        None => {}
391    }
392
393    Ok(packet)
394}
395
396/// Reads a _single_ OpenPGP packet from a file.
397///
398/// The file contents may be binary or ASCII-armored.
399///
400/// # Errors
401///
402/// Returns an error if
403///
404/// - the file at `path` cannot be opened for reading,
405/// - the first byte of the file at `path` cannot be read,
406/// - the file at `path` is empty,
407/// - or if not exactly one OpenPGP packet is found in the file at `path`.
408fn read_packet_from_file(path: impl AsRef<Path>) -> Result<Packet, crate::Error> {
409    let path = path.as_ref();
410    debug!("Reading a single OpenPGP packet from file {path:?}");
411
412    let file = File::open(path).map_err(|source| crate::Error::IoPath {
413        path: path.to_path_buf(),
414        context: "reading the file",
415        source,
416    })?;
417    let mut reader = BufReader::new(file);
418
419    // Check whether the file contains OpenPGP binary or ASCII-armored data.
420    //
421    // NOTE: In OpenPGP binary data, the highest bit of the first byte is a one.
422    //       The first bit in ASCII is **always** `0`, which makes this a solid heuristic.
423    let is_binary = {
424        // Read (at least) the first byte into a buffer, without consuming it from the reader.
425        let buffer = reader.fill_buf().map_err(|source| crate::Error::IoPath {
426            path: path.to_path_buf(),
427            context: "filling the buffer",
428            source,
429        })?;
430        if buffer.is_empty() {
431            return Err(
432                crate::import::Error::DestructuredImport(Error::FileIsEmpty {
433                    path: path.to_path_buf(),
434                })
435                .into(),
436            );
437        }
438
439        // If the highest bit of the first byte is set, we assume this is OpenPGP binary data.
440        buffer[0] & 0x80 != 0
441    };
442
443    if is_binary {
444        parse_packet_from_reader(reader, path)
445    } else {
446        parse_packet_from_reader(BufReader::new(Dearmor::new(reader)), path)
447    }
448}
449
450/// Creates a _single_ [`SignedPublicKey`] from regular files in a directory.
451///
452/// First collects the paths of all regular files in `path`.
453/// Then parses each regular file as a _single_ [OpenPGP packet].
454/// Finally, reads a _single_ [OpenPGP certificate] from the packets.
455///
456/// # Errors
457///
458/// Returns an error if
459///
460/// - recursively collecting regular files from `path` fails,
461/// - parsing a _single_ OpenPGP packet from each collected regular file fails,
462/// - no [`SignedPublicKey`] can be created from the OpenPGP packets,
463/// - creating a [`SignedPublicKey`] from the OpenPGP packets fails,
464/// - an additional, unwanted [`SignedPublicKey`] is created from the OpenPGP packets,
465/// - or creating an additional, unwanted [`SignedPublicKey`] from the OpenPGP packets fails.
466///
467/// [OpenPGP packet]: https://openpgp.dev/book/zoom/certificates.html
468/// [OpenPGP certificate]: https://openpgp.dev/book/certificates.html
469fn signed_public_key_from_dir(path: impl AsRef<Path>) -> Result<SignedPublicKey, crate::Error> {
470    let path = path.as_ref();
471    debug!("Reading a single OpenPGP certificate from OpenPGP packets in directory {path:?}");
472
473    let paths = collect_files_in_dir(path)?;
474    let mut packets: Vec<_> = Vec::new();
475
476    for file_path in paths.iter() {
477        debug!("Reading regular file {file_path:?} as an OpenPGP packet");
478        packets.push(Ok(read_packet_from_file(file_path)?));
479    }
480
481    let mut cert_iter = SignedPublicKey::from_packets(packets.into_iter().peekable());
482    let pubkey = match cert_iter.next() {
483        Some(Ok(cert)) => cert,
484        Some(Err(source)) => {
485            return Err(crate::Error::OpenPgpPath {
486                path: path.to_path_buf(),
487                context: "reading an OpenPGP certificate from OpenPGP packets in a directory",
488                source,
489            });
490        }
491        None => {
492            return Err(
493                crate::import::Error::DestructuredImport(Error::NoOpenPgpCertInDir {
494                    path: path.to_path_buf(),
495                })
496                .into(),
497            );
498        }
499    };
500
501    match cert_iter.next() {
502        Some(Ok(cert)) => {
503            return Err(
504                crate::import::Error::DestructuredImport(Error::ExcessCertificateInDir {
505                    path: path.to_path_buf(),
506                    fingerprint: cert.fingerprint(),
507                })
508                .into(),
509            );
510        }
511        Some(Err(source)) => {
512            return Err(crate::Error::OpenPgpPath {
513                path: path.to_path_buf(),
514                context: "finding additional, unwanted OpenPGP packets in a directory",
515                source,
516            });
517        }
518        None => {}
519    }
520
521    Ok(pubkey)
522}
523
524/// Creates an [`OpenPgpImport`] from a directory containing OpenPGP packet files.
525///
526/// Recursively collects all regular files in the directory, concatenates them and
527/// attempts to create a single [`SignedPublicKey`] from the accumulated data.
528/// Supports both binary and ASCII-armored data.
529///
530/// The collected regular files must be sorted in the order of an [OpenPGP Transferable Public
531/// Key].
532/// Both **flat** and **Arch Linux keyring** structures are supported (see the
533/// [`import::destructured`][crate::import::destructured] module documentation for details).
534///
535/// # Errors
536///
537/// Returns an error if
538///
539/// - `path` is not a directory,
540/// - or a [`SignedPublicKey`] cannot be created from the accumulated data.
541///
542/// [OpenPGP Transferable Public Key]: https://www.rfc-editor.org/rfc/rfc9580#name-transferable-public-keys
543pub fn load_from_dir(path: impl AsRef<Path>) -> Result<OpenPgpImport, crate::Error> {
544    let path = path.as_ref();
545    debug!("Reading an OpenPGP certificate from directory: {path:?}");
546
547    if !path.is_dir() {
548        return Err(
549            crate::import::Error::DestructuredImport(Error::PathIsNotADir {
550                path: path.to_path_buf(),
551            })
552            .into(),
553        );
554    }
555
556    Ok(OpenPgpImport(signed_public_key_from_dir(path)?))
557}
558
559#[cfg(test)]
560mod tests {
561
562    use std::fs::{create_dir_all, remove_file};
563
564    use log::info;
565    use pgp::{
566        armor::{BlockType, write},
567        bytes::Buf,
568        composed::{KeyType, SecretKeyParamsBuilder, SubkeyParamsBuilder},
569        packet::{PacketParser, PacketTrait, Signature, SignatureType},
570        ser::Serialize,
571        types::{Password, Tag},
572    };
573    use rand::thread_rng;
574    use rstest::{fixture, rstest};
575    use simplelog::{ColorChoice, Config, LevelFilter, TermLogger, TerminalMode};
576    use tempfile::tempdir;
577    use testresult::TestResult;
578    use voa_core::VerifierWriter;
579
580    use super::*;
581
582    /// The configuration for splitting of input files.
583    enum SplitConfig {
584        /// Place all split OpenPGP packet files in the top-level directory.
585        Flat,
586        /// Place split OpenPGP packet files in subdirectory structures.
587        ArchLinuxKeyring,
588    }
589
590    /// The configuration for tests.
591    struct TestConfig {
592        /// Whether to ASCII-armor the input file(s).
593        pub armor: bool,
594        /// Whether to split the input file into files per packet.
595        pub split: SplitConfig,
596    }
597
598    /// Init logger
599    fn init_logger() {
600        if TermLogger::init(
601            LevelFilter::Trace,
602            Config::default(),
603            TerminalMode::Stderr,
604            ColorChoice::Auto,
605        )
606        .is_err()
607        {
608            debug!("Not initializing another logger, as one is initialized already.");
609        }
610    }
611
612    /// Splits an OpenPGP certificate into separate OpenPGP packets.
613    ///
614    /// The packets are written to separate files in `path`.
615    /// If `armor` is `true`, each packet is written ASCII-armored using [`BlockType::PublicKey`].
616    fn split_openpgp_cert(
617        openpgp_cert: &SignedPublicKey,
618        path: impl AsRef<Path>,
619        armor: bool,
620        config: SplitConfig,
621    ) -> TestResult {
622        let path = path.as_ref();
623
624        match config {
625            SplitConfig::Flat => {
626                info!("Splitting OpenPGP certificate into packets in a top-level directory.");
627                for (i, packet_result) in
628                    PacketParser::new(openpgp_cert.to_bytes()?.reader()).enumerate()
629                {
630                    let packet = packet_result?;
631                    let packet_file = path.join(format!("{:06}-{:?}", i, packet.tag()));
632                    info!("Creating packet file {packet_file:?}");
633                    let mut file = File::create(packet_file)?;
634                    if armor {
635                        write(&packet, BlockType::PublicKey, &mut file, None, true)?;
636                    } else {
637                        packet.to_writer(&mut file)?;
638                    }
639                }
640            }
641            SplitConfig::ArchLinuxKeyring => {
642                info!("Splitting OpenPGP certificate into packets in subdirectories.");
643                let mut current_subdir: Option<&str> = None;
644                for (i, packet_result) in
645                    PacketParser::new(openpgp_cert.to_bytes()?.reader()).enumerate()
646                {
647                    let packet = packet_result?;
648
649                    // Set the currently used subdirectory depending on detected OpenPGP packet.
650                    current_subdir = match packet.tag() {
651                        Tag::Signature => {
652                            let signature = Signature::try_from(packet.clone())?;
653                            match signature.typ() {
654                                Some(SignatureType::Key) => Some("directkey"),
655                                Some(SignatureType::KeyRevocation) => Some("revocation"),
656                                _ => current_subdir,
657                            }
658                        }
659                        Tag::UserId => Some("uid"),
660                        Tag::PublicSubkey => Some("subkey"),
661                        _ => current_subdir,
662                    };
663                    if let Some(current_subdir) = current_subdir {
664                        create_dir_all(path.join(current_subdir))?;
665                    }
666
667                    // If a current subdirectory is used, write all OpenPGP packet files to it.
668                    let packet_file = if let Some(current_subdir) = current_subdir {
669                        path.join(current_subdir)
670                            .join(format!("{:06}-{:?}", i, packet.tag()))
671                    } else {
672                        path.join(format!("{:06}-{:?}", i, packet.tag()))
673                    };
674
675                    info!("Creating packet file {packet_file:?}");
676                    let mut file = File::create(packet_file)?;
677                    if armor {
678                        write(&packet, BlockType::PublicKey, &mut file, None, true)?;
679                    } else {
680                        packet.to_writer(&mut file)?;
681                    }
682                }
683            }
684        }
685
686        Ok(())
687    }
688
689    /// Creates an OpenPGP certificate ([`SignedPublicKey`]).
690    #[fixture]
691    fn openpgp_cert() -> TestResult<SignedPublicKey> {
692        let mut signkey = SubkeyParamsBuilder::default();
693        signkey
694            .key_type(KeyType::Ed25519Legacy)
695            .can_sign(true)
696            .can_encrypt(false)
697            .can_authenticate(false);
698        let mut key_params = SecretKeyParamsBuilder::default();
699        key_params
700            .key_type(KeyType::Ed25519Legacy)
701            .can_certify(true)
702            .can_sign(false)
703            .can_encrypt(false)
704            .primary_user_id("John Doe <jdoe@example.org>".to_string())
705            .subkeys(vec![signkey.build()?]);
706
707        let secret_key_params = key_params.build()?;
708        let secret_key = secret_key_params.generate(thread_rng())?;
709
710        // Produce binding self-signatures that link all the components together
711        let signed = secret_key.sign(&mut thread_rng(), &Password::from(""))?;
712
713        let pubkey = SignedPublicKey::from(signed);
714        Ok(pubkey)
715    }
716
717    /// Ensures that [`OpenPgpImport`] can be created from OpenPGP certificate.
718    ///
719    /// The OpenPGP certificate input may be
720    /// Further ensures that the export of the verifier with [`OpenPgpImport::write_to_hierarchy`]
721    /// works.
722    #[rstest]
723    #[case::split_top_level_binary(TestConfig{armor: false, split: SplitConfig::Flat})]
724    #[case::split_top_level_armor(TestConfig{armor: true, split: SplitConfig::Flat})]
725    #[case::split_dirs_binary(TestConfig{armor: false, split: SplitConfig::ArchLinuxKeyring})]
726    #[case::split_dirs_armor(TestConfig{armor: true, split: SplitConfig::ArchLinuxKeyring})]
727    fn write_to_hierarchy_succeeds(
728        openpgp_cert: TestResult<SignedPublicKey>,
729        #[case] config: TestConfig,
730    ) -> TestResult {
731        init_logger();
732
733        let input_pubkey = openpgp_cert?;
734
735        let temp_dir = tempdir()?;
736        let path = temp_dir.path().to_path_buf();
737        split_openpgp_cert(&input_pubkey, &path, config.armor, config.split)?;
738
739        let import = load_from_dir(path.as_path())?;
740
741        let temp_dir = tempdir()?;
742        let output_dir = temp_dir.path();
743        import.write_to_hierarchy(
744            output_dir,
745            "os".parse()?,
746            "purpose".parse()?,
747            Some("context".parse()?),
748        )?;
749
750        let output_file = output_dir
751            .join("os")
752            .join("purpose")
753            .join("context")
754            .join(import.technology().to_string())
755            .join(import.file_name());
756
757        let (output_pubkey, _) = SignedPublicKey::from_armor_file(&output_file)?;
758
759        assert_eq!(input_pubkey, output_pubkey);
760
761        Ok(())
762    }
763
764    /// Ensures that [`openpgp_import_from_destructured_dir`] fails on invalid flat structures.
765    #[rstest]
766    fn openpgp_import_from_destructured_dir_fails_on_invalid_flat_structure(
767        openpgp_cert: TestResult<SignedPublicKey>,
768    ) -> TestResult {
769        init_logger();
770
771        let input_pubkey = openpgp_cert?;
772
773        let temp_dir = tempdir()?;
774        let path = temp_dir.path().to_path_buf();
775
776        // Write both flat and Arch Linux keyring structures to the import directory.
777        // This renders this structure an invalid flat structure.
778        split_openpgp_cert(&input_pubkey, &path, true, SplitConfig::Flat)?;
779        split_openpgp_cert(&input_pubkey, &path, true, SplitConfig::ArchLinuxKeyring)?;
780
781        match load_from_dir(path.as_path()) {
782            Ok(verifier) => {
783                return Err(format!(
784                    "Should have failed, but succeeded to create a verifier: {verifier:?}"
785                )
786                .into());
787            }
788            Err(error) => match error {
789                crate::Error::Import(crate::import::error::Error::DestructuredImport(
790                    Error::InvalidFlatStructure { .. },
791                )) => {}
792                error => {
793                    return Err(format!("Did not return the correct error, got: {error}").into());
794                }
795            },
796        }
797
798        Ok(())
799    }
800
801    /// Ensures that [`openpgp_import_from_destructured_dir`] fails on invalid Arch Linux keyring
802    /// structures.
803    #[rstest]
804    fn openpgp_import_from_destructured_dir_fails_on_invalid_arch_linux_keyring_structure(
805        openpgp_cert: TestResult<SignedPublicKey>,
806    ) -> TestResult {
807        init_logger();
808
809        let input_pubkey = openpgp_cert?;
810
811        let temp_dir = tempdir()?;
812        let path = temp_dir.path().to_path_buf();
813        split_openpgp_cert(&input_pubkey, &path, true, SplitConfig::ArchLinuxKeyring)?;
814
815        // Remove the only top-level file in the import directory.
816        // This renders this structure an invalid Arch Linux keyring structure
817        for dir_entry in path.read_dir()? {
818            let dir_entry = dir_entry?;
819            let path = dir_entry.path();
820            if path.is_file() {
821                remove_file(path)?;
822            }
823        }
824
825        match load_from_dir(path.as_path()) {
826            Ok(verifier) => {
827                return Err(format!(
828                    "Should have failed, but succeeded to create a verifier: {verifier:?}"
829                )
830                .into());
831            }
832            Err(error) => match error {
833                crate::Error::Import(crate::import::error::Error::DestructuredImport(
834                    Error::InvalidArchLinuxKeyringStructure { .. },
835                )) => {}
836                error => {
837                    return Err(format!("Did not return the correct error, got: {error}").into());
838                }
839            },
840        }
841
842        Ok(())
843    }
844
845    /// Ensures that [`OpenPgpImport::new`] fails if the input path is not a file or a directory.
846    #[test]
847    #[cfg(target_os = "linux")]
848    fn openpgp_import_from_destructured_dir_fails_on_path_not_a_dir() -> TestResult {
849        match load_from_dir("/dev/null") {
850            Ok(verifier) => {
851                return Err(format!(
852                    "Should have failed, but succeeded to create a verifier: {verifier:?}"
853                )
854                .into());
855            }
856            Err(error) => match error {
857                crate::Error::Import(crate::import::error::Error::DestructuredImport(
858                    crate::import::destructured::error::Error::PathIsNotADir { .. },
859                )) => {}
860                error => {
861                    return Err(format!("Did not return the correct error, got: {error}").into());
862                }
863            },
864        }
865        Ok(())
866    }
867}