Skip to main content

aven_core/sync/client/keys/
adoption.rs

1//! Protected host ownership for original-seed publication and adoption.
2use super::*;
3use crate::db::installation::InstallationGuard;
4use crate::sync::seed_claim::{PublicationOutcome, SeedAuthority};
5use crate::sync::shared_state::validated::ProofCache;
6use crate::sync::{SeedPublicationIntent, SeedSourceAuthority};
7use anyhow::Context;
8
9impl ProtectedLocalKeyStore {
10    /// Resumes sealed ownership before exposing any publication transport input.
11    pub async fn seed_http_inputs(
12        &self,
13        database: &Database,
14    ) -> anyhow::Result<(
15        SeedAuthority,
16        SeedPublicationIntent,
17        Option<crate::sync::bootstrap_format::Package>,
18    )> {
19        let mut proofs = ProofCache::default();
20        let (seed, intent) = self.seed_resume_intent(database, &mut proofs).await?;
21        let upload = match self.seed_upload(database, &intent, &mut proofs).await? {
22            Some(upload) => Some(upload.package(database).await?),
23            None => None,
24        };
25        Ok((seed, intent, upload))
26    }
27
28    /// Recovers protected seed authority and the sealed publication intent.
29    pub(crate) async fn seed_resume_intent(
30        &self,
31        database: &Database,
32        proofs: &mut ProofCache,
33    ) -> anyhow::Result<(SeedAuthority, SeedPublicationIntent)> {
34        let intent = self
35            .prepare_seed_adoption_intent_with(database, proofs)
36            .await?;
37        let package = self.load_required()?;
38        let seed = {
39            let _guard = self.lock()?;
40            self.required_seed(&package)?
41        };
42        Ok((seed, intent))
43    }
44
45    /// Loads exact upload bytes for a sealed intent, or `None` once adopted.
46    pub(crate) async fn seed_upload(
47        &self,
48        database: &Database,
49        intent: &SeedPublicationIntent,
50        proofs: &mut ProofCache,
51    ) -> anyhow::Result<Option<crate::sync::shared_state::package::upload::FrozenUpload>> {
52        let _installation = InstallationGuard::acquire(database.path())?;
53        self.validate_database(database).await?;
54        let package = self.load_required()?;
55        let _guard = self.lock()?;
56        let seed = self.required_seed(&package)?;
57        let source = self.decode_source(
58            &self
59                .load_adoption_record("source", 104, true)?
60                .context("missing source")?,
61            &seed,
62        )?;
63        let protected = self
64            .load_adoption_record("intent", 65536, true)?
65            .context("missing intent")?;
66        anyhow::ensure!(
67            protected == intent.protected_storage_bytes(),
68            "error seed-protected-intent-mismatch"
69        );
70        proofs.trust_protected(intent);
71        let _timer = super::super::bootstrap::StageTimer::start("load_upload");
72        database
73            .seed_publication_upload(&source, intent, &seed, package.package_key(), proofs)
74            .await
75    }
76
77    /// Nonsecret digest record that detects loss of secret item `kind`.
78    fn adoption_marker(kind: &str) -> String {
79        format!("{kind}-authority")
80    }
81
82    pub(super) fn load_adoption_record(
83        &self,
84        kind: &str,
85        limit: usize,
86        required: bool,
87    ) -> anyhow::Result<Option<Vec<u8>>> {
88        let marker = self.read_record(&Self::adoption_marker(kind), 32)?;
89        let bytes = self.load_secret(kind, limit)?;
90        match bytes {
91            Some(bytes) => {
92                let digest = Sha256::digest(&bytes);
93                anyhow::ensure!(
94                    marker.as_deref().is_none_or(|m| m == digest.as_slice()),
95                    "error seed-protected-record-corrupt"
96                );
97                if marker.is_none() {
98                    self.write_record(&Self::adoption_marker(kind), &digest)?;
99                }
100                if kind == "intent" {
101                    let length = u32::from_be_bytes(bytes[..4].try_into()?) as usize;
102                    anyhow::ensure!(
103                        length <= limit - 4 && bytes[4 + length..].iter().all(|b| *b == 0),
104                        "error seed-protected-intent-framing"
105                    );
106                    return Ok(Some(bytes[4..4 + length].to_vec()));
107                }
108                Ok(Some(bytes.to_vec()))
109            }
110            None => {
111                anyhow::ensure!(
112                    !required && marker.is_none(),
113                    "error seed-protected-authority-missing"
114                );
115                Ok(None)
116            }
117        }
118    }
119
120    fn create_adoption_record(&self, kind: &str, bytes: &[u8], limit: usize) -> anyhow::Result<()> {
121        let encoded;
122        let storage = if kind == "intent" {
123            anyhow::ensure!(bytes.len() <= limit - 4, "error seed-intent-too-large");
124            let mut frame = vec![0; limit];
125            frame[..4].copy_from_slice(&(bytes.len() as u32).to_be_bytes());
126            frame[4..4 + bytes.len()].copy_from_slice(bytes);
127            encoded = frame;
128            encoded.as_slice()
129        } else {
130            bytes
131        };
132        self.create_secret(kind, storage)?;
133        let stored = self
134            .load_adoption_record(kind, limit, true)?
135            .context("error seed-protected-write-missing")?;
136        anyhow::ensure!(stored == bytes, "error seed-protected-write-mismatch");
137        Ok(())
138    }
139
140    pub fn required_seed(
141        &self,
142        package: &ProtectedLocalPackageKey,
143    ) -> anyhow::Result<SeedAuthority> {
144        let bytes = self
145            .load_secret(seed::SEED_ITEM, seed::SEED_BYTES)?
146            .context("error seed-protected-authority-missing")?;
147        Ok(self.decode_seed(&bytes, package)?)
148    }
149
150    pub(super) fn decode_source(
151        &self,
152        bytes: &[u8],
153        seed: &SeedAuthority,
154    ) -> anyhow::Result<SeedSourceAuthority> {
155        SeedSourceAuthority::from_protected_storage(
156            bytes,
157            hex::decode(&self.account)?
158                .try_into()
159                .map_err(|_| anyhow::anyhow!("invalid source account"))?,
160            seed.genesis(),
161        )
162    }
163
164    /// Explicit irreversible opt-in before capture. Early failure can leave the
165    /// installation fenced; the denial marker never authorizes key regeneration.
166    pub async fn prepare_seed_source(
167        &self,
168        database: &Database,
169    ) -> anyhow::Result<SeedSourceAuthority> {
170        let installation = InstallationGuard::acquire(database.path())?;
171        self.validate_database(database).await?;
172        #[cfg(test)]
173        wait_source_boundary(database.path()).await;
174        let was_unbound = installation.ensure_unbound().is_ok();
175        let package = self.load_required()?;
176        self.prepare()?;
177        let _guard = self.lock()?;
178        let seed = self.required_seed(&package)?;
179        let pin = database.seed_source_pin().await?;
180        let existing = self.load_adoption_record("source", 104, pin.is_some())?;
181        if existing.is_none() {
182            anyhow::ensure!(
183                was_unbound,
184                "error seed-source-incomplete installation-remains-fenced"
185            );
186            anyhow::ensure!(
187                database
188                    .resume_local_shared_state_never_dispatched()
189                    .await?
190                    .is_none(),
191                "error seed-source-requires-recapture-never-dispatched"
192            );
193        }
194        installation.fence()?;
195        let source = match existing {
196            Some(bytes) => self.decode_source(&bytes, &seed)?,
197            None => {
198                let source = SeedSourceAuthority::generate(
199                    hex::decode(&self.account)?
200                        .try_into()
201                        .map_err(|_| anyhow::anyhow!("invalid source account"))?,
202                    seed.genesis(),
203                )?;
204                self.create_adoption_record("source", source.protected_storage_bytes(), 104)?;
205                source
206            }
207        };
208        if pin.is_none() {
209            anyhow::ensure!(
210                self.load_adoption_record("intent", 65536, false)?.is_none(),
211                "error seed-source-database-lost"
212            );
213        }
214        database.bind_seed_source(&source, &installation).await?;
215        Ok(source)
216    }
217
218    /// Fences SQLite cancellation before persisting exact protected intent.
219    /// An intent cannot be locally abandoned.
220    pub async fn prepare_seed_adoption_intent(
221        &self,
222        database: &Database,
223    ) -> anyhow::Result<SeedPublicationIntent> {
224        self.prepare_seed_adoption_intent_with(database, &mut ProofCache::default())
225            .await
226    }
227
228    pub(crate) async fn prepare_seed_adoption_intent_with(
229        &self,
230        database: &Database,
231        proofs: &mut ProofCache,
232    ) -> anyhow::Result<SeedPublicationIntent> {
233        let _installation = InstallationGuard::acquire(database.path())?;
234        self.validate_database(database).await?;
235        let package = self.load_required()?;
236        let _guard = self.lock()?;
237        let seed = self.required_seed(&package)?;
238        let source = self.decode_source(
239            &self
240                .load_adoption_record("source", 104, true)?
241                .context("missing source")?,
242            &seed,
243        )?;
244        let existing = database.seed_publication_intent_bytes().await?;
245        let protected = self.load_adoption_record(
246            "intent",
247            65536,
248            existing
249                .as_ref()
250                .is_some_and(|(_, state)| state != "preparing"),
251        )?;
252        anyhow::ensure!(
253            protected.is_none() || existing.is_some(),
254            "error seed-intent-database-lost"
255        );
256        // Only the protected copy may vouch for a freeze; a `preparing`
257        // intent in SQLite alone is authenticated again before promotion.
258        if let Some(bytes) = &protected {
259            proofs.trust_protected(&SeedPublicationIntent::from_protected_storage(
260                bytes,
261                &source,
262                seed.genesis(),
263            )?);
264        }
265        let timer = super::super::bootstrap::StageTimer::start("prepare_intent");
266        let intent = database
267            .prepare_seed_publication_intent_with(&source, &seed, package.package_key(), proofs)
268            .await?;
269        match protected {
270            Some(bytes) => anyhow::ensure!(
271                bytes == intent.protected_storage_bytes(),
272                "error seed-protected-intent-mismatch"
273            ),
274            None => {
275                self.create_adoption_record("intent", intent.protected_storage_bytes(), 65536)?
276            }
277        }
278        let reloaded = SeedPublicationIntent::from_protected_storage(
279            &self
280                .load_adoption_record("intent", 65536, true)?
281                .context("missing intent")?,
282            &source,
283            seed.genesis(),
284        )?;
285        drop(timer);
286        let _timer = super::super::bootstrap::StageTimer::start("seal");
287        if existing.is_none_or(|(_, state)| state != "adopted") {
288            database
289                .seal_seed_publication_intent(&source, &reloaded)
290                .await?;
291        }
292        Ok(reloaded)
293    }
294
295    /// Authenticates host authority and commits adoption, then retries pin cleanup.
296    /// Cleanup failure is reported without rolling back the committed adoption.
297    pub async fn adopt_seed_publication(
298        &self,
299        database: &Database,
300        outcome: &PublicationOutcome,
301    ) -> anyhow::Result<bool> {
302        self.adopt_seed_publication_with(database, outcome, &mut ProofCache::default())
303            .await
304    }
305
306    pub(crate) async fn adopt_seed_publication_with(
307        &self,
308        database: &Database,
309        outcome: &PublicationOutcome,
310        proofs: &mut ProofCache,
311    ) -> anyhow::Result<bool> {
312        let _installation = InstallationGuard::acquire(database.path())?;
313        self.validate_database(database).await?;
314        let package = self.load_required()?;
315        let _guard = self.lock()?;
316        let seed = self.required_seed(&package)?;
317        let source = self.decode_source(
318            &self
319                .load_adoption_record("source", 104, true)?
320                .context("missing source")?,
321            &seed,
322        )?;
323        let intent = SeedPublicationIntent::from_protected_storage(
324            &self
325                .load_adoption_record("intent", 65536, true)?
326                .context("missing intent")?,
327            &source,
328            seed.genesis(),
329        )?;
330        proofs.trust_protected(&intent);
331        let adopted = database
332            .adopt_seed_publication(
333                &source,
334                &intent,
335                &seed,
336                package.package_key(),
337                outcome,
338                proofs,
339            )
340            .await?;
341        database
342            .cleanup_adopted_seed_capture(&source, &intent)
343            .await?;
344        Ok(adopted)
345    }
346}
347
348#[cfg(test)]
349mod tests;
350
351#[cfg(test)]
352type SourceBarrier = (
353    std::path::PathBuf,
354    tokio::sync::oneshot::Sender<()>,
355    tokio::sync::oneshot::Receiver<()>,
356);
357#[cfg(test)]
358static SOURCE_BARRIER: std::sync::Mutex<Option<SourceBarrier>> = std::sync::Mutex::new(None);
359#[cfg(test)]
360async fn wait_source_boundary(path: &Path) {
361    let barrier = {
362        let mut slot = SOURCE_BARRIER.lock().unwrap();
363        if slot.as_ref().is_some_and(|(p, _, _)| p == path) {
364            slot.take()
365        } else {
366            None
367        }
368    };
369    if let Some((_, entered, resume)) = barrier {
370        let _ = entered.send(());
371        let _ = resume.await;
372    }
373}