Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
11 changes: 11 additions & 0 deletions changelog.d/rest-electrum.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,11 @@
Security

- `/rest/` on the RPC listener is off unless `--rest` or `rest=` is set.
It uses its own queue, and the body is read before that permit is taken.
- A silent-payment subscribe scans at most the recent 256-block window,
including when the client passes a start height. The scan stops when
the client hangs up.
- RPC waits are capped at two minutes, the listener accepts at most 256
connections, and a long-poll does not hold a work-queue slot.
- API logs strip `xprv` / `tprv` material and silent-payment scan secrets.
- Findings write-ups: 063, 064, 065, 066.
46 changes: 40 additions & 6 deletions crates/rbitcoin-electrum/src/server.rs
Original file line number Diff line number Diff line change
Expand Up @@ -17,7 +17,7 @@ use std::net::SocketAddr;
use std::sync::atomic::{AtomicBool, Ordering};
use std::sync::{Arc, Mutex, OnceLock};
use std::time::{Duration, Instant};
use tokio::io::{AsyncBufReadExt, AsyncRead, AsyncWrite, AsyncWriteExt, BufReader};
use tokio::io::{AsyncBufRead, AsyncBufReadExt, AsyncRead, AsyncWrite, AsyncWriteExt, BufReader};
use tokio::net::TcpListener;
use tokio::sync::{broadcast, Notify, Semaphore};
use tokio::task::JoinHandle;
Expand Down Expand Up @@ -570,6 +570,7 @@ where
let params_v = req.get("params").cloned().unwrap_or(json!([]));
if method == "blockchain.silentpayments.subscribe" {
serve_sp_subscribe(
&mut reader,
&mut writer,
&query,
&params,
Expand Down Expand Up @@ -738,7 +739,8 @@ where
/// Tweaks stream: JSON-RPC result = first height, then one notify per
/// following height, then `{"message":"done"}`. Honor `count` through tip.
/// Answer `server.ping` while computing.
async fn serve_sp_subscribe<W>(
async fn serve_sp_subscribe<R, W>(
reader: &mut R,
writer: &mut W,
query: &Arc<Query>,
chain: &Arc<ChainParams>,
Expand All @@ -748,6 +750,7 @@ async fn serve_sp_subscribe<W>(
conn: &mut ElectrumConn,
) -> Result<(), std::io::Error>
where
R: AsyncBufRead + Unpin,
W: AsyncWrite + Unpin,
{
let tip = query.tip_height().map(|h| h.0);
Expand Down Expand Up @@ -782,8 +785,13 @@ where
write_line(writer, &rpc_result(&id, &result, None)).await?;
conn.sp_scan_busy = true;
let last = tip.unwrap_or(sub.start);
let hits = scan_sp_off_connection(query, chain, &sub, last).await;
let scanned = scan_sp_off_connection(reader, query, chain, &sub, last).await;
let hits = scanned.hits;
conn.sp_scan_busy = false;
// The client left before any chunk. A history line would write into a closed socket.
if scanned.chunks == 0 && sub.start <= last {
return Ok(());
}
let note = json!({
"jsonrpc": "2.0",
"method": "blockchain.silentpayments.subscribe",
Expand Down Expand Up @@ -901,16 +909,41 @@ fn sp_scan_ranges(start: u32, last: u32) -> Vec<(u32, u32)> {
ranges
}

async fn scan_sp_off_connection(
struct SpScan {
hits: Vec<Value>,
/// Chunks scanned before the client hung up.
chunks: usize,
}

/// One poll. `fill_buf` does not consume: an empty ready buffer is EOF,
/// and pending means the client is still connected. A zero timeout can
/// poll twice, so this does not use one.
fn peer_hung_up<R: AsyncBufRead + Unpin>(reader: &mut R) -> bool {
let waker = std::task::Waker::noop();
let mut cx = std::task::Context::from_waker(waker);
let mut fut = std::pin::pin!(reader.fill_buf());
match std::future::Future::poll(fut.as_mut(), &mut cx) {
std::task::Poll::Ready(Ok(buf)) => buf.is_empty(),
std::task::Poll::Ready(Err(_)) => true,
std::task::Poll::Pending => false,
}
}

async fn scan_sp_off_connection<R: AsyncBufRead + Unpin>(
reader: &mut R,
query: &Arc<Query>,
chain: &Arc<ChainParams>,
sub: &crate::silent_scan::SpSub,
last: u32,
) -> Vec<Value> {
) -> SpScan {
use crate::silent_scan::SP_SCAN_PERMITS;
static PERMITS: tokio::sync::Semaphore = tokio::sync::Semaphore::const_new(SP_SCAN_PERMITS);
let mut hits = Vec::new();
let mut chunks = 0usize;
for (from, end) in sp_scan_ranges(sub.start, last) {
if peer_hung_up(reader) {
break;
}
let Ok(permit) = PERMITS.acquire().await else {
break;
};
Expand All @@ -925,8 +958,9 @@ async fn scan_sp_off_connection(
.unwrap_or_default();
drop(permit);
hits.extend(chunk);
chunks += 1;
}
hits
SpScan { hits, chunks }
}

#[allow(clippy::too_many_arguments)] // matches handle_client call-site
Expand Down
58 changes: 58 additions & 0 deletions crates/rbitcoin-electrum/src/server_tests.rs
Original file line number Diff line number Diff line change
Expand Up @@ -1498,4 +1498,62 @@ fn sp_scan_ranges_cover_one_height_and_the_next_chunk() {
assert_eq!(super::sp_scan_ranges(0, 1), vec![(0, 1)]);
}

#[tokio::test(flavor = "current_thread")]
async fn sp_scan_stops_when_the_client_hangs_up() {
use std::pin::Pin;
use std::task::{Context, Poll};
use tokio::io::{AsyncBufRead, AsyncRead, ReadBuf};

struct HangUp {
polls: std::cell::Cell<usize>,
}
impl AsyncRead for HangUp {
fn poll_read(
self: Pin<&mut Self>,
_cx: &mut Context<'_>,
_buf: &mut ReadBuf<'_>,
) -> Poll<std::io::Result<()>> {
Poll::Pending
}
}
impl AsyncBufRead for HangUp {
fn poll_fill_buf(
self: Pin<&mut Self>,
_cx: &mut Context<'_>,
) -> Poll<std::io::Result<&[u8]>> {
let n = self.polls.get();
self.polls.set(n + 1);
if n == 0 {
Poll::Pending
} else {
Poll::Ready(Ok(&[]))
}
}
fn consume(self: Pin<&mut Self>, _amt: usize) {}
}

let (_dir, q) = tmp_store();
let q = std::sync::Arc::new(q);
let chain = std::sync::Arc::new(ChainParams::regtest());
let scan = "0f694e068028a717f8af6b9411f9a133dd3565258714cc226594b34db90c1f2c";
let spend = "025cc9856d6f8375350e123978daac200c260cb5b5ae83106cab90484dcd8fcf36";
let sub = crate::silent_scan::parse_sub(
&json!([scan, spend, 0]),
bitcoin::Network::Regtest,
Some(crate::silent_scan::SP_SCAN_CHUNK),
)
.unwrap();
let last = crate::silent_scan::SP_SCAN_CHUNK;
let ranges = super::sp_scan_ranges(sub.start, last);
assert_eq!(ranges.len(), 2, "the fixture spans two chunks");
let mut reader = HangUp {
polls: std::cell::Cell::new(0),
};
let scanned = super::scan_sp_off_connection(&mut reader, &q, &chain, &sub, last).await;
assert_eq!(
scanned.chunks, 1,
"a hang-up before the next chunk stops the scan"
);
}

include!("electrum_sh_journey.rs");
12 changes: 7 additions & 5 deletions crates/rbitcoin-electrum/src/silent_scan.rs
Original file line number Diff line number Diff line change
Expand Up @@ -66,11 +66,10 @@ pub fn parse_sub(params: &Value, network: Network, tip: Option<u32>) -> Result<S
return Err("too many silent payment labels".into());
}
let start = start.min(tip.unwrap_or(start));
// A missing or zero start is the whole chain. Bound it to a recent window.
let start = if start == 0 {
tip.unwrap_or(0).saturating_sub(SP_HISTORY_WINDOW)
} else {
start
// Any start, including a nonzero one, stays inside the recent window.
let start = match tip {
Some(tip_h) => start.max(tip_h.saturating_sub(SP_HISTORY_WINDOW)),
None => start,
};
let address = encode_sp_address(network, &scan, &spend);
Ok(SpSub {
Expand Down Expand Up @@ -198,6 +197,9 @@ mod tests {
assert_eq!(null_start.start, 0);
let bounded = parse_sub(&json!([scan, spend, 0]), Network::Regtest, Some(1_000)).unwrap();
assert_eq!(bounded.start, 1_000 - SP_HISTORY_WINDOW);
let wide = parse_sub(&json!([scan, spend, 1]), Network::Regtest, Some(10_000)).unwrap();
assert_eq!(wide.start, 10_000 - SP_HISTORY_WINDOW);
assert!(10_000 - wide.start <= SP_HISTORY_WINDOW);
let bad_spend = match parse_sub(&json!([scan, "02"]), Network::Regtest, Some(0)) {
Err(e) => e,
Ok(_) => panic!("spend"),
Expand Down
125 changes: 122 additions & 3 deletions crates/rbitcoin-log/src/api_log.rs
Original file line number Diff line number Diff line change
Expand Up @@ -54,8 +54,8 @@ fn compact_params(params: &str) -> String {

/// Record one Electrum / Esplora / RPC call.
///
/// `params` should already be compact (see [`compact_params`]). `err` is
/// `None` on success.
/// Extended private keys and silent-payment scan secrets are stripped here,
/// then the params blob is compacted. `err` is `None` on success.
pub fn api_call(
surface: &str,
peer: &str,
Expand All @@ -64,7 +64,7 @@ pub fn api_call(
wall_ms: u64,
err: Option<&str>,
) {
let params = compact_params(params);
let params = compact_params(&redact_secrets(method, params));

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[suggestion] api_call now always runs redact_ext_privkeys, which allocates and copies the entire params string, and then compact_params copies again. exec_one already built that string with serde_json::to_string and calls api_call on the blocking RPC thread while rpc_post still holds the work-queue permit. compact_params used to keep at most 384 bytes; a submitblock body (up to the 2 MiB HTTP cap) is now copied in full before truncation. Redaction itself is in the right place (before the trace line and the JSONL write) and covers unsubscribe via method.contains("silentpayment") plus xprv/tprv/yprv/zprv on every method.

Suggestion: Scan for the tags and a quoted 64-hex secret, and allocate a second string only when one matches. Truncate to PARAMS_MAX before the copy when the line will not contain a secret past the cap, so a large RPC body is not duplicated on the work-queue thread.

match err {
None => trace!("api: {surface} peer={peer} {method} {params} wall_ms={wall_ms} ok"),
Some(e) => trace!("api: {surface} peer={peer} {method} {params} wall_ms={wall_ms} err={e}"),
Expand All @@ -91,6 +91,75 @@ pub fn api_call(
let _ = file.flush();
}

fn redact_secrets(method: &str, params: &str) -> String {
let out = redact_ext_privkeys(params);
if method.contains("silentpayment") {
redact_quoted_hex64(&out)
} else {
out
}
}

fn is_base58(b: u8) -> bool {
matches!(
b,
b'1'..=b'9' | b'A'..=b'H' | b'J'..=b'N' | b'P'..=b'Z' | b'a'..=b'k' | b'm'..=b'z'
)
}

fn is_hex(b: u8) -> bool {
b.is_ascii_hexdigit()
}

/// `xprv` / `tprv` / `yprv` / `zprv` plus the following base58 key material.
fn redact_ext_privkeys(s: &str) -> String {
let b = s.as_bytes();
let mut out = String::with_capacity(s.len());
let mut i = 0;
while i < b.len() {
if i + 4 <= b.len() {
let tag = &b[i..i + 4];
if matches!(tag, b"xprv" | b"tprv" | b"yprv" | b"zprv") {
let mut j = i + 4;
while j < b.len() && is_base58(b[j]) {
j += 1;
}
if j > i + 4 {
out.push_str("<redacted>");
i = j;
continue;
}
}
}
let ch = s[i..].chars().next().unwrap();
out.push(ch);
i += ch.len_utf8();
}
out
}

/// A quoted 64-hex string is a silent-payment scan secret on that method.
fn redact_quoted_hex64(s: &str) -> String {
let b = s.as_bytes();
let mut out = String::with_capacity(s.len());
let mut i = 0;
while i < b.len() {
if b[i] == b'"'
&& i + 65 < b.len()
&& b[i + 65] == b'"'
&& b[i + 1..i + 65].iter().copied().all(is_hex)
{
out.push_str("\"<redacted>\"");
i += 66;
continue;
}
let ch = s[i..].chars().next().unwrap();
out.push(ch);
i += ch.len_utf8();
}
out
}

fn json_escape(s: &str) -> String {
let mut out = String::with_capacity(s.len());
for c in s.chars() {
Expand Down Expand Up @@ -189,4 +258,54 @@ mod tests {
fn json_escape_quotes() {
assert_eq!(json_escape("a\"b\\c"), "a\\\"b\\\\c");
}

#[test]
fn api_call_redacts_scan_secrets_and_ext_privkeys() {
let _g = TEST_LOCK.lock().unwrap_or_else(|e| e.into_inner());
let scan = "ab".repeat(32);
let xprv = format!("xprv{}", "1".repeat(40));
let tprv = format!("tprv{}", "A".repeat(20));
let path = std::env::temp_dir().join(format!(
"rbitcoin-api-redact-{}-{}.jsonl",
std::process::id(),
std::time::SystemTime::now()
.duration_since(std::time::UNIX_EPOCH)
.map(|d| d.as_nanos())
.unwrap_or(0)
));
let _ = std::fs::remove_file(&path);
init_api_log(&path).unwrap();
crate::capture_logs(true);
api_call(
"electrum",
"127.0.0.1:1",
"blockchain.silentpayments.unsubscribe",
&format!("[\"{scan}\",\"02ff\",1]"),
4,
None,
);
api_call(
"rpc",
"-",
"scantxoutset",
&format!("[\"start\",[\"{xprv}\",\"{tprv}\"]]"),
5,
None,
);
let logs = crate::take_logs();
crate::capture_logs(false);
close_api_log();
let body = std::fs::read_to_string(&path).unwrap();
let _ = std::fs::remove_file(&path);
let trace = logs
.iter()
.map(|(_, msg)| msg.clone())
.collect::<Vec<_>>()
.join("\n");
let all = format!("{body}\n{trace}");
assert!(!all.contains(&scan), "{all}");
assert!(!all.contains(&xprv), "{all}");
assert!(!all.contains(&tprv), "{all}");
assert!(all.contains("<redacted>"), "{all}");
}
}
6 changes: 4 additions & 2 deletions crates/rbitcoin-node/src/cli.rs
Original file line number Diff line number Diff line change
Expand Up @@ -304,7 +304,7 @@ fn operator_usage() -> String {
[--i2p-sam [HOST:PORT]] [--i2p-accept-incoming] \\\n\
[--electrum-listen ADDR] [--esplora-listen ADDR] [--esplora-onion[=0|1]] [--health-listen [ADDR]] [--metrics] \\\n\
[--sh-index] [--block-filter-index] [--prune-seqsigwit] [--prune-seqsigwit-ram-threshold-bytes N] [--sp-tweaks] [--sp-tweaks-dust SATS] [--max-sh-creates N] [--electrum-max-subs N] [--esplora-block-template] \\\n\
[--rpc] [--rpc-listen [ADDR]] [--rpc-socket PATH] [--rpc-token-file PATH] [--rpc-cookie-file PATH] [--rpc-work-queue N] \\\n\
[--rpc] [--rpc-listen [ADDR]] [--rest] [--rpc-socket PATH] [--rpc-token-file PATH] [--rpc-cookie-file PATH] [--rpc-work-queue N] \\\n\
[--milestone HEIGHT] \\\n\
[--max-outbound N] [--max-inbound N] \\\n\
[--mempool-size-mb N] [--mempool-expiry HOURS] \\\n\
Expand Down Expand Up @@ -357,7 +357,7 @@ Silent payments: --sp-tweaks (default off) writes/serves the thin BIP-352 tweak
Health: --health-listen [ADDR] serves GET /healthz from the first second of startup\n\
and GET /readyz (default 127.0.0.1:9332). Unauthenticated; keep it on loopback or a\n\
probe-only network. --metrics adds Prometheus GET /metrics there (needs --health-listen).\n\
RPC: --rpc unix socket {{datadir}}/rpc.sock; --rpc-listen [ADDR] adds TCP (default 127.0.0.1 and Core-matching port). Token {{datadir}}/rpc.token (Bearer); --rpc-cookie-file opts TCP into Core cookie HTTP Basic. No --rpcuser.\n\
RPC: --rpc unix socket {{datadir}}/rpc.sock; --rpc-listen [ADDR] adds TCP (default 127.0.0.1 and Core-matching port). Token {{datadir}}/rpc.token (Bearer); --rpc-cookie-file opts TCP into Core cookie HTTP Basic. No --rpcuser. --rest turns on unauthenticated /rest/ on those listeners (off unless set).\n\
Cold files: --datadir-cold PATH puts Class A seqsigwit.body/idx under PATH/store (HDD).\n\
Default (flag omitted): hot and cold files both live under --datadir.\n\
Conf: --conf FILE (snake_case key=value; CLI kebab overrides conf). See OPERATOR.md and docs/rpc.md.\n\
Expand Down Expand Up @@ -413,6 +413,7 @@ fn is_bool_key(key: &str) -> bool {
| "i2p_accept_incoming"
| "inhibit_suspend"
| "metrics"
| "rest"
| "trusted"
| "always_relay"
| "relay"
Expand Down Expand Up @@ -625,6 +626,7 @@ mod tests {
"--esplora-onion",
"--rpc",
"--rpc-listen",
"--rest",
"--rpc-socket",
"--rpc-token-file",
"--rpc-cookie-file",
Expand Down
Loading
Loading