1use super::platform::NativeKeychain;
4use super::{KeyError, KeyMaterial, PersonRestoreCode};
5use crate::{wire, MaterialError, MemberIdentity, SecretBytes, SecretText, StoreKeyring};
6use coven_foundation::id_source::{DeviceId, StoreId};
7use coven_foundation::locks::lock_unpoisoned;
8use std::{
9 collections::BTreeSet,
10 marker::PhantomData,
11 sync::{Arc, Mutex},
12};
13
14fn validate_service(name: &str) -> Result<(), KeyError> {
15 if name.is_empty() || name.contains('\0') {
16 return Err(KeyError::InvalidServiceName);
17 }
18 Ok(())
19}
20
21const DEVICE_ID_ENTRY: &str = "device-id";
22const CREDENTIALS_ENTRY: &str = "storage-credentials";
23const HOST_SECRET_NAMES_ENTRY: &str = "host-secret-names";
24const INVITE_CODE_ENTRY: &str = "invite-code";
25const SETUP_CREDENTIALS_ENTRY: &str = "setup-credentials";
26const REPLACING_KEY_ENTRY: &str = "replacing-access-key";
27const RESTORE_RECORD_ENTRY: &str = "restore-record";
28const REPLACEMENT_RECORD_ENTRY: &str = "replacement-record";
29
30#[derive(Clone, Copy, Debug, Eq, PartialEq, Ord, PartialOrd)]
31pub(crate) enum EntryScope {
32 DeviceOnly,
34 Person,
38}
39
40pub(crate) trait KeychainBackend: std::any::Any + Send + Sync {
41 fn read(
42 &self,
43 scope: EntryScope,
44 service: &str,
45 account: &str,
46 ) -> Result<Option<SecretBytes>, KeyError>;
47 fn write(
48 &self,
49 scope: EntryScope,
50 service: &str,
51 account: &str,
52 bytes: &[u8],
53 ) -> Result<(), KeyError>;
54 fn delete(&self, scope: EntryScope, service: &str, account: &str) -> Result<(), KeyError>;
55 fn person_restore_codes(&self, service: &str) -> Result<Vec<(StoreId, SecretBytes)>, KeyError>;
56 fn person_copies(&self, service: &str, account: &str) -> Result<Vec<SecretBytes>, KeyError>;
58 fn replace_person_entry(
62 &self,
63 service: &str,
64 account: &str,
65 bytes: &[u8],
66 ) -> Result<(), KeyError>;
67 fn supports_person_entries(&self) -> bool;
68 fn person_value_limit(&self, service: &str, account: &str) -> Option<usize>;
71 fn person_backup_follows_writes(&self) -> bool;
74}
75
76pub struct Keychain {
79 name: String,
80 backend: Box<dyn KeychainBackend>,
81 host_secrets: Mutex<()>,
82}
83
84impl Keychain {
85 pub fn native(name: impl Into<String>) -> Result<Arc<Self>, KeyError> {
89 let name = name.into();
90 validate_service(&name)?;
91 Ok(Arc::new(Self {
92 name,
93 backend: Box::new(NativeKeychain::new()?),
94 host_secrets: Mutex::new(()),
95 }))
96 }
97
98 pub fn person_restore_codes(&self) -> Result<Vec<(StoreId, PersonRestoreCode)>, KeyError> {
108 Ok(by_store(self.backend.person_restore_codes(&self.name)?))
109 }
110
111 pub(super) fn supports_person_entries(&self) -> bool {
115 self.backend.supports_person_entries()
116 }
117
118 pub(super) fn person_backup_follows_writes(&self) -> bool {
121 self.backend.person_backup_follows_writes()
122 }
123
124 pub(super) fn person_value_limit(&self, account: &str) -> Option<usize> {
126 self.backend.person_value_limit(&self.name, account)
127 }
128
129 pub(super) fn read(
130 &self,
131 scope: EntryScope,
132 account: &str,
133 ) -> Result<Option<SecretBytes>, KeyError> {
134 self.backend.read(scope, &self.name, account)
135 }
136
137 pub(super) fn write(
138 &self,
139 scope: EntryScope,
140 account: &str,
141 bytes: &[u8],
142 ) -> Result<(), KeyError> {
143 self.backend.write(scope, &self.name, account, bytes)
144 }
145
146 pub(super) fn delete(&self, scope: EntryScope, account: &str) -> Result<(), KeyError> {
147 self.backend.delete(scope, &self.name, account)
148 }
149
150 pub(super) fn person_copies(&self, account: &str) -> Result<Vec<SecretBytes>, KeyError> {
151 self.backend.person_copies(&self.name, account)
152 }
153
154 pub(super) fn replace_person_entry(&self, account: &str, bytes: &[u8]) -> Result<(), KeyError> {
155 self.backend.replace_person_entry(&self.name, account, bytes)
156 }
157}
158
159impl std::fmt::Debug for Keychain {
160 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
161 f.write_str("Keychain([REDACTED])")
162 }
163}
164
165pub struct StoreKeychain {
173 keychain: Arc<Keychain>,
174 store: StoreId,
175}
176
177impl StoreKeychain {
178 pub fn new(keychain: Arc<Keychain>, store: StoreId) -> Self {
180 Self { keychain, store }
181 }
182
183 fn account(&self, name: &str) -> String {
184 account(name, self.store)
185 }
186 fn read(&self, name: &str) -> Result<Option<SecretBytes>, KeyError> {
187 self.keychain.read(EntryScope::DeviceOnly, &self.account(name))
188 }
189 fn write(&self, name: &str, bytes: &[u8]) -> Result<(), KeyError> {
190 self.keychain.write(EntryScope::DeviceOnly, &self.account(name), bytes)
191 }
192 fn remove(&self, name: &str) -> Result<(), KeyError> {
193 self.keychain.delete(EntryScope::DeviceOnly, &self.account(name))
194 }
195
196 #[expect(clippy::map_err_ignore, reason = "a stored device id fails only by not being 8 bytes")]
200 pub fn device_id(&self) -> Result<Option<DeviceId>, KeyError> {
201 self.read(DEVICE_ID_ENTRY)?
202 .map(|bytes| {
203 let bytes: [u8; 8] =
204 bytes.as_bytes().try_into().map_err(|_| KeyError::DeviceIdEncoding)?;
205 Ok(DeviceId(u64::from_be_bytes(bytes)))
206 })
207 .transpose()
208 }
209
210 pub fn set_device_id(&self, device: DeviceId) -> Result<(), KeyError> {
212 self.write(DEVICE_ID_ENTRY, &device.0.to_be_bytes())
213 }
214
215 pub fn delete_device_id(&self) -> Result<(), KeyError> {
217 self.remove(DEVICE_ID_ENTRY)
218 }
219
220 pub fn storage_credentials(&self) -> Result<Option<SecretBytes>, KeyError> {
222 self.read(CREDENTIALS_ENTRY)
223 }
224
225 pub fn set_storage_credentials(&self, bytes: &SecretBytes) -> Result<(), KeyError> {
227 self.write(CREDENTIALS_ENTRY, bytes.as_bytes())
228 }
229
230 pub fn delete_storage_credentials(&self) -> Result<(), KeyError> {
232 self.remove(CREDENTIALS_ENTRY)
233 }
234
235 pub fn setup_credentials(&self) -> Result<Option<SecretBytes>, KeyError> {
239 self.read(SETUP_CREDENTIALS_ENTRY)
240 }
241
242 pub fn set_setup_credentials(&self, bytes: &SecretBytes) -> Result<(), KeyError> {
244 self.write(SETUP_CREDENTIALS_ENTRY, bytes.as_bytes())
245 }
246
247 pub fn delete_setup_credentials(&self) -> Result<(), KeyError> {
250 self.remove(SETUP_CREDENTIALS_ENTRY)
251 }
252
253 pub fn replacing_access_key(&self) -> Result<Option<SecretBytes>, KeyError> {
257 self.read(REPLACING_KEY_ENTRY)
258 }
259
260 pub fn set_replacing_access_key(&self, bytes: &SecretBytes) -> Result<(), KeyError> {
262 self.write(REPLACING_KEY_ENTRY, bytes.as_bytes())
263 }
264
265 pub fn delete_replacing_access_key(&self) -> Result<(), KeyError> {
268 self.remove(REPLACING_KEY_ENTRY)
269 }
270
271 #[expect(
275 clippy::map_err_ignore,
276 reason = "a saved invite code fails only by not being UTF-8, which Encoding names"
277 )]
278 pub fn invite_code(&self) -> Result<Option<SecretText>, KeyError> {
279 self.read(INVITE_CODE_ENTRY)?
280 .map(|bytes| {
281 std::str::from_utf8(bytes.as_bytes())
282 .map(|code| SecretText::new(code.to_owned()))
283 .map_err(|_| MaterialError::Encoding.into())
284 })
285 .transpose()
286 }
287
288 pub fn set_invite_code(&self, code: &SecretText) -> Result<(), KeyError> {
290 self.write(INVITE_CODE_ENTRY, code.as_str().as_bytes())
291 }
292
293 pub fn delete_invite_code(&self) -> Result<(), KeyError> {
296 self.remove(INVITE_CODE_ENTRY)
297 }
298
299 pub fn restore_record(&self) -> Result<Option<(SecretText, String)>, KeyError> {
304 self.read_code_record(RESTORE_RECORD_ENTRY)
305 }
306
307 pub fn set_restore_record(&self, code: &SecretText, device_name: &str) -> Result<(), KeyError> {
311 self.write_code_record(RESTORE_RECORD_ENTRY, code, device_name)
312 }
313
314 pub fn delete_restore_record(&self) -> Result<(), KeyError> {
317 self.remove(RESTORE_RECORD_ENTRY)
318 }
319
320 pub fn replacement_record(&self) -> Result<Option<(SecretText, String)>, KeyError> {
325 self.read_code_record(REPLACEMENT_RECORD_ENTRY)
326 }
327
328 pub fn set_replacement_record(
332 &self,
333 code: &SecretText,
334 device_name: &str,
335 ) -> Result<(), KeyError> {
336 self.write_code_record(REPLACEMENT_RECORD_ENTRY, code, device_name)
337 }
338
339 pub fn delete_replacement_record(&self) -> Result<(), KeyError> {
342 self.remove(REPLACEMENT_RECORD_ENTRY)
343 }
344
345 #[expect(
346 clippy::map_err_ignore,
347 reason = "a code record fails only by its layout, which Encoding names"
348 )]
349 fn read_code_record(&self, entry: &str) -> Result<Option<(SecretText, String)>, KeyError> {
350 self.read(entry)?
351 .map(|bytes| {
352 let text = std::str::from_utf8(bytes.as_bytes())
353 .map_err(|_| KeyError::from(MaterialError::Encoding))?;
354 let (code, name) = text.split_once('\0').ok_or(MaterialError::Encoding)?;
355 Ok((SecretText::new(code.to_owned()), name.to_owned()))
356 })
357 .transpose()
358 }
359
360 fn write_code_record(
361 &self,
362 entry: &str,
363 code: &SecretText,
364 device_name: &str,
365 ) -> Result<(), KeyError> {
366 if code.as_str().contains('\0') {
367 return Err(MaterialError::Encoding.into());
368 }
369 let record = SecretText::new(format!("{}\0{device_name}", code.as_str()));
370 self.write(entry, record.as_str().as_bytes())
371 }
372
373 pub fn delete_device_entries(&self) -> Result<(), KeyError> {
380 let _guard = lock_unpoisoned(&self.keychain.host_secrets);
381 if let Some(bytes) = self.read(HOST_SECRET_NAMES_ENTRY)? {
382 for name in decode_host_secret_names(bytes.as_bytes())? {
383 self.remove(&host_secret_entry(name))?;
384 }
385 }
386 for name in [
387 HOST_SECRET_NAMES_ENTRY,
388 StoreKeyring::ENTRY,
389 MemberIdentity::ENTRY,
390 DEVICE_ID_ENTRY,
391 CREDENTIALS_ENTRY,
392 SETUP_CREDENTIALS_ENTRY,
393 REPLACING_KEY_ENTRY,
394 INVITE_CODE_ENTRY,
395 RESTORE_RECORD_ENTRY,
396 REPLACEMENT_RECORD_ENTRY,
397 ] {
398 self.remove(name)?;
399 }
400 Ok(())
401 }
402
403 pub fn set_host_secret(&self, name: &str, value: &str) -> Result<(), KeyError> {
409 self.change_host_secret(name, Some(value))
410 }
411
412 #[expect(
414 clippy::map_err_ignore,
415 reason = "a host secret fails only by not being UTF-8, which Encoding names"
416 )]
417 pub fn host_secret(&self, name: &str) -> Result<Option<String>, KeyError> {
418 self.read(&host_secret_entry(name))?
419 .map(|bytes| {
420 std::str::from_utf8(bytes.as_bytes())
421 .map(str::to_owned)
422 .map_err(|_| MaterialError::Encoding.into())
423 })
424 .transpose()
425 }
426
427 pub fn delete_host_secret(&self, name: &str) -> Result<(), KeyError> {
431 self.change_host_secret(name, None)
432 }
433
434 fn change_host_secret(&self, name: &str, value: Option<&str>) -> Result<(), KeyError> {
435 let _guard = lock_unpoisoned(&self.keychain.host_secrets);
436 let bytes = self.read(HOST_SECRET_NAMES_ENTRY)?;
437 let mut names = match &bytes {
438 Some(bytes) => decode_host_secret_names(bytes.as_bytes())?,
439 None => BTreeSet::new(),
440 };
441 match value {
442 Some(value) => {
443 names.insert(name);
444 self.write_host_secret_names(&names)?;
445 self.write(&host_secret_entry(name), value.as_bytes())
446 }
447 None => {
448 self.remove(&host_secret_entry(name))?;
449 names.remove(name);
450 self.write_host_secret_names(&names)
451 }
452 }
453 }
454
455 fn write_host_secret_names(&self, names: &BTreeSet<&str>) -> Result<(), KeyError> {
456 if names.is_empty() {
457 self.remove(HOST_SECRET_NAMES_ENTRY)
458 } else {
459 self.write(HOST_SECRET_NAMES_ENTRY, encode_host_secret_names(names)?.as_bytes())
460 }
461 }
462}
463
464impl std::fmt::Debug for StoreKeychain {
465 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
466 f.write_str("StoreKeychain([REDACTED])")
467 }
468}
469
470pub(super) fn account(name: &str, store: StoreId) -> String {
473 format!("{name}:{store}")
474}
475
476fn host_secret_entry(name: &str) -> String {
479 format!("host-secret-{}", hex::encode(name.as_bytes()))
480}
481
482const HOST_SECRET_NAMES_PREFIX: &[u8] = b"CVHN\x01";
485
486#[expect(
487 clippy::map_err_ignore,
488 reason = "a name list fails only by an invalid length or name: Encoding"
489)]
490fn decode_host_secret_names(mut bytes: &[u8]) -> Result<BTreeSet<&str>, MaterialError> {
491 wire::prefix(&mut bytes, HOST_SECRET_NAMES_PREFIX)?;
492 let mut names = BTreeSet::new();
493 while !bytes.is_empty() {
494 let len =
495 usize::try_from(wire::number(&mut bytes)?).map_err(|_| MaterialError::Encoding)?;
496 let name = std::str::from_utf8(wire::take(&mut bytes, len)?)
497 .map_err(|_| MaterialError::Encoding)?;
498 if !names.insert(name) {
499 return Err(MaterialError::Encoding);
500 }
501 }
502 Ok(names)
503}
504
505fn encode_host_secret_names(names: &BTreeSet<&str>) -> Result<SecretBytes, MaterialError> {
506 let len = names
507 .iter()
508 .try_fold(HOST_SECRET_NAMES_PREFIX.len(), |len, name| {
509 len.checked_add(8)?.checked_add(name.len())
510 })
511 .ok_or(MaterialError::Encoding)?;
512 let mut bytes = SecretBytes::new(Vec::with_capacity(len));
513 bytes.0.extend_from_slice(HOST_SECRET_NAMES_PREFIX);
514 for name in names {
515 bytes.0.extend_from_slice(&(name.len() as u64).to_le_bytes());
516 bytes.0.extend_from_slice(name.as_bytes());
517 }
518 Ok(bytes)
519}
520
521pub struct KeyringCustody<T> {
523 keychain: Arc<StoreKeychain>,
524 material: PhantomData<fn() -> T>,
525}
526
527impl<T> KeyringCustody<T> {
528 pub fn new(keychain: Arc<StoreKeychain>) -> Self {
530 Self { keychain, material: PhantomData }
531 }
532 pub(crate) fn read(&self, name: &str) -> Result<Option<SecretBytes>, KeyError> {
533 self.keychain.read(name)
534 }
535 pub(crate) fn write(&self, name: &str, bytes: &[u8]) -> Result<(), KeyError> {
536 self.keychain.write(name, bytes)
537 }
538 pub(crate) fn remove(&self, name: &str) -> Result<(), KeyError> {
539 self.keychain.remove(name)
540 }
541}
542
543impl<T> std::fmt::Debug for KeyringCustody<T> {
544 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
545 f.write_str("KeyringCustody([REDACTED])")
546 }
547}
548
549fn by_store(codes: Vec<(StoreId, SecretBytes)>) -> Vec<(StoreId, PersonRestoreCode)> {
552 let mut stores = std::collections::BTreeMap::new();
553 for (store, code) in codes {
554 stores
555 .entry(store)
556 .and_modify(|found| {
557 tracing::warn!(%store, "more than one keychain group holds a restore code");
558 *found = PersonRestoreCode::Ambiguous;
559 })
560 .or_insert(PersonRestoreCode::Saved(code));
561 }
562 stores.into_iter().collect()
563}
564
565#[cfg(test)]
566#[path = "keychain_tests.rs"]
567mod tests;
568
569#[cfg(any(test, feature = "test-utils"))]
570#[path = "keychain_test_utils.rs"]
571mod test_utils;