diff --git a/CHANGELOG.md b/CHANGELOG.md index 2312c6b5..e9021ea1 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -25,6 +25,8 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 `get_entry_data_as_u16_array()`, `get_entry_data_as_u32()`, `get_entry_data_as_u32_array()`, `get_entry_data_as_u64()`, `get_entry_data_as_u64_array()`, `get_entry_data_as_string_array()`, `get_entry_data_as_i18n_string()` +- Added `verify_signature()` and `verify_digests()` to `RPMPackage` to enable checking the integrity + and provenance of packages. ### Fixed @@ -33,6 +35,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 did not have any files associated. - Ensured that digests are always added to built RPMs. Previously they would not be included unless the "signature-meta" (or "signature-pgp") features were enabled. +- Added `PAYLOADDIGEST`, `PAYLOADDIGESTALT`, and `PAYLOADDIGESTALGO` tags to built packages. ### Breaking Changes diff --git a/Cargo.toml b/Cargo.toml index e72fd21e..804bb9c3 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -42,10 +42,10 @@ enum-display-derive = "0.1" cpio = "0.2" # consider migrating to flate2 libflate = "1" +digest = "0.10" sha2 = "0.10" md-5 = "0.10" sha1 = "0.10" -rand = { version = "0.8" } pgp = { version = "0.9", optional = true } chrono = "0.4" log = "0.4" @@ -60,8 +60,6 @@ async-std = { version = "1.12.0", optional = true } tokio = { version = "1", optional = true } tokio-util = { version = "0.7.4", features = ["compat"], optional = true } -digest = "0.10.6" - [dev-dependencies] rsa = { version = "0.8" } rsa-der = { version = "^0.3.0" } diff --git a/src/constants.rs b/src/constants.rs index 5fdeeed9..3b431fa4 100644 --- a/src/constants.rs +++ b/src/constants.rs @@ -335,12 +335,12 @@ pub enum IndexTag { RPMTAG_TRANSFILETRIGGERTYPE = 5089, RPMTAG_FILESIGNATURES = 5090, RPMTAG_FILESIGNATURELENGTH = 5091, - RPMTAG_PAYLOADDIGEST = 5092, + RPMTAG_PAYLOADDIGEST = 5092, // hex-encoded string representing the digest of the payload RPMTAG_PAYLOADDIGESTALGO = 5093, RPMTAG_AUTOINSTALLED = 5094, RPMTAG_IDENTITY = 5095, RPMTAG_MODULARITYLABEL = 5096, - RPMTAG_PAYLOADDIGESTALT = 5097, + RPMTAG_PAYLOADDIGESTALT = 5097, // hex-encoded string representing the digest of the payload without compression RPMTAG_ARCHSUFFIX = 5098, RPMTAG_SPEC = 5099, RPMTAG_TRANSLATIONURL = 5100, @@ -375,11 +375,12 @@ pub enum IndexSignatureTag { /// This tag specifies the uncompressed size of the Payload archive, including the cpio headers. RPMSIGTAG_PAYLOADSIZE = HEADER_TAGBASE + 7, - /// This index contains the SHA1 checksum of the entire Header Section, - /// including the Header Record, Index Records and Header store. + /// The SHA1 checksum of the entire Header Section, including the Header Record, Index Records and + /// Header store, stored as a hex-encoded string. RPMSIGTAG_SHA1 = 269, - /// This tag specifies the 128-bit MD5 checksum of the combined Header and Archive sections. + /// This tag specifies the 128-bit MD5 checksum of the combined Header and Archive sections, stored as + /// a binary representation. RPMSIGTAG_MD5 = 1004, /// The tag contains the DSA signature of the Header section. @@ -415,8 +416,8 @@ pub enum IndexSignatureTag { /// The data is formatted as a Version 3 Signature Packet as specified in RFC 2440: OpenPGP Message Format. RPMSIGTAG_GPG = 1005, - /// This index contains the SHA256 checksum of the entire Header Section, - /// including the Header Record, Index Records and Header store. + /// This index contains the SHA256 checksum of the entire Header Section, including the Header Record, + /// Index Records and Header store, stored as a hex-encoded string. RPMSIGTAG_SHA256 = IndexTag::RPMTAG_SHA256HEADER as u32, /// A silly tag for a date. diff --git a/src/errors.rs b/src/errors.rs index 0088a346..b5984618 100644 --- a/src/errors.rs +++ b/src/errors.rs @@ -73,6 +73,9 @@ pub enum RPMError { key_ref: String, }, + #[error("digests from content did not match those in the header")] + DigestMismatchError, + #[error("unable to find key with key-ref: {key_ref}")] KeyNotFoundError { key_ref: String }, diff --git a/src/rpm/builder.rs b/src/rpm/builder.rs index f46caebe..8638f5db 100644 --- a/src/rpm/builder.rs +++ b/src/rpm/builder.rs @@ -8,6 +8,7 @@ use std::os::unix::fs::PermissionsExt; use std::path::{Path, PathBuf}; use std::time::{SystemTime, UNIX_EPOCH}; +use digest::Digest; use gethostname::gethostname; use super::compressor::Compressor; @@ -328,8 +329,6 @@ impl RPMBuilder { modified_at: u32, options: RPMFileOptions, ) -> Result<(), RPMError> { - use sha2::Digest; - let dest = options.destination; if !dest.starts_with("./") && !dest.starts_with('/') { return Err(RPMError::InvalidDestinationPath { @@ -501,7 +500,7 @@ impl RPMBuilder { let Digests { header_and_content_digest: header_and_content_digest_md5, header_digest: header_digest_sha1, - } = RPMPackage::create_digests_from_readers( + } = RPMPackage::create_legacy_header_digests( &mut header.as_slice(), header_and_content_cursor, )?; @@ -546,7 +545,7 @@ impl RPMBuilder { let Digests { header_and_content_digest: header_and_content_digest_md5, header_digest: header_digest_sha1, - } = RPMPackage::create_digests_from_readers( + } = RPMPackage::create_legacy_header_digests( &mut header.as_slice(), header_and_content_cursor, )?; @@ -592,6 +591,13 @@ impl RPMBuilder { // Lead is not important. just build it here let lead = Lead::new(&self.name); + let possible_compression_details = self.compressor.get_details(); + + // Make the borrow checker happy + let is_zstd = matches!(self.compressor, Compressor::Zstd(_)); + // Calculate the sha256 of the archive as we write it into the compressor, so that we don't + // need to keep two copies in memory simultaneously. + let mut archive = Sha256Writer::new(&mut self.compressor); let mut ino_index = 1; @@ -645,13 +651,14 @@ impl RPMBuilder { .ino(ino_index) .uid(self.uid.unwrap_or(0)) .gid(self.gid.unwrap_or(0)) - .write(&mut self.compressor, content.len() as u32); + .write(&mut archive, content.len() as u32); writer.write_all(&content)?; writer.finish()?; ino_index += 1; } + cpio::newc::trailer(&mut archive)?; self.provides .push(Dependency::eq(self.name.clone(), self.version.clone())); @@ -675,7 +682,7 @@ impl RPMBuilder { "4.0-1".to_string(), )); - if matches!(self.compressor, Compressor::Zstd(_)) { + if is_zstd { self.requires.push(Dependency::rpmlib( "rpmlib(PayloadIsZstd)".to_string(), "5.4.18-1".to_string(), @@ -960,7 +967,34 @@ impl RPMBuilder { ), ]); - let possible_compression_details = self.compressor.get_details(); + // digest of the uncompressed raw archive calculated on the inner writer + let raw_archive_digest_sha256 = hex::encode(archive.into_digest()); + let payload = self.compressor.finish_compression()?; + + // digest of the post-compression archive (payload) + let payload_digest_sha256 = { + let mut hasher = sha2::Sha256::default(); + hasher.update(payload.as_slice()); + hex::encode(hasher.finalize()) + }; + + actual_records.extend([ + IndexEntry::new( + IndexTag::RPMTAG_PAYLOADDIGEST, + offset, + IndexData::StringArray(vec![payload_digest_sha256]), + ), + IndexEntry::new( + IndexTag::RPMTAG_PAYLOADDIGESTALGO, + offset, + IndexData::Int32(vec![FileDigestAlgorithm::Sha2_256 as u32]), + ), + IndexEntry::new( + IndexTag::RPMTAG_PAYLOADDIGESTALT, + offset, + IndexData::StringArray(vec![raw_archive_digest_sha256]), + ), + ]); if let Some(details) = possible_compression_details { actual_records.push(IndexEntry::new( @@ -1184,9 +1218,7 @@ impl RPMBuilder { } let header = Header::from_entries(actual_records, IndexTag::RPMTAG_HEADERIMMUTABLE); - self.compressor = cpio::newc::trailer(self.compressor)?; - let content = self.compressor.finish_compression()?; - Ok((lead, header, content)) + Ok((lead, header, payload)) } } diff --git a/src/rpm/headers/header.rs b/src/rpm/headers/header.rs index 2d886483..578be65d 100644 --- a/src/rpm/headers/header.rs +++ b/src/rpm/headers/header.rs @@ -416,15 +416,15 @@ impl Default for FileCategory { pub enum FileDigestAlgorithm { // broken and very broken Md5 = constants::PGPHASHALGO_MD5, - Sha1 = constants::PGPHASHALGO_SHA1, - Md2 = constants::PGPHASHALGO_MD2, + // Sha1 = constants::PGPHASHALGO_SHA1, + // Md2 = constants::PGPHASHALGO_MD2, - // not proven to be broken, weaker variants broken - #[allow(non_camel_case_types)] - Haval_5_160 = constants::PGPHASHALGO_HAVAL_5_160, // not part of PGP - Ripemd160 = constants::PGPHASHALGO_RIPEMD160, + // // not proven to be broken, weaker variants broken + // #[allow(non_camel_case_types)] + // Haval_5_160 = constants::PGPHASHALGO_HAVAL_5_160, // not part of PGP + // Ripemd160 = constants::PGPHASHALGO_RIPEMD160, - Tiger192 = constants::PGPHASHALGO_TIGER192, // not part of PGP + // Tiger192 = constants::PGPHASHALGO_TIGER192, // not part of PGP Sha2_256 = constants::PGPHASHALGO_SHA256, Sha2_384 = constants::PGPHASHALGO_SHA384, Sha2_512 = constants::PGPHASHALGO_SHA512, diff --git a/src/rpm/headers/types.rs b/src/rpm/headers/types.rs index be494102..fafd8384 100644 --- a/src/rpm/headers/types.rs +++ b/src/rpm/headers/types.rs @@ -1,5 +1,6 @@ //! A collection of types used in various header records. use crate::{constants::*, errors}; +use digest::Digest; /// Describes a file present in the rpm file. pub struct RPMFileEntry { @@ -315,6 +316,36 @@ impl Dependency { } } +/// A wrapper for calculating the sha256 checksum of the contents written to it +pub struct Sha256Writer { + writer: W, + hasher: sha2::Sha256, +} + +impl Sha256Writer { + pub fn new(writer: W) -> Self { + Sha256Writer { + writer, + hasher: sha2::Sha256::new(), + } + } + + pub fn into_digest(self) -> impl AsRef<[u8]> { + self.hasher.finalize() + } +} + +impl std::io::Write for Sha256Writer { + fn write(&mut self, buf: &[u8]) -> std::io::Result { + self.hasher.update(buf); + self.writer.write(buf) + } + + fn flush(&mut self) -> std::io::Result<()> { + self.writer.flush() + } +} + mod test { #[test] diff --git a/src/rpm/package.rs b/src/rpm/package.rs index 6d1d72eb..b79749ec 100644 --- a/src/rpm/package.rs +++ b/src/rpm/package.rs @@ -5,6 +5,7 @@ use std::path::{Path, PathBuf}; use chrono::offset::TimeZone; +use digest::Digest; #[cfg(feature = "async-futures")] use futures::io::{AsyncRead, AsyncReadExt, AsyncWrite, AsyncWriteExt}; use num_traits::FromPrimitive; @@ -20,16 +21,6 @@ use crate::sequential_cursor::SeqCursor; #[cfg(feature = "signature-meta")] use crate::signature; -pub enum SignatureVerificationOutcome { - Pass, - Failure, -} - -pub enum DigestVerificationOutcome { - Match, - Mismatch, -} - /// Combined digest of signature header tags `RPMSIGTAG_MD5` and `RPMSIGTAG_SHA1` /// /// Succinct to cover to "verify" the content of the rpm file. Quotes because @@ -110,8 +101,9 @@ impl RPMPackage { } } + // @todo: delete when EL7 goes EOL, PAYLOADDIGEST has been available for years now. /// Prepare both header and content digests as used by the `SignatureIndex`. - pub(crate) fn create_digests_from_readers( + pub(crate) fn create_legacy_header_digests( header_cursor: HC, header_and_content_cursor: HACC, ) -> Result @@ -120,7 +112,6 @@ impl RPMPackage { HACC: std::io::Read, { let digest_md5 = { - use md5::Digest; let mut hasher = md5::Md5::default(); Self::read_and_update_digest_256(&mut hasher, header_and_content_cursor); let hash_result = hasher.finalize(); @@ -128,7 +119,6 @@ impl RPMPackage { }; let digest_sha1 = { - use sha1::Digest; let mut hasher = sha1::Sha1::default(); Self::read_and_update_digest_256(&mut hasher, header_cursor); let digest = hasher.finalize(); @@ -160,7 +150,7 @@ impl RPMPackage { let Digests { header_digest, header_and_content_digest, - } = Self::create_digests_from_readers( + } = Self::create_legacy_header_digests( &mut header_bytes.as_slice(), &mut header_and_content_cursor, )?; @@ -187,11 +177,13 @@ impl RPMPackage { Ok(()) } + // @todo: a function that returns the key ID of the key used to sign this package would be useful + // @todo: verify_signature() and verify_digests() don't provide any feedback on whether a signature/digest + // was present and verified or whether it was not present at all. + /// Verify the signature as present within the RPM package. - /// - /// #[cfg(feature = "signature-meta")] - pub fn verify_signature(&self, verifier: V) -> Result + pub fn verify_signature(&self, verifier: V) -> Result<(), RPMError> where V: signature::Verifying>, { @@ -216,31 +208,96 @@ impl RPMPackage { ); verifier.verify(header_bytes.as_slice(), signature_header_only)?; + self.verify_digests()?; let header_and_content_cursor = SeqCursor::new(&[header_bytes.as_slice(), self.content.as_slice()]); verifier.verify(header_and_content_cursor, signature_header_and_content)?; - Ok(SignatureVerificationOutcome::Pass) + Ok(()) } - pub fn verify_digest(&self) -> Result { + /// Verify any digests which may be present in the RPM headers + pub fn verify_digests(&self) -> Result<(), RPMError> { let mut header = Vec::::with_capacity(1024); // make sure to not hash any previous signatures in the header self.metadata.header.write(&mut header)?; - let recreated_from_content = Self::create_digests_from_readers( + let recreated_from_content = Self::create_legacy_header_digests( &mut header.as_slice(), SeqCursor::new(&[header.as_slice(), self.content.as_slice()]), )?; - let package_contained = self.metadata.get_digests()?; - if recreated_from_content == package_contained { - Ok(DigestVerificationOutcome::Match) - } else { - Ok(DigestVerificationOutcome::Mismatch) + let md5 = self + .metadata + .signature + .get_entry_data_as_binary(IndexSignatureTag::RPMSIGTAG_MD5); + let sha1 = self + .metadata + .signature + .get_entry_data_as_string(IndexSignatureTag::RPMSIGTAG_SHA1); + let sha256 = self + .metadata + .signature + .get_entry_data_as_string(IndexSignatureTag::RPMSIGTAG_SHA256); + + if let Ok(md5) = md5 { + if md5 != recreated_from_content.header_and_content_digest { + return Err(RPMError::DigestMismatchError); + } } + + if let Ok(sha1) = sha1 { + if sha1 != recreated_from_content.header_digest { + return Err(RPMError::DigestMismatchError); + } + } + + if let Ok(sha256) = sha256 { + let sha256_calculated = { + let mut hasher = sha2::Sha256::default(); + hasher.update(self.content.as_slice()); + hex::encode(hasher.finalize()) + }; + if sha256 != sha256_calculated { + return Err(RPMError::DigestMismatchError); + } + } + + let payload_digest_val = self + .metadata + .header + .get_entry_data_as_string_array(IndexTag::RPMTAG_PAYLOADDIGEST); + let payload_digest_algo = self + .metadata + .header + .get_entry_data_as_u32(IndexTag::RPMTAG_PAYLOADDIGESTALGO); + + if let (Ok(payload_digest_val), Ok(payload_digest_algo)) = + (payload_digest_val, payload_digest_algo) + { + let payload_digest_algo = FileDigestAlgorithm::from_u32(payload_digest_algo) + .expect("Completely unknown payload digest algorithm"); + + // @todo: UnsupportedFileDigestAlgorithm is awkward, if a number is outside the range of the expected + // variants to begin with, we can't even return it, as it carries a FileDigestAlgorithm + + let mut hasher = match payload_digest_algo { + FileDigestAlgorithm::Sha2_256 => sha2::Sha256::default(), + a => return Err(RPMError::UnsupportedFileDigestAlgorithm(a)), + // At the present moment even rpmbuild only supports sha256 + }; + let payload_digest = { + hasher.update(self.content.as_slice()); + hex::encode(hasher.finalize()) + }; + if payload_digest != payload_digest_val[0] { + return Err(RPMError::DigestMismatchError); + } + } + + Ok(()) } } @@ -294,19 +351,6 @@ impl RPMPackageMetadata { Ok(()) } - pub fn get_digests(&self) -> Result { - let md5 = self - .signature - .get_entry_data_as_binary(IndexSignatureTag::RPMSIGTAG_MD5)?; - let sha1 = self - .signature - .get_entry_data_as_string(IndexSignatureTag::RPMSIGTAG_SHA1)?; - Ok(Digests { - header_digest: sha1.to_owned(), - header_and_content_digest: Vec::from(md5), - }) - } - #[inline] pub fn is_source_package(&self) -> bool { self.header diff --git a/src/tests.rs b/src/tests.rs index e1fe48dd..0ec0ddef 100644 --- a/src/tests.rs +++ b/src/tests.rs @@ -284,6 +284,8 @@ fn test_rpm_header_base(package: RPMPackage) -> Result<(), Box Result<(), Box> { pkg.write(&mut buff)?; + pkg.verify_digests()?; + Ok(()) }