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!`. //! the missing authenticity only produces a `warn!`.
//! //!
//! Only actual tampering evidence — an invalid signature, or an index whose //! 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 //! ASCII-armored keyrings (Debian distributes `.asc` keyrings, and
//! keyserver responses are armored) are dearmored in-process, so no `gpg` //! 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 /// or, for flat repositories, 'Sources.xz'). Both the digest and the
/// size are checked; a file not listed in the Release file is refused, /// size are checked; a file not listed in the Release file is refused,
/// as it is not covered by the repository signature. /// 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 { if !self.available {
return Err(format!( return Err(FileVerifyError::NoReleaseFile {
"no Release file was found for '{}': cannot checksum-verify '{rel_path}'", suite_url: self.suite_url.clone(),
self.suite_url path: rel_path.to_string(),
)); });
} }
let entry = self.checksums.get(rel_path).ok_or_else(|| { let entry = self
format!( .checksums
"'{rel_path}' is not listed in the Release file of '{}'; refusing \ .get(rel_path)
to use an index that is not covered by the repository metadata", .ok_or_else(|| FileVerifyError::NotListed {
self.suite_url path: rel_path.to_string(),
) suite_url: self.suite_url.clone(),
})?; })?;
let digest = entry.kind.digest(data); let digest = entry.kind.digest(data);
if !digest.eq_ignore_ascii_case(&entry.hash) { if !digest.eq_ignore_ascii_case(&entry.hash) {
return Err(format!( return Err(FileVerifyError::ChecksumMismatch {
"checksum mismatch for '{rel_path}' of '{}': the Release file \ path: rel_path.to_string(),
expects {} {} but the downloaded file hashes to {}; the index \ suite_url: self.suite_url.clone(),
does not match the signed repository metadata", algorithm: entry.kind.name().to_string(),
self.suite_url, expected: entry.hash.clone(),
entry.kind.name(), actual: digest,
entry.hash, });
digest
));
} }
if entry.size > 0 && entry.size != data.len() as u64 { if entry.size > 0 && entry.size != data.len() as u64 {
return Err(format!( return Err(FileVerifyError::SizeMismatch {
"size mismatch for '{rel_path}' of '{}': expected {} bytes, got {}", path: rel_path.to_string(),
self.suite_url, suite_url: self.suite_url.clone(),
entry.size, expected: entry.size,
data.len() actual: data.len(),
)); });
} }
Ok(()) 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 /// Parse a Release file body (the signed payload of an InRelease, or a
/// plain Release file) into a [`VerifiedRelease`] /// plain Release file) into a [`VerifiedRelease`]
fn parse_release_body(suite_url: &str, body: &str, authenticated: bool) -> 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) 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 /// Decode an OpenPGP ASCII-armored keyring into its binary form (the
/// equivalent of 'gpg --dearmor'). Binary input passes through unchanged. /// 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 // Only input starting with an armor marker is armored; anything else is
// assumed to already be a binary keyring // assumed to already be a binary keyring
let first = data let first = data
@@ -926,8 +1005,7 @@ fn dearmor(data: &[u8]) -> Result<Vec<u8>, String> {
return Ok(data.to_vec()); return Ok(data.to_vec());
} }
let text = std::str::from_utf8(data) let text = std::str::from_utf8(data).map_err(|_| ArmorError::NotUtf8)?;
.map_err(|_| "the armored keyring is not valid UTF-8".to_string())?;
#[derive(PartialEq)] #[derive(PartialEq)]
enum State { enum State {
@@ -963,7 +1041,7 @@ fn dearmor(data: &[u8]) -> Result<Vec<u8>, String> {
| (u32::from(expected[1]) << 8) | (u32::from(expected[1]) << 8)
| u32::from(expected[2]); | u32::from(expected[2]);
if value != crc24(&decoded) { 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() { if out.is_empty() {
return Err("no armored OpenPGP data found".to_string()); return Err(ArmorError::NoArmoredData);
} }
Ok(out) Ok(out)
} }
/// Decode standard base64, ignoring whitespace; '=' padding is optional /// Decode standard base64, ignoring whitespace; '=' padding is optional
fn base64_decode(input: &str) -> Result<Vec<u8>, String> { fn base64_decode(input: &str) -> Result<Vec<u8>, ArmorError> {
fn value(byte: u8) -> Result<u32, String> { fn value(byte: u8) -> Result<u32, ArmorError> {
match byte { match byte {
b'A'..=b'Z' => Ok(u32::from(byte - b'A')), b'A'..=b'Z' => Ok(u32::from(byte - b'A')),
b'a'..=b'z' => Ok(u32::from(byte - b'a' + 26)), b'a'..=b'z' => Ok(u32::from(byte - b'a' + 26)),
b'0'..=b'9' => Ok(u32::from(byte - b'0' + 52)), b'0'..=b'9' => Ok(u32::from(byte - b'0' + 52)),
b'+' => Ok(62), b'+' => Ok(62),
b'/' => Ok(63), 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), 3 => (value(chunk[0])? << 18) | (value(chunk[1])? << 12) | (value(chunk[2])? << 6),
2 => (value(chunk[0])? << 18) | (value(chunk[1])? << 12), 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); out.push((n >> 16) as u8);
if chunk.len() >= 3 { if chunk.len() >= 3 {
@@ -1154,20 +1232,23 @@ garbage
// Tampered content fails with a checksum mismatch // Tampered content fails with a checksum mismatch
let err = verified let err = verified
.verify_file("main/source/Sources.gz", b"tampered!") .verify_file("main/source/Sources.gz", b"tampered!")
.unwrap_err(); .unwrap_err()
.to_string();
assert!(err.contains("checksum mismatch"), "got: {err}"); assert!(err.contains("checksum mismatch"), "got: {err}");
// A correct hash with a wrong size also fails // A correct hash with a wrong size also fails
let err = verified let err = verified
.verify_file("main/source/Sizes.gz", b"test") .verify_file("main/source/Sizes.gz", b"test")
.unwrap_err(); .unwrap_err()
.to_string();
assert!(err.contains("size mismatch"), "got: {err}"); assert!(err.contains("size mismatch"), "got: {err}");
// A file not listed in the Release file is refused: it is not // A file not listed in the Release file is refused: it is not
// covered by the repository signature // covered by the repository signature
let err = verified let err = verified
.verify_file("evil/source/Sources.gz", b"test") .verify_file("evil/source/Sources.gz", b"test")
.unwrap_err(); .unwrap_err()
.to_string();
assert!(err.contains("not listed"), "got: {err}"); assert!(err.contains("not listed"), "got: {err}");
// Without a Release file nothing can be verified // Without a Release file nothing can be verified