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;