From 486a3f61c0a9bea13ba50fd71910c99fb5c11f3c Mon Sep 17 00:00:00 2001 From: Anthony Ronning <101225832+AnthonyRonning@users.noreply.github.com> Date: Tue, 14 Jul 2026 18:37:13 +0000 Subject: [PATCH 1/2] test: add crypto property invariants --- Cargo.lock | 32 ++++++ Cargo.toml | 1 + src/crypto_property_tests.rs | 196 +++++++++++++++++++++++++++++++++++ src/main.rs | 2 + 4 files changed, 231 insertions(+) create mode 100644 src/crypto_property_tests.rs diff --git a/Cargo.lock b/Cargo.lock index 5c05d5da..0d860951 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -2520,6 +2520,7 @@ dependencies = [ "once_cell", "openssl", "password-auth", + "proptest", "rand_core", "rcgen", "regex", @@ -2788,6 +2789,22 @@ dependencies = [ "unicode-ident", ] +[[package]] +name = "proptest" +version = "1.6.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "14cae93065090804185d3b75f0bf93b8eeda30c7a9b4a33d3bdb3988d6229e50" +dependencies = [ + "bitflags 2.6.0", + "lazy_static", + "num-traits", + "rand", + "rand_chacha", + "rand_xorshift", + "regex-syntax", + "unarray", +] + [[package]] name = "quanta" version = "0.12.3" @@ -2859,6 +2876,15 @@ dependencies = [ "getrandom 0.2.15", ] +[[package]] +name = "rand_xorshift" +version = "0.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d25bf25ec5ae4a3f1b92f929810509a2f53d7dca2f50b794ff57e3face536c8f" +dependencies = [ + "rand_core", +] + [[package]] name = "raw-cpuid" version = "11.1.0" @@ -3858,6 +3884,12 @@ version = "1.17.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "42ff0bf0c66b8238c6f3b578df37d0b7848e55df8577b3f74f92a69acceeb825" +[[package]] +name = "unarray" +version = "0.1.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "eaea85b334db583fe3274d12b4cd1880032beab409c0d774be044d4480ab9a94" + [[package]] name = "unicode-ident" version = "1.0.19" diff --git a/Cargo.toml b/Cargo.toml index 3f4ffe80..93a1b53d 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -78,3 +78,4 @@ once_cell = "1.19" [dev-dependencies] openssl = "0.10.78" +proptest = { version = "=1.6.0", default-features = false, features = ["std"] } diff --git a/src/crypto_property_tests.rs b/src/crypto_property_tests.rs new file mode 100644 index 00000000..a3d03866 --- /dev/null +++ b/src/crypto_property_tests.rs @@ -0,0 +1,196 @@ +use proptest::{collection::vec, prelude::*, test_runner::Config}; +use uuid::Uuid; + +use crate::{ + encrypt::{decrypt_aead_v1, encrypt_aead_v1, CanonicalBytes}, + seed_wrapping::{ + decrypt_seed_v1, encrypt_seed_v1, normalize_email_login_identifier, AuthBinding, + CredentialKind, + }, +}; + +const PROPERTY_CASES: u32 = 64; + +fn property_config() -> Config { + Config { + cases: PROPERTY_CASES, + max_shrink_iters: 512, + ..Config::default() + } +} + +fn credential_kind() -> impl Strategy { + prop_oneof![Just(CredentialKind::Password), Just(CredentialKind::OAuth)] +} + +proptest! { + #![proptest_config(property_config())] + + #[test] + fn aead_round_trips_arbitrary_plaintext_and_aad( + key in any::<[u8; 32]>(), + plaintext in vec(any::(), 0..513), + aad in vec(any::(), 0..129), + ) { + let encrypted = encrypt_aead_v1(&key, &plaintext, &aad) + .expect("bounded plaintext should encrypt"); + let decrypted = decrypt_aead_v1(&key, &encrypted, &aad) + .expect("matching key and AAD should decrypt"); + + prop_assert_eq!(decrypted, plaintext); + } + + #[test] + fn aead_rejects_any_single_bit_ciphertext_tamper( + key in any::<[u8; 32]>(), + plaintext in vec(any::(), 0..513), + aad in vec(any::(), 0..129), + offset in any::(), + bit in 0u8..8, + ) { + let mut encrypted = encrypt_aead_v1(&key, &plaintext, &aad) + .expect("bounded plaintext should encrypt"); + let tampered_offset = offset % encrypted.len(); + encrypted[tampered_offset] ^= 1 << bit; + + prop_assert!(decrypt_aead_v1(&key, &encrypted, &aad).is_err()); + } + + #[test] + fn aead_rejects_changed_aad( + key in any::<[u8; 32]>(), + plaintext in vec(any::(), 0..513), + aad in vec(any::(), 0..129), + ) { + let encrypted = encrypt_aead_v1(&key, &plaintext, &aad) + .expect("bounded plaintext should encrypt"); + let mut changed_aad = aad.clone(); + changed_aad.push(0); + + prop_assert!(decrypt_aead_v1(&key, &encrypted, &changed_aad).is_err()); + } + + #[test] + fn canonical_bytes_unambiguously_frame_field_boundaries( + prefix in vec(any::(), 0..65), + moved_bytes in vec(any::(), 1..65), + suffix in vec(any::(), 0..65), + ) { + let mut left_first_field = prefix.clone(); + left_first_field.extend_from_slice(&moved_bytes); + let mut right_second_field = moved_bytes; + right_second_field.extend_from_slice(&suffix); + + let mut left = CanonicalBytes::new("property-test-domain"); + left.append_bytes(&left_first_field).append_bytes(&suffix); + + let mut right = CanonicalBytes::new("property-test-domain"); + right.append_bytes(&prefix).append_bytes(&right_second_field); + + // Both logical sequences contain identical concatenated payload bytes; + // only their field boundaries differ. + prop_assert_ne!(left.into_bytes(), right.into_bytes()); + } + + #[test] + fn seed_wrap_round_trips_and_rejects_changed_context( + root_key in any::<[u8; 32]>(), + plaintext_seed in vec(any::(), 0..257), + user_id in any::(), + project_id in any::(), + kind in credential_kind(), + auth_binding_bytes in any::<[u8; 32]>(), + ) { + let user_uuid = Uuid::from_u128(user_id); + let auth_binding = AuthBinding::from_bytes(auth_binding_bytes); + let encrypted = encrypt_seed_v1( + &root_key, + &plaintext_seed, + user_uuid, + project_id, + kind, + &auth_binding, + ) + .expect("bounded seed should encrypt"); + + let decrypted = decrypt_seed_v1( + &root_key, + &encrypted, + user_uuid, + project_id, + kind, + &auth_binding, + ) + .expect("matching seed-wrap context should decrypt"); + prop_assert_eq!(decrypted, plaintext_seed); + + let changed_user_uuid = Uuid::from_u128(user_id ^ 1); + prop_assert!(decrypt_seed_v1( + &root_key, + &encrypted, + changed_user_uuid, + project_id, + kind, + &auth_binding, + ) + .is_err()); + + prop_assert!(decrypt_seed_v1( + &root_key, + &encrypted, + user_uuid, + project_id.wrapping_add(1), + kind, + &auth_binding, + ) + .is_err()); + + let changed_kind = match kind { + CredentialKind::Password => CredentialKind::OAuth, + CredentialKind::OAuth => CredentialKind::Password, + }; + prop_assert!(decrypt_seed_v1( + &root_key, + &encrypted, + user_uuid, + project_id, + changed_kind, + &auth_binding, + ) + .is_err()); + + let mut changed_auth_binding_bytes = auth_binding_bytes; + changed_auth_binding_bytes[0] ^= 1; + let changed_auth_binding = AuthBinding::from_bytes(changed_auth_binding_bytes); + prop_assert!(decrypt_seed_v1( + &root_key, + &encrypted, + user_uuid, + project_id, + kind, + &changed_auth_binding, + ) + .is_err()); + + let mut changed_root_key = root_key; + changed_root_key[0] ^= 1; + prop_assert!(decrypt_seed_v1( + &changed_root_key, + &encrypted, + user_uuid, + project_id, + kind, + &auth_binding, + ) + .is_err()); + } + + #[test] + fn email_normalization_is_idempotent( + email in vec(any::(), 0..129).prop_map(|chars| chars.into_iter().collect::()), + ) { + let normalized = normalize_email_login_identifier(&email); + + prop_assert_eq!(normalize_email_login_identifier(&normalized), normalized); + } +} diff --git a/src/main.rs b/src/main.rs index 86cc5ff6..e326c900 100644 --- a/src/main.rs +++ b/src/main.rs @@ -84,6 +84,8 @@ mod apple_signin; mod aws_credentials; mod billing; mod brave; +#[cfg(test)] +mod crypto_property_tests; mod db; mod email; mod encrypt; From c32bbca2a6dbf2c0b0b58bf2d2722c78a4c88f8d Mon Sep 17 00:00:00 2001 From: Anthony Ronning <101225832+AnthonyRonning@users.noreply.github.com> Date: Tue, 14 Jul 2026 18:50:13 +0000 Subject: [PATCH 2/2] test: generate alternate crypto contexts --- src/crypto_property_tests.rs | 13 +++++++------ 1 file changed, 7 insertions(+), 6 deletions(-) diff --git a/src/crypto_property_tests.rs b/src/crypto_property_tests.rs index a3d03866..581bbca6 100644 --- a/src/crypto_property_tests.rs +++ b/src/crypto_property_tests.rs @@ -95,12 +95,17 @@ proptest! { #[test] fn seed_wrap_round_trips_and_rejects_changed_context( root_key in any::<[u8; 32]>(), + alternate_root_key in any::<[u8; 32]>(), plaintext_seed in vec(any::(), 0..257), user_id in any::(), project_id in any::(), kind in credential_kind(), auth_binding_bytes in any::<[u8; 32]>(), + alternate_auth_binding_bytes in any::<[u8; 32]>(), ) { + prop_assume!(alternate_root_key != root_key); + prop_assume!(alternate_auth_binding_bytes != auth_binding_bytes); + let user_uuid = Uuid::from_u128(user_id); let auth_binding = AuthBinding::from_bytes(auth_binding_bytes); let encrypted = encrypt_seed_v1( @@ -159,9 +164,7 @@ proptest! { ) .is_err()); - let mut changed_auth_binding_bytes = auth_binding_bytes; - changed_auth_binding_bytes[0] ^= 1; - let changed_auth_binding = AuthBinding::from_bytes(changed_auth_binding_bytes); + let changed_auth_binding = AuthBinding::from_bytes(alternate_auth_binding_bytes); prop_assert!(decrypt_seed_v1( &root_key, &encrypted, @@ -172,10 +175,8 @@ proptest! { ) .is_err()); - let mut changed_root_key = root_key; - changed_root_key[0] ^= 1; prop_assert!(decrypt_seed_v1( - &changed_root_key, + &alternate_root_key, &encrypted, user_uuid, project_id,