From 1a465581c28460700fa571b417e430561a96e4c1 Mon Sep 17 00:00:00 2001 From: Sirius DevOps Date: Thu, 10 Sep 2026 19:29:39 -0400 Subject: [PATCH] [verified] fix(wallet): conjunctive receipt verify Require non-empty txid AND amount AND address against one get_transfers row. Signed rcp alone never sets verified=1. --- docs/THREAT_MODEL.md | 2 +- src/wallet.rs | 11 +++--- tests/wallet.rs | 86 +++++++++++++++++++++++++++++++++++++++++++- 3 files changed, 91 insertions(+), 8 deletions(-) diff --git a/docs/THREAT_MODEL.md b/docs/THREAT_MODEL.md index aa74de7..e2442c1 100644 --- a/docs/THREAT_MODEL.md +++ b/docs/THREAT_MODEL.md @@ -30,7 +30,7 @@ A signed `prf` frame is shown to people who already have a session with you. App ## Monero sidecar is not a wallet -OnionWire never holds spend keys. Optional `ONIONWIRE_WALLET_RPC` talks HTTP to a user-hosted `monero-wallet-rpc`. Never trust a `rcp` frame without RPC confirmation (`verified` stays 0). Subaddress reuse is the user’s wallet policy. +OnionWire never holds spend keys. Optional `ONIONWIRE_WALLET_RPC` talks HTTP to a user-hosted `monero-wallet-rpc`. A Noise friend can sign any `rcp`; the signature proves who sent the claim, not that a payment happened. `verified=1` only after a conjunctive RPC match: one `get_transfers` row with the same non-empty `txid`, `amount`, and `address`. Incoming `rcp` stays `verified=0` if RPC is down, errors, or no exact row. Subaddress reuse is the user’s wallet policy. ## Backup file is the identity diff --git a/src/wallet.rs b/src/wallet.rs index c3a7004..e7cc081 100644 --- a/src/wallet.rs +++ b/src/wallet.rs @@ -140,12 +140,11 @@ impl Wallet { } pub fn transfers_match(rows: &[TransferRow], txid: &str, amount: &str, address: &str) -> bool { - rows.iter().any(|r| { - r.txid == txid - || (!address.is_empty() - && r.address == address - && (amount.is_empty() || r.amount == amount)) - }) + if txid.is_empty() || amount.is_empty() || address.is_empty() { + return false; + } + rows.iter() + .any(|r| r.txid == txid && r.amount == amount && r.address == address) } fn parse_http_url(url: &str) -> Result { diff --git a/tests/wallet.rs b/tests/wallet.rs index 6483f19..2747e0f 100644 --- a/tests/wallet.rs +++ b/tests/wallet.rs @@ -1,9 +1,25 @@ //! M8: optional monero-wallet-rpc JSON client. Mock TCP only — no live monerod. -use onionwire::wallet::{self, Wallet}; +use ed25519_dalek::SigningKey; +use onionwire::pay; +use onionwire::wallet::{self, TransferRow, Wallet}; +use onionwire::{PaymentWrite, Store}; +use rand::rngs::OsRng; use tokio::io::{AsyncReadExt, AsyncWriteExt}; use tokio::net::TcpListener; +fn row(txid: &str, amount: &str, address: &str) -> TransferRow { + TransferRow { + txid: txid.into(), + amount: amount.into(), + address: address.into(), + } +} + +fn xmr_addr() -> String { + format!("8{}", "B".repeat(94)) +} + fn json_rpc_ok(result: &str) -> String { let body = format!(r#"{{"jsonrpc":"2.0","id":"0","result":{result}}}"#); format!( @@ -96,3 +112,71 @@ async fn mock_get_transfers_matches_txid() { )); assert!(!wallet::transfers_match(&rows, "nope", "1", "nope")); } + +#[test] +fn transfers_match_txid_only_wrong_amount_or_addr_is_false() { + let addr = xmr_addr(); + let rows = [row("deadbeef", "5", &addr)]; + assert!(!wallet::transfers_match(&rows, "deadbeef", "99", &addr)); + let other = format!("8{}", "C".repeat(94)); + assert!(!wallet::transfers_match(&rows, "deadbeef", "5", &other)); +} + +#[test] +fn transfers_match_address_and_amount_wrong_txid_is_false() { + let addr = xmr_addr(); + let rows = [row("deadbeef", "5", &addr)]; + assert!(!wallet::transfers_match(&rows, "cafebabe", "5", &addr)); +} + +#[test] +fn transfers_match_honest_triple_is_true() { + let addr = xmr_addr(); + let rows = [row("deadbeef", "5", &addr)]; + assert!(wallet::transfers_match(&rows, "deadbeef", "5", &addr)); +} + +#[test] +fn transfers_match_empty_field_is_false() { + let addr = xmr_addr(); + let rows = [row("deadbeef", "5", &addr)]; + assert!(!wallet::transfers_match(&rows, "", "5", &addr)); + assert!(!wallet::transfers_match(&rows, "deadbeef", "", &addr)); + assert!(!wallet::transfers_match(&rows, "deadbeef", "5", "")); + let empty = [row("", "", "")]; + assert!(!wallet::transfers_match(&empty, "", "", "")); +} + +/// ingest_receipt inserts verified=0, then mark_verified iff transfers_match. +/// A Noise-signed rcp that cites an unrelated wallet row must stay unverified. +#[test] +fn ingest_signed_receipt_mismatched_wallet_history_stays_unverified() { + let dir = tempfile::tempdir().expect("tempdir"); + let store = Store::open_at_with_passphrase(dir.path(), "onionwire-test").expect("open"); + let sk = SigningKey::generate(&mut OsRng); + let pk = sk.verifying_key().to_bytes(); + store.upsert_friend(&pk, "peer.onion", None).unwrap(); + let addr = xmr_addr(); + let rcp = pay::sign_receipt(&sk.to_bytes(), "unrelated", "5", &addr, 1).unwrap(); + assert!(pay::verify_receipt(&pk, &rcp)); + let id = store + .insert_payment( + &pk, + PaymentWrite { + dir: "in", + kind: "receipt", + amount_atomic: &rcp.amount_atomic, + address: &rcp.address, + memo: "", + txid: Some(&rcp.txid), + verified: false, + }, + ) + .unwrap(); + let history = [row("unrelated", "99", &format!("8{}", "C".repeat(94)))]; + if wallet::transfers_match(&history, &rcp.txid, &rcp.amount_atomic, &rcp.address) { + store.mark_verified(id).unwrap(); + } + let rows = store.list_payments(&pk).unwrap(); + assert!(!rows[0].verified); +} -- 2.45.3