From ef58f260e8e8c65b52ba03f121b525d9a04eb124 Mon Sep 17 00:00:00 2001 From: 2ro <17595647+2ro@users.noreply.github.com> Date: Sun, 5 Jul 2026 06:08:25 -0400 Subject: [PATCH] harden(nostr): cap relay ws frames and zeroize conversation keys Two self-contained hardening measures: - WebSocket frame cap: the Tor relay transport dialed every relay with tungstenite's default 64 MiB message / 16 MiB frame ceiling. Pass a WebSocketConfig capping both to 4 MiB via client_async_tls_with_config, so a hostile or buggy relay can't stream a giant frame into wallet memory. The pool only requires max_message_length >= 128 KiB and the wallet's own events are far smaller, so 4 MiB keeps ample headroom. - Secret zeroization: the raw NIP-44 v3 ECDH conversation keys in wrapv3.rs are now Zeroizing<[u8; 32]> so they are scrubbed from memory on drop instead of lingering. zeroize (already a transitive dep) is pulled in directly. Adds a ws-config test asserting the caps sit below the tungstenite defaults and above the pool minimum. --- Cargo.lock | 1 + Cargo.toml | 3 +++ src/nostr/wrapv3.rs | 10 ++++++++-- src/tor/transport.rs | 44 +++++++++++++++++++++++++++++++++++++++++++- 4 files changed, 55 insertions(+), 3 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index aec30b73..d269507d 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -4227,6 +4227,7 @@ dependencies = [ "wgpu", "winit", "winresource", + "zeroize", ] [[package]] diff --git a/Cargo.toml b/Cargo.toml index 2cc031a8..0324afa4 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -122,6 +122,9 @@ nip44 = "0.3.0" ## Only to construct the key types the `nip44` crate takes: nostr-sdk pins ## secp256k1 0.29, the nip44 crate 0.31 — bridged via byte arrays in wrapv3.rs. secp256k1 = "0.31" +## Scrub the NIP-44 conversation-key buffers (raw ECDH secret) from memory on +## drop in wrapv3.rs. Already a transitive dep; pulled in directly here. +zeroize = "1" async-wsocket = "0.13" tokio-tungstenite = { version = "0.26", features = ["rustls-tls-webpki-roots"] } regex = "1" diff --git a/src/nostr/wrapv3.rs b/src/nostr/wrapv3.rs index 92ad0851..2a952a7f 100644 --- a/src/nostr/wrapv3.rs +++ b/src/nostr/wrapv3.rs @@ -30,6 +30,7 @@ use nostr_sdk::nips::nip59::{self, UnwrappedGift}; use nostr_sdk::{ Event, EventBuilder, JsonUtil, Keys, Kind, PublicKey, Tag, Timestamp, UnsignedEvent, }; +use zeroize::Zeroizing; /// The capability Goblin advertises in its kind 10050 `encryption` tag, /// space-separated best-first (NIP-17 backward-compat extension). @@ -56,12 +57,17 @@ pub fn peer_supports_v3(encryption: Option<&str>) -> bool { /// Derive the v3 conversation key between our secret key and a peer's /// public key, bridging nostr-sdk's key types (secp256k1 0.29) to the nip44 /// crate's (0.31) via their byte serializations. -fn conversation_key(secret: &nostr_sdk::SecretKey, public: &PublicKey) -> Result<[u8; 32], String> { +fn conversation_key( + secret: &nostr_sdk::SecretKey, + public: &PublicKey, +) -> Result, String> { let sk = secp256k1::SecretKey::from_byte_array(secret.to_secret_bytes()) .map_err(|e| format!("invalid secret key: {e}"))?; let pk = secp256k1::XOnlyPublicKey::from_byte_array(*public.as_bytes()) .map_err(|e| format!("invalid public key: {e}"))?; - Ok(nip44::get_conversation_key_v3(sk, pk)) + // The raw ECDH conversation key is secret material: wrap it so it is scrubbed + // from memory on drop. It derefs to `&[u8; 32]` for the nip44 calls below. + Ok(Zeroizing::new(nip44::get_conversation_key_v3(sk, pk))) } /// Build a NIP-17 private-message gift wrap encrypted with NIP-44 v3. diff --git a/src/tor/transport.rs b/src/tor/transport.rs index 6de0f480..6580ed9d 100644 --- a/src/tor/transport.rs +++ b/src/tor/transport.rs @@ -39,6 +39,23 @@ fn terr(msg: impl Into) -> TransportError { TransportError::backend(std::io::Error::other(msg.into())) } +/// Per-message / per-frame ceiling for every relay websocket. The relay pool +/// only ever requires `max_message_length >= 131072` (128 KiB) from a relay, +/// and the wallet's own events (gift wraps, kind-0/10050) are far smaller. +/// tungstenite otherwise defaults to a 64 MiB message / 16 MiB frame ceiling; +/// tighten it to a few MiB so a hostile or buggy relay can't stream a giant +/// frame into wallet memory, while keeping ample headroom above any legitimate +/// event. +const WS_MAX_MESSAGE_SIZE: usize = 4 << 20; // 4 MiB +const WS_MAX_FRAME_SIZE: usize = 4 << 20; // 4 MiB + +/// The tightened websocket config applied to every relay dial. +fn ws_config() -> tokio_tungstenite::tungstenite::protocol::WebSocketConfig { + tokio_tungstenite::tungstenite::protocol::WebSocketConfig::default() + .max_message_size(Some(WS_MAX_MESSAGE_SIZE)) + .max_frame_size(Some(WS_MAX_FRAME_SIZE)) +} + /// Nostr websocket transport over embedded Tor. #[derive(Debug, Clone, Copy, Default)] pub struct TorWebSocketTransport; @@ -81,7 +98,12 @@ impl WebSocketTransport for TorWebSocketTransport { .map_err(terr)?; let (ws, _response) = tokio::time::timeout( timeout, - tokio_tungstenite::client_async_tls(url.as_str(), stream), + tokio_tungstenite::client_async_tls_with_config( + url.as_str(), + stream, + Some(ws_config()), + None, + ), ) .await .map_err(|_| terr("websocket handshake timeout"))? @@ -160,3 +182,23 @@ where .map_err(TransportError::backend) } } + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn ws_config_caps_message_and_frame_size() { + let cfg = ws_config(); + // Tightened to the constants, well below tungstenite's 64 MiB / 16 MiB + // defaults so a hostile relay can't stream a giant frame into memory... + assert_eq!(cfg.max_message_size, Some(WS_MAX_MESSAGE_SIZE)); + assert_eq!(cfg.max_frame_size, Some(WS_MAX_FRAME_SIZE)); + assert!(WS_MAX_MESSAGE_SIZE < 64 << 20); + assert!(WS_MAX_FRAME_SIZE < 16 << 20); + // ...yet with ample headroom above the pool's 128 KiB minimum event size, + // so no legitimate relay traffic is ever truncated. + assert!(WS_MAX_MESSAGE_SIZE >= 131_072); + assert!(WS_MAX_FRAME_SIZE >= 131_072); + } +}