Skip to main content

vta_backup/ops/
mod.rs

1//! VTA backup export and import operations.
2//!
3//! Export: reads all keyspaces + seed, assembles a `BackupPayload`, encrypts
4//! with Argon2id + AES-256-GCM, and wraps in a `BackupEnvelope`.
5//!
6//! Import: decrypts the envelope, validates the payload, optionally previews,
7//! then replaces all keyspace data and updates the seed store.
8//!
9//! ## Sub-modules
10//!
11//! - [`descriptors`] — the 3-phase descriptor-pattern op layer for
12//!   the trust-task slice. Wraps the inline `export_backup` /
13//!   `preview_import` / `apply_import` functions below, decoupling
14//!   bulk byte transport from the JSON envelope. See
15//!   `docs/05-design-notes/backup-descriptor-pattern.md`.
16
17pub mod blob;
18pub mod descriptors;
19
20use std::sync::Arc;
21
22use aes_gcm::aead::Aead;
23use aes_gcm::{Aes256Gcm, KeyInit, Nonce};
24use argon2::Argon2;
25use base64::Engine;
26use base64::engine::general_purpose::URL_SAFE_NO_PAD as BASE64;
27use chrono::Utc;
28use tracing::info;
29
30use vta_keys::KeyOrigin;
31use vta_keys::imported;
32use vta_keys::seed_store::SeedStore;
33use vta_keys::seeds::{SeedRecord, get_active_seed_id, save_seed_record, set_active_seed_id};
34use vta_support::seal::{SealRecord, get_seal};
35use vti_common::auth::AuthClaims;
36use vti_common::error::AppError;
37use vti_common::store::{KeyspaceHandle, RawKvPair};
38
39use vta_sdk::protocols::backup_management::types::*;
40
41// ── Argon2id parameters (OWASP recommended) ────────────────────────
42
43const ARGON2_M_COST: u32 = 65536; // 64 MiB
44const ARGON2_T_COST: u32 = 3;
45const ARGON2_P_COST: u32 = 4;
46const SALT_LEN: usize = 32;
47const NONCE_LEN: usize = 12;
48
49// ── Argon2id parameter clamps (import-side defence) ────────────────
50//
51// `decrypt_backup` reads KDF parameters from the envelope itself —
52// without bounds, an attacker who can submit a backup can force a
53// memory bomb (`m_cost = u32::MAX` ≈ 4 TiB) or a trivially-fast KDF
54// for known-plaintext probes. On a Nitro Enclave with fixed memory,
55// a memory bomb is fatal. These bounds give honest backups generous
56// headroom (the OWASP profile sits well within them) while rejecting
57// adversarial values.
58
59/// Maximum memory cost (in KiB) accepted on import. 1 GiB.
60const MAX_M_COST: u32 = 1 << 20;
61/// Minimum memory cost (in KiB) accepted on import. 8 MiB — well below
62/// the OWASP recommendation, here only to reject the m=1 footgun.
63const MIN_M_COST: u32 = 8 * 1024;
64/// Maximum iteration count.
65const MAX_T_COST: u32 = 10;
66/// Minimum iteration count.
67const MIN_T_COST: u32 = 1;
68/// Maximum parallelism factor.
69const MAX_P_COST: u32 = 16;
70/// Minimum parallelism factor.
71const MIN_P_COST: u32 = 1;
72
73// ── Export ──────────────────────────────────────────────────────────
74
75/// Assemble and encrypt a backup of the entire VTA state.
76pub async fn export_backup(
77    ks: &vta_keyspaces::Keyspaces<'_>,
78    seed_store: &dyn SeedStore,
79    config: &vta_config::AppConfig,
80    auth: &AuthClaims,
81    password: &str,
82    include_audit: bool,
83) -> Result<BackupEnvelope, AppError> {
84    // The keyspaces captured here are exactly `vta_keyspaces::BACKED_UP`
85    // ({keys, acl, contexts, audit, imported_secrets, webvh}). That registry
86    // partitions every keyspace into BACKED_UP vs EXCLUDED_FROM_BACKUP and a
87    // guard test (`keyspaces::tests::backup_partition_is_total`) keeps the
88    // partition total, so a newly-added keyspace can't be silently dropped from
89    // the backup decision. If you add a keyspace to BACKED_UP, wire it in here.
90    let keys_ks = ks.keys;
91    let acl_ks = ks.acl;
92    let contexts_ks = ks.contexts;
93    let audit_ks = ks.audit;
94    let imported_ks = ks.imported;
95    #[cfg(feature = "webvh")]
96    let webvh_ks = ks.webvh;
97    auth.require_super_admin()?;
98
99    vta_sdk::protocols::backup_management::validate_backup_password(password)
100        .map_err(AppError::Validation)?;
101
102    // 1. Collect the active seed
103    let seed_bytes = seed_store
104        .get()
105        .await
106        .map_err(|e| AppError::Internal(format!("seed store: {e}")))?
107        .ok_or_else(|| AppError::Internal("no active seed available".into()))?;
108    let active_seed_hex = hex::encode(&seed_bytes);
109    let active_seed_id = get_active_seed_id(keys_ks)
110        .await
111        .map_err(|e| AppError::Internal(format!("get active seed id: {e}")))?;
112
113    // A backup must be COMPLETE: unlike the steady-state list paths (which
114    // skip a corrupt row so one bad entry can't break management), export
115    // FAILS LOUDLY on any row it cannot deserialize. Silently omitting a
116    // key or ACL row from a backup loses key material or an admin grant —
117    // far worse than refusing to take the backup.
118    fn corrupt_row(kind: &str, key: &[u8], e: impl std::fmt::Display) -> AppError {
119        AppError::Internal(format!(
120            "backup aborted: {kind} row '{}' is corrupt and would be silently \
121             omitted from the backup: {e}",
122            String::from_utf8_lossy(key)
123        ))
124    }
125
126    // 2. Collect seed records (retired seeds)
127    let seed_records: Vec<SeedRecordBackup> = {
128        let raw = keys_ks.prefix_iter_raw("seed:").await?;
129        let mut records = Vec::with_capacity(raw.len());
130        for (key, value) in raw {
131            let sr: SeedRecord =
132                serde_json::from_slice(&value).map_err(|e| corrupt_row("seed", &key, e))?;
133            records.push(SeedRecordBackup {
134                id: sr.id,
135                seed_hex: sr.seed_hex,
136                seed_enc: sr.seed_enc,
137                created_at: sr.created_at,
138                retired_at: sr.retired_at,
139            });
140        }
141        records
142    };
143
144    // 3. Collect key records
145    let key_records: Vec<vta_sdk::keys::KeyRecord> = {
146        let raw = keys_ks.prefix_iter_raw("key:").await?;
147        let mut out = Vec::with_capacity(raw.len());
148        for (key, value) in raw {
149            out.push(serde_json::from_slice(&value).map_err(|e| corrupt_row("key", &key, e))?);
150        }
151        out
152    };
153
154    // 4. Collect context records + counter
155    let context_records: Vec<vta_sdk::contexts::ContextRecord> = {
156        let raw = contexts_ks.prefix_iter_raw("ctx:").await?;
157        let mut out = Vec::with_capacity(raw.len());
158        for (key, value) in raw {
159            out.push(serde_json::from_slice(&value).map_err(|e| corrupt_row("context", &key, e))?);
160        }
161        out
162    };
163    let context_counter: u32 = contexts_ks
164        .get_raw("ctx_counter")
165        .await?
166        .and_then(|b| b.try_into().ok().map(u32::from_le_bytes))
167        .unwrap_or(0);
168
169    // 4b. Collect the BIP-32 allocation counters so a restore onto a fresh
170    // store cannot re-derive private keys / context subtrees that restored
171    // records already occupy (P0.5). `path_counter:{base}` lives in the keys
172    // keyspace; `ctx_counter:{parent}` (per-parent sub-context counters) live
173    // in the contexts keyspace alongside the top-level `ctx_counter`.
174    let read_u32_counters = |pairs: Vec<RawKvPair>| -> Vec<(String, u32)> {
175        pairs
176            .into_iter()
177            .filter_map(|(k, v)| {
178                let key = String::from_utf8(k).ok()?;
179                let arr: [u8; 4] = v.try_into().ok()?;
180                Some((key, u32::from_le_bytes(arr)))
181            })
182            .collect()
183    };
184    let path_counters = read_u32_counters(keys_ks.prefix_iter_raw("path_counter:").await?);
185    let subcontext_counters = read_u32_counters(contexts_ks.prefix_iter_raw("ctx_counter:").await?);
186
187    // 5. Collect ACL entries. Two forms, both from the same scan:
188    //   - `acl_entries_full`: the stored `AclEntry` JSON verbatim (lossless).
189    //   - `acl_entries`: the legacy 6-field projection (kept for
190    //     forward/backward compatibility; the importer prefers the full form).
191    // A row that can't be parsed aborts the backup rather than being silently
192    // dropped (an incomplete backup loses an admin grant) — see P0.14.
193    let (acl_entries, acl_entries_full): (Vec<AclEntryBackup>, Vec<serde_json::Value>) = {
194        let raw = acl_ks.prefix_iter_raw("acl:").await?;
195        let mut lossy = Vec::with_capacity(raw.len());
196        let mut full = Vec::with_capacity(raw.len());
197        for (key, v) in raw {
198            let val: serde_json::Value =
199                serde_json::from_slice(&v).map_err(|e| corrupt_row("ACL", &key, e))?;
200            lossy.push(AclEntryBackup {
201                did: val["did"].as_str().unwrap_or_default().to_string(),
202                role: val["role"].as_str().unwrap_or("Reader").to_string(),
203                label: val["label"].as_str().map(String::from),
204                allowed_contexts: val["allowed_contexts"]
205                    .as_array()
206                    .map(|a| {
207                        a.iter()
208                            .filter_map(|v| v.as_str().map(String::from))
209                            .collect()
210                    })
211                    .unwrap_or_default(),
212                created_at: val["created_at"].as_u64().unwrap_or(0),
213                created_by: val["created_by"].as_str().unwrap_or_default().to_string(),
214            });
215            full.push(val);
216        }
217        (lossy, full)
218    };
219
220    // 6. Collect seal record
221    let seal = get_seal(acl_ks)
222        .await
223        .ok()
224        .flatten()
225        .map(|s| SealRecordBackup {
226            sealed_by: s.sealed_by,
227            sealed_at: s.sealed_at,
228            reason: s.reason,
229        });
230
231    // 7. Collect WebVH records
232    #[cfg(feature = "webvh")]
233    let (webvh_servers, webvh_dids, webvh_logs) = {
234        let servers: Vec<vta_sdk::webvh::WebvhServerRecord> = webvh_ks
235            .prefix_iter_raw("server:")
236            .await?
237            .into_iter()
238            .filter_map(|(_, v)| serde_json::from_slice(&v).ok())
239            .collect();
240        let dids: Vec<vta_sdk::webvh::WebvhDidRecord> = webvh_ks
241            .prefix_iter_raw("did:")
242            .await?
243            .into_iter()
244            .filter_map(|(_, v)| serde_json::from_slice(&v).ok())
245            .collect();
246        let logs: Vec<WebvhLogBackup> = webvh_ks
247            .prefix_iter_raw("log:")
248            .await?
249            .into_iter()
250            .filter_map(|(k, v)| {
251                let did = String::from_utf8(k).ok()?.strip_prefix("log:")?.to_string();
252                let log_json = String::from_utf8(v).ok()?;
253                Some(WebvhLogBackup { did, log_json })
254            })
255            .collect();
256        (servers, dids, logs)
257    };
258    #[cfg(not(feature = "webvh"))]
259    let (webvh_servers, webvh_dids, webvh_logs) = (Vec::new(), Vec::new(), Vec::new());
260
261    // 8. Collect audit logs (optional)
262    let audit_logs = if include_audit {
263        let raw = audit_ks.prefix_iter_raw("log:").await?;
264        raw.into_iter()
265            .filter_map(|(_, v)| serde_json::from_slice(&v).ok())
266            .collect()
267    } else {
268        Vec::new()
269    };
270
271    // 9. Config snapshot
272    let backup_config = BackupConfig {
273        vta_did: config.vta_did.clone(),
274        vta_name: config.vta_name.clone(),
275        public_url: config.public_url.clone(),
276        mediator_url: config.messaging.as_ref().map(|m| m.mediator_url.clone()),
277        mediator_did: config.messaging.as_ref().map(|m| m.mediator_did.clone()),
278    };
279
280    // 10. JWT signing key
281    let jwt_signing_key = config.auth.jwt_signing_key.clone();
282
283    // 11. Collect imported secrets
284    let imported_kek_salt = imported::get_salt(keys_ks).await?.map(hex::encode);
285    let imported_secrets = {
286        let mut secrets = Vec::new();
287        for kr in &key_records {
288            if kr.origin == KeyOrigin::Imported
289                && kr.status == vta_sdk::keys::KeyStatus::Active
290                && let Ok(mut plaintext) = imported::load_secret(
291                    imported_ks,
292                    keys_ks,
293                    &seed_bytes,
294                    &kr.key_id,
295                    &kr.key_type.to_string(),
296                )
297                .await
298            {
299                secrets.push(ImportedSecretBackup {
300                    key_id: kr.key_id.clone(),
301                    private_key_hex: hex::encode(&plaintext),
302                });
303                use zeroize::Zeroize;
304                plaintext.zeroize();
305            }
306        }
307        secrets
308    };
309
310    // Assemble payload
311    let payload = BackupPayload {
312        active_seed_hex,
313        active_seed_id,
314        seed_records,
315        jwt_signing_key,
316        key_records,
317        context_records,
318        context_counter,
319        path_counters,
320        subcontext_counters,
321        acl_entries,
322        acl_entries_full,
323        seal,
324        webvh_servers,
325        webvh_dids,
326        webvh_logs,
327        config: backup_config,
328        audit_logs,
329        imported_secrets,
330        imported_kek_salt,
331    };
332
333    // Encrypt
334    let envelope = encrypt_payload(&payload, password, include_audit, config)?;
335
336    info!(
337        keys = payload.key_records.len(),
338        acls = payload.acl_entries.len(),
339        contexts = payload.context_records.len(),
340        audit = payload.audit_logs.len(),
341        "backup exported"
342    );
343
344    Ok(envelope)
345}
346
347// ── Import ─────────────────────────────────────────────────────────
348
349/// Decrypt and validate a backup, returning a preview without modifying state.
350pub async fn preview_import(
351    envelope: &BackupEnvelope,
352    password: &str,
353) -> Result<(BackupPayload, ImportResult), AppError> {
354    let payload = decrypt_backup(envelope, password)?;
355
356    let result = ImportResult {
357        status: "preview".into(),
358        source_did: payload.config.vta_did.clone(),
359        key_count: payload.key_records.len(),
360        acl_count: payload.acl_entries.len(),
361        context_count: payload.context_records.len(),
362        audit_count: payload.audit_logs.len(),
363        imported_secret_count: payload.imported_secrets.len(),
364        message: Some("Preview only — no changes applied. Set confirm=true to import.".into()),
365    };
366
367    Ok((payload, result))
368}
369
370/// Reject an import if the backup's `vta_did` would overwrite a
371/// different running VTA's identity. A fresh install (no running
372/// `vta_did`) accepts any backup — this covers disaster recovery from
373/// a completely lost VTA. An identity migration (deliberately
374/// replacing one VTA DID with another) requires the operator to clear
375/// `vta_did` from the running config first.
376fn check_vta_did_compatibility(
377    running_did: Option<&str>,
378    backup_did: Option<&str>,
379) -> Result<(), AppError> {
380    let running = match running_did {
381        Some(d) if !d.is_empty() => d,
382        _ => return Ok(()),
383    };
384    let backup = backup_did.unwrap_or("");
385    if backup == running {
386        return Ok(());
387    }
388    Err(AppError::Validation(format!(
389        "backup vta_did mismatch: backup claims '{backup}' but this VTA is running \
390         as '{running}'. Refusing to overwrite identity. If this is intentional \
391         (identity migration), clear vta_did from the running config first."
392    )))
393}
394
395/// Key under which `apply_import` records that a destructive import is in
396/// flight. Written (and persisted) before the keyspaces are cleared and
397/// removed only after every record is back; if a crash interrupts the
398/// import, this survives and boot refuses to start on the resulting hybrid
399/// state (see `vta-service`'s `server::run`). Lives in the keys keyspace under a
400/// prefix no clear/scan touches.
401pub const IMPORT_IN_PROGRESS_KEY: &str = "backup:import_in_progress";
402
403/// Recompute `path_counter:{base}` values from restored key records, so an
404/// old (pre-P0.5) backup with no exported counters still can't re-derive an
405/// in-use BIP-32 path. For each derived key (`derivation_path` = `{base}/{n}'`)
406/// the counter for `base` must be at least `n + 1`.
407fn recompute_path_counters(
408    key_records: &[vta_sdk::keys::KeyRecord],
409) -> std::collections::HashMap<String, u32> {
410    let mut counters: std::collections::HashMap<String, u32> = std::collections::HashMap::new();
411    for kr in key_records {
412        let path = kr.derivation_path.trim();
413        if path.is_empty() {
414            continue; // imported (non-derived) key — no allocation counter
415        }
416        if let Some((base, last)) = path.rsplit_once('/')
417            && let Ok(index) = last.trim_end_matches('\'').parse::<u32>()
418        {
419            let next = index.saturating_add(1);
420            let slot = counters.entry(base.to_string()).or_insert(0);
421            *slot = (*slot).max(next);
422        }
423    }
424    counters
425}
426
427/// Recompute `ctx_counter:{parent}` values from restored context records. A
428/// sub-context carries its per-parent `index`; the counter for that parent
429/// must be at least `index + 1`.
430fn recompute_subcontext_counters(
431    context_records: &[vta_sdk::contexts::ContextRecord],
432) -> std::collections::HashMap<String, u32> {
433    let mut counters: std::collections::HashMap<String, u32> = std::collections::HashMap::new();
434    for cr in context_records {
435        if let Some(parent) = cr.parent.as_deref() {
436            let next = cr.index.saturating_add(1);
437            let slot = counters.entry(parent.to_string()).or_insert(0);
438            *slot = (*slot).max(next);
439        }
440    }
441    counters
442}
443
444/// Apply an import: clears all keyspaces and writes the backup data.
445///
446/// When `store` and TEE KMS config are provided, re-encrypts the imported
447/// seed and JWT key with KMS for the bootstrap keyspace. The `store`
448/// parameter is therefore only consumed under `feature = "tee"`; non-TEE
449/// builds receive `None` and silently skip step 12.
450///
451/// **vta_did guard**: if the running VTA already has a vta_did in config
452/// and it differs from the backup's, the import is rejected — a foreign
453/// backup replacing a live VTA's state is almost certainly an operator
454/// mistake. A fresh install (no vta_did yet) accepts any backup; this
455/// covers the legitimate disaster-recovery path. To deliberately migrate
456/// an identity, clear the running config first.
457///
458/// The caller is responsible for triggering a soft restart after this returns.
459#[cfg_attr(not(feature = "tee"), allow(unused_variables))]
460pub async fn apply_import(
461    payload: &BackupPayload,
462    ks: &vta_keyspaces::Keyspaces<'_>,
463    seed_store: &Arc<dyn SeedStore>,
464    config: &tokio::sync::RwLock<vta_config::AppConfig>,
465    store: Option<&vti_common::store::Store>,
466    #[cfg(feature = "tee")] re_encryptor: Option<&dyn crate::BootstrapReEncryptor>,
467) -> Result<ImportResult, AppError> {
468    // vta_did cross-check: refuse to overwrite a different VTA's
469    // identity with this backup. A fresh install (running_did is None)
470    // accepts any backup.
471    {
472        let running_did = config.read().await.vta_did.clone();
473        check_vta_did_compatibility(running_did.as_deref(), payload.config.vta_did.as_deref())?;
474    }
475
476    let keys_ks = ks.keys;
477    let acl_ks = ks.acl;
478    let contexts_ks = ks.contexts;
479    let audit_ks = ks.audit;
480    let imported_ks = ks.imported;
481    #[cfg(feature = "webvh")]
482    let webvh_ks = ks.webvh;
483
484    // Crash-safety sentinel (P0.5): record that a destructive import has
485    // begun, and fsync it, BEFORE clearing anything. If a crash interrupts
486    // the rewrite below, the store is left in a hybrid half-imported state;
487    // this marker survives and boot refuses to start on it (server::run)
488    // rather than running on corrupt state. Removed + fsynced only after the
489    // import fully completes.
490    keys_ks
491        .insert_raw(IMPORT_IN_PROGRESS_KEY, b"1".to_vec())
492        .await?;
493    keys_ks.persist().await?;
494
495    // 1. Clear all keyspaces. `path_counter:` (keys) and `ctx_counter:`
496    // (per-parent sub-context counters, contexts) are cleared too so a
497    // re-import over a dirty store can't leave a stale counter that would
498    // re-allocate an in-use BIP-32 index (P0.5).
499    clear_keyspace(keys_ks, &["key:", "seed:", "path_counter:"]).await?;
500    clear_keyspace(acl_ks, &["acl:", "vta:"]).await?;
501    clear_keyspace(contexts_ks, &["ctx:", "ctx_counter:"]).await?;
502    clear_keyspace(audit_ks, &["log:"]).await?;
503    clear_keyspace(imported_ks, &["secret:"]).await?;
504    #[cfg(feature = "webvh")]
505    clear_keyspace(
506        webvh_ks,
507        // `server-auth:` is explicitly included so that a restore
508        // wipes any cached daemon-REST tokens before installing the
509        // backed-up server registry. Tokens never travel in the
510        // backup payload itself (the export path scans `server:`
511        // only — `server-auth:` is service-local secret material),
512        // so a fresh import correctly leaves us un-authenticated to
513        // every daemon and forces a re-authenticate on first use.
514        &["server:", "server-auth:", "did:", "log:"],
515    )
516    .await?;
517
518    // Also remove counters
519    let _ = keys_ks.remove("active_seed_id").await;
520    let _ = contexts_ks.remove("ctx_counter").await;
521
522    // 2. Write seed to external store
523    let seed_bytes = hex::decode(&payload.active_seed_hex)
524        .map_err(|e| AppError::Internal(format!("invalid seed hex in backup: {e}")))?;
525    seed_store
526        .set(&seed_bytes)
527        .await
528        .map_err(|e| AppError::Internal(format!("seed store: {e}")))?;
529
530    // 3. Write active_seed_id
531    set_active_seed_id(keys_ks, payload.active_seed_id)
532        .await
533        .map_err(|e| AppError::Internal(format!("set active seed id: {e}")))?;
534
535    // 4. Write seed records
536    for sr in &payload.seed_records {
537        let record = SeedRecord {
538            id: sr.id,
539            seed_hex: sr.seed_hex.clone(),
540            seed_enc: sr.seed_enc.clone(),
541            created_at: sr.created_at,
542            retired_at: sr.retired_at,
543        };
544        save_seed_record(keys_ks, &record)
545            .await
546            .map_err(|e| AppError::Internal(format!("save seed record: {e}")))?;
547    }
548
549    // 5. Write key records
550    for kr in &payload.key_records {
551        keys_ks.insert(vta_keys::store_key(&kr.key_id), kr).await?;
552    }
553
554    // 6. Write context records + counters
555    for cr in &payload.context_records {
556        contexts_ks.insert(format!("ctx:{}", cr.id), cr).await?;
557    }
558    contexts_ks
559        .insert_raw("ctx_counter", &payload.context_counter.to_le_bytes())
560        .await?;
561
562    // 6b. Restore the BIP-32 allocation counters (P0.5). Take the MAX of the
563    // exported value (exact — preserves gaps left by deleted keys) and the
564    // value recomputed from the restored records (the only source for a
565    // pre-P0.5 backup that has no exported counters). Either alone could
566    // under-count and re-derive an in-use key/subtree; the max never does.
567    {
568        let mut path_counters = recompute_path_counters(&payload.key_records);
569        for (k, v) in &payload.path_counters {
570            // Exported keys are the full `path_counter:{base}`; recompute is
571            // keyed by bare `{base}`. Normalise to bare base for the merge.
572            let base = k.strip_prefix("path_counter:").unwrap_or(k).to_string();
573            let slot = path_counters.entry(base).or_insert(0);
574            *slot = (*slot).max(*v);
575        }
576        for (base, next) in path_counters {
577            keys_ks
578                .insert_raw(format!("path_counter:{base}"), next.to_le_bytes().to_vec())
579                .await?;
580        }
581
582        let mut sub_counters = recompute_subcontext_counters(&payload.context_records);
583        for (k, v) in &payload.subcontext_counters {
584            let parent = k.strip_prefix("ctx_counter:").unwrap_or(k).to_string();
585            let slot = sub_counters.entry(parent).or_insert(0);
586            *slot = (*slot).max(*v);
587        }
588        for (parent, next) in sub_counters {
589            contexts_ks
590                .insert_raw(format!("ctx_counter:{parent}"), next.to_le_bytes().to_vec())
591                .await?;
592        }
593    }
594
595    // 7. Write ACL entries. Prefer the lossless full-JSON form
596    // (`acl_entries_full`) so expiry / step-up floors / capabilities / kind /
597    // device / version survive; fall back to the lossy 6-field `acl_entries`
598    // only for a pre-P0.5 backup that carries no full form.
599    if !payload.acl_entries_full.is_empty() {
600        for entry in &payload.acl_entries_full {
601            let did = entry
602                .get("did")
603                .and_then(|d| d.as_str())
604                .ok_or_else(|| AppError::Internal("backup ACL entry has no `did` field".into()))?;
605            let bytes = serde_json::to_vec(entry)?;
606            acl_ks.insert_raw(format!("acl:{did}"), bytes).await?;
607        }
608    } else {
609        if !payload.acl_entries.is_empty() {
610            tracing::warn!(
611                count = payload.acl_entries.len(),
612                "restoring ACL from a pre-P0.5 backup's lossy form — expiry, step-up \
613                 floors, and capability restrictions are not present and default to \
614                 permanent/none. Re-export with this build for a lossless backup."
615            );
616            for entry in &payload.acl_entries {
617                acl_ks.insert(format!("acl:{}", entry.did), entry).await?;
618            }
619        }
620    }
621
622    // 8. Write seal record
623    if let Some(ref seal) = payload.seal {
624        let record = SealRecord {
625            sealed_by: seal.sealed_by.clone(),
626            sealed_at: seal.sealed_at,
627            reason: seal.reason.clone(),
628        };
629        acl_ks.insert("vta:sealed", &record).await?;
630    }
631
632    // 9. Write WebVH records.
633    //
634    // Cross-VTA disaster recovery (audit H3): if we're importing
635    // someone else's backup (running_did present and != backup_did,
636    // *or* backup carries no vta_did), strip `server_id`/`mnemonic`
637    // off every imported `WebvhDidRecord` before persisting. The
638    // imported daemon registrations still belong to the source VTA;
639    // re-publishing from this VTA would clobber the source's slot.
640    // Operator must explicitly re-`register_did_with_server` per
641    // imported DID. See `docs/05-design-notes/webvh-rest-auth-audit.md`
642    // §H3.
643    #[cfg(feature = "webvh")]
644    {
645        let backup_vta_did = payload.config.vta_did.as_deref();
646        let running_vta_did = config.read().await.vta_did.clone();
647        let cross_vta_restore = match (running_vta_did.as_deref(), backup_vta_did) {
648            (None, _) => false,      // fresh install — backup is authoritative
649            (Some(_), None) => true, // running but backup missing identity
650            (Some(running), Some(backup)) => running != backup, // operator-confirmed swap
651        };
652
653        for server in &payload.webvh_servers {
654            webvh_ks
655                .insert(format!("server:{}", server.id), server)
656                .await?;
657        }
658        for did_rec in &payload.webvh_dids {
659            let mut record = did_rec.clone();
660            if cross_vta_restore && record.server_id != "serverless" {
661                tracing::warn!(
662                    did = %record.did,
663                    original_server = %record.server_id,
664                    "cross-VTA restore: stripping server_id/mnemonic from imported WebvhDidRecord; \
665                     operator must `register_did_with_server` to re-attach to this VTA",
666                );
667                record.server_id = "serverless".to_string();
668                record.mnemonic = String::new();
669            }
670            webvh_ks
671                .insert(format!("did:{}", record.did), &record)
672                .await?;
673        }
674        for log in &payload.webvh_logs {
675            webvh_ks
676                .insert_raw(format!("log:{}", log.did), log.log_json.as_bytes())
677                .await?;
678        }
679    }
680
681    // 10. Write audit logs
682    for entry in &payload.audit_logs {
683        audit_ks
684            .insert(format!("log:{:020}:{}", entry.timestamp, entry.id), entry)
685            .await?;
686    }
687
688    // 11. Restore imported secrets
689    if !payload.imported_secrets.is_empty() {
690        // Restore the KEK salt (or create a new one)
691        if let Some(ref salt_hex) = payload.imported_kek_salt {
692            let salt = hex::decode(salt_hex)
693                .map_err(|e| AppError::Internal(format!("invalid imported KEK salt hex: {e}")))?;
694            imported::set_salt(keys_ks, &salt).await?;
695        }
696
697        for secret_backup in &payload.imported_secrets {
698            let private_bytes = hex::decode(&secret_backup.private_key_hex)
699                .map_err(|e| AppError::Internal(format!("invalid imported secret hex: {e}")))?;
700
701            // Find the matching key record for AAD
702            let key_type_str = payload
703                .key_records
704                .iter()
705                .find(|kr| kr.key_id == secret_backup.key_id)
706                .map(|kr| kr.key_type.to_string())
707                .unwrap_or_else(|| "ed25519".to_string());
708
709            imported::store_secret(
710                imported_ks,
711                keys_ks,
712                &seed_bytes,
713                &secret_backup.key_id,
714                &key_type_str,
715                &private_bytes,
716            )
717            .await?;
718        }
719    }
720
721    // 12. Update config
722    {
723        let mut cfg = config.write().await;
724        if let Some(ref did) = payload.config.vta_did {
725            cfg.vta_did = Some(did.clone());
726        }
727        if let Some(ref name) = payload.config.vta_name {
728            cfg.vta_name = Some(name.clone());
729        }
730        if let Some(ref url) = payload.config.public_url {
731            cfg.public_url = Some(url.clone());
732        }
733        if let Some(ref jwt) = payload.jwt_signing_key {
734            cfg.auth.jwt_signing_key = Some(jwt.clone());
735        }
736        if payload.config.mediator_url.is_some() || payload.config.mediator_did.is_some() {
737            let messaging =
738                cfg.messaging
739                    .get_or_insert_with(|| vti_common::config::MessagingConfig {
740                        mediator_url: String::new(),
741                        mediator_did: String::new(),
742                        mediator_host: None,
743                        setup_acl: false,
744                        drain_inbox_on_start: false,
745                    });
746            if let Some(ref url) = payload.config.mediator_url {
747                messaging.mediator_url = url.clone();
748            }
749            if let Some(ref did) = payload.config.mediator_did {
750                messaging.mediator_did = did.clone();
751            }
752        }
753    }
754
755    // 12. TEE: re-encrypt seed + JWT key with KMS for bootstrap keyspace
756    #[cfg(feature = "tee")]
757    if let Some(store) = store {
758        let cfg = config.read().await;
759        if let vta_config::TeeMode::Required = cfg.tee.mode
760            && let Some(ref kms_config) = cfg.tee.kms
761        {
762            let jwt_key_bytes: Option<[u8; 32]> =
763                payload.jwt_signing_key.as_ref().and_then(|b64| {
764                    base64::Engine::decode(&BASE64, b64)
765                        .ok()
766                        .and_then(|b| b.try_into().ok())
767                });
768            if let Some(jwt_key) = jwt_key_bytes {
769                // The KMS call lives in `vta-service`'s `tee` module; it is
770                // injected here as `re_encryptor` (see
771                // [`crate::BootstrapReEncryptor`]). Absent it (should not
772                // happen in a Mode-B `vta-service`), fail closed rather than
773                // silently skip re-encryption.
774                let re = re_encryptor.ok_or_else(|| {
775                    AppError::Internal(
776                        "TEE import requires a BootstrapReEncryptor but none was supplied".into(),
777                    )
778                })?;
779                re.re_encrypt(kms_config, store, &seed_bytes, &jwt_key)
780                    .await?;
781            } else {
782                info!("no JWT key in backup — skipping KMS re-encryption");
783            }
784        }
785    }
786
787    // Import complete and consistent: clear the crash-safety sentinel and
788    // fsync, so boot no longer sees a half-imported store (P0.5). Done last,
789    // after every record (incl. the TEE KMS re-encryption above) is in place.
790    keys_ks.remove(IMPORT_IN_PROGRESS_KEY).await?;
791    keys_ks.persist().await?;
792
793    // Restore rewrote the covered singletons (ACL, counters, …) via raw inserts
794    // that bypass the integrity chokepoints, so re-seal the TEE manifest to the
795    // freshly-restored state — otherwise the next boot would fail closed against
796    // the pre-restore manifest. The restore is super-admin-authorized, so the
797    // restored state is the new legitimate baseline (P0.2a). No-op outside a TEE.
798    vti_common::integrity::reseal_if_active().await?;
799
800    info!(
801        keys = payload.key_records.len(),
802        acls = payload.acl_entries.len(),
803        contexts = payload.context_records.len(),
804        audit = payload.audit_logs.len(),
805        "backup imported — soft restart required"
806    );
807
808    Ok(ImportResult {
809        status: "imported".into(),
810        source_did: payload.config.vta_did.clone(),
811        key_count: payload.key_records.len(),
812        acl_count: payload.acl_entries.len(),
813        context_count: payload.context_records.len(),
814        audit_count: payload.audit_logs.len(),
815        imported_secret_count: payload.imported_secrets.len(),
816        message: Some("Import complete. VTA will restart with new identity.".into()),
817    })
818}
819
820// ── Crypto helpers ─────────────────────────────────────────────────
821
822fn encrypt_payload(
823    payload: &BackupPayload,
824    password: &str,
825    include_audit: bool,
826    config: &vta_config::AppConfig,
827) -> Result<BackupEnvelope, AppError> {
828    let plaintext =
829        serde_json::to_vec(payload).map_err(|e| AppError::Internal(format!("serialize: {e}")))?;
830
831    use rand::Rng;
832    let mut rng = rand::rng();
833    let mut salt = [0u8; SALT_LEN];
834    rng.fill_bytes(&mut salt);
835    let mut nonce_bytes = [0u8; NONCE_LEN];
836    rng.fill_bytes(&mut nonce_bytes);
837
838    // Derive key via Argon2id
839    let argon2 = Argon2::new(
840        argon2::Algorithm::Argon2id,
841        argon2::Version::V0x13,
842        argon2::Params::new(ARGON2_M_COST, ARGON2_T_COST, ARGON2_P_COST, Some(32))
843            .map_err(|e| AppError::Internal(format!("argon2 params: {e}")))?,
844    );
845    let mut key = [0u8; 32];
846    argon2
847        .hash_password_into(password.as_bytes(), &salt, &mut key)
848        .map_err(|e| AppError::Internal(format!("argon2 hash: {e}")))?;
849
850    // Encrypt with AES-256-GCM
851    let cipher =
852        Aes256Gcm::new_from_slice(&key).map_err(|e| AppError::Internal(format!("aes key: {e}")))?;
853    let nonce = (&nonce_bytes).into();
854    let ciphertext = cipher
855        .encrypt(nonce, plaintext.as_ref())
856        .map_err(|e| AppError::Internal(format!("aes encrypt: {e}")))?;
857
858    Ok(BackupEnvelope {
859        version: 1,
860        format: "vta-backup-v1".into(),
861        created_at: Utc::now(),
862        source_did: config.vta_did.clone(),
863        source_version: env!("CARGO_PKG_VERSION").into(),
864        kdf: KdfParams {
865            algorithm: "argon2id".into(),
866            salt: BASE64.encode(salt),
867            m_cost: ARGON2_M_COST,
868            t_cost: ARGON2_T_COST,
869            p_cost: ARGON2_P_COST,
870        },
871        encryption: EncryptionParams {
872            algorithm: "aes-256-gcm".into(),
873            nonce: BASE64.encode(nonce_bytes),
874        },
875        includes_audit: include_audit,
876        ciphertext: BASE64.encode(&ciphertext),
877    })
878}
879
880/// Decrypt a backup envelope and return the payload.
881///
882/// Use this for confirmed imports to avoid the overhead of building an
883/// `ImportResult` preview. For preview mode, use `preview_import()`.
884pub fn decrypt_backup(
885    envelope: &BackupEnvelope,
886    password: &str,
887) -> Result<BackupPayload, AppError> {
888    if envelope.version != 1 || envelope.format != "vta-backup-v1" {
889        return Err(AppError::Validation(format!(
890            "unsupported backup format: {} v{}",
891            envelope.format, envelope.version
892        )));
893    }
894
895    // Reject KDF parameters outside sane bounds. An untrusted envelope
896    // can otherwise force a memory bomb or a near-trivial KDF.
897    if envelope.kdf.algorithm != "argon2id" {
898        return Err(AppError::Validation(format!(
899            "unsupported KDF algorithm: '{}' (only 'argon2id' is accepted)",
900            envelope.kdf.algorithm
901        )));
902    }
903    if !(MIN_M_COST..=MAX_M_COST).contains(&envelope.kdf.m_cost) {
904        return Err(AppError::Validation(format!(
905            "argon2 m_cost {} out of bounds [{}, {}]",
906            envelope.kdf.m_cost, MIN_M_COST, MAX_M_COST
907        )));
908    }
909    if !(MIN_T_COST..=MAX_T_COST).contains(&envelope.kdf.t_cost) {
910        return Err(AppError::Validation(format!(
911            "argon2 t_cost {} out of bounds [{}, {}]",
912            envelope.kdf.t_cost, MIN_T_COST, MAX_T_COST
913        )));
914    }
915    if !(MIN_P_COST..=MAX_P_COST).contains(&envelope.kdf.p_cost) {
916        return Err(AppError::Validation(format!(
917            "argon2 p_cost {} out of bounds [{}, {}]",
918            envelope.kdf.p_cost, MIN_P_COST, MAX_P_COST
919        )));
920    }
921    if envelope.encryption.algorithm != "aes-256-gcm" {
922        return Err(AppError::Validation(format!(
923            "unsupported encryption algorithm: '{}' (only 'aes-256-gcm' is accepted)",
924            envelope.encryption.algorithm
925        )));
926    }
927
928    let salt = BASE64
929        .decode(&envelope.kdf.salt)
930        .map_err(|e| AppError::Validation(format!("invalid salt: {e}")))?;
931    if salt.len() != SALT_LEN {
932        return Err(AppError::Validation(format!(
933            "invalid salt length: {} (expected {SALT_LEN})",
934            salt.len()
935        )));
936    }
937    let nonce_bytes = BASE64
938        .decode(&envelope.encryption.nonce)
939        .map_err(|e| AppError::Validation(format!("invalid nonce: {e}")))?;
940    // Length check before the nonce is built. This used to be the *only*
941    // thing standing between a crafted backup envelope and a process-killing
942    // DoS on `/backup/import`, because `Nonce::from_slice` panicked on the
943    // wrong length. The conversion below is now `TryFrom`, so the panic is
944    // gone by construction and this check is no longer load-bearing for
945    // safety — it stays because it produces the better error: "invalid nonce
946    // length: 11 (expected 12)" rather than a bare conversion failure.
947    if nonce_bytes.len() != NONCE_LEN {
948        return Err(AppError::Validation(format!(
949            "invalid nonce length: {} (expected {NONCE_LEN})",
950            nonce_bytes.len()
951        )));
952    }
953    let ciphertext = BASE64
954        .decode(&envelope.ciphertext)
955        .map_err(|e| AppError::Validation(format!("invalid ciphertext: {e}")))?;
956
957    // Derive key via Argon2id (using params from envelope)
958    let argon2 = Argon2::new(
959        argon2::Algorithm::Argon2id,
960        argon2::Version::V0x13,
961        argon2::Params::new(
962            envelope.kdf.m_cost,
963            envelope.kdf.t_cost,
964            envelope.kdf.p_cost,
965            Some(32),
966        )
967        .map_err(|e| AppError::Validation(format!("argon2 params: {e}")))?,
968    );
969    let mut key = [0u8; 32];
970    argon2
971        .hash_password_into(password.as_bytes(), &salt, &mut key)
972        .map_err(|e| AppError::Internal(format!("argon2 hash: {e}")))?;
973
974    // Decrypt with AES-256-GCM
975    let cipher =
976        Aes256Gcm::new_from_slice(&key).map_err(|e| AppError::Internal(format!("aes key: {e}")))?;
977    let nonce = Nonce::try_from(nonce_bytes.as_slice())
978        .map_err(|_| AppError::Validation(format!("nonce must be {NONCE_LEN} bytes")))?;
979    let plaintext = cipher
980        .decrypt(&nonce, ciphertext.as_ref())
981        .map_err(|_| AppError::Authentication("incorrect backup password".into()))?;
982
983    serde_json::from_slice(&plaintext)
984        .map_err(|e| AppError::Internal(format!("backup payload corrupt: {e}")))
985}
986
987/// Remove all entries under the given prefixes from a keyspace.
988async fn clear_keyspace(ks: &KeyspaceHandle, prefixes: &[&str]) -> Result<(), AppError> {
989    for prefix in prefixes {
990        let keys = ks.prefix_keys(prefix.to_string()).await?;
991        for key in keys {
992            ks.remove(key).await?;
993        }
994    }
995    Ok(())
996}
997
998#[cfg(test)]
999mod tests {
1000    use super::*;
1001    use vta_webvh::webvh_store::{WebvhServerAuthRecord, store_server_auth};
1002    use vti_common::config::StoreConfig as VtiStoreConfig;
1003    use vti_common::store::Store;
1004
1005    /// The Argon2id key derivation, pinned to a frozen vector.
1006    ///
1007    /// A backup envelope stores the salt and the KDF parameters, not the key.
1008    /// Decryption re-derives it, so this function's output *is* the format:
1009    /// change a byte of it and every backup ever written becomes
1010    /// undecryptable, with nothing to say so but an AES-GCM tag failure.
1011    ///
1012    /// The encrypt/decrypt round-trip tests cannot catch that — they derive
1013    /// the key twice with the same linked version, so both sides move
1014    /// together. This vector was produced by argon2 0.5 and re-checked
1015    /// against 0.6 during that upgrade; it does not move.
1016    ///
1017    /// If this fails, do not regenerate it. It means the linked crate changed
1018    /// its derivation, and the change needs a versioned KDF marker in the
1019    /// envelope plus a path that reads the old one.
1020    #[test]
1021    fn argon2id_derivation_matches_the_frozen_vector() {
1022        let argon2 = Argon2::new(
1023            argon2::Algorithm::Argon2id,
1024            argon2::Version::V0x13,
1025            argon2::Params::new(ARGON2_M_COST, ARGON2_T_COST, ARGON2_P_COST, Some(32)).unwrap(),
1026        );
1027        let mut key = [0u8; 32];
1028        argon2
1029            .hash_password_into(b"backup-password", b"0123456789abcdef", &mut key)
1030            .unwrap();
1031        assert_eq!(
1032            key.iter().map(|b| format!("{b:02x}")).collect::<String>(),
1033            "3558837960e818d4ae946a900d505053894bf02a6ac9e046f0781fe09a616bf9",
1034            "the backup KDF changed — every existing envelope is now \
1035             undecryptable"
1036        );
1037    }
1038
1039    /// Pre-existing daemon REST auth-cache records must be wiped by
1040    /// the restore path. Otherwise a backup imported on a different
1041    /// VTA (or a fresh install before re-onboarding the daemons)
1042    /// would inherit tokens that, if still un-expired, could be used
1043    /// against daemons the operator no longer controls. The import
1044    /// path does not carry tokens *forward* (the export filter only
1045    /// touches `server:` keys, not `server-auth:`), so what matters
1046    /// is that the wipe step explicitly includes our prefix.
1047    #[tokio::test]
1048    async fn restore_clears_pre_existing_webvh_auth_cache() {
1049        let dir = tempfile::tempdir().unwrap();
1050        let store = Store::open(&VtiStoreConfig {
1051            data_dir: dir.path().into(),
1052        })
1053        .unwrap();
1054        let webvh_ks = store.keyspace(vta_keyspaces::WEBVH).unwrap();
1055
1056        // Plant a stale auth record (as if a previous VTA installation
1057        // had cached daemon REST tokens here).
1058        let stale = WebvhServerAuthRecord {
1059            server_id: "prod".into(),
1060            access_token: "stale-access".into(),
1061            access_expires_at: 9_999_999_999,
1062            refresh_token: "stale-refresh".into(),
1063            refresh_expires_at: 9_999_999_999,
1064        };
1065        store_server_auth(&webvh_ks, &stale).await.unwrap();
1066
1067        // Run the same wipe-prefixes call `apply_import` uses on the
1068        // webvh keyspace.
1069        clear_keyspace(&webvh_ks, &["server:", "server-auth:", "did:", "log:"])
1070            .await
1071            .unwrap();
1072
1073        // The stale auth record must be gone.
1074        let remaining = vta_webvh::webvh_store::get_server_auth(&webvh_ks, "prod")
1075            .await
1076            .unwrap();
1077        assert!(
1078            remaining.is_none(),
1079            "server-auth: prefix must be cleared on import; otherwise stale tokens leak across installations"
1080        );
1081    }
1082
1083    /// A backup must be complete: a corrupt `key:` row must ABORT the
1084    /// export rather than be silently omitted (which would drop key
1085    /// material from the backup). This is the deliberate opposite of the
1086    /// steady-state list paths, which skip corrupt rows.
1087    #[tokio::test]
1088    async fn export_aborts_on_corrupt_key_row() {
1089        let ts = crate::test_support::open_test_store().await;
1090        let seed_store = crate::test_support::TestSeedStore(vec![42u8; 32]);
1091        let config = crate::test_support::test_app_config(ts.data_dir.clone());
1092        let auth = crate::test_support::super_admin_claims();
1093
1094        // Plant a garbage row under the `key:` prefix that export scans.
1095        ts.keys_ks
1096            .insert_raw("key:corrupt", b"{not a key record".to_vec())
1097            .await
1098            .unwrap();
1099
1100        let ks = vta_keyspaces::Keyspaces {
1101            keys: &ts.keys_ks,
1102            acl: &ts.acl_ks,
1103            contexts: &ts.contexts_ks,
1104            did_templates: &ts.did_templates_ks,
1105            audit: &ts.audit_ks,
1106            imported: &ts.imported_ks,
1107            #[cfg(feature = "webvh")]
1108            webvh: &ts.webvh_ks,
1109        };
1110
1111        let err = export_backup(&ks, &seed_store, &config, &auth, "a-strong-password", false)
1112            .await
1113            .expect_err("export must abort on a corrupt key row");
1114        let msg = format!("{err:?}");
1115        assert!(
1116            msg.contains("corrupt") && msg.contains("key"),
1117            "error must name the corrupt-row cause, got: {msg}"
1118        );
1119    }
1120
1121    /// `export_backup` rejects passwords shorter than 15 characters with a
1122    /// `Validation` error. The check fires before any keyspace I/O.
1123    /// A password of exactly 15 characters must be accepted (boundary).
1124    #[tokio::test]
1125    async fn export_rejects_short_password() {
1126        let ts = crate::test_support::open_test_store().await;
1127        let seed_store = crate::test_support::TestSeedStore(vec![42u8; 32]);
1128        let config = crate::test_support::test_app_config(ts.data_dir.clone());
1129        let auth = crate::test_support::super_admin_claims();
1130
1131        let ks = vta_keyspaces::Keyspaces {
1132            keys: &ts.keys_ks,
1133            acl: &ts.acl_ks,
1134            contexts: &ts.contexts_ks,
1135            did_templates: &ts.did_templates_ks,
1136            audit: &ts.audit_ks,
1137            imported: &ts.imported_ks,
1138            #[cfg(feature = "webvh")]
1139            webvh: &ts.webvh_ks,
1140        };
1141
1142        // 14 chars — one short of the minimum
1143        let err = export_backup(&ks, &seed_store, &config, &auth, "14-char-passwo", false)
1144            .await
1145            .expect_err("export must reject a 14-character password");
1146        assert!(
1147            format!("{err}").contains("15 characters"),
1148            "error must mention the 15-character minimum, got: {err}"
1149        );
1150
1151        // Exactly 15 chars — must be accepted (boundary)
1152        export_backup(&ks, &seed_store, &config, &auth, "15-char-passwor", false)
1153            .await
1154            .expect("export must accept a 15-character password");
1155    }
1156
1157    fn test_payload() -> BackupPayload {
1158        BackupPayload {
1159            active_seed_hex: hex::encode([42u8; 32]),
1160            active_seed_id: 1,
1161            seed_records: vec![SeedRecordBackup {
1162                id: 0,
1163                seed_hex: None,
1164                // An encrypted retired-seed archive (P0.7b) must round-trip
1165                // through backup verbatim so it stays decryptable on restore
1166                // (the active seed + KEK salt are restored alongside it).
1167                seed_enc: Some(vec![0xDEu8, 0xAD, 0xBE, 0xEF, 0x01, 0x02, 0x03]),
1168                created_at: Utc::now(),
1169                retired_at: Some(Utc::now()),
1170            }],
1171            jwt_signing_key: Some(BASE64.encode([99u8; 32])),
1172            key_records: vec![],
1173            context_records: vec![],
1174            context_counter: 2,
1175            path_counters: vec![],
1176            subcontext_counters: vec![],
1177            acl_entries: vec![AclEntryBackup {
1178                did: "did:key:z6MkTest".into(),
1179                role: "Admin".into(),
1180                label: Some("test admin".into()),
1181                allowed_contexts: vec!["ctx1".into()],
1182                created_at: 1000,
1183                created_by: "did:key:z6MkSetup".into(),
1184            }],
1185            acl_entries_full: vec![],
1186            seal: None,
1187            webvh_servers: vec![],
1188            webvh_dids: vec![],
1189            webvh_logs: vec![],
1190            config: BackupConfig {
1191                vta_did: Some("did:key:z6MkVTA".into()),
1192                vta_name: Some("Test VTA".into()),
1193                public_url: None,
1194                mediator_url: None,
1195                mediator_did: None,
1196            },
1197            audit_logs: vec![],
1198            imported_secrets: vec![],
1199            imported_kek_salt: None,
1200        }
1201    }
1202
1203    fn test_config() -> vta_config::AppConfig {
1204        toml::from_str("").unwrap()
1205    }
1206
1207    // ── P0.5 import-fidelity tests ──────────────────────────────────
1208
1209    fn mk_key_record(key_id: &str, derivation_path: &str) -> vta_sdk::keys::KeyRecord {
1210        use vta_sdk::keys::{KeyOrigin, KeyRecord, KeyStatus, KeyType};
1211        let now = Utc::now();
1212        KeyRecord {
1213            key_id: key_id.into(),
1214            derivation_path: derivation_path.into(),
1215            key_type: KeyType::Ed25519,
1216            status: KeyStatus::Active,
1217            public_key: "zPlaceholder".into(),
1218            label: None,
1219            context_id: None,
1220            seed_id: None,
1221            exportable: None,
1222            origin: KeyOrigin::Derived,
1223            created_at: now,
1224            updated_at: now,
1225        }
1226    }
1227
1228    fn import_keyspaces<'a>(
1229        ts: &'a crate::test_support::TestStore,
1230    ) -> vta_keyspaces::Keyspaces<'a> {
1231        vta_keyspaces::Keyspaces {
1232            keys: &ts.keys_ks,
1233            acl: &ts.acl_ks,
1234            contexts: &ts.contexts_ks,
1235            did_templates: &ts.did_templates_ks,
1236            audit: &ts.audit_ks,
1237            imported: &ts.imported_ks,
1238            #[cfg(feature = "webvh")]
1239            webvh: &ts.webvh_ks,
1240        }
1241    }
1242
1243    /// The headline P0.5 fix: a restore must carry the BIP-32 path counter
1244    /// forward, or the next key minted after restore re-derives a private
1245    /// key a restored record already occupies.
1246    #[tokio::test]
1247    async fn import_restores_path_counter_preventing_key_reuse() {
1248        let ts = crate::test_support::open_test_store().await;
1249        let seed_store: std::sync::Arc<dyn SeedStore> =
1250            std::sync::Arc::new(crate::test_support::TestSeedStore(vec![42u8; 32]));
1251        let config =
1252            tokio::sync::RwLock::new(crate::test_support::test_app_config(ts.data_dir.clone()));
1253
1254        let base = "m/26'/2'/0'";
1255        let mut payload = test_payload();
1256        payload.key_records = vec![mk_key_record("k0", &format!("{base}/0'"))];
1257        payload.path_counters = vec![(format!("path_counter:{base}"), 1)];
1258        payload.acl_entries = vec![]; // exercise only the counter path here
1259
1260        apply_import(
1261            &payload,
1262            &import_keyspaces(&ts),
1263            &seed_store,
1264            &config,
1265            None,
1266            #[cfg(feature = "tee")]
1267            None,
1268        )
1269        .await
1270        .expect("import");
1271
1272        // Allocating under the same base must NOT hand back the in-use index 0.
1273        let next = vta_keys::paths::allocate_path(&ts.keys_ks, base)
1274            .await
1275            .expect("alloc");
1276        assert_eq!(
1277            next,
1278            format!("{base}/1'"),
1279            "restore must carry the path counter forward (no key reuse)"
1280        );
1281    }
1282
1283    /// Without exported counters (a pre-P0.5 backup), the importer recomputes
1284    /// from the restored key records — so the reuse bug is closed for old
1285    /// backups too.
1286    #[tokio::test]
1287    async fn import_recomputes_path_counter_for_legacy_backup() {
1288        let ts = crate::test_support::open_test_store().await;
1289        let seed_store: std::sync::Arc<dyn SeedStore> =
1290            std::sync::Arc::new(crate::test_support::TestSeedStore(vec![42u8; 32]));
1291        let config =
1292            tokio::sync::RwLock::new(crate::test_support::test_app_config(ts.data_dir.clone()));
1293
1294        let base = "m/26'/2'/0'";
1295        let mut payload = test_payload();
1296        // Two restored keys at indices 0 and 1, NO exported counters (legacy).
1297        payload.key_records = vec![
1298            mk_key_record("k0", &format!("{base}/0'")),
1299            mk_key_record("k1", &format!("{base}/1'")),
1300        ];
1301        payload.path_counters = vec![];
1302        payload.acl_entries = vec![];
1303
1304        apply_import(
1305            &payload,
1306            &import_keyspaces(&ts),
1307            &seed_store,
1308            &config,
1309            None,
1310            #[cfg(feature = "tee")]
1311            None,
1312        )
1313        .await
1314        .expect("import");
1315
1316        let next = vta_keys::paths::allocate_path(&ts.keys_ks, base)
1317            .await
1318            .expect("alloc");
1319        assert_eq!(
1320            next,
1321            format!("{base}/2'"),
1322            "recomputed counter must skip both in-use indices"
1323        );
1324    }
1325
1326    /// ACL entries must round-trip ALL fields — expiry, step-up floors,
1327    /// capabilities, etc. — not collapse to the lossy 6-field projection
1328    /// (which restored expired grants as permanent and stripped step-up).
1329    #[tokio::test]
1330    async fn import_restores_full_acl_entry_fields() {
1331        use vti_common::acl::{AclEntry, Role};
1332        use vti_common::auth::step_up::StepUpMode;
1333
1334        let ts = crate::test_support::open_test_store().await;
1335        let seed_store: std::sync::Arc<dyn SeedStore> =
1336            std::sync::Arc::new(crate::test_support::TestSeedStore(vec![42u8; 32]));
1337        let config =
1338            tokio::sync::RwLock::new(crate::test_support::test_app_config(ts.data_dir.clone()));
1339
1340        let mut entry = AclEntry::new("did:key:zAcl", Role::Admin, "did:key:zSetup");
1341        entry.expires_at = Some(1_900_000_000);
1342        entry.step_up_require = Some(StepUpMode::SelfApprove);
1343        let full = serde_json::to_value(&entry).unwrap();
1344
1345        let mut payload = test_payload();
1346        payload.acl_entries = vec![]; // ensure the full form is what's used
1347        payload.acl_entries_full = vec![full];
1348
1349        apply_import(
1350            &payload,
1351            &import_keyspaces(&ts),
1352            &seed_store,
1353            &config,
1354            None,
1355            #[cfg(feature = "tee")]
1356            None,
1357        )
1358        .await
1359        .expect("import");
1360
1361        let restored: AclEntry = ts
1362            .acl_ks
1363            .get("acl:did:key:zAcl")
1364            .await
1365            .unwrap()
1366            .expect("acl entry restored");
1367        assert_eq!(restored.role, Role::Admin);
1368        assert_eq!(
1369            restored.expires_at,
1370            Some(1_900_000_000),
1371            "expiry must survive (a lossy restore would make it permanent)"
1372        );
1373        assert_eq!(
1374            restored.step_up_require,
1375            Some(StepUpMode::SelfApprove),
1376            "step-up floor must survive (a lossy restore would strip it)"
1377        );
1378    }
1379
1380    /// The crash-safety sentinel must be gone after a successful import, and
1381    /// must live under a key the clear step doesn't wipe (so an interrupted
1382    /// import leaves it for boot to detect).
1383    #[tokio::test]
1384    async fn successful_import_leaves_no_in_progress_sentinel() {
1385        let ts = crate::test_support::open_test_store().await;
1386        let seed_store: std::sync::Arc<dyn SeedStore> =
1387            std::sync::Arc::new(crate::test_support::TestSeedStore(vec![42u8; 32]));
1388        let config =
1389            tokio::sync::RwLock::new(crate::test_support::test_app_config(ts.data_dir.clone()));
1390
1391        let mut payload = test_payload();
1392        payload.acl_entries = vec![];
1393
1394        apply_import(
1395            &payload,
1396            &import_keyspaces(&ts),
1397            &seed_store,
1398            &config,
1399            None,
1400            #[cfg(feature = "tee")]
1401            None,
1402        )
1403        .await
1404        .expect("import");
1405
1406        assert!(
1407            ts.keys_ks
1408                .get_raw(IMPORT_IN_PROGRESS_KEY)
1409                .await
1410                .unwrap()
1411                .is_none(),
1412            "a completed import must clear its in-progress sentinel"
1413        );
1414    }
1415
1416    #[tokio::test]
1417    async fn import_sentinel_survives_keyspace_clear() {
1418        // The sentinel is written before the clear; verify the clear
1419        // prefixes used by apply_import don't wipe it.
1420        let ts = crate::test_support::open_test_store().await;
1421        ts.keys_ks
1422            .insert_raw(IMPORT_IN_PROGRESS_KEY, b"1".to_vec())
1423            .await
1424            .unwrap();
1425        clear_keyspace(&ts.keys_ks, &["key:", "seed:", "path_counter:"])
1426            .await
1427            .unwrap();
1428        assert!(
1429            ts.keys_ks
1430                .get_raw(IMPORT_IN_PROGRESS_KEY)
1431                .await
1432                .unwrap()
1433                .is_some(),
1434            "the sentinel must survive the import clear so an interrupted import is detectable at boot"
1435        );
1436    }
1437
1438    #[test]
1439    fn recompute_path_counters_skips_imported_keys_and_takes_max() {
1440        let recs = vec![
1441            mk_key_record("a", "m/26'/2'/0'/0'"),
1442            mk_key_record("b", "m/26'/2'/0'/3'"),
1443            mk_key_record("imported", ""), // no derivation path → ignored
1444        ];
1445        let counters = recompute_path_counters(&recs);
1446        assert_eq!(counters.get("m/26'/2'/0'"), Some(&4)); // max index 3 + 1
1447        assert_eq!(counters.len(), 1);
1448    }
1449
1450    #[test]
1451    fn encrypt_decrypt_roundtrip() {
1452        let payload = test_payload();
1453        let password = "test-password-12chars!";
1454        let config = test_config();
1455
1456        let envelope = encrypt_payload(&payload, password, false, &config).unwrap();
1457
1458        assert_eq!(envelope.version, 1);
1459        assert_eq!(envelope.format, "vta-backup-v1");
1460        assert_eq!(envelope.kdf.algorithm, "argon2id");
1461        assert_eq!(envelope.encryption.algorithm, "aes-256-gcm");
1462        assert!(!envelope.ciphertext.is_empty());
1463
1464        let decrypted = decrypt_backup(&envelope, password).unwrap();
1465
1466        assert_eq!(decrypted.active_seed_hex, payload.active_seed_hex);
1467        assert_eq!(decrypted.active_seed_id, payload.active_seed_id);
1468        assert_eq!(decrypted.seed_records.len(), 1);
1469        assert_eq!(decrypted.seed_records[0].id, 0);
1470        // The encrypted retired-seed archive survives the backup round-trip
1471        // byte-for-byte (P0.7b); no plaintext seed_hex is introduced.
1472        assert_eq!(decrypted.seed_records[0].seed_hex, None);
1473        assert_eq!(
1474            decrypted.seed_records[0].seed_enc,
1475            payload.seed_records[0].seed_enc
1476        );
1477        assert_eq!(decrypted.jwt_signing_key, payload.jwt_signing_key);
1478        assert_eq!(decrypted.context_counter, 2);
1479        assert_eq!(decrypted.acl_entries.len(), 1);
1480        assert_eq!(decrypted.acl_entries[0].did, "did:key:z6MkTest");
1481        assert_eq!(decrypted.acl_entries[0].role, "Admin");
1482        assert_eq!(decrypted.config.vta_did, Some("did:key:z6MkVTA".into()));
1483        assert_eq!(decrypted.config.vta_name, Some("Test VTA".into()));
1484    }
1485
1486    #[test]
1487    fn wrong_password_fails() {
1488        let payload = test_payload();
1489        let config = test_config();
1490
1491        let envelope = encrypt_payload(&payload, "correct-password!!", false, &config).unwrap();
1492        let result = decrypt_backup(&envelope, "wrong-password!!!");
1493
1494        assert!(result.is_err());
1495        let err = result.unwrap_err();
1496        // AES-GCM auth tag mismatch → authentication error
1497        assert!(
1498            format!("{err}").contains("incorrect backup password"),
1499            "expected auth error, got: {err}"
1500        );
1501    }
1502
1503    #[test]
1504    fn tampered_ciphertext_detected() {
1505        let payload = test_payload();
1506        let config = test_config();
1507        let password = "test-password-12chars!";
1508
1509        let mut envelope = encrypt_payload(&payload, password, false, &config).unwrap();
1510
1511        // Tamper with the ciphertext (flip a byte)
1512        let mut ct_bytes = BASE64.decode(&envelope.ciphertext).unwrap();
1513        if let Some(byte) = ct_bytes.last_mut() {
1514            *byte ^= 0xFF;
1515        }
1516        envelope.ciphertext = BASE64.encode(&ct_bytes);
1517
1518        let result = decrypt_backup(&envelope, password);
1519        assert!(result.is_err());
1520        assert!(
1521            format!("{}", result.unwrap_err()).contains("incorrect backup password"),
1522            "tampered ciphertext should fail AES-GCM auth"
1523        );
1524    }
1525
1526    #[test]
1527    fn unsupported_version_rejected() {
1528        let payload = test_payload();
1529        let config = test_config();
1530        let password = "test-password-12chars!";
1531
1532        let mut envelope = encrypt_payload(&payload, password, false, &config).unwrap();
1533        envelope.version = 99;
1534
1535        let result = decrypt_backup(&envelope, password);
1536        assert!(result.is_err());
1537        assert!(
1538            format!("{}", result.unwrap_err()).contains("unsupported backup format"),
1539            "should reject unknown version"
1540        );
1541    }
1542
1543    #[test]
1544    fn unsupported_format_rejected() {
1545        let payload = test_payload();
1546        let config = test_config();
1547        let password = "test-password-12chars!";
1548
1549        let mut envelope = encrypt_payload(&payload, password, false, &config).unwrap();
1550        envelope.format = "unknown-format".into();
1551
1552        let result = decrypt_backup(&envelope, password);
1553        assert!(result.is_err());
1554        assert!(
1555            format!("{}", result.unwrap_err()).contains("unsupported backup format"),
1556            "should reject unknown format"
1557        );
1558    }
1559
1560    #[test]
1561    fn envelope_serialization_roundtrip() {
1562        let payload = test_payload();
1563        let config = test_config();
1564        let password = "test-password-12chars!";
1565
1566        let envelope = encrypt_payload(&payload, password, true, &config).unwrap();
1567
1568        // Serialize to JSON and back
1569        let json = serde_json::to_string_pretty(&envelope).unwrap();
1570        let deserialized: BackupEnvelope = serde_json::from_str(&json).unwrap();
1571
1572        assert_eq!(deserialized.version, envelope.version);
1573        assert_eq!(deserialized.format, envelope.format);
1574        assert!(deserialized.includes_audit);
1575        assert_eq!(deserialized.ciphertext, envelope.ciphertext);
1576
1577        // Should still decrypt correctly
1578        let decrypted = decrypt_backup(&deserialized, password).unwrap();
1579        assert_eq!(decrypted.active_seed_hex, payload.active_seed_hex);
1580    }
1581
1582    #[test]
1583    fn different_passwords_produce_different_ciphertexts() {
1584        let payload = test_payload();
1585        let config = test_config();
1586
1587        let env1 = encrypt_payload(&payload, "password-one-12!!", false, &config).unwrap();
1588        let env2 = encrypt_payload(&payload, "password-two-12!!", false, &config).unwrap();
1589
1590        // Different salts → different ciphertexts
1591        assert_ne!(env1.kdf.salt, env2.kdf.salt);
1592        assert_ne!(env1.ciphertext, env2.ciphertext);
1593    }
1594
1595    // ── vta_did cross-check guard ───────────────────────────────────
1596
1597    #[test]
1598    fn vta_did_guard_fresh_install_accepts_any_backup() {
1599        // A VTA that has not yet configured a vta_did accepts any
1600        // backup — this is the disaster-recovery case.
1601        check_vta_did_compatibility(None, Some("did:key:z6MkAnything"))
1602            .expect("fresh install must accept any backup");
1603        check_vta_did_compatibility(None, None).expect("fresh install accepts no-did backup");
1604        check_vta_did_compatibility(Some(""), Some("did:key:z6MkAnything"))
1605            .expect("empty-string vta_did counts as fresh install");
1606    }
1607
1608    #[test]
1609    fn vta_did_guard_matching_dids_accepted() {
1610        // Legitimate disaster recovery: restore the same VTA's backup
1611        // onto a fresh host that has the expected vta_did configured.
1612        check_vta_did_compatibility(Some("did:key:z6MkSame"), Some("did:key:z6MkSame"))
1613            .expect("matching vta_did must pass");
1614    }
1615
1616    #[test]
1617    fn vta_did_guard_mismatch_rejected() {
1618        let err = check_vta_did_compatibility(
1619            Some("did:key:z6MkRunning"),
1620            Some("did:key:z6MkForeignBackup"),
1621        )
1622        .expect_err("mismatched vta_did must be rejected");
1623        let msg = format!("{err}");
1624        assert!(msg.contains("vta_did mismatch"), "got: {msg}");
1625        assert!(
1626            msg.contains("z6MkForeignBackup"),
1627            "must name backup did: {msg}"
1628        );
1629        assert!(msg.contains("z6MkRunning"), "must name running did: {msg}");
1630    }
1631
1632    #[test]
1633    fn vta_did_guard_backup_missing_did_rejected_when_running_has_did() {
1634        // A backup with no vta_did can't legitimately replace a
1635        // running VTA's identity — treat empty as mismatch.
1636        let err = check_vta_did_compatibility(Some("did:key:z6MkRunning"), None)
1637            .expect_err("missing backup vta_did must be rejected when running has one");
1638        assert!(format!("{err}").contains("vta_did mismatch"), "got {err:?}");
1639    }
1640
1641    // ── KDF parameter clamps on import ──────────────────────────────
1642
1643    fn make_envelope_with_kdf(m_cost: u32, t_cost: u32, p_cost: u32, alg: &str) -> BackupEnvelope {
1644        // Build a real encrypted envelope, then mutate the KDF params.
1645        // The ciphertext won't decrypt with the wrong params, but the
1646        // bounds check fires before decrypt is attempted — that's the
1647        // behaviour we're testing.
1648        let payload = test_payload();
1649        let config = test_config();
1650        let mut env = encrypt_payload(&payload, "password-12!ok!a", false, &config).unwrap();
1651        env.kdf.algorithm = alg.into();
1652        env.kdf.m_cost = m_cost;
1653        env.kdf.t_cost = t_cost;
1654        env.kdf.p_cost = p_cost;
1655        env
1656    }
1657
1658    #[test]
1659    fn kdf_m_cost_above_max_rejected() {
1660        let env = make_envelope_with_kdf(MAX_M_COST + 1, ARGON2_T_COST, ARGON2_P_COST, "argon2id");
1661        let err = decrypt_backup(&env, "anything").expect_err("must reject huge m_cost");
1662        assert!(format!("{err}").contains("m_cost"), "got {err:?}");
1663    }
1664
1665    #[test]
1666    fn kdf_m_cost_below_min_rejected() {
1667        let env = make_envelope_with_kdf(1, ARGON2_T_COST, ARGON2_P_COST, "argon2id");
1668        let err = decrypt_backup(&env, "anything").expect_err("must reject m_cost = 1");
1669        assert!(format!("{err}").contains("m_cost"), "got {err:?}");
1670    }
1671
1672    #[test]
1673    fn kdf_t_cost_zero_rejected() {
1674        let env = make_envelope_with_kdf(ARGON2_M_COST, 0, ARGON2_P_COST, "argon2id");
1675        let err = decrypt_backup(&env, "anything").expect_err("must reject t_cost = 0");
1676        assert!(format!("{err}").contains("t_cost"), "got {err:?}");
1677    }
1678
1679    #[test]
1680    fn kdf_p_cost_above_max_rejected() {
1681        let env = make_envelope_with_kdf(ARGON2_M_COST, ARGON2_T_COST, MAX_P_COST + 1, "argon2id");
1682        let err = decrypt_backup(&env, "anything").expect_err("must reject huge p_cost");
1683        assert!(format!("{err}").contains("p_cost"), "got {err:?}");
1684    }
1685
1686    #[test]
1687    fn kdf_unknown_algorithm_rejected() {
1688        let env =
1689            make_envelope_with_kdf(ARGON2_M_COST, ARGON2_T_COST, ARGON2_P_COST, "scrypt-custom");
1690        let err = decrypt_backup(&env, "anything").expect_err("must reject non-argon2id KDF");
1691        assert!(format!("{err}").contains("KDF algorithm"), "got {err:?}");
1692    }
1693
1694    // ── Salt / nonce length validation on import ───────────────────
1695    //
1696    // Regression tests for the DoS where a crafted envelope's wrong-length
1697    // nonce would panic `Nonce::from_slice`, taking the import handler
1698    // (super-admin only, but reachable over REST) down with it.
1699    //
1700    // The panic is now impossible by construction — the conversion is
1701    // `TryFrom` — but these stay: they assert the *rejection*, not the
1702    // absence of a panic, and that is still the behaviour callers depend on.
1703
1704    #[test]
1705    fn nonce_wrong_length_rejected_without_panic() {
1706        let payload = test_payload();
1707        let config = test_config();
1708        let mut env = encrypt_payload(&payload, "password-12!ok!a", false, &config).unwrap();
1709        // Replace the 12-byte nonce with a 16-byte one (the smallest
1710        // wrong size large enough that decode succeeds easily).
1711        env.encryption.nonce = BASE64.encode([0u8; 16]);
1712        let err = decrypt_backup(&env, "password-12!ok!a")
1713            .expect_err("wrong-length nonce must be rejected pre-decrypt");
1714        let msg = format!("{err}");
1715        assert!(
1716            msg.contains("nonce length"),
1717            "expected nonce-length error, got: {msg}"
1718        );
1719    }
1720
1721    #[test]
1722    fn salt_wrong_length_rejected_without_panic() {
1723        let payload = test_payload();
1724        let config = test_config();
1725        let mut env = encrypt_payload(&payload, "password-12!ok!a", false, &config).unwrap();
1726        // 16 bytes instead of the expected 32.
1727        env.kdf.salt = BASE64.encode([0u8; 16]);
1728        let err = decrypt_backup(&env, "password-12!ok!a")
1729            .expect_err("wrong-length salt must be rejected pre-decrypt");
1730        let msg = format!("{err}");
1731        assert!(
1732            msg.contains("salt length"),
1733            "expected salt-length error, got: {msg}"
1734        );
1735    }
1736}