apt/release: type the file-verification and armor errors

verify_file returns FileVerifyError (no Release file, unlisted path,
checksum mismatch, size mismatch — the structured pieces carried, so
the by-hash retry in package_info can match on the failure instead of
parsing text) and the in-process armor/base64 decoders return
ArmorError; the gpgv staging and keyring-download paths keep their
diagnostic strings, rendered at the boundary. The existing
VerifyError(String) keeps its name and downcast role for suite
signature verification. Display texts are byte-identical to the old
format! strings.
This commit is contained in:
2026-10-03 02:17:22 +02:00
parent a59e99b107
commit 82600b1d22
+121 -40
View File
@@ -39,7 +39,9 @@
//! 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`].
//! hash does not match the signed Release — is reported as an error:
//! [`VerifyError`] for the suite signature, [`FileVerifyError`] for an
//! index that fails its Release checksums.
//!
//! ASCII-armored keyrings (Debian distributes `.asc` keyrings, and
//! keyserver responses are armored) are dearmored in-process, so no `gpg`
@@ -227,48 +229,101 @@ impl VerifiedRelease {
/// 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> {
pub fn verify_file(&self, rel_path: &str, data: &[u8]) -> Result<(), FileVerifyError> {
if !self.available {
return Err(format!(
"no Release file was found for '{}': cannot checksum-verify '{rel_path}'",
self.suite_url
));
return Err(FileVerifyError::NoReleaseFile {
suite_url: self.suite_url.clone(),
path: rel_path.to_string(),
});
}
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 entry = self
.checksums
.get(rel_path)
.ok_or_else(|| FileVerifyError::NotListed {
path: rel_path.to_string(),
suite_url: self.suite_url.clone(),
})?;
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
));
return Err(FileVerifyError::ChecksumMismatch {
path: rel_path.to_string(),
suite_url: self.suite_url.clone(),
algorithm: entry.kind.name().to_string(),
expected: entry.hash.clone(),
actual: 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()
));
return Err(FileVerifyError::SizeMismatch {
path: rel_path.to_string(),
suite_url: self.suite_url.clone(),
expected: entry.size,
actual: data.len(),
});
}
Ok(())
}
}
/// Failure of [`VerifiedRelease::verify_file`]: the downloaded index is not
/// covered by the suite's signed Release metadata.
#[derive(Debug, Clone, PartialEq, Eq, thiserror::Error)]
pub enum FileVerifyError {
/// No Release file was seen on the suite, so there is nothing to
/// verify against.
#[error("no Release file was found for '{suite_url}': cannot checksum-verify '{path}'")]
NoReleaseFile {
/// URL of the suite directory.
suite_url: String,
/// The path that was to be verified.
path: String,
},
/// The Release file does not list the path: it is not covered by the
/// repository signature.
#[error(
"'{path}' is not listed in the Release file of '{suite_url}'; refusing to use an index that is not covered by the repository metadata"
)]
NotListed {
/// The unlisted path.
path: String,
/// URL of the suite directory.
suite_url: String,
},
/// The downloaded file does not hash to the Release entry.
#[error(
"checksum mismatch for '{path}' of '{suite_url}': the Release file expects {algorithm} {expected} but the downloaded file hashes to {actual}; the index does not match the signed repository metadata"
)]
ChecksumMismatch {
/// The verified path.
path: String,
/// URL of the suite directory.
suite_url: String,
/// Name of the checksum algorithm (`SHA256`, ...).
algorithm: String,
/// The digest the Release file lists.
expected: String,
/// The digest of the downloaded bytes.
actual: String,
},
/// The downloaded file's length differs from the Release entry (a
/// zeroed entry skips the check).
#[error("size mismatch for '{path}' of '{suite_url}': expected {expected} bytes, got {actual}")]
SizeMismatch {
/// The verified path.
path: String,
/// URL of the suite directory.
suite_url: String,
/// The size the Release file lists.
expected: u64,
/// The size of the downloaded bytes.
actual: usize,
},
}
/// 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 {
@@ -913,9 +968,33 @@ pub async fn ppa_keyring_bytes(
Ok(keyring)
}
/// Failure of the OpenPGP armor and base64 decoders ([`dearmor`],
/// [`base64_decode`]).
#[derive(Debug, Clone, PartialEq, Eq, thiserror::Error)]
pub enum ArmorError {
/// The armored input is not valid UTF-8.
#[error("the armored keyring is not valid UTF-8")]
NotUtf8,
/// The armor CRC-24 does not match the decoded payload.
#[error("the armor CRC-24 checksum does not match")]
CrcMismatch,
/// An armor marker was present but no payload followed it.
#[error("no armored OpenPGP data found")]
NoArmoredData,
/// A byte outside the base64 alphabet.
#[error("invalid base64 character '{c}'")]
InvalidBase64 {
/// The offending character.
c: char,
},
/// The base64 payload ends mid-group.
#[error("truncated base64 data")]
Truncated,
}
/// 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> {
fn dearmor(data: &[u8]) -> Result<Vec<u8>, ArmorError> {
// Only input starting with an armor marker is armored; anything else is
// assumed to already be a binary keyring
let first = data
@@ -926,8 +1005,7 @@ fn dearmor(data: &[u8]) -> Result<Vec<u8>, String> {
return Ok(data.to_vec());
}
let text = std::str::from_utf8(data)
.map_err(|_| "the armored keyring is not valid UTF-8".to_string())?;
let text = std::str::from_utf8(data).map_err(|_| ArmorError::NotUtf8)?;
#[derive(PartialEq)]
enum State {
@@ -963,7 +1041,7 @@ fn dearmor(data: &[u8]) -> Result<Vec<u8>, String> {
| (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());
return Err(ArmorError::CrcMismatch);
}
}
}
@@ -981,21 +1059,21 @@ fn dearmor(data: &[u8]) -> Result<Vec<u8>, String> {
}
if out.is_empty() {
return Err("no armored OpenPGP data found".to_string());
return Err(ArmorError::NoArmoredData);
}
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> {
fn base64_decode(input: &str) -> Result<Vec<u8>, ArmorError> {
fn value(byte: u8) -> Result<u32, ArmorError> {
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)),
_ => Err(ArmorError::InvalidBase64 { c: byte as char }),
}
}
@@ -1015,7 +1093,7 @@ fn base64_decode(input: &str) -> Result<Vec<u8>, String> {
}
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()),
_ => return Err(ArmorError::Truncated),
};
out.push((n >> 16) as u8);
if chunk.len() >= 3 {
@@ -1154,20 +1232,23 @@ garbage
// Tampered content fails with a checksum mismatch
let err = verified
.verify_file("main/source/Sources.gz", b"tampered!")
.unwrap_err();
.unwrap_err()
.to_string();
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();
.unwrap_err()
.to_string();
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();
.unwrap_err()
.to_string();
assert!(err.contains("not listed"), "got: {err}");
// Without a Release file nothing can be verified