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 (bitlocker-core unlocks it via \
106                 unlock_clear_key)"
107                .to_string(),
108            AnomalyKind::Protector { protector_type } => format!(
109                "key protector present: {} (type 0x{protector_type:04x})",
110                protector_name(*protector_type)
111            ),
112            AnomalyKind::WeakCipher { method } => format!(
113                "volume cipher is {} (method 0x{method:04x}); AES-CBC, with or without the Elephant \
114                 Diffuser, is weaker than AES-XTS and is consistent with an older Windows version",
115                cipher_name(*method)
116            ),
117            AnomalyKind::ToGo => "the volume is a BitLocker To Go volume (removable-media BitLocker \
118                 on a FAT-formatted volume)"
119                .to_string(),
120        }
121    }
122
123    fn evidence(&self) -> Vec<Evidence> {
124        match self {
125            AnomalyKind::Protector { protector_type } => {
126                vec![evidence(
127                    "protector_type",
128                    format!("0x{protector_type:04x}"),
129                )]
130            }
131            AnomalyKind::WeakCipher { method } => {
132                vec![evidence("encryption_method", format!("0x{method:04x}"))]
133            }
134            AnomalyKind::ClearKeyPresent | AnomalyKind::ToGo => Vec::new(),
135        }
136    }
137}
138
139fn evidence(field: &str, value: String) -> Evidence {
140    Evidence {
141        field: field.to_string(),
142        value,
143        location: None,
144    }
145}
146
147/// Human name for a BitLocker key-protection type.
148#[must_use]
149pub fn protector_name(protector_type: u16) -> &'static str {
150    match protector_type {
151        PROT_CLEAR_KEY => "clear key",
152        PROT_TPM => "TPM",
153        PROT_STARTUP_KEY => "startup key",
154        PROT_TPM_PIN => "TPM and PIN",
155        PROT_RECOVERY => "recovery password",
156        PROT_PASSWORD => "password",
157        _ => "other/unknown",
158    }
159}
160
161/// Human name for a BitLocker encryption method.
162#[must_use]
163pub fn cipher_name(method: u16) -> &'static str {
164    match method {
165        0x8000 => "AES-128-CBC + Elephant Diffuser",
166        0x8001 => "AES-256-CBC + Elephant Diffuser",
167        0x8002 => "AES-128-CBC",
168        0x8003 => "AES-256-CBC",
169        0x8004 => "AES-128-XTS",
170        0x8005 => "AES-256-XTS",
171        _ => "unknown",
172    }
173}
174
175/// Whether a cipher method is AES-XTS (the strong, current mode).
176#[must_use]
177fn is_aes_xts(method: u16) -> bool {
178    matches!(method, 0x8004 | 0x8005)
179}
180
181/// A BitLocker forensic anomaly: an observation graded by severity, with a stable
182/// code and note derived from its [`AnomalyKind`] so they cannot drift.
183#[derive(Debug, Clone, PartialEq, Eq)]
184pub struct Anomaly {
185    /// Severity, derived from `kind`.
186    pub severity: Severity,
187    /// Stable machine-readable code, derived from `kind`.
188    pub code: &'static str,
189    /// The classified anomaly.
190    pub kind: AnomalyKind,
191    /// Human-readable note, derived from `kind`.
192    pub note: String,
193}
194
195impl Anomaly {
196    /// Build an [`Anomaly`], deriving severity/code/note from `kind`.
197    #[must_use]
198    pub fn new(kind: AnomalyKind) -> Self {
199        Anomaly {
200            severity: kind.severity(),
201            code: kind.code(),
202            note: kind.note(),
203            kind,
204        }
205    }
206}
207
208impl Observation for Anomaly {
209    fn severity(&self) -> Option<Severity> {
210        Some(self.severity)
211    }
212    fn code(&self) -> &'static str {
213        self.code
214    }
215    fn note(&self) -> String {
216        self.note.clone()
217    }
218    fn category(&self) -> Category {
219        self.kind.category()
220    }
221    fn evidence(&self) -> Vec<Evidence> {
222        self.kind.evidence()
223    }
224}
225
226/// Audit already-parsed metadata (and whether the volume is BitLocker To Go),
227/// returning classified anomalies. Pure and side-effect-free.
228#[must_use]
229pub fn audit(metadata: &FveMetadata, to_go: bool) -> Vec<Anomaly> {
230    let mut out = Vec::new();
231
232    if to_go {
233        out.push(Anomaly::new(AnomalyKind::ToGo));
234    }
235
236    if !is_aes_xts(metadata.encryption_method) {
237        out.push(Anomaly::new(AnomalyKind::WeakCipher {
238            method: metadata.encryption_method,
239        }));
240    }
241
242    for protector_type in metadata.protector_types() {
243        if protector_type == PROT_CLEAR_KEY {
244            out.push(Anomaly::new(AnomalyKind::ClearKeyPresent));
245        }
246        out.push(Anomaly::new(AnomalyKind::Protector { protector_type }));
247    }
248
249    out
250}
251
252/// Parse a BitLocker volume from `reader` and audit its metadata.
253///
254/// # Errors
255/// Propagates [`BdeError`] from header/metadata parsing (e.g. a non-BitLocker
256/// image or no valid FVE metadata block).
257pub fn audit_reader<R: Read + Seek>(reader: &mut R) -> Result<Vec<Anomaly>, BdeError> {
258    let mut header = [0u8; 512];
259    reader.seek(SeekFrom::Start(0))?;
260    reader.read_exact(&mut header)?;
261    let variant = VolumeHeader::parse(&header)?.variant;
262    let metadata = BitLockerVolume::read_metadata(reader)?;
263    Ok(audit(&metadata, variant == BdeVariant::BitLockerToGo))
264}
265
266/// Audit a BitLocker volume and map each anomaly to a canonical [`Finding`],
267/// tagged with the producing [`Source`] (`scope` names the evidence).
268///
269/// # Errors
270/// Propagates [`BdeError`] from parsing.
271pub fn audit_findings<R: Read + Seek>(
272    reader: &mut R,
273    scope: impl Into<String>,
274) -> Result<Vec<Finding>, BdeError> {
275    let source = Source {
276        analyzer: ANALYZER.to_string(),
277        scope: scope.into(),
278        version: Some(env!("CARGO_PKG_VERSION").to_string()),
279    };
280    Ok(audit_reader(reader)?
281        .into_iter()
282        .map(|anomaly| anomaly.to_finding(source.clone()))
283        .collect())
284}
285
286#[cfg(test)]
287mod tests;