Skip to main content

a3s_box_runtime/tee/
rollback.rs

1//! Version-based rollback protection for sealed storage.
2//!
3//! Extends sealed storage with a monotonic version counter to prevent
4//! replay attacks where an attacker replaces a newer sealed blob with
5//! an older one.
6//!
7//! ## How It Works
8//!
9//! - Each sealed blob includes a `version` field (monotonically increasing)
10//! - A `VersionStore` persists the latest known version per context
11//! - On unseal, the blob's version is checked against the stored version
12//! - If the blob's version is older than the stored version, unseal is rejected
13//!
14//! ## Storage
15//!
16//! Version state is stored in `~/.a3s/sealed-versions.json` (or a custom path).
17//! The file is atomically updated on each seal operation.
18
19use std::collections::HashMap;
20use std::path::{Path, PathBuf};
21
22use a3s_box_core::error::{BoxError, Result};
23use serde::{Deserialize, Serialize};
24
25/// Versioned sealed data — extends SealedData with rollback protection.
26#[derive(Debug, Clone, Serialize, Deserialize)]
27pub struct VersionedSealedData {
28    /// Monotonically increasing version number
29    pub version: u64,
30    /// Context string (key derivation + version tracking)
31    pub context: String,
32    /// Sealing policy used
33    pub policy: super::sealed::SealingPolicy,
34    /// Sealed blob: nonce || ciphertext || tag
35    #[serde(with = "base64_serde")]
36    pub blob: Vec<u8>,
37}
38
39/// Persistent store for version counters per context.
40pub struct VersionStore {
41    path: PathBuf,
42    versions: HashMap<String, u64>,
43}
44
45impl VersionStore {
46    /// Load or create a version store at the given path.
47    pub fn load(path: &Path) -> Result<Self> {
48        if let Some(parent) = path.parent() {
49            std::fs::create_dir_all(parent).map_err(|e| {
50                BoxError::ConfigError(format!(
51                    "Failed to create version store directory {}: {}",
52                    parent.display(),
53                    e
54                ))
55            })?;
56        }
57
58        let versions = if path.exists() {
59            let data = std::fs::read_to_string(path).map_err(|e| {
60                BoxError::ConfigError(format!(
61                    "Failed to read version store {}: {}",
62                    path.display(),
63                    e
64                ))
65            })?;
66            serde_json::from_str(&data).unwrap_or_default()
67        } else {
68            HashMap::new()
69        };
70
71        Ok(Self {
72            path: path.to_path_buf(),
73            versions,
74        })
75    }
76
77    /// Load from the default path (~/.a3s/sealed-versions.json).
78    pub fn load_default() -> Result<Self> {
79        let home = a3s_box_core::dirs_home();
80        Self::load(&home.join("sealed-versions.json"))
81    }
82
83    /// Get the current version for a context (0 if never sealed).
84    pub fn current_version(&self, context: &str) -> u64 {
85        self.versions.get(context).copied().unwrap_or(0)
86    }
87
88    /// Advance the version for a context and persist.
89    /// Returns the new version number.
90    pub fn advance(&mut self, context: &str) -> Result<u64> {
91        let next = self.current_version(context) + 1;
92        self.versions.insert(context.to_string(), next);
93        self.save()?;
94        Ok(next)
95    }
96
97    /// Check if a versioned blob is current (not rolled back).
98    ///
99    /// Returns `Ok(())` if the blob's version >= stored version,
100    /// or an error if the blob is older (rollback detected).
101    pub fn check_version(&self, context: &str, blob_version: u64) -> Result<()> {
102        let stored = self.current_version(context);
103        if blob_version < stored {
104            return Err(BoxError::AttestationError(format!(
105                "Rollback detected for context '{}': blob version {} < stored version {}",
106                context, blob_version, stored
107            )));
108        }
109        Ok(())
110    }
111
112    /// Update the stored version after successful unseal.
113    /// Only advances if the blob version is newer.
114    pub fn update_version(&mut self, context: &str, blob_version: u64) -> Result<()> {
115        let stored = self.current_version(context);
116        if blob_version > stored {
117            self.versions.insert(context.to_string(), blob_version);
118            self.save()?;
119        }
120        Ok(())
121    }
122
123    /// List all tracked contexts and their versions.
124    pub fn list(&self) -> &HashMap<String, u64> {
125        &self.versions
126    }
127
128    /// Remove version tracking for a context.
129    pub fn remove(&mut self, context: &str) -> Result<bool> {
130        if self.versions.remove(context).is_some() {
131            self.save()?;
132            Ok(true)
133        } else {
134            Ok(false)
135        }
136    }
137
138    /// Save the version store atomically.
139    fn save(&self) -> Result<()> {
140        let data = serde_json::to_string_pretty(&self.versions).map_err(|e| {
141            BoxError::SerializationError(format!("Failed to serialize version store: {}", e))
142        })?;
143        let tmp = self.path.with_extension("json.tmp");
144        std::fs::write(&tmp, &data).map_err(BoxError::IoError)?;
145        std::fs::rename(&tmp, &self.path).map_err(BoxError::IoError)?;
146        Ok(())
147    }
148}
149
150/// Bind a context and version into the string used for sealing.
151///
152/// The underlying `sealed::seal` uses its `context` argument as BOTH the HKDF
153/// key-derivation `info` and the AES-GCM additional-authenticated-data. By
154/// folding the version into that string we make the version part of the key
155/// and the AAD: any tampering with the stored `version` field (e.g. forging an
156/// old blob to a newer version to defeat `check_version`) yields a different
157/// key/AAD and makes unseal fail authentication. The NUL separator cannot
158/// appear in a normal context string, so `(context, version)` maps to a unique,
159/// unambiguous binding.
160fn versioned_seal_context(context: &str, version: u64) -> String {
161    format!("{context}\0v{version}")
162}
163
164/// Seal data with version-based rollback protection.
165///
166/// Advances the version counter for the context, then seals the data with the
167/// version cryptographically bound into the sealing context (see
168/// [`versioned_seal_context`]).
169pub fn seal_versioned(
170    report: &[u8],
171    plaintext: &[u8],
172    context: &str,
173    policy: super::sealed::SealingPolicy,
174    version_store: &mut VersionStore,
175) -> Result<VersionedSealedData> {
176    let version = version_store.advance(context)?;
177    let bound_context = versioned_seal_context(context, version);
178    let sealed = super::sealed::seal(report, plaintext, &bound_context, policy)?;
179
180    Ok(VersionedSealedData {
181        version,
182        // Store the ORIGINAL context for version tracking / rollback checks; the
183        // version is bound into the sealing context separately.
184        context: context.to_string(),
185        policy: sealed.policy,
186        blob: sealed.blob,
187    })
188}
189
190/// Unseal data with rollback protection check.
191///
192/// Verifies the blob's version against the stored version before unsealing.
193/// After successful unseal, updates the stored version.
194pub fn unseal_versioned(
195    report: &[u8],
196    versioned: &VersionedSealedData,
197    version_store: &mut VersionStore,
198) -> Result<Vec<u8>> {
199    // Check for rollback
200    version_store.check_version(&versioned.context, versioned.version)?;
201
202    // Reconstruct SealedData for the underlying unseal. The version is bound
203    // into the sealing context, so a forged `version` field (one that slips past
204    // check_version) produces a mismatched key/AAD and unseal fails to
205    // authenticate.
206    let sealed = super::sealed::SealedData {
207        policy: versioned.policy,
208        context: versioned_seal_context(&versioned.context, versioned.version),
209        blob: versioned.blob.clone(),
210    };
211
212    let plaintext = super::sealed::unseal(report, &sealed)?;
213
214    // Update version after successful unseal
215    version_store.update_version(&versioned.context, versioned.version)?;
216
217    Ok(plaintext)
218}
219
220// Base64 serde helper (same as sealed.rs)
221mod base64_serde {
222    use serde::{Deserialize, Deserializer, Serializer};
223
224    pub fn serialize<S: Serializer>(bytes: &Vec<u8>, s: S) -> std::result::Result<S::Ok, S::Error> {
225        use base64::Engine;
226        s.serialize_str(&base64::engine::general_purpose::STANDARD.encode(bytes))
227    }
228
229    pub fn deserialize<'de, D: Deserializer<'de>>(d: D) -> std::result::Result<Vec<u8>, D::Error> {
230        use base64::Engine;
231        let s = String::deserialize(d)?;
232        base64::engine::general_purpose::STANDARD
233            .decode(&s)
234            .map_err(serde::de::Error::custom)
235    }
236}
237
238#[cfg(test)]
239mod tests {
240    use super::super::sealed::SealingPolicy;
241    use super::*;
242    use tempfile::TempDir;
243
244    fn make_test_report() -> Vec<u8> {
245        let mut report = vec![0u8; 1184];
246        for i in 0..48 {
247            report[0x90 + i] = (i as u8).wrapping_mul(0xA3);
248        }
249        for b in &mut report[0x1A0..0x1E0] {
250            *b = 0xA3;
251        }
252        report
253    }
254
255    fn test_version_store(tmp: &TempDir) -> VersionStore {
256        VersionStore::load(&tmp.path().join("versions.json")).unwrap()
257    }
258
259    #[test]
260    fn test_version_store_new() {
261        let tmp = TempDir::new().unwrap();
262        let store = test_version_store(&tmp);
263        assert_eq!(store.current_version("test"), 0);
264        assert!(store.list().is_empty());
265    }
266
267    #[test]
268    fn test_version_store_advance() {
269        let tmp = TempDir::new().unwrap();
270        let mut store = test_version_store(&tmp);
271
272        assert_eq!(store.advance("ctx-a").unwrap(), 1);
273        assert_eq!(store.advance("ctx-a").unwrap(), 2);
274        assert_eq!(store.advance("ctx-b").unwrap(), 1);
275        assert_eq!(store.current_version("ctx-a"), 2);
276        assert_eq!(store.current_version("ctx-b"), 1);
277    }
278
279    #[test]
280    fn test_version_store_persistence() {
281        let tmp = TempDir::new().unwrap();
282        let path = tmp.path().join("versions.json");
283
284        {
285            let mut store = VersionStore::load(&path).unwrap();
286            store.advance("ctx-1").unwrap();
287            store.advance("ctx-1").unwrap();
288            store.advance("ctx-2").unwrap();
289        }
290
291        {
292            let store = VersionStore::load(&path).unwrap();
293            assert_eq!(store.current_version("ctx-1"), 2);
294            assert_eq!(store.current_version("ctx-2"), 1);
295        }
296    }
297
298    #[test]
299    fn test_version_store_check_version_ok() {
300        let tmp = TempDir::new().unwrap();
301        let mut store = test_version_store(&tmp);
302        store.advance("ctx").unwrap(); // version = 1
303
304        // Blob version >= stored version → OK
305        assert!(store.check_version("ctx", 1).is_ok());
306        assert!(store.check_version("ctx", 2).is_ok());
307    }
308
309    #[test]
310    fn test_version_store_check_version_rollback() {
311        let tmp = TempDir::new().unwrap();
312        let mut store = test_version_store(&tmp);
313        store.advance("ctx").unwrap(); // version = 1
314        store.advance("ctx").unwrap(); // version = 2
315
316        // Blob version < stored version → rollback
317        let result = store.check_version("ctx", 1);
318        assert!(result.is_err());
319        assert!(result
320            .unwrap_err()
321            .to_string()
322            .contains("Rollback detected"));
323    }
324
325    #[test]
326    fn test_version_store_update_version() {
327        let tmp = TempDir::new().unwrap();
328        let mut store = test_version_store(&tmp);
329
330        store.update_version("ctx", 5).unwrap();
331        assert_eq!(store.current_version("ctx"), 5);
332
333        // Lower version doesn't update
334        store.update_version("ctx", 3).unwrap();
335        assert_eq!(store.current_version("ctx"), 5);
336    }
337
338    #[test]
339    fn test_version_store_remove() {
340        let tmp = TempDir::new().unwrap();
341        let mut store = test_version_store(&tmp);
342        store.advance("ctx").unwrap();
343
344        assert!(store.remove("ctx").unwrap());
345        assert_eq!(store.current_version("ctx"), 0);
346        assert!(!store.remove("ctx").unwrap());
347    }
348
349    #[test]
350    fn test_seal_versioned_roundtrip() {
351        let tmp = TempDir::new().unwrap();
352        let mut store = test_version_store(&tmp);
353        let report = make_test_report();
354
355        let sealed = seal_versioned(
356            &report,
357            b"secret data",
358            "my-context",
359            SealingPolicy::default(),
360            &mut store,
361        )
362        .unwrap();
363
364        assert_eq!(sealed.version, 1);
365        assert_eq!(sealed.context, "my-context");
366
367        let plaintext = unseal_versioned(&report, &sealed, &mut store).unwrap();
368        assert_eq!(plaintext, b"secret data");
369    }
370
371    #[test]
372    fn test_seal_versioned_increments() {
373        let tmp = TempDir::new().unwrap();
374        let mut store = test_version_store(&tmp);
375        let report = make_test_report();
376
377        let s1 =
378            seal_versioned(&report, b"v1", "ctx", SealingPolicy::default(), &mut store).unwrap();
379        let s2 =
380            seal_versioned(&report, b"v2", "ctx", SealingPolicy::default(), &mut store).unwrap();
381
382        assert_eq!(s1.version, 1);
383        assert_eq!(s2.version, 2);
384    }
385
386    #[test]
387    fn test_unseal_versioned_rollback_rejected() {
388        let tmp = TempDir::new().unwrap();
389        let mut store = test_version_store(&tmp);
390        let report = make_test_report();
391
392        let old =
393            seal_versioned(&report, b"old", "ctx", SealingPolicy::default(), &mut store).unwrap();
394        let _new =
395            seal_versioned(&report, b"new", "ctx", SealingPolicy::default(), &mut store).unwrap();
396
397        // Try to unseal the old blob — should fail (rollback)
398        let result = unseal_versioned(&report, &old, &mut store);
399        assert!(result.is_err());
400        assert!(result.unwrap_err().to_string().contains("Rollback"));
401    }
402
403    #[test]
404    fn test_unseal_versioned_forged_version_rejected() {
405        let tmp = TempDir::new().unwrap();
406        let mut store = test_version_store(&tmp);
407        let report = make_test_report();
408
409        // Seal at version 1 (store advances to 1).
410        let mut sealed = seal_versioned(
411            &report,
412            b"secret-v1",
413            "ctx",
414            SealingPolicy::default(),
415            &mut store,
416        )
417        .unwrap();
418        assert_eq!(sealed.version, 1);
419
420        // Forge the plaintext version field to a value that still passes the
421        // rollback check (>= stored). Without cryptographic binding this would
422        // let a stale blob masquerade as a newer version; because the version is
423        // bound into the sealing context (key + AAD), unseal must fail to
424        // authenticate instead of returning the rolled-back plaintext.
425        sealed.version = 2;
426        assert!(store.check_version("ctx", sealed.version).is_ok());
427
428        let result = unseal_versioned(&report, &sealed, &mut store);
429        assert!(result.is_err(), "forged version must not unseal");
430        let msg = result.unwrap_err().to_string();
431        assert!(
432            msg.contains("Unseal failed") || msg.contains("mismatch") || msg.contains("corrupted"),
433            "expected an authentication failure, got: {msg}"
434        );
435    }
436
437    #[test]
438    fn test_unseal_versioned_same_version_ok() {
439        let tmp = TempDir::new().unwrap();
440        let mut store = test_version_store(&tmp);
441        let report = make_test_report();
442
443        let sealed = seal_versioned(
444            &report,
445            b"data",
446            "ctx",
447            SealingPolicy::default(),
448            &mut store,
449        )
450        .unwrap();
451
452        // Unseal same version twice — should work
453        let p1 = unseal_versioned(&report, &sealed, &mut store).unwrap();
454        let p2 = unseal_versioned(&report, &sealed, &mut store).unwrap();
455        assert_eq!(p1, b"data");
456        assert_eq!(p2, b"data");
457    }
458
459    #[test]
460    fn test_versioned_sealed_data_serde() {
461        let tmp = TempDir::new().unwrap();
462        let mut store = test_version_store(&tmp);
463        let report = make_test_report();
464
465        let sealed = seal_versioned(
466            &report,
467            b"serde-test",
468            "ctx",
469            SealingPolicy::default(),
470            &mut store,
471        )
472        .unwrap();
473        let json = serde_json::to_string(&sealed).unwrap();
474        let parsed: VersionedSealedData = serde_json::from_str(&json).unwrap();
475
476        assert_eq!(parsed.version, sealed.version);
477        assert_eq!(parsed.context, sealed.context);
478        assert_eq!(parsed.blob, sealed.blob);
479
480        let plaintext = unseal_versioned(&report, &parsed, &mut store).unwrap();
481        assert_eq!(plaintext, b"serde-test");
482    }
483
484    #[test]
485    fn test_independent_contexts() {
486        let tmp = TempDir::new().unwrap();
487        let mut store = test_version_store(&tmp);
488        let report = make_test_report();
489
490        let s1 =
491            seal_versioned(&report, b"a", "ctx-a", SealingPolicy::default(), &mut store).unwrap();
492        let s2 =
493            seal_versioned(&report, b"b", "ctx-b", SealingPolicy::default(), &mut store).unwrap();
494
495        // Each context has independent versioning
496        assert_eq!(s1.version, 1);
497        assert_eq!(s2.version, 1);
498
499        // Both can be unsealed
500        assert_eq!(unseal_versioned(&report, &s1, &mut store).unwrap(), b"a");
501        assert_eq!(unseal_versioned(&report, &s2, &mut store).unwrap(), b"b");
502    }
503}