[verified] feat(wallet): require RPC digest login and cap reads
All checks were successful
ci / test (pull_request) Successful in 3m8s
All checks were successful
ci / test (pull_request) Successful in 3m8s
Refuse unauthenticated ONIONWIRE_WALLET_RPC, speak HTTP Digest like monero-wallet-rpc --rpc-login, cap responses at 1 MiB, and reject .onion RPC URLs instead of half-dialing them over TCP.
This commit is contained in:
parent
2b42864eba
commit
125aafb816
6 changed files with 352 additions and 47 deletions
11
Cargo.lock
generated
11
Cargo.lock
generated
|
|
@ -2348,6 +2348,16 @@ dependencies = [
|
|||
"regex-automata",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "md-5"
|
||||
version = "0.10.6"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "d89e7ee0cfbedfc4da3340218492196241d89eefb6dab27de5df917a6d2e78cf"
|
||||
dependencies = [
|
||||
"cfg-if",
|
||||
"digest 0.10.7",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "memchr"
|
||||
version = "2.8.3"
|
||||
|
|
@ -2615,6 +2625,7 @@ dependencies = [
|
|||
"chacha20poly1305",
|
||||
"ed25519-dalek",
|
||||
"futures",
|
||||
"md-5",
|
||||
"rand 0.8.8",
|
||||
"ratatui",
|
||||
"rusqlite",
|
||||
|
|
|
|||
|
|
@ -25,6 +25,7 @@ snow = "0.10"
|
|||
serde_json = "1"
|
||||
argon2 = "0.5"
|
||||
chacha20poly1305 = "0.10"
|
||||
md-5 = "0.10"
|
||||
|
||||
[dev-dependencies]
|
||||
tempfile = "3"
|
||||
|
|
|
|||
|
|
@ -162,13 +162,17 @@ If a peer’s onion is down, send fails. v1 has no outbox, no retry queue, no DH
|
|||
|
||||
## Monero sidecar
|
||||
|
||||
OnionWire is not a wallet. Optional JSON-RPC to a user-hosted `monero-wallet-rpc`:
|
||||
OnionWire is not a wallet. Optional JSON-RPC to a user-hosted `monero-wallet-rpc`. The wallet **must** use `--rpc-login`; OnionWire refuses an open RPC (HTTP 200 without a Digest challenge) and refuses URLs with no credentials.
|
||||
|
||||
```bash
|
||||
# monero-wallet-rpc --rpc-bind-ip 127.0.0.1 --rpc-bind-port 18083 --rpc-login onionwire:secret
|
||||
export ONIONWIRE_WALLET_RPC=http://onionwire:secret@127.0.0.1:18083
|
||||
# or keep the password out of the URL:
|
||||
export ONIONWIRE_WALLET_RPC=http://127.0.0.1:18083
|
||||
export ONIONWIRE_WALLET_RPC_LOGIN=onionwire:secret
|
||||
```
|
||||
|
||||
Loopback or `.onion` only, HTTP, 5s timeout. Unset → chat still works; `/pay` and `/tip` say so.
|
||||
Loopback only, HTTP Digest (RFC 2617, matching `--rpc-login`), 5s timeout, 1 MiB response cap. `.onion` RPC URLs are rejected (no Arti dial; do not point this at a remote wallet). Unset → chat still works; `/pay` and `/tip` say so. Do not log the RPC password.
|
||||
|
||||
- `/pay <xmr> [memo]` — invoice (we want to receive). Uses a wallet subaddress if RPC is up, else the profile `xmr_addr`.
|
||||
- `/tip <xmr> [memo]` — pay the selected friend’s profile address, then send a signed `rcp`. Incoming receipts stay unverified until RPC `get_transfers` matches.
|
||||
|
|
|
|||
|
|
@ -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 Digest to a user-hosted `monero-wallet-rpc` on loopback (`--rpc-login` required; open RPC is refused). Never trust a `rcp` frame without RPC confirmation (`verified` stays 0). Subaddress reuse is the user’s wallet policy.
|
||||
|
||||
## Backup file is the identity
|
||||
|
||||
|
|
|
|||
250
src/wallet.rs
250
src/wallet.rs
|
|
@ -1,5 +1,6 @@
|
|||
use std::time::Duration;
|
||||
|
||||
use rand::RngCore;
|
||||
use tokio::io::{AsyncReadExt, AsyncWriteExt};
|
||||
use tokio::net::TcpStream;
|
||||
|
||||
|
|
@ -16,11 +17,25 @@ impl std::fmt::Display for Error {
|
|||
|
||||
impl std::error::Error for Error {}
|
||||
|
||||
#[derive(Debug, Clone)]
|
||||
#[derive(Clone)]
|
||||
struct Endpoint {
|
||||
host: String,
|
||||
port: u16,
|
||||
path: String,
|
||||
user: String,
|
||||
pass: String,
|
||||
}
|
||||
|
||||
impl std::fmt::Debug for Endpoint {
|
||||
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
|
||||
f.debug_struct("Endpoint")
|
||||
.field("host", &self.host)
|
||||
.field("port", &self.port)
|
||||
.field("path", &self.path)
|
||||
.field("user", &self.user)
|
||||
.field("pass", &"<redacted>")
|
||||
.finish()
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone)]
|
||||
|
|
@ -44,20 +59,28 @@ impl Wallet {
|
|||
|
||||
pub fn from_env() -> Self {
|
||||
match std::env::var("ONIONWIRE_WALLET_RPC") {
|
||||
Ok(s) if !s.trim().is_empty() => match Self::from_url(s.trim()) {
|
||||
Ok(w) => w,
|
||||
Err(e) => {
|
||||
eprintln!("ONIONWIRE_WALLET_RPC: {e}");
|
||||
Self::disabled()
|
||||
Ok(s) if !s.trim().is_empty() => {
|
||||
let login = std::env::var("ONIONWIRE_WALLET_RPC_LOGIN")
|
||||
.ok()
|
||||
.map(|v| v.trim().to_string())
|
||||
.filter(|v| !v.is_empty());
|
||||
match parse_http_url(s.trim(), login.as_deref()) {
|
||||
Ok(endpoint) => Self {
|
||||
endpoint: Some(endpoint),
|
||||
},
|
||||
Err(e) => {
|
||||
eprintln!("ONIONWIRE_WALLET_RPC: {e}");
|
||||
Self::disabled()
|
||||
}
|
||||
}
|
||||
},
|
||||
}
|
||||
_ => Self::disabled(),
|
||||
}
|
||||
}
|
||||
|
||||
pub fn from_url(url: &str) -> Result<Self> {
|
||||
Ok(Self {
|
||||
endpoint: Some(parse_http_url(url)?),
|
||||
endpoint: Some(parse_http_url(url, None)?),
|
||||
})
|
||||
}
|
||||
|
||||
|
|
@ -126,15 +149,26 @@ impl Wallet {
|
|||
"params": params,
|
||||
})
|
||||
.to_string();
|
||||
let host_hdr = host_header(&ep.host, ep.port);
|
||||
let req = format!(
|
||||
"POST {} HTTP/1.1\r\nHost: {host_hdr}\r\nContent-Type: application/json\r\nContent-Length: {}\r\nConnection: close\r\n\r\n{body}",
|
||||
ep.path,
|
||||
body.len()
|
||||
);
|
||||
let raw = tokio::time::timeout(RPC_TIMEOUT, http_post(ep, req.as_bytes()))
|
||||
.await
|
||||
.map_err(|_| Error("wallet RPC timed out".into()))??;
|
||||
let raw = post_timeout(ep, &build_req(ep, &body, None)).await?;
|
||||
let status = http_status(&raw).unwrap_or(0);
|
||||
if status == 200 {
|
||||
return Err(Error(
|
||||
"wallet RPC requires digest auth (open RPC refused)".into(),
|
||||
));
|
||||
}
|
||||
if status != 401 {
|
||||
return parse_json_rpc(&raw);
|
||||
}
|
||||
let challenge = www_authenticate(&raw).ok_or_else(|| {
|
||||
Error("wallet RPC digest required (--rpc-login / HTTP Digest)".into())
|
||||
})?;
|
||||
if !challenge.trim().to_ascii_lowercase().starts_with("digest") {
|
||||
return Err(Error(
|
||||
"wallet RPC digest required (--rpc-login / HTTP Digest)".into(),
|
||||
));
|
||||
}
|
||||
let auth = digest_authorization(ep, &challenge)?;
|
||||
let raw = post_timeout(ep, &build_req(ep, &body, Some(&auth))).await?;
|
||||
parse_json_rpc(&raw)
|
||||
}
|
||||
}
|
||||
|
|
@ -148,13 +182,36 @@ pub fn transfers_match(rows: &[TransferRow], txid: &str, amount: &str, address:
|
|||
})
|
||||
}
|
||||
|
||||
fn parse_http_url(url: &str) -> Result<Endpoint> {
|
||||
fn credentials_required() -> Error {
|
||||
Error("wallet RPC requires credentials (user:pass in URL or ONIONWIRE_WALLET_RPC_LOGIN)".into())
|
||||
}
|
||||
|
||||
fn parse_login(login: &str) -> Result<(String, String)> {
|
||||
let (user, pass) = login.split_once(':').ok_or_else(credentials_required)?;
|
||||
if user.is_empty() || pass.is_empty() {
|
||||
return Err(credentials_required());
|
||||
}
|
||||
Ok((user.to_string(), pass.to_string()))
|
||||
}
|
||||
|
||||
fn parse_http_url(url: &str, extra_login: Option<&str>) -> Result<Endpoint> {
|
||||
let rest = url
|
||||
.strip_prefix("http://")
|
||||
.ok_or_else(|| Error("wallet RPC must be http:// (no TLS)".into()))?;
|
||||
if rest.contains("://") {
|
||||
return Err(Error("wallet RPC must be http:// (no TLS)".into()));
|
||||
}
|
||||
let (userinfo, rest) = match rest.rsplit_once('@') {
|
||||
Some((ui, hostpart)) => (Some(ui), hostpart),
|
||||
None => (None, rest),
|
||||
};
|
||||
let (user, pass) = match userinfo {
|
||||
Some(ui) => parse_login(ui)?,
|
||||
None => match extra_login {
|
||||
Some(login) => parse_login(login)?,
|
||||
None => return Err(credentials_required()),
|
||||
},
|
||||
};
|
||||
let (hostport, path) = match rest.split_once('/') {
|
||||
Some((hp, p)) => (hp, format!("/{p}")),
|
||||
None => (rest, "/json_rpc".into()),
|
||||
|
|
@ -165,10 +222,21 @@ fn parse_http_url(url: &str) -> Result<Endpoint> {
|
|||
path
|
||||
};
|
||||
let (host, port) = parse_hostport(hostport)?;
|
||||
if !allowed_host(&host) {
|
||||
return Err(Error("wallet RPC host must be loopback or .onion".into()));
|
||||
if host.trim().to_ascii_lowercase().ends_with(".onion") {
|
||||
return Err(Error(
|
||||
"wallet RPC over .onion is not supported (loopback only; no Arti dial)".into(),
|
||||
));
|
||||
}
|
||||
Ok(Endpoint { host, port, path })
|
||||
if !allowed_host(&host) {
|
||||
return Err(Error("wallet RPC host must be loopback".into()));
|
||||
}
|
||||
Ok(Endpoint {
|
||||
host,
|
||||
port,
|
||||
path,
|
||||
user,
|
||||
pass,
|
||||
})
|
||||
}
|
||||
|
||||
fn parse_hostport(hostport: &str) -> Result<(String, u16)> {
|
||||
|
|
@ -201,9 +269,6 @@ fn parse_port(p: &str) -> Result<u16> {
|
|||
|
||||
fn allowed_host(host: &str) -> bool {
|
||||
let h = host.trim();
|
||||
if h.to_ascii_lowercase().ends_with(".onion") {
|
||||
return true;
|
||||
}
|
||||
if h.eq_ignore_ascii_case("localhost") {
|
||||
return true;
|
||||
}
|
||||
|
|
@ -220,6 +285,31 @@ fn host_header(host: &str, port: u16) -> String {
|
|||
}
|
||||
}
|
||||
|
||||
const MAX_RPC_BYTES: usize = 1024 * 1024;
|
||||
|
||||
async fn post_timeout(ep: &Endpoint, req: &str) -> Result<Vec<u8>> {
|
||||
tokio::time::timeout(RPC_TIMEOUT, http_post(ep, req.as_bytes()))
|
||||
.await
|
||||
.map_err(|_| Error("wallet RPC timed out".into()))?
|
||||
}
|
||||
|
||||
fn build_req(ep: &Endpoint, body: &str, authorization: Option<&str>) -> String {
|
||||
let host_hdr = host_header(&ep.host, ep.port);
|
||||
let mut req = format!(
|
||||
"POST {} HTTP/1.1\r\nHost: {host_hdr}\r\nContent-Type: application/json\r\nContent-Length: {}\r\nConnection: close\r\n",
|
||||
ep.path,
|
||||
body.len()
|
||||
);
|
||||
if let Some(auth) = authorization {
|
||||
req.push_str("Authorization: ");
|
||||
req.push_str(auth);
|
||||
req.push_str("\r\n");
|
||||
}
|
||||
req.push_str("\r\n");
|
||||
req.push_str(body);
|
||||
req
|
||||
}
|
||||
|
||||
async fn http_post(ep: &Endpoint, req: &[u8]) -> Result<Vec<u8>> {
|
||||
let mut stream = TcpStream::connect((ep.host.as_str(), ep.port))
|
||||
.await
|
||||
|
|
@ -229,13 +319,117 @@ async fn http_post(ep: &Endpoint, req: &[u8]) -> Result<Vec<u8>> {
|
|||
.await
|
||||
.map_err(|e| Error(format!("wallet write: {e}")))?;
|
||||
let mut buf = Vec::new();
|
||||
stream
|
||||
.read_to_end(&mut buf)
|
||||
.await
|
||||
.map_err(|e| Error(format!("wallet read: {e}")))?;
|
||||
let mut tmp = [0u8; 8192];
|
||||
loop {
|
||||
let n = stream
|
||||
.read(&mut tmp)
|
||||
.await
|
||||
.map_err(|e| Error(format!("wallet read: {e}")))?;
|
||||
if n == 0 {
|
||||
break;
|
||||
}
|
||||
if buf.len().saturating_add(n) > MAX_RPC_BYTES {
|
||||
return Err(Error("wallet RPC response too large".into()));
|
||||
}
|
||||
buf.extend_from_slice(&tmp[..n]);
|
||||
}
|
||||
Ok(buf)
|
||||
}
|
||||
|
||||
fn http_status(raw: &[u8]) -> Option<u16> {
|
||||
let text = std::str::from_utf8(raw).ok()?;
|
||||
let line = text.lines().next()?;
|
||||
let mut parts = line.split_whitespace();
|
||||
let _http = parts.next()?;
|
||||
parts.next()?.parse().ok()
|
||||
}
|
||||
|
||||
fn www_authenticate(raw: &[u8]) -> Option<String> {
|
||||
let text = std::str::from_utf8(raw).ok()?;
|
||||
let (head, _) = text
|
||||
.split_once("\r\n\r\n")
|
||||
.or_else(|| text.split_once("\n\n"))?;
|
||||
for line in head.lines().skip(1) {
|
||||
let (k, v) = match line.split_once(':') {
|
||||
Some(kv) => kv,
|
||||
None => continue,
|
||||
};
|
||||
if k.eq_ignore_ascii_case("www-authenticate") {
|
||||
return Some(v.trim().to_string());
|
||||
}
|
||||
}
|
||||
None
|
||||
}
|
||||
|
||||
fn digest_param(challenge: &str, key: &str) -> Option<String> {
|
||||
let t = challenge.trim();
|
||||
let rest = if t.len() >= 6 && t[..6].eq_ignore_ascii_case("digest") {
|
||||
t[6..].trim()
|
||||
} else {
|
||||
return None;
|
||||
};
|
||||
for part in rest.split(',') {
|
||||
let part = part.trim();
|
||||
let (k, v) = match part.split_once('=') {
|
||||
Some(kv) => kv,
|
||||
None => continue,
|
||||
};
|
||||
if k.eq_ignore_ascii_case(key) {
|
||||
return Some(v.trim().trim_matches('"').to_string());
|
||||
}
|
||||
}
|
||||
None
|
||||
}
|
||||
|
||||
fn md5_hex(s: &str) -> String {
|
||||
use md5::{Digest, Md5};
|
||||
hex_lower(&Md5::digest(s.as_bytes()))
|
||||
}
|
||||
|
||||
fn hex_lower(bytes: &[u8]) -> String {
|
||||
const H: &[u8; 16] = b"0123456789abcdef";
|
||||
let mut out = String::with_capacity(bytes.len() * 2);
|
||||
for &b in bytes {
|
||||
out.push(H[(b >> 4) as usize] as char);
|
||||
out.push(H[(b & 0xf) as usize] as char);
|
||||
}
|
||||
out
|
||||
}
|
||||
|
||||
fn digest_authorization(ep: &Endpoint, challenge: &str) -> Result<String> {
|
||||
if let Some(alg) = digest_param(challenge, "algorithm")
|
||||
&& !alg.eq_ignore_ascii_case("MD5")
|
||||
{
|
||||
return Err(Error("wallet RPC digest algorithm not MD5".into()));
|
||||
}
|
||||
let realm = digest_param(challenge, "realm").unwrap_or_default();
|
||||
let nonce = digest_param(challenge, "nonce")
|
||||
.ok_or_else(|| Error("wallet RPC digest required (--rpc-login / HTTP Digest)".into()))?;
|
||||
let qop = digest_param(challenge, "qop");
|
||||
let ha1 = md5_hex(&format!("{}:{realm}:{}", ep.user, ep.pass));
|
||||
let ha2 = md5_hex(&format!("POST:{}", ep.path));
|
||||
let (qop_part, resp) = if qop
|
||||
.as_deref()
|
||||
.is_some_and(|q| q.split(',').any(|x| x.trim() == "auth"))
|
||||
{
|
||||
let mut cnonce_bytes = [0u8; 8];
|
||||
rand::thread_rng().fill_bytes(&mut cnonce_bytes);
|
||||
let cnonce = hex_lower(&cnonce_bytes);
|
||||
let nc = "00000001";
|
||||
let response = md5_hex(&format!("{ha1}:{nonce}:{nc}:{cnonce}:auth:{ha2}"));
|
||||
(
|
||||
format!(", qop=auth, nc={nc}, cnonce=\"{cnonce}\""),
|
||||
response,
|
||||
)
|
||||
} else {
|
||||
(String::new(), md5_hex(&format!("{ha1}:{nonce}:{ha2}")))
|
||||
};
|
||||
Ok(format!(
|
||||
"Digest username=\"{}\", realm=\"{realm}\", nonce=\"{nonce}\", uri=\"{}\", algorithm=MD5, response=\"{resp}\"{qop_part}",
|
||||
ep.user, ep.path
|
||||
))
|
||||
}
|
||||
|
||||
fn parse_json_rpc(raw: &[u8]) -> Result<serde_json::Value> {
|
||||
let text = std::str::from_utf8(raw).map_err(|_| Error("wallet RPC not UTF-8".into()))?;
|
||||
let (head, body) = text
|
||||
|
|
|
|||
127
tests/wallet.rs
127
tests/wallet.rs
|
|
@ -12,13 +12,6 @@ fn json_rpc_ok(result: &str) -> String {
|
|||
)
|
||||
}
|
||||
|
||||
async fn serve_once(listener: TcpListener, response: String) {
|
||||
let (mut sock, _) = listener.accept().await.expect("accept");
|
||||
let mut buf = vec![0u8; 4096];
|
||||
let _ = sock.read(&mut buf).await;
|
||||
sock.write_all(response.as_bytes()).await.expect("write");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn disabled_create_address_is_not_configured() {
|
||||
let w = Wallet::disabled();
|
||||
|
|
@ -31,9 +24,19 @@ fn disabled_create_address_is_not_configured() {
|
|||
assert!(err.to_string().contains("not configured"), "got {err}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn refuse_url_without_credentials() {
|
||||
let err = Wallet::from_url("http://127.0.0.1:18083").unwrap_err();
|
||||
let msg = err.to_string().to_ascii_lowercase();
|
||||
assert!(
|
||||
msg.contains("credential") || msg.contains("login") || msg.contains("user"),
|
||||
"got {err}"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn refuse_non_loopback_non_onion_host() {
|
||||
let err = Wallet::from_url("http://example.com:18083").unwrap_err();
|
||||
let err = Wallet::from_url("http://ow:secret@example.com:18083").unwrap_err();
|
||||
assert!(
|
||||
err.to_string().to_ascii_lowercase().contains("host")
|
||||
|| err.to_string().contains("loopback")
|
||||
|
|
@ -42,6 +45,34 @@ fn refuse_non_loopback_non_onion_host() {
|
|||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn refuse_onion_rpc_url() {
|
||||
let err = Wallet::from_url(
|
||||
"http://ow:s3cretPASS@abcdefghijklmnopqrstuvwxyz234567abcdefghijklmnopqrstuvwxyz.onion:18083",
|
||||
)
|
||||
.unwrap_err();
|
||||
let msg = err.to_string();
|
||||
assert!(msg.to_ascii_lowercase().contains("onion"), "got {err}");
|
||||
assert!(
|
||||
!msg.contains("s3cretPASS"),
|
||||
"password leaked in error: {err}"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn password_absent_from_url_errors() {
|
||||
let err = Wallet::from_url("http://ow:s3cretPASS@example.com:18083").unwrap_err();
|
||||
assert!(
|
||||
!err.to_string().contains("s3cretPASS"),
|
||||
"password leaked in error: {err}"
|
||||
);
|
||||
let err = Wallet::from_url("http://ow:s3cretPASS@").unwrap_err();
|
||||
assert!(
|
||||
!err.to_string().contains("s3cretPASS"),
|
||||
"password leaked in error: {err}"
|
||||
);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn mock_get_address_parses_string() {
|
||||
let listener = TcpListener::bind("127.0.0.1:0").await.unwrap();
|
||||
|
|
@ -49,8 +80,8 @@ async fn mock_get_address_parses_string() {
|
|||
let canned = json_rpc_ok(
|
||||
r#"{"address":"4AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA"}"#,
|
||||
);
|
||||
tokio::spawn(serve_once(listener, canned));
|
||||
let w = Wallet::from_url(&format!("http://127.0.0.1:{}", addr.port())).unwrap();
|
||||
tokio::spawn(serve_digest_then(listener, canned));
|
||||
let w = Wallet::from_url(&format!("http://ow:secret@127.0.0.1:{}", addr.port())).unwrap();
|
||||
let got = w.get_address().await.expect("get_address");
|
||||
assert!(got.starts_with('4'), "got {got}");
|
||||
}
|
||||
|
|
@ -62,15 +93,15 @@ async fn mock_create_address_and_transfer() {
|
|||
let canned = json_rpc_ok(
|
||||
r#"{"address":"8BBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBB","tx_hash":"abc123"}"#,
|
||||
);
|
||||
tokio::spawn(serve_once(listener, canned.clone()));
|
||||
let w = Wallet::from_url(&format!("http://127.0.0.1:{}", addr.port())).unwrap();
|
||||
tokio::spawn(serve_digest_then(listener, canned.clone()));
|
||||
let w = Wallet::from_url(&format!("http://ow:secret@127.0.0.1:{}", addr.port())).unwrap();
|
||||
let created = w.create_address().await.expect("create_address");
|
||||
assert!(created.starts_with('8'));
|
||||
|
||||
let listener = TcpListener::bind("127.0.0.1:0").await.unwrap();
|
||||
let addr = listener.local_addr().unwrap();
|
||||
tokio::spawn(serve_once(listener, canned));
|
||||
let w = Wallet::from_url(&format!("http://127.0.0.1:{}", addr.port())).unwrap();
|
||||
tokio::spawn(serve_digest_then(listener, canned));
|
||||
let w = Wallet::from_url(&format!("http://ow:secret@127.0.0.1:{}", addr.port())).unwrap();
|
||||
let txid = w
|
||||
.transfer("8BBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBB", 1)
|
||||
.await
|
||||
|
|
@ -85,8 +116,8 @@ async fn mock_get_transfers_matches_txid() {
|
|||
let canned = json_rpc_ok(
|
||||
r#"{"in":[{"txid":"deadbeef","amount":5,"address":"8BBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBB"}],"pending":[]}"#,
|
||||
);
|
||||
tokio::spawn(serve_once(listener, canned));
|
||||
let w = Wallet::from_url(&format!("http://127.0.0.1:{}", addr.port())).unwrap();
|
||||
tokio::spawn(serve_digest_then(listener, canned));
|
||||
let w = Wallet::from_url(&format!("http://ow:secret@127.0.0.1:{}", addr.port())).unwrap();
|
||||
let rows = w.get_transfers().await.expect("get_transfers");
|
||||
assert!(wallet::transfers_match(
|
||||
&rows,
|
||||
|
|
@ -96,3 +127,67 @@ async fn mock_get_transfers_matches_txid() {
|
|||
));
|
||||
assert!(!wallet::transfers_match(&rows, "nope", "1", "nope"));
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn oversized_rpc_response_is_err() {
|
||||
let listener = TcpListener::bind("127.0.0.1:0").await.unwrap();
|
||||
let addr = listener.local_addr().unwrap();
|
||||
let huge = vec![b'A'; 2 * 1024 * 1024];
|
||||
tokio::spawn(async move {
|
||||
let (mut sock, _) = listener.accept().await.expect("accept");
|
||||
let mut buf = vec![0u8; 4096];
|
||||
let _ = sock.read(&mut buf).await;
|
||||
sock.write_all(b"HTTP/1.1 200 OK\r\nContent-Type: text/plain\r\nConnection: close\r\n\r\n")
|
||||
.await
|
||||
.expect("hdr");
|
||||
sock.write_all(&huge).await.expect("body");
|
||||
});
|
||||
let w = Wallet::from_url(&format!("http://ow:secret@127.0.0.1:{}", addr.port())).unwrap();
|
||||
let err = w.get_address().await.unwrap_err();
|
||||
let msg = err.to_string().to_ascii_lowercase();
|
||||
assert!(
|
||||
msg.contains("large") || msg.contains("size") || msg.contains("cap"),
|
||||
"got {err}"
|
||||
);
|
||||
assert!(
|
||||
!err.to_string().contains("secret"),
|
||||
"password leaked: {err}"
|
||||
);
|
||||
}
|
||||
|
||||
fn digest_401() -> String {
|
||||
"HTTP/1.1 401 Unauthorized\r\nWWW-Authenticate: Digest realm=\"monero-rpc\", nonce=\"abcnonce\", qop=\"auth\", algorithm=MD5\r\nContent-Length: 0\r\nConnection: close\r\n\r\n".into()
|
||||
}
|
||||
|
||||
async fn serve_digest_then(listener: TcpListener, ok: String) {
|
||||
let (mut sock, _) = listener.accept().await.expect("accept");
|
||||
let mut buf = vec![0u8; 8192];
|
||||
let _ = sock.read(&mut buf).await;
|
||||
sock.write_all(digest_401().as_bytes()).await.expect("401");
|
||||
drop(sock);
|
||||
|
||||
let (mut sock, _) = listener.accept().await.expect("accept2");
|
||||
buf.fill(0);
|
||||
let n = sock.read(&mut buf).await.unwrap_or(0);
|
||||
let req = String::from_utf8_lossy(&buf[..n]);
|
||||
assert!(
|
||||
req.contains("Authorization: Digest"),
|
||||
"missing digest auth: {req}"
|
||||
);
|
||||
assert!(req.contains("username=\"ow\""), "missing user: {req}");
|
||||
assert!(req.contains("response=\""), "missing response: {req}");
|
||||
sock.write_all(ok.as_bytes()).await.expect("200");
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn mock_digest_auth_accepted() {
|
||||
let listener = TcpListener::bind("127.0.0.1:0").await.unwrap();
|
||||
let addr = listener.local_addr().unwrap();
|
||||
let canned = json_rpc_ok(
|
||||
r#"{"address":"4AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA"}"#,
|
||||
);
|
||||
tokio::spawn(serve_digest_then(listener, canned));
|
||||
let w = Wallet::from_url(&format!("http://ow:s3cretPASS@127.0.0.1:{}", addr.port())).unwrap();
|
||||
let got = w.get_address().await.expect("get_address");
|
||||
assert!(got.starts_with('4'), "got {got}");
|
||||
}
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue