//! Mandatory OpenPGP verification of Core update artifacts (port of `openPgpVerifier.js`). //! //! * the bundled public key must have the pinned primary fingerprint (trust anchor); //! * the primary key must be valid (bindings verify, not revoked, not expired); //! * the detached signature must be made by a signing-capable subkey bound to that primary //! key (valid, unrevoked, unexpired binding) — never by the primary key itself; //! * the artifact is hashed while streaming (artifacts up to 2 GiB). use crate::core::error::{CoreError, CoreResult}; use pgp::composed::{Deserializable, DetachedSignature, SignedPublicKey}; use pgp::packet::{Signature, SignatureType}; use pgp::types::KeyDetails; use std::path::Path; use std::io::Read; use tokio_util::sync::CancellationToken; use std::time::{Duration, SystemTime}; pub const TRUSTED_PRIMARY_FINGERPRINT: &str = "0D92778AD8ECF85C80E3924848F2433AD9CDF453"; const MAX_PUBLIC_KEY_BYTES: u64 = 128 * 1024; const MAX_SIGNATURE_BYTES: u64 = 128 * 1024; fn error(code: &str, message: &str) -> CoreError { CoreError::new(code, message) } fn regular_file(path: &Path, label: &str, max: Option) -> CoreResult<()> { let meta = std::fs::symlink_metadata(path).map_err(|_| error("UPDATE_SIGNATURE_FILE_MISSING", &format!("{label} is missing.")))?; if meta.file_type().is_symlink() || !meta.is_file() { return Err(error("UPDATE_SIGNATURE_UNSAFE_FILE", &format!("{label} must be a regular file."))); } if meta.len() == 0 { return Err(error("UPDATE_SIGNATURE_EMPTY", &format!("{label} is empty."))); } if max.map(|m| meta.len() > m).unwrap_or(false) { return Err(error("UPDATE_SIGNATURE_TOO_LARGE", &format!("{label} is too large."))); } Ok(()) } pub fn normalize_fingerprint(value: &str) -> String { value.chars().filter(|c| c.is_ascii_hexdigit()).collect::().to_uppercase() } fn fingerprint_hex(fp: &pgp::types::Fingerprint) -> String { normalize_fingerprint(&format!("{fp:X}")) } fn system_time(ts: pgp::types::Timestamp) -> SystemTime { ts.into() } /// Signature created and not expired at `now`. fn signature_current(sig: &Signature, now: SystemTime) -> bool { let Some(created) = sig.created().map(system_time) else { return false }; if created > now + Duration::from_secs(300) { return false; } match sig.signature_expiration_time() { Some(exp) if exp.as_secs() > 0 => created + std::time::Duration::from(exp) > now, _ => true, } } /// Key not expired at `now` according to the given (binding/self) signature. fn key_unexpired(key_created: SystemTime, sig: &Signature, now: SystemTime) -> bool { match sig.key_expiration_time() { Some(exp) if exp.as_secs() > 0 => key_created + std::time::Duration::from(exp) > now, _ => true, } } #[derive(Debug, Clone, serde::Serialize)] #[serde(rename_all = "camelCase")] pub struct Verification { pub valid: bool, pub primary_fingerprint: String, pub signing_fingerprints: Vec, pub signature_created_at: Option, } pub struct OpenPgpVerifier { pub public_key_path: std::path::PathBuf, pub trusted_primary: String, } /// Check cancellation before every artifact read, including buffered refills. struct CancellableReader { reader: R, cancel: CancellationToken, } impl Read for CancellableReader { fn read(&mut self, buffer: &mut [u8]) -> std::io::Result { if self.cancel.is_cancelled() { return Err(std::io::Error::new(std::io::ErrorKind::TimedOut, "Verification cancelled")); } self.reader.read(buffer) } } impl OpenPgpVerifier { pub fn new(public_key_path: std::path::PathBuf) -> Self { Self { public_key_path, trusted_primary: TRUSTED_PRIMARY_FINGERPRINT.to_string() } } fn load_key(&self, now: SystemTime) -> CoreResult { regular_file(&self.public_key_path, "Bundled OpenPGP public key", Some(MAX_PUBLIC_KEY_BYTES))?; let file = std::fs::File::open(&self.public_key_path).map_err(|_| error("UPDATE_PUBLIC_KEY_INVALID", "Bundled OpenPGP public key is malformed."))?; let (key, _) = SignedPublicKey::from_armor_single(std::io::BufReader::new(file)).map_err(|_| error("UPDATE_PUBLIC_KEY_INVALID", "Bundled OpenPGP public key is malformed."))?; let primary = fingerprint_hex(&key.primary_key.fingerprint()); if primary != self.trusted_primary { return Err(error("UPDATE_PUBLIC_KEY_FINGERPRINT_MISMATCH", "Bundled OpenPGP primary fingerprint does not match the trust anchor.")); } let invalid = || error("UPDATE_PUBLIC_KEY_INVALID", "Bundled OpenPGP primary key is revoked, expired, or invalid."); key.verify_bindings().map_err(|_| invalid())?; // Any valid key revocation signature revokes the primary key. for revocation in &key.details.revocation_signatures { if revocation.verify_key(&key.primary_key).is_ok() { return Err(invalid()); } } // Bindings were verified above; expiry comes from the newest current self-certification. let created = system_time(key.primary_key.created_at()); let newest = key .details .users .iter() .flat_map(|user| user.signatures.iter()) .filter(|sig| signature_current(sig, now)) .max_by_key(|sig| sig.created().map(system_time)); match newest { Some(sig) if key_unexpired(created, sig, now) => Ok(key), _ => Err(invalid()), } } pub fn verify_file(&self, artifact: &Path, signature: &Path) -> CoreResult { self.verify_file_cancellable(artifact, signature, CancellationToken::new()) } pub fn verify_file_cancellable(&self, artifact: &Path, signature: &Path, cancel: CancellationToken) -> CoreResult { regular_file(artifact, "Update artifact", None)?; regular_file(signature, "Detached OpenPGP signature", Some(MAX_SIGNATURE_BYTES))?; let now = SystemTime::now(); let key = self.load_key(now)?; let primary_fp = fingerprint_hex(&key.primary_key.fingerprint()); let sig_file = std::fs::File::open(signature).map_err(|_| error("UPDATE_SIGNATURE_MALFORMED", "Detached OpenPGP signature is malformed."))?; let (detached, _) = DetachedSignature::from_armor_single(std::io::BufReader::new(sig_file)).map_err(|_| error("UPDATE_SIGNATURE_MALFORMED", "Detached OpenPGP signature is malformed."))?; let sig = &detached.signature; let issuer_ids: Vec = sig.issuer_key_id().iter().map(|id| hex::encode_upper(id.as_ref())).collect(); let issuer_fps: Vec = sig.issuer_fingerprint().iter().map(|fp| fingerprint_hex(fp)).collect(); let untrusted = || error("UPDATE_SIGNATURE_UNTRUSTED_SIGNER", "The signature was not made by a valid signing subkey of the trusted primary key."); let subkey = key .public_subkeys .iter() .find(|sub| { let fp = fingerprint_hex(&sub.key.fingerprint()); let id = hex::encode_upper(sub.key.legacy_key_id().as_ref()); issuer_fps.contains(&fp) || issuer_ids.iter().any(|i| i.eq_ignore_ascii_case(&id)) }) .ok_or_else(|| { let primary_id = hex::encode_upper(key.primary_key.legacy_key_id().as_ref()); if issuer_fps.contains(&primary_fp) || issuer_ids.iter().any(|i| i.eq_ignore_ascii_case(&primary_id)) { error("UPDATE_SIGNATURE_NOT_SIGNING_SUBKEY", "Updates must be signed by a valid signing subkey of the trusted primary key.") } else { untrusted() } })?; let subkey_invalid = || error("UPDATE_SIGNING_SUBKEY_INVALID", "The signing subkey is revoked, expired, or has an invalid binding."); let mut binding: Option<&Signature> = None; for candidate in &subkey.signatures { if candidate.verify_subkey_binding(&key.primary_key, &subkey.key).is_err() { continue; } match candidate.typ() { Some(SignatureType::SubkeyRevocation) => return Err(subkey_invalid()), Some(SignatureType::SubkeyBinding) if signature_current(candidate, now) => { if binding.map(|b| b.created().map(system_time) < candidate.created().map(system_time)).unwrap_or(true) { binding = Some(candidate); } } _ => {} } } let binding = binding.ok_or_else(subkey_invalid)?; if !key_unexpired(system_time(subkey.key.created_at()), binding, now) { return Err(subkey_invalid()); } if !binding.key_flags().sign() { return Err(error("UPDATE_SIGNING_SUBKEY_CAPABILITY_INVALID", "The OpenPGP subkey does not have signing capability.")); } if !signature_current(sig, now) { return Err(error("UPDATE_SIGNATURE_INVALID", "Detached OpenPGP signature verification failed.")); } let data = std::fs::File::open(artifact).map_err(|_| error("UPDATE_SIGNATURE_FILE_MISSING", "Update artifact is missing."))?; sig.verify(&subkey.key, CancellableReader { reader: std::io::BufReader::with_capacity(1 << 20, data), cancel }) .map_err(|_| error("UPDATE_SIGNATURE_INVALID", "Detached OpenPGP signature is not cryptographically valid."))?; let created = sig.created().map(|t| chrono::DateTime::::from(system_time(t)).to_rfc3339_opts(chrono::SecondsFormat::Millis, true)); Ok(Verification { valid: true, primary_fingerprint: primary_fp, signing_fingerprints: vec![fingerprint_hex(&subkey.key.fingerprint())], signature_created_at: created }) } } #[cfg(test)] mod tests { use super::*; use std::path::PathBuf; fn verifier() -> OpenPgpVerifier { OpenPgpVerifier::new(Path::new(env!("CARGO_MANIFEST_DIR")).join("resources/signing/ch-j-signing-public.asc")) } fn fixture(name: &str) -> PathBuf { Path::new(env!("CARGO_MANIFEST_DIR")).join("tests/fixtures/openpgp").join(name) } #[test] fn cancelled_reader_stops_before_more_io() { let cancel = CancellationToken::new(); let mut reader = CancellableReader { reader: std::io::Cursor::new(vec![1; 32]), cancel: cancel.clone() }; assert_eq!(reader.read(&mut [0; 4]).unwrap(), 4); cancel.cancel(); assert_eq!(reader.read(&mut [0; 4]).unwrap_err().kind(), std::io::ErrorKind::TimedOut); assert_eq!(reader.reader.position(), 4); } #[test] fn verifies_real_release_signature() { let result = verifier().verify_file(&fixture("SHA512SUMS"), &fixture("SHA512SUMS.asc")).unwrap(); assert!(result.valid); assert_eq!(result.primary_fingerprint, TRUSTED_PRIMARY_FINGERPRINT); assert_eq!(result.signing_fingerprints, vec!["F3EA1875CBCF63C89B2ED89744028841DE4EC7E4".to_string()]); } #[test] fn rejects_tampered_artifact_and_wrong_anchor() { let dir = tempfile::tempdir().unwrap(); let tampered = dir.path().join("SHA512SUMS"); let mut content = std::fs::read(fixture("SHA512SUMS")).unwrap(); content[0] ^= 1; std::fs::write(&tampered, content).unwrap(); assert_eq!(verifier().verify_file(&tampered, &fixture("SHA512SUMS.asc")).unwrap_err().code, "UPDATE_SIGNATURE_INVALID"); let mut wrong = verifier(); wrong.trusted_primary = "0".repeat(40); assert_eq!(wrong.verify_file(&fixture("SHA512SUMS"), &fixture("SHA512SUMS.asc")).unwrap_err().code, "UPDATE_PUBLIC_KEY_FINGERPRINT_MISMATCH"); let garbage = dir.path().join("sig.asc"); std::fs::write(&garbage, "-----BEGIN PGP SIGNATURE-----\n\nAAAA\n-----END PGP SIGNATURE-----\n").unwrap(); assert_eq!(verifier().verify_file(&fixture("SHA512SUMS"), &garbage).unwrap_err().code, "UPDATE_SIGNATURE_MALFORMED"); } }