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 vta_sdk::protocols::backup_management::validate_backup_password(password)
100 .map_err(AppError::Validation)?;
101
102 let seed_bytes = seed_store
104 .get()
105 .await
106 .map_err(|e| AppError::Internal(format!("seed store: {e}")))?
107 .ok_or_else(|| AppError::Internal("no active seed available".into()))?;
108 let active_seed_hex = hex::encode(&seed_bytes);
109 let active_seed_id = get_active_seed_id(keys_ks)
110 .await
111 .map_err(|e| AppError::Internal(format!("get active seed id: {e}")))?;
112
113 fn corrupt_row(kind: &str, key: &[u8], e: impl std::fmt::Display) -> AppError {
119 AppError::Internal(format!(
120 "backup aborted: {kind} row '{}' is corrupt and would be silently \
121 omitted from the backup: {e}",
122 String::from_utf8_lossy(key)
123 ))
124 }
125
126 let seed_records: Vec<SeedRecordBackup> = {
128 let raw = keys_ks.prefix_iter_raw("seed:").await?;
129 let mut records = Vec::with_capacity(raw.len());
130 for (key, value) in raw {
131 let sr: SeedRecord =
132 serde_json::from_slice(&value).map_err(|e| corrupt_row("seed", &key, e))?;
133 records.push(SeedRecordBackup {
134 id: sr.id,
135 seed_hex: sr.seed_hex,
136 seed_enc: sr.seed_enc,
137 created_at: sr.created_at,
138 retired_at: sr.retired_at,
139 });
140 }
141 records
142 };
143
144 let key_records: Vec<vta_sdk::keys::KeyRecord> = {
146 let raw = keys_ks.prefix_iter_raw("key:").await?;
147 let mut out = Vec::with_capacity(raw.len());
148 for (key, value) in raw {
149 out.push(serde_json::from_slice(&value).map_err(|e| corrupt_row("key", &key, e))?);
150 }
151 out
152 };
153
154 let context_records: Vec<vta_sdk::contexts::ContextRecord> = {
156 let raw = contexts_ks.prefix_iter_raw("ctx:").await?;
157 let mut out = Vec::with_capacity(raw.len());
158 for (key, value) in raw {
159 out.push(serde_json::from_slice(&value).map_err(|e| corrupt_row("context", &key, e))?);
160 }
161 out
162 };
163 let context_counter: u32 = contexts_ks
164 .get_raw("ctx_counter")
165 .await?
166 .and_then(|b| b.try_into().ok().map(u32::from_le_bytes))
167 .unwrap_or(0);
168
169 let read_u32_counters = |pairs: Vec<RawKvPair>| -> Vec<(String, u32)> {
175 pairs
176 .into_iter()
177 .filter_map(|(k, v)| {
178 let key = String::from_utf8(k).ok()?;
179 let arr: [u8; 4] = v.try_into().ok()?;
180 Some((key, u32::from_le_bytes(arr)))
181 })
182 .collect()
183 };
184 let path_counters = read_u32_counters(keys_ks.prefix_iter_raw("path_counter:").await?);
185 let subcontext_counters = read_u32_counters(contexts_ks.prefix_iter_raw("ctx_counter:").await?);
186
187 let (acl_entries, acl_entries_full): (Vec<AclEntryBackup>, Vec<serde_json::Value>) = {
194 let raw = acl_ks.prefix_iter_raw("acl:").await?;
195 let mut lossy = Vec::with_capacity(raw.len());
196 let mut full = Vec::with_capacity(raw.len());
197 for (key, v) in raw {
198 let val: serde_json::Value =
199 serde_json::from_slice(&v).map_err(|e| corrupt_row("ACL", &key, e))?;
200 lossy.push(AclEntryBackup {
201 did: val["did"].as_str().unwrap_or_default().to_string(),
202 role: val["role"].as_str().unwrap_or("Reader").to_string(),
203 label: val["label"].as_str().map(String::from),
204 allowed_contexts: val["allowed_contexts"]
205 .as_array()
206 .map(|a| {
207 a.iter()
208 .filter_map(|v| v.as_str().map(String::from))
209 .collect()
210 })
211 .unwrap_or_default(),
212 created_at: val["created_at"].as_u64().unwrap_or(0),
213 created_by: val["created_by"].as_str().unwrap_or_default().to_string(),
214 });
215 full.push(val);
216 }
217 (lossy, full)
218 };
219
220 let seal = get_seal(acl_ks)
222 .await
223 .ok()
224 .flatten()
225 .map(|s| SealRecordBackup {
226 sealed_by: s.sealed_by,
227 sealed_at: s.sealed_at,
228 reason: s.reason,
229 });
230
231 #[cfg(feature = "webvh")]
233 let (webvh_servers, webvh_dids, webvh_logs) = {
234 let servers: Vec<vta_sdk::webvh::WebvhServerRecord> = webvh_ks
235 .prefix_iter_raw("server:")
236 .await?
237 .into_iter()
238 .filter_map(|(_, v)| serde_json::from_slice(&v).ok())
239 .collect();
240 let dids: Vec<vta_sdk::webvh::WebvhDidRecord> = webvh_ks
241 .prefix_iter_raw("did:")
242 .await?
243 .into_iter()
244 .filter_map(|(_, v)| serde_json::from_slice(&v).ok())
245 .collect();
246 let logs: Vec<WebvhLogBackup> = webvh_ks
247 .prefix_iter_raw("log:")
248 .await?
249 .into_iter()
250 .filter_map(|(k, v)| {
251 let did = String::from_utf8(k).ok()?.strip_prefix("log:")?.to_string();
252 let log_json = String::from_utf8(v).ok()?;
253 Some(WebvhLogBackup { did, log_json })
254 })
255 .collect();
256 (servers, dids, logs)
257 };
258 #[cfg(not(feature = "webvh"))]
259 let (webvh_servers, webvh_dids, webvh_logs) = (Vec::new(), Vec::new(), Vec::new());
260
261 let audit_logs = if include_audit {
263 let raw = audit_ks.prefix_iter_raw("log:").await?;
264 raw.into_iter()
265 .filter_map(|(_, v)| serde_json::from_slice(&v).ok())
266 .collect()
267 } else {
268 Vec::new()
269 };
270
271 let backup_config = BackupConfig {
273 vta_did: config.vta_did.clone(),
274 vta_name: config.vta_name.clone(),
275 public_url: config.public_url.clone(),
276 mediator_url: config.messaging.as_ref().map(|m| m.mediator_url.clone()),
277 mediator_did: config.messaging.as_ref().map(|m| m.mediator_did.clone()),
278 };
279
280 let jwt_signing_key = config.auth.jwt_signing_key.clone();
282
283 let imported_kek_salt = imported::get_salt(keys_ks).await?.map(hex::encode);
285 let imported_secrets = {
286 let mut secrets = Vec::new();
287 for kr in &key_records {
288 if kr.origin == KeyOrigin::Imported
289 && kr.status == vta_sdk::keys::KeyStatus::Active
290 && let Ok(mut plaintext) = imported::load_secret(
291 imported_ks,
292 keys_ks,
293 &seed_bytes,
294 &kr.key_id,
295 &kr.key_type.to_string(),
296 )
297 .await
298 {
299 secrets.push(ImportedSecretBackup {
300 key_id: kr.key_id.clone(),
301 private_key_hex: hex::encode(&plaintext),
302 });
303 use zeroize::Zeroize;
304 plaintext.zeroize();
305 }
306 }
307 secrets
308 };
309
310 let payload = BackupPayload {
312 active_seed_hex,
313 active_seed_id,
314 seed_records,
315 jwt_signing_key,
316 key_records,
317 context_records,
318 context_counter,
319 path_counters,
320 subcontext_counters,
321 acl_entries,
322 acl_entries_full,
323 seal,
324 webvh_servers,
325 webvh_dids,
326 webvh_logs,
327 config: backup_config,
328 audit_logs,
329 imported_secrets,
330 imported_kek_salt,
331 };
332
333 let envelope = encrypt_payload(&payload, password, include_audit, config)?;
335
336 info!(
337 keys = payload.key_records.len(),
338 acls = payload.acl_entries.len(),
339 contexts = payload.context_records.len(),
340 audit = payload.audit_logs.len(),
341 "backup exported"
342 );
343
344 Ok(envelope)
345}
346
347pub async fn preview_import(
351 envelope: &BackupEnvelope,
352 password: &str,
353) -> Result<(BackupPayload, ImportResult), AppError> {
354 let payload = decrypt_backup(envelope, password)?;
355
356 let result = ImportResult {
357 status: "preview".into(),
358 source_did: payload.config.vta_did.clone(),
359 key_count: payload.key_records.len(),
360 acl_count: payload.acl_entries.len(),
361 context_count: payload.context_records.len(),
362 audit_count: payload.audit_logs.len(),
363 imported_secret_count: payload.imported_secrets.len(),
364 message: Some("Preview only — no changes applied. Set confirm=true to import.".into()),
365 };
366
367 Ok((payload, result))
368}
369
370fn check_vta_did_compatibility(
377 running_did: Option<&str>,
378 backup_did: Option<&str>,
379) -> Result<(), AppError> {
380 let running = match running_did {
381 Some(d) if !d.is_empty() => d,
382 _ => return Ok(()),
383 };
384 let backup = backup_did.unwrap_or("");
385 if backup == running {
386 return Ok(());
387 }
388 Err(AppError::Validation(format!(
389 "backup vta_did mismatch: backup claims '{backup}' but this VTA is running \
390 as '{running}'. Refusing to overwrite identity. If this is intentional \
391 (identity migration), clear vta_did from the running config first."
392 )))
393}
394
395pub const IMPORT_IN_PROGRESS_KEY: &str = "backup:import_in_progress";
402
403fn recompute_path_counters(
408 key_records: &[vta_sdk::keys::KeyRecord],
409) -> std::collections::HashMap<String, u32> {
410 let mut counters: std::collections::HashMap<String, u32> = std::collections::HashMap::new();
411 for kr in key_records {
412 let path = kr.derivation_path.trim();
413 if path.is_empty() {
414 continue; }
416 if let Some((base, last)) = path.rsplit_once('/')
417 && let Ok(index) = last.trim_end_matches('\'').parse::<u32>()
418 {
419 let next = index.saturating_add(1);
420 let slot = counters.entry(base.to_string()).or_insert(0);
421 *slot = (*slot).max(next);
422 }
423 }
424 counters
425}
426
427fn recompute_subcontext_counters(
431 context_records: &[vta_sdk::contexts::ContextRecord],
432) -> std::collections::HashMap<String, u32> {
433 let mut counters: std::collections::HashMap<String, u32> = std::collections::HashMap::new();
434 for cr in context_records {
435 if let Some(parent) = cr.parent.as_deref() {
436 let next = cr.index.saturating_add(1);
437 let slot = counters.entry(parent.to_string()).or_insert(0);
438 *slot = (*slot).max(next);
439 }
440 }
441 counters
442}
443
444#[cfg_attr(not(feature = "tee"), allow(unused_variables))]
460pub async fn apply_import(
461 payload: &BackupPayload,
462 ks: &vta_keyspaces::Keyspaces<'_>,
463 seed_store: &Arc<dyn SeedStore>,
464 config: &tokio::sync::RwLock<vta_config::AppConfig>,
465 store: Option<&vti_common::store::Store>,
466 #[cfg(feature = "tee")] re_encryptor: Option<&dyn crate::BootstrapReEncryptor>,
467) -> Result<ImportResult, AppError> {
468 {
472 let running_did = config.read().await.vta_did.clone();
473 check_vta_did_compatibility(running_did.as_deref(), payload.config.vta_did.as_deref())?;
474 }
475
476 let keys_ks = ks.keys;
477 let acl_ks = ks.acl;
478 let contexts_ks = ks.contexts;
479 let audit_ks = ks.audit;
480 let imported_ks = ks.imported;
481 #[cfg(feature = "webvh")]
482 let webvh_ks = ks.webvh;
483
484 keys_ks
491 .insert_raw(IMPORT_IN_PROGRESS_KEY, b"1".to_vec())
492 .await?;
493 keys_ks.persist().await?;
494
495 clear_keyspace(keys_ks, &["key:", "seed:", "path_counter:"]).await?;
500 clear_keyspace(acl_ks, &["acl:", "vta:"]).await?;
501 clear_keyspace(contexts_ks, &["ctx:", "ctx_counter:"]).await?;
502 clear_keyspace(audit_ks, &["log:"]).await?;
503 clear_keyspace(imported_ks, &["secret:"]).await?;
504 #[cfg(feature = "webvh")]
505 clear_keyspace(
506 webvh_ks,
507 &["server:", "server-auth:", "did:", "log:"],
515 )
516 .await?;
517
518 let _ = keys_ks.remove("active_seed_id").await;
520 let _ = contexts_ks.remove("ctx_counter").await;
521
522 let seed_bytes = hex::decode(&payload.active_seed_hex)
524 .map_err(|e| AppError::Internal(format!("invalid seed hex in backup: {e}")))?;
525 seed_store
526 .set(&seed_bytes)
527 .await
528 .map_err(|e| AppError::Internal(format!("seed store: {e}")))?;
529
530 set_active_seed_id(keys_ks, payload.active_seed_id)
532 .await
533 .map_err(|e| AppError::Internal(format!("set active seed id: {e}")))?;
534
535 for sr in &payload.seed_records {
537 let record = SeedRecord {
538 id: sr.id,
539 seed_hex: sr.seed_hex.clone(),
540 seed_enc: sr.seed_enc.clone(),
541 created_at: sr.created_at,
542 retired_at: sr.retired_at,
543 };
544 save_seed_record(keys_ks, &record)
545 .await
546 .map_err(|e| AppError::Internal(format!("save seed record: {e}")))?;
547 }
548
549 for kr in &payload.key_records {
551 keys_ks.insert(vta_keys::store_key(&kr.key_id), kr).await?;
552 }
553
554 for cr in &payload.context_records {
556 contexts_ks.insert(format!("ctx:{}", cr.id), cr).await?;
557 }
558 contexts_ks
559 .insert_raw("ctx_counter", &payload.context_counter.to_le_bytes())
560 .await?;
561
562 {
568 let mut path_counters = recompute_path_counters(&payload.key_records);
569 for (k, v) in &payload.path_counters {
570 let base = k.strip_prefix("path_counter:").unwrap_or(k).to_string();
573 let slot = path_counters.entry(base).or_insert(0);
574 *slot = (*slot).max(*v);
575 }
576 for (base, next) in path_counters {
577 keys_ks
578 .insert_raw(format!("path_counter:{base}"), next.to_le_bytes().to_vec())
579 .await?;
580 }
581
582 let mut sub_counters = recompute_subcontext_counters(&payload.context_records);
583 for (k, v) in &payload.subcontext_counters {
584 let parent = k.strip_prefix("ctx_counter:").unwrap_or(k).to_string();
585 let slot = sub_counters.entry(parent).or_insert(0);
586 *slot = (*slot).max(*v);
587 }
588 for (parent, next) in sub_counters {
589 contexts_ks
590 .insert_raw(format!("ctx_counter:{parent}"), next.to_le_bytes().to_vec())
591 .await?;
592 }
593 }
594
595 if !payload.acl_entries_full.is_empty() {
600 for entry in &payload.acl_entries_full {
601 let did = entry
602 .get("did")
603 .and_then(|d| d.as_str())
604 .ok_or_else(|| AppError::Internal("backup ACL entry has no `did` field".into()))?;
605 let bytes = serde_json::to_vec(entry)?;
606 acl_ks.insert_raw(format!("acl:{did}"), bytes).await?;
607 }
608 } else {
609 if !payload.acl_entries.is_empty() {
610 tracing::warn!(
611 count = payload.acl_entries.len(),
612 "restoring ACL from a pre-P0.5 backup's lossy form — expiry, step-up \
613 floors, and capability restrictions are not present and default to \
614 permanent/none. Re-export with this build for a lossless backup."
615 );
616 for entry in &payload.acl_entries {
617 acl_ks.insert(format!("acl:{}", entry.did), entry).await?;
618 }
619 }
620 }
621
622 if let Some(ref seal) = payload.seal {
624 let record = SealRecord {
625 sealed_by: seal.sealed_by.clone(),
626 sealed_at: seal.sealed_at,
627 reason: seal.reason.clone(),
628 };
629 acl_ks.insert("vta:sealed", &record).await?;
630 }
631
632 #[cfg(feature = "webvh")]
644 {
645 let backup_vta_did = payload.config.vta_did.as_deref();
646 let running_vta_did = config.read().await.vta_did.clone();
647 let cross_vta_restore = match (running_vta_did.as_deref(), backup_vta_did) {
648 (None, _) => false, (Some(_), None) => true, (Some(running), Some(backup)) => running != backup, };
652
653 for server in &payload.webvh_servers {
654 webvh_ks
655 .insert(format!("server:{}", server.id), server)
656 .await?;
657 }
658 for did_rec in &payload.webvh_dids {
659 let mut record = did_rec.clone();
660 if cross_vta_restore && record.server_id != "serverless" {
661 tracing::warn!(
662 did = %record.did,
663 original_server = %record.server_id,
664 "cross-VTA restore: stripping server_id/mnemonic from imported WebvhDidRecord; \
665 operator must `register_did_with_server` to re-attach to this VTA",
666 );
667 record.server_id = "serverless".to_string();
668 record.mnemonic = String::new();
669 }
670 webvh_ks
671 .insert(format!("did:{}", record.did), &record)
672 .await?;
673 }
674 for log in &payload.webvh_logs {
675 webvh_ks
676 .insert_raw(format!("log:{}", log.did), log.log_json.as_bytes())
677 .await?;
678 }
679 }
680
681 for entry in &payload.audit_logs {
683 audit_ks
684 .insert(format!("log:{:020}:{}", entry.timestamp, entry.id), entry)
685 .await?;
686 }
687
688 if !payload.imported_secrets.is_empty() {
690 if let Some(ref salt_hex) = payload.imported_kek_salt {
692 let salt = hex::decode(salt_hex)
693 .map_err(|e| AppError::Internal(format!("invalid imported KEK salt hex: {e}")))?;
694 imported::set_salt(keys_ks, &salt).await?;
695 }
696
697 for secret_backup in &payload.imported_secrets {
698 let private_bytes = hex::decode(&secret_backup.private_key_hex)
699 .map_err(|e| AppError::Internal(format!("invalid imported secret hex: {e}")))?;
700
701 let key_type_str = payload
703 .key_records
704 .iter()
705 .find(|kr| kr.key_id == secret_backup.key_id)
706 .map(|kr| kr.key_type.to_string())
707 .unwrap_or_else(|| "ed25519".to_string());
708
709 imported::store_secret(
710 imported_ks,
711 keys_ks,
712 &seed_bytes,
713 &secret_backup.key_id,
714 &key_type_str,
715 &private_bytes,
716 )
717 .await?;
718 }
719 }
720
721 {
723 let mut cfg = config.write().await;
724 if let Some(ref did) = payload.config.vta_did {
725 cfg.vta_did = Some(did.clone());
726 }
727 if let Some(ref name) = payload.config.vta_name {
728 cfg.vta_name = Some(name.clone());
729 }
730 if let Some(ref url) = payload.config.public_url {
731 cfg.public_url = Some(url.clone());
732 }
733 if let Some(ref jwt) = payload.jwt_signing_key {
734 cfg.auth.jwt_signing_key = Some(jwt.clone());
735 }
736 if payload.config.mediator_url.is_some() || payload.config.mediator_did.is_some() {
737 let messaging =
738 cfg.messaging
739 .get_or_insert_with(|| vti_common::config::MessagingConfig {
740 mediator_url: String::new(),
741 mediator_did: String::new(),
742 mediator_host: None,
743 setup_acl: false,
744 drain_inbox_on_start: false,
745 });
746 if let Some(ref url) = payload.config.mediator_url {
747 messaging.mediator_url = url.clone();
748 }
749 if let Some(ref did) = payload.config.mediator_did {
750 messaging.mediator_did = did.clone();
751 }
752 }
753 }
754
755 #[cfg(feature = "tee")]
757 if let Some(store) = store {
758 let cfg = config.read().await;
759 if let vta_config::TeeMode::Required = cfg.tee.mode
760 && let Some(ref kms_config) = cfg.tee.kms
761 {
762 let jwt_key_bytes: Option<[u8; 32]> =
763 payload.jwt_signing_key.as_ref().and_then(|b64| {
764 base64::Engine::decode(&BASE64, b64)
765 .ok()
766 .and_then(|b| b.try_into().ok())
767 });
768 if let Some(jwt_key) = jwt_key_bytes {
769 let re = re_encryptor.ok_or_else(|| {
775 AppError::Internal(
776 "TEE import requires a BootstrapReEncryptor but none was supplied".into(),
777 )
778 })?;
779 re.re_encrypt(kms_config, store, &seed_bytes, &jwt_key)
780 .await?;
781 } else {
782 info!("no JWT key in backup — skipping KMS re-encryption");
783 }
784 }
785 }
786
787 keys_ks.remove(IMPORT_IN_PROGRESS_KEY).await?;
791 keys_ks.persist().await?;
792
793 vti_common::integrity::reseal_if_active().await?;
799
800 info!(
801 keys = payload.key_records.len(),
802 acls = payload.acl_entries.len(),
803 contexts = payload.context_records.len(),
804 audit = payload.audit_logs.len(),
805 "backup imported — soft restart required"
806 );
807
808 Ok(ImportResult {
809 status: "imported".into(),
810 source_did: payload.config.vta_did.clone(),
811 key_count: payload.key_records.len(),
812 acl_count: payload.acl_entries.len(),
813 context_count: payload.context_records.len(),
814 audit_count: payload.audit_logs.len(),
815 imported_secret_count: payload.imported_secrets.len(),
816 message: Some("Import complete. VTA will restart with new identity.".into()),
817 })
818}
819
820fn encrypt_payload(
823 payload: &BackupPayload,
824 password: &str,
825 include_audit: bool,
826 config: &vta_config::AppConfig,
827) -> Result<BackupEnvelope, AppError> {
828 let plaintext =
829 serde_json::to_vec(payload).map_err(|e| AppError::Internal(format!("serialize: {e}")))?;
830
831 use rand::Rng;
832 let mut rng = rand::rng();
833 let mut salt = [0u8; SALT_LEN];
834 rng.fill_bytes(&mut salt);
835 let mut nonce_bytes = [0u8; NONCE_LEN];
836 rng.fill_bytes(&mut nonce_bytes);
837
838 let argon2 = Argon2::new(
840 argon2::Algorithm::Argon2id,
841 argon2::Version::V0x13,
842 argon2::Params::new(ARGON2_M_COST, ARGON2_T_COST, ARGON2_P_COST, Some(32))
843 .map_err(|e| AppError::Internal(format!("argon2 params: {e}")))?,
844 );
845 let mut key = [0u8; 32];
846 argon2
847 .hash_password_into(password.as_bytes(), &salt, &mut key)
848 .map_err(|e| AppError::Internal(format!("argon2 hash: {e}")))?;
849
850 let cipher =
852 Aes256Gcm::new_from_slice(&key).map_err(|e| AppError::Internal(format!("aes key: {e}")))?;
853 let nonce = (&nonce_bytes).into();
854 let ciphertext = cipher
855 .encrypt(nonce, plaintext.as_ref())
856 .map_err(|e| AppError::Internal(format!("aes encrypt: {e}")))?;
857
858 Ok(BackupEnvelope {
859 version: 1,
860 format: "vta-backup-v1".into(),
861 created_at: Utc::now(),
862 source_did: config.vta_did.clone(),
863 source_version: env!("CARGO_PKG_VERSION").into(),
864 kdf: KdfParams {
865 algorithm: "argon2id".into(),
866 salt: BASE64.encode(salt),
867 m_cost: ARGON2_M_COST,
868 t_cost: ARGON2_T_COST,
869 p_cost: ARGON2_P_COST,
870 },
871 encryption: EncryptionParams {
872 algorithm: "aes-256-gcm".into(),
873 nonce: BASE64.encode(nonce_bytes),
874 },
875 includes_audit: include_audit,
876 ciphertext: BASE64.encode(&ciphertext),
877 })
878}
879
880pub fn decrypt_backup(
885 envelope: &BackupEnvelope,
886 password: &str,
887) -> Result<BackupPayload, AppError> {
888 if envelope.version != 1 || envelope.format != "vta-backup-v1" {
889 return Err(AppError::Validation(format!(
890 "unsupported backup format: {} v{}",
891 envelope.format, envelope.version
892 )));
893 }
894
895 if envelope.kdf.algorithm != "argon2id" {
898 return Err(AppError::Validation(format!(
899 "unsupported KDF algorithm: '{}' (only 'argon2id' is accepted)",
900 envelope.kdf.algorithm
901 )));
902 }
903 if !(MIN_M_COST..=MAX_M_COST).contains(&envelope.kdf.m_cost) {
904 return Err(AppError::Validation(format!(
905 "argon2 m_cost {} out of bounds [{}, {}]",
906 envelope.kdf.m_cost, MIN_M_COST, MAX_M_COST
907 )));
908 }
909 if !(MIN_T_COST..=MAX_T_COST).contains(&envelope.kdf.t_cost) {
910 return Err(AppError::Validation(format!(
911 "argon2 t_cost {} out of bounds [{}, {}]",
912 envelope.kdf.t_cost, MIN_T_COST, MAX_T_COST
913 )));
914 }
915 if !(MIN_P_COST..=MAX_P_COST).contains(&envelope.kdf.p_cost) {
916 return Err(AppError::Validation(format!(
917 "argon2 p_cost {} out of bounds [{}, {}]",
918 envelope.kdf.p_cost, MIN_P_COST, MAX_P_COST
919 )));
920 }
921 if envelope.encryption.algorithm != "aes-256-gcm" {
922 return Err(AppError::Validation(format!(
923 "unsupported encryption algorithm: '{}' (only 'aes-256-gcm' is accepted)",
924 envelope.encryption.algorithm
925 )));
926 }
927
928 let salt = BASE64
929 .decode(&envelope.kdf.salt)
930 .map_err(|e| AppError::Validation(format!("invalid salt: {e}")))?;
931 if salt.len() != SALT_LEN {
932 return Err(AppError::Validation(format!(
933 "invalid salt length: {} (expected {SALT_LEN})",
934 salt.len()
935 )));
936 }
937 let nonce_bytes = BASE64
938 .decode(&envelope.encryption.nonce)
939 .map_err(|e| AppError::Validation(format!("invalid nonce: {e}")))?;
940 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::try_from(nonce_bytes.as_slice())
978 .map_err(|_| AppError::Validation(format!("nonce must be {NONCE_LEN} bytes")))?;
979 let plaintext = cipher
980 .decrypt(&nonce, ciphertext.as_ref())
981 .map_err(|_| AppError::Authentication("incorrect backup password".into()))?;
982
983 serde_json::from_slice(&plaintext)
984 .map_err(|e| AppError::Internal(format!("backup payload corrupt: {e}")))
985}
986
987async fn clear_keyspace(ks: &KeyspaceHandle, prefixes: &[&str]) -> Result<(), AppError> {
989 for prefix in prefixes {
990 let keys = ks.prefix_keys(prefix.to_string()).await?;
991 for key in keys {
992 ks.remove(key).await?;
993 }
994 }
995 Ok(())
996}
997
998#[cfg(test)]
999mod tests {
1000 use super::*;
1001 use vta_webvh::webvh_store::{WebvhServerAuthRecord, store_server_auth};
1002 use vti_common::config::StoreConfig as VtiStoreConfig;
1003 use vti_common::store::Store;
1004
1005 #[test]
1021 fn argon2id_derivation_matches_the_frozen_vector() {
1022 let argon2 = Argon2::new(
1023 argon2::Algorithm::Argon2id,
1024 argon2::Version::V0x13,
1025 argon2::Params::new(ARGON2_M_COST, ARGON2_T_COST, ARGON2_P_COST, Some(32)).unwrap(),
1026 );
1027 let mut key = [0u8; 32];
1028 argon2
1029 .hash_password_into(b"backup-password", b"0123456789abcdef", &mut key)
1030 .unwrap();
1031 assert_eq!(
1032 key.iter().map(|b| format!("{b:02x}")).collect::<String>(),
1033 "3558837960e818d4ae946a900d505053894bf02a6ac9e046f0781fe09a616bf9",
1034 "the backup KDF changed — every existing envelope is now \
1035 undecryptable"
1036 );
1037 }
1038
1039 #[tokio::test]
1048 async fn restore_clears_pre_existing_webvh_auth_cache() {
1049 let dir = tempfile::tempdir().unwrap();
1050 let store = Store::open(&VtiStoreConfig {
1051 data_dir: dir.path().into(),
1052 })
1053 .unwrap();
1054 let webvh_ks = store.keyspace(vta_keyspaces::WEBVH).unwrap();
1055
1056 let stale = WebvhServerAuthRecord {
1059 server_id: "prod".into(),
1060 access_token: "stale-access".into(),
1061 access_expires_at: 9_999_999_999,
1062 refresh_token: "stale-refresh".into(),
1063 refresh_expires_at: 9_999_999_999,
1064 };
1065 store_server_auth(&webvh_ks, &stale).await.unwrap();
1066
1067 clear_keyspace(&webvh_ks, &["server:", "server-auth:", "did:", "log:"])
1070 .await
1071 .unwrap();
1072
1073 let remaining = vta_webvh::webvh_store::get_server_auth(&webvh_ks, "prod")
1075 .await
1076 .unwrap();
1077 assert!(
1078 remaining.is_none(),
1079 "server-auth: prefix must be cleared on import; otherwise stale tokens leak across installations"
1080 );
1081 }
1082
1083 #[tokio::test]
1088 async fn export_aborts_on_corrupt_key_row() {
1089 let ts = crate::test_support::open_test_store().await;
1090 let seed_store = crate::test_support::TestSeedStore(vec![42u8; 32]);
1091 let config = crate::test_support::test_app_config(ts.data_dir.clone());
1092 let auth = crate::test_support::super_admin_claims();
1093
1094 ts.keys_ks
1096 .insert_raw("key:corrupt", b"{not a key record".to_vec())
1097 .await
1098 .unwrap();
1099
1100 let ks = vta_keyspaces::Keyspaces {
1101 keys: &ts.keys_ks,
1102 acl: &ts.acl_ks,
1103 contexts: &ts.contexts_ks,
1104 did_templates: &ts.did_templates_ks,
1105 audit: &ts.audit_ks,
1106 imported: &ts.imported_ks,
1107 #[cfg(feature = "webvh")]
1108 webvh: &ts.webvh_ks,
1109 };
1110
1111 let err = export_backup(&ks, &seed_store, &config, &auth, "a-strong-password", false)
1112 .await
1113 .expect_err("export must abort on a corrupt key row");
1114 let msg = format!("{err:?}");
1115 assert!(
1116 msg.contains("corrupt") && msg.contains("key"),
1117 "error must name the corrupt-row cause, got: {msg}"
1118 );
1119 }
1120
1121 #[tokio::test]
1125 async fn export_rejects_short_password() {
1126 let ts = crate::test_support::open_test_store().await;
1127 let seed_store = crate::test_support::TestSeedStore(vec![42u8; 32]);
1128 let config = crate::test_support::test_app_config(ts.data_dir.clone());
1129 let auth = crate::test_support::super_admin_claims();
1130
1131 let ks = vta_keyspaces::Keyspaces {
1132 keys: &ts.keys_ks,
1133 acl: &ts.acl_ks,
1134 contexts: &ts.contexts_ks,
1135 did_templates: &ts.did_templates_ks,
1136 audit: &ts.audit_ks,
1137 imported: &ts.imported_ks,
1138 #[cfg(feature = "webvh")]
1139 webvh: &ts.webvh_ks,
1140 };
1141
1142 let err = export_backup(&ks, &seed_store, &config, &auth, "14-char-passwo", false)
1144 .await
1145 .expect_err("export must reject a 14-character password");
1146 assert!(
1147 format!("{err}").contains("15 characters"),
1148 "error must mention the 15-character minimum, got: {err}"
1149 );
1150
1151 export_backup(&ks, &seed_store, &config, &auth, "15-char-passwor", false)
1153 .await
1154 .expect("export must accept a 15-character password");
1155 }
1156
1157 fn test_payload() -> BackupPayload {
1158 BackupPayload {
1159 active_seed_hex: hex::encode([42u8; 32]),
1160 active_seed_id: 1,
1161 seed_records: vec![SeedRecordBackup {
1162 id: 0,
1163 seed_hex: None,
1164 seed_enc: Some(vec![0xDEu8, 0xAD, 0xBE, 0xEF, 0x01, 0x02, 0x03]),
1168 created_at: Utc::now(),
1169 retired_at: Some(Utc::now()),
1170 }],
1171 jwt_signing_key: Some(BASE64.encode([99u8; 32])),
1172 key_records: vec![],
1173 context_records: vec![],
1174 context_counter: 2,
1175 path_counters: vec![],
1176 subcontext_counters: vec![],
1177 acl_entries: vec![AclEntryBackup {
1178 did: "did:key:z6MkTest".into(),
1179 role: "Admin".into(),
1180 label: Some("test admin".into()),
1181 allowed_contexts: vec!["ctx1".into()],
1182 created_at: 1000,
1183 created_by: "did:key:z6MkSetup".into(),
1184 }],
1185 acl_entries_full: vec![],
1186 seal: None,
1187 webvh_servers: vec![],
1188 webvh_dids: vec![],
1189 webvh_logs: vec![],
1190 config: BackupConfig {
1191 vta_did: Some("did:key:z6MkVTA".into()),
1192 vta_name: Some("Test VTA".into()),
1193 public_url: None,
1194 mediator_url: None,
1195 mediator_did: None,
1196 },
1197 audit_logs: vec![],
1198 imported_secrets: vec![],
1199 imported_kek_salt: None,
1200 }
1201 }
1202
1203 fn test_config() -> vta_config::AppConfig {
1204 toml::from_str("").unwrap()
1205 }
1206
1207 fn mk_key_record(key_id: &str, derivation_path: &str) -> vta_sdk::keys::KeyRecord {
1210 use vta_sdk::keys::{KeyOrigin, KeyRecord, KeyStatus, KeyType};
1211 let now = Utc::now();
1212 KeyRecord {
1213 key_id: key_id.into(),
1214 derivation_path: derivation_path.into(),
1215 key_type: KeyType::Ed25519,
1216 status: KeyStatus::Active,
1217 public_key: "zPlaceholder".into(),
1218 label: None,
1219 context_id: None,
1220 seed_id: None,
1221 exportable: None,
1222 origin: KeyOrigin::Derived,
1223 created_at: now,
1224 updated_at: now,
1225 }
1226 }
1227
1228 fn import_keyspaces<'a>(
1229 ts: &'a crate::test_support::TestStore,
1230 ) -> vta_keyspaces::Keyspaces<'a> {
1231 vta_keyspaces::Keyspaces {
1232 keys: &ts.keys_ks,
1233 acl: &ts.acl_ks,
1234 contexts: &ts.contexts_ks,
1235 did_templates: &ts.did_templates_ks,
1236 audit: &ts.audit_ks,
1237 imported: &ts.imported_ks,
1238 #[cfg(feature = "webvh")]
1239 webvh: &ts.webvh_ks,
1240 }
1241 }
1242
1243 #[tokio::test]
1247 async fn import_restores_path_counter_preventing_key_reuse() {
1248 let ts = crate::test_support::open_test_store().await;
1249 let seed_store: std::sync::Arc<dyn SeedStore> =
1250 std::sync::Arc::new(crate::test_support::TestSeedStore(vec![42u8; 32]));
1251 let config =
1252 tokio::sync::RwLock::new(crate::test_support::test_app_config(ts.data_dir.clone()));
1253
1254 let base = "m/26'/2'/0'";
1255 let mut payload = test_payload();
1256 payload.key_records = vec![mk_key_record("k0", &format!("{base}/0'"))];
1257 payload.path_counters = vec![(format!("path_counter:{base}"), 1)];
1258 payload.acl_entries = vec![]; apply_import(
1261 &payload,
1262 &import_keyspaces(&ts),
1263 &seed_store,
1264 &config,
1265 None,
1266 #[cfg(feature = "tee")]
1267 None,
1268 )
1269 .await
1270 .expect("import");
1271
1272 let next = vta_keys::paths::allocate_path(&ts.keys_ks, base)
1274 .await
1275 .expect("alloc");
1276 assert_eq!(
1277 next,
1278 format!("{base}/1'"),
1279 "restore must carry the path counter forward (no key reuse)"
1280 );
1281 }
1282
1283 #[tokio::test]
1287 async fn import_recomputes_path_counter_for_legacy_backup() {
1288 let ts = crate::test_support::open_test_store().await;
1289 let seed_store: std::sync::Arc<dyn SeedStore> =
1290 std::sync::Arc::new(crate::test_support::TestSeedStore(vec![42u8; 32]));
1291 let config =
1292 tokio::sync::RwLock::new(crate::test_support::test_app_config(ts.data_dir.clone()));
1293
1294 let base = "m/26'/2'/0'";
1295 let mut payload = test_payload();
1296 payload.key_records = vec![
1298 mk_key_record("k0", &format!("{base}/0'")),
1299 mk_key_record("k1", &format!("{base}/1'")),
1300 ];
1301 payload.path_counters = vec![];
1302 payload.acl_entries = vec![];
1303
1304 apply_import(
1305 &payload,
1306 &import_keyspaces(&ts),
1307 &seed_store,
1308 &config,
1309 None,
1310 #[cfg(feature = "tee")]
1311 None,
1312 )
1313 .await
1314 .expect("import");
1315
1316 let next = vta_keys::paths::allocate_path(&ts.keys_ks, base)
1317 .await
1318 .expect("alloc");
1319 assert_eq!(
1320 next,
1321 format!("{base}/2'"),
1322 "recomputed counter must skip both in-use indices"
1323 );
1324 }
1325
1326 #[tokio::test]
1330 async fn import_restores_full_acl_entry_fields() {
1331 use vti_common::acl::{AclEntry, Role};
1332 use vti_common::auth::step_up::StepUpMode;
1333
1334 let ts = crate::test_support::open_test_store().await;
1335 let seed_store: std::sync::Arc<dyn SeedStore> =
1336 std::sync::Arc::new(crate::test_support::TestSeedStore(vec![42u8; 32]));
1337 let config =
1338 tokio::sync::RwLock::new(crate::test_support::test_app_config(ts.data_dir.clone()));
1339
1340 let mut entry = AclEntry::new("did:key:zAcl", Role::Admin, "did:key:zSetup");
1341 entry.expires_at = Some(1_900_000_000);
1342 entry.step_up_require = Some(StepUpMode::SelfApprove);
1343 let full = serde_json::to_value(&entry).unwrap();
1344
1345 let mut payload = test_payload();
1346 payload.acl_entries = vec![]; payload.acl_entries_full = vec![full];
1348
1349 apply_import(
1350 &payload,
1351 &import_keyspaces(&ts),
1352 &seed_store,
1353 &config,
1354 None,
1355 #[cfg(feature = "tee")]
1356 None,
1357 )
1358 .await
1359 .expect("import");
1360
1361 let restored: AclEntry = ts
1362 .acl_ks
1363 .get("acl:did:key:zAcl")
1364 .await
1365 .unwrap()
1366 .expect("acl entry restored");
1367 assert_eq!(restored.role, Role::Admin);
1368 assert_eq!(
1369 restored.expires_at,
1370 Some(1_900_000_000),
1371 "expiry must survive (a lossy restore would make it permanent)"
1372 );
1373 assert_eq!(
1374 restored.step_up_require,
1375 Some(StepUpMode::SelfApprove),
1376 "step-up floor must survive (a lossy restore would strip it)"
1377 );
1378 }
1379
1380 #[tokio::test]
1384 async fn successful_import_leaves_no_in_progress_sentinel() {
1385 let ts = crate::test_support::open_test_store().await;
1386 let seed_store: std::sync::Arc<dyn SeedStore> =
1387 std::sync::Arc::new(crate::test_support::TestSeedStore(vec![42u8; 32]));
1388 let config =
1389 tokio::sync::RwLock::new(crate::test_support::test_app_config(ts.data_dir.clone()));
1390
1391 let mut payload = test_payload();
1392 payload.acl_entries = vec![];
1393
1394 apply_import(
1395 &payload,
1396 &import_keyspaces(&ts),
1397 &seed_store,
1398 &config,
1399 None,
1400 #[cfg(feature = "tee")]
1401 None,
1402 )
1403 .await
1404 .expect("import");
1405
1406 assert!(
1407 ts.keys_ks
1408 .get_raw(IMPORT_IN_PROGRESS_KEY)
1409 .await
1410 .unwrap()
1411 .is_none(),
1412 "a completed import must clear its in-progress sentinel"
1413 );
1414 }
1415
1416 #[tokio::test]
1417 async fn import_sentinel_survives_keyspace_clear() {
1418 let ts = crate::test_support::open_test_store().await;
1421 ts.keys_ks
1422 .insert_raw(IMPORT_IN_PROGRESS_KEY, b"1".to_vec())
1423 .await
1424 .unwrap();
1425 clear_keyspace(&ts.keys_ks, &["key:", "seed:", "path_counter:"])
1426 .await
1427 .unwrap();
1428 assert!(
1429 ts.keys_ks
1430 .get_raw(IMPORT_IN_PROGRESS_KEY)
1431 .await
1432 .unwrap()
1433 .is_some(),
1434 "the sentinel must survive the import clear so an interrupted import is detectable at boot"
1435 );
1436 }
1437
1438 #[test]
1439 fn recompute_path_counters_skips_imported_keys_and_takes_max() {
1440 let recs = vec![
1441 mk_key_record("a", "m/26'/2'/0'/0'"),
1442 mk_key_record("b", "m/26'/2'/0'/3'"),
1443 mk_key_record("imported", ""), ];
1445 let counters = recompute_path_counters(&recs);
1446 assert_eq!(counters.get("m/26'/2'/0'"), Some(&4)); assert_eq!(counters.len(), 1);
1448 }
1449
1450 #[test]
1451 fn encrypt_decrypt_roundtrip() {
1452 let payload = test_payload();
1453 let password = "test-password-12chars!";
1454 let config = test_config();
1455
1456 let envelope = encrypt_payload(&payload, password, false, &config).unwrap();
1457
1458 assert_eq!(envelope.version, 1);
1459 assert_eq!(envelope.format, "vta-backup-v1");
1460 assert_eq!(envelope.kdf.algorithm, "argon2id");
1461 assert_eq!(envelope.encryption.algorithm, "aes-256-gcm");
1462 assert!(!envelope.ciphertext.is_empty());
1463
1464 let decrypted = decrypt_backup(&envelope, password).unwrap();
1465
1466 assert_eq!(decrypted.active_seed_hex, payload.active_seed_hex);
1467 assert_eq!(decrypted.active_seed_id, payload.active_seed_id);
1468 assert_eq!(decrypted.seed_records.len(), 1);
1469 assert_eq!(decrypted.seed_records[0].id, 0);
1470 assert_eq!(decrypted.seed_records[0].seed_hex, None);
1473 assert_eq!(
1474 decrypted.seed_records[0].seed_enc,
1475 payload.seed_records[0].seed_enc
1476 );
1477 assert_eq!(decrypted.jwt_signing_key, payload.jwt_signing_key);
1478 assert_eq!(decrypted.context_counter, 2);
1479 assert_eq!(decrypted.acl_entries.len(), 1);
1480 assert_eq!(decrypted.acl_entries[0].did, "did:key:z6MkTest");
1481 assert_eq!(decrypted.acl_entries[0].role, "Admin");
1482 assert_eq!(decrypted.config.vta_did, Some("did:key:z6MkVTA".into()));
1483 assert_eq!(decrypted.config.vta_name, Some("Test VTA".into()));
1484 }
1485
1486 #[test]
1487 fn wrong_password_fails() {
1488 let payload = test_payload();
1489 let config = test_config();
1490
1491 let envelope = encrypt_payload(&payload, "correct-password!!", false, &config).unwrap();
1492 let result = decrypt_backup(&envelope, "wrong-password!!!");
1493
1494 assert!(result.is_err());
1495 let err = result.unwrap_err();
1496 assert!(
1498 format!("{err}").contains("incorrect backup password"),
1499 "expected auth error, got: {err}"
1500 );
1501 }
1502
1503 #[test]
1504 fn tampered_ciphertext_detected() {
1505 let payload = test_payload();
1506 let config = test_config();
1507 let password = "test-password-12chars!";
1508
1509 let mut envelope = encrypt_payload(&payload, password, false, &config).unwrap();
1510
1511 let mut ct_bytes = BASE64.decode(&envelope.ciphertext).unwrap();
1513 if let Some(byte) = ct_bytes.last_mut() {
1514 *byte ^= 0xFF;
1515 }
1516 envelope.ciphertext = BASE64.encode(&ct_bytes);
1517
1518 let result = decrypt_backup(&envelope, password);
1519 assert!(result.is_err());
1520 assert!(
1521 format!("{}", result.unwrap_err()).contains("incorrect backup password"),
1522 "tampered ciphertext should fail AES-GCM auth"
1523 );
1524 }
1525
1526 #[test]
1527 fn unsupported_version_rejected() {
1528 let payload = test_payload();
1529 let config = test_config();
1530 let password = "test-password-12chars!";
1531
1532 let mut envelope = encrypt_payload(&payload, password, false, &config).unwrap();
1533 envelope.version = 99;
1534
1535 let result = decrypt_backup(&envelope, password);
1536 assert!(result.is_err());
1537 assert!(
1538 format!("{}", result.unwrap_err()).contains("unsupported backup format"),
1539 "should reject unknown version"
1540 );
1541 }
1542
1543 #[test]
1544 fn unsupported_format_rejected() {
1545 let payload = test_payload();
1546 let config = test_config();
1547 let password = "test-password-12chars!";
1548
1549 let mut envelope = encrypt_payload(&payload, password, false, &config).unwrap();
1550 envelope.format = "unknown-format".into();
1551
1552 let result = decrypt_backup(&envelope, password);
1553 assert!(result.is_err());
1554 assert!(
1555 format!("{}", result.unwrap_err()).contains("unsupported backup format"),
1556 "should reject unknown format"
1557 );
1558 }
1559
1560 #[test]
1561 fn envelope_serialization_roundtrip() {
1562 let payload = test_payload();
1563 let config = test_config();
1564 let password = "test-password-12chars!";
1565
1566 let envelope = encrypt_payload(&payload, password, true, &config).unwrap();
1567
1568 let json = serde_json::to_string_pretty(&envelope).unwrap();
1570 let deserialized: BackupEnvelope = serde_json::from_str(&json).unwrap();
1571
1572 assert_eq!(deserialized.version, envelope.version);
1573 assert_eq!(deserialized.format, envelope.format);
1574 assert!(deserialized.includes_audit);
1575 assert_eq!(deserialized.ciphertext, envelope.ciphertext);
1576
1577 let decrypted = decrypt_backup(&deserialized, password).unwrap();
1579 assert_eq!(decrypted.active_seed_hex, payload.active_seed_hex);
1580 }
1581
1582 #[test]
1583 fn different_passwords_produce_different_ciphertexts() {
1584 let payload = test_payload();
1585 let config = test_config();
1586
1587 let env1 = encrypt_payload(&payload, "password-one-12!!", false, &config).unwrap();
1588 let env2 = encrypt_payload(&payload, "password-two-12!!", false, &config).unwrap();
1589
1590 assert_ne!(env1.kdf.salt, env2.kdf.salt);
1592 assert_ne!(env1.ciphertext, env2.ciphertext);
1593 }
1594
1595 #[test]
1598 fn vta_did_guard_fresh_install_accepts_any_backup() {
1599 check_vta_did_compatibility(None, Some("did:key:z6MkAnything"))
1602 .expect("fresh install must accept any backup");
1603 check_vta_did_compatibility(None, None).expect("fresh install accepts no-did backup");
1604 check_vta_did_compatibility(Some(""), Some("did:key:z6MkAnything"))
1605 .expect("empty-string vta_did counts as fresh install");
1606 }
1607
1608 #[test]
1609 fn vta_did_guard_matching_dids_accepted() {
1610 check_vta_did_compatibility(Some("did:key:z6MkSame"), Some("did:key:z6MkSame"))
1613 .expect("matching vta_did must pass");
1614 }
1615
1616 #[test]
1617 fn vta_did_guard_mismatch_rejected() {
1618 let err = check_vta_did_compatibility(
1619 Some("did:key:z6MkRunning"),
1620 Some("did:key:z6MkForeignBackup"),
1621 )
1622 .expect_err("mismatched vta_did must be rejected");
1623 let msg = format!("{err}");
1624 assert!(msg.contains("vta_did mismatch"), "got: {msg}");
1625 assert!(
1626 msg.contains("z6MkForeignBackup"),
1627 "must name backup did: {msg}"
1628 );
1629 assert!(msg.contains("z6MkRunning"), "must name running did: {msg}");
1630 }
1631
1632 #[test]
1633 fn vta_did_guard_backup_missing_did_rejected_when_running_has_did() {
1634 let err = check_vta_did_compatibility(Some("did:key:z6MkRunning"), None)
1637 .expect_err("missing backup vta_did must be rejected when running has one");
1638 assert!(format!("{err}").contains("vta_did mismatch"), "got {err:?}");
1639 }
1640
1641 fn make_envelope_with_kdf(m_cost: u32, t_cost: u32, p_cost: u32, alg: &str) -> BackupEnvelope {
1644 let payload = test_payload();
1649 let config = test_config();
1650 let mut env = encrypt_payload(&payload, "password-12!ok!a", false, &config).unwrap();
1651 env.kdf.algorithm = alg.into();
1652 env.kdf.m_cost = m_cost;
1653 env.kdf.t_cost = t_cost;
1654 env.kdf.p_cost = p_cost;
1655 env
1656 }
1657
1658 #[test]
1659 fn kdf_m_cost_above_max_rejected() {
1660 let env = make_envelope_with_kdf(MAX_M_COST + 1, ARGON2_T_COST, ARGON2_P_COST, "argon2id");
1661 let err = decrypt_backup(&env, "anything").expect_err("must reject huge m_cost");
1662 assert!(format!("{err}").contains("m_cost"), "got {err:?}");
1663 }
1664
1665 #[test]
1666 fn kdf_m_cost_below_min_rejected() {
1667 let env = make_envelope_with_kdf(1, ARGON2_T_COST, ARGON2_P_COST, "argon2id");
1668 let err = decrypt_backup(&env, "anything").expect_err("must reject m_cost = 1");
1669 assert!(format!("{err}").contains("m_cost"), "got {err:?}");
1670 }
1671
1672 #[test]
1673 fn kdf_t_cost_zero_rejected() {
1674 let env = make_envelope_with_kdf(ARGON2_M_COST, 0, ARGON2_P_COST, "argon2id");
1675 let err = decrypt_backup(&env, "anything").expect_err("must reject t_cost = 0");
1676 assert!(format!("{err}").contains("t_cost"), "got {err:?}");
1677 }
1678
1679 #[test]
1680 fn kdf_p_cost_above_max_rejected() {
1681 let env = make_envelope_with_kdf(ARGON2_M_COST, ARGON2_T_COST, MAX_P_COST + 1, "argon2id");
1682 let err = decrypt_backup(&env, "anything").expect_err("must reject huge p_cost");
1683 assert!(format!("{err}").contains("p_cost"), "got {err:?}");
1684 }
1685
1686 #[test]
1687 fn kdf_unknown_algorithm_rejected() {
1688 let env =
1689 make_envelope_with_kdf(ARGON2_M_COST, ARGON2_T_COST, ARGON2_P_COST, "scrypt-custom");
1690 let err = decrypt_backup(&env, "anything").expect_err("must reject non-argon2id KDF");
1691 assert!(format!("{err}").contains("KDF algorithm"), "got {err:?}");
1692 }
1693
1694 #[test]
1705 fn nonce_wrong_length_rejected_without_panic() {
1706 let payload = test_payload();
1707 let config = test_config();
1708 let mut env = encrypt_payload(&payload, "password-12!ok!a", false, &config).unwrap();
1709 env.encryption.nonce = BASE64.encode([0u8; 16]);
1712 let err = decrypt_backup(&env, "password-12!ok!a")
1713 .expect_err("wrong-length nonce must be rejected pre-decrypt");
1714 let msg = format!("{err}");
1715 assert!(
1716 msg.contains("nonce length"),
1717 "expected nonce-length error, got: {msg}"
1718 );
1719 }
1720
1721 #[test]
1722 fn salt_wrong_length_rejected_without_panic() {
1723 let payload = test_payload();
1724 let config = test_config();
1725 let mut env = encrypt_payload(&payload, "password-12!ok!a", false, &config).unwrap();
1726 env.kdf.salt = BASE64.encode([0u8; 16]);
1728 let err = decrypt_backup(&env, "password-12!ok!a")
1729 .expect_err("wrong-length salt must be rejected pre-decrypt");
1730 let msg = format!("{err}");
1731 assert!(
1732 msg.contains("salt length"),
1733 "expected salt-length error, got: {msg}"
1734 );
1735 }
1736}