tacenta_client/secure_store.rs
1//! The wrapping-key seam for authenticated persisted state — decision 0078's
2//! anchor B.
3//!
4//! **Why a seam and not a concrete store.** Anchor B authenticates the
5//! persisted-state generation so that a file-rewriter cannot forge it (internal
6//! audit AR-1). The authenticator needs a key, and the whole construction rests
7//! on that key living somewhere the file-rewriter *cannot reach*: an attacker
8//! who can rewrite the state file and the key has defeated it. That "somewhere"
9//! is platform-specific — iOS/macOS Keychain, Android Keystore, an OS keyring on
10//! desktop — and none of those exists in this crate. So the key source is a
11//! trait the platform binding implements, not a thing tacenta-client ships.
12//!
13//! **What the implementation must guarantee**, and what B is worth is exactly
14//! these:
15//!
16//! - *Unreachable to a file-rewriter.* If the key can be read or rewritten by an
17//! attacker who can rewrite `state.bin`, B authenticates nothing. A key kept
18//! in an ordinary file beside the blob is the canonical wrong answer.
19//! - *Stable across restarts.* The same install must recover the same key, or
20//! every restart reads as tampering and discards every session.
21//! - *Per-install.* Two installs need not share a key; the key identifies the
22//! local store, not the user.
23//!
24//! The seam is deliberately small — a 32-byte wrapping key plus a monotonic
25//! rollback counter (three methods: [`SecureStore::wrap_key`],
26//! [`SecureStore::rollback_counter`], [`SecureStore::bump_rollback_counter`]) —
27//! because the sealing, the generation binding and the freshness policy already
28//! live in `tacenta_core::persist` (`seal`/`unseal`/`freshness`). All this owes
29//! them is a key they can trust and a counter that only moves forward.
30
31/// Platform secure storage backing rollback-resistant persisted state
32/// ([`export_state_sealed`](crate::Client::export_state_sealed) and its restore
33/// siblings): a wrapping **key** and a monotonic **counter**, both held where an
34/// attacker who can rewrite the state file cannot reach them.
35///
36/// See the module documentation in `secure_store.rs` for the guarantees an
37/// implementation must meet (the module is private, so rustdoc does not show it);
38/// they are the entirety of what anchor B is worth.
39pub trait SecureStore {
40 /// Return the wrapping key, creating and persisting it on first use.
41 ///
42 /// Called once per export and once per restore. Implementations may cache,
43 /// but the returned key must be identical across process restarts for the
44 /// same install — a key that changed between runs would make every restore
45 /// look like tampering.
46 fn wrap_key(&self) -> Result<[u8; 32], SecureStoreError>;
47
48 /// Read the highest rollback counter this store has committed, or `0` if it
49 /// never has. **Read-only**; used on restore to tell whether the state being
50 /// restored is older than the newest this device produced.
51 ///
52 /// The counter must be **rollback-resistant to a file-rewriter** — kept in
53 /// secure storage, never beside the state blob — and it must survive
54 /// restarts. This is the freshness anchor that catches a same-generation
55 /// rollback: an attacker who keeps an old sealed file and restores it later
56 /// presents a counter below this high-water mark.
57 fn rollback_counter(&self) -> Result<u64, SecureStoreError>;
58
59 /// Atomically increment the rollback counter, persist it, and return the new
60 /// value. **Never decreases.** Called on **every send and every
61 /// ratchet-advancing receive** (the client commits one advance each), *not*
62 /// on export — [`export_state_sealed`](crate::Client::export_state_sealed)
63 /// binds the *current* value read via [`rollback_counter`](Self::rollback_counter).
64 /// Because the counter tracks ratchet advances rather than export cadence, a
65 /// later restore of any state older than the latest send presents a lower
66 /// counter than this store now holds and is caught as a rollback.
67 fn bump_rollback_counter(&self) -> Result<u64, SecureStoreError>;
68}
69
70/// Why a [`SecureStore`] could not produce a key.
71#[derive(Debug)]
72pub enum SecureStoreError {
73 /// This platform or build has no secure storage to hold the key. **A caller
74 /// that meets this must not silently fall back to an unprotected key** —
75 /// that would look like anchor B while being none. It should use the
76 /// unsealed path (which makes no rollback claim) and say so. A store
77 /// that exists but cannot be reached right now (a Keychain before first
78 /// unlock, a Keystore that needs the user) is this too, with the reason:
79 /// the client reports it as its own kind, so the app can retry after
80 /// the unlock rather than treat it as tampering.
81 Unavailable(String),
82 /// The secure-storage backend was present but failed. The string is for a
83 /// log line, not for matching on.
84 Backend(String),
85}
86
87impl std::fmt::Display for SecureStoreError {
88 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
89 match self {
90 SecureStoreError::Unavailable(reason) => {
91 write!(f, "secure storage is not available: {reason}")
92 }
93 SecureStoreError::Backend(e) => write!(f, "secure storage failed: {e}"),
94 }
95 }
96}
97
98impl std::error::Error for SecureStoreError {}