329 lines
9.2 KiB
Rust
329 lines
9.2 KiB
Rust
|
|
use ed25519_dalek::{Signature, Signer, SigningKey, Verifier, VerifyingKey};
|
||
|
|
|
||
|
|
pub type Result<T> = std::result::Result<T, Error>;
|
||
|
|
|
||
|
|
#[derive(Debug)]
|
||
|
|
pub struct Error(String);
|
||
|
|
|
||
|
|
impl std::fmt::Display for Error {
|
||
|
|
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
|
||
|
|
self.0.fmt(f)
|
||
|
|
}
|
||
|
|
}
|
||
|
|
|
||
|
|
impl std::error::Error for Error {}
|
||
|
|
|
||
|
|
#[derive(Debug, Clone, PartialEq, Eq)]
|
||
|
|
pub struct Invoice {
|
||
|
|
pub amount_atomic: String,
|
||
|
|
pub address: String,
|
||
|
|
pub memo: String,
|
||
|
|
pub ts: i64,
|
||
|
|
pub sig: Vec<u8>,
|
||
|
|
}
|
||
|
|
|
||
|
|
#[derive(Debug, Clone, PartialEq, Eq)]
|
||
|
|
pub struct Receipt {
|
||
|
|
pub txid: String,
|
||
|
|
pub amount_atomic: String,
|
||
|
|
pub address: String,
|
||
|
|
pub ts: i64,
|
||
|
|
pub sig: Vec<u8>,
|
||
|
|
}
|
||
|
|
|
||
|
|
const INV_PREFIX: &[u8] = b"inv ";
|
||
|
|
const RCP_PREFIX: &[u8] = b"rcp ";
|
||
|
|
const PICONERO: u128 = 1_000_000_000_000;
|
||
|
|
|
||
|
|
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,
|
||
|
|
_ => false,
|
||
|
|
};
|
||
|
|
if ok && !addr.contains('\n') {
|
||
|
|
Ok(())
|
||
|
|
} else {
|
||
|
|
Err(Error("invalid Monero address".into()))
|
||
|
|
}
|
||
|
|
}
|
||
|
|
|
||
|
|
pub fn parse_atomic(s: &str) -> Result<u128> {
|
||
|
|
if s.is_empty() || !s.bytes().all(|b| b.is_ascii_digit()) {
|
||
|
|
return Err(Error("amount must be decimal piconero".into()));
|
||
|
|
}
|
||
|
|
let n: u128 = s.parse().map_err(|_| Error("amount out of range".into()))?;
|
||
|
|
if n == 0 {
|
||
|
|
return Err(Error("amount must be > 0".into()));
|
||
|
|
}
|
||
|
|
Ok(n)
|
||
|
|
}
|
||
|
|
|
||
|
|
pub fn xmr_to_atomic(s: &str) -> Result<String> {
|
||
|
|
let s = s.trim();
|
||
|
|
let (whole, frac) = match s.split_once('.') {
|
||
|
|
Some((w, f)) => (w, f),
|
||
|
|
None => (s, ""),
|
||
|
|
};
|
||
|
|
if whole.is_empty() || !whole.bytes().all(|b| b.is_ascii_digit()) {
|
||
|
|
return Err(Error("invalid XMR amount".into()));
|
||
|
|
}
|
||
|
|
if frac.len() > 12 || !frac.bytes().all(|b| b.is_ascii_digit()) {
|
||
|
|
return Err(Error("invalid XMR amount".into()));
|
||
|
|
}
|
||
|
|
let mut frac_pad = frac.to_string();
|
||
|
|
while frac_pad.len() < 12 {
|
||
|
|
frac_pad.push('0');
|
||
|
|
}
|
||
|
|
let combined = format!("{whole}{frac_pad}");
|
||
|
|
let n: u128 = combined
|
||
|
|
.parse()
|
||
|
|
.map_err(|_| Error("amount out of range".into()))?;
|
||
|
|
if n == 0 {
|
||
|
|
return Err(Error("amount must be > 0".into()));
|
||
|
|
}
|
||
|
|
Ok(n.to_string())
|
||
|
|
}
|
||
|
|
|
||
|
|
pub fn atomic_to_xmr_str(s: &str) -> String {
|
||
|
|
let n: u128 = s.parse().unwrap_or(0);
|
||
|
|
let whole = n / PICONERO;
|
||
|
|
let frac = n % PICONERO;
|
||
|
|
if frac == 0 {
|
||
|
|
return whole.to_string();
|
||
|
|
}
|
||
|
|
let mut f = format!("{frac:012}");
|
||
|
|
while f.ends_with('0') {
|
||
|
|
f.pop();
|
||
|
|
}
|
||
|
|
format!("{whole}.{f}")
|
||
|
|
}
|
||
|
|
|
||
|
|
pub fn invoice_chat_line(amount_atomic: &str, memo: &str) -> String {
|
||
|
|
let xmr = atomic_to_xmr_str(amount_atomic);
|
||
|
|
if memo.is_empty() {
|
||
|
|
format!("[invoice] {xmr} XMR")
|
||
|
|
} else {
|
||
|
|
format!("[invoice] {xmr} XMR — {memo}")
|
||
|
|
}
|
||
|
|
}
|
||
|
|
|
||
|
|
pub fn receipt_chat_line(amount_atomic: &str, verified: bool) -> String {
|
||
|
|
let xmr = atomic_to_xmr_str(amount_atomic);
|
||
|
|
if verified {
|
||
|
|
format!("[receipt] {xmr} XMR")
|
||
|
|
} else {
|
||
|
|
format!("[receipt] {xmr} XMR — unverified")
|
||
|
|
}
|
||
|
|
}
|
||
|
|
|
||
|
|
pub fn sign_invoice(
|
||
|
|
identity_sk: &[u8],
|
||
|
|
amount_atomic: &str,
|
||
|
|
address: &str,
|
||
|
|
memo: &str,
|
||
|
|
ts: i64,
|
||
|
|
) -> Result<Invoice> {
|
||
|
|
check_invoice_fields(amount_atomic, address, memo)?;
|
||
|
|
let sig = sign_bytes(identity_sk, &inv_sign_msg(amount_atomic, address, memo, ts))?;
|
||
|
|
Ok(Invoice {
|
||
|
|
amount_atomic: amount_atomic.to_string(),
|
||
|
|
address: address.to_string(),
|
||
|
|
memo: memo.to_string(),
|
||
|
|
ts,
|
||
|
|
sig,
|
||
|
|
})
|
||
|
|
}
|
||
|
|
|
||
|
|
pub fn verify_invoice(identity_pk: &[u8], inv: &Invoice) -> bool {
|
||
|
|
if check_invoice_fields(&inv.amount_atomic, &inv.address, &inv.memo).is_err() {
|
||
|
|
return false;
|
||
|
|
}
|
||
|
|
verify_bytes(
|
||
|
|
identity_pk,
|
||
|
|
&inv_sign_msg(&inv.amount_atomic, &inv.address, &inv.memo, inv.ts),
|
||
|
|
&inv.sig,
|
||
|
|
)
|
||
|
|
}
|
||
|
|
|
||
|
|
pub fn encode_invoice(inv: &Invoice) -> Vec<u8> {
|
||
|
|
let mut out = Vec::from(INV_PREFIX);
|
||
|
|
out.extend_from_slice(inv.amount_atomic.as_bytes());
|
||
|
|
out.push(b'\n');
|
||
|
|
out.extend_from_slice(inv.address.as_bytes());
|
||
|
|
out.push(b'\n');
|
||
|
|
out.extend_from_slice(inv.memo.as_bytes());
|
||
|
|
out.push(b'\n');
|
||
|
|
out.extend_from_slice(inv.ts.to_string().as_bytes());
|
||
|
|
out.push(b'\n');
|
||
|
|
out.extend_from_slice(to_hex(&inv.sig).as_bytes());
|
||
|
|
out
|
||
|
|
}
|
||
|
|
|
||
|
|
pub fn decode_invoice(pt: &[u8]) -> Option<Invoice> {
|
||
|
|
let rest = pt.strip_prefix(INV_PREFIX)?;
|
||
|
|
let text = std::str::from_utf8(rest).ok()?;
|
||
|
|
let mut parts = text.splitn(5, '\n');
|
||
|
|
let amount_atomic = parts.next()?.to_string();
|
||
|
|
let address = parts.next()?.to_string();
|
||
|
|
let memo = parts.next()?.to_string();
|
||
|
|
let ts = parts.next()?.parse::<i64>().ok()?;
|
||
|
|
let sig = from_hex(parts.next()?)?;
|
||
|
|
if check_invoice_fields(&amount_atomic, &address, &memo).is_err() {
|
||
|
|
return None;
|
||
|
|
}
|
||
|
|
Some(Invoice {
|
||
|
|
amount_atomic,
|
||
|
|
address,
|
||
|
|
memo,
|
||
|
|
ts,
|
||
|
|
sig,
|
||
|
|
})
|
||
|
|
}
|
||
|
|
|
||
|
|
pub fn sign_receipt(
|
||
|
|
identity_sk: &[u8],
|
||
|
|
txid: &str,
|
||
|
|
amount_atomic: &str,
|
||
|
|
address: &str,
|
||
|
|
ts: i64,
|
||
|
|
) -> Result<Receipt> {
|
||
|
|
check_receipt_fields(txid, amount_atomic, address)?;
|
||
|
|
let sig = sign_bytes(identity_sk, &rcp_sign_msg(txid, amount_atomic, address, ts))?;
|
||
|
|
Ok(Receipt {
|
||
|
|
txid: txid.to_string(),
|
||
|
|
amount_atomic: amount_atomic.to_string(),
|
||
|
|
address: address.to_string(),
|
||
|
|
ts,
|
||
|
|
sig,
|
||
|
|
})
|
||
|
|
}
|
||
|
|
|
||
|
|
pub fn verify_receipt(identity_pk: &[u8], rcp: &Receipt) -> bool {
|
||
|
|
if check_receipt_fields(&rcp.txid, &rcp.amount_atomic, &rcp.address).is_err() {
|
||
|
|
return false;
|
||
|
|
}
|
||
|
|
verify_bytes(
|
||
|
|
identity_pk,
|
||
|
|
&rcp_sign_msg(&rcp.txid, &rcp.amount_atomic, &rcp.address, rcp.ts),
|
||
|
|
&rcp.sig,
|
||
|
|
)
|
||
|
|
}
|
||
|
|
|
||
|
|
pub fn encode_receipt(rcp: &Receipt) -> Vec<u8> {
|
||
|
|
let mut out = Vec::from(RCP_PREFIX);
|
||
|
|
out.extend_from_slice(rcp.txid.as_bytes());
|
||
|
|
out.push(b'\n');
|
||
|
|
out.extend_from_slice(rcp.amount_atomic.as_bytes());
|
||
|
|
out.push(b'\n');
|
||
|
|
out.extend_from_slice(rcp.address.as_bytes());
|
||
|
|
out.push(b'\n');
|
||
|
|
out.extend_from_slice(rcp.ts.to_string().as_bytes());
|
||
|
|
out.push(b'\n');
|
||
|
|
out.extend_from_slice(to_hex(&rcp.sig).as_bytes());
|
||
|
|
out
|
||
|
|
}
|
||
|
|
|
||
|
|
pub fn decode_receipt(pt: &[u8]) -> Option<Receipt> {
|
||
|
|
let rest = pt.strip_prefix(RCP_PREFIX)?;
|
||
|
|
let text = std::str::from_utf8(rest).ok()?;
|
||
|
|
let mut parts = text.splitn(5, '\n');
|
||
|
|
let txid = parts.next()?.to_string();
|
||
|
|
let amount_atomic = parts.next()?.to_string();
|
||
|
|
let address = parts.next()?.to_string();
|
||
|
|
let ts = parts.next()?.parse::<i64>().ok()?;
|
||
|
|
let sig = from_hex(parts.next()?)?;
|
||
|
|
if check_receipt_fields(&txid, &amount_atomic, &address).is_err() {
|
||
|
|
return None;
|
||
|
|
}
|
||
|
|
Some(Receipt {
|
||
|
|
txid,
|
||
|
|
amount_atomic,
|
||
|
|
address,
|
||
|
|
ts,
|
||
|
|
sig,
|
||
|
|
})
|
||
|
|
}
|
||
|
|
|
||
|
|
fn check_invoice_fields(amount_atomic: &str, address: &str, memo: &str) -> Result<()> {
|
||
|
|
parse_atomic(amount_atomic)?;
|
||
|
|
check_address(address)?;
|
||
|
|
if memo.contains('\n') {
|
||
|
|
return Err(Error("memo must not contain newlines".into()));
|
||
|
|
}
|
||
|
|
Ok(())
|
||
|
|
}
|
||
|
|
|
||
|
|
fn check_receipt_fields(txid: &str, amount_atomic: &str, address: &str) -> Result<()> {
|
||
|
|
if txid.is_empty() || txid.contains('\n') {
|
||
|
|
return Err(Error("invalid txid".into()));
|
||
|
|
}
|
||
|
|
parse_atomic(amount_atomic)?;
|
||
|
|
check_address(address)
|
||
|
|
}
|
||
|
|
|
||
|
|
fn inv_sign_msg(amount_atomic: &str, address: &str, memo: &str, ts: i64) -> Vec<u8> {
|
||
|
|
let mut msg = Vec::from(amount_atomic.as_bytes());
|
||
|
|
msg.push(b'\n');
|
||
|
|
msg.extend_from_slice(address.as_bytes());
|
||
|
|
msg.push(b'\n');
|
||
|
|
msg.extend_from_slice(memo.as_bytes());
|
||
|
|
msg.push(b'\n');
|
||
|
|
msg.extend_from_slice(ts.to_string().as_bytes());
|
||
|
|
msg
|
||
|
|
}
|
||
|
|
|
||
|
|
fn rcp_sign_msg(txid: &str, amount_atomic: &str, address: &str, ts: i64) -> Vec<u8> {
|
||
|
|
let mut msg = Vec::from(txid.as_bytes());
|
||
|
|
msg.push(b'\n');
|
||
|
|
msg.extend_from_slice(amount_atomic.as_bytes());
|
||
|
|
msg.push(b'\n');
|
||
|
|
msg.extend_from_slice(address.as_bytes());
|
||
|
|
msg.push(b'\n');
|
||
|
|
msg.extend_from_slice(ts.to_string().as_bytes());
|
||
|
|
msg
|
||
|
|
}
|
||
|
|
|
||
|
|
fn sign_bytes(identity_sk: &[u8], msg: &[u8]) -> Result<Vec<u8>> {
|
||
|
|
let sk_bytes: [u8; 32] = identity_sk
|
||
|
|
.try_into()
|
||
|
|
.map_err(|_| Error("identity secret key must be 32 bytes".into()))?;
|
||
|
|
let sk = SigningKey::from_bytes(&sk_bytes);
|
||
|
|
Ok(sk.sign(msg).to_bytes().to_vec())
|
||
|
|
}
|
||
|
|
|
||
|
|
fn verify_bytes(identity_pk: &[u8], msg: &[u8], sig: &[u8]) -> bool {
|
||
|
|
if identity_pk.len() != 32 || sig.len() != 64 {
|
||
|
|
return false;
|
||
|
|
}
|
||
|
|
let pk: [u8; 32] = match identity_pk.try_into() {
|
||
|
|
Ok(p) => p,
|
||
|
|
Err(_) => return false,
|
||
|
|
};
|
||
|
|
let sig: [u8; 64] = match sig.try_into() {
|
||
|
|
Ok(s) => s,
|
||
|
|
Err(_) => return false,
|
||
|
|
};
|
||
|
|
let Ok(vk) = VerifyingKey::from_bytes(&pk) else {
|
||
|
|
return false;
|
||
|
|
};
|
||
|
|
vk.verify(msg, &Signature::from_bytes(&sig)).is_ok()
|
||
|
|
}
|
||
|
|
|
||
|
|
fn to_hex(bytes: &[u8]) -> String {
|
||
|
|
bytes.iter().map(|b| format!("{b:02x}")).collect()
|
||
|
|
}
|
||
|
|
|
||
|
|
fn from_hex(s: &str) -> Option<Vec<u8>> {
|
||
|
|
if s.is_empty() || !s.len().is_multiple_of(2) {
|
||
|
|
return None;
|
||
|
|
}
|
||
|
|
if !s.bytes().all(|c| c.is_ascii_hexdigit()) {
|
||
|
|
return None;
|
||
|
|
}
|
||
|
|
(0..s.len())
|
||
|
|
.step_by(2)
|
||
|
|
.map(|i| u8::from_str_radix(&s[i..i + 2], 16).ok())
|
||
|
|
.collect()
|
||
|
|
}
|