//! Release-file signature and checksum verification for APT repositories. //! //! `pkh pull` fetches the `Sources` index and the source artifacts of a //! package from archive URLs. Historically only the artifacts were //! checksum-verified, using hashes taken from the `Sources` index itself, //! which was downloaded with no authentication at all: a man-in-the-middle //! could substitute both the index (with matching checksums) and the //! tarballs. //! //! This module closes that hole with the same two-layer scheme apt uses: //! //! 1. *Authenticity*: fetch the clearsigned `InRelease` file of a suite //! (falling back to `Release` plus detached `Release.gpg`) and verify //! its OpenPGP signature with `gpgv` against the archive keyrings. //! `gpgv` is used (via [`std::process::Command`]) rather than `gpgme` //! because it is unattended by design, is the exact verification tool //! apt itself runs, and never touches the user's default keyring; a //! missing binary is reported as a distinguishable error (see //! [`GpgvStatus::GpgvMissing`]) so callers can tell it apart from a //! signature failure. //! 2. *Integrity*: parse the deb822 `SHA512`/`SHA256`/`SHA1`/`MD5Sum` //! checksum fields of the (verified) Release body, then check every //! downloaded index file against them before parsing. //! //! Behavioral policy: //! //! - Distro archives and PPAs are verified in `strict` mode: an existing //! but invalid, unsigned, or otherwise unverifiable signature is a hard //! error, and so is a missing `gpgv` binary. A suite with *no* Release //! file at all is not tampering (apt probes pockets the same way), and a //! suite whose keyring cannot be downloaded (e.g. the release key of a //! future Debian series does not exist yet) cannot be authenticated but //! gives no evidence of attack: both yield a non-tampering outcome that //! callers treat as 'skip this suite', never 'trust the index'. //! - Flat third-party repositories (`--repository`) have no known signing //! key: callers pass no keyring and `strict = false`. Checksum integrity //! is still enforced whenever a Release file exists (a mismatched index //! is a hard error, since the artifact hashes would come from it), but //! the missing authenticity only produces a `warn!`. //! //! Only actual tampering evidence — an invalid signature, or an index whose //! hash does not match the signed Release — is reported as [`VerifyError`]. //! //! ASCII-armored keyrings (Debian distributes `.asc` keyrings, and //! keyserver responses are armored) are dearmored in-process, so no `gpg` //! binary is needed. use lazy_static::lazy_static; use log::{debug, warn}; use serde::Deserialize; use std::collections::HashMap; use std::error::Error; use std::fmt; use std::path::Path; use std::path::PathBuf; use std::sync::Mutex; use std::sync::PoisonError; use crate::debian::control::{Paragraph, parse_paragraphs}; /// `gpgv` binary used for signature verification (as apt itself does) const GPGV_BIN: &str = "gpgv"; /// A Release-verification failure. /// /// Verification errors are wrapped in this type so that callers can /// distinguish 'the repository metadata could not be authenticated' (which /// must abort a multi-series search) from 'the package was not found there' /// (which must not), via [`Error::downcast_ref`]. #[derive(Debug)] pub struct VerifyError( /// Description of the verification failure pub String, ); impl fmt::Display for VerifyError { fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { write!(f, "Release verification failed: {}", self.0) } } impl Error for VerifyError {} /// Checksum algorithm listed in a Release file #[derive(Debug, Clone, Copy, PartialEq, Eq)] pub enum ChecksumKind { /// MD5, from the legacy 'MD5Sum' field Md5, /// SHA-1, from the 'SHA1' field Sha1, /// SHA-256, from the 'SHA256' field Sha256, /// SHA-512, from the 'SHA512' field Sha512, } impl ChecksumKind { /// Map a Release checksum field name to its algorithm fn from_field(field: &str) -> Option { match field { "MD5Sum" => Some(ChecksumKind::Md5), "SHA1" => Some(ChecksumKind::Sha1), "SHA256" => Some(ChecksumKind::Sha256), "SHA512" => Some(ChecksumKind::Sha512), _ => None, } } /// Compute the hex-encoded digest of `data` with this algorithm pub fn digest(self, data: &[u8]) -> String { match self { ChecksumKind::Md5 => { use md5::{Digest, Md5}; let mut hasher = Md5::new(); hasher.update(data); hex::encode(hasher.finalize()) } ChecksumKind::Sha1 => { use sha1::{Digest, Sha1}; let mut hasher = Sha1::new(); hasher.update(data); hex::encode(hasher.finalize()) } ChecksumKind::Sha256 => { use sha2::{Digest, Sha256}; let mut hasher = Sha256::new(); hasher.update(data); hex::encode(hasher.finalize()) } ChecksumKind::Sha512 => { use sha2::{Digest, Sha512}; let mut hasher = Sha512::new(); hasher.update(data); hex::encode(hasher.finalize()) } } } /// Human-readable algorithm name, for error messages pub fn name(self) -> &'static str { match self { ChecksumKind::Md5 => "MD5", ChecksumKind::Sha1 => "SHA-1", ChecksumKind::Sha256 => "SHA-256", ChecksumKind::Sha512 => "SHA-512", } } } /// A single `hash size path` entry of a Release checksum field #[derive(Debug, Clone)] pub struct ChecksumEntry { /// Algorithm used for [`ChecksumEntry::hash`] pub kind: ChecksumKind, /// Expected hex-encoded digest of the file pub hash: String, /// Expected size of the file, in bytes pub size: u64, } /// A Release (or clearsigned InRelease) file fetched from a repository /// suite, carrying its signature status and the parsed per-file checksums. /// /// Index files fetched from the same suite can then be checksum-verified /// with [`VerifiedRelease::verify_file`] before being parsed. #[derive(Debug, Clone)] pub struct VerifiedRelease { /// URL of the suite directory this Release file came from suite_url: String, /// True when an InRelease/Release file was found and parsed available: bool, /// True when the signature of the Release file verified successfully authenticated: bool, /// Top-level fields of the Release file (Codename, Components, ...) fields: Paragraph, /// Checksums keyed by path, relative to the suite directory checksums: HashMap, } impl VerifiedRelease { /// A placeholder for a suite that publishes no Release file: nothing /// can be verified against it (only used by tests) #[cfg(test)] fn unavailable(suite_url: &str) -> VerifiedRelease { VerifiedRelease { suite_url: suite_url.to_string(), available: false, authenticated: false, fields: Paragraph::new(), checksums: HashMap::new(), } } /// True when an InRelease/Release file was found and parsed pub fn is_available(&self) -> bool { self.available } /// True when the signature of the Release file was verified against the /// provided keyrings pub fn is_authenticated(&self) -> bool { self.authenticated } /// Value of a top-level Release field (e.g. 'Codename', 'Components') pub fn field(&self, name: &str) -> Option<&str> { self.fields.get(name) } /// Components listed in the 'Components' field of the Release file pub fn components(&self) -> Vec { self.field("Components") .map(|value| value.split_whitespace().map(str::to_string).collect()) .unwrap_or_default() } /// Checksum listed for a file, by path relative to the suite directory /// (e.g. 'main/source/Sources.gz') pub fn hash_for(&self, rel_path: &str) -> Option<&ChecksumEntry> { self.checksums.get(rel_path) } /// Checksum-verify the content of a file listed in the Release file. /// /// `rel_path` is relative to the suite directory, matching the paths /// used by the Release checksum fields (e.g. 'main/source/Sources.gz' /// or, for flat repositories, 'Sources.xz'). Both the digest and the /// size are checked; a file not listed in the Release file is refused, /// as it is not covered by the repository signature. pub fn verify_file(&self, rel_path: &str, data: &[u8]) -> Result<(), String> { if !self.available { return Err(format!( "no Release file was found for '{}': cannot checksum-verify '{rel_path}'", self.suite_url )); } let entry = self.checksums.get(rel_path).ok_or_else(|| { format!( "'{rel_path}' is not listed in the Release file of '{}'; refusing \ to use an index that is not covered by the repository metadata", self.suite_url ) })?; let digest = entry.kind.digest(data); if !digest.eq_ignore_ascii_case(&entry.hash) { return Err(format!( "checksum mismatch for '{rel_path}' of '{}': the Release file \ expects {} {} but the downloaded file hashes to {}; the index \ does not match the signed repository metadata", self.suite_url, entry.kind.name(), entry.hash, digest )); } if entry.size > 0 && entry.size != data.len() as u64 { return Err(format!( "size mismatch for '{rel_path}' of '{}': expected {} bytes, got {}", self.suite_url, entry.size, data.len() )); } Ok(()) } } /// Parse a Release file body (the signed payload of an InRelease, or a /// plain Release file) into a [`VerifiedRelease`] fn parse_release_body(suite_url: &str, body: &str, authenticated: bool) -> VerifiedRelease { let fields = parse_paragraphs(body) .into_iter() .find(|paragraph| !paragraph.is_empty()) .unwrap_or_default(); // Iterate by decreasing algorithm strength: the first entry seen for a // path therefore comes from the strongest available algorithm let mut checksums: HashMap = HashMap::new(); for field in ["SHA512", "SHA256", "SHA1", "MD5Sum"] { let Some(kind) = ChecksumKind::from_field(field) else { continue; }; let Some(value) = fields.get(field) else { continue; }; // Each continuation line is 'hash size path' for line in value.lines() { let parts: Vec<&str> = line.split_whitespace().collect(); if parts.len() < 3 { continue; } let Ok(size) = parts[1].parse::() else { continue; }; checksums .entry(parts[2].to_string()) .or_insert(ChecksumEntry { kind, hash: parts[0].to_string(), size, }); } } VerifiedRelease { suite_url: suite_url.to_string(), available: true, authenticated, fields, checksums, } } /// Extract the signed body of a clearsigned OpenPGP message (the /// 'InRelease' format), i.e. the lines between the armor header block and /// the '-----BEGIN PGP SIGNATURE-----' marker, with dash-escaped lines /// unescaped. Returns None when `text` is not a clearsigned message. pub fn split_clearsigned(text: &str) -> Option { let mut found_start = false; let mut in_headers = false; let mut body: Vec<&str> = Vec::new(); for line in text.lines() { if !found_start { if line.starts_with("-----BEGIN PGP SIGNED MESSAGE-----") { found_start = true; // Armor header lines (e.g. 'Hash: SHA512') run up to the // first empty line in_headers = true; } continue; } if in_headers { if line.is_empty() { in_headers = false; } continue; } if line.starts_with("-----BEGIN PGP SIGNATURE-----") { break; } // Dash-escaped lines ('- - foo' is really '-foo') are unescaped body.push(line.strip_prefix("- ").unwrap_or(line)); } if !found_start { return None; } Some(body.join("\n")) } /// Outcome of a `gpgv` signature verification #[derive(Debug)] enum GpgvStatus { /// The signature verified successfully Good, /// The signature is invalid, or gpgv failed to run; carries the stderr BadSignature(String), /// The gpgv binary could not be found at all GpgvMissing(String), } /// Run `gpgv` on a signature: either a clearsigned document (`signed` is /// None and `signature` holds the whole clearsigned file), or a detached /// signature (`signed` is the signed payload). `program` is parameterized /// for testing (see the 'gpgv missing' test). /// /// `homedir` must be an empty scratch directory: gpgv 2.4 dropped support /// for `--no-default-keyrings`, so isolating the homedir (which holds the /// default 'trustedkeys.gpg') is what confines gpgv to the keyrings we /// pass. Keyring paths must be absolute: gpgv resolves relative ones /// against its homedir. /// /// Like apt, a Release file is accepted when AT LEAST ONE of its /// signatures verifies against the provided keyrings: archives sign their /// metadata with several keys (e.g. Debian's '-proposed-updates' suites are /// co-signed by the current and the next release's automatic key), and the /// keyring of a series does not necessarily contain all of them. When /// gpgv exits nonzero but at least one '[GNUPG:] VALIDSIG' status line /// proves a good signature from one of our keys, the verification succeeds. fn run_gpgv( program: &str, homedir: &Path, keyrings: &[PathBuf], signature: &Path, signed: Option<&Path>, ) -> GpgvStatus { let mut command = std::process::Command::new(program); command.arg("--status-fd").arg("1"); command.arg("--homedir").arg(homedir); for keyring in keyrings { command.arg("--keyring").arg(keyring); } command.arg(signature); if let Some(signed) = signed { command.arg(signed); } match command.output() { Ok(output) if output.status.success() => GpgvStatus::Good, Ok(output) => { let stderr = String::from_utf8_lossy(&output.stderr); let stdout = String::from_utf8_lossy(&output.stdout); let valid_signatures = stdout .lines() .filter(|line| line.starts_with("[GNUPG:] VALIDSIG ")) .count(); if valid_signatures > 0 { debug!( "{valid_signatures} signature(s) verified with the provided \ keyrings, others were skipped: {}", stderr.trim() ); GpgvStatus::Good } else { GpgvStatus::BadSignature(format!( "gpgv exited with {}: {}", output.status, stderr.trim() )) } } Err(e) if e.kind() == std::io::ErrorKind::NotFound => { GpgvStatus::GpgvMissing(format!("'{program}' not found in PATH ({e})")) } Err(e) => GpgvStatus::BadSignature(format!("could not run '{program}': {e}")), } } /// Scratch directory for gpgv inputs, removed on drop struct TempVerifyDir(PathBuf); impl TempVerifyDir { /// Create a unique 0700 directory under the system temp dir fn create() -> Result { use std::os::unix::fs::PermissionsExt; use std::time::{SystemTime, UNIX_EPOCH}; let base = std::env::temp_dir(); let nanos = SystemTime::now() .duration_since(UNIX_EPOCH) .map(|d| d.subsec_nanos()) .unwrap_or(0); for attempt in 0..64u32 { let dir = base.join(format!( "pkh-release-verify-{}-{}-{}", std::process::id(), nanos, attempt )); // create_dir (not _all) fails if the path already exists, which // also protects against symlink attacks on the predictable name match std::fs::create_dir(&dir) { Ok(()) => { let mut permissions = std::fs::symlink_metadata(&dir)?.permissions(); permissions.set_mode(0o700); std::fs::set_permissions(&dir, permissions)?; return Ok(TempVerifyDir(dir)); } Err(e) if e.kind() == std::io::ErrorKind::AlreadyExists => continue, Err(e) => return Err(e), } } Err(std::io::Error::new( std::io::ErrorKind::AlreadyExists, "could not create a temporary directory for gpgv", )) } /// Write `data` to a file named `name` inside the scratch directory fn write(&self, name: &str, data: &[u8]) -> Result { let path = self.0.join(name); std::fs::write(&path, data)?; Ok(path) } } impl Drop for TempVerifyDir { fn drop(&mut self) { let _ = std::fs::remove_dir_all(&self.0); } } /// Verify the signature of a Release file with gpgv. /// /// Exactly one of `clearsigned` (whole InRelease armor) or /// (`release`, `sig`) (detached Release.gpg over Release) must be given. fn verify_with_gpgv( keyrings: &[Vec], clearsigned: Option<&[u8]>, release: Option<&[u8]>, sig: Option<&[u8]>, ) -> Result { let scratch = TempVerifyDir::create() .map_err(|e| format!("could not create a scratch directory for gpgv: {e}"))?; let mut keyring_files = Vec::with_capacity(keyrings.len()); for (index, keyring) in keyrings.iter().enumerate() { let binary = dearmor(keyring) .map_err(|e| format!("invalid keyring #{index} for signature verification: {e}"))?; keyring_files.push( scratch .write(&format!("keyring-{index}.gpg"), &binary) .map_err(|e| format!("could not stage the keyring for gpgv: {e}"))?, ); } if let Some(clearsigned) = clearsigned { let path = scratch .write("InRelease", clearsigned) .map_err(|e| format!("could not stage the InRelease file for gpgv: {e}"))?; return Ok(run_gpgv(GPGV_BIN, &scratch.0, &keyring_files, &path, None)); } if let (Some(release), Some(sig)) = (release, sig) { let release_path = scratch .write("Release", release) .map_err(|e| format!("could not stage the Release file for gpgv: {e}"))?; let sig_path = scratch .write("Release.gpg", sig) .map_err(|e| format!("could not stage the Release.gpg file for gpgv: {e}"))?; return Ok(run_gpgv( GPGV_BIN, &scratch.0, &keyring_files, &sig_path, Some(&release_path), )); } Ok(GpgvStatus::BadSignature( "no signature file was found".to_string(), )) } /// Where the signing keys of a suite come from. /// /// Keyrings are resolved lazily: only after a Release file was actually /// found on the suite (a suite that does not exist — e.g. a pocket probed /// speculatively by a multi-series search — does not need any key). pub enum KeyringSource { /// No keyring is known (flat third-party repositories) None, /// Raw keyring bytes (binary or ASCII-armored), already at hand Bytes(Vec>), /// The archive keyring URLs of a distribution series, resolved through /// distro_info and downloaded over TLS Distro { /// The distribution series (e.g. 'noble', 'sid') series: String, }, /// The signing key of a Launchpad PPA, fetched from the Launchpad API /// and keyserver.ubuntu.com (both over TLS) Ppa { /// Base URL of the PPA (e.g. 'https://ppa.launchpadcontent.net/u/p/ubuntu') base_url: String, }, } impl KeyringSource { /// Resolve this source into raw keyring bytes; empty means 'no keyring /// available', which makes an existing Release unverifiable async fn resolve(self, suite_url: &str) -> Result>, String> { match self { KeyringSource::None => Ok(Vec::new()), KeyringSource::Bytes(keyrings) => Ok(keyrings), KeyringSource::Distro { series } => { let urls = crate::distro_info::get_keyring_urls(&series) .await .map_err(|e| { format!( "cannot resolve the archive keyring needed to \ verify '{suite_url}': {e}" ) })?; fetch_keyrings(&urls).await.map_err(|e| { format!( "cannot download the archive keyring needed to \ verify '{suite_url}': {e}" ) }) } KeyringSource::Ppa { base_url } => { let keyring = ppa_keyring_bytes(&base_url).await.map_err(|e| { format!( "cannot obtain the signing key of the PPA at \ '{base_url}' to verify its Release file: {e}" ) })?; Ok(vec![keyring]) } } } } /// Outcome of [`verify_suite`] #[derive(Debug)] pub enum Verification { /// A Release file was found and processed: its signature was checked /// (see [`VerifiedRelease::is_authenticated`]) and its checksums parsed Available(VerifiedRelease), /// The suite publishes no Release file at all (or is unreachable): /// nothing was verified, and nothing was trusted either. This is not an /// error, as suites are often probed speculatively (pockets, series). Unavailable { /// URL of the suite directory suite_url: String, }, /// A Release file exists but its signing keys could not be obtained /// (e.g. the keyring download failed): the suite must not be trusted, /// but this is not evidence of tampering KeyringUnavailable { /// URL of the suite directory suite_url: String, /// Description of the keyring failure reason: String, }, } /// Fetch and verify the Release metadata of a repository suite. /// /// `suite_url` is the directory holding the Release files: the /// 'dists/' directory of an archive or PPA (e.g. /// 'https://archive.ubuntu.com/ubuntu/dists/noble'), or the root of a flat /// repository. `keyrings` tells where the signing keys of the suite come /// from (see [`KeyringSource`]); they are only resolved when a Release file /// actually exists. In `strict` mode a present-but-unverifiable or invalid /// signature is a hard error (see the module docs for the full policy); /// otherwise only a `warn!` is emitted and the checksums of an existing /// Release file are still returned. /// /// Network failures and missing files are not tampering: a suite without /// any Release file (or whose keyring cannot be downloaded) yields /// [`Verification::Unavailable`] / [`Verification::KeyringUnavailable`], /// which callers treat as 'skip this suite' rather than 'abort everything'. /// Only actual tampering evidence (invalid signature, or a mismatched index /// found via [`VerifiedRelease::verify_file`]) is reported as /// [`VerifyError`]. pub async fn verify_suite( suite_url: &str, keyrings: KeyringSource, strict: bool, ) -> Result { let suite_url = suite_url.trim_end_matches('/'); // 1. Fetch InRelease, falling back to Release + detached Release.gpg let inrelease = fetch_optional(&format!("{suite_url}/InRelease")).await; let (release, release_gpg) = if inrelease.is_some() { (None, None) } else { let release = fetch_optional(&format!("{suite_url}/Release")).await; if release.is_some() { let gpg = fetch_optional(&format!("{suite_url}/Release.gpg")).await; (release, gpg) } else { (None, None) } }; // 2. No Release file at all: the suite simply does not exist (or is // unreachable). Nothing can be verified, but nothing was trusted // either, so this is not treated as tampering. let (Some(metadata), clearsigned, sig) = ( inrelease.as_deref().or(release.as_deref()), inrelease.as_deref(), release_gpg.as_deref(), ) else { warn!( "No InRelease or Release file found at '{suite_url}': the suite \ does not exist (or is unreachable), so its index cannot be \ authenticated" ); return Ok(Verification::Unavailable { suite_url: suite_url.to_string(), }); }; // 3. The suite exists: resolve its signing keys. Failing to obtain // them is an environment/mirror problem, not tampering: report it so // the existing Release is not trusted, without aborting everything. let keyrings = match keyrings.resolve(suite_url).await { Ok(keyrings) => keyrings, Err(reason) => { warn!( "Cannot verify the Release file of '{suite_url}': {reason}; \ the suite is not trusted" ); return Ok(Verification::KeyringUnavailable { suite_url: suite_url.to_string(), reason, }); } }; // 4. Verify the signature when a keyring is available let mut authenticated = false; if !keyrings.is_empty() { match verify_with_gpgv(&keyrings, clearsigned, Some(metadata), sig) .map_err(|e| VerifyError(format!("could not verify '{suite_url}' with gpgv: {e}")))? { GpgvStatus::Good => { debug!("Signature of the Release file of '{suite_url}' verified with gpgv"); authenticated = true; } GpgvStatus::BadSignature(error) => { return Err(VerifyError(format!( "the signature of the Release file of '{suite_url}' is \ INVALID: {error}. The repository metadata may have been \ tampered with, or the wrong archive keyring was used." ))); } GpgvStatus::GpgvMissing(error) => { if strict { return Err(VerifyError(format!( "the 'gpgv' binary is required to verify the signature \ of the Release file of '{suite_url}' but could not be \ run: {error}. Install the 'gpgv' package and retry." ))); } warn!( "gpgv is not available ({error}); cannot verify the \ signature of the repository at '{suite_url}', which is \ treated as UNAUTHENTICATED" ); } } } else if clearsigned.is_some() || sig.is_some() { let message = format!( "the repository at '{suite_url}' publishes a Release signature \ but no keyring was provided to verify it" ); if strict { return Err(VerifyError(format!("{message}; refusing to trust it"))); } warn!("{message}; the repository is UNAUTHENTICATED"); } else { let message = format!("the repository at '{suite_url}' publishes an UNSIGNED Release file"); if strict { return Err(VerifyError(format!("{message}; refusing to trust it"))); } warn!("{message}; the repository is UNAUTHENTICATED"); } // 4. Parse the checksum fields of the (verified) Release body let body = match clearsigned { Some(clearsigned) => { let text = std::str::from_utf8(clearsigned).map_err(|_| { VerifyError(format!( "the InRelease file of '{suite_url}' is not valid UTF-8" )) })?; split_clearsigned(text).ok_or_else(|| { VerifyError(format!( "the InRelease file of '{suite_url}' is not a valid \ clearsigned message" )) })? } None => std::str::from_utf8(metadata) .map_err(|_| { VerifyError(format!( "the Release file of '{suite_url}' is not valid UTF-8" )) })? .to_string(), }; Ok(Verification::Available(parse_release_body( suite_url, &body, authenticated, ))) } /// GET a URL, returning None on any HTTP error, non-success status, or body /// read failure (Release files are probed, so absence is a normal outcome) async fn fetch_optional(url: &str) -> Option> { match crate::distro_info::http_get_retried(url).await { Ok(response) if response.status().is_success() => match response.bytes().await { Ok(bytes) => return Some(bytes.to_vec()), Err(e) => debug!("Reading the body of '{url}' failed: {e}"), }, Ok(response) => debug!("Fetching '{url}' returned HTTP {}", response.status()), Err(e) => debug!("Fetching '{url}' failed: {e}"), } None } lazy_static! { /// Downloaded keyrings, keyed by URL: keyrings are small but would /// otherwise be re-downloaded once per series/pocket lookup static ref KEYRING_CACHE: Mutex>> = Mutex::new(HashMap::new()); } /// Read the keyring cache, ignoring poisoning (the map is only corrupted on /// panics, in which case a fresh map is just as good) fn keyring_cache() -> std::sync::MutexGuard<'static, HashMap>> { KEYRING_CACHE.lock().unwrap_or_else(PoisonError::into_inner) } /// Download a keyring over TLS, with in-memory caching async fn fetch_keyring_cached(url: &str) -> Result, Box> { if let Some(cached) = keyring_cache().get(url) { return Ok(cached.clone()); } let response = crate::distro_info::http_get_retried(url).await?; if !response.status().is_success() { return Err(format!( "downloading keyring from '{url}' failed with HTTP {}", response.status() ) .into()); } let bytes = response.bytes().await?.to_vec(); if bytes.is_empty() { return Err(format!("the keyring downloaded from '{url}' is empty").into()); } keyring_cache().insert(url.to_string(), bytes.clone()); Ok(bytes) } /// Download the archive keyrings of a distribution series (URLs obtained /// from distro_info), as raw keyring bytes pub async fn fetch_keyrings(urls: &[String]) -> Result>, Box> { let mut keyrings = Vec::with_capacity(urls.len()); for url in urls { keyrings.push(fetch_keyring_cached(url).await?); } Ok(keyrings) } /// Launchpad API response for a PPA archive #[derive(Deserialize)] struct LaunchpadPpa { signing_key_fingerprint: String, } /// Extract the PPA owner and name from a Launchpad PPA base URL /// (e.g. 'https://ppa.launchpadcontent.net/user/ppa/ubuntu') fn parse_ppa_url(ppa_base_url: &str) -> Option<(String, String)> { let rest = ppa_base_url .strip_prefix("https://") .or_else(|| ppa_base_url.strip_prefix("http://"))?; let (host, path) = rest.split_once('/')?; if host != crate::launchpad::ppa_content_host() { return None; } let mut segments = path.trim_end_matches('/').split('/'); let owner = segments.next()?; let name = segments.next()?; if owner.is_empty() || name.is_empty() { return None; } Some((owner.to_string(), name.to_string())) } /// Obtain the signing key of a Launchpad PPA as raw keyring bytes. /// /// The key is identified by the fingerprint published by the Launchpad API /// (over TLS) and downloaded from keyserver.ubuntu.com (over TLS), mirroring /// what `apt::keyring::download_trust_ppa_key` installs for apt itself; the /// TLS anchoring of both endpoints is what prevents a man-in-the-middle on /// the PPA archive from substituting its own key. pub async fn ppa_keyring_bytes( ppa_base_url: &str, ) -> Result, Box> { let (owner, name) = parse_ppa_url(ppa_base_url).ok_or_else(|| { format!( "'{ppa_base_url}' is not a recognized Launchpad PPA URL, so its \ signing key cannot be looked up" ) })?; let cache_key = format!("ppa:{owner}/{name}"); if let Some(cached) = keyring_cache().get(&cache_key) { return Ok(cached.clone()); } let api_url = crate::launchpad::archive_url(&owner, &name); let response = crate::distro_info::http_get_retried(&api_url).await?; if !response.status().is_success() { return Err(format!( "querying the Launchpad API for the signing key of PPA \ '{owner}/{name}' failed with HTTP {}", response.status() ) .into()); } let ppa: LaunchpadPpa = response.json().await?; let fingerprint = ppa.signing_key_fingerprint; if fingerprint.is_empty() { return Err(format!( "the Launchpad API returned no signing key fingerprint for PPA '{owner}/{name}'" ) .into()); } let key_url = crate::apt::keyring::keyserver_lookup_url(&fingerprint); let armored = fetch_keyring_cached(&key_url).await?; let keyring = dearmor(&armored) .map_err(|e| format!("invalid PGP armor in the key of PPA '{owner}/{name}': {e}"))?; keyring_cache().insert(cache_key, keyring.clone()); Ok(keyring) } /// Decode an OpenPGP ASCII-armored keyring into its binary form (the /// equivalent of 'gpg --dearmor'). Binary input passes through unchanged. fn dearmor(data: &[u8]) -> Result, String> { // Only input starting with an armor marker is armored; anything else is // assumed to already be a binary keyring let first = data .iter() .position(|byte| !byte.is_ascii_whitespace()) .unwrap_or(0); if !data[first..].starts_with(b"-----BEGIN PGP") { return Ok(data.to_vec()); } let text = std::str::from_utf8(data) .map_err(|_| "the armored keyring is not valid UTF-8".to_string())?; #[derive(PartialEq)] enum State { SeekingBlock, InHeaders, InBase64, } let mut out = Vec::new(); let mut state = State::SeekingBlock; let mut base64 = String::new(); let mut crc_line: Option = None; for line in text.lines() { match state { State::SeekingBlock => { if line.starts_with("-----BEGIN PGP") { state = State::InHeaders; } } State::InHeaders => { if line.is_empty() { state = State::InBase64; } } State::InBase64 => { if line.starts_with("-----END PGP") { let decoded = base64_decode(&base64)?; if let Some(crc) = &crc_line { let expected = base64_decode(crc)?; if expected.len() == 3 { let value = (u32::from(expected[0]) << 16) | (u32::from(expected[1]) << 8) | u32::from(expected[2]); if value != crc24(&decoded) { return Err("the armor CRC-24 checksum does not match".to_string()); } } } out.extend_from_slice(&decoded); base64.clear(); crc_line = None; state = State::SeekingBlock; } else if let Some(crc) = line.strip_prefix('=') { crc_line = Some(crc.to_string()); } else if !line.is_empty() { base64.push_str(line); } } } } if out.is_empty() { return Err("no armored OpenPGP data found".to_string()); } Ok(out) } /// Decode standard base64, ignoring whitespace; '=' padding is optional fn base64_decode(input: &str) -> Result, String> { fn value(byte: u8) -> Result { match byte { b'A'..=b'Z' => Ok(u32::from(byte - b'A')), b'a'..=b'z' => Ok(u32::from(byte - b'a' + 26)), b'0'..=b'9' => Ok(u32::from(byte - b'0' + 52)), b'+' => Ok(62), b'/' => Ok(63), _ => Err(format!("invalid base64 character '{}'", byte as char)), } } let cleaned: Vec = input .bytes() .filter(|byte| !byte.is_ascii_whitespace() && *byte != b'=') .collect(); let mut out = Vec::with_capacity(cleaned.len() * 3 / 4); for chunk in cleaned.chunks(4) { let n = match chunk.len() { 4 => { (value(chunk[0])? << 18) | (value(chunk[1])? << 12) | (value(chunk[2])? << 6) | value(chunk[3])? } 3 => (value(chunk[0])? << 18) | (value(chunk[1])? << 12) | (value(chunk[2])? << 6), 2 => (value(chunk[0])? << 18) | (value(chunk[1])? << 12), _ => return Err("truncated base64 data".to_string()), }; out.push((n >> 16) as u8); if chunk.len() >= 3 { out.push((n >> 8) as u8); } if chunk.len() == 4 { out.push(n as u8); } } Ok(out) } /// CRC-24 checksum of OpenPGP armor (RFC 4880 section 6.6) fn crc24(data: &[u8]) -> u32 { let mut crc: u32 = 0xB7_04_CE; for &byte in data { crc ^= u32::from(byte) << 16; for _ in 0..8 { crc <<= 1; if crc & 0x100_0000 != 0 { crc ^= 0x0186_4CFB; } } } crc & 0xFF_FFFF } #[cfg(test)] mod tests { use super::*; /// Modeled on the InRelease of Ubuntu noble (hashes/paths abbreviated /// but the field layout is the real one) const NOBLE_INRELEASE: &str = "\ -----BEGIN PGP SIGNED MESSAGE----- Hash: SHA512 Origin: Ubuntu Label: Ubuntu Suite: noble Version: 24.04 Codename: noble Date: Thu, 25 Apr 2024 15:10:33 UTC Architectures: amd64 arm64 armhf i386 ppc64el riscv64 s390x Components: main restricted universe multiverse Description: Ubuntu Noble 24.04 MD5Sum: 098f6bcd4621d373cade4e832627b4f6 9999999 main/source/Sources.gz SHA256: 9f86d081884c7d659a2feaa0c55ad015a3bf4f1b2b0b822cd15d6c15b0f00a08 4 main/source/Sources.gz e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855 0 universe/source/Sources.xz 9f86d081884c7d659a2feaa0c55ad015a3bf4f1b2b0b822cd15d6c15b0f00a08 9999999 main/source/Sizes.gz -----BEGIN PGP SIGNATURE----- iQIzBAABCgAdFiEEEXAMPLEEXAMPLEEXAMPLEEXAMPLEAAonQAACgkQEXAMPLEAAAA EwQbAcFaKe4rlLx9e/EXAMPLEEXAMPLEEXAMPLEEXAMPLEEXAMPLEEXAMPLEEXAMPLE =AbCd -----END PGP SIGNATURE----- "; /// sha256(b"test") const SHA256_TEST: &str = "9f86d081884c7d659a2feaa0c55ad015a3bf4f1b2b0b822cd15d6c15b0f00a08"; fn verified_noble() -> VerifiedRelease { let body = split_clearsigned(NOBLE_INRELEASE).unwrap(); parse_release_body("https://archive.ubuntu.com/ubuntu/dists/noble", &body, true) } #[test] fn test_split_clearsigned() { let body = split_clearsigned(NOBLE_INRELEASE).unwrap(); assert!(body.starts_with("Origin: Ubuntu")); assert!(body.contains("Components: main restricted universe multiverse")); // Neither the armor headers nor the signature leak into the body assert!(!body.contains("Hash: SHA512")); assert!(!body.contains("PGP SIGNATURE")); assert!(body.trim_end().ends_with("main/source/Sizes.gz")); // Dash-escaped lines are unescaped let escaped = "\ -----BEGIN PGP SIGNED MESSAGE----- Hash: SHA1 ok - -dash-escaped -----BEGIN PGP SIGNATURE----- garbage -----END PGP SIGNATURE----- "; assert_eq!(split_clearsigned(escaped).unwrap(), "ok\n-dash-escaped"); // Plain (non-clearsigned) input is rejected assert!(split_clearsigned("Origin: Ubuntu\nSuite: noble\n").is_none()); } #[test] fn test_parse_release_checksums() { let verified = verified_noble(); assert!(verified.is_available()); assert!(verified.is_authenticated()); assert_eq!(verified.field("Codename"), Some("noble")); assert_eq!(verified.field("Suite"), Some("noble")); assert_eq!( verified.components(), vec!["main", "restricted", "universe", "multiverse"] ); // The strongest algorithm wins over the (wrong) legacy MD5Sum entry let entry = verified.hash_for("main/source/Sources.gz").unwrap(); assert_eq!(entry.kind, ChecksumKind::Sha256); assert_eq!(entry.hash, SHA256_TEST); assert_eq!(entry.size, 4); assert!(verified.hash_for("missing/source/Sources").is_none()); } #[test] fn test_verify_file_match_and_mismatch() { let verified = verified_noble(); // Matching content passes (sha256 of 'test') assert!( verified .verify_file("main/source/Sources.gz", b"test") .is_ok() ); // Empty content matches the zero-size entry (sha256 of '') assert!( verified .verify_file("universe/source/Sources.xz", b"") .is_ok() ); // Tampered content fails with a checksum mismatch let err = verified .verify_file("main/source/Sources.gz", b"tampered!") .unwrap_err(); assert!(err.contains("checksum mismatch"), "got: {err}"); // A correct hash with a wrong size also fails let err = verified .verify_file("main/source/Sizes.gz", b"test") .unwrap_err(); assert!(err.contains("size mismatch"), "got: {err}"); // A file not listed in the Release file is refused: it is not // covered by the repository signature let err = verified .verify_file("evil/source/Sources.gz", b"test") .unwrap_err(); assert!(err.contains("not listed"), "got: {err}"); // Without a Release file nothing can be verified let none = VerifiedRelease::unavailable("https://example.org/dists/none"); assert!(!none.is_available()); assert!(!none.is_authenticated()); assert!(none.verify_file("main/source/Sources.gz", b"test").is_err()); } #[test] fn test_base64_decode() { assert_eq!(base64_decode("").unwrap(), b""); assert_eq!(base64_decode("dGVzdA==").unwrap(), b"test"); assert_eq!(base64_decode("Zm9vYmE=").unwrap(), b"fooba"); assert_eq!(base64_decode("Zm9vYmFy").unwrap(), b"foobar"); // Line wrapping whitespace is ignored assert_eq!(base64_decode("Zm9v\nYmFy").unwrap(), b"foobar"); assert!(base64_decode("a***").is_err()); } fn b64_char(value: u8) -> char { match value { 0..=25 => (b'A' + value) as char, 26..=51 => (b'a' + value - 26) as char, 52..=61 => (b'0' + value - 52) as char, 62 => '+', _ => '/', } } fn b64_encode3(a: u8, b: u8, c: u8) -> String { let n = (u32::from(a) << 16) | (u32::from(b) << 8) | u32::from(c); [ b64_char(((n >> 18) & 63) as u8), b64_char(((n >> 12) & 63) as u8), b64_char(((n >> 6) & 63) as u8), b64_char((n & 63) as u8), ] .into_iter() .collect() } #[test] fn test_dearmor() { // Binary keyrings pass through untouched assert_eq!(dearmor(b"\x99\x02\x00").unwrap(), b"\x99\x02\x00"); // A well-formed armored block decodes to its payload, headers are // skipped and the CRC is checked let crc = crc24(b"test"); let armored = format!( "-----BEGIN PGP PUBLIC KEY BLOCK-----\nComment: test fixture\n\ndGVzdA==\n={}\n-----END PGP PUBLIC KEY BLOCK-----\n", b64_encode3((crc >> 16) as u8, (crc >> 8) as u8, crc as u8) ); assert_eq!(dearmor(armored.as_bytes()).unwrap(), b"test"); // A corrupted CRC is rejected let bad_crc = "-----BEGIN PGP PUBLIC KEY BLOCK-----\n\ndGVzdA==\n=AAAA\n-----END PGP PUBLIC KEY BLOCK-----\n"; assert!(dearmor(bad_crc.as_bytes()).is_err()); // Truncated armor is rejected assert!(dearmor(b"-----BEGIN PGP PUBLIC KEY BLOCK-----\n").is_err()); } #[test] fn test_gpgv_missing_is_distinguishable() { // A program path that cannot exist: the spawn failure must be // reported as GpgvMissing, distinct from a signature failure let status = run_gpgv( "/nonexistent/pkh-test-gpgv", Path::new("/unused/scratch"), &[PathBuf::from("/unused/keyring.gpg")], Path::new("/unused/InRelease"), None, ); assert!(matches!(status, GpgvStatus::GpgvMissing(_))); } #[test] fn test_parse_ppa_url() { assert_eq!( parse_ppa_url("https://ppa.launchpadcontent.net/mozillateam/ppa/ubuntu"), Some(("mozillateam".to_string(), "ppa".to_string())) ); assert_eq!( parse_ppa_url("https://ppa.launchpadcontent.net/user/name/ubuntu/"), Some(("user".to_string(), "name".to_string())) ); // Not a Launchpad PPA assert_eq!(parse_ppa_url("https://archive.ubuntu.com/ubuntu"), None); assert_eq!(parse_ppa_url("not a url"), None); } /// Network test: exercises the full PPA chain (Launchpad API, keyserver, /// armor decoding, gpgv, checksum parsing) on a real PPA, the same way /// `package_info::get` does for the '--ppa' pull path #[tokio::test] async fn test_verify_ppa_suite_end_to_end() { let base_url = "https://ppa.launchpadcontent.net/mozillateam/ppa/ubuntu"; let suite_url = format!("{base_url}/dists/noble"); let verification = verify_suite( &suite_url, KeyringSource::Ppa { base_url: base_url.to_string(), }, true, ) .await .unwrap(); let verified = match verification { Verification::Available(verified) => verified, other => panic!("expected an available verification, got {other:?}"), }; assert!(verified.is_available()); assert!(verified.is_authenticated()); assert!(!verified.components().is_empty()); // The PPA's Sources index must match the checksums of its signed // Release file let data = reqwest::get(format!("{suite_url}/main/source/Sources.gz")) .await .unwrap() .bytes() .await .unwrap(); verified .verify_file("main/source/Sources.gz", &data) .unwrap(); } }