Skip to main content

aven_core/sync/client/keys/
seed.rs

1use super::*;
2use crate::sync::seed_claim::{SEED_STORAGE_BYTES, SeedAuthority};
3use std::path::Path;
4
5const SEED_MAGIC: &[u8; 8] = b"AVENSED1";
6const SEED_MARKER: &[u8; 8] = b"AVENSPN1";
7pub(super) const SEED_BYTES: usize = 8 + 32 + SEED_STORAGE_BYTES + 32;
8const SEED_MARKER_BYTES: usize = 8 + 32;
9pub(super) const SEED_ITEM: &str = "seed";
10/// Nonsecret record that detects loss of the seed item.
11const SEED_MARKER_RECORD: &str = "seed-authority";
12const SEED_ORIGIN_ITEM: &str = "seed-origin";
13const SEED_ORIGIN_BYTES: usize = 44 + crate::sync::client::MAX_SERVER_BYTES;
14const SEED_ORIGIN_MISSING: &str = "error sync-setup-server-missing hint=\"the saved setup server is missing; abandon setup with `aven sync reset --force` before starting again\"";
15
16impl ProtectedLocalKeyStore {
17    /// Pins the canonical setup origin before any request can disclose credentials.
18    /// Existing seed authority without this binding cannot acquire one on resume.
19    pub(crate) async fn bind_seed_origin(
20        &self,
21        database: &Database,
22        server: &str,
23    ) -> anyhow::Result<()> {
24        self.validate_database(database).await?;
25        let origin = crate::sync::client::server_origin(server)?;
26        let established = database.local_seed_genesis_commitment().await?.is_some()
27            || database.seed_source_pin().await?.is_some()
28            || database.seed_publication_intent_bytes().await?.is_some();
29        self.prepare()?;
30        let _guard = self.lock()?;
31        if let Some(saved) = self.read_owned(SEED_ORIGIN_ITEM, SEED_ORIGIN_BYTES, false)? {
32            anyhow::ensure!(
33                saved.as_slice() == origin.as_bytes(),
34                "error sync-setup-server-mismatch hint=\"resume with an invitation from the original server; if none is available, back up this database and restore it to a new path for a local-only copy\""
35            );
36            return Ok(());
37        }
38        anyhow::ensure!(
39            !established && !self.seed_authority_exists()?,
40            SEED_ORIGIN_MISSING
41        );
42        self.write_owned(SEED_ORIGIN_ITEM, SEED_ORIGIN_BYTES, origin.as_bytes())
43    }
44
45    /// The canonical origin and setup ID that started setup was bound to, so
46    /// it can resume without its invitation. Reads existing authority only;
47    /// nothing is created or replaced.
48    pub(crate) async fn retained_seed_setup(
49        &self,
50        database: &Database,
51    ) -> anyhow::Result<(String, [u8; 32])> {
52        self.validate_database(database).await?;
53        self.prepare()?;
54        let _guard = self.lock()?;
55        let package = self.load_required_locked()?;
56        let origin = self
57            .read_owned(SEED_ORIGIN_ITEM, SEED_ORIGIN_BYTES, false)?
58            .ok_or_else(|| anyhow::anyhow!(SEED_ORIGIN_MISSING))?;
59        let seed = self
60            .load_secret(SEED_ITEM, SEED_BYTES)?
61            .ok_or_else(|| error(ProtectedLocalKeyStoreErrorKind::MissingAuthority))?;
62        let seed = self.decode_seed(&seed, &package)?;
63        Ok((
64            String::from_utf8(origin.to_vec())?,
65            seed.genesis().setup_id(),
66        ))
67    }
68
69    /// Persists private seed keys, bearer and exact validated genesis before use.
70    /// Reuses package vault/generation/secret and refuses a changed setup binding.
71    /// The public DB pin detects lost protected authority, never authorizes replacement.
72    pub async fn prepare_seed_claim(
73        &self,
74        database: &Database,
75        setup_id: [u8; 32],
76    ) -> anyhow::Result<SeedAuthority> {
77        self.validate_database(database).await?;
78        anyhow::ensure!(
79            database
80                .enrollment_pin()
81                .await?
82                .is_none_or(|(_, _, role)| role == "inviter"),
83            "error enrollment-peer-cannot-become-seed"
84        );
85        let mut pin = database.local_seed_genesis_commitment().await?;
86        if let Some(commitment) = pin
87            && self.clear_unfenced_orphan_pin(database, commitment).await?
88        {
89            pin = None;
90        }
91        let package = if pin.is_some()
92            || database
93                .has_local_shared_state_package_never_dispatched()
94                .await?
95        {
96            self.load_required()?
97        } else {
98            self.load_or_create()?
99        };
100        let seed = match (self.load_or_create_seed(&package, setup_id, pin), pin) {
101            // A refused claim stays unconfirmed, so its authority is kept for
102            // an exact retry. A different setup ID replaces the unfenced claim.
103            (Err(error), Some(commitment))
104                if error.kind() == ProtectedLocalKeyStoreErrorKind::SetupMismatch
105                    && database.local_seed_claim_refused().await? =>
106            {
107                self.rollback_seed_commitment(database, commitment).await?;
108                self.load_or_create_seed(&package, setup_id, None)?
109            }
110            (result, _) => result?,
111        };
112        database.pin_local_seed_genesis(seed.genesis()).await?;
113        Ok(seed)
114    }
115
116    /// Freezes a local package only under already persisted seed genesis context.
117    /// Incompatible frozen bytes fail unchanged; cancel/recapture explicitly.
118    /// This does not prove server claim admission or enable dispatch.
119    pub async fn package_seed_capture(
120        &self,
121        database: &Database,
122        blob_dir: &Path,
123        setup_id: [u8; 32],
124    ) -> anyhow::Result<EncryptedLocalSharedStatePackage> {
125        let seed = self.prepare_seed_claim(database, setup_id).await?;
126        self.package_local_capture(database, blob_dir, seed.genesis().commitment())
127            .await
128    }
129
130    /// [`Self::package_seed_capture`], keeping only the validation proof.
131    /// `capture` is the value the capture step just returned, if any; see
132    /// [`Database::package_and_validate`].
133    pub(crate) async fn package_seed_capture_validated(
134        &self,
135        database: &Database,
136        blob_dir: &Path,
137        setup_id: [u8; 32],
138        capture: Option<NeverDispatchedLocalSharedCapture>,
139    ) -> anyhow::Result<crate::sync::shared_state::validated::ValidatedSeed> {
140        let seed = self.prepare_seed_claim(database, setup_id).await?;
141        self.package_local_capture_validated(
142            database,
143            blob_dir,
144            seed.genesis().commitment(),
145            capture,
146        )
147        .await
148    }
149
150    /// Removes only the provisional seed authority for an exact, unfenced
151    /// claim. Package keys remain available for ordinary local data.
152    #[cfg(test)]
153    pub async fn rollback_seed_claim(
154        &self,
155        database: &Database,
156        seed: &SeedAuthority,
157    ) -> anyhow::Result<()> {
158        self.validate_database(database).await?;
159        self.rollback_seed_commitment(database, seed.genesis().commitment())
160            .await
161    }
162
163    async fn rollback_seed_commitment(
164        &self,
165        database: &Database,
166        commitment: [u8; 32],
167    ) -> anyhow::Result<()> {
168        anyhow::ensure!(
169            database.seed_source_pin().await?.is_none()
170                && database.seed_publication_intent_bytes().await?.is_none()
171                && !database
172                    .has_local_shared_state_package_never_dispatched()
173                    .await?,
174            "error seed-claim-rollback-fenced"
175        );
176        // Protected authority goes first so an interruption leaves an unfenced
177        // pin without authority, which `prepare_seed_claim` clears.
178        {
179            let _guard = self.lock()?;
180            let package = self.load_required_locked()?;
181            if let Some(bytes) = self.load_secret(SEED_ITEM, SEED_BYTES)? {
182                let stored = self.decode_seed(&bytes, &package)?;
183                if stored.genesis().commitment() != commitment {
184                    anyhow::bail!("error seed-claim-rollback-conflict");
185                }
186            }
187            self.delete_secret(SEED_ITEM)?;
188            self.remove_record(SEED_MARKER_RECORD)?;
189        }
190        database.rollback_local_seed_genesis(commitment).await
191    }
192
193    /// Clears a seed pin whose protected authority is gone when nothing was
194    /// fenced, captured or dispatched under it. Only an interrupted rollback
195    /// leaves that state; the refused claim authorized nothing on the server.
196    async fn clear_unfenced_orphan_pin(
197        &self,
198        database: &Database,
199        commitment: [u8; 32],
200    ) -> anyhow::Result<bool> {
201        if database.seed_source_pin().await?.is_some()
202            || database.seed_publication_intent_bytes().await?.is_some()
203            || database
204                .has_local_shared_state_package_never_dispatched()
205                .await?
206        {
207            return Ok(false);
208        }
209        {
210            let _guard = self.lock()?;
211            if self.load_secret(SEED_ITEM, SEED_BYTES)?.is_some() {
212                return Ok(false);
213            }
214            self.remove_record(SEED_MARKER_RECORD)?;
215        }
216        database.rollback_local_seed_genesis(commitment).await?;
217        Ok(true)
218    }
219
220    pub(super) fn seed_authority_exists(&self) -> StoreResult<bool> {
221        Ok(self
222            .read_record(SEED_MARKER_RECORD, SEED_MARKER_BYTES)?
223            .is_some()
224            || self.load_secret(SEED_ITEM, SEED_BYTES)?.is_some())
225    }
226
227    fn load_or_create_seed(
228        &self,
229        package: &ProtectedLocalPackageKey,
230        setup: [u8; 32],
231        pin: Option<[u8; 32]>,
232    ) -> StoreResult<SeedAuthority> {
233        self.prepare()?;
234        let _guard = self.lock()?;
235        let marker = self.read_record(SEED_MARKER_RECORD, SEED_MARKER_BYTES)?;
236        let seed = match self.load_secret(SEED_ITEM, SEED_BYTES)? {
237            Some(bytes) => self.decode_seed(&bytes, package)?,
238            None if marker.is_some() || pin.is_some() => {
239                return Err(error(ProtectedLocalKeyStoreErrorKind::MissingAuthority));
240            }
241            None => {
242                let seed = SeedAuthority::generate(package.context(), package.package_key(), setup)
243                    .map_err(|_| error(ProtectedLocalKeyStoreErrorKind::Unavailable))?;
244                let encoded = self.encode_seed(&seed)?;
245                self.create_secret(SEED_ITEM, &encoded)?;
246                let saved = self
247                    .load_secret(SEED_ITEM, SEED_BYTES)?
248                    .ok_or_else(|| error(ProtectedLocalKeyStoreErrorKind::WriteFailed))?;
249                if saved.as_slice() != encoded.as_slice() {
250                    return Err(error(ProtectedLocalKeyStoreErrorKind::WriteFailed));
251                }
252                self.decode_seed(&saved, package)?
253            }
254        };
255        let commitment = seed.genesis().commitment();
256        if seed.genesis().setup_id() != setup {
257            return Err(error(ProtectedLocalKeyStoreErrorKind::SetupMismatch));
258        }
259        if pin.is_some_and(|pin| pin != commitment) {
260            return Err(error(ProtectedLocalKeyStoreErrorKind::Corrupt));
261        }
262        let mut expected = SEED_MARKER.to_vec();
263        expected.extend(commitment);
264        match marker {
265            Some(marker) if marker != expected => {
266                return Err(error(ProtectedLocalKeyStoreErrorKind::Corrupt));
267            }
268            Some(_) => {}
269            None => self.write_record(SEED_MARKER_RECORD, &expected)?,
270        }
271        Ok(seed)
272    }
273
274    fn encode_seed(&self, seed: &SeedAuthority) -> StoreResult<Zeroizing<Vec<u8>>> {
275        let mut out = Zeroizing::new(Vec::with_capacity(SEED_BYTES));
276        out.extend_from_slice(SEED_MAGIC);
277        out.extend_from_slice(
278            &hex::decode(&self.account)
279                .map_err(|_| error(ProtectedLocalKeyStoreErrorKind::WrongDatabase))?,
280        );
281        out.extend_from_slice(&seed.protected_storage_bytes());
282        let checksum = Sha256::digest(out.as_slice());
283        out.extend_from_slice(&checksum);
284        Ok(out)
285    }
286
287    pub(super) fn decode_seed(
288        &self,
289        bytes: &[u8],
290        package: &ProtectedLocalPackageKey,
291    ) -> StoreResult<SeedAuthority> {
292        let account = hex::decode(&self.account)
293            .map_err(|_| error(ProtectedLocalKeyStoreErrorKind::WrongDatabase))?;
294        if bytes.len() != SEED_BYTES
295            || &bytes[..8] != SEED_MAGIC
296            || bytes[8..40] != account
297            || Sha256::digest(&bytes[..SEED_BYTES - 32]).as_slice() != &bytes[SEED_BYTES - 32..]
298        {
299            return Err(error(ProtectedLocalKeyStoreErrorKind::Corrupt));
300        }
301        SeedAuthority::from_protected_storage(
302            &bytes[40..SEED_BYTES - 32],
303            package.context(),
304            package.package_key(),
305        )
306        .map_err(|_| error(ProtectedLocalKeyStoreErrorKind::Corrupt))
307    }
308}
309
310#[cfg(test)]
311mod tests;