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        let memory = target.keyspace(vta_keyspaces::MEMORY)?;
318        let app_state = target.keyspace(vta_keyspaces::APP_STATE)?;
319        let internal = target.keyspace(vta_keyspaces::INTERNAL_KEYS)?;
320        let vault = target.keyspace(vta_keyspaces::VAULT)?;
321        let consent = target.keyspace(vta_keyspaces::CONSENT)?;
322        let consent_approvers = target.keyspace(vta_keyspaces::CONSENT_APPROVERS)?;
323        let persona = target.keyspace(vta_keyspaces::PERSONA)?;
324        let pcs_engine = target.keyspace(vta_keyspaces::PCS_ENGINE)?;
325        let notifications = target.keyspace(vta_keyspaces::NOTIFICATIONS)?;
326        let external_accounts = target.keyspace(vta_keyspaces::EXTERNAL_ACCOUNTS)?;
327        let external_secrets = target.keyspace(vta_keyspaces::EXTERNAL_SECRETS)?;
328        let collection = target.keyspace(vta_keyspaces::COLLECTION)?;
329        #[cfg(feature = "webvh")]
330        let webvh = target.keyspace(vta_keyspaces::WEBVH)?;
331        let ks = vta_keyspaces::Keyspaces {
332            keys: &keys,
333            acl: &acl,
334            contexts: &contexts,
335            did_templates: &did_templates,
336            audit: &audit,
337            imported: &imported,
338            memory: &memory,
339            app_state: &app_state,
340            internal: &internal,
341            vault: &vault,
342            consent: &consent,
343            consent_approvers: &consent_approvers,
344            persona: &persona,
345            pcs_engine: &pcs_engine,
346            notifications: &notifications,
347            external_accounts: &external_accounts,
348            external_secrets: &external_secrets,
349            collection: &collection,
350            #[cfg(feature = "webvh")]
351            webvh: &webvh,
352        };
353        return crate::ops::write_legacy_payload(payload, &ks, seed).await;
354    }
355    for dump in &payload.keyspaces {
356        let ks = target.keyspace(&dump.name)?;
357        for (k, v) in &dump.rows {
358            let key = BASE64
359                .decode(k)
360                .map_err(|e| AppError::Validation(format!("restored row key: {e}")))?;
361            // Validated before staging; checked again because this is the write.
362            if vta_keyspaces::is_environment_bound(&dump.name, &key) {
363                continue;
364            }
365            let value = zeroize::Zeroizing::new(
366                BASE64
367                    .decode(v)
368                    .map_err(|e| AppError::Validation(format!("restored row value: {e}")))?,
369            );
370            ks.insert_raw(key, value.to_vec()).await?;
371        }
372    }
373    Ok(())
374}
375
376/// Keep an internal key's material only where it belongs to a restored record
377/// *and* still opens under this storage key — a restore onto the VTA that took
378/// the backup keeps its internal keys; a restore anywhere else cannot, by
379/// design. Returns the restored internal-key records left without material.
380async fn reconcile_internal_keys(target: &BackupTarget<'_>) -> Result<Vec<String>, AppError> {
381    let keys = target.keyspace(vta_keyspaces::KEYS)?;
382    let mut restored_internal = std::collections::BTreeSet::new();
383    for key in keys.prefix_keys("key:").await? {
384        let Some(bytes) = keys.get_raw(key).await? else {
385            continue;
386        };
387        if let Ok(record) = serde_json::from_slice::<vta_sdk::keys::KeyRecord>(&bytes)
388            && record.origin == KeyOrigin::Internal
389        {
390            restored_internal.insert(record.key_id);
391        }
392    }
393
394    let bare = target.store.keyspace(vta_keyspaces::INTERNAL_KEYS)?;
395    let sealed = target.keyspace(vta_keyspaces::INTERNAL_KEYS)?;
396    let mut kept = std::collections::BTreeSet::new();
397    for key in bare.prefix_keys(Vec::<u8>::new()).await? {
398        let id = key
399            .strip_prefix(INTERNAL_KEY_PREFIX.as_bytes())
400            .and_then(|id| std::str::from_utf8(id).ok())
401            .map(str::to_owned);
402        let keep = match &id {
403            Some(id) if restored_internal.contains(id) => {
404                matches!(sealed.get_raw(key.clone()).await, Ok(Some(_)))
405            }
406            _ => false,
407        };
408        if keep {
409            kept.extend(id);
410        } else {
411            bare.remove(key).await?;
412        }
413    }
414    Ok(restored_internal.difference(&kept).cloned().collect())
415}
416
417/// The restore replaced a different identity: registrations on DID-hosting
418/// servers were made by the source, so re-publishing from here would clobber
419/// the source's slot (`webvh-rest-auth-audit.md` §H3). Detach them; the
420/// operator re-attaches each deliberately.
421async fn detach_hosted_dids(target: &BackupTarget<'_>) -> Result<Vec<String>, AppError> {
422    let webvh = target.keyspace(vta_keyspaces::WEBVH)?;
423    let mut detached = Vec::new();
424    for key in webvh.prefix_keys("did:").await? {
425        let Some(mut record) = webvh
426            .get::<vta_sdk::webvh::WebvhDidRecord>(key.clone())
427            .await?
428        else {
429            continue;
430        };
431        if record.server_id == "serverless" {
432            continue;
433        }
434        record.server_id = "serverless".into();
435        record.mnemonic = String::new();
436        webvh.insert(key, &record).await?;
437        detached.push(record.did);
438    }
439    Ok(detached)
440}
441
442/// Write what the target keeps outside the backed-up state: identity, JWT key,
443/// and — in an enclave — the carve-out and the re-baseline instruction.
444async fn write_deployment_rows(
445    target: &BackupTarget<'_>,
446    meta: &StageMeta,
447    payload: &BackupPayload,
448    config: &mut vta_config::AppConfig,
449) -> Result<bool, AppError> {
450    let keys = target.keyspace(vta_keyspaces::KEYS)?;
451    let restored = &payload.config;
452    match target.environment {
453        BackupEnvironment::Tee => {
454            if let Some(did) = &restored.vta_did {
455                // The enclave's identity lives in the store (config is
456                // parent-delivered each boot, and a stored identity wins over
457                // it — `did_autogen`), so this is what makes it stick.
458                keys.insert_raw(TEE_VTA_DID_KEY, did.as_bytes().to_vec())
459                    .await?;
460                if let Some(log) = target
461                    .keyspace(vta_keyspaces::WEBVH)?
462                    .get_raw(format!("log:{did}"))
463                    .await?
464                {
465                    keys.insert_raw(TEE_DID_LOG_KEY, log.clone()).await?;
466                    // The parent-side proxy reads the log from here to serve it.
467                    target.bootstrap()?.insert_raw(TEE_DID_LOG_KEY, log).await?;
468                }
469                config.vta_did = Some(did.clone());
470            }
471            // The restored ACL is the authority now. Leaving the single-use
472            // Mode-B carve-out open on a restored enclave would let anyone who
473            // reaches it first mint themselves an admin beside it.
474            keys.insert_raw(TEE_CARVEOUT_CLOSED_KEY, b"restored".to_vec())
475                .await?;
476            keys.insert(
477                REBASELINE_KEY,
478                &RebaselineMarker {
479                    restore_id: meta.restore_id.clone(),
480                    anchor: meta.anchor.clone(),
481                },
482            )
483            .await?;
484            // The JWT key was sealed under KMS at import; nothing to do here.
485            Ok(restored.vta_did.is_some())
486        }
487        BackupEnvironment::Hardened | BackupEnvironment::Plain => {
488            if let Some(jwt) = &payload.jwt_signing_key {
489                if target.environment == BackupEnvironment::Hardened {
490                    let key = BASE64
491                        .decode(jwt)
492                        .map_err(|e| AppError::Validation(format!("backup JWT key: {e}")))?;
493                    keys.insert_raw(HARDENED_JWT_KEY, key).await?;
494                } else {
495                    config.auth.jwt_signing_key = Some(jwt.clone());
496                }
497            }
498            if let Some(did) = &restored.vta_did {
499                config.vta_did = Some(did.clone());
500            }
501            if let Some(name) = &restored.vta_name {
502                config.vta_name = Some(name.clone());
503            }
504            // The restored DID document advertises these; the node has to be
505            // where its own document says it is.
506            if let Some(url) = &restored.public_url {
507                config.public_url = Some(url.clone());
508            }
509            if restored.mediator_url.is_some() || restored.mediator_did.is_some() {
510                let messaging =
511                    config
512                        .messaging
513                        .get_or_insert_with(|| vti_common::config::MessagingConfig {
514                            mediator_url: String::new(),
515                            mediator_did: String::new(),
516                            mediator_host: None,
517                            setup_acl: false,
518                            drain_inbox_on_start: false,
519                        });
520                if let Some(url) = &restored.mediator_url {
521                    messaging.mediator_url = url.clone();
522                }
523                if let Some(did) = &restored.mediator_did {
524                    messaging.mediator_did = did.clone();
525                }
526            }
527            // Idempotent, so a boot interrupted after this re-saves the same
528            // file. A config with no path (tests, in-memory) is not saved.
529            if !config.config_path.as_os_str().is_empty() {
530                config.save()?;
531            }
532            Ok(true)
533        }
534    }
535}