Skip to main content

coven_crypto/custody/
keychain.rs

1//! One registered service, with independent entries for each store (E1).
2
3use 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    /// This device's own entries, which never leave it.
33    DeviceOnly,
34    /// The person's entries, which reach their next devices: iCloud Keychain
35    /// carries them to every device on the person's Apple account, and Block
36    /// Store gives an Android phone's to a phone set up from it.
37    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    /// Every keychain group's copy of one person entry.
57    fn person_copies(&self, service: &str, account: &str) -> Result<Vec<SecretBytes>, KeyError>;
58    /// Leave exactly one copy of a person entry, holding `bytes`: write the
59    /// first group's copy, or a new entry when there is none, then delete the
60    /// other groups' copies. A failure part way leaves more than one copy.
61    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    /// The most bytes a person entry's value may take, or `None` when the
69    /// backend sets no limit a restore code could reach.
70    fn person_value_limit(&self, service: &str, account: &str) -> Option<usize>;
71    /// Whether each write of a person entry chooses again whether it goes to
72    /// a cloud backup, as Block Store's does.
73    fn person_backup_follows_writes(&self) -> bool;
74}
75
76/// The OS keychain capability, constructed only at a composition root.
77/// It never exposes native entries or its underlying credential store.
78pub struct Keychain {
79    name: String,
80    backend: Box<dyn KeychainBackend>,
81    host_secrets: Mutex<()>,
82}
83
84impl Keychain {
85    /// The OS keychain, every key and secret kept under the service `name`
86    /// (E1). Call only at a composition root, then inject it; no key is read
87    /// here.
88    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    /// List this service's saved restore codes without knowing any store ids,
99    /// one per store, ordered by store id (§13.1). Apple searches iCloud
100    /// Keychain by service; account prefixes are filtered locally because its
101    /// keyring API supports exact matches only. Results are limited to the
102    /// app's accessible keychain groups; a store more than one of them holds
103    /// an entry for is `PersonRestoreCode::Ambiguous`. Block Store gives every
104    /// entry the app keeps, and only this service's restore codes are kept. Any read failure fails
105    /// the entire call; a missing or unreadable code is never silently omitted.
106    /// Keychains with neither iCloud Keychain nor Block Store return `KeyError::Unsupported`.
107    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    /// Whether this backend keeps the person's entries: iCloud Keychain on
112    /// Apple, Block Store on an Android phone with Google Play services, and
113    /// the memory backend on every test platform.
114    pub(super) fn supports_person_entries(&self) -> bool {
115        self.backend.supports_person_entries()
116    }
117
118    /// Whether each write of a person entry chooses again whether it goes to
119    /// a cloud backup (§13.1).
120    pub(super) fn person_backup_follows_writes(&self) -> bool {
121        self.backend.person_backup_follows_writes()
122    }
123
124    /// The most bytes the person entry `account` may hold (§13.1).
125    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
165/// One store's entries kept on this device only: its device id, store and
166/// member keys, provider credentials, the invite code of a join under way,
167/// the record of a restore or a replacement under way and the app's host
168/// secrets (§12.4, §13.1, §13.2, §19.3). They
169/// never leave the device, so deleting them reaches no other device of the
170/// person. Every read, write and deletion here uses the device-only scope; the
171/// person's entries belong to [`PersonEntries`](super::PersonEntries).
172pub struct StoreKeychain {
173    keychain: Arc<Keychain>,
174    store: StoreId,
175}
176
177impl StoreKeychain {
178    /// Bind the injected keychain to one store without reading a key (E1).
179    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    /// The installation id, in a device-only entry a backup doesn't carry to
197    /// another device (§19.3). Reading it does not unlock any member or store
198    /// key.
199    #[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    /// Keep the installation id in this device's non-restored keychain scope.
211    pub fn set_device_id(&self, device: DeviceId) -> Result<(), KeyError> {
212        self.write(DEVICE_ID_ENTRY, &device.0.to_be_bytes())
213    }
214
215    /// Remove an unpublished installation's entry after creation failed.
216    pub fn delete_device_id(&self) -> Result<(), KeyError> {
217        self.remove(DEVICE_ID_ENTRY)
218    }
219
220    /// Provider-encoded credentials. Public storage settings never contain them.
221    pub fn storage_credentials(&self) -> Result<Option<SecretBytes>, KeyError> {
222        self.read(CREDENTIALS_ENTRY)
223    }
224
225    /// Commit provider-encoded credentials in this device's keychain scope.
226    pub fn set_storage_credentials(&self, bytes: &SecretBytes) -> Result<(), KeyError> {
227        self.write(CREDENTIALS_ENTRY, bytes.as_bytes())
228    }
229
230    /// Remove this device's provider credentials; an absent entry succeeds.
231    pub fn delete_storage_credentials(&self) -> Result<(), KeyError> {
232        self.remove(CREDENTIALS_ENTRY)
233    }
234
235    /// The credentials a storage setup under way connects with, kept apart
236    /// from the committed ones until it finishes, so undoing it leaves those
237    /// as they were (§11.6, §20.8).
238    pub fn setup_credentials(&self) -> Result<Option<SecretBytes>, KeyError> {
239        self.read(SETUP_CREDENTIALS_ENTRY)
240    }
241
242    /// Keep the credentials of a storage setup about to write to storage.
243    pub fn set_setup_credentials(&self, bytes: &SecretBytes) -> Result<(), KeyError> {
244        self.write(SETUP_CREDENTIALS_ENTRY, bytes.as_bytes())
245    }
246
247    /// Remove a finished or undone setup's credentials; an absent entry
248    /// succeeds.
249    pub fn delete_setup_credentials(&self) -> Result<(), KeyError> {
250        self.remove(SETUP_CREDENTIALS_ENTRY)
251    }
252
253    /// The S3 key replacing this member's own, kept apart from the
254    /// committed credentials until history records it as the member's key,
255    /// so custody never connects with a key history doesn't name (§14.3).
256    pub fn replacing_access_key(&self) -> Result<Option<SecretBytes>, KeyError> {
257        self.read(REPLACING_KEY_ENTRY)
258    }
259
260    /// Keep a replacement S3 key apart until history records it.
261    pub fn set_replacing_access_key(&self, bytes: &SecretBytes) -> Result<(), KeyError> {
262        self.write(REPLACING_KEY_ENTRY, bytes.as_bytes())
263    }
264
265    /// Remove a replacement key once custody connects with it or the
266    /// replacement ended unrecorded; an absent entry succeeds.
267    pub fn delete_replacing_access_key(&self) -> Result<(), KeyError> {
268        self.remove(REPLACING_KEY_ENTRY)
269    }
270
271    /// The invite code of a join under way for this store on this device:
272    /// its saved request is encrypted with the code's secret, so resuming
273    /// needs the code (§13.2). It is never synced.
274    #[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    /// Keep a join's invite code until the join ends.
289    pub fn set_invite_code(&self, code: &SecretText) -> Result<(), KeyError> {
290        self.write(INVITE_CODE_ENTRY, code.as_str().as_bytes())
291    }
292
293    /// Remove a join's invite code once the join has ended; an absent entry
294    /// succeeds.
295    pub fn delete_invite_code(&self) -> Result<(), KeyError> {
296        self.remove(INVITE_CODE_ENTRY)
297    }
298
299    /// The record of a restore under way for this store on this device: the
300    /// restore code it loads with and the name it registers the device
301    /// under. A restore past its registration finishes from it, so the app
302    /// can resume it without the code (§13.1). It is never synced.
303    pub fn restore_record(&self) -> Result<Option<(SecretText, String)>, KeyError> {
304        self.read_code_record(RESTORE_RECORD_ENTRY)
305    }
306
307    /// Keep a restore's record from before it registers the device until the
308    /// restore ends: the code, which holds no NUL, then a NUL, then the
309    /// device's name.
310    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    /// Remove a restore's record once the restore has ended; an absent entry
315    /// succeeds.
316    pub fn delete_restore_record(&self) -> Result<(), KeyError> {
317        self.remove(RESTORE_RECORD_ENTRY)
318    }
319
320    /// What a replacement of this device's installation under way keeps to
321    /// finish (§19.3): the restore code it loads with and the name it
322    /// registers the fresh device under. A replacement past its record
323    /// resumes from it with nothing from the app. It is never synced.
324    pub fn replacement_record(&self) -> Result<Option<(SecretText, String)>, KeyError> {
325        self.read_code_record(REPLACEMENT_RECORD_ENTRY)
326    }
327
328    /// Keep a replacement's code and device name from before it registers the
329    /// fresh device until it is published or ends, as a restore's record is
330    /// kept.
331    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    /// Remove a replacement's record once it has been published or has ended;
340    /// an absent entry succeeds.
341    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    /// Remove this device's entries for the store, including all recorded host
374    /// secrets, without opening the database or decoding the values. Delete
375    /// listed secrets before the list, then coven's other entries. The person's
376    /// restore code and S3 key stay: they serve the person's next devices
377    /// (§13.1). The caller holds the store's deletion lock;
378    /// repeating after a failure is safe, including absent listed entries.
379    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    /// Records the name in a device-only keychain list before writing the value
404    /// to its own entry. Arbitrary names are encoded into native account names;
405    /// each value retains the platform's per-entry size limit. A failed write
406    /// may leave an extra name, so deletion can always find every saved secret.
407    /// The caller holds the store's writer lock; this capability serializes updates.
408    pub fn set_host_secret(&self, name: &str, value: &str) -> Result<(), KeyError> {
409        self.change_host_secret(name, Some(value))
410    }
411
412    /// The app secret, or `None` if it was never set (E11).
413    #[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    /// Removes the secret before its recorded name (E11); absent entries succeed.
428    /// Failure can leave an extra name; retrying is safe. The caller holds the
429    /// store's writer lock.
430    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
470/// The account of one store's entry `name`, in device-only and person
471/// entries alike: `<name>:<store>`.
472pub(super) fn account(name: &str, store: StoreId) -> String {
473    format!("{name}:{store}")
474}
475
476// Hex keeps empty, NUL-containing and Unicode names distinct and separates every
477// app name from coven's own entries, even on case-insensitive native keychains.
478fn host_secret_entry(name: &str) -> String {
479    format!("host-secret-{}", hex::encode(name.as_bytes()))
480}
481
482// Local custody framing: a versioned prefix and length-prefixed UTF-8 names.
483// Values live in independent native entries and have no coven framing overhead.
484const 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
521/// OS keychain custody for `StoreKeyring` or `MemberIdentity`, built at a composition root.
522pub struct KeyringCustody<T> {
523    keychain: Arc<StoreKeychain>,
524    material: PhantomData<fn() -> T>,
525}
526
527impl<T> KeyringCustody<T> {
528    /// Retain the injected store keychain; construction reads no key (E1).
529    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
549/// Group the restore codes a search found by store, ordered by store id. A
550/// store found more than once is ambiguous: no copy is the store's code.
551fn 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;