fix(pay): checksum Monero addresses (base58 + keccak) #7
6 changed files with 99 additions and 16 deletions
1
Cargo.lock
generated
1
Cargo.lock
generated
|
|
@ -2620,6 +2620,7 @@ dependencies = [
|
||||||
"rusqlite",
|
"rusqlite",
|
||||||
"safelog",
|
"safelog",
|
||||||
"serde_json",
|
"serde_json",
|
||||||
|
"sha3 0.10.9",
|
||||||
"snow",
|
"snow",
|
||||||
"tempfile",
|
"tempfile",
|
||||||
"tokio",
|
"tokio",
|
||||||
|
|
|
||||||
|
|
@ -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"
|
||||||
|
|
|
||||||
62
src/pay.rs
62
src/pay.rs
|
|
@ -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()));
|
||||||
|
|
|
||||||
|
|
@ -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(())
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
32
tests/pay.rs
32
tests/pay.rs
|
|
@ -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());
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -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));
|
||||||
|
|
|
||||||
Loading…
Add table
Reference in a new issue