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