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