Files
pkh/src/apt/release.rs
T

1304 lines
47 KiB
Rust

//! 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<ChecksumKind> {
match field {
"MD5Sum" => Some(ChecksumKind::Md5),
"SHA1" => Some(ChecksumKind::Sha1),
"SHA256" => Some(ChecksumKind::Sha256),
"SHA512" => Some(ChecksumKind::Sha512),
_ => None,
}
}
/// Compute the hex-encoded digest of `data` with this algorithm
pub fn digest(self, data: &[u8]) -> String {
match self {
ChecksumKind::Md5 => {
use md5::{Digest, Md5};
let mut hasher = Md5::new();
hasher.update(data);
hex::encode(hasher.finalize())
}
ChecksumKind::Sha1 => {
use sha1::{Digest, Sha1};
let mut hasher = Sha1::new();
hasher.update(data);
hex::encode(hasher.finalize())
}
ChecksumKind::Sha256 => {
use sha2::{Digest, Sha256};
let mut hasher = Sha256::new();
hasher.update(data);
hex::encode(hasher.finalize())
}
ChecksumKind::Sha512 => {
use sha2::{Digest, Sha512};
let mut hasher = Sha512::new();
hasher.update(data);
hex::encode(hasher.finalize())
}
}
}
/// Human-readable algorithm name, for error messages
pub fn name(self) -> &'static str {
match self {
ChecksumKind::Md5 => "MD5",
ChecksumKind::Sha1 => "SHA-1",
ChecksumKind::Sha256 => "SHA-256",
ChecksumKind::Sha512 => "SHA-512",
}
}
}
/// A single `hash size path` entry of a Release checksum field
#[derive(Debug, Clone)]
pub struct ChecksumEntry {
/// Algorithm used for [`ChecksumEntry::hash`]
pub kind: ChecksumKind,
/// Expected hex-encoded digest of the file
pub hash: String,
/// Expected size of the file, in bytes
pub size: u64,
}
/// A Release (or clearsigned InRelease) file fetched from a repository
/// suite, carrying its signature status and the parsed per-file checksums.
///
/// Index files fetched from the same suite can then be checksum-verified
/// with [`VerifiedRelease::verify_file`] before being parsed.
#[derive(Debug, Clone)]
pub struct VerifiedRelease {
/// URL of the suite directory this Release file came from
suite_url: String,
/// True when an InRelease/Release file was found and parsed
available: bool,
/// True when the signature of the Release file verified successfully
authenticated: bool,
/// Top-level fields of the Release file (Codename, Components, ...)
fields: Paragraph,
/// Checksums keyed by path, relative to the suite directory
checksums: HashMap<String, ChecksumEntry>,
}
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<String> {
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<String, ChecksumEntry> = 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::<u64>() 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<String> {
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<TempVerifyDir, std::io::Error> {
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<PathBuf, std::io::Error> {
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<u8>],
clearsigned: Option<&[u8]>,
release: Option<&[u8]>,
sig: Option<&[u8]>,
) -> Result<GpgvStatus, String> {
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<Vec<u8>>),
/// 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<Vec<Vec<u8>>, 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/<suite>' 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<Verification, VerifyError> {
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<Vec<u8>> {
match crate::distro_info::http_get_retried(url).await {
Ok(response) if response.status().is_success() => match response.bytes().await {
Ok(bytes) => return Some(bytes.to_vec()),
Err(e) => debug!("Reading the body of '{url}' failed: {e}"),
},
Ok(response) => debug!("Fetching '{url}' returned HTTP {}", response.status()),
Err(e) => debug!("Fetching '{url}' failed: {e}"),
}
None
}
lazy_static! {
/// Downloaded keyrings, keyed by URL: keyrings are small but would
/// otherwise be re-downloaded once per series/pocket lookup
static ref KEYRING_CACHE: Mutex<HashMap<String, Vec<u8>>> = 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<String, Vec<u8>>> {
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<Vec<u8>, Box<dyn Error + Send + Sync>> {
if let Some(cached) = keyring_cache().get(url) {
return Ok(cached.clone());
}
let response = crate::distro_info::http_get_retried(url).await?;
if !response.status().is_success() {
return Err(format!(
"downloading keyring from '{url}' failed with HTTP {}",
response.status()
)
.into());
}
let bytes = response.bytes().await?.to_vec();
if bytes.is_empty() {
return Err(format!("the keyring downloaded from '{url}' is empty").into());
}
keyring_cache().insert(url.to_string(), bytes.clone());
Ok(bytes)
}
/// Download the archive keyrings of a distribution series (URLs obtained
/// from distro_info), as raw keyring bytes
pub async fn fetch_keyrings(urls: &[String]) -> Result<Vec<Vec<u8>>, Box<dyn Error + Send + Sync>> {
let mut keyrings = Vec::with_capacity(urls.len());
for url in urls {
keyrings.push(fetch_keyring_cached(url).await?);
}
Ok(keyrings)
}
/// Launchpad API response for a PPA archive
#[derive(Deserialize)]
struct LaunchpadPpa {
signing_key_fingerprint: String,
}
/// Extract the PPA owner and name from a Launchpad PPA base URL
/// (e.g. 'https://ppa.launchpadcontent.net/user/ppa/ubuntu')
fn parse_ppa_url(ppa_base_url: &str) -> Option<(String, String)> {
let rest = ppa_base_url
.strip_prefix("https://")
.or_else(|| ppa_base_url.strip_prefix("http://"))?;
let (host, path) = rest.split_once('/')?;
if host != crate::launchpad::ppa_content_host() {
return None;
}
let mut segments = path.trim_end_matches('/').split('/');
let owner = segments.next()?;
let name = segments.next()?;
if owner.is_empty() || name.is_empty() {
return None;
}
Some((owner.to_string(), name.to_string()))
}
/// Obtain the signing key of a Launchpad PPA as raw keyring bytes.
///
/// The key is identified by the fingerprint published by the Launchpad API
/// (over TLS) and downloaded from keyserver.ubuntu.com (over TLS), mirroring
/// what `apt::keyring::download_trust_ppa_key` installs for apt itself; the
/// TLS anchoring of both endpoints is what prevents a man-in-the-middle on
/// the PPA archive from substituting its own key.
pub async fn ppa_keyring_bytes(
ppa_base_url: &str,
) -> Result<Vec<u8>, Box<dyn Error + Send + Sync>> {
let (owner, name) = parse_ppa_url(ppa_base_url).ok_or_else(|| {
format!(
"'{ppa_base_url}' is not a recognized Launchpad PPA URL, so its \
signing key cannot be looked up"
)
})?;
let cache_key = format!("ppa:{owner}/{name}");
if let Some(cached) = keyring_cache().get(&cache_key) {
return Ok(cached.clone());
}
let api_url = crate::launchpad::archive_url(&owner, &name);
let response = crate::distro_info::http_get_retried(&api_url).await?;
if !response.status().is_success() {
return Err(format!(
"querying the Launchpad API for the signing key of PPA \
'{owner}/{name}' failed with HTTP {}",
response.status()
)
.into());
}
let ppa: LaunchpadPpa = response.json().await?;
let fingerprint = ppa.signing_key_fingerprint;
if fingerprint.is_empty() {
return Err(format!(
"the Launchpad API returned no signing key fingerprint for PPA '{owner}/{name}'"
)
.into());
}
let key_url = crate::apt::keyring::keyserver_lookup_url(&fingerprint);
let armored = fetch_keyring_cached(&key_url).await?;
let keyring = dearmor(&armored)
.map_err(|e| format!("invalid PGP armor in the key of PPA '{owner}/{name}': {e}"))?;
keyring_cache().insert(cache_key, keyring.clone());
Ok(keyring)
}
/// Decode an OpenPGP ASCII-armored keyring into its binary form (the
/// equivalent of 'gpg --dearmor'). Binary input passes through unchanged.
fn dearmor(data: &[u8]) -> Result<Vec<u8>, 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<String> = 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<Vec<u8>, String> {
fn value(byte: u8) -> Result<u32, String> {
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<u8> = input
.bytes()
.filter(|byte| !byte.is_ascii_whitespace() && *byte != b'=')
.collect();
let mut out = Vec::with_capacity(cleaned.len() * 3 / 4);
for chunk in cleaned.chunks(4) {
let n = match chunk.len() {
4 => {
(value(chunk[0])? << 18)
| (value(chunk[1])? << 12)
| (value(chunk[2])? << 6)
| value(chunk[3])?
}
3 => (value(chunk[0])? << 18) | (value(chunk[1])? << 12) | (value(chunk[2])? << 6),
2 => (value(chunk[0])? << 18) | (value(chunk[1])? << 12),
_ => return Err("truncated base64 data".to_string()),
};
out.push((n >> 16) as u8);
if chunk.len() >= 3 {
out.push((n >> 8) as u8);
}
if chunk.len() == 4 {
out.push(n as u8);
}
}
Ok(out)
}
/// CRC-24 checksum of OpenPGP armor (RFC 4880 section 6.6)
fn crc24(data: &[u8]) -> u32 {
let mut crc: u32 = 0xB7_04_CE;
for &byte in data {
crc ^= u32::from(byte) << 16;
for _ in 0..8 {
crc <<= 1;
if crc & 0x100_0000 != 0 {
crc ^= 0x0186_4CFB;
}
}
}
crc & 0xFF_FFFF
}
#[cfg(test)]
mod tests {
use super::*;
/// Modeled on the InRelease of Ubuntu noble (hashes/paths abbreviated
/// but the field layout is the real one)
const NOBLE_INRELEASE: &str = "\
-----BEGIN PGP SIGNED MESSAGE-----
Hash: SHA512
Origin: Ubuntu
Label: Ubuntu
Suite: noble
Version: 24.04
Codename: noble
Date: Thu, 25 Apr 2024 15:10:33 UTC
Architectures: amd64 arm64 armhf i386 ppc64el riscv64 s390x
Components: main restricted universe multiverse
Description: Ubuntu Noble 24.04
MD5Sum:
098f6bcd4621d373cade4e832627b4f6 9999999 main/source/Sources.gz
SHA256:
9f86d081884c7d659a2feaa0c55ad015a3bf4f1b2b0b822cd15d6c15b0f00a08 4 main/source/Sources.gz
e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855 0 universe/source/Sources.xz
9f86d081884c7d659a2feaa0c55ad015a3bf4f1b2b0b822cd15d6c15b0f00a08 9999999 main/source/Sizes.gz
-----BEGIN PGP SIGNATURE-----
iQIzBAABCgAdFiEEEXAMPLEEXAMPLEEXAMPLEEXAMPLEAAonQAACgkQEXAMPLEAAAA
EwQbAcFaKe4rlLx9e/EXAMPLEEXAMPLEEXAMPLEEXAMPLEEXAMPLEEXAMPLEEXAMPLE
=AbCd
-----END PGP SIGNATURE-----
";
/// sha256(b"test")
const SHA256_TEST: &str = "9f86d081884c7d659a2feaa0c55ad015a3bf4f1b2b0b822cd15d6c15b0f00a08";
fn verified_noble() -> VerifiedRelease {
let body = split_clearsigned(NOBLE_INRELEASE).unwrap();
parse_release_body("https://archive.ubuntu.com/ubuntu/dists/noble", &body, true)
}
#[test]
fn test_split_clearsigned() {
let body = split_clearsigned(NOBLE_INRELEASE).unwrap();
assert!(body.starts_with("Origin: Ubuntu"));
assert!(body.contains("Components: main restricted universe multiverse"));
// Neither the armor headers nor the signature leak into the body
assert!(!body.contains("Hash: SHA512"));
assert!(!body.contains("PGP SIGNATURE"));
assert!(body.trim_end().ends_with("main/source/Sizes.gz"));
// Dash-escaped lines are unescaped
let escaped = "\
-----BEGIN PGP SIGNED MESSAGE-----
Hash: SHA1
ok
- -dash-escaped
-----BEGIN PGP SIGNATURE-----
garbage
-----END PGP SIGNATURE-----
";
assert_eq!(split_clearsigned(escaped).unwrap(), "ok\n-dash-escaped");
// Plain (non-clearsigned) input is rejected
assert!(split_clearsigned("Origin: Ubuntu\nSuite: noble\n").is_none());
}
#[test]
fn test_parse_release_checksums() {
let verified = verified_noble();
assert!(verified.is_available());
assert!(verified.is_authenticated());
assert_eq!(verified.field("Codename"), Some("noble"));
assert_eq!(verified.field("Suite"), Some("noble"));
assert_eq!(
verified.components(),
vec!["main", "restricted", "universe", "multiverse"]
);
// The strongest algorithm wins over the (wrong) legacy MD5Sum entry
let entry = verified.hash_for("main/source/Sources.gz").unwrap();
assert_eq!(entry.kind, ChecksumKind::Sha256);
assert_eq!(entry.hash, SHA256_TEST);
assert_eq!(entry.size, 4);
assert!(verified.hash_for("missing/source/Sources").is_none());
}
#[test]
fn test_verify_file_match_and_mismatch() {
let verified = verified_noble();
// Matching content passes (sha256 of 'test')
assert!(
verified
.verify_file("main/source/Sources.gz", b"test")
.is_ok()
);
// Empty content matches the zero-size entry (sha256 of '')
assert!(
verified
.verify_file("universe/source/Sources.xz", b"")
.is_ok()
);
// Tampered content fails with a checksum mismatch
let err = verified
.verify_file("main/source/Sources.gz", b"tampered!")
.unwrap_err();
assert!(err.contains("checksum mismatch"), "got: {err}");
// A correct hash with a wrong size also fails
let err = verified
.verify_file("main/source/Sizes.gz", b"test")
.unwrap_err();
assert!(err.contains("size mismatch"), "got: {err}");
// A file not listed in the Release file is refused: it is not
// covered by the repository signature
let err = verified
.verify_file("evil/source/Sources.gz", b"test")
.unwrap_err();
assert!(err.contains("not listed"), "got: {err}");
// Without a Release file nothing can be verified
let none = VerifiedRelease::unavailable("https://example.org/dists/none");
assert!(!none.is_available());
assert!(!none.is_authenticated());
assert!(none.verify_file("main/source/Sources.gz", b"test").is_err());
}
#[test]
fn test_base64_decode() {
assert_eq!(base64_decode("").unwrap(), b"");
assert_eq!(base64_decode("dGVzdA==").unwrap(), b"test");
assert_eq!(base64_decode("Zm9vYmE=").unwrap(), b"fooba");
assert_eq!(base64_decode("Zm9vYmFy").unwrap(), b"foobar");
// Line wrapping whitespace is ignored
assert_eq!(base64_decode("Zm9v\nYmFy").unwrap(), b"foobar");
assert!(base64_decode("a***").is_err());
}
fn b64_char(value: u8) -> char {
match value {
0..=25 => (b'A' + value) as char,
26..=51 => (b'a' + value - 26) as char,
52..=61 => (b'0' + value - 52) as char,
62 => '+',
_ => '/',
}
}
fn b64_encode3(a: u8, b: u8, c: u8) -> String {
let n = (u32::from(a) << 16) | (u32::from(b) << 8) | u32::from(c);
[
b64_char(((n >> 18) & 63) as u8),
b64_char(((n >> 12) & 63) as u8),
b64_char(((n >> 6) & 63) as u8),
b64_char((n & 63) as u8),
]
.into_iter()
.collect()
}
#[test]
fn test_dearmor() {
// Binary keyrings pass through untouched
assert_eq!(dearmor(b"\x99\x02\x00").unwrap(), b"\x99\x02\x00");
// A well-formed armored block decodes to its payload, headers are
// skipped and the CRC is checked
let crc = crc24(b"test");
let armored = format!(
"-----BEGIN PGP PUBLIC KEY BLOCK-----\nComment: test fixture\n\ndGVzdA==\n={}\n-----END PGP PUBLIC KEY BLOCK-----\n",
b64_encode3((crc >> 16) as u8, (crc >> 8) as u8, crc as u8)
);
assert_eq!(dearmor(armored.as_bytes()).unwrap(), b"test");
// A corrupted CRC is rejected
let bad_crc = "-----BEGIN PGP PUBLIC KEY BLOCK-----\n\ndGVzdA==\n=AAAA\n-----END PGP PUBLIC KEY BLOCK-----\n";
assert!(dearmor(bad_crc.as_bytes()).is_err());
// Truncated armor is rejected
assert!(dearmor(b"-----BEGIN PGP PUBLIC KEY BLOCK-----\n").is_err());
}
#[test]
fn test_gpgv_missing_is_distinguishable() {
// A program path that cannot exist: the spawn failure must be
// reported as GpgvMissing, distinct from a signature failure
let status = run_gpgv(
"/nonexistent/pkh-test-gpgv",
Path::new("/unused/scratch"),
&[PathBuf::from("/unused/keyring.gpg")],
Path::new("/unused/InRelease"),
None,
);
assert!(matches!(status, GpgvStatus::GpgvMissing(_)));
}
#[test]
fn test_parse_ppa_url() {
assert_eq!(
parse_ppa_url("https://ppa.launchpadcontent.net/mozillateam/ppa/ubuntu"),
Some(("mozillateam".to_string(), "ppa".to_string()))
);
assert_eq!(
parse_ppa_url("https://ppa.launchpadcontent.net/user/name/ubuntu/"),
Some(("user".to_string(), "name".to_string()))
);
// Not a Launchpad PPA
assert_eq!(parse_ppa_url("https://archive.ubuntu.com/ubuntu"), None);
assert_eq!(parse_ppa_url("not a url"), None);
}
/// Network test: exercises the full PPA chain (Launchpad API, keyserver,
/// armor decoding, gpgv, checksum parsing) on a real PPA, the same way
/// `package_info::get` does for the '--ppa' pull path
#[tokio::test]
async fn test_verify_ppa_suite_end_to_end() {
let base_url = "https://ppa.launchpadcontent.net/mozillateam/ppa/ubuntu";
let suite_url = format!("{base_url}/dists/noble");
let verification = verify_suite(
&suite_url,
KeyringSource::Ppa {
base_url: base_url.to_string(),
},
true,
)
.await
.unwrap();
let verified = match verification {
Verification::Available(verified) => verified,
other => panic!("expected an available verification, got {other:?}"),
};
assert!(verified.is_available());
assert!(verified.is_authenticated());
assert!(!verified.components().is_empty());
// The PPA's Sources index must match the checksums of its signed
// Release file
let data = reqwest::get(format!("{suite_url}/main/source/Sources.gz"))
.await
.unwrap()
.bytes()
.await
.unwrap();
verified
.verify_file("main/source/Sources.gz", &data)
.unwrap();
}
}