1#![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
21pub const ANALYZER: &str = "veracrypt-forensic";
23
24#[derive(Debug, Clone, PartialEq, Eq)]
26pub enum AnomalyKind {
27 LegacyTrueCrypt,
29 HiddenVolumeDeclared {
31 size: u64,
33 },
34 CipherInventory {
36 prf: &'static str,
38 cipher: String,
40 offset: u64,
42 },
43}
44
45impl AnomalyKind {
46 #[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 #[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 #[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 #[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#[derive(Debug, Clone, PartialEq, Eq)]
125pub struct Anomaly {
126 pub severity: Severity,
128 pub code: &'static str,
130 pub kind: AnomalyKind,
132 pub note: String,
134}
135
136impl Anomaly {
137 #[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#[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#[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 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}