diff --git a/distro_info.yml b/distro_info.yml index c73575b..a94fe58 100644 --- a/distro_info.yml +++ b/distro_info.yml @@ -9,7 +9,7 @@ dist_info: network: https://salsa.debian.org/debian/distro-info-data/-/raw/main/ dist: debian: - base_url: http://deb.debian.org/debian + base_url: https://deb.debian.org/debian archive_keyring: https://ftp-master.debian.org/keys/archive-key-{series_num}.asc pockets: - proposed-updates @@ -18,8 +18,8 @@ dist: local: /usr/share/distro-info/debian.csv network: https://salsa.debian.org/debian/distro-info-data/-/raw/main/debian.csv ubuntu: - base_url: http://archive.ubuntu.com/ubuntu - archive_keyring: http://archive.ubuntu.com/ubuntu/project/ubuntu-archive-keyring.gpg + base_url: https://archive.ubuntu.com/ubuntu + archive_keyring: https://archive.ubuntu.com/ubuntu/project/ubuntu-archive-keyring.gpg pockets: - proposed - updates diff --git a/src/apt/mod.rs b/src/apt/mod.rs index c05834c..2e77cb9 100644 --- a/src/apt/mod.rs +++ b/src/apt/mod.rs @@ -1,2 +1,4 @@ pub mod keyring; +/// Release-file signature and checksum verification for repositories +pub mod release; pub mod sources; diff --git a/src/apt/release.rs b/src/apt/release.rs new file mode 100644 index 0000000..817317c --- /dev/null +++ b/src/apt/release.rs @@ -0,0 +1,1305 @@ +//! 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 + 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 reqwest::get(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 = reqwest::get(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 != "ppa.launchpadcontent.net" { + 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 = format!("https://api.launchpad.net/1.0/~{owner}/+archive/ubuntu/{name}"); + let response = reqwest::get(&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 = format!("https://keyserver.ubuntu.com/pks/lookup?op=get&search=0x{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 ^= 0x1864_CFB; + } + } + } + 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"; + /// sha256(b"") + const SHA256_EMPTY: &str = "e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855"; + + 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").to_path_buf(), + &[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(); + } +} diff --git a/src/distro_info.rs b/src/distro_info.rs index 75ef95f..c4f9c2b 100644 --- a/src/distro_info.rs +++ b/src/distro_info.rs @@ -234,7 +234,7 @@ pub fn get_sources_url(base_url: &str, series: &str, pocket: &str, component: &s /// Get the archive base URL for a distribution /// -/// Example: ubuntu => http://archive.ubuntu.com/ubuntu +/// Example: ubuntu => https://archive.ubuntu.com/ubuntu pub fn get_base_url(dist: &str) -> Result> { DATA.dist .get(dist) diff --git a/src/package_info.rs b/src/package_info.rs index f0c5046..37cc99d 100644 --- a/src/package_info.rs +++ b/src/package_info.rs @@ -5,6 +5,7 @@ use std::io::Read; use xz2::read::XzDecoder; use crate::ProgressCallback; +use crate::apt::release::{self, VerifiedRelease}; use crossterm::style::Stylize; use log::{debug, warn}; @@ -17,7 +18,7 @@ use log::{debug, warn}; /// # Returns /// * The base URL for the PPA (e.g., "https://ppa.launchpadcontent.net/user/ppa_name/ubuntu/") pub fn ppa_to_base_url(user: &str, name: &str) -> String { - format!("http://ppa.launchpadcontent.net/{}/{}/ubuntu", user, name) + format!("https://ppa.launchpadcontent.net/{}/{}/ubuntu", user, name) } fn check_launchpad_repo_sync(package: &str) -> Result, String> { @@ -315,7 +316,49 @@ async fn get( // If using a custom base URL (PPA), disable VCS lookup to force archive download let from_ppa = base_url != distro_base_url; - let components = crate::distro_info::get_components(&base_url, series, pocket).await?; + // Authenticate the metadata of the suite before trusting any index + // fetched from it: the signed Release file (InRelease, or Release plus + // detached Release.gpg) is verified against the archive keyring (or the + // PPA signing key), and each Sources index downloaded below is + // checksum-checked against it before parsing. + let suite = if pocket.is_empty() { + series.to_string() + } else { + format!("{series}-{pocket}") + }; + let suite_url = format!("{base_url}/dists/{suite}"); + + let keyring_source = if from_ppa { + release::KeyringSource::Ppa { + base_url: base_url.clone(), + } + } else { + release::KeyringSource::Distro { + series: series.to_string(), + } + }; + + debug!("Verifying the Release file of: {}", suite_url); + let verified = match release::verify_suite(&suite_url, keyring_source, true).await { + Ok(release::Verification::Available(verified)) => verified, + Ok(release::Verification::Unavailable { .. }) => { + // The suite does not exist: same outcome as before verification + // existed (probing callers simply try the next pocket/series) + return Err(format!("No Release file found for suite '{suite}' at {base_url}").into()); + } + Ok(release::Verification::KeyringUnavailable { reason, .. }) => { + // An existing suite whose keys are unavailable must not be + // trusted, but this is not tampering: report and let callers + // move on + return Err(reason.into()); + } + Err(e) => return Err(e.into()), + }; + + let components = verified.components(); + if components.is_empty() { + return Err(format!("Components not found in the Release file of '{suite_url}'").into()); + } debug!("Found components: {:?}", components); for component in components { @@ -338,6 +381,14 @@ async fn get( let compressed_data = response.bytes().await?; + // The index must match the checksums listed in the signed Release + // file: this is what closes the 'substituted index with matching + // artifact checksums' man-in-the-middle attack + let suite_rel_path = format!("{component}/source/Sources.gz"); + verified + .verify_file(&suite_rel_path, &compressed_data) + .map_err(release::VerifyError)?; + debug!( "Downloaded Sources.gz for {}/{}/{}", dist, series, component @@ -428,7 +479,13 @@ async fn find_package( } return Ok(info); } - Err(_e) => { + Err(e) => { + // A Release verification failure is a security error, + // not a missing package: abort the search instead of + // silently probing other series/pockets + if e.downcast_ref::().is_some() { + return Err(e); + } continue; } } @@ -471,15 +528,39 @@ async fn get_flat_repo_series(base_url: &str) -> Result> /// Fetch the sources index of a flat repository /// /// Flat repositories are free to serve any compressed variant of the index -/// (or an uncompressed one), so try the usual candidates in turn. -async fn get_flat_repo_sources(base_url: &str) -> Result, Box> { +/// (or an uncompressed one), so try the usual candidates in turn. When the +/// repository published a Release file, each index candidate is +/// checksum-verified against it (failing on mismatch, since the artifact +/// hashes would come from the index itself). +async fn get_flat_repo_sources( + base_url: &str, + verified: Option<&VerifiedRelease>, +) -> Result, Box> { let base = base_url.trim_end_matches('/'); let mut errors = Vec::new(); for name in ["Sources.xz", "Sources.gz", "Sources"] { let url = format!("{base}/{name}"); match reqwest::get(&url).await { Ok(response) if response.status().is_success() => { - return Ok(response.bytes().await?.to_vec()); + let data = response.bytes().await?.to_vec(); + + // Some flat Release files list their entries with a './' prefix + if let Some(release) = verified { + let mismatch = match release.verify_file(name, &data) { + Ok(()) => None, + Err(first) => release + .verify_file(&format!("./{name}"), &data) + .err() + .map(|_| first), + }; + if let Some(e) = mismatch { + return Err( + format!("Verification of the repository index failed: {e}").into() + ); + } + } + + return Ok(data); } Ok(response) => errors.push(format!("{}: HTTP {}", url, response.status())), Err(e) => errors.push(format!("{}: {}", url, e)), @@ -505,6 +586,11 @@ async fn get_flat_repo_sources(base_url: &str) -> Result, Box /// Packages from external repositories are always downloaded from the /// repository itself; the 'Vcs-Git' of the stanza is never used, as it may /// point to an arbitrary source. +/// +/// Third-party flat repositories have no known signing key: when they +/// publish a Release file its checksums are enforced on the index (with a +/// hard error on mismatch), but the absence of a verifiable signature only +/// produces a warning, preserving the previous behavior for such repos. pub async fn lookup_repository( package: &str, version: Option<&str>, @@ -521,8 +607,35 @@ pub async fn lookup_repository( ); } + // Attempt to authenticate the repository. In non-strict mode only the + // absence of a Release file (or of a way to verify it) is tolerated: + // tampering evidence — an invalid or malformed signature — is a hard + // error even for third-party repositories. + let verified = match release::verify_suite( + repo_url.trim_end_matches('/'), + release::KeyringSource::None, + false, + ) + .await + { + Ok(release::Verification::Available(verified)) => Some(verified), + Ok(release::Verification::Unavailable { .. }) => None, + Ok(release::Verification::KeyringUnavailable { reason, .. }) => { + warn!("Release verification of repository {repo_url} failed: {reason}"); + None + } + Err(e) => return Err(e.into()), + }; + + // Prefer the suite name from the (fetched, possibly verified) Release + // file; fall back to a dedicated fetch when there is none let resolved_series = if let Some(s) = series { s.to_string() + } else if let Some(codename) = verified + .as_ref() + .and_then(|v| v.field("Codename").or_else(|| v.field("Suite"))) + { + codename.to_string() } else { get_flat_repo_series(repo_url).await? }; @@ -536,7 +649,7 @@ pub async fn lookup_repository( ); } - let sources = get_flat_repo_sources(repo_url).await?; + let sources = get_flat_repo_sources(repo_url, verified.as_ref()).await?; let stanza = parse_sources(&sources, package, version)? .ok_or_else(|| format!("Package '{package}' not found in repository {repo_url}"))?;