Skip to main content

zoi_core/
pgp.rs

1use std::fs;
2use std::path::{Path, PathBuf};
3use std::process::Command;
4use std::time::SystemTime;
5
6use anyhow::{Result, anyhow};
7use chrono::{DateTime, Utc};
8use colored::Colorize;
9use sequoia_openpgp::Cert;
10use sequoia_openpgp::parse::Parse;
11use sequoia_openpgp::policy::StandardPolicy;
12use sequoia_openpgp::types::RevocationStatus;
13
14// Manages PGP keys and signature verification for the Zoi "Chain of Trust".
15//
16// Zoi uses PGP to verify:
17// - Registry Integrity: Every git commit in an official registry should be
18//   signed.
19// - Package Authenticity: Pre-built archives are verified against maintainer
20//   keys.
21//
22// This module handles local keyring management in Zoi's user data directory and
23// provides utilities for importing, searching, and verifying signatures.
24
25include!(concat!(env!("OUT_DIR"), "/generated_pgp_keys.rs"));
26
27/// Synchronizes the local keyring with trusted keys embedded in the Zoi binary.
28///
29/// During the build process, Zoi bakes in "Root of Trust" keys for official
30/// registries. This function ensures these keys are present and up-to-date
31/// in the user's local keyring on every startup.
32///
33/// # Errors
34///
35/// Returns an error if the local keyring directory cannot be created or
36/// accessed.
37pub fn ensure_builtin_keys() -> Result<()> {
38    for (name, bytes) in BUILTIN_KEYS {
39        if let Err(e) = add_key_from_bytes(bytes, name, true) {
40            eprintln!(
41                "Warning: Failed to ensure builtin PGP key '{name}': {e}"
42            );
43        }
44    }
45    Ok(())
46}
47
48/// Returns a human-readable string representing the status of a PGP
49/// certificate.
50///
51/// The status can be "Valid", "Revoked", "Expired", or "Invalid" with
52/// additional details like expiration dates where applicable.
53pub fn get_cert_status(cert: &Cert) -> String {
54    let policy = StandardPolicy::new();
55    let now = SystemTime::now();
56    match cert.with_policy(&policy, now) {
57        Ok(vc) => {
58            if let RevocationStatus::Revoked(_) = vc.revocation_status() {
59                return "Revoked".red().bold().to_string();
60            }
61            if let Some(expiration) = vc.primary_key().key_expiration_time() {
62                let datetime: DateTime<Utc> = DateTime::<Utc>::from(expiration);
63                if expiration < now {
64                    return format!(
65                        "Expired ({})",
66                        datetime.format("%Y-%m-%d")
67                    )
68                    .red()
69                    .to_string();
70                }
71                return format!(
72                    "Valid (expires {})",
73                    datetime.format("%Y-%m-%d")
74                )
75                .green()
76                .to_string();
77            }
78            "Valid (no expiration)".green().to_string()
79        }
80        Err(e) => format!("Invalid: {e}").red().to_string()
81    }
82}
83
84/// Validates a PGP certificate, checking for revocation and expiration.
85///
86/// Returns `Ok(())` if the certificate is valid, or an error if it is revoked,
87/// expired, or otherwise invalid under the standard policy.
88///
89/// # Errors
90///
91/// Returns an error if the certificate is revoked, expired, or invalid.
92pub fn validate_cert(cert: &Cert) -> Result<()> {
93    let policy = StandardPolicy::new();
94    let now = SystemTime::now();
95    match cert.with_policy(&policy, now) {
96        Ok(vc) => {
97            if let RevocationStatus::Revoked(_) = vc.revocation_status() {
98                return Err(anyhow!("The PGP key is revoked."));
99            }
100            if let Some(expiration) = vc.primary_key().key_expiration_time()
101                && expiration < now
102            {
103                let datetime: DateTime<Utc> = DateTime::<Utc>::from(expiration);
104                return Err(anyhow!(
105                    "The PGP key expired on {}.",
106                    datetime.format("%Y-%m-%d")
107                ));
108            }
109            Ok(())
110        }
111        Err(e) => Err(anyhow!("The PGP key is invalid: {e}"))
112    }
113}
114
115/// Returns the path to the local PGP keyring directory.
116///
117/// This resides in Zoi's user data directory under `pgps/`. The directory is
118/// created if it does not exist.
119///
120/// # Errors
121///
122/// Returns an error if the home directory cannot be found or the PGP directory
123/// cannot be created.
124pub fn get_pgp_dir() -> Result<PathBuf> {
125    let pgp_dir = crate::utils::get_user_data_dir()?.join("pgps");
126    fs::create_dir_all(&pgp_dir)?;
127    Ok(pgp_dir)
128}
129
130/// Adds a PGP key from a byte slice to the local keyring.
131///
132/// The key is saved as `<name>.asc` in the PGP directory. If a key with the
133/// same name exists but has different content, it will be overwritten (with a
134/// warning if `quiet` is false).
135///
136/// # Errors
137///
138/// Returns an error if the PGP directory cannot be accessed, or if the key is
139/// invalid.
140pub fn add_key_from_bytes(
141    key_bytes: &[u8],
142    name: &str,
143    quiet: bool
144) -> Result<()> {
145    let pgp_dir = get_pgp_dir()?;
146    let dest_path = pgp_dir.join(format!("{name}.asc"));
147
148    if dest_path.exists() {
149        let existing_bytes = fs::read(&dest_path)?;
150        if existing_bytes == key_bytes {
151            return Ok(());
152        }
153        if !quiet {
154            println!(
155                "{} A different key with the name '{name}' already exists. \
156                 Overwriting.",
157                "Warning:".yellow(),
158            );
159        }
160    }
161
162    let cert = Cert::from_bytes(key_bytes)?;
163    validate_cert(&cert)?;
164
165    fs::write(&dest_path, key_bytes)?;
166    if !quiet {
167        println!("Successfully added/updated key '{}'.", name.cyan());
168    }
169
170    Ok(())
171}
172
173/// Imports a PGP key from a file path into the local keyring.
174///
175/// If `name` is provided, it is used as the key's name in the store.
176/// Otherwise, the file stem of the path is used.
177///
178/// # Errors
179///
180/// Returns an error if the key file does not exist or cannot be read.
181pub fn add_key_from_path(
182    path: &str,
183    name: Option<&str>,
184    quiet: bool
185) -> Result<()> {
186    let key_path = Path::new(path);
187    if !key_path.exists() {
188        return Err(anyhow!("Key file not found at: {path}"));
189    }
190
191    let key_name = name.unwrap_or_else(|| {
192        key_path
193            .file_stem()
194            .and_then(|s| s.to_str())
195            .unwrap_or("unnamed")
196    });
197
198    if !quiet {
199        println!("Validating PGP key file...");
200    }
201    let key_bytes = fs::read(key_path)?;
202    if !quiet {
203        println!("{}", "Key is valid.".green());
204    }
205
206    add_key_from_bytes(&key_bytes, key_name, quiet)
207}
208
209/// Fetches a PGP key from a keyserver by fingerprint and adds it to the local
210/// keyring.
211///
212/// Currently uses `keys.openpgp.org` as the keyserver.
213///
214/// # Errors
215///
216/// Returns an error if the key cannot be fetched from the keyserver or is
217/// invalid.
218pub fn add_key_from_fingerprint(
219    fingerprint: &str,
220    name: &str,
221    quiet: bool
222) -> Result<()> {
223    let url = format!(
224        "https://keys.openpgp.org/vks/v1/by-fingerprint/{}",
225        fingerprint.to_uppercase()
226    );
227    if !quiet {
228        println!(
229            "Fetching key for fingerprint {} from keys.openpgp.org...",
230            fingerprint.cyan()
231        );
232    }
233
234    let client = crate::utils::get_http_client()?;
235    let response = client.get(&url).send()?;
236    if !response.status().is_success() {
237        return Err(anyhow!(
238            "Failed to fetch key from keyserver (HTTP {}).",
239            response.status()
240        ));
241    }
242
243    let key_bytes = response.bytes()?.to_vec();
244
245    if !quiet {
246        println!("Validating PGP key...");
247    }
248    Cert::from_bytes(&key_bytes)?;
249    if !quiet {
250        println!("{}", "Key is valid.".green());
251    }
252
253    add_key_from_bytes(&key_bytes, name, quiet)
254}
255
256/// Downloads a PGP key from a URL and adds it to the local keyring.
257///
258/// # Errors
259///
260/// Returns an error if the key cannot be fetched from the URL or is invalid.
261pub fn add_key_from_url(url: &str, name: &str, quiet: bool) -> Result<()> {
262    if !quiet {
263        println!(
264            "Fetching key for {} from url {}...",
265            name.cyan(),
266            url.cyan()
267        );
268    }
269
270    let client = crate::utils::get_http_client()?;
271    let response = client.get(url).send()?;
272    if !response.status().is_success() {
273        return Err(anyhow!(
274            "Failed to fetch key from url (HTTP {})",
275            response.status()
276        ));
277    }
278
279    let key_bytes = response.bytes()?.to_vec();
280
281    if !quiet {
282        println!("Validating PGP key...");
283    }
284    Cert::from_bytes(&key_bytes)?;
285    if !quiet {
286        println!("{}", "Key is valid.".green());
287    }
288
289    add_key_from_bytes(&key_bytes, name, quiet)
290}
291
292/// Removes a PGP key from the local keyring by its name.
293///
294/// # Errors
295///
296/// Returns an error if the key with the given name is not found or cannot be
297/// removed.
298pub fn remove_key_by_name(name: &str) -> Result<()> {
299    let pgp_dir = get_pgp_dir()?;
300    let key_path = pgp_dir.join(format!("{name}.asc"));
301
302    if !key_path.exists() {
303        return Err(anyhow!("Key with name '{name}' not found."));
304    }
305
306    fs::remove_file(&key_path)?;
307    println!("Successfully removed key '{}'.", name.cyan());
308
309    Ok(())
310}
311
312/// Searches for and removes a PGP key from the local keyring by its
313/// fingerprint.
314///
315/// # Errors
316///
317/// Returns an error if no key with the given fingerprint is found or cannot be
318/// removed.
319pub fn remove_key_by_fingerprint(fingerprint: &str) -> Result<()> {
320    let pgp_dir = get_pgp_dir()?;
321    let fingerprint_upper = fingerprint.to_uppercase();
322
323    for entry in fs::read_dir(pgp_dir)? {
324        let entry = entry?;
325        let path = entry.path();
326        if path.is_file()
327            && path.extension().and_then(|s| s.to_str()) == Some("asc")
328        {
329            let key_bytes = fs::read(&path)?;
330            if let Ok(cert) = Cert::from_bytes(&key_bytes)
331                && cert.fingerprint().to_string().to_uppercase()
332                    == fingerprint_upper
333            {
334                fs::remove_file(&path)?;
335                println!(
336                    "Successfully removed key with fingerprint {}.",
337                    fingerprint.cyan()
338                );
339                return Ok(());
340            }
341        }
342    }
343
344    Err(anyhow!("Key with fingerprint '{fingerprint}' not found."))
345}
346
347/// Prints a formatted list of all PGP keys stored in the local keyring.
348///
349/// # Errors
350///
351/// Returns an error if the local keyring cannot be read.
352pub fn list_keys() -> Result<()> {
353    let keys = get_all_local_keys_info()?;
354
355    if keys.is_empty() {
356        println!("No PGP keys found in the store.");
357        return Ok(());
358    }
359
360    println!("{} Stored PGP Keys", "::".bold().blue());
361
362    for key_info in keys {
363        println!();
364        println!("{}: {}", "Name".cyan(), key_info.name.bold());
365        println!("{}: {}", "  Status".cyan(), get_cert_status(&key_info.cert));
366        println!(
367            "  {}: {}",
368            "Fingerprint".cyan(),
369            key_info.cert.fingerprint()
370        );
371        for userid_amalgamation in key_info.cert.userids() {
372            let userid_packet = userid_amalgamation.userid();
373            let name = userid_packet
374                .name()
375                .ok()
376                .flatten()
377                .unwrap_or("[invalid name]");
378            let email =
379                userid_packet.email().ok().flatten().unwrap_or_default();
380
381            if email.is_empty() {
382                println!("  {}: {}", "UserID".cyan(), name);
383            } else {
384                println!("  {}: {} <{}>", "UserID".cyan(), name, email);
385            }
386        }
387    }
388
389    Ok(())
390}
391
392/// Searches for PGP keys in the local keyring by name, fingerprint, or `UserID`
393/// (name/email).
394///
395/// # Errors
396///
397/// Returns an error if the local keyring cannot be read.
398pub fn search_keys(term: &str) -> Result<()> {
399    let keys = get_all_local_keys_info()?;
400    let term_lower = term.to_lowercase();
401    let mut found_keys = Vec::new();
402
403    for key_info in keys {
404        let fingerprint =
405            key_info.cert.fingerprint().to_string().to_lowercase();
406        let name = key_info.name.to_lowercase();
407
408        let mut is_match =
409            name.contains(&term_lower) || fingerprint.contains(&term_lower);
410
411        if !is_match {
412            for userid_amalgamation in key_info.cert.userids() {
413                let userid_packet = userid_amalgamation.userid();
414                let uid_name = userid_packet
415                    .name()
416                    .ok()
417                    .flatten()
418                    .unwrap_or_default()
419                    .to_lowercase();
420                let uid_email = userid_packet
421                    .email()
422                    .ok()
423                    .flatten()
424                    .unwrap_or_default()
425                    .to_lowercase();
426
427                if uid_name.contains(&term_lower)
428                    || uid_email.contains(&term_lower)
429                {
430                    is_match = true;
431                    break;
432                }
433            }
434        }
435
436        if is_match {
437            found_keys.push(key_info);
438        }
439    }
440
441    if found_keys.is_empty() {
442        println!("\n{}", "No keys found matching your query.".yellow());
443        return Ok(());
444    }
445
446    println!(
447        "{} Found {} key(s) matching '{}'",
448        "::".bold().blue(),
449        found_keys.len(),
450        term.blue().bold()
451    );
452
453    for key_info in found_keys {
454        println!();
455        println!("{}: {}", "Name".cyan(), key_info.name.bold());
456        println!("{}: {}", "  Status".cyan(), get_cert_status(&key_info.cert));
457        println!(
458            "  {}: {}",
459            "Fingerprint".cyan(),
460            key_info.cert.fingerprint()
461        );
462        for userid_amalgamation in key_info.cert.userids() {
463            let userid_packet = userid_amalgamation.userid();
464            let name = userid_packet
465                .name()
466                .ok()
467                .flatten()
468                .unwrap_or("[invalid name]");
469            let email =
470                userid_packet.email().ok().flatten().unwrap_or_default();
471
472            if email.is_empty() {
473                println!("  {}: {}", "UserID".cyan(), name);
474            } else {
475                println!("  {}: {} <{}>", "UserID".cyan(), name, email);
476            }
477        }
478    }
479
480    Ok(())
481}
482
483/// Prints the ASCII-armored content of a PGP key from the local keyring.
484///
485/// # Errors
486///
487/// Returns an error if the key with the given name is not found or cannot be
488/// read.
489pub fn show_key(name: &str) -> Result<()> {
490    let pgp_dir = get_pgp_dir()?;
491    let key_path = pgp_dir.join(format!("{name}.asc"));
492
493    if !key_path.exists() {
494        return Err(anyhow!("Key with name '{name}' not found."));
495    }
496
497    let key_contents = fs::read_to_string(&key_path)?;
498    println!("{key_contents}");
499
500    Ok(())
501}
502
503/// A structure holding a PGP key's name and its parsed certificate.
504pub struct KeyInfo {
505    /// The name of the key (usually its filename without extension).
506    pub name: String,
507    /// The parsed PGP certificate.
508    pub cert: Cert
509}
510
511/// Retrieves information for all PGP keys stored in the local keyring.
512///
513/// # Errors
514///
515/// Returns an error if the local keyring cannot be read or contains invalid
516/// keys.
517pub fn get_all_local_keys_info() -> Result<Vec<KeyInfo>> {
518    let pgp_dir = get_pgp_dir()?;
519    let mut keys = Vec::new();
520    if !pgp_dir.exists() {
521        return Ok(keys);
522    }
523    for entry in fs::read_dir(pgp_dir)? {
524        let entry = entry?;
525        let path = entry.path();
526        if path.is_file()
527            && path.extension().and_then(|s| s.to_str()) == Some("asc")
528            && let Ok(bytes) = fs::read(&path)
529            && let Ok(cert) = Cert::from_bytes(&bytes)
530        {
531            let name = path
532                .file_stem()
533                .ok_or_else(|| {
534                    anyhow!("Path should have a file stem: {}", path.display())
535                })?
536                .to_string_lossy()
537                .to_string();
538            keys.push(KeyInfo { name, cert });
539        }
540    }
541    keys.sort_by(|a, b| a.name.cmp(&b.name));
542    Ok(keys)
543}
544
545/// Retrieves all PGP certificates stored in the local keyring.
546///
547/// # Errors
548///
549/// Returns an error if the local keyring cannot be read or contains invalid
550/// keys.
551pub fn get_all_local_certs() -> Result<Vec<Cert>> {
552    let pgp_dir = get_pgp_dir()?;
553    let mut certs = Vec::new();
554    if !pgp_dir.exists() {
555        return Ok(certs);
556    }
557    for entry in fs::read_dir(pgp_dir)? {
558        let entry = entry?;
559        let path = entry.path();
560        if path.is_file()
561            && path.extension().and_then(|s| s.to_str()) == Some("asc")
562            && let Ok(bytes) = fs::read(&path)
563            && let Ok(cert) = Cert::from_bytes(&bytes)
564        {
565            certs.push(cert);
566        }
567    }
568    Ok(certs)
569}
570
571use sequoia_openpgp::KeyHandle;
572use sequoia_openpgp::parse::stream::{
573    DetachedVerifierBuilder, MessageLayer, MessageStructure, VerificationHelper
574};
575
576/// A Sequoia verification helper that allows verification against multiple
577/// trusted certificates.
578struct MultiCertHelper {
579    /// The list of trusted certificates to use for verification.
580    certs: Vec<Cert>
581}
582
583impl VerificationHelper for MultiCertHelper {
584    fn get_certs(&mut self, _ids: &[KeyHandle]) -> anyhow::Result<Vec<Cert>> {
585        Ok(self.certs.clone())
586    }
587
588    fn check(&mut self, structure: MessageStructure) -> anyhow::Result<()> {
589        if let Some(layer) = structure.into_iter().next() {
590            match layer {
591                MessageLayer::SignatureGroup { results } => {
592                    if results.iter().any(Result::is_ok) {
593                        return Ok(());
594                    }
595                    return Err(anyhow!(
596                        "No valid signature found from any trusted key."
597                    ));
598                }
599                _ => {
600                    return Err(anyhow!(
601                        "Unexpected message structure: not a signature group."
602                    ));
603                }
604            }
605        }
606        Err(anyhow!(
607            "No signature layer found in the message structure."
608        ))
609    }
610}
611
612/// A Sequoia verification helper that allows verification against a single
613/// trusted certificate.
614struct OneCertHelper {
615    /// The trusted certificate to use for verification.
616    cert: Cert
617}
618
619impl VerificationHelper for OneCertHelper {
620    fn get_certs(&mut self, _ids: &[KeyHandle]) -> anyhow::Result<Vec<Cert>> {
621        Ok(vec![self.cert.clone()])
622    }
623
624    fn check(&mut self, structure: MessageStructure) -> anyhow::Result<()> {
625        if let Some(layer) = structure.into_iter().next() {
626            match layer {
627                MessageLayer::SignatureGroup { results } => {
628                    if results.iter().any(Result::is_ok) {
629                        return Ok(());
630                    }
631                    return Err(anyhow!("No valid signature found"));
632                }
633                _ => return Err(anyhow!("Unexpected message structure"))
634            }
635        }
636        Err(anyhow!("No signature layer found"))
637    }
638}
639
640/// A CLI-friendly wrapper for verifying a file's signature using a named key
641/// from the local keyring.
642///
643/// # Errors
644///
645/// Returns an error if the key is not found or the signature is invalid.
646pub fn cli_verify_signature(
647    file_path: &str,
648    sig_path: &str,
649    key_name: &str
650) -> Result<()> {
651    println!(
652        "Verifying {file_path} with signature {sig_path} using key \
653         '{key_name}'"
654    );
655
656    let pgp_dir = get_pgp_dir()?;
657    let key_path = pgp_dir.join(format!("{key_name}.asc"));
658    if !key_path.exists() {
659        return Err(anyhow!("Key '{key_name}' not found in local store."));
660    }
661    let key_bytes = fs::read(key_path)?;
662    let cert = Cert::from_bytes(&key_bytes)?;
663
664    verify_detached_signature(
665        Path::new(file_path),
666        Path::new(sig_path),
667        &cert
668    )?;
669
670    println!("{}", "Signature is valid.".green());
671    Ok(())
672}
673
674/// Verifies a detached PGP signature for a file using a specific certificate.
675///
676/// # Errors
677///
678/// Returns an error if the file cannot be read or the signature is invalid.
679pub fn verify_detached_signature(
680    data_path: &Path,
681    signature_path: &Path,
682    cert: &Cert
683) -> Result<()> {
684    let data = fs::read(data_path)?;
685    let signature = fs::read(signature_path)?;
686    verify_detached_signature_raw(&data, &signature, cert)
687}
688
689/// Verifies a detached PGP signature for raw data using a specific certificate.
690///
691/// # Errors
692///
693/// Returns an error if the signature is invalid.
694pub fn verify_detached_signature_raw(
695    data: &[u8],
696    signature: &[u8],
697    cert: &Cert
698) -> Result<()> {
699    let policy = &StandardPolicy::new();
700    let helper = OneCertHelper { cert: cert.clone() };
701
702    let mut verifier = DetachedVerifierBuilder::from_bytes(signature)?
703        .with_policy(policy, None, helper)?;
704
705    verifier.verify_bytes(data)?;
706
707    Ok(())
708}
709
710/// Signs a file using `GnuPG`.
711///
712/// This function calls the external `gpg` command to create a detached
713/// signature. It can take an optional `GPG_PASSWORD` environment variable for
714/// password-protected keys.
715///
716/// # Errors
717///
718/// Returns an error if `gpg` is not installed, the file cannot be read, or
719/// signing fails.
720pub fn sign_detached(
721    data_path: &Path,
722    signature_path: &Path,
723    key_id: &str
724) -> Result<()> {
725    if !crate::utils::command_exists("gpg") {
726        return Err(anyhow!(
727            "gpg command not found. Please install GnuPG and ensure it's in \
728             your PATH."
729        ));
730    }
731
732    let data_path_str = data_path
733        .to_str()
734        .ok_or_else(|| anyhow!("Invalid data path for signing."))?;
735    let signature_path_str = signature_path
736        .to_str()
737        .ok_or_else(|| anyhow!("Invalid signature path for signing."))?;
738
739    let mut command = Command::new("gpg");
740    command
741        .arg("--batch")
742        .arg("--no-tty")
743        .arg("--yes")
744        .arg("--detach-sign");
745
746    if let Ok(password) = std::env::var("GPG_PASSWORD") {
747        command
748            .arg("--pinentry-mode")
749            .arg("loopback")
750            .arg("--passphrase")
751            .arg(password);
752    }
753
754    command
755        .arg("--local-user")
756        .arg(key_id)
757        .arg("--output")
758        .arg(signature_path_str)
759        .arg(data_path_str);
760
761    let output = command.output()?;
762
763    if !output.status.success() {
764        use std::fmt::Write;
765        let stderr = String::from_utf8_lossy(&output.stderr);
766        let mut error_message =
767            format!("gpg signing failed with status: {}.\n", output.status);
768        if stderr.contains("No secret key") {
769            let _ = writeln!(
770                error_message,
771                "The secret key for '{key_id}' was not found in your GPG \
772                 keychain."
773            );
774            error_message.push_str(
775                "Please ensure the key is imported into GPG and is trusted."
776            );
777        } else if stderr.contains("bad passphrase")
778            || stderr.contains("Passphrase check failed")
779        {
780            error_message.push_str(
781                "Incorrect passphrase provided, or the agent could not get \
782                 the passphrase.\n"
783            );
784            error_message.push_str(
785                "Ensure your GPG agent is running and configured correctly if \
786                 the key is password-protected."
787            );
788        } else {
789            let _ = write!(error_message, "Stderr: {stderr}");
790        }
791
792        return Err(anyhow!(error_message));
793    }
794
795    Ok(())
796}
797
798/// Resolves a list of names or fingerprints to PGP certificates from the local
799/// keyring.
800///
801/// # Errors
802///
803/// Returns an error if any of the trusted keys are not found in the local
804/// keyring.
805pub fn get_certs_by_name_or_fingerprint(
806    identifiers: &[String]
807) -> Result<Vec<Cert>> {
808    let all_keys = get_all_local_keys_info()?;
809    let mut found_certs = Vec::new();
810
811    for identifier in identifiers {
812        let identifier_lower = identifier.to_lowercase();
813        let mut found = false;
814        for key_info in &all_keys {
815            let fingerprint_lower =
816                key_info.cert.fingerprint().to_string().to_lowercase();
817            if key_info.name == *identifier
818                || fingerprint_lower.starts_with(&identifier_lower)
819            {
820                found_certs.push(key_info.cert.clone());
821                found = true;
822                break;
823            }
824        }
825        if !found {
826            return Err(anyhow!(
827                "Trusted key '{identifier}' not found in Zoi's PGP keyring."
828            ));
829        }
830    }
831    Ok(found_certs)
832}
833
834/// Verifies a detached PGP signature for a file against a set of trusted
835/// certificates.
836///
837/// Returns `Ok(())` if at least one trusted certificate successfully verifies
838/// the signature.
839///
840/// # Errors
841///
842/// Returns an error if the file cannot be read or no valid signature is found.
843pub fn verify_detached_signature_multi_key(
844    data_path: &Path,
845    signature_path: &Path,
846    trusted_certs: Vec<Cert>
847) -> Result<()> {
848    let policy = &StandardPolicy::new();
849    let data = fs::read(data_path)?;
850    let signature = fs::read(signature_path)?;
851
852    let helper = MultiCertHelper {
853        certs: trusted_certs
854    };
855
856    let mut verifier = DetachedVerifierBuilder::from_bytes(&signature)?
857        .with_policy(policy, None, helper)?;
858
859    verifier.verify_bytes(&data)?;
860
861    Ok(())
862}