Skip to main content

luks/
volume.rs

1//! Public API: parse a LUKS1 or LUKS2 container and unlock it from a passphrase.
2//!
3//! [`LuksVolume::unlock_with_passphrase`] auto-detects the version. Both run the
4//! same shape — parse the header, try each keyslot (KDF → decrypt AF material →
5//! AF-merge → master key), verify the master key against the digest, and return a
6//! [`DecryptedPayload`] that decrypts the data segment on demand.
7
8use std::io::{Read, Seek, SeekFrom};
9use std::time::{Duration, Instant};
10
11use crate::af;
12use crate::crypto::{
13    derive_key_argon2_within, derive_key_within, xts_decrypt_area, Argon2Params, DERIVATION_BUDGET,
14    UNLOCK_BUDGET,
15};
16use crate::error::{LuksError, Result};
17use crate::header::{Luks1Header, LUKS1_PHDR_LEN, MK_DIGEST_LEN, SECTOR};
18use crate::header2::{Luks2Header, Luks2Kdf, LUKS2_BIN_HDR_LEN};
19
20/// Namespace for opening a LUKS volume. State lives in [`DecryptedPayload`].
21pub struct LuksVolume;
22
23/// The cipher/version facts of an unlocked volume.
24#[derive(Debug, Clone)]
25pub struct VolumeInfo {
26    /// LUKS version (1 or 2).
27    pub version: u16,
28    /// Cipher name, e.g. `aes`.
29    pub cipher_name: String,
30    /// Cipher mode fed to the sector cipher, e.g. `xts-plain64`.
31    pub cipher_mode: String,
32    /// Master-key length in bytes.
33    pub key_bytes: u32,
34    /// Encryption sector size in bytes (512 or 4096).
35    pub sector_size: usize,
36}
37
38/// Split a LUKS2 `encryption` string (`aes-xts-plain64`) into (name, mode). For a
39/// LUKS1 header the mode is already separate, so `cipher_mode_of` is the identity.
40fn split_encryption(enc: &str) -> (String, String) {
41    match enc.split_once('-') {
42        Some((name, mode)) => (name.to_string(), mode.to_string()),
43        None => (enc.to_string(), String::new()),
44    }
45}
46
47impl LuksVolume {
48    /// Unlock a LUKS1 or LUKS2 container, auto-detecting the version from the
49    /// header.
50    ///
51    /// # Errors
52    /// See [`Self::unlock1_with_passphrase`] / [`Self::unlock2_with_passphrase`].
53    pub fn unlock_with_passphrase<R: Read + Seek>(
54        reader: R,
55        passphrase: &[u8],
56    ) -> Result<DecryptedPayload<R>> {
57        Self::unlock_with_passphrase_within(reader, passphrase, UNLOCK_BUDGET)
58    }
59
60    /// [`Self::unlock_with_passphrase`] against an explicit TOTAL budget.
61    ///
62    /// The auto-detecting entry point carries the budget too, because the
63    /// callers that do not know a container's version up front — the VFS layer,
64    /// the fuzz target, a triage sweep over an image — are exactly the ones that
65    /// must not silently lose the bound at the version dispatch.
66    ///
67    /// # Errors
68    /// [`LuksError::UnlockBudgetExceeded`] when the total runs out, plus
69    /// everything [`Self::unlock_with_passphrase`] returns.
70    pub fn unlock_with_passphrase_within<R: Read + Seek>(
71        mut reader: R,
72        passphrase: &[u8],
73        budget: Duration,
74    ) -> Result<DecryptedPayload<R>> {
75        let mut ver = [0u8; 8];
76        reader.seek(SeekFrom::Start(0))?;
77        read_fill(&mut reader, &mut ver)?;
78        reader.seek(SeekFrom::Start(0))?;
79        match crate::bytes::be_u16(&ver, 6) {
80            1 => Self::unlock1_with_passphrase_within(reader, passphrase, budget),
81            2 => Self::unlock2_with_passphrase_within(reader, passphrase, budget),
82            other => {
83                if ver.starts_with(&crate::header::LUKS_MAGIC) {
84                    Err(LuksError::UnsupportedVersion { version: other })
85                } else {
86                    Err(LuksError::NotLuks {
87                        found: crate::bytes::bytes_n::<6>(&ver, 0),
88                    })
89                }
90            }
91        }
92    }
93
94    /// [`Self::unlock1_with_passphrase`] against an explicit TOTAL budget.
95    ///
96    /// The budget spans every keyslot the unlock tries, not each derivation. A
97    /// forensic caller working through a large image may want a tighter bound
98    /// than the default; a crafted header cannot exceed whichever is given.
99    ///
100    /// # Errors
101    /// [`LuksError::UnlockBudgetExceeded`] when the total runs out, plus
102    /// everything [`Self::unlock1_with_passphrase`] returns.
103    pub fn unlock1_with_passphrase_within<R: Read + Seek>(
104        reader: R,
105        passphrase: &[u8],
106        budget: Duration,
107    ) -> Result<DecryptedPayload<R>> {
108        Self::unlock1_inner(reader, passphrase, UnlockDeadline::new(budget))
109    }
110
111    /// Unlock a LUKS1 container from `reader` with `passphrase`.
112    ///
113    /// # Errors
114    /// Header-parse errors, [`LuksError::NoActiveKeyslot`], or
115    /// [`LuksError::AuthenticationFailed`] on a wrong passphrase.
116    pub fn unlock1_with_passphrase<R: Read + Seek>(
117        reader: R,
118        passphrase: &[u8],
119    ) -> Result<DecryptedPayload<R>> {
120        Self::unlock1_inner(reader, passphrase, UnlockDeadline::new(UNLOCK_BUDGET))
121    }
122
123    fn unlock1_inner<R: Read + Seek>(
124        mut reader: R,
125        passphrase: &[u8],
126        deadline: UnlockDeadline,
127    ) -> Result<DecryptedPayload<R>> {
128        let mut hdr_buf = vec![0u8; LUKS1_PHDR_LEN];
129        reader.seek(SeekFrom::Start(0))?;
130        read_fill(&mut reader, &mut hdr_buf)?;
131        let header = Luks1Header::parse(&hdr_buf)?;
132        if header.active_keyslots().next().is_none() {
133            return Err(LuksError::NoActiveKeyslot);
134        }
135        let key_bytes = header.key_bytes as usize;
136        let master_key =
137            recover_master_key1(&mut reader, &header, passphrase, key_bytes, deadline)?;
138        let total_size = reader.seek(SeekFrom::End(0))?;
139        Ok(DecryptedPayload {
140            reader,
141            master_key,
142            cipher_mode: header.cipher_mode.clone(),
143            payload_offset: header.payload_byte_offset(),
144            sector_size: SECTOR as usize,
145            iv_tweak: 0,
146            total_size,
147            position: 0,
148            info: VolumeInfo {
149                version: 1,
150                cipher_name: header.cipher_name.clone(),
151                cipher_mode: header.cipher_mode.clone(),
152                key_bytes: header.key_bytes,
153                sector_size: SECTOR as usize,
154            },
155        })
156    }
157
158    /// [`Self::unlock2_with_passphrase`] against an explicit TOTAL budget.
159    ///
160    /// Same bound as the LUKS1 twin, and needed for the same reason:
161    /// `recover_master_key2` derives once per keyslot plus once per digest that
162    /// references it, over a slot count and per-slot cost the header chooses.
163    ///
164    /// # Errors
165    /// [`LuksError::UnlockBudgetExceeded`] when the total runs out, plus
166    /// everything [`Self::unlock2_with_passphrase`] returns.
167    pub fn unlock2_with_passphrase_within<R: Read + Seek>(
168        reader: R,
169        passphrase: &[u8],
170        budget: Duration,
171    ) -> Result<DecryptedPayload<R>> {
172        Self::unlock2_inner(reader, passphrase, UnlockDeadline::new(budget))
173    }
174
175    /// Unlock a LUKS2 container from `reader` with `passphrase`.
176    ///
177    /// # Errors
178    /// Header/JSON-parse errors, [`LuksError::NoActiveKeyslot`] if there is no
179    /// crypt segment or keyslot, or [`LuksError::AuthenticationFailed`].
180    pub fn unlock2_with_passphrase<R: Read + Seek>(
181        reader: R,
182        passphrase: &[u8],
183    ) -> Result<DecryptedPayload<R>> {
184        Self::unlock2_inner(reader, passphrase, UnlockDeadline::new(UNLOCK_BUDGET))
185    }
186
187    fn unlock2_inner<R: Read + Seek>(
188        mut reader: R,
189        passphrase: &[u8],
190        deadline: UnlockDeadline,
191    ) -> Result<DecryptedPayload<R>> {
192        // Read the binary header to learn hdr_size, then the whole header+JSON.
193        let mut bin = vec![0u8; LUKS2_BIN_HDR_LEN];
194        reader.seek(SeekFrom::Start(0))?;
195        read_fill(&mut reader, &mut bin)?;
196        let hdr_size = crate::bytes::be_u64(&bin, 8).max(LUKS2_BIN_HDR_LEN as u64) as usize;
197        let mut full = vec![0u8; hdr_size];
198        reader.seek(SeekFrom::Start(0))?;
199        read_available(&mut reader, &mut full)?;
200        let header = Luks2Header::parse(&full)?;
201
202        let segment = header
203            .crypt_segment()
204            .cloned()
205            .ok_or(LuksError::NoActiveKeyslot)?;
206        if header.keyslots.is_empty() {
207            return Err(LuksError::NoActiveKeyslot);
208        }
209
210        let master_key = recover_master_key2(&mut reader, &header, passphrase, deadline)?;
211        let total_size = reader.seek(SeekFrom::End(0))?;
212        let (cipher_name, cipher_mode) = split_encryption(&segment.encryption);
213        let key_bytes = master_key.len() as u32;
214        Ok(DecryptedPayload {
215            reader,
216            master_key,
217            cipher_mode: cipher_mode.clone(),
218            payload_offset: segment.offset,
219            sector_size: segment.sector_size.max(512),
220            iv_tweak: segment.iv_tweak,
221            total_size,
222            position: 0,
223            info: VolumeInfo {
224                version: 2,
225                cipher_name,
226                cipher_mode,
227                key_bytes,
228                sector_size: segment.sector_size.max(512),
229            },
230        })
231    }
232}
233
234/// Remaining allowance in an unlock's total budget.
235///
236/// Bounds the WHOLE unlock rather than each derivation. `recover_master_key*`
237/// derives twice per active keyslot over up to 8 attacker-chosen slots, so a
238/// per-derivation budget leaves the sum unbounded — see
239/// [`crypto::UNLOCK_BUDGET`].
240#[derive(Debug, Clone, Copy)]
241struct UnlockDeadline {
242    started: Instant,
243    total: Duration,
244}
245
246impl UnlockDeadline {
247    fn new(total: Duration) -> Self {
248        Self {
249            started: Instant::now(),
250            total,
251        }
252    }
253
254    /// Time left, or the error naming how far the unlock got before running out.
255    ///
256    /// Clamped to [`crypto::DERIVATION_BUDGET`], so the aggregate bound does not
257    /// quietly widen the per-derivation one it was added alongside: without the
258    /// clamp a single slot could spend the entire [`crypto::UNLOCK_BUDGET`],
259    /// which is three times what one derivation was ever allowed. Both ceilings
260    /// hold together — no derivation over 30s, no unlock over 90s.
261    fn remaining(self, slots_tried: usize) -> Result<Duration> {
262        self.total
263            .checked_sub(self.started.elapsed())
264            .filter(|left| !left.is_zero())
265            .map(|left| left.min(DERIVATION_BUDGET))
266            .ok_or(LuksError::UnlockBudgetExceeded {
267                slots_tried,
268                budget_secs: self.total.as_secs(),
269            })
270    }
271}
272
273/// LUKS1: try each active keyslot until one yields a master key whose digest
274/// matches.
275fn recover_master_key1<R: Read + Seek>(
276    reader: &mut R,
277    header: &Luks1Header,
278    passphrase: &[u8],
279    key_bytes: usize,
280    deadline: UnlockDeadline,
281) -> Result<Vec<u8>> {
282    for (tried, slot) in header.active_keyslots().enumerate() {
283        let slot_key = derive_key_within(
284            &header.hash_spec,
285            passphrase,
286            &slot.salt,
287            slot.iterations,
288            key_bytes,
289            deadline.remaining(tried)?,
290        )?;
291        let material_len = af::material_len(key_bytes, slot.stripes as usize)?;
292        let mut material = vec![0u8; material_len];
293        reader.seek(SeekFrom::Start(
294            u64::from(slot.key_material_offset) * SECTOR,
295        ))?;
296        if read_available(reader, &mut material)? < material_len {
297            continue;
298        }
299        xts_decrypt_area(&header.cipher_mode, &slot_key, &mut material, 512, 0)?;
300        let candidate = af::merge(
301            &header.hash_spec,
302            &material,
303            key_bytes,
304            slot.stripes as usize,
305        )?; // cov:unreachable: hash_spec already validated by the slot-key derive_key above
306        let digest = derive_key_within(
307            &header.hash_spec,
308            &candidate,
309            &header.mk_digest_salt,
310            header.mk_digest_iter,
311            MK_DIGEST_LEN,
312            deadline.remaining(tried)?,
313        )?; // cov:unreachable: hash_spec already validated by the slot-key derive_key above
314        if digest == header.mk_digest {
315            return Ok(candidate);
316        }
317    }
318    Err(LuksError::AuthenticationFailed)
319}
320
321/// LUKS2: try each keyslot (Argon2 or PBKDF2 KDF) and verify against a digest
322/// that references it.
323///
324/// `deadline` bounds the loop as a whole. Each iteration re-reads what is left
325/// before committing to any derivation, so a header cannot multiply a
326/// per-derivation budget by its own slot count. For the Argon2 branch that is
327/// the *only* bound on a slot that is already running — see
328/// [`crypto::derive_key_argon2_within`] — but it is what stops the next slot
329/// from starting, which is the multiplication this exists to prevent.
330fn recover_master_key2<R: Read + Seek>(
331    reader: &mut R,
332    header: &Luks2Header,
333    passphrase: &[u8],
334    deadline: UnlockDeadline,
335) -> Result<Vec<u8>> {
336    for (tried, slot) in header.keyslots.iter().enumerate() {
337        let slot_key = match &slot.kdf {
338            Luks2Kdf::Argon2 {
339                kind,
340                time,
341                memory,
342                cpus,
343                salt,
344            } => derive_key_argon2_within(
345                &Argon2Params {
346                    kind,
347                    time: *time,
348                    memory: *memory,
349                    cpus: *cpus,
350                    salt,
351                },
352                passphrase,
353                slot.key_size,
354                deadline.remaining(tried)?,
355            )?,
356            Luks2Kdf::Pbkdf2 {
357                hash,
358                iterations,
359                salt,
360            } => derive_key_within(
361                hash,
362                passphrase,
363                salt,
364                *iterations,
365                slot.key_size,
366                deadline.remaining(tried)?,
367            )?,
368        };
369
370        let (_, area_mode) = split_encryption(&slot.area_encryption);
371        let material_len = af::material_len(slot.key_size, slot.af_stripes as usize)?;
372        let mut material = vec![0u8; material_len];
373        reader.seek(SeekFrom::Start(slot.area_offset))?;
374        if read_available(reader, &mut material)? < material_len {
375            continue;
376        }
377        xts_decrypt_area(&area_mode, &slot_key, &mut material, 512, 0)?;
378        let candidate = af::merge(
379            &slot.af_hash,
380            &material,
381            slot.key_size,
382            slot.af_stripes as usize,
383        )?;
384
385        for dig in &header.digests {
386            if dig.kind == "pbkdf2" && dig.keyslots.iter().any(|k| k == &slot.id) {
387                let d = derive_key_within(
388                    &dig.hash,
389                    &candidate,
390                    &dig.salt,
391                    dig.iterations,
392                    dig.digest.len(),
393                    deadline.remaining(tried)?,
394                )?;
395                if d == dig.digest {
396                    return Ok(candidate);
397                }
398            }
399        }
400    }
401    Err(LuksError::AuthenticationFailed)
402}
403
404/// A plaintext view of an unlocked LUKS payload.
405pub struct DecryptedPayload<R> {
406    reader: R,
407    master_key: Vec<u8>,
408    cipher_mode: String,
409    payload_offset: u64,
410    sector_size: usize,
411    iv_tweak: u128,
412    total_size: u64,
413    position: u64,
414    info: VolumeInfo,
415}
416
417impl<R: Read + Seek> DecryptedPayload<R> {
418    /// Cipher/version facts of the volume.
419    #[must_use]
420    pub fn info(&self) -> &VolumeInfo {
421        &self.info
422    }
423
424    /// The recovered master key (sensitive).
425    #[must_use]
426    pub fn master_key(&self) -> &[u8] {
427        &self.master_key
428    }
429
430    /// Size of the encrypted payload in bytes.
431    #[must_use]
432    pub fn payload_size(&self) -> u64 {
433        self.total_size.saturating_sub(self.payload_offset)
434    }
435
436    /// Read decrypted payload bytes at payload-relative `offset` into `buf`,
437    /// filling it completely (bytes past the end read back as zero).
438    ///
439    /// # Errors
440    /// Propagates I/O errors and any cipher error.
441    pub fn read_at(&mut self, offset: u64, buf: &mut [u8]) -> Result<()> {
442        let ss = self.sector_size as u64;
443        let step = (self.sector_size / 512).max(1) as u128;
444        let mut done = 0usize;
445        while done < buf.len() {
446            let pos = offset + done as u64;
447            let unit = pos / ss;
448            let within = (pos % ss) as usize;
449            let physical = self.payload_offset + unit * ss;
450
451            let mut ct = vec![0u8; self.sector_size];
452            self.reader.seek(SeekFrom::Start(physical))?;
453            read_available(&mut self.reader, &mut ct)?;
454            let tweak = self.iv_tweak + u128::from(unit) * step;
455            xts_decrypt_area(
456                &self.cipher_mode,
457                &self.master_key,
458                &mut ct,
459                self.sector_size,
460                tweak,
461            )?; // cov:unreachable: cipher_mode + master-key size validated during unlock
462
463            let take = (self.sector_size - within).min(buf.len() - done);
464            buf[done..done + take].copy_from_slice(&ct[within..within + take]);
465            done += take;
466        }
467        Ok(())
468    }
469}
470
471impl<R: Read + Seek> Read for DecryptedPayload<R> {
472    fn read(&mut self, buf: &mut [u8]) -> std::io::Result<usize> {
473        let size = self.payload_size();
474        if self.position >= size {
475            return Ok(0);
476        }
477        let n = (buf.len() as u64).min(size - self.position) as usize;
478        self.read_at(self.position, &mut buf[..n])
479            .map_err(|e| std::io::Error::other(e.to_string()))?;
480        self.position += n as u64;
481        Ok(n)
482    }
483}
484
485impl<R: Read + Seek> Seek for DecryptedPayload<R> {
486    fn seek(&mut self, pos: SeekFrom) -> std::io::Result<u64> {
487        let size = self.payload_size();
488        let new = match pos {
489            SeekFrom::Start(o) => i128::from(o),
490            SeekFrom::End(o) => i128::from(size) + i128::from(o),
491            SeekFrom::Current(o) => i128::from(self.position) + i128::from(o),
492        };
493        if new < 0 {
494            return Err(std::io::Error::new(
495                std::io::ErrorKind::InvalidInput,
496                "seek before start",
497            ));
498        }
499        self.position = new as u64;
500        Ok(self.position)
501    }
502}
503
504/// Read exactly `buf.len()` bytes, erroring on premature EOF.
505fn read_fill<R: Read>(reader: &mut R, buf: &mut [u8]) -> Result<()> {
506    reader.read_exact(buf)?;
507    Ok(())
508}
509
510/// Read up to `buf.len()` bytes, zero-filling the remainder on EOF. Returns the
511/// number of real bytes read.
512fn read_available<R: Read>(reader: &mut R, buf: &mut [u8]) -> Result<usize> {
513    let mut filled = 0;
514    while filled < buf.len() {
515        match reader.read(&mut buf[filled..]) {
516            Ok(0) => break,
517            Ok(n) => filled += n,
518            Err(ref e) if e.kind() == std::io::ErrorKind::Interrupted => {}
519            Err(e) => return Err(e.into()),
520        }
521    }
522    for b in &mut buf[filled..] {
523        *b = 0;
524    }
525    Ok(filled)
526}
527
528#[cfg(test)]
529mod tests {
530    //! Hermetic round-trip tests: build a synthetic LUKS container in memory with
531    //! the same audited primitives, unlock it, and assert the plaintext comes
532    //! back. These are Tier-3 self-consistency scaffolding — the real correctness
533    //! proof is the `cryptsetup` oracle (`tests/oracle_luks{1,2}.rs`).
534
535    use std::time::Duration;
536
537    use super::*;
538    use crate::af;
539    use crate::crypto::{derive_key_argon2_within, derive_key_within, Argon2Params};
540    use crate::header::{
541        KEYSLOT_LEN, KEY_DISABLED, KEY_ENABLED, LUKS1_PHDR_LEN, LUKS_MAGIC, LUKS_NUM_KEYS,
542    };
543    use crate::header2::LUKS2_BIN_HDR_LEN;
544    use aes::cipher::KeyInit;
545    use aes::Aes256;
546    use base64::Engine;
547    use std::io::Cursor;
548    use xts_mode::Xts128;
549
550    const PASS: &[u8] = b"luks-TEST";
551
552    /// XTS-encrypt `buf` in place, one `unit_size`-byte unit at a time, with the
553    /// per-unit plain64 tweak `base + u * (unit_size/512)` — the encrypt inverse
554    /// of `xts_decrypt_area` (64-byte AES-256 key only, which is all the fixtures
555    /// use).
556    fn xts_encrypt(key: &[u8], buf: &mut [u8], unit_size: usize, base: u128) {
557        let step = (unit_size / 512).max(1) as u128;
558        let (k1, k2) = key.split_at(32);
559        let xts = Xts128::<Aes256>::new(Aes256::new(k1.into()), Aes256::new(k2.into()));
560        for (u, chunk) in buf.chunks_mut(unit_size).enumerate() {
561            let tweak = (base + u as u128 * step).to_le_bytes();
562            xts.encrypt_sector(chunk, tweak);
563        }
564    }
565
566    fn pbkdf2_sha256(pw: &[u8], salt: &[u8], iters: u32, len: usize) -> Vec<u8> {
567        let mut out = vec![0u8; len];
568        pbkdf2::pbkdf2_hmac::<sha2::Sha256>(pw, salt, iters, &mut out);
569        out
570    }
571
572    fn known_plain(len: usize) -> Vec<u8> {
573        (0..len).map(|i| (i as u8) ^ 0x3c).collect()
574    }
575
576    // ---- LUKS1 ------------------------------------------------------------
577
578    const L1_KM_OFF: u32 = 2; // sector 2 = byte 1024 (past the 592-byte phdr)
579    const L1_PAYLOAD_OFF: u32 = 4; // sector 4 = byte 2048
580    const L1_STRIPES: u32 = 8;
581    const L1_KEY_BYTES: u32 = 64;
582    const L1_ITERS: u32 = 5;
583    const L1_MK_ITER: u32 = 7;
584
585    /// Build a valid LUKS1 container (`aes` / `xts-plain64`, AES-256-XTS) unlocking
586    /// to `master`, plus the 3-sector plaintext payload. Returns (image, plain).
587    fn build_luks1(master: &[u8]) -> (Vec<u8>, Vec<u8>) {
588        let salt = [0x11u8; 32];
589        let mk_salt = [0x22u8; 32];
590        let slot_key = pbkdf2_sha256(PASS, &salt, L1_ITERS, L1_KEY_BYTES as usize);
591
592        let mut material =
593            af::af_split::<sha2::Sha256>(master, L1_KEY_BYTES as usize, L1_STRIPES as usize, 0x5a);
594        xts_encrypt(&slot_key, &mut material, 512, 0);
595
596        let mk_digest = pbkdf2_sha256(master, &mk_salt, L1_MK_ITER, 20);
597
598        let mut payload = known_plain(3 * 512);
599        let plain = payload.clone();
600        xts_encrypt(master, &mut payload, 512, 0);
601
602        let payload_byte = L1_PAYLOAD_OFF as usize * 512;
603        let mut img = vec![0u8; payload_byte + payload.len()];
604        img[0..6].copy_from_slice(&LUKS_MAGIC);
605        img[6..8].copy_from_slice(&1u16.to_be_bytes());
606        img[8..11].copy_from_slice(b"aes");
607        img[40..51].copy_from_slice(b"xts-plain64");
608        img[72..78].copy_from_slice(b"sha256");
609        img[104..108].copy_from_slice(&L1_PAYLOAD_OFF.to_be_bytes());
610        img[108..112].copy_from_slice(&L1_KEY_BYTES.to_be_bytes());
611        img[112..132].copy_from_slice(&mk_digest);
612        img[132..164].copy_from_slice(&mk_salt);
613        img[164..168].copy_from_slice(&L1_MK_ITER.to_be_bytes());
614        img[168..204].copy_from_slice(b"b22690e1-a392-4ecc-83b1-c1cf21200116");
615
616        for i in 0..LUKS_NUM_KEYS {
617            let base = 208 + i * KEYSLOT_LEN;
618            if i == 0 {
619                img[base..base + 4].copy_from_slice(&KEY_ENABLED.to_be_bytes());
620                img[base + 4..base + 8].copy_from_slice(&L1_ITERS.to_be_bytes());
621                img[base + 8..base + 40].copy_from_slice(&salt);
622                img[base + 40..base + 44].copy_from_slice(&L1_KM_OFF.to_be_bytes());
623                img[base + 44..base + 48].copy_from_slice(&L1_STRIPES.to_be_bytes());
624            } else {
625                img[base..base + 4].copy_from_slice(&KEY_DISABLED.to_be_bytes());
626            }
627        }
628
629        let km_byte = L1_KM_OFF as usize * 512;
630        img[km_byte..km_byte + material.len()].copy_from_slice(&material);
631        img[payload_byte..payload_byte + payload.len()].copy_from_slice(&payload);
632        assert!(
633            LUKS1_PHDR_LEN <= km_byte,
634            "phdr must not overlap key material"
635        );
636        (img, plain)
637    }
638
639    #[test]
640    fn luks1_roundtrip_via_autodetect() {
641        let master: Vec<u8> = (0..64u8)
642            .map(|x| x.wrapping_mul(7).wrapping_add(3))
643            .collect();
644        let (img, plain) = build_luks1(&master);
645        let mut vol = LuksVolume::unlock_with_passphrase(Cursor::new(img), PASS).unwrap();
646
647        assert_eq!(vol.info().version, 1);
648        assert_eq!(vol.info().cipher_name, "aes");
649        assert_eq!(vol.info().cipher_mode, "xts-plain64");
650        assert_eq!(vol.info().key_bytes, 64);
651        assert_eq!(vol.info().sector_size, 512);
652        assert_eq!(vol.master_key(), &master[..]);
653        assert_eq!(vol.payload_size(), 3 * 512);
654
655        for lba in 0..3u64 {
656            let mut buf = [0u8; 512];
657            vol.read_at(lba * 512, &mut buf).unwrap();
658            assert_eq!(
659                &buf[..],
660                &plain[lba as usize * 512..lba as usize * 512 + 512]
661            );
662        }
663    }
664
665    #[test]
666    fn luks1_read_seek_traits() {
667        let master = vec![0xABu8; 64];
668        let (img, plain) = build_luks1(&master);
669        let mut vol = LuksVolume::unlock1_with_passphrase(Cursor::new(img), PASS).unwrap();
670
671        // Seek variants.
672        assert_eq!(vol.seek(SeekFrom::Start(512)).unwrap(), 512);
673        assert_eq!(vol.seek(SeekFrom::Current(-256)).unwrap(), 256);
674        let end = vol.seek(SeekFrom::End(0)).unwrap();
675        assert_eq!(end, 3 * 512);
676        assert!(vol.seek(SeekFrom::End(-1)).is_ok());
677        assert!(vol.seek(SeekFrom::Start(0)).is_ok());
678
679        // Read the whole payload back through Read.
680        let mut got = Vec::new();
681        std::io::Read::read_to_end(&mut vol, &mut got).unwrap();
682        assert_eq!(got, plain);
683        // At EOF a further read yields 0.
684        let mut z = [0u8; 16];
685        assert_eq!(std::io::Read::read(&mut vol, &mut z).unwrap(), 0);
686    }
687
688    #[test]
689    fn luks1_seek_before_start_errors() {
690        let master = vec![0x01u8; 64];
691        let (img, _) = build_luks1(&master);
692        let mut vol = LuksVolume::unlock1_with_passphrase(Cursor::new(img), PASS).unwrap();
693        assert!(vol.seek(SeekFrom::Start(0)).is_ok());
694        assert!(vol.seek(SeekFrom::Current(-1)).is_err());
695    }
696
697    /// RED for the fuzz timeout (issue #10). `DERIVATION_BUDGET` bounds ONE
698    /// derivation at 30s, but `recover_master_key1` calls `derive_key` twice per
699    /// active keyslot and LUKS permits 8 of them — all attacker-controlled. Each
700    /// call passes its own budget while the total is unbounded, which is how the
701    /// `unlock` fuzz target reached a 1778-second timeout.
702    ///
703    /// The invariant: an unlock must be bounded as a WHOLE, not per derivation.
704    /// Expressed with a zero budget so the test costs nothing to run — if any
705    /// aggregate bound exists, no derivation may start.
706    #[test]
707    fn unlock_is_bounded_in_aggregate_not_just_per_derivation() {
708        let master = [0x33u8; L1_KEY_BYTES as usize];
709        let (img, _plain) = build_luks1(&master);
710
711        // `DecryptedPayload` is deliberately not Debug (it holds key material),
712        // so match rather than `expect_err`.
713        match LuksVolume::unlock1_with_passphrase_within(Cursor::new(img), PASS, Duration::ZERO) {
714            Err(LuksError::UnlockBudgetExceeded { .. }) => {}
715            Err(other) => panic!("expected UnlockBudgetExceeded, got {other:?}"), // cov:unreachable: a zero budget cannot reach any other error
716            Ok(_) => panic!("a zero total budget must refuse before doing derivation work"), // cov:unreachable: unreachable while the deadline is checked before the first derive
717        }
718    }
719
720    #[test]
721    fn luks1_wrong_passphrase() {
722        let master = vec![0x5Cu8; 64];
723        let (img, _) = build_luks1(&master);
724        assert!(matches!(
725            LuksVolume::unlock1_with_passphrase(Cursor::new(img), b"wrong"),
726            Err(LuksError::AuthenticationFailed)
727        ));
728    }
729
730    #[test]
731    fn luks1_no_active_keyslot() {
732        let master = vec![0x5Cu8; 64];
733        let (mut img, _) = build_luks1(&master);
734        // Disable keyslot 0.
735        img[208..212].copy_from_slice(&KEY_DISABLED.to_be_bytes());
736        assert!(matches!(
737            LuksVolume::unlock1_with_passphrase(Cursor::new(img), PASS),
738            Err(LuksError::NoActiveKeyslot)
739        ));
740    }
741
742    #[test]
743    fn luks1_short_key_material_is_skipped() {
744        // Active keyslot whose key material lies past EOF: read_available returns
745        // short, the slot is skipped, and unlock ends in AuthenticationFailed.
746        let mut img = vec![0u8; LUKS1_PHDR_LEN];
747        img[0..6].copy_from_slice(&LUKS_MAGIC);
748        img[6..8].copy_from_slice(&1u16.to_be_bytes());
749        img[8..11].copy_from_slice(b"aes");
750        img[40..51].copy_from_slice(b"xts-plain64");
751        img[72..78].copy_from_slice(b"sha256");
752        img[104..108].copy_from_slice(&4u32.to_be_bytes());
753        img[108..112].copy_from_slice(&64u32.to_be_bytes());
754        let base = 208;
755        img[base..base + 4].copy_from_slice(&KEY_ENABLED.to_be_bytes());
756        img[base + 4..base + 8].copy_from_slice(&5u32.to_be_bytes());
757        img[base + 40..base + 44].copy_from_slice(&1000u32.to_be_bytes()); // sector 1000 >> EOF
758        img[base + 44..base + 48].copy_from_slice(&8u32.to_be_bytes());
759        assert!(matches!(
760            LuksVolume::unlock1_with_passphrase(Cursor::new(img), PASS),
761            Err(LuksError::AuthenticationFailed)
762        ));
763    }
764
765    // ---- version dispatch / bad headers -----------------------------------
766
767    #[test]
768    fn not_a_luks_container() {
769        assert!(matches!(
770            LuksVolume::unlock_with_passphrase(Cursor::new(vec![0u8; 8]), PASS),
771            Err(LuksError::NotLuks { .. })
772        ));
773    }
774
775    #[test]
776    fn unsupported_version() {
777        let mut img = vec![0u8; 8];
778        img[0..6].copy_from_slice(&LUKS_MAGIC);
779        img[6..8].copy_from_slice(&3u16.to_be_bytes());
780        assert!(matches!(
781            LuksVolume::unlock_with_passphrase(Cursor::new(img), PASS),
782            Err(LuksError::UnsupportedVersion { version: 3 })
783        ));
784    }
785
786    #[test]
787    fn split_encryption_without_dash() {
788        assert_eq!(split_encryption("aes"), ("aes".to_string(), String::new()));
789        assert_eq!(
790            split_encryption("aes-xts-plain64"),
791            ("aes".to_string(), "xts-plain64".to_string())
792        );
793    }
794
795    // ---- LUKS2 ------------------------------------------------------------
796
797    const L2_AREA_OFF: u64 = 8192;
798    const L2_SEG_OFF: u64 = 12288;
799    const L2_STRIPES: usize = 8;
800    const L2_DIG_ITER: u32 = 5;
801
802    /// Assemble a LUKS2 container from a keyslot key + KDF JSON fragment, unlocking
803    /// to `master`. Returns (image, plain payload).
804    fn build_luks2(
805        master: &[u8],
806        slot_key: &[u8],
807        kdf_json: &str,
808        sector_size: usize,
809    ) -> (Vec<u8>, Vec<u8>) {
810        let b64 = base64::engine::general_purpose::STANDARD;
811        let dig_salt = [0x22u8; 16];
812        let digest = pbkdf2_sha256(master, &dig_salt, L2_DIG_ITER, 32);
813
814        let mut material = af::af_split::<sha2::Sha256>(master, 64, L2_STRIPES, 0x5a);
815        xts_encrypt(slot_key, &mut material, 512, 0);
816
817        let mut payload = known_plain(sector_size); // one data unit
818        let plain = payload.clone();
819        xts_encrypt(master, &mut payload, sector_size, 0);
820
821        let json = format!(
822            concat!(
823                "{{\"keyslots\":{{\"0\":{{\"key_size\":64,",
824                "\"af\":{{\"type\":\"luks1\",\"stripes\":{stripes},\"hash\":\"sha256\"}},",
825                "\"area\":{{\"type\":\"raw\",\"offset\":\"{area}\",\"size\":\"512\",\"encryption\":\"aes-xts-plain64\",\"key_size\":64}},",
826                "\"kdf\":{kdf}}}}},",
827                "\"segments\":{{\"0\":{{\"type\":\"crypt\",\"offset\":\"{seg}\",\"size\":\"dynamic\",\"iv_tweak\":\"0\",\"encryption\":\"aes-xts-plain64\",\"sector_size\":{ss}}}}},",
828                "\"digests\":{{\"0\":{{\"type\":\"pbkdf2\",\"keyslots\":[\"0\"],\"segments\":[\"0\"],\"hash\":\"sha256\",\"iterations\":{diter},\"salt\":\"{dsalt}\",\"digest\":\"{dig}\"}}}}}}"
829            ),
830            stripes = L2_STRIPES,
831            area = L2_AREA_OFF,
832            kdf = kdf_json,
833            seg = L2_SEG_OFF,
834            ss = sector_size,
835            diter = L2_DIG_ITER,
836            dsalt = b64.encode(dig_salt),
837            dig = b64.encode(&digest),
838        );
839
840        let hdr_size = LUKS2_BIN_HDR_LEN + 4096; // 8192
841        assert!(json.len() < 4096);
842        let total = L2_SEG_OFF as usize + payload.len();
843        let mut img = vec![0u8; total];
844        img[0..6].copy_from_slice(&LUKS_MAGIC);
845        img[6..8].copy_from_slice(&2u16.to_be_bytes());
846        img[8..16].copy_from_slice(&(hdr_size as u64).to_be_bytes());
847        img[LUKS2_BIN_HDR_LEN..LUKS2_BIN_HDR_LEN + json.len()].copy_from_slice(json.as_bytes());
848        let a = L2_AREA_OFF as usize;
849        img[a..a + material.len()].copy_from_slice(&material);
850        let s = L2_SEG_OFF as usize;
851        img[s..s + payload.len()].copy_from_slice(&payload);
852        (img, plain)
853    }
854
855    #[test]
856    fn luks2_argon2id_roundtrip() {
857        let master = vec![0xC3u8; 64];
858        let salt = [0x77u8; 16];
859        let slot_key = derive_key_argon2_within(
860            &Argon2Params {
861                kind: "argon2id",
862                time: 1,
863                memory: 32,
864                cpus: 1,
865                salt: &salt,
866            },
867            PASS,
868            64,
869            DERIVATION_BUDGET,
870        )
871        .unwrap();
872        let b64 = base64::engine::general_purpose::STANDARD;
873        let kdf = format!(
874            "{{\"type\":\"argon2id\",\"time\":1,\"memory\":32,\"cpus\":1,\"salt\":\"{}\"}}",
875            b64.encode(salt)
876        );
877        let (img, plain) = build_luks2(&master, &slot_key, &kdf, 4096);
878
879        let mut vol = LuksVolume::unlock_with_passphrase(Cursor::new(img), PASS).unwrap();
880        assert_eq!(vol.info().version, 2);
881        assert_eq!(vol.info().cipher_name, "aes");
882        assert_eq!(vol.info().cipher_mode, "xts-plain64");
883        assert_eq!(vol.info().sector_size, 4096);
884        assert_eq!(vol.info().key_bytes, 64);
885        assert_eq!(vol.master_key(), &master[..]);
886
887        let mut buf = [0u8; 512];
888        vol.read_at(0, &mut buf).unwrap();
889        assert_eq!(&buf[..], &plain[..512]);
890    }
891
892    #[test]
893    fn luks2_pbkdf2_roundtrip() {
894        let master = vec![0x1Eu8; 64];
895        let salt = [0x88u8; 16];
896        let slot_key = derive_key_within("sha256", PASS, &salt, 5, 64, DERIVATION_BUDGET).unwrap();
897        let b64 = base64::engine::general_purpose::STANDARD;
898        let kdf = format!(
899            "{{\"type\":\"pbkdf2\",\"hash\":\"sha256\",\"iterations\":5,\"salt\":\"{}\"}}",
900            b64.encode(salt)
901        );
902        let (img, plain) = build_luks2(&master, &slot_key, &kdf, 512);
903
904        let mut vol = LuksVolume::unlock2_with_passphrase(Cursor::new(img), PASS).unwrap();
905        assert_eq!(vol.info().sector_size, 512);
906        let mut buf = [0u8; 512];
907        vol.read_at(0, &mut buf).unwrap();
908        assert_eq!(&buf[..], &plain[..512]);
909    }
910
911    /// RED for the LUKS2 half of issue #10. `recover_master_key1` is now bounded
912    /// in aggregate; `recover_master_key2` is not. It derives once per keyslot
913    /// (Argon2 or PBKDF2) and once more per digest that references the slot,
914    /// each against its own fresh per-derivation budget — so the same
915    /// multiplication that took the `unlock` fuzz target to 1778 seconds on
916    /// LUKS1 applies here unchanged, on a header that chooses both the slot
917    /// count and each slot's cost.
918    ///
919    /// Expressed with a zero budget, like the LUKS1 twin: if any aggregate bound
920    /// exists then no derivation may start, so the test costs nothing to run.
921    #[test]
922    fn luks2_pbkdf2_unlock_is_bounded_in_aggregate_not_just_per_derivation() {
923        let master = vec![0x44u8; 64];
924        let salt = [0x88u8; 16];
925        let slot_key = derive_key_within("sha256", PASS, &salt, 5, 64, DERIVATION_BUDGET).unwrap();
926        let b64 = base64::engine::general_purpose::STANDARD;
927        let kdf = format!(
928            "{{\"type\":\"pbkdf2\",\"hash\":\"sha256\",\"iterations\":5,\"salt\":\"{}\"}}",
929            b64.encode(salt)
930        );
931        let (img, _plain) = build_luks2(&master, &slot_key, &kdf, 512);
932
933        // `DecryptedPayload` is deliberately not Debug (it holds key material),
934        // so match rather than `expect_err`.
935        match LuksVolume::unlock2_with_passphrase_within(Cursor::new(img), PASS, Duration::ZERO) {
936            Err(LuksError::UnlockBudgetExceeded { slots_tried, .. }) => assert_eq!(slots_tried, 0),
937            Err(other) => panic!("expected UnlockBudgetExceeded, got {other:?}"), // cov:unreachable: a zero budget cannot reach any other error
938            Ok(_) => panic!("a zero total budget must refuse before doing derivation work"), // cov:unreachable: unreachable while the deadline is checked before the first derive
939        }
940    }
941
942    /// The Argon2 keyslot branch needs the same aggregate bound as the PBKDF2
943    /// one. Argon2's own time projection is per-derivation, so without the
944    /// deadline a header full of Argon2 slots multiplies exactly as PBKDF2 ones
945    /// do — and the memory cap does nothing about that, it bounds the other axis.
946    #[test]
947    fn luks2_argon2_unlock_is_bounded_in_aggregate_too() {
948        let master = vec![0xC3u8; 64];
949        let salt = [0x77u8; 16];
950        let slot_key = derive_key_argon2_within(
951            &Argon2Params {
952                kind: "argon2id",
953                time: 1,
954                memory: 32,
955                cpus: 1,
956                salt: &salt,
957            },
958            PASS,
959            64,
960            DERIVATION_BUDGET,
961        )
962        .unwrap();
963        let b64 = base64::engine::general_purpose::STANDARD;
964        let kdf = format!(
965            "{{\"type\":\"argon2id\",\"time\":1,\"memory\":32,\"cpus\":1,\"salt\":\"{}\"}}",
966            b64.encode(salt)
967        );
968        let (img, _plain) = build_luks2(&master, &slot_key, &kdf, 512);
969
970        match LuksVolume::unlock2_with_passphrase_within(Cursor::new(img), PASS, Duration::ZERO) {
971            Err(LuksError::UnlockBudgetExceeded { slots_tried, .. }) => assert_eq!(slots_tried, 0),
972            Err(other) => panic!("expected UnlockBudgetExceeded, got {other:?}"), // cov:unreachable: a zero budget cannot reach any other error
973            Ok(_) => panic!("a zero total budget must refuse before running Argon2"), // cov:unreachable: unreachable while the deadline is checked before the first derive
974        }
975    }
976
977    /// The auto-detecting entry point must carry the budget too, or a caller
978    /// that does not know the container's version silently loses the bound —
979    /// which is the whole population the fuzz target and the VFS layer come
980    /// from. Both versions are asserted, since the dispatch is where a budget
981    /// gets dropped.
982    #[test]
983    fn autodetect_unlock_within_bounds_both_versions() {
984        let (img1, _) = build_luks1(&[0x33u8; L1_KEY_BYTES as usize]);
985        match LuksVolume::unlock_with_passphrase_within(Cursor::new(img1), PASS, Duration::ZERO) {
986            Err(LuksError::UnlockBudgetExceeded { .. }) => {}
987            Err(other) => panic!("LUKS1: expected UnlockBudgetExceeded, got {other:?}"), // cov:unreachable: a zero budget cannot reach any other error
988            Ok(_) => panic!("LUKS1: a zero total budget must refuse"), // cov:unreachable: unreachable while the deadline is checked before the first derive
989        }
990
991        let salt = [0x88u8; 16];
992        let slot_key = derive_key_within("sha256", PASS, &salt, 5, 64, DERIVATION_BUDGET).unwrap();
993        let b64 = base64::engine::general_purpose::STANDARD;
994        let kdf = format!(
995            "{{\"type\":\"pbkdf2\",\"hash\":\"sha256\",\"iterations\":5,\"salt\":\"{}\"}}",
996            b64.encode(salt)
997        );
998        let (img2, _) = build_luks2(&[0x44u8; 64], &slot_key, &kdf, 512);
999        match LuksVolume::unlock_with_passphrase_within(Cursor::new(img2), PASS, Duration::ZERO) {
1000            Err(LuksError::UnlockBudgetExceeded { .. }) => {}
1001            Err(other) => panic!("LUKS2: expected UnlockBudgetExceeded, got {other:?}"), // cov:unreachable: a zero budget cannot reach any other error
1002            Ok(_) => panic!("LUKS2: a zero total budget must refuse"), // cov:unreachable: unreachable while the deadline is checked before the first derive
1003        }
1004    }
1005
1006    #[test]
1007    fn luks2_no_crypt_segment() {
1008        let json = r#"{"keyslots":{"0":{"key_size":64,
1009          "af":{"stripes":8,"hash":"sha256"},
1010          "area":{"offset":"8192","size":"512","encryption":"aes-xts-plain64"},
1011          "kdf":{"type":"pbkdf2","hash":"sha256","iterations":5,"salt":"AAAA"}}},
1012          "segments":{},"digests":{}}"#;
1013        let img = build_luks2_json(json);
1014        assert!(matches!(
1015            LuksVolume::unlock2_with_passphrase(Cursor::new(img), PASS),
1016            Err(LuksError::NoActiveKeyslot)
1017        ));
1018    }
1019
1020    #[test]
1021    fn luks2_no_keyslots() {
1022        let json = r#"{"keyslots":{},
1023          "segments":{"0":{"type":"crypt","offset":"12288","encryption":"aes-xts-plain64","sector_size":512}},
1024          "digests":{}}"#;
1025        let img = build_luks2_json(json);
1026        assert!(matches!(
1027            LuksVolume::unlock2_with_passphrase(Cursor::new(img), PASS),
1028            Err(LuksError::NoActiveKeyslot)
1029        ));
1030    }
1031
1032    #[test]
1033    fn luks2_short_key_material_is_skipped() {
1034        // Keyslot area past EOF: material read is short, slot skipped -> auth fail.
1035        let json = r#"{"keyslots":{"0":{"key_size":64,
1036          "af":{"stripes":8,"hash":"sha256"},
1037          "area":{"offset":"9999999","size":"512","encryption":"aes-xts-plain64"},
1038          "kdf":{"type":"pbkdf2","hash":"sha256","iterations":5,"salt":"AAAA"}}},
1039          "segments":{"0":{"type":"crypt","offset":"12288","encryption":"aes-xts-plain64","sector_size":512}},
1040          "digests":{"0":{"type":"pbkdf2","keyslots":["0"],"hash":"sha256","iterations":5,"salt":"AAAA","digest":"AAAA"}}}"#;
1041        let img = build_luks2_json(json);
1042        assert!(matches!(
1043            LuksVolume::unlock2_with_passphrase(Cursor::new(img), PASS),
1044            Err(LuksError::AuthenticationFailed)
1045        ));
1046    }
1047
1048    /// Minimal LUKS2 image carrying an arbitrary JSON metadata document (no valid
1049    /// key material) — for the header-level error-path tests above.
1050    fn build_luks2_json(json: &str) -> Vec<u8> {
1051        let hdr_size = LUKS2_BIN_HDR_LEN + 4096;
1052        let total = 16384usize;
1053        let mut img = vec![0u8; total];
1054        img[0..6].copy_from_slice(&LUKS_MAGIC);
1055        img[6..8].copy_from_slice(&2u16.to_be_bytes());
1056        img[8..16].copy_from_slice(&(hdr_size as u64).to_be_bytes());
1057        img[LUKS2_BIN_HDR_LEN..LUKS2_BIN_HDR_LEN + json.len()].copy_from_slice(json.as_bytes());
1058        img
1059    }
1060
1061    // ---- reachable KDF/hash error propagation -----------------------------
1062
1063    #[test]
1064    fn luks1_unsupported_hash_errors() {
1065        // An unsupported hash spec makes the very first slot-key derive_key fail,
1066        // and unlock surfaces a loud Unsupported error (fail-loud, never panic).
1067        let mut img = vec![0u8; 4096];
1068        img[0..6].copy_from_slice(&LUKS_MAGIC);
1069        img[6..8].copy_from_slice(&1u16.to_be_bytes());
1070        img[8..11].copy_from_slice(b"aes");
1071        img[40..51].copy_from_slice(b"xts-plain64");
1072        img[72..75].copy_from_slice(b"md5");
1073        img[104..108].copy_from_slice(&4u32.to_be_bytes());
1074        img[108..112].copy_from_slice(&64u32.to_be_bytes());
1075        let base = 208;
1076        img[base..base + 4].copy_from_slice(&KEY_ENABLED.to_be_bytes());
1077        img[base + 4..base + 8].copy_from_slice(&5u32.to_be_bytes());
1078        img[base + 40..base + 44].copy_from_slice(&1u32.to_be_bytes());
1079        img[base + 44..base + 48].copy_from_slice(&8u32.to_be_bytes());
1080        assert!(matches!(
1081            LuksVolume::unlock1_with_passphrase(Cursor::new(img), PASS),
1082            Err(LuksError::Unsupported { what: "hash", .. })
1083        ));
1084    }
1085
1086    #[test]
1087    fn luks2_bad_argon2_params_errors() {
1088        // memory cost 0 fails Argon2's Params::new inside recover_master_key2.
1089        let json = r#"{"keyslots":{"0":{"key_size":64,
1090          "af":{"stripes":8,"hash":"sha256"},
1091          "area":{"offset":"8192","size":"512","encryption":"aes-xts-plain64"},
1092          "kdf":{"type":"argon2id","time":1,"memory":0,"cpus":1,"salt":"AAAAAAAAAAA="}}},
1093          "segments":{"0":{"type":"crypt","offset":"12288","encryption":"aes-xts-plain64","sector_size":512}},
1094          "digests":{}}"#;
1095        let img = build_luks2_json(json);
1096        assert!(matches!(
1097            LuksVolume::unlock2_with_passphrase(Cursor::new(img), PASS),
1098            Err(LuksError::Unsupported { .. })
1099        ));
1100    }
1101
1102    /// The PBKDF2 keyslot branch's own error propagation, which the Argon2 branch
1103    /// has (`luks2_bad_argon2_params_errors`) and this one did not: an
1104    /// unsupported hash in the SLOT's KDF, distinct from the AF hash and the
1105    /// digest hash the neighbouring tests cover. Fails at the first derivation,
1106    /// before any key material is read.
1107    #[test]
1108    fn luks2_unsupported_keyslot_kdf_hash_errors() {
1109        let json = r#"{"keyslots":{"0":{"key_size":64,
1110          "af":{"stripes":8,"hash":"sha256"},
1111          "area":{"offset":"8192","size":"512","encryption":"aes-xts-plain64"},
1112          "kdf":{"type":"pbkdf2","hash":"md5","iterations":5,"salt":"AAAA"}}},
1113          "segments":{"0":{"type":"crypt","offset":"12288","encryption":"aes-xts-plain64","sector_size":512}},
1114          "digests":{}}"#;
1115        let img = build_luks2_json(json);
1116        assert!(matches!(
1117            LuksVolume::unlock2_with_passphrase(Cursor::new(img), PASS),
1118            Err(LuksError::Unsupported { what: "hash", .. })
1119        ));
1120    }
1121
1122    #[test]
1123    fn luks2_unsupported_af_hash_errors() {
1124        // Valid KDF, readable material, but an unsupported AF hash makes af::merge
1125        // fail after the keyslot decrypt.
1126        let json = r#"{"keyslots":{"0":{"key_size":64,
1127          "af":{"stripes":8,"hash":"md5"},
1128          "area":{"offset":"8192","size":"512","encryption":"aes-xts-plain64"},
1129          "kdf":{"type":"pbkdf2","hash":"sha256","iterations":5,"salt":"AAAA"}}},
1130          "segments":{"0":{"type":"crypt","offset":"12288","encryption":"aes-xts-plain64","sector_size":512}},
1131          "digests":{}}"#;
1132        let img = build_luks2_json(json);
1133        assert!(matches!(
1134            LuksVolume::unlock2_with_passphrase(Cursor::new(img), PASS),
1135            Err(LuksError::Unsupported { what: "hash", .. })
1136        ));
1137    }
1138
1139    #[test]
1140    fn luks2_unsupported_digest_hash_errors() {
1141        // Reaches the digest loop, where an unsupported digest hash errors.
1142        let json = r#"{"keyslots":{"0":{"key_size":64,
1143          "af":{"stripes":8,"hash":"sha256"},
1144          "area":{"offset":"8192","size":"512","encryption":"aes-xts-plain64"},
1145          "kdf":{"type":"pbkdf2","hash":"sha256","iterations":5,"salt":"AAAA"}}},
1146          "segments":{"0":{"type":"crypt","offset":"12288","encryption":"aes-xts-plain64","sector_size":512}},
1147          "digests":{"0":{"type":"pbkdf2","keyslots":["0"],"hash":"md5","iterations":5,"salt":"AAAA","digest":"AAAA"}}}"#;
1148        let img = build_luks2_json(json);
1149        assert!(matches!(
1150            LuksVolume::unlock2_with_passphrase(Cursor::new(img), PASS),
1151            Err(LuksError::Unsupported { what: "hash", .. })
1152        ));
1153    }
1154
1155    #[test]
1156    fn luks2_digest_mismatch_falls_through() {
1157        // Two digests exercise both digest-loop branches: digest "0" references the
1158        // keyslot but its bytes don't match (the wrong-value fall-through), while
1159        // digest "1" references another keyslot (the skip branch). Neither
1160        // verifies, so unlock ends in AuthenticationFailed.
1161        let json = r#"{"keyslots":{"0":{"key_size":64,
1162          "af":{"stripes":8,"hash":"sha256"},
1163          "area":{"offset":"8192","size":"512","encryption":"aes-xts-plain64"},
1164          "kdf":{"type":"pbkdf2","hash":"sha256","iterations":5,"salt":"AAAA"}}},
1165          "segments":{"0":{"type":"crypt","offset":"12288","encryption":"aes-xts-plain64","sector_size":512}},
1166          "digests":{"0":{"type":"pbkdf2","keyslots":["0"],"hash":"sha256","iterations":5,"salt":"AAAA","digest":"AAAA"},
1167                     "1":{"type":"pbkdf2","keyslots":["9"],"hash":"sha256","iterations":5,"salt":"AAAA","digest":"AAAA"}}}"#;
1168        let img = build_luks2_json(json);
1169        assert!(matches!(
1170            LuksVolume::unlock2_with_passphrase(Cursor::new(img), PASS),
1171            Err(LuksError::AuthenticationFailed)
1172        ));
1173    }
1174
1175    // ---- read_available I/O arms ------------------------------------------
1176
1177    struct InterruptOnce(bool);
1178    impl Read for InterruptOnce {
1179        fn read(&mut self, _buf: &mut [u8]) -> std::io::Result<usize> {
1180            if self.0 {
1181                Ok(0)
1182            } else {
1183                self.0 = true;
1184                Err(std::io::Error::from(std::io::ErrorKind::Interrupted))
1185            }
1186        }
1187    }
1188
1189    struct AlwaysError;
1190    impl Read for AlwaysError {
1191        fn read(&mut self, _buf: &mut [u8]) -> std::io::Result<usize> {
1192            Err(std::io::Error::other("boom"))
1193        }
1194    }
1195
1196    #[test]
1197    fn read_available_retries_on_interrupted() {
1198        let mut r = InterruptOnce(false);
1199        let mut buf = [0xFFu8; 4];
1200        assert_eq!(read_available(&mut r, &mut buf).unwrap(), 0);
1201        assert_eq!(buf, [0u8; 4]); // zero-filled on EOF
1202    }
1203
1204    #[test]
1205    fn read_available_propagates_hard_error() {
1206        let mut r = AlwaysError;
1207        let mut buf = [0u8; 4];
1208        assert!(read_available(&mut r, &mut buf).is_err());
1209    }
1210}