Skip to main content

aven_core/sync/client/
bootstrap.rs

1//! Seed bootstrap: claiming a server for a new sync and publishing the
2//! seed's frozen snapshot.
3//!
4//! HTTP framing: POST /e2ee/bootstrap/v1. Control operations are
5//! application/json, one externally tagged operation in a context envelope.
6//! Package records travel only in binary batches (see
7//! [`staging::batch`]), each carrying its context in the batch header; the
8//! server answers a stored batch with `Stored`. Base64 strings and batch
9//! records carry exact existing codec bytes, never a second encrypted package
10//! representation. Setup and device credentials use Authorization: Bearer <64
11//! lowercase hex digits>; only ClaimSetup uses setup authority. IDs and
12//! payloads never enter URLs. JSON requests are bounded at the base64 length
13//! of one chunk plus 4096 bytes of framing, batches at
14//! [`staging::batch::MAX_BYTES`]. Status responses are bounded at 1 MiB. Busy
15//! callers retry a bounded number of times.
16use anyhow::{Result, ensure};
17use serde::{Deserialize, Serialize};
18
19use super::exchange::{self, Link};
20use super::keys::ProtectedLocalKeyStore;
21use crate::db::Database;
22use crate::sync::shared_state::validated::{ProofCache, ValidatedSeed};
23use crate::sync::{
24    base64_bytes, bootstrap_staging as staging,
25    seed_claim::{ClaimAuthentication, ClaimResult, Genesis, PublicationOutcome, Secret},
26};
27
28/// Logs one setup stage's duration at debug level when dropped.
29pub(crate) struct StageTimer {
30    stage: &'static str,
31    started: std::time::Instant,
32}
33
34impl StageTimer {
35    pub(crate) fn start(stage: &'static str) -> Self {
36        Self {
37            stage,
38            started: std::time::Instant::now(),
39        }
40    }
41}
42
43impl Drop for StageTimer {
44    fn drop(&mut self) {
45        tracing::debug!(
46            stage = self.stage,
47            elapsed_ms = self.started.elapsed().as_secs_f64() * 1000.0,
48            "setup stage finished"
49        );
50    }
51}
52
53pub const PATH: &str = "/e2ee/bootstrap/v1";
54pub const REQUEST_LIMIT: usize = base64_bytes::encoded_len(staging::MAX_REQUEST_BYTES) + 4096;
55pub const RESPONSE_LIMIT: usize = 1_048_576;
56
57#[derive(Serialize, Deserialize)]
58#[serde(deny_unknown_fields)]
59pub struct Envelope {
60    pub vault: [u8; 32],
61    pub genesis: [u8; 32],
62    pub operation: Operation,
63}
64
65#[derive(Serialize, Deserialize)]
66#[serde(deny_unknown_fields)]
67pub enum Operation {
68    ClaimSetup {
69        #[serde(with = "crate::sync::base64_bytes")]
70        bytes: Vec<u8>,
71    },
72    ClaimBearer {
73        #[serde(with = "crate::sync::base64_bytes")]
74        bytes: Vec<u8>,
75    },
76    Declare {
77        #[serde(with = "crate::sync::base64_bytes")]
78        descriptor: Vec<u8>,
79        budget: staging::Budget,
80    },
81    Status {
82        bootstrap: [u8; 32],
83    },
84    Ensure {
85        bootstrap: [u8; 32],
86        commitment: [u8; 32],
87    },
88    Cancel {
89        bootstrap: [u8; 32],
90    },
91    Publish {
92        bootstrap: [u8; 32],
93        commitment: [u8; 32],
94        #[serde(with = "crate::sync::base64_bytes")]
95        record: Vec<u8>,
96    },
97}
98
99#[derive(Serialize, Deserialize)]
100#[serde(deny_unknown_fields)]
101pub enum Reply {
102    Claimed {
103        vault: [u8; 32],
104        claim: [u8; 32],
105        genesis: [u8; 32],
106    },
107    Missing,
108    Canceled,
109    Staging(staging::StagingStatus),
110    Stored,
111    Published(#[serde(with = "crate::sync::base64_bytes")] Vec<u8>),
112}
113
114impl From<staging::Status> for Reply {
115    fn from(value: staging::Status) -> Self {
116        match value {
117            staging::Status::Missing => Self::Missing,
118            staging::Status::Canceled => Self::Canceled,
119            staging::Status::Staging(status) => Self::Staging(status),
120            staging::Status::Published(outcome) => {
121                Self::Published(outcome.publication().record().to_vec())
122            }
123        }
124    }
125}
126
127/// Bounded seed bootstrap exchanges with one server.
128pub struct Client {
129    link: Link,
130    endpoint: url::Url,
131}
132
133impl Client {
134    pub fn new(origin: &str, link: Link) -> Result<Self> {
135        Ok(Self {
136            link,
137            endpoint: super::origin::endpoint(origin, PATH)?,
138        })
139    }
140
141    pub async fn exchange(
142        &self,
143        genesis: &Genesis,
144        secret: &Secret,
145        operation: Operation,
146    ) -> Result<Reply> {
147        let claim = matches!(
148            &operation,
149            Operation::ClaimSetup { .. } | Operation::ClaimBearer { .. }
150        );
151        let bytes = serde_json::to_vec(&Envelope {
152            vault: genesis.context().vault_id,
153            genesis: genesis.commitment(),
154            operation,
155        })
156        .map_err(|_| anyhow::anyhow!("error bootstrap-request"))?;
157        ensure!(
158            bytes.len() <= REQUEST_LIMIT,
159            "error bootstrap-request-limit"
160        );
161        self.post(secret, exchange::json_content(), bytes, claim)
162            .await
163    }
164
165    /// Stores one batch of package records. Every record is sent exactly as
166    /// frozen; the server checks each against its descriptor slot.
167    pub async fn put_batch(
168        &self,
169        secret: &Secret,
170        header: &staging::batch::Header,
171        records: &[&[u8]],
172    ) -> Result<Reply> {
173        let bytes = staging::batch::encode(header, records)?;
174        let content_type = exchange::HttpHeader {
175            name: "content-type".into(),
176            value: staging::batch::CONTENT_TYPE.into(),
177        };
178        self.post(secret, content_type, bytes, false).await
179    }
180
181    async fn post(
182        &self,
183        secret: &Secret,
184        content_type: exchange::HttpHeader,
185        bytes: Vec<u8>,
186        claim: bool,
187    ) -> Result<Reply> {
188        let bytes = exchange::post(
189            &self.link,
190            &self.endpoint,
191            Some(secret),
192            content_type,
193            bytes,
194            RESPONSE_LIMIT,
195        )
196        .await
197        .map_err(|failure| match failure {
198            exchange::Failure::Network => {
199                anyhow::anyhow!("error bootstrap-network outcome-unknown")
200            }
201            exchange::Failure::SecureTransport => {
202                anyhow::anyhow!("error bootstrap-tls outcome-unknown")
203            }
204            exchange::Failure::Malformed => anyhow::anyhow!("error bootstrap-response"),
205            exchange::Failure::TooLarge => anyhow::anyhow!("error bootstrap-response-limit"),
206            exchange::Failure::RequestBodyLimit => {
207                anyhow::anyhow!("error sync-request-body-limit")
208            }
209            exchange::Failure::Refused { code, .. } => match code.as_deref() {
210                Some("bootstrap-storage-already-claimed") => {
211                    anyhow::anyhow!("error bootstrap-storage-already-claimed")
212                }
213                Some("bootstrap-setup-invitation-rejected") if claim => {
214                    anyhow::anyhow!("error bootstrap-setup-invitation-rejected")
215                }
216                Some("bootstrap-setup-invitation-expired") if claim => {
217                    anyhow::anyhow!("error bootstrap-setup-invitation-expired")
218                }
219                Some("attachment-quota-exceeded") => {
220                    anyhow::anyhow!("error attachment-quota-exceeded")
221                }
222                _ => anyhow::anyhow!("error bootstrap-refused outcome-unknown"),
223            },
224        })?;
225        serde_json::from_slice(&bytes).map_err(|_| anyhow::anyhow!("error bootstrap-response"))
226    }
227
228    /// Initial setup claim or exact bearer-authorized claim resumption.
229    /// Authority must already be durably protected locally. Publication retires
230    /// claim resumption; use the protected-intent resume path after dispatch.
231    pub async fn claim(
232        &self,
233        genesis: &Genesis,
234        authentication: ClaimAuthentication<'_>,
235    ) -> Result<()> {
236        let (secret, operation) = match authentication {
237            ClaimAuthentication::SetupSecret(secret) => (
238                secret,
239                Operation::ClaimSetup {
240                    bytes: genesis.claim_bytes(),
241                },
242            ),
243            ClaimAuthentication::SeedBearer(secret) => (
244                secret,
245                Operation::ClaimBearer {
246                    bytes: genesis.claim_bytes(),
247                },
248            ),
249        };
250        match self.exchange(genesis, secret, operation).await? {
251            Reply::Claimed {
252                vault,
253                claim,
254                genesis: commitment,
255            } => ClaimResult {
256                vault_id: vault,
257                claim_id: claim,
258                genesis_commitment: commitment,
259            }
260            .validate_pinned(genesis),
261            _ => anyhow::bail!("error bootstrap-response"),
262        }
263    }
264
265    /// Resume one frozen candidate, then validate outcome, adopt and clean up.
266    /// Call after claim, source preparation, capture and packaging. This never
267    /// creates authority, recaptures, cancels, or enables ordinary encrypted sync.
268    pub async fn resume(
269        &self,
270        store: &ProtectedLocalKeyStore,
271        database: &Database,
272    ) -> Result<bool> {
273        self.resume_reporting(store, database, &|_, _| {}).await
274    }
275
276    /// [`Self::resume`], reporting acknowledged payload bytes for an
277    /// unpublished upload attempt against the package's exact total. Reporting
278    /// starts at zero; server presence never suppresses a frozen slot.
279    pub async fn resume_reporting(
280        &self,
281        store: &ProtectedLocalKeyStore,
282        database: &Database,
283        uploaded: &(dyn Fn(u64, u64) + Sync),
284    ) -> Result<bool> {
285        self.resume_validated(store, database, None, uploaded).await
286    }
287
288    /// [`Self::resume_reporting`], reusing a proof the caller already made
289    /// so the frozen package is not authenticated again.
290    pub(crate) async fn resume_validated(
291        &self,
292        store: &ProtectedLocalKeyStore,
293        database: &Database,
294        proof: Option<ValidatedSeed>,
295        uploaded: &(dyn Fn(u64, u64) + Sync),
296    ) -> Result<bool> {
297        let mut proofs = ProofCache::new(proof);
298        let (seed, intent) = store.seed_resume_intent(database, &mut proofs).await?;
299        let publication = intent.publication(seed.genesis())?;
300        let binding = publication.binding();
301        let status = self
302            .exchange(
303                seed.genesis(),
304                seed.bearer(),
305                Operation::Status {
306                    bootstrap: binding.bootstrap_id,
307                },
308            )
309            .await?;
310        let record = match status {
311            Reply::Published(record) => record,
312            Reply::Missing | Reply::Staging(_) => {
313                let upload = store
314                    .seed_upload(database, &intent, &mut proofs)
315                    .await?
316                    .ok_or_else(|| anyhow::anyhow!("error bootstrap-outcome-missing"))?;
317                let _timer = StageTimer::start("upload");
318                let budget = upload.budget();
319                if let Reply::Staging(ref current) = status {
320                    validate_staging(current, upload.slots(), binding, budget)?;
321                }
322                let staging = match status {
323                    Reply::Missing => {
324                        self.exchange(
325                            seed.genesis(),
326                            seed.bearer(),
327                            Operation::Declare {
328                                descriptor: upload.descriptor().to_vec(),
329                                budget,
330                            },
331                        )
332                        .await?
333                    }
334                    Reply::Staging(_) => {
335                        self.exchange(
336                            seed.genesis(),
337                            seed.bearer(),
338                            Operation::Ensure {
339                                bootstrap: binding.bootstrap_id,
340                                commitment: binding.descriptor_commitment,
341                            },
342                        )
343                        .await?
344                    }
345                    _ => unreachable!(),
346                };
347                let Reply::Staging(staging) = staging else {
348                    anyhow::bail!("error bootstrap-response");
349                };
350                validate_staging(&staging, upload.slots(), binding, budget)?;
351                let header = |records| staging::batch::Header {
352                    vault: seed.genesis().context().vault_id,
353                    genesis: seed.genesis().commitment(),
354                    bootstrap: binding.bootstrap_id,
355                    commitment: binding.descriptor_commitment,
356                    records,
357                };
358                uploaded(0, budget.bytes);
359                let mut sent = 0_u64;
360                // Catalog slices precede every artifact batch, so the server
361                // can verify each catalog before it accepts dependent records.
362                for batch in pack(upload.slots(), header) {
363                    let records = upload.read(database, &batch).await?;
364                    let refs: Vec<&[u8]> = records.iter().map(Vec::as_slice).collect();
365                    let reply = self.put_batch(seed.bearer(), &header(batch), &refs).await?;
366                    ensure!(
367                        matches!(reply, Reply::Stored),
368                        "error bootstrap-upload-refused"
369                    );
370                    sent += records
371                        .iter()
372                        .map(|record| record.len() as u64)
373                        .sum::<u64>();
374                    uploaded(sent, budget.bytes);
375                }
376                tracing::debug!(sent, total = budget.bytes, "uploaded frozen staged bytes");
377                match self
378                    .exchange(
379                        seed.genesis(),
380                        seed.bearer(),
381                        Operation::Publish {
382                            bootstrap: binding.bootstrap_id,
383                            commitment: binding.descriptor_commitment,
384                            record: publication.record().to_vec(),
385                        },
386                    )
387                    .await?
388                {
389                    Reply::Published(record) => record,
390                    _ => anyhow::bail!("error bootstrap-response"),
391                }
392            }
393            _ => anyhow::bail!("error bootstrap-candidate-unavailable"),
394        };
395        let outcome =
396            PublicationOutcome::from_response(seed.genesis(), intent.descriptor(), &record)?;
397        ensure!(
398            outcome.publication() == &publication,
399            "error bootstrap-outcome-mismatch"
400        );
401        let _timer = StageTimer::start("adopt");
402        store
403            .adopt_seed_publication_with(database, &outcome, &mut proofs)
404            .await
405    }
406}
407
408/// Validates a staging reply without treating server presence as an upload
409/// filter. Artifact components may be absent until their catalog is complete.
410fn validate_staging(
411    status: &staging::StagingStatus,
412    slots: &[staging::batch::Slot],
413    binding: &crate::sync::seed_claim::PublicationBinding,
414    budget: staging::Budget,
415) -> Result<()> {
416    ensure!(
417        status.descriptor_commitment == binding.descriptor_commitment
418            && status.stream_id == binding.stream_id
419            && status.budget == budget,
420        "error bootstrap-status-mismatch"
421    );
422
423    let mut seen = Vec::new();
424    for listed in &status.components {
425        let expected = slots
426            .iter()
427            .filter(|slot| slot.component == listed.component)
428            .count();
429        ensure!(
430            expected > 0 && listed.chunks.len() == expected && !seen.contains(&listed.component),
431            "error bootstrap-status-mismatch"
432        );
433        seen.push(listed.component);
434    }
435
436    // Catalog slots and the descriptor's manifest are always listed. State
437    // and image slots appear only after their describing catalogs verify.
438    for component in [
439        staging::Component::DataCatalog,
440        staging::Component::PrefixCatalog,
441        staging::Component::ImageCatalog,
442        staging::Component::Manifest,
443    ] {
444        ensure!(seen.contains(&component), "error bootstrap-status-mismatch");
445    }
446
447    let catalog_complete = |component| {
448        status
449            .components
450            .iter()
451            .find(|listed| listed.component == component)
452            .is_some_and(|listed| {
453                listed
454                    .chunks
455                    .iter()
456                    .all(|presence| *presence == staging::Presence::Verified)
457            })
458    };
459    let data_complete = catalog_complete(staging::Component::DataCatalog);
460    let images_complete = catalog_complete(staging::Component::ImageCatalog);
461    let listed = |component| seen.contains(&component);
462    ensure!(
463        listed(staging::Component::State) == data_complete,
464        "error bootstrap-status-mismatch"
465    );
466    let mut image_components = Vec::new();
467    for slot in slots {
468        if let staging::Component::Image(_) = slot.component
469            && !image_components.contains(&slot.component)
470        {
471            image_components.push(slot.component);
472        }
473    }
474    ensure!(
475        image_components
476            .iter()
477            .all(|component| listed(*component) == images_complete),
478        "error bootstrap-status-mismatch"
479    );
480    Ok(())
481}
482
483/// Splits `slots` into batches within every batch limit, keeping catalog
484/// slices apart from the records catalogs describe.
485fn pack(
486    slots: &[staging::batch::Slot],
487    header: impl Fn(Vec<staging::batch::Slot>) -> staging::batch::Header,
488) -> Vec<Vec<staging::batch::Slot>> {
489    let mut batches: Vec<Vec<staging::batch::Slot>> = Vec::new();
490    let mut current: Vec<staging::batch::Slot> = Vec::new();
491    let mut payload = 0_u64;
492    for slot in slots {
493        let fits = !current.is_empty()
494            && current.len() < staging::batch::MAX_RECORDS
495            && payload + slot.len <= staging::batch::MAX_PAYLOAD as u64
496            && current[0].component.is_catalog() == slot.component.is_catalog()
497            && staging::batch::header_len(&header(
498                current.iter().copied().chain([*slot]).collect(),
499            ))
500            .is_some();
501        if !fits && !current.is_empty() {
502            batches.push(std::mem::take(&mut current));
503            payload = 0;
504        }
505        current.push(*slot);
506        payload += slot.len;
507    }
508    if !current.is_empty() {
509        batches.push(current);
510    }
511    batches
512}
513
514/// A package's chunks by component, in upload order.
515#[cfg(any(test, feature = "test-support"))]
516pub fn components(
517    package: &crate::sync::bootstrap_format::Package,
518) -> Vec<(staging::Component, Vec<&[u8]>)> {
519    let mut out = Vec::new();
520    for (index, component) in [
521        staging::Component::DataCatalog,
522        staging::Component::PrefixCatalog,
523        staging::Component::ImageCatalog,
524    ]
525    .into_iter()
526    .enumerate()
527    {
528        out.push((
529            component,
530            package.catalogs[index].chunks(1_048_576).collect(),
531        ));
532    }
533    out.push((
534        staging::Component::Manifest,
535        package.manifest.iter().map(Vec::as_slice).collect(),
536    ));
537    out.push((
538        staging::Component::State,
539        package.state.iter().map(Vec::as_slice).collect(),
540    ));
541    for image in &package.images {
542        out.push((
543            staging::Component::Image(image.object_id),
544            image.records.iter().map(Vec::as_slice).collect(),
545        ));
546    }
547    out
548}