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