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:
+121
-40
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user