1pub mod blob;
18pub mod chunked;
19pub mod descriptors;
20
21pub(crate) fn chunked_algorithm() -> &'static str {
23 vta_sdk::protocols::backup_management::chunked::ALGORITHM_CHUNKED
24}
25
26use std::sync::Arc;
27
28use aes_gcm::aead::Aead;
29use aes_gcm::{Aes256Gcm, KeyInit, Nonce};
30use argon2::Argon2;
31use base64::Engine;
32use base64::engine::general_purpose::URL_SAFE_NO_PAD as BASE64;
33use chrono::Utc;
34use tracing::info;
35
36use vta_keys::KeyOrigin;
37use vta_keys::imported;
38use vta_keys::seed_store::SeedStore;
39use vta_keys::seeds::{SeedRecord, get_active_seed_id, save_seed_record, set_active_seed_id};
40use vta_support::seal::{SealRecord, get_seal};
41use vti_common::auth::AuthClaims;
42use vti_common::error::AppError;
43use vti_common::store::{KeyspaceHandle, RawKvPair};
44
45use vta_sdk::protocols::backup_management::types::*;
46
47const ARGON2_M_COST: u32 = 65536; const ARGON2_T_COST: u32 = 3;
51const ARGON2_P_COST: u32 = 4;
52const SALT_LEN: usize = 32;
53const NONCE_LEN: usize = 12;
54
55const MAX_M_COST: u32 = 1 << 20;
67const MIN_M_COST: u32 = 8 * 1024;
70const MAX_T_COST: u32 = 10;
72const MIN_T_COST: u32 = 1;
74const MAX_P_COST: u32 = 16;
76const MIN_P_COST: u32 = 1;
78
79pub async fn export_backup(
83 ks: &vta_keyspaces::Keyspaces<'_>,
84 seed_store: &dyn SeedStore,
85 config: &vta_config::AppConfig,
86 auth: &AuthClaims,
87 password: &str,
88 include_audit: bool,
89) -> Result<BackupEnvelope, AppError> {
90 let keys_ks = ks.keys;
97 let acl_ks = ks.acl;
98 let contexts_ks = ks.contexts;
99 let audit_ks = ks.audit;
100 let imported_ks = ks.imported;
101 #[cfg(feature = "webvh")]
102 let webvh_ks = ks.webvh;
103 auth.require_super_admin()?;
104
105 vta_sdk::protocols::backup_management::validate_backup_password(password)
106 .map_err(AppError::Validation)?;
107
108 let seed_bytes = seed_store
110 .get()
111 .await
112 .map_err(|e| AppError::Internal(format!("seed store: {e}")))?
113 .ok_or_else(|| AppError::Internal("no active seed available".into()))?;
114 let active_seed_hex = hex::encode(&seed_bytes);
115 let active_seed_id = get_active_seed_id(keys_ks)
116 .await
117 .map_err(|e| AppError::Internal(format!("get active seed id: {e}")))?;
118
119 fn corrupt_row(kind: &str, key: &[u8], e: impl std::fmt::Display) -> AppError {
125 AppError::Internal(format!(
126 "backup aborted: {kind} row '{}' is corrupt and would be silently \
127 omitted from the backup: {e}",
128 String::from_utf8_lossy(key)
129 ))
130 }
131
132 let seed_records: Vec<SeedRecordBackup> = {
134 let raw = keys_ks.prefix_iter_raw("seed:").await?;
135 let mut records = Vec::with_capacity(raw.len());
136 for (key, value) in raw {
137 let sr: SeedRecord =
138 serde_json::from_slice(&value).map_err(|e| corrupt_row("seed", &key, e))?;
139 records.push(SeedRecordBackup {
140 id: sr.id,
141 seed_hex: sr.seed_hex,
142 seed_enc: sr.seed_enc,
143 created_at: sr.created_at,
144 retired_at: sr.retired_at,
145 });
146 }
147 records
148 };
149
150 let key_records: Vec<vta_sdk::keys::KeyRecord> = {
152 let raw = keys_ks.prefix_iter_raw("key:").await?;
153 let mut out = Vec::with_capacity(raw.len());
154 for (key, value) in raw {
155 out.push(serde_json::from_slice(&value).map_err(|e| corrupt_row("key", &key, e))?);
156 }
157 out
158 };
159
160 let context_records: Vec<vta_sdk::contexts::ContextRecord> = {
162 let raw = contexts_ks.prefix_iter_raw("ctx:").await?;
163 let mut out = Vec::with_capacity(raw.len());
164 for (key, value) in raw {
165 out.push(serde_json::from_slice(&value).map_err(|e| corrupt_row("context", &key, e))?);
166 }
167 out
168 };
169 let context_counter: u32 = contexts_ks
170 .get_raw("ctx_counter")
171 .await?
172 .and_then(|b| b.try_into().ok().map(u32::from_le_bytes))
173 .unwrap_or(0);
174
175 let read_u32_counters = |pairs: Vec<RawKvPair>| -> Vec<(String, u32)> {
181 pairs
182 .into_iter()
183 .filter_map(|(k, v)| {
184 let key = String::from_utf8(k).ok()?;
185 let arr: [u8; 4] = v.try_into().ok()?;
186 Some((key, u32::from_le_bytes(arr)))
187 })
188 .collect()
189 };
190 let path_counters = read_u32_counters(keys_ks.prefix_iter_raw("path_counter:").await?);
191 let subcontext_counters = read_u32_counters(contexts_ks.prefix_iter_raw("ctx_counter:").await?);
192
193 let (acl_entries, acl_entries_full): (Vec<AclEntryBackup>, Vec<serde_json::Value>) = {
200 let raw = acl_ks.prefix_iter_raw("acl:").await?;
201 let mut lossy = Vec::with_capacity(raw.len());
202 let mut full = Vec::with_capacity(raw.len());
203 for (key, v) in raw {
204 let val: serde_json::Value =
205 serde_json::from_slice(&v).map_err(|e| corrupt_row("ACL", &key, e))?;
206 lossy.push(AclEntryBackup {
207 did: val["did"].as_str().unwrap_or_default().to_string(),
208 role: val["role"].as_str().unwrap_or("Reader").to_string(),
209 label: val["label"].as_str().map(String::from),
210 allowed_contexts: val["allowed_contexts"]
211 .as_array()
212 .map(|a| {
213 a.iter()
214 .filter_map(|v| v.as_str().map(String::from))
215 .collect()
216 })
217 .unwrap_or_default(),
218 created_at: val["created_at"].as_u64().unwrap_or(0),
219 created_by: val["created_by"].as_str().unwrap_or_default().to_string(),
220 });
221 full.push(val);
222 }
223 (lossy, full)
224 };
225
226 let seal = get_seal(acl_ks)
228 .await
229 .ok()
230 .flatten()
231 .map(|s| SealRecordBackup {
232 sealed_by: s.sealed_by,
233 sealed_at: s.sealed_at,
234 reason: s.reason,
235 });
236
237 #[cfg(feature = "webvh")]
239 let (webvh_servers, webvh_dids, webvh_logs) = {
240 let servers: Vec<vta_sdk::webvh::WebvhServerRecord> = webvh_ks
241 .prefix_iter_raw("server:")
242 .await?
243 .into_iter()
244 .filter_map(|(_, v)| serde_json::from_slice(&v).ok())
245 .collect();
246 let dids: Vec<vta_sdk::webvh::WebvhDidRecord> = webvh_ks
247 .prefix_iter_raw("did:")
248 .await?
249 .into_iter()
250 .filter_map(|(_, v)| serde_json::from_slice(&v).ok())
251 .collect();
252 let logs: Vec<WebvhLogBackup> = webvh_ks
253 .prefix_iter_raw("log:")
254 .await?
255 .into_iter()
256 .filter_map(|(k, v)| {
257 let did = String::from_utf8(k).ok()?.strip_prefix("log:")?.to_string();
258 let log_json = String::from_utf8(v).ok()?;
259 Some(WebvhLogBackup { did, log_json })
260 })
261 .collect();
262 (servers, dids, logs)
263 };
264 #[cfg(not(feature = "webvh"))]
265 let (webvh_servers, webvh_dids, webvh_logs) = (Vec::new(), Vec::new(), Vec::new());
266
267 let audit_logs = if include_audit {
269 let raw = audit_ks.prefix_iter_raw("log:").await?;
270 raw.into_iter()
271 .filter_map(|(_, v)| serde_json::from_slice(&v).ok())
272 .collect()
273 } else {
274 Vec::new()
275 };
276
277 let backup_config = BackupConfig {
279 vta_did: config.vta_did.clone(),
280 vta_name: config.vta_name.clone(),
281 public_url: config.public_url.clone(),
282 mediator_url: config.messaging.as_ref().map(|m| m.mediator_url.clone()),
283 mediator_did: config.messaging.as_ref().map(|m| m.mediator_did.clone()),
284 };
285
286 let jwt_signing_key = config.auth.jwt_signing_key.clone();
288
289 let imported_kek_salt = imported::get_salt(keys_ks).await?.map(hex::encode);
291 let imported_secrets = {
292 let mut secrets = Vec::new();
293 for kr in &key_records {
294 if kr.origin == KeyOrigin::Imported
295 && kr.status == vta_sdk::keys::KeyStatus::Active
296 && let Ok(mut plaintext) = imported::load_secret(
297 imported_ks,
298 keys_ks,
299 &seed_bytes,
300 &kr.key_id,
301 &kr.key_type.to_string(),
302 )
303 .await
304 {
305 secrets.push(ImportedSecretBackup {
306 key_id: kr.key_id.clone(),
307 private_key_hex: hex::encode(&plaintext),
308 });
309 use zeroize::Zeroize;
310 plaintext.zeroize();
311 }
312 }
313 secrets
314 };
315
316 let payload = BackupPayload {
318 active_seed_hex,
319 active_seed_id,
320 seed_records,
321 jwt_signing_key,
322 key_records,
323 context_records,
324 context_counter,
325 path_counters,
326 subcontext_counters,
327 acl_entries,
328 acl_entries_full,
329 seal,
330 webvh_servers,
331 webvh_dids,
332 webvh_logs,
333 config: backup_config,
334 audit_logs,
335 imported_secrets,
336 imported_kek_salt,
337 };
338
339 let envelope = encrypt_payload(&payload, password, include_audit, config)?;
341
342 info!(
343 keys = payload.key_records.len(),
344 acls = payload.acl_entries.len(),
345 contexts = payload.context_records.len(),
346 audit = payload.audit_logs.len(),
347 "backup exported"
348 );
349
350 Ok(envelope)
351}
352
353pub async fn preview_import(
357 envelope: &BackupEnvelope,
358 password: &str,
359) -> Result<(BackupPayload, ImportResult), AppError> {
360 let payload = decrypt_backup(envelope, password)?;
361
362 let result = ImportResult {
363 status: "preview".into(),
364 source_did: payload.config.vta_did.clone(),
365 key_count: payload.key_records.len(),
366 acl_count: payload.acl_entries.len(),
367 context_count: payload.context_records.len(),
368 audit_count: payload.audit_logs.len(),
369 imported_secret_count: payload.imported_secrets.len(),
370 message: Some("Preview only — no changes applied. Set confirm=true to import.".into()),
371 };
372
373 Ok((payload, result))
374}
375
376fn check_vta_did_compatibility(
383 running_did: Option<&str>,
384 backup_did: Option<&str>,
385) -> Result<(), AppError> {
386 let running = match running_did {
387 Some(d) if !d.is_empty() => d,
388 _ => return Ok(()),
389 };
390 let backup = backup_did.unwrap_or("");
391 if backup == running {
392 return Ok(());
393 }
394 Err(AppError::Validation(format!(
395 "backup vta_did mismatch: backup claims '{backup}' but this VTA is running \
396 as '{running}'. Refusing to overwrite identity. If this is intentional \
397 (identity migration), clear vta_did from the running config first."
398 )))
399}
400
401pub const IMPORT_IN_PROGRESS_KEY: &str = "backup:import_in_progress";
408
409fn recompute_path_counters(
414 key_records: &[vta_sdk::keys::KeyRecord],
415) -> std::collections::HashMap<String, u32> {
416 let mut counters: std::collections::HashMap<String, u32> = std::collections::HashMap::new();
417 for kr in key_records {
418 let path = kr.derivation_path.trim();
419 if path.is_empty() {
420 continue; }
422 if let Some((base, last)) = path.rsplit_once('/')
423 && let Ok(index) = last.trim_end_matches('\'').parse::<u32>()
424 {
425 let next = index.saturating_add(1);
426 let slot = counters.entry(base.to_string()).or_insert(0);
427 *slot = (*slot).max(next);
428 }
429 }
430 counters
431}
432
433fn recompute_subcontext_counters(
437 context_records: &[vta_sdk::contexts::ContextRecord],
438) -> std::collections::HashMap<String, u32> {
439 let mut counters: std::collections::HashMap<String, u32> = std::collections::HashMap::new();
440 for cr in context_records {
441 if let Some(parent) = cr.parent.as_deref() {
442 let next = cr.index.saturating_add(1);
443 let slot = counters.entry(parent.to_string()).or_insert(0);
444 *slot = (*slot).max(next);
445 }
446 }
447 counters
448}
449
450#[cfg_attr(not(feature = "tee"), allow(unused_variables))]
466pub async fn apply_import(
467 payload: &BackupPayload,
468 ks: &vta_keyspaces::Keyspaces<'_>,
469 seed_store: &Arc<dyn SeedStore>,
470 config: &tokio::sync::RwLock<vta_config::AppConfig>,
471 store: Option<&vti_common::store::Store>,
472 #[cfg(feature = "tee")] re_encryptor: Option<&dyn crate::BootstrapReEncryptor>,
473) -> Result<ImportResult, AppError> {
474 {
478 let running_did = config.read().await.vta_did.clone();
479 check_vta_did_compatibility(running_did.as_deref(), payload.config.vta_did.as_deref())?;
480 }
481
482 let keys_ks = ks.keys;
483 let acl_ks = ks.acl;
484 let contexts_ks = ks.contexts;
485 let audit_ks = ks.audit;
486 let imported_ks = ks.imported;
487 #[cfg(feature = "webvh")]
488 let webvh_ks = ks.webvh;
489
490 keys_ks
497 .insert_raw(IMPORT_IN_PROGRESS_KEY, b"1".to_vec())
498 .await?;
499 keys_ks.persist().await?;
500
501 clear_keyspace(keys_ks, &["key:", "seed:", "path_counter:"]).await?;
506 clear_keyspace(acl_ks, &["acl:", "vta:"]).await?;
507 clear_keyspace(contexts_ks, &["ctx:", "ctx_counter:"]).await?;
508 clear_keyspace(audit_ks, &["log:"]).await?;
509 clear_keyspace(imported_ks, &["secret:"]).await?;
510 #[cfg(feature = "webvh")]
511 clear_keyspace(
512 webvh_ks,
513 &["server:", "server-auth:", "did:", "log:"],
521 )
522 .await?;
523
524 let _ = keys_ks.remove("active_seed_id").await;
526 let _ = contexts_ks.remove("ctx_counter").await;
527
528 let seed_bytes = hex::decode(&payload.active_seed_hex)
530 .map_err(|e| AppError::Internal(format!("invalid seed hex in backup: {e}")))?;
531 seed_store
532 .set(&seed_bytes)
533 .await
534 .map_err(|e| AppError::Internal(format!("seed store: {e}")))?;
535
536 set_active_seed_id(keys_ks, payload.active_seed_id)
538 .await
539 .map_err(|e| AppError::Internal(format!("set active seed id: {e}")))?;
540
541 for sr in &payload.seed_records {
543 let record = SeedRecord {
544 id: sr.id,
545 seed_hex: sr.seed_hex.clone(),
546 seed_enc: sr.seed_enc.clone(),
547 created_at: sr.created_at,
548 retired_at: sr.retired_at,
549 };
550 save_seed_record(keys_ks, &record)
551 .await
552 .map_err(|e| AppError::Internal(format!("save seed record: {e}")))?;
553 }
554
555 for kr in &payload.key_records {
557 keys_ks.insert(vta_keys::store_key(&kr.key_id), kr).await?;
558 }
559
560 for cr in &payload.context_records {
562 contexts_ks.insert(format!("ctx:{}", cr.id), cr).await?;
563 }
564 contexts_ks
565 .insert_raw("ctx_counter", &payload.context_counter.to_le_bytes())
566 .await?;
567
568 {
574 let mut path_counters = recompute_path_counters(&payload.key_records);
575 for (k, v) in &payload.path_counters {
576 let base = k.strip_prefix("path_counter:").unwrap_or(k).to_string();
579 let slot = path_counters.entry(base).or_insert(0);
580 *slot = (*slot).max(*v);
581 }
582 for (base, next) in path_counters {
583 keys_ks
584 .insert_raw(format!("path_counter:{base}"), next.to_le_bytes().to_vec())
585 .await?;
586 }
587
588 let mut sub_counters = recompute_subcontext_counters(&payload.context_records);
589 for (k, v) in &payload.subcontext_counters {
590 let parent = k.strip_prefix("ctx_counter:").unwrap_or(k).to_string();
591 let slot = sub_counters.entry(parent).or_insert(0);
592 *slot = (*slot).max(*v);
593 }
594 for (parent, next) in sub_counters {
595 contexts_ks
596 .insert_raw(format!("ctx_counter:{parent}"), next.to_le_bytes().to_vec())
597 .await?;
598 }
599 }
600
601 if !payload.acl_entries_full.is_empty() {
606 for entry in &payload.acl_entries_full {
607 let did = entry
608 .get("did")
609 .and_then(|d| d.as_str())
610 .ok_or_else(|| AppError::Internal("backup ACL entry has no `did` field".into()))?;
611 let bytes = serde_json::to_vec(entry)?;
612 acl_ks.insert_raw(format!("acl:{did}"), bytes).await?;
613 }
614 } else {
615 if !payload.acl_entries.is_empty() {
616 tracing::warn!(
617 count = payload.acl_entries.len(),
618 "restoring ACL from a pre-P0.5 backup's lossy form — expiry, step-up \
619 floors, and capability restrictions are not present and default to \
620 permanent/none. Re-export with this build for a lossless backup."
621 );
622 for entry in &payload.acl_entries {
623 acl_ks.insert(format!("acl:{}", entry.did), entry).await?;
624 }
625 }
626 }
627
628 if let Some(ref seal) = payload.seal {
630 let record = SealRecord {
631 sealed_by: seal.sealed_by.clone(),
632 sealed_at: seal.sealed_at,
633 reason: seal.reason.clone(),
634 };
635 acl_ks.insert("vta:sealed", &record).await?;
636 }
637
638 #[cfg(feature = "webvh")]
650 {
651 let backup_vta_did = payload.config.vta_did.as_deref();
652 let running_vta_did = config.read().await.vta_did.clone();
653 let cross_vta_restore = match (running_vta_did.as_deref(), backup_vta_did) {
654 (None, _) => false, (Some(_), None) => true, (Some(running), Some(backup)) => running != backup, };
658
659 for server in &payload.webvh_servers {
660 webvh_ks
661 .insert(format!("server:{}", server.id), server)
662 .await?;
663 }
664 for did_rec in &payload.webvh_dids {
665 let mut record = did_rec.clone();
666 if cross_vta_restore && record.server_id != "serverless" {
667 tracing::warn!(
668 did = %record.did,
669 original_server = %record.server_id,
670 "cross-VTA restore: stripping server_id/mnemonic from imported WebvhDidRecord; \
671 operator must `register_did_with_server` to re-attach to this VTA",
672 );
673 record.server_id = "serverless".to_string();
674 record.mnemonic = String::new();
675 }
676 webvh_ks
677 .insert(format!("did:{}", record.did), &record)
678 .await?;
679 }
680 for log in &payload.webvh_logs {
681 webvh_ks
682 .insert_raw(format!("log:{}", log.did), log.log_json.as_bytes())
683 .await?;
684 }
685 }
686
687 for entry in &payload.audit_logs {
689 audit_ks
690 .insert(format!("log:{:020}:{}", entry.timestamp, entry.id), entry)
691 .await?;
692 }
693
694 if !payload.imported_secrets.is_empty() {
696 if let Some(ref salt_hex) = payload.imported_kek_salt {
698 let salt = hex::decode(salt_hex)
699 .map_err(|e| AppError::Internal(format!("invalid imported KEK salt hex: {e}")))?;
700 imported::set_salt(keys_ks, &salt).await?;
701 }
702
703 for secret_backup in &payload.imported_secrets {
704 let private_bytes = hex::decode(&secret_backup.private_key_hex)
705 .map_err(|e| AppError::Internal(format!("invalid imported secret hex: {e}")))?;
706
707 let key_type_str = payload
709 .key_records
710 .iter()
711 .find(|kr| kr.key_id == secret_backup.key_id)
712 .map(|kr| kr.key_type.to_string())
713 .unwrap_or_else(|| "ed25519".to_string());
714
715 imported::store_secret(
716 imported_ks,
717 keys_ks,
718 &seed_bytes,
719 &secret_backup.key_id,
720 &key_type_str,
721 &private_bytes,
722 )
723 .await?;
724 }
725 }
726
727 {
729 let mut cfg = config.write().await;
730 if let Some(ref did) = payload.config.vta_did {
731 cfg.vta_did = Some(did.clone());
732 }
733 if let Some(ref name) = payload.config.vta_name {
734 cfg.vta_name = Some(name.clone());
735 }
736 if let Some(ref url) = payload.config.public_url {
737 cfg.public_url = Some(url.clone());
738 }
739 if let Some(ref jwt) = payload.jwt_signing_key {
740 cfg.auth.jwt_signing_key = Some(jwt.clone());
741 }
742 if payload.config.mediator_url.is_some() || payload.config.mediator_did.is_some() {
743 let messaging =
744 cfg.messaging
745 .get_or_insert_with(|| vti_common::config::MessagingConfig {
746 mediator_url: String::new(),
747 mediator_did: String::new(),
748 mediator_host: None,
749 setup_acl: false,
750 drain_inbox_on_start: false,
751 });
752 if let Some(ref url) = payload.config.mediator_url {
753 messaging.mediator_url = url.clone();
754 }
755 if let Some(ref did) = payload.config.mediator_did {
756 messaging.mediator_did = did.clone();
757 }
758 }
759 }
760
761 #[cfg(feature = "tee")]
763 if let Some(store) = store {
764 let cfg = config.read().await;
765 if let vta_config::TeeMode::Required = cfg.tee.mode
766 && let Some(ref kms_config) = cfg.tee.kms
767 {
768 let jwt_key_bytes: Option<[u8; 32]> =
769 payload.jwt_signing_key.as_ref().and_then(|b64| {
770 base64::Engine::decode(&BASE64, b64)
771 .ok()
772 .and_then(|b| b.try_into().ok())
773 });
774 if let Some(jwt_key) = jwt_key_bytes {
775 let re = re_encryptor.ok_or_else(|| {
781 AppError::Internal(
782 "TEE import requires a BootstrapReEncryptor but none was supplied".into(),
783 )
784 })?;
785 re.re_encrypt(kms_config, store, &seed_bytes, &jwt_key)
786 .await?;
787 } else {
788 info!("no JWT key in backup — skipping KMS re-encryption");
789 }
790 }
791 }
792
793 keys_ks.remove(IMPORT_IN_PROGRESS_KEY).await?;
797 keys_ks.persist().await?;
798
799 vti_common::integrity::reseal_if_active().await?;
805
806 info!(
807 keys = payload.key_records.len(),
808 acls = payload.acl_entries.len(),
809 contexts = payload.context_records.len(),
810 audit = payload.audit_logs.len(),
811 "backup imported — soft restart required"
812 );
813
814 Ok(ImportResult {
815 status: "imported".into(),
816 source_did: payload.config.vta_did.clone(),
817 key_count: payload.key_records.len(),
818 acl_count: payload.acl_entries.len(),
819 context_count: payload.context_records.len(),
820 audit_count: payload.audit_logs.len(),
821 imported_secret_count: payload.imported_secrets.len(),
822 message: Some("Import complete. VTA will restart with new identity.".into()),
823 })
824}
825
826fn encrypt_payload(
829 payload: &BackupPayload,
830 password: &str,
831 include_audit: bool,
832 config: &vta_config::AppConfig,
833) -> Result<BackupEnvelope, AppError> {
834 let plaintext =
835 serde_json::to_vec(payload).map_err(|e| AppError::Internal(format!("serialize: {e}")))?;
836
837 use rand::Rng;
838 let mut rng = rand::rng();
839 let mut salt = [0u8; SALT_LEN];
840 rng.fill_bytes(&mut salt);
841 let mut nonce_bytes = [0u8; NONCE_LEN];
842 rng.fill_bytes(&mut nonce_bytes);
843
844 let argon2 = Argon2::new(
846 argon2::Algorithm::Argon2id,
847 argon2::Version::V0x13,
848 argon2::Params::new(ARGON2_M_COST, ARGON2_T_COST, ARGON2_P_COST, Some(32))
849 .map_err(|e| AppError::Internal(format!("argon2 params: {e}")))?,
850 );
851 let mut key = [0u8; 32];
852 argon2
853 .hash_password_into(password.as_bytes(), &salt, &mut key)
854 .map_err(|e| AppError::Internal(format!("argon2 hash: {e}")))?;
855
856 let cipher =
858 Aes256Gcm::new_from_slice(&key).map_err(|e| AppError::Internal(format!("aes key: {e}")))?;
859 let nonce = (&nonce_bytes).into();
860 let ciphertext = cipher
861 .encrypt(nonce, plaintext.as_ref())
862 .map_err(|e| AppError::Internal(format!("aes encrypt: {e}")))?;
863
864 Ok(BackupEnvelope {
865 version: 1,
866 format: "vta-backup-v1".into(),
867 created_at: Utc::now(),
868 source_did: config.vta_did.clone(),
869 source_version: env!("CARGO_PKG_VERSION").into(),
870 kdf: KdfParams {
871 algorithm: "argon2id".into(),
872 salt: BASE64.encode(salt),
873 m_cost: ARGON2_M_COST,
874 t_cost: ARGON2_T_COST,
875 p_cost: ARGON2_P_COST,
876 },
877 encryption: EncryptionParams {
878 algorithm: "aes-256-gcm".into(),
879 nonce: BASE64.encode(nonce_bytes),
880 },
881 includes_audit: include_audit,
882 ciphertext: BASE64.encode(&ciphertext),
883 })
884}
885
886pub fn decrypt_backup(
891 envelope: &BackupEnvelope,
892 password: &str,
893) -> Result<BackupPayload, AppError> {
894 if envelope.version != 1 || envelope.format != "vta-backup-v1" {
895 return Err(AppError::Validation(format!(
896 "unsupported backup format: {} v{}",
897 envelope.format, envelope.version
898 )));
899 }
900
901 if envelope.kdf.algorithm != "argon2id" {
904 return Err(AppError::Validation(format!(
905 "unsupported KDF algorithm: '{}' (only 'argon2id' is accepted)",
906 envelope.kdf.algorithm
907 )));
908 }
909 if !(MIN_M_COST..=MAX_M_COST).contains(&envelope.kdf.m_cost) {
910 return Err(AppError::Validation(format!(
911 "argon2 m_cost {} out of bounds [{}, {}]",
912 envelope.kdf.m_cost, MIN_M_COST, MAX_M_COST
913 )));
914 }
915 if !(MIN_T_COST..=MAX_T_COST).contains(&envelope.kdf.t_cost) {
916 return Err(AppError::Validation(format!(
917 "argon2 t_cost {} out of bounds [{}, {}]",
918 envelope.kdf.t_cost, MIN_T_COST, MAX_T_COST
919 )));
920 }
921 if !(MIN_P_COST..=MAX_P_COST).contains(&envelope.kdf.p_cost) {
922 return Err(AppError::Validation(format!(
923 "argon2 p_cost {} out of bounds [{}, {}]",
924 envelope.kdf.p_cost, MIN_P_COST, MAX_P_COST
925 )));
926 }
927 if envelope.encryption.algorithm != "aes-256-gcm" {
928 return Err(AppError::Validation(format!(
929 "unsupported encryption algorithm: '{}' (only 'aes-256-gcm' is accepted)",
930 envelope.encryption.algorithm
931 )));
932 }
933
934 let salt = BASE64
935 .decode(&envelope.kdf.salt)
936 .map_err(|e| AppError::Validation(format!("invalid salt: {e}")))?;
937 if salt.len() != SALT_LEN {
938 return Err(AppError::Validation(format!(
939 "invalid salt length: {} (expected {SALT_LEN})",
940 salt.len()
941 )));
942 }
943 let nonce_bytes = BASE64
944 .decode(&envelope.encryption.nonce)
945 .map_err(|e| AppError::Validation(format!("invalid nonce: {e}")))?;
946 if nonce_bytes.len() != NONCE_LEN {
954 return Err(AppError::Validation(format!(
955 "invalid nonce length: {} (expected {NONCE_LEN})",
956 nonce_bytes.len()
957 )));
958 }
959 let ciphertext = BASE64
960 .decode(&envelope.ciphertext)
961 .map_err(|e| AppError::Validation(format!("invalid ciphertext: {e}")))?;
962
963 let argon2 = Argon2::new(
965 argon2::Algorithm::Argon2id,
966 argon2::Version::V0x13,
967 argon2::Params::new(
968 envelope.kdf.m_cost,
969 envelope.kdf.t_cost,
970 envelope.kdf.p_cost,
971 Some(32),
972 )
973 .map_err(|e| AppError::Validation(format!("argon2 params: {e}")))?,
974 );
975 let mut key = [0u8; 32];
976 argon2
977 .hash_password_into(password.as_bytes(), &salt, &mut key)
978 .map_err(|e| AppError::Internal(format!("argon2 hash: {e}")))?;
979
980 let cipher =
982 Aes256Gcm::new_from_slice(&key).map_err(|e| AppError::Internal(format!("aes key: {e}")))?;
983 let nonce = Nonce::try_from(nonce_bytes.as_slice())
984 .map_err(|_| AppError::Validation(format!("nonce must be {NONCE_LEN} bytes")))?;
985 let plaintext = cipher
986 .decrypt(&nonce, ciphertext.as_ref())
987 .map_err(|_| AppError::Authentication("incorrect backup password".into()))?;
988
989 serde_json::from_slice(&plaintext)
990 .map_err(|e| AppError::Internal(format!("backup payload corrupt: {e}")))
991}
992
993async fn clear_keyspace(ks: &KeyspaceHandle, prefixes: &[&str]) -> Result<(), AppError> {
995 for prefix in prefixes {
996 let keys = ks.prefix_keys(prefix.to_string()).await?;
997 for key in keys {
998 ks.remove(key).await?;
999 }
1000 }
1001 Ok(())
1002}
1003
1004#[cfg(test)]
1005mod tests {
1006 use super::*;
1007 use vta_webvh::webvh_store::{WebvhServerAuthRecord, store_server_auth};
1008 use vti_common::config::StoreConfig as VtiStoreConfig;
1009 use vti_common::store::Store;
1010
1011 #[test]
1027 fn argon2id_derivation_matches_the_frozen_vector() {
1028 let argon2 = Argon2::new(
1029 argon2::Algorithm::Argon2id,
1030 argon2::Version::V0x13,
1031 argon2::Params::new(ARGON2_M_COST, ARGON2_T_COST, ARGON2_P_COST, Some(32)).unwrap(),
1032 );
1033 let mut key = [0u8; 32];
1034 argon2
1035 .hash_password_into(b"backup-password", b"0123456789abcdef", &mut key)
1036 .unwrap();
1037 assert_eq!(
1038 key.iter().map(|b| format!("{b:02x}")).collect::<String>(),
1039 "3558837960e818d4ae946a900d505053894bf02a6ac9e046f0781fe09a616bf9",
1040 "the backup KDF changed — every existing envelope is now \
1041 undecryptable"
1042 );
1043 }
1044
1045 #[tokio::test]
1054 async fn restore_clears_pre_existing_webvh_auth_cache() {
1055 let dir = tempfile::tempdir().unwrap();
1056 let store = Store::open(&VtiStoreConfig {
1057 data_dir: dir.path().into(),
1058 })
1059 .unwrap();
1060 let webvh_ks = store.keyspace(vta_keyspaces::WEBVH).unwrap();
1061
1062 let stale = WebvhServerAuthRecord {
1065 server_id: "prod".into(),
1066 access_token: "stale-access".into(),
1067 access_expires_at: 9_999_999_999,
1068 refresh_token: "stale-refresh".into(),
1069 refresh_expires_at: 9_999_999_999,
1070 };
1071 store_server_auth(&webvh_ks, &stale).await.unwrap();
1072
1073 clear_keyspace(&webvh_ks, &["server:", "server-auth:", "did:", "log:"])
1076 .await
1077 .unwrap();
1078
1079 let remaining = vta_webvh::webvh_store::get_server_auth(&webvh_ks, "prod")
1081 .await
1082 .unwrap();
1083 assert!(
1084 remaining.is_none(),
1085 "server-auth: prefix must be cleared on import; otherwise stale tokens leak across installations"
1086 );
1087 }
1088
1089 #[tokio::test]
1094 async fn export_aborts_on_corrupt_key_row() {
1095 let ts = crate::test_support::open_test_store().await;
1096 let seed_store = crate::test_support::TestSeedStore(vec![42u8; 32]);
1097 let config = crate::test_support::test_app_config(ts.data_dir.clone());
1098 let auth = crate::test_support::super_admin_claims();
1099
1100 ts.keys_ks
1102 .insert_raw("key:corrupt", b"{not a key record".to_vec())
1103 .await
1104 .unwrap();
1105
1106 let ks = vta_keyspaces::Keyspaces {
1107 keys: &ts.keys_ks,
1108 acl: &ts.acl_ks,
1109 contexts: &ts.contexts_ks,
1110 did_templates: &ts.did_templates_ks,
1111 audit: &ts.audit_ks,
1112 imported: &ts.imported_ks,
1113 #[cfg(feature = "webvh")]
1114 webvh: &ts.webvh_ks,
1115 };
1116
1117 let err = export_backup(&ks, &seed_store, &config, &auth, "a-strong-password", false)
1118 .await
1119 .expect_err("export must abort on a corrupt key row");
1120 let msg = format!("{err:?}");
1121 assert!(
1122 msg.contains("corrupt") && msg.contains("key"),
1123 "error must name the corrupt-row cause, got: {msg}"
1124 );
1125 }
1126
1127 #[tokio::test]
1131 async fn export_rejects_short_password() {
1132 let ts = crate::test_support::open_test_store().await;
1133 let seed_store = crate::test_support::TestSeedStore(vec![42u8; 32]);
1134 let config = crate::test_support::test_app_config(ts.data_dir.clone());
1135 let auth = crate::test_support::super_admin_claims();
1136
1137 let ks = vta_keyspaces::Keyspaces {
1138 keys: &ts.keys_ks,
1139 acl: &ts.acl_ks,
1140 contexts: &ts.contexts_ks,
1141 did_templates: &ts.did_templates_ks,
1142 audit: &ts.audit_ks,
1143 imported: &ts.imported_ks,
1144 #[cfg(feature = "webvh")]
1145 webvh: &ts.webvh_ks,
1146 };
1147
1148 let err = export_backup(&ks, &seed_store, &config, &auth, "14-char-passwo", false)
1150 .await
1151 .expect_err("export must reject a 14-character password");
1152 assert!(
1153 format!("{err}").contains("15 characters"),
1154 "error must mention the 15-character minimum, got: {err}"
1155 );
1156
1157 export_backup(&ks, &seed_store, &config, &auth, "15-char-passwor", false)
1159 .await
1160 .expect("export must accept a 15-character password");
1161 }
1162
1163 fn test_payload() -> BackupPayload {
1164 BackupPayload {
1165 active_seed_hex: hex::encode([42u8; 32]),
1166 active_seed_id: 1,
1167 seed_records: vec![SeedRecordBackup {
1168 id: 0,
1169 seed_hex: None,
1170 seed_enc: Some(vec![0xDEu8, 0xAD, 0xBE, 0xEF, 0x01, 0x02, 0x03]),
1174 created_at: Utc::now(),
1175 retired_at: Some(Utc::now()),
1176 }],
1177 jwt_signing_key: Some(BASE64.encode([99u8; 32])),
1178 key_records: vec![],
1179 context_records: vec![],
1180 context_counter: 2,
1181 path_counters: vec![],
1182 subcontext_counters: vec![],
1183 acl_entries: vec![AclEntryBackup {
1184 did: "did:key:z6MkTest".into(),
1185 role: "Admin".into(),
1186 label: Some("test admin".into()),
1187 allowed_contexts: vec!["ctx1".into()],
1188 created_at: 1000,
1189 created_by: "did:key:z6MkSetup".into(),
1190 }],
1191 acl_entries_full: vec![],
1192 seal: None,
1193 webvh_servers: vec![],
1194 webvh_dids: vec![],
1195 webvh_logs: vec![],
1196 config: BackupConfig {
1197 vta_did: Some("did:key:z6MkVTA".into()),
1198 vta_name: Some("Test VTA".into()),
1199 public_url: None,
1200 mediator_url: None,
1201 mediator_did: None,
1202 },
1203 audit_logs: vec![],
1204 imported_secrets: vec![],
1205 imported_kek_salt: None,
1206 }
1207 }
1208
1209 fn test_config() -> vta_config::AppConfig {
1210 toml::from_str("").unwrap()
1211 }
1212
1213 fn mk_key_record(key_id: &str, derivation_path: &str) -> vta_sdk::keys::KeyRecord {
1216 use vta_sdk::keys::{KeyOrigin, KeyRecord, KeyStatus, KeyType};
1217 let now = Utc::now();
1218 KeyRecord {
1219 key_id: key_id.into(),
1220 derivation_path: derivation_path.into(),
1221 key_type: KeyType::Ed25519,
1222 status: KeyStatus::Active,
1223 public_key: "zPlaceholder".into(),
1224 label: None,
1225 context_id: None,
1226 seed_id: None,
1227 exportable: None,
1228 origin: KeyOrigin::Derived,
1229 created_at: now,
1230 updated_at: now,
1231 }
1232 }
1233
1234 fn import_keyspaces<'a>(
1235 ts: &'a crate::test_support::TestStore,
1236 ) -> vta_keyspaces::Keyspaces<'a> {
1237 vta_keyspaces::Keyspaces {
1238 keys: &ts.keys_ks,
1239 acl: &ts.acl_ks,
1240 contexts: &ts.contexts_ks,
1241 did_templates: &ts.did_templates_ks,
1242 audit: &ts.audit_ks,
1243 imported: &ts.imported_ks,
1244 #[cfg(feature = "webvh")]
1245 webvh: &ts.webvh_ks,
1246 }
1247 }
1248
1249 #[tokio::test]
1253 async fn import_restores_path_counter_preventing_key_reuse() {
1254 let ts = crate::test_support::open_test_store().await;
1255 let seed_store: std::sync::Arc<dyn SeedStore> =
1256 std::sync::Arc::new(crate::test_support::TestSeedStore(vec![42u8; 32]));
1257 let config =
1258 tokio::sync::RwLock::new(crate::test_support::test_app_config(ts.data_dir.clone()));
1259
1260 let base = "m/26'/2'/0'";
1261 let mut payload = test_payload();
1262 payload.key_records = vec![mk_key_record("k0", &format!("{base}/0'"))];
1263 payload.path_counters = vec![(format!("path_counter:{base}"), 1)];
1264 payload.acl_entries = vec![]; apply_import(
1267 &payload,
1268 &import_keyspaces(&ts),
1269 &seed_store,
1270 &config,
1271 None,
1272 #[cfg(feature = "tee")]
1273 None,
1274 )
1275 .await
1276 .expect("import");
1277
1278 let next = vta_keys::paths::allocate_path(&ts.keys_ks, base)
1280 .await
1281 .expect("alloc");
1282 assert_eq!(
1283 next,
1284 format!("{base}/1'"),
1285 "restore must carry the path counter forward (no key reuse)"
1286 );
1287 }
1288
1289 #[tokio::test]
1293 async fn import_recomputes_path_counter_for_legacy_backup() {
1294 let ts = crate::test_support::open_test_store().await;
1295 let seed_store: std::sync::Arc<dyn SeedStore> =
1296 std::sync::Arc::new(crate::test_support::TestSeedStore(vec![42u8; 32]));
1297 let config =
1298 tokio::sync::RwLock::new(crate::test_support::test_app_config(ts.data_dir.clone()));
1299
1300 let base = "m/26'/2'/0'";
1301 let mut payload = test_payload();
1302 payload.key_records = vec![
1304 mk_key_record("k0", &format!("{base}/0'")),
1305 mk_key_record("k1", &format!("{base}/1'")),
1306 ];
1307 payload.path_counters = vec![];
1308 payload.acl_entries = vec![];
1309
1310 apply_import(
1311 &payload,
1312 &import_keyspaces(&ts),
1313 &seed_store,
1314 &config,
1315 None,
1316 #[cfg(feature = "tee")]
1317 None,
1318 )
1319 .await
1320 .expect("import");
1321
1322 let next = vta_keys::paths::allocate_path(&ts.keys_ks, base)
1323 .await
1324 .expect("alloc");
1325 assert_eq!(
1326 next,
1327 format!("{base}/2'"),
1328 "recomputed counter must skip both in-use indices"
1329 );
1330 }
1331
1332 #[tokio::test]
1336 async fn import_restores_full_acl_entry_fields() {
1337 use vti_common::acl::{AclEntry, Role};
1338 use vti_common::auth::step_up::StepUpMode;
1339
1340 let ts = crate::test_support::open_test_store().await;
1341 let seed_store: std::sync::Arc<dyn SeedStore> =
1342 std::sync::Arc::new(crate::test_support::TestSeedStore(vec![42u8; 32]));
1343 let config =
1344 tokio::sync::RwLock::new(crate::test_support::test_app_config(ts.data_dir.clone()));
1345
1346 let mut entry = AclEntry::new("did:key:zAcl", Role::Admin, "did:key:zSetup");
1347 entry.expires_at = Some(1_900_000_000);
1348 entry.step_up_require = Some(StepUpMode::SelfApprove);
1349 let full = serde_json::to_value(&entry).unwrap();
1350
1351 let mut payload = test_payload();
1352 payload.acl_entries = vec![]; payload.acl_entries_full = vec![full];
1354
1355 apply_import(
1356 &payload,
1357 &import_keyspaces(&ts),
1358 &seed_store,
1359 &config,
1360 None,
1361 #[cfg(feature = "tee")]
1362 None,
1363 )
1364 .await
1365 .expect("import");
1366
1367 let restored: AclEntry = ts
1368 .acl_ks
1369 .get("acl:did:key:zAcl")
1370 .await
1371 .unwrap()
1372 .expect("acl entry restored");
1373 assert_eq!(restored.role, Role::Admin);
1374 assert_eq!(
1375 restored.expires_at,
1376 Some(1_900_000_000),
1377 "expiry must survive (a lossy restore would make it permanent)"
1378 );
1379 assert_eq!(
1380 restored.step_up_require,
1381 Some(StepUpMode::SelfApprove),
1382 "step-up floor must survive (a lossy restore would strip it)"
1383 );
1384 }
1385
1386 #[tokio::test]
1390 async fn successful_import_leaves_no_in_progress_sentinel() {
1391 let ts = crate::test_support::open_test_store().await;
1392 let seed_store: std::sync::Arc<dyn SeedStore> =
1393 std::sync::Arc::new(crate::test_support::TestSeedStore(vec![42u8; 32]));
1394 let config =
1395 tokio::sync::RwLock::new(crate::test_support::test_app_config(ts.data_dir.clone()));
1396
1397 let mut payload = test_payload();
1398 payload.acl_entries = vec![];
1399
1400 apply_import(
1401 &payload,
1402 &import_keyspaces(&ts),
1403 &seed_store,
1404 &config,
1405 None,
1406 #[cfg(feature = "tee")]
1407 None,
1408 )
1409 .await
1410 .expect("import");
1411
1412 assert!(
1413 ts.keys_ks
1414 .get_raw(IMPORT_IN_PROGRESS_KEY)
1415 .await
1416 .unwrap()
1417 .is_none(),
1418 "a completed import must clear its in-progress sentinel"
1419 );
1420 }
1421
1422 #[tokio::test]
1423 async fn import_sentinel_survives_keyspace_clear() {
1424 let ts = crate::test_support::open_test_store().await;
1427 ts.keys_ks
1428 .insert_raw(IMPORT_IN_PROGRESS_KEY, b"1".to_vec())
1429 .await
1430 .unwrap();
1431 clear_keyspace(&ts.keys_ks, &["key:", "seed:", "path_counter:"])
1432 .await
1433 .unwrap();
1434 assert!(
1435 ts.keys_ks
1436 .get_raw(IMPORT_IN_PROGRESS_KEY)
1437 .await
1438 .unwrap()
1439 .is_some(),
1440 "the sentinel must survive the import clear so an interrupted import is detectable at boot"
1441 );
1442 }
1443
1444 #[test]
1445 fn recompute_path_counters_skips_imported_keys_and_takes_max() {
1446 let recs = vec![
1447 mk_key_record("a", "m/26'/2'/0'/0'"),
1448 mk_key_record("b", "m/26'/2'/0'/3'"),
1449 mk_key_record("imported", ""), ];
1451 let counters = recompute_path_counters(&recs);
1452 assert_eq!(counters.get("m/26'/2'/0'"), Some(&4)); assert_eq!(counters.len(), 1);
1454 }
1455
1456 #[test]
1457 fn encrypt_decrypt_roundtrip() {
1458 let payload = test_payload();
1459 let password = "test-password-12chars!";
1460 let config = test_config();
1461
1462 let envelope = encrypt_payload(&payload, password, false, &config).unwrap();
1463
1464 assert_eq!(envelope.version, 1);
1465 assert_eq!(envelope.format, "vta-backup-v1");
1466 assert_eq!(envelope.kdf.algorithm, "argon2id");
1467 assert_eq!(envelope.encryption.algorithm, "aes-256-gcm");
1468 assert!(!envelope.ciphertext.is_empty());
1469
1470 let decrypted = decrypt_backup(&envelope, password).unwrap();
1471
1472 assert_eq!(decrypted.active_seed_hex, payload.active_seed_hex);
1473 assert_eq!(decrypted.active_seed_id, payload.active_seed_id);
1474 assert_eq!(decrypted.seed_records.len(), 1);
1475 assert_eq!(decrypted.seed_records[0].id, 0);
1476 assert_eq!(decrypted.seed_records[0].seed_hex, None);
1479 assert_eq!(
1480 decrypted.seed_records[0].seed_enc,
1481 payload.seed_records[0].seed_enc
1482 );
1483 assert_eq!(decrypted.jwt_signing_key, payload.jwt_signing_key);
1484 assert_eq!(decrypted.context_counter, 2);
1485 assert_eq!(decrypted.acl_entries.len(), 1);
1486 assert_eq!(decrypted.acl_entries[0].did, "did:key:z6MkTest");
1487 assert_eq!(decrypted.acl_entries[0].role, "Admin");
1488 assert_eq!(decrypted.config.vta_did, Some("did:key:z6MkVTA".into()));
1489 assert_eq!(decrypted.config.vta_name, Some("Test VTA".into()));
1490 }
1491
1492 #[test]
1493 fn wrong_password_fails() {
1494 let payload = test_payload();
1495 let config = test_config();
1496
1497 let envelope = encrypt_payload(&payload, "correct-password!!", false, &config).unwrap();
1498 let result = decrypt_backup(&envelope, "wrong-password!!!");
1499
1500 assert!(result.is_err());
1501 let err = result.unwrap_err();
1502 assert!(
1504 format!("{err}").contains("incorrect backup password"),
1505 "expected auth error, got: {err}"
1506 );
1507 }
1508
1509 #[test]
1510 fn tampered_ciphertext_detected() {
1511 let payload = test_payload();
1512 let config = test_config();
1513 let password = "test-password-12chars!";
1514
1515 let mut envelope = encrypt_payload(&payload, password, false, &config).unwrap();
1516
1517 let mut ct_bytes = BASE64.decode(&envelope.ciphertext).unwrap();
1519 if let Some(byte) = ct_bytes.last_mut() {
1520 *byte ^= 0xFF;
1521 }
1522 envelope.ciphertext = BASE64.encode(&ct_bytes);
1523
1524 let result = decrypt_backup(&envelope, password);
1525 assert!(result.is_err());
1526 assert!(
1527 format!("{}", result.unwrap_err()).contains("incorrect backup password"),
1528 "tampered ciphertext should fail AES-GCM auth"
1529 );
1530 }
1531
1532 #[test]
1533 fn unsupported_version_rejected() {
1534 let payload = test_payload();
1535 let config = test_config();
1536 let password = "test-password-12chars!";
1537
1538 let mut envelope = encrypt_payload(&payload, password, false, &config).unwrap();
1539 envelope.version = 99;
1540
1541 let result = decrypt_backup(&envelope, password);
1542 assert!(result.is_err());
1543 assert!(
1544 format!("{}", result.unwrap_err()).contains("unsupported backup format"),
1545 "should reject unknown version"
1546 );
1547 }
1548
1549 #[test]
1550 fn unsupported_format_rejected() {
1551 let payload = test_payload();
1552 let config = test_config();
1553 let password = "test-password-12chars!";
1554
1555 let mut envelope = encrypt_payload(&payload, password, false, &config).unwrap();
1556 envelope.format = "unknown-format".into();
1557
1558 let result = decrypt_backup(&envelope, password);
1559 assert!(result.is_err());
1560 assert!(
1561 format!("{}", result.unwrap_err()).contains("unsupported backup format"),
1562 "should reject unknown format"
1563 );
1564 }
1565
1566 #[test]
1567 fn envelope_serialization_roundtrip() {
1568 let payload = test_payload();
1569 let config = test_config();
1570 let password = "test-password-12chars!";
1571
1572 let envelope = encrypt_payload(&payload, password, true, &config).unwrap();
1573
1574 let json = serde_json::to_string_pretty(&envelope).unwrap();
1576 let deserialized: BackupEnvelope = serde_json::from_str(&json).unwrap();
1577
1578 assert_eq!(deserialized.version, envelope.version);
1579 assert_eq!(deserialized.format, envelope.format);
1580 assert!(deserialized.includes_audit);
1581 assert_eq!(deserialized.ciphertext, envelope.ciphertext);
1582
1583 let decrypted = decrypt_backup(&deserialized, password).unwrap();
1585 assert_eq!(decrypted.active_seed_hex, payload.active_seed_hex);
1586 }
1587
1588 #[test]
1589 fn different_passwords_produce_different_ciphertexts() {
1590 let payload = test_payload();
1591 let config = test_config();
1592
1593 let env1 = encrypt_payload(&payload, "password-one-12!!", false, &config).unwrap();
1594 let env2 = encrypt_payload(&payload, "password-two-12!!", false, &config).unwrap();
1595
1596 assert_ne!(env1.kdf.salt, env2.kdf.salt);
1598 assert_ne!(env1.ciphertext, env2.ciphertext);
1599 }
1600
1601 #[test]
1604 fn vta_did_guard_fresh_install_accepts_any_backup() {
1605 check_vta_did_compatibility(None, Some("did:key:z6MkAnything"))
1608 .expect("fresh install must accept any backup");
1609 check_vta_did_compatibility(None, None).expect("fresh install accepts no-did backup");
1610 check_vta_did_compatibility(Some(""), Some("did:key:z6MkAnything"))
1611 .expect("empty-string vta_did counts as fresh install");
1612 }
1613
1614 #[test]
1615 fn vta_did_guard_matching_dids_accepted() {
1616 check_vta_did_compatibility(Some("did:key:z6MkSame"), Some("did:key:z6MkSame"))
1619 .expect("matching vta_did must pass");
1620 }
1621
1622 #[test]
1623 fn vta_did_guard_mismatch_rejected() {
1624 let err = check_vta_did_compatibility(
1625 Some("did:key:z6MkRunning"),
1626 Some("did:key:z6MkForeignBackup"),
1627 )
1628 .expect_err("mismatched vta_did must be rejected");
1629 let msg = format!("{err}");
1630 assert!(msg.contains("vta_did mismatch"), "got: {msg}");
1631 assert!(
1632 msg.contains("z6MkForeignBackup"),
1633 "must name backup did: {msg}"
1634 );
1635 assert!(msg.contains("z6MkRunning"), "must name running did: {msg}");
1636 }
1637
1638 #[test]
1639 fn vta_did_guard_backup_missing_did_rejected_when_running_has_did() {
1640 let err = check_vta_did_compatibility(Some("did:key:z6MkRunning"), None)
1643 .expect_err("missing backup vta_did must be rejected when running has one");
1644 assert!(format!("{err}").contains("vta_did mismatch"), "got {err:?}");
1645 }
1646
1647 fn make_envelope_with_kdf(m_cost: u32, t_cost: u32, p_cost: u32, alg: &str) -> BackupEnvelope {
1650 let payload = test_payload();
1655 let config = test_config();
1656 let mut env = encrypt_payload(&payload, "password-12!ok!a", false, &config).unwrap();
1657 env.kdf.algorithm = alg.into();
1658 env.kdf.m_cost = m_cost;
1659 env.kdf.t_cost = t_cost;
1660 env.kdf.p_cost = p_cost;
1661 env
1662 }
1663
1664 #[test]
1665 fn kdf_m_cost_above_max_rejected() {
1666 let env = make_envelope_with_kdf(MAX_M_COST + 1, ARGON2_T_COST, ARGON2_P_COST, "argon2id");
1667 let err = decrypt_backup(&env, "anything").expect_err("must reject huge m_cost");
1668 assert!(format!("{err}").contains("m_cost"), "got {err:?}");
1669 }
1670
1671 #[test]
1672 fn kdf_m_cost_below_min_rejected() {
1673 let env = make_envelope_with_kdf(1, ARGON2_T_COST, ARGON2_P_COST, "argon2id");
1674 let err = decrypt_backup(&env, "anything").expect_err("must reject m_cost = 1");
1675 assert!(format!("{err}").contains("m_cost"), "got {err:?}");
1676 }
1677
1678 #[test]
1679 fn kdf_t_cost_zero_rejected() {
1680 let env = make_envelope_with_kdf(ARGON2_M_COST, 0, ARGON2_P_COST, "argon2id");
1681 let err = decrypt_backup(&env, "anything").expect_err("must reject t_cost = 0");
1682 assert!(format!("{err}").contains("t_cost"), "got {err:?}");
1683 }
1684
1685 #[test]
1686 fn kdf_p_cost_above_max_rejected() {
1687 let env = make_envelope_with_kdf(ARGON2_M_COST, ARGON2_T_COST, MAX_P_COST + 1, "argon2id");
1688 let err = decrypt_backup(&env, "anything").expect_err("must reject huge p_cost");
1689 assert!(format!("{err}").contains("p_cost"), "got {err:?}");
1690 }
1691
1692 #[test]
1693 fn kdf_unknown_algorithm_rejected() {
1694 let env =
1695 make_envelope_with_kdf(ARGON2_M_COST, ARGON2_T_COST, ARGON2_P_COST, "scrypt-custom");
1696 let err = decrypt_backup(&env, "anything").expect_err("must reject non-argon2id KDF");
1697 assert!(format!("{err}").contains("KDF algorithm"), "got {err:?}");
1698 }
1699
1700 #[test]
1711 fn nonce_wrong_length_rejected_without_panic() {
1712 let payload = test_payload();
1713 let config = test_config();
1714 let mut env = encrypt_payload(&payload, "password-12!ok!a", false, &config).unwrap();
1715 env.encryption.nonce = BASE64.encode([0u8; 16]);
1718 let err = decrypt_backup(&env, "password-12!ok!a")
1719 .expect_err("wrong-length nonce must be rejected pre-decrypt");
1720 let msg = format!("{err}");
1721 assert!(
1722 msg.contains("nonce length"),
1723 "expected nonce-length error, got: {msg}"
1724 );
1725 }
1726
1727 #[test]
1728 fn salt_wrong_length_rejected_without_panic() {
1729 let payload = test_payload();
1730 let config = test_config();
1731 let mut env = encrypt_payload(&payload, "password-12!ok!a", false, &config).unwrap();
1732 env.kdf.salt = BASE64.encode([0u8; 16]);
1734 let err = decrypt_backup(&env, "password-12!ok!a")
1735 .expect_err("wrong-length salt must be rejected pre-decrypt");
1736 let msg = format!("{err}");
1737 assert!(
1738 msg.contains("salt length"),
1739 "expected salt-length error, got: {msg}"
1740 );
1741 }
1742}