1#![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
18pub const ANALYZER: &str = "veracrypt-forensic";
20
21#[derive(Debug, Clone, PartialEq, Eq)]
23pub enum AnomalyKind {
24 LegacyTrueCrypt,
26 HiddenVolumeDeclared {
28 size: u64,
30 },
31 CipherInventory {
33 prf: &'static str,
35 cipher: String,
37 offset: u64,
39 },
40}
41
42impl AnomalyKind {
43 #[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 #[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 #[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 #[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#[derive(Debug, Clone, PartialEq, Eq)]
122pub struct Anomaly {
123 pub severity: Severity,
125 pub code: &'static str,
127 pub kind: AnomalyKind,
129 pub note: String,
131}
132
133impl Anomaly {
134 #[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#[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#[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 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}