Skip to main content

hardware_enclave/
integrity.rs

1// Copyright 2026 Jay Gowdy
2// SPDX-License-Identifier: MIT
3
4use 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/// How the tamper-evident handle enforces integrity.
14///
15/// Choose based on the number of files you need to protect:
16///
17/// - **`Sidecar`** (default): One platform secure-store entry per app (the HMAC key).
18///   Each file gets a `.hmac` sidecar that IS the authoritative integrity check.
19///   Scales to any number of files. Suitable for directories with thousands of entries.
20///
21/// - **`TrustAnchor`**: One platform secure-store entry per app (HMAC key) **plus**
22///   one per protected file (the HMAC tag itself). The sidecar is a forensic cache;
23///   the platform secure store is authoritative. Deleting a sidecar cannot bypass
24///   verification. Use only for low-volume, high-value files.
25#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
26pub enum IntegrityMode {
27    /// HMAC sidecar is the authoritative integrity check.
28    /// One secure-store entry per app. Scales to any file count.
29    #[default]
30    Sidecar,
31    /// Per-file trust anchor in the platform secure store (Keychain / DPAPI / Secret Service).
32    /// One secure-store entry per protected file in addition to the per-app HMAC key.
33    TrustAnchor,
34}
35
36/// Result of a verification check.
37#[derive(Debug, Clone, PartialEq, Eq)]
38pub enum VerifyOutcome {
39    /// File matches its trust anchor.
40    Match,
41    /// HMAC mismatch — file has been modified outside the API.
42    Tamper,
43    /// No trust anchor exists yet (pre-migration or new path). Call `migrate()`.
44    Legacy,
45    /// File does not exist.
46    NotFound,
47    /// Secure store is unreachable; verification was skipped (fail-open).
48    StoreUnavailable,
49}
50
51/// Handle to the tamper-evident file subsystem for one app.
52///
53/// Constructed via [`create_tamper_evident()`][crate::create_tamper_evident].
54/// Defaults to [`IntegrityMode::Sidecar`]. Upgrade to
55/// [`IntegrityMode::TrustAnchor`] with [`with_trust_anchor()`][Self::with_trust_anchor].
56pub 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    /// Create a handle with a random ephemeral HMAC key — no platform secure store access.
83    ///
84    /// The key is generated from `OsRng` and held in memory only. Suitable for
85    /// testing, CI, and development examples where interactive prompts are unacceptable.
86    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    /// Enable `TrustAnchor` mode: each file's HMAC is also stored in the
99    /// platform secure store (Keychain / DPAPI / Secret Service).
100    ///
101    /// Use only for low-volume, high-value files — each file adds one entry
102    /// to the platform secure store. For directories with thousands of files,
103    /// stay with the default `Sidecar` mode.
104    #[must_use]
105    pub fn with_trust_anchor(mut self) -> Self {
106        self.mode = IntegrityMode::TrustAnchor;
107        self
108    }
109
110    /// The integrity mode this handle uses.
111    pub fn mode(&self) -> IntegrityMode {
112        self.mode
113    }
114
115    /// Write `content` to `path` atomically and update integrity data.
116    ///
117    /// - **Sidecar mode**: writes the file and a `.hmac` sidecar.
118    /// - **TrustAnchor mode**: additionally stores a per-file tag in the
119    ///   platform secure store.
120    ///
121    /// **Crash-consistency note:** multiple I/O operations occur. If the
122    /// process crashes mid-write, the next `verify()` may return `Legacy`
123    /// or `Tamper`. Call `migrate()` to rebuild integrity data after an
124    /// unclean shutdown.
125    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        // In test builds, skip all platform secure-store calls (Keychain / DPAPI /
138        // D-Bus Secret Service). CI runners do not have these services configured.
139        // This mirrors the #[cfg(not(test))] pattern used throughout enclaveapp-app-storage.
140        #[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    /// Read `path`, verify its integrity, and return the content.
157    ///
158    /// Returns `Error::TamperDetected` if the HMAC doesn't match.
159    ///
160    /// **Fail-open cases:** `Legacy` and `StoreUnavailable` both return the
161    /// file contents unverified. Use [`verify()`][Self::verify] directly and
162    /// inspect the outcome if your threat model requires fail-closed behavior.
163    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    /// Verify `path` without reading the full content into a returned buffer.
187    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    /// Sidecar-mode verification: the `.hmac` sidecar is the authoritative source.
202    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    /// TrustAnchor-mode verification: the platform secure store is authoritative.
222    ///
223    /// The sidecar is a forensic cache only — deleting it does not bypass verification.
224    fn verify_anchor(&self, path: &Path, key: &[u8]) -> Result<VerifyOutcome> {
225        // In test builds skip all platform secure-store calls (Keychain / DPAPI /
226        // D-Bus Secret Service). CI runners do not have these configured.
227        // Mirror of #[cfg(not(test))] pattern used in enclaveapp-app-storage.
228        // In test builds, skip platform secure-store calls (no Keychain/DPAPI/D-Bus in CI).
229        // The #[cfg(not(test))] block below is the production path.
230        #[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    /// Write integrity data for an existing file. Idempotent.
256    ///
257    /// - **Sidecar mode**: writes the `.hmac` sidecar.
258    /// - **TrustAnchor mode**: writes sidecar and stores the trust anchor.
259    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    /// Delete integrity data for `path`. Does not delete the file itself.
291    ///
292    /// - **Sidecar mode**: removes the `.hmac` sidecar.
293    /// - **TrustAnchor mode**: removes sidecar and deletes the trust anchor.
294    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            // Best-effort: if the store is unavailable, that's acceptable.
303            drop(crate::internal::app_storage::platform::delete_file_tag(
304                &self.app_name,
305                &path_label,
306            ));
307        }
308        Ok(())
309    }
310
311    /// App name this handle was created for.
312    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
335/// Decode a 64-char lowercase hex string into a 32-byte array.
336/// Returns `VerifyOutcome::Tamper` disguised as `Ok([0;32])` on error — callers
337/// must use this only when they intend to return Tamper on bad input.
338fn decode_hex_tag(hex: &str) -> Result<[u8; 32]> {
339    if hex.len() != 64 {
340        // Wrong length → treat as tampered.
341        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
357/// Derive a stable 64-char hex label from a file path for use as the
358/// trust anchor key in the platform secure store.
359fn 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    // ── Sidecar mode tests ───────────────────────────────────────────
402
403    #[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        // In Sidecar mode, a deleted sidecar = Legacy (can't distinguish from never-written).
501        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    // ── TrustAnchor mode tests ───────────────────────────────────────
521
522    #[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        // In TrustAnchor mode, deleting the sidecar cannot cause Tamper
533        // when content is unchanged. Result is Match (if secure store has the
534        // tag) or Legacy (if the secure store is unavailable in CI).
535        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        // write() in TrustAnchor mode calls store_file_tag; skip if the
539        // platform secure store is unavailable (D-Bus absent on CI Linux,
540        // Keychain locked on headless macOS runners).
541        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    // ── path_to_label tests ──────────────────────────────────────────
559
560    #[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}