1pub 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
41const ARGON2_M_COST: u32 = 65536; const ARGON2_T_COST: u32 = 3;
45const ARGON2_P_COST: u32 = 4;
46const SALT_LEN: usize = 32;
47const NONCE_LEN: usize = 12;
48
49const MAX_M_COST: u32 = 1 << 20;
61const MIN_M_COST: u32 = 8 * 1024;
64const MAX_T_COST: u32 = 10;
66const MIN_T_COST: u32 = 1;
68const MAX_P_COST: u32 = 16;
70const MIN_P_COST: u32 = 1;
72
73pub 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 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 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 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 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 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 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 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 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 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 #[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 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 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 let jwt_signing_key = config.auth.jwt_signing_key.clone();
285
286 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 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 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
350pub 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
373fn 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
398pub const IMPORT_IN_PROGRESS_KEY: &str = "backup:import_in_progress";
405
406fn 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; }
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
430fn 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#[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 {
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 keys_ks
494 .insert_raw(IMPORT_IN_PROGRESS_KEY, b"1".to_vec())
495 .await?;
496 keys_ks.persist().await?;
497
498 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:", "server-auth:", "did:", "log:"],
518 )
519 .await?;
520
521 let _ = keys_ks.remove("active_seed_id").await;
523 let _ = contexts_ks.remove("ctx_counter").await;
524
525 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 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 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 for kr in &payload.key_records {
554 keys_ks.insert(vta_keys::store_key(&kr.key_id), kr).await?;
555 }
556
557 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 {
571 let mut path_counters = recompute_path_counters(&payload.key_records);
572 for (k, v) in &payload.path_counters {
573 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 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 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 #[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, (Some(_), None) => true, (Some(running), Some(backup)) => running != backup, };
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 for entry in &payload.audit_logs {
686 audit_ks
687 .insert(format!("log:{:020}:{}", entry.timestamp, entry.id), entry)
688 .await?;
689 }
690
691 if !payload.imported_secrets.is_empty() {
693 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 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 {
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 #[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 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 keys_ks.remove(IMPORT_IN_PROGRESS_KEY).await?;
794 keys_ks.persist().await?;
795
796 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
823fn 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 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 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
883pub 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 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 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 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 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
986async 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 #[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 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 clear_keyspace(&webvh_ks, &["server:", "server-auth:", "did:", "log:"])
1035 .await
1036 .unwrap();
1037
1038 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 #[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 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 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 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 #[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![]; 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 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 #[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 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 #[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![]; 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 #[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 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", ""), ];
1373 let counters = recompute_path_counters(&recs);
1374 assert_eq!(counters.get("m/26'/2'/0'"), Some(&4)); 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 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 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 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 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 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 assert_ne!(env1.kdf.salt, env2.kdf.salt);
1520 assert_ne!(env1.ciphertext, env2.ciphertext);
1521 }
1522
1523 #[test]
1526 fn vta_did_guard_fresh_install_accepts_any_backup() {
1527 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 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 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 fn make_envelope_with_kdf(m_cost: u32, t_cost: u32, p_cost: u32, alg: &str) -> BackupEnvelope {
1572 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 #[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 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 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}