iroh_db/
snapshot.rs

1use std::time::{SystemTime, UNIX_EPOCH};
2
3use iroh_db_blobs::BlobRef;
4use iroh_db_core::{AuthorId, CapabilityId, CommitId, SchemaActivation};
5use iroh_db_security::{CapabilityCertificate, Permission, verify_snapshot_signature};
6use iroh_db_store::{MaterializedRecord, SnapshotBaseline, StoreError};
7use tokio::io::AsyncReadExt as _;
8
9use crate::{DbError, IrohDb};
10
11const SNAPSHOT_VERSION: u16 = 3;
12
13/// An immutable encrypted local snapshot and its verified state root.
14#[derive(Debug, Clone, Copy, PartialEq, Eq)]
15pub struct Snapshot {
16    blob: BlobRef,
17    state_root: [u8; 32],
18}
19
20impl Snapshot {
21    /// Reconstructs an out-of-band snapshot reference after authorization metadata exchange.
22    pub const fn from_parts(blob: BlobRef, state_root: [u8; 32]) -> Self {
23        Self { blob, state_root }
24    }
25
26    /// Returns the encrypted snapshot blob reference.
27    pub const fn blob_ref(self) -> BlobRef {
28        self.blob
29    }
30
31    /// Returns the BLAKE3 root of canonical captured state.
32    pub const fn state_root(self) -> [u8; 32] {
33        self.state_root
34    }
35}
36
37#[derive(minicbor::Encode, minicbor::Decode)]
38#[cbor(array)]
39struct SnapshotContent {
40    #[n(0)]
41    version: u16,
42    #[n(1)]
43    domain_id: iroh_db_core::DomainId,
44    #[n(2)]
45    frontier: Vec<CommitId>,
46    #[n(3)]
47    records: Vec<Vec<u8>>,
48    #[n(4)]
49    covered_commits: Vec<CommitId>,
50    #[n(5)]
51    author_sequences: Vec<(AuthorId, u64)>,
52    #[n(6)]
53    epoch: u64,
54    #[n(7)]
55    control_head: Option<[u8; 32]>,
56    #[n(8)]
57    capability_id: CapabilityId,
58    #[n(9)]
59    schema_activations: Vec<Vec<u8>>,
60}
61
62#[derive(minicbor::Encode, minicbor::Decode)]
63#[cbor(array)]
64struct SignedSnapshot {
65    #[n(0)]
66    content: Vec<u8>,
67    #[n(1)]
68    state_root: [u8; 32],
69    #[n(2)]
70    author: AuthorId,
71    #[n(3)]
72    signature: [u8; 64],
73}
74
75impl IrohDb {
76    /// Captures canonical materialized state and frontier in a signed encrypted blob.
77    #[tracing::instrument(name = "iroh_db.snapshot.create", skip_all)]
78    pub async fn snapshot(&self) -> Result<Snapshot, DbError> {
79        let _control_reader = self.inner.control_gate.read().await;
80        let _writer = self.inner.writer.lock().await;
81        let domain_id = self.domain_id();
82        let active_epoch = self.inner.store.current_epoch(domain_id)?;
83        if self.epoch() != active_epoch {
84            return Err(DbError::StaleEpoch {
85                handle: self.epoch(),
86                active: active_epoch,
87            });
88        }
89        let capability_id =
90            self.capability_for_permission(self.author_id(), Permission::Snapshot, active_epoch)?;
91        let records = self.inner.store.export_materialized(domain_id)?;
92        let content = SnapshotContent {
93            version: SNAPSHOT_VERSION,
94            domain_id,
95            frontier: self.inner.store.frontier(domain_id)?,
96            records: encode_records(&records)?,
97            covered_commits: self.inner.store.list_commit_ids(domain_id)?,
98            author_sequences: self.inner.store.author_sequences(domain_id)?,
99            epoch: active_epoch,
100            control_head: control_head_id(self)?,
101            capability_id,
102            schema_activations: encode_activations(
103                &self.inner.store.active_schema_activations(domain_id)?,
104            ),
105        };
106        let content = minicbor::to_vec(content).map_err(snapshot_codec)?;
107        let state_root = state_root(&records)?;
108        let signer = self.inner.credentials.signer();
109        let signing_bytes = snapshot_signing_bytes(&content, &state_root, signer.author())?;
110        let envelope = SignedSnapshot {
111            content,
112            state_root,
113            author: signer.author(),
114            signature: signer.sign_snapshot(&signing_bytes),
115        };
116        let bytes = minicbor::to_vec(envelope).map_err(snapshot_codec)?;
117        let blob = self.blob_engine().import_bytes(bytes, None).await?;
118        self.inner
119            .store
120            .record_snapshot_observed_at(domain_id, unix_seconds()?)?;
121        self.inner.telemetry.record_snapshot_created();
122        Ok(Snapshot { blob, state_root })
123    }
124
125    /// Verifies and atomically installs a local snapshot's derived state.
126    #[tracing::instrument(name = "iroh_db.snapshot.install", skip_all)]
127    pub async fn install_snapshot(&self, snapshot: Snapshot) -> Result<(), DbError> {
128        let _control_reader = self.inner.control_gate.read().await;
129        let _writer = self.inner.writer.lock().await;
130        let (envelope, content, records) = self.decode_snapshot(snapshot).await?;
131        let active_epoch = self.inner.store.current_epoch(self.domain_id())?;
132        if content.epoch != active_epoch || self.epoch() != active_epoch {
133            return Err(DbError::InvalidSnapshot(
134                "snapshot epoch does not match active authority".into(),
135            ));
136        }
137        if content.control_head != control_head_id(self)? {
138            return Err(DbError::InvalidSnapshot(
139                "snapshot control head does not match active authority".into(),
140            ));
141        }
142        let capability_id =
143            self.capability_for_permission(envelope.author, Permission::Snapshot, content.epoch)?;
144        if content.capability_id != capability_id {
145            return Err(DbError::UnauthorizedDevice);
146        }
147        let baseline = SnapshotBaseline::new(
148            content.domain_id,
149            content.epoch,
150            snapshot.blob.manifest_hash(),
151            envelope.state_root,
152            content.frontier.clone(),
153            content.author_sequences.clone(),
154        )?;
155        let activations = decode_activations(&content.schema_activations)?;
156        let observed_at = unix_seconds()?;
157        if let Err(error) = self.inner.store.install_snapshot(
158            content.domain_id,
159            &records,
160            &content.covered_commits,
161            &content.frontier,
162            &content.author_sequences,
163            &activations,
164            observed_at,
165            &baseline,
166        ) {
167            return match error {
168                StoreError::InvalidSnapshotMetadata(message) => {
169                    Err(DbError::InvalidSnapshot(message))
170                }
171                error => Err(DbError::Store(error)),
172            };
173        }
174        self.inner.telemetry.record_snapshot_installed();
175        Ok(())
176    }
177
178    pub(crate) async fn verified_snapshot_baseline_records(
179        &self,
180    ) -> Result<Option<Vec<MaterializedRecord>>, DbError> {
181        let Some(baseline) = self.inner.store.snapshot_baseline(self.domain_id())? else {
182            if self.inner.store.has_snapshot_coverage(self.domain_id())? {
183                return Err(DbError::InvalidSnapshot(
184                    "snapshot coverage has no durable recovery baseline".into(),
185                ));
186            }
187            return Ok(None);
188        };
189        let snapshot = Snapshot::from_parts(
190            BlobRef::new(
191                baseline.domain_id(),
192                baseline.epoch(),
193                baseline.manifest_hash(),
194            ),
195            baseline.state_root(),
196        );
197        let (envelope, content, records) = self.decode_snapshot(snapshot).await?;
198        if content.epoch != baseline.epoch()
199            || content.frontier != baseline.frontier()
200            || content.author_sequences != baseline.author_sequences()
201        {
202            return Err(DbError::InvalidSnapshot(
203                "retained snapshot differs from its recovery baseline".into(),
204            ));
205        }
206        let coverage = self.inner.store.snapshot_coverage(self.domain_id())?;
207        if coverage
208            .iter()
209            .any(|commit_id| content.covered_commits.binary_search(commit_id).is_err())
210        {
211            return Err(DbError::InvalidSnapshot(
212                "snapshot coverage contains an unsigned commit".into(),
213            ));
214        }
215        for commit_id in &content.covered_commits {
216            if !self
217                .inner
218                .store
219                .contains_commit(self.domain_id(), *commit_id)?
220                && !self.inner.store.is_commit_quarantined(*commit_id)?
221            {
222                return Err(DbError::InvalidSnapshot(
223                    "signed snapshot coverage is incomplete".into(),
224                ));
225            }
226        }
227        self.verify_historical_snapshot_authority(&envelope, &content)?;
228        Ok(Some(records))
229    }
230
231    async fn decode_snapshot(
232        &self,
233        snapshot: Snapshot,
234    ) -> Result<(SignedSnapshot, SnapshotContent, Vec<MaterializedRecord>), DbError> {
235        if snapshot.blob.domain_id() != self.domain_id() {
236            return Err(DbError::InvalidSnapshot(
237                "snapshot blob belongs to another domain".into(),
238            ));
239        }
240        let mut stream = self
241            .blob_engine_for_epoch(snapshot.blob.epoch())?
242            .open(&snapshot.blob)
243            .await?;
244        let mut bytes = Vec::new();
245        stream
246            .read_to_end(&mut bytes)
247            .await
248            .map_err(|error| DbError::InvalidSnapshot(error.to_string()))?;
249        let envelope: SignedSnapshot = decode_canonical(&bytes)?;
250        if envelope.state_root != snapshot.state_root {
251            return Err(DbError::InvalidSnapshot("state root mismatch".into()));
252        }
253        let signing_bytes =
254            snapshot_signing_bytes(&envelope.content, &envelope.state_root, envelope.author)?;
255        verify_snapshot_signature(envelope.author, &signing_bytes, &envelope.signature)?;
256        let content: SnapshotContent = decode_canonical(&envelope.content)?;
257        if content.version != SNAPSHOT_VERSION
258            || content.domain_id != self.domain_id()
259            || content.epoch != snapshot.blob.epoch()
260        {
261            return Err(DbError::InvalidSnapshot(
262                "unsupported version, wrong domain, or wrong epoch".into(),
263            ));
264        }
265        validate_snapshot_metadata(&content)?;
266        let records = decode_records(&content.records)?;
267        if state_root(&records)? != envelope.state_root {
268            return Err(DbError::InvalidSnapshot("state root mismatch".into()));
269        }
270        Ok((envelope, content, records))
271    }
272
273    fn verify_historical_snapshot_authority(
274        &self,
275        envelope: &SignedSnapshot,
276        content: &SnapshotContent,
277    ) -> Result<(), DbError> {
278        let Some(descriptor) = self.inner.store.domain_descriptor(content.domain_id)? else {
279            if content.capability_id != crate::db::private_capability(content.domain_id) {
280                return Err(DbError::UnauthorizedDevice);
281            }
282            return Ok(());
283        };
284        let snapshot_control_sequence = match content.control_head {
285            Some(control_id) => {
286                let control = self
287                    .inner
288                    .store
289                    .control_transition(content.domain_id, control_id)?
290                    .ok_or_else(|| {
291                        DbError::InvalidSnapshot("snapshot authority anchor is not retained".into())
292                    })?;
293                if control.id()? != control_id || control.new_epoch() != content.epoch {
294                    return Err(DbError::InvalidSnapshot(
295                        "snapshot authority anchor does not authorize its epoch".into(),
296                    ));
297                }
298                if self
299                    .inner
300                    .store
301                    .accepted_control_at_sequence(content.domain_id, control.sequence())?
302                    .as_ref()
303                    .map(iroh_db_security::ControlTransition::id)
304                    .transpose()?
305                    != Some(control_id)
306                {
307                    return Err(DbError::InvalidSnapshot(
308                        "snapshot authority anchor was not accepted".into(),
309                    ));
310                }
311                control.sequence()
312            }
313            None if content.epoch == 0 => 0,
314            None => {
315                return Err(DbError::InvalidSnapshot(
316                    "snapshot epoch has no authority anchor".into(),
317                ));
318            }
319        };
320        let chain = self.inner.store.capability_chain(content.capability_id)?;
321        let Some(root) = chain.first() else {
322            return Err(DbError::UnauthorizedDevice);
323        };
324        let Some(leaf) = chain.last() else {
325            return Err(DbError::UnauthorizedDevice);
326        };
327        if root.domain_id() != content.domain_id
328            || root.subject() != descriptor.owner()
329            || leaf.subject() != envelope.author
330            || !leaf.permits(Permission::Snapshot, content.epoch)
331        {
332            return Err(DbError::UnauthorizedDevice);
333        }
334        if !self.historical_capability_anchors_accepted(
335            content.domain_id,
336            &chain,
337            snapshot_control_sequence,
338        )? {
339            return Err(DbError::UnauthorizedDevice);
340        }
341        let revocations: std::collections::BTreeMap<_, _> = self
342            .inner
343            .store
344            .subject_revocations(content.domain_id)?
345            .into_iter()
346            .collect();
347        if chain.iter().any(|capability| {
348            revocations
349                .get(&capability.subject())
350                .is_some_and(|revoked_at| {
351                    *revoked_at > capability.issued_control_sequence()
352                        && *revoked_at <= snapshot_control_sequence
353                })
354        }) {
355            return Err(DbError::UnauthorizedDevice);
356        }
357        Ok(())
358    }
359
360    fn historical_capability_anchors_accepted(
361        &self,
362        domain_id: iroh_db_core::DomainId,
363        chain: &[CapabilityCertificate],
364        snapshot_control_sequence: u64,
365    ) -> Result<bool, DbError> {
366        let checkpoint = self.inner.store.authority_checkpoint(domain_id)?;
367        let checkpoint_sequence = checkpoint
368            .as_ref()
369            .and_then(iroh_db_security::AuthorityCheckpoint::head)
370            .map(iroh_db_security::ControlTransition::sequence);
371        for capability in chain {
372            let sequence = capability.issued_control_sequence();
373            if sequence > snapshot_control_sequence {
374                return Ok(false);
375            }
376            let before_checkpoint = checkpoint_sequence.is_some_and(|cut| sequence < cut);
377            let anchor_is_accepted = if before_checkpoint {
378                let capability_id = capability.id()?;
379                checkpoint
380                    .as_ref()
381                    .is_some_and(|checkpoint| checkpoint.capability_ids().contains(&capability_id))
382            } else if sequence == 0 {
383                capability.issued_control_head().is_none()
384            } else {
385                let Some(control) = self
386                    .inner
387                    .store
388                    .accepted_control_at_sequence(domain_id, sequence)?
389                else {
390                    return Ok(false);
391                };
392                capability.issued_control_head() == Some(control.id()?)
393            };
394            if !anchor_is_accepted {
395                return Ok(false);
396            }
397        }
398        Ok(true)
399    }
400
401    /// Rebuilds all derived records and indexes from validated immutable commits.
402    pub async fn rebuild(&self) -> Result<[u8; 32], DbError> {
403        let _control_reader = self.inner.control_gate.read().await;
404        let _writer = self.inner.writer.lock().await;
405        let domain_id = self.domain_id();
406        let mutations = self.rebuild_mutations().await?;
407        self.inner
408            .store
409            .replace_materialized(domain_id, &mutations)?;
410        state_root(&mutations)
411    }
412
413    /// Computes the canonical root of current materialized state.
414    pub fn state_root(&self) -> Result<[u8; 32], DbError> {
415        let records = self.inner.store.export_materialized(self.domain_id())?;
416        state_root(&records)
417    }
418}
419
420fn validate_snapshot_metadata(content: &SnapshotContent) -> Result<(), DbError> {
421    if !strictly_sorted(&content.frontier)
422        || !strictly_sorted(&content.covered_commits)
423        || !content
424            .author_sequences
425            .windows(2)
426            .all(|pair| pair[0].0 < pair[1].0)
427        || content
428            .frontier
429            .iter()
430            .any(|commit_id| content.covered_commits.binary_search(commit_id).is_err())
431        || content.covered_commits.is_empty() != content.frontier.is_empty()
432        || content.covered_commits.is_empty() != content.author_sequences.is_empty()
433    {
434        return Err(DbError::InvalidSnapshot(
435            "snapshot causal metadata is malformed".into(),
436        ));
437    }
438    decode_activations(&content.schema_activations)?;
439    Ok(())
440}
441
442fn strictly_sorted<T: Ord>(values: &[T]) -> bool {
443    values.windows(2).all(|pair| pair[0] < pair[1])
444}
445
446fn control_head_id(db: &IrohDb) -> Result<Option<[u8; 32]>, DbError> {
447    db.inner
448        .store
449        .control_head(db.domain_id())?
450        .as_ref()
451        .map(iroh_db_security::ControlTransition::id)
452        .transpose()
453        .map_err(DbError::from)
454}
455
456fn encode_records(records: &[MaterializedRecord]) -> Result<Vec<Vec<u8>>, DbError> {
457    records
458        .iter()
459        .map(|record| record.encode_canonical().map_err(DbError::from))
460        .collect()
461}
462
463fn decode_records(records: &[Vec<u8>]) -> Result<Vec<MaterializedRecord>, DbError> {
464    records
465        .iter()
466        .map(|record| MaterializedRecord::decode_canonical(record).map_err(DbError::from))
467        .collect()
468}
469
470fn encode_activations(activations: &[SchemaActivation]) -> Vec<Vec<u8>> {
471    activations
472        .iter()
473        .map(SchemaActivation::encode_canonical)
474        .collect()
475}
476
477fn decode_activations(values: &[Vec<u8>]) -> Result<Vec<SchemaActivation>, DbError> {
478    let activations = values
479        .iter()
480        .map(|bytes| SchemaActivation::decode_canonical(bytes).map_err(DbError::from))
481        .collect::<Result<Vec<_>, _>>()?;
482    if activations
483        .windows(2)
484        .any(|pair| pair[0].to().collection_id() >= pair[1].to().collection_id())
485    {
486        return Err(DbError::InvalidSnapshot(
487            "schema activations are not unique and ordered".into(),
488        ));
489    }
490    Ok(activations)
491}
492
493fn state_root(records: &[MaterializedRecord]) -> Result<[u8; 32], DbError> {
494    let bytes = minicbor::to_vec(encode_records(records)?).map_err(snapshot_codec)?;
495    Ok(*blake3::hash(&bytes).as_bytes())
496}
497
498fn snapshot_signing_bytes(
499    content: &[u8],
500    state_root: &[u8; 32],
501    author: AuthorId,
502) -> Result<Vec<u8>, DbError> {
503    minicbor::to_vec((content, state_root, author)).map_err(snapshot_codec)
504}
505
506fn decode_canonical<T>(bytes: &[u8]) -> Result<T, DbError>
507where
508    T: minicbor::Encode<()> + for<'value> minicbor::Decode<'value, ()>,
509{
510    let mut decoder = minicbor::Decoder::new(bytes);
511    let value = decoder.decode().map_err(snapshot_codec)?;
512    if decoder.position() != bytes.len()
513        || minicbor::to_vec(&value).map_err(snapshot_codec)? != bytes
514    {
515        return Err(DbError::InvalidSnapshot("non-canonical encoding".into()));
516    }
517    Ok(value)
518}
519
520#[allow(clippy::needless_pass_by_value)]
521fn snapshot_codec(error: impl std::fmt::Display) -> DbError {
522    DbError::InvalidSnapshot(error.to_string())
523}
524
525fn unix_seconds() -> Result<u64, DbError> {
526    SystemTime::now()
527        .duration_since(UNIX_EPOCH)
528        .map(|duration| duration.as_secs())
529        .map_err(|_| DbError::Operation("system clock is before the Unix epoch".into()))
530}