Skip to main content

coven/
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, RwLock};
8
9use crate::encryption::MasterKeyring;
10pub use crate::envelope::Passphrase;
11use crate::envelope::PassphraseVault;
12use crate::keys::{KeyError, MasterKeyCustody, StoreKeys};
13use crate::store_dir::StoreDir;
14
15/// How a store's master key is protected. The builder accepts this and never
16/// sees a cipher again — coven resolves the selection into a
17/// [`MasterKeyCustody`] and builds every cipher from what it supplies.
18pub enum KeyCustody {
19    /// The OS keyring — the default, byte-for-byte today's behavior.
20    Keyring,
21    /// Argon2id over a memorized passphrase wraps the keyring; the wrapped
22    /// blob lives in a file in the store directory.
23    Passphrase(Passphrase),
24    /// Supplied for this session, never persisted by coven.
25    InMemory(MasterKeyring),
26    /// A host-supplied custody implementation.
27    Custom(Arc<dyn MasterKeyCustody>),
28}
29
30impl KeyCustody {
31    /// Resolve the selected policy into the trait object coven drives the
32    /// sync engine through, injecting what each preset needs from the store's
33    /// identity: `store_id` for [`KeyCustody::Keyring`] (the keyring account
34    /// name), `store_dir` for [`KeyCustody::Passphrase`] (the wrapped-file
35    /// path).
36    pub fn resolve(self, store_id: &str, store_dir: &StoreDir) -> Arc<dyn MasterKeyCustody> {
37        match self {
38            KeyCustody::Keyring => Arc::new(KeyringCustody::new(store_id.to_string())),
39            KeyCustody::Passphrase(passphrase) => {
40                Arc::new(PassphraseCustody::new(passphrase, store_dir))
41            }
42            KeyCustody::InMemory(keyring) => Arc::new(InMemoryCustody::new(keyring)),
43            KeyCustody::Custom(custody) => custody,
44        }
45    }
46}
47
48// =============================================================================
49// Keyring preset
50// =============================================================================
51
52/// The OS keyring, wrapping [`StoreKeys`]'s master-key methods verbatim: same
53/// `encryption_master_key:{store_id}` account
54/// (`keyring_account_names_are_a_stable_storage_contract` pins it), same
55/// present-but-empty-is-corrupt read discipline (enforced once, at
56/// [`StoreKeys::get_encryption_key`]'s single keyring-read chokepoint), so
57/// existing installs' keys are found unchanged.
58struct KeyringCustody {
59    keys: StoreKeys,
60}
61
62impl KeyringCustody {
63    fn new(store_id: String) -> Self {
64        Self {
65            keys: StoreKeys::new(store_id),
66        }
67    }
68}
69
70impl MasterKeyCustody for KeyringCustody {
71    fn unlock(&self) -> Result<Option<MasterKeyring>, KeyError> {
72        self.keys
73            .get_encryption_key()?
74            .map(|s| {
75                MasterKeyring::from_serialized(&s).map_err(|e| KeyError::Crypto(e.to_string()))
76            })
77            .transpose()
78    }
79
80    fn persist(&self, keyring: &MasterKeyring) -> Result<(), KeyError> {
81        self.keys.set_encryption_key(&keyring.to_serialized())
82    }
83
84    fn forget(&self) -> Result<(), KeyError> {
85        self.keys.delete_encryption_key()
86    }
87}
88
89// =============================================================================
90// InMemory preset
91// =============================================================================
92
93/// Supplied per session and never persisted by coven.
94struct InMemoryCustody {
95    keyring: RwLock<Option<MasterKeyring>>,
96}
97
98impl InMemoryCustody {
99    fn new(seed: MasterKeyring) -> Self {
100        Self {
101            keyring: RwLock::new(Some(seed)),
102        }
103    }
104}
105
106impl MasterKeyCustody for InMemoryCustody {
107    fn unlock(&self) -> Result<Option<MasterKeyring>, KeyError> {
108        Ok(self.keyring.read().unwrap().clone())
109    }
110
111    fn persist(&self, keyring: &MasterKeyring) -> Result<(), KeyError> {
112        *self.keyring.write().unwrap() = Some(keyring.clone());
113        Ok(())
114    }
115
116    fn forget(&self) -> Result<(), KeyError> {
117        *self.keyring.write().unwrap() = None;
118        Ok(())
119    }
120}
121
122// =============================================================================
123// Passphrase preset
124// =============================================================================
125
126/// Argon2id over a [`Passphrase`] wraps the master keyring, via the shared
127/// [`PassphraseVault`] — the wrapped blob is a JSON envelope in a file under
128/// the store directory (`<store_dir>/master.keyring`), not a keyring entry.
129struct PassphraseCustody {
130    vault: PassphraseVault,
131}
132
133impl PassphraseCustody {
134    fn new(passphrase: Passphrase, store_dir: &StoreDir) -> Self {
135        Self {
136            vault: PassphraseVault::new(passphrase, store_dir.join("master.keyring")),
137        }
138    }
139}
140
141impl MasterKeyCustody for PassphraseCustody {
142    fn unlock(&self) -> Result<Option<MasterKeyring>, KeyError> {
143        let Some(plaintext) = self.vault.unlock()? else {
144            return Ok(None);
145        };
146        let serialized = String::from_utf8(plaintext)
147            .map_err(|e| KeyError::Crypto(format!("decrypted master keyring is not UTF-8: {e}")))?;
148        MasterKeyring::from_serialized(&serialized)
149            .map(Some)
150            .map_err(|e| KeyError::Crypto(e.to_string()))
151    }
152
153    fn persist(&self, keyring: &MasterKeyring) -> Result<(), KeyError> {
154        self.vault.persist(keyring.to_serialized().as_bytes())
155    }
156
157    fn forget(&self) -> Result<(), KeyError> {
158        self.vault.forget()
159    }
160}
161
162/// Re-wrap a store's passphrase-protected master keyring under a new
163/// passphrase — the store-side half of covenpass's "change passphrase". The
164/// `<store_dir>/master.keyring` envelope is decrypted with `old` and re-sealed
165/// under `new` (fresh salt and nonce). Errors if nothing is established there
166/// ([`KeyError::Persistence`]) or if `old` is wrong ([`KeyError::Crypto`]),
167/// leaving the existing file untouched on either failure. After it returns,
168/// the store's custody is re-opened under `new`.
169pub fn rewrap_passphrase_custody(
170    store_dir: &StoreDir,
171    old: Passphrase,
172    new: &Passphrase,
173) -> Result<(), KeyError> {
174    PassphraseCustody::new(old, store_dir).vault.rewrap(new)
175}
176
177#[cfg(test)]
178mod tests {
179    use super::*;
180    use crate::encryption::EncryptionService;
181
182    fn temp_store_dir() -> (tempfile::TempDir, StoreDir) {
183        let tmp = tempfile::tempdir().expect("temp dir");
184        let dir = StoreDir::new(tmp.path());
185        (tmp, dir)
186    }
187
188    // =========================================================================
189    // Keyring preset
190    // =========================================================================
191
192    #[test]
193    fn keyring_preset_unlock_persist_forget_round_trip() {
194        crate::keys::test_keyring::install();
195        let custody = KeyringCustody::new("custody-keyring-roundtrip".to_string());
196
197        assert!(
198            custody.unlock().expect("unlock a fresh store").is_none(),
199            "a fresh store has no established keyring",
200        );
201
202        let keyring = MasterKeyring::generate();
203        custody.persist(&keyring).expect("persist");
204        let unlocked = custody
205            .unlock()
206            .expect("unlock after persist")
207            .expect("keyring is established");
208        assert_eq!(unlocked.fingerprint(), keyring.fingerprint());
209
210        custody.forget().expect("forget");
211        assert!(
212            custody.unlock().expect("unlock after forget").is_none(),
213            "forget removes the established keyring",
214        );
215    }
216
217    /// The corrupt-empty-entry discipline lives once, in `StoreKeys::read`
218    /// (`empty_keyring_entry_is_an_error_not_absence` pins it there), and
219    /// `KeyringCustody::unlock` inherits it by construction: a present-but-empty
220    /// entry surfaces as `Err`, never `Ok(None)` — so `initialize_master_key`
221    /// (which generates only on `unlock() == Ok(None)`) cannot clobber it.
222    #[test]
223    fn keyring_preset_unlock_does_not_read_a_corrupt_empty_entry_as_absent() {
224        crate::keys::test_keyring::install();
225        let store_id = "custody-keyring-corrupt-empty".to_string();
226        let account = crate::keys::KeyringSlot::EncryptionMasterKey(store_id.clone()).account();
227        crate::keys::entry_for(&account)
228            .expect("create entry")
229            .set_password("")
230            .expect("write empty entry");
231
232        let custody = KeyringCustody::new(store_id);
233        let error = custody.unlock().expect_err("empty entry is corrupt");
234        assert!(error.to_string().contains("present but empty"));
235    }
236
237    // =========================================================================
238    // InMemory preset
239    // =========================================================================
240
241    #[test]
242    fn in_memory_preset_unlock_returns_the_seeded_keyring() {
243        let seed = MasterKeyring::generate();
244        let fingerprint = seed.fingerprint();
245        let custody = InMemoryCustody::new(seed);
246
247        let unlocked = custody
248            .unlock()
249            .expect("unlock")
250            .expect("seeded keyring is present");
251        assert_eq!(unlocked.fingerprint(), fingerprint);
252    }
253
254    #[test]
255    fn in_memory_preset_persist_replaces_and_forget_clears() {
256        let custody = InMemoryCustody::new(MasterKeyring::generate());
257
258        let rotated = MasterKeyring::generate();
259        custody.persist(&rotated).expect("persist");
260        assert_eq!(
261            custody.unlock().unwrap().unwrap().fingerprint(),
262            rotated.fingerprint(),
263        );
264
265        custody.forget().expect("forget");
266        assert!(custody.unlock().unwrap().is_none());
267    }
268
269    #[test]
270    fn in_memory_preset_never_writes_under_the_store_dir() {
271        let (tmp, _dir) = temp_store_dir();
272        let custody = InMemoryCustody::new(MasterKeyring::generate());
273        custody
274            .persist(&MasterKeyring::generate())
275            .expect("persist");
276        custody.forget().expect("forget");
277
278        let entries: Vec<_> = std::fs::read_dir(tmp.path())
279            .expect("read store dir")
280            .collect();
281        assert!(
282            entries.is_empty(),
283            "InMemory custody must touch no file under the store dir",
284        );
285    }
286
287    // =========================================================================
288    // Passphrase preset
289    // =========================================================================
290
291    #[test]
292    fn passphrase_preset_establish_then_unlock_round_trips() {
293        let (_tmp, dir) = temp_store_dir();
294        let custody = PassphraseCustody::new(
295            Passphrase::new("correct horse battery staple".to_string()),
296            &dir,
297        );
298
299        assert!(custody.unlock().expect("unlock before establish").is_none());
300
301        let keyring = MasterKeyring::generate();
302        custody.persist(&keyring).expect("establish");
303        let unlocked = custody
304            .unlock()
305            .expect("unlock after establish")
306            .expect("keyring is established");
307        assert_eq!(unlocked.fingerprint(), keyring.fingerprint());
308    }
309
310    #[test]
311    fn passphrase_preset_wrong_passphrase_is_err_not_none() {
312        let (_tmp, dir) = temp_store_dir();
313        let writer = PassphraseCustody::new(Passphrase::new("right passphrase".to_string()), &dir);
314        writer
315            .persist(&MasterKeyring::generate())
316            .expect("establish");
317
318        let reader = PassphraseCustody::new(Passphrase::new("wrong passphrase".to_string()), &dir);
319        let error = reader
320            .unlock()
321            .expect_err("wrong passphrase must not unlock");
322        assert!(
323            error.to_string().to_lowercase().contains("passphrase")
324                || matches!(error, KeyError::Crypto(_))
325        );
326    }
327
328    #[test]
329    fn passphrase_preset_missing_file_is_none() {
330        let (_tmp, dir) = temp_store_dir();
331        let custody = PassphraseCustody::new(Passphrase::new("unused".to_string()), &dir);
332        assert!(custody.unlock().expect("unlock with no file").is_none());
333    }
334
335    // Rotation re-wrap, atomic-write-no-torn-file, and wrong-derivation
336    // behavior are shared envelope-format guarantees, pinned generically once
337    // in `envelope.rs` rather than duplicated per payload type here.
338
339    /// A literal v1 envelope, pinned here so a future change to the
340    /// derivation, AEAD, or serialization code is caught by a failing test
341    /// rather than silently stranding every already-wrapped `master.keyring`
342    /// file. Wraps `MasterKeyring::from(EncryptionService::from_key([0x11u8;
343    /// 32]))` under passphrase "fixture-passphrase" with a fixed salt and
344    /// nonce (real writes use random ones; the fixture fixes them only so its
345    /// bytes are reproducible here).
346    const V1_FIXTURE_PASSPHRASE: &str = "fixture-passphrase";
347    const V1_FIXTURE_ENVELOPE_JSON: &str = concat!(
348        r#"{"v":1,"kdf":{"algo":"argon2id","m_cost":65536,"t_cost":3,"p_cost":4,"#,
349        r#""salt_b64":"3q2+7wEjRWeJq83vABEiMw=="},"#,
350        r#""nonce_b64":"AAECAwQFBgcICQoLDA0ODxAREhMUFRYX","#,
351        r#""ciphertext_b64":"+7/Z7TSK5xtqL6fqDzh5ayBkPPtuzf/0FyBy3mrgtiFjfabWOqVb8FonvR7SwvntJd9ERnTDljuE0o3Ofzs8a6XMbf0VJ6HlDp2aB62apAV3Fv1e1eb8su6/TxVOCskR9cvDmPr2P3CnsX3YRaGcEuilgSW8uKosW6gowDDZHqGat1XSPbnG02GO5QYuMxM="}"#
352    );
353
354    /// The store-side "change passphrase" entry point wires through to the
355    /// vault's re-wrap: after it, the old passphrase no longer unlocks
356    /// `master.keyring` and the new one does. The envelope's own re-wrap
357    /// guarantees are pinned in `envelope.rs`; this only proves the wiring.
358    #[test]
359    fn rewrap_passphrase_custody_moves_the_master_keyring_to_the_new_passphrase() {
360        let (_tmp, dir) = temp_store_dir();
361        let established = PassphraseCustody::new(Passphrase::new("old".to_string()), &dir);
362        let keyring = MasterKeyring::generate();
363        established.persist(&keyring).expect("establish");
364
365        rewrap_passphrase_custody(
366            &dir,
367            Passphrase::new("old".to_string()),
368            &Passphrase::new("new".to_string()),
369        )
370        .expect("re-wrap under the new passphrase");
371
372        let with_old = PassphraseCustody::new(Passphrase::new("old".to_string()), &dir);
373        assert!(
374            with_old.unlock().is_err(),
375            "the old passphrase must no longer unlock after a re-wrap",
376        );
377        let with_new = PassphraseCustody::new(Passphrase::new("new".to_string()), &dir);
378        assert_eq!(
379            with_new
380                .unlock()
381                .expect("the new passphrase unlocks")
382                .expect("keyring present")
383                .fingerprint(),
384            keyring.fingerprint(),
385        );
386    }
387
388    #[test]
389    fn passphrase_preset_envelope_fixture_v1_unlocks() {
390        let (_tmp, dir) = temp_store_dir();
391        std::fs::write(dir.join("master.keyring"), V1_FIXTURE_ENVELOPE_JSON)
392            .expect("write fixture envelope");
393
394        let custody =
395            PassphraseCustody::new(Passphrase::new(V1_FIXTURE_PASSPHRASE.to_string()), &dir);
396        let keyring = custody
397            .unlock()
398            .expect("the v1 fixture must still unlock")
399            .expect("the fixture names an established keyring");
400        assert_eq!(
401            keyring.fingerprint(),
402            EncryptionService::from_key([0x11u8; 32]).fingerprint(),
403        );
404    }
405}