Skip to main content

coven_crypto/custody/
person_entries.rs

1//! The person's entries for one store, which reach their next devices:
2//! iCloud Keychain carries them to all their Apple devices, and Block Store
3//! gives an Android phone's to a phone set up from it (§13.1).
4
5use super::keychain::{EntryScope, Keychain};
6use super::KeyError;
7use crate::SecretBytes;
8use coven_foundation::id_source::StoreId;
9use coven_foundation::locks::lock_unpoisoned;
10use std::sync::{Arc, Mutex};
11
12pub(super) const RESTORE_CODE_ENTRY: &str = "restore-code";
13pub(super) const S3_CREDENTIALS_ENTRY: &str = "s3-credentials";
14
15/// Held across every change of a person entry in this process, so a change
16/// that reads the restore code and writes it back sees no other change in
17/// between. The native keychain is one per process, and so is every handle,
18/// discovery call or deletion that reaches the same entries.
19static CHANGES: Mutex<()> = Mutex::new(());
20
21fn changes() -> std::sync::MutexGuard<'static, ()> {
22    lock_unpoisoned(&CHANGES)
23}
24
25/// What [`PersonEntries::change_restore_code`] does with the entry it read.
26pub enum RestoreCodeChange {
27    /// Leave the entry as it is, or absent.
28    Keep,
29    /// Replace the entry with these bytes.
30    Write(SecretBytes),
31    /// Delete the entry.
32    Delete,
33}
34
35/// One store's restore code and the S3 key saved beside it, in iCloud
36/// Keychain or Block Store. They belong to the person, not to this device:
37/// every device on the person's Apple account sees them, and a phone set up
38/// from this one receives them. So removing the store from one device keeps
39/// them, and only the person leaving the store deletes them (§13.1, §14.1).
40/// Every read, write and deletion here uses the person scope; this
41/// device's own entries belong to [`StoreKeychain`](super::StoreKeychain).
42pub struct PersonEntries {
43    keychain: Arc<Keychain>,
44    store: StoreId,
45    /// Set once the person leaves; every later write is refused, so nothing
46    /// sharing this keychain puts an entry back (§14.1).
47    left: Mutex<bool>,
48}
49
50impl PersonEntries {
51    /// Bind the injected keychain to one store without reading an entry (E1).
52    pub fn new(keychain: Arc<Keychain>, store: StoreId) -> Self {
53        Self { keychain, store, left: Mutex::new(false) }
54    }
55
56    /// The store whose entries these are.
57    pub fn store(&self) -> StoreId {
58        self.store
59    }
60
61    fn account(&self, name: &str) -> String {
62        super::keychain::account(name, self.store)
63    }
64    fn read(&self, name: &str) -> Result<Option<SecretBytes>, KeyError> {
65        self.keychain.read(EntryScope::Person, &self.account(name))
66    }
67    fn write(&self, name: &str, bytes: &[u8]) -> Result<(), KeyError> {
68        // Held through the write, so leaving can't run in between.
69        let left = lock_unpoisoned(&self.left);
70        if *left {
71            return Err(KeyError::Left);
72        }
73        let _changes = changes();
74        self.keychain.write(EntryScope::Person, &self.account(name), bytes)
75    }
76    fn remove(&self, name: &str) -> Result<(), KeyError> {
77        let _changes = changes();
78        self.keychain.delete(EntryScope::Person, &self.account(name))
79    }
80
81    /// Whether this keychain keeps person entries: Apple keychains, Block
82    /// Store on Android, and the memory backend on every test platform.
83    pub fn supported(&self) -> bool {
84        self.keychain.supports_person_entries()
85    }
86
87    /// Store this store's entries again, unchanged, where each write chooses
88    /// whether the entry goes to the cloud backup: Block Store copies it there
89    /// only while the copy is end-to-end encrypted, and a phone may gain a
90    /// screen lock after the entry was last written (§13.1). Opening the store
91    /// calls it. Absent entries stay absent, and no other change of a person
92    /// entry in this process runs in between. Elsewhere it does nothing.
93    pub fn store_again(&self) -> Result<(), KeyError> {
94        if !self.supported() || !self.keychain.person_backup_follows_writes() {
95            return Ok(());
96        }
97        let left = lock_unpoisoned(&self.left);
98        if *left {
99            tracing::debug!(store = %self.store, "the person left: no entries to store again");
100            return Ok(());
101        }
102        let _changes = changes();
103        for name in [RESTORE_CODE_ENTRY, S3_CREDENTIALS_ENTRY] {
104            let account = self.account(name);
105            if let Some(bytes) = self.keychain.read(EntryScope::Person, &account)? {
106                self.keychain.write(EntryScope::Person, &account, bytes.as_bytes())?;
107            }
108        }
109        Ok(())
110    }
111
112    /// The most bytes the saved restore code may take, or `None` when the
113    /// keychain sets no limit it could reach. Block Store holds 1,024 bytes
114    /// for an entry, its name included (§13.1).
115    pub fn restore_code_limit(&self) -> Option<usize> {
116        self.keychain.person_value_limit(&self.account(RESTORE_CODE_ENTRY))
117    }
118
119    /// Keep exactly these bytes as the saved restore code (§13.1), whatever
120    /// the entry held. The caller encodes the code with the store's name; the
121    /// crypto crate does not define that format.
122    /// Keychains with neither iCloud Keychain nor Block Store return `KeyError::Unsupported`.
123    pub fn set_restore_code(&self, code: &SecretBytes) -> Result<(), KeyError> {
124        self.write(RESTORE_CODE_ENTRY, code.as_bytes())
125    }
126
127    /// Read the saved restore code's bytes, or `None` if absent (§13.1).
128    /// Keychains with neither iCloud Keychain nor Block Store return `KeyError::Unsupported`.
129    pub fn restore_code(&self) -> Result<Option<SecretBytes>, KeyError> {
130        self.read(RESTORE_CODE_ENTRY)
131    }
132
133    /// Read the saved restore code and keep, replace or delete it as `change`
134    /// decides from what it read, with no other change of a person entry in
135    /// this process in between. A name rewrite keeps the code it read this
136    /// way, and a code saved meanwhile is never overwritten by an older one
137    /// (§13.1). `change` sees every keychain group's copy: more than one is
138    /// `PersonRestoreCode::Ambiguous`. A write leaves exactly one copy: it
139    /// writes the first group's and then deletes the others, so a failure
140    /// part way leaves the entry ambiguous, never absent, and repeating the
141    /// change finishes. A write after leaving returns `KeyError::Left`.
142    /// Keychains with neither iCloud Keychain nor Block Store return `KeyError::Unsupported`.
143    pub fn change_restore_code<E: From<KeyError>>(
144        &self,
145        change: impl FnOnce(Option<PersonRestoreCode>) -> Result<RestoreCodeChange, E>,
146    ) -> Result<(), E> {
147        let left = lock_unpoisoned(&self.left);
148        let _changes = changes();
149        let account = self.account(RESTORE_CODE_ENTRY);
150        let mut copies = self.keychain.person_copies(&account)?;
151        let saved = match copies.len() {
152            0 => None,
153            1 => copies.pop().map(PersonRestoreCode::Saved),
154            _ => Some(PersonRestoreCode::Ambiguous),
155        };
156        match change(saved)? {
157            RestoreCodeChange::Keep => Ok(()),
158            RestoreCodeChange::Write(_) if *left => Err(KeyError::Left.into()),
159            RestoreCodeChange::Write(bytes) => {
160                Ok(self.keychain.replace_person_entry(&account, bytes.as_bytes())?)
161            }
162            RestoreCodeChange::Delete => Ok(self.keychain.delete(EntryScope::Person, &account)?),
163        }
164    }
165
166    /// Save a copy of the restore code in a second keychain group beside this
167    /// store's entry, so listing finds the store ambiguous. Requires an
168    /// in-memory keychain.
169    #[cfg(any(test, feature = "test-utils"))]
170    pub fn set_restore_code_in_another_group(&self, code: &SecretBytes) {
171        self.keychain.write_in_another_group(&self.account(RESTORE_CODE_ENTRY), code.as_bytes());
172    }
173
174    /// Delete the saved restore code; succeeds if absent (§13.1).
175    /// Keychains with neither iCloud Keychain nor Block Store return `KeyError::Unsupported`.
176    pub fn delete_restore_code(&self) -> Result<(), KeyError> {
177        self.remove(RESTORE_CODE_ENTRY)
178    }
179
180    /// Keep a separately encoded S3 key beside the code.
181    /// Storage owns the encoding; this entry never contains the restore code.
182    /// Keychains with neither iCloud Keychain nor Block Store return `KeyError::Unsupported`.
183    pub fn set_s3_credentials(&self, credentials: &SecretBytes) -> Result<(), KeyError> {
184        self.write(S3_CREDENTIALS_ENTRY, credentials.as_bytes())
185    }
186
187    /// Read the separately held S3 key, or `None` when the person must enter it.
188    pub fn s3_credentials(&self) -> Result<Option<SecretBytes>, KeyError> {
189        self.read(S3_CREDENTIALS_ENTRY)
190    }
191
192    /// Remove the S3 key saved beside the code; absent entries succeed.
193    pub fn delete_s3_credentials(&self) -> Result<(), KeyError> {
194        self.remove(S3_CREDENTIALS_ENTRY)
195    }
196
197    /// Delete the store's S3 key, then its restore code, from every one of
198    /// the person's devices, without leaving: the store, its storage and every
199    /// device's copy stay, and a later saved code is kept (§13.1). The code
200    /// goes last, because it is what `saved_restore_codes` lists: a failure
201    /// part way leaves the store listed, never an unlisted key. Absent entries
202    /// succeed, so repeating after a failure is safe.
203    /// Keychains with neither iCloud Keychain nor Block Store return `KeyError::Unsupported`.
204    pub fn forget(&self) -> Result<(), KeyError> {
205        self.delete_s3_credentials()?;
206        self.delete_restore_code()
207    }
208
209    /// The person left the store: forget its entries (§14.1). A keychain
210    /// without person entries holds neither, and nothing is deleted. From the
211    /// first call on, writes return `KeyError::Left`, even if this deletion
212    /// fails part way.
213    pub fn delete_entries(&self) -> Result<(), KeyError> {
214        let mut left = lock_unpoisoned(&self.left);
215        *left = true;
216        if !self.supported() {
217            return Ok(());
218        }
219        self.forget()
220    }
221}
222
223impl std::fmt::Debug for PersonEntries {
224    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
225        f.write_str("PersonEntries([REDACTED])")
226    }
227}
228
229/// One store's saved restore-code entry, as listing found it (§13.1).
230pub enum PersonRestoreCode {
231    /// The entry's bytes.
232    Saved(SecretBytes),
233    /// More than one of the app's keychain groups holds an entry for the
234    /// store. Reading by store id could give either; deleting by store id
235    /// removes them all.
236    Ambiguous,
237}
238
239impl std::fmt::Debug for PersonRestoreCode {
240    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
241        match self {
242            Self::Saved(_) => f.write_str("Saved([REDACTED])"),
243            Self::Ambiguous => f.write_str("Ambiguous"),
244        }
245    }
246}
247
248/// The store named by a saved restore-code account; `None` for other entries.
249/// The id must be spelled as [`PersonEntries`] writes it: an account it never
250/// writes is one no store id reads or deletes.
251#[cfg(any(
252    target_os = "macos",
253    target_os = "ios",
254    target_os = "android",
255    test,
256    feature = "test-utils"
257))]
258pub(crate) fn restore_code_store(account: &str) -> Result<Option<StoreId>, KeyError> {
259    match account.split_once(':') {
260        Some((RESTORE_CODE_ENTRY, id)) => coven_foundation::id_source::canonical_uuid(id)
261            .map(|id| Some(StoreId(id)))
262            .map_err(|_not_canonical| KeyError::RestoreCodeStoreId),
263        _ => {
264            tracing::debug!(account, "skipping person entry that is not a restore code");
265            Ok(None)
266        }
267    }
268}
269
270#[cfg(test)]
271#[path = "person_entries_tests.rs"]
272mod tests;