Skip to main content

vti_common/integrity/
mod.rs

1//! TEE integrity manifest (P0.2a — Layer 0 of the anti-rollback anchor).
2//!
3//! In the Nitro-Enclave deployment the parent EC2 host owns the on-disk fjall
4//! database and can delete or replay whole ciphertexts. KMS attestation gives
5//! confidentiality and P0.1 AAD gives location-integrity, but neither gives
6//! **freshness**: a covered row deleted, or the store replayed to an
7//! inconsistent past snapshot, is not detected. This module pins the
8//! security-critical singletons into a single MAC'd record so deletion and
9//! inconsistent tamper are caught at boot.
10//!
11//! Layer 0 alone does **not** catch a fully-consistent rollback (manifest and
12//! all covered rows restored together to a genuine past epoch) — that needs the
13//! external monotonic counter (P0.2b). See
14//! `docs/05-design-notes/tee-anti-rollback-anchor.md`.
15//!
16//! ## Activation
17//!
18//! The manifest is **TEE-only**. It is activated solely by [`install_sealer`],
19//! which the boot path calls only when a storage-encryption key is present
20//! (i.e. in a TEE). Outside a TEE the global sealer is never set, so
21//! [`reseal_if_active`] — invoked from the covered mutation chokepoints
22//! ([`crate::acl::store_acl_entry`] et al.) — is a cheap no-op. No feature flag
23//! is needed; the module compiles everywhere and simply lies dormant.
24//!
25//! ## Covered singletons (design §5.1)
26//!
27//! | Singleton | Location | Rollback it blocks |
28//! |---|---|---|
29//! | Carve-out sentinel | `keys` ▸ [`CARVEOUT_KEY`] | reopen single-use Mode-B carve-out → fresh admin |
30//! | JWT fingerprint | `bootstrap` ▸ [`JWT_FINGERPRINT_KEY`] | delete → silent JWT re-baseline |
31//! | ACL keyspace root | `acl` ▸ `acl:*` | replay → resurrect a revoked admin |
32//! | Path/context counters | `keys` ▸ `path_counter:*`, `contexts` ▸ `ctx_counter*` | rollback → BIP-32 key reuse |
33
34use std::future::Future;
35use std::pin::Pin;
36use std::sync::{Arc, OnceLock};
37
38use hkdf::Hkdf;
39use hmac::digest::KeyInit;
40use hmac::{Hmac, Mac};
41use sha2::{Digest, Sha256};
42use tokio::sync::Mutex;
43use tracing::warn;
44
45use crate::error::AppError;
46use crate::store::KeyspaceHandle;
47
48type HmacSha256 = Hmac<Sha256>;
49type BoxFuture<'a, T> = Pin<Box<dyn Future<Output = T> + Send + 'a>>;
50
51/// The external monotonic anchor (P0.2b). An AWS-managed linearizable store
52/// (DynamoDB) holding one version counter per VTA DID, which the parent can
53/// proxy but not forge. The concrete implementation lives in the service crate
54/// (it needs the AWS SDK, which must not leak into this foundation crate); this
55/// trait is what the integrity layer depends on, and what the unit tests mock.
56///
57/// All three methods are single-item operations keyed by the VTA DID. `set`
58/// MUST be an atomic compare-and-set (DynamoDB `ConditionExpression
59/// version = :expected`) — that conditional is the §5.4 linearization point.
60pub trait AnchorCounter: Send + Sync {
61    /// Read the authoritative version, or `None` if the counter has never been
62    /// initialized (first boot / migration from a manifest-only P0.2a VTA).
63    fn read(&self) -> BoxFuture<'_, Result<Option<u64>, AppError>>;
64
65    /// Create the counter at `version` if it does not yet exist
66    /// (`attribute_not_exists` guard). Errors if it already exists.
67    fn init(&self, version: u64, digest: [u8; 32]) -> BoxFuture<'_, Result<(), AppError>>;
68
69    /// Atomic compare-and-set: move the counter from `expected` to `new`,
70    /// failing (`AppError::Conflict`) if the stored value is not `expected`.
71    fn set(&self, expected: u64, new: u64, digest: [u8; 32])
72    -> BoxFuture<'_, Result<(), AppError>>;
73}
74
75/// Carve-out sentinel key (lives in the `keys` keyspace). Must match
76/// `vta_service::tee::admin_bootstrap::BOOTSTRAP_CARVEOUT_CLOSED_KEY`; a
77/// drift-guard test in vta-service asserts the two stay equal.
78pub const CARVEOUT_KEY: &str = "tee:bootstrap-carveout-closed";
79/// JWT-fingerprint key (lives in the `bootstrap` keyspace). Must match
80/// `vta_service::tee::kms_bootstrap::BOOTSTRAP_JWT_FINGERPRINT_KEY`.
81pub const JWT_FINGERPRINT_KEY: &str = "bootstrap:jwt_fingerprint";
82/// Manifest record key (lives in the `bootstrap` keyspace).
83pub const MANIFEST_KEY: &str = "tee:integrity-manifest";
84
85const ACL_PREFIX: &str = "acl:";
86const PATH_COUNTER_PREFIX: &str = "path_counter:";
87const CTX_COUNTER_PREFIX: &str = "ctx_counter";
88
89/// HKDF `info` separating the manifest MAC key from the storage-encryption key
90/// it is derived from.
91const MAC_KEY_INFO: &[u8] = b"vti-integrity-manifest-mac/v1";
92/// Domain tag prefixing the MAC input so a manifest blob can't be reinterpreted
93/// in another context.
94const MAC_DOMAIN: &[u8] = b"vti-integrity-manifest/v1";
95
96/// Serialized manifest length: version(8) + carveout(1) + jwt_fp(16) +
97/// acl_root(32) + counters(32) + mac(32).
98const MANIFEST_LEN: usize = 8 + 1 + 16 + 32 + 32 + 32;
99
100/// The hashed snapshot of the four covered singletons.
101#[derive(Clone, PartialEq, Eq, Debug)]
102struct CoveredState {
103    carveout_present: bool,
104    jwt_fp: [u8; 16],
105    acl_root: [u8; 32],
106    counters: [u8; 32],
107}
108
109impl CoveredState {
110    /// Canonical MAC input: domain tag ‖ version ‖ fixed-width fields.
111    fn mac_input(&self, version: u64) -> Vec<u8> {
112        let mut buf = Vec::with_capacity(MAC_DOMAIN.len() + 8 + 1 + 16 + 32 + 32);
113        buf.extend_from_slice(MAC_DOMAIN);
114        buf.extend_from_slice(&version.to_le_bytes());
115        buf.push(self.carveout_present as u8);
116        buf.extend_from_slice(&self.jwt_fp);
117        buf.extend_from_slice(&self.acl_root);
118        buf.extend_from_slice(&self.counters);
119        buf
120    }
121}
122
123/// A loaded-or-recomputed manifest. The `mac` authenticates `version` + state.
124struct Manifest {
125    version: u64,
126    state: CoveredState,
127    mac: [u8; 32],
128}
129
130impl Manifest {
131    /// Build a manifest for `state` at `version`, computing its MAC.
132    fn sealed(mac_key: &[u8; 32], version: u64, state: CoveredState) -> Self {
133        let mac = mac_over(mac_key, &state.mac_input(version));
134        Self {
135            version,
136            state,
137            mac,
138        }
139    }
140
141    fn to_bytes(&self) -> Vec<u8> {
142        let mut buf = Vec::with_capacity(MANIFEST_LEN);
143        buf.extend_from_slice(&self.version.to_le_bytes());
144        buf.push(self.state.carveout_present as u8);
145        buf.extend_from_slice(&self.state.jwt_fp);
146        buf.extend_from_slice(&self.state.acl_root);
147        buf.extend_from_slice(&self.state.counters);
148        buf.extend_from_slice(&self.mac);
149        buf
150    }
151
152    fn from_bytes(b: &[u8]) -> Option<Self> {
153        if b.len() != MANIFEST_LEN {
154            return None;
155        }
156        let version = u64::from_le_bytes(b[0..8].try_into().ok()?);
157        let carveout_present = match b[8] {
158            0 => false,
159            1 => true,
160            _ => return None,
161        };
162        let jwt_fp: [u8; 16] = b[9..25].try_into().ok()?;
163        let acl_root: [u8; 32] = b[25..57].try_into().ok()?;
164        let counters: [u8; 32] = b[57..89].try_into().ok()?;
165        let mac: [u8; 32] = b[89..121].try_into().ok()?;
166        Some(Self {
167            version,
168            state: CoveredState {
169                carveout_present,
170                jwt_fp,
171                acl_root,
172                counters,
173            },
174            mac,
175        })
176    }
177
178    /// Constant-time MAC check against `mac_key`.
179    fn mac_valid(&self, mac_key: &[u8; 32]) -> bool {
180        let mut h = HmacSha256::new_from_slice(mac_key).expect("hmac accepts 32-byte key");
181        h.update(&self.state.mac_input(self.version));
182        h.verify_slice(&self.mac).is_ok()
183    }
184}
185
186fn mac_over(mac_key: &[u8; 32], input: &[u8]) -> [u8; 32] {
187    let mut h = HmacSha256::new_from_slice(mac_key).expect("hmac accepts 32-byte key");
188    h.update(input);
189    h.finalize().into_bytes().into()
190}
191
192/// Derive the manifest MAC key from the TEE storage-encryption key. Domain-
193/// separated so it never coincides with the key's encryption use.
194pub fn derive_mac_key(storage_key: &[u8; 32]) -> [u8; 32] {
195    let hk = Hkdf::<Sha256>::new(None, storage_key);
196    let mut out = [0u8; 32];
197    hk.expand(MAC_KEY_INFO, &mut out)
198        .expect("32-byte HKDF output is valid");
199    out
200}
201
202/// Hash a set of key/value rows canonically (sorted by key, length-prefixed),
203/// so the digest is independent of iteration order.
204fn hash_rows(mut rows: Vec<(Vec<u8>, Vec<u8>)>) -> [u8; 32] {
205    rows.sort_by(|a, b| a.0.cmp(&b.0));
206    let mut hasher = Sha256::new();
207    for (k, v) in rows {
208        hasher.update((k.len() as u32).to_le_bytes());
209        hasher.update(&k);
210        hasher.update((v.len() as u32).to_le_bytes());
211        hasher.update(&v);
212    }
213    hasher.finalize().into()
214}
215
216/// The installed sealer. Holds the MAC key and clones of every keyspace the
217/// covered singletons live in. Set once at TEE boot via [`install_sealer`].
218struct ManifestSealer {
219    mac_key: [u8; 32],
220    /// carve-out sentinel + `path_counter:*`
221    keys_ks: KeyspaceHandle,
222    /// JWT fingerprint + the manifest record itself
223    bootstrap_ks: KeyspaceHandle,
224    /// `acl:*`
225    acl_ks: KeyspaceHandle,
226    /// `ctx_counter*`
227    contexts_ks: KeyspaceHandle,
228    /// External monotonic counter (P0.2b). `None` = manifest-only mode: either
229    /// no anchor is configured, or boot fell back to unanchored after the
230    /// counter was unreachable (`allow_unanchored`).
231    anchor: Option<Arc<dyn AnchorCounter>>,
232}
233
234impl ManifestSealer {
235    async fn compute_state(&self) -> Result<CoveredState, AppError> {
236        let carveout_present = self.keys_ks.get_raw(CARVEOUT_KEY).await?.is_some();
237
238        let jwt_fp = match self.bootstrap_ks.get_raw(JWT_FINGERPRINT_KEY).await? {
239            Some(bytes) => {
240                let digest = Sha256::digest(&bytes);
241                let mut fp = [0u8; 16];
242                fp.copy_from_slice(&digest[..16]);
243                fp
244            }
245            None => [0u8; 16],
246        };
247
248        let acl_root = hash_rows(self.acl_ks.prefix_iter_raw(ACL_PREFIX).await?);
249
250        // Counters span two keyspaces; tag each row's key with a keyspace
251        // discriminant so a `path_counter:x` can never collide with a
252        // hypothetical `ctx_counter:x` in the combined digest.
253        let mut counter_rows: Vec<(Vec<u8>, Vec<u8>)> = Vec::new();
254        for (k, v) in self.keys_ks.prefix_iter_raw(PATH_COUNTER_PREFIX).await? {
255            let mut tagged = b"k:".to_vec();
256            tagged.extend_from_slice(&k);
257            counter_rows.push((tagged, v));
258        }
259        for (k, v) in self.contexts_ks.prefix_iter_raw(CTX_COUNTER_PREFIX).await? {
260            let mut tagged = b"c:".to_vec();
261            tagged.extend_from_slice(&k);
262            counter_rows.push((tagged, v));
263        }
264        let counters = hash_rows(counter_rows);
265
266        Ok(CoveredState {
267            carveout_present,
268            jwt_fp,
269            acl_root,
270            counters,
271        })
272    }
273
274    async fn load_manifest(&self) -> Result<Option<Manifest>, AppError> {
275        Ok(self
276            .bootstrap_ks
277            .get_raw(MANIFEST_KEY)
278            .await?
279            .and_then(|b| Manifest::from_bytes(&b)))
280    }
281
282    async fn write_manifest(&self, m: &Manifest) -> Result<(), AppError> {
283        self.bootstrap_ks
284            .insert_raw(MANIFEST_KEY, m.to_bytes())
285            .await?;
286        self.bootstrap_ks.persist().await?;
287        Ok(())
288    }
289
290    /// Recompute the covered state, advance the version, and persist —
291    /// external-first (§5.4). No global side effect, so it is unit-testable.
292    async fn reseal(&self) -> Result<(), AppError> {
293        let cur_version = self.load_manifest().await?.map(|m| m.version);
294        let next_version = cur_version.map(|v| v + 1).unwrap_or(0);
295        let state = self.compute_state().await?;
296        let manifest = Manifest::sealed(&self.mac_key, next_version, state);
297
298        // Advance the counter before committing the local manifest, so a crash
299        // leaves manifest_version < counter (fail-closed safe direction).
300        if let Some(anchor) = &self.anchor {
301            match cur_version {
302                Some(expected) => anchor.set(expected, next_version, manifest.mac).await?,
303                None => anchor.init(next_version, manifest.mac).await?,
304            }
305        }
306        self.write_manifest(&manifest).await
307    }
308
309    /// Boot check core (no global side effect, so it is unit-testable). Returns
310    /// the outcome and whether the installed sealer should drop the anchor
311    /// (unanchored fallback). See [`boot_verify_and_install`] for semantics.
312    async fn verify_or_baseline(
313        &self,
314        allow_anchor_init: bool,
315        allow_unanchored: bool,
316    ) -> Result<(BootOutcome, bool), AppError> {
317        // ── Layer 0: the local MAC'd manifest (P0.2a) ───────────────────────
318        let current = self.compute_state().await?;
319        let (manifest, baselined) = match self.load_manifest().await? {
320            None => {
321                if !allow_anchor_init {
322                    return Err(AppError::Internal(
323                        "TEE integrity manifest is missing and allow_anchor_init is false — \
324                         refusing to start. A missing manifest on a configured VTA is \
325                         indistinguishable from a parent-deleted one; set \
326                         tee.kms.allow_anchor_init = true for ONE boot to establish the \
327                         baseline, then set it back to false."
328                            .into(),
329                    ));
330                }
331                let m = Manifest::sealed(&self.mac_key, 0, current);
332                self.write_manifest(&m).await?;
333                warn!(
334                    "TEE integrity manifest established (version 0) under allow_anchor_init — \
335                     set tee.kms.allow_anchor_init = false now that the baseline exists"
336                );
337                (m, true)
338            }
339            Some(stored) => {
340                if !stored.mac_valid(&self.mac_key) {
341                    return Err(AppError::Internal(
342                        "TEE integrity manifest MAC verification failed — the manifest was \
343                         tampered with or the storage key changed. Refusing to start (P0.2). \
344                         Restore a consistent backup or investigate parent-host compromise."
345                            .into(),
346                    ));
347                }
348                if stored.state != current {
349                    return Err(AppError::Internal(format!(
350                        "TEE integrity manifest mismatch — the on-disk state does not match the \
351                         last sealed manifest (a covered singleton was deleted or the store was \
352                         replayed to an inconsistent snapshot). Refusing to start (P0.2). [{}]",
353                        describe_mismatch(&stored.state, &current),
354                    )));
355                }
356                (stored, false)
357            }
358        };
359
360        // ── Layer 1: the external monotonic counter (P0.2b) ─────────────────
361        let Some(anchor) = &self.anchor else {
362            // No external counter configured → manifest-only (P0.2a behaviour).
363            return Ok((BootOutcome::manifest(baselined), false));
364        };
365        let m_version = manifest.version;
366        let digest = manifest.mac;
367
368        match anchor.read().await {
369            // Parent denies egress / transient AWS failure: we cannot verify
370            // freshness. Fail closed unless the operator opted into a
371            // (loudly-warned) unanchored boot — which then runs manifest-only.
372            Err(e) => {
373                if !allow_unanchored {
374                    return Err(AppError::Internal(format!(
375                        "external anchor counter is unreachable ({e}) — cannot verify rollback \
376                         freshness, refusing to start (P0.2b). This is a denial-of-service, not \
377                         an integrity breach; set tee.kms.allow_unanchored = true to boot \
378                         manifest-only for incident recovery."
379                    )));
380                }
381                warn!(
382                    error = %e,
383                    "external anchor counter unreachable — booting UNANCHORED (manifest-only, \
384                     P0.2a level) under allow_unanchored. Rollback protection is degraded until \
385                     the counter is reachable again."
386                );
387                Ok((BootOutcome::Unanchored, true))
388            }
389            // Counter absent: first boot, or migration from a manifest-only
390            // P0.2a VTA. Establish it under the same one-shot init flag.
391            Ok(None) => {
392                if !allow_anchor_init {
393                    return Err(AppError::Internal(
394                        "external anchor counter does not exist and allow_anchor_init is false — \
395                         refusing to start. Set tee.kms.allow_anchor_init = true for ONE boot to \
396                         initialize it (first boot, or migration from a manifest-only VTA)."
397                            .into(),
398                    ));
399                }
400                anchor.init(m_version, digest).await?;
401                warn!(version = m_version, "external anchor counter initialized");
402                Ok((BootOutcome::manifest(baselined), false))
403            }
404            Ok(Some(n_ext)) if n_ext == m_version => Ok((BootOutcome::manifest(baselined), false)),
405            // Version mismatch — a rollback (of the local store or the counter)
406            // or a torn commit. Fail closed; allow_unanchored re-anchors the
407            // counter to the MAC-trusted local manifest for recovery.
408            Ok(Some(n_ext)) => {
409                if !allow_unanchored {
410                    let cause = if m_version < n_ext {
411                        "the local store was rolled back to an older epoch"
412                    } else {
413                        "the external counter was rolled back, or a tightening op was torn \
414                         mid-commit"
415                    };
416                    return Err(AppError::Internal(format!(
417                        "external anchor version mismatch: manifest=v{m_version}, counter=v{n_ext} \
418                         — {cause}. Refusing to start (P0.2b). Restore a consistent backup whose \
419                         manifest matches the counter, or set tee.kms.allow_unanchored = true to \
420                         re-anchor the counter to the local manifest."
421                    )));
422                }
423                anchor.set(n_ext, m_version, digest).await?;
424                warn!(
425                    from = n_ext,
426                    to = m_version,
427                    "external anchor counter RE-ANCHORED to the local manifest under \
428                     allow_unanchored — set it back to false now that they agree"
429                );
430                Ok((BootOutcome::ReAnchored, false))
431            }
432        }
433    }
434}
435
436/// Process-global sealer + a lock serializing reseals so concurrent mutations
437/// can't lose a manifest update or skip a version.
438static SEALER: OnceLock<ManifestSealer> = OnceLock::new();
439static RESEAL_LOCK: Mutex<()> = Mutex::const_new(());
440
441/// Re-seal the manifest after a covered-singleton mutation. **No-op unless a
442/// sealer is installed** (i.e. always, outside a TEE). Recomputes the covered
443/// state, bumps the version, and persists — **external-first** (§5.4): the
444/// counter is advanced via CAS *before* the local manifest is written, so a
445/// crash leaves `manifest_version < counter` (the fail-closed safe direction),
446/// never a silently-rolled-back-but-self-consistent local state.
447///
448/// Called from the covered mutation chokepoints. With an external anchor this
449/// does a synchronous DynamoDB CAS per covered mutation; a CAS conflict or an
450/// unreachable counter fails the mutation (fail closed), which is the intended
451/// coupling.
452pub async fn reseal_if_active() -> Result<(), AppError> {
453    let Some(sealer) = SEALER.get() else {
454        return Ok(());
455    };
456    let _guard = RESEAL_LOCK.lock().await;
457    sealer.reseal().await
458}
459
460/// Outcome of the boot check, for logging.
461#[derive(Debug, PartialEq, Eq)]
462pub enum BootOutcome {
463    /// A valid manifest matched the live store (and the counter, if any).
464    Verified,
465    /// No manifest existed; a baseline was established (first boot /
466    /// `allow_anchor_init`).
467    Baselined,
468    /// The external counter was re-anchored to the local manifest under
469    /// `allow_unanchored` (recovery from a divergence).
470    ReAnchored,
471    /// The external counter was unreachable; booted manifest-only under
472    /// `allow_unanchored` (degraded — no rollback freshness this session).
473    Unanchored,
474}
475
476impl BootOutcome {
477    fn manifest(baselined: bool) -> Self {
478        if baselined {
479            Self::Baselined
480        } else {
481            Self::Verified
482        }
483    }
484}
485
486/// Verify the integrity manifest against the live store and install the sealer
487/// for runtime reseals. Call exactly once at TEE boot, before serving.
488///
489/// - **No manifest present:** with `allow_anchor_init` true, establish a
490///   version-0 baseline over the current state (first boot / migration);
491///   otherwise **fail closed** — a silent baseline would accept whatever
492///   (possibly rolled-back) state the parent presents.
493/// - **Manifest present:** verify its MAC, then recompute the covered state and
494///   compare. A MAC failure (forged/tampered manifest) or a state mismatch
495///   (a covered row deleted, or an inconsistent snapshot) **fails closed**.
496///
497/// On success the sealer is installed so subsequent covered mutations reseal.
498#[allow(clippy::too_many_arguments)]
499pub async fn boot_verify_and_install(
500    mac_key: [u8; 32],
501    keys_ks: KeyspaceHandle,
502    bootstrap_ks: KeyspaceHandle,
503    acl_ks: KeyspaceHandle,
504    contexts_ks: KeyspaceHandle,
505    anchor: Option<Arc<dyn AnchorCounter>>,
506    allow_anchor_init: bool,
507    allow_unanchored: bool,
508) -> Result<BootOutcome, AppError> {
509    let mut sealer = ManifestSealer {
510        mac_key,
511        keys_ks,
512        bootstrap_ks,
513        acl_ks,
514        contexts_ks,
515        anchor,
516    };
517    let (outcome, drop_anchor) = sealer
518        .verify_or_baseline(allow_anchor_init, allow_unanchored)
519        .await?;
520    // Unanchored fallback: install without the anchor so this session's reseals
521    // stay manifest-only (they can't reach the counter anyway).
522    if drop_anchor {
523        sealer.anchor = None;
524    }
525    // Install for runtime reseals (only fails if called twice — boot calls once).
526    let _ = SEALER.set(sealer);
527    Ok(outcome)
528}
529
530/// Human-readable diff of which covered component(s) diverged, for the
531/// fail-closed boot error. Never includes secret material — only which field.
532fn describe_mismatch(stored: &CoveredState, current: &CoveredState) -> String {
533    let mut parts = Vec::new();
534    if stored.carveout_present != current.carveout_present {
535        parts.push(format!(
536            "carve-out sentinel (sealed present={}, now present={})",
537            stored.carveout_present, current.carveout_present
538        ));
539    }
540    if stored.jwt_fp != current.jwt_fp {
541        parts.push("JWT fingerprint".into());
542    }
543    if stored.acl_root != current.acl_root {
544        parts.push("ACL root".into());
545    }
546    if stored.counters != current.counters {
547        parts.push("path/context counters".into());
548    }
549    if parts.is_empty() {
550        "no field differs (internal error)".into()
551    } else {
552        parts.join("; ")
553    }
554}
555
556#[cfg(test)]
557mod tests {
558    use super::*;
559    use crate::config::StoreConfig;
560    use crate::store::Store;
561
562    struct Ks {
563        keys: KeyspaceHandle,
564        bootstrap: KeyspaceHandle,
565        acl: KeyspaceHandle,
566        contexts: KeyspaceHandle,
567        _dir: tempfile::TempDir,
568    }
569
570    fn open() -> Ks {
571        let dir = tempfile::tempdir().unwrap();
572        let store = Store::open(&StoreConfig {
573            data_dir: dir.path().to_path_buf(),
574        })
575        .unwrap();
576        Ks {
577            keys: store.keyspace("keys").unwrap(),
578            bootstrap: store.keyspace("bootstrap").unwrap(),
579            acl: store.keyspace("acl").unwrap(),
580            contexts: store.keyspace("contexts").unwrap(),
581            _dir: dir,
582        }
583    }
584
585    fn sealer(ks: &Ks, mac_key: [u8; 32]) -> ManifestSealer {
586        sealer_with(ks, mac_key, None)
587    }
588
589    fn sealer_with(
590        ks: &Ks,
591        mac_key: [u8; 32],
592        anchor: Option<Arc<dyn AnchorCounter>>,
593    ) -> ManifestSealer {
594        ManifestSealer {
595            mac_key,
596            keys_ks: ks.keys.clone(),
597            bootstrap_ks: ks.bootstrap.clone(),
598            acl_ks: ks.acl.clone(),
599            contexts_ks: ks.contexts.clone(),
600            anchor,
601        }
602    }
603
604    /// In-memory `AnchorCounter` with real compare-and-set semantics; can also
605    /// simulate an unreachable counter (parent egress denied). All logic runs
606    /// synchronously under a std Mutex, returning a ready future.
607    struct MockCounter {
608        version: std::sync::Mutex<Option<u64>>,
609        unreachable: bool,
610    }
611    impl MockCounter {
612        fn empty() -> Arc<Self> {
613            Arc::new(Self {
614                version: std::sync::Mutex::new(None),
615                unreachable: false,
616            })
617        }
618        fn at(v: u64) -> Arc<Self> {
619            Arc::new(Self {
620                version: std::sync::Mutex::new(Some(v)),
621                unreachable: false,
622            })
623        }
624        fn unreachable() -> Arc<Self> {
625            Arc::new(Self {
626                version: std::sync::Mutex::new(None),
627                unreachable: true,
628            })
629        }
630        fn current(&self) -> Option<u64> {
631            *self.version.lock().unwrap()
632        }
633    }
634    impl AnchorCounter for MockCounter {
635        fn read(&self) -> BoxFuture<'_, Result<Option<u64>, AppError>> {
636            let r = if self.unreachable {
637                Err(AppError::Internal("egress denied".into()))
638            } else {
639                Ok(*self.version.lock().unwrap())
640            };
641            Box::pin(async move { r })
642        }
643        fn init(&self, version: u64, _digest: [u8; 32]) -> BoxFuture<'_, Result<(), AppError>> {
644            let r = if self.unreachable {
645                Err(AppError::Internal("egress denied".into()))
646            } else {
647                let mut g = self.version.lock().unwrap();
648                if g.is_some() {
649                    Err(AppError::Conflict("counter already exists".into()))
650                } else {
651                    *g = Some(version);
652                    Ok(())
653                }
654            };
655            Box::pin(async move { r })
656        }
657        fn set(
658            &self,
659            expected: u64,
660            new: u64,
661            _digest: [u8; 32],
662        ) -> BoxFuture<'_, Result<(), AppError>> {
663            let r = if self.unreachable {
664                Err(AppError::Internal("egress denied".into()))
665            } else {
666                let mut g = self.version.lock().unwrap();
667                if *g == Some(expected) {
668                    *g = Some(new);
669                    Ok(())
670                } else {
671                    Err(AppError::Conflict(format!(
672                        "CAS failed: expected {expected}, found {:?}",
673                        *g
674                    )))
675                }
676            };
677            Box::pin(async move { r })
678        }
679    }
680
681    /// Run the boot check and return just the outcome (dropping the
682    /// drop-anchor bool the installer uses).
683    async fn vob(
684        s: &ManifestSealer,
685        allow_init: bool,
686        allow_unanchored: bool,
687    ) -> Result<BootOutcome, AppError> {
688        s.verify_or_baseline(allow_init, allow_unanchored)
689            .await
690            .map(|(o, _)| o)
691    }
692
693    #[test]
694    fn manifest_bytes_round_trip() {
695        let mac_key = [3u8; 32];
696        let state = CoveredState {
697            carveout_present: true,
698            jwt_fp: [7u8; 16],
699            acl_root: [9u8; 32],
700            counters: [11u8; 32],
701        };
702        let m = Manifest::sealed(&mac_key, 5, state.clone());
703        let bytes = m.to_bytes();
704        assert_eq!(bytes.len(), MANIFEST_LEN);
705        let back = Manifest::from_bytes(&bytes).expect("round trip");
706        assert_eq!(back.version, 5);
707        assert_eq!(back.state, state);
708        assert!(back.mac_valid(&mac_key));
709        // Wrong key fails the MAC.
710        assert!(!back.mac_valid(&[4u8; 32]));
711    }
712
713    #[test]
714    fn tampering_any_field_breaks_the_mac() {
715        let mac_key = [1u8; 32];
716        let state = CoveredState {
717            carveout_present: true,
718            jwt_fp: [0u8; 16],
719            acl_root: [0u8; 32],
720            counters: [0u8; 32],
721        };
722        let m = Manifest::sealed(&mac_key, 1, state);
723        let mut bytes = m.to_bytes();
724        bytes[8] ^= 1; // flip carveout_present
725        let tampered = Manifest::from_bytes(&bytes).expect("decodes");
726        assert!(!tampered.mac_valid(&mac_key));
727    }
728
729    // NOTE: these tests drive the global-free `verify_or_baseline` (and direct
730    // sealer methods) rather than `boot_verify_and_install`, so they never set
731    // the process-global SEALER — keeping `reseal_if_active_is_noop` valid
732    // regardless of test order.
733
734    #[tokio::test]
735    async fn baseline_refused_without_allow_anchor_init() {
736        let ks = open();
737        let err = vob(&sealer(&ks, [2u8; 32]), false, false)
738            .await
739            .expect_err("missing manifest + flag false must fail closed");
740        assert!(format!("{err:?}").contains("allow_anchor_init"), "{err:?}");
741    }
742
743    #[tokio::test]
744    async fn baseline_then_verify_roundtrips() {
745        let ks = open();
746        let mac_key = [5u8; 32];
747        // Seed some covered state across keyspaces.
748        ks.keys
749            .insert_raw(CARVEOUT_KEY, b"closed".to_vec())
750            .await
751            .unwrap();
752        crate::store::counter::allocate_u32(&ks.keys, "path_counter:m/26'/0'")
753            .await
754            .unwrap();
755        crate::store::counter::allocate_u32(&ks.contexts, "ctx_counter")
756            .await
757            .unwrap();
758        let s = sealer(&ks, mac_key);
759
760        // First boot establishes the baseline.
761        assert_eq!(vob(&s, true, false).await.unwrap(), BootOutcome::Baselined);
762        // A second boot against the unchanged store verifies cleanly.
763        assert_eq!(vob(&s, false, false).await.unwrap(), BootOutcome::Verified);
764    }
765
766    #[tokio::test]
767    async fn deleting_a_covered_row_is_detected_at_boot() {
768        let ks = open();
769        let mac_key = [6u8; 32];
770        let s = sealer(&ks, mac_key);
771
772        // Baseline with the carve-out sentinel present.
773        ks.keys
774            .insert_raw(CARVEOUT_KEY, b"closed".to_vec())
775            .await
776            .unwrap();
777        vob(&s, true, false).await.unwrap();
778
779        // Parent deletes the sentinel (reopen the carve-out) while down.
780        ks.keys.remove(CARVEOUT_KEY).await.unwrap();
781
782        let err = vob(&s, false, false)
783            .await
784            .expect_err("deletion must be detected");
785        assert!(format!("{err:?}").contains("carve-out"), "{err:?}");
786        assert!(format!("{err:?}").contains("mismatch"), "{err:?}");
787    }
788
789    #[tokio::test]
790    async fn forged_manifest_fails_mac_at_boot() {
791        let ks = open();
792        let mac_key = [8u8; 32];
793        let s = sealer(&ks, mac_key);
794        vob(&s, true, false).await.unwrap();
795
796        // Parent overwrites the manifest with a self-consistent one under a
797        // DIFFERENT key (it can't forge our MAC key).
798        let forged = Manifest::sealed(
799            &[0xFFu8; 32],
800            99,
801            CoveredState {
802                carveout_present: false,
803                jwt_fp: [0u8; 16],
804                acl_root: [0u8; 32],
805                counters: [0u8; 32],
806            },
807        );
808        ks.bootstrap
809            .insert_raw(MANIFEST_KEY, forged.to_bytes())
810            .await
811            .unwrap();
812
813        let err = vob(&s, false, false)
814            .await
815            .expect_err("forged manifest must fail the MAC");
816        assert!(
817            format!("{err:?}").contains("MAC verification failed"),
818            "{err:?}"
819        );
820    }
821
822    #[tokio::test]
823    async fn acl_change_reflected_after_reseal() {
824        // A legitimate ACL change followed by a reseal must verify on the next
825        // boot (i.e. reseal keeps the manifest in step with covered mutations).
826        let ks = open();
827        let mac_key = [10u8; 32];
828        let s = sealer(&ks, mac_key);
829        vob(&s, true, false).await.unwrap();
830
831        // Legitimate ACL write + reseal (simulating the chokepoint).
832        ks.acl
833            .insert_raw("acl:did:key:zAlice", b"{}".to_vec())
834            .await
835            .unwrap();
836        let next = s
837            .load_manifest()
838            .await
839            .unwrap()
840            .map(|m| m.version + 1)
841            .unwrap();
842        let state = s.compute_state().await.unwrap();
843        s.write_manifest(&Manifest::sealed(&mac_key, next, state))
844            .await
845            .unwrap();
846
847        assert_eq!(
848            vob(&s, false, false).await.unwrap(),
849            BootOutcome::Verified,
850            "post-reseal state must verify"
851        );
852    }
853
854    #[tokio::test]
855    async fn reseal_if_active_is_noop_without_installed_sealer() {
856        // No sealer installed (non-TEE) → reseal is a cheap no-op, never errors.
857        reseal_if_active().await.expect("no-op when not installed");
858    }
859
860    // ── External anchor (P0.2b) ─────────────────────────────────────────────
861
862    #[tokio::test]
863    async fn first_boot_initializes_counter_and_reseal_advances_it() {
864        let ks = open();
865        let counter = MockCounter::empty();
866        let s = sealer_with(&ks, [1u8; 32], Some(counter.clone()));
867
868        // First boot baselines the manifest (v0) and initializes the counter.
869        assert_eq!(vob(&s, true, false).await.unwrap(), BootOutcome::Baselined);
870        assert_eq!(counter.current(), Some(0));
871
872        // A covered mutation + reseal advances BOTH (external-first CAS).
873        ks.acl
874            .insert_raw("acl:did:key:zA", b"{}".to_vec())
875            .await
876            .unwrap();
877        s.reseal().await.unwrap();
878        assert_eq!(
879            counter.current(),
880            Some(1),
881            "reseal must CAS-bump the counter"
882        );
883
884        // And the next boot verifies (manifest v1 == counter v1).
885        assert_eq!(vob(&s, false, false).await.unwrap(), BootOutcome::Verified);
886    }
887
888    #[tokio::test]
889    async fn local_rollback_behind_counter_is_detected() {
890        // Manifest is rolled back (v0) while the counter stayed ahead (v1) —
891        // exactly the storage-rollback the external anchor exists to catch.
892        let ks = open();
893        let mac_key = [2u8; 32];
894        // Baseline manifest at v0.
895        sealer(&ks, mac_key).reseal().await.unwrap(); // writes manifest v0 (no anchor)
896        let counter = MockCounter::at(1); // counter ahead
897        let s = sealer_with(&ks, mac_key, Some(counter));
898
899        let err = vob(&s, false, false)
900            .await
901            .expect_err("manifest v0 < counter v1 must fail closed");
902        let msg = format!("{err:?}");
903        assert!(msg.contains("mismatch"), "{msg}");
904        assert!(msg.contains("rolled back"), "{msg}");
905    }
906
907    #[tokio::test]
908    async fn counter_rollback_ahead_of_manifest_is_detected() {
909        // Manifest is ahead (v2) of the counter (v0) — a rolled-back counter or
910        // a torn commit. Fail closed.
911        let ks = open();
912        let mac_key = [3u8; 32];
913        let plain = sealer(&ks, mac_key);
914        plain.reseal().await.unwrap(); // v0
915        plain.reseal().await.unwrap(); // v1
916        plain.reseal().await.unwrap(); // v2
917        let counter = MockCounter::at(0);
918        let s = sealer_with(&ks, mac_key, Some(counter));
919
920        let err = vob(&s, false, false)
921            .await
922            .expect_err("manifest v2 > counter v0 must fail closed");
923        assert!(format!("{err:?}").contains("mismatch"), "{err:?}");
924    }
925
926    #[tokio::test]
927    async fn allow_unanchored_reanchors_counter_to_manifest() {
928        let ks = open();
929        let mac_key = [4u8; 32];
930        let plain = sealer(&ks, mac_key);
931        plain.reseal().await.unwrap(); // manifest v0
932        plain.reseal().await.unwrap(); // manifest v1
933        let counter = MockCounter::at(5); // diverged ahead
934        let s = sealer_with(&ks, mac_key, Some(counter.clone()));
935
936        // Without the flag → fail closed.
937        assert!(vob(&s, false, false).await.is_err());
938        // With allow_unanchored → re-anchor the counter to the local manifest.
939        assert_eq!(vob(&s, false, true).await.unwrap(), BootOutcome::ReAnchored);
940        assert_eq!(
941            counter.current(),
942            Some(1),
943            "counter re-anchored to manifest v1"
944        );
945    }
946
947    #[tokio::test]
948    async fn unreachable_counter_fails_closed_unless_allowed() {
949        let ks = open();
950        let mac_key = [7u8; 32];
951        sealer(&ks, mac_key).reseal().await.unwrap(); // manifest v0
952        let counter = MockCounter::unreachable();
953        let s = sealer_with(&ks, mac_key, Some(counter));
954
955        // Egress denied + no flag → fail closed.
956        let err = vob(&s, false, false)
957            .await
958            .expect_err("unreachable counter must fail closed");
959        assert!(format!("{err:?}").contains("unreachable"), "{err:?}");
960        // With allow_unanchored → boot manifest-only (degraded).
961        assert_eq!(vob(&s, false, true).await.unwrap(), BootOutcome::Unanchored);
962    }
963}