diff --git a/crates/core/src/lib.rs b/crates/core/src/lib.rs index 9f9676c9..c4aeeec8 100644 --- a/crates/core/src/lib.rs +++ b/crates/core/src/lib.rs @@ -35,3 +35,5 @@ pub mod partition_schemes; pub mod partition_types; pub mod report; pub mod volatility; +pub mod volume_encryption; +pub mod volume_serial; diff --git a/crates/core/src/volume_encryption.rs b/crates/core/src/volume_encryption.rs new file mode 100644 index 00000000..4910dce7 --- /dev/null +++ b/crates/core/src/volume_encryption.rs @@ -0,0 +1,127 @@ +//! Volume full-disk-encryption detection from a volume boot record. +//! +//! Complements [`crate::filesystems`]: where that identifies a plaintext filesystem, this +//! identifies a **BitLocker** volume, which a filesystem detector cannot — a BitLocker To Go +//! volume presents a genuine FAT/exFAT boot record and looks like an ordinary FAT volume. +//! +//! Detection is scheme- and bus-agnostic: it reads the volume boot record, so it is identical +//! whether the volume is reached over USB, FireWire, Thunderbolt, or SATA, and whether the +//! partition table is MBR, GPT, or APM. +//! +//! Reference: libbde, *BitLocker Drive Encryption (BDE) format* (Joachim Metz), +//! +//! — volume-header tables. + +/// A detected volume-encryption type. LUKS and other encrypted-filesystem magics are surfaced +/// by [`crate::filesystems::detect_name`]; this enum covers the Microsoft FVE volumes that a +/// filesystem-signature scan cannot see. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +#[cfg_attr(feature = "serde", derive(serde::Serialize))] +pub enum VolumeEncryption { + /// BitLocker on a **fixed drive** (or an NTFS-on-removable volume): the boot record's OEM + /// identifier at offset 3 is the documented `-FVE-FS-` signature (Windows Vista `EB 52 90` + /// / 7-10 `EB 58 90`). + BitLocker, + /// **BitLocker To Go** on removable media: the discovery volume presents a real FAT/exFAT + /// boot record (an ordinary OEM identifier at offset 3), so it is identified only by the + /// BitLocker identifier GUID carried in the volume header, not by `-FVE-FS-`. + BitLockerToGo, +} + +/// The `-FVE-FS-` filesystem signature at volume-header offset 3 (fixed-drive BitLocker). +const FVE_SIGNATURE: &[u8; 8] = b"-FVE-FS-"; + +/// The BitLocker identifier GUID `4967D63B-2E29-4AD8-8399-F6A339E3D001`, in the mixed-endian +/// byte order it is stored in a volume header (`Data1`-`Data3` little-endian, `Data4` +/// big-endian). The volume header carries it in both layouts — offset 160 (fixed drive) and +/// offset 424 (To Go) — so callers scan for it rather than reading a fixed offset. +pub const BITLOCKER_IDENTIFIER_GUID: [u8; 16] = [ + 0x3B, 0xD6, 0x67, 0x49, 0x29, 0x2E, 0xD8, 0x4A, 0x83, 0x99, 0xF6, 0xA3, 0x39, 0xE3, 0xD0, 0x01, +]; + +/// Detect BitLocker volume encryption from a volume's leading bytes (its boot record). +/// +/// - `-FVE-FS-` at offset 3 → [`VolumeEncryption::BitLocker`] (fixed drive; Vista/7/8/10). +/// - otherwise, the [`BITLOCKER_IDENTIFIER_GUID`] present anywhere in the volume header → +/// [`VolumeEncryption::BitLockerToGo`] (a FAT/exFAT discovery volume carrying the GUID). +/// +/// `None` for a plaintext volume. Scanning the header for the 16-byte GUID (rather than a +/// fixed offset) catches every BitLocker layout version and is effectively false-positive-free. +#[must_use] +pub fn detect_encryption(volume: &[u8]) -> Option { + if volume.len() >= 11 && &volume[3..11] == FVE_SIGNATURE { + return Some(VolumeEncryption::BitLocker); + } + if volume + .windows(BITLOCKER_IDENTIFIER_GUID.len()) + .any(|w| w == BITLOCKER_IDENTIFIER_GUID) + { + return Some(VolumeEncryption::BitLockerToGo); + } + None +} + +#[cfg(test)] +mod tests { + use super::*; + + /// Offset of the BitLocker identifier GUID in a To Go volume header (libbde). + const TO_GO_GUID_OFFSET: usize = 424; + + #[test] + fn fixed_drive_bitlocker_is_detected_by_the_fve_signature() { + let mut vbr = vec![0u8; 512]; + vbr[0..3].copy_from_slice(&[0xEB, 0x58, 0x90]); // Win7+ boot entry + vbr[3..11].copy_from_slice(b"-FVE-FS-"); + assert_eq!(detect_encryption(&vbr), Some(VolumeEncryption::BitLocker)); + } + + #[test] + fn bitlocker_to_go_is_detected_by_the_identifier_guid_on_a_fat_volume() { + // A real FAT32 discovery volume: MSWIN4.1 OEM id, FAT32 signature — no -FVE-FS- — but + // carrying the BitLocker identifier GUID at the To Go offset. + let mut vbr = vec![0u8; 512]; + vbr[0..3].copy_from_slice(&[0xEB, 0x58, 0x90]); + vbr[3..11].copy_from_slice(b"MSWIN4.1"); + vbr[0x52..0x5A].copy_from_slice(b"FAT32 "); + vbr[TO_GO_GUID_OFFSET..TO_GO_GUID_OFFSET + 16].copy_from_slice(&BITLOCKER_IDENTIFIER_GUID); + assert_eq!( + detect_encryption(&vbr), + Some(VolumeEncryption::BitLockerToGo) + ); + } + + #[test] + fn the_fve_signature_takes_precedence_over_the_guid() { + // A fixed-drive BitLocker volume carries both -FVE-FS- and the GUID (at offset 160); + // it classifies as BitLocker, not To Go. + let mut vbr = vec![0u8; 512]; + vbr[3..11].copy_from_slice(b"-FVE-FS-"); + vbr[160..176].copy_from_slice(&BITLOCKER_IDENTIFIER_GUID); + assert_eq!(detect_encryption(&vbr), Some(VolumeEncryption::BitLocker)); + } + + #[test] + fn a_plaintext_fat_volume_is_not_flagged() { + // A real FAT32 boot record with no FVE signature and no GUID → not encrypted (the + // non-false-positive control). + let mut vbr = vec![0u8; 512]; + vbr[3..11].copy_from_slice(b"MSDOS5.0"); + vbr[0x52..0x5A].copy_from_slice(b"FAT32 "); + assert_eq!(detect_encryption(&vbr), None); + } + + #[test] + fn a_plaintext_ntfs_volume_is_not_flagged() { + let mut vbr = vec![0u8; 512]; + vbr[3..11].copy_from_slice(b"NTFS "); + assert_eq!(detect_encryption(&vbr), None); + } + + #[test] + fn a_short_slice_does_not_panic() { + assert_eq!(detect_encryption(&[]), None); + assert_eq!(detect_encryption(&[0u8; 4]), None); + assert_eq!(detect_encryption(b"-FVE-FS"), None); // one byte short of the signature + } +} diff --git a/crates/core/src/volume_serial.rs b/crates/core/src/volume_serial.rs new file mode 100644 index 00000000..59181215 --- /dev/null +++ b/crates/core/src/volume_serial.rs @@ -0,0 +1,128 @@ +//! Volume serial-number extraction from a volume boot record. +//! +//! The volume serial is a forensic join key: a FAT `BS_VolID` matches the serial that +//! `EMDMgmt` (ReadyBoost) and Windows Shell Links record for the same volume; an NTFS volume +//! serial appears in its own artifacts. Like [`crate::volume_encryption`], this reads the +//! boot record, so it is independent of the bus and of the MBR/GPT/APM partition table. +//! +//! Field offsets are from the Microsoft FAT specification (`fatgen103`), the Microsoft exFAT +//! specification, and the NTFS boot-sector layout. + +/// A volume serial number, tagged by width so a 4-byte serial cannot be confused with an +/// 8-byte one (they render differently and join different artifacts). +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +#[cfg_attr(feature = "serde", derive(serde::Serialize))] +pub enum VolumeSerial { + /// A 4-byte serial — FAT `BS_VolID` or exFAT `VolumeSerialNumber`. Rendered `XXXX-XXXX`, + /// the form Shell Links and `EMDMgmt` record. + Short(u32), + /// The 8-byte NTFS volume serial. Rendered `XXXXXXXX-XXXXXXXX`; by width it cannot collide + /// with a 4-byte serial. + Long(u64), +} + +impl VolumeSerial { + /// The canonical hex rendering: `XXXX-XXXX` for a 4-byte serial, `XXXXXXXX-XXXXXXXX` for + /// the 8-byte NTFS serial. + #[must_use] + pub fn display(self) -> String { + match self { + Self::Short(v) => format!("{:04X}-{:04X}", v >> 16, v & 0xFFFF), + Self::Long(v) => format!("{:08X}-{:08X}", (v >> 32) as u32, (v & 0xFFFF_FFFF) as u32), + } + } +} + +/// Extract the volume serial from a volume's boot record, keyed off the filesystem signature: +/// +/// - NTFS (`"NTFS "` at offset 3) → 8-byte serial at offset `0x48`. +/// - exFAT (`"EXFAT "` at offset 3) → 4-byte `VolumeSerialNumber` at offset `0x64`. +/// - FAT32 (`"FAT32 "` at offset `0x52`) → 4-byte `BS_VolID` at offset `0x43`. +/// - FAT12/16 (`"FAT"` at offset `0x36`) → 4-byte `BS_VolID` at offset `0x27`. +/// +/// `None` for any other volume, or a slice too short to reach the field. Panic-free. +#[must_use] +pub fn volume_serial(volume: &[u8]) -> Option { + if volume.get(3..11) == Some(b"NTFS ") { + return u64_le(volume, 0x48).map(VolumeSerial::Long); + } + if volume.get(3..11) == Some(b"EXFAT ") { + return u32_le(volume, 0x64).map(VolumeSerial::Short); + } + if volume.get(0x52..0x5A) == Some(b"FAT32 ") { + return u32_le(volume, 0x43).map(VolumeSerial::Short); + } + if volume.get(0x36..0x39) == Some(b"FAT") { + return u32_le(volume, 0x27).map(VolumeSerial::Short); + } + None +} + +/// Read a little-endian `u32` at `off`; `None` if the slice is too short. Panic-free. +fn u32_le(b: &[u8], off: usize) -> Option { + let end = off.checked_add(4)?; + let bytes: [u8; 4] = b.get(off..end)?.try_into().ok()?; + Some(u32::from_le_bytes(bytes)) +} + +/// Read a little-endian `u64` at `off`; `None` if the slice is too short. Panic-free. +fn u64_le(b: &[u8], off: usize) -> Option { + let end = off.checked_add(8)?; + let bytes: [u8; 8] = b.get(off..end)?.try_into().ok()?; + Some(u64::from_le_bytes(bytes)) +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn fat32_reads_bs_volid_at_0x43() { + let mut vbr = vec![0u8; 512]; + vbr[0x52..0x5A].copy_from_slice(b"FAT32 "); + vbr[0x43..0x47].copy_from_slice(&0xB4D8_5399u32.to_le_bytes()); + assert_eq!(volume_serial(&vbr), Some(VolumeSerial::Short(0xB4D8_5399))); + assert_eq!(VolumeSerial::Short(0xB4D8_5399).display(), "B4D8-5399"); + } + + #[test] + fn fat16_reads_bs_volid_at_0x27() { + let mut vbr = vec![0u8; 512]; + vbr[0x36..0x39].copy_from_slice(b"FAT"); + vbr[0x27..0x2B].copy_from_slice(&0x1234_5678u32.to_le_bytes()); + assert_eq!(volume_serial(&vbr), Some(VolumeSerial::Short(0x1234_5678))); + } + + #[test] + fn ntfs_reads_the_8_byte_serial_at_0x48() { + let mut vbr = vec![0u8; 512]; + vbr[3..11].copy_from_slice(b"NTFS "); + vbr[0x48..0x50].copy_from_slice(&0x0011_2233_4455_6677u64.to_le_bytes()); + assert_eq!( + volume_serial(&vbr), + Some(VolumeSerial::Long(0x0011_2233_4455_6677)) + ); + assert_eq!( + VolumeSerial::Long(0x0011_2233_4455_6677).display(), + "00112233-44556677" + ); + } + + #[test] + fn exfat_reads_the_serial_at_0x64() { + let mut vbr = vec![0u8; 512]; + vbr[3..11].copy_from_slice(b"EXFAT "); + vbr[0x64..0x68].copy_from_slice(&0xDEAD_BEEFu32.to_le_bytes()); + assert_eq!(volume_serial(&vbr), Some(VolumeSerial::Short(0xDEAD_BEEF))); + } + + #[test] + fn an_unrecognized_or_short_volume_has_no_serial() { + assert_eq!(volume_serial(&[0xABu8; 512]), None); + assert_eq!(volume_serial(&[]), None); + // NTFS OEM present but the slice ends before the serial field → None, not a panic. + let mut short = vec![0u8; 0x48]; + short[3..11].copy_from_slice(b"NTFS "); + assert_eq!(volume_serial(&short), None); + } +} diff --git a/public-api/forensicnomicon-core.txt b/public-api/forensicnomicon-core.txt index 511e01d6..b66504e0 100644 --- a/public-api/forensicnomicon-core.txt +++ b/public-api/forensicnomicon-core.txt @@ -643,3 +643,16 @@ pub forensicnomicon_core::volatility::VolatilityClass::Persistent = 1 pub forensicnomicon_core::volatility::VolatilityClass::Residual = 0 pub forensicnomicon_core::volatility::VolatilityClass::RotatingBuffer = 3 pub forensicnomicon_core::volatility::VolatilityClass::Volatile = 4 +pub mod forensicnomicon_core::volume_encryption +pub enum forensicnomicon_core::volume_encryption::VolumeEncryption +pub forensicnomicon_core::volume_encryption::VolumeEncryption::BitLocker +pub forensicnomicon_core::volume_encryption::VolumeEncryption::BitLockerToGo +pub const forensicnomicon_core::volume_encryption::BITLOCKER_IDENTIFIER_GUID: [u8; 16] +pub fn forensicnomicon_core::volume_encryption::detect_encryption(&[u8]) -> core::option::Option +pub mod forensicnomicon_core::volume_serial +pub enum forensicnomicon_core::volume_serial::VolumeSerial +pub forensicnomicon_core::volume_serial::VolumeSerial::Long(u64) +pub forensicnomicon_core::volume_serial::VolumeSerial::Short(u32) +impl forensicnomicon_core::volume_serial::VolumeSerial +pub fn forensicnomicon_core::volume_serial::VolumeSerial::display(self) -> alloc::string::String +pub fn forensicnomicon_core::volume_serial::volume_serial(&[u8]) -> core::option::Option