Skip to main content

coven_sync/
saved_code.rs

1//! The person's restore code as iCloud Keychain or Block Store saves it, with
2//! the store's name as their devices last saw it (§13.1).
3
4use crate::SyncError;
5use coven_crypto::{
6    custody::{KeyError, PersonEntries, PersonRestoreCode, RestoreCodeChange},
7    MaterialError, MemberIdentity, SecretBytes,
8};
9use coven_database::StoreState;
10use coven_format::codes::{KeychainRestoreCode, RestoreCode};
11use coven_foundation::id_source::StoreId;
12use coven_merge::Slot;
13use coven_storage::{RestoreStorage, StorageConfig};
14
15/// The store's name as this device has it, and the slot that set it: 1 for
16/// creation, otherwise the rename's. Of two names, the later slot's is newer.
17#[derive(Clone, Debug, PartialEq, Eq)]
18pub struct SavedName {
19    /// The store's name.
20    pub name: String,
21    /// The slot that set it.
22    pub named_at: Slot,
23}
24
25impl SavedName {
26    /// The name in `store`'s identity, as its applied state holds it.
27    pub fn of(store: &coven_database::StoreIdentity) -> Self {
28        Self { name: store.name.clone(), named_at: store.named_at }
29    }
30}
31
32/// Save `code`, with this device's name or the entry's when that is newer
33/// (§13.1). The code is this device's to save; an entry it can't read, or
34/// one for another store, is replaced.
35pub(crate) fn save(
36    person: &PersonEntries,
37    code: &RestoreCode,
38    name: &SavedName,
39) -> Result<(), SyncError> {
40    person.change_restore_code(|saved| {
41        let newer = match saved {
42            Some(PersonRestoreCode::Saved(bytes)) => {
43                KeychainRestoreCode::from_bytes(bytes.as_bytes()).ok()
44            }
45            None | Some(PersonRestoreCode::Ambiguous) => None,
46        }
47        .filter(|saved| saved.code.store == code.store && saved.named_at > name.named_at);
48        let (label, named_at) = match &newer {
49            Some(saved) => (saved.name.as_str(), saved.named_at),
50            None => (name.name.as_str(), name.named_at),
51        };
52        Ok::<_, SyncError>(RestoreCodeChange::Write(encode(person, label, named_at, code)?))
53    })
54}
55
56/// Rewrite the entry's name when `name` is newer, keeping the code the entry
57/// holds: only setup, joining, restoring, resetting and member-key replacement
58/// save a code over a readable one (§13.1). An absent entry stays absent, so a
59/// forgotten entry stays forgotten.
60///
61/// This device has the store open, so it holds the store's current code and
62/// name. An entry it can't read (another format, another store's code, or a
63/// copy in more than one keychain group) is replaced with `current`'s code
64/// under `name`, as one copy. When `current` gives no code, because this
65/// device's keys were replaced, the store has no location, or the member was
66/// removed, the entry stays as it is. A keychain failure fails the call.
67pub fn follow_name<E: From<KeyError>>(
68    person: &PersonEntries,
69    name: &SavedName,
70    current: impl FnOnce() -> Result<Option<RestoreCode>, E>,
71) -> Result<(), E> {
72    if !person.supported() {
73        return Ok(());
74    }
75    person.change_restore_code(|saved| {
76        let read = match saved {
77            None => return Ok(RestoreCodeChange::Keep),
78            Some(PersonRestoreCode::Saved(bytes)) => {
79                read_saved_code(person.store(), bytes.as_bytes())
80            }
81            Some(PersonRestoreCode::Ambiguous) => Err(UnreadableSavedCode::Ambiguous),
82        };
83        let saved = match read {
84            Ok((saved, _)) => saved,
85            Err(reason) => {
86                let Some(code) = current()? else {
87                    tracing::warn!(
88                        ?reason,
89                        "keeping an unreadable saved restore code: this device holds no current code"
90                    );
91                    return Ok(RestoreCodeChange::Keep);
92                };
93                tracing::warn!(?reason, "replacing an unreadable saved restore code");
94                let bytes = encode(person, &name.name, name.named_at, &code)?;
95                return Ok(RestoreCodeChange::Write(bytes));
96            }
97        };
98        if saved.named_at >= name.named_at {
99            return Ok(RestoreCodeChange::Keep);
100        }
101        tracing::debug!(
102            from = saved.named_at.get(),
103            to = name.named_at.get(),
104            "rewriting the saved restore code's store name"
105        );
106        let bytes = encode(person, &name.name, name.named_at, &saved.code)?;
107        Ok(RestoreCodeChange::Write(bytes))
108    })
109}
110
111/// The code a device with the store open saves in place of an unreadable
112/// entry (§13.1), or `None` when it holds no current code: the store has no
113/// location or identity, custody holds no member pair, the member was
114/// removed, or another device replaced the keys this device holds.
115pub fn replacement_code(
116    state: &StoreState,
117    identity: Option<&MemberIdentity>,
118    location: Option<&StorageConfig>,
119) -> Result<Option<RestoreCode>, SyncError> {
120    let (Some(identity), Some(location)) = (identity, location) else {
121        return Ok(None);
122    };
123    let storage = RestoreStorage::location_only(location.clone());
124    match crate::restore_codes::member_code(state, identity, &storage) {
125        Ok(code) => Ok(Some(code)),
126        Err(SyncError::NoStorage | SyncError::Removed | SyncError::MemberKeysReplaced { .. }) => {
127            Ok(None)
128        }
129        Err(error) => Err(error),
130    }
131}
132
133/// Why a saved entry can't be restored from. Listing shows it, with its
134/// store id, so the person can forget it (§13.1).
135#[derive(Clone, Copy, Debug, PartialEq, Eq)]
136pub enum UnreadableSavedCode {
137    /// Not a saved restore code coven reads: saved in another format, such as
138    /// a bare restore code from before entries carried the store's name, or
139    /// damaged. Includes a code whose storage part doesn't decode or
140    /// carries an S3 key.
141    Format,
142    /// The code of another store than the one its account names.
143    OtherStore,
144    /// More than one of the app's keychain groups holds an entry for the
145    /// store, so reading it by store id could give either.
146    Ambiguous,
147}
148
149/// Read a saved entry for `store`, and its code's location.
150pub fn read_saved_code(
151    store: StoreId,
152    bytes: &[u8],
153) -> Result<(KeychainRestoreCode, StorageConfig), UnreadableSavedCode> {
154    let saved = KeychainRestoreCode::from_bytes(bytes).map_err(|error| {
155        tracing::warn!(%store, %error, "saved restore code does not decode");
156        UnreadableSavedCode::Format
157    })?;
158    if saved.code.store != store {
159        tracing::warn!(%store, other = %saved.code.store, "saved restore code names another store");
160        return Err(UnreadableSavedCode::OtherStore);
161    }
162    let storage = RestoreStorage::decode(saved.code.storage.as_bytes()).map_err(|error| {
163        tracing::warn!(%store, %error, "saved restore code's location does not decode");
164        UnreadableSavedCode::Format
165    })?;
166    // The S3 key is its own entry beside the code, which carries none.
167    if storage.s3_key.is_some() {
168        tracing::warn!(%store, "saved restore code carries an S3 key");
169        return Err(UnreadableSavedCode::Format);
170    }
171    Ok((saved, storage.location))
172}
173
174/// The saved entry's bytes for `code` under `name`. Where the keychain limits
175/// an entry, as Block Store does, a name too long to fit is saved shortened,
176/// cut at a character boundary: the name only labels the entry, and restore
177/// finds the store by its id (§13.1). A code that doesn't fit even with a
178/// one-character name fails with `SavedEntryTooLarge`.
179#[expect(
180    clippy::map_err_ignore,
181    reason = "the encoder fails only on a name past its bound or on memory: Encoding"
182)]
183fn encode(
184    person: &PersonEntries,
185    name: &str,
186    named_at: Slot,
187    code: &RestoreCode,
188) -> Result<SecretBytes, KeyError> {
189    let encode = |name: &str| {
190        KeychainRestoreCode::encode(name, named_at, code)
191            .map_err(|_| KeyError::from(MaterialError::Encoding))
192    };
193    let whole = encode(name)?;
194    let Some(limit) = person.restore_code_limit() else {
195        return Ok(SecretBytes::new(whole.to_vec()));
196    };
197    if whole.len() <= limit {
198        return Ok(SecretBytes::new(whole.to_vec()));
199    }
200    let room = limit.checked_sub(whole.len() - name.len()).ok_or(KeyError::SavedEntryTooLarge)?;
201    let shortened = &name[..name.floor_char_boundary(room)];
202    if shortened.is_empty() {
203        return Err(KeyError::SavedEntryTooLarge);
204    }
205    tracing::info!(
206        from = name.len(),
207        to = shortened.len(),
208        "saving the store's name shortened to fit the entry"
209    );
210    Ok(SecretBytes::new(encode(shortened)?.to_vec()))
211}
212
213#[cfg(test)]
214#[path = "saved_code_tests.rs"]
215mod tests;