Skip to main content

veracrypt_forensic/
lib.rs

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