Skip to main content

zoi_core/
pgp.rs

1use anyhow::{Result, anyhow};
2use chrono::{DateTime, Utc};
3use colored::*;
4use sequoia_openpgp::Cert;
5use sequoia_openpgp::parse::Parse;
6use sequoia_openpgp::policy::StandardPolicy;
7use sequoia_openpgp::types::RevocationStatus;
8use std::fs;
9use std::path::{Path, PathBuf};
10use std::process::Command;
11use std::time::SystemTime;
12
13// Manages PGP keys and signature verification for the Zoi "Chain of Trust".
14//
15// Zoi uses PGP to verify:
16// - Registry Integrity: Every git commit in an official registry should be signed.
17// - Package Authenticity: Pre-built archives are verified against maintainer keys.
18//
19// This module handles local keyring management (`~/.zoi/pgps/`) and provides
20// utilities for importing, searching, and verifying signatures.
21
22include!(concat!(env!("OUT_DIR"), "/generated_pgp_keys.rs"));
23
24/// Synchronizes the local keyring with trusted keys embedded in the Zoi binary.
25///
26/// During the build process, Zoi bakes in "Root of Trust" keys for official
27/// registries. This function ensures these keys are present and up-to-date
28/// in the user's local keyring on every startup.
29pub fn ensure_builtin_keys() -> Result<()> {
30    for (name, bytes) in BUILTIN_KEYS {
31        if let Err(e) = add_key_from_bytes(bytes, name, true) {
32            eprintln!(
33                "Warning: Failed to ensure builtin PGP key '{}': {}",
34                name, e
35            );
36        }
37    }
38    Ok(())
39}
40
41pub fn get_cert_status(cert: &Cert) -> String {
42    let policy = StandardPolicy::new();
43    let now = SystemTime::now();
44    match cert.with_policy(&policy, now) {
45        Ok(vc) => {
46            if let RevocationStatus::Revoked(_) = vc.revocation_status() {
47                return "Revoked".red().bold().to_string();
48            }
49            if let Some(expiration) = vc.primary_key().key_expiration_time() {
50                let datetime: DateTime<Utc> = DateTime::<Utc>::from(expiration);
51                if expiration < now {
52                    return format!("Expired ({})", datetime.format("%Y-%m-%d"))
53                        .red()
54                        .to_string();
55                } else {
56                    return format!("Valid (expires {})", datetime.format("%Y-%m-%d"))
57                        .green()
58                        .to_string();
59                }
60            }
61            "Valid (no expiration)".green().to_string()
62        }
63        Err(e) => format!("Invalid: {}", e).red().to_string(),
64    }
65}
66
67pub fn validate_cert(cert: &Cert) -> Result<()> {
68    let policy = StandardPolicy::new();
69    let now = SystemTime::now();
70    match cert.with_policy(&policy, now) {
71        Ok(vc) => {
72            if let RevocationStatus::Revoked(_) = vc.revocation_status() {
73                return Err(anyhow!("The PGP key is revoked."));
74            }
75            if let Some(expiration) = vc.primary_key().key_expiration_time()
76                && expiration < now
77            {
78                let datetime: DateTime<Utc> = DateTime::<Utc>::from(expiration);
79                return Err(anyhow!(
80                    "The PGP key expired on {}.",
81                    datetime.format("%Y-%m-%d")
82                ));
83            }
84            Ok(())
85        }
86        Err(e) => Err(anyhow!("The PGP key is invalid: {}", e)),
87    }
88}
89
90pub fn get_pgp_dir() -> Result<PathBuf> {
91    let home_dir =
92        crate::utils::get_user_home().ok_or_else(|| anyhow!("Could not find home directory."))?;
93    let pgp_dir = home_dir.join(".zoi").join("pgps");
94    fs::create_dir_all(&pgp_dir)?;
95    Ok(pgp_dir)
96}
97
98pub fn add_key_from_bytes(key_bytes: &[u8], name: &str, quiet: bool) -> Result<()> {
99    let pgp_dir = get_pgp_dir()?;
100    let dest_path = pgp_dir.join(format!("{}.asc", name));
101
102    if dest_path.exists() {
103        let existing_bytes = fs::read(&dest_path)?;
104        if existing_bytes == key_bytes {
105            return Ok(());
106        }
107        if !quiet {
108            println!(
109                "{} A different key with the name '{}' already exists. Overwriting.",
110                "Warning:".yellow(),
111                name
112            );
113        }
114    }
115
116    let cert = Cert::from_bytes(key_bytes)?;
117    validate_cert(&cert)?;
118
119    fs::write(&dest_path, key_bytes)?;
120    if !quiet {
121        println!("Successfully added/updated key '{}'.", name.cyan());
122    }
123
124    Ok(())
125}
126
127pub fn add_key_from_path(path: &str, name: Option<&str>, quiet: bool) -> Result<()> {
128    let key_path = Path::new(path);
129    if !key_path.exists() {
130        return Err(anyhow!("Key file not found at: {}", path));
131    }
132
133    let key_name = name.unwrap_or_else(|| {
134        key_path
135            .file_stem()
136            .and_then(|s| s.to_str())
137            .unwrap_or("unnamed")
138    });
139
140    if !quiet {
141        println!("Validating PGP key file...");
142    }
143    let key_bytes = fs::read(key_path)?;
144    if !quiet {
145        println!("{}", "Key is valid.".green());
146    }
147
148    add_key_from_bytes(&key_bytes, key_name, quiet)
149}
150
151pub fn add_key_from_fingerprint(fingerprint: &str, name: &str, quiet: bool) -> Result<()> {
152    let url = format!(
153        "https://keys.openpgp.org/vks/v1/by-fingerprint/{}",
154        fingerprint.to_uppercase()
155    );
156    if !quiet {
157        println!(
158            "Fetching key for fingerprint {} from keys.openpgp.org...",
159            fingerprint.cyan()
160        );
161    }
162
163    let client = crate::utils::get_http_client()?;
164    let response = client.get(&url).send()?;
165    if !response.status().is_success() {
166        return Err(anyhow!(
167            "Failed to fetch key from keyserver (HTTP {}).",
168            response.status()
169        ));
170    }
171
172    let key_bytes = response.bytes()?.to_vec();
173
174    if !quiet {
175        println!("Validating PGP key...");
176    }
177    Cert::from_bytes(&key_bytes)?;
178    if !quiet {
179        println!("{}", "Key is valid.".green());
180    }
181
182    add_key_from_bytes(&key_bytes, name, quiet)
183}
184
185pub fn add_key_from_url(url: &str, name: &str, quiet: bool) -> Result<()> {
186    if !quiet {
187        println!(
188            "Fetching key for {} from url {}...",
189            name.cyan(),
190            url.cyan()
191        );
192    }
193
194    let client = crate::utils::get_http_client()?;
195    let response = client.get(url).send()?;
196    if !response.status().is_success() {
197        return Err(anyhow!(
198            "Failed to fetch key from url (HTTP {})",
199            response.status()
200        ));
201    }
202
203    let key_bytes = response.bytes()?.to_vec();
204
205    if !quiet {
206        println!("Validating PGP key...");
207    }
208    Cert::from_bytes(&key_bytes)?;
209    if !quiet {
210        println!("{}", "Key is valid.".green());
211    }
212
213    add_key_from_bytes(&key_bytes, name, quiet)
214}
215
216pub fn remove_key_by_name(name: &str) -> Result<()> {
217    let pgp_dir = get_pgp_dir()?;
218    let key_path = pgp_dir.join(format!("{}.asc", name));
219
220    if !key_path.exists() {
221        return Err(anyhow!("Key with name '{}' not found.", name));
222    }
223
224    fs::remove_file(&key_path)?;
225    println!("Successfully removed key '{}'.", name.cyan());
226
227    Ok(())
228}
229
230pub fn remove_key_by_fingerprint(fingerprint: &str) -> Result<()> {
231    let pgp_dir = get_pgp_dir()?;
232    let fingerprint_upper = fingerprint.to_uppercase();
233
234    for entry in fs::read_dir(pgp_dir)? {
235        let entry = entry?;
236        let path = entry.path();
237        if path.is_file() && path.extension().and_then(|s| s.to_str()) == Some("asc") {
238            let key_bytes = fs::read(&path)?;
239            if let Ok(cert) = Cert::from_bytes(&key_bytes)
240                && cert.fingerprint().to_string().to_uppercase() == fingerprint_upper
241            {
242                fs::remove_file(&path)?;
243                println!(
244                    "Successfully removed key with fingerprint {}.",
245                    fingerprint.cyan()
246                );
247                return Ok(());
248            }
249        }
250    }
251
252    Err(anyhow!("Key with fingerprint '{}' not found.", fingerprint))
253}
254
255pub fn list_keys() -> Result<()> {
256    let keys = get_all_local_keys_info()?;
257
258    if keys.is_empty() {
259        println!("No PGP keys found in the store.");
260        return Ok(());
261    }
262
263    println!("{} Stored PGP Keys", "::".bold().blue());
264
265    for key_info in keys {
266        println!();
267        println!("{}: {}", "Name".cyan(), key_info.name.bold());
268        println!("{}: {}", "  Status".cyan(), get_cert_status(&key_info.cert));
269        println!(
270            "  {}: {}",
271            "Fingerprint".cyan(),
272            key_info.cert.fingerprint()
273        );
274        for userid_amalgamation in key_info.cert.userids() {
275            let userid_packet = userid_amalgamation.userid();
276            let name = userid_packet
277                .name()
278                .ok()
279                .flatten()
280                .unwrap_or("[invalid name]");
281            let email = userid_packet.email().ok().flatten().unwrap_or_default();
282
283            if !email.is_empty() {
284                println!("  {}: {} <{}>", "UserID".cyan(), name, email);
285            } else {
286                println!("  {}: {}", "UserID".cyan(), name);
287            }
288        }
289    }
290
291    Ok(())
292}
293
294pub fn search_keys(term: &str) -> Result<()> {
295    let keys = get_all_local_keys_info()?;
296    let term_lower = term.to_lowercase();
297    let mut found_keys = Vec::new();
298
299    for key_info in keys {
300        let fingerprint = key_info.cert.fingerprint().to_string().to_lowercase();
301        let name = key_info.name.to_lowercase();
302
303        let mut is_match = name.contains(&term_lower) || fingerprint.contains(&term_lower);
304
305        if !is_match {
306            for userid_amalgamation in key_info.cert.userids() {
307                let userid_packet = userid_amalgamation.userid();
308                let uid_name = userid_packet
309                    .name()
310                    .ok()
311                    .flatten()
312                    .unwrap_or_default()
313                    .to_lowercase();
314                let uid_email = userid_packet
315                    .email()
316                    .ok()
317                    .flatten()
318                    .unwrap_or_default()
319                    .to_lowercase();
320
321                if uid_name.contains(&term_lower) || uid_email.contains(&term_lower) {
322                    is_match = true;
323                    break;
324                }
325            }
326        }
327
328        if is_match {
329            found_keys.push(key_info);
330        }
331    }
332
333    if found_keys.is_empty() {
334        println!("\n{}", "No keys found matching your query.".yellow());
335        return Ok(());
336    }
337
338    println!(
339        "{} Found {} key(s) matching '{}'",
340        "::".bold().blue(),
341        found_keys.len(),
342        term.blue().bold()
343    );
344
345    for key_info in found_keys {
346        println!();
347        println!("{}: {}", "Name".cyan(), key_info.name.bold());
348        println!("{}: {}", "  Status".cyan(), get_cert_status(&key_info.cert));
349        println!(
350            "  {}: {}",
351            "Fingerprint".cyan(),
352            key_info.cert.fingerprint()
353        );
354        for userid_amalgamation in key_info.cert.userids() {
355            let userid_packet = userid_amalgamation.userid();
356            let name = userid_packet
357                .name()
358                .ok()
359                .flatten()
360                .unwrap_or("[invalid name]");
361            let email = userid_packet.email().ok().flatten().unwrap_or_default();
362
363            if !email.is_empty() {
364                println!("  {}: {} <{}>", "UserID".cyan(), name, email);
365            } else {
366                println!("  {}: {}", "UserID".cyan(), name);
367            }
368        }
369    }
370
371    Ok(())
372}
373
374pub fn show_key(name: &str) -> Result<()> {
375    let pgp_dir = get_pgp_dir()?;
376    let key_path = pgp_dir.join(format!("{}.asc", name));
377
378    if !key_path.exists() {
379        return Err(anyhow!("Key with name '{}' not found.", name));
380    }
381
382    let key_contents = fs::read_to_string(&key_path)?;
383    println!("{}", key_contents);
384
385    Ok(())
386}
387
388pub struct KeyInfo {
389    pub name: String,
390    pub cert: Cert,
391}
392
393pub fn get_all_local_keys_info() -> Result<Vec<KeyInfo>> {
394    let pgp_dir = get_pgp_dir()?;
395    let mut keys = Vec::new();
396    if !pgp_dir.exists() {
397        return Ok(keys);
398    }
399    for entry in fs::read_dir(pgp_dir)? {
400        let entry = entry?;
401        let path = entry.path();
402        if path.is_file()
403            && path.extension().and_then(|s| s.to_str()) == Some("asc")
404            && let Ok(bytes) = fs::read(&path)
405            && let Ok(cert) = Cert::from_bytes(&bytes)
406        {
407            let name = path
408                .file_stem()
409                .ok_or_else(|| anyhow!("Path should have a file stem: {:?}", path))?
410                .to_string_lossy()
411                .to_string();
412            keys.push(KeyInfo { name, cert });
413        }
414    }
415    keys.sort_by(|a, b| a.name.cmp(&b.name));
416    Ok(keys)
417}
418
419pub fn get_all_local_certs() -> Result<Vec<Cert>> {
420    let pgp_dir = get_pgp_dir()?;
421    let mut certs = Vec::new();
422    if !pgp_dir.exists() {
423        return Ok(certs);
424    }
425    for entry in fs::read_dir(pgp_dir)? {
426        let entry = entry?;
427        let path = entry.path();
428        if path.is_file()
429            && path.extension().and_then(|s| s.to_str()) == Some("asc")
430            && let Ok(bytes) = fs::read(&path)
431            && let Ok(cert) = Cert::from_bytes(&bytes)
432        {
433            certs.push(cert);
434        }
435    }
436    Ok(certs)
437}
438
439use sequoia_openpgp::{
440    KeyHandle,
441    parse::stream::{DetachedVerifierBuilder, MessageLayer, MessageStructure, VerificationHelper},
442};
443
444struct MultiCertHelper {
445    certs: Vec<Cert>,
446}
447
448impl VerificationHelper for MultiCertHelper {
449    fn get_certs(&mut self, _ids: &[KeyHandle]) -> anyhow::Result<Vec<Cert>> {
450        Ok(self.certs.clone())
451    }
452
453    fn check(&mut self, structure: MessageStructure) -> anyhow::Result<()> {
454        if let Some(layer) = structure.into_iter().next() {
455            match layer {
456                MessageLayer::SignatureGroup { results } => {
457                    if results.iter().any(|r| r.is_ok()) {
458                        return Ok(());
459                    } else {
460                        return Err(anyhow!("No valid signature found from any trusted key."));
461                    }
462                }
463                _ => {
464                    return Err(anyhow!(
465                        "Unexpected message structure: not a signature group."
466                    ));
467                }
468            }
469        }
470        Err(anyhow!(
471            "No signature layer found in the message structure."
472        ))
473    }
474}
475
476struct OneCertHelper {
477    cert: Cert,
478}
479
480impl VerificationHelper for OneCertHelper {
481    fn get_certs(&mut self, _ids: &[KeyHandle]) -> anyhow::Result<Vec<Cert>> {
482        Ok(vec![self.cert.clone()])
483    }
484
485    fn check(&mut self, structure: MessageStructure) -> anyhow::Result<()> {
486        if let Some(layer) = structure.into_iter().next() {
487            match layer {
488                MessageLayer::SignatureGroup { results } => {
489                    if results.iter().any(|r| r.is_ok()) {
490                        return Ok(());
491                    } else {
492                        return Err(anyhow!("No valid signature found"));
493                    }
494                }
495                _ => return Err(anyhow!("Unexpected message structure")),
496            }
497        }
498        Err(anyhow!("No signature layer found"))
499    }
500}
501
502pub fn cli_verify_signature(file_path: &str, sig_path: &str, key_name: &str) -> Result<()> {
503    println!(
504        "Verifying {} with signature {} using key '{}'",
505        file_path, sig_path, key_name
506    );
507
508    let pgp_dir = get_pgp_dir()?;
509    let key_path = pgp_dir.join(format!("{}.asc", key_name));
510    if !key_path.exists() {
511        return Err(anyhow!("Key '{}' not found in local store.", key_name));
512    }
513    let key_bytes = fs::read(key_path)?;
514    let cert = Cert::from_bytes(&key_bytes)?;
515
516    verify_detached_signature(Path::new(file_path), Path::new(sig_path), &cert)?;
517
518    println!("{}", "Signature is valid.".green());
519    Ok(())
520}
521
522pub fn verify_detached_signature(
523    data_path: &Path,
524    signature_path: &Path,
525    cert: &Cert,
526) -> Result<()> {
527    let data = fs::read(data_path)?;
528    let signature = fs::read(signature_path)?;
529    verify_detached_signature_raw(&data, &signature, cert)
530}
531
532pub fn verify_detached_signature_raw(data: &[u8], signature: &[u8], cert: &Cert) -> Result<()> {
533    let policy = &StandardPolicy::new();
534    let helper = OneCertHelper { cert: cert.clone() };
535
536    let mut verifier =
537        DetachedVerifierBuilder::from_bytes(signature)?.with_policy(policy, None, helper)?;
538
539    verifier.verify_bytes(data)?;
540
541    Ok(())
542}
543
544pub fn sign_detached(data_path: &Path, signature_path: &Path, key_id: &str) -> Result<()> {
545    if !crate::utils::command_exists("gpg") {
546        return Err(anyhow!(
547            "gpg command not found. Please install GnuPG and ensure it's in your PATH."
548        ));
549    }
550
551    let data_path_str = data_path
552        .to_str()
553        .ok_or_else(|| anyhow!("Invalid data path for signing."))?;
554    let signature_path_str = signature_path
555        .to_str()
556        .ok_or_else(|| anyhow!("Invalid signature path for signing."))?;
557
558    let mut command = Command::new("gpg");
559    command
560        .arg("--batch")
561        .arg("--no-tty")
562        .arg("--yes")
563        .arg("--detach-sign");
564
565    if let Ok(password) = std::env::var("GPG_PASSWORD") {
566        command
567            .arg("--pinentry-mode")
568            .arg("loopback")
569            .arg("--passphrase")
570            .arg(password);
571    }
572
573    command
574        .arg("--local-user")
575        .arg(key_id)
576        .arg("--output")
577        .arg(signature_path_str)
578        .arg(data_path_str);
579
580    let output = command.output()?;
581
582    if !output.status.success() {
583        let stderr = String::from_utf8_lossy(&output.stderr);
584        let mut error_message = format!("gpg signing failed with status: {}.\n", output.status);
585
586        if stderr.contains("No secret key") {
587            error_message.push_str(&format!(
588                "The secret key for '{}' was not found in your GPG keychain.\n",
589                key_id
590            ));
591            error_message.push_str("Please ensure the key is imported into GPG and is trusted.");
592        } else if stderr.contains("bad passphrase") || stderr.contains("Passphrase check failed") {
593            error_message.push_str(
594                "Incorrect passphrase provided, or the agent could not get the passphrase.\n",
595            );
596            error_message.push_str("Ensure your GPG agent is running and configured correctly if the key is password-protected.");
597        } else {
598            error_message.push_str(&format!("Stderr: {}", stderr));
599        }
600
601        return Err(anyhow!(error_message));
602    }
603
604    Ok(())
605}
606
607pub fn get_certs_by_name_or_fingerprint(identifiers: &[String]) -> Result<Vec<Cert>> {
608    let all_keys = get_all_local_keys_info()?;
609    let mut found_certs = Vec::new();
610
611    for identifier in identifiers {
612        let identifier_lower = identifier.to_lowercase();
613        let mut found = false;
614        for key_info in &all_keys {
615            let fingerprint_lower = key_info.cert.fingerprint().to_string().to_lowercase();
616            if key_info.name == *identifier || fingerprint_lower.starts_with(&identifier_lower) {
617                found_certs.push(key_info.cert.clone());
618                found = true;
619                break;
620            }
621        }
622        if !found {
623            return Err(anyhow!(
624                "Trusted key '{}' not found in Zoi's PGP keyring.",
625                identifier
626            ));
627        }
628    }
629    Ok(found_certs)
630}
631
632pub fn verify_detached_signature_multi_key(
633    data_path: &Path,
634    signature_path: &Path,
635    trusted_certs: Vec<Cert>,
636) -> Result<()> {
637    let policy = &StandardPolicy::new();
638    let data = fs::read(data_path)?;
639    let signature = fs::read(signature_path)?;
640
641    let helper = MultiCertHelper {
642        certs: trusted_certs,
643    };
644
645    let mut verifier =
646        DetachedVerifierBuilder::from_bytes(&signature)?.with_policy(policy, None, helper)?;
647
648    verifier.verify_bytes(&data)?;
649
650    Ok(())
651}