Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 2 additions & 0 deletions crates/core/src/lib.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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;
127 changes: 127 additions & 0 deletions crates/core/src/volume_encryption.rs
Original file line number Diff line number Diff line change
@@ -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),
//! <https://github.com/libyal/libbde/blob/main/documentation/BitLocker%20Drive%20Encryption%20(BDE)%20format.asciidoc>
//! — 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<VolumeEncryption> {
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
}
}
128 changes: 128 additions & 0 deletions crates/core/src/volume_serial.rs
Original file line number Diff line number Diff line change
@@ -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<VolumeSerial> {
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<u32> {
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<u64> {
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);
}
}
13 changes: 13 additions & 0 deletions public-api/forensicnomicon-core.txt
Original file line number Diff line number Diff line change
Expand Up @@ -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<forensicnomicon_core::volume_encryption::VolumeEncryption>
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<forensicnomicon_core::volume_serial::VolumeSerial>
Loading