use std::fs::File;
use std::io::{Read, Seek, SeekFrom};
use std::path::Path;
#[cfg(feature = "dmg-encrypted")]
use std::io::{self, Write};
use crate::Result;
#[cfg(feature = "dmg-encrypted")]
use crate::block::BlockDevice;
pub const ENCRCDSA_MAGIC: &[u8; 8] = b"encrcdsa";
pub const ENCRCDSA_V2_HEADER_MIN_BYTES: usize = 0x4C;
const KEY_ENTRY_BYTES: usize = 20;
const PASSPHRASE_RECORD_FIXED_BYTES: usize = 0x68;
const MAX_KEY_ENTRIES: u32 = 64;
const MAX_KEY_RECORD_BYTES: u64 = 64 * 1024;
pub mod algid {
pub const AES: u32 = 0x8000_0001;
pub const TDES_3KEY_EDE: u32 = 17;
pub const PKCS5_PBKDF2: u32 = 0x67;
}
pub const KEY_ENTRY_PASSPHRASE: u32 = 1;
pub fn probe(path: &Path) -> Result<bool> {
let mut f = match File::open(path) {
Ok(f) => f,
Err(_) => return Ok(false),
};
let mut head = [0u8; 8];
if f.read_exact(&mut head).is_err() {
return Ok(false);
}
Ok(&head == ENCRCDSA_MAGIC)
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct KeyEntry {
pub kind: u32,
pub offset: u64,
pub size: u64,
}
#[derive(Debug, Clone)]
pub struct PassphraseKey {
pub kdf_algorithm: u32,
pub kdf_prng_algorithm: u32,
pub pbkdf2_iteration_count: u32,
pub pbkdf2_salt_length: u32,
pub pbkdf2_salt: [u8; 32],
pub blob_enc_iv_size: u32,
pub blob_enc_iv: [u8; 32],
pub blob_enc_key_bits: u32,
pub blob_enc_algorithm: u32,
pub blob_enc_padding: u32,
pub blob_enc_mode: u32,
pub encrypted_keyblob: Vec<u8>,
}
impl PassphraseKey {
pub fn decode(rec: &[u8]) -> Result<Self> {
if rec.len() < PASSPHRASE_RECORD_FIXED_BYTES {
return Err(crate::Error::InvalidImage(format!(
"encrcdsa: passphrase key record is {} bytes, need >= {PASSPHRASE_RECORD_FIXED_BYTES}",
rec.len()
)));
}
let kdf_algorithm = u32_be(rec, 0x00);
let kdf_prng_algorithm = u32_be(rec, 0x04);
let pbkdf2_iteration_count = u32_be(rec, 0x08);
let pbkdf2_salt_length = u32_be(rec, 0x0C);
if pbkdf2_salt_length > 32 {
return Err(crate::Error::InvalidImage(format!(
"encrcdsa: pbkdf2_salt_length {pbkdf2_salt_length} exceeds 32-byte salt buffer"
)));
}
let mut pbkdf2_salt = [0u8; 32];
pbkdf2_salt.copy_from_slice(&rec[0x10..0x30]);
let blob_enc_iv_size = u32_be(rec, 0x30);
if blob_enc_iv_size > 32 {
return Err(crate::Error::InvalidImage(format!(
"encrcdsa: blob_enc_iv_size {blob_enc_iv_size} exceeds 32-byte IV buffer"
)));
}
if blob_enc_iv_size < 8 {
return Err(crate::Error::InvalidImage(format!(
"encrcdsa: blob_enc_iv_size {blob_enc_iv_size} too small (need >= 8 for CBC)"
)));
}
let mut blob_enc_iv = [0u8; 32];
blob_enc_iv.copy_from_slice(&rec[0x34..0x54]);
let blob_enc_key_bits = u32_be(rec, 0x54);
let blob_enc_algorithm = u32_be(rec, 0x58);
let blob_enc_padding = u32_be(rec, 0x5C);
let blob_enc_mode = u32_be(rec, 0x60);
let encrypted_keyblob_size = u32_be(rec, 0x64);
let blob_end = PASSPHRASE_RECORD_FIXED_BYTES
.checked_add(encrypted_keyblob_size as usize)
.filter(|&end| end <= rec.len())
.ok_or_else(|| {
crate::Error::InvalidImage(format!(
"encrcdsa: keyblob ({encrypted_keyblob_size} bytes) overruns its \
{}-byte key record",
rec.len()
))
})?;
let encrypted_keyblob = rec[PASSPHRASE_RECORD_FIXED_BYTES..blob_end].to_vec();
Ok(Self {
kdf_algorithm,
kdf_prng_algorithm,
pbkdf2_iteration_count,
pbkdf2_salt_length,
pbkdf2_salt,
blob_enc_iv_size,
blob_enc_iv,
blob_enc_key_bits,
blob_enc_algorithm,
blob_enc_padding,
blob_enc_mode,
encrypted_keyblob,
})
}
pub fn salt(&self) -> &[u8] {
&self.pbkdf2_salt[..self.pbkdf2_salt_length as usize]
}
pub fn blob_iv(&self) -> &[u8] {
&self.blob_enc_iv[..self.blob_enc_iv_size as usize]
}
}
#[derive(Debug, Clone)]
pub struct EncryptedDmgHeader {
pub version: u32,
pub enc_iv_size: u32,
pub encryption_mode: u32,
pub encryption_algorithm: u32,
pub key_bits: u32,
pub prng_algorithm: u32,
pub prng_key_size: u32,
pub uuid: [u8; 16],
pub block_size: u32,
pub data_size: u64,
pub data_offset: u64,
pub key_entries: Vec<KeyEntry>,
pub passphrase_keys: Vec<PassphraseKey>,
}
impl EncryptedDmgHeader {
pub fn decode_prefix(buf: &[u8]) -> Result<Self> {
if buf.len() < ENCRCDSA_V2_HEADER_MIN_BYTES {
return Err(crate::Error::InvalidImage(format!(
"encrcdsa: header slice shorter than {ENCRCDSA_V2_HEADER_MIN_BYTES} bytes"
)));
}
if &buf[0..8] != ENCRCDSA_MAGIC {
return Err(crate::Error::InvalidImage(
"encrcdsa: magic mismatch (expected \"encrcdsa\")".into(),
));
}
let version = u32_be(buf, 0x08);
if version != 2 {
return Err(crate::Error::Unsupported(format!(
"encrcdsa: version {version} not supported (only v2)"
)));
}
let enc_iv_size = u32_be(buf, 0x0C);
let encryption_mode = u32_be(buf, 0x10);
let encryption_algorithm = u32_be(buf, 0x14);
let key_bits = u32_be(buf, 0x18);
let prng_algorithm = u32_be(buf, 0x1C);
let prng_key_size = u32_be(buf, 0x20);
let mut uuid = [0u8; 16];
uuid.copy_from_slice(&buf[0x24..0x34]);
let block_size = u32_be(buf, 0x34);
let data_size = u64_be(buf, 0x38);
let data_offset = u64_be(buf, 0x40);
let key_count = u32_be(buf, 0x48);
if key_count > MAX_KEY_ENTRIES {
return Err(crate::Error::InvalidImage(format!(
"encrcdsa: key_count {key_count} exceeds maximum {MAX_KEY_ENTRIES}"
)));
}
let table_end = ENCRCDSA_V2_HEADER_MIN_BYTES + key_count as usize * KEY_ENTRY_BYTES;
if buf.len() < table_end {
return Err(crate::Error::InvalidImage(format!(
"encrcdsa: key-entry table ({key_count} entries) overruns the \
{}-byte header buffer",
buf.len()
)));
}
let key_entries = (0..key_count as usize)
.map(|i| {
let at = ENCRCDSA_V2_HEADER_MIN_BYTES + i * KEY_ENTRY_BYTES;
KeyEntry {
kind: u32_be(buf, at),
offset: u64_be(buf, at + 4),
size: u64_be(buf, at + 12),
}
})
.collect();
Ok(Self {
version,
enc_iv_size,
encryption_mode,
encryption_algorithm,
key_bits,
prng_algorithm,
prng_key_size,
uuid,
block_size,
data_size,
data_offset,
key_entries,
passphrase_keys: Vec::new(),
})
}
pub fn required_len(&self) -> Result<usize> {
let mut need = ENCRCDSA_V2_HEADER_MIN_BYTES + self.key_entries.len() * KEY_ENTRY_BYTES;
for e in &self.key_entries {
if e.size > MAX_KEY_RECORD_BYTES {
return Err(crate::Error::InvalidImage(format!(
"encrcdsa: key record of {} bytes exceeds maximum {MAX_KEY_RECORD_BYTES}",
e.size
)));
}
let end = e
.offset
.checked_add(e.size)
.filter(|&end| end <= usize::MAX as u64)
.ok_or_else(|| {
crate::Error::InvalidImage(
"encrcdsa: key record offset + size overflows".into(),
)
})?;
need = need.max(end as usize);
}
Ok(need)
}
pub fn decode(buf: &[u8]) -> Result<Self> {
let mut h = Self::decode_prefix(buf)?;
let need = h.required_len()?;
if buf.len() < need {
return Err(crate::Error::InvalidImage(format!(
"encrcdsa: key records reach offset {need}, past the {}-byte header buffer",
buf.len()
)));
}
for e in &h.key_entries {
if e.kind != KEY_ENTRY_PASSPHRASE {
continue;
}
let rec = &buf[e.offset as usize..(e.offset + e.size) as usize];
h.passphrase_keys.push(PassphraseKey::decode(rec)?);
}
Ok(h)
}
pub fn n_chunks(&self) -> u64 {
if self.block_size == 0 {
return 0;
}
self.data_size.div_ceil(self.block_size as u64)
}
pub fn aes_key_len(&self) -> Result<usize> {
match self.key_bits {
128 => Ok(16),
192 => Ok(24),
256 => Ok(32),
other => Err(crate::Error::Unsupported(format!(
"encrcdsa: unsupported key_bits {other} (expected 128, 192 or 256)"
))),
}
}
}
fn u32_be(buf: &[u8], at: usize) -> u32 {
u32::from_be_bytes(buf[at..at + 4].try_into().unwrap())
}
fn u64_be(buf: &[u8], at: usize) -> u64 {
u64::from_be_bytes(buf[at..at + 8].try_into().unwrap())
}
fn read_prefix(file: &mut File, len: usize) -> Result<Vec<u8>> {
file.seek(SeekFrom::Start(0))?;
let mut buf = Vec::with_capacity(len);
file.take(len as u64).read_to_end(&mut buf)?;
Ok(buf)
}
pub fn read_header(file: &mut File) -> Result<EncryptedDmgHeader> {
let table_max = ENCRCDSA_V2_HEADER_MIN_BYTES + MAX_KEY_ENTRIES as usize * KEY_ENTRY_BYTES;
let head = read_prefix(file, table_max)?;
let prefix = EncryptedDmgHeader::decode_prefix(&head)?;
let need = prefix.required_len()?;
let buf = if need <= head.len() {
head
} else {
read_prefix(file, need)?
};
EncryptedDmgHeader::decode(&buf)
}
#[cfg(feature = "dmg-encrypted")]
#[derive(Debug)]
pub struct EncryptedDmgBackend {
file: File,
header: EncryptedDmgHeader,
aes_key: Vec<u8>,
hmac_key: [u8; 20],
virtual_size: u64,
cursor: u64,
}
#[cfg(feature = "dmg-encrypted")]
impl EncryptedDmgBackend {
pub fn open_with_password(path: &Path, password: &str) -> Result<Self> {
let mut file = File::open(path)?;
let header = read_header(&mut file)?;
if header.encryption_algorithm != algid::AES {
return Err(crate::Error::Unsupported(format!(
"encrcdsa: encryption_algorithm {:#x} not supported (only {:#x} = AES)",
header.encryption_algorithm,
algid::AES
)));
}
let aes_key_len = header.aes_key_len()?;
if header.block_size == 0 || !header.block_size.is_multiple_of(16) {
return Err(crate::Error::InvalidImage(format!(
"encrcdsa: block_size {} is not a positive multiple of the 16-byte AES block",
header.block_size
)));
}
const MAX_BLOCK_SIZE: u32 = 1 << 20;
if header.block_size > MAX_BLOCK_SIZE {
return Err(crate::Error::InvalidImage(format!(
"encrcdsa: block_size {} exceeds maximum {MAX_BLOCK_SIZE}",
header.block_size
)));
}
if !header.data_size.is_multiple_of(16) {
return Err(crate::Error::InvalidImage(format!(
"encrcdsa: data_size {} is not a multiple of the 16-byte AES block",
header.data_size
)));
}
let file_len = file.metadata()?.len();
let data_end = header
.data_offset
.checked_add(header.data_size)
.ok_or_else(|| {
crate::Error::InvalidImage("encrcdsa: data_offset + data_size overflows u64".into())
})?;
if data_end > file_len {
return Err(crate::Error::InvalidImage(format!(
"encrcdsa: data extent (offset {} + size {} = {}) exceeds file length {}",
header.data_offset, header.data_size, data_end, file_len
)));
}
if header.passphrase_keys.is_empty() {
return Err(crate::Error::Unsupported(format!(
"encrcdsa: no passphrase key entry among {} key entries \
(certificate-only images are not supported)",
header.key_entries.len()
)));
}
let needed = aes_key_len + 20;
let mut keyblob_plain = None;
let mut last_err = None;
for key in &header.passphrase_keys {
match unwrap_keyblob(key, password) {
Ok(plain) if plain.len() >= needed => {
keyblob_plain = Some(plain);
break;
}
Ok(plain) => {
last_err = Some(crate::Error::InvalidImage(format!(
"encrcdsa: unwrapped keyblob too short ({} bytes, need >= {needed})",
plain.len()
)));
}
Err(e) => last_err = Some(e),
}
}
let keyblob_plain = match keyblob_plain {
Some(p) => p,
None => return Err(last_err.expect("at least one passphrase key was tried")),
};
let aes_key = keyblob_plain[..aes_key_len].to_vec();
let mut hmac_key = [0u8; 20];
hmac_key.copy_from_slice(&keyblob_plain[aes_key_len..aes_key_len + 20]);
let virtual_size = header.data_size;
Ok(Self {
file,
header,
aes_key,
hmac_key,
virtual_size,
cursor: 0,
})
}
pub fn header(&self) -> &EncryptedDmgHeader {
&self.header
}
fn chunk_len(&self, chunk_base: u64) -> u64 {
(self.header.block_size as u64).min(self.virtual_size - chunk_base)
}
fn decrypt_chunk(&mut self, chunk_index: u64) -> Result<Vec<u8>> {
let index32 = u32::try_from(chunk_index).map_err(|_| {
crate::Error::InvalidImage(format!(
"encrcdsa: chunk index {chunk_index} does not fit the 32-bit IV counter"
))
})?;
let rel = chunk_index * self.header.block_size as u64;
let len = self.chunk_len(rel) as usize;
let abs_offset = self.header.data_offset.checked_add(rel).ok_or_else(|| {
crate::Error::InvalidImage(
"encrcdsa: chunk absolute offset overflows the data fork".into(),
)
})?;
self.file.seek(SeekFrom::Start(abs_offset))?;
let mut ciphertext = vec![0u8; len];
self.file.read_exact(&mut ciphertext)?;
let iv = chunk_iv(&self.hmac_key, index32);
aes_cbc_decrypt(&self.aes_key, &iv, &mut ciphertext)?;
Ok(ciphertext)
}
}
#[cfg(feature = "dmg-encrypted")]
fn aes_cbc_decrypt(key: &[u8], iv: &[u8; 16], buf: &mut [u8]) -> Result<()> {
use purecrypto::cipher::{Aes128, Aes192, Aes256, Cbc};
let res = match key.len() {
16 => Cbc::new(Aes128::new(key.try_into().unwrap()), iv).decrypt(buf),
24 => Cbc::new(Aes192::new(key.try_into().unwrap()), iv).decrypt(buf),
32 => Cbc::new(Aes256::new(key.try_into().unwrap()), iv).decrypt(buf),
other => {
return Err(crate::Error::InvalidImage(format!(
"encrcdsa: AES key has unexpected length {other}"
)));
}
};
res.map_err(|e| crate::Error::InvalidImage(format!("encrcdsa: AES-CBC: {e}")))
}
#[cfg(feature = "dmg-encrypted")]
fn unwrap_keyblob(key: &PassphraseKey, password: &str) -> Result<Vec<u8>> {
use purecrypto::cipher::{Cbc64, TdesEde3};
if key.kdf_algorithm != algid::PKCS5_PBKDF2 {
return Err(crate::Error::Unsupported(format!(
"encrcdsa: kdf_algorithm {:#x} not supported (only {:#x} = PKCS5_PBKDF2)",
key.kdf_algorithm,
algid::PKCS5_PBKDF2
)));
}
if key.pbkdf2_iteration_count == 0 {
return Err(crate::Error::InvalidImage(
"encrcdsa: pbkdf2 iteration count is zero".into(),
));
}
let (block, kek_len) = match (key.blob_enc_algorithm, key.blob_enc_key_bits) {
(algid::AES, 128) => (16usize, 16usize),
(algid::AES, 192) => (16, 24),
(algid::AES, 256) => (16, 32),
(algid::TDES_3KEY_EDE, 192) => (8, 24),
(alg, bits) => {
return Err(crate::Error::Unsupported(format!(
"encrcdsa: keyblob wrap algorithm {alg:#x} with {bits}-bit key not supported \
(only AES-128/192/256 and 3DES-EDE3)"
)));
}
};
let ct = &key.encrypted_keyblob;
if ct.is_empty() || !ct.len().is_multiple_of(block) {
return Err(crate::Error::InvalidImage(format!(
"encrcdsa: keyblob ciphertext length {} is not a positive multiple of {block}",
ct.len()
)));
}
let mut kek = vec![0u8; kek_len];
purecrypto::kdf::pbkdf2::<purecrypto::hash::Sha1>(
password.as_bytes(),
key.salt(),
key.pbkdf2_iteration_count,
&mut kek,
);
let mut buf = ct.clone();
let iv = key.blob_iv();
if block == 16 {
let mut iv16 = [0u8; 16];
let n = iv.len().min(16);
iv16[..n].copy_from_slice(&iv[..n]);
aes_cbc_decrypt(&kek, &iv16, &mut buf)?;
} else {
let iv8: [u8; 8] = iv[..8].try_into().unwrap();
let kek24: &[u8; 24] = kek.as_slice().try_into().unwrap();
Cbc64::new(TdesEde3::new(kek24), &iv8)
.decrypt(&mut buf)
.map_err(|e| crate::Error::InvalidImage(format!("encrcdsa: 3DES-CBC: {e}")))?;
}
let plain = strip_pkcs7(&buf, block).ok_or_else(|| {
crate::Error::Unsupported(
"encrcdsa: keyblob unwrap failed — wrong password, or unsupported padding".into(),
)
})?;
Ok(plain.to_vec())
}
#[cfg(feature = "dmg-encrypted")]
fn strip_pkcs7(buf: &[u8], block: usize) -> Option<&[u8]> {
let n = *buf.last()? as usize;
if n == 0 || n > block || n > buf.len() {
return None;
}
let cut = buf.len() - n;
if buf[cut..].iter().all(|&b| b as usize == n) {
Some(&buf[..cut])
} else {
None
}
}
#[cfg(feature = "dmg-encrypted")]
fn chunk_iv(hmac_key: &[u8; 20], chunk_index: u32) -> [u8; 16] {
use purecrypto::hash::{Hmac, Sha1};
let tag = Hmac::<Sha1>::mac(hmac_key, &chunk_index.to_be_bytes());
let mut iv = [0u8; 16];
iv.copy_from_slice(&tag[..16]);
iv
}
#[cfg(feature = "dmg-encrypted")]
impl BlockDevice for EncryptedDmgBackend {
fn block_size(&self) -> u32 {
512
}
fn total_size(&self) -> u64 {
self.virtual_size
}
fn sync(&mut self) -> Result<()> {
Ok(())
}
fn read_at(&mut self, offset: u64, buf: &mut [u8]) -> Result<()> {
let size = self.virtual_size;
let end = offset
.checked_add(buf.len() as u64)
.ok_or(crate::Error::OutOfBounds {
offset,
len: buf.len() as u64,
size,
})?;
if end > size {
return Err(crate::Error::OutOfBounds {
offset,
len: buf.len() as u64,
size,
});
}
if buf.is_empty() {
return Ok(());
}
let bs = self.header.block_size as u64;
let mut filled = 0usize;
let mut cursor = offset;
while filled < buf.len() {
let chunk_index = cursor / bs;
let chunk_base = chunk_index * bs;
let plain = self.decrypt_chunk(chunk_index)?;
let local_start = (cursor - chunk_base) as usize;
let available = plain.len() - local_start;
let want = (buf.len() - filled).min(available);
buf[filled..filled + want].copy_from_slice(&plain[local_start..local_start + want]);
filled += want;
cursor += want as u64;
}
Ok(())
}
fn write_at(&mut self, _offset: u64, _buf: &[u8]) -> Result<()> {
Err(crate::Error::Unsupported(
"encrcdsa: read-only container; writes are out of scope".into(),
))
}
}
#[cfg(feature = "dmg-encrypted")]
impl Read for EncryptedDmgBackend {
fn read(&mut self, buf: &mut [u8]) -> io::Result<usize> {
if self.cursor >= self.virtual_size {
return Ok(0);
}
let remaining = self.virtual_size - self.cursor;
let take = (buf.len() as u64).min(remaining) as usize;
if take == 0 {
return Ok(0);
}
self.read_at(self.cursor, &mut buf[..take])
.map_err(|e| io::Error::other(format!("{e}")))?;
self.cursor += take as u64;
Ok(take)
}
}
#[cfg(feature = "dmg-encrypted")]
impl Write for EncryptedDmgBackend {
fn write(&mut self, _buf: &[u8]) -> io::Result<usize> {
Err(io::Error::other("encrcdsa: read-only container"))
}
fn flush(&mut self) -> io::Result<()> {
Ok(())
}
}
#[cfg(feature = "dmg-encrypted")]
impl Seek for EncryptedDmgBackend {
fn seek(&mut self, pos: SeekFrom) -> io::Result<u64> {
let total = self.virtual_size;
let new = match pos {
SeekFrom::Start(o) => o,
SeekFrom::Current(d) => (self.cursor as i64).saturating_add(d).max(0) as u64,
SeekFrom::End(d) => (total as i64).saturating_add(d).max(0) as u64,
};
self.cursor = new;
Ok(new)
}
}
#[cfg(not(feature = "dmg-encrypted"))]
#[derive(Debug)]
pub struct EncryptedDmgBackend {
_never: std::convert::Infallible,
}
#[cfg(not(feature = "dmg-encrypted"))]
impl EncryptedDmgBackend {
pub fn open_with_password(_path: &Path, _password: &str) -> Result<Self> {
Err(crate::Error::Unsupported(
"encrcdsa: encrypted DMG support requires the `dmg-encrypted` Cargo feature".into(),
))
}
}
#[cfg(test)]
mod tests {
use super::*;
const REC_OFFSET: usize = ENCRCDSA_V2_HEADER_MIN_BYTES + KEY_ENTRY_BYTES;
#[derive(Clone, Copy)]
enum Wrap {
Aes192,
Tdes,
}
#[cfg(feature = "dmg-encrypted")]
fn encrypt_keyblob(
wrap: Wrap,
kek: &[u8; 24],
iv8: &[u8; 8],
aes_key: &[u8],
hmac_key: &[u8; 20],
) -> Vec<u8> {
use purecrypto::cipher::{Aes192, Cbc, Cbc64, TdesEde3};
let mut blob = [aes_key, &hmac_key[..]].concat();
match wrap {
Wrap::Aes192 => {
let pad = 16 - (blob.len() % 16);
blob.extend(std::iter::repeat_n(pad as u8, pad));
let mut iv16 = [0u8; 16];
iv16[..8].copy_from_slice(iv8);
Cbc::new(Aes192::new(kek), &iv16)
.encrypt(&mut blob)
.unwrap();
}
Wrap::Tdes => {
let pad = 8 - (blob.len() % 8);
blob.extend(std::iter::repeat_n(pad as u8, pad));
Cbc64::new(TdesEde3::new(kek), iv8)
.encrypt(&mut blob)
.unwrap();
}
}
blob
}
#[cfg(feature = "dmg-encrypted")]
fn aes_cbc_encrypt(key: &[u8], iv: &[u8; 16], buf: &mut [u8]) {
use purecrypto::cipher::{Aes128, Aes256, Cbc};
match key.len() {
16 => Cbc::new(Aes128::new(key.try_into().unwrap()), iv)
.encrypt(buf)
.unwrap(),
32 => Cbc::new(Aes256::new(key.try_into().unwrap()), iv)
.encrypt(buf)
.unwrap(),
n => panic!("test AES key must be 16 or 32 bytes, got {n}"),
}
}
#[allow(clippy::too_many_arguments)]
fn build_header_bytes(
wrap: Wrap,
iter_count: u32,
salt: &[u8],
blob_iv: &[u8],
keyblob: &[u8],
key_bits: u32,
block_size: u32,
data_size: u64,
data_offset: u64,
) -> Vec<u8> {
let rec_len = PASSPHRASE_RECORD_FIXED_BYTES + keyblob.len();
let mut buf = vec![0u8; (REC_OFFSET + rec_len).max(data_offset as usize)];
buf[0..8].copy_from_slice(ENCRCDSA_MAGIC);
buf[0x08..0x0C].copy_from_slice(&2u32.to_be_bytes());
buf[0x0C..0x10].copy_from_slice(&16u32.to_be_bytes());
buf[0x10..0x14].copy_from_slice(&5u32.to_be_bytes()); buf[0x14..0x18].copy_from_slice(&algid::AES.to_be_bytes());
buf[0x18..0x1C].copy_from_slice(&key_bits.to_be_bytes());
buf[0x1C..0x20].copy_from_slice(&0x5Bu32.to_be_bytes());
buf[0x20..0x24].copy_from_slice(&160u32.to_be_bytes());
buf[0x24..0x34].copy_from_slice(b"fstool-test-uuid");
buf[0x34..0x38].copy_from_slice(&block_size.to_be_bytes());
buf[0x38..0x40].copy_from_slice(&data_size.to_be_bytes());
buf[0x40..0x48].copy_from_slice(&data_offset.to_be_bytes());
buf[0x48..0x4C].copy_from_slice(&1u32.to_be_bytes());
buf[0x4C..0x50].copy_from_slice(&KEY_ENTRY_PASSPHRASE.to_be_bytes());
buf[0x50..0x58].copy_from_slice(&(REC_OFFSET as u64).to_be_bytes());
buf[0x58..0x60].copy_from_slice(&(rec_len as u64).to_be_bytes());
let r = REC_OFFSET;
buf[r..r + 4].copy_from_slice(&algid::PKCS5_PBKDF2.to_be_bytes());
buf[r + 0x04..r + 0x08].copy_from_slice(&0u32.to_be_bytes());
buf[r + 0x08..r + 0x0C].copy_from_slice(&iter_count.to_be_bytes());
buf[r + 0x0C..r + 0x10].copy_from_slice(&(salt.len() as u32).to_be_bytes());
buf[r + 0x10..r + 0x10 + salt.len()].copy_from_slice(salt);
buf[r + 0x30..r + 0x34].copy_from_slice(&(blob_iv.len() as u32).to_be_bytes());
buf[r + 0x34..r + 0x34 + blob_iv.len()].copy_from_slice(blob_iv);
buf[r + 0x54..r + 0x58].copy_from_slice(&192u32.to_be_bytes());
let alg = match wrap {
Wrap::Aes192 => algid::AES,
Wrap::Tdes => algid::TDES_3KEY_EDE,
};
buf[r + 0x58..r + 0x5C].copy_from_slice(&alg.to_be_bytes());
buf[r + 0x5C..r + 0x60].copy_from_slice(&7u32.to_be_bytes()); buf[r + 0x60..r + 0x64].copy_from_slice(&6u32.to_be_bytes()); buf[r + 0x64..r + 0x68].copy_from_slice(&(keyblob.len() as u32).to_be_bytes());
buf[r + 0x68..r + 0x68 + keyblob.len()].copy_from_slice(keyblob);
buf
}
fn plain_header() -> Vec<u8> {
build_header_bytes(
Wrap::Aes192,
1000,
b"saltsaltsaltsaltsalt",
b"iv8iv8iv",
&[0u8; 48],
128,
512,
4096,
0x400,
)
}
#[cfg(feature = "dmg-encrypted")]
#[test]
fn pbkdf2_hmac_sha1_rfc6070() {
fn derive(pw: &[u8], salt: &[u8], iters: u32, n: usize) -> Vec<u8> {
let mut out = vec![0u8; n];
purecrypto::kdf::pbkdf2::<purecrypto::hash::Sha1>(pw, salt, iters, &mut out);
out
}
assert_eq!(
derive(b"password", b"salt", 1, 20),
hex(b"0c60c80f961f0e71f3a9b524af6012062fe037a6")
);
assert_eq!(
derive(b"password", b"salt", 2, 20),
hex(b"ea6c014dc72d6f8ccd1ed92ace1d41f0d8de8957")
);
assert_eq!(
derive(
b"passwordPASSWORDpassword",
b"saltSALTsaltSALTsaltSALTsaltSALTsalt",
4096,
25
),
hex(b"3d2eec4fe41c849b80c8d83662c0e44a8b291a964cf2f07038")
);
}
#[cfg(feature = "dmg-encrypted")]
fn hex(s: &[u8]) -> Vec<u8> {
s.chunks(2)
.map(|c| {
let v = std::str::from_utf8(c).unwrap();
u8::from_str_radix(v, 16).unwrap()
})
.collect()
}
#[test]
fn header_decodes_minimal_v2() {
let buf = plain_header();
let h = EncryptedDmgHeader::decode(&buf).unwrap();
assert_eq!(h.version, 2);
assert_eq!(h.encryption_algorithm, algid::AES);
assert_eq!(h.key_bits, 128);
assert_eq!(h.aes_key_len().unwrap(), 16);
assert_eq!(h.block_size, 512);
assert_eq!(h.data_size, 4096);
assert_eq!(h.n_chunks(), 8);
assert_eq!(h.data_offset, 0x400);
assert_eq!(h.key_entries.len(), 1);
assert_eq!(h.key_entries[0].kind, KEY_ENTRY_PASSPHRASE);
assert_eq!(h.passphrase_keys.len(), 1);
let k = &h.passphrase_keys[0];
assert_eq!(k.kdf_algorithm, algid::PKCS5_PBKDF2);
assert_eq!(k.pbkdf2_iteration_count, 1000);
assert_eq!(k.salt(), b"saltsaltsaltsaltsalt");
assert_eq!(k.blob_iv(), b"iv8iv8iv");
assert_eq!(k.blob_enc_key_bits, 192);
assert_eq!(k.blob_enc_algorithm, algid::AES);
assert_eq!(k.encrypted_keyblob.len(), 48);
}
#[test]
fn header_decodes_real_hdiutil_image() {
let img: &[u8] = include_bytes!("testdata/encrcdsa_aes128_fat12_hunter2.dmg");
let h = EncryptedDmgHeader::decode(img).unwrap();
assert_eq!(h.version, 2);
assert_eq!(h.enc_iv_size, 16);
assert_eq!(h.encryption_algorithm, algid::AES);
assert_eq!(h.key_bits, 128);
assert_eq!(h.block_size, 512);
assert_eq!(h.data_size, 64 * 1024);
assert_eq!(h.data_offset, 0x1DE00);
assert_eq!(h.key_entries.len(), 1);
assert_eq!(h.key_entries[0].kind, KEY_ENTRY_PASSPHRASE);
assert_eq!(h.key_entries[0].offset, 0x60);
assert_eq!(h.passphrase_keys.len(), 1);
let k = &h.passphrase_keys[0];
assert_eq!(k.kdf_algorithm, algid::PKCS5_PBKDF2);
assert_eq!(k.pbkdf2_iteration_count, 625_000);
assert_eq!(k.pbkdf2_salt_length, 20);
assert_eq!(k.blob_enc_iv_size, 8);
assert_eq!(k.blob_enc_key_bits, 192);
assert_eq!(k.blob_enc_algorithm, algid::AES);
assert_eq!(k.blob_enc_padding, 7);
assert_eq!(k.encrypted_keyblob.len(), 0x30);
}
#[test]
fn header_rejects_wrong_magic() {
let mut buf = plain_header();
buf[0] = b'X';
let err = EncryptedDmgHeader::decode(&buf).unwrap_err();
assert!(matches!(err, crate::Error::InvalidImage(_)), "{err:?}");
}
#[test]
fn header_rejects_v1() {
let mut buf = plain_header();
buf[0x08..0x0C].copy_from_slice(&1u32.to_be_bytes());
let err = EncryptedDmgHeader::decode(&buf).unwrap_err();
assert!(matches!(err, crate::Error::Unsupported(_)), "{err:?}");
}
#[test]
fn header_rejects_oversized_salt_length() {
let mut buf = plain_header();
buf[REC_OFFSET + 0x0C..REC_OFFSET + 0x10].copy_from_slice(&33u32.to_be_bytes());
let err = EncryptedDmgHeader::decode(&buf).unwrap_err();
assert!(matches!(err, crate::Error::InvalidImage(_)));
}
#[test]
fn header_rejects_oversized_blob_iv_size() {
let mut buf = plain_header();
buf[REC_OFFSET + 0x30..REC_OFFSET + 0x34].copy_from_slice(&33u32.to_be_bytes());
let err = EncryptedDmgHeader::decode(&buf).unwrap_err();
assert!(matches!(err, crate::Error::InvalidImage(_)));
}
#[test]
fn header_rejects_undersized_blob_iv_size() {
let mut buf = plain_header();
buf[REC_OFFSET + 0x30..REC_OFFSET + 0x34].copy_from_slice(&4u32.to_be_bytes());
let err = EncryptedDmgHeader::decode(&buf).unwrap_err();
assert!(matches!(err, crate::Error::InvalidImage(_)));
}
#[test]
fn header_rejects_key_record_past_buffer() {
let mut buf = plain_header();
let past_end = buf.len() as u64;
buf[0x50..0x58].copy_from_slice(&past_end.to_be_bytes());
let err = EncryptedDmgHeader::decode(&buf).unwrap_err();
assert!(matches!(err, crate::Error::InvalidImage(_)), "{err:?}");
let mut buf = plain_header();
buf[0x58..0x60].copy_from_slice(&u64::MAX.to_be_bytes());
let err = EncryptedDmgHeader::decode(&buf).unwrap_err();
assert!(matches!(err, crate::Error::InvalidImage(_)), "{err:?}");
}
#[test]
fn header_rejects_absurd_key_count() {
let mut buf = plain_header();
buf[0x48..0x4C].copy_from_slice(&u32::MAX.to_be_bytes());
let err = EncryptedDmgHeader::decode(&buf).unwrap_err();
assert!(matches!(err, crate::Error::InvalidImage(_)), "{err:?}");
}
#[test]
fn non_passphrase_entries_are_listed_but_not_decoded() {
let mut buf = plain_header();
buf[0x4C..0x50].copy_from_slice(&2u32.to_be_bytes());
let h = EncryptedDmgHeader::decode(&buf).unwrap();
assert_eq!(h.key_entries.len(), 1);
assert_eq!(h.key_entries[0].kind, 2);
assert!(h.passphrase_keys.is_empty());
}
#[test]
fn probe_recognises_v2_magic() {
let dir = tempfile::tempdir().unwrap();
let p = dir.path().join("enc.dmg");
let mut content = vec![0u8; 128];
content[..8].copy_from_slice(ENCRCDSA_MAGIC);
std::fs::write(&p, &content).unwrap();
assert!(probe(&p).unwrap());
}
#[test]
fn probe_misses_unrelated_file() {
let dir = tempfile::tempdir().unwrap();
let p = dir.path().join("not-encrypted.dmg");
std::fs::write(&p, b"random bytes").unwrap();
assert!(!probe(&p).unwrap());
}
#[cfg(not(feature = "dmg-encrypted"))]
#[test]
fn open_returns_unsupported_without_feature() {
let dir = tempfile::tempdir().unwrap();
let p = dir.path().join("enc.dmg");
std::fs::write(&p, ENCRCDSA_MAGIC).unwrap();
let err = EncryptedDmgBackend::open_with_password(&p, "irrelevant").unwrap_err();
match err {
crate::Error::Unsupported(_) => {}
_ => panic!("expected Unsupported, got {err:?}"),
}
}
#[cfg(feature = "dmg-encrypted")]
#[allow(clippy::too_many_arguments)]
fn synthesise_image(
wrap: Wrap,
password: &str,
iter_count: u32,
salt: &[u8],
blob_iv8: &[u8; 8],
aes_key: &[u8],
hmac_key: &[u8; 20],
block_size: u32,
plain: &[u8],
) -> Vec<u8> {
let mut kek = [0u8; 24];
purecrypto::kdf::pbkdf2::<purecrypto::hash::Sha1>(
password.as_bytes(),
salt,
iter_count,
&mut kek,
);
let keyblob = encrypt_keyblob(wrap, &kek, blob_iv8, aes_key, hmac_key);
let data_offset = 0x400u64;
let mut file_bytes = build_header_bytes(
wrap,
iter_count,
salt,
blob_iv8,
&keyblob,
aes_key.len() as u32 * 8,
block_size,
plain.len() as u64,
data_offset,
);
assert_eq!(file_bytes.len() as u64, data_offset);
for (idx, chunk) in plain.chunks(block_size as usize).enumerate() {
let mut ct = chunk.to_vec();
aes_cbc_encrypt(aes_key, &chunk_iv(hmac_key, idx as u32), &mut ct);
file_bytes.extend_from_slice(&ct);
}
file_bytes
}
#[cfg(feature = "dmg-encrypted")]
fn pattern(n: usize) -> Vec<u8> {
(0..n).map(|i| ((i * 31 + 7) ^ (i >> 4)) as u8).collect()
}
#[cfg(feature = "dmg-encrypted")]
#[test]
fn round_trip_synthetic_aes128() {
let plain = pattern(4096);
let file_bytes = synthesise_image(
Wrap::Aes192,
"correct horse battery staple",
100,
b"saltsaltsaltsaltsalt",
b"ivivivIV",
b"AESKEY-128-BIT!!",
b"HMACKEY-20-BYTES!!??",
4096,
&plain,
);
let dir = tempfile::tempdir().unwrap();
let p = dir.path().join("enc.dmg");
std::fs::write(&p, &file_bytes).unwrap();
let mut be =
EncryptedDmgBackend::open_with_password(&p, "correct horse battery staple").unwrap();
assert_eq!(be.total_size(), 4096);
let mut out = vec![0u8; 4096];
be.read_at(0, &mut out).unwrap();
assert_eq!(out, plain);
let mut mid = vec![0u8; 16];
be.read_at(100, &mut mid).unwrap();
assert_eq!(mid, &plain[100..116]);
}
#[cfg(feature = "dmg-encrypted")]
#[test]
fn round_trip_synthetic_aes256_tdes_wrap() {
let plain: Vec<u8> = (0..8192usize)
.map(|i| ((i ^ (i >> 4)) & 0xFF) as u8)
.collect();
let file_bytes = synthesise_image(
Wrap::Tdes,
"another-password",
64,
b"sodium_chloride_xx",
&[0x11, 0x22, 0x33, 0x44, 0x55, 0x66, 0x77, 0x88],
b"AES256-KEY-MATERIAL-32-BYTES---!",
b"hmac-key-20-bytes-OK",
4096,
&plain,
);
let dir = tempfile::tempdir().unwrap();
let p = dir.path().join("enc256.dmg");
std::fs::write(&p, &file_bytes).unwrap();
let mut be = EncryptedDmgBackend::open_with_password(&p, "another-password").unwrap();
assert_eq!(be.total_size(), 8192);
let mut out = vec![0u8; 8192];
be.read_at(0, &mut out).unwrap();
assert_eq!(out, plain);
let mut cross = vec![0u8; 64];
be.read_at(4096 - 32, &mut cross).unwrap();
assert_eq!(&cross[..32], &plain[4096 - 32..4096]);
assert_eq!(&cross[32..], &plain[4096..4096 + 32]);
}
#[cfg(feature = "dmg-encrypted")]
#[test]
fn partial_trailing_chunk() {
let plain = pattern(512 * 3 + 64);
let file_bytes = synthesise_image(
Wrap::Aes192,
"pw",
50,
b"saltsaltsaltsaltsalt",
b"ivivivIV",
b"AESKEY-128-BIT!!",
b"HMACKEY-20-BYTES!!??",
512,
&plain,
);
let dir = tempfile::tempdir().unwrap();
let p = dir.path().join("enc.dmg");
std::fs::write(&p, &file_bytes).unwrap();
let mut be = EncryptedDmgBackend::open_with_password(&p, "pw").unwrap();
assert_eq!(be.total_size(), plain.len() as u64);
let mut out = vec![0u8; plain.len()];
be.read_at(0, &mut out).unwrap();
assert_eq!(out, plain);
let mut tail = [0u8; 16];
be.read_at(plain.len() as u64 - 16, &mut tail).unwrap();
assert_eq!(&tail, &plain[plain.len() - 16..]);
let err = be.read_at(plain.len() as u64 - 8, &mut tail).unwrap_err();
assert!(matches!(err, crate::Error::OutOfBounds { .. }));
}
#[cfg(feature = "dmg-encrypted")]
#[test]
fn wrong_password_rejected() {
let file_bytes = synthesise_image(
Wrap::Aes192,
"supersecret",
100,
b"saltsaltsaltsaltsalt",
b"ivivivIV",
b"AESKEY-128-BIT!!",
b"HMACKEY-20-BYTES!!??",
4096,
&[0u8; 4096],
);
let dir = tempfile::tempdir().unwrap();
let p = dir.path().join("enc.dmg");
std::fs::write(&p, &file_bytes).unwrap();
let err = EncryptedDmgBackend::open_with_password(&p, "wrong-password").unwrap_err();
match err {
crate::Error::Unsupported(msg) => {
assert!(msg.contains("wrong password") || msg.contains("padding"));
}
_ => panic!("expected Unsupported, got {err:?}"),
}
}
#[cfg(feature = "dmg-encrypted")]
#[test]
fn read_at_rejects_out_of_bounds() {
let file_bytes = synthesise_image(
Wrap::Aes192,
"pw",
50,
b"saltsaltsaltsaltsalt",
b"ivivivIV",
b"AESKEY-128-BIT!!",
b"HMACKEY-20-BYTES!!??",
4096,
&[0u8; 8192],
);
let dir = tempfile::tempdir().unwrap();
let p = dir.path().join("enc.dmg");
std::fs::write(&p, &file_bytes).unwrap();
let mut be = EncryptedDmgBackend::open_with_password(&p, "pw").unwrap();
assert_eq!(be.total_size(), 8192);
let mut out = [0u8; 16];
let err = be.read_at(8192, &mut out).unwrap_err();
match err {
crate::Error::OutOfBounds { .. } => {}
_ => panic!("expected OutOfBounds, got {err:?}"),
}
}
#[cfg(feature = "dmg-encrypted")]
#[test]
fn decrypts_real_hdiutil_image() {
let img: &[u8] = include_bytes!("testdata/encrcdsa_aes128_fat12_hunter2.dmg");
let dir = tempfile::tempdir().unwrap();
let p = dir.path().join("real.dmg");
std::fs::write(&p, img).unwrap();
let mut be = EncryptedDmgBackend::open_with_password(&p, "hunter2").unwrap();
assert_eq!(be.total_size(), 64 * 1024);
let mut boot = vec![0u8; 512];
be.read_at(0, &mut boot).unwrap();
assert_eq!(&boot[510..512], &[0x55, 0xAA], "boot signature");
assert_eq!(&boot[0x36..0x3E], b"FAT12 ", "FAT type string");
let mut all = vec![0u8; 64 * 1024];
be.read_at(0, &mut all).unwrap();
let reserved = u16::from_le_bytes([boot[14], boot[15]]) as usize;
let bps = u16::from_le_bytes([boot[11], boot[12]]) as usize;
assert_eq!(
all[reserved * bps],
boot[21],
"FAT[0] carries the media byte"
);
}
}