Skip to main content

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 {}