Skip to main content

coven_keys/
custody.rs

1//! Master-key custody: where the store's master keyring is unlocked from,
2//! where a newly established or rotated one is written, and how it is
3//! removed. [`KeyCustody`] is the policy a host selects on the builder;
4//! [`KeyCustody::resolve`] turns it into the [`MasterKeyCustody`] trait object
5//! coven drives the rest of the sync engine through.
6
7use std::sync::Arc;
8
9use crate::encryption::MasterKeyring;
10pub use crate::envelope::Passphrase;
11use crate::keys::{KeyError, MasterKeyCustody, StoreKeys};
12use coven_foundation::store_dir::StoreDir;
13
14pub(crate) mod preset;
15use preset::{CustodySecret, InMemoryCustody, PassphraseCustody};
16
17/// How a store's master key is protected. The builder accepts this and never
18/// sees a cipher again — coven resolves the selection into a
19/// [`MasterKeyCustody`] and builds every cipher from what it supplies.
20pub enum KeyCustody {
21    /// The OS keyring — the default, byte-for-byte today's behavior.
22    Keyring,
23    /// Argon2id over a memorized passphrase wraps the keyring; the wrapped
24    /// blob lives in a file in the store directory.
25    Passphrase(Passphrase),
26    /// Supplied for this session, never persisted by coven.
27    InMemory(MasterKeyring),
28    /// A host-supplied custody implementation.
29    Custom(Arc<dyn MasterKeyCustody>),
30}
31
32impl KeyCustody {
33    /// Resolve the selected policy into the trait object coven drives the
34    /// sync engine through, injecting what each preset needs from the store's
35    /// retained owners: `store_keys` for [`KeyCustody::Keyring`] and
36    /// `store_dir` for [`KeyCustody::Passphrase`].
37    pub fn resolve(
38        self,
39        store_keys: &StoreKeys,
40        store_dir: &StoreDir,
41    ) -> Arc<dyn MasterKeyCustody> {
42        match self {
43            KeyCustody::Keyring => Arc::new(KeyringCustody::new(store_keys.clone())),
44            KeyCustody::Passphrase(passphrase) => Arc::new(
45                PassphraseCustody::<MasterKeyring>::new(passphrase, store_dir),
46            ),
47            KeyCustody::InMemory(keyring) => Arc::new(InMemoryCustody::new(keyring)),
48            KeyCustody::Custom(custody) => custody,
49        }
50    }
51}
52
53struct KeyringCustody {
54    keys: StoreKeys,
55}
56
57impl KeyringCustody {
58    fn new(keys: StoreKeys) -> Self {
59        Self { keys }
60    }
61}
62
63impl MasterKeyCustody for KeyringCustody {
64    fn unlock(&self) -> Result<Option<MasterKeyring>, KeyError> {
65        self.keys
66            .get_encryption_key()?
67            .map(|serialized| {
68                MasterKeyring::from_serialized(&serialized).map_err(KeyError::Encryption)
69            })
70            .transpose()
71    }
72
73    fn persist(&self, keyring: &MasterKeyring) -> Result<(), KeyError> {
74        self.keys.set_encryption_key(&keyring.to_serialized())
75    }
76
77    fn forget(&self) -> Result<(), KeyError> {
78        self.keys.delete_encryption_key()
79    }
80}
81
82// =============================================================================
83// The keyring as a custody secret
84// =============================================================================
85
86impl CustodySecret for MasterKeyring {
87    const FILE: &'static str = "master.keyring";
88
89    fn to_bytes(&self) -> Vec<u8> {
90        self.to_serialized().into_bytes()
91    }
92
93    fn from_bytes(bytes: Vec<u8>) -> Result<Self, KeyError> {
94        let serialized = String::from_utf8(bytes)?;
95        MasterKeyring::from_serialized(&serialized).map_err(KeyError::Encryption)
96    }
97}
98
99impl MasterKeyCustody for InMemoryCustody<MasterKeyring> {
100    fn unlock(&self) -> Result<Option<MasterKeyring>, KeyError> {
101        InMemoryCustody::unlock(self)
102    }
103
104    fn persist(&self, keyring: &MasterKeyring) -> Result<(), KeyError> {
105        InMemoryCustody::persist(self, keyring)
106    }
107
108    fn forget(&self) -> Result<(), KeyError> {
109        InMemoryCustody::forget(self)
110    }
111}
112
113impl MasterKeyCustody for PassphraseCustody<MasterKeyring> {
114    fn unlock(&self) -> Result<Option<MasterKeyring>, KeyError> {
115        PassphraseCustody::unlock(self)
116    }
117
118    fn persist(&self, keyring: &MasterKeyring) -> Result<(), KeyError> {
119        PassphraseCustody::persist(self, keyring)
120    }
121
122    fn forget(&self) -> Result<(), KeyError> {
123        PassphraseCustody::forget(self)
124    }
125}
126
127#[cfg(test)]
128mod tests {
129    use super::*;
130    use crate::encryption::EncryptionService;
131
132    fn temp_store_dir() -> (tempfile::TempDir, StoreDir) {
133        let tmp = tempfile::tempdir().expect("temp dir");
134        let dir = StoreDir::new_ephemeral(tmp.path());
135        (tmp, dir)
136    }
137
138    // =========================================================================
139    // Keyring preset
140    // =========================================================================
141
142    #[test]
143    fn keyring_preset_unlock_persist_forget_round_trip() {
144        crate::keys::test_keyring::install();
145        let store_keys = StoreKeys::bind("custody-keyring-roundtrip".to_string());
146        let custody = KeyCustody::Keyring.resolve(&store_keys, &StoreDir::new_ephemeral("unused"));
147
148        assert!(
149            custody.unlock().expect("unlock a fresh store").is_none(),
150            "a fresh store has no established keyring",
151        );
152
153        let keyring = MasterKeyring::generate();
154        custody.persist(&keyring).expect("persist");
155        let unlocked = custody
156            .unlock()
157            .expect("unlock after persist")
158            .expect("keyring is established");
159        assert_eq!(unlocked.fingerprint(), keyring.fingerprint());
160
161        custody.forget().expect("forget");
162        assert!(
163            custody.unlock().expect("unlock after forget").is_none(),
164            "forget removes the established keyring",
165        );
166    }
167
168    /// The corrupt-empty-entry discipline lives once, in `StoreKeys::read`
169    /// (`empty_keyring_entry_is_an_error_not_absence` pins it there), and
170    /// `KeyringCustody::unlock` inherits it by construction: a present-but-empty
171    /// entry surfaces as `Err`, never `Ok(None)` — so cloud-home setup cannot
172    /// mistake corruption for an absent key and generate over it.
173    #[test]
174    fn keyring_preset_unlock_does_not_read_a_corrupt_empty_entry_as_absent() {
175        crate::keys::test_keyring::install();
176        let store_id = "custody-keyring-corrupt-empty".to_string();
177        let store_keys = StoreKeys::bind(store_id.clone());
178        store_keys
179            .write_empty_encryption_key_for_test()
180            .expect("write empty entry");
181
182        let custody = KeyCustody::Keyring.resolve(&store_keys, &StoreDir::new_ephemeral("unused"));
183        let error = custody.unlock().expect_err("empty entry is corrupt");
184        assert!(error.to_string().contains("present but empty"));
185    }
186
187    // =========================================================================
188    // InMemory preset
189    // =========================================================================
190
191    #[test]
192    fn in_memory_preset_unlock_returns_the_seeded_keyring() {
193        let seed = MasterKeyring::generate();
194        let fingerprint = seed.fingerprint();
195        let custody = InMemoryCustody::new(seed);
196
197        let unlocked = custody
198            .unlock()
199            .expect("unlock")
200            .expect("seeded keyring is present");
201        assert_eq!(unlocked.fingerprint(), fingerprint);
202    }
203
204    #[test]
205    fn in_memory_preset_persist_replaces_and_forget_clears() {
206        let custody = InMemoryCustody::new(MasterKeyring::generate());
207
208        let rotated = MasterKeyring::generate();
209        custody.persist(&rotated).expect("persist");
210        assert_eq!(
211            custody.unlock().unwrap().unwrap().fingerprint(),
212            rotated.fingerprint(),
213        );
214
215        custody.forget().expect("forget");
216        assert!(custody.unlock().unwrap().is_none());
217    }
218
219    #[test]
220    fn in_memory_preset_never_writes_under_the_store_dir() {
221        let (tmp, _dir) = temp_store_dir();
222        let custody = InMemoryCustody::new(MasterKeyring::generate());
223        custody
224            .persist(&MasterKeyring::generate())
225            .expect("persist");
226        custody.forget().expect("forget");
227
228        let entries: Vec<_> = std::fs::read_dir(tmp.path())
229            .expect("read store dir")
230            .collect();
231        assert!(
232            entries.is_empty(),
233            "InMemory custody must touch no file under the store dir",
234        );
235    }
236
237    // =========================================================================
238    // Passphrase preset
239    // =========================================================================
240
241    #[test]
242    fn passphrase_preset_establish_then_unlock_round_trips() {
243        let (_tmp, dir) = temp_store_dir();
244        let custody = PassphraseCustody::<MasterKeyring>::new(
245            Passphrase::new("correct horse battery staple".to_string()),
246            &dir,
247        );
248
249        assert!(custody.unlock().expect("unlock before establish").is_none());
250
251        let keyring = MasterKeyring::generate();
252        custody.persist(&keyring).expect("establish");
253        let unlocked = custody
254            .unlock()
255            .expect("unlock after establish")
256            .expect("keyring is established");
257        assert_eq!(unlocked.fingerprint(), keyring.fingerprint());
258    }
259
260    #[test]
261    fn passphrase_preset_wrong_passphrase_is_err_not_none() {
262        let (_tmp, dir) = temp_store_dir();
263        let writer = PassphraseCustody::<MasterKeyring>::new(
264            Passphrase::new("right passphrase".to_string()),
265            &dir,
266        );
267        writer
268            .persist(&MasterKeyring::generate())
269            .expect("establish");
270
271        let reader = PassphraseCustody::<MasterKeyring>::new(
272            Passphrase::new("wrong passphrase".to_string()),
273            &dir,
274        );
275        let error = reader
276            .unlock()
277            .expect_err("wrong passphrase must not unlock");
278        assert!(
279            error.to_string().to_lowercase().contains("passphrase")
280                || matches!(error, KeyError::PassphraseEnvelopeDecryption)
281        );
282    }
283
284    #[test]
285    fn passphrase_preset_missing_file_is_none() {
286        let (_tmp, dir) = temp_store_dir();
287        let custody =
288            PassphraseCustody::<MasterKeyring>::new(Passphrase::new("unused".to_string()), &dir);
289        assert!(custody.unlock().expect("unlock with no file").is_none());
290    }
291
292    // Rotation re-wrap, atomic-write-no-torn-file, and wrong-derivation
293    // behavior are shared envelope-format guarantees, pinned generically once
294    // in `envelope.rs` rather than duplicated per payload type here.
295
296    /// A literal v1 envelope, pinned here so a future change to the
297    /// derivation, AEAD, or serialization code is caught by a failing test
298    /// rather than silently stranding every already-wrapped `master.keyring`
299    /// file. Wraps `MasterKeyring::from(EncryptionService::from_key([0x11u8;
300    /// 32]))` under passphrase "fixture-passphrase" with a fixed salt and
301    /// nonce (real writes use random ones; the fixture fixes them only so its
302    /// bytes are reproducible here).
303    const V1_FIXTURE_PASSPHRASE: &str = "fixture-passphrase";
304    const V1_FIXTURE_ENVELOPE_JSON: &str = concat!(
305        r#"{"v":1,"kdf":{"algo":"argon2id","m_cost":65536,"t_cost":3,"p_cost":4,"#,
306        r#""salt_b64":"3q2+7wEjRWeJq83vABEiMw=="},"#,
307        r#""nonce_b64":"AAECAwQFBgcICQoLDA0ODxAREhMUFRYX","#,
308        r#""ciphertext_b64":"+7/Z7TSK5xtqL6fqDzh5ayBkPPtuzf/0FyBy3mrgtiFjfabWOqVb8FonvR7SwvntJd9ERnTDljuE0o3Ofzs8a6XMbf0VJ6HlDp2aB62apAV3Fv1e1eb8su6/TxVOCskR9cvDmPr2P3CnsX3YRaGcEuilgSW8uKosW6gowDDZHqGat1XSPbnG02GO5QYuMxM="}"#
309    );
310
311    #[test]
312    fn passphrase_preset_envelope_fixture_v1_unlocks() {
313        let (_tmp, dir) = temp_store_dir();
314        std::fs::write(dir.join("master.keyring"), V1_FIXTURE_ENVELOPE_JSON)
315            .expect("write fixture envelope");
316
317        let custody = PassphraseCustody::<MasterKeyring>::new(
318            Passphrase::new(V1_FIXTURE_PASSPHRASE.to_string()),
319            &dir,
320        );
321        let keyring = custody
322            .unlock()
323            .expect("the v1 fixture must still unlock")
324            .expect("the fixture names an established keyring");
325        assert_eq!(
326            keyring.fingerprint(),
327            EncryptionService::from_key([0x11u8; 32]).fingerprint(),
328        );
329    }
330}