1use 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
16pub enum IdentityCustody {
20 Keyring,
22 Passphrase(Passphrase),
25 InMemory(UserKeypair),
27 Custom(Arc<dyn DeviceIdentityCustody>),
29}
30
31impl IdentityCustody {
32 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
55struct 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
97struct 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
131struct 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
175pub 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 #[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 #[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 #[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 #[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 #[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 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 #[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}