Skip to main content

coven/
identity_custody.rs

1//! A store's device-identity custody: where its signing keypair is unlocked
2//! from, where a newly established one is written, and how it is removed.
3//! [`IdentityCustody`] is the policy a host selects on the builder, next to
4//! [`crate::custody::KeyCustody`]; [`IdentityCustody::resolve`] turns it into
5//! the [`DeviceIdentityCustody`] trait object the identity-establishing call
6//! sites (create, join, restore) drive.
7
8use std::sync::{Arc, RwLock};
9
10use crate::envelope::PassphraseVault;
11use crate::keys::{DeviceIdentityCustody, KeyError, KeyringSlot, UserKeypair, SIGN_SECRETKEYBYTES};
12use crate::store_dir::StoreDir;
13
14pub(crate) use crate::envelope::Passphrase;
15
16/// How a store's device-signing identity is protected. Selected on the
17/// builder, resolved once per store — the identity sibling of
18/// [`crate::custody::KeyCustody`], same shape.
19pub enum IdentityCustody {
20    /// The OS keyring — the default, byte-for-byte today's behavior.
21    Keyring,
22    /// Argon2id over a memorized passphrase wraps the keypair; the wrapped
23    /// blob lives in a file in the store directory.
24    Passphrase(Passphrase),
25    /// Supplied for this session, never persisted by coven.
26    InMemory(UserKeypair),
27    /// A host-supplied custody implementation.
28    Custom(Arc<dyn DeviceIdentityCustody>),
29}
30
31impl IdentityCustody {
32    /// Resolve the selected policy into the trait object the identity-
33    /// establishing call sites drive, injecting what each preset needs from
34    /// the store's identity: `store_id` for [`IdentityCustody::Keyring`] (the
35    /// keyring account name), `store_dir` for [`IdentityCustody::Passphrase`]
36    /// (the wrapped-file path).
37    ///
38    /// Public to match [`KeyCustody::resolve`](crate::KeyCustody::resolve): the
39    /// low-level [`restore_from_cloud`](crate::restore_from_cloud) takes the
40    /// already-resolved `Arc<dyn DeviceIdentityCustody>`, so a host restoring by
41    /// a directly-supplied key (no restore code) must be able to resolve a preset
42    /// itself, the same way it already resolves its `KeyCustody`.
43    pub fn resolve(self, store_id: &str, store_dir: &StoreDir) -> Arc<dyn DeviceIdentityCustody> {
44        match self {
45            IdentityCustody::Keyring => Arc::new(KeyringIdentityCustody::new(store_id.to_string())),
46            IdentityCustody::Passphrase(passphrase) => {
47                Arc::new(PassphraseIdentityCustody::new(passphrase, store_dir))
48            }
49            IdentityCustody::InMemory(keypair) => Arc::new(InMemoryIdentityCustody::new(keypair)),
50            IdentityCustody::Custom(custody) => custody,
51        }
52    }
53}
54
55// =============================================================================
56// Keyring preset
57// =============================================================================
58
59/// The OS keyring, wrapping this store's [`KeyringSlot::DeviceSigningKey`]
60/// account verbatim (`keyring_account_names_are_a_stable_storage_contract`
61/// pins the account name), so an already-stored identity is found unchanged.
62struct KeyringIdentityCustody {
63    store_id: String,
64}
65
66impl KeyringIdentityCustody {
67    fn new(store_id: String) -> Self {
68        Self { store_id }
69    }
70}
71
72impl DeviceIdentityCustody for KeyringIdentityCustody {
73    fn unlock(&self) -> Result<Option<UserKeypair>, KeyError> {
74        let slot = KeyringSlot::DeviceSigningKey(self.store_id.clone());
75        let Some(sk_hex) = crate::keys::read(&slot)? else {
76            return Ok(None);
77        };
78        let signing_key: [u8; SIGN_SECRETKEYBYTES] = hex::decode(&sk_hex)
79            .map_err(|e| KeyError::Crypto(format!("Invalid signing key hex: {e}")))?
80            .try_into()
81            .map_err(|_| KeyError::Crypto("Signing key wrong length".to_string()))?;
82        Ok(Some(UserKeypair::from_signing_key_bytes(&signing_key)?))
83    }
84
85    fn persist(&self, keypair: &UserKeypair) -> Result<(), KeyError> {
86        crate::keys::write(
87            &KeyringSlot::DeviceSigningKey(self.store_id.clone()),
88            &hex::encode(keypair.to_keypair_bytes()),
89        )
90    }
91
92    fn forget(&self) -> Result<(), KeyError> {
93        crate::keys::delete(&KeyringSlot::DeviceSigningKey(self.store_id.clone())).map(|_| ())
94    }
95}
96
97// =============================================================================
98// InMemory preset
99// =============================================================================
100
101/// Supplied for this session, never persisted by coven — the identity
102/// sibling of [`crate::custody::KeyCustody::InMemory`].
103struct InMemoryIdentityCustody {
104    keypair: RwLock<Option<UserKeypair>>,
105}
106
107impl InMemoryIdentityCustody {
108    fn new(seed: UserKeypair) -> Self {
109        Self {
110            keypair: RwLock::new(Some(seed)),
111        }
112    }
113}
114
115impl DeviceIdentityCustody for InMemoryIdentityCustody {
116    fn unlock(&self) -> Result<Option<UserKeypair>, KeyError> {
117        Ok(self.keypair.read().unwrap().clone())
118    }
119
120    fn persist(&self, keypair: &UserKeypair) -> Result<(), KeyError> {
121        *self.keypair.write().unwrap() = Some(keypair.clone());
122        Ok(())
123    }
124
125    fn forget(&self) -> Result<(), KeyError> {
126        *self.keypair.write().unwrap() = None;
127        Ok(())
128    }
129}
130
131// =============================================================================
132// Passphrase preset
133// =============================================================================
134
135/// Argon2id over a [`Passphrase`] wraps this store's raw 64-byte signing
136/// keypair, via the shared [`PassphraseVault`] — the same envelope format
137/// [`crate::custody::KeyCustody::Passphrase`] uses for the master keyring,
138/// parameterized here by a different payload and a different file name
139/// (`identity.envelope`) in the same store directory.
140struct PassphraseIdentityCustody {
141    vault: PassphraseVault,
142}
143
144impl PassphraseIdentityCustody {
145    fn new(passphrase: Passphrase, store_dir: &StoreDir) -> Self {
146        Self {
147            vault: PassphraseVault::new(passphrase, store_dir.join("identity.envelope")),
148        }
149    }
150}
151
152impl DeviceIdentityCustody for PassphraseIdentityCustody {
153    fn unlock(&self) -> Result<Option<UserKeypair>, KeyError> {
154        let Some(plaintext) = self.vault.unlock()? else {
155            return Ok(None);
156        };
157        let len = plaintext.len();
158        let signing_key: [u8; SIGN_SECRETKEYBYTES] = plaintext.try_into().map_err(|_| {
159            KeyError::Crypto(format!(
160                "decrypted device identity is {len} bytes, expected {SIGN_SECRETKEYBYTES}"
161            ))
162        })?;
163        Ok(Some(UserKeypair::from_signing_key_bytes(&signing_key)?))
164    }
165
166    fn persist(&self, keypair: &UserKeypair) -> Result<(), KeyError> {
167        self.vault.persist(&keypair.to_keypair_bytes())
168    }
169
170    fn forget(&self) -> Result<(), KeyError> {
171        self.vault.forget()
172    }
173}
174
175/// Re-wrap a store's passphrase-protected signing identity under a new
176/// passphrase — the identity half of a host's "change passphrase", the
177/// sibling of [`crate::custody::rewrap_passphrase_custody`]. The store's
178/// `identity.envelope` is decrypted with `old` and re-sealed under `new`
179/// (fresh salt and nonce). Errors if nothing is established there
180/// ([`KeyError::Persistence`]) or if `old` is wrong ([`KeyError::Crypto`]),
181/// leaving the existing file untouched on either failure. After it returns,
182/// the identity is re-opened under `new`.
183pub fn rewrap_passphrase_identity_custody(
184    store_dir: &StoreDir,
185    old: Passphrase,
186    new: &Passphrase,
187) -> Result<(), KeyError> {
188    PassphraseIdentityCustody::new(old, store_dir)
189        .vault
190        .rewrap(new)
191}
192
193#[cfg(test)]
194mod tests {
195    use super::*;
196    use crate::keys::test_keyring;
197
198    fn temp_store_dir() -> (tempfile::TempDir, StoreDir) {
199        let tmp = tempfile::tempdir().expect("temp dir");
200        let dir = StoreDir::new(tmp.path());
201        (tmp, dir)
202    }
203
204    // =========================================================================
205    // Keyring preset
206    // =========================================================================
207
208    #[test]
209    fn keyring_preset_unlock_persist_forget_round_trip() {
210        test_keyring::install();
211        let custody = KeyringIdentityCustody::new("identity-keyring-roundtrip".to_string());
212
213        assert!(
214            custody.unlock().expect("unlock a fresh store").is_none(),
215            "a fresh store has no established identity",
216        );
217
218        let keypair = UserKeypair::generate();
219        custody.persist(&keypair).expect("persist");
220        let unlocked = custody
221            .unlock()
222            .expect("unlock after persist")
223            .expect("identity is established");
224        assert_eq!(unlocked.public_key(), keypair.public_key());
225
226        custody.forget().expect("forget");
227        assert!(
228            custody.unlock().expect("unlock after forget").is_none(),
229            "forget removes the established identity",
230        );
231    }
232
233    /// Two stores' keyring identities never collide: each `KeyringIdentityCustody`
234    /// is scoped by its own `store_id`, the identity sibling of the master
235    /// key's per-store keyring account.
236    #[test]
237    fn keyring_preset_is_scoped_to_its_store() {
238        test_keyring::install();
239        let store_a = KeyringIdentityCustody::new("identity-keyring-scope-a".to_string());
240        let store_b = KeyringIdentityCustody::new("identity-keyring-scope-b".to_string());
241
242        let keypair_a = UserKeypair::generate();
243        store_a.persist(&keypair_a).expect("persist to store a");
244
245        assert_eq!(
246            store_a.unlock().unwrap().unwrap().public_key(),
247            keypair_a.public_key(),
248        );
249        assert!(
250            store_b.unlock().unwrap().is_none(),
251            "store b must not see store a's identity",
252        );
253    }
254
255    // =========================================================================
256    // InMemory preset
257    // =========================================================================
258
259    #[test]
260    fn in_memory_preset_unlock_returns_the_seeded_keypair() {
261        let seed = UserKeypair::generate();
262        let expected = seed.public_key();
263        let custody = InMemoryIdentityCustody::new(seed);
264
265        let unlocked = custody
266            .unlock()
267            .expect("unlock")
268            .expect("seeded keypair is present");
269        assert_eq!(unlocked.public_key(), expected);
270    }
271
272    #[test]
273    fn in_memory_preset_persist_replaces_and_forget_clears() {
274        let custody = InMemoryIdentityCustody::new(UserKeypair::generate());
275
276        let rotated = UserKeypair::generate();
277        custody.persist(&rotated).expect("persist");
278        assert_eq!(
279            custody.unlock().unwrap().unwrap().public_key(),
280            rotated.public_key(),
281        );
282
283        custody.forget().expect("forget");
284        assert!(custody.unlock().unwrap().is_none());
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 = PassphraseIdentityCustody::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 keypair = UserKeypair::generate();
302        custody.persist(&keypair).expect("establish");
303        let unlocked = custody
304            .unlock()
305            .expect("unlock after establish")
306            .expect("identity is established");
307        assert_eq!(unlocked.public_key(), keypair.public_key());
308    }
309
310    #[test]
311    fn passphrase_preset_wrong_passphrase_is_err_not_none() {
312        let (_tmp, dir) = temp_store_dir();
313        let writer =
314            PassphraseIdentityCustody::new(Passphrase::new("right passphrase".to_string()), &dir);
315        writer.persist(&UserKeypair::generate()).expect("establish");
316
317        let reader =
318            PassphraseIdentityCustody::new(Passphrase::new("wrong passphrase".to_string()), &dir);
319        match reader.unlock() {
320            Err(error) => assert!(matches!(error, KeyError::Crypto(_)), "got {error:?}"),
321            Ok(_) => panic!("wrong passphrase must not unlock"),
322        }
323    }
324
325    /// The identity envelope lives inside the store directory, alongside
326    /// `master.keyring` — a store's identity belongs with the rest of that
327    /// store's own state.
328    #[test]
329    fn passphrase_preset_lives_inside_the_store_directory() {
330        let (_tmp, dir) = temp_store_dir();
331        let custody = PassphraseIdentityCustody::new(Passphrase::new("unused".to_string()), &dir);
332        custody.persist(&UserKeypair::generate()).expect("persist");
333
334        let path = dir.join("identity.envelope");
335        assert!(
336            path.exists(),
337            "the envelope is written inside the store directory"
338        );
339    }
340
341    /// A literal v1 envelope, independently captured, pinned here to prove the
342    /// identity preset reads the exact same wire format `custody.rs`'s
343    /// master-key preset does — one shared envelope implementation, not two
344    /// that happen to agree today. Wraps the 64-byte keypair built from
345    /// Ed25519 seed `[0x11u8; 32]` under passphrase "fixture-passphrase"; a
346    /// future change to the derivation, AEAD, or serialization code that
347    /// diverges between the two payload types is caught here as a failing
348    /// test.
349    const V1_FIXTURE_PASSPHRASE: &str = "fixture-passphrase";
350    const V1_FIXTURE_ENVELOPE_JSON: &str = concat!(
351        r#"{"v":1,"kdf":{"algo":"argon2id","m_cost":65536,"t_cost":3,"p_cost":4,"#,
352        r#""salt_b64":"yfYYT3S+eUdDHpvRkRJZZg=="},"#,
353        r#""nonce_b64":"+pRYN/2QyizRpYZrpG++Y9fU7R7POwp6","#,
354        r#""ciphertext_b64":"IrHxxF+oOCv4n80oKVo2VAjPA7m1rbX654FW8u4kt+0FIhqhpotFOke8JL2E8TuKuXperOtbHOtxluSb6LBGtYISbxc3RMnTot98mFXdX8A="}"#
355    );
356
357    /// The identity "change passphrase" entry point wires through to the
358    /// vault's re-wrap: after it, the old passphrase no longer unlocks the
359    /// identity file and the new one does. The envelope's own re-wrap
360    /// guarantees are pinned in `envelope.rs`; this only proves the wiring.
361    #[test]
362    fn rewrap_passphrase_identity_custody_moves_the_identity_to_the_new_passphrase() {
363        let (_tmp, dir) = temp_store_dir();
364        let keypair = UserKeypair::generate();
365        PassphraseIdentityCustody::new(Passphrase::new("old".to_string()), &dir)
366            .persist(&keypair)
367            .expect("establish");
368
369        rewrap_passphrase_identity_custody(
370            &dir,
371            Passphrase::new("old".to_string()),
372            &Passphrase::new("new".to_string()),
373        )
374        .expect("re-wrap under the new passphrase");
375
376        let with_old = PassphraseIdentityCustody::new(Passphrase::new("old".to_string()), &dir);
377        assert!(
378            with_old.unlock().is_err(),
379            "the old passphrase must no longer unlock after a re-wrap",
380        );
381        let with_new = PassphraseIdentityCustody::new(Passphrase::new("new".to_string()), &dir);
382        assert_eq!(
383            with_new
384                .unlock()
385                .expect("the new passphrase unlocks")
386                .expect("identity present")
387                .public_key(),
388            keypair.public_key(),
389        );
390    }
391
392    #[test]
393    fn passphrase_preset_envelope_fixture_v1_unlocks() {
394        let (_tmp, dir) = temp_store_dir();
395        std::fs::write(dir.join("identity.envelope"), V1_FIXTURE_ENVELOPE_JSON)
396            .expect("write fixture envelope");
397
398        let custody = PassphraseIdentityCustody::new(
399            Passphrase::new(V1_FIXTURE_PASSPHRASE.to_string()),
400            &dir,
401        );
402        let keypair = custody
403            .unlock()
404            .expect("the v1 fixture must still unlock")
405            .expect("the fixture names an established identity");
406
407        let expected = UserKeypair::from_signing_key_bytes(
408            &ed25519_dalek::SigningKey::from_bytes(&[0x11u8; 32]).to_keypair_bytes(),
409        )
410        .expect("build the expected keypair from the fixture's seed");
411        assert_eq!(keypair.public_key(), expected.public_key());
412    }
413}