|
| 1 | +//! A `.nupkg`'s content hash: the `contentHash` NuGet writes into |
| 2 | +//! `packages.lock.json` and `.nupkg.metadata` (#624). |
| 3 | +//! |
| 4 | +//! For an unsigned package it is the base64 SHA-512 of the file. For a |
| 5 | +//! signed package — nuget.org repository-signs every package — NuGet hashes |
| 6 | +//! the archive AS IF the `.signature.p7s` entry were absent |
| 7 | +//! (`PackageArchiveReader.GetContentHash` → |
| 8 | +//! `SignedPackageArchiveUtility.GetPackageContentHash`): |
| 9 | +//! |
| 10 | +//! 1. the bytes before the first (non-signature) local file entry; |
| 11 | +//! 2. every non-signature file entry (local header, data, data |
| 12 | +//! descriptor), in archive order; |
| 13 | +//! 3. every non-signature central directory record, in directory order, |
| 14 | +//! with its local-header offset moved back by the signature entry's size |
| 15 | +//! when the entry it points at follows the signature; |
| 16 | +//! 4. the end-of-central-directory record with the entry counts one lower, |
| 17 | +//! the directory size less the signature's record and the directory |
| 18 | +//! offset less the signature entry's size, then the rest of the file. |
| 19 | +//! |
| 20 | +//! So the catalog `packageHash` (SHA-512 of the signed file as served) is |
| 21 | +//! NOT a lock's `contentHash`, and pinning it fails every restore NU1403. |
| 22 | +//! Zip64 archives are refused rather than guessed at. |
| 23 | +
|
| 24 | +use sha2::{Digest, Sha512}; |
| 25 | + |
| 26 | +/// The signature entry NuGet excludes (`SigningSpecifications.SignaturePath`). |
| 27 | +const SIGNATURE_PATH: &[u8] = b".signature.p7s"; |
| 28 | + |
| 29 | +const EOCD_SIG: u32 = 0x0605_4b50; |
| 30 | +const ZIP64_LOCATOR_SIG: u32 = 0x0706_4b50; |
| 31 | +const CENTRAL_SIG: u32 = 0x0201_4b50; |
| 32 | +const LOCAL_SIG: u32 = 0x0403_4b50; |
| 33 | +const DESCRIPTOR_SIG: u32 = 0x0807_4b50; |
| 34 | +const EOCD_LEN: usize = 22; |
| 35 | + |
| 36 | +fn u16_at(b: &[u8], at: usize) -> Result<u16, String> { |
| 37 | + b.get(at..at + 2) |
| 38 | + .map(|s| u16::from_le_bytes([s[0], s[1]])) |
| 39 | + .ok_or_else(|| truncated(at)) |
| 40 | +} |
| 41 | + |
| 42 | +fn u32_at(b: &[u8], at: usize) -> Result<u32, String> { |
| 43 | + b.get(at..at + 4) |
| 44 | + .map(|s| u32::from_le_bytes([s[0], s[1], s[2], s[3]])) |
| 45 | + .ok_or_else(|| truncated(at)) |
| 46 | +} |
| 47 | + |
| 48 | +fn truncated(at: usize) -> String { |
| 49 | + format!("the package archive is truncated at byte {at}") |
| 50 | +} |
| 51 | + |
| 52 | +/// One central directory record and the file entry it describes. |
| 53 | +struct Record { |
| 54 | + /// Offset of the central directory record. |
| 55 | + position: usize, |
| 56 | + header_size: usize, |
| 57 | + local_offset: usize, |
| 58 | + /// Local header + data + data descriptor. |
| 59 | + entry_size: usize, |
| 60 | + is_signature: bool, |
| 61 | +} |
| 62 | + |
| 63 | +/// The base64 SHA-512 NuGet records as `contentHash` for `nupkg`. |
| 64 | +pub(crate) fn package_content_hash(nupkg: &[u8]) -> Result<String, String> { |
| 65 | + use base64::Engine as _; |
| 66 | + let eocd = find_eocd(nupkg)?; |
| 67 | + if eocd >= 20 && u32_at(nupkg, eocd - 20)? == ZIP64_LOCATOR_SIG { |
| 68 | + return Err("zip64 package archives are not supported".to_string()); |
| 69 | + } |
| 70 | + let entries_disk = u16_at(nupkg, eocd + 8)?; |
| 71 | + let entries = u16_at(nupkg, eocd + 10)?; |
| 72 | + let cd_size = u32_at(nupkg, eocd + 12)?; |
| 73 | + let cd_offset = u32_at(nupkg, eocd + 16)?; |
| 74 | + if entries == u16::MAX || cd_size == u32::MAX || cd_offset == u32::MAX { |
| 75 | + return Err("zip64 package archives are not supported".to_string()); |
| 76 | + } |
| 77 | + if entries_disk != entries || u16_at(nupkg, eocd + 4)? != 0 || u16_at(nupkg, eocd + 6)? != 0 { |
| 78 | + return Err("multi-disk package archives are not supported".to_string()); |
| 79 | + } |
| 80 | + let mut records = Vec::with_capacity(entries as usize); |
| 81 | + let mut at = cd_offset as usize; |
| 82 | + for _ in 0..entries { |
| 83 | + if u32_at(nupkg, at)? != CENTRAL_SIG { |
| 84 | + return Err(format!("no central directory record at byte {at}")); |
| 85 | + } |
| 86 | + let flags = u16_at(nupkg, at + 8)?; |
| 87 | + let compressed = u32_at(nupkg, at + 20)? as usize; |
| 88 | + let name_len = u16_at(nupkg, at + 28)? as usize; |
| 89 | + let extra_len = u16_at(nupkg, at + 30)? as usize; |
| 90 | + let comment_len = u16_at(nupkg, at + 32)? as usize; |
| 91 | + let local_offset = u32_at(nupkg, at + 42)? as usize; |
| 92 | + let name = nupkg |
| 93 | + .get(at + 46..at + 46 + name_len) |
| 94 | + .ok_or_else(|| truncated(at + 46))?; |
| 95 | + if u32_at(nupkg, local_offset)? != LOCAL_SIG { |
| 96 | + return Err(format!("no local file header at byte {local_offset}")); |
| 97 | + } |
| 98 | + let local_header = 30 |
| 99 | + + u16_at(nupkg, local_offset + 26)? as usize |
| 100 | + + u16_at(nupkg, local_offset + 28)? as usize; |
| 101 | + let mut entry_size = local_header + compressed; |
| 102 | + if flags & 0x0008 != 0 { |
| 103 | + // A data descriptor follows the data, with or without its |
| 104 | + // optional signature. |
| 105 | + let d = local_offset + entry_size; |
| 106 | + entry_size += if u32_at(nupkg, d)? == DESCRIPTOR_SIG { |
| 107 | + 16 |
| 108 | + } else { |
| 109 | + 12 |
| 110 | + }; |
| 111 | + } |
| 112 | + if local_offset + entry_size > nupkg.len() { |
| 113 | + return Err(truncated(local_offset + entry_size)); |
| 114 | + } |
| 115 | + let header_size = 46 + name_len + extra_len + comment_len; |
| 116 | + // The whole record is hashed below: it must lie inside the archive. |
| 117 | + if at + header_size > nupkg.len() { |
| 118 | + return Err(truncated(at + header_size)); |
| 119 | + } |
| 120 | + records.push(Record { |
| 121 | + position: at, |
| 122 | + header_size, |
| 123 | + local_offset, |
| 124 | + entry_size, |
| 125 | + is_signature: name == SIGNATURE_PATH, |
| 126 | + }); |
| 127 | + at += header_size; |
| 128 | + } |
| 129 | + let mut signatures = records.iter().filter(|r| r.is_signature); |
| 130 | + let signature = match (signatures.next(), signatures.next()) { |
| 131 | + (None, _) => return Ok(crate::utils::digest::sha512_base64_of(nupkg)), |
| 132 | + (Some(sig), None) => (sig.local_offset, sig.entry_size, sig.header_size), |
| 133 | + (Some(_), Some(_)) => return Err("the package has two signature entries".to_string()), |
| 134 | + }; |
| 135 | + let (sig_offset, sig_entry_size, sig_header_size) = signature; |
| 136 | + let mut rest: Vec<&Record> = records.iter().filter(|r| !r.is_signature).collect(); |
| 137 | + if rest.is_empty() { |
| 138 | + return Err("the package holds nothing but its signature".to_string()); |
| 139 | + } |
| 140 | + |
| 141 | + let inconsistent = |
| 142 | + || "the package's signature entry is inconsistent with its directory".to_string(); |
| 143 | + let mut hash = Sha512::new(); |
| 144 | + rest.sort_by_key(|r| r.local_offset); |
| 145 | + hash.update(&nupkg[..rest[0].local_offset]); |
| 146 | + for r in &rest { |
| 147 | + hash.update(&nupkg[r.local_offset..r.local_offset + r.entry_size]); |
| 148 | + } |
| 149 | + rest.sort_by_key(|r| r.position); |
| 150 | + for r in &rest { |
| 151 | + hash.update(&nupkg[r.position..r.position + 42]); |
| 152 | + let offset = if r.local_offset > sig_offset { |
| 153 | + r.local_offset - sig_entry_size |
| 154 | + } else { |
| 155 | + r.local_offset |
| 156 | + }; |
| 157 | + hash.update( |
| 158 | + u32::try_from(offset) |
| 159 | + .map_err(|_| inconsistent())? |
| 160 | + .to_le_bytes(), |
| 161 | + ); |
| 162 | + hash.update(&nupkg[r.position + 46..r.position + r.header_size]); |
| 163 | + } |
| 164 | + hash.update(&nupkg[eocd..eocd + 8]); |
| 165 | + hash.update((entries_disk - 1).to_le_bytes()); |
| 166 | + hash.update((entries - 1).to_le_bytes()); |
| 167 | + let cd_size = u32::try_from(sig_header_size) |
| 168 | + .ok() |
| 169 | + .and_then(|n| cd_size.checked_sub(n)) |
| 170 | + .ok_or_else(inconsistent)?; |
| 171 | + let cd_offset = u32::try_from(sig_entry_size) |
| 172 | + .ok() |
| 173 | + .and_then(|n| cd_offset.checked_sub(n)) |
| 174 | + .ok_or_else(inconsistent)?; |
| 175 | + hash.update(cd_size.to_le_bytes()); |
| 176 | + hash.update(cd_offset.to_le_bytes()); |
| 177 | + hash.update(&nupkg[eocd + 20..]); |
| 178 | + Ok(base64::engine::general_purpose::STANDARD.encode(hash.finalize())) |
| 179 | +} |
| 180 | + |
| 181 | +/// Offset of the end-of-central-directory record: the last signature whose |
| 182 | +/// comment length reaches exactly to the end of the file. |
| 183 | +fn find_eocd(b: &[u8]) -> Result<usize, String> { |
| 184 | + if b.len() < EOCD_LEN { |
| 185 | + return Err("the package is not a zip archive".to_string()); |
| 186 | + } |
| 187 | + let floor = b.len().saturating_sub(EOCD_LEN + u16::MAX as usize); |
| 188 | + (floor..=b.len() - EOCD_LEN) |
| 189 | + .rev() |
| 190 | + .find(|&at| { |
| 191 | + u32_at(b, at) == Ok(EOCD_SIG) |
| 192 | + && u16_at(b, at + 20).is_ok_and(|c| at + EOCD_LEN + c as usize == b.len()) |
| 193 | + }) |
| 194 | + .ok_or_else(|| "the package is not a zip archive".to_string()) |
| 195 | +} |
| 196 | + |
| 197 | +#[cfg(test)] |
| 198 | +mod tests { |
| 199 | + use super::*; |
| 200 | + use std::io::Write as _; |
| 201 | + |
| 202 | + fn zip(entries: &[(&str, &[u8])], descriptor_free: bool) -> Vec<u8> { |
| 203 | + let mut zw = zip::ZipWriter::new(std::io::Cursor::new(Vec::new())); |
| 204 | + let opts = zip::write::SimpleFileOptions::default() |
| 205 | + .last_modified_time(zip::DateTime::default()) |
| 206 | + .compression_method(if descriptor_free { |
| 207 | + zip::CompressionMethod::Stored |
| 208 | + } else { |
| 209 | + zip::CompressionMethod::Deflated |
| 210 | + }); |
| 211 | + for (name, data) in entries { |
| 212 | + zw.start_file(*name, opts).unwrap(); |
| 213 | + zw.write_all(data).unwrap(); |
| 214 | + } |
| 215 | + zw.finish().unwrap().into_inner() |
| 216 | + } |
| 217 | + |
| 218 | + const FILES: [(&str, &[u8]); 3] = [ |
| 219 | + ("[Content_Types].xml", b"<?xml version=\"1.0\"?><Types/>"), |
| 220 | + ( |
| 221 | + "pkg.nuspec", |
| 222 | + b"<package><metadata><id>Pkg</id></metadata></package>", |
| 223 | + ), |
| 224 | + ( |
| 225 | + "lib/net8.0/Pkg.dll", |
| 226 | + b"MZ-not-really-an-assembly-but-long-enough", |
| 227 | + ), |
| 228 | + ]; |
| 229 | + |
| 230 | + #[test] |
| 231 | + fn unsigned_package_hashes_the_whole_file() { |
| 232 | + let bytes = zip(&FILES, true); |
| 233 | + assert_eq!( |
| 234 | + package_content_hash(&bytes).unwrap(), |
| 235 | + crate::utils::digest::sha512_base64_of(&bytes) |
| 236 | + ); |
| 237 | + } |
| 238 | + |
| 239 | + /// A signature appended last (where NuGet places it) hashes exactly like |
| 240 | + /// the same archive written without it. |
| 241 | + #[test] |
| 242 | + fn signed_package_hashes_as_if_unsigned() { |
| 243 | + for stored in [true, false] { |
| 244 | + let unsigned = zip(&FILES, stored); |
| 245 | + let mut with_sig: Vec<(&str, &[u8])> = FILES.to_vec(); |
| 246 | + with_sig.push((".signature.p7s", b"PKCS7-signature-bytes")); |
| 247 | + let signed = zip(&with_sig, stored); |
| 248 | + let hash = package_content_hash(&signed).unwrap(); |
| 249 | + assert_ne!(hash, crate::utils::digest::sha512_base64_of(&signed)); |
| 250 | + assert_eq!(hash, crate::utils::digest::sha512_base64_of(&unsigned)); |
| 251 | + } |
| 252 | + } |
| 253 | + |
| 254 | + /// A signature that is not the last entry: the entries after it have |
| 255 | + /// their offsets moved back by its size. |
| 256 | + #[test] |
| 257 | + fn signature_in_the_middle_is_excluded_with_offsets_fixed() { |
| 258 | + let unsigned = zip(&FILES, true); |
| 259 | + let signed = zip( |
| 260 | + &[ |
| 261 | + FILES[0], |
| 262 | + (".signature.p7s", b"PKCS7-signature-bytes"), |
| 263 | + FILES[1], |
| 264 | + FILES[2], |
| 265 | + ], |
| 266 | + true, |
| 267 | + ); |
| 268 | + assert_eq!( |
| 269 | + package_content_hash(&signed).unwrap(), |
| 270 | + crate::utils::digest::sha512_base64_of(&unsigned) |
| 271 | + ); |
| 272 | + } |
| 273 | + |
| 274 | + #[test] |
| 275 | + fn malformed_archives_are_refused() { |
| 276 | + assert!(package_content_hash(b"").is_err()); |
| 277 | + assert!(package_content_hash(b"not a zip at all, just some bytes").is_err()); |
| 278 | + let mut bytes = zip(&FILES, true); |
| 279 | + bytes.truncate(bytes.len() / 2); |
| 280 | + assert!(package_content_hash(&bytes).is_err()); |
| 281 | + // A central-directory record whose extra/comment lengths run past |
| 282 | + // the end of the archive is refused, not sliced out of bounds. |
| 283 | + let mut with_sig: Vec<(&str, &[u8])> = FILES.to_vec(); |
| 284 | + with_sig.push((".signature.p7s", b"sig")); |
| 285 | + let mut bytes = zip(&with_sig, true); |
| 286 | + let eocd = find_eocd(&bytes).unwrap(); |
| 287 | + let cd = u32_at(&bytes, eocd + 16).unwrap() as usize; |
| 288 | + bytes[cd + 32..cd + 34].copy_from_slice(&u16::MAX.to_le_bytes()); |
| 289 | + assert!(package_content_hash(&bytes).is_err()); |
| 290 | + } |
| 291 | +} |
0 commit comments