Skip to main content

bitlocker_forensic/
lib.rs

1//! # bitlocker-forensic — BitLocker metadata anomaly auditor
2//!
3//! Emits severity-graded [`forensicnomicon::report::Finding`]s over the
4//! key-protector and cipher metadata decoded by [`bitlocker`].
5//! Findings are OBSERVATIONS, never verdicts — the examiner draws conclusions.
6//!
7//! The analyzer never decrypts; it audits the *protector inventory* and *cipher*
8//! that are visible without any credential:
9//!
10//! - `BDE-CLEAR-KEY-PRESENT` — a clear-key protector ⇒ the VMK is unprotected,
11//!   so the volume is effectively unencrypted (High).
12//! - `BDE-PROTECTOR-INVENTORY` — one per key protector (password / recovery /
13//!   TPM / startup key / …) (Info).
14//! - `BDE-WEAK-CIPHER` — AES-CBC ± Elephant Diffuser is weaker than AES-XTS,
15//!   consistent with an older Windows version (Low).
16//! - `BDE-TO-GO` — a BitLocker To Go removable-media volume (Info).
17//!
18//! ```no_run
19//! use std::fs::File;
20//! let mut image = File::open("bdetogo.raw")?;
21//! for anomaly in bitlocker_forensic::audit_reader(&mut image)? {
22//!     println!("{}: {}", anomaly.code, anomaly.note);
23//! }
24//! # Ok::<(), Box<dyn std::error::Error>>(())
25//! ```
26
27#![forbid(unsafe_code)]
28#![cfg_attr(test, allow(clippy::unwrap_used, clippy::expect_used))]
29
30use std::io::{Read, Seek, SeekFrom};
31
32use bitlocker::{BdeError, BdeVariant, BitLockerVolume, FveMetadata, VolumeHeader};
33use forensicnomicon::report::{Category, Evidence, Finding, Observation, Severity, Source};
34
35/// The producing analyzer name embedded in emitted findings' `Source`.
36pub const ANALYZER: &str = "bitlocker-forensic";
37
38// BitLocker key-protection types (VMK protector-type field).
39const PROT_CLEAR_KEY: u16 = 0x0000;
40const PROT_TPM: u16 = 0x0100;
41const PROT_STARTUP_KEY: u16 = 0x0200;
42const PROT_TPM_PIN: u16 = 0x0500;
43const PROT_RECOVERY: u16 = 0x0800;
44const PROT_PASSWORD: u16 = 0x2000;
45
46/// A classified BitLocker metadata observation.
47#[derive(Debug, Clone, PartialEq, Eq)]
48pub enum AnomalyKind {
49    /// A clear-key protector is present: the VMK is stored unprotected, so the
50    /// volume can be decrypted with no credential — effectively unencrypted.
51    ClearKeyPresent,
52    /// A key protector is present (one per protector).
53    Protector {
54        /// The raw protection-type value.
55        protector_type: u16,
56    },
57    /// The volume cipher is AES-CBC (with or without the Elephant Diffuser),
58    /// which is weaker than AES-XTS.
59    WeakCipher {
60        /// The raw encryption-method value.
61        method: u16,
62    },
63    /// The volume is a BitLocker To Go removable-media volume.
64    ToGo,
65}
66
67impl AnomalyKind {
68    /// Severity — the single source of truth for this kind.
69    #[must_use]
70    pub fn severity(&self) -> Severity {
71        match self {
72            AnomalyKind::ClearKeyPresent => Severity::High,
73            AnomalyKind::WeakCipher { .. } => Severity::Low,
74            AnomalyKind::Protector { .. } | AnomalyKind::ToGo => Severity::Info,
75        }
76    }
77
78    /// Stable, scheme-prefixed machine code (published contract).
79    #[must_use]
80    pub fn code(&self) -> &'static str {
81        match self {
82            AnomalyKind::ClearKeyPresent => "BDE-CLEAR-KEY-PRESENT",
83            AnomalyKind::Protector { .. } => "BDE-PROTECTOR-INVENTORY",
84            AnomalyKind::WeakCipher { .. } => "BDE-WEAK-CIPHER",
85            AnomalyKind::ToGo => "BDE-TO-GO",
86        }
87    }
88
89    /// Analytical lens.
90    #[must_use]
91    pub fn category(&self) -> Category {
92        match self {
93            AnomalyKind::ClearKeyPresent | AnomalyKind::WeakCipher { .. } => Category::Integrity,
94            AnomalyKind::Protector { .. } => Category::Provenance,
95            AnomalyKind::ToGo => Category::Structure,
96        }
97    }
98
99    /// Human-readable note including the offending value.
100    #[must_use]
101    pub fn note(&self) -> String {
102        match self {
103            AnomalyKind::ClearKeyPresent => "a clear-key protector (type 0x0000) is present; the \
104                 volume master key is stored unprotected, so the volume can be decrypted with no \
105                 credential — it is effectively unencrypted"
106                .to_string(),
107            AnomalyKind::Protector { protector_type } => format!(
108                "key protector present: {} (type 0x{protector_type:04x})",
109                protector_name(*protector_type)
110            ),
111            AnomalyKind::WeakCipher { method } => format!(
112                "volume cipher is {} (method 0x{method:04x}); AES-CBC, with or without the Elephant \
113                 Diffuser, is weaker than AES-XTS and is consistent with an older Windows version",
114                cipher_name(*method)
115            ),
116            AnomalyKind::ToGo => "the volume is a BitLocker To Go volume (removable-media BitLocker \
117                 on a FAT-formatted volume)"
118                .to_string(),
119        }
120    }
121
122    fn evidence(&self) -> Vec<Evidence> {
123        match self {
124            AnomalyKind::Protector { protector_type } => {
125                vec![evidence(
126                    "protector_type",
127                    format!("0x{protector_type:04x}"),
128                )]
129            }
130            AnomalyKind::WeakCipher { method } => {
131                vec![evidence("encryption_method", format!("0x{method:04x}"))]
132            }
133            AnomalyKind::ClearKeyPresent | AnomalyKind::ToGo => Vec::new(),
134        }
135    }
136}
137
138fn evidence(field: &str, value: String) -> Evidence {
139    Evidence {
140        field: field.to_string(),
141        value,
142        location: None,
143    }
144}
145
146/// Human name for a BitLocker key-protection type.
147#[must_use]
148pub fn protector_name(protector_type: u16) -> &'static str {
149    match protector_type {
150        PROT_CLEAR_KEY => "clear key",
151        PROT_TPM => "TPM",
152        PROT_STARTUP_KEY => "startup key",
153        PROT_TPM_PIN => "TPM and PIN",
154        PROT_RECOVERY => "recovery password",
155        PROT_PASSWORD => "password",
156        _ => "other/unknown",
157    }
158}
159
160/// Human name for a BitLocker encryption method.
161#[must_use]
162pub fn cipher_name(method: u16) -> &'static str {
163    match method {
164        0x8000 => "AES-128-CBC + Elephant Diffuser",
165        0x8001 => "AES-256-CBC + Elephant Diffuser",
166        0x8002 => "AES-128-CBC",
167        0x8003 => "AES-256-CBC",
168        0x8004 => "AES-128-XTS",
169        0x8005 => "AES-256-XTS",
170        _ => "unknown",
171    }
172}
173
174/// Whether a cipher method is AES-XTS (the strong, current mode).
175#[must_use]
176fn is_aes_xts(method: u16) -> bool {
177    matches!(method, 0x8004 | 0x8005)
178}
179
180/// A BitLocker forensic anomaly: an observation graded by severity, with a stable
181/// code and note derived from its [`AnomalyKind`] so they cannot drift.
182#[derive(Debug, Clone, PartialEq, Eq)]
183pub struct Anomaly {
184    /// Severity, derived from `kind`.
185    pub severity: Severity,
186    /// Stable machine-readable code, derived from `kind`.
187    pub code: &'static str,
188    /// The classified anomaly.
189    pub kind: AnomalyKind,
190    /// Human-readable note, derived from `kind`.
191    pub note: String,
192}
193
194impl Anomaly {
195    /// Build an [`Anomaly`], deriving severity/code/note from `kind`.
196    #[must_use]
197    pub fn new(kind: AnomalyKind) -> Self {
198        Anomaly {
199            severity: kind.severity(),
200            code: kind.code(),
201            note: kind.note(),
202            kind,
203        }
204    }
205}
206
207impl Observation for Anomaly {
208    fn severity(&self) -> Option<Severity> {
209        Some(self.severity)
210    }
211    fn code(&self) -> &'static str {
212        self.code
213    }
214    fn note(&self) -> String {
215        self.note.clone()
216    }
217    fn category(&self) -> Category {
218        self.kind.category()
219    }
220    fn evidence(&self) -> Vec<Evidence> {
221        self.kind.evidence()
222    }
223}
224
225/// Audit already-parsed metadata (and whether the volume is BitLocker To Go),
226/// returning classified anomalies. Pure and side-effect-free.
227#[must_use]
228pub fn audit(metadata: &FveMetadata, to_go: bool) -> Vec<Anomaly> {
229    let mut out = Vec::new();
230
231    if to_go {
232        out.push(Anomaly::new(AnomalyKind::ToGo));
233    }
234
235    if !is_aes_xts(metadata.encryption_method) {
236        out.push(Anomaly::new(AnomalyKind::WeakCipher {
237            method: metadata.encryption_method,
238        }));
239    }
240
241    for protector_type in metadata.protector_types() {
242        if protector_type == PROT_CLEAR_KEY {
243            out.push(Anomaly::new(AnomalyKind::ClearKeyPresent));
244        }
245        out.push(Anomaly::new(AnomalyKind::Protector { protector_type }));
246    }
247
248    out
249}
250
251/// Parse a BitLocker volume from `reader` and audit its metadata.
252///
253/// # Errors
254/// Propagates [`BdeError`] from header/metadata parsing (e.g. a non-BitLocker
255/// image or no valid FVE metadata block).
256pub fn audit_reader<R: Read + Seek>(reader: &mut R) -> Result<Vec<Anomaly>, BdeError> {
257    let mut header = [0u8; 512];
258    reader.seek(SeekFrom::Start(0))?;
259    reader.read_exact(&mut header)?;
260    let variant = VolumeHeader::parse(&header)?.variant;
261    let metadata = BitLockerVolume::read_metadata(reader)?;
262    Ok(audit(&metadata, variant == BdeVariant::BitLockerToGo))
263}
264
265/// Audit a BitLocker volume and map each anomaly to a canonical [`Finding`],
266/// tagged with the producing [`Source`] (`scope` names the evidence).
267///
268/// # Errors
269/// Propagates [`BdeError`] from parsing.
270pub fn audit_findings<R: Read + Seek>(
271    reader: &mut R,
272    scope: impl Into<String>,
273) -> Result<Vec<Finding>, BdeError> {
274    let source = Source {
275        analyzer: ANALYZER.to_string(),
276        scope: scope.into(),
277        version: Some(env!("CARGO_PKG_VERSION").to_string()),
278    };
279    Ok(audit_reader(reader)?
280        .into_iter()
281        .map(|anomaly| anomaly.to_finding(source.clone()))
282        .collect())
283}
284
285#[cfg(test)]
286mod tests;