diff --git a/stellar/Cargo.lock b/stellar/Cargo.lock index de3ffd99..2d5c35f8 100644 --- a/stellar/Cargo.lock +++ b/stellar/Cargo.lock @@ -1611,6 +1611,7 @@ checksum = "a2eb9349b6444b326872e140eb1cf5e7c522154d69e7a0ffb0fb81c06b37543f" name = "stealth-announcer" version = "0.1.0" dependencies = [ + "curve25519-dalek", "proptest", "soroban-sdk", ] diff --git a/stellar/bench-crossover/src/crossover.rs b/stellar/bench-crossover/src/crossover.rs index f8efe568..6f47ad3a 100644 --- a/stellar/bench-crossover/src/crossover.rs +++ b/stellar/bench-crossover/src/crossover.rs @@ -14,6 +14,17 @@ use stealth_sender::{StealthSenderContract, StealthSenderContractClient}; pub const CROSSOVER_SIZES: [u32; 6] = [1, 2, 5, 10, 15, 20]; +/// Fill bytes whose `[fill; 32]` encoding is a canonical, on-curve, torsion-free +/// ed25519 public key, so the announcer's validation accepts the generated +/// crossover inputs. +const VALID_EPK_FILLS: [u8; 14] = [ + 0x06, 0x19, 0x1a, 0x1d, 0x35, 0x4c, 0x7b, 0x81, 0x83, 0x8f, 0x97, 0xa7, 0xd6, 0xe2, +]; + +fn valid_epk_fill(seed: usize) -> u8 { + VALID_EPK_FILLS[seed % VALID_EPK_FILLS.len()] +} + #[derive(Clone, Debug)] pub struct CrossoverRow { pub n: u32, @@ -86,7 +97,7 @@ fn measure_individual(n: u32) -> Measured { &100, &2, &Address::generate(&env), - &BytesN::from_array(&env, &[(i as u8).wrapping_add(1); 32]), + &BytesN::from_array(&env, &[valid_epk_fill(i as usize); 32]), &bytes(&env, 32, (i as u8).wrapping_add(1)), ); total_wall_ns += start.elapsed().as_nanos(); @@ -116,7 +127,7 @@ fn measure_batch(n: u32) -> Measured { for i in 0..n { transfers.push_back(Transfer { stealth_address: Address::generate(&env), - ephemeral_pub_key: bytes(&env, 32, 0x02u8.wrapping_add(i as u8)), + ephemeral_pub_key: bytes(&env, 32, valid_epk_fill(i as usize)), amount: 100, metadata: bytes(&env, 1, (i as u8).wrapping_add(1)), }); diff --git a/stellar/bench/src/lib.rs b/stellar/bench/src/lib.rs index 44de12c7..f6fb5588 100644 --- a/stellar/bench/src/lib.rs +++ b/stellar/bench/src/lib.rs @@ -16,6 +16,17 @@ use stealth_splitter::{Beneficiary, StealthSplitterContract, StealthSplitterCont use stealth_vault::{StealthVaultContract, StealthVaultContractClient}; use wraith_names::{WraithNamesContract, WraithNamesContractClient}; +/// Fill bytes whose `[fill; 32]` encoding is a canonical, on-curve, torsion-free +/// ed25519 public key, so the announcer's validation accepts the generated +/// harness inputs. +const VALID_EPK_FILLS: [u8; 14] = [ + 0x06, 0x19, 0x1a, 0x1d, 0x35, 0x4c, 0x7b, 0x81, 0x83, 0x8f, 0x97, 0xa7, 0xd6, 0xe2, +]; + +fn valid_epk_fill(seed: usize) -> u8 { + VALID_EPK_FILLS[seed % VALID_EPK_FILLS.len()] +} + #[derive(Clone, Debug)] pub struct Row { pub contract: &'static str, @@ -52,7 +63,7 @@ pub fn collect_rows() -> std::vec::Vec { client.announce( &STELLAR_V2_SCHEME_ID, &Address::generate(env), - &BytesN::from_array(env, &[7u8; 32]), + &BytesN::from_array(env, &[0x06u8; 32]), &bytes(env, metadata_len, 9), ); }, @@ -104,7 +115,7 @@ pub fn collect_rows() -> std::vec::Vec { &100, &STELLAR_V2_SCHEME_ID, &Address::generate(env), - &BytesN::from_array(env, &[3u8; 32]), + &BytesN::from_array(env, &[0x19u8; 32]), &bytes(env, 32, 4), ); }, @@ -130,7 +141,7 @@ pub fn collect_rows() -> std::vec::Vec { let mut amounts: SorobanVec = vec![env]; for i in 0..batch_size { addresses.push_back(Address::generate(env)); - keys.push_back(BytesN::from_array(env, &[i as u8; 32])); + keys.push_back(BytesN::from_array(env, &[valid_epk_fill(i as usize); 32])); metadatas.push_back(bytes(env, 32, i as u8)); amounts.push_back(100); } @@ -304,7 +315,7 @@ pub fn collect_rows() -> std::vec::Vec { let mut metadatas: SorobanVec = vec![env]; for i in 0..n { addresses.push_back(Address::generate(env)); - keys.push_back(BytesN::from_array(env, &[i as u8; 32])); + keys.push_back(BytesN::from_array(env, &[valid_epk_fill(i as usize); 32])); metadatas.push_back(bytes(env, 32, i as u8)); } client.fund_split( @@ -335,7 +346,7 @@ pub fn collect_rows() -> std::vec::Vec { &token, &VAULT_UNLOCK_LEDGER, &VAULT_REFUND_AFTER, - &BytesN::from_array(env, &[11u8; 32]), + &BytesN::from_array(env, &[0x1au8; 32]), ); }, )); @@ -355,7 +366,7 @@ pub fn collect_rows() -> std::vec::Vec { &token, &VAULT_UNLOCK_LEDGER, &VAULT_REFUND_AFTER, - &BytesN::from_array(env, &[12u8; 32]), + &BytesN::from_array(env, &[0x1du8; 32]), ); env.ledger() .with_mut(|li| li.sequence_number = VAULT_UNLOCK_LEDGER); @@ -377,7 +388,7 @@ pub fn collect_rows() -> std::vec::Vec { &token, &VAULT_UNLOCK_LEDGER, &VAULT_REFUND_AFTER, - &BytesN::from_array(env, &[13u8; 32]), + &BytesN::from_array(env, &[0x35u8; 32]), ); env.ledger() .with_mut(|li| li.sequence_number = VAULT_REFUND_AFTER); @@ -399,7 +410,7 @@ pub fn collect_rows() -> std::vec::Vec { &token, &VAULT_UNLOCK_LEDGER, &VAULT_REFUND_AFTER, - &BytesN::from_array(env, &[14u8; 32]), + &BytesN::from_array(env, &[0x4cu8; 32]), ); env.ledger().with_mut(|li| { li.sequence_number = VAULT_REFUND_AFTER + stealth_vault::DEFAULT_GRACE_PERIOD @@ -482,7 +493,7 @@ pub fn collect_rows() -> std::vec::Vec { for i in 0..count { transfers.push_back(Transfer { stealth_address: Address::generate(env), - ephemeral_pub_key: bytes(env, 32, i as u8), + ephemeral_pub_key: bytes(env, 32, valid_epk_fill(i as usize)), amount: 100, metadata: bytes(env, 32, i as u8), }); diff --git a/stellar/integration-tests/test_snapshots/announcer_announce_through_chaos.1.json b/stellar/integration-tests/test_snapshots/announcer_announce_through_chaos.1.json index a5d9aa78..b5a7fbf4 100644 --- a/stellar/integration-tests/test_snapshots/announcer_announce_through_chaos.1.json +++ b/stellar/integration-tests/test_snapshots/announcer_announce_through_chaos.1.json @@ -100,7 +100,7 @@ "address": "CAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAFCT4" }, { - "bytes": "0101010101010101010101010101010101010101010101010101010101010101" + "bytes": "0606060606060606060606060606060606060606060606060606060606060606" }, { "bytes": "2a07" diff --git a/stellar/integration-tests/test_snapshots/announcer_multiple_schemes_through_chaos.1.json b/stellar/integration-tests/test_snapshots/announcer_multiple_schemes_through_chaos.1.json index 7d9918cf..87f17531 100644 --- a/stellar/integration-tests/test_snapshots/announcer_multiple_schemes_through_chaos.1.json +++ b/stellar/integration-tests/test_snapshots/announcer_multiple_schemes_through_chaos.1.json @@ -101,7 +101,7 @@ "address": "CAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAFCT4" }, { - "bytes": "abababababababababababababababababababababababababababababababab" + "bytes": "1919191919191919191919191919191919191919191919191919191919191919" }, { "bytes": "02" diff --git a/stellar/integration-tests/test_snapshots/batch_sender_batch_send_through_chaos.1.json b/stellar/integration-tests/test_snapshots/batch_sender_batch_send_through_chaos.1.json index e640b868..380c1c99 100644 --- a/stellar/integration-tests/test_snapshots/batch_sender_batch_send_through_chaos.1.json +++ b/stellar/integration-tests/test_snapshots/batch_sender_batch_send_through_chaos.1.json @@ -83,7 +83,7 @@ "symbol": "ephemeral_pub_key" }, "val": { - "bytes": "0101010101010101010101010101010101010101010101010101010101010101" + "bytes": "0606060606060606060606060606060606060606060606060606060606060606" } }, { @@ -122,7 +122,7 @@ "symbol": "ephemeral_pub_key" }, "val": { - "bytes": "0202020202020202020202020202020202020202020202020202020202020202" + "bytes": "1919191919191919191919191919191919191919191919191919191919191919" } }, { @@ -161,7 +161,7 @@ "symbol": "ephemeral_pub_key" }, "val": { - "bytes": "0303030303030303030303030303030303030303030303030303030303030303" + "bytes": "1a1a1a1a1a1a1a1a1a1a1a1a1a1a1a1a1a1a1a1a1a1a1a1a1a1a1a1a1a1a1a1a" } }, { diff --git a/stellar/integration-tests/test_snapshots/sender_announcer_lifecycle_through_chaos.1.json b/stellar/integration-tests/test_snapshots/sender_announcer_lifecycle_through_chaos.1.json index 626e59ee..a56dbd2f 100644 --- a/stellar/integration-tests/test_snapshots/sender_announcer_lifecycle_through_chaos.1.json +++ b/stellar/integration-tests/test_snapshots/sender_announcer_lifecycle_through_chaos.1.json @@ -79,7 +79,7 @@ "address": "CAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAOLZM" }, { - "bytes": "ffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffff" + "bytes": "1a1a1a1a1a1a1a1a1a1a1a1a1a1a1a1a1a1a1a1a1a1a1a1a1a1a1a1a1a1a1a1a" }, { "bytes": "07" diff --git a/stellar/integration-tests/test_snapshots/sender_batch_send_through_chaos.1.json b/stellar/integration-tests/test_snapshots/sender_batch_send_through_chaos.1.json index 04a71f22..01befb97 100644 --- a/stellar/integration-tests/test_snapshots/sender_batch_send_through_chaos.1.json +++ b/stellar/integration-tests/test_snapshots/sender_batch_send_through_chaos.1.json @@ -85,7 +85,7 @@ "bytes": "0101010101010101010101010101010101010101010101010101010101010101" }, { - "bytes": "0202020202020202020202020202020202020202020202020202020202020202" + "bytes": "0303030303030303030303030303030303030303030303030303030303030303" } ] }, diff --git a/stellar/integration-tests/test_snapshots/splitter_create_and_fund_through_chaos.1.json b/stellar/integration-tests/test_snapshots/splitter_create_and_fund_through_chaos.1.json index 4deb8d0d..44261835 100644 --- a/stellar/integration-tests/test_snapshots/splitter_create_and_fund_through_chaos.1.json +++ b/stellar/integration-tests/test_snapshots/splitter_create_and_fund_through_chaos.1.json @@ -163,10 +163,10 @@ { "vec": [ { - "bytes": "0101010101010101010101010101010101010101010101010101010101010101" + "bytes": "0606060606060606060606060606060606060606060606060606060606060606" }, { - "bytes": "0202020202020202020202020202020202020202020202020202020202020202" + "bytes": "1919191919191919191919191919191919191919191919191919191919191919" } ] }, diff --git a/stellar/integration-tests/test_snapshots/test_conformance_announcer.1.json b/stellar/integration-tests/test_snapshots/test_conformance_announcer.1.json index 7175058f..9061115a 100644 --- a/stellar/integration-tests/test_snapshots/test_conformance_announcer.1.json +++ b/stellar/integration-tests/test_snapshots/test_conformance_announcer.1.json @@ -100,7 +100,7 @@ "address": "CAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAFCT4" }, { - "bytes": "0202020202020202020202020202020202020202020202020202020202020202" + "bytes": "0606060606060606060606060606060606060606060606060606060606060606" }, { "bytes": "0000000000000000000000000000000000000000000000000000000000000000" diff --git a/stellar/integration-tests/test_snapshots/topic_3_view_tag_filter_across_announcer_splitter_and_batch_sender.1.json b/stellar/integration-tests/test_snapshots/topic_3_view_tag_filter_across_announcer_splitter_and_batch_sender.1.json index c4e4f85c..e4089f97 100644 --- a/stellar/integration-tests/test_snapshots/topic_3_view_tag_filter_across_announcer_splitter_and_batch_sender.1.json +++ b/stellar/integration-tests/test_snapshots/topic_3_view_tag_filter_across_announcer_splitter_and_batch_sender.1.json @@ -165,10 +165,10 @@ { "vec": [ { - "bytes": "2121212121212121212121212121212121212121212121212121212121212121" + "bytes": "1a1a1a1a1a1a1a1a1a1a1a1a1a1a1a1a1a1a1a1a1a1a1a1a1a1a1a1a1a1a1a1a" }, { - "bytes": "2222222222222222222222222222222222222222222222222222222222222222" + "bytes": "1d1d1d1d1d1d1d1d1d1d1d1d1d1d1d1d1d1d1d1d1d1d1d1d1d1d1d1d1d1d1d1d" } ] }, @@ -270,7 +270,7 @@ "symbol": "ephemeral_pub_key" }, "val": { - "bytes": "3131313131313131313131313131313131313131313131313131313131313131" + "bytes": "0606060606060606060606060606060606060606060606060606060606060606" } }, { @@ -309,7 +309,7 @@ "symbol": "ephemeral_pub_key" }, "val": { - "bytes": "3232323232323232323232323232323232323232323232323232323232323232" + "bytes": "1919191919191919191919191919191919191919191919191919191919191919" } }, { @@ -1400,7 +1400,7 @@ "address": "CAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAYRE5" }, { - "bytes": "3131313131313131313131313131313131313131313131313131313131313131" + "bytes": "0606060606060606060606060606060606060606060606060606060606060606" }, { "bytes": "07" @@ -1471,7 +1471,7 @@ "address": "CAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA2ZMN" }, { - "bytes": "3232323232323232323232323232323232323232323232323232323232323232" + "bytes": "1919191919191919191919191919191919191919191919191919191919191919" }, { "bytes": "c8" diff --git a/stellar/integration-tests/tests/chaos.rs b/stellar/integration-tests/tests/chaos.rs index 4a7d8c41..8db878b9 100644 --- a/stellar/integration-tests/tests/chaos.rs +++ b/stellar/integration-tests/tests/chaos.rs @@ -112,7 +112,7 @@ fn announcer_announce_through_chaos() { let contract_id = env.register(StealthAnnouncerContract, ()); let client = StealthAnnouncerContractClient::new(&env, &contract_id); let stealth_address = Address::generate(&env); - let epk = bytes32(&env, &[1u8; 32]); + let epk = bytes32(&env, &[0x06u8; 32]); let metadata = bytes(&env, &[42u8, 7u8]); client.announce(&STELLAR_V2_SCHEME_ID, &stealth_address, &epk, &metadata); Ok::<_, harness::ChaosError>(()) @@ -133,7 +133,7 @@ fn announcer_multiple_schemes_through_chaos() { let contract_id = env.register(StealthAnnouncerContract, ()); let client = StealthAnnouncerContractClient::new(&env, &contract_id); let addr = Address::generate(&env); - let epk = bytes32(&env, &[0xab; 32]); + let epk = bytes32(&env, &[0x19u8; 32]); let meta = bytes(&env, &[0x01]); client.announce(&STELLAR_V2_SCHEME_ID, &addr, &epk, &meta); client.announce(&STELLAR_V2_SCHEME_ID, &addr, &epk, &bytes(&env, &[0x02])); @@ -244,7 +244,7 @@ fn sender_batch_send_through_chaos() { let stealth1 = Address::generate(&env); let stealth2 = Address::generate(&env); let epk1 = bytes32(&env, &[1u8; 32]); - let epk2 = bytes32(&env, &[2u8; 32]); + let epk2 = bytes32(&env, &[3u8; 32]); let meta1 = bytes(&env, &[10u8]); let meta2 = bytes(&env, &[20u8]); let addresses = soroban_sdk::vec![&env, stealth1.clone(), stealth2.clone()]; @@ -416,7 +416,7 @@ fn sender_announcer_lifecycle_through_chaos() { client.init(&announcer_id, &None, &None, &0, &admin); let (token, sender_addr) = funded_token(&env); let stealth = Address::generate(&env); - let epk = bytes32(&env, &[0xff; 32]); + let epk = bytes32(&env, &[0x1au8; 32]); let meta = bytes(&env, &[0x07]); client.send( &sender_addr, @@ -646,8 +646,8 @@ fn splitter_create_and_fund_through_chaos() { let stealth_addrs = soroban_sdk::vec![&env, stealth1.clone(), stealth2.clone()]; let epks = soroban_sdk::vec![ &env, - bytes32(&env, &[0x01u8; 32]), - bytes32(&env, &[0x02u8; 32]) + bytes32(&env, &[0x06u8; 32]), + bytes32(&env, &[0x19u8; 32]) ]; let metadatas = soroban_sdk::vec![&env, bytes(&env, &[0x01]), bytes(&env, &[0x02])]; @@ -722,19 +722,19 @@ fn batch_sender_batch_send_through_chaos() { let mut transfers = soroban_sdk::vec![&env]; transfers.push_back(Transfer { stealth_address: stealth1.clone(), - ephemeral_pub_key: bytes(&env, &[0x01u8; 32]), + ephemeral_pub_key: bytes(&env, &[0x06u8; 32]), amount: 100, metadata: bytes(&env, &[0x01]), }); transfers.push_back(Transfer { stealth_address: stealth2.clone(), - ephemeral_pub_key: bytes(&env, &[0x02u8; 32]), + ephemeral_pub_key: bytes(&env, &[0x19u8; 32]), amount: 200, metadata: bytes(&env, &[0x02]), }); transfers.push_back(Transfer { stealth_address: stealth3.clone(), - ephemeral_pub_key: bytes(&env, &[0x03u8; 32]), + ephemeral_pub_key: bytes(&env, &[0x1au8; 32]), amount: 300, metadata: bytes(&env, &[0x03]), }); diff --git a/stellar/integration-tests/tests/conformance.rs b/stellar/integration-tests/tests/conformance.rs index 60633483..8126d0f7 100644 --- a/stellar/integration-tests/tests/conformance.rs +++ b/stellar/integration-tests/tests/conformance.rs @@ -15,7 +15,7 @@ fn test_conformance_announcer() { let client = StealthAnnouncerContractClient::new(&env, &contract_id); let stealth_address = Address::generate(&env); - let ephemeral_pub_key = BytesN::from_array(&env, &[2; 32]); + let ephemeral_pub_key = BytesN::from_array(&env, &[0x06; 32]); let mut meta_arr = [0u8; 32]; meta_arr[0] = 0x00; // view tag let metadata = Bytes::from_array(&env, &meta_arr); @@ -48,6 +48,53 @@ fn test_conformance_announcer() { assert_eq!(d_metadata.get(0).unwrap(), 0x00); // verify view tag } +/// Cross-chain conformance fixture for the rejection path. +/// +/// The canonical announcement fixture above uses a valid key. These cases pin +/// the inputs the Stellar announcer must reject: the all-zero key and ed25519 +/// encodings that are non-canonical (`y >= p`) or off-curve. A rejected +/// announcement must not emit an event. +#[test] +#[should_panic(expected = "invalid ed25519 ephemeral public key")] +fn test_conformance_announcer_rejects_zero_ephemeral_pub_key() { + announce_with_raw_key(&[0u8; 32]); +} + +#[test] +#[should_panic(expected = "invalid ed25519 ephemeral public key")] +fn test_conformance_announcer_rejects_non_canonical_ephemeral_pub_key() { + announce_with_raw_key(&[0xffu8; 32]); +} + +#[test] +#[should_panic(expected = "invalid ed25519 ephemeral public key")] +fn test_conformance_announcer_rejects_off_curve_ephemeral_pub_key() { + announce_with_raw_key(&[0x02u8; 32]); +} + +/// A mixed-order point (`[0x01; 32]`) is on the curve and not small-order but +/// not in the prime-order subgroup, so it must be rejected as well. +#[test] +#[should_panic(expected = "invalid ed25519 ephemeral public key")] +fn test_conformance_announcer_rejects_mixed_order_ephemeral_pub_key() { + announce_with_raw_key(&[0x01u8; 32]); +} + +fn announce_with_raw_key(raw: &[u8; 32]) { + let env = Env::default(); + let contract_id = env.register(StealthAnnouncerContract, ()); + let client = StealthAnnouncerContractClient::new(&env, &contract_id); + + let mut meta_arr = [0u8; 32]; + meta_arr[0] = 0x00; + client.announce( + &2u32, + &Address::generate(&env), + &BytesN::from_array(&env, raw), + &Bytes::from_array(&env, &meta_arr), + ); +} + #[test] fn test_conformance_registry() { let env = Env::default(); diff --git a/stellar/integration-tests/tests/topic_filter.rs b/stellar/integration-tests/tests/topic_filter.rs index c29a6def..725ae4a5 100644 --- a/stellar/integration-tests/tests/topic_filter.rs +++ b/stellar/integration-tests/tests/topic_filter.rs @@ -108,14 +108,14 @@ fn topic_3_view_tag_filter_across_announcer_splitter_and_batch_sender() { announcer.announce( &STELLAR_V2_SCHEME_ID, &direct_match, - &bytes32(&env, 0x11), + &bytes32(&env, 0x06), &meta(&env, MATCH_TAG), ); harvest_announces(&env, &mut harvested); announcer.announce( &STELLAR_V2_SCHEME_ID, &direct_other, - &bytes32(&env, 0x12), + &bytes32(&env, 0x19), &meta(&env, OTHER_TAGS[0]), ); harvest_announces(&env, &mut harvested); @@ -149,7 +149,7 @@ fn topic_3_view_tag_filter_across_announcer_splitter_and_batch_sender() { &2_000, &STELLAR_V2_SCHEME_ID, &soroban_sdk::vec![&env, split_match.clone(), split_other.clone()], - &soroban_sdk::vec![&env, bytes32(&env, 0x21), bytes32(&env, 0x22)], + &soroban_sdk::vec![&env, bytes32(&env, 0x1a), bytes32(&env, 0x1d)], &soroban_sdk::vec![&env, meta(&env, MATCH_TAG), meta(&env, OTHER_TAGS[1])], ); harvest_announces(&env, &mut harvested); @@ -166,13 +166,13 @@ fn topic_3_view_tag_filter_across_announcer_splitter_and_batch_sender() { &env, Transfer { stealth_address: batch_match.clone(), - ephemeral_pub_key: bytes(&env, &[0x31u8; 32]), + ephemeral_pub_key: bytes(&env, &[0x06u8; 32]), amount: 100, metadata: meta(&env, MATCH_TAG), }, Transfer { stealth_address: batch_other.clone(), - ephemeral_pub_key: bytes(&env, &[0x32u8; 32]), + ephemeral_pub_key: bytes(&env, &[0x19u8; 32]), amount: 200, metadata: meta(&env, OTHER_TAGS[2]), }, diff --git a/stellar/stealth-announcer/Cargo.toml b/stellar/stealth-announcer/Cargo.toml index 777b8d32..d8f0c19f 100644 --- a/stellar/stealth-announcer/Cargo.toml +++ b/stellar/stealth-announcer/Cargo.toml @@ -11,6 +11,10 @@ testutils = ["soroban-sdk/testutils"] [dependencies] soroban-sdk = { workspace = true } +# Vetted curve arithmetic for ed25519 point validation. `default-features = +# false` keeps it `no_std`/allocation-free, as required for the wasm contract; +# only decompression and subgroup membership checks are used. +curve25519-dalek = { version = "4", default-features = false } [dev-dependencies] proptest = "1.6.0" diff --git a/stellar/stealth-announcer/README.md b/stellar/stealth-announcer/README.md index 39587598..a10ea35b 100644 --- a/stellar/stealth-announcer/README.md +++ b/stellar/stealth-announcer/README.md @@ -21,7 +21,7 @@ Emits a Stellar v2 stealth address announcement event. **Parameters:** - `scheme_id: u32` — Must be `2` for the v2 Stellar announcer deployment - `stealth_address: Address` — The one-time stealth address that received funds -- `ephemeral_pub_key: BytesN<32>` — The ephemeral public key used to derive the stealth address +- `ephemeral_pub_key: BytesN<32>` — The ephemeral public key used to derive the stealth address. It must be a canonical, on-curve ed25519 encoding that is torsion-free (in the prime-order subgroup) and not small-order; the all-zero key and invalid encodings are rejected. - `metadata: Bytes` — Non-empty metadata whose first byte is the view tag **Returns:** None @@ -42,6 +42,7 @@ The contract uses panics for validation (no custom error enum): |-----------|--------------| | `scheme_id != 2` | Assertion failure (v1 scheme rejected) | | Empty `metadata` | `metadata.get(0)` panic (view tag required) | +| Invalid `ephemeral_pub_key` | `invalid ed25519 ephemeral public key` (all-zero, non-canonical, off-curve, small-order, or mixed-order) | ## Event Topics @@ -83,5 +84,7 @@ Tests cover: - View tag bucket derivation from first metadata byte - Rejection of v1 scheme ID - Rejection of missing view tag (empty metadata) +- Rejection of the all-zero key and invalid ed25519 encodings (non-canonical, off-curve, small-order, mixed-order) +- Acceptance of canonical, on-curve, torsion-free ed25519 keys (RFC 8032 vectors) Error codes and panic-only coverage status are tracked in the [Stellar error catalog](../ERRORS.md#stealth-announcer). \ No newline at end of file diff --git a/stellar/stealth-announcer/audits/2026-05-gpt-5-3-codex.md b/stellar/stealth-announcer/audits/2026-05-gpt-5-3-codex.md index 5d02b2d3..d870484c 100644 --- a/stellar/stealth-announcer/audits/2026-05-gpt-5-3-codex.md +++ b/stellar/stealth-announcer/audits/2026-05-gpt-5-3-codex.md @@ -24,7 +24,7 @@ Testing added in `contracts/stellar/stealth-announcer/tests/audit.rs` originally | --- | --- | --- | --- | | WA-ANN-01 | Medium | Event `caller` payload is always the announcer contract, not the invoker | Open | | WA-ANN-02 | Low | Unbounded metadata can inflate event payloads and indexer workload | Open | -| WA-ANN-03 | Low | Invalid all-zero ephemeral public keys are accepted | Open | +| WA-ANN-03 | Low | Invalid all-zero ephemeral public keys are accepted | Fixed (v2) | | WA-ANN-04 | Informational | Unauthenticated CPI calls can emit indistinguishable announcements | Open | ## Findings @@ -69,6 +69,8 @@ This is Low because malformed announcements do not let an attacker steal funds a **Recommendation:** If the default Stellar scheme always uses a fixed curve/key encoding, validate supported `scheme_id` values and reject known-invalid encodings, at minimum the all-zero key. If full curve validation is intentionally left off-chain, document that the contract only enforces byte length and add client/indexer requirements to discard invalid keys before cryptographic processing. +**Remediation (v2):** Implemented on-chain. `announce` rejects the all-zero encoding, non-canonical encodings (`y >= p`), off-curve encodings, the eight small-order points, and mixed-order points that are outside the prime-order subgroup before emitting an event. The accepted set matches `libsodium`'s `crypto_core_ed25519_is_valid_point`, and point decompression and subgroup membership are delegated to the vetted `curve25519-dalek` implementation (`src/ed25519.rs`) rather than hand-rolled curve arithmetic. The original reproducer is retained as `wa_ann_03_zero_ephemeral_pub_key_is_rejected` (see `tests/audit.rs`), the review's mixed-order reproducer `[0x01; 32]` is covered by `wa_ann_03_mixed_order_ephemeral_pub_key_is_rejected`, and additional cases live in `tests/properties.rs` and cross-chain fixtures in `integration-tests/tests/conformance.rs`. + ### WA-ANN-04 — Unauthenticated CPI calls can emit indistinguishable announcements **Severity:** Informational @@ -94,7 +96,7 @@ The v2 event shape is: `view_tag_bucket` is derived stably as `metadata[0] as u32`, and `metadata_kind = 1` records that the first metadata byte is the view tag while the remaining bytes are scheme-specific metadata. This moves the indexed stealth-address-sized topic out of the topic set, replaces it with a compact recipient scan bucket, and removes the misleading v1 `caller` payload field from the v2 data tuple. -The v2 redesign resolves the WA-ANN-01 caller-attribution ambiguity for new deployments by no longer publishing the announcer contract address as a caller-equivalent payload. WA-ANN-02 and WA-ANN-03 remain operational/client-hardening considerations: v2 still accepts large metadata subject to Soroban resource limits, and `BytesN<32>` still does not prove that an ephemeral public key is curve-valid. WA-ANN-04 remains an intended permissionless-announcement property, but CPI-originated v2 events are no longer coupled to a misleading `caller` value. +The v2 redesign resolves the WA-ANN-01 caller-attribution ambiguity for new deployments by no longer publishing the announcer contract address as a caller-equivalent payload. The v2 redesign also resolves WA-ANN-03: `announce` now validates the ephemeral public key on-chain and rejects the all-zero key, non-canonical encodings, off-curve encodings, small-order points, and mixed-order points outside the prime-order subgroup. WA-ANN-02 remains an operational/client-hardening consideration: v2 still accepts large metadata subject to Soroban resource limits. WA-ANN-04 remains an intended permissionless-announcement property, but CPI-originated v2 events are no longer coupled to a misleading `caller` value. ## Additional observations diff --git a/stellar/stealth-announcer/src/ed25519.rs b/stellar/stealth-announcer/src/ed25519.rs new file mode 100644 index 00000000..8cdaff57 --- /dev/null +++ b/stellar/stealth-announcer/src/ed25519.rs @@ -0,0 +1,163 @@ +//! Validation of compressed ed25519 public-key encodings. +//! +//! The accepted set mirrors `libsodium`'s `crypto_core_ed25519_is_valid_point`: +//! +//! * the encoding must be canonical (`y < p`, and no non-canonical sign bit for +//! a point with `x == 0`); +//! * it must decompress to a point on the twisted Edwards curve +//! `-x^2 + y^2 = 1 + d * x^2 * y^2`; +//! * the point must be torsion-free (in the prime-order subgroup); and +//! * the point must not be in the small-order (order 1/2/4/8) subgroup. +//! +//! The final two checks are what separate a genuine ed25519 public key from a +//! mixed-order point such as the repeated `0x01` encoding, which is on the +//! curve and non-small-order but has a non-zero torsion component. Such a value +//! cannot have been produced by ed25519 key generation and must be rejected. +//! +//! Point decompression and subgroup membership are delegated to +//! `curve25519-dalek`, a vetted, `no_std` curve implementation, instead of +//! re-implementing the curve arithmetic in this contract. + +use curve25519_dalek::edwards::CompressedEdwardsY; + +/// Returns `true` when `bytes` is a canonical, on-curve, torsion-free, +/// non-small-order encoding of an ed25519 public key. +pub fn is_valid_ed25519_public_key(bytes: &[u8; 32]) -> bool { + let compressed = CompressedEdwardsY(*bytes); + + // Reject encodings that do not decompress to a curve point. + let Some(point) = compressed.decompress() else { + return false; + }; + + // Re-encoding a canonical encoding is the identity. This rejects non-canonical + // encodings (`y >= p`, or a sign bit set for a zero `x`), matching + // `ge25519_is_canonical`. + if point.compress() != compressed { + return false; + } + + // Reject the small-order subgroup (order 1/2/4/8) and anything with a + // non-zero torsion component, matching `ge25519_has_small_order` and + // `ge25519_is_on_main_subgroup` respectively. A torsion-free point is in + // the prime-order subgroup; `is_torsion_free` alone would still accept the + // identity, so the small-order check is kept. + !point.is_small_order() && point.is_torsion_free() +} + +#[cfg(test)] +mod test { + use super::*; + use curve25519_dalek::constants::{ED25519_BASEPOINT_POINT, EIGHT_TORSION}; + use curve25519_dalek::scalar::Scalar; + + fn bytes_from_hex(hex: &str) -> [u8; 32] { + let mut out = [0u8; 32]; + let raw = hex.as_bytes(); + let mut i = 0; + while i < 32 { + let hi = (raw[i * 2] as char).to_digit(16).unwrap() as u8; + let lo = (raw[i * 2 + 1] as char).to_digit(16).unwrap() as u8; + out[i] = (hi << 4) | lo; + i += 1; + } + out + } + + #[test] + fn accepts_rfc8032_public_keys() { + // RFC 8032 section 7.1 public keys. + let keys = [ + "d75a980182b10ab7d54bfed3c964073a0ee172f3daa62325af021a68f707511a", + "3d4017c3e843895a92b70aa74d1b7ebc9c982ccf2ec4968cc0cd55f12af4660c", + "fc51cd8e6218a1a38da47ed00230f0580816ed13ba3303ac5deb911548908025", + ]; + for key in keys { + assert!( + is_valid_ed25519_public_key(&bytes_from_hex(key)), + "RFC 8032 key {key} must be accepted" + ); + } + } + + #[test] + fn accepts_prime_order_points() { + // The basepoint and its small scalar multiples are all torsion-free. + let base = ED25519_BASEPOINT_POINT; + for k in [1u64, 2, 3, 8, 9, 42, 255, 256, u32::MAX as u64] { + let point = base * Scalar::from(k); + assert!( + is_valid_ed25519_public_key(point.compress().as_bytes()), + "scalar multiple {k} of the basepoint must be accepted" + ); + } + } + + #[test] + fn rejects_all_zero() { + assert!(!is_valid_ed25519_public_key(&[0u8; 32])); + } + + #[test] + fn rejects_small_order_points() { + for point in EIGHT_TORSION.iter() { + assert!( + !is_valid_ed25519_public_key(point.compress().as_bytes()), + "small-order point must be rejected" + ); + } + } + + /// Regression for the reviewer's reproducer: `[0x01; 32]` decompresses to an + /// on-curve, non-small-order point that is *not* in the prime-order subgroup. + #[test] + fn rejects_mixed_order_points() { + // Reproduce the reviewer's differential: `[0x01; 32]` decompresses to + // an on-curve, non-small-order point that is *not* torsion-free. + let repro = CompressedEdwardsY([0x01; 32]); + let point = repro.decompress().expect("[0x01; 32] must decompress"); + assert!(!point.is_small_order()); + assert!(!point.is_torsion_free()); + assert!( + !is_valid_ed25519_public_key(&[0x01; 32]), + "[0x01; 32] is mixed-order and must be rejected" + ); + + // Basepoint + non-identity torsion is on the curve but not torsion-free. + for torsion in EIGHT_TORSION.iter().skip(1) { + let mixed = ED25519_BASEPOINT_POINT + torsion; + assert!( + !is_valid_ed25519_public_key(mixed.compress().as_bytes()), + "basepoint + torsion must be rejected" + ); + } + } + + #[test] + fn rejects_non_canonical_encodings() { + // 0xff * 32 clears the sign bit to 2^255 - 1, which is >= p. + assert!(!is_valid_ed25519_public_key(&[0xffu8; 32])); + // Exactly p, little-endian. + assert!(!is_valid_ed25519_public_key(&bytes_from_hex( + "edffffffffffffffffffffffffffffffffffffffffffffffffffffffffffff7f" + ))); + } + + #[test] + fn rejects_off_curve_encodings() { + let keys = [ + "4f611d9cb8886f044449d848bbf6d4ddd34536cb117175af43bf53a76b5f5532", + "7edd466be23f782d7eb7d8510b3bcb623a7f029c0ac83683140a04e71d54ad25", + "44fa0c5f362a18260424583095cd9903e29f6fbabd97a7a877b4850c4be3baa4", + ]; + for key in keys { + assert!( + !is_valid_ed25519_public_key(&bytes_from_hex(key)), + "off-curve key {key} must be rejected" + ); + } + // `[2u8; 32]` and `[4u8; 32]` are the placeholders the old fixtures used. + assert!(!is_valid_ed25519_public_key(&[2u8; 32])); + assert!(!is_valid_ed25519_public_key(&[4u8; 32])); + } +} diff --git a/stellar/stealth-announcer/src/lib.rs b/stellar/stealth-announcer/src/lib.rs index 074e8dd5..adf2d802 100644 --- a/stellar/stealth-announcer/src/lib.rs +++ b/stellar/stealth-announcer/src/lib.rs @@ -1,5 +1,7 @@ #![no_std] +mod ed25519; + use soroban_sdk::{contract, contractimpl, symbol_short, Address, Bytes, BytesN, Env}; /// Stellar v2 deployment scheme id. @@ -69,6 +71,10 @@ impl StealthAnnouncerContract { metadata: Bytes, ) { assert_eq!(scheme_id, STELLAR_V2_SCHEME_ID); + assert!( + ed25519::is_valid_ed25519_public_key(&ephemeral_pub_key.to_array()), + "invalid ed25519 ephemeral public key" + ); let view_tag_bucket = view_tag_bucket(&metadata); let metadata_kind = METADATA_KIND_VIEW_TAG; @@ -98,7 +104,7 @@ mod test { let client = StealthAnnouncerContractClient::new(&env, &contract_id); let stealth_address = Address::generate(&env); - let ephemeral_pub_key = BytesN::from_array(&env, &[1u8; 32]); + let ephemeral_pub_key = BytesN::from_array(&env, &[0x06u8; 32]); let metadata = Bytes::from_slice(&env, &[42u8, 7u8]); let scheme_id: u32 = STELLAR_V2_SCHEME_ID; @@ -134,7 +140,7 @@ mod test { let client = StealthAnnouncerContractClient::new(&env, &contract_id); let addr = Address::generate(&env); - let epk = BytesN::from_array(&env, &[1u8; 32]); + let epk = BytesN::from_array(&env, &[0x06u8; 32]); let first_meta = Bytes::from_slice(&env, &[0u8, 99u8]); let second_meta = Bytes::from_slice(&env, &[255u8, 99u8]); @@ -175,7 +181,7 @@ mod test { let client = StealthAnnouncerContractClient::new(&env, &contract_id); let addr = Address::generate(&env); - let epk = BytesN::from_array(&env, &[1u8; 32]); + let epk = BytesN::from_array(&env, &[0x06u8; 32]); let meta = Bytes::from_slice(&env, &[0u8; 1]); client.announce(&1u32, &addr, &epk, &meta); @@ -191,7 +197,7 @@ mod test { let client = StealthAnnouncerContractClient::new(&env, &contract_id); let addr = Address::generate(&env); - let epk = BytesN::from_array(&env, &[1u8; 32]); + let epk = BytesN::from_array(&env, &[0x06u8; 32]); let meta = Bytes::new(&env); client.announce(&STELLAR_V2_SCHEME_ID, &addr, &epk, &meta); diff --git a/stellar/stealth-announcer/test_snapshots/test/test_announce_emits_event.1.json b/stellar/stealth-announcer/test_snapshots/test/test_announce_emits_event.1.json index a5d9aa78..b5a7fbf4 100644 --- a/stellar/stealth-announcer/test_snapshots/test/test_announce_emits_event.1.json +++ b/stellar/stealth-announcer/test_snapshots/test/test_announce_emits_event.1.json @@ -100,7 +100,7 @@ "address": "CAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAFCT4" }, { - "bytes": "0101010101010101010101010101010101010101010101010101010101010101" + "bytes": "0606060606060606060606060606060606060606060606060606060606060606" }, { "bytes": "2a07" diff --git a/stellar/stealth-announcer/test_snapshots/test/test_view_tag_bucket_derives_from_first_metadata_byte.1.json b/stellar/stealth-announcer/test_snapshots/test/test_view_tag_bucket_derives_from_first_metadata_byte.1.json index bf857fb9..d1e9d9ca 100644 --- a/stellar/stealth-announcer/test_snapshots/test/test_view_tag_bucket_derives_from_first_metadata_byte.1.json +++ b/stellar/stealth-announcer/test_snapshots/test/test_view_tag_bucket_derives_from_first_metadata_byte.1.json @@ -101,7 +101,7 @@ "address": "CAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAFCT4" }, { - "bytes": "0101010101010101010101010101010101010101010101010101010101010101" + "bytes": "0606060606060606060606060606060606060606060606060606060606060606" }, { "bytes": "ff63" diff --git a/stellar/stealth-announcer/test_snapshots/test_behavior_deterministic_and_unchanging.1.json b/stellar/stealth-announcer/test_snapshots/test_behavior_deterministic_and_unchanging.1.json index f94e97ec..bdfe193c 100644 --- a/stellar/stealth-announcer/test_snapshots/test_behavior_deterministic_and_unchanging.1.json +++ b/stellar/stealth-announcer/test_snapshots/test_behavior_deterministic_and_unchanging.1.json @@ -101,7 +101,7 @@ "address": "CAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAFCT4" }, { - "bytes": "2a2a2a2a2a2a2a2a2a2a2a2a2a2a2a2a2a2a2a2a2a2a2a2a2a2a2a2a2a2a2a2a" + "bytes": "1a1a1a1a1a1a1a1a1a1a1a1a1a1a1a1a1a1a1a1a1a1a1a1a1a1a1a1a1a1a1a1a" }, { "bytes": "0a14" diff --git a/stellar/stealth-announcer/test_snapshots/test_frozen_contract_fully_functional.1.json b/stellar/stealth-announcer/test_snapshots/test_frozen_contract_fully_functional.1.json index d8c1859b..9d6238f5 100644 --- a/stellar/stealth-announcer/test_snapshots/test_frozen_contract_fully_functional.1.json +++ b/stellar/stealth-announcer/test_snapshots/test_frozen_contract_fully_functional.1.json @@ -1,6 +1,6 @@ { "generators": { - "address": 12, + "address": 16, "nonce": 0 }, "auth": [ @@ -15,6 +15,10 @@ [], [], [], + [], + [], + [], + [], [] ], "ledger": { @@ -98,7 +102,7 @@ "u32": 2 }, { - "u32": 9 + "u32": 13 }, { "u32": 1 @@ -107,13 +111,13 @@ "data": { "vec": [ { - "address": "CAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAYRE5" + "address": "CAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAABB6KO" }, { - "bytes": "0902020202020202020202020202020202020202020202020202020202020202" + "bytes": "e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2" }, { - "bytes": "0902" + "bytes": "0d02" } ] } diff --git a/stellar/stealth-announcer/test_snapshots/test_immutability_documented.1.json b/stellar/stealth-announcer/test_snapshots/test_immutability_documented.1.json index 5493bae0..0007b130 100644 --- a/stellar/stealth-announcer/test_snapshots/test_immutability_documented.1.json +++ b/stellar/stealth-announcer/test_snapshots/test_immutability_documented.1.json @@ -100,7 +100,7 @@ "address": "CAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAFCT4" }, { - "bytes": "ffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffff" + "bytes": "1919191919191919191919191919191919191919191919191919191919191919" }, { "bytes": "80ff" diff --git a/stellar/stealth-announcer/test_snapshots/test_no_upgrade_function_exists.1.json b/stellar/stealth-announcer/test_snapshots/test_no_upgrade_function_exists.1.json index a5d9aa78..b5a7fbf4 100644 --- a/stellar/stealth-announcer/test_snapshots/test_no_upgrade_function_exists.1.json +++ b/stellar/stealth-announcer/test_snapshots/test_no_upgrade_function_exists.1.json @@ -100,7 +100,7 @@ "address": "CAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAFCT4" }, { - "bytes": "0101010101010101010101010101010101010101010101010101010101010101" + "bytes": "0606060606060606060606060606060606060606060606060606060606060606" }, { "bytes": "2a07" diff --git a/stellar/stealth-announcer/tests/audit.rs b/stellar/stealth-announcer/tests/audit.rs index e7a269fd..fcc7591b 100644 --- a/stellar/stealth-announcer/tests/audit.rs +++ b/stellar/stealth-announcer/tests/audit.rs @@ -13,6 +13,24 @@ fn audit_env() -> Env { }) } +fn hex_digit(byte: u8) -> u8 { + match byte { + b'0'..=b'9' => byte - b'0', + b'a'..=b'f' => byte - b'a' + 10, + b'A'..=b'F' => byte - b'A' + 10, + _ => panic!("invalid hex digit"), + } +} + +fn pubkey(hex: &str) -> [u8; 32] { + let raw = hex.as_bytes(); + let mut out = [0u8; 32]; + for (index, byte) in out.iter_mut().enumerate() { + *byte = (hex_digit(raw[index * 2]) << 4) | hex_digit(raw[index * 2 + 1]); + } + out +} + #[test] fn wa_ann_01_v2_payload_uses_stealth_address_not_caller() { let env = audit_env(); @@ -21,7 +39,7 @@ fn wa_ann_01_v2_payload_uses_stealth_address_not_caller() { let invoker = Address::generate(&env); let stealth_address = Address::generate(&env); - let ephemeral_pub_key = BytesN::from_array(&env, &[1u8; 32]); + let ephemeral_pub_key = BytesN::from_array(&env, &[0x06u8; 32]); let metadata = Bytes::from_slice(&env, &[0u8; 1]); client.announce( @@ -46,7 +64,7 @@ fn wa_ann_02_oversized_metadata_is_accepted() { let client = StealthAnnouncerContractClient::new(&env, &contract_id); let stealth_address = Address::generate(&env); - let ephemeral_pub_key = BytesN::from_array(&env, &[2u8; 32]); + let ephemeral_pub_key = BytesN::from_array(&env, &[0x19u8; 32]); let metadata = Bytes::from_array(&env, &[7u8; 4096]); client.announce( @@ -64,7 +82,8 @@ fn wa_ann_02_oversized_metadata_is_accepted() { } #[test] -fn wa_ann_03_zero_ephemeral_pub_key_is_accepted() { +#[should_panic(expected = "invalid ed25519 ephemeral public key")] +fn wa_ann_03_zero_ephemeral_pub_key_is_rejected() { let env = audit_env(); let contract_id = env.register(StealthAnnouncerContract, ()); let client = StealthAnnouncerContractClient::new(&env, &contract_id); @@ -79,15 +98,105 @@ fn wa_ann_03_zero_ephemeral_pub_key_is_accepted() { &zero_ephemeral_pub_key, &metadata, ); +} + +/// A non-canonical encoding (`y >= p`) is not a valid ed25519 point. The +/// all-`0xff` bytes mask the sign bit to `2^255 - 1`, which is `>= p`. +#[test] +#[should_panic(expected = "invalid ed25519 ephemeral public key")] +fn wa_ann_03_non_canonical_ephemeral_pub_key_is_rejected() { + let env = audit_env(); + let contract_id = env.register(StealthAnnouncerContract, ()); + let client = StealthAnnouncerContractClient::new(&env, &contract_id); + + client.announce( + &STELLAR_V2_SCHEME_ID, + &Address::generate(&env), + &BytesN::from_array(&env, &[0xff; 32]), + &Bytes::from_slice(&env, &[0u8; 1]), + ); +} + +/// An encoding that does not decompress to a curve point is rejected. The +/// all-`0x02` bytes are the placeholder the old fixtures used and are off-curve. +#[test] +#[should_panic(expected = "invalid ed25519 ephemeral public key")] +fn wa_ann_03_off_curve_ephemeral_pub_key_is_rejected() { + let env = audit_env(); + let contract_id = env.register(StealthAnnouncerContract, ()); + let client = StealthAnnouncerContractClient::new(&env, &contract_id); + + client.announce( + &STELLAR_V2_SCHEME_ID, + &Address::generate(&env), + &BytesN::from_array(&env, &[2u8; 32]), + &Bytes::from_slice(&env, &[0u8; 1]), + ); +} + +/// Small-order points are not usable public keys; the identity (`01 00 …`) +/// is the canonical example. +#[test] +#[should_panic(expected = "invalid ed25519 ephemeral public key")] +fn wa_ann_03_small_order_ephemeral_pub_key_is_rejected() { + let env = audit_env(); + let contract_id = env.register(StealthAnnouncerContract, ()); + let client = StealthAnnouncerContractClient::new(&env, &contract_id); + + let mut identity = [0u8; 32]; + identity[0] = 1; + + client.announce( + &STELLAR_V2_SCHEME_ID, + &Address::generate(&env), + &BytesN::from_array(&env, &identity), + &Bytes::from_slice(&env, &[0u8; 1]), + ); +} + +/// A mixed-order point is on the curve and not small-order, but it is not in +/// the prime-order subgroup, so it cannot be an ed25519 public key. This is the +/// exact input from the review: the repeated `0x01` encoding. +#[test] +#[should_panic(expected = "invalid ed25519 ephemeral public key")] +fn wa_ann_03_mixed_order_ephemeral_pub_key_is_rejected() { + let env = audit_env(); + let contract_id = env.register(StealthAnnouncerContract, ()); + let client = StealthAnnouncerContractClient::new(&env, &contract_id); + + client.announce( + &STELLAR_V2_SCHEME_ID, + &Address::generate(&env), + &BytesN::from_array(&env, &[0x01; 32]), + &Bytes::from_slice(&env, &[0u8; 1]), + ); +} + +/// The rejection must not block valid keys: an RFC 8032 public key is accepted. +#[test] +fn wa_ann_03_valid_ephemeral_pub_key_is_accepted() { + let env = audit_env(); + let contract_id = env.register(StealthAnnouncerContract, ()); + let client = StealthAnnouncerContractClient::new(&env, &contract_id); + + let stealth_address = Address::generate(&env); + let ephemeral_pub_key = BytesN::from_array( + &env, + &pubkey("d75a980182b10ab7d54bfed3c964073a0ee172f3daa62325af021a68f707511a"), + ); + let metadata = Bytes::from_slice(&env, &[0u8; 1]); + + client.announce( + &STELLAR_V2_SCHEME_ID, + &stealth_address, + &ephemeral_pub_key, + &metadata, + ); let events = env.events().all(); let event = events.last().unwrap(); let actual_value: (Address, BytesN<32>, Bytes) = FromVal::from_val(&env, &event.2); - - assert_eq!( - actual_value, - (stealth_address, zero_ephemeral_pub_key, metadata) - ); + assert_eq!(actual_value, (stealth_address, ephemeral_pub_key, metadata)); } #[contract] @@ -116,7 +225,7 @@ fn wa_ann_04_cpi_can_emit_announcements_without_auth() { let forwarder = ForwarderContractClient::new(&env, &forwarder_id); let stealth_address = Address::generate(&env); - let ephemeral_pub_key = BytesN::from_array(&env, &[4u8; 32]); + let ephemeral_pub_key = BytesN::from_array(&env, &[6u8; 32]); let metadata = Bytes::from_slice(&env, &[0u8; 1]); forwarder.forward( diff --git a/stellar/stealth-announcer/tests/properties.rs b/stellar/stealth-announcer/tests/properties.rs index 78214465..82179376 100644 --- a/stellar/stealth-announcer/tests/properties.rs +++ b/stellar/stealth-announcer/tests/properties.rs @@ -29,10 +29,22 @@ fn bytes32(env: &Env, data: &[u8]) -> BytesN<32> { BytesN::from_array(env, &fixed) } +/// Fill bytes whose `[fill; 32]` encoding is a canonical, on-curve, torsion-free +/// ed25519 public key (in the prime-order subgroup, not small-order). The +/// announcer rejects malformed encodings, so the payload properties draw from +/// this pool instead of arbitrary bytes. +const VALID_EPK_FILLS: [u8; 14] = [ + 0x06, 0x19, 0x1a, 0x1d, 0x35, 0x4c, 0x7b, 0x81, 0x83, 0x8f, 0x97, 0xa7, 0xd6, 0xe2, +]; + +fn valid_epk() -> impl Strategy { + (0..VALID_EPK_FILLS.len()).prop_map(|index| [VALID_EPK_FILLS[index]; 32]) +} + proptest! { #![proptest_config(ProptestConfig { cases: cases(), .. ProptestConfig::default() })] #[test] - fn announces_once_for_valid_v2_payloads(epk in any::<[u8; 32]>(), metadata in prop::collection::vec(any::(), 1..128)) { + fn announces_once_for_valid_v2_payloads(epk in valid_epk(), metadata in prop::collection::vec(any::(), 1..128)) { let env = env(); let contract_id = env.register(StealthAnnouncerContract, ()); let client = StealthAnnouncerContractClient::new(&env, &contract_id); @@ -44,7 +56,7 @@ proptest! { } #[test] - fn v2_topics_include_scheme_view_tag_bucket_and_metadata_kind(epk in any::<[u8; 32]>(), metadata in prop::collection::vec(any::(), 1..128)) { + fn v2_topics_include_scheme_view_tag_bucket_and_metadata_kind(epk in valid_epk(), metadata in prop::collection::vec(any::(), 1..128)) { let env = env(); let contract_id = env.register(StealthAnnouncerContract, ()); let client = StealthAnnouncerContractClient::new(&env, &contract_id); @@ -66,7 +78,7 @@ proptest! { } #[test] - fn v2_payload_round_trips_without_caller(epk in any::<[u8; 32]>(), metadata in prop::collection::vec(any::(), 1..128)) { + fn v2_payload_round_trips_without_caller(epk in valid_epk(), metadata in prop::collection::vec(any::(), 1..128)) { let env = env(); let contract_id = env.register(StealthAnnouncerContract, ()); let client = StealthAnnouncerContractClient::new(&env, &contract_id); @@ -83,7 +95,7 @@ proptest! { } #[test] - fn repeated_v2_announcements_publish_latest_view_tag_bucket(epk in any::<[u8; 32]>(), first_view_tag in any::(), second_view_tag in any::()) { + fn repeated_v2_announcements_publish_latest_view_tag_bucket(epk in valid_epk(), first_view_tag in any::(), second_view_tag in any::()) { let env = env(); let contract_id = env.register(StealthAnnouncerContract, ()); let client = StealthAnnouncerContractClient::new(&env, &contract_id); @@ -118,7 +130,7 @@ fn zero_length_metadata_is_rejected() { client.announce( &STELLAR_V2_SCHEME_ID, &stealth_address, - &bytes32(&env, &[1u8; 32]), + &bytes32(&env, &[0x06u8; 32]), &Bytes::new(&env), ); } @@ -135,7 +147,7 @@ fn non_v2_scheme_id_is_rejected() { client.announce( &1u32, &stealth_address, - &bytes32(&env, &[1u8; 32]), + &bytes32(&env, &[0x06u8; 32]), &metadata, ); } @@ -144,3 +156,69 @@ fn non_v2_scheme_id_is_rejected() { fn default_property_case_count_is_at_least_1024() { assert!(cases() >= 1024); } + +/// The all-zero key is a well-formed encoding of an order-4 point, so it needs +/// an explicit rejection. +#[test] +#[should_panic(expected = "invalid ed25519 ephemeral public key")] +fn zero_ephemeral_pub_key_is_rejected() { + let env = env(); + let contract_id = env.register(StealthAnnouncerContract, ()); + let client = StealthAnnouncerContractClient::new(&env, &contract_id); + + client.announce( + &STELLAR_V2_SCHEME_ID, + &Address::generate(&env), + &bytes32(&env, &[0u8; 32]), + &bytes(&env, &[0u8]), + ); +} + +/// `0xff * 32` masks to `2^255 - 1 >= p`, a non-canonical encoding. +#[test] +#[should_panic(expected = "invalid ed25519 ephemeral public key")] +fn non_canonical_ephemeral_pub_key_is_rejected() { + let env = env(); + let contract_id = env.register(StealthAnnouncerContract, ()); + let client = StealthAnnouncerContractClient::new(&env, &contract_id); + + client.announce( + &STELLAR_V2_SCHEME_ID, + &Address::generate(&env), + &bytes32(&env, &[0xffu8; 32]), + &bytes(&env, &[0u8]), + ); +} + +/// `0x02 * 32` does not decompress to a curve point. +#[test] +#[should_panic(expected = "invalid ed25519 ephemeral public key")] +fn off_curve_ephemeral_pub_key_is_rejected() { + let env = env(); + let contract_id = env.register(StealthAnnouncerContract, ()); + let client = StealthAnnouncerContractClient::new(&env, &contract_id); + + client.announce( + &STELLAR_V2_SCHEME_ID, + &Address::generate(&env), + &bytes32(&env, &[0x02u8; 32]), + &bytes(&env, &[0u8]), + ); +} + +/// `0x01 * 32` is on the curve and not small-order, but it is mixed-order (not +/// in the prime-order subgroup), so it must be rejected like libsodium does. +#[test] +#[should_panic(expected = "invalid ed25519 ephemeral public key")] +fn mixed_order_ephemeral_pub_key_is_rejected() { + let env = env(); + let contract_id = env.register(StealthAnnouncerContract, ()); + let client = StealthAnnouncerContractClient::new(&env, &contract_id); + + client.announce( + &STELLAR_V2_SCHEME_ID, + &Address::generate(&env), + &bytes32(&env, &[0x01u8; 32]), + &bytes(&env, &[0u8]), + ); +} diff --git a/stellar/stealth-announcer/tests/upgrade_auth.rs b/stellar/stealth-announcer/tests/upgrade_auth.rs index 3d5ddcc9..4e5cc2bd 100644 --- a/stellar/stealth-announcer/tests/upgrade_auth.rs +++ b/stellar/stealth-announcer/tests/upgrade_auth.rs @@ -54,7 +54,7 @@ fn test_no_upgrade_function_exists() { // Verify the contract only has the announce function let addr = Address::generate(&env); - let epk = BytesN::from_array(&env, &[1u8; 32]); + let epk = BytesN::from_array(&env, &[0x06u8; 32]); let meta = Bytes::from_slice(&env, &[42u8, 7u8]); // This should work (normal operation) @@ -96,18 +96,19 @@ fn test_frozen_contract_fully_functional() { // Test normal operation let addr1 = Address::generate(&env); - let epk1 = BytesN::from_array(&env, &[1u8; 32]); + let epk1 = BytesN::from_array(&env, &[0x06u8; 32]); let meta1 = Bytes::from_slice(&env, &[100u8, 1u8]); client.announce(&STELLAR_V2_SCHEME_ID, &addr1, &epk1, &meta1); // Test multiple announcements - for i in 0..10 { + const VALID_EPK_FILLS: [u8; 14] = [ + 0x06, 0x19, 0x1a, 0x1d, 0x35, 0x4c, 0x7b, 0x81, 0x83, 0x8f, 0x97, 0xa7, 0xd6, 0xe2, + ]; + for (i, fill) in VALID_EPK_FILLS.iter().enumerate() { let addr = Address::generate(&env); - let mut epk_bytes = [2u8; 32]; - epk_bytes[0] = i; - let epk = BytesN::from_array(&env, &epk_bytes); - let meta = Bytes::from_slice(&env, &[i, 2u8]); + let epk = BytesN::from_array(&env, &[*fill; 32]); + let meta = Bytes::from_slice(&env, &[i as u8, 2u8]); client.announce(&STELLAR_V2_SCHEME_ID, &addr, &epk, &meta); } @@ -147,7 +148,7 @@ fn test_immutability_documented() { // Verify the contract works exactly as designed let client = StealthAnnouncerContractClient::new(&env, &contract_id); let addr = Address::generate(&env); - let epk = BytesN::from_array(&env, &[255u8; 32]); + let epk = BytesN::from_array(&env, &[0x19u8; 32]); let meta = Bytes::from_slice(&env, &[128u8, 255u8]); client.announce(&STELLAR_V2_SCHEME_ID, &addr, &epk, &meta); @@ -183,7 +184,7 @@ fn test_behavior_deterministic_and_unchanging() { // Test same inputs always produce same outputs/events let addr = Address::generate(&env); - let epk = BytesN::from_array(&env, &[42u8; 32]); + let epk = BytesN::from_array(&env, &[0x1au8; 32]); let meta = Bytes::from_slice(&env, &[10u8, 20u8]); // First call diff --git a/stellar/stealth-batch-sender/src/test.rs b/stellar/stealth-batch-sender/src/test.rs index bdbaaaad..94bcc3a2 100644 --- a/stellar/stealth-batch-sender/src/test.rs +++ b/stellar/stealth-batch-sender/src/test.rs @@ -19,7 +19,7 @@ fn create_token<'a>(env: &Env, admin: &Address) -> (TokenClient<'a>, StellarAsse } fn dummy_pub_key(env: &Env) -> Bytes { - Bytes::from_slice(env, &[0x02u8; 32]) + Bytes::from_slice(env, &[0x06u8; 32]) } fn dummy_metadata(env: &Env) -> Bytes { diff --git a/stellar/stealth-batch-sender/test_snapshots/test/test_atomicity_on_mid_batch_failure.1.json b/stellar/stealth-batch-sender/test_snapshots/test/test_atomicity_on_mid_batch_failure.1.json index 52520c49..1689e8f4 100644 --- a/stellar/stealth-batch-sender/test_snapshots/test/test_atomicity_on_mid_batch_failure.1.json +++ b/stellar/stealth-batch-sender/test_snapshots/test/test_atomicity_on_mid_batch_failure.1.json @@ -514,7 +514,7 @@ "address": "CAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAOLZM" }, { - "bytes": "0202020202020202020202020202020202020202020202020202020202020202" + "bytes": "0606060606060606060606060606060606060606060606060606060606060606" }, { "bytes": "2a" diff --git a/stellar/stealth-batch-sender/test_snapshots/test/test_batch_send_emits_v2_four_topic_announce.1.json b/stellar/stealth-batch-sender/test_snapshots/test/test_batch_send_emits_v2_four_topic_announce.1.json index a427ed96..50857bfd 100644 --- a/stellar/stealth-batch-sender/test_snapshots/test/test_batch_send_emits_v2_four_topic_announce.1.json +++ b/stellar/stealth-batch-sender/test_snapshots/test/test_batch_send_emits_v2_four_topic_announce.1.json @@ -83,7 +83,7 @@ "symbol": "ephemeral_pub_key" }, "val": { - "bytes": "0202020202020202020202020202020202020202020202020202020202020202" + "bytes": "0606060606060606060606060606060606060606060606060606060606060606" } }, { @@ -122,7 +122,7 @@ "symbol": "ephemeral_pub_key" }, "val": { - "bytes": "0202020202020202020202020202020202020202020202020202020202020202" + "bytes": "0606060606060606060606060606060606060606060606060606060606060606" } }, { @@ -843,7 +843,7 @@ "address": "CAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAOLZM" }, { - "bytes": "0202020202020202020202020202020202020202020202020202020202020202" + "bytes": "0606060606060606060606060606060606060606060606060606060606060606" }, { "bytes": "07" @@ -914,7 +914,7 @@ "address": "CAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAARQG5" }, { - "bytes": "0202020202020202020202020202020202020202020202020202020202020202" + "bytes": "0606060606060606060606060606060606060606060606060606060606060606" }, { "bytes": "63" diff --git a/stellar/stealth-batch-sender/test_snapshots/test/test_batch_send_success.1.json b/stellar/stealth-batch-sender/test_snapshots/test/test_batch_send_success.1.json index a0adb1e8..522ce345 100644 --- a/stellar/stealth-batch-sender/test_snapshots/test/test_batch_send_success.1.json +++ b/stellar/stealth-batch-sender/test_snapshots/test/test_batch_send_success.1.json @@ -83,7 +83,7 @@ "symbol": "ephemeral_pub_key" }, "val": { - "bytes": "0202020202020202020202020202020202020202020202020202020202020202" + "bytes": "0606060606060606060606060606060606060606060606060606060606060606" } }, { @@ -122,7 +122,7 @@ "symbol": "ephemeral_pub_key" }, "val": { - "bytes": "0202020202020202020202020202020202020202020202020202020202020202" + "bytes": "0606060606060606060606060606060606060606060606060606060606060606" } }, { @@ -161,7 +161,7 @@ "symbol": "ephemeral_pub_key" }, "val": { - "bytes": "0202020202020202020202020202020202020202020202020202020202020202" + "bytes": "0606060606060606060606060606060606060606060606060606060606060606" } }, { diff --git a/stellar/stealth-batch-sender/tests/production.rs b/stellar/stealth-batch-sender/tests/production.rs index bca08574..9ed27d80 100644 --- a/stellar/stealth-batch-sender/tests/production.rs +++ b/stellar/stealth-batch-sender/tests/production.rs @@ -15,7 +15,7 @@ use stealth_batch_sender::{ }; fn dummy_pub_key(env: &Env) -> Bytes { - Bytes::from_slice(env, &[0x02u8; 32]) + Bytes::from_slice(env, &[0x06u8; 32]) } fn dummy_metadata(env: &Env) -> Bytes { diff --git a/stellar/stealth-vault/test_snapshots/deposit_and_claim_round_trip_against_the_real_announcer.1.json b/stellar/stealth-vault/test_snapshots/deposit_and_claim_round_trip_against_the_real_announcer.1.json index e48bb987..2692e0a8 100644 --- a/stellar/stealth-vault/test_snapshots/deposit_and_claim_round_trip_against_the_real_announcer.1.json +++ b/stellar/stealth-vault/test_snapshots/deposit_and_claim_round_trip_against_the_real_announcer.1.json @@ -82,7 +82,7 @@ "u32": 2000 }, { - "bytes": "0202020202020202020202020202020202020202020202020202020202020202" + "bytes": "1919191919191919191919191919191919191919191919191919191919191919" } ] } @@ -126,7 +126,7 @@ "function_name": "claim", "args": [ { - "bytes": "c2d1c6fe6cc5aec5f1be67a1b96f390e8775954a0896e9736fc4a819a0c2262f" + "bytes": "4ead22e6ffe93a77e5d5532833e1561e85e42cf1f416ed20c5e72230f2d8f439" }, { "address": "CAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAK3IM" diff --git a/stellar/stealth-vault/test_snapshots/deposit_announces_through_the_real_announcer.1.json b/stellar/stealth-vault/test_snapshots/deposit_announces_through_the_real_announcer.1.json index 30e2f33f..28c5d175 100644 --- a/stellar/stealth-vault/test_snapshots/deposit_announces_through_the_real_announcer.1.json +++ b/stellar/stealth-vault/test_snapshots/deposit_announces_through_the_real_announcer.1.json @@ -82,7 +82,7 @@ "u32": 2000 }, { - "bytes": "0101010101010101010101010101010101010101010101010101010101010101" + "bytes": "0606060606060606060606060606060606060606060606060606060606060606" } ] } @@ -229,7 +229,7 @@ "symbol": "Deposit" }, { - "bytes": "dc643fe150c2fd1d0e4d9f604d6b6d2732522f50af51d06896e007e65e33bf2c" + "bytes": "949449a824e92612291916f362fc8e49be1c997a8df72e1175848e862068f6ce" } ] }, @@ -249,7 +249,7 @@ "symbol": "Deposit" }, { - "bytes": "dc643fe150c2fd1d0e4d9f604d6b6d2732522f50af51d06896e007e65e33bf2c" + "bytes": "949449a824e92612291916f362fc8e49be1c997a8df72e1175848e862068f6ce" } ] }, @@ -780,7 +780,7 @@ "u32": 2 }, { - "u32": 1 + "u32": 6 }, { "u32": 1 @@ -792,10 +792,10 @@ "address": "CAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAK3IM" }, { - "bytes": "0101010101010101010101010101010101010101010101010101010101010101" + "bytes": "0606060606060606060606060606060606060606060606060606060606060606" }, { - "bytes": "01" + "bytes": "06" } ] } @@ -816,7 +816,7 @@ "symbol": "deposit" }, { - "bytes": "dc643fe150c2fd1d0e4d9f604d6b6d2732522f50af51d06896e007e65e33bf2c" + "bytes": "949449a824e92612291916f362fc8e49be1c997a8df72e1175848e862068f6ce" } ], "data": { diff --git a/stellar/stealth-vault/tests/announcer.rs b/stellar/stealth-vault/tests/announcer.rs index 3390b084..8a2c168e 100644 --- a/stellar/stealth-vault/tests/announcer.rs +++ b/stellar/stealth-vault/tests/announcer.rs @@ -56,7 +56,7 @@ fn fixture() -> Fixture { #[test] fn deposit_announces_through_the_real_announcer() { let f = fixture(); - let epk = BytesN::from_array(&f.env, &[1u8; 32]); + let epk = BytesN::from_array(&f.env, &[0x06u8; 32]); f.client.deposit( &f.sender, @@ -91,7 +91,7 @@ fn deposit_announces_through_the_real_announcer() { #[test] fn deposit_and_claim_round_trip_against_the_real_announcer() { let f = fixture(); - let epk = BytesN::from_array(&f.env, &[2u8; 32]); + let epk = BytesN::from_array(&f.env, &[0x19u8; 32]); let token_client = token::Client::new(&f.env, &f.token_id); let deposit_id = f.client.deposit(