Skip to main content

coven_keys/
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;
9
10use crate::custody::preset::{CustodySecret, InMemoryCustody, PassphraseCustody};
11use crate::keys::{DeviceIdentityCustody, KeyError, StoreKeys, UserKeypair, SIGN_SECRETKEYBYTES};
12use coven_foundation::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 retained owners: `store_keys` for
35    /// [`IdentityCustody::Keyring`] and `store_dir` for
36    /// [`IdentityCustody::Passphrase`].
37    ///
38    /// Public to match [`KeyCustody::resolve`](crate::custody::KeyCustody::resolve): a
39    /// host can resolve either policy against the same retained store-key
40    /// capability used by the store boundary.
41    pub fn resolve(
42        self,
43        store_keys: &StoreKeys,
44        store_dir: &StoreDir,
45    ) -> Arc<dyn DeviceIdentityCustody> {
46        match self {
47            IdentityCustody::Keyring => Arc::new(store_keys.clone()),
48            IdentityCustody::Passphrase(passphrase) => {
49                Arc::new(PassphraseCustody::<UserKeypair>::new(passphrase, store_dir))
50            }
51            IdentityCustody::InMemory(keypair) => Arc::new(InMemoryCustody::new(keypair)),
52            IdentityCustody::Custom(custody) => custody,
53        }
54    }
55}
56
57// =============================================================================
58// The signing identity as a custody secret
59// =============================================================================
60
61impl CustodySecret for UserKeypair {
62    const FILE: &'static str = "identity.envelope";
63
64    fn to_bytes(&self) -> Vec<u8> {
65        self.to_keypair_bytes().to_vec()
66    }
67
68    fn from_bytes(bytes: Vec<u8>) -> Result<Self, KeyError> {
69        let len = bytes.len();
70        let signing_key: [u8; SIGN_SECRETKEYBYTES] =
71            bytes.try_into().map_err(|_| KeyError::InvalidLength {
72                subject: "decrypted device identity",
73                expected: SIGN_SECRETKEYBYTES,
74                actual: len,
75            })?;
76        UserKeypair::from_signing_key_bytes(&signing_key)
77    }
78}
79
80impl DeviceIdentityCustody for InMemoryCustody<UserKeypair> {
81    fn unlock(&self) -> Result<Option<UserKeypair>, KeyError> {
82        InMemoryCustody::unlock(self)
83    }
84
85    fn persist(&self, keypair: &UserKeypair) -> Result<(), KeyError> {
86        InMemoryCustody::persist(self, keypair)
87    }
88
89    fn forget(&self) -> Result<(), KeyError> {
90        InMemoryCustody::forget(self)
91    }
92}
93
94impl DeviceIdentityCustody for PassphraseCustody<UserKeypair> {
95    fn unlock(&self) -> Result<Option<UserKeypair>, KeyError> {
96        PassphraseCustody::unlock(self)
97    }
98
99    fn persist(&self, keypair: &UserKeypair) -> Result<(), KeyError> {
100        PassphraseCustody::persist(self, keypair)
101    }
102
103    fn forget(&self) -> Result<(), KeyError> {
104        PassphraseCustody::forget(self)
105    }
106}
107
108#[cfg(test)]
109mod tests {
110    use super::*;
111    use crate::keys::test_keyring;
112
113    fn temp_store_dir() -> (tempfile::TempDir, StoreDir) {
114        let tmp = tempfile::tempdir().expect("temp dir");
115        let dir = StoreDir::new_ephemeral(tmp.path());
116        (tmp, dir)
117    }
118
119    // =========================================================================
120    // Keyring preset
121    // =========================================================================
122
123    #[test]
124    fn keyring_preset_unlock_persist_forget_round_trip() {
125        test_keyring::install();
126        let store_keys = StoreKeys::bind("identity-keyring-roundtrip".to_string());
127        let custody =
128            IdentityCustody::Keyring.resolve(&store_keys, &StoreDir::new_ephemeral("unused"));
129
130        assert!(
131            custody.unlock().expect("unlock a fresh store").is_none(),
132            "a fresh store has no established identity",
133        );
134
135        let keypair = UserKeypair::generate();
136        custody.persist(&keypair).expect("persist");
137        let unlocked = custody
138            .unlock()
139            .expect("unlock after persist")
140            .expect("identity is established");
141        assert_eq!(unlocked.public_key(), keypair.public_key());
142
143        custody.forget().expect("forget");
144        assert!(
145            custody.unlock().expect("unlock after forget").is_none(),
146            "forget removes the established identity",
147        );
148    }
149
150    /// Two stores' keyring identities never collide: each `StoreKeys` custody
151    /// is scoped by its own `store_id`, the identity sibling of the master key's
152    /// per-store keyring account.
153    #[test]
154    fn keyring_preset_is_scoped_to_its_store() {
155        test_keyring::install();
156        let store_a_keys = StoreKeys::bind("identity-keyring-scope-a".to_string());
157        let store_b_keys = StoreKeys::bind("identity-keyring-scope-b".to_string());
158        let store_a =
159            IdentityCustody::Keyring.resolve(&store_a_keys, &StoreDir::new_ephemeral("unused"));
160        let store_b =
161            IdentityCustody::Keyring.resolve(&store_b_keys, &StoreDir::new_ephemeral("unused"));
162
163        let keypair_a = UserKeypair::generate();
164        store_a.persist(&keypair_a).expect("persist to store a");
165
166        assert_eq!(
167            store_a.unlock().unwrap().unwrap().public_key(),
168            keypair_a.public_key(),
169        );
170        assert!(
171            store_b.unlock().unwrap().is_none(),
172            "store b must not see store a's identity",
173        );
174    }
175
176    // =========================================================================
177    // InMemory preset
178    // =========================================================================
179
180    #[test]
181    fn in_memory_preset_unlock_returns_the_seeded_keypair() {
182        let seed = UserKeypair::generate();
183        let expected = seed.public_key();
184        let custody = InMemoryCustody::new(seed);
185
186        let unlocked = custody
187            .unlock()
188            .expect("unlock")
189            .expect("seeded keypair is present");
190        assert_eq!(unlocked.public_key(), expected);
191    }
192
193    #[test]
194    fn in_memory_preset_persist_replaces_and_forget_clears() {
195        let custody = InMemoryCustody::new(UserKeypair::generate());
196
197        let rotated = UserKeypair::generate();
198        custody.persist(&rotated).expect("persist");
199        assert_eq!(
200            custody.unlock().unwrap().unwrap().public_key(),
201            rotated.public_key(),
202        );
203
204        custody.forget().expect("forget");
205        assert!(custody.unlock().unwrap().is_none());
206    }
207
208    // =========================================================================
209    // Passphrase preset
210    // =========================================================================
211
212    #[test]
213    fn passphrase_preset_establish_then_unlock_round_trips() {
214        let (_tmp, dir) = temp_store_dir();
215        let custody = PassphraseCustody::<UserKeypair>::new(
216            Passphrase::new("correct horse battery staple".to_string()),
217            &dir,
218        );
219
220        assert!(custody.unlock().expect("unlock before establish").is_none());
221
222        let keypair = UserKeypair::generate();
223        custody.persist(&keypair).expect("establish");
224        let unlocked = custody
225            .unlock()
226            .expect("unlock after establish")
227            .expect("identity is established");
228        assert_eq!(unlocked.public_key(), keypair.public_key());
229    }
230
231    #[test]
232    fn passphrase_preset_wrong_passphrase_is_err_not_none() {
233        let (_tmp, dir) = temp_store_dir();
234        let writer = PassphraseCustody::<UserKeypair>::new(
235            Passphrase::new("right passphrase".to_string()),
236            &dir,
237        );
238        writer.persist(&UserKeypair::generate()).expect("establish");
239
240        let reader = PassphraseCustody::<UserKeypair>::new(
241            Passphrase::new("wrong passphrase".to_string()),
242            &dir,
243        );
244        match reader.unlock() {
245            Err(error) => assert!(
246                matches!(error, KeyError::PassphraseEnvelopeDecryption),
247                "got {error:?}"
248            ),
249            Ok(_) => panic!("wrong passphrase must not unlock"),
250        }
251    }
252
253    /// The identity envelope lives inside the store directory, alongside
254    /// `master.keyring` — a store's identity belongs with the rest of that
255    /// store's own state.
256    #[test]
257    fn passphrase_preset_lives_inside_the_store_directory() {
258        let (_tmp, dir) = temp_store_dir();
259        let custody =
260            PassphraseCustody::<UserKeypair>::new(Passphrase::new("unused".to_string()), &dir);
261        custody.persist(&UserKeypair::generate()).expect("persist");
262
263        let path = dir.join("identity.envelope");
264        assert!(
265            path.exists(),
266            "the envelope is written inside the store directory"
267        );
268    }
269
270    /// A literal v1 envelope, independently captured, pinned here to prove the
271    /// identity preset reads the exact same wire format `custody.rs`'s
272    /// master-key preset does — one shared envelope implementation, not two
273    /// that happen to agree today. Wraps the 64-byte keypair built from
274    /// Ed25519 seed `[0x11u8; 32]` under passphrase "fixture-passphrase"; a
275    /// future change to the derivation, AEAD, or serialization code that
276    /// diverges between the two payload types is caught here as a failing
277    /// test.
278    const V1_FIXTURE_PASSPHRASE: &str = "fixture-passphrase";
279    const V1_FIXTURE_ENVELOPE_JSON: &str = concat!(
280        r#"{"v":1,"kdf":{"algo":"argon2id","m_cost":65536,"t_cost":3,"p_cost":4,"#,
281        r#""salt_b64":"yfYYT3S+eUdDHpvRkRJZZg=="},"#,
282        r#""nonce_b64":"+pRYN/2QyizRpYZrpG++Y9fU7R7POwp6","#,
283        r#""ciphertext_b64":"IrHxxF+oOCv4n80oKVo2VAjPA7m1rbX654FW8u4kt+0FIhqhpotFOke8JL2E8TuKuXperOtbHOtxluSb6LBGtYISbxc3RMnTot98mFXdX8A="}"#
284    );
285
286    #[test]
287    fn passphrase_preset_envelope_fixture_v1_unlocks() {
288        let (_tmp, dir) = temp_store_dir();
289        std::fs::write(dir.join("identity.envelope"), V1_FIXTURE_ENVELOPE_JSON)
290            .expect("write fixture envelope");
291
292        let custody = PassphraseCustody::<UserKeypair>::new(
293            Passphrase::new(V1_FIXTURE_PASSPHRASE.to_string()),
294            &dir,
295        );
296        let keypair = custody
297            .unlock()
298            .expect("the v1 fixture must still unlock")
299            .expect("the fixture names an established identity");
300
301        let expected = UserKeypair::from_signing_key_bytes(
302            &ed25519_dalek::SigningKey::from_bytes(&[0x11u8; 32]).to_keypair_bytes(),
303        )
304        .expect("build the expected keypair from the fixture's seed");
305        assert_eq!(keypair.public_key(), expected.public_key());
306    }
307}