// file: crates/ksp-wallet-lib/unit_tests/crypto.rs // version: 3 use base64::Engine; // rust-rules: trait-import const VECTOR: &[u8] = include_bytes!("../tests/fixtures/kspwallet_v1_crypto_vectors.json"); #[derive(serde::Deserialize)] struct CryptoVector { password_utf8: std::string::String, salt: std::string::String, memory_kib: u32, iterations: u32, parallelism: u32, derived_key: std::string::String, content_key: std::string::String, nonce: std::string::String, aad: std::string::String, wrapped_key: std::string::String, } #[derive(serde::Deserialize)] struct CryptoVectors { vector_version: u32, warning: std::string::String, argon2id_xchacha20poly1305_wrap: CryptoVector, } #[test] fn deterministic_argon2id_and_xchacha_wrap_vector_matches_external_canary() -> ksp_core_lib::Result<()> { let vectors_result = serde_json::from_slice::(VECTOR); let vectors = match vectors_result { std::result::Result::Ok(vectors) => vectors, std::result::Result::Err(_) => { return std::result::Result::Err(ksp_core_lib::Error::new(crate::ERROR_CODE_FORMAT_INVALID, "Wallet crypto test vector JSON is invalid")); }, }; assert_eq!(vectors.vector_version, 1); assert!(vectors.warning.contains("TEST ONLY")); let vector = vectors.argon2id_xchacha20poly1305_wrap; let salt = match decode(vector.salt.as_str()) { std::result::Result::Ok(value) => value, std::result::Result::Err(error) => return std::result::Result::Err(error), }; let expected_derived = match decode_fixed::<32>(vector.derived_key.as_str()) { std::result::Result::Ok(value) => value, std::result::Result::Err(error) => return std::result::Result::Err(error), }; let content_key = match decode_fixed::<32>(vector.content_key.as_str()) { std::result::Result::Ok(value) => value, std::result::Result::Err(error) => return std::result::Result::Err(error), }; let nonce = match decode_fixed::<{ crate::KSPWALLET_V1_XCHACHA_NONCE_BYTES }>(vector.nonce.as_str()) { std::result::Result::Ok(value) => value, std::result::Result::Err(error) => return std::result::Result::Err(error), }; let aad = match decode(vector.aad.as_str()) { std::result::Result::Ok(value) => value, std::result::Result::Err(error) => return std::result::Result::Err(error), }; let expected_wrapped = match decode(vector.wrapped_key.as_str()) { std::result::Result::Ok(value) => value, std::result::Result::Err(error) => return std::result::Result::Err(error), }; let derived_result = super::derive_argon2id(vector.password_utf8.as_bytes(), salt.as_slice(), vector.memory_kib, vector.iterations, vector.parallelism); let derived = match derived_result { std::result::Result::Ok(value) => value, std::result::Result::Err(error) => return std::result::Result::Err(error), }; assert_eq!(derived.as_bytes(), &expected_derived); let content = crate::SecretKeyV1::from_bytes(content_key); let wrapped_result = crate::wrap_key(&derived, &content, &nonce, aad.as_slice()); let wrapped = match wrapped_result { std::result::Result::Ok(value) => value, std::result::Result::Err(error) => return std::result::Result::Err(error), }; assert_eq!(wrapped, expected_wrapped); let unwrapped_result = crate::unwrap_key(&derived, &nonce, aad.as_slice(), wrapped.as_slice()); let unwrapped = match unwrapped_result { std::result::Result::Ok(value) => value, std::result::Result::Err(error) => return std::result::Result::Err(error), }; assert_eq!(unwrapped.as_bytes(), content.as_bytes()); return std::result::Result::Ok(()); } #[test] fn xchacha_tampering_is_reported_as_generic_authentication_failure() -> ksp_core_lib::Result<()> { let key = crate::SecretKeyV1::from_bytes([0x11_u8; 32]); let nonce = [0x22_u8; crate::KSPWALLET_V1_XCHACHA_NONCE_BYTES]; let plaintext = [0x33_u8; 32]; let encrypted_result = crate::encrypt_bytes(&key, &nonce, b"kspwallet-test-aad", plaintext.as_slice()); let mut encrypted = match encrypted_result { std::result::Result::Ok(value) => value, std::result::Result::Err(error) => return std::result::Result::Err(error), }; encrypted[0] ^= 1; let result = crate::decrypt_bytes(&key, &nonce, b"kspwallet-test-aad", encrypted.as_slice()); assert!(result.is_err()); let error = match result { std::result::Result::Err(error) => error, std::result::Result::Ok(_) => return std::result::Result::Ok(()), }; assert_eq!(error.code(), crate::ERROR_CODE_AUTHENTICATION_FAILED); return std::result::Result::Ok(()); } #[test] fn secret_key_debug_is_redacted_and_random_sources_are_callable() -> ksp_core_lib::Result<()> { let key_result = crate::SecretKeyV1::random(); let key = match key_result { std::result::Result::Ok(value) => value, std::result::Result::Err(error) => return std::result::Result::Err(error), }; let nonce_result = crate::random_nonce(); let nonce = match nonce_result { std::result::Result::Ok(value) => value, std::result::Result::Err(error) => return std::result::Result::Err(error), }; let rendered = format!("{key:?}"); assert_eq!(rendered, "SecretKeyV1()"); assert_eq!(nonce.len(), crate::KSPWALLET_V1_XCHACHA_NONCE_BYTES); return std::result::Result::Ok(()); } #[test] #[ignore = "operator-only Argon2 calibration; run single-threaded with --nocapture"] fn benchmark_argon2_creation_candidates() -> ksp_core_lib::Result<()> { let password = b"KSPWALLET-BENCHMARK-TEST-ONLY-PASSWORD"; let salt = [0x5a_u8; 16]; let candidates = [(65_536_u32, 3_u32, 1_u32), (131_072_u32, 3_u32, 1_u32), (262_144_u32, 3_u32, 1_u32)]; for candidate in candidates { let started = std::time::Instant::now(); let result = super::derive_argon2id(password, salt.as_slice(), candidate.0, candidate.1, candidate.2); match result { std::result::Result::Ok(_) => {}, std::result::Result::Err(error) => return std::result::Result::Err(error), } println!( "KSP Argon2id candidate memory_kib={} iterations={} parallelism={} elapsed_ms={}", candidate.0, candidate.1, candidate.2, started.elapsed().as_millis() ); } return std::result::Result::Ok(()); } fn decode(value: &str) -> ksp_core_lib::Result> { let decoded = base64::engine::general_purpose::URL_SAFE_NO_PAD.decode(value.as_bytes()); return match decoded { std::result::Result::Ok(value) => std::result::Result::Ok(value), std::result::Result::Err(_) => { std::result::Result::Err(ksp_core_lib::Error::new(crate::ERROR_CODE_FORMAT_INVALID, "Wallet crypto test vector Base64url is invalid")) }, }; } fn decode_fixed(value: &str) -> ksp_core_lib::Result<[u8; LENGTH]> { let decoded = match decode(value) { std::result::Result::Ok(value) => value, std::result::Result::Err(error) => return std::result::Result::Err(error), }; let converted = <[u8; LENGTH]>::try_from(decoded.as_slice()); return match converted { std::result::Result::Ok(value) => std::result::Result::Ok(value), std::result::Result::Err(_) => { std::result::Result::Err(ksp_core_lib::Error::new(crate::ERROR_CODE_FORMAT_INVALID, "Wallet crypto test vector has an invalid fixed-size field")) }, }; }