From 213668fa82429851d56855af9ee358210bd6cde0 Mon Sep 17 00:00:00 2001 From: Valentin Haudiquet Date: Wed, 16 Sep 2026 01:22:22 +0200 Subject: [PATCH] pull: authenticate archive indexes against signed Release files The Sources index was downloaded with no authentication: per-artifact checksums were verified, but against hashes taken from an index a MITM could substitute along with the artifacts. Fetch each suite's InRelease (or Release + Release.gpg), verify the signature with gpgv against the archive keyring (or the PPA signing key) the same way apt does, and checksum-check every Sources index against it before parsing. Distro archives and PPAs verify strictly: an invalid or unverifiable signature, or a missing gpgv binary, is a hard error. Flat repositories keep working without a Release file or without a verifiable one (warned as unauthenticated), but tampering evidence is a hard error there too. Also switches all archive, PPA and keyring base URLs to https, and reads suite components from the verified Release instead of fetching them separately over an unauthenticated channel. --- distro_info.yml | 6 +- src/apt/mod.rs | 2 + src/apt/release.rs | 1305 +++++++++++++++++++++++++++++++++++++++++++ src/distro_info.rs | 2 +- src/package_info.rs | 127 ++++- 5 files changed, 1431 insertions(+), 11 deletions(-) create mode 100644 src/apt/release.rs 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}"))?;