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, Metadata},
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/// The name of a directory used for OpenPGP packets that serve as certification.
168pub(crate) const CERTIFICATION_DIR: &str = "certification";
169/// The name of a directory used for OpenPGP packets that serve as revocation.
170pub(crate) const REVOCATION_DIR: &str = "revocation";
171/// The name of a directory used for OpenPGP packets that serve as direct key signatures.
172pub(crate) const DIRECTKEY_DIR: &str = "directkey";
173/// The name of a directory containing directory structures with OpenPGP packet files for User IDs.
174pub(crate) const UID_DIR: &str = "uid";
175/// The name of a directory containing directory structures with OpenPGP packet files for subkeys.
176pub(crate) const SUBKEY_DIR: &str = "subkey";
177
178/// Reads a directory `path` and returns its entries as a sorted list of paths.
179///
180/// # Errors
181///
182/// Returns an error if
183///
184/// - `path` cannot be read using [`Path::read_dir`],
185/// - or one of the directory entries in `path` is not readable.
186fn read_dir_as_sorted_paths(path: impl AsRef<Path>) -> Result<Vec<PathBuf>, crate::Error> {
187 let dir = path.as_ref();
188 let read_dir = dir.read_dir().map_err(|source| crate::Error::IoPath {
189 path: dir.to_path_buf(),
190 context: "reading entries of a directory",
191 source,
192 })?;
193
194 let paths = {
195 let mut paths: Vec<PathBuf> = Vec::new();
196
197 for dir_entry in read_dir {
198 let entry = dir_entry.map_err(|source| crate::Error::IoPath {
199 path: dir.to_path_buf(),
200 context: "reading entry in directory",
201 source,
202 })?;
203 paths.push(entry.path());
204 }
205
206 // Ensure a stable sorted list of paths.
207 // Some filesystems are not sorted by default.
208 paths.sort();
209
210 paths
211 };
212
213 Ok(paths)
214}
215
216/// Retrieves [`Metadata`] for a `path` and maps [`std::io::Error`] to [`crate::Error`].
217fn path_metadata(path: impl AsRef<Path>) -> Result<Metadata, crate::Error> {
218 let path = path.as_ref();
219 path.metadata().map_err(|source| crate::Error::IoPath {
220 path: path.to_path_buf(),
221 context: "retrieving metadata for a path",
222 source,
223 })
224}
225
226/// Collects all regular files in a directory `path`.
227///
228/// # Errors
229///
230/// Returns an error if
231///
232/// - `path` is not a directory,
233/// - an entry in `path` cannot be read,
234/// - metadata for an entry in `path` cannot be retrieved,
235/// - or any path in `path` does not represent a regular file.
236fn files_in_dir(path: impl AsRef<Path>, list: &mut Vec<PathBuf>) -> Result<(), crate::Error> {
237 let dir = path.as_ref();
238 let paths = read_dir_as_sorted_paths(dir)?;
239
240 for path in paths {
241 let metadata = path_metadata(&path)?;
242
243 if !metadata.is_file() {
244 return Err(crate::import::Error::DestructuredImport(
245 Error::DirMustContainRegularFiles {
246 dir: dir.to_path_buf(),
247 path: path.clone(),
248 },
249 )
250 .into());
251 }
252
253 list.push(path.to_path_buf());
254 }
255
256 Ok(())
257}
258
259/// Creates a list of component directories from a directory.
260///
261/// Component directories may be those for OpenPGP User IDs or OpenPGP subkeys.
262///
263/// # Errors
264///
265/// Returns an error if
266///
267/// - `path` is not a directory,
268/// - an entry in `path` cannot be read,
269/// - metadata for an entry in `path` cannot be retrieved,
270/// - any path in `path` does not represent a directory,
271/// - or any path in `path` cannot be turned into a [`ComponentDir`].
272fn component_dirs_from_dir(path: impl AsRef<Path>) -> Result<Vec<ComponentDir>, crate::Error> {
273 let dir = path.as_ref();
274 let paths = read_dir_as_sorted_paths(dir)?;
275 let mut dirs = Vec::new();
276
277 for path in paths {
278 let metadata = path_metadata(&path)?;
279
280 if !metadata.is_dir() {
281 return Err(
282 crate::import::Error::DestructuredImport(Error::DirMustContainDirs {
283 dir: dir.to_path_buf(),
284 path: path.clone(),
285 })
286 .into(),
287 );
288 }
289 dirs.push(ComponentDir::try_from(path.as_path())?);
290 }
291
292 Ok(dirs)
293}
294
295/// Creates a list of direct key certification directories from a directory.
296///
297/// # Errors
298///
299/// Returns an error if
300///
301/// - `path` does not have a subdirectory named [`CERTIFICATION_DIR`], or reading entries from that
302/// subdirectory fails,
303/// - the [`CERTIFICATION_DIR`] subdirectory contains paths that are not directories,
304/// - or creating a [`DirectKeyCertificationDir`] from one of the paths in the [`CERTIFICATION_DIR`]
305/// subdirectory fails.
306fn direct_key_certification_dirs_from_dir(
307 path: impl AsRef<Path>,
308) -> Result<Vec<DirectKeyCertificationDir>, crate::Error> {
309 let dir = path.as_ref().join(CERTIFICATION_DIR);
310 let paths = read_dir_as_sorted_paths(&dir)?;
311 let mut dirs = Vec::new();
312
313 for path in paths {
314 let metadata = path_metadata(&path)?;
315
316 if !metadata.is_dir() {
317 return Err(
318 crate::import::Error::DestructuredImport(Error::DirMustContainDirs {
319 dir: dir.to_path_buf(),
320 path: path.clone(),
321 })
322 .into(),
323 );
324 }
325
326 dirs.push(DirectKeyCertificationDir::try_from(path.as_path())?);
327 }
328
329 Ok(dirs)
330}
331
332/// The representation of a direct key certification directory.
333///
334/// A direct key certification directory consists of one directory with
335/// one or more direct key signature packets.
336///
337/// ```text
338/// E242ED3BFFCCDF271B7FBAF34ED72D089537B42F
339/// ├── 2024-04-01_15-17-48.asc
340/// └── 2024-11-05_18-52-46.asc
341/// ```
342pub(crate) struct DirectKeyCertificationDir(Vec<PathBuf>);
343
344impl DirectKeyCertificationDir {
345 /// Returns the list of tracked regular file paths.
346 pub fn paths(&self) -> Vec<PathBuf> {
347 self.0.to_vec()
348 }
349}
350
351impl TryFrom<&Path> for DirectKeyCertificationDir {
352 type Error = crate::Error;
353
354 /// Creates a new [`DirectKeyCertificationDir`] from a [`Path`] reference.
355 fn try_from(value: &Path) -> Result<Self, Self::Error> {
356 let mut paths = Vec::new();
357 files_in_dir(value, &mut paths)?;
358
359 Ok(Self(paths))
360 }
361}
362
363/// The representation of a component directory.
364///
365/// A component directory must contain exactly one top-level OpenPGP packet file and may contain
366/// zero or more OpenPGP packet files in the [`CERTIFICATION_DIR`] and/or [`REVOCATION_DIR`]
367/// subdirectories.
368/// It may be used to describe OpenPGP User ID directories
369///
370/// ```text
371/// John_Doe__jdoe@example.org_d2ad250f
372/// ├── John_Doe__jdoe@example.org_d2ad250f.asc
373/// └── certification
374/// ├── B787A81C32997FD39A5F4C0188363902D3586E7B.asc
375/// ├── 2072A695613E5103D9AC03C2885C5E2656CB5FF0.asc
376/// ├── 68D61AF364B99AD0226A9C8859F18BF95A99BCE9.asc
377/// ├── 033DB9A2637803F63BDA651106B2C4BEF184C21D.asc
378/// ├── 868672B9CDB0BF449BF3782CFDA1DBE372838AA3.asc
379/// ├── F1D2D2F924E986AC86FDF7B36C94BCDF32BEEC15.asc
380/// ├── 98EECC29ABC53C31B0DA5C85CB26CE720C7FF763.asc
381/// └── 52428846EFFD79371A81D6C82D00FBFED9C654F3.asc
382/// ```
383///
384/// or subkey directories in an Arch Linux keyring structure.
385///
386/// ```text
387/// E242ED3BFFCCDF271B7FBAF34ED72D089537B42F
388/// ├── E242ED3BFFCCDF271B7FBAF34ED72D089537B42F.asc
389/// └── certification
390/// └── F1D2D2F924E986AC86FDF7B36C94BCDF32BEEC15.asc
391/// ```
392pub(crate) struct ComponentDir {
393 top_level: PathBuf,
394 certifications: Vec<PathBuf>,
395 revocations: Vec<PathBuf>,
396}
397
398impl ComponentDir {
399 /// Returns the list of tracked regular file paths.
400 ///
401 /// The top-level regular file path is returned before any regular file paths in the
402 /// "revocation" or "certification" subdirectories.
403 pub fn paths(&self) -> Vec<PathBuf> {
404 [
405 vec![self.top_level.clone()],
406 self.certifications.clone(),
407 self.revocations.clone(),
408 ]
409 .concat()
410 }
411}
412
413impl TryFrom<&Path> for ComponentDir {
414 type Error = crate::Error;
415
416 /// Creates a new [`ComponentDir`] from a [`Path`] reference.
417 ///
418 /// # Errors
419 ///
420 /// Returns an error if
421 ///
422 /// - `value` is not a directory, or entries in it cannot be read,
423 /// - an entry in `value` cannot be read,
424 /// - metadata for a path in `value` cannot be retrieved,
425 /// - there is more than one top-level regular file in `value`,
426 /// - the directory [`CERTIFICATION_DIR`] exists in `value` but regular files from it cannot be
427 /// retrieved,
428 /// - the directory [`REVOCATION_DIR`] exists in `value` but regular files from it cannot be
429 /// retrieved,
430 /// - a directory not named [`CERTIFICATION_DIR`] or [`REVOCATION_DIR`] exists in `value`,
431 /// - a path in `value` is not a regular file or a directory,
432 /// - or there is not top-level regular file present in `value`.
433 fn try_from(value: &Path) -> Result<Self, Self::Error> {
434 let paths = read_dir_as_sorted_paths(value)?;
435 let mut top_level = None;
436 let mut certifications = Vec::new();
437 let mut revocations = Vec::new();
438
439 for path in paths {
440 let metadata = path_metadata(&path)?;
441
442 if metadata.is_file() {
443 if top_level.is_some() {
444 return Err(crate::import::Error::DestructuredImport(
445 Error::MultipleTopLevelPackets {
446 path: value.to_path_buf(),
447 },
448 )
449 .into());
450 }
451
452 top_level = Some(path.clone());
453 } else if metadata.is_dir() {
454 if path.ends_with(CERTIFICATION_DIR) {
455 files_in_dir(path, &mut certifications)?;
456 } else if path.ends_with(REVOCATION_DIR) {
457 files_in_dir(path, &mut revocations)?;
458 } else {
459 return Err(crate::import::Error::DestructuredImport(
460 Error::InvalidComponentSubDirectory { path: path.clone() },
461 )
462 .into());
463 }
464 } else {
465 return Err(crate::import::Error::DestructuredImport(
466 Error::DirMustContainRegularFilesOrDirs {
467 dir: value.to_path_buf(),
468 path: path.clone(),
469 },
470 )
471 .into());
472 }
473 }
474
475 let Some(top_level) = top_level else {
476 return Err(
477 crate::import::Error::DestructuredImport(Error::NoTopLevelPacket {
478 path: value.to_path_buf(),
479 })
480 .into(),
481 );
482 };
483
484 Ok(Self {
485 top_level,
486 certifications,
487 revocations,
488 })
489 }
490}
491
492/// Collects all regular files in a directory in the order of an [OpenPGP Transferable Public
493/// Key].
494///
495/// All top-level regular files in the directory are considered.
496/// Regular files located in the following list of directories (if they exist) are considered, in
497/// the following order:
498///
499/// - `revocation`: for Revocation Signature packets
500/// - `directkey`: for Direct Key Signature packets
501/// - `uid`: for User ID or User Attribute packets
502/// - `subkey`: for Subkey packets
503///
504/// # Errors
505///
506/// Returns an error if
507///
508/// - entries in `path` cannot be retrieved,
509/// - reading an entry in `path` fails,
510/// - or collecting files in any of the subdirectories (`revocation`, `directkey`, `uid` or
511/// `subkey`) fails.
512///
513/// [OpenPGP Transferable Public Key]: https://www.rfc-editor.org/rfc/rfc9580#name-transferable-public-keys
514fn collect_files_in_dir(path: impl AsRef<Path>) -> Result<Vec<PathBuf>, crate::Error> {
515 let path = path.as_ref();
516 debug!("Collecting regular files in {path:?}");
517
518 let paths = read_dir_as_sorted_paths(path)?;
519 let mut top_level: Vec<PathBuf> = Vec::new();
520 let mut revocation: Vec<PathBuf> = Vec::new();
521 let mut directkey: Vec<PathBuf> = Vec::new();
522 let mut subkey: Vec<PathBuf> = Vec::new();
523 let mut uid: Vec<PathBuf> = Vec::new();
524
525 for file_path in paths {
526 let metadata = path_metadata(&file_path)?;
527
528 if !(metadata.is_dir() || metadata.is_file()) {
529 return Err(crate::import::Error::DestructuredImport(
530 Error::DirMustContainRegularFilesOrDirs {
531 dir: path.to_path_buf(),
532 path: file_path.clone(),
533 },
534 )
535 .into());
536 }
537
538 if file_path.is_file() {
539 debug!("Found regular file {file_path:?}");
540 top_level.push(file_path);
541 continue;
542 }
543
544 if metadata.is_dir() && top_level.len() > 1 {
545 return Err(
546 crate::import::Error::DestructuredImport(Error::InvalidFlatStructure {
547 path: path.to_path_buf(),
548 })
549 .into(),
550 );
551 }
552
553 if file_path.ends_with(REVOCATION_DIR) {
554 trace!("Found a directory for Revocation Signature packets");
555 files_in_dir(&file_path, &mut revocation)?;
556 } else if file_path.ends_with(DIRECTKEY_DIR) {
557 trace!("Found a directory for Direct Key Signature packets");
558 for direct_key_certification_dir in direct_key_certification_dirs_from_dir(&file_path)?
559 {
560 directkey.append(&mut direct_key_certification_dir.paths())
561 }
562 } else if file_path.ends_with(UID_DIR) {
563 trace!("Found a directory for User ID or User Attribute packets");
564 for user_id_dir in component_dirs_from_dir(&file_path)? {
565 uid.append(&mut user_id_dir.paths())
566 }
567 } else if file_path.ends_with(SUBKEY_DIR) {
568 trace!("Found a directory for Subkey packets");
569 for subkey_dir in component_dirs_from_dir(&file_path)? {
570 subkey.append(&mut subkey_dir.paths())
571 }
572 } else {
573 return Err(crate::import::Error::DestructuredImport(
574 Error::InvalidComponentSubDirectory {
575 path: file_path.clone(),
576 },
577 )
578 .into());
579 }
580 }
581
582 top_level.sort();
583 revocation.sort();
584 directkey.sort();
585
586 // If there are no top-level OpenPGP packets, but some in subdirectories, this is not a valid
587 // Arch Linux keyring structure.
588 if top_level.is_empty()
589 && (!revocation.is_empty()
590 || !directkey.is_empty()
591 || !uid.is_empty()
592 || !subkey.is_empty())
593 {
594 return Err(crate::import::Error::DestructuredImport(
595 Error::InvalidArchLinuxKeyringStructure {
596 path: path.to_path_buf(),
597 },
598 )
599 .into());
600 }
601
602 // If there is more than one top-level OpenPGP packet and any OpenPGP packets in one of the Arch
603 // Linux keyring specific subdirectories, this is not a valid flat structure.
604 if top_level.len() > 1
605 && (!revocation.is_empty()
606 || !directkey.is_empty()
607 || !uid.is_empty()
608 || !subkey.is_empty())
609 {
610 return Err(
611 crate::import::Error::DestructuredImport(Error::InvalidFlatStructure {
612 path: path.to_path_buf(),
613 })
614 .into(),
615 );
616 }
617
618 Ok([top_level, revocation, directkey, uid, subkey].concat())
619}
620
621/// Recognizes a _single_ [OpenPGP packet] in a reader.
622///
623/// # Note
624///
625/// The `path` parameter is meant to reflect the file path from which the reader is created.
626/// It is only used for better error reporting.
627///
628/// # Errors
629///
630/// Returns an error if
631///
632/// - an OpenPGP packet cannot be parsed from `reader`,
633/// - there is no OpenPGP packet in `reader`,
634/// - there is at least one additional OpenPGP packet in `reader`,
635/// - or there is unparsable data after an initial `OpenPGP` packet.
636///
637/// [OpenPGP packet]: https://openpgp.dev/book/zoom/certificates.html
638fn parse_packet_from_reader<T: Read>(
639 reader: BufReader<T>,
640 path: &Path,
641) -> Result<Packet, crate::Error> {
642 let mut packet_parser = PacketParser::new(reader);
643
644 let packet = match packet_parser.next() {
645 Some(Ok(packet)) => packet,
646 Some(Err(source)) => {
647 return Err(crate::Error::OpenPgpPath {
648 path: path.to_path_buf(),
649 context: "parsing an OpenPGP packet from a buffer",
650 source,
651 });
652 }
653 None => {
654 return Err(
655 crate::import::Error::DestructuredImport(Error::NoPacketInFile {
656 path: path.to_path_buf(),
657 })
658 .into(),
659 );
660 }
661 };
662
663 match packet_parser.next() {
664 Some(Ok(packet)) => {
665 return Err(
666 crate::import::Error::DestructuredImport(Error::ExcessPacket {
667 path: path.to_path_buf(),
668 tag: packet.tag(),
669 })
670 .into(),
671 );
672 }
673 Some(Err(source)) => {
674 return Err(crate::Error::OpenPgpPath {
675 path: path.to_path_buf(),
676 context: "parsing an excess OpenPGP packet from a buffer",
677 source,
678 });
679 }
680 None => {}
681 }
682
683 Ok(packet)
684}
685
686/// Reads a _single_ OpenPGP packet from a file.
687///
688/// The file contents may be binary or ASCII-armored.
689///
690/// # Errors
691///
692/// Returns an error if
693///
694/// - the file at `path` cannot be opened for reading,
695/// - the first byte of the file at `path` cannot be read,
696/// - the file at `path` is empty,
697/// - or if not exactly one OpenPGP packet is found in the file at `path`.
698fn read_packet_from_file(path: impl AsRef<Path>) -> Result<Packet, crate::Error> {
699 let path = path.as_ref();
700 debug!("Reading a single OpenPGP packet from file {path:?}");
701
702 let file = File::open(path).map_err(|source| crate::Error::IoPath {
703 path: path.to_path_buf(),
704 context: "reading the file",
705 source,
706 })?;
707 let mut reader = BufReader::new(file);
708
709 // Check whether the file contains OpenPGP binary or ASCII-armored data.
710 //
711 // NOTE: In OpenPGP binary data, the highest bit of the first byte is a one.
712 // The first bit in ASCII is **always** `0`, which makes this a solid heuristic.
713 let is_binary = {
714 // Read (at least) the first byte into a buffer, without consuming it from the reader.
715 let buffer = reader.fill_buf().map_err(|source| crate::Error::IoPath {
716 path: path.to_path_buf(),
717 context: "filling the buffer",
718 source,
719 })?;
720 if buffer.is_empty() {
721 return Err(
722 crate::import::Error::DestructuredImport(Error::FileIsEmpty {
723 path: path.to_path_buf(),
724 })
725 .into(),
726 );
727 }
728
729 // If the highest bit of the first byte is set, we assume this is OpenPGP binary data.
730 buffer[0] & 0x80 != 0
731 };
732
733 if is_binary {
734 parse_packet_from_reader(reader, path)
735 } else {
736 parse_packet_from_reader(BufReader::new(Dearmor::new(reader)), path)
737 }
738}
739
740/// Creates a _single_ [`SignedPublicKey`] from regular files in a directory.
741///
742/// First collects the paths of all regular files in `path`.
743/// Then parses each regular file as a _single_ [OpenPGP packet].
744/// Finally, reads a _single_ [OpenPGP certificate] from the packets.
745///
746/// # Errors
747///
748/// Returns an error if
749///
750/// - recursively collecting regular files from `path` fails,
751/// - parsing a _single_ OpenPGP packet from each collected regular file fails,
752/// - no [`SignedPublicKey`] can be created from the OpenPGP packets,
753/// - creating a [`SignedPublicKey`] from the OpenPGP packets fails,
754/// - an additional, unwanted [`SignedPublicKey`] is created from the OpenPGP packets,
755/// - or creating an additional, unwanted [`SignedPublicKey`] from the OpenPGP packets fails.
756///
757/// [OpenPGP packet]: https://openpgp.dev/book/zoom/certificates.html
758/// [OpenPGP certificate]: https://openpgp.dev/book/certificates.html
759fn signed_public_key_from_dir(path: impl AsRef<Path>) -> Result<SignedPublicKey, crate::Error> {
760 let path = path.as_ref();
761 debug!("Reading a single OpenPGP certificate from OpenPGP packets in directory {path:?}");
762
763 let paths = collect_files_in_dir(path)?;
764 let mut packets: Vec<_> = Vec::new();
765
766 for file_path in paths.iter() {
767 debug!("Reading regular file {file_path:?} as an OpenPGP packet");
768 packets.push(Ok(read_packet_from_file(file_path)?));
769 }
770
771 let mut cert_iter = SignedPublicKey::from_packets(packets.into_iter().peekable());
772 let pubkey = match cert_iter.next() {
773 Some(Ok(cert)) => cert,
774 Some(Err(source)) => {
775 return Err(crate::Error::OpenPgpPath {
776 path: path.to_path_buf(),
777 context: "reading an OpenPGP certificate from OpenPGP packets in a directory",
778 source,
779 });
780 }
781 None => {
782 return Err(
783 crate::import::Error::DestructuredImport(Error::NoOpenPgpCertInDir {
784 path: path.to_path_buf(),
785 })
786 .into(),
787 );
788 }
789 };
790
791 match cert_iter.next() {
792 Some(Ok(cert)) => {
793 return Err(
794 crate::import::Error::DestructuredImport(Error::ExcessCertificateInDir {
795 path: path.to_path_buf(),
796 fingerprint: cert.fingerprint(),
797 })
798 .into(),
799 );
800 }
801 Some(Err(source)) => {
802 return Err(crate::Error::OpenPgpPath {
803 path: path.to_path_buf(),
804 context: "finding additional, unwanted OpenPGP packets in a directory",
805 source,
806 });
807 }
808 None => {}
809 }
810
811 Ok(pubkey)
812}
813
814/// Creates an [`OpenPgpImport`] from a directory containing OpenPGP packet files.
815///
816/// Recursively collects all regular files in the directory, concatenates them and
817/// attempts to create a single [`SignedPublicKey`] from the accumulated data.
818/// Supports both binary and ASCII-armored data.
819///
820/// The collected regular files must be sorted in the order of an [OpenPGP Transferable Public
821/// Key].
822/// Both **flat** and **Arch Linux keyring** structures are supported (see the
823/// [`import::destructured`][crate::import::destructured] module documentation for details).
824///
825/// # Errors
826///
827/// Returns an error if
828///
829/// - `path` is not a directory,
830/// - or a [`SignedPublicKey`] cannot be created from the accumulated data.
831///
832/// [OpenPGP Transferable Public Key]: https://www.rfc-editor.org/rfc/rfc9580#name-transferable-public-keys
833pub fn load_from_dir(path: impl AsRef<Path>) -> Result<OpenPgpImport, crate::Error> {
834 let path = path.as_ref();
835 debug!("Reading an OpenPGP certificate from directory: {path:?}");
836
837 if !path.is_dir() {
838 return Err(
839 crate::import::Error::DestructuredImport(Error::PathIsNotADir {
840 path: path.to_path_buf(),
841 })
842 .into(),
843 );
844 }
845
846 Ok(OpenPgpImport(signed_public_key_from_dir(path)?))
847}
848
849#[cfg(test)]
850mod tests {
851
852 use std::fs::{create_dir_all, remove_file};
853
854 use log::{info, warn};
855 use pgp::{
856 armor::{BlockType, write as armor_write},
857 bytes::Buf,
858 composed::{
859 EncryptionCaps,
860 KeyType,
861 SecretKeyParamsBuilder,
862 SignedSecretKey,
863 SubkeyParamsBuilder,
864 },
865 crypto::{hash::HashAlgorithm, sym::SymmetricKeyAlgorithm},
866 packet::{
867 Features,
868 KeyFlags,
869 PacketParser,
870 PacketTrait,
871 RevocationCode,
872 SignatureConfig,
873 SignatureType,
874 Subpacket,
875 SubpacketData,
876 },
877 ser::Serialize,
878 types::{Fingerprint, Password, SigningKey, Tag, Timestamp},
879 };
880 use rand::thread_rng;
881 use rstest::{fixture, rstest};
882 use simplelog::{ColorChoice, Config, LevelFilter, TermLogger, TerminalMode};
883 use tempfile::tempdir;
884 use testresult::TestResult;
885 use voa_core::VerifierWriter;
886
887 use super::*;
888
889 /// The configuration for splitting of input files.
890 enum SplitConfig {
891 /// Place all split OpenPGP packet files in the top-level directory.
892 Flat,
893 /// Place split OpenPGP packet files in subdirectory structures.
894 ArchLinuxKeyring,
895 }
896
897 /// The configuration for tests.
898 struct TestConfig {
899 /// Whether to ASCII-armor the input file(s).
900 pub armor: bool,
901 /// Whether to split the input file into files per packet.
902 pub split: SplitConfig,
903 }
904
905 /// Init logger
906 fn init_logger() {
907 if TermLogger::init(
908 LevelFilter::Trace,
909 Config::default(),
910 TerminalMode::Stderr,
911 ColorChoice::Auto,
912 )
913 .is_err()
914 {
915 debug!("Not initializing another logger, as one is initialized already.");
916 }
917 }
918
919 /// The current component when iterating over packets of an OpenPGP certificate.
920 ///
921 /// Tracks either subkey fingerprints or User IDs.
922 enum CurrentComponent {
923 /// No current component yet.
924 None,
925 /// A primary key.
926 Primary,
927 /// A subkey.
928 Subkey(Fingerprint),
929 /// A User ID.
930 UserId(String),
931 }
932
933 /// Splits an OpenPGP certificate into a list of packets in a single directory.
934 ///
935 /// The packets are written to separate files in `path`.
936 /// If `armor` is `true`, each packet is written ASCII-armored using [`BlockType::PublicKey`].
937 fn split_openpgp_cert_flat(
938 openpgp_cert: &SignedPublicKey,
939 path: impl AsRef<Path>,
940 armor: bool,
941 ) -> TestResult {
942 let path = path.as_ref();
943 info!("Splitting OpenPGP certificate into packets in a top-level directory.");
944 for (i, packet_result) in PacketParser::new(openpgp_cert.to_bytes()?.reader()).enumerate() {
945 let packet = packet_result?;
946 let packet_file = path.join(format!("{:06}-{:?}", i, packet.tag()));
947 info!("Creating packet file {packet_file:?}");
948 let mut file = File::create(packet_file)?;
949 if armor {
950 armor_write(&packet, BlockType::PublicKey, &mut file, None, true)?;
951 } else {
952 packet.to_writer(&mut file)?;
953 }
954 }
955 Ok(())
956 }
957
958 /// Writes a single OpenPGP `packet` file to a directory `path`.
959 ///
960 /// Creates the parent directory.
961 /// If `armor` is `true`, each packet is written ASCII-armored using [`BlockType::PublicKey`].
962 ///
963 /// # Errors
964 ///
965 /// Returns an error if
966 ///
967 /// - the parent directory cannot be created,
968 /// - creating a file at `path` fails,
969 /// - or writing the `packet` data to `path` fails.
970 fn write_openpgp_packet_file_to_dir(
971 path: impl AsRef<Path>,
972 packet: Packet,
973 armor: bool,
974 ) -> TestResult {
975 let path = path.as_ref();
976
977 if let Some(parent) = path.parent() {
978 create_dir_all(parent)?;
979 }
980
981 info!("Creating packet file {path:?}");
982 let mut file = File::create(path)?;
983 if armor {
984 armor_write(&packet, BlockType::PublicKey, &mut file, None, true)?;
985 } else {
986 packet.to_writer(&mut file)?;
987 }
988
989 Ok(())
990 }
991
992 /// Splits the OpenPGP certificate `openpgp_cert` into packets in a directory structure.
993 ///
994 /// The packets are written to separate files in a directory structure in `path`.
995 /// If `armor` is `true`, each packet is written ASCII-armored using [`BlockType::PublicKey`].
996 ///
997 /// # Note
998 ///
999 /// This implementation is mostly compatible with the Arch Linux keyring structure.
1000 /// However, it does not (yet) adjust the names of User ID directories and packet files
1001 /// accordingly.
1002 /// For test purposes this is sufficient though.
1003 fn split_openpgp_cert_arch_linux(
1004 openpgp_cert: &SignedPublicKey,
1005 path: impl AsRef<Path>,
1006 armor: bool,
1007 ) -> TestResult {
1008 let path = path.as_ref();
1009
1010 info!("Splitting OpenPGP certificate into packets in subdirectories.");
1011 let pubkey_fingerprint: Fingerprint = openpgp_cert.fingerprint();
1012 let mut current_component = CurrentComponent::None;
1013
1014 for packet_result in PacketParser::new(openpgp_cert.to_bytes()?.reader()) {
1015 let packet = packet_result?;
1016
1017 match &packet {
1018 Packet::PublicKey(_) => {
1019 let file_path = path.join(format!("{pubkey_fingerprint}.asc"));
1020 current_component = CurrentComponent::Primary;
1021
1022 write_openpgp_packet_file_to_dir(file_path, packet.clone(), armor)?;
1023 }
1024 Packet::PublicSubkey(subkey) => {
1025 let fingerprint = subkey.fingerprint();
1026 let file_path = path
1027 .join(SUBKEY_DIR)
1028 .join(fingerprint.to_string())
1029 .join(format!("{fingerprint}.asc"));
1030 current_component = CurrentComponent::Subkey(fingerprint.clone());
1031
1032 write_openpgp_packet_file_to_dir(file_path, packet.clone(), armor)?;
1033 }
1034 Packet::UserId(user_id) => {
1035 let user_id_string = user_id
1036 .as_str()
1037 .ok_or("The User ID contains invalid UTF-8")?
1038 .to_string();
1039 let file_path = path
1040 .join(UID_DIR)
1041 .join(&user_id_string)
1042 .join(format!("{user_id_string}.asc"));
1043 current_component = CurrentComponent::UserId(user_id_string.clone());
1044
1045 write_openpgp_packet_file_to_dir(file_path, packet.clone(), armor)?;
1046 }
1047 Packet::Signature(signature) => {
1048 info!(
1049 "Found OpenPGP signature packet of type: {:?}",
1050 signature.typ()
1051 );
1052 // NOTE: There may be zero or more "Issuer Fingerprint" subpackets.
1053 //
1054 // - Older signatures may have no "Isser Fingerprint" subpacket at all, but
1055 // instead rely on "Issuer" subpackets. This test setup currently only deals
1056 // with the modern "Issuer Fingerprint" scenario.
1057 // - If one or more "Issuer Fingerprint" subpackets exist, we only use the first
1058 // for this test setup.
1059 let issuer_fingerprints: Vec<Fingerprint> = signature
1060 .issuer_fingerprint()
1061 .into_iter()
1062 .cloned()
1063 .collect();
1064 let issuer_fingerprint = issuer_fingerprints
1065 .as_slice()
1066 .first()
1067 .map(|f| f.to_string())
1068 .unwrap_or("empty".to_string());
1069
1070 match signature.typ() {
1071 Some(SignatureType::SubkeyBinding) => 'inner: {
1072 let CurrentComponent::Subkey(subkey_fingerprint) = ¤t_component
1073 else {
1074 warn!(
1075 "Skipping SubkeyBinding, because there is no subkey fingerprint"
1076 );
1077 break 'inner;
1078 };
1079
1080 let file_path = path
1081 .join(SUBKEY_DIR)
1082 .join(subkey_fingerprint.to_string())
1083 .join(CERTIFICATION_DIR)
1084 .join(format!("{issuer_fingerprint}.asc"));
1085
1086 write_openpgp_packet_file_to_dir(file_path, packet.clone(), armor)?;
1087 }
1088 Some(SignatureType::CertPositive)
1089 | Some(SignatureType::CertGeneric) => 'inner: {
1090 let CurrentComponent::UserId(user_id) = ¤t_component else {
1091 warn!("Skipping CertPositive, because there is no User ID");
1092 break 'inner;
1093 };
1094 let file_path = path
1095 .join(UID_DIR)
1096 .join(user_id)
1097 .join(CERTIFICATION_DIR)
1098 .join(format!("{issuer_fingerprint}.asc"));
1099
1100 write_openpgp_packet_file_to_dir(file_path, packet.clone(), armor)?;
1101 }
1102 Some(SignatureType::CertRevocation) => 'inner: {
1103 let CurrentComponent::UserId(user_id) = ¤t_component else {
1104 warn!("Skipping CertRevocation, because there is no User ID");
1105 break 'inner;
1106 };
1107 let file_path = path
1108 .join(UID_DIR)
1109 .join(user_id)
1110 .join(REVOCATION_DIR)
1111 .join(format!("{issuer_fingerprint}.asc"));
1112
1113 write_openpgp_packet_file_to_dir(file_path, packet.clone(), armor)?;
1114 }
1115 Some(SignatureType::Key) => {
1116 let created_at_date = signature
1117 .created()
1118 .map(|d| d.as_secs().to_string())
1119 .unwrap_or("empty".to_string());
1120 let file_path = path
1121 .join(DIRECTKEY_DIR)
1122 .join(CERTIFICATION_DIR)
1123 .join(issuer_fingerprint)
1124 .join(format!("{created_at_date}.asc"));
1125
1126 write_openpgp_packet_file_to_dir(file_path, packet.clone(), armor)?;
1127 }
1128 Some(SignatureType::KeyRevocation) => {
1129 let file_path = path
1130 .join(REVOCATION_DIR)
1131 .join(format!("{}.asc", issuer_fingerprint));
1132
1133 write_openpgp_packet_file_to_dir(file_path, packet.clone(), armor)?;
1134 }
1135 _ => {
1136 warn!("Not using unknown signature packet {signature:?}");
1137 }
1138 }
1139 }
1140 _ => {
1141 warn!("Not using unknown packet {packet:?}");
1142 }
1143 }
1144 }
1145 Ok(())
1146 }
1147
1148 /// Splits an OpenPGP certificate into separate OpenPGP packets.
1149 ///
1150 /// The packets are written to separate files in `path`.
1151 /// If `armor` is `true`, each packet is written ASCII-armored using [`BlockType::PublicKey`].
1152 fn split_openpgp_cert(
1153 openpgp_cert: &SignedPublicKey,
1154 path: impl AsRef<Path>,
1155 armor: bool,
1156 config: SplitConfig,
1157 ) -> TestResult {
1158 let path = path.as_ref();
1159
1160 match config {
1161 SplitConfig::Flat => split_openpgp_cert_flat(openpgp_cert, path, armor)?,
1162 SplitConfig::ArchLinuxKeyring => {
1163 split_openpgp_cert_arch_linux(openpgp_cert, path, armor)?
1164 }
1165 }
1166
1167 Ok(())
1168 }
1169
1170 /// Creates a baseline OpenPGP private key for further specialization.
1171 fn openpgp_private_key() -> TestResult<SignedSecretKey> {
1172 let mut signkey = SubkeyParamsBuilder::default();
1173 signkey
1174 .key_type(KeyType::Ed25519Legacy)
1175 .can_sign(true)
1176 .can_encrypt(EncryptionCaps::None)
1177 .can_authenticate(false);
1178 let mut key_params = SecretKeyParamsBuilder::default();
1179 key_params
1180 .key_type(KeyType::Ed25519Legacy)
1181 .can_certify(true)
1182 .can_sign(false)
1183 .can_encrypt(EncryptionCaps::None)
1184 .primary_user_id("John Doe <jdoe@example.org>".to_string())
1185 .subkeys(vec![signkey.build()?]);
1186
1187 let secret_key_params = key_params.build()?;
1188 Ok(secret_key_params.generate(thread_rng())?)
1189 }
1190
1191 /// Creates an OpenPGP certificate ([`SignedPublicKey`]).
1192 #[fixture]
1193 fn openpgp_cert() -> TestResult<SignedPublicKey> {
1194 let private = openpgp_private_key()?;
1195
1196 Ok(SignedPublicKey::from(private))
1197 }
1198
1199 /// Creates an OpenPGP certificate ([`SignedPublicKey`]) with a direct key signature.
1200 fn openpgp_cert_dks() -> TestResult<SignedPublicKey> {
1201 let mut private = openpgp_private_key()?;
1202
1203 // additionally produce a direct key signature with the certificate metadata
1204 let mut config =
1205 SignatureConfig::v4(SignatureType::Key, private.algorithm(), private.hash_alg());
1206
1207 let mut flags = KeyFlags::default();
1208 flags.set_certify(true);
1209
1210 let mut features = Features::default();
1211 features.set_seipd_v1(true);
1212
1213 config.hashed_subpackets = vec![
1214 Subpacket::regular(SubpacketData::SignatureCreationTime(Timestamp::now()))?,
1215 Subpacket::regular(SubpacketData::IssuerFingerprint(private.fingerprint()))?,
1216 Subpacket::regular(SubpacketData::KeyFlags(flags))?,
1217 Subpacket::regular(SubpacketData::Features(features))?,
1218 Subpacket::regular(SubpacketData::PreferredSymmetricAlgorithms(
1219 vec![
1220 SymmetricKeyAlgorithm::AES256,
1221 SymmetricKeyAlgorithm::AES192,
1222 SymmetricKeyAlgorithm::AES128,
1223 ]
1224 .into(),
1225 ))?,
1226 Subpacket::regular(SubpacketData::PreferredHashAlgorithms(
1227 vec![
1228 HashAlgorithm::Sha256,
1229 HashAlgorithm::Sha384,
1230 HashAlgorithm::Sha512,
1231 ]
1232 .into(),
1233 ))?,
1234 ];
1235
1236 let dks = config.sign_key(
1237 &private.primary_key,
1238 &Password::empty(),
1239 private.primary_key.public_key(),
1240 )?;
1241 private.details.direct_signatures.push(dks);
1242
1243 Ok(SignedPublicKey::from(private))
1244 }
1245
1246 /// Creates an OpenPGP certificate ([`SignedPublicKey`]) with a revocation signature.
1247 ///
1248 /// The resulting OpenPGP certificate is considered "revoked".
1249 fn openpgp_cert_revoked() -> TestResult<SignedPublicKey> {
1250 let mut private = openpgp_private_key()?;
1251
1252 // produce a revocation signature and add it to the top level key
1253 let mut config = SignatureConfig::v4(
1254 SignatureType::KeyRevocation,
1255 private.algorithm(),
1256 private.hash_alg(),
1257 );
1258
1259 let mut flags = KeyFlags::default();
1260 flags.set_certify(true);
1261
1262 let mut features = Features::default();
1263 features.set_seipd_v1(true);
1264
1265 config.hashed_subpackets = vec![
1266 Subpacket::regular(SubpacketData::SignatureCreationTime(Timestamp::now()))?,
1267 Subpacket::regular(SubpacketData::IssuerFingerprint(private.fingerprint()))?,
1268 Subpacket::regular(SubpacketData::RevocationReason(
1269 RevocationCode::KeyRetired,
1270 "Key has been replaced by 0x1234".into(),
1271 ))?,
1272 ];
1273
1274 let revocation = config.sign_key(
1275 &private.primary_key,
1276 &Password::empty(),
1277 private.primary_key.public_key(),
1278 )?;
1279 private.details.revocation_signatures.push(revocation);
1280
1281 let pubkey = SignedPublicKey::from(private);
1282 Ok(pubkey)
1283 }
1284
1285 /// Creates an OpenPGP certificate ([`SignedPublicKey`]) with a User ID revocation signature.
1286 ///
1287 /// The User ID of the resulting OpenPGP certificate is considered "revoked".
1288 fn openpgp_cert_revoked_primary_user() -> TestResult<SignedPublicKey> {
1289 let mut private = openpgp_private_key()?;
1290
1291 // produce a revocation signature and add it to the top level key
1292 let mut config = SignatureConfig::v4(
1293 SignatureType::CertRevocation,
1294 private.algorithm(),
1295 private.hash_alg(),
1296 );
1297
1298 let mut flags = KeyFlags::default();
1299 flags.set_certify(true);
1300
1301 let mut features = Features::default();
1302 features.set_seipd_v1(true);
1303
1304 config.hashed_subpackets = vec![
1305 Subpacket::regular(SubpacketData::SignatureCreationTime(Timestamp::now()))?,
1306 Subpacket::regular(SubpacketData::IssuerFingerprint(private.fingerprint()))?,
1307 Subpacket::regular(SubpacketData::RevocationReason(
1308 RevocationCode::CertUserIdInvalid,
1309 "This User ID is now invalid".into(),
1310 ))?,
1311 ];
1312
1313 let revocation = config.sign_certification(
1314 &private.primary_key,
1315 private.primary_key.public_key(),
1316 &Password::empty(),
1317 Tag::UserId,
1318 &private.details.users[0].id,
1319 )?;
1320 private.details.users[0].signatures.push(revocation);
1321
1322 let pubkey = SignedPublicKey::from(private);
1323 Ok(pubkey)
1324 }
1325
1326 /// Ensures that [`OpenPgpImport`] can be created from OpenPGP certificate.
1327 ///
1328 /// Further ensures that the export of the verifier with [`OpenPgpImport::write_to_hierarchy`]
1329 /// works.
1330 ///
1331 /// Provided OpenPGP packet files are created based on a [`TestConfig`].
1332 #[rstest]
1333 #[case::split_top_level_binary(TestConfig{armor: false, split: SplitConfig::Flat}, openpgp_cert())]
1334 #[case::split_top_level_armor(TestConfig{armor: true, split: SplitConfig::Flat}, openpgp_cert())]
1335 #[case::split_dirs_binary(TestConfig{armor: false, split: SplitConfig::ArchLinuxKeyring}, openpgp_cert())]
1336 #[case::split_dirs_armor(TestConfig{armor: true, split: SplitConfig::ArchLinuxKeyring}, openpgp_cert())]
1337 #[case::split_top_level_binary_dks(TestConfig{armor: false, split: SplitConfig::Flat}, openpgp_cert_dks())]
1338 #[case::split_top_level_armor_dks(TestConfig{armor: true, split: SplitConfig::Flat}, openpgp_cert_dks())]
1339 #[case::split_dirs_binary_dks(TestConfig{armor: false, split: SplitConfig::ArchLinuxKeyring}, openpgp_cert_dks())]
1340 #[case::split_dirs_armor_dks(TestConfig{armor: true, split: SplitConfig::ArchLinuxKeyring}, openpgp_cert_dks())]
1341 #[case::split_top_level_binary_revoked(TestConfig{armor: false, split: SplitConfig::Flat}, openpgp_cert_revoked())]
1342 #[case::split_top_level_armor_revoked(TestConfig{armor: true, split: SplitConfig::Flat}, openpgp_cert_revoked())]
1343 #[case::split_dirs_binary_revoked(TestConfig{armor: false, split: SplitConfig::ArchLinuxKeyring}, openpgp_cert_revoked())]
1344 #[case::split_dirs_armor_revoked(TestConfig{armor: true, split: SplitConfig::ArchLinuxKeyring}, openpgp_cert_revoked())]
1345 #[case::split_top_level_binary_revoked_primary_user(TestConfig{armor: false, split: SplitConfig::Flat}, openpgp_cert_revoked_primary_user())]
1346 #[case::split_top_level_armor_revoked_primary_user(TestConfig{armor: true, split: SplitConfig::Flat}, openpgp_cert_revoked_primary_user())]
1347 #[case::split_dirs_binary_revoked_primary_user(TestConfig{armor: false, split: SplitConfig::ArchLinuxKeyring}, openpgp_cert_revoked_primary_user())]
1348 #[case::split_dirs_armor_revoked_primary_user(TestConfig{armor: true, split: SplitConfig::ArchLinuxKeyring}, openpgp_cert_revoked_primary_user())]
1349 fn write_to_hierarchy_succeeds(
1350 #[case] config: TestConfig,
1351 #[case] cert: TestResult<SignedPublicKey>,
1352 ) -> TestResult {
1353 init_logger();
1354
1355 let input_pubkey = cert?;
1356
1357 let temp_dir = tempdir()?;
1358 let path = temp_dir.path().to_path_buf();
1359 split_openpgp_cert(&input_pubkey, &path, config.armor, config.split)?;
1360
1361 let import = load_from_dir(path.as_path())?;
1362
1363 let temp_dir = tempdir()?;
1364 let output_dir = temp_dir.path();
1365 import.write_to_hierarchy(
1366 output_dir,
1367 "os".parse()?,
1368 "purpose".parse()?,
1369 Some("context".parse()?),
1370 )?;
1371
1372 let output_file = output_dir
1373 .join("os")
1374 .join("purpose")
1375 .join("context")
1376 .join(import.technology().to_string())
1377 .join(import.file_name());
1378
1379 let (output_pubkey, _) = SignedPublicKey::from_armor_file(&output_file)?;
1380
1381 assert_eq!(input_pubkey, output_pubkey);
1382
1383 Ok(())
1384 }
1385
1386 /// Ensures that [`openpgp_import_from_destructured_dir`] fails on invalid flat structures.
1387 #[rstest]
1388 fn openpgp_import_from_destructured_dir_fails_on_invalid_flat_structure(
1389 openpgp_cert: TestResult<SignedPublicKey>,
1390 ) -> TestResult {
1391 init_logger();
1392
1393 let input_pubkey = openpgp_cert?;
1394
1395 let temp_dir = tempdir()?;
1396 let path = temp_dir.path().to_path_buf();
1397
1398 // Write both flat and Arch Linux keyring structures to the import directory.
1399 // This renders this structure an invalid flat structure.
1400 split_openpgp_cert(&input_pubkey, &path, true, SplitConfig::Flat)?;
1401 split_openpgp_cert(&input_pubkey, &path, true, SplitConfig::ArchLinuxKeyring)?;
1402
1403 match load_from_dir(path.as_path()) {
1404 Ok(verifier) => {
1405 panic!("Should have failed, but succeeded to create a verifier: {verifier:?}");
1406 }
1407 Err(error) => match error {
1408 crate::Error::Import(crate::import::error::Error::DestructuredImport(
1409 Error::InvalidFlatStructure { .. },
1410 )) => {}
1411 error => {
1412 panic!("Did not return the correct error, got: {error}");
1413 }
1414 },
1415 }
1416
1417 Ok(())
1418 }
1419
1420 /// Ensures that [`openpgp_import_from_destructured_dir`] fails on invalid Arch Linux keyring
1421 /// structures.
1422 #[rstest]
1423 fn openpgp_import_from_destructured_dir_fails_on_invalid_arch_linux_keyring_structure(
1424 openpgp_cert: TestResult<SignedPublicKey>,
1425 ) -> TestResult {
1426 init_logger();
1427
1428 let input_pubkey = openpgp_cert?;
1429
1430 let temp_dir = tempdir()?;
1431 let path = temp_dir.path().to_path_buf();
1432 split_openpgp_cert(&input_pubkey, &path, true, SplitConfig::ArchLinuxKeyring)?;
1433
1434 // Remove the only top-level file in the import directory.
1435 // This renders this structure an invalid Arch Linux keyring structure
1436 for dir_entry in path.read_dir()? {
1437 let dir_entry = dir_entry?;
1438 let path = dir_entry.path();
1439 if path.is_file() {
1440 remove_file(path)?;
1441 }
1442 }
1443
1444 match load_from_dir(path.as_path()) {
1445 Ok(verifier) => {
1446 panic!("Should have failed, but succeeded to create a verifier: {verifier:?}");
1447 }
1448 Err(error) => match error {
1449 crate::Error::Import(crate::import::error::Error::DestructuredImport(
1450 Error::InvalidArchLinuxKeyringStructure { .. },
1451 )) => {}
1452 error => {
1453 panic!("Did not return the correct error, got: {error}");
1454 }
1455 },
1456 }
1457
1458 Ok(())
1459 }
1460
1461 /// Ensures that [`OpenPgpImport::new`] fails if the input path is not a file or a directory.
1462 #[test]
1463 #[cfg(target_os = "linux")]
1464 fn openpgp_import_from_destructured_dir_fails_on_path_not_a_dir() -> TestResult {
1465 match load_from_dir("/dev/null") {
1466 Ok(verifier) => {
1467 panic!("Should have failed, but succeeded to create a verifier: {verifier:?}");
1468 }
1469 Err(error) => match error {
1470 crate::Error::Import(crate::import::error::Error::DestructuredImport(
1471 crate::import::destructured::error::Error::PathIsNotADir { .. },
1472 )) => {}
1473 error => {
1474 panic!("Did not return the correct error, got: {error}");
1475 }
1476 },
1477 }
1478 Ok(())
1479 }
1480}