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.
This commit is contained in:
2ro
2026-07-05 06:08:25 -04:00
parent 210c4ab662
commit ef58f260e8
4 changed files with 55 additions and 3 deletions
Generated
+1
View File
@@ -4227,6 +4227,7 @@ dependencies = [
"wgpu",
"winit",
"winresource",
"zeroize",
]
[[package]]
+3
View File
@@ -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"
+8 -2
View File
@@ -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<Zeroizing<[u8; 32]>, 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.
+43 -1
View File
@@ -39,6 +39,23 @@ fn terr(msg: impl Into<String>) -> 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);
}
}