1304 lines
47 KiB
Rust
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();
|
|
}
|
|
}
|