fix(pay): checksum Monero addresses (base58 + keccak) #7

Merged
sirius merged 2 commits from wt/t_45d30b55 into main 2026-09-11 00:03:11 +00:00
6 changed files with 99 additions and 16 deletions
Showing only changes of commit 666b9d2e86 - Show all commits

1
Cargo.lock generated
View file

@ -2620,6 +2620,7 @@ dependencies = [
"rusqlite", "rusqlite",
"safelog", "safelog",
"serde_json", "serde_json",
"sha3 0.10.9",
"snow", "snow",
"tempfile", "tempfile",
"tokio", "tokio",

View file

@ -25,6 +25,7 @@ snow = "0.10"
serde_json = "1" serde_json = "1"
argon2 = "0.5" argon2 = "0.5"
chacha20poly1305 = "0.10" chacha20poly1305 = "0.10"
sha3 = "0.10"
[dev-dependencies] [dev-dependencies]
tempfile = "3" tempfile = "3"

View file

@ -1,4 +1,5 @@
use ed25519_dalek::{Signature, Signer, SigningKey, Verifier, VerifyingKey}; use ed25519_dalek::{Signature, Signer, SigningKey, Verifier, VerifyingKey};
use sha3::{Digest, Keccak256};
pub type Result<T> = std::result::Result<T, Error>; pub type Result<T> = std::result::Result<T, Error>;
@ -35,19 +36,72 @@ const INV_PREFIX: &[u8] = b"inv ";
const RCP_PREFIX: &[u8] = b"rcp "; const RCP_PREFIX: &[u8] = b"rcp ";
const PICONERO: u128 = 1_000_000_000_000; 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<()> { pub fn check_address(addr: &str) -> Result<()> {
let ok = match addr.as_bytes().first() { let raw = decode_monero_b58(addr).ok_or_else(|| Error("invalid Monero address".into()))?;
Some(b'4') if addr.len() == 95 || addr.len() == 106 => true, if raw.len() != 69 && raw.len() != 77 {
Some(b'8') if addr.len() == 95 => true, 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, _ => false,
}; };
if ok && !addr.contains('\n') { if ok {
Ok(()) Ok(())
} else { } else {
Err(Error("invalid Monero address".into())) Err(Error("invalid Monero address".into()))
} }
} }
fn decode_monero_b58(addr: &str) -> Option<Vec<u8>> {
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<Vec<u8>> {
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<u128> { pub fn parse_atomic(s: &str) -> Result<u128> {
if s.is_empty() || !s.bytes().all(|b| b.is_ascii_digit()) { if s.is_empty() || !s.bytes().all(|b| b.is_ascii_digit()) {
return Err(Error("amount must be decimal piconero".into())); return Err(Error("amount must be decimal piconero".into()));

View file

@ -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') { if display_name.contains('\n') || bio.contains('\n') || xmr_addr.contains('\n') {
return Err(Error("profile fields must not contain newlines".into())); 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(()) Ok(())
} }

View file

@ -12,16 +12,20 @@ fn store() -> (tempfile::TempDir, Store) {
(dir, 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 { fn addr_std() -> String {
format!("4{}", "A".repeat(94)) MAINNET_STD.to_string()
} }
fn addr_sub() -> String { fn addr_sub() -> String {
format!("8{}", "B".repeat(94)) MAINNET_SUB.to_string()
}
fn addr_integrated() -> String {
format!("4{}", "C".repeat(105))
} }
fn payw<'a>( fn payw<'a>(
@ -46,12 +50,20 @@ fn payw<'a>(
#[test] #[test]
fn valid_and_invalid_xmr_addresses() { fn valid_and_invalid_xmr_addresses() {
assert!(pay::check_address(&addr_std()).is_ok()); assert!(pay::check_address(MAINNET_STD).is_ok());
assert!(pay::check_address(&addr_sub()).is_ok()); assert!(pay::check_address(MAINNET_SUB).is_ok());
assert!(pay::check_address(&addr_integrated()).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!("4{}", "A".repeat(93))).is_err());
assert!(pay::check_address(&format!("8{}", "B".repeat(95))).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("").is_err());
assert!(pay::check_address("not-an-address").is_err()); assert!(pay::check_address("not-an-address").is_err());
} }

View file

@ -108,21 +108,33 @@ fn chat_is_not_profile() {
assert!(profile::decode(b"hello wire").is_none()); assert!(profile::decode(b"hello wire").is_none());
} }
const MAINNET_STD: &str = "4AdUndXHHZ6cfufTMvppY6JwXNouMBzSkbLYfpAV5Usx3skxNgYeYTRj5UzqtReoS44qo9mtmXCqY45DJ852K5Jv2684Rge";
#[test] #[test]
fn self_profile_roundtrip() { fn self_profile_roundtrip() {
let (_dir, store) = store(); 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(); let got = store.self_profile().unwrap();
assert_eq!(got.display_name, "me"); assert_eq!(got.display_name, "me");
assert_eq!(got.bio, "a bio"); assert_eq!(got.bio, "a bio");
assert_eq!(got.xmr_addr, "4abc"); assert_eq!(got.xmr_addr, MAINNET_STD);
assert!(got.updated_at > 0); assert!(got.updated_at > 0);
store.set_self_profile("", "", "").unwrap(); store.set_self_profile("", "", "").unwrap();
let empty = store.self_profile().unwrap(); let empty = store.self_profile().unwrap();
assert_eq!(empty.display_name, ""); assert_eq!(empty.display_name, "");
assert_eq!(empty.xmr_addr, "");
assert!(empty.updated_at >= got.updated_at); 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] #[test]
fn slash_profile_who_and_wipe_still_parse() { fn slash_profile_who_and_wipe_still_parse() {
assert_eq!(parse_cmd("/profile"), Some(SlashCmd::Profile)); assert_eq!(parse_cmd("/profile"), Some(SlashCmd::Profile));