Skip to main content

vta_backup/ops/
mod.rs

1//! VTA backup export and import operations.
2//!
3//! **Export** ([`export_backup`]) writes every keyspace in
4//! [`vta_keyspaces::BACKED_UP`], row for row, plus the master seed and the JWT
5//! signing key, into a `vta-backup-v2` payload, encrypted with Argon2id +
6//! AES-256-GCM. Nothing is collected per keyspace: the export walks the list,
7//! so a keyspace added to it is backed up with no further code.
8//!
9//! **Import** decrypts and validates a backup, optionally previews it, and on
10//! commit *stages* the restore ([`stage_import`]): the payload is sealed into
11//! the `bootstrap` keyspace under a key derived from the restored seed, the
12//! target adopts that seed, and the VTA reboots. The restore is applied at boot
13//! by [`crate::restore`], under the storage key the restored seed yields. That
14//! is what lets a backup move between a plain VTA, a hardened one and an
15//! enclave in any direction — see `vta_support::restore_stage`.
16//!
17//! A `vta-backup-v1` backup (typed collections for six keyspaces) still
18//! restores; [`write_legacy_payload`] writes it at boot.
19//!
20//! ## Sub-modules
21//!
22//! - [`descriptors`] — the 3-phase descriptor-pattern op layer for
23//!   the trust-task slice. Wraps [`export_backup`] / [`preview_import`] /
24//!   [`stage_import`], decoupling bulk byte transport from the JSON envelope.
25//!   See `docs/05-design-notes/backup-descriptor-pattern.md`.
26
27pub mod blob;
28pub mod chunked;
29pub mod descriptors;
30
31/// The `chunkedTrustTask` algorithm name, as the bundle record stores it.
32pub(crate) fn chunked_algorithm() -> &'static str {
33    vta_sdk::protocols::backup_management::chunked::ALGORITHM_CHUNKED
34}
35
36use aes_gcm::aead::{Aead, Payload};
37use aes_gcm::{Aes256Gcm, KeyInit, Nonce};
38use argon2::Argon2;
39use base64::Engine;
40use base64::engine::general_purpose::URL_SAFE_NO_PAD as BASE64;
41use chrono::Utc;
42use tracing::{info, warn};
43
44use vta_keys::KeyOrigin;
45use vta_keys::imported;
46use vta_keys::seed_store::SeedStore;
47use vta_keys::seeds::{SeedRecord, get_active_seed_id, save_seed_record, set_active_seed_id};
48use vta_support::restore_stage::{self, StageMeta};
49use vta_support::seal::SealRecord;
50use vti_common::auth::AuthClaims;
51use vti_common::error::AppError;
52use vti_common::store::KeyspaceHandle;
53
54use vta_sdk::protocols::backup_management::types::*;
55
56use crate::{BackupTarget, RestoreCommitter, RestoredSecrets};
57
58// ── Argon2id parameters (OWASP recommended) ────────────────────────
59
60const ARGON2_M_COST: u32 = 65536; // 64 MiB
61const ARGON2_T_COST: u32 = 3;
62const ARGON2_P_COST: u32 = 4;
63const SALT_LEN: usize = 32;
64const NONCE_LEN: usize = 12;
65
66// ── Argon2id parameter clamps (import-side defence) ────────────────
67//
68// `decrypt_backup` reads KDF parameters from the envelope itself —
69// without bounds, an attacker who can submit a backup can force a
70// memory bomb (`m_cost = u32::MAX` ≈ 4 TiB) or a trivially-fast KDF
71// for known-plaintext probes. On a Nitro Enclave with fixed memory,
72// a memory bomb is fatal. These bounds give honest backups generous
73// headroom (the OWASP profile sits well within them) while rejecting
74// adversarial values.
75
76/// Maximum memory cost (in KiB) accepted on import. 1 GiB.
77const MAX_M_COST: u32 = 1 << 20;
78/// Minimum memory cost (in KiB) accepted on import. 8 MiB — well below
79/// the OWASP recommendation, here only to reject the m=1 footgun.
80const MIN_M_COST: u32 = 8 * 1024;
81/// Maximum iteration count.
82const MAX_T_COST: u32 = 10;
83/// Minimum iteration count.
84const MIN_T_COST: u32 = 1;
85/// Maximum parallelism factor.
86const MAX_P_COST: u32 = 16;
87/// Minimum parallelism factor.
88const MIN_P_COST: u32 = 1;
89
90/// Key under which the pre-staging import recorded that a destructive import
91/// was in flight. Imports no longer write it — a staged restore is its own
92/// crash marker — but boot still refuses a store an older build left
93/// half-imported (see `vta-service`'s `server::run`).
94pub const IMPORT_IN_PROGRESS_KEY: &str = "backup:import_in_progress";
95
96// ── Export ──────────────────────────────────────────────────────────
97
98/// Every row of `ks`, decrypted.
99///
100/// Keys first, then one read per row, rather than a single scan: in an enclave
101/// the store is a vsock proxy whose frames are capped at 16 MiB, and a whole
102/// keyspace in one response is exactly what a long audit trail outgrows.
103async fn dump_rows(ks: &KeyspaceHandle) -> Result<Vec<(Vec<u8>, Vec<u8>)>, AppError> {
104    let mut rows = Vec::new();
105    for key in ks.prefix_keys(Vec::<u8>::new()).await? {
106        // A row deleted between the two reads is simply not in the backup; a
107        // row that exists but cannot be read (a failed decrypt) aborts it.
108        if let Some(value) = ks.get_raw(key.clone()).await? {
109            rows.push((key, value));
110        }
111    }
112    Ok(rows)
113}
114
115/// A backup carries the seed — every key the VTA can derive — so it is the
116/// largest export this VTA makes, and it needs the capability every other
117/// export needs (VTI-VTA-003), not only the super-admin role. Checked here,
118/// in the one function every backup path calls (REST, DIDComm, the `stream`
119/// and `chunkedTrustTask` Trust Tasks), so no transport can skip it.
120///
121/// Reads the caller's ACL entry, so a super-admin narrowed without
122/// `key-export` is refused; with no entry the role decides; a store error
123/// refuses.
124async fn require_key_export(target: &BackupTarget<'_>, auth: &AuthClaims) -> Result<(), AppError> {
125    use vti_common::acl::{Capability, entry_has_capability, get_acl_entry, role_has_capability};
126    let acl_ks = target.keyspace(vta_keyspaces::ACL)?;
127    let may = match get_acl_entry(&acl_ks, &auth.did).await {
128        Ok(Some(entry)) => entry_has_capability(&entry, Capability::KeyExport),
129        Ok(None) => role_has_capability(&auth.role, Capability::KeyExport),
130        Err(e) => {
131            tracing::error!(error = %e, did = %auth.did, "could not read the ACL entry for the backup key-export check; refusing");
132            false
133        }
134    };
135    if may {
136        return Ok(());
137    }
138    Err(AppError::Forbidden(format!(
139        "backup export denied: {} does not carry the key-export capability. A backup carries \
140         the seed, so it is an export of every key this VTA holds (VTI-VTA-003)",
141        auth.did
142    )))
143}
144
145/// Assemble and encrypt a backup of the entire VTA state.
146pub async fn export_backup(
147    target: &BackupTarget<'_>,
148    seed_store: &dyn SeedStore,
149    config: &vta_config::AppConfig,
150    auth: &AuthClaims,
151    password: &str,
152    include_audit: bool,
153) -> Result<BackupEnvelope, AppError> {
154    auth.require_super_admin()?;
155    require_key_export(target, auth).await?;
156    vta_sdk::protocols::backup_management::validate_backup_password(password)
157        .map_err(AppError::Validation)?;
158
159    let seed_bytes = seed_store
160        .get()
161        .await
162        .map_err(|e| AppError::Internal(format!("seed store: {e}")))?
163        .ok_or_else(|| AppError::Internal("no active seed available".into()))?;
164    let active_seed_hex = hex::encode(&seed_bytes);
165    let active_seed_id = get_active_seed_id(&target.keyspace(vta_keyspaces::KEYS)?)
166        .await
167        .map_err(|e| AppError::Internal(format!("get active seed id: {e}")))?;
168
169    let mut keyspaces = Vec::with_capacity(vta_keyspaces::BACKED_UP.len());
170    let mut internal_keys_not_carried = Vec::new();
171    for name in vta_keyspaces::BACKED_UP {
172        // A backup without its trail is a legitimate thing to ask for; the
173        // audit *keys* still travel, so a trail restored later from another
174        // backup of the same agent remains checkable.
175        if *name == vta_keyspaces::AUDIT && !include_audit {
176            continue;
177        }
178        let mut rows = Vec::new();
179        for (key, value) in dump_rows(&target.keyspace(name)?).await? {
180            if vta_keyspaces::is_environment_bound(name, &key) {
181                continue;
182            }
183            if *name == vta_keyspaces::KEYS && key.starts_with(b"key:") {
184                // A backup must be COMPLETE: unlike the steady-state list
185                // paths, which skip a corrupt row so one bad entry cannot break
186                // management, export refuses to write a backup that would
187                // restore a key record nothing can read.
188                let record: vta_sdk::keys::KeyRecord =
189                    serde_json::from_slice(&value).map_err(|e| {
190                        AppError::Internal(format!(
191                            "backup aborted: key row '{}' is corrupt and would be restored \
192                             unreadable: {e}",
193                            String::from_utf8_lossy(&key)
194                        ))
195                    })?;
196                if record.origin == KeyOrigin::Internal {
197                    internal_keys_not_carried.push(record.key_id);
198                }
199            }
200            rows.push((BASE64.encode(&key), BASE64.encode(&value)));
201        }
202        keyspaces.push(KeyspaceDump {
203            name: (*name).to_string(),
204            rows,
205        });
206    }
207
208    let payload = BackupPayload {
209        active_seed_hex,
210        active_seed_id,
211        jwt_signing_key: config.auth.jwt_signing_key.clone(),
212        config: BackupConfig {
213            vta_did: config.vta_did.clone(),
214            vta_name: config.vta_name.clone(),
215            public_url: config.public_url.clone(),
216            mediator_url: config.messaging.as_ref().map(|m| m.mediator_url.clone()),
217            mediator_did: config.messaging.as_ref().map(|m| m.mediator_did.clone()),
218        },
219        keyspaces,
220        source_environment: Some(target.environment),
221        internal_keys_not_carried,
222        // v1's typed collections; the raw dump above supersedes them.
223        seed_records: Vec::new(),
224        key_records: Vec::new(),
225        context_records: Vec::new(),
226        context_counter: 0,
227        path_counters: Vec::new(),
228        subcontext_counters: Vec::new(),
229        acl_entries: Vec::new(),
230        acl_entries_full: Vec::new(),
231        seal: None,
232        webvh_servers: Vec::new(),
233        webvh_dids: Vec::new(),
234        webvh_logs: Vec::new(),
235        audit_logs: Vec::new(),
236        imported_secrets: Vec::new(),
237        imported_kek_salt: None,
238    };
239    let counts = payload_counts(&payload);
240    let envelope = encrypt_payload(&payload, password, include_audit, config)?;
241
242    info!(
243        keyspaces = payload.keyspaces.len(),
244        keys = counts.keys,
245        acls = counts.acls,
246        contexts = counts.contexts,
247        audit = counts.audit,
248        internal_keys_not_carried = payload.internal_keys_not_carried.len(),
249        environment = %target.environment,
250        "backup exported"
251    );
252    Ok(envelope)
253}
254
255// ── Import ─────────────────────────────────────────────────────────
256
257/// Row counts a preview or an import reports. Counts, not an inventory.
258#[derive(Debug, Default, Clone, Copy, PartialEq, Eq)]
259pub struct PayloadCounts {
260    pub keys: usize,
261    pub acls: usize,
262    pub contexts: usize,
263    pub audit: usize,
264    pub imported_secrets: usize,
265}
266
267/// Count what a payload restores, whichever format it is.
268pub fn payload_counts(payload: &BackupPayload) -> PayloadCounts {
269    if payload.keyspaces.is_empty() {
270        return PayloadCounts {
271            keys: payload.key_records.len(),
272            acls: payload
273                .acl_entries_full
274                .len()
275                .max(payload.acl_entries.len()),
276            contexts: payload.context_records.len(),
277            audit: payload.audit_logs.len(),
278            imported_secrets: payload.imported_secrets.len(),
279        };
280    }
281    let count = |keyspace: &str, prefix: &str| {
282        payload
283            .keyspaces
284            .iter()
285            .filter(|d| d.name == keyspace)
286            .flat_map(|d| d.rows.iter())
287            .filter(|(k, _)| {
288                BASE64
289                    .decode(k)
290                    .is_ok_and(|k| k.starts_with(prefix.as_bytes()))
291            })
292            .count()
293    };
294    PayloadCounts {
295        keys: count(vta_keyspaces::KEYS, "key:"),
296        acls: count(vta_keyspaces::ACL, "acl:"),
297        contexts: count(vta_keyspaces::CONTEXTS, "ctx:"),
298        audit: count(vta_keyspaces::AUDIT, "log:"),
299        imported_secrets: count(vta_keyspaces::IMPORTED_SECRETS, "secret:"),
300    }
301}
302
303fn import_result(payload: &BackupPayload, status: &str, message: String) -> ImportResult {
304    let counts = payload_counts(payload);
305    ImportResult {
306        status: status.into(),
307        source_did: payload.config.vta_did.clone(),
308        key_count: counts.keys,
309        acl_count: counts.acls,
310        context_count: counts.contexts,
311        audit_count: counts.audit,
312        imported_secret_count: counts.imported_secrets,
313        message: Some(message),
314    }
315}
316
317/// Refuse a payload this build cannot restore faithfully, before anything is
318/// staged. Everything here is checked again at boot; checking it first means
319/// a bad backup is refused to the operator rather than discovered by a boot.
320pub fn validate_payload(payload: &BackupPayload) -> Result<(), AppError> {
321    let seed = hex::decode(&payload.active_seed_hex)
322        .map_err(|e| AppError::Validation(format!("backup seed is not hex: {e}")))?;
323    if seed.is_empty() {
324        return Err(AppError::Validation("backup carries an empty seed".into()));
325    }
326    if let Some(jwt) = &payload.jwt_signing_key {
327        decode_jwt_key(jwt)?;
328    }
329    let mut seen = std::collections::BTreeSet::new();
330    for dump in &payload.keyspaces {
331        // The dump names the keyspace it writes. A backup is attacker-supplied
332        // input to a super-admin, and a name outside `BACKED_UP` would let it
333        // write into `bootstrap` (the enclave's boot material) or plant an
334        // internal key — neither of which any export produces.
335        if !vta_keyspaces::BACKED_UP.contains(&dump.name.as_str()) {
336            return Err(AppError::Validation(format!(
337                "backup carries keyspace '{}', which is not one a backup may restore",
338                dump.name
339            )));
340        }
341        if !seen.insert(dump.name.as_str()) {
342            return Err(AppError::Validation(format!(
343                "backup carries keyspace '{}' twice",
344                dump.name
345            )));
346        }
347        for (k, v) in &dump.rows {
348            let key = BASE64.decode(k).map_err(|e| {
349                AppError::Validation(format!(
350                    "backup row key in '{}' is not base64: {e}",
351                    dump.name
352                ))
353            })?;
354            BASE64.decode(v).map_err(|e| {
355                AppError::Validation(format!(
356                    "backup row value in '{}' is not base64: {e}",
357                    dump.name
358                ))
359            })?;
360            if vta_keyspaces::is_environment_bound(&dump.name, &key) {
361                return Err(AppError::Validation(format!(
362                    "backup carries deployment-bound row '{}' in '{}'; a backup of this \
363                     agent never does",
364                    String::from_utf8_lossy(&key),
365                    dump.name
366                )));
367            }
368        }
369    }
370    Ok(())
371}
372
373/// Decrypt and validate a backup, returning a preview without modifying state.
374pub async fn preview_import(
375    envelope: &BackupEnvelope,
376    password: &str,
377) -> Result<(BackupPayload, ImportResult), AppError> {
378    let payload = decrypt_backup(envelope, password)?;
379    validate_payload(&payload)?;
380    let mut message =
381        String::from("Preview only — no changes applied. Set confirm=true to import.");
382    if !payload.internal_keys_not_carried.is_empty() {
383        message.push_str(&format!(
384            " {} internal key(s) are not in the backup and cannot be restored: {}.",
385            payload.internal_keys_not_carried.len(),
386            payload.internal_keys_not_carried.join(", ")
387        ));
388    }
389    let result = import_result(&payload, "preview", message);
390    Ok((payload, result))
391}
392
393/// [`preview_import`], plus the identity check a commit would make — so an
394/// operator who previews a backup of another agent is told then, with the
395/// flag that allows it, rather than after confirming.
396pub async fn preview_import_for(
397    envelope: &BackupEnvelope,
398    password: &str,
399    running_did: Option<&str>,
400    replace_identity: bool,
401) -> Result<(BackupPayload, ImportResult), AppError> {
402    let (payload, result) = preview_import(envelope, password).await?;
403    check_vta_did_compatibility(
404        running_did,
405        payload.config.vta_did.as_deref(),
406        replace_identity,
407    )?;
408    Ok((payload, result))
409}
410
411/// Reject an import if the backup's `vta_did` would overwrite a
412/// different running VTA's identity, unless the operator said to.
413///
414/// A fresh install (no running `vta_did`) accepts any backup. A VTA that
415/// already runs as some DID accepts a backup of *that* DID; a backup of a
416/// different one needs `replace_identity` — the disaster-recovery case, where
417/// the target was set up fresh and minted a DID of its own (every `vta setup`
418/// does, and an enclave with a DID template does on first boot).
419fn check_vta_did_compatibility(
420    running_did: Option<&str>,
421    backup_did: Option<&str>,
422    replace_identity: bool,
423) -> Result<(), AppError> {
424    let running = match running_did {
425        Some(d) if !d.is_empty() => d,
426        _ => return Ok(()),
427    };
428    let backup = backup_did.unwrap_or("");
429    if backup == running || replace_identity {
430        return Ok(());
431    }
432    Err(AppError::Validation(format!(
433        "backup vta_did mismatch: backup claims '{backup}' but this VTA is running \
434         as '{running}'. Refusing to overwrite identity. If this is intentional — \
435         restoring onto a freshly set-up VTA, or migrating an identity — re-run the \
436         import with --replace-identity."
437    )))
438}
439
440fn decode_jwt_key(b64: &str) -> Result<[u8; 32], AppError> {
441    BASE64
442        .decode(b64)
443        .ok()
444        .and_then(|b| <[u8; 32]>::try_from(b.as_slice()).ok())
445        .ok_or_else(|| {
446            AppError::Validation("backup JWT signing key is not 32 bytes of base64url".into())
447        })
448}
449
450/// What a caller needs to stage a restore.
451pub struct StageRequest<'a> {
452    pub target: &'a BackupTarget<'a>,
453    pub config: &'a tokio::sync::RwLock<vta_config::AppConfig>,
454    pub committer: &'a dyn RestoreCommitter,
455    pub auth: &'a AuthClaims,
456    /// Allow the restore to replace a *different* identity this VTA already
457    /// runs as. Without it a backup whose DID differs from the running one is
458    /// refused — the guard against restoring the wrong file over a live agent.
459    /// Disaster recovery onto a freshly set-up VTA (which has minted a DID of
460    /// its own) is the case that needs it.
461    pub replace_identity: bool,
462}
463
464/// Commit an import: stage the restore, have the target adopt the restored
465/// seed, and return. **The caller must then reboot the VTA** — the restore is
466/// applied by the next boot, not by this call (see `vta_support::restore_stage`
467/// for why it cannot be applied in place).
468///
469/// Nothing in the running store is changed. If this returns an error before the
470/// commit, the VTA carries on exactly as it was; the stage it may have written
471/// cannot be opened and the next boot discards it.
472pub async fn stage_import(
473    payload: BackupPayload,
474    req: StageRequest<'_>,
475) -> Result<ImportResult, AppError> {
476    req.auth.require_super_admin()?;
477    validate_payload(&payload)?;
478
479    let running_did = req.config.read().await.vta_did.clone();
480    check_vta_did_compatibility(
481        running_did.as_deref(),
482        payload.config.vta_did.as_deref(),
483        req.replace_identity,
484    )?;
485    let cross_identity = matches!(
486        (running_did.as_deref(), payload.config.vta_did.as_deref()),
487        (Some(running), backup) if !running.is_empty() && Some(running) != backup
488    );
489
490    let seed = zeroize::Zeroizing::new(
491        hex::decode(&payload.active_seed_hex)
492            .map_err(|e| AppError::Validation(format!("backup seed is not hex: {e}")))?,
493    );
494    let jwt_key = payload
495        .jwt_signing_key
496        .as_deref()
497        .map(decode_jwt_key)
498        .transpose()?;
499    let restored_did = payload.config.vta_did.clone();
500    let secrets = RestoredSecrets {
501        seed: &seed,
502        jwt_key,
503        vta_did: restored_did.as_deref(),
504    };
505
506    let prepared = req.committer.prepare(&secrets).await?;
507    let restore_id = uuid::Uuid::new_v4().to_string();
508    let meta = StageMeta {
509        restore_id: restore_id.clone(),
510        staged_at: Utc::now(),
511        staged_by: req.auth.did.clone(),
512        target_environment: req.target.environment,
513        cross_identity,
514        tee_secrets_sha256: prepared
515            .tee_secrets_row
516            .as_deref()
517            .map(restore_stage::sha256_hex),
518        anchor: prepared.anchor.clone(),
519    };
520    let result = import_result(
521        &payload,
522        "imported",
523        restore_message(&payload, req.target.environment),
524    );
525
526    let staged = async {
527        restore_stage::write_stage(&req.target.bootstrap()?, &seed, meta, payload).await?;
528        // The point of no return: from here the next boot opens the stage.
529        req.committer.commit(&secrets, prepared).await
530    }
531    .await;
532    if let Err(e) = staged {
533        req.committer.abort().await;
534        return Err(e);
535    }
536
537    info!(
538        restore_id,
539        staged_by = %req.auth.did,
540        source_did = result.source_did.as_deref().unwrap_or("unknown"),
541        target_environment = %req.target.environment,
542        cross_identity,
543        "backup import committed — the VTA reboots to apply it"
544    );
545    Ok(result)
546}
547
548fn restore_message(payload: &BackupPayload, target: BackupEnvironment) -> String {
549    let mut message = format!(
550        "Restore committed. The VTA is rebooting to apply it{}.",
551        match payload.source_environment {
552            Some(source) if source != target => format!(" ({source} → {target})"),
553            _ => String::new(),
554        }
555    );
556    if !payload.internal_keys_not_carried.is_empty() {
557        message.push_str(&format!(
558            " {} internal key(s) were not in the backup and are lost: {}.",
559            payload.internal_keys_not_carried.len(),
560            payload.internal_keys_not_carried.join(", ")
561        ));
562    }
563    message
564}
565
566/// Recompute `path_counter:{base}` values from restored key records, so an
567/// old (pre-P0.5) backup with no exported counters still can't re-derive an
568/// in-use BIP-32 path. For each derived key (`derivation_path` = `{base}/{n}'`)
569/// the counter for `base` must be at least `n + 1`.
570fn recompute_path_counters(
571    key_records: &[vta_sdk::keys::KeyRecord],
572) -> std::collections::HashMap<String, u32> {
573    let mut counters: std::collections::HashMap<String, u32> = std::collections::HashMap::new();
574    for kr in key_records {
575        let path = kr.derivation_path.trim();
576        if path.is_empty() {
577            continue; // imported (non-derived) key — no allocation counter
578        }
579        if let Some((base, last)) = path.rsplit_once('/')
580            && let Ok(index) = last.trim_end_matches('\'').parse::<u32>()
581        {
582            let next = index.saturating_add(1);
583            let slot = counters.entry(base.to_string()).or_insert(0);
584            *slot = (*slot).max(next);
585        }
586    }
587    counters
588}
589
590/// Recompute `ctx_counter:{parent}` values from restored context records. A
591/// sub-context carries its per-parent `index`; the counter for that parent
592/// must be at least `index + 1`.
593fn recompute_subcontext_counters(
594    context_records: &[vta_sdk::contexts::ContextRecord],
595) -> std::collections::HashMap<String, u32> {
596    let mut counters: std::collections::HashMap<String, u32> = std::collections::HashMap::new();
597    for cr in context_records {
598        if let Some(parent) = cr.parent.as_deref() {
599            let next = cr.index.saturating_add(1);
600            let slot = counters.entry(parent.to_string()).or_insert(0);
601            *slot = (*slot).max(next);
602        }
603    }
604    counters
605}
606
607/// Write a `vta-backup-v1` payload's typed collections into an empty store.
608/// Called by the boot-time restore for a v1 backup, after the wipe; a v2
609/// backup is written row for row instead.
610pub(crate) async fn write_legacy_payload(
611    payload: &BackupPayload,
612    ks: &vta_keyspaces::Keyspaces<'_>,
613    seed_bytes: &[u8],
614) -> Result<(), AppError> {
615    let keys_ks = ks.keys;
616    let acl_ks = ks.acl;
617    let contexts_ks = ks.contexts;
618    let audit_ks = ks.audit;
619    let imported_ks = ks.imported;
620
621    set_active_seed_id(keys_ks, payload.active_seed_id)
622        .await
623        .map_err(|e| AppError::Internal(format!("set active seed id: {e}")))?;
624
625    for sr in &payload.seed_records {
626        let record = SeedRecord {
627            id: sr.id,
628            seed_hex: sr.seed_hex.clone(),
629            seed_enc: sr.seed_enc.clone(),
630            created_at: sr.created_at,
631            retired_at: sr.retired_at,
632        };
633        save_seed_record(keys_ks, &record)
634            .await
635            .map_err(|e| AppError::Internal(format!("save seed record: {e}")))?;
636    }
637
638    for kr in &payload.key_records {
639        keys_ks.insert(vta_keys::store_key(&kr.key_id), kr).await?;
640    }
641
642    for cr in &payload.context_records {
643        contexts_ks.insert(format!("ctx:{}", cr.id), cr).await?;
644    }
645    contexts_ks
646        .insert_raw("ctx_counter", &payload.context_counter.to_le_bytes())
647        .await?;
648
649    // Restore the BIP-32 allocation counters (P0.5). Take the MAX of the
650    // exported value (exact — preserves gaps left by deleted keys) and the
651    // value recomputed from the restored records (the only source for a
652    // pre-P0.5 backup that has no exported counters). Either alone could
653    // under-count and re-derive an in-use key/subtree; the max never does.
654    {
655        let mut path_counters = recompute_path_counters(&payload.key_records);
656        for (k, v) in &payload.path_counters {
657            // Exported keys are the full `path_counter:{base}`; recompute is
658            // keyed by bare `{base}`. Normalise to bare base for the merge.
659            let base = k.strip_prefix("path_counter:").unwrap_or(k).to_string();
660            let slot = path_counters.entry(base).or_insert(0);
661            *slot = (*slot).max(*v);
662        }
663        for (base, next) in path_counters {
664            keys_ks
665                .insert_raw(format!("path_counter:{base}"), next.to_le_bytes().to_vec())
666                .await?;
667        }
668
669        let mut sub_counters = recompute_subcontext_counters(&payload.context_records);
670        for (k, v) in &payload.subcontext_counters {
671            let parent = k.strip_prefix("ctx_counter:").unwrap_or(k).to_string();
672            let slot = sub_counters.entry(parent).or_insert(0);
673            *slot = (*slot).max(*v);
674        }
675        for (parent, next) in sub_counters {
676            contexts_ks
677                .insert_raw(format!("ctx_counter:{parent}"), next.to_le_bytes().to_vec())
678                .await?;
679        }
680    }
681
682    // Prefer the lossless full-JSON ACL form (`acl_entries_full`) so expiry /
683    // step-up floors / capabilities / kind / device / version survive; fall
684    // back to the lossy 6-field `acl_entries` only for a pre-P0.5 backup.
685    if !payload.acl_entries_full.is_empty() {
686        for entry in &payload.acl_entries_full {
687            let did = entry
688                .get("did")
689                .and_then(|d| d.as_str())
690                .ok_or_else(|| AppError::Internal("backup ACL entry has no `did` field".into()))?;
691            let bytes = serde_json::to_vec(entry)?;
692            acl_ks.insert_raw(format!("acl:{did}"), bytes).await?;
693        }
694    } else if !payload.acl_entries.is_empty() {
695        warn!(
696            count = payload.acl_entries.len(),
697            "restoring ACL from a pre-P0.5 backup's lossy form — expiry, step-up \
698             floors, and capability restrictions are not present and default to \
699             permanent/none. Re-export with this build for a lossless backup."
700        );
701        for entry in &payload.acl_entries {
702            acl_ks.insert(format!("acl:{}", entry.did), entry).await?;
703        }
704    }
705
706    if let Some(ref seal) = payload.seal {
707        let record = SealRecord {
708            sealed_by: seal.sealed_by.clone(),
709            sealed_at: seal.sealed_at,
710            reason: seal.reason.clone(),
711        };
712        acl_ks.insert("vta:sealed", &record).await?;
713    }
714
715    #[cfg(feature = "webvh")]
716    {
717        let webvh_ks = ks.webvh;
718        for server in &payload.webvh_servers {
719            webvh_ks
720                .insert(format!("server:{}", server.id), server)
721                .await?;
722        }
723        for did_rec in &payload.webvh_dids {
724            webvh_ks
725                .insert(format!("did:{}", did_rec.did), did_rec)
726                .await?;
727        }
728        for log in &payload.webvh_logs {
729            webvh_ks
730                .insert_raw(format!("log:{}", log.did), log.log_json.as_bytes())
731                .await?;
732        }
733    }
734
735    for entry in &payload.audit_logs {
736        audit_ks
737            .insert(format!("log:{:020}:{}", entry.timestamp, entry.id), entry)
738            .await?;
739    }
740
741    if !payload.imported_secrets.is_empty() {
742        if let Some(ref salt_hex) = payload.imported_kek_salt {
743            let salt = hex::decode(salt_hex)
744                .map_err(|e| AppError::Internal(format!("invalid imported KEK salt hex: {e}")))?;
745            imported::set_salt(keys_ks, &salt).await?;
746        }
747        for secret_backup in &payload.imported_secrets {
748            let mut private_bytes = hex::decode(&secret_backup.private_key_hex)
749                .map_err(|e| AppError::Internal(format!("invalid imported secret hex: {e}")))?;
750            let key_type_str = payload
751                .key_records
752                .iter()
753                .find(|kr| kr.key_id == secret_backup.key_id)
754                .map(|kr| kr.key_type.to_string())
755                .unwrap_or_else(|| "ed25519".to_string());
756            let stored = imported::store_secret(
757                imported_ks,
758                keys_ks,
759                seed_bytes,
760                &secret_backup.key_id,
761                &key_type_str,
762                &private_bytes,
763            )
764            .await;
765            use zeroize::Zeroize;
766            private_bytes.zeroize();
767            stored?;
768        }
769    }
770    Ok(())
771}
772
773// ── Crypto helpers ─────────────────────────────────────────────────
774
775/// Associated data binding a v2 envelope's metadata to its ciphertext, so the
776/// unencrypted fields an operator reads before typing a password (source DID,
777/// whether the trail is included) cannot be swapped onto another backup.
778fn envelope_aad(envelope: &BackupEnvelope) -> Vec<u8> {
779    let mut aad = Vec::new();
780    for field in [
781        envelope.format.as_bytes(),
782        &envelope.version.to_be_bytes(),
783        envelope.source_did.as_deref().unwrap_or("").as_bytes(),
784        &[u8::from(envelope.includes_audit)],
785        envelope.kdf.salt.as_bytes(),
786        envelope.encryption.nonce.as_bytes(),
787    ] {
788        aad.extend_from_slice(&(field.len() as u32).to_be_bytes());
789        aad.extend_from_slice(field);
790    }
791    aad
792}
793
794fn derive_backup_key(
795    password: &str,
796    salt: &[u8],
797    m_cost: u32,
798    t_cost: u32,
799    p_cost: u32,
800) -> Result<zeroize::Zeroizing<[u8; 32]>, AppError> {
801    let argon2 = Argon2::new(
802        argon2::Algorithm::Argon2id,
803        argon2::Version::V0x13,
804        argon2::Params::new(m_cost, t_cost, p_cost, Some(32))
805            .map_err(|e| AppError::Validation(format!("argon2 params: {e}")))?,
806    );
807    let mut key = zeroize::Zeroizing::new([0u8; 32]);
808    argon2
809        .hash_password_into(password.as_bytes(), salt, key.as_mut())
810        .map_err(|e| AppError::Internal(format!("argon2 hash: {e}")))?;
811    Ok(key)
812}
813
814fn encrypt_payload(
815    payload: &BackupPayload,
816    password: &str,
817    include_audit: bool,
818    config: &vta_config::AppConfig,
819) -> Result<BackupEnvelope, AppError> {
820    let plaintext = zeroize::Zeroizing::new(
821        serde_json::to_vec(payload).map_err(|e| AppError::Internal(format!("serialize: {e}")))?,
822    );
823
824    let mut salt = [0u8; SALT_LEN];
825    rand::fill(&mut salt);
826    let mut nonce_bytes = [0u8; NONCE_LEN];
827    rand::fill(&mut nonce_bytes);
828
829    let key = derive_backup_key(password, &salt, ARGON2_M_COST, ARGON2_T_COST, ARGON2_P_COST)?;
830
831    let mut envelope = BackupEnvelope {
832        version: 2,
833        format: BACKUP_FORMAT_V2.into(),
834        created_at: Utc::now(),
835        source_did: config.vta_did.clone(),
836        source_version: env!("CARGO_PKG_VERSION").into(),
837        kdf: KdfParams {
838            algorithm: "argon2id".into(),
839            salt: BASE64.encode(salt),
840            m_cost: ARGON2_M_COST,
841            t_cost: ARGON2_T_COST,
842            p_cost: ARGON2_P_COST,
843        },
844        encryption: EncryptionParams {
845            algorithm: "aes-256-gcm".into(),
846            nonce: BASE64.encode(nonce_bytes),
847        },
848        includes_audit: include_audit,
849        ciphertext: String::new(),
850    };
851
852    let cipher = Aes256Gcm::new_from_slice(key.as_ref())
853        .map_err(|e| AppError::Internal(format!("aes key: {e}")))?;
854    let nonce = (&nonce_bytes).into();
855    let ciphertext = cipher
856        .encrypt(
857            nonce,
858            Payload {
859                msg: plaintext.as_ref(),
860                aad: &envelope_aad(&envelope),
861            },
862        )
863        .map_err(|e| AppError::Internal(format!("aes encrypt: {e}")))?;
864    envelope.ciphertext = BASE64.encode(&ciphertext);
865    Ok(envelope)
866}
867
868/// Decrypt a backup envelope and return the payload.
869///
870/// Accepts `vta-backup-v2` (every export this build writes) and `vta-backup-v1`
871/// (typed collections, no associated data). Use this for confirmed imports to
872/// avoid the overhead of building an `ImportResult` preview. For preview mode,
873/// use `preview_import()`.
874pub fn decrypt_backup(
875    envelope: &BackupEnvelope,
876    password: &str,
877) -> Result<BackupPayload, AppError> {
878    let v2 = match (envelope.version, envelope.format.as_str()) {
879        (2, BACKUP_FORMAT_V2) => true,
880        (1, BACKUP_FORMAT_V1) => false,
881        _ => {
882            return Err(AppError::Validation(format!(
883                "unsupported backup format: {} v{}",
884                envelope.format, envelope.version
885            )));
886        }
887    };
888
889    // Reject KDF parameters outside sane bounds. An untrusted envelope
890    // can otherwise force a memory bomb or a near-trivial KDF.
891    if envelope.kdf.algorithm != "argon2id" {
892        return Err(AppError::Validation(format!(
893            "unsupported KDF algorithm: '{}' (only 'argon2id' is accepted)",
894            envelope.kdf.algorithm
895        )));
896    }
897    if !(MIN_M_COST..=MAX_M_COST).contains(&envelope.kdf.m_cost) {
898        return Err(AppError::Validation(format!(
899            "argon2 m_cost {} out of bounds [{}, {}]",
900            envelope.kdf.m_cost, MIN_M_COST, MAX_M_COST
901        )));
902    }
903    if !(MIN_T_COST..=MAX_T_COST).contains(&envelope.kdf.t_cost) {
904        return Err(AppError::Validation(format!(
905            "argon2 t_cost {} out of bounds [{}, {}]",
906            envelope.kdf.t_cost, MIN_T_COST, MAX_T_COST
907        )));
908    }
909    if !(MIN_P_COST..=MAX_P_COST).contains(&envelope.kdf.p_cost) {
910        return Err(AppError::Validation(format!(
911            "argon2 p_cost {} out of bounds [{}, {}]",
912            envelope.kdf.p_cost, MIN_P_COST, MAX_P_COST
913        )));
914    }
915    if envelope.encryption.algorithm != "aes-256-gcm" {
916        return Err(AppError::Validation(format!(
917            "unsupported encryption algorithm: '{}' (only 'aes-256-gcm' is accepted)",
918            envelope.encryption.algorithm
919        )));
920    }
921
922    let salt = BASE64
923        .decode(&envelope.kdf.salt)
924        .map_err(|e| AppError::Validation(format!("invalid salt: {e}")))?;
925    if salt.len() != SALT_LEN {
926        return Err(AppError::Validation(format!(
927            "invalid salt length: {} (expected {SALT_LEN})",
928            salt.len()
929        )));
930    }
931    let nonce_bytes = BASE64
932        .decode(&envelope.encryption.nonce)
933        .map_err(|e| AppError::Validation(format!("invalid nonce: {e}")))?;
934    // Length check before the nonce is built. This used to be the *only*
935    // thing standing between a crafted backup envelope and a process-killing
936    // DoS on `/backup/import`, because `Nonce::from_slice` panicked on the
937    // wrong length. The conversion below is now `TryFrom`, so the panic is
938    // gone by construction and this check is no longer load-bearing for
939    // safety — it stays because it produces the better error: "invalid nonce
940    // length: 11 (expected 12)" rather than a bare conversion failure.
941    if nonce_bytes.len() != NONCE_LEN {
942        return Err(AppError::Validation(format!(
943            "invalid nonce length: {} (expected {NONCE_LEN})",
944            nonce_bytes.len()
945        )));
946    }
947    let ciphertext = BASE64
948        .decode(&envelope.ciphertext)
949        .map_err(|e| AppError::Validation(format!("invalid ciphertext: {e}")))?;
950
951    let key = derive_backup_key(
952        password,
953        &salt,
954        envelope.kdf.m_cost,
955        envelope.kdf.t_cost,
956        envelope.kdf.p_cost,
957    )?;
958    let cipher = Aes256Gcm::new_from_slice(key.as_ref())
959        .map_err(|e| AppError::Internal(format!("aes key: {e}")))?;
960    let nonce = Nonce::try_from(nonce_bytes.as_slice())
961        .map_err(|_| AppError::Validation(format!("nonce must be {NONCE_LEN} bytes")))?;
962    let aad = if v2 {
963        envelope_aad(envelope)
964    } else {
965        Vec::new()
966    };
967    let plaintext = zeroize::Zeroizing::new(
968        cipher
969            .decrypt(
970                &nonce,
971                Payload {
972                    msg: ciphertext.as_ref(),
973                    aad: &aad,
974                },
975            )
976            .map_err(|_| AppError::Authentication("incorrect backup password".into()))?,
977    );
978
979    serde_json::from_slice(&plaintext)
980        .map_err(|e| AppError::Internal(format!("backup payload corrupt: {e}")))
981}
982
983#[cfg(test)]
984pub(crate) mod tests {
985    use super::*;
986    use crate::test_support::{
987        TestSeedStore, open_test_store, super_admin_claims, test_app_config,
988    };
989
990    /// The Argon2id key derivation, pinned to a frozen vector.
991    ///
992    /// A backup envelope stores the salt and the KDF parameters, not the key.
993    /// Decryption re-derives it, so this function's output *is* the format:
994    /// change a byte of it and every backup ever written becomes
995    /// undecryptable, with nothing to say so but an AES-GCM tag failure.
996    ///
997    /// The encrypt/decrypt round-trip tests cannot catch that — they derive
998    /// the key twice with the same linked version, so both sides move
999    /// together. This vector was produced by argon2 0.5 and re-checked
1000    /// against 0.6 during that upgrade; it does not move.
1001    ///
1002    /// If this fails, do not regenerate it. It means the linked crate changed
1003    /// its derivation, and the change needs a versioned KDF marker in the
1004    /// envelope plus a path that reads the old one.
1005    #[test]
1006    fn argon2id_derivation_matches_the_frozen_vector() {
1007        let key = derive_backup_key(
1008            "backup-password",
1009            b"0123456789abcdef",
1010            ARGON2_M_COST,
1011            ARGON2_T_COST,
1012            ARGON2_P_COST,
1013        )
1014        .unwrap();
1015        assert_eq!(
1016            key.iter().map(|b| format!("{b:02x}")).collect::<String>(),
1017            "3558837960e818d4ae946a900d505053894bf02a6ac9e046f0781fe09a616bf9",
1018            "the backup KDF changed — every existing envelope is now \
1019             undecryptable"
1020        );
1021    }
1022
1023    fn plain_target(ts: &crate::test_support::TestStore) -> BackupTarget<'_> {
1024        BackupTarget {
1025            store: &ts.store,
1026            storage_key: None,
1027            environment: BackupEnvironment::Plain,
1028        }
1029    }
1030
1031    /// A backup must be complete: a corrupt `key:` row must ABORT the
1032    /// export rather than be silently carried (which would restore a key
1033    /// record nothing can read). This is the deliberate opposite of the
1034    /// steady-state list paths, which skip corrupt rows.
1035    #[tokio::test]
1036    async fn export_aborts_on_corrupt_key_row() {
1037        let ts = open_test_store().await;
1038        let config = test_app_config(ts.data_dir.clone());
1039        ts.keys_ks
1040            .insert_raw("key:corrupt", b"{not a key record".to_vec())
1041            .await
1042            .unwrap();
1043
1044        let err = export_backup(
1045            &plain_target(&ts),
1046            &TestSeedStore(vec![42u8; 32]),
1047            &config,
1048            &super_admin_claims(),
1049            "a-strong-password",
1050            false,
1051        )
1052        .await
1053        .expect_err("export must abort on a corrupt key row");
1054        let msg = format!("{err:?}");
1055        assert!(
1056            msg.contains("corrupt") && msg.contains("key"),
1057            "error must name the corrupt-row cause, got: {msg}"
1058        );
1059    }
1060
1061    /// `export_backup` rejects passwords shorter than 15 characters with a
1062    /// `Validation` error. The check fires before any keyspace I/O.
1063    /// A password of exactly 15 characters must be accepted (boundary).
1064    #[tokio::test]
1065    async fn export_rejects_short_password() {
1066        let ts = open_test_store().await;
1067        let config = test_app_config(ts.data_dir.clone());
1068        let seed_store = TestSeedStore(vec![42u8; 32]);
1069        let target = plain_target(&ts);
1070        let auth = super_admin_claims();
1071
1072        let err = export_backup(
1073            &target,
1074            &seed_store,
1075            &config,
1076            &auth,
1077            "14-char-passwo",
1078            false,
1079        )
1080        .await
1081        .expect_err("export must reject a 14-character password");
1082        assert!(
1083            format!("{err}").contains("15 characters"),
1084            "error must mention the 15-character minimum, got: {err}"
1085        );
1086        export_backup(
1087            &target,
1088            &seed_store,
1089            &config,
1090            &auth,
1091            "15-char-passwor",
1092            false,
1093        )
1094        .await
1095        .expect("export must accept a 15-character password");
1096    }
1097
1098    /// Environment-bound rows are the deployment's, not the agent's. An export
1099    /// that carried the source enclave's identity mirror or a hardened VTA's
1100    /// JWT row would plant it on a target that means something else by it.
1101    #[tokio::test]
1102    async fn export_leaves_deployment_bound_rows_behind() {
1103        let ts = open_test_store().await;
1104        let config = test_app_config(ts.data_dir.clone());
1105        ts.keys_ks
1106            .insert_raw("tee:vta_did", b"did:example:enclave".to_vec())
1107            .await
1108            .unwrap();
1109        ts.keys_ks
1110            .insert_raw("hardened:jwt_key", vec![1u8; 32])
1111            .await
1112            .unwrap();
1113        ts.keys_ks
1114            .insert_raw("path_counter:m/1'", 3u32.to_le_bytes().to_vec())
1115            .await
1116            .unwrap();
1117
1118        let env = export_backup(
1119            &plain_target(&ts),
1120            &TestSeedStore(vec![42u8; 32]),
1121            &config,
1122            &super_admin_claims(),
1123            "a-strong-password",
1124            false,
1125        )
1126        .await
1127        .unwrap();
1128        let payload = decrypt_backup(&env, "a-strong-password").unwrap();
1129        let keys: Vec<Vec<u8>> = payload
1130            .keyspaces
1131            .iter()
1132            .flat_map(|d| d.rows.iter().map(|(k, _)| BASE64.decode(k).unwrap()))
1133            .collect();
1134        assert!(keys.iter().any(|k| k == b"path_counter:m/1'"));
1135        for bound in [&b"tee:vta_did"[..], b"hardened:jwt_key"] {
1136            assert!(
1137                !keys.iter().any(|k| k == bound),
1138                "{} must not be exported",
1139                String::from_utf8_lossy(bound)
1140            );
1141        }
1142        assert_eq!(payload.source_environment, Some(BackupEnvironment::Plain));
1143    }
1144
1145    fn v2_payload() -> BackupPayload {
1146        BackupPayload {
1147            active_seed_hex: hex::encode([42u8; 32]),
1148            active_seed_id: 1,
1149            seed_records: vec![],
1150            jwt_signing_key: Some(BASE64.encode([99u8; 32])),
1151            key_records: vec![],
1152            context_records: vec![],
1153            context_counter: 0,
1154            path_counters: vec![],
1155            subcontext_counters: vec![],
1156            acl_entries: vec![],
1157            acl_entries_full: vec![],
1158            seal: None,
1159            webvh_servers: vec![],
1160            webvh_dids: vec![],
1161            webvh_logs: vec![],
1162            config: BackupConfig {
1163                vta_did: Some("did:key:z6MkVTA".into()),
1164                vta_name: Some("Test VTA".into()),
1165                public_url: None,
1166                mediator_url: None,
1167                mediator_did: None,
1168            },
1169            audit_logs: vec![],
1170            imported_secrets: vec![],
1171            imported_kek_salt: None,
1172            keyspaces: vec![KeyspaceDump {
1173                name: vta_keyspaces::ACL.into(),
1174                rows: vec![(BASE64.encode("acl:did:key:zA"), BASE64.encode("{}"))],
1175            }],
1176            source_environment: Some(BackupEnvironment::Tee),
1177            internal_keys_not_carried: vec![],
1178        }
1179    }
1180
1181    fn test_config() -> vta_config::AppConfig {
1182        toml::from_str("").unwrap()
1183    }
1184
1185    /// The pre-v2 envelope, exactly as the previous build wrote it (no
1186    /// associated data), so the v1 read path stays tested against real bytes
1187    /// rather than against its own writer.
1188    fn encrypt_v1(payload: &BackupPayload, password: &str) -> BackupEnvelope {
1189        let plaintext = serde_json::to_vec(payload).unwrap();
1190        let salt = [5u8; SALT_LEN];
1191        let nonce_bytes = [6u8; NONCE_LEN];
1192        let key = derive_backup_key(password, &salt, ARGON2_M_COST, ARGON2_T_COST, ARGON2_P_COST)
1193            .unwrap();
1194        let cipher = Aes256Gcm::new_from_slice(key.as_ref()).unwrap();
1195        let ciphertext = cipher
1196            .encrypt((&nonce_bytes).into(), plaintext.as_ref())
1197            .unwrap();
1198        BackupEnvelope {
1199            version: 1,
1200            format: BACKUP_FORMAT_V1.into(),
1201            created_at: Utc::now(),
1202            source_did: payload.config.vta_did.clone(),
1203            source_version: "0.0.0".into(),
1204            kdf: KdfParams {
1205                algorithm: "argon2id".into(),
1206                salt: BASE64.encode(salt),
1207                m_cost: ARGON2_M_COST,
1208                t_cost: ARGON2_T_COST,
1209                p_cost: ARGON2_P_COST,
1210            },
1211            encryption: EncryptionParams {
1212                algorithm: "aes-256-gcm".into(),
1213                nonce: BASE64.encode(nonce_bytes),
1214            },
1215            includes_audit: false,
1216            ciphertext: BASE64.encode(ciphertext),
1217        }
1218    }
1219
1220    #[test]
1221    fn encrypt_decrypt_roundtrip() {
1222        let payload = v2_payload();
1223        let password = "test-password-12chars!";
1224        let envelope = encrypt_payload(&payload, password, false, &test_config()).unwrap();
1225
1226        assert_eq!(envelope.version, 2);
1227        assert_eq!(envelope.format, BACKUP_FORMAT_V2);
1228        assert_eq!(envelope.kdf.algorithm, "argon2id");
1229        assert_eq!(envelope.encryption.algorithm, "aes-256-gcm");
1230
1231        let decrypted = decrypt_backup(&envelope, password).unwrap();
1232        assert_eq!(decrypted.active_seed_hex, payload.active_seed_hex);
1233        assert_eq!(decrypted.jwt_signing_key, payload.jwt_signing_key);
1234        assert_eq!(decrypted.keyspaces.len(), 1);
1235        assert_eq!(decrypted.keyspaces[0].rows, payload.keyspaces[0].rows);
1236        assert_eq!(decrypted.source_environment, Some(BackupEnvironment::Tee));
1237        assert_eq!(decrypted.config.vta_did, Some("did:key:z6MkVTA".into()));
1238    }
1239
1240    /// Every backup written before v2 must keep restoring.
1241    #[test]
1242    fn a_v1_envelope_still_decrypts() {
1243        let mut payload = v2_payload();
1244        payload.keyspaces.clear();
1245        payload.acl_entries = vec![AclEntryBackup {
1246            did: "did:key:z6MkTest".into(),
1247            role: "Admin".into(),
1248            label: None,
1249            allowed_contexts: vec![],
1250            created_at: 1000,
1251            created_by: "did:key:z6MkSetup".into(),
1252        }];
1253        let env = encrypt_v1(&payload, "v1-password-12chars");
1254        let back = decrypt_backup(&env, "v1-password-12chars").unwrap();
1255        assert!(back.keyspaces.is_empty());
1256        assert_eq!(back.acl_entries.len(), 1);
1257    }
1258
1259    /// The fields an operator reads before typing a password are bound to the
1260    /// ciphertext in v2: relabelling a backup as another agent's fails.
1261    #[test]
1262    fn v2_envelope_metadata_is_authenticated() {
1263        let password = "test-password-12chars!";
1264        let mut env = encrypt_payload(&v2_payload(), password, false, &test_config()).unwrap();
1265        env.source_did = Some("did:key:z6MkSomeoneElse".into());
1266        assert!(decrypt_backup(&env, password).is_err());
1267
1268        let mut env = encrypt_payload(&v2_payload(), password, false, &test_config()).unwrap();
1269        env.includes_audit = true;
1270        assert!(decrypt_backup(&env, password).is_err());
1271    }
1272
1273    #[test]
1274    fn wrong_password_fails() {
1275        let envelope =
1276            encrypt_payload(&v2_payload(), "correct-password!!", false, &test_config()).unwrap();
1277        let err = decrypt_backup(&envelope, "wrong-password!!!").unwrap_err();
1278        assert!(
1279            format!("{err}").contains("incorrect backup password"),
1280            "expected auth error, got: {err}"
1281        );
1282    }
1283
1284    #[test]
1285    fn tampered_ciphertext_detected() {
1286        let password = "test-password-12chars!";
1287        let mut envelope = encrypt_payload(&v2_payload(), password, false, &test_config()).unwrap();
1288        let mut ct_bytes = BASE64.decode(&envelope.ciphertext).unwrap();
1289        if let Some(byte) = ct_bytes.last_mut() {
1290            *byte ^= 0xFF;
1291        }
1292        envelope.ciphertext = BASE64.encode(&ct_bytes);
1293        assert!(
1294            format!("{}", decrypt_backup(&envelope, password).unwrap_err())
1295                .contains("incorrect backup password"),
1296            "tampered ciphertext should fail AES-GCM auth"
1297        );
1298    }
1299
1300    #[test]
1301    fn unsupported_version_and_format_rejected() {
1302        let password = "test-password-12chars!";
1303        for (version, format) in [
1304            (99, BACKUP_FORMAT_V2),
1305            (2, "unknown-format"),
1306            // A version that does not match its format is not guessed at.
1307            (1, BACKUP_FORMAT_V2),
1308            (2, BACKUP_FORMAT_V1),
1309        ] {
1310            let mut envelope =
1311                encrypt_payload(&v2_payload(), password, false, &test_config()).unwrap();
1312            envelope.version = version;
1313            envelope.format = format.into();
1314            assert!(
1315                format!("{}", decrypt_backup(&envelope, password).unwrap_err())
1316                    .contains("unsupported backup format"),
1317                "v{version} {format} must be refused"
1318            );
1319        }
1320    }
1321
1322    #[test]
1323    fn envelope_serialization_roundtrip() {
1324        let password = "test-password-12chars!";
1325        let envelope = encrypt_payload(&v2_payload(), password, true, &test_config()).unwrap();
1326        let json = serde_json::to_string_pretty(&envelope).unwrap();
1327        let deserialized: BackupEnvelope = serde_json::from_str(&json).unwrap();
1328        assert!(deserialized.includes_audit);
1329        assert_eq!(deserialized.ciphertext, envelope.ciphertext);
1330        let decrypted = decrypt_backup(&deserialized, password).unwrap();
1331        assert_eq!(decrypted.active_seed_hex, v2_payload().active_seed_hex);
1332    }
1333
1334    #[test]
1335    fn different_passwords_produce_different_ciphertexts() {
1336        let env1 =
1337            encrypt_payload(&v2_payload(), "password-one-12!!", false, &test_config()).unwrap();
1338        let env2 =
1339            encrypt_payload(&v2_payload(), "password-two-12!!", false, &test_config()).unwrap();
1340        assert_ne!(env1.kdf.salt, env2.kdf.salt);
1341        assert_ne!(env1.ciphertext, env2.ciphertext);
1342    }
1343
1344    // ── payload validation ──────────────────────────────────────────
1345
1346    /// A backup is attacker-supplied input to a super-admin. A dump naming a
1347    /// keyspace outside `BACKED_UP` would write straight into an enclave's
1348    /// boot material, or plant an "internal" key nobody generated.
1349    #[test]
1350    fn a_payload_cannot_write_outside_the_backed_up_keyspaces() {
1351        for name in [
1352            vta_keyspaces::BOOTSTRAP,
1353            vta_keyspaces::INTERNAL_KEYS,
1354            "no_such",
1355        ] {
1356            let mut payload = v2_payload();
1357            payload.keyspaces.push(KeyspaceDump {
1358                name: name.into(),
1359                rows: vec![],
1360            });
1361            let err = validate_payload(&payload).unwrap_err();
1362            assert!(format!("{err}").contains(name), "{name}: {err}");
1363        }
1364    }
1365
1366    #[test]
1367    fn a_payload_cannot_carry_a_deployment_bound_row() {
1368        let mut payload = v2_payload();
1369        payload.keyspaces.push(KeyspaceDump {
1370            name: vta_keyspaces::KEYS.into(),
1371            rows: vec![(BASE64.encode("tee:vta_did"), BASE64.encode("did:x"))],
1372        });
1373        assert!(
1374            format!("{}", validate_payload(&payload).unwrap_err()).contains("deployment-bound")
1375        );
1376    }
1377
1378    #[test]
1379    fn a_payload_cannot_name_a_keyspace_twice() {
1380        let mut payload = v2_payload();
1381        payload.keyspaces.push(payload.keyspaces[0].clone());
1382        assert!(format!("{}", validate_payload(&payload).unwrap_err()).contains("twice"));
1383    }
1384
1385    #[test]
1386    fn payload_counts_read_raw_rows() {
1387        let mut payload = v2_payload();
1388        payload.keyspaces.push(KeyspaceDump {
1389            name: vta_keyspaces::KEYS.into(),
1390            rows: vec![
1391                (BASE64.encode("key:a"), BASE64.encode("{}")),
1392                (BASE64.encode("key:b"), BASE64.encode("{}")),
1393                (BASE64.encode("path_counter:x"), BASE64.encode("1")),
1394            ],
1395        });
1396        let counts = payload_counts(&payload);
1397        assert_eq!(counts.keys, 2);
1398        assert_eq!(counts.acls, 1);
1399    }
1400
1401    // ── vta_did cross-check guard ───────────────────────────────────
1402
1403    #[test]
1404    fn vta_did_guard_fresh_install_accepts_any_backup() {
1405        check_vta_did_compatibility(None, Some("did:key:z6MkAnything"), false)
1406            .expect("fresh install must accept any backup");
1407        check_vta_did_compatibility(None, None, false)
1408            .expect("fresh install accepts no-did backup");
1409        check_vta_did_compatibility(Some(""), Some("did:key:z6MkAnything"), false)
1410            .expect("empty-string vta_did counts as fresh install");
1411    }
1412
1413    #[test]
1414    fn vta_did_guard_matching_dids_accepted() {
1415        check_vta_did_compatibility(Some("did:key:z6MkSame"), Some("did:key:z6MkSame"), false)
1416            .expect("matching vta_did must pass");
1417    }
1418
1419    #[test]
1420    fn vta_did_guard_mismatch_rejected_with_the_fix() {
1421        let err = check_vta_did_compatibility(
1422            Some("did:key:z6MkRunning"),
1423            Some("did:key:z6MkForeignBackup"),
1424            false,
1425        )
1426        .expect_err("mismatched vta_did must be rejected");
1427        let msg = format!("{err}");
1428        assert!(msg.contains("vta_did mismatch"), "got: {msg}");
1429        assert!(
1430            msg.contains("z6MkForeignBackup"),
1431            "must name backup did: {msg}"
1432        );
1433        assert!(msg.contains("z6MkRunning"), "must name running did: {msg}");
1434        assert!(
1435            msg.contains("--replace-identity"),
1436            "must name the fix: {msg}"
1437        );
1438    }
1439
1440    /// Disaster recovery onto a freshly set-up VTA: it minted a DID of its
1441    /// own, and the operator says to replace it.
1442    #[test]
1443    fn vta_did_guard_yields_to_replace_identity() {
1444        check_vta_did_compatibility(
1445            Some("did:key:z6MkFreshlySetUp"),
1446            Some("did:key:z6MkTheOneWeLost"),
1447            true,
1448        )
1449        .expect("replace_identity must allow the swap");
1450    }
1451
1452    #[test]
1453    fn vta_did_guard_backup_missing_did_rejected_when_running_has_did() {
1454        let err = check_vta_did_compatibility(Some("did:key:z6MkRunning"), None, false)
1455            .expect_err("missing backup vta_did must be rejected when running has one");
1456        assert!(format!("{err}").contains("vta_did mismatch"), "got {err:?}");
1457    }
1458
1459    #[test]
1460    fn recompute_path_counters_skips_imported_keys_and_takes_max() {
1461        let recs = vec![
1462            mk_key_record("a", "m/26'/2'/0'/0'"),
1463            mk_key_record("b", "m/26'/2'/0'/3'"),
1464            mk_key_record("imported", ""),
1465        ];
1466        let counters = recompute_path_counters(&recs);
1467        assert_eq!(counters.get("m/26'/2'/0'"), Some(&4));
1468        assert_eq!(counters.len(), 1);
1469    }
1470
1471    pub(crate) fn mk_key_record(key_id: &str, derivation_path: &str) -> vta_sdk::keys::KeyRecord {
1472        use vta_sdk::keys::{KeyOrigin, KeyRecord, KeyStatus, KeyType};
1473        let now = Utc::now();
1474        KeyRecord {
1475            key_id: key_id.into(),
1476            derivation_path: derivation_path.into(),
1477            key_type: KeyType::Ed25519,
1478            status: KeyStatus::Active,
1479            public_key: "zPlaceholder".into(),
1480            label: None,
1481            context_id: None,
1482            seed_id: None,
1483            exportable: None,
1484            origin: KeyOrigin::Derived,
1485            created_at: now,
1486            updated_at: now,
1487        }
1488    }
1489
1490    // ── KDF parameter clamps on import ──────────────────────────────
1491
1492    fn make_envelope_with_kdf(m_cost: u32, t_cost: u32, p_cost: u32, alg: &str) -> BackupEnvelope {
1493        // Build a real encrypted envelope, then mutate the KDF params. The
1494        // bounds check fires before decrypt is attempted — that's the
1495        // behaviour under test.
1496        let mut env =
1497            encrypt_payload(&v2_payload(), "password-12!ok!a", false, &test_config()).unwrap();
1498        env.kdf.algorithm = alg.into();
1499        env.kdf.m_cost = m_cost;
1500        env.kdf.t_cost = t_cost;
1501        env.kdf.p_cost = p_cost;
1502        env
1503    }
1504
1505    #[test]
1506    fn kdf_m_cost_above_max_rejected() {
1507        let env = make_envelope_with_kdf(MAX_M_COST + 1, ARGON2_T_COST, ARGON2_P_COST, "argon2id");
1508        let err = decrypt_backup(&env, "anything").expect_err("must reject huge m_cost");
1509        assert!(format!("{err}").contains("m_cost"), "got {err:?}");
1510    }
1511
1512    #[test]
1513    fn kdf_m_cost_below_min_rejected() {
1514        let env = make_envelope_with_kdf(1, ARGON2_T_COST, ARGON2_P_COST, "argon2id");
1515        let err = decrypt_backup(&env, "anything").expect_err("must reject m_cost = 1");
1516        assert!(format!("{err}").contains("m_cost"), "got {err:?}");
1517    }
1518
1519    #[test]
1520    fn kdf_t_cost_zero_rejected() {
1521        let env = make_envelope_with_kdf(ARGON2_M_COST, 0, ARGON2_P_COST, "argon2id");
1522        let err = decrypt_backup(&env, "anything").expect_err("must reject t_cost = 0");
1523        assert!(format!("{err}").contains("t_cost"), "got {err:?}");
1524    }
1525
1526    #[test]
1527    fn kdf_p_cost_above_max_rejected() {
1528        let env = make_envelope_with_kdf(ARGON2_M_COST, ARGON2_T_COST, MAX_P_COST + 1, "argon2id");
1529        let err = decrypt_backup(&env, "anything").expect_err("must reject huge p_cost");
1530        assert!(format!("{err}").contains("p_cost"), "got {err:?}");
1531    }
1532
1533    #[test]
1534    fn kdf_unknown_algorithm_rejected() {
1535        let env =
1536            make_envelope_with_kdf(ARGON2_M_COST, ARGON2_T_COST, ARGON2_P_COST, "scrypt-custom");
1537        let err = decrypt_backup(&env, "anything").expect_err("must reject non-argon2id KDF");
1538        assert!(format!("{err}").contains("KDF algorithm"), "got {err:?}");
1539    }
1540
1541    // ── Salt / nonce length validation on import ───────────────────
1542    //
1543    // Regression tests for the DoS where a crafted envelope's wrong-length
1544    // nonce would panic `Nonce::from_slice`. The panic is now impossible by
1545    // construction — the conversion is `TryFrom` — but these stay: they
1546    // assert the *rejection*, which is still what callers depend on.
1547
1548    #[test]
1549    fn nonce_wrong_length_rejected_without_panic() {
1550        let mut env =
1551            encrypt_payload(&v2_payload(), "password-12!ok!a", false, &test_config()).unwrap();
1552        env.encryption.nonce = BASE64.encode([0u8; 16]);
1553        let msg = format!(
1554            "{}",
1555            decrypt_backup(&env, "password-12!ok!a")
1556                .expect_err("wrong-length nonce must be rejected pre-decrypt")
1557        );
1558        assert!(
1559            msg.contains("nonce length"),
1560            "expected nonce-length error, got: {msg}"
1561        );
1562    }
1563
1564    #[test]
1565    fn salt_wrong_length_rejected_without_panic() {
1566        let mut env =
1567            encrypt_payload(&v2_payload(), "password-12!ok!a", false, &test_config()).unwrap();
1568        env.kdf.salt = BASE64.encode([0u8; 16]);
1569        let msg = format!(
1570            "{}",
1571            decrypt_backup(&env, "password-12!ok!a")
1572                .expect_err("wrong-length salt must be rejected pre-decrypt")
1573        );
1574        assert!(
1575            msg.contains("salt length"),
1576            "expected salt-length error, got: {msg}"
1577        );
1578    }
1579}