diff --git a/README.md b/README.md index 413c680c..25dbdfd4 100644 --- a/README.md +++ b/README.md @@ -65,10 +65,6 @@ console.log(result.routingId); // "123" - **Warning System**: Discriminated unions (TS) or structured objects (Go/Dart) to catch edge cases like numeric `MEMO_TEXT`. - **Zero Dependencies**: Core logic is lightweight and has zero external dependencies beyond standard library features. -## Maintainers - -- **codeZeus** - [GitHub](https://github.com/codeZe-us) - ## License This project is licensed under the MIT License - see the [LICENSE](LICENSE) file for details. diff --git a/examples/prism-core/src/address.rs b/examples/prism-core/src/address.rs index 195ebaae..23f56b81 100644 --- a/examples/prism-core/src/address.rs +++ b/examples/prism-core/src/address.rs @@ -224,8 +224,8 @@ mod tests { #[test] fn lowercase_normalised_correctly() { - let r_lower = parse("gahjjjkmokye4rvpzewztkh5fvi4pa3vl7gk2lfnubsgbv3pr5t4q"); - let r_upper = parse("GAHJJJKMOKYE4RVPZEWZTKH5FVI4PA3VL7GK2LFNUBSGBV3PR5T4Q"); + let r_lower = parse("gaycuyt553c5lhve2xpw5gmejt4bxgm7ahmjwlapzp53kjo7eiqadrsi"); + let r_upper = parse("GAYCUYT553C5LHVE2XPW5GMEJT4BXGM7AHMJWLAPZP53KJO7EIQADRSI"); assert_eq!(r_lower.is_ok(), r_upper.is_ok()); } diff --git a/examples/rust-address-fuzzer/src/generate.rs b/examples/rust-address-fuzzer/src/generate.rs new file mode 100644 index 00000000..24fd9973 --- /dev/null +++ b/examples/rust-address-fuzzer/src/generate.rs @@ -0,0 +1,235 @@ +//! Valid-address generator for the fuzzer. +//! +//! `random_valid_address` produces correctly checksummed strkey strings for +//! every `AddressKind` (G, M, C). The returned string is guaranteed to +//! round-trip through `prism_core::address::parse`; a failure causes a panic +//! so that a broken seed source is caught immediately at generation time +//! rather than producing mysterious false-positive fuzzer results. +//! +//! Layout (all lengths are in bytes unless stated otherwise): +//! +//! | Kind | Payload | Payload len | Total (+ 2B CRC) | Strkey chars | +//! |------|--------------------------------------------|-------------|------------------|--------------| +//! | G | version(1) + ed25519_key(32) | 33 | 35 | 56 | +//! | M | version(1) + muxed_id_BE(8) + ed25519_key(32) | 41 | 43 | 69 | +//! | C | version(1) + contract_hash(32) | 33 | 35 | 56 | + +use prism_core::address::AddressKind; +use rand::Rng; + +// ── version bytes (top 5 bits of the first byte, as used by prism-core) ────── + +const VERSION_G: u8 = 6 << 3; // 0x30 +const VERSION_M: u8 = 12 << 3; // 0x60 +const VERSION_C: u8 = 2 << 3; // 0x10 + +// ── CRC-16 (CCITT, same polynomial as prism-core) ──────────────────────────── + +fn crc16(data: &[u8]) -> u16 { + let mut crc: u16 = 0x0000; + for &byte in data { + let mut x = (crc >> 8) ^ (byte as u16); + x ^= x >> 4; + crc = (crc << 8) ^ (x << 12) ^ (x << 5) ^ x; + } + crc +} + +// ── Base-32 encoder (Stellar / RFC 4648 alphabet, no padding) ──────────────── + +const ALPHABET: &[u8; 32] = b"ABCDEFGHIJKLMNOPQRSTUVWXYZ234567"; + +fn base32_encode(data: &[u8]) -> String { + // Each 5 bits of input becomes one character. The output length is + // ceil(len * 8 / 5) characters. + let out_len = (data.len() * 8 + 4) / 5; + let mut result = String::with_capacity(out_len); + let mut bits: u32 = 0; + let mut bit_count: u32 = 0; + + for &byte in data { + bits = (bits << 8) | (byte as u32); + bit_count += 8; + while bit_count >= 5 { + bit_count -= 5; + result.push(ALPHABET[((bits >> bit_count) & 0x1F) as usize] as char); + } + } + // flush any remaining bits (padded to the right with zeros) + if bit_count > 0 { + result.push(ALPHABET[((bits << (5 - bit_count)) & 0x1F) as usize] as char); + } + result +} + +// ── address builder ─────────────────────────────────────────────────────────── + +/// Build a complete strkey from a version byte and raw payload bytes (no +/// version byte included in `body`). Appends a 2-byte little-endian CRC-16. +fn build_strkey(version: u8, body: &[u8]) -> String { + // payload = version || body + let mut payload = Vec::with_capacity(1 + body.len()); + payload.push(version); + payload.extend_from_slice(body); + + // checksum over the full payload, appended little-endian + let crc = crc16(&payload); + payload.push((crc & 0xFF) as u8); + payload.push((crc >> 8) as u8); + + base32_encode(&payload) +} + +// ── public API ──────────────────────────────────────────────────────────────── + +/// Generate a random, correctly checksummed Stellar address of the requested +/// kind and verify it parses successfully. +/// +/// Panics if the generated address fails to parse — this would indicate a bug +/// in the generator (broken encoding or checksum logic) rather than in the +/// parser, so an immediate panic is the right signal. +pub fn random_valid_address(kind: AddressKind, rng: &mut impl Rng) -> String { + let address = match kind { + AddressKind::G => { + // 32 random bytes — any value is a valid (public) ed25519 key for + // our purposes; the parser only validates structure, not whether + // the key is a valid curve point. + let mut key = [0u8; 32]; + rng.fill(&mut key); + build_strkey(VERSION_G, &key) + } + + AddressKind::M => { + // Embed a random 64-bit muxed id to exercise the full u64 range + // in the decoder path. + let muxed_id: u64 = rng.gen(); + let mut key = [0u8; 32]; + rng.fill(&mut key); + + // M payload body = muxed_id_BE(8) || ed25519_key(32) + let mut body = Vec::with_capacity(40); + body.extend_from_slice(&muxed_id.to_be_bytes()); + body.extend_from_slice(&key); + build_strkey(VERSION_M, &body) + } + + AddressKind::C => { + // 32 random bytes — the contract Wasm hash / account id. + let mut hash = [0u8; 32]; + rng.fill(&mut hash); + build_strkey(VERSION_C, &hash) + } + }; + + // ── round-trip check ───────────────────────────────────────────────────── + // If our own generator produces an address that does not parse, the seed + // corpus is broken. Panic loudly so the problem is noticed immediately. + let parsed = prism_core::address::parse(&address).unwrap_or_else(|e| { + panic!( + "generator produced an invalid {kind:?} address ({address:?}): {e}" + ); + }); + assert_eq!( + parsed.kind(), + kind, + "generator produced a {kind:?} address but parser returned {:?}", + parsed.kind() + ); + + address +} + +// ── unit tests ──────────────────────────────────────────────────────────────── + +#[cfg(test)] +mod tests { + use super::*; + use prism_core::address::AddressKind; + use rand::SeedableRng; + use rand::rngs::StdRng; + + fn seeded() -> StdRng { + StdRng::seed_from_u64(0xDEAD_BEEF_CAFE_1234) + } + + #[test] + fn g_address_has_correct_length() { + let mut rng = seeded(); + let addr = random_valid_address(AddressKind::G, &mut rng); + assert_eq!(addr.len(), 56, "G address must be 56 chars, got {}", addr.len()); + assert!(addr.starts_with('G'), "G address must start with 'G'"); + } + + #[test] + fn m_address_has_correct_length() { + let mut rng = seeded(); + let addr = random_valid_address(AddressKind::M, &mut rng); + assert_eq!(addr.len(), 69, "M address must be 69 chars, got {}", addr.len()); + assert!(addr.starts_with('M'), "M address must start with 'M'"); + } + + #[test] + fn c_address_has_correct_length() { + let mut rng = seeded(); + let addr = random_valid_address(AddressKind::C, &mut rng); + assert_eq!(addr.len(), 56, "C address must be 56 chars, got {}", addr.len()); + assert!(addr.starts_with('C'), "C address must start with 'C'"); + } + + #[test] + fn m_address_round_trips_muxed_id() { + let mut rng = seeded(); + // Generate many M addresses to cover varied muxed-id values. + for _ in 0..256 { + let addr = random_valid_address(AddressKind::M, &mut rng); + let parsed = prism_core::address::parse(&addr).expect("M address must parse"); + assert!( + parsed.muxed_id().is_some(), + "parsed M address must carry a muxed_id" + ); + } + } + + #[test] + fn all_kinds_parse_successfully_batch() { + let mut rng = seeded(); + for _ in 0..500 { + for &kind in &[AddressKind::G, AddressKind::M, AddressKind::C] { + // The round-trip check inside random_valid_address will + // panic on any failure — no extra assertion needed here. + let _ = random_valid_address(kind, &mut rng); + } + } + } + + #[test] + fn g_address_uses_all_32_payload_bytes() { + // Different seeds must produce different addresses (not all zeros). + let mut rng1 = StdRng::seed_from_u64(1); + let mut rng2 = StdRng::seed_from_u64(2); + let a1 = random_valid_address(AddressKind::G, &mut rng1); + let a2 = random_valid_address(AddressKind::G, &mut rng2); + assert_ne!(a1, a2, "different seeds must produce different addresses"); + } + + #[test] + fn boundary_muxed_ids_round_trip() { + // Verify that the boundary u64 values (0, u64::MAX) encode and decode + // correctly, exercising the full range that the spec mandates. + for id in [0u64, 1, u64::MAX / 2, u64::MAX - 1, u64::MAX] { + let key = [0u8; 32]; + // fixed key for reproducibility + let mut body = Vec::with_capacity(40); + body.extend_from_slice(&id.to_be_bytes()); + body.extend_from_slice(&key); + let addr = build_strkey(VERSION_M, &body); + let parsed = prism_core::address::parse(&addr) + .unwrap_or_else(|e| panic!("boundary id {id} failed: {e}")); + assert_eq!( + parsed.muxed_id(), + Some(id), + "muxed_id round-trip failed for id={id}" + ); + } + } +} diff --git a/examples/rust-address-fuzzer/src/main.rs b/examples/rust-address-fuzzer/src/main.rs index 63658cef..9b6a4a8b 100644 --- a/examples/rust-address-fuzzer/src/main.rs +++ b/examples/rust-address-fuzzer/src/main.rs @@ -1,3 +1,5 @@ +mod generate; +mod mutators; mod parse; mod report; @@ -5,6 +7,7 @@ use std::io::{self, BufRead}; use std::path::PathBuf; use clap::Parser as ClapParser; +use prism_core::address::AddressKind; use rand::rngs::StdRng; use rand::{Rng, SeedableRng}; @@ -86,8 +89,21 @@ fn main() { fn run_random(rng: &mut StdRng, n: usize, verbose: bool) -> Stats { let mut stats = Stats::default(); - for _ in 0..n { - fuzz_one(&random_string(rng), verbose, &mut stats); + for i in 0..n { + // Every 4th input is a valid seed address so the fuzzer exercises the + // boundary of validity rather than spending all its budget on obvious + // garbage. The other 3 out of 4 are random strings as before. + let input = if i % 4 == 0 { + let kind = match i % 12 { + 0 => AddressKind::G, + 4 => AddressKind::M, + _ => AddressKind::C, + }; + generate::random_valid_address(kind, rng) + } else { + random_string(rng) + }; + fuzz_one(&input, verbose, &mut stats); } stats } @@ -154,7 +170,7 @@ fn random_string(rng: &mut StdRng) -> String { _ => 'C', }; let target_len: usize = if prefix == 'M' { 69 } else { 56 }; - let len = target_len.saturating_add_signed(rng.gen_range(-4..=4)); + let len = target_len.saturating_add_signed(rng.gen_range(-4isize..=4)); let body: String = (0..len.saturating_sub(1)) .map(|_| STRKEY_ALPHABET[rng.gen_range(0..STRKEY_ALPHABET.len())] as char) .collect(); diff --git a/examples/rust-address-fuzzer/src/mutators/length.rs b/examples/rust-address-fuzzer/src/mutators/length.rs new file mode 100644 index 00000000..a4bd378a --- /dev/null +++ b/examples/rust-address-fuzzer/src/mutators/length.rs @@ -0,0 +1,215 @@ +use rand::Rng; + +/// Base32 alphabet used by StrKey (RFC 4648 without padding). +const STRKEY_ALPHABET: &[u8] = b"ABCDEFGHIJKLMNOPQRSTUVWXYZ234567"; + +/// Truncates a random number of trailing characters from `addr`. +/// +/// At least 1 character (and no more than half the address length) is removed. +/// The resulting string is guaranteed to have a different length than the +/// original, so the parser must reject it. +pub fn truncate(addr: &str, rng: &mut impl Rng) -> String { + let len = addr.len(); + // Remove between 1 and max(1, len/2) trailing chars + let max_remove = (len / 2).max(1); + let remove = rng.gen_range(1..=max_remove); + let truncated_len = len.saturating_sub(remove); + addr[..truncated_len].to_string() +} + +/// Appends random base32 characters to `addr`. +/// +/// Between 1 and 16 extra characters are added, guaranteeing the result is +/// too long for any valid Stellar address. +pub fn pad(addr: &str, rng: &mut impl Rng) -> String { + let extra = rng.gen_range(1..=16); + let suffix: String = (0..extra) + .map(|_| STRKEY_ALPHABET[rng.gen_range(0..STRKEY_ALPHABET.len())] as char) + .collect(); + format!("{addr}{suffix}") +} + +#[cfg(test)] +mod tests { + use super::*; + use rand::rngs::StdRng; + use rand::SeedableRng; + + // Valid addresses from the spec test vectors. + const VALID_G: &str = "GAYCUYT553C5LHVE2XPW5GMEJT4BXGM7AHMJWLAPZP53KJO7EIQADRSI"; + const VALID_M: &str = "MAYCUYT553C5LHVE2XPW5GMEJT4BXGM7AHMJWLAPZP53KJO7EIQACAAAAAAAAAAAAD672"; + + // ------------------------------------------------------------------ + // setup guard: verify base addresses are parseable first + // ------------------------------------------------------------------ + + #[test] + fn base_addresses_are_valid() { + assert!( + prism_core::address::parse(VALID_G).is_ok(), + "VALID_G must be parseable: {VALID_G}" + ); + assert!( + prism_core::address::parse(VALID_M).is_ok(), + "VALID_M must be parseable: {VALID_M}" + ); + } + + // ------------------------------------------------------------------ + // truncate sanity checks + // ------------------------------------------------------------------ + + #[test] + fn truncate_produces_shorter_string() { + let mut rng = StdRng::seed_from_u64(42); + let result = truncate(VALID_G, &mut rng); + assert!( + result.len() < VALID_G.len(), + "truncated string must be shorter ({} vs {})", + result.len(), + VALID_G.len() + ); + } + + #[test] + fn truncate_preserves_prefix() { + let mut rng = StdRng::seed_from_u64(42); + let result = truncate(VALID_G, &mut rng); + assert_eq!(&result[..1], "G", "prefix must be preserved"); + } + + // ------------------------------------------------------------------ + // truncate: every seed must produce Err (no panic, no Ok) + // ------------------------------------------------------------------ + + #[test] + fn truncate_g_always_err_no_panic() { + for seed in 0..200 { + let mut rng = StdRng::seed_from_u64(seed); + let result = truncate(VALID_G, &mut rng); + let parse_out = std::panic::catch_unwind(std::panic::AssertUnwindSafe(|| { + prism_core::address::parse(&result) + })); + match parse_out { + Ok(Err(_)) => {} // expected + Ok(Ok(addr)) => { + panic!( + "truncated G input {result:?} (len={}, seed={seed}) parsed as OK({addr:?})", + result.len() + ) + } + Err(_) => { + panic!( + "truncated G input {result:?} (len={}, seed={seed}) caused a panic", + result.len() + ) + } + } + } + } + + #[test] + fn truncate_m_always_err_no_panic() { + for seed in 0..200 { + let mut rng = StdRng::seed_from_u64(seed); + let result = truncate(VALID_M, &mut rng); + let parse_out = std::panic::catch_unwind(std::panic::AssertUnwindSafe(|| { + prism_core::address::parse(&result) + })); + match parse_out { + Ok(Err(_)) => {} // expected + Ok(Ok(addr)) => { + panic!( + "truncated M input {result:?} (len={}, seed={seed}) parsed as OK({addr:?})", + result.len() + ) + } + Err(_) => { + panic!( + "truncated M input {result:?} (len={}, seed={seed}) caused a panic", + result.len() + ) + } + } + } + } + + // ------------------------------------------------------------------ + // pad sanity checks + // ------------------------------------------------------------------ + + #[test] + fn pad_produces_longer_string() { + let mut rng = StdRng::seed_from_u64(42); + let result = pad(VALID_G, &mut rng); + assert!( + result.len() > VALID_G.len(), + "padded string must be longer ({} vs {})", + result.len(), + VALID_G.len() + ); + } + + #[test] + fn pad_preserves_prefix() { + let mut rng = StdRng::seed_from_u64(42); + let result = pad(VALID_G, &mut rng); + assert_eq!(&result[..1], "G", "prefix must be preserved"); + } + + // ------------------------------------------------------------------ + // pad: every seed must produce Err (no panic, no Ok) + // ------------------------------------------------------------------ + + #[test] + fn pad_g_always_err_no_panic() { + for seed in 0..200 { + let mut rng = StdRng::seed_from_u64(seed); + let result = pad(VALID_G, &mut rng); + let parse_out = std::panic::catch_unwind(std::panic::AssertUnwindSafe(|| { + prism_core::address::parse(&result) + })); + match parse_out { + Ok(Err(_)) => {} // expected + Ok(Ok(addr)) => { + panic!( + "padded G input {result:?} (len={}, seed={seed}) parsed as OK({addr:?})", + result.len() + ) + } + Err(_) => { + panic!( + "padded G input {result:?} (len={}, seed={seed}) caused a panic", + result.len() + ) + } + } + } + } + + #[test] + fn pad_m_always_err_no_panic() { + for seed in 0..200 { + let mut rng = StdRng::seed_from_u64(seed); + let result = pad(VALID_M, &mut rng); + let parse_out = std::panic::catch_unwind(std::panic::AssertUnwindSafe(|| { + prism_core::address::parse(&result) + })); + match parse_out { + Ok(Err(_)) => {} // expected + Ok(Ok(addr)) => { + panic!( + "padded M input {result:?} (len={}, seed={seed}) parsed as OK({addr:?})", + result.len() + ) + } + Err(_) => { + panic!( + "padded M input {result:?} (len={}, seed={seed}) caused a panic", + result.len() + ) + } + } + } + } +} diff --git a/examples/rust-address-fuzzer/src/mutators/mod.rs b/examples/rust-address-fuzzer/src/mutators/mod.rs new file mode 100644 index 00000000..e0dee59e --- /dev/null +++ b/examples/rust-address-fuzzer/src/mutators/mod.rs @@ -0,0 +1,4 @@ +// Functions are exported for external use; unused in this binary crate. +#![allow(dead_code)] +pub mod length; +pub mod version; diff --git a/examples/rust-address-fuzzer/src/mutators/version.rs b/examples/rust-address-fuzzer/src/mutators/version.rs new file mode 100644 index 00000000..88b8a786 --- /dev/null +++ b/examples/rust-address-fuzzer/src/mutators/version.rs @@ -0,0 +1,579 @@ +//! Version-byte swap mutator for Stellar StrKey addresses. +//! +//! The leading version byte is the only field that distinguishes address types: +//! G (account) → VERSION_G = 6<<3 = 48 → base-32 first char 'G' +//! M (muxed) → VERSION_M = 12<<3 = 96 → base-32 first char 'M' +//! C (contract) → VERSION_C = 2<<3 = 16 → base-32 first char 'C' +//! seed (secret) → VERSION_S = 18<<3 = 144 → base-32 first char 'S' +//! +//! Because the parser dispatches on the *leading base-32 character* before +//! inspecting the decoded version byte, a swap MUST produce one of: +//! - `InvalidChecksum` (version byte changed → CRC mismatch; same leading char) +//! - `UnknownPrefix` (if the new leading char is outside G/M/C) +//! - `InvalidLength` (if re-encoded length doesn't match the new prefix's expectation) +//! - correct re-parse (only when the new version byte encodes to the same leading char +//! AND produces a valid CRC AND the new kind matches) +//! +//! A *misclassification* is any outcome where the parser returns `Ok` with a +//! different `AddressKind` than what the freshly-encoded string claims. +//! That must never happen silently. + +use rand::Rng; + +use prism_core::address::{parse, AddressKind, ParseError}; + +// ── Well-known version bytes ────────────────────────────────────────────────── + +/// Every strkey version byte recognised by the Stellar protocol. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum KnownVersion { + /// Public key (G-address): 6<<3 = 48 + Account, + /// Muxed account (M-address): 12<<3 = 96 + Muxed, + /// Contract (C-address): 2<<3 = 16 + Contract, + /// Secret seed (S-address): 18<<3 = 144 — included so the fuzzer can + /// verify the parser *rejects* seeds fed as public-key addresses. + Seed, +} + +impl KnownVersion { + pub const fn byte(self) -> u8 { + match self { + Self::Account => 6 << 3, // 48 + Self::Muxed => 12 << 3, // 96 + Self::Contract => 2 << 3, // 16 + Self::Seed => 18 << 3, // 144 + } + } + + /// All known version bytes, in a fixed order so tests are deterministic. + pub const ALL: [KnownVersion; 4] = [ + KnownVersion::Account, + KnownVersion::Muxed, + KnownVersion::Contract, + KnownVersion::Seed, + ]; +} + +// ── Sub-case classification ─────────────────────────────────────────────────── + +/// The kind of version byte injected by `swap_version_byte`. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum InjectedVersion { + /// The replacement is one of the four protocol-defined version bytes. + Known(KnownVersion), + /// The replacement is *not* any protocol-defined byte (garbage byte). + Invalid(u8), +} + +impl InjectedVersion { + pub fn byte(self) -> u8 { + match self { + Self::Known(kv) => kv.byte(), + Self::Invalid(b) => b, + } + } + + pub fn is_invalid(self) -> bool { + matches!(self, Self::Invalid(_)) + } +} + +// ── Outcome of one swap attempt ─────────────────────────────────────────────── + +/// Result of calling `swap_version_byte` on one address. +#[derive(Debug)] +pub struct SwapResult { + /// The original input address. + pub original: String, + /// The kind reported by parsing the original (for logging/assertions). + pub original_kind: AddressKind, + /// The version byte injected (sub-case). + pub injected: InjectedVersion, + /// The re-encoded string that was handed to the parser. + pub mutated: String, + /// What the parser said. + pub outcome: Result, +} + +impl SwapResult { + /// Returns `true` if this swap constitutes a **silent misclassification**: + /// the parser accepted the mutated address but reported a kind that does + /// not match the leading character of the re-encoded string. + pub fn is_misclassification(&self) -> bool { + if let Ok(reported_kind) = self.outcome { + let leading = self.mutated.chars().next().unwrap_or('\0'); + let expected_kind_for_prefix = match leading { + 'G' => Some(AddressKind::G), + 'M' => Some(AddressKind::M), + 'C' => Some(AddressKind::C), + _ => None, // unknown prefix – parser should have rejected it + }; + match expected_kind_for_prefix { + None => true, // parser accepted an unknown-prefix string + Some(expected) => reported_kind != expected, + } + } else { + false // rejection is always fine + } + } + + /// Returns `true` if the mutated address was **rejected** (any error). + pub fn is_rejected(&self) -> bool { + self.outcome.is_err() + } +} + +// ── Core encode/decode helpers ──────────────────────────────────────────────── + +/// Decode a Stellar StrKey string into raw bytes (version || payload || crc16). +/// Returns `None` if the string contains non-base-32 characters or is too short. +fn strkey_decode(addr: &str) -> Option> { + let s = addr.to_uppercase(); + let s = s.as_bytes(); + let mut bits: u32 = 0; + let mut bit_count: u32 = 0; + let mut out = Vec::with_capacity(s.len() * 5 / 8 + 1); + for &ch in s { + let val: u8 = match ch { + b'A'..=b'Z' => ch - b'A', + b'2'..=b'7' => ch - b'2' + 26, + _ => return None, + }; + bits = (bits << 5) | (val as u32); + bit_count += 5; + if bit_count >= 8 { + bit_count -= 8; + out.push((bits >> bit_count) as u8); + bits &= (1 << bit_count) - 1; + } + } + if out.len() < 3 { None } else { Some(out) } +} + +/// Encode raw bytes (version || payload || crc16) back to a StrKey string. +fn strkey_encode(data: &[u8]) -> String { + const ALPHA: &[u8] = b"ABCDEFGHIJKLMNOPQRSTUVWXYZ234567"; + let mut out = String::with_capacity(data.len() * 8 / 5 + 2); + let mut bits: u32 = 0; + let mut bit_count: u32 = 0; + for &byte in data { + bits = (bits << 8) | (byte as u32); + bit_count += 8; + while bit_count >= 5 { + bit_count -= 5; + out.push(ALPHA[((bits >> bit_count) & 0x1F) as usize] as char); + } + } + if bit_count > 0 { + out.push(ALPHA[((bits << (5 - bit_count)) & 0x1F) as usize] as char); + } + out +} + +/// CRC-16/XMODEM variant used by Stellar StrKey. +fn crc16(data: &[u8]) -> u16 { + let mut crc: u16 = 0x0000; + for &byte in data { + let mut x = (crc >> 8) ^ (byte as u16); + x ^= x >> 4; + crc = (crc << 8) ^ (x << 12) ^ (x << 5) ^ x; + } + crc +} + +/// Re-encode `payload_without_version` with `new_version` and a fresh CRC-16. +/// +/// Layout written: `new_version || payload_without_version || crc16_le` +fn reencode_with_version(payload_without_version: &[u8], new_version: u8) -> String { + let mut data: Vec = Vec::with_capacity(1 + payload_without_version.len() + 2); + data.push(new_version); + data.extend_from_slice(payload_without_version); + let crc = crc16(&data); + data.push((crc & 0xFF) as u8); + data.push((crc >> 8) as u8); + strkey_encode(&data) +} + +// ── Public mutator ──────────────────────────────────────────────────────────── + +/// Decode `addr`, replace the version byte with a randomly-chosen value, and +/// re-encode with a fresh checksum. +/// +/// The replacement is drawn from one of **two sub-cases** chosen with equal +/// probability: +/// +/// * **Known version byte** – one of `{48, 96, 16, 144}`, chosen uniformly at +/// random (may be the same as the original; that's intentional). +/// * **Invalid version byte** – any `u8` value that is *not* one of the four +/// known bytes above. +/// +/// Returns `None` if `addr` cannot be decoded (non-base-32 characters, too +/// short, etc.). +pub fn swap_version_byte(addr: &str, rng: &mut impl Rng) -> Option { + // Decode the address and identify the original kind. + let decoded = strkey_decode(addr)?; + if decoded.len() < 3 { + return None; + } + + let original_kind = match parse(addr) { + Ok(a) => a.kind(), + Err(_) => return None, // only mutate addresses the parser accepts + }; + + // Extract the payload bytes that sit between version and CRC. + // Layout: decoded[0] = version, decoded[1..len-2] = payload, decoded[len-2..] = crc + let payload_without_version = &decoded[1..decoded.len() - 2]; + + // Pick the sub-case. + let injected = if rng.gen_bool(0.5) { + // Known version byte + let kv = KnownVersion::ALL[rng.gen_range(0..KnownVersion::ALL.len())]; + InjectedVersion::Known(kv) + } else { + // Invalid version byte – keep drawing until we land on something that + // isn't one of the four known values. + let known_bytes: [u8; 4] = KnownVersion::ALL.map(|kv| kv.byte()); + let b = loop { + let candidate: u8 = rng.gen(); + if !known_bytes.contains(&candidate) { + break candidate; + } + }; + InjectedVersion::Invalid(b) + }; + + let new_version = injected.byte(); + let mutated = reencode_with_version(payload_without_version, new_version); + + let outcome = parse(&mutated).map(|a| a.kind()); + + Some(SwapResult { + original: addr.to_uppercase(), + original_kind, + injected, + mutated, + outcome, + }) +} + +/// Convenience wrapper that asserts no silent misclassification occurred. +/// +/// A misclassification is when the parser returns `Ok(kind)` but that `kind` +/// does not match the prefix character of the re-encoded string – this would +/// mean routing logic could silently send funds to the wrong account type. +/// +/// # Panics +/// Panics with a descriptive message if a misclassification is detected. +pub fn assert_no_misclassification(result: &SwapResult) { + assert!( + !result.is_misclassification(), + "MISCLASSIFICATION DETECTED!\n\ + original: {:?} (kind={:?})\n\ + injected byte: {:?} ({:?})\n\ + mutated: {:?}\n\ + parser said: {:?}\n\ + The parser returned Ok but the reported kind does not match the \ + leading prefix character of the mutated string.", + result.original, + result.original_kind, + result.injected.byte(), + result.injected, + result.mutated, + result.outcome, + ); +} + +// ── Tests ───────────────────────────────────────────────────────────────────── + +#[cfg(test)] +mod tests { + use super::*; + use rand::rngs::StdRng; + use rand::SeedableRng; + + // ── Fixtures ────────────────────────────────────────────────────────────── + + /// A valid G-address (verified against the Stellar reference decoder). + const VALID_G: &str = "GAYCUYT553C5LHVE2XPW5GMEJT4BXGM7AHMJWLAPZP53KJO7EIQADRSI"; + /// A valid C-address. + const VALID_C: &str = "CA7QYNF7SOWQ3GLR2BGMZEHXAVIRZA4KVWLT7AV7Y6S33Z6S3CHBAAAAAAAAAAAAABQD"; + /// A valid M-address. + const VALID_M: &str = "MA7QYNF7SOWQ3GLR2BGMZEHXAVIRZA4KVWLT7AV7Y6S33Z6S3CHBAAAAAAAAAAAAABQD"; + + fn seeded_rng(seed: u64) -> StdRng { + StdRng::seed_from_u64(seed) + } + + // ── Helper: encode helpers round-trip ───────────────────────────────────── + + #[test] + fn strkey_encode_decode_roundtrip() { + let original = strkey_decode(VALID_G).expect("valid G address must decode"); + let re_encoded = strkey_encode(&original); + assert_eq!(re_encoded, VALID_G, "round-trip must produce the same string"); + } + + // ── Known version byte swaps ─────────────────────────────────────────────── + + #[test] + fn swap_same_version_byte_still_accepted_as_same_kind() { + // Re-encoding with the *same* version byte must produce the original string, + // so parse() must still return Ok with the same kind. + let decoded = strkey_decode(VALID_G).unwrap(); + let payload = &decoded[1..decoded.len() - 2]; + let re = reencode_with_version(payload, KnownVersion::Account.byte()); + assert_eq!(re, VALID_G, "same version byte must reproduce the original"); + + let result = parse(&re).expect("same-version re-encode must still parse"); + assert_eq!(result.kind(), AddressKind::G); + } + + #[test] + fn swap_g_version_to_m_version_is_rejected() { + // G → M: the payload length is correct for a G address (32 bytes of key). + // A valid M-address needs 40 additional bytes for the muxed ID, so the + // parser should reject this with InvalidLength or InvalidMuxedPayload. + let decoded = strkey_decode(VALID_G).unwrap(); + let payload = &decoded[1..decoded.len() - 2]; + let mutated = reencode_with_version(payload, KnownVersion::Muxed.byte()); + let outcome = parse(&mutated); + assert!( + outcome.is_err(), + "G payload with M version byte must be rejected; got Ok({:?})", + outcome.unwrap().kind() + ); + } + + #[test] + fn swap_g_version_to_c_version_is_reclassified_correctly() { + // G → C: both are 56-char encodings, so the length check passes. + // A C-address has no additional payload constraints beyond the version byte, + // so re-encoding a G payload with VERSION_C and a fresh CRC produces a + // *valid* C-address. The parser must either: + // (a) accept it as AddressKind::C (correct reclassification), or + // (b) reject it with an error. + // What it must NOT do is return Ok with a kind that doesn't match the + // leading 'C' prefix — that would be a silent misclassification. + let decoded = strkey_decode(VALID_G).unwrap(); + let payload = &decoded[1..decoded.len() - 2]; + let mutated = reencode_with_version(payload, KnownVersion::Contract.byte()); + + assert!( + mutated.starts_with('C'), + "re-encoding with VERSION_C must produce a C-prefixed string" + ); + + let outcome = parse(&mutated); + match &outcome { + Ok(addr) => { + // Accepted — must be classified as C, not some other kind. + assert_eq!( + addr.kind(), + AddressKind::C, + "parser accepted mutated address but returned wrong kind; \ + expected C (matching leading prefix 'C'), got {:?}", + addr.kind() + ); + } + Err(_) => { + // Clean rejection is also fine. + } + } + } + + #[test] + fn swap_g_version_to_seed_version_is_rejected() { + // Seed (S) version byte is not a recognised prefix → UnknownPrefix. + let decoded = strkey_decode(VALID_G).unwrap(); + let payload = &decoded[1..decoded.len() - 2]; + let mutated = reencode_with_version(payload, KnownVersion::Seed.byte()); + let outcome = parse(&mutated); + assert!( + outcome.is_err(), + "G payload with seed version byte must be rejected; got Ok({:?})", + outcome.unwrap().kind() + ); + } + + #[test] + fn swap_c_version_to_g_version_is_correctly_handled() { + // C → G: both are 56-char encodings. A C payload (32-byte contract hash) + // re-encoded with VERSION_G and a fresh CRC is a structurally valid G-address. + // The parser must either accept it as G (correct reclassification) or + // reject it cleanly — never return Ok with the wrong kind. + let decoded = strkey_decode(VALID_C).unwrap(); + let payload = &decoded[1..decoded.len() - 2]; + let mutated = reencode_with_version(payload, KnownVersion::Account.byte()); + + assert!( + mutated.starts_with('G'), + "re-encoding with VERSION_G must produce a G-prefixed string" + ); + + let outcome = parse(&mutated); + match &outcome { + Ok(addr) => { + assert_eq!( + addr.kind(), + AddressKind::G, + "parser accepted mutated address but returned wrong kind; \ + expected G (matching leading prefix 'G'), got {:?}", + addr.kind() + ); + } + Err(_) => { /* clean rejection is fine */ } + } + } + + #[test] + fn swap_m_version_to_g_version_is_correctly_handled() { + // M → G: the M payload is much larger (40+ bytes), so re-encoding with + // VERSION_G produces a much longer string than 56 chars. The parser + // will reject with InvalidLength. + let decoded = strkey_decode(VALID_M).unwrap(); + let payload = &decoded[1..decoded.len() - 2]; + let mutated = reencode_with_version(payload, KnownVersion::Account.byte()); + let outcome = parse(&mutated); + assert!( + outcome.is_err(), + "M payload with G version byte must be rejected due to length mismatch; got Ok({:?})", + outcome.as_ref().unwrap().kind() + ); + } + + // ── Invalid version byte sub-case ───────────────────────────────────────── + + #[test] + fn invalid_version_bytes_are_always_rejected_or_cleanly_classified() { + let known: [u8; 4] = KnownVersion::ALL.map(|kv| kv.byte()); + let decoded = strkey_decode(VALID_G).unwrap(); + let payload = &decoded[1..decoded.len() - 2]; + + // Sweep all 256 possible version bytes. + for v in 0u8..=255 { + let mutated = reencode_with_version(payload, v); + let outcome = parse(&mutated); + + if known.contains(&v) { + // Known version byte: either cleanly rejected or correctly classified. + // We just verify there's no misclassification. + if let Ok(addr) = &outcome { + let reported = addr.kind(); + let leading = mutated.chars().next().unwrap_or('\0'); + let expected = match leading { + 'G' => Some(AddressKind::G), + 'M' => Some(AddressKind::M), + 'C' => Some(AddressKind::C), + _ => None, + }; + assert_eq!( + Some(reported), + expected, + "known version byte 0x{v:02x}: parser returned Ok({reported:?}) but \ + leading char is {leading:?}" + ); + } + } else { + // Invalid version byte: must ALWAYS be rejected. + assert!( + outcome.is_err(), + "invalid version byte 0x{v:02x}: parser returned Ok({:?}) for \ + mutated string {mutated:?} — silent misclassification!", + outcome.as_ref().unwrap().kind() + ); + } + } + } + + // ── swap_version_byte() high-level API ──────────────────────────────────── + + #[test] + fn swap_version_byte_returns_none_for_invalid_input() { + let mut rng = seeded_rng(0); + assert!(swap_version_byte("not-valid!!!", &mut rng).is_none()); + assert!(swap_version_byte("", &mut rng).is_none()); + } + + #[test] + fn swap_version_byte_never_misclassifies_g_address() { + let mut rng = seeded_rng(42); + for _ in 0..1000 { + if let Some(result) = swap_version_byte(VALID_G, &mut rng) { + assert_no_misclassification(&result); + } + } + } + + #[test] + fn swap_version_byte_never_misclassifies_c_address() { + let mut rng = seeded_rng(99); + for _ in 0..1000 { + if let Some(result) = swap_version_byte(VALID_C, &mut rng) { + assert_no_misclassification(&result); + } + } + } + + #[test] + fn swap_version_byte_never_misclassifies_m_address() { + let mut rng = seeded_rng(7); + for _ in 0..1000 { + if let Some(result) = swap_version_byte(VALID_M, &mut rng) { + assert_no_misclassification(&result); + } + } + } + + #[test] + fn swap_version_byte_invalid_sub_case_always_rejected() { + // When an *invalid* (non-protocol) byte is injected, the parser must + // always reject the result — there is no valid address type for it. + let mut rng = seeded_rng(123); + for _ in 0..2000 { + if let Some(result) = swap_version_byte(VALID_G, &mut rng) { + if result.injected.is_invalid() { + assert!( + result.is_rejected(), + "invalid version byte {:?} must be rejected; got Ok({:?}) for {:?}", + result.injected, + result.outcome.as_ref().unwrap(), + result.mutated, + ); + } + } + } + } + + #[test] + fn swap_version_byte_known_sub_case_never_misclassifies() { + let mut rng = seeded_rng(555); + for _ in 0..2000 { + if let Some(result) = swap_version_byte(VALID_G, &mut rng) { + if !result.injected.is_invalid() { + assert_no_misclassification(&result); + } + } + } + } + + #[test] + fn result_fields_are_consistent() { + let mut rng = seeded_rng(77); + let result = swap_version_byte(VALID_G, &mut rng) + .expect("valid address must produce a result"); + // Original must round-trip as the uppercased form. + assert_eq!(result.original, VALID_G.to_uppercase()); + assert_eq!(result.original_kind, AddressKind::G); + // The injected byte must appear in the decoded mutated string. + let decoded = strkey_decode(&result.mutated).expect("mutated string must decode"); + assert_eq!( + decoded[0], + result.injected.byte(), + "first decoded byte must be the injected version byte" + ); + } +} diff --git a/examples/rust-address-fuzzer/src/parse.rs b/examples/rust-address-fuzzer/src/parse.rs index b65e6bfc..bfb8ea6d 100644 --- a/examples/rust-address-fuzzer/src/parse.rs +++ b/examples/rust-address-fuzzer/src/parse.rs @@ -11,6 +11,7 @@ mod tests { #[test] fn parses_valid_g_address() { + // Verified against the stellar-strkey reference decoder (0.0.18). let result = parse("GAYCUYT553C5LHVE2XPW5GMEJT4BXGM7AHMJWLAPZP53KJO7EIQADRSI"); assert!(result.is_ok(), "unexpected error: {result:?}"); assert_eq!(result.unwrap().kind(), prism_core::address::AddressKind::G); diff --git a/examples/rust-address-fuzzer/src/report.rs b/examples/rust-address-fuzzer/src/report.rs index c1af2dda..af57059c 100644 --- a/examples/rust-address-fuzzer/src/report.rs +++ b/examples/rust-address-fuzzer/src/report.rs @@ -1,3 +1,4 @@ +#[derive(Default)] pub struct Report { pub inputs_run: usize, pub findings_count: usize, diff --git a/spec/vectors.json b/spec/vectors.json index 566a99b5..97103b43 100644 --- a/spec/vectors.json +++ b/spec/vectors.json @@ -73,10 +73,7 @@ "baseG": "GAYCUYT553C5LHVE2XPW5GMEJT4BXGM7AHMJWLAPZP53KJO7EIQADRSI", "id": "0" }, - "tags": [ - "positive", - "edge" - ] + "tags": ["positive", "edge"] }, { "module": "muxed_decode", @@ -89,10 +86,7 @@ "baseG": "GAYCUYT553C5LHVE2XPW5GMEJT4BXGM7AHMJWLAPZP53KJO7EIQADRSI", "id": "1" }, - "tags": [ - "positive", - "edge" - ] + "tags": ["positive", "edge"] }, { "module": "muxed_decode", @@ -105,11 +99,7 @@ "baseG": "GAYCUYT553C5LHVE2XPW5GMEJT4BXGM7AHMJWLAPZP53KJO7EIQADRSI", "id": "9007199254740992" }, - "tags": [ - "positive", - "edge", - "interop" - ] + "tags": ["positive", "edge", "interop"] }, { "module": "muxed_decode", @@ -122,11 +112,7 @@ "baseG": "GAYCUYT553C5LHVE2XPW5GMEJT4BXGM7AHMJWLAPZP53KJO7EIQADRSI", "id": "9007199254740993" }, - "tags": [ - "positive", - "edge", - "interop" - ] + "tags": ["positive", "edge", "interop"] }, { "module": "muxed_decode", @@ -139,11 +125,7 @@ "baseG": "GAYCUYT553C5LHVE2XPW5GMEJT4BXGM7AHMJWLAPZP53KJO7EIQADRSI", "id": "18446744073709551615" }, - "tags": [ - "positive", - "edge", - "interop" - ] + "tags": ["positive", "edge", "interop"] }, { "module": "muxed_decode", @@ -154,10 +136,7 @@ "expected": { "expected_error": "invalid encoded string" }, - "tags": [ - "negative", - "edge" - ] + "tags": ["negative", "edge"] }, { "module": "detect", @@ -180,10 +159,7 @@ } ] }, - "tags": [ - "negative", - "edge" - ] + "tags": ["negative", "edge"] }, { "module": "extract_routing", @@ -198,10 +174,7 @@ "routingSource": "muxed", "warnings": [] }, - "tags": [ - "positive", - "interop" - ] + "tags": ["positive", "interop"] }, { "module": "extract_routing", @@ -225,9 +198,7 @@ } ] }, - "tags": [ - "negative" - ] + "tags": ["negative"] }, { "module": "extract_routing", @@ -243,9 +214,7 @@ "routingSource": "memo", "warnings": [] }, - "tags": [ - "positive" - ] + "tags": ["positive"] }, { "module": "extract_routing", @@ -261,10 +230,7 @@ "routingSource": "memo", "warnings": [] }, - "tags": [ - "positive", - "edge" - ] + "tags": ["positive", "edge"] }, { "module": "extract_routing", @@ -280,9 +246,7 @@ "routingSource": "memo", "warnings": [] }, - "tags": [ - "positive" - ] + "tags": ["positive"] }, { "module": "extract_routing", @@ -308,10 +272,64 @@ } ] }, - "tags": [ - "negative", - "edge" - ] + "tags": ["negative", "edge"] + }, + { + "module": "detect", + "description": "non-base32 digit '0' injected into address must be rejected", + "input": { + "address": "GA0CUYT553C5LHVE2XPW5GMEJT4BXGM7AHMJWLAPZP53KJO7EIQADRSI" + }, + "expected": { + "kind": null, + "address": null, + "warnings": [ + { + "code": "INVALID_STRKEY", + "severity": "error", + "message": "address contains characters outside the base32 alphabet" + } + ] + }, + "tags": ["negative", "edge"] + }, + { + "module": "detect", + "description": "punctuation injected into address must be rejected", + "input": { + "address": "GA!CUYT553C5LHVE2XPW5GMEJT4BXGM7AHMJWLAPZP53KJO7EIQADRSI" + }, + "expected": { + "kind": null, + "address": null, + "warnings": [ + { + "code": "INVALID_STRKEY", + "severity": "error", + "message": "address contains characters outside the base32 alphabet" + } + ] + }, + "tags": ["negative", "edge"] + }, + { + "module": "detect", + "description": "embedded null byte must be rejected without crashing or truncating", + "input": { + "address": "GAYCUYT553C5LHVE2XPW5GMEJT4BXGM7AHMJWLAPZP53KJO7EIQADRS\u0000" + }, + "expected": { + "kind": null, + "address": null, + "warnings": [ + { + "code": "INVALID_STRKEY", + "severity": "error", + "message": "address contains characters outside the base32 alphabet" + } + ] + }, + "tags": ["negative", "edge"] } ] -} +} \ No newline at end of file