1pub mod blob;
28pub mod chunked;
29pub mod descriptors;
30
31pub(crate) fn chunked_algorithm() -> &'static str {
33 vta_sdk::protocols::backup_management::chunked::ALGORITHM_CHUNKED
34}
35
36use aes_gcm::aead::{Aead, Payload};
37use aes_gcm::{Aes256Gcm, KeyInit, Nonce};
38use argon2::Argon2;
39use base64::Engine;
40use base64::engine::general_purpose::URL_SAFE_NO_PAD as BASE64;
41use chrono::Utc;
42use tracing::{info, warn};
43
44use vta_keys::KeyOrigin;
45use vta_keys::imported;
46use vta_keys::seed_store::SeedStore;
47use vta_keys::seeds::{SeedRecord, get_active_seed_id, save_seed_record, set_active_seed_id};
48use vta_support::restore_stage::{self, StageMeta};
49use vta_support::seal::SealRecord;
50use vti_common::auth::AuthClaims;
51use vti_common::error::AppError;
52use vti_common::store::KeyspaceHandle;
53
54use vta_sdk::protocols::backup_management::types::*;
55
56use crate::{BackupTarget, RestoreCommitter, RestoredSecrets};
57
58const ARGON2_M_COST: u32 = 65536; const ARGON2_T_COST: u32 = 3;
62const ARGON2_P_COST: u32 = 4;
63const SALT_LEN: usize = 32;
64const NONCE_LEN: usize = 12;
65
66const MAX_M_COST: u32 = 1 << 20;
78const MIN_M_COST: u32 = 8 * 1024;
81const MAX_T_COST: u32 = 10;
83const MIN_T_COST: u32 = 1;
85const MAX_P_COST: u32 = 16;
87const MIN_P_COST: u32 = 1;
89
90pub const IMPORT_IN_PROGRESS_KEY: &str = "backup:import_in_progress";
95
96async fn dump_rows(ks: &KeyspaceHandle) -> Result<Vec<(Vec<u8>, Vec<u8>)>, AppError> {
104 let mut rows = Vec::new();
105 for key in ks.prefix_keys(Vec::<u8>::new()).await? {
106 if let Some(value) = ks.get_raw(key.clone()).await? {
109 rows.push((key, value));
110 }
111 }
112 Ok(rows)
113}
114
115async fn require_key_export(target: &BackupTarget<'_>, auth: &AuthClaims) -> Result<(), AppError> {
125 use vti_common::acl::{Capability, entry_has_capability, get_acl_entry, role_has_capability};
126 let acl_ks = target.keyspace(vta_keyspaces::ACL)?;
127 let may = match get_acl_entry(&acl_ks, &auth.did).await {
128 Ok(Some(entry)) => entry_has_capability(&entry, Capability::KeyExport),
129 Ok(None) => role_has_capability(&auth.role, Capability::KeyExport),
130 Err(e) => {
131 tracing::error!(error = %e, did = %auth.did, "could not read the ACL entry for the backup key-export check; refusing");
132 false
133 }
134 };
135 if may {
136 return Ok(());
137 }
138 Err(AppError::Forbidden(format!(
139 "backup export denied: {} does not carry the key-export capability. A backup carries \
140 the seed, so it is an export of every key this VTA holds (VTI-VTA-003)",
141 auth.did
142 )))
143}
144
145pub async fn export_backup(
147 target: &BackupTarget<'_>,
148 seed_store: &dyn SeedStore,
149 config: &vta_config::AppConfig,
150 auth: &AuthClaims,
151 password: &str,
152 include_audit: bool,
153) -> Result<BackupEnvelope, AppError> {
154 auth.require_super_admin()?;
155 require_key_export(target, auth).await?;
156 vta_sdk::protocols::backup_management::validate_backup_password(password)
157 .map_err(AppError::Validation)?;
158
159 let seed_bytes = seed_store
160 .get()
161 .await
162 .map_err(|e| AppError::Internal(format!("seed store: {e}")))?
163 .ok_or_else(|| AppError::Internal("no active seed available".into()))?;
164 let active_seed_hex = hex::encode(&seed_bytes);
165 let active_seed_id = get_active_seed_id(&target.keyspace(vta_keyspaces::KEYS)?)
166 .await
167 .map_err(|e| AppError::Internal(format!("get active seed id: {e}")))?;
168
169 let mut keyspaces = Vec::with_capacity(vta_keyspaces::BACKED_UP.len());
170 let mut internal_keys_not_carried = Vec::new();
171 for name in vta_keyspaces::BACKED_UP {
172 if *name == vta_keyspaces::AUDIT && !include_audit {
176 continue;
177 }
178 let mut rows = Vec::new();
179 for (key, value) in dump_rows(&target.keyspace(name)?).await? {
180 if vta_keyspaces::is_environment_bound(name, &key) {
181 continue;
182 }
183 if *name == vta_keyspaces::KEYS && key.starts_with(b"key:") {
184 let record: vta_sdk::keys::KeyRecord =
189 serde_json::from_slice(&value).map_err(|e| {
190 AppError::Internal(format!(
191 "backup aborted: key row '{}' is corrupt and would be restored \
192 unreadable: {e}",
193 String::from_utf8_lossy(&key)
194 ))
195 })?;
196 if record.origin == KeyOrigin::Internal {
197 internal_keys_not_carried.push(record.key_id);
198 }
199 }
200 rows.push((BASE64.encode(&key), BASE64.encode(&value)));
201 }
202 keyspaces.push(KeyspaceDump {
203 name: (*name).to_string(),
204 rows,
205 });
206 }
207
208 let payload = BackupPayload {
209 active_seed_hex,
210 active_seed_id,
211 jwt_signing_key: config.auth.jwt_signing_key.clone(),
212 config: BackupConfig {
213 vta_did: config.vta_did.clone(),
214 vta_name: config.vta_name.clone(),
215 public_url: config.public_url.clone(),
216 mediator_url: config.messaging.as_ref().map(|m| m.mediator_url.clone()),
217 mediator_did: config.messaging.as_ref().map(|m| m.mediator_did.clone()),
218 },
219 keyspaces,
220 source_environment: Some(target.environment),
221 internal_keys_not_carried,
222 seed_records: Vec::new(),
224 key_records: Vec::new(),
225 context_records: Vec::new(),
226 context_counter: 0,
227 path_counters: Vec::new(),
228 subcontext_counters: Vec::new(),
229 acl_entries: Vec::new(),
230 acl_entries_full: Vec::new(),
231 seal: None,
232 webvh_servers: Vec::new(),
233 webvh_dids: Vec::new(),
234 webvh_logs: Vec::new(),
235 audit_logs: Vec::new(),
236 imported_secrets: Vec::new(),
237 imported_kek_salt: None,
238 };
239 let counts = payload_counts(&payload);
240 let envelope = encrypt_payload(&payload, password, include_audit, config)?;
241
242 info!(
243 keyspaces = payload.keyspaces.len(),
244 keys = counts.keys,
245 acls = counts.acls,
246 contexts = counts.contexts,
247 audit = counts.audit,
248 internal_keys_not_carried = payload.internal_keys_not_carried.len(),
249 environment = %target.environment,
250 "backup exported"
251 );
252 Ok(envelope)
253}
254
255#[derive(Debug, Default, Clone, Copy, PartialEq, Eq)]
259pub struct PayloadCounts {
260 pub keys: usize,
261 pub acls: usize,
262 pub contexts: usize,
263 pub audit: usize,
264 pub imported_secrets: usize,
265}
266
267pub fn payload_counts(payload: &BackupPayload) -> PayloadCounts {
269 if payload.keyspaces.is_empty() {
270 return PayloadCounts {
271 keys: payload.key_records.len(),
272 acls: payload
273 .acl_entries_full
274 .len()
275 .max(payload.acl_entries.len()),
276 contexts: payload.context_records.len(),
277 audit: payload.audit_logs.len(),
278 imported_secrets: payload.imported_secrets.len(),
279 };
280 }
281 let count = |keyspace: &str, prefix: &str| {
282 payload
283 .keyspaces
284 .iter()
285 .filter(|d| d.name == keyspace)
286 .flat_map(|d| d.rows.iter())
287 .filter(|(k, _)| {
288 BASE64
289 .decode(k)
290 .is_ok_and(|k| k.starts_with(prefix.as_bytes()))
291 })
292 .count()
293 };
294 PayloadCounts {
295 keys: count(vta_keyspaces::KEYS, "key:"),
296 acls: count(vta_keyspaces::ACL, "acl:"),
297 contexts: count(vta_keyspaces::CONTEXTS, "ctx:"),
298 audit: count(vta_keyspaces::AUDIT, "log:"),
299 imported_secrets: count(vta_keyspaces::IMPORTED_SECRETS, "secret:"),
300 }
301}
302
303fn import_result(payload: &BackupPayload, status: &str, message: String) -> ImportResult {
304 let counts = payload_counts(payload);
305 ImportResult {
306 status: status.into(),
307 source_did: payload.config.vta_did.clone(),
308 key_count: counts.keys,
309 acl_count: counts.acls,
310 context_count: counts.contexts,
311 audit_count: counts.audit,
312 imported_secret_count: counts.imported_secrets,
313 message: Some(message),
314 }
315}
316
317pub fn validate_payload(payload: &BackupPayload) -> Result<(), AppError> {
321 let seed = hex::decode(&payload.active_seed_hex)
322 .map_err(|e| AppError::Validation(format!("backup seed is not hex: {e}")))?;
323 if seed.is_empty() {
324 return Err(AppError::Validation("backup carries an empty seed".into()));
325 }
326 if let Some(jwt) = &payload.jwt_signing_key {
327 decode_jwt_key(jwt)?;
328 }
329 let mut seen = std::collections::BTreeSet::new();
330 for dump in &payload.keyspaces {
331 if !vta_keyspaces::BACKED_UP.contains(&dump.name.as_str()) {
336 return Err(AppError::Validation(format!(
337 "backup carries keyspace '{}', which is not one a backup may restore",
338 dump.name
339 )));
340 }
341 if !seen.insert(dump.name.as_str()) {
342 return Err(AppError::Validation(format!(
343 "backup carries keyspace '{}' twice",
344 dump.name
345 )));
346 }
347 for (k, v) in &dump.rows {
348 let key = BASE64.decode(k).map_err(|e| {
349 AppError::Validation(format!(
350 "backup row key in '{}' is not base64: {e}",
351 dump.name
352 ))
353 })?;
354 BASE64.decode(v).map_err(|e| {
355 AppError::Validation(format!(
356 "backup row value in '{}' is not base64: {e}",
357 dump.name
358 ))
359 })?;
360 if vta_keyspaces::is_environment_bound(&dump.name, &key) {
361 return Err(AppError::Validation(format!(
362 "backup carries deployment-bound row '{}' in '{}'; a backup of this \
363 agent never does",
364 String::from_utf8_lossy(&key),
365 dump.name
366 )));
367 }
368 }
369 }
370 Ok(())
371}
372
373pub async fn preview_import(
375 envelope: &BackupEnvelope,
376 password: &str,
377) -> Result<(BackupPayload, ImportResult), AppError> {
378 let payload = decrypt_backup(envelope, password)?;
379 validate_payload(&payload)?;
380 let mut message =
381 String::from("Preview only — no changes applied. Set confirm=true to import.");
382 if !payload.internal_keys_not_carried.is_empty() {
383 message.push_str(&format!(
384 " {} internal key(s) are not in the backup and cannot be restored: {}.",
385 payload.internal_keys_not_carried.len(),
386 payload.internal_keys_not_carried.join(", ")
387 ));
388 }
389 let result = import_result(&payload, "preview", message);
390 Ok((payload, result))
391}
392
393pub async fn preview_import_for(
397 envelope: &BackupEnvelope,
398 password: &str,
399 running_did: Option<&str>,
400 replace_identity: bool,
401) -> Result<(BackupPayload, ImportResult), AppError> {
402 let (payload, result) = preview_import(envelope, password).await?;
403 check_vta_did_compatibility(
404 running_did,
405 payload.config.vta_did.as_deref(),
406 replace_identity,
407 )?;
408 Ok((payload, result))
409}
410
411fn check_vta_did_compatibility(
420 running_did: Option<&str>,
421 backup_did: Option<&str>,
422 replace_identity: bool,
423) -> Result<(), AppError> {
424 let running = match running_did {
425 Some(d) if !d.is_empty() => d,
426 _ => return Ok(()),
427 };
428 let backup = backup_did.unwrap_or("");
429 if backup == running || replace_identity {
430 return Ok(());
431 }
432 Err(AppError::Validation(format!(
433 "backup vta_did mismatch: backup claims '{backup}' but this VTA is running \
434 as '{running}'. Refusing to overwrite identity. If this is intentional — \
435 restoring onto a freshly set-up VTA, or migrating an identity — re-run the \
436 import with --replace-identity."
437 )))
438}
439
440fn decode_jwt_key(b64: &str) -> Result<[u8; 32], AppError> {
441 BASE64
442 .decode(b64)
443 .ok()
444 .and_then(|b| <[u8; 32]>::try_from(b.as_slice()).ok())
445 .ok_or_else(|| {
446 AppError::Validation("backup JWT signing key is not 32 bytes of base64url".into())
447 })
448}
449
450pub struct StageRequest<'a> {
452 pub target: &'a BackupTarget<'a>,
453 pub config: &'a tokio::sync::RwLock<vta_config::AppConfig>,
454 pub committer: &'a dyn RestoreCommitter,
455 pub auth: &'a AuthClaims,
456 pub replace_identity: bool,
462}
463
464pub async fn stage_import(
473 payload: BackupPayload,
474 req: StageRequest<'_>,
475) -> Result<ImportResult, AppError> {
476 req.auth.require_super_admin()?;
477 validate_payload(&payload)?;
478
479 let running_did = req.config.read().await.vta_did.clone();
480 check_vta_did_compatibility(
481 running_did.as_deref(),
482 payload.config.vta_did.as_deref(),
483 req.replace_identity,
484 )?;
485 let cross_identity = matches!(
486 (running_did.as_deref(), payload.config.vta_did.as_deref()),
487 (Some(running), backup) if !running.is_empty() && Some(running) != backup
488 );
489
490 let seed = zeroize::Zeroizing::new(
491 hex::decode(&payload.active_seed_hex)
492 .map_err(|e| AppError::Validation(format!("backup seed is not hex: {e}")))?,
493 );
494 let jwt_key = payload
495 .jwt_signing_key
496 .as_deref()
497 .map(decode_jwt_key)
498 .transpose()?;
499 let restored_did = payload.config.vta_did.clone();
500 let secrets = RestoredSecrets {
501 seed: &seed,
502 jwt_key,
503 vta_did: restored_did.as_deref(),
504 };
505
506 let prepared = req.committer.prepare(&secrets).await?;
507 let restore_id = uuid::Uuid::new_v4().to_string();
508 let meta = StageMeta {
509 restore_id: restore_id.clone(),
510 staged_at: Utc::now(),
511 staged_by: req.auth.did.clone(),
512 target_environment: req.target.environment,
513 cross_identity,
514 tee_secrets_sha256: prepared
515 .tee_secrets_row
516 .as_deref()
517 .map(restore_stage::sha256_hex),
518 anchor: prepared.anchor.clone(),
519 };
520 let result = import_result(
521 &payload,
522 "imported",
523 restore_message(&payload, req.target.environment),
524 );
525
526 let staged = async {
527 restore_stage::write_stage(&req.target.bootstrap()?, &seed, meta, payload).await?;
528 req.committer.commit(&secrets, prepared).await
530 }
531 .await;
532 if let Err(e) = staged {
533 req.committer.abort().await;
534 return Err(e);
535 }
536
537 info!(
538 restore_id,
539 staged_by = %req.auth.did,
540 source_did = result.source_did.as_deref().unwrap_or("unknown"),
541 target_environment = %req.target.environment,
542 cross_identity,
543 "backup import committed — the VTA reboots to apply it"
544 );
545 Ok(result)
546}
547
548fn restore_message(payload: &BackupPayload, target: BackupEnvironment) -> String {
549 let mut message = format!(
550 "Restore committed. The VTA is rebooting to apply it{}.",
551 match payload.source_environment {
552 Some(source) if source != target => format!(" ({source} → {target})"),
553 _ => String::new(),
554 }
555 );
556 if !payload.internal_keys_not_carried.is_empty() {
557 message.push_str(&format!(
558 " {} internal key(s) were not in the backup and are lost: {}.",
559 payload.internal_keys_not_carried.len(),
560 payload.internal_keys_not_carried.join(", ")
561 ));
562 }
563 message
564}
565
566fn recompute_path_counters(
571 key_records: &[vta_sdk::keys::KeyRecord],
572) -> std::collections::HashMap<String, u32> {
573 let mut counters: std::collections::HashMap<String, u32> = std::collections::HashMap::new();
574 for kr in key_records {
575 let path = kr.derivation_path.trim();
576 if path.is_empty() {
577 continue; }
579 if let Some((base, last)) = path.rsplit_once('/')
580 && let Ok(index) = last.trim_end_matches('\'').parse::<u32>()
581 {
582 let next = index.saturating_add(1);
583 let slot = counters.entry(base.to_string()).or_insert(0);
584 *slot = (*slot).max(next);
585 }
586 }
587 counters
588}
589
590fn recompute_subcontext_counters(
594 context_records: &[vta_sdk::contexts::ContextRecord],
595) -> std::collections::HashMap<String, u32> {
596 let mut counters: std::collections::HashMap<String, u32> = std::collections::HashMap::new();
597 for cr in context_records {
598 if let Some(parent) = cr.parent.as_deref() {
599 let next = cr.index.saturating_add(1);
600 let slot = counters.entry(parent.to_string()).or_insert(0);
601 *slot = (*slot).max(next);
602 }
603 }
604 counters
605}
606
607pub(crate) async fn write_legacy_payload(
611 payload: &BackupPayload,
612 ks: &vta_keyspaces::Keyspaces<'_>,
613 seed_bytes: &[u8],
614) -> Result<(), AppError> {
615 let keys_ks = ks.keys;
616 let acl_ks = ks.acl;
617 let contexts_ks = ks.contexts;
618 let audit_ks = ks.audit;
619 let imported_ks = ks.imported;
620
621 set_active_seed_id(keys_ks, payload.active_seed_id)
622 .await
623 .map_err(|e| AppError::Internal(format!("set active seed id: {e}")))?;
624
625 for sr in &payload.seed_records {
626 let record = SeedRecord {
627 id: sr.id,
628 seed_hex: sr.seed_hex.clone(),
629 seed_enc: sr.seed_enc.clone(),
630 created_at: sr.created_at,
631 retired_at: sr.retired_at,
632 };
633 save_seed_record(keys_ks, &record)
634 .await
635 .map_err(|e| AppError::Internal(format!("save seed record: {e}")))?;
636 }
637
638 for kr in &payload.key_records {
639 keys_ks.insert(vta_keys::store_key(&kr.key_id), kr).await?;
640 }
641
642 for cr in &payload.context_records {
643 contexts_ks.insert(format!("ctx:{}", cr.id), cr).await?;
644 }
645 contexts_ks
646 .insert_raw("ctx_counter", &payload.context_counter.to_le_bytes())
647 .await?;
648
649 {
655 let mut path_counters = recompute_path_counters(&payload.key_records);
656 for (k, v) in &payload.path_counters {
657 let base = k.strip_prefix("path_counter:").unwrap_or(k).to_string();
660 let slot = path_counters.entry(base).or_insert(0);
661 *slot = (*slot).max(*v);
662 }
663 for (base, next) in path_counters {
664 keys_ks
665 .insert_raw(format!("path_counter:{base}"), next.to_le_bytes().to_vec())
666 .await?;
667 }
668
669 let mut sub_counters = recompute_subcontext_counters(&payload.context_records);
670 for (k, v) in &payload.subcontext_counters {
671 let parent = k.strip_prefix("ctx_counter:").unwrap_or(k).to_string();
672 let slot = sub_counters.entry(parent).or_insert(0);
673 *slot = (*slot).max(*v);
674 }
675 for (parent, next) in sub_counters {
676 contexts_ks
677 .insert_raw(format!("ctx_counter:{parent}"), next.to_le_bytes().to_vec())
678 .await?;
679 }
680 }
681
682 if !payload.acl_entries_full.is_empty() {
686 for entry in &payload.acl_entries_full {
687 let did = entry
688 .get("did")
689 .and_then(|d| d.as_str())
690 .ok_or_else(|| AppError::Internal("backup ACL entry has no `did` field".into()))?;
691 let bytes = serde_json::to_vec(entry)?;
692 acl_ks.insert_raw(format!("acl:{did}"), bytes).await?;
693 }
694 } else if !payload.acl_entries.is_empty() {
695 warn!(
696 count = payload.acl_entries.len(),
697 "restoring ACL from a pre-P0.5 backup's lossy form — expiry, step-up \
698 floors, and capability restrictions are not present and default to \
699 permanent/none. Re-export with this build for a lossless backup."
700 );
701 for entry in &payload.acl_entries {
702 acl_ks.insert(format!("acl:{}", entry.did), entry).await?;
703 }
704 }
705
706 if let Some(ref seal) = payload.seal {
707 let record = SealRecord {
708 sealed_by: seal.sealed_by.clone(),
709 sealed_at: seal.sealed_at,
710 reason: seal.reason.clone(),
711 };
712 acl_ks.insert("vta:sealed", &record).await?;
713 }
714
715 #[cfg(feature = "webvh")]
716 {
717 let webvh_ks = ks.webvh;
718 for server in &payload.webvh_servers {
719 webvh_ks
720 .insert(format!("server:{}", server.id), server)
721 .await?;
722 }
723 for did_rec in &payload.webvh_dids {
724 webvh_ks
725 .insert(format!("did:{}", did_rec.did), did_rec)
726 .await?;
727 }
728 for log in &payload.webvh_logs {
729 webvh_ks
730 .insert_raw(format!("log:{}", log.did), log.log_json.as_bytes())
731 .await?;
732 }
733 }
734
735 for entry in &payload.audit_logs {
736 audit_ks
737 .insert(format!("log:{:020}:{}", entry.timestamp, entry.id), entry)
738 .await?;
739 }
740
741 if !payload.imported_secrets.is_empty() {
742 if let Some(ref salt_hex) = payload.imported_kek_salt {
743 let salt = hex::decode(salt_hex)
744 .map_err(|e| AppError::Internal(format!("invalid imported KEK salt hex: {e}")))?;
745 imported::set_salt(keys_ks, &salt).await?;
746 }
747 for secret_backup in &payload.imported_secrets {
748 let mut private_bytes = hex::decode(&secret_backup.private_key_hex)
749 .map_err(|e| AppError::Internal(format!("invalid imported secret hex: {e}")))?;
750 let key_type_str = payload
751 .key_records
752 .iter()
753 .find(|kr| kr.key_id == secret_backup.key_id)
754 .map(|kr| kr.key_type.to_string())
755 .unwrap_or_else(|| "ed25519".to_string());
756 let stored = imported::store_secret(
757 imported_ks,
758 keys_ks,
759 seed_bytes,
760 &secret_backup.key_id,
761 &key_type_str,
762 &private_bytes,
763 )
764 .await;
765 use zeroize::Zeroize;
766 private_bytes.zeroize();
767 stored?;
768 }
769 }
770 Ok(())
771}
772
773fn envelope_aad(envelope: &BackupEnvelope) -> Vec<u8> {
779 let mut aad = Vec::new();
780 for field in [
781 envelope.format.as_bytes(),
782 &envelope.version.to_be_bytes(),
783 envelope.source_did.as_deref().unwrap_or("").as_bytes(),
784 &[u8::from(envelope.includes_audit)],
785 envelope.kdf.salt.as_bytes(),
786 envelope.encryption.nonce.as_bytes(),
787 ] {
788 aad.extend_from_slice(&(field.len() as u32).to_be_bytes());
789 aad.extend_from_slice(field);
790 }
791 aad
792}
793
794fn derive_backup_key(
795 password: &str,
796 salt: &[u8],
797 m_cost: u32,
798 t_cost: u32,
799 p_cost: u32,
800) -> Result<zeroize::Zeroizing<[u8; 32]>, AppError> {
801 let argon2 = Argon2::new(
802 argon2::Algorithm::Argon2id,
803 argon2::Version::V0x13,
804 argon2::Params::new(m_cost, t_cost, p_cost, Some(32))
805 .map_err(|e| AppError::Validation(format!("argon2 params: {e}")))?,
806 );
807 let mut key = zeroize::Zeroizing::new([0u8; 32]);
808 argon2
809 .hash_password_into(password.as_bytes(), salt, key.as_mut())
810 .map_err(|e| AppError::Internal(format!("argon2 hash: {e}")))?;
811 Ok(key)
812}
813
814fn encrypt_payload(
815 payload: &BackupPayload,
816 password: &str,
817 include_audit: bool,
818 config: &vta_config::AppConfig,
819) -> Result<BackupEnvelope, AppError> {
820 let plaintext = zeroize::Zeroizing::new(
821 serde_json::to_vec(payload).map_err(|e| AppError::Internal(format!("serialize: {e}")))?,
822 );
823
824 let mut salt = [0u8; SALT_LEN];
825 rand::fill(&mut salt);
826 let mut nonce_bytes = [0u8; NONCE_LEN];
827 rand::fill(&mut nonce_bytes);
828
829 let key = derive_backup_key(password, &salt, ARGON2_M_COST, ARGON2_T_COST, ARGON2_P_COST)?;
830
831 let mut envelope = BackupEnvelope {
832 version: 2,
833 format: BACKUP_FORMAT_V2.into(),
834 created_at: Utc::now(),
835 source_did: config.vta_did.clone(),
836 source_version: env!("CARGO_PKG_VERSION").into(),
837 kdf: KdfParams {
838 algorithm: "argon2id".into(),
839 salt: BASE64.encode(salt),
840 m_cost: ARGON2_M_COST,
841 t_cost: ARGON2_T_COST,
842 p_cost: ARGON2_P_COST,
843 },
844 encryption: EncryptionParams {
845 algorithm: "aes-256-gcm".into(),
846 nonce: BASE64.encode(nonce_bytes),
847 },
848 includes_audit: include_audit,
849 ciphertext: String::new(),
850 };
851
852 let cipher = Aes256Gcm::new_from_slice(key.as_ref())
853 .map_err(|e| AppError::Internal(format!("aes key: {e}")))?;
854 let nonce = (&nonce_bytes).into();
855 let ciphertext = cipher
856 .encrypt(
857 nonce,
858 Payload {
859 msg: plaintext.as_ref(),
860 aad: &envelope_aad(&envelope),
861 },
862 )
863 .map_err(|e| AppError::Internal(format!("aes encrypt: {e}")))?;
864 envelope.ciphertext = BASE64.encode(&ciphertext);
865 Ok(envelope)
866}
867
868pub fn decrypt_backup(
875 envelope: &BackupEnvelope,
876 password: &str,
877) -> Result<BackupPayload, AppError> {
878 let v2 = match (envelope.version, envelope.format.as_str()) {
879 (2, BACKUP_FORMAT_V2) => true,
880 (1, BACKUP_FORMAT_V1) => false,
881 _ => {
882 return Err(AppError::Validation(format!(
883 "unsupported backup format: {} v{}",
884 envelope.format, envelope.version
885 )));
886 }
887 };
888
889 if envelope.kdf.algorithm != "argon2id" {
892 return Err(AppError::Validation(format!(
893 "unsupported KDF algorithm: '{}' (only 'argon2id' is accepted)",
894 envelope.kdf.algorithm
895 )));
896 }
897 if !(MIN_M_COST..=MAX_M_COST).contains(&envelope.kdf.m_cost) {
898 return Err(AppError::Validation(format!(
899 "argon2 m_cost {} out of bounds [{}, {}]",
900 envelope.kdf.m_cost, MIN_M_COST, MAX_M_COST
901 )));
902 }
903 if !(MIN_T_COST..=MAX_T_COST).contains(&envelope.kdf.t_cost) {
904 return Err(AppError::Validation(format!(
905 "argon2 t_cost {} out of bounds [{}, {}]",
906 envelope.kdf.t_cost, MIN_T_COST, MAX_T_COST
907 )));
908 }
909 if !(MIN_P_COST..=MAX_P_COST).contains(&envelope.kdf.p_cost) {
910 return Err(AppError::Validation(format!(
911 "argon2 p_cost {} out of bounds [{}, {}]",
912 envelope.kdf.p_cost, MIN_P_COST, MAX_P_COST
913 )));
914 }
915 if envelope.encryption.algorithm != "aes-256-gcm" {
916 return Err(AppError::Validation(format!(
917 "unsupported encryption algorithm: '{}' (only 'aes-256-gcm' is accepted)",
918 envelope.encryption.algorithm
919 )));
920 }
921
922 let salt = BASE64
923 .decode(&envelope.kdf.salt)
924 .map_err(|e| AppError::Validation(format!("invalid salt: {e}")))?;
925 if salt.len() != SALT_LEN {
926 return Err(AppError::Validation(format!(
927 "invalid salt length: {} (expected {SALT_LEN})",
928 salt.len()
929 )));
930 }
931 let nonce_bytes = BASE64
932 .decode(&envelope.encryption.nonce)
933 .map_err(|e| AppError::Validation(format!("invalid nonce: {e}")))?;
934 if nonce_bytes.len() != NONCE_LEN {
942 return Err(AppError::Validation(format!(
943 "invalid nonce length: {} (expected {NONCE_LEN})",
944 nonce_bytes.len()
945 )));
946 }
947 let ciphertext = BASE64
948 .decode(&envelope.ciphertext)
949 .map_err(|e| AppError::Validation(format!("invalid ciphertext: {e}")))?;
950
951 let key = derive_backup_key(
952 password,
953 &salt,
954 envelope.kdf.m_cost,
955 envelope.kdf.t_cost,
956 envelope.kdf.p_cost,
957 )?;
958 let cipher = Aes256Gcm::new_from_slice(key.as_ref())
959 .map_err(|e| AppError::Internal(format!("aes key: {e}")))?;
960 let nonce = Nonce::try_from(nonce_bytes.as_slice())
961 .map_err(|_| AppError::Validation(format!("nonce must be {NONCE_LEN} bytes")))?;
962 let aad = if v2 {
963 envelope_aad(envelope)
964 } else {
965 Vec::new()
966 };
967 let plaintext = zeroize::Zeroizing::new(
968 cipher
969 .decrypt(
970 &nonce,
971 Payload {
972 msg: ciphertext.as_ref(),
973 aad: &aad,
974 },
975 )
976 .map_err(|_| AppError::Authentication("incorrect backup password".into()))?,
977 );
978
979 serde_json::from_slice(&plaintext)
980 .map_err(|e| AppError::Internal(format!("backup payload corrupt: {e}")))
981}
982
983#[cfg(test)]
984pub(crate) mod tests {
985 use super::*;
986 use crate::test_support::{
987 TestSeedStore, open_test_store, super_admin_claims, test_app_config,
988 };
989
990 #[test]
1006 fn argon2id_derivation_matches_the_frozen_vector() {
1007 let key = derive_backup_key(
1008 "backup-password",
1009 b"0123456789abcdef",
1010 ARGON2_M_COST,
1011 ARGON2_T_COST,
1012 ARGON2_P_COST,
1013 )
1014 .unwrap();
1015 assert_eq!(
1016 key.iter().map(|b| format!("{b:02x}")).collect::<String>(),
1017 "3558837960e818d4ae946a900d505053894bf02a6ac9e046f0781fe09a616bf9",
1018 "the backup KDF changed — every existing envelope is now \
1019 undecryptable"
1020 );
1021 }
1022
1023 fn plain_target(ts: &crate::test_support::TestStore) -> BackupTarget<'_> {
1024 BackupTarget {
1025 store: &ts.store,
1026 storage_key: None,
1027 environment: BackupEnvironment::Plain,
1028 }
1029 }
1030
1031 #[tokio::test]
1036 async fn export_aborts_on_corrupt_key_row() {
1037 let ts = open_test_store().await;
1038 let config = test_app_config(ts.data_dir.clone());
1039 ts.keys_ks
1040 .insert_raw("key:corrupt", b"{not a key record".to_vec())
1041 .await
1042 .unwrap();
1043
1044 let err = export_backup(
1045 &plain_target(&ts),
1046 &TestSeedStore(vec![42u8; 32]),
1047 &config,
1048 &super_admin_claims(),
1049 "a-strong-password",
1050 false,
1051 )
1052 .await
1053 .expect_err("export must abort on a corrupt key row");
1054 let msg = format!("{err:?}");
1055 assert!(
1056 msg.contains("corrupt") && msg.contains("key"),
1057 "error must name the corrupt-row cause, got: {msg}"
1058 );
1059 }
1060
1061 #[tokio::test]
1065 async fn export_rejects_short_password() {
1066 let ts = open_test_store().await;
1067 let config = test_app_config(ts.data_dir.clone());
1068 let seed_store = TestSeedStore(vec![42u8; 32]);
1069 let target = plain_target(&ts);
1070 let auth = super_admin_claims();
1071
1072 let err = export_backup(
1073 &target,
1074 &seed_store,
1075 &config,
1076 &auth,
1077 "14-char-passwo",
1078 false,
1079 )
1080 .await
1081 .expect_err("export must reject a 14-character password");
1082 assert!(
1083 format!("{err}").contains("15 characters"),
1084 "error must mention the 15-character minimum, got: {err}"
1085 );
1086 export_backup(
1087 &target,
1088 &seed_store,
1089 &config,
1090 &auth,
1091 "15-char-passwor",
1092 false,
1093 )
1094 .await
1095 .expect("export must accept a 15-character password");
1096 }
1097
1098 #[tokio::test]
1102 async fn export_leaves_deployment_bound_rows_behind() {
1103 let ts = open_test_store().await;
1104 let config = test_app_config(ts.data_dir.clone());
1105 ts.keys_ks
1106 .insert_raw("tee:vta_did", b"did:example:enclave".to_vec())
1107 .await
1108 .unwrap();
1109 ts.keys_ks
1110 .insert_raw("hardened:jwt_key", vec![1u8; 32])
1111 .await
1112 .unwrap();
1113 ts.keys_ks
1114 .insert_raw("path_counter:m/1'", 3u32.to_le_bytes().to_vec())
1115 .await
1116 .unwrap();
1117
1118 let env = export_backup(
1119 &plain_target(&ts),
1120 &TestSeedStore(vec![42u8; 32]),
1121 &config,
1122 &super_admin_claims(),
1123 "a-strong-password",
1124 false,
1125 )
1126 .await
1127 .unwrap();
1128 let payload = decrypt_backup(&env, "a-strong-password").unwrap();
1129 let keys: Vec<Vec<u8>> = payload
1130 .keyspaces
1131 .iter()
1132 .flat_map(|d| d.rows.iter().map(|(k, _)| BASE64.decode(k).unwrap()))
1133 .collect();
1134 assert!(keys.iter().any(|k| k == b"path_counter:m/1'"));
1135 for bound in [&b"tee:vta_did"[..], b"hardened:jwt_key"] {
1136 assert!(
1137 !keys.iter().any(|k| k == bound),
1138 "{} must not be exported",
1139 String::from_utf8_lossy(bound)
1140 );
1141 }
1142 assert_eq!(payload.source_environment, Some(BackupEnvironment::Plain));
1143 }
1144
1145 fn v2_payload() -> BackupPayload {
1146 BackupPayload {
1147 active_seed_hex: hex::encode([42u8; 32]),
1148 active_seed_id: 1,
1149 seed_records: vec![],
1150 jwt_signing_key: Some(BASE64.encode([99u8; 32])),
1151 key_records: vec![],
1152 context_records: vec![],
1153 context_counter: 0,
1154 path_counters: vec![],
1155 subcontext_counters: vec![],
1156 acl_entries: vec![],
1157 acl_entries_full: vec![],
1158 seal: None,
1159 webvh_servers: vec![],
1160 webvh_dids: vec![],
1161 webvh_logs: vec![],
1162 config: BackupConfig {
1163 vta_did: Some("did:key:z6MkVTA".into()),
1164 vta_name: Some("Test VTA".into()),
1165 public_url: None,
1166 mediator_url: None,
1167 mediator_did: None,
1168 },
1169 audit_logs: vec![],
1170 imported_secrets: vec![],
1171 imported_kek_salt: None,
1172 keyspaces: vec![KeyspaceDump {
1173 name: vta_keyspaces::ACL.into(),
1174 rows: vec![(BASE64.encode("acl:did:key:zA"), BASE64.encode("{}"))],
1175 }],
1176 source_environment: Some(BackupEnvironment::Tee),
1177 internal_keys_not_carried: vec![],
1178 }
1179 }
1180
1181 fn test_config() -> vta_config::AppConfig {
1182 toml::from_str("").unwrap()
1183 }
1184
1185 fn encrypt_v1(payload: &BackupPayload, password: &str) -> BackupEnvelope {
1189 let plaintext = serde_json::to_vec(payload).unwrap();
1190 let salt = [5u8; SALT_LEN];
1191 let nonce_bytes = [6u8; NONCE_LEN];
1192 let key = derive_backup_key(password, &salt, ARGON2_M_COST, ARGON2_T_COST, ARGON2_P_COST)
1193 .unwrap();
1194 let cipher = Aes256Gcm::new_from_slice(key.as_ref()).unwrap();
1195 let ciphertext = cipher
1196 .encrypt((&nonce_bytes).into(), plaintext.as_ref())
1197 .unwrap();
1198 BackupEnvelope {
1199 version: 1,
1200 format: BACKUP_FORMAT_V1.into(),
1201 created_at: Utc::now(),
1202 source_did: payload.config.vta_did.clone(),
1203 source_version: "0.0.0".into(),
1204 kdf: KdfParams {
1205 algorithm: "argon2id".into(),
1206 salt: BASE64.encode(salt),
1207 m_cost: ARGON2_M_COST,
1208 t_cost: ARGON2_T_COST,
1209 p_cost: ARGON2_P_COST,
1210 },
1211 encryption: EncryptionParams {
1212 algorithm: "aes-256-gcm".into(),
1213 nonce: BASE64.encode(nonce_bytes),
1214 },
1215 includes_audit: false,
1216 ciphertext: BASE64.encode(ciphertext),
1217 }
1218 }
1219
1220 #[test]
1221 fn encrypt_decrypt_roundtrip() {
1222 let payload = v2_payload();
1223 let password = "test-password-12chars!";
1224 let envelope = encrypt_payload(&payload, password, false, &test_config()).unwrap();
1225
1226 assert_eq!(envelope.version, 2);
1227 assert_eq!(envelope.format, BACKUP_FORMAT_V2);
1228 assert_eq!(envelope.kdf.algorithm, "argon2id");
1229 assert_eq!(envelope.encryption.algorithm, "aes-256-gcm");
1230
1231 let decrypted = decrypt_backup(&envelope, password).unwrap();
1232 assert_eq!(decrypted.active_seed_hex, payload.active_seed_hex);
1233 assert_eq!(decrypted.jwt_signing_key, payload.jwt_signing_key);
1234 assert_eq!(decrypted.keyspaces.len(), 1);
1235 assert_eq!(decrypted.keyspaces[0].rows, payload.keyspaces[0].rows);
1236 assert_eq!(decrypted.source_environment, Some(BackupEnvironment::Tee));
1237 assert_eq!(decrypted.config.vta_did, Some("did:key:z6MkVTA".into()));
1238 }
1239
1240 #[test]
1242 fn a_v1_envelope_still_decrypts() {
1243 let mut payload = v2_payload();
1244 payload.keyspaces.clear();
1245 payload.acl_entries = vec![AclEntryBackup {
1246 did: "did:key:z6MkTest".into(),
1247 role: "Admin".into(),
1248 label: None,
1249 allowed_contexts: vec![],
1250 created_at: 1000,
1251 created_by: "did:key:z6MkSetup".into(),
1252 }];
1253 let env = encrypt_v1(&payload, "v1-password-12chars");
1254 let back = decrypt_backup(&env, "v1-password-12chars").unwrap();
1255 assert!(back.keyspaces.is_empty());
1256 assert_eq!(back.acl_entries.len(), 1);
1257 }
1258
1259 #[test]
1262 fn v2_envelope_metadata_is_authenticated() {
1263 let password = "test-password-12chars!";
1264 let mut env = encrypt_payload(&v2_payload(), password, false, &test_config()).unwrap();
1265 env.source_did = Some("did:key:z6MkSomeoneElse".into());
1266 assert!(decrypt_backup(&env, password).is_err());
1267
1268 let mut env = encrypt_payload(&v2_payload(), password, false, &test_config()).unwrap();
1269 env.includes_audit = true;
1270 assert!(decrypt_backup(&env, password).is_err());
1271 }
1272
1273 #[test]
1274 fn wrong_password_fails() {
1275 let envelope =
1276 encrypt_payload(&v2_payload(), "correct-password!!", false, &test_config()).unwrap();
1277 let err = decrypt_backup(&envelope, "wrong-password!!!").unwrap_err();
1278 assert!(
1279 format!("{err}").contains("incorrect backup password"),
1280 "expected auth error, got: {err}"
1281 );
1282 }
1283
1284 #[test]
1285 fn tampered_ciphertext_detected() {
1286 let password = "test-password-12chars!";
1287 let mut envelope = encrypt_payload(&v2_payload(), password, false, &test_config()).unwrap();
1288 let mut ct_bytes = BASE64.decode(&envelope.ciphertext).unwrap();
1289 if let Some(byte) = ct_bytes.last_mut() {
1290 *byte ^= 0xFF;
1291 }
1292 envelope.ciphertext = BASE64.encode(&ct_bytes);
1293 assert!(
1294 format!("{}", decrypt_backup(&envelope, password).unwrap_err())
1295 .contains("incorrect backup password"),
1296 "tampered ciphertext should fail AES-GCM auth"
1297 );
1298 }
1299
1300 #[test]
1301 fn unsupported_version_and_format_rejected() {
1302 let password = "test-password-12chars!";
1303 for (version, format) in [
1304 (99, BACKUP_FORMAT_V2),
1305 (2, "unknown-format"),
1306 (1, BACKUP_FORMAT_V2),
1308 (2, BACKUP_FORMAT_V1),
1309 ] {
1310 let mut envelope =
1311 encrypt_payload(&v2_payload(), password, false, &test_config()).unwrap();
1312 envelope.version = version;
1313 envelope.format = format.into();
1314 assert!(
1315 format!("{}", decrypt_backup(&envelope, password).unwrap_err())
1316 .contains("unsupported backup format"),
1317 "v{version} {format} must be refused"
1318 );
1319 }
1320 }
1321
1322 #[test]
1323 fn envelope_serialization_roundtrip() {
1324 let password = "test-password-12chars!";
1325 let envelope = encrypt_payload(&v2_payload(), password, true, &test_config()).unwrap();
1326 let json = serde_json::to_string_pretty(&envelope).unwrap();
1327 let deserialized: BackupEnvelope = serde_json::from_str(&json).unwrap();
1328 assert!(deserialized.includes_audit);
1329 assert_eq!(deserialized.ciphertext, envelope.ciphertext);
1330 let decrypted = decrypt_backup(&deserialized, password).unwrap();
1331 assert_eq!(decrypted.active_seed_hex, v2_payload().active_seed_hex);
1332 }
1333
1334 #[test]
1335 fn different_passwords_produce_different_ciphertexts() {
1336 let env1 =
1337 encrypt_payload(&v2_payload(), "password-one-12!!", false, &test_config()).unwrap();
1338 let env2 =
1339 encrypt_payload(&v2_payload(), "password-two-12!!", false, &test_config()).unwrap();
1340 assert_ne!(env1.kdf.salt, env2.kdf.salt);
1341 assert_ne!(env1.ciphertext, env2.ciphertext);
1342 }
1343
1344 #[test]
1350 fn a_payload_cannot_write_outside_the_backed_up_keyspaces() {
1351 for name in [
1352 vta_keyspaces::BOOTSTRAP,
1353 vta_keyspaces::INTERNAL_KEYS,
1354 "no_such",
1355 ] {
1356 let mut payload = v2_payload();
1357 payload.keyspaces.push(KeyspaceDump {
1358 name: name.into(),
1359 rows: vec![],
1360 });
1361 let err = validate_payload(&payload).unwrap_err();
1362 assert!(format!("{err}").contains(name), "{name}: {err}");
1363 }
1364 }
1365
1366 #[test]
1367 fn a_payload_cannot_carry_a_deployment_bound_row() {
1368 let mut payload = v2_payload();
1369 payload.keyspaces.push(KeyspaceDump {
1370 name: vta_keyspaces::KEYS.into(),
1371 rows: vec![(BASE64.encode("tee:vta_did"), BASE64.encode("did:x"))],
1372 });
1373 assert!(
1374 format!("{}", validate_payload(&payload).unwrap_err()).contains("deployment-bound")
1375 );
1376 }
1377
1378 #[test]
1379 fn a_payload_cannot_name_a_keyspace_twice() {
1380 let mut payload = v2_payload();
1381 payload.keyspaces.push(payload.keyspaces[0].clone());
1382 assert!(format!("{}", validate_payload(&payload).unwrap_err()).contains("twice"));
1383 }
1384
1385 #[test]
1386 fn payload_counts_read_raw_rows() {
1387 let mut payload = v2_payload();
1388 payload.keyspaces.push(KeyspaceDump {
1389 name: vta_keyspaces::KEYS.into(),
1390 rows: vec![
1391 (BASE64.encode("key:a"), BASE64.encode("{}")),
1392 (BASE64.encode("key:b"), BASE64.encode("{}")),
1393 (BASE64.encode("path_counter:x"), BASE64.encode("1")),
1394 ],
1395 });
1396 let counts = payload_counts(&payload);
1397 assert_eq!(counts.keys, 2);
1398 assert_eq!(counts.acls, 1);
1399 }
1400
1401 #[test]
1404 fn vta_did_guard_fresh_install_accepts_any_backup() {
1405 check_vta_did_compatibility(None, Some("did:key:z6MkAnything"), false)
1406 .expect("fresh install must accept any backup");
1407 check_vta_did_compatibility(None, None, false)
1408 .expect("fresh install accepts no-did backup");
1409 check_vta_did_compatibility(Some(""), Some("did:key:z6MkAnything"), false)
1410 .expect("empty-string vta_did counts as fresh install");
1411 }
1412
1413 #[test]
1414 fn vta_did_guard_matching_dids_accepted() {
1415 check_vta_did_compatibility(Some("did:key:z6MkSame"), Some("did:key:z6MkSame"), false)
1416 .expect("matching vta_did must pass");
1417 }
1418
1419 #[test]
1420 fn vta_did_guard_mismatch_rejected_with_the_fix() {
1421 let err = check_vta_did_compatibility(
1422 Some("did:key:z6MkRunning"),
1423 Some("did:key:z6MkForeignBackup"),
1424 false,
1425 )
1426 .expect_err("mismatched vta_did must be rejected");
1427 let msg = format!("{err}");
1428 assert!(msg.contains("vta_did mismatch"), "got: {msg}");
1429 assert!(
1430 msg.contains("z6MkForeignBackup"),
1431 "must name backup did: {msg}"
1432 );
1433 assert!(msg.contains("z6MkRunning"), "must name running did: {msg}");
1434 assert!(
1435 msg.contains("--replace-identity"),
1436 "must name the fix: {msg}"
1437 );
1438 }
1439
1440 #[test]
1443 fn vta_did_guard_yields_to_replace_identity() {
1444 check_vta_did_compatibility(
1445 Some("did:key:z6MkFreshlySetUp"),
1446 Some("did:key:z6MkTheOneWeLost"),
1447 true,
1448 )
1449 .expect("replace_identity must allow the swap");
1450 }
1451
1452 #[test]
1453 fn vta_did_guard_backup_missing_did_rejected_when_running_has_did() {
1454 let err = check_vta_did_compatibility(Some("did:key:z6MkRunning"), None, false)
1455 .expect_err("missing backup vta_did must be rejected when running has one");
1456 assert!(format!("{err}").contains("vta_did mismatch"), "got {err:?}");
1457 }
1458
1459 #[test]
1460 fn recompute_path_counters_skips_imported_keys_and_takes_max() {
1461 let recs = vec![
1462 mk_key_record("a", "m/26'/2'/0'/0'"),
1463 mk_key_record("b", "m/26'/2'/0'/3'"),
1464 mk_key_record("imported", ""),
1465 ];
1466 let counters = recompute_path_counters(&recs);
1467 assert_eq!(counters.get("m/26'/2'/0'"), Some(&4));
1468 assert_eq!(counters.len(), 1);
1469 }
1470
1471 pub(crate) fn mk_key_record(key_id: &str, derivation_path: &str) -> vta_sdk::keys::KeyRecord {
1472 use vta_sdk::keys::{KeyOrigin, KeyRecord, KeyStatus, KeyType};
1473 let now = Utc::now();
1474 KeyRecord {
1475 key_id: key_id.into(),
1476 derivation_path: derivation_path.into(),
1477 key_type: KeyType::Ed25519,
1478 status: KeyStatus::Active,
1479 public_key: "zPlaceholder".into(),
1480 label: None,
1481 context_id: None,
1482 seed_id: None,
1483 exportable: None,
1484 origin: KeyOrigin::Derived,
1485 created_at: now,
1486 updated_at: now,
1487 }
1488 }
1489
1490 fn make_envelope_with_kdf(m_cost: u32, t_cost: u32, p_cost: u32, alg: &str) -> BackupEnvelope {
1493 let mut env =
1497 encrypt_payload(&v2_payload(), "password-12!ok!a", false, &test_config()).unwrap();
1498 env.kdf.algorithm = alg.into();
1499 env.kdf.m_cost = m_cost;
1500 env.kdf.t_cost = t_cost;
1501 env.kdf.p_cost = p_cost;
1502 env
1503 }
1504
1505 #[test]
1506 fn kdf_m_cost_above_max_rejected() {
1507 let env = make_envelope_with_kdf(MAX_M_COST + 1, ARGON2_T_COST, ARGON2_P_COST, "argon2id");
1508 let err = decrypt_backup(&env, "anything").expect_err("must reject huge m_cost");
1509 assert!(format!("{err}").contains("m_cost"), "got {err:?}");
1510 }
1511
1512 #[test]
1513 fn kdf_m_cost_below_min_rejected() {
1514 let env = make_envelope_with_kdf(1, ARGON2_T_COST, ARGON2_P_COST, "argon2id");
1515 let err = decrypt_backup(&env, "anything").expect_err("must reject m_cost = 1");
1516 assert!(format!("{err}").contains("m_cost"), "got {err:?}");
1517 }
1518
1519 #[test]
1520 fn kdf_t_cost_zero_rejected() {
1521 let env = make_envelope_with_kdf(ARGON2_M_COST, 0, ARGON2_P_COST, "argon2id");
1522 let err = decrypt_backup(&env, "anything").expect_err("must reject t_cost = 0");
1523 assert!(format!("{err}").contains("t_cost"), "got {err:?}");
1524 }
1525
1526 #[test]
1527 fn kdf_p_cost_above_max_rejected() {
1528 let env = make_envelope_with_kdf(ARGON2_M_COST, ARGON2_T_COST, MAX_P_COST + 1, "argon2id");
1529 let err = decrypt_backup(&env, "anything").expect_err("must reject huge p_cost");
1530 assert!(format!("{err}").contains("p_cost"), "got {err:?}");
1531 }
1532
1533 #[test]
1534 fn kdf_unknown_algorithm_rejected() {
1535 let env =
1536 make_envelope_with_kdf(ARGON2_M_COST, ARGON2_T_COST, ARGON2_P_COST, "scrypt-custom");
1537 let err = decrypt_backup(&env, "anything").expect_err("must reject non-argon2id KDF");
1538 assert!(format!("{err}").contains("KDF algorithm"), "got {err:?}");
1539 }
1540
1541 #[test]
1549 fn nonce_wrong_length_rejected_without_panic() {
1550 let mut env =
1551 encrypt_payload(&v2_payload(), "password-12!ok!a", false, &test_config()).unwrap();
1552 env.encryption.nonce = BASE64.encode([0u8; 16]);
1553 let msg = format!(
1554 "{}",
1555 decrypt_backup(&env, "password-12!ok!a")
1556 .expect_err("wrong-length nonce must be rejected pre-decrypt")
1557 );
1558 assert!(
1559 msg.contains("nonce length"),
1560 "expected nonce-length error, got: {msg}"
1561 );
1562 }
1563
1564 #[test]
1565 fn salt_wrong_length_rejected_without_panic() {
1566 let mut env =
1567 encrypt_payload(&v2_payload(), "password-12!ok!a", false, &test_config()).unwrap();
1568 env.kdf.salt = BASE64.encode([0u8; 16]);
1569 let msg = format!(
1570 "{}",
1571 decrypt_backup(&env, "password-12!ok!a")
1572 .expect_err("wrong-length salt must be rejected pre-decrypt")
1573 );
1574 assert!(
1575 msg.contains("salt length"),
1576 "expected salt-length error, got: {msg}"
1577 );
1578 }
1579}