use std::fs::File;
use std::io::{Read, Seek, SeekFrom};
use crate::Result;
use crate::block::luks::crypt::{CipherSpec, SectorCipher};
use crate::block::luks::{self, v1};
use super::header::{Header, crypt, ext_type};
pub const CRYPT_SECTOR_SIZE: u32 = 512;
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub struct CryptoHeaderExtent {
pub offset: u64,
pub length: u64,
}
pub fn crypto_header_extent(header: &Header) -> Result<Option<CryptoHeaderExtent>> {
let Some(ext) = header
.extensions
.iter()
.find(|e| e.kind == ext_type::CRYPTO_HEADER)
else {
return Ok(None);
};
if ext.data.len() < 16 {
return Err(crate::Error::InvalidImage(format!(
"qcow2: crypto-header extension is {} bytes, need 16",
ext.data.len()
)));
}
Ok(Some(CryptoHeaderExtent {
offset: u64::from_be_bytes(ext.data[0..8].try_into().unwrap()),
length: u64::from_be_bytes(ext.data[8..16].try_into().unwrap()),
}))
}
pub fn crypto_header_ext_data(extent: CryptoHeaderExtent) -> Vec<u8> {
let mut v = Vec::with_capacity(16);
v.extend_from_slice(&extent.offset.to_be_bytes());
v.extend_from_slice(&extent.length.to_be_bytes());
v
}
pub struct Qcow2Crypt {
cipher: SectorCipher,
physical: bool,
method: u32,
}
impl std::fmt::Debug for Qcow2Crypt {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
f.debug_struct("Qcow2Crypt")
.field(
"method",
&match self.method {
crypt::AES => "aes",
crypt::LUKS => "luks",
_ => "?",
},
)
.field("cipher", &self.cipher)
.field("iv_from", &if self.physical { "host" } else { "guest" })
.finish()
}
}
impl Qcow2Crypt {
pub fn open_aes(password: &str) -> Result<Self> {
let mut key = [0u8; 16];
let pw = password.as_bytes();
let n = pw.len().min(16);
key[..n].copy_from_slice(&pw[..n]);
let spec = CipherSpec::parse("aes-cbc-plain64", 16)?;
Ok(Self {
cipher: SectorCipher::new(spec, &key, CRYPT_SECTOR_SIZE)?,
physical: false,
method: crypt::AES,
})
}
pub fn open_luks(file: &mut File, extent: CryptoHeaderExtent, password: &str) -> Result<Self> {
let (spec, key) = unlock_embedded_luks(file, extent, password)?;
Ok(Self {
cipher: SectorCipher::new(spec, &key, CRYPT_SECTOR_SIZE)?,
physical: true,
method: crypt::LUKS,
})
}
pub fn from_luks_master_key(spec: CipherSpec, key: &[u8]) -> Result<Self> {
Ok(Self {
cipher: SectorCipher::new(spec, key, CRYPT_SECTOR_SIZE)?,
physical: true,
method: crypt::LUKS,
})
}
pub fn method(&self) -> u32 {
self.method
}
pub fn uses_host_offset(&self) -> bool {
self.physical
}
pub fn sector_size(&self) -> u32 {
CRYPT_SECTOR_SIZE
}
pub fn cipher(&self) -> &SectorCipher {
&self.cipher
}
pub fn decrypt(&self, offset: u64, buf: &mut [u8]) -> Result<()> {
self.cipher.decrypt(self.sector_of(offset)?, buf)
}
pub fn encrypt(&self, offset: u64, buf: &mut [u8]) -> Result<()> {
self.cipher.encrypt(self.sector_of(offset)?, buf)
}
fn sector_of(&self, offset: u64) -> Result<u64> {
if !offset.is_multiple_of(CRYPT_SECTOR_SIZE as u64) {
return Err(crate::Error::InvalidArgument(format!(
"qcow2: crypto offset {offset} is not a multiple of {CRYPT_SECTOR_SIZE}"
)));
}
Ok(offset / CRYPT_SECTOR_SIZE as u64)
}
}
fn unlock_embedded_luks(
file: &mut File,
extent: CryptoHeaderExtent,
password: &str,
) -> Result<(CipherSpec, Vec<u8>)> {
let file_len = file.metadata()?.len();
let end = extent.offset.checked_add(extent.length).ok_or_else(|| {
crate::Error::InvalidImage("qcow2: crypto header extent overflows u64".into())
})?;
if extent.length < v1::PHDR_BYTES as u64 || end > file_len {
return Err(crate::Error::InvalidImage(format!(
"qcow2: crypto header at {} + {} does not lie inside the {file_len}-byte image",
extent.offset, extent.length
)));
}
let mut phdr = vec![0u8; v1::PHDR_BYTES];
file.seek(SeekFrom::Start(extent.offset))?;
file.read_exact(&mut phdr)?;
match luks::detect(&phdr) {
Some(luks::Version::V1) => {}
Some(luks::Version::V2) => {
return Err(crate::Error::Unsupported(
"qcow2: the embedded crypto header is LUKS2; qemu writes LUKS1 \
here and fstool reads only that"
.into(),
));
}
None => {
return Err(crate::Error::InvalidImage(
"qcow2: the crypto-header extension does not point at a LUKS header".into(),
));
}
}
let header = v1::Header::decode(&phdr)?;
let spec = header.cipher_spec()?;
for i in 0..v1::NUM_KEYS {
if !header.slots[i].is_enabled() {
continue;
}
let (rel, len) = header.slot_material_extent(i);
let at = extent
.offset
.checked_add(rel)
.filter(|a| a + len <= end)
.ok_or_else(|| {
crate::Error::InvalidImage(format!(
"qcow2: embedded keyslot {i} material lies outside the crypto header"
))
})?;
let mut material = vec![0u8; len as usize];
file.seek(SeekFrom::Start(at))?;
file.read_exact(&mut material)?;
if let Some(mk) = header.unlock_slot(i, password.as_bytes(), &mut material)? {
return Ok((spec, mk));
}
}
Err(crate::Error::InvalidArgument(
"qcow2: no keyslot in the embedded LUKS header accepted the passphrase".into(),
))
}
pub struct NewCryptoHeader {
pub bytes: Vec<u8>,
pub master_key: Vec<u8>,
pub cipher_spec: CipherSpec,
}
pub fn create_luks_header(password: &str, opts: &luks::FormatOpts) -> Result<NewCryptoHeader> {
if opts.version != luks::Version::V1 {
return Err(crate::Error::InvalidArgument(
"qcow2: an embedded crypto header must be LUKS1 — that is what \
qemu writes and reads"
.into(),
));
}
let spec = CipherSpec::parse(&opts.cipher, opts.key_bytes)?;
let master_key = match &opts.master_key {
Some(k) if k.len() == opts.key_bytes => k.clone(),
Some(k) => {
return Err(crate::Error::InvalidArgument(format!(
"qcow2: supplied master key is {} bytes, key_bytes says {}",
k.len(),
opts.key_bytes
)));
}
None => {
let mut k = vec![0u8; opts.key_bytes];
purecrypto::rng::RngCore::fill_bytes(&mut purecrypto::rng::OsRng, &mut k);
k
}
};
let region_len = luks::v1::KEYSLOT_ALIGN + opts_slot_area(opts) * luks::v1::NUM_KEYS as u64;
let image = luks::format::build_luks1(password, opts, region_len, &master_key)?;
Ok(NewCryptoHeader {
bytes: image.bytes,
master_key: image.master_key,
cipher_spec: spec,
})
}
fn opts_slot_area(opts: &luks::FormatOpts) -> u64 {
let exact = opts.stripes as u64 * opts.key_bytes as u64;
exact.div_ceil(luks::v1::KEYSLOT_ALIGN) * luks::v1::KEYSLOT_ALIGN
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn crypto_header_extension_round_trips() {
let extent = CryptoHeaderExtent {
offset: 262144,
length: 2068480,
};
let data = crypto_header_ext_data(extent);
assert_eq!(data.len(), 16);
let mut h = super::super::header::Header::decode(&sample_header_bytes()).unwrap();
h.extensions.push(super::super::header::Extension {
kind: ext_type::CRYPTO_HEADER,
data,
});
assert_eq!(crypto_header_extent(&h).unwrap(), Some(extent));
}
#[test]
fn no_extension_means_no_extent() {
let h = super::super::header::Header::decode(&sample_header_bytes()).unwrap();
assert_eq!(crypto_header_extent(&h).unwrap(), None);
}
#[test]
fn short_extension_is_rejected() {
let mut h = super::super::header::Header::decode(&sample_header_bytes()).unwrap();
h.extensions.push(super::super::header::Extension {
kind: ext_type::CRYPTO_HEADER,
data: vec![0u8; 8],
});
assert!(crypto_header_extent(&h).is_err());
}
#[test]
fn legacy_aes_key_is_the_padded_password() {
let a = Qcow2Crypt::open_aes("0123456789abcdef").unwrap();
let b = Qcow2Crypt::open_aes("0123456789abcdefIGNORED").unwrap();
let mut x = vec![0x11u8; 512];
let mut y = vec![0x11u8; 512];
a.encrypt(0, &mut x).unwrap();
b.encrypt(0, &mut y).unwrap();
assert_eq!(x, y);
assert!(!a.uses_host_offset());
}
#[test]
fn legacy_aes_round_trips_and_varies_by_sector() {
let c = Qcow2Crypt::open_aes("swordfish").unwrap();
let plain: Vec<u8> = (0..1024).map(|i| (i % 251) as u8).collect();
let mut buf = plain.clone();
c.encrypt(4096, &mut buf).unwrap();
assert_ne!(buf, plain);
c.decrypt(4096, &mut buf).unwrap();
assert_eq!(buf, plain);
let mut a = vec![0u8; 512];
let mut b = vec![0u8; 512];
c.encrypt(0, &mut a).unwrap();
c.encrypt(512, &mut b).unwrap();
assert_ne!(a, b);
}
#[test]
fn unaligned_offsets_are_refused() {
let c = Qcow2Crypt::open_aes("pw").unwrap();
let mut buf = vec![0u8; 512];
assert!(matches!(
c.encrypt(100, &mut buf),
Err(crate::Error::InvalidArgument(_))
));
}
#[test]
fn embedded_header_must_be_luks1() {
let opts = luks::FormatOpts {
version: luks::Version::V2,
..luks::FormatOpts::fast_for_tests()
};
assert!(matches!(
create_luks_header("pw", &opts),
Err(crate::Error::InvalidArgument(_))
));
}
#[test]
fn built_header_unlocks_from_a_file() {
use std::io::Write as _;
let opts = luks::FormatOpts {
version: luks::Version::V1,
..luks::FormatOpts::fast_for_tests()
};
let built = create_luks_header("open me", &opts).unwrap();
let mut tmp = tempfile::NamedTempFile::new().unwrap();
tmp.write_all(&vec![0u8; 65536]).unwrap();
tmp.write_all(&built.bytes).unwrap();
tmp.flush().unwrap();
let extent = CryptoHeaderExtent {
offset: 65536,
length: built.bytes.len() as u64,
};
let mut file = std::fs::File::open(tmp.path()).unwrap();
let (spec, mk) = unlock_embedded_luks(&mut file, extent, "open me").unwrap();
assert_eq!(mk, built.master_key);
assert_eq!(spec, built.cipher_spec);
let err = unlock_embedded_luks(&mut file, extent, "nope").unwrap_err();
assert!(matches!(err, crate::Error::InvalidArgument(_)), "{err}");
}
#[test]
fn extent_outside_the_file_is_rejected() {
let tmp = tempfile::NamedTempFile::new().unwrap();
let mut file = std::fs::File::open(tmp.path()).unwrap();
let err = unlock_embedded_luks(
&mut file,
CryptoHeaderExtent {
offset: 1 << 40,
length: 4096,
},
"pw",
)
.unwrap_err();
assert!(matches!(err, crate::Error::InvalidImage(_)), "{err}");
}
fn sample_header_bytes() -> [u8; 512] {
let h = super::super::header::Header {
version: super::super::header::VERSION_V3,
backing_file_offset: 0,
backing_file_size: 0,
cluster_bits: 16,
size: 16 * 1024 * 1024,
crypt_method: crypt::LUKS,
l1_size: 1,
l1_table_offset: 3 * 65536,
refcount_table_offset: 65536,
refcount_table_clusters: 1,
nb_snapshots: 0,
snapshots_offset: 0,
incompatible_features: 0,
compatible_features: 0,
autoclear_features: 0,
refcount_order: 4,
header_length: super::super::header::V3_HEADER_LEN as u32,
compression_type: 0,
extensions: Vec::new(),
};
let mut b = [0u8; 512];
b[..super::super::header::V3_HEADER_LEN].copy_from_slice(&h.encode_v3());
b
}
}