1use std::path::{Path, PathBuf};
5
6use crate::internal::core::metadata::{atomic_write, compute_meta_hmac_bytes};
7use rand::TryRngCore;
8use subtle::ConstantTimeEq;
9use zeroize::Zeroizing;
10
11use crate::error::{Error, Result};
12
13#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
26pub enum IntegrityMode {
27 #[default]
30 Sidecar,
31 TrustAnchor,
34}
35
36#[derive(Debug, Clone, PartialEq, Eq)]
38pub enum VerifyOutcome {
39 Match,
41 Tamper,
43 Legacy,
45 NotFound,
47 StoreUnavailable,
49}
50
51pub struct TamperEvidentHandle {
57 app_name: String,
58 hmac_key: Option<Zeroizing<Vec<u8>>>,
59 mode: IntegrityMode,
60}
61
62impl std::fmt::Debug for TamperEvidentHandle {
63 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
64 f.debug_struct("TamperEvidentHandle")
65 .field("app_name", &self.app_name)
66 .field("hmac_key_loaded", &self.hmac_key.is_some())
67 .field("mode", &self.mode)
68 .finish()
69 }
70}
71
72impl TamperEvidentHandle {
73 pub(crate) fn new(app_name: String) -> Self {
74 let hmac_key = crate::internal::app_storage::platform::meta_hmac_key(&app_name);
75 Self {
76 app_name,
77 hmac_key,
78 mode: IntegrityMode::Sidecar,
79 }
80 }
81
82 pub(crate) fn new_ephemeral(app_name: String) -> Self {
87 let mut key = vec![0_u8; 32];
88 rand::rngs::OsRng
89 .try_fill_bytes(&mut key)
90 .expect("OsRng must succeed for ephemeral key generation");
91 Self {
92 app_name,
93 hmac_key: Some(Zeroizing::new(key)),
94 mode: IntegrityMode::Sidecar,
95 }
96 }
97
98 #[must_use]
105 pub fn with_trust_anchor(mut self) -> Self {
106 self.mode = IntegrityMode::TrustAnchor;
107 self
108 }
109
110 pub fn mode(&self) -> IntegrityMode {
112 self.mode
113 }
114
115 pub fn write(&self, path: &Path, content: &[u8]) -> Result<()> {
126 atomic_write(path, content).map_err(Error::from)?;
127
128 let Some(key) = &self.hmac_key else {
129 return Ok(());
130 };
131
132 let tag = compute_meta_hmac_bytes(key.as_slice(), content);
133 let hex = bytes_to_hex(&tag);
134 let sidecar = sidecar_path(path);
135 atomic_write(&sidecar, hex.as_bytes()).map_err(Error::from)?;
136
137 #[cfg(not(test))]
141 if self.mode == IntegrityMode::TrustAnchor {
142 let path_label = path_to_label(path);
143 crate::internal::app_storage::platform::store_file_tag(
144 &self.app_name,
145 &path_label,
146 &tag,
147 )
148 .map_err(|e| Error::KeyOperation {
149 operation: "store_file_tag".into(),
150 detail: e.to_string(),
151 })?;
152 }
153 Ok(())
154 }
155
156 pub fn read(&self, path: &Path) -> Result<Vec<u8>> {
164 if !path.exists() {
165 return Err(Error::KeyNotFound {
166 label: path.display().to_string(),
167 });
168 }
169 let outcome = self.verify(path)?;
170 match outcome {
171 VerifyOutcome::Match | VerifyOutcome::Legacy | VerifyOutcome::StoreUnavailable => {}
172 VerifyOutcome::Tamper => {
173 return Err(Error::TamperDetected {
174 path: path.display().to_string(),
175 });
176 }
177 VerifyOutcome::NotFound => {
178 return Err(Error::KeyNotFound {
179 label: path.display().to_string(),
180 });
181 }
182 }
183 std::fs::read(path).map_err(Error::Io)
184 }
185
186 pub fn verify(&self, path: &Path) -> Result<VerifyOutcome> {
188 if !path.exists() {
189 return Ok(VerifyOutcome::NotFound);
190 }
191 let Some(key) = &self.hmac_key else {
192 return Ok(VerifyOutcome::StoreUnavailable);
193 };
194
195 match self.mode {
196 IntegrityMode::Sidecar => self.verify_sidecar(path, key),
197 IntegrityMode::TrustAnchor => self.verify_anchor(path, key),
198 }
199 }
200
201 fn verify_sidecar(&self, path: &Path, key: &[u8]) -> Result<VerifyOutcome> {
203 let sidecar = sidecar_path(path);
204 if !sidecar.exists() {
205 return Ok(VerifyOutcome::Legacy);
206 }
207
208 let stored_hex = std::fs::read_to_string(&sidecar).map_err(Error::Io)?;
209 let stored_hex = stored_hex.trim();
210 let stored_bytes = decode_hex_tag(stored_hex)?;
211 let content = std::fs::read(path).map_err(Error::Io)?;
212 let computed: [u8; 32] = compute_meta_hmac_bytes(key, &content);
213
214 if computed.ct_eq(&stored_bytes).into() {
215 Ok(VerifyOutcome::Match)
216 } else {
217 Ok(VerifyOutcome::Tamper)
218 }
219 }
220
221 fn verify_anchor(&self, path: &Path, key: &[u8]) -> Result<VerifyOutcome> {
225 #[cfg(test)]
231 let _ = (path, key);
232 #[cfg(test)]
233 return Ok(VerifyOutcome::Legacy);
234 #[cfg(not(test))]
235 {
236 let path_label = path_to_label(path);
237 let stored_tag: [u8; 32] = match crate::internal::app_storage::platform::load_file_tag(
238 &self.app_name,
239 &path_label,
240 ) {
241 Ok(Some(t)) => t,
242 Ok(None) => return Ok(VerifyOutcome::Legacy),
243 Err(_) => return Ok(VerifyOutcome::StoreUnavailable),
244 };
245 let content = std::fs::read(path).map_err(Error::Io)?;
246 let computed: [u8; 32] = compute_meta_hmac_bytes(key, &content);
247 if computed.ct_eq(&stored_tag).into() {
248 Ok(VerifyOutcome::Match)
249 } else {
250 Ok(VerifyOutcome::Tamper)
251 }
252 }
253 }
254
255 pub fn migrate(&self, path: &Path) -> Result<()> {
260 if !path.exists() {
261 return Err(Error::KeyNotFound {
262 label: path.display().to_string(),
263 });
264 }
265 let key = match &self.hmac_key {
266 Some(k) => k,
267 None => return Ok(()),
268 };
269 let content = std::fs::read(path).map_err(Error::Io)?;
270 let tag = compute_meta_hmac_bytes(key.as_slice(), &content);
271 let hex = bytes_to_hex(&tag);
272 let sidecar = sidecar_path(path);
273 atomic_write(&sidecar, hex.as_bytes()).map_err(Error::from)?;
274
275 if self.mode == IntegrityMode::TrustAnchor {
276 let path_label = path_to_label(path);
277 crate::internal::app_storage::platform::store_file_tag(
278 &self.app_name,
279 &path_label,
280 &tag,
281 )
282 .map_err(|e| Error::KeyOperation {
283 operation: "migrate_file_tag".into(),
284 detail: e.to_string(),
285 })?;
286 }
287 Ok(())
288 }
289
290 pub fn remove_integrity_data(&self, path: &Path) -> Result<()> {
295 let sidecar = sidecar_path(path);
296 if sidecar.exists() {
297 std::fs::remove_file(&sidecar).map_err(Error::Io)?;
298 }
299
300 if self.mode == IntegrityMode::TrustAnchor {
301 let path_label = path_to_label(path);
302 drop(crate::internal::app_storage::platform::delete_file_tag(
304 &self.app_name,
305 &path_label,
306 ));
307 }
308 Ok(())
309 }
310
311 pub fn app_name(&self) -> &str {
313 &self.app_name
314 }
315}
316
317fn sidecar_path(path: &Path) -> PathBuf {
318 let mut s = path.as_os_str().to_owned();
319 s.push(".hmac");
320 PathBuf::from(s)
321}
322
323fn bytes_to_hex(bytes: &[u8]) -> String {
324 let mut s = String::with_capacity(bytes.len() * 2);
325 for b in bytes {
326 let hi = (b >> 4) as usize;
327 let lo = (b & 0xf) as usize;
328 const HEX: &[u8] = b"0123456789abcdef";
329 s.push(HEX[hi] as char);
330 s.push(HEX[lo] as char);
331 }
332 s
333}
334
335fn decode_hex_tag(hex: &str) -> Result<[u8; 32]> {
339 if hex.len() != 64 {
340 return Ok([0_u8; 32]);
342 }
343 let mut out = [0_u8; 32];
344 for (i, chunk) in hex.as_bytes().chunks(2).enumerate() {
345 let s = match std::str::from_utf8(chunk) {
346 Ok(s) => s,
347 Err(_) => return Ok([0_u8; 32]),
348 };
349 out[i] = match u8::from_str_radix(s, 16) {
350 Ok(b) => b,
351 Err(_) => return Ok([0_u8; 32]),
352 };
353 }
354 Ok(out)
355}
356
357fn path_to_label(path: &Path) -> String {
360 use sha2::{Digest, Sha256};
361 let hash = Sha256::digest(path.as_os_str().as_encoded_bytes());
362 let mut s = String::with_capacity(64);
363 for b in &hash {
364 s.push_str(&format!("{b:02x}"));
365 }
366 s
367}
368
369#[cfg(test)]
370impl TamperEvidentHandle {
371 fn with_key(app_name: &str, key: Vec<u8>) -> Self {
372 Self {
373 app_name: app_name.into(),
374 hmac_key: Some(Zeroizing::new(key)),
375 mode: IntegrityMode::Sidecar,
376 }
377 }
378 fn without_key(app_name: &str) -> Self {
379 Self {
380 app_name: app_name.into(),
381 hmac_key: None,
382 mode: IntegrityMode::Sidecar,
383 }
384 }
385 fn with_key_anchored(app_name: &str, key: Vec<u8>) -> Self {
386 Self {
387 app_name: app_name.into(),
388 hmac_key: Some(Zeroizing::new(key)),
389 mode: IntegrityMode::TrustAnchor,
390 }
391 }
392}
393
394#[cfg(test)]
395#[allow(clippy::unwrap_used)]
396mod tests {
397 use super::*;
398 use std::fs;
399 use tempfile::TempDir;
400
401 #[test]
404 fn sidecar_write_and_verify_match() {
405 let dir = TempDir::new().unwrap();
406 let handle = TamperEvidentHandle::with_key("test", vec![0x42_u8; 32]);
407 let path = dir.path().join("file.txt");
408 handle.write(&path, b"hello").unwrap();
409 assert_eq!(handle.verify(&path).unwrap(), VerifyOutcome::Match);
410 }
411
412 #[test]
413 fn sidecar_tampered_file_detected() {
414 let dir = TempDir::new().unwrap();
415 let handle = TamperEvidentHandle::with_key("test", vec![0x42_u8; 32]);
416 let path = dir.path().join("file.txt");
417 handle.write(&path, b"hello").unwrap();
418 fs::write(&path, b"tampered").unwrap();
419 assert_eq!(handle.verify(&path).unwrap(), VerifyOutcome::Tamper);
420 }
421
422 #[test]
423 fn sidecar_read_tampered_returns_error() {
424 let dir = TempDir::new().unwrap();
425 let handle = TamperEvidentHandle::with_key("test", vec![0x42_u8; 32]);
426 let path = dir.path().join("file.txt");
427 handle.write(&path, b"hello").unwrap();
428 fs::write(&path, b"tampered").unwrap();
429 let result = handle.read(&path);
430 assert!(matches!(result, Err(Error::TamperDetected { .. })));
431 }
432
433 #[test]
434 fn sidecar_missing_sidecar_is_legacy() {
435 let dir = TempDir::new().unwrap();
436 let handle = TamperEvidentHandle::with_key("test", vec![0x42_u8; 32]);
437 let path = dir.path().join("file.txt");
438 fs::write(&path, b"legacy").unwrap();
439 assert_eq!(handle.verify(&path).unwrap(), VerifyOutcome::Legacy);
440 }
441
442 #[test]
443 fn sidecar_store_unavailable_returns_correct_outcome() {
444 let handle = TamperEvidentHandle::without_key("test");
445 let dir = TempDir::new().unwrap();
446 let path = dir.path().join("file.txt");
447 fs::write(&path, b"content").unwrap();
448 fs::write(dir.path().join("file.txt.hmac"), b"fakehex").unwrap();
449 assert_eq!(
450 handle.verify(&path).unwrap(),
451 VerifyOutcome::StoreUnavailable
452 );
453 }
454
455 #[test]
456 fn sidecar_migrate_creates_valid_sidecar() {
457 let dir = TempDir::new().unwrap();
458 let handle = TamperEvidentHandle::with_key("test", vec![0x42_u8; 32]);
459 let path = dir.path().join("file.txt");
460 fs::write(&path, b"existing content").unwrap();
461 assert_eq!(handle.verify(&path).unwrap(), VerifyOutcome::Legacy);
462 handle.migrate(&path).unwrap();
463 assert_eq!(handle.verify(&path).unwrap(), VerifyOutcome::Match);
464 }
465
466 #[test]
467 fn sidecar_truncated_sidecar_is_tamper() {
468 let dir = TempDir::new().unwrap();
469 let handle = TamperEvidentHandle::with_key("test", vec![0x42_u8; 32]);
470 let path = dir.path().join("file.txt");
471 handle.write(&path, b"hello").unwrap();
472 let sidecar = dir.path().join("file.txt.hmac");
473 fs::write(&sidecar, b"tooshort").unwrap();
474 assert_eq!(handle.verify(&path).unwrap(), VerifyOutcome::Tamper);
475 }
476
477 #[test]
478 fn sidecar_not_found_on_missing_file() {
479 let handle = TamperEvidentHandle::with_key("test", vec![0x42_u8; 32]);
480 let dir = TempDir::new().unwrap();
481 let path = dir.path().join("nonexistent.txt");
482 assert_eq!(handle.verify(&path).unwrap(), VerifyOutcome::NotFound);
483 }
484
485 #[test]
486 fn sidecar_invalid_hex_returns_tamper() {
487 let dir = TempDir::new().unwrap();
488 let handle = TamperEvidentHandle::with_key("test", vec![0x42_u8; 32]);
489 let path = dir.path().join("file.txt");
490 handle.write(&path, b"content").unwrap();
491 let mut bad_hex = vec![b'a'; 64];
492 bad_hex[10] = b'\x00';
493 let sidecar = dir.path().join("file.txt.hmac");
494 fs::write(&sidecar, &bad_hex).unwrap();
495 assert_eq!(handle.verify(&path).unwrap(), VerifyOutcome::Tamper);
496 }
497
498 #[test]
499 fn sidecar_delete_sidecar_is_legacy_not_tamper() {
500 let dir = TempDir::new().unwrap();
502 let handle = TamperEvidentHandle::with_key("test", vec![0x42_u8; 32]);
503 let path = dir.path().join("file.txt");
504 handle.write(&path, b"content").unwrap();
505 let sidecar = dir.path().join("file.txt.hmac");
506 fs::remove_file(&sidecar).unwrap();
507 assert_eq!(handle.verify(&path).unwrap(), VerifyOutcome::Legacy);
508 }
509
510 #[test]
511 fn read_store_unavailable_returns_content() {
512 let handle = TamperEvidentHandle::without_key("test");
513 let dir = TempDir::new().unwrap();
514 let path = dir.path().join("file.txt");
515 fs::write(&path, b"unverified content").unwrap();
516 let result = handle.read(&path).unwrap();
517 assert_eq!(result, b"unverified content");
518 }
519
520 #[test]
523 fn trust_anchor_mode_is_not_default() {
524 let handle = TamperEvidentHandle::with_key("test", vec![0x42_u8; 32]);
525 assert_eq!(handle.mode(), IntegrityMode::Sidecar);
526 let handle = handle.with_trust_anchor();
527 assert_eq!(handle.mode(), IntegrityMode::TrustAnchor);
528 }
529
530 #[test]
531 fn trust_anchor_sidecar_deletion_is_still_match_or_legacy() {
532 let dir = TempDir::new().unwrap();
536 let handle = TamperEvidentHandle::with_key_anchored("test", vec![0x42_u8; 32]);
537 let path = dir.path().join("file.txt");
538 if handle.write(&path, b"content").is_err() {
542 return;
543 }
544 let sidecar = dir.path().join("file.txt.hmac");
545 if sidecar.exists() {
546 fs::remove_file(&sidecar).unwrap();
547 }
548 let outcome = handle.verify(&path).unwrap();
549 assert!(
550 matches!(
551 outcome,
552 VerifyOutcome::Match | VerifyOutcome::Legacy | VerifyOutcome::StoreUnavailable
553 ),
554 "deleting sidecar must not return Tamper when content unchanged: {outcome:?}"
555 );
556 }
557
558 #[test]
561 fn path_to_label_is_64_chars() {
562 let label = path_to_label(Path::new("/some/path/file.txt"));
563 assert_eq!(label.len(), 64);
564 assert!(label.chars().all(|c| c.is_ascii_hexdigit()));
565 }
566
567 #[test]
568 fn path_to_label_is_stable() {
569 let p = Path::new("/stable/path");
570 assert_eq!(path_to_label(p), path_to_label(p));
571 }
572
573 #[test]
574 fn path_to_label_differs_for_different_paths() {
575 let a = path_to_label(Path::new("/a"));
576 let b = path_to_label(Path::new("/b"));
577 assert_ne!(a, b);
578 }
579}