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!`.
|
//! 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
|
||||||
|
|||||||
Reference in New Issue
Block a user