// file: crates/ksp-wallet-lib/unit_tests/security.rs // version: 3 //! Adversarial security canaries for native `.kspwallet` V1. use base64::Engine; // rust-rules: trait-import const FULL_VECTOR: &[u8] = include_bytes!("../tests/fixtures/kspwallet_v1_full_vector.json"); #[test] fn owner_signed_regions_reject_canonical_tampering_before_unlock() { for pointer in [ "/key_slots/0/kdf/salt", "/key_slots/0/wrap/ciphertext", "/owner_control/ciphertext", "/metadata/ciphertext", "/secret/ciphertext", "/state_signature/signature", ] { let tampered = tamper_base64url_field(FULL_VECTOR, pointer); let error = crate::inspect_locked_wallet_v1(tampered.as_slice()).expect_err("OWNER-signed canonical tampering must be rejected"); assert_eq!(error.code(), crate::ERROR_CODE_AUTHENTICATION_FAILED, "unexpected error code for tampered field {pointer}"); } } #[test] fn owner_signature_verification_precedes_owner_and_view_password_kdf() { let tampered = tamper_base64url_field(FULL_VECTOR, "/metadata/ciphertext"); let runtime = runtime(); let owner = runtime.block_on(crate::open_wallet_owner_v1(tampered.as_slice(), crate::OwnerPassword::new(std::string::String::new()))); assert_eq!(owner.expect_err("tampered state must fail before an empty OWNER password reaches Argon2").code(), crate::ERROR_CODE_AUTHENTICATION_FAILED); let view = runtime.block_on(crate::open_wallet_view_v1(tampered.as_slice(), crate::ViewPassword::new(std::string::String::new()))); assert_eq!(view.expect_err("tampered state must fail before an empty VIEW password reaches Argon2").code(), crate::ERROR_CODE_AUTHENTICATION_FAILED); } #[test] fn view_credential_tampering_does_not_forge_owner_state_and_cannot_unlock_view() { let tampered = tamper_base64url_field(FULL_VECTOR, "/key_slots/1/wrap/ciphertext"); let locked = crate::inspect_locked_wallet_v1(tampered.as_slice()).expect("VIEW wrapping credentials are intentionally outside the OWNER state transcript"); assert!(locked.view_enabled()); let runtime = runtime(); let owner = runtime.block_on(crate::open_wallet_owner_v1(tampered.as_slice(), crate::OwnerPassword::new(std::string::String::from("pre005-owner-password")))); assert!(owner.is_ok(), "VIEW credential tampering must not invalidate OWNER unlock"); let view = runtime.block_on(crate::open_wallet_view_v1(tampered.as_slice(), crate::ViewPassword::new(std::string::String::from("pre005-view-password")))); assert_eq!(view.expect_err("tampered VIEW wrapping must not unlock metadata").code(), crate::ERROR_CODE_VIEW_UNLOCK_FAILED); } #[test] fn unlock_failures_do_not_echo_password_material() { let owner_canary = "OWNER-PASSWORD-LEAK-CANARY"; let view_canary = "VIEW-PASSWORD-LEAK-CANARY"; let runtime = runtime(); let owner = runtime.block_on(crate::open_wallet_owner_v1(FULL_VECTOR, crate::OwnerPassword::new(std::string::String::from(owner_canary)))); let owner_error = owner.expect_err("wrong OWNER password must fail"); assert_eq!(owner_error.code(), crate::ERROR_CODE_OWNER_UNLOCK_FAILED); assert!(!owner_error.to_string().contains(owner_canary)); assert!(!format!("{owner_error:?}").contains(owner_canary)); let view = runtime.block_on(crate::open_wallet_view_v1(FULL_VECTOR, crate::ViewPassword::new(std::string::String::from(view_canary)))); let view_error = view.expect_err("wrong VIEW password must fail"); assert_eq!(view_error.code(), crate::ERROR_CODE_VIEW_UNLOCK_FAILED); assert!(!view_error.to_string().contains(view_canary)); assert!(!format!("{view_error:?}").contains(view_canary)); } fn tamper_base64url_field(source: &[u8], pointer: &str) -> std::vec::Vec { let mut value: serde_json::Value = serde_json::from_slice(source).expect("security fixture must be valid JSON"); let target = value.pointer_mut(pointer).expect("security fixture pointer must exist"); let encoded = target.as_str().expect("security fixture target must be a Base64url string"); let mut decoded = base64::engine::general_purpose::URL_SAFE_NO_PAD.decode(encoded.as_bytes()).expect("security fixture Base64url must decode"); let first = decoded.first_mut().expect("security fixture Base64url field must not be empty"); *first ^= 0x01; *target = serde_json::Value::String(base64::engine::general_purpose::URL_SAFE_NO_PAD.encode(decoded.as_slice())); zeroize::Zeroize::zeroize(decoded.as_mut_slice()); return serde_json::to_vec(&value).expect("tampered security fixture must serialize"); } fn runtime() -> tokio::runtime::Runtime { return tokio::runtime::Builder::new_current_thread().build().expect("security test runtime must be creatable"); }