From d09b1dcd9a3ab0952a0f028b21b8030fb7882f61 Mon Sep 17 00:00:00 2001 From: code3ks Date: Fri, 16 Oct 2026 03:36:27 +0100 Subject: [PATCH 1/2] fix(stellar-announcer): reject invalid ed25519 ephemeral public keys Resolve audit finding WA-ANN-03: `announce` accepted any 32-byte value as `ephemeral_pub_key`, including the all-zero key and encodings that do not represent valid ed25519 points. Validate the key on-chain before emitting an event. The dependency-free check in `src/ed25519.rs` rejects: - the all-zero encoding, - non-canonical encodings (`y >= p`), - encodings that do not decompress to a curve point, and - the small-order (order 1/2/4/8) subgroup points. Canonical, on-curve keys, including the RFC 8032 test vectors, still work. Fixtures: `tests/audit.rs` flips the WA-ANN-03 reproducer to a rejection and adds non-canonical/off-curve/small-order cases plus a valid-key acceptance test; `tests/properties.rs` draws payload keys from a valid pool and adds rejection properties; cross-chain conformance gains rejection fixtures in `integration-tests/tests/conformance.rs`. Existing harnesses that used `[0x02; 32]` and other malformed placeholders now use valid encodings. --- stellar/bench-crossover/src/crossover.rs | 15 +- stellar/bench/src/lib.rs | 23 +- stellar/integration-tests/tests/chaos.rs | 10 +- .../integration-tests/tests/conformance.rs | 41 +- .../integration-tests/tests/topic_filter.rs | 6 +- stellar/stealth-announcer/README.md | 5 +- .../audits/2026-05-gpt-5-3-codex.md | 6 +- stellar/stealth-announcer/src/ed25519.rs | 442 ++++++++++++++++++ stellar/stealth-announcer/src/lib.rs | 6 + stellar/stealth-announcer/tests/audit.rs | 107 ++++- stellar/stealth-announcer/tests/properties.rs | 69 ++- .../stealth-announcer/tests/upgrade_auth.rs | 11 +- stellar/stealth-batch-sender/src/test.rs | 2 +- .../stealth-batch-sender/tests/production.rs | 2 +- stellar/stealth-vault/tests/announcer.rs | 2 +- 15 files changed, 706 insertions(+), 41 deletions(-) create mode 100644 stellar/stealth-announcer/src/ed25519.rs diff --git a/stellar/bench-crossover/src/crossover.rs b/stellar/bench-crossover/src/crossover.rs index f8efe568..6632f9da 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 valid ed25519 public key, so the +/// announcer's encoding checks accept the generated crossover inputs. +const VALID_EPK_FILLS: [u8; 32] = [ + 0x01, 0x03, 0x06, 0x09, 0x0a, 0x0b, 0x0c, 0x10, 0x11, 0x13, 0x16, 0x17, 0x19, 0x1a, 0x1d, 0x20, + 0x22, 0x23, 0x24, 0x25, 0x28, 0x2a, 0x2c, 0x2e, 0x2f, 0x32, 0x33, 0x35, 0x36, 0x3a, 0x47, 0x49, +]; + +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..77fa28dd 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 valid ed25519 public key, so the +/// announcer's encoding checks accept the generated harness inputs. +const VALID_EPK_FILLS: [u8; 32] = [ + 0x01, 0x03, 0x06, 0x09, 0x0a, 0x0b, 0x0c, 0x10, 0x11, 0x13, 0x16, 0x17, 0x19, 0x1a, 0x1d, 0x20, + 0x22, 0x23, 0x24, 0x25, 0x28, 0x2a, 0x2c, 0x2e, 0x2f, 0x32, 0x33, 0x35, 0x36, 0x3a, 0x47, 0x49, +]; + +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, &[0x09u8; 32]), &bytes(env, metadata_len, 9), ); }, @@ -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( @@ -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, &[0x11u8; 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, &[0x13u8; 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/tests/chaos.rs b/stellar/integration-tests/tests/chaos.rs index 4a7d8c41..8a146c18 100644 --- a/stellar/integration-tests/tests/chaos.rs +++ b/stellar/integration-tests/tests/chaos.rs @@ -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, &[0xaa; 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, &[0xfe; 32]); let meta = bytes(&env, &[0x07]); client.send( &sender_addr, @@ -647,7 +647,7 @@ fn splitter_create_and_fund_through_chaos() { let epks = soroban_sdk::vec![ &env, bytes32(&env, &[0x01u8; 32]), - bytes32(&env, &[0x02u8; 32]) + bytes32(&env, &[0x03u8; 32]) ]; let metadatas = soroban_sdk::vec![&env, bytes(&env, &[0x01]), bytes(&env, &[0x02])]; @@ -728,7 +728,7 @@ fn batch_sender_batch_send_through_chaos() { }); transfers.push_back(Transfer { stealth_address: stealth2.clone(), - ephemeral_pub_key: bytes(&env, &[0x02u8; 32]), + ephemeral_pub_key: bytes(&env, &[0x03u8; 32]), amount: 200, metadata: bytes(&env, &[0x02]), }); diff --git a/stellar/integration-tests/tests/conformance.rs b/stellar/integration-tests/tests/conformance.rs index 60633483..bd5cfa74 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, &[3; 32]); let mut meta_arr = [0u8; 32]; meta_arr[0] = 0x00; // view tag let metadata = Bytes::from_array(&env, &meta_arr); @@ -48,6 +48,45 @@ 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]); +} + +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..af23f784 100644 --- a/stellar/integration-tests/tests/topic_filter.rs +++ b/stellar/integration-tests/tests/topic_filter.rs @@ -115,7 +115,7 @@ fn topic_3_view_tag_filter_across_announcer_splitter_and_batch_sender() { announcer.announce( &STELLAR_V2_SCHEME_ID, &direct_other, - &bytes32(&env, 0x12), + &bytes32(&env, 0x13), &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, 0x23), bytes32(&env, 0x22)], &soroban_sdk::vec![&env, meta(&env, MATCH_TAG), meta(&env, OTHER_TAGS[1])], ); harvest_announces(&env, &mut harvested); @@ -166,7 +166,7 @@ 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, &[0x33u8; 32]), amount: 100, metadata: meta(&env, MATCH_TAG), }, diff --git a/stellar/stealth-announcer/README.md b/stellar/stealth-announcer/README.md index 39587598..688bb118 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, non-small-order ed25519 encoding; 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, or small-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) +- Acceptance of canonical, on-curve 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..f1fc8ce8 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, and the eight small-order points before emitting an event. The check is dependency-free (`src/ed25519.rs`) so the frozen announcer keeps its minimal build. The original reproducer is retained as `wa_ann_03_zero_ephemeral_pub_key_is_rejected` (see `tests/audit.rs`) with additional cases 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, and small-order points. 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..e4895371 --- /dev/null +++ b/stellar/stealth-announcer/src/ed25519.rs @@ -0,0 +1,442 @@ +//! Self-contained validation of compressed ed25519 public-key encodings. +//! +//! Soroban's host exposes `ed25519_verify` but no point decompression, and the +//! `stealth-announcer` is a deliberately dependency-free, frozen event emitter, +//! so this module implements the encoding checks directly over the field +//! `GF(2^255 - 19)` instead of pulling in a curve library. +//! +//! The accepted set mirrors `libsodium`'s `crypto_core_ed25519_is_valid_point`: +//! +//! * the encoding must be a canonical `y` coordinate (`y < p`); +//! * it must decompress to a point on the twisted Edwards curve +//! `-x^2 + y^2 = 1 + d * x^2 * y^2`; and +//! * the point must not live in the small-order (order 1/2/4/8) subgroup. +//! +//! The all-zero encoding is rejected explicitly as well. It is a well-formed +//! encoding of an order-4 point, so the on-curve check alone would accept it; +//! it is the concrete input called out by audit finding `WA-ANN-03`. +//! +//! Field elements are held as four little-endian `u64` limbs whose value is +//! always reduced modulo `p`. + +/// Field modulus `p = 2^255 - 19`. +const P: [u64; 4] = [ + 0xffff_ffff_ffff_ffed, + 0xffff_ffff_ffff_ffff, + 0xffff_ffff_ffff_ffff, + 0x7fff_ffff_ffff_ffff, +]; + +/// ed25519 curve constant `d = -121665 / 121666 (mod p)`. +const D: [u64; 4] = [ + 0x75eb_4dca_1359_78a3, + 0x0070_0a4d_4141_d8ab, + 0x8cc7_4079_7779_e898, + 0x5203_6cee_2b6f_fe73, +]; + +/// Exponent `(p - 5) / 8`, used for the square-root candidate in decompression. +const POW_EXP: [u64; 4] = [ + 0xffff_ffff_ffff_fffd, + 0xffff_ffff_ffff_ffff, + 0xffff_ffff_ffff_ffff, + 0x0fff_ffff_ffff_ffff, +]; + +const ZERO: [u64; 4] = [0, 0, 0, 0]; +const ONE: [u64; 4] = [1, 0, 0, 0]; + +/// Canonical encodings of the eight small-order points (order 1, 2, 4 and 8). +const SMALL_ORDER: [[u8; 32]; 8] = [ + [ + 0x01, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, + 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, + 0x00, 0x00, + ], + [ + 0x26, 0xe8, 0x95, 0x8f, 0xc2, 0xb2, 0x27, 0xb0, 0x45, 0xc3, 0xf4, 0x89, 0xf2, 0xef, 0x98, + 0xf0, 0xd5, 0xdf, 0xac, 0x05, 0xd3, 0xc6, 0x33, 0x39, 0xb1, 0x38, 0x02, 0x88, 0x6d, 0x53, + 0xfc, 0x85, + ], + [ + 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, + 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, + 0x00, 0x80, + ], + [ + 0xc7, 0x17, 0x6a, 0x70, 0x3d, 0x4d, 0xd8, 0x4f, 0xba, 0x3c, 0x0b, 0x76, 0x0d, 0x10, 0x67, + 0x0f, 0x2a, 0x20, 0x53, 0xfa, 0x2c, 0x39, 0xcc, 0xc6, 0x4e, 0xc7, 0xfd, 0x77, 0x92, 0xac, + 0x03, 0xfa, + ], + [ + 0xec, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, + 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, + 0xff, 0x7f, + ], + [ + 0xc7, 0x17, 0x6a, 0x70, 0x3d, 0x4d, 0xd8, 0x4f, 0xba, 0x3c, 0x0b, 0x76, 0x0d, 0x10, 0x67, + 0x0f, 0x2a, 0x20, 0x53, 0xfa, 0x2c, 0x39, 0xcc, 0xc6, 0x4e, 0xc7, 0xfd, 0x77, 0x92, 0xac, + 0x03, 0x7a, + ], + [ + 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, + 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, + 0x00, 0x00, + ], + [ + 0x26, 0xe8, 0x95, 0x8f, 0xc2, 0xb2, 0x27, 0xb0, 0x45, 0xc3, 0xf4, 0x89, 0xf2, 0xef, 0x98, + 0xf0, 0xd5, 0xdf, 0xac, 0x05, 0xd3, 0xc6, 0x33, 0x39, 0xb1, 0x38, 0x02, 0x88, 0x6d, 0x53, + 0xfc, 0x05, + ], +]; + +/// Returns `true` when `bytes` is a canonical, on-curve, non-small-order +/// encoding of an ed25519 public key. +pub fn is_valid_ed25519_public_key(bytes: &[u8; 32]) -> bool { + // All-zero is the concrete `WA-ANN-03` reproducer. It decodes to an + // order-4 point, so it also appears in `SMALL_ORDER`; the explicit check + // keeps the rejection obvious and independent of the table. + if *bytes == [0u8; 32] { + return false; + } + for candidate in SMALL_ORDER.iter() { + if bytes == candidate { + return false; + } + } + + // The high bit of the last octet is the sign of x; the rest is y. + let sign = bytes[31] >> 7; + let mut y_bytes = *bytes; + y_bytes[31] &= 0x7f; + let y = from_le(&y_bytes); + if ge(&y, &P) { + // Non-canonical: y must be reduced below p. + return false; + } + + // Candidate root x = u * v^3 * (u * v^7)^((p-5)/8), with + // u = y^2 - 1 and v = d * y^2 + 1. It is correct when v * x^2 == u, and + // correct up to a factor of sqrt(-1) when v * x^2 == -u; otherwise the + // encoding does not decompress and is rejected. + let y2 = mul_mod(&y, &y); + let u = sub_mod(&y2, &ONE); + let v = add_mod(&mul_mod(&D, &y2), &ONE); + let v3 = mul_mod(&mul_mod(&v, &v), &v); + let v7 = mul_mod(&mul_mod(&v3, &v3), &v); + let x = mul_mod(&mul_mod(&u, &v3), &pow_mod(&mul_mod(&u, &v7), &POW_EXP)); + let vx2 = mul_mod(&v, &mul_mod(&x, &x)); + + if vx2 == u { + // A zero root has no distinct negation, so an encoded sign of 1 would + // not round-trip and the encoding is not canonical. + !(is_zero(&x) && sign == 1) + } else { + vx2 == sub_mod(&ZERO, &u) + } +} + +/// Decodes 32 little-endian octets into field limbs. +fn from_le(bytes: &[u8; 32]) -> [u64; 4] { + let mut out = [0u64; 4]; + let mut i = 0; + while i < 4 { + let mut word = 0u64; + let mut j = 0; + while j < 8 { + word |= (bytes[i * 8 + j] as u64) << (8 * j); + j += 1; + } + out[i] = word; + i += 1; + } + out +} + +fn is_zero(a: &[u64; 4]) -> bool { + a[0] | a[1] | a[2] | a[3] == 0 +} + +/// Returns `true` when `a >= b` for canonical (reduced) inputs. +fn ge(a: &[u64; 4], b: &[u64; 4]) -> bool { + let mut i = 4; + while i > 0 { + i -= 1; + if a[i] != b[i] { + return a[i] > b[i]; + } + } + true +} + +/// `a - b`, returning the reduced result and whether a borrow occurred. +fn sub(a: &[u64; 4], b: &[u64; 4]) -> ([u64; 4], bool) { + let mut out = ZERO; + let mut borrow = 0u64; + let mut i = 0; + while i < 4 { + let (d1, b1) = a[i].overflowing_sub(b[i]); + let (d2, b2) = d1.overflowing_sub(borrow); + out[i] = d2; + borrow = (b1 as u64) | (b2 as u64); + i += 1; + } + (out, borrow != 0) +} + +/// `(a + b, carry)` over four limbs. +fn add(a: &[u64; 4], b: &[u64; 4]) -> ([u64; 4], u64) { + let mut out = ZERO; + let mut carry = 0u64; + let mut i = 0; + while i < 4 { + let sum = a[i] as u128 + b[i] as u128 + carry as u128; + out[i] = sum as u64; + carry = (sum >> 64) as u64; + i += 1; + } + (out, carry) +} + +fn add_mod(a: &[u64; 4], b: &[u64; 4]) -> [u64; 4] { + // Inputs are < p < 2^255, so the sum never overflows four limbs. + let (sum, _carry) = add(a, b); + if ge(&sum, &P) { + sub(&sum, &P).0 + } else { + sum + } +} + +fn sub_mod(a: &[u64; 4], b: &[u64; 4]) -> [u64; 4] { + let (diff, borrow) = sub(a, b); + if borrow { + add(&diff, &P).0 + } else { + diff + } +} + +/// Schoolbook 256x256 -> 512 bit product. +fn mul_wide(a: &[u64; 4], b: &[u64; 4]) -> [u64; 8] { + let mut t = [0u64; 8]; + let mut i = 0; + while i < 4 { + let mut carry = 0u64; + let mut j = 0; + while j < 4 { + let cur = t[i + j] as u128 + (a[i] as u128) * (b[j] as u128) + carry as u128; + t[i + j] = cur as u64; + carry = (cur >> 64) as u64; + j += 1; + } + let mut k = i + 4; + while carry != 0 && k < 8 { + let cur = t[k] as u128 + carry as u128; + t[k] = cur as u64; + carry = (cur >> 64) as u64; + k += 1; + } + i += 1; + } + t +} + +/// `x * 38` as five limbs. +fn mul_38(x: &[u64; 4]) -> [u64; 5] { + let mut out = [0u64; 5]; + let mut carry = 0u64; + let mut i = 0; + while i < 4 { + let cur = (x[i] as u128) * 38 + carry as u128; + out[i] = cur as u64; + carry = (cur >> 64) as u64; + i += 1; + } + out[4] = carry; + out +} + +/// Reduces a 512-bit value modulo `p`, folding on `2^256 = 38 (mod p)`. +fn reduce(t: &[u64; 8]) -> [u64; 4] { + let hi = [t[4], t[5], t[6], t[7]]; + let mut wide = mul_38(&hi); + let mut carry = 0u64; + let mut i = 0; + while i < 4 { + let cur = wide[i] as u128 + t[i] as u128 + carry as u128; + wide[i] = cur as u64; + carry = (cur >> 64) as u64; + i += 1; + } + // `wide[4]` is < 65 and `carry` is 0 or 1, so this cannot overflow. + wide[4] += carry; + + // Fold the fifth limb back in: value = low + 2^256 * wide[4]. + let mut low = [wide[0], wide[1], wide[2], wide[3]]; + let mut carry = add_small(&mut low, 38 * wide[4]); + while carry != 0 { + carry = add_small(&mut low, 38); + } + + let mut round = 0; + while round < 3 && ge(&low, &P) { + low = sub(&low, &P).0; + round += 1; + } + low +} + +/// Adds a small (<= 2^32) value to `limbs`, returning the carry out. +fn add_small(limbs: &mut [u64; 4], value: u64) -> u64 { + let mut carry = value as u128; + let mut i = 0; + while i < 4 && carry != 0 { + let cur = limbs[i] as u128 + carry; + limbs[i] = cur as u64; + carry = cur >> 64; + i += 1; + } + carry as u64 +} + +fn mul_mod(a: &[u64; 4], b: &[u64; 4]) -> [u64; 4] { + reduce(&mul_wide(a, b)) +} + +/// Square-and-multiply exponentiation modulo `p`. +fn pow_mod(base: &[u64; 4], exp: &[u64; 4]) -> [u64; 4] { + let mut result = ONE; + let mut power = *base; + let mut bit = 0; + while bit < 256 { + if (exp[bit / 64] >> (bit % 64)) & 1 == 1 { + result = mul_mod(&result, &power); + } + power = mul_mod(&power, &power); + bit += 1; + } + result +} + +#[cfg(test)] +mod test { + use super::*; + + fn fe(value: u64) -> [u64; 4] { + [value, 0, 0, 0] + } + + 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 field_arithmetic_is_consistent() { + // (-1)^2 == 1. + let minus_one = sub_mod(&ZERO, &ONE); + assert_eq!(mul_mod(&minus_one, &minus_one), ONE); + + // d * 121666 == -121665. + let lhs = mul_mod(&D, &fe(121666)); + let rhs = sub_mod(&ZERO, &fe(121665)); + assert_eq!(lhs, rhs); + + // Fermat: 2^(p-1) == 1. + let p_minus_one = sub(&P, &ONE).0; + assert_eq!(pow_mod(&fe(2), &p_minus_one), ONE); + } + + #[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_canonical_on_curve_keys() { + // Fill-byte keys that happen to be canonical, on-curve and not small + // order; these keep the wide test fixtures readable. + for fill in [1u8, 3, 6, 9, 0x2a, 0x32, 0x33, 0xaa] { + let key = [fill; 32]; + assert!( + is_valid_ed25519_public_key(&key), + "fill 0x{fill:02x} must be accepted" + ); + } + // Arbitrary on-curve encodings generated from the reference implementation. + let keys = [ + "307dd826d83e7052ff564654c67402c6a06023538136d505728adeb9f37e14a9", + "d7148ddda885eef95f89e6b9053a17cf1b3d5725779d02851c4a94fb3f451396", + "9b5b3f9bd89f5771aaa4c0a140bef0c5d907f67493a40fe8971e38076d52dca2", + ]; + for key in keys { + assert!( + is_valid_ed25519_public_key(&bytes_from_hex(key)), + "key {key} must be accepted" + ); + } + } + + #[test] + fn rejects_all_zero() { + assert!(!is_valid_ed25519_public_key(&[0u8; 32])); + } + + #[test] + fn rejects_small_order_points() { + for candidate in SMALL_ORDER.iter() { + assert!( + !is_valid_ed25519_public_key(candidate), + "small-order point 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..cda8e5e1 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; diff --git a/stellar/stealth-announcer/tests/audit.rs b/stellar/stealth-announcer/tests/audit.rs index e7a269fd..8b07643f 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(); @@ -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, &[3u8; 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,87 @@ 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]), + ); +} + +/// 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 +207,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..2e9fe04a 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, +/// non-small-order ed25519 public key. The announcer rejects malformed +/// encodings, so the payload properties draw from this pool instead of +/// arbitrary bytes. +const VALID_EPK_FILLS: [u8; 16] = [ + 0x01, 0x03, 0x06, 0x09, 0x0a, 0x0b, 0x0c, 0x10, 0x11, 0x13, 0x16, 0x17, 0x19, 0x1a, 0x1d, 0x20, +]; + +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); @@ -144,3 +156,52 @@ 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]), + ); +} diff --git a/stellar/stealth-announcer/tests/upgrade_auth.rs b/stellar/stealth-announcer/tests/upgrade_auth.rs index 3d5ddcc9..36e7a008 100644 --- a/stellar/stealth-announcer/tests/upgrade_auth.rs +++ b/stellar/stealth-announcer/tests/upgrade_auth.rs @@ -102,12 +102,11 @@ fn test_frozen_contract_fully_functional() { client.announce(&STELLAR_V2_SCHEME_ID, &addr1, &epk1, &meta1); // Test multiple announcements - for i in 0..10 { + const VALID_EPK_FILLS: [u8; 10] = [0x01, 0x03, 0x06, 0x09, 0x0a, 0x0b, 0x0c, 0x10, 0x11, 0x13]; + 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 +146,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, &[254u8; 32]); let meta = Bytes::from_slice(&env, &[128u8, 255u8]); client.announce(&STELLAR_V2_SCHEME_ID, &addr, &epk, &meta); diff --git a/stellar/stealth-batch-sender/src/test.rs b/stellar/stealth-batch-sender/src/test.rs index bdbaaaad..a198c60c 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, &[0x03u8; 32]) } fn dummy_metadata(env: &Env) -> Bytes { diff --git a/stellar/stealth-batch-sender/tests/production.rs b/stellar/stealth-batch-sender/tests/production.rs index bca08574..6ede6de8 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, &[0x03u8; 32]) } fn dummy_metadata(env: &Env) -> Bytes { diff --git a/stellar/stealth-vault/tests/announcer.rs b/stellar/stealth-vault/tests/announcer.rs index 3390b084..8874cce3 100644 --- a/stellar/stealth-vault/tests/announcer.rs +++ b/stellar/stealth-vault/tests/announcer.rs @@ -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, &[3u8; 32]); let token_client = token::Client::new(&f.env, &f.token_id); let deposit_id = f.client.deposit( From aa85de1ba238888b702ba78d41d11d44127b860e Mon Sep 17 00:00:00 2001 From: code3ks Date: Sun, 18 Oct 2026 11:22:18 +0100 Subject: [PATCH 2/2] fix(stellar-announcer): vetted key validation and mixed-order rejection Address review on #242: - Delegate ed25519 point validation to curve25519-dalek 4 (default-features = false, no_std) instead of hand-rolled curve arithmetic. - Enforce the prime-order subgroup (is_torsion_free) alongside the canonical/on-curve/non-small-order checks, matching libsodium's crypto_core_ed25519_is_valid_point. - Add mixed-order regression vectors, including the reviewer reproducer [0x01; 32], in the unit tests, audit/property tests and cross-chain conformance tests. - Update real-announcer fixtures to valid prime-order keys, regenerate the affected test snapshots, and refresh the README and WA-ANN-03 remediation note. --- stellar/Cargo.lock | 1 + stellar/bench-crossover/src/crossover.rs | 10 +- stellar/bench/src/lib.rs | 22 +- .../announcer_announce_through_chaos.1.json | 2 +- ...ncer_multiple_schemes_through_chaos.1.json | 2 +- ...tch_sender_batch_send_through_chaos.1.json | 6 +- ...r_announcer_lifecycle_through_chaos.1.json | 2 +- .../sender_batch_send_through_chaos.1.json | 2 +- ...itter_create_and_fund_through_chaos.1.json | 4 +- .../test_conformance_announcer.1.json | 2 +- ...announcer_splitter_and_batch_sender.1.json | 12 +- stellar/integration-tests/tests/chaos.rs | 16 +- .../integration-tests/tests/conformance.rs | 10 +- .../integration-tests/tests/topic_filter.rs | 10 +- stellar/stealth-announcer/Cargo.toml | 4 + stellar/stealth-announcer/README.md | 8 +- .../audits/2026-05-gpt-5-3-codex.md | 4 +- stellar/stealth-announcer/src/ed25519.rs | 413 +++--------------- stellar/stealth-announcer/src/lib.rs | 8 +- .../test/test_announce_emits_event.1.json | 2 +- ...et_derives_from_first_metadata_byte.1.json | 2 +- ...havior_deterministic_and_unchanging.1.json | 2 +- ...st_frozen_contract_fully_functional.1.json | 14 +- .../test_immutability_documented.1.json | 2 +- .../test_no_upgrade_function_exists.1.json | 2 +- stellar/stealth-announcer/tests/audit.rs | 22 +- stellar/stealth-announcer/tests/properties.rs | 33 +- .../stealth-announcer/tests/upgrade_auth.rs | 12 +- stellar/stealth-batch-sender/src/test.rs | 2 +- ...test_atomicity_on_mid_batch_failure.1.json | 2 +- ...h_send_emits_v2_four_topic_announce.1.json | 8 +- .../test/test_batch_send_success.1.json | 6 +- .../stealth-batch-sender/tests/production.rs | 2 +- ...und_trip_against_the_real_announcer.1.json | 4 +- ...nnounces_through_the_real_announcer.1.json | 14 +- stellar/stealth-vault/tests/announcer.rs | 4 +- 36 files changed, 223 insertions(+), 448 deletions(-) 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 6632f9da..6f47ad3a 100644 --- a/stellar/bench-crossover/src/crossover.rs +++ b/stellar/bench-crossover/src/crossover.rs @@ -14,11 +14,11 @@ 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 valid ed25519 public key, so the -/// announcer's encoding checks accept the generated crossover inputs. -const VALID_EPK_FILLS: [u8; 32] = [ - 0x01, 0x03, 0x06, 0x09, 0x0a, 0x0b, 0x0c, 0x10, 0x11, 0x13, 0x16, 0x17, 0x19, 0x1a, 0x1d, 0x20, - 0x22, 0x23, 0x24, 0x25, 0x28, 0x2a, 0x2c, 0x2e, 0x2f, 0x32, 0x33, 0x35, 0x36, 0x3a, 0x47, 0x49, +/// 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 { diff --git a/stellar/bench/src/lib.rs b/stellar/bench/src/lib.rs index 77fa28dd..f6fb5588 100644 --- a/stellar/bench/src/lib.rs +++ b/stellar/bench/src/lib.rs @@ -16,11 +16,11 @@ use stealth_splitter::{Beneficiary, StealthSplitterContract, StealthSplitterCont use stealth_vault::{StealthVaultContract, StealthVaultContractClient}; use wraith_names::{WraithNamesContract, WraithNamesContractClient}; -/// Fill bytes whose `[fill; 32]` encoding is a valid ed25519 public key, so the -/// announcer's encoding checks accept the generated harness inputs. -const VALID_EPK_FILLS: [u8; 32] = [ - 0x01, 0x03, 0x06, 0x09, 0x0a, 0x0b, 0x0c, 0x10, 0x11, 0x13, 0x16, 0x17, 0x19, 0x1a, 0x1d, 0x20, - 0x22, 0x23, 0x24, 0x25, 0x28, 0x2a, 0x2c, 0x2e, 0x2f, 0x32, 0x33, 0x35, 0x36, 0x3a, 0x47, 0x49, +/// 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 { @@ -63,7 +63,7 @@ pub fn collect_rows() -> std::vec::Vec { client.announce( &STELLAR_V2_SCHEME_ID, &Address::generate(env), - &BytesN::from_array(env, &[0x09u8; 32]), + &BytesN::from_array(env, &[0x06u8; 32]), &bytes(env, metadata_len, 9), ); }, @@ -115,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), ); }, @@ -346,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]), ); }, )); @@ -366,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); @@ -388,7 +388,7 @@ pub fn collect_rows() -> std::vec::Vec { &token, &VAULT_UNLOCK_LEDGER, &VAULT_REFUND_AFTER, - &BytesN::from_array(env, &[0x11u8; 32]), + &BytesN::from_array(env, &[0x35u8; 32]), ); env.ledger() .with_mut(|li| li.sequence_number = VAULT_REFUND_AFTER); @@ -410,7 +410,7 @@ pub fn collect_rows() -> std::vec::Vec { &token, &VAULT_UNLOCK_LEDGER, &VAULT_REFUND_AFTER, - &BytesN::from_array(env, &[0x13u8; 32]), + &BytesN::from_array(env, &[0x4cu8; 32]), ); env.ledger().with_mut(|li| { li.sequence_number = VAULT_REFUND_AFTER + stealth_vault::DEFAULT_GRACE_PERIOD 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 8a146c18..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, &[0xaa; 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])); @@ -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, &[0xfe; 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, &[0x03u8; 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, &[0x03u8; 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 bd5cfa74..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, &[3; 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); @@ -72,6 +72,14 @@ 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, ()); diff --git a/stellar/integration-tests/tests/topic_filter.rs b/stellar/integration-tests/tests/topic_filter.rs index af23f784..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, 0x13), + &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, 0x23), 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, &[0x33u8; 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 688bb118..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. It must be a canonical, on-curve, non-small-order ed25519 encoding; the all-zero key and invalid encodings are rejected. +- `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,7 +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, or small-order) | +| Invalid `ephemeral_pub_key` | `invalid ed25519 ephemeral public key` (all-zero, non-canonical, off-curve, small-order, or mixed-order) | ## Event Topics @@ -84,7 +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) -- Acceptance of canonical, on-curve ed25519 keys (RFC 8032 vectors) +- 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 f1fc8ce8..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 @@ -69,7 +69,7 @@ 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, and the eight small-order points before emitting an event. The check is dependency-free (`src/ed25519.rs`) so the frozen announcer keeps its minimal build. The original reproducer is retained as `wa_ann_03_zero_ephemeral_pub_key_is_rejected` (see `tests/audit.rs`) with additional cases in `tests/properties.rs` and cross-chain fixtures in `integration-tests/tests/conformance.rs`. +**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 @@ -96,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. 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, and small-order points. 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. +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 index e4895371..8cdaff57 100644 --- a/stellar/stealth-announcer/src/ed25519.rs +++ b/stellar/stealth-announcer/src/ed25519.rs @@ -1,331 +1,55 @@ -//! Self-contained validation of compressed ed25519 public-key encodings. -//! -//! Soroban's host exposes `ed25519_verify` but no point decompression, and the -//! `stealth-announcer` is a deliberately dependency-free, frozen event emitter, -//! so this module implements the encoding checks directly over the field -//! `GF(2^255 - 19)` instead of pulling in a curve library. +//! Validation of compressed ed25519 public-key encodings. //! //! The accepted set mirrors `libsodium`'s `crypto_core_ed25519_is_valid_point`: //! -//! * the encoding must be a canonical `y` coordinate (`y < p`); +//! * 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`; and -//! * the point must not live in the small-order (order 1/2/4/8) subgroup. +//! `-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 all-zero encoding is rejected explicitly as well. It is a well-formed -//! encoding of an order-4 point, so the on-curve check alone would accept it; -//! it is the concrete input called out by audit finding `WA-ANN-03`. +//! 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. //! -//! Field elements are held as four little-endian `u64` limbs whose value is -//! always reduced modulo `p`. - -/// Field modulus `p = 2^255 - 19`. -const P: [u64; 4] = [ - 0xffff_ffff_ffff_ffed, - 0xffff_ffff_ffff_ffff, - 0xffff_ffff_ffff_ffff, - 0x7fff_ffff_ffff_ffff, -]; - -/// ed25519 curve constant `d = -121665 / 121666 (mod p)`. -const D: [u64; 4] = [ - 0x75eb_4dca_1359_78a3, - 0x0070_0a4d_4141_d8ab, - 0x8cc7_4079_7779_e898, - 0x5203_6cee_2b6f_fe73, -]; - -/// Exponent `(p - 5) / 8`, used for the square-root candidate in decompression. -const POW_EXP: [u64; 4] = [ - 0xffff_ffff_ffff_fffd, - 0xffff_ffff_ffff_ffff, - 0xffff_ffff_ffff_ffff, - 0x0fff_ffff_ffff_ffff, -]; - -const ZERO: [u64; 4] = [0, 0, 0, 0]; -const ONE: [u64; 4] = [1, 0, 0, 0]; +//! 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. -/// Canonical encodings of the eight small-order points (order 1, 2, 4 and 8). -const SMALL_ORDER: [[u8; 32]; 8] = [ - [ - 0x01, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, - 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, - 0x00, 0x00, - ], - [ - 0x26, 0xe8, 0x95, 0x8f, 0xc2, 0xb2, 0x27, 0xb0, 0x45, 0xc3, 0xf4, 0x89, 0xf2, 0xef, 0x98, - 0xf0, 0xd5, 0xdf, 0xac, 0x05, 0xd3, 0xc6, 0x33, 0x39, 0xb1, 0x38, 0x02, 0x88, 0x6d, 0x53, - 0xfc, 0x85, - ], - [ - 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, - 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, - 0x00, 0x80, - ], - [ - 0xc7, 0x17, 0x6a, 0x70, 0x3d, 0x4d, 0xd8, 0x4f, 0xba, 0x3c, 0x0b, 0x76, 0x0d, 0x10, 0x67, - 0x0f, 0x2a, 0x20, 0x53, 0xfa, 0x2c, 0x39, 0xcc, 0xc6, 0x4e, 0xc7, 0xfd, 0x77, 0x92, 0xac, - 0x03, 0xfa, - ], - [ - 0xec, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, - 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, - 0xff, 0x7f, - ], - [ - 0xc7, 0x17, 0x6a, 0x70, 0x3d, 0x4d, 0xd8, 0x4f, 0xba, 0x3c, 0x0b, 0x76, 0x0d, 0x10, 0x67, - 0x0f, 0x2a, 0x20, 0x53, 0xfa, 0x2c, 0x39, 0xcc, 0xc6, 0x4e, 0xc7, 0xfd, 0x77, 0x92, 0xac, - 0x03, 0x7a, - ], - [ - 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, - 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, - 0x00, 0x00, - ], - [ - 0x26, 0xe8, 0x95, 0x8f, 0xc2, 0xb2, 0x27, 0xb0, 0x45, 0xc3, 0xf4, 0x89, 0xf2, 0xef, 0x98, - 0xf0, 0xd5, 0xdf, 0xac, 0x05, 0xd3, 0xc6, 0x33, 0x39, 0xb1, 0x38, 0x02, 0x88, 0x6d, 0x53, - 0xfc, 0x05, - ], -]; +use curve25519_dalek::edwards::CompressedEdwardsY; -/// Returns `true` when `bytes` is a canonical, on-curve, non-small-order -/// encoding of an ed25519 public key. +/// 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 { - // All-zero is the concrete `WA-ANN-03` reproducer. It decodes to an - // order-4 point, so it also appears in `SMALL_ORDER`; the explicit check - // keeps the rejection obvious and independent of the table. - if *bytes == [0u8; 32] { - return false; - } - for candidate in SMALL_ORDER.iter() { - if bytes == candidate { - return false; - } - } + let compressed = CompressedEdwardsY(*bytes); - // The high bit of the last octet is the sign of x; the rest is y. - let sign = bytes[31] >> 7; - let mut y_bytes = *bytes; - y_bytes[31] &= 0x7f; - let y = from_le(&y_bytes); - if ge(&y, &P) { - // Non-canonical: y must be reduced below p. + // Reject encodings that do not decompress to a curve point. + let Some(point) = compressed.decompress() else { return false; - } - - // Candidate root x = u * v^3 * (u * v^7)^((p-5)/8), with - // u = y^2 - 1 and v = d * y^2 + 1. It is correct when v * x^2 == u, and - // correct up to a factor of sqrt(-1) when v * x^2 == -u; otherwise the - // encoding does not decompress and is rejected. - let y2 = mul_mod(&y, &y); - let u = sub_mod(&y2, &ONE); - let v = add_mod(&mul_mod(&D, &y2), &ONE); - let v3 = mul_mod(&mul_mod(&v, &v), &v); - let v7 = mul_mod(&mul_mod(&v3, &v3), &v); - let x = mul_mod(&mul_mod(&u, &v3), &pow_mod(&mul_mod(&u, &v7), &POW_EXP)); - let vx2 = mul_mod(&v, &mul_mod(&x, &x)); - - if vx2 == u { - // A zero root has no distinct negation, so an encoded sign of 1 would - // not round-trip and the encoding is not canonical. - !(is_zero(&x) && sign == 1) - } else { - vx2 == sub_mod(&ZERO, &u) - } -} - -/// Decodes 32 little-endian octets into field limbs. -fn from_le(bytes: &[u8; 32]) -> [u64; 4] { - let mut out = [0u64; 4]; - let mut i = 0; - while i < 4 { - let mut word = 0u64; - let mut j = 0; - while j < 8 { - word |= (bytes[i * 8 + j] as u64) << (8 * j); - j += 1; - } - out[i] = word; - i += 1; - } - out -} - -fn is_zero(a: &[u64; 4]) -> bool { - a[0] | a[1] | a[2] | a[3] == 0 -} + }; -/// Returns `true` when `a >= b` for canonical (reduced) inputs. -fn ge(a: &[u64; 4], b: &[u64; 4]) -> bool { - let mut i = 4; - while i > 0 { - i -= 1; - if a[i] != b[i] { - return a[i] > b[i]; - } - } - true -} - -/// `a - b`, returning the reduced result and whether a borrow occurred. -fn sub(a: &[u64; 4], b: &[u64; 4]) -> ([u64; 4], bool) { - let mut out = ZERO; - let mut borrow = 0u64; - let mut i = 0; - while i < 4 { - let (d1, b1) = a[i].overflowing_sub(b[i]); - let (d2, b2) = d1.overflowing_sub(borrow); - out[i] = d2; - borrow = (b1 as u64) | (b2 as u64); - i += 1; - } - (out, borrow != 0) -} - -/// `(a + b, carry)` over four limbs. -fn add(a: &[u64; 4], b: &[u64; 4]) -> ([u64; 4], u64) { - let mut out = ZERO; - let mut carry = 0u64; - let mut i = 0; - while i < 4 { - let sum = a[i] as u128 + b[i] as u128 + carry as u128; - out[i] = sum as u64; - carry = (sum >> 64) as u64; - i += 1; - } - (out, carry) -} - -fn add_mod(a: &[u64; 4], b: &[u64; 4]) -> [u64; 4] { - // Inputs are < p < 2^255, so the sum never overflows four limbs. - let (sum, _carry) = add(a, b); - if ge(&sum, &P) { - sub(&sum, &P).0 - } else { - sum - } -} - -fn sub_mod(a: &[u64; 4], b: &[u64; 4]) -> [u64; 4] { - let (diff, borrow) = sub(a, b); - if borrow { - add(&diff, &P).0 - } else { - diff - } -} - -/// Schoolbook 256x256 -> 512 bit product. -fn mul_wide(a: &[u64; 4], b: &[u64; 4]) -> [u64; 8] { - let mut t = [0u64; 8]; - let mut i = 0; - while i < 4 { - let mut carry = 0u64; - let mut j = 0; - while j < 4 { - let cur = t[i + j] as u128 + (a[i] as u128) * (b[j] as u128) + carry as u128; - t[i + j] = cur as u64; - carry = (cur >> 64) as u64; - j += 1; - } - let mut k = i + 4; - while carry != 0 && k < 8 { - let cur = t[k] as u128 + carry as u128; - t[k] = cur as u64; - carry = (cur >> 64) as u64; - k += 1; - } - i += 1; - } - t -} - -/// `x * 38` as five limbs. -fn mul_38(x: &[u64; 4]) -> [u64; 5] { - let mut out = [0u64; 5]; - let mut carry = 0u64; - let mut i = 0; - while i < 4 { - let cur = (x[i] as u128) * 38 + carry as u128; - out[i] = cur as u64; - carry = (cur >> 64) as u64; - i += 1; - } - out[4] = carry; - out -} - -/// Reduces a 512-bit value modulo `p`, folding on `2^256 = 38 (mod p)`. -fn reduce(t: &[u64; 8]) -> [u64; 4] { - let hi = [t[4], t[5], t[6], t[7]]; - let mut wide = mul_38(&hi); - let mut carry = 0u64; - let mut i = 0; - while i < 4 { - let cur = wide[i] as u128 + t[i] as u128 + carry as u128; - wide[i] = cur as u64; - carry = (cur >> 64) as u64; - i += 1; - } - // `wide[4]` is < 65 and `carry` is 0 or 1, so this cannot overflow. - wide[4] += carry; - - // Fold the fifth limb back in: value = low + 2^256 * wide[4]. - let mut low = [wide[0], wide[1], wide[2], wide[3]]; - let mut carry = add_small(&mut low, 38 * wide[4]); - while carry != 0 { - carry = add_small(&mut low, 38); - } - - let mut round = 0; - while round < 3 && ge(&low, &P) { - low = sub(&low, &P).0; - round += 1; - } - low -} - -/// Adds a small (<= 2^32) value to `limbs`, returning the carry out. -fn add_small(limbs: &mut [u64; 4], value: u64) -> u64 { - let mut carry = value as u128; - let mut i = 0; - while i < 4 && carry != 0 { - let cur = limbs[i] as u128 + carry; - limbs[i] = cur as u64; - carry = cur >> 64; - i += 1; + // 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; } - carry as u64 -} -fn mul_mod(a: &[u64; 4], b: &[u64; 4]) -> [u64; 4] { - reduce(&mul_wide(a, b)) -} - -/// Square-and-multiply exponentiation modulo `p`. -fn pow_mod(base: &[u64; 4], exp: &[u64; 4]) -> [u64; 4] { - let mut result = ONE; - let mut power = *base; - let mut bit = 0; - while bit < 256 { - if (exp[bit / 64] >> (bit % 64)) & 1 == 1 { - result = mul_mod(&result, &power); - } - power = mul_mod(&power, &power); - bit += 1; - } - result + // 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::*; - - fn fe(value: u64) -> [u64; 4] { - [value, 0, 0, 0] - } + 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]; @@ -340,22 +64,6 @@ mod test { out } - #[test] - fn field_arithmetic_is_consistent() { - // (-1)^2 == 1. - let minus_one = sub_mod(&ZERO, &ONE); - assert_eq!(mul_mod(&minus_one, &minus_one), ONE); - - // d * 121666 == -121665. - let lhs = mul_mod(&D, &fe(121666)); - let rhs = sub_mod(&ZERO, &fe(121665)); - assert_eq!(lhs, rhs); - - // Fermat: 2^(p-1) == 1. - let p_minus_one = sub(&P, &ONE).0; - assert_eq!(pow_mod(&fe(2), &p_minus_one), ONE); - } - #[test] fn accepts_rfc8032_public_keys() { // RFC 8032 section 7.1 public keys. @@ -373,26 +81,14 @@ mod test { } #[test] - fn accepts_canonical_on_curve_keys() { - // Fill-byte keys that happen to be canonical, on-curve and not small - // order; these keep the wide test fixtures readable. - for fill in [1u8, 3, 6, 9, 0x2a, 0x32, 0x33, 0xaa] { - let key = [fill; 32]; + 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(&key), - "fill 0x{fill:02x} must be accepted" - ); - } - // Arbitrary on-curve encodings generated from the reference implementation. - let keys = [ - "307dd826d83e7052ff564654c67402c6a06023538136d505728adeb9f37e14a9", - "d7148ddda885eef95f89e6b9053a17cf1b3d5725779d02851c4a94fb3f451396", - "9b5b3f9bd89f5771aaa4c0a140bef0c5d907f67493a40fe8971e38076d52dca2", - ]; - for key in keys { - assert!( - is_valid_ed25519_public_key(&bytes_from_hex(key)), - "key {key} must be accepted" + is_valid_ed25519_public_key(point.compress().as_bytes()), + "scalar multiple {k} of the basepoint must be accepted" ); } } @@ -404,14 +100,39 @@ mod test { #[test] fn rejects_small_order_points() { - for candidate in SMALL_ORDER.iter() { + for point in EIGHT_TORSION.iter() { assert!( - !is_valid_ed25519_public_key(candidate), + !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. diff --git a/stellar/stealth-announcer/src/lib.rs b/stellar/stealth-announcer/src/lib.rs index cda8e5e1..adf2d802 100644 --- a/stellar/stealth-announcer/src/lib.rs +++ b/stellar/stealth-announcer/src/lib.rs @@ -104,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; @@ -140,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]); @@ -181,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); @@ -197,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 8b07643f..fcc7591b 100644 --- a/stellar/stealth-announcer/tests/audit.rs +++ b/stellar/stealth-announcer/tests/audit.rs @@ -39,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( @@ -64,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, &[3u8; 32]); + let ephemeral_pub_key = BytesN::from_array(&env, &[0x19u8; 32]); let metadata = Bytes::from_array(&env, &[7u8; 4096]); client.announce( @@ -154,6 +154,24 @@ fn wa_ann_03_small_order_ephemeral_pub_key_is_rejected() { ); } +/// 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() { diff --git a/stellar/stealth-announcer/tests/properties.rs b/stellar/stealth-announcer/tests/properties.rs index 2e9fe04a..82179376 100644 --- a/stellar/stealth-announcer/tests/properties.rs +++ b/stellar/stealth-announcer/tests/properties.rs @@ -29,12 +29,12 @@ fn bytes32(env: &Env, data: &[u8]) -> BytesN<32> { BytesN::from_array(env, &fixed) } -/// Fill bytes whose `[fill; 32]` encoding is a canonical, on-curve, -/// non-small-order ed25519 public key. The announcer rejects malformed -/// encodings, so the payload properties draw from this pool instead of -/// arbitrary bytes. -const VALID_EPK_FILLS: [u8; 16] = [ - 0x01, 0x03, 0x06, 0x09, 0x0a, 0x0b, 0x0c, 0x10, 0x11, 0x13, 0x16, 0x17, 0x19, 0x1a, 0x1d, 0x20, +/// 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 { @@ -130,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), ); } @@ -147,7 +147,7 @@ fn non_v2_scheme_id_is_rejected() { client.announce( &1u32, &stealth_address, - &bytes32(&env, &[1u8; 32]), + &bytes32(&env, &[0x06u8; 32]), &metadata, ); } @@ -205,3 +205,20 @@ fn off_curve_ephemeral_pub_key_is_rejected() { &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 36e7a008..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,13 +96,15 @@ 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 - const VALID_EPK_FILLS: [u8; 10] = [0x01, 0x03, 0x06, 0x09, 0x0a, 0x0b, 0x0c, 0x10, 0x11, 0x13]; + 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 epk = BytesN::from_array(&env, &[*fill; 32]); @@ -146,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, &[254u8; 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); @@ -182,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 a198c60c..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, &[0x03u8; 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 6ede6de8..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, &[0x03u8; 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 8874cce3..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, &[3u8; 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(