Skip to main content

vta_backup/
restore.rs

1//! Applying a staged restore at boot.
2//!
3//! [`crate::ops::stage_import`] leaves a sealed restore in the `bootstrap`
4//! keyspace and has the target adopt the restored seed. Every VTA binary calls
5//! [`load_pending`] as soon as it knows its seed and storage key — before
6//! anything else reads the store — and, if a committed restore is waiting,
7//! applies it:
8//!
9//! ```text
10//! load_pending(store, seed)
11//!   └─ Ready(r) → r.apply(target, &mut config)   wipe, write, re-create the
12//!                                                 deployment-bound rows
13//!               → (caller rebuilds derived indexes)
14//!               → r.finish(target)               drop the stage
15//! ```
16//!
17//! Every step is idempotent and the stage is dropped last, so a boot
18//! interrupted anywhere simply applies the restore again.
19//!
20//! # What the target re-creates
21//!
22//! The backup carries no deployment-bound rows (`vta_keyspaces::
23//! ENVIRONMENT_BOUND_ROWS`); [`ReadyRestore::apply`] writes the target's own:
24//!
25//! | Target | Identity | JWT key | Also |
26//! |---|---|---|---|
27//! | plain | `config.toml` `vta_did` | `config.toml` | — |
28//! | hardened | `config.toml` `vta_did` | `keys` ▸ `hardened:jwt_key` | — |
29//! | TEE | `keys` ▸ `tee:vta_did`, `tee:did_log` | KMS row, committed at import | Mode-B carve-out closed; integrity manifest re-baselined |
30
31use base64::Engine;
32use base64::engine::general_purpose::URL_SAFE_NO_PAD as BASE64;
33use chrono::{DateTime, Utc};
34use serde::{Deserialize, Serialize};
35use tracing::{info, warn};
36
37use vta_keys::KeyOrigin;
38use vta_sdk::protocols::backup_management::types::{BackupEnvironment, BackupPayload};
39use vta_support::restore_stage::{self, AnchorBinding, OpenedStage, StageMeta, StageState};
40use vti_common::error::AppError;
41
42use crate::BackupTarget;
43
44/// Where a restore records that the node's state derives from it (`keys`
45/// keyspace, encrypted). VTI-VTA-051: a node MUST be able to report that its
46/// current state derives from a restore, and from when.
47pub const PROVENANCE_KEY: &str = "restore:provenance";
48/// Tells an enclave's boot to re-baseline the integrity manifest instead of
49/// verifying it (`keys` keyspace, encrypted, so the parent cannot forge one).
50pub const REBASELINE_KEY: &str = "restore:rebaseline";
51
52/// Mirror of `vta_tee::did_autogen::VTA_DID_STORE_KEY`. `vta-backup` sits
53/// beside `vta-tee` rather than above it; a drift test in `vta-service` holds
54/// the two equal.
55pub const TEE_VTA_DID_KEY: &str = "tee:vta_did";
56/// Mirror of `vta_tee::did_autogen::DID_LOG_STORE_KEY`.
57pub const TEE_DID_LOG_KEY: &str = "tee:did_log";
58/// Mirror of `vta_tee::admin_bootstrap::BOOTSTRAP_CARVEOUT_CLOSED_KEY`.
59pub const TEE_CARVEOUT_CLOSED_KEY: &str = "tee:bootstrap-carveout-closed";
60/// Mirror of `vta_service::hardened_bootstrap::HARDENED_JWT_KEY`.
61pub const HARDENED_JWT_KEY: &str = "hardened:jwt_key";
62
63const INTERNAL_KEY_PREFIX: &str = "internal:";
64
65/// The record VTI-VTA-051 asks for.
66#[derive(Debug, Clone, Serialize, Deserialize)]
67pub struct RestoreProvenance {
68    pub restore_id: String,
69    pub applied_at: DateTime<Utc>,
70    pub staged_at: DateTime<Utc>,
71    pub staged_by: String,
72    #[serde(default, skip_serializing_if = "Option::is_none")]
73    pub source_did: Option<String>,
74    #[serde(default, skip_serializing_if = "Option::is_none")]
75    pub source_environment: Option<BackupEnvironment>,
76    pub target_environment: BackupEnvironment,
77    /// Internal keys whose records came back without their material.
78    #[serde(default)]
79    pub internal_keys_lost: Vec<String>,
80    /// Hosted-DID registrations were detached because the restore replaced a
81    /// different identity; each needs `did-mgmt dids register` again.
82    #[serde(default)]
83    pub hosted_dids_detached: Vec<String>,
84    /// Whether the restore has been written to the audit trail yet. The
85    /// trail is part of the state the restore replaced, so the row is written
86    /// by the first boot that has an audit sink, not by the restore itself.
87    #[serde(default)]
88    pub audited: bool,
89}
90
91/// Read the node's restore provenance, if its state derives from a restore.
92pub async fn read_provenance(
93    target: &BackupTarget<'_>,
94) -> Result<Option<RestoreProvenance>, AppError> {
95    target
96        .keyspace(vta_keyspaces::KEYS)?
97        .get::<RestoreProvenance>(PROVENANCE_KEY)
98        .await
99}
100
101/// Record that the restore has been written to the audit trail.
102pub async fn mark_provenance_audited(target: &BackupTarget<'_>) -> Result<(), AppError> {
103    let keys = target.keyspace(vta_keyspaces::KEYS)?;
104    if let Some(mut p) = keys.get::<RestoreProvenance>(PROVENANCE_KEY).await? {
105        p.audited = true;
106        keys.insert(PROVENANCE_KEY, &p).await?;
107    }
108    Ok(())
109}
110
111/// Instruction to an enclave's boot to re-baseline its integrity manifest.
112#[derive(Debug, Clone, Serialize, Deserialize)]
113pub struct RebaselineMarker {
114    pub restore_id: String,
115    #[serde(default, skip_serializing_if = "Option::is_none")]
116    pub anchor: Option<AnchorBinding>,
117}
118
119/// The pending re-baseline, if a restore left one.
120pub async fn read_rebaseline_marker(
121    target: &BackupTarget<'_>,
122) -> Result<Option<RebaselineMarker>, AppError> {
123    target
124        .keyspace(vta_keyspaces::KEYS)?
125        .get::<RebaselineMarker>(REBASELINE_KEY)
126        .await
127}
128
129/// Drop the re-baseline marker once the manifest is sealed.
130pub async fn clear_rebaseline_marker(target: &BackupTarget<'_>) -> Result<(), AppError> {
131    let keys = target.keyspace(vta_keyspaces::KEYS)?;
132    keys.remove(REBASELINE_KEY).await?;
133    keys.persist().await
134}
135
136/// What boot found in the `bootstrap` keyspace.
137pub enum PendingRestore {
138    None,
139    /// A stage that does not open under this seed was dropped: its import never
140    /// reached the commit, so the VTA is unchanged.
141    Discarded {
142        restore_id: String,
143    },
144    Ready(Box<ReadyRestore>),
145}
146
147/// A committed restore waiting to be applied.
148pub struct ReadyRestore {
149    stage: OpenedStage,
150}
151
152/// Look for a staged restore that opens under `seed`. An uncommitted one is
153/// discarded here.
154pub async fn load_pending(
155    target: &BackupTarget<'_>,
156    seed: &[u8],
157) -> Result<PendingRestore, AppError> {
158    let bootstrap = target.bootstrap()?;
159    match restore_stage::open_stage(&bootstrap, seed).await? {
160        StageState::None => Ok(PendingRestore::None),
161        StageState::Uncommitted { restore_id } => {
162            warn!(
163                restore_id,
164                "discarding a staged restore that does not open under this VTA's seed — \
165                 its import never committed, so nothing was changed"
166            );
167            restore_stage::clear_stage(&bootstrap).await?;
168            Ok(PendingRestore::Discarded { restore_id })
169        }
170        StageState::Committed(stage) => Ok(PendingRestore::Ready(Box::new(ReadyRestore {
171            stage: *stage,
172        }))),
173    }
174}
175
176/// What applying a restore did, for the caller's follow-up and its log.
177pub struct AppliedRestore {
178    pub provenance: RestoreProvenance,
179    /// Whether `config` was changed (and, outside an enclave, saved).
180    pub config_changed: bool,
181}
182
183impl ReadyRestore {
184    pub fn meta(&self) -> &StageMeta {
185        &self.stage.meta
186    }
187
188    /// Replace the store's state with the restored one, under `target`'s
189    /// storage key, and re-create the rows `target` needs that a backup never
190    /// carries. Idempotent: an interrupted apply is simply run again.
191    ///
192    /// `config` is the running config. Outside an enclave the restored identity
193    /// is written to it and saved to `config.toml`; in an enclave config is
194    /// delivered by the parent each boot, so the identity is written to the
195    /// store instead and only mirrored into `config` for this boot.
196    pub async fn apply(
197        &self,
198        target: &BackupTarget<'_>,
199        config: &mut vta_config::AppConfig,
200    ) -> Result<AppliedRestore, AppError> {
201        let meta = &self.stage.meta;
202        let payload = &self.stage.payload;
203        if meta.target_environment != target.environment {
204            // The stage was committed for one kind of deployment and the VTA
205            // booted as another (its config changed in between). Applying it
206            // would write the identity and JWT key where this deployment does
207            // not look for them. Refuse, and say how to get back.
208            return Err(AppError::Config(format!(
209                "a restore ({}) was staged for a {} deployment but this VTA booted as {}. \
210                 Boot it with the configuration it had when the import was committed to \
211                 apply the restore.",
212                meta.restore_id, meta.target_environment, target.environment
213            )));
214        }
215        crate::ops::validate_payload(payload)?;
216        let seed = zeroize::Zeroizing::new(
217            hex::decode(&payload.active_seed_hex)
218                .map_err(|e| AppError::Validation(format!("backup seed is not hex: {e}")))?,
219        );
220
221        info!(
222            restore_id = %meta.restore_id,
223            source_did = payload.config.vta_did.as_deref().unwrap_or("unknown"),
224            environment = %target.environment,
225            "applying staged restore"
226        );
227
228        wipe(target).await?;
229        write_state(target, payload, &seed).await?;
230        let internal_keys_lost = reconcile_internal_keys(target).await?;
231        let hosted_dids_detached = if meta.cross_identity {
232            detach_hosted_dids(target).await?
233        } else {
234            Vec::new()
235        };
236        let config_changed = write_deployment_rows(target, meta, payload, config).await?;
237
238        let provenance = RestoreProvenance {
239            restore_id: meta.restore_id.clone(),
240            applied_at: Utc::now(),
241            staged_at: meta.staged_at,
242            staged_by: meta.staged_by.clone(),
243            source_did: payload.config.vta_did.clone(),
244            source_environment: payload.source_environment,
245            target_environment: target.environment,
246            internal_keys_lost,
247            hosted_dids_detached,
248            audited: false,
249        };
250        target
251            .keyspace(vta_keyspaces::KEYS)?
252            .insert(PROVENANCE_KEY, &provenance)
253            .await?;
254        target.store.persist().await?;
255
256        if !provenance.internal_keys_lost.is_empty() {
257            warn!(
258                keys = ?provenance.internal_keys_lost,
259                "restored internal-key records whose material was not in the backup — \
260                 these keys cannot sign and cannot be recovered"
261            );
262        }
263        if !provenance.hosted_dids_detached.is_empty() {
264            warn!(
265                dids = ?provenance.hosted_dids_detached,
266                "the restore replaced a different identity, so hosted-DID registrations \
267                 were detached; re-attach each with `did-mgmt dids register`"
268            );
269        }
270        Ok(AppliedRestore {
271            provenance,
272            config_changed,
273        })
274    }
275
276    /// Drop the stage. Call only after everything that depends on the restored
277    /// state (derived indexes) has been rebuilt: until then, a crash must leave
278    /// the stage in place so the next boot applies it again.
279    pub async fn finish(self, target: &BackupTarget<'_>) -> Result<(), AppError> {
280        restore_stage::clear_stage(&target.bootstrap()?).await?;
281        info!(restore_id = %self.stage.meta.restore_id, "staged restore applied");
282        Ok(())
283    }
284}
285
286/// Remove every row of every keyspace except `bootstrap` (the deployment's own
287/// boot material) and `internal_keys` (reconciled once the restored records
288/// are in place).
289///
290/// Keys are enumerated through bare handles: a row the previous storage key
291/// sealed cannot be decrypted under the new one, but it can be deleted.
292async fn wipe(target: &BackupTarget<'_>) -> Result<(), AppError> {
293    for name in vta_keyspaces::ALL {
294        if *name == vta_keyspaces::BOOTSTRAP || *name == vta_keyspaces::INTERNAL_KEYS {
295            continue;
296        }
297        let ks = target.store.keyspace(name)?;
298        for key in ks.prefix_keys(Vec::<u8>::new()).await? {
299            ks.remove(key).await?;
300        }
301    }
302    Ok(())
303}
304
305async fn write_state(
306    target: &BackupTarget<'_>,
307    payload: &BackupPayload,
308    seed: &[u8],
309) -> Result<(), AppError> {
310    if payload.keyspaces.is_empty() {
311        let keys = target.keyspace(vta_keyspaces::KEYS)?;
312        let acl = target.keyspace(vta_keyspaces::ACL)?;
313        let contexts = target.keyspace(vta_keyspaces::CONTEXTS)?;
314        let did_templates = target.keyspace(vta_keyspaces::DID_TEMPLATES)?;
315        let audit = target.keyspace(vta_keyspaces::AUDIT)?;
316        let imported = target.keyspace(vta_keyspaces::IMPORTED_SECRETS)?;
317        #[cfg(feature = "webvh")]
318        let webvh = target.keyspace(vta_keyspaces::WEBVH)?;
319        let ks = vta_keyspaces::Keyspaces {
320            keys: &keys,
321            acl: &acl,
322            contexts: &contexts,
323            did_templates: &did_templates,
324            audit: &audit,
325            imported: &imported,
326            #[cfg(feature = "webvh")]
327            webvh: &webvh,
328        };
329        return crate::ops::write_legacy_payload(payload, &ks, seed).await;
330    }
331    for dump in &payload.keyspaces {
332        let ks = target.keyspace(&dump.name)?;
333        for (k, v) in &dump.rows {
334            let key = BASE64
335                .decode(k)
336                .map_err(|e| AppError::Validation(format!("restored row key: {e}")))?;
337            // Validated before staging; checked again because this is the write.
338            if vta_keyspaces::is_environment_bound(&dump.name, &key) {
339                continue;
340            }
341            let value = zeroize::Zeroizing::new(
342                BASE64
343                    .decode(v)
344                    .map_err(|e| AppError::Validation(format!("restored row value: {e}")))?,
345            );
346            ks.insert_raw(key, value.to_vec()).await?;
347        }
348    }
349    Ok(())
350}
351
352/// Keep an internal key's material only where it belongs to a restored record
353/// *and* still opens under this storage key — a restore onto the VTA that took
354/// the backup keeps its internal keys; a restore anywhere else cannot, by
355/// design. Returns the restored internal-key records left without material.
356async fn reconcile_internal_keys(target: &BackupTarget<'_>) -> Result<Vec<String>, AppError> {
357    let keys = target.keyspace(vta_keyspaces::KEYS)?;
358    let mut restored_internal = std::collections::BTreeSet::new();
359    for key in keys.prefix_keys("key:").await? {
360        let Some(bytes) = keys.get_raw(key).await? else {
361            continue;
362        };
363        if let Ok(record) = serde_json::from_slice::<vta_sdk::keys::KeyRecord>(&bytes)
364            && record.origin == KeyOrigin::Internal
365        {
366            restored_internal.insert(record.key_id);
367        }
368    }
369
370    let bare = target.store.keyspace(vta_keyspaces::INTERNAL_KEYS)?;
371    let sealed = target.keyspace(vta_keyspaces::INTERNAL_KEYS)?;
372    let mut kept = std::collections::BTreeSet::new();
373    for key in bare.prefix_keys(Vec::<u8>::new()).await? {
374        let id = key
375            .strip_prefix(INTERNAL_KEY_PREFIX.as_bytes())
376            .and_then(|id| std::str::from_utf8(id).ok())
377            .map(str::to_owned);
378        let keep = match &id {
379            Some(id) if restored_internal.contains(id) => {
380                matches!(sealed.get_raw(key.clone()).await, Ok(Some(_)))
381            }
382            _ => false,
383        };
384        if keep {
385            kept.extend(id);
386        } else {
387            bare.remove(key).await?;
388        }
389    }
390    Ok(restored_internal.difference(&kept).cloned().collect())
391}
392
393/// The restore replaced a different identity: registrations on DID-hosting
394/// servers were made by the source, so re-publishing from here would clobber
395/// the source's slot (`webvh-rest-auth-audit.md` §H3). Detach them; the
396/// operator re-attaches each deliberately.
397async fn detach_hosted_dids(target: &BackupTarget<'_>) -> Result<Vec<String>, AppError> {
398    let webvh = target.keyspace(vta_keyspaces::WEBVH)?;
399    let mut detached = Vec::new();
400    for key in webvh.prefix_keys("did:").await? {
401        let Some(mut record) = webvh
402            .get::<vta_sdk::webvh::WebvhDidRecord>(key.clone())
403            .await?
404        else {
405            continue;
406        };
407        if record.server_id == "serverless" {
408            continue;
409        }
410        record.server_id = "serverless".into();
411        record.mnemonic = String::new();
412        webvh.insert(key, &record).await?;
413        detached.push(record.did);
414    }
415    Ok(detached)
416}
417
418/// Write what the target keeps outside the backed-up state: identity, JWT key,
419/// and — in an enclave — the carve-out and the re-baseline instruction.
420async fn write_deployment_rows(
421    target: &BackupTarget<'_>,
422    meta: &StageMeta,
423    payload: &BackupPayload,
424    config: &mut vta_config::AppConfig,
425) -> Result<bool, AppError> {
426    let keys = target.keyspace(vta_keyspaces::KEYS)?;
427    let restored = &payload.config;
428    match target.environment {
429        BackupEnvironment::Tee => {
430            if let Some(did) = &restored.vta_did {
431                // The enclave's identity lives in the store (config is
432                // parent-delivered each boot, and a stored identity wins over
433                // it — `did_autogen`), so this is what makes it stick.
434                keys.insert_raw(TEE_VTA_DID_KEY, did.as_bytes().to_vec())
435                    .await?;
436                if let Some(log) = target
437                    .keyspace(vta_keyspaces::WEBVH)?
438                    .get_raw(format!("log:{did}"))
439                    .await?
440                {
441                    keys.insert_raw(TEE_DID_LOG_KEY, log.clone()).await?;
442                    // The parent-side proxy reads the log from here to serve it.
443                    target.bootstrap()?.insert_raw(TEE_DID_LOG_KEY, log).await?;
444                }
445                config.vta_did = Some(did.clone());
446            }
447            // The restored ACL is the authority now. Leaving the single-use
448            // Mode-B carve-out open on a restored enclave would let anyone who
449            // reaches it first mint themselves an admin beside it.
450            keys.insert_raw(TEE_CARVEOUT_CLOSED_KEY, b"restored".to_vec())
451                .await?;
452            keys.insert(
453                REBASELINE_KEY,
454                &RebaselineMarker {
455                    restore_id: meta.restore_id.clone(),
456                    anchor: meta.anchor.clone(),
457                },
458            )
459            .await?;
460            // The JWT key was sealed under KMS at import; nothing to do here.
461            Ok(restored.vta_did.is_some())
462        }
463        BackupEnvironment::Hardened | BackupEnvironment::Plain => {
464            if let Some(jwt) = &payload.jwt_signing_key {
465                if target.environment == BackupEnvironment::Hardened {
466                    let key = BASE64
467                        .decode(jwt)
468                        .map_err(|e| AppError::Validation(format!("backup JWT key: {e}")))?;
469                    keys.insert_raw(HARDENED_JWT_KEY, key).await?;
470                } else {
471                    config.auth.jwt_signing_key = Some(jwt.clone());
472                }
473            }
474            if let Some(did) = &restored.vta_did {
475                config.vta_did = Some(did.clone());
476            }
477            if let Some(name) = &restored.vta_name {
478                config.vta_name = Some(name.clone());
479            }
480            // The restored DID document advertises these; the node has to be
481            // where its own document says it is.
482            if let Some(url) = &restored.public_url {
483                config.public_url = Some(url.clone());
484            }
485            if restored.mediator_url.is_some() || restored.mediator_did.is_some() {
486                let messaging =
487                    config
488                        .messaging
489                        .get_or_insert_with(|| vti_common::config::MessagingConfig {
490                            mediator_url: String::new(),
491                            mediator_did: String::new(),
492                            mediator_host: None,
493                            setup_acl: false,
494                            drain_inbox_on_start: false,
495                        });
496                if let Some(url) = &restored.mediator_url {
497                    messaging.mediator_url = url.clone();
498                }
499                if let Some(did) = &restored.mediator_did {
500                    messaging.mediator_did = did.clone();
501                }
502            }
503            // Idempotent, so a boot interrupted after this re-saves the same
504            // file. A config with no path (tests, in-memory) is not saved.
505            if !config.config_path.as_os_str().is_empty() {
506                config.save()?;
507            }
508            Ok(true)
509        }
510    }
511}