Skip to main content

veracrypt_forensic/
lib.rs

1//! # veracrypt-forensic — VeraCrypt/TrueCrypt volume anomaly auditor
2//!
3//! Emits severity-graded [`forensicnomicon::report::Finding`]s over an unlocked
4//! volume's recovered facts: flavor (VeraCrypt vs legacy TrueCrypt), the
5//! PRF/cipher in use, and whether the header advertises a hidden volume. Findings
6//! are observations, never verdicts — the examiner draws conclusions.
7//!
8//! - `VC-LEGACY-TRUECRYPT` — a legacy TrueCrypt (not VeraCrypt) container (Low).
9//! - `VC-HIDDEN-VOLUME-DECLARED` — the outer header declares a hidden volume (Medium).
10//! - `VC-CIPHER-INVENTORY` — the PRF, cipher, and data offset in use (Info).
11
12#![forbid(unsafe_code)]
13#![cfg_attr(test, allow(clippy::unwrap_used, clippy::expect_used))]
14
15use forensicnomicon::report::{Category, Evidence, Finding, Observation, Severity, Source};
16use veracrypt::{Flavor, VolumeInfo};
17
18/// The producing analyzer name embedded in emitted findings' `Source`.
19pub const ANALYZER: &str = "veracrypt-forensic";
20
21/// A classified VeraCrypt observation.
22#[derive(Debug, Clone, PartialEq, Eq)]
23pub enum AnomalyKind {
24    /// The volume is a legacy TrueCrypt container.
25    LegacyTrueCrypt,
26    /// The outer header declares a hidden volume (deniable-encryption indicator).
27    HiddenVolumeDeclared {
28        /// Declared hidden-volume size in bytes.
29        size: u64,
30    },
31    /// The cipher/PRF inventory of the unlocked volume.
32    CipherInventory {
33        /// PRF name.
34        prf: &'static str,
35        /// Cipher chain display (e.g. `aes`, `aes-twofish`).
36        cipher: String,
37        /// Byte offset of the encrypted data area.
38        offset: u64,
39    },
40}
41
42impl AnomalyKind {
43    /// Severity — the single source of truth for this kind.
44    #[must_use]
45    pub fn severity(&self) -> Severity {
46        match self {
47            AnomalyKind::LegacyTrueCrypt => Severity::Low,
48            AnomalyKind::HiddenVolumeDeclared { .. } => Severity::Medium,
49            AnomalyKind::CipherInventory { .. } => Severity::Info,
50        }
51    }
52
53    /// Stable, scheme-prefixed machine code (published contract).
54    #[must_use]
55    pub fn code(&self) -> &'static str {
56        match self {
57            AnomalyKind::LegacyTrueCrypt => "VC-LEGACY-TRUECRYPT",
58            AnomalyKind::HiddenVolumeDeclared { .. } => "VC-HIDDEN-VOLUME-DECLARED",
59            AnomalyKind::CipherInventory { .. } => "VC-CIPHER-INVENTORY",
60        }
61    }
62
63    /// Analytical lens.
64    #[must_use]
65    pub fn category(&self) -> Category {
66        match self {
67            AnomalyKind::LegacyTrueCrypt | AnomalyKind::CipherInventory { .. } => {
68                Category::Provenance
69            }
70            AnomalyKind::HiddenVolumeDeclared { .. } => Category::Concealment,
71        }
72    }
73
74    /// Human-readable note including the offending value.
75    #[must_use]
76    pub fn note(&self) -> String {
77        match self {
78            AnomalyKind::LegacyTrueCrypt => {
79                "volume is a legacy TrueCrypt (not VeraCrypt) container".to_string()
80            }
81            AnomalyKind::HiddenVolumeDeclared { size } => format!(
82                "outer header declares a hidden volume of {size} bytes (deniable-encryption indicator)"
83            ),
84            AnomalyKind::CipherInventory {
85                prf,
86                cipher,
87                offset,
88            } => format!("PRF {prf}, cipher {cipher}, data area at byte {offset}"),
89        }
90    }
91
92    fn evidence(&self) -> Vec<Evidence> {
93        match self {
94            AnomalyKind::LegacyTrueCrypt => Vec::new(),
95            AnomalyKind::HiddenVolumeDeclared { size } => {
96                vec![evidence("hidden_size", size.to_string())]
97            }
98            AnomalyKind::CipherInventory {
99                prf,
100                cipher,
101                offset,
102            } => vec![
103                evidence("prf", (*prf).to_string()),
104                evidence("cipher", cipher.clone()),
105                evidence("encrypted_area_start", offset.to_string()),
106            ],
107        }
108    }
109}
110
111fn evidence(field: &str, value: String) -> Evidence {
112    Evidence {
113        field: field.to_string(),
114        value,
115        location: None,
116    }
117}
118
119/// A VeraCrypt forensic anomaly: an observation graded by severity, with a stable
120/// code and note derived from its [`AnomalyKind`] so they cannot drift.
121#[derive(Debug, Clone, PartialEq, Eq)]
122pub struct Anomaly {
123    /// Severity, derived from `kind`.
124    pub severity: Severity,
125    /// Stable machine-readable code, derived from `kind`.
126    pub code: &'static str,
127    /// The classified anomaly.
128    pub kind: AnomalyKind,
129    /// Human-readable note, derived from `kind`.
130    pub note: String,
131}
132
133impl Anomaly {
134    /// Build an [`Anomaly`], deriving severity/code/note from `kind`.
135    #[must_use]
136    pub fn new(kind: AnomalyKind) -> Self {
137        Anomaly {
138            severity: kind.severity(),
139            code: kind.code(),
140            note: kind.note(),
141            kind,
142        }
143    }
144}
145
146impl Observation for Anomaly {
147    fn severity(&self) -> Option<Severity> {
148        Some(self.severity)
149    }
150    fn code(&self) -> &'static str {
151        self.code
152    }
153    fn note(&self) -> String {
154        self.note.clone()
155    }
156    fn category(&self) -> Category {
157        self.kind.category()
158    }
159    fn evidence(&self) -> Vec<Evidence> {
160        self.kind.evidence()
161    }
162}
163
164/// Audit an unlocked volume's [`VolumeInfo`] plus its declared hidden-volume size,
165/// returning classified anomalies. Pure.
166#[must_use]
167pub fn audit(info: &VolumeInfo, hidden_size: u64) -> Vec<Anomaly> {
168    let mut out = Vec::new();
169
170    if info.flavor == Flavor::TrueCrypt {
171        out.push(Anomaly::new(AnomalyKind::LegacyTrueCrypt));
172    }
173
174    if hidden_size != 0 {
175        out.push(Anomaly::new(AnomalyKind::HiddenVolumeDeclared {
176            size: hidden_size,
177        }));
178    }
179
180    out.push(Anomaly::new(AnomalyKind::CipherInventory {
181        prf: info.prf.name(),
182        cipher: info.cipher_display(),
183        offset: info.encrypted_area_start,
184    }));
185
186    out
187}
188
189/// Audit a volume and map each anomaly to a canonical [`Finding`], tagged with the
190/// producing [`Source`] (`scope` names the evidence).
191#[must_use]
192pub fn audit_findings(
193    info: &VolumeInfo,
194    hidden_size: u64,
195    scope: impl Into<String>,
196) -> Vec<Finding> {
197    let source = Source {
198        analyzer: ANALYZER.to_string(),
199        scope: scope.into(),
200        version: Some(env!("CARGO_PKG_VERSION").to_string()),
201    };
202    audit(info, hidden_size)
203        .into_iter()
204        .map(|a| a.to_finding(source.clone()))
205        .collect()
206}
207
208#[cfg(test)]
209mod tests {
210    use super::*;
211    use veracrypt::{Cipher, Prf};
212
213    fn info(flavor: Flavor) -> VolumeInfo {
214        VolumeInfo {
215            flavor,
216            prf: Prf::Sha512,
217            ciphers: vec![Cipher::Serpent],
218            version: 5,
219            encrypted_area_start: 131_072,
220            encrypted_area_size: 36_864,
221        }
222    }
223
224    #[test]
225    fn veracrypt_clean_only_inventory() {
226        let a = audit(&info(Flavor::VeraCrypt), 0);
227        assert_eq!(a.len(), 1);
228        assert_eq!(a[0].code, "VC-CIPHER-INVENTORY");
229        assert_eq!(a[0].severity, Severity::Info);
230    }
231
232    #[test]
233    fn flags_truecrypt_and_hidden() {
234        let a = audit(&info(Flavor::TrueCrypt), 4096);
235        let codes: Vec<_> = a.iter().map(|x| x.code).collect();
236        assert!(codes.contains(&"VC-LEGACY-TRUECRYPT"));
237        assert!(codes.contains(&"VC-HIDDEN-VOLUME-DECLARED"));
238    }
239
240    #[test]
241    fn findings_carry_source_category_and_evidence() {
242        // TrueCrypt + hidden + inventory → exercises every Observation arm.
243        let findings = audit_findings(&info(Flavor::TrueCrypt), 4096, "container.vc");
244        assert_eq!(findings.len(), 3);
245        for f in &findings {
246            assert_eq!(f.source.analyzer, "veracrypt-forensic");
247            assert_eq!(f.source.scope, "container.vc");
248            assert!(f.source.version.is_some());
249        }
250        let hidden = findings
251            .iter()
252            .find(|f| f.code == "VC-HIDDEN-VOLUME-DECLARED")
253            .unwrap();
254        assert_eq!(hidden.category, Category::Concealment);
255        assert_eq!(hidden.severity, Some(Severity::Medium));
256        assert_eq!(hidden.evidence.len(), 1);
257        let inv = findings
258            .iter()
259            .find(|f| f.code == "VC-CIPHER-INVENTORY")
260            .unwrap();
261        assert_eq!(inv.category, Category::Provenance);
262        assert_eq!(inv.evidence.len(), 3);
263        let legacy = findings
264            .iter()
265            .find(|f| f.code == "VC-LEGACY-TRUECRYPT")
266            .unwrap();
267        assert!(legacy.evidence.is_empty());
268    }
269}