diff --git a/Cargo.lock b/Cargo.lock index 53fc6a7..67ba8a4 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -2631,6 +2631,7 @@ dependencies = [ "rusqlite", "safelog", "serde_json", + "sha3 0.10.9", "snow", "tempfile", "tokio", diff --git a/Cargo.toml b/Cargo.toml index 9131d4b..342d15c 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -43,6 +43,7 @@ snow = "0.10" serde_json = "1" argon2 = "0.5" chacha20poly1305 = "0.10" +sha3 = "0.10" [dev-dependencies] tempfile = "3" diff --git a/src/pay.rs b/src/pay.rs index faae2dd..2f79d43 100644 --- a/src/pay.rs +++ b/src/pay.rs @@ -1,4 +1,5 @@ use ed25519_dalek::{Signature, Signer, SigningKey, Verifier, VerifyingKey}; +use sha3::{Digest, Keccak256}; pub type Result = std::result::Result; @@ -35,19 +36,72 @@ const INV_PREFIX: &[u8] = b"inv "; const RCP_PREFIX: &[u8] = b"rcp "; const PICONERO: u128 = 1_000_000_000_000; +/// Bitcoin-style alphabet; Monero encodes 8-byte blocks (11 chars), not raw base58. +const B58: &[u8] = b"123456789ABCDEFGHJKLMNPQRSTUVWXYZabcdefghijkmnopqrstuvwxyz"; +const B58_ENC_LEN: [usize; 9] = [0, 2, 3, 5, 6, 7, 9, 10, 11]; + pub fn check_address(addr: &str) -> Result<()> { - let ok = match addr.as_bytes().first() { - Some(b'4') if addr.len() == 95 || addr.len() == 106 => true, - Some(b'8') if addr.len() == 95 => true, + let raw = decode_monero_b58(addr).ok_or_else(|| Error("invalid Monero address".into()))?; + if raw.len() != 69 && raw.len() != 77 { + return Err(Error("invalid Monero address".into())); + } + let (payload, ck) = raw.split_at(raw.len() - 4); + let hash = Keccak256::digest(payload); + if hash.as_slice().get(..4) != Some(ck) { + return Err(Error("invalid Monero address".into())); + } + let ok = match (payload[0], raw.len()) { + (18 | 24 | 42 | 36, 69) => true, // mainnet/stagenet standard + subaddress + (19 | 25, 77) => true, // mainnet/stagenet integrated _ => false, }; - if ok && !addr.contains('\n') { + if ok { Ok(()) } else { Err(Error("invalid Monero address".into())) } } +fn decode_monero_b58(addr: &str) -> Option> { + let bytes = addr.as_bytes(); + if bytes.is_empty() || !bytes.iter().all(|b| B58.contains(b)) { + return None; + } + let mut out = Vec::new(); + let mut i = 0; + while i < bytes.len() { + let rest = bytes.len() - i; + let (enc_len, dec_len) = if rest >= 11 { + (11, 8) + } else { + let dec_len = B58_ENC_LEN.iter().position(|&n| n == rest)?; + (rest, dec_len) + }; + out.extend_from_slice(&decode_b58_block(&bytes[i..i + enc_len], dec_len)?); + i += enc_len; + } + Some(out) +} + +fn decode_b58_block(enc: &[u8], out_len: usize) -> Option> { + let mut acc: u128 = 0; + for &c in enc { + let d = B58.iter().position(|&a| a == c)? as u128; + acc = acc.checked_mul(58)?.checked_add(d)?; + } + let max = if out_len >= 16 { + return None; + } else if out_len == 0 { + 0 + } else { + (1u128 << (8 * out_len)) - 1 + }; + if acc > max { + return None; + } + Some(acc.to_be_bytes()[16 - out_len..].to_vec()) +} + pub fn parse_atomic(s: &str) -> Result { if s.is_empty() || !s.bytes().all(|b| b.is_ascii_digit()) { return Err(Error("amount must be decimal piconero".into())); diff --git a/src/profile.rs b/src/profile.rs index 38228cc..81fc7d5 100644 --- a/src/profile.rs +++ b/src/profile.rs @@ -125,6 +125,9 @@ fn check_fields(display_name: &str, bio: &str, xmr_addr: &str) -> Result<()> { if display_name.contains('\n') || bio.contains('\n') || xmr_addr.contains('\n') { return Err(Error("profile fields must not contain newlines".into())); } + if !xmr_addr.is_empty() { + crate::pay::check_address(xmr_addr).map_err(|e| Error(e.to_string()))?; + } Ok(()) } diff --git a/tests/pay.rs b/tests/pay.rs index 1cc7ffa..693e67b 100644 --- a/tests/pay.rs +++ b/tests/pay.rs @@ -12,16 +12,20 @@ fn store() -> (tempfile::TempDir, Store) { (dir, store) } +// Official mainnet standard from Monero docs (not live RPC). +const MAINNET_STD: &str = "4AdUndXHHZ6cfufTMvppY6JwXNouMBzSkbLYfpAV5Usx3skxNgYeYTRj5UzqtReoS44qo9mtmXCqY45DJ852K5Jv2684Rge"; +// Same documented spend/view keys, mainnet subaddress (0x2A) and integrated (0x13 + 8 zero pid). +const MAINNET_SUB: &str = "8BTd81B7syWcfufTMvppY6JwXNouMBzSkbLYfpAV5Usx3skxNgYeYTRj5UzqtReoS44qo9mtmXCqY45DJ852K5Jv25pnJx6"; +const MAINNET_INT: &str = "4LL9oSLmtpccfufTMvppY6JwXNouMBzSkbLYfpAV5Usx3skxNgYeYTRj5UzqtReoS44qo9mtmXCqY45DJ852K5Jv2WK48GNSUQf17NLRTG"; +// Same keys, stagenet standard (0x18). +const STAGENET_STD: &str = "5AqWsUSEwACcfufTMvppY6JwXNouMBzSkbLYfpAV5Usx3skxNgYeYTRj5UzqtReoS44qo9mtmXCqY45DJ852K5Jv23X7tqA"; + fn addr_std() -> String { - format!("4{}", "A".repeat(94)) + MAINNET_STD.to_string() } fn addr_sub() -> String { - format!("8{}", "B".repeat(94)) -} - -fn addr_integrated() -> String { - format!("4{}", "C".repeat(105)) + MAINNET_SUB.to_string() } fn payw<'a>( @@ -46,12 +50,20 @@ fn payw<'a>( #[test] fn valid_and_invalid_xmr_addresses() { - assert!(pay::check_address(&addr_std()).is_ok()); - assert!(pay::check_address(&addr_sub()).is_ok()); - assert!(pay::check_address(&addr_integrated()).is_ok()); + assert!(pay::check_address(MAINNET_STD).is_ok()); + assert!(pay::check_address(MAINNET_SUB).is_ok()); + assert!(pay::check_address(MAINNET_INT).is_ok()); + assert!(pay::check_address(STAGENET_STD).is_ok()); + // prefix+length junk that the old checker accepted + assert!(pay::check_address(&format!("4{}", "A".repeat(94))).is_err()); + assert!(pay::check_address(&format!("8{}", "B".repeat(94))).is_err()); + assert!(pay::check_address(&format!("4{}", "C".repeat(105))).is_err()); assert!(pay::check_address(&format!("4{}", "A".repeat(93))).is_err()); assert!(pay::check_address(&format!("8{}", "B".repeat(95))).is_err()); - assert!(pay::check_address(&format!("5{}", "A".repeat(94))).is_err()); + assert!(pay::check_address(&format!("{MAINNET_STD}\n")).is_err()); + let mut bad_ck = MAINNET_STD.to_string(); + bad_ck.replace_range(94..95, "f"); + assert!(pay::check_address(&bad_ck).is_err()); assert!(pay::check_address("").is_err()); assert!(pay::check_address("not-an-address").is_err()); } diff --git a/tests/profile.rs b/tests/profile.rs index e73c4a3..83453ae 100644 --- a/tests/profile.rs +++ b/tests/profile.rs @@ -108,21 +108,33 @@ fn chat_is_not_profile() { assert!(profile::decode(b"hello wire").is_none()); } +const MAINNET_STD: &str = "4AdUndXHHZ6cfufTMvppY6JwXNouMBzSkbLYfpAV5Usx3skxNgYeYTRj5UzqtReoS44qo9mtmXCqY45DJ852K5Jv2684Rge"; + #[test] fn self_profile_roundtrip() { let (_dir, store) = store(); - store.set_self_profile("me", "a bio", "4abc").unwrap(); + store.set_self_profile("me", "a bio", MAINNET_STD).unwrap(); let got = store.self_profile().unwrap(); assert_eq!(got.display_name, "me"); assert_eq!(got.bio, "a bio"); - assert_eq!(got.xmr_addr, "4abc"); + assert_eq!(got.xmr_addr, MAINNET_STD); assert!(got.updated_at > 0); store.set_self_profile("", "", "").unwrap(); let empty = store.self_profile().unwrap(); assert_eq!(empty.display_name, ""); + assert_eq!(empty.xmr_addr, ""); assert!(empty.updated_at >= got.updated_at); } +#[test] +fn self_profile_rejects_short_xmr_addr() { + let (_dir, store) = store(); + assert!(store.set_self_profile("me", "a bio", "4abc").is_err()); + assert!(profile::validate("me", "a bio", "4abc").is_err()); + assert!(profile::validate("me", "a bio", "").is_ok()); + assert!(profile::validate("me", "a bio", MAINNET_STD).is_ok()); +} + #[test] fn slash_profile_who_and_wipe_still_parse() { assert_eq!(parse_cmd("/profile"), Some(SlashCmd::Profile));