mirror of
https://code.gri.mw/GUI/grim.git
synced 2026-07-15 11:18:55 +00:00
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:
Generated
+1
@@ -4227,6 +4227,7 @@ dependencies = [
|
||||
"wgpu",
|
||||
"winit",
|
||||
"winresource",
|
||||
"zeroize",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
|
||||
@@ -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
@@ -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
@@ -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);
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user