From 2c1bcacf0ad91f9c9e60512d01ca297f4ee3ba17 Mon Sep 17 00:00:00 2001 From: archipelago Date: Mon, 5 Oct 2026 10:00:41 -0400 Subject: [PATCH] Test Nostr encryption compatibility and forged relay messages --- core/archipelago/src/main.rs | 2 + core/archipelago/src/nostr_security_tests.rs | 115 +++++++++++++++++++ 2 files changed, 117 insertions(+) create mode 100644 core/archipelago/src/nostr_security_tests.rs diff --git a/core/archipelago/src/main.rs b/core/archipelago/src/main.rs index 2013a13c..1e8b99dc 100644 --- a/core/archipelago/src/main.rs +++ b/core/archipelago/src/main.rs @@ -70,6 +70,8 @@ mod node_message; mod nostr_discovery; mod nostr_handshake; mod nostr_relays; +#[cfg(test)] +mod nostr_security_tests; mod peers; mod port_allocator; mod rate_limit; diff --git a/core/archipelago/src/nostr_security_tests.rs b/core/archipelago/src/nostr_security_tests.rs new file mode 100644 index 00000000..a960125e --- /dev/null +++ b/core/archipelago/src/nostr_security_tests.rs @@ -0,0 +1,115 @@ +//! Compatibility and hostile-relay regression tests for the 0.44 security update. +//! Loopback sockets run only through scripts/test-backend-isolated.sh. +use nostr_sdk::prelude::*; + +#[test] +fn native_signer_encryption_remains_compatible_and_rejects_hostile_payloads() { + let alice = Keys::generate(); + let bob = Keys::generate(); + let message = "Archipelago signing compatibility — \u{1f30a}"; + let encrypted = nip44::encrypt( + alice.secret_key(), + &bob.public_key(), + message, + nip44::Version::V2, + ) + .unwrap(); + assert_eq!( + nip44::decrypt(bob.secret_key(), &alice.public_key(), &encrypted).unwrap(), + message + ); + let encrypted = nip04::encrypt(alice.secret_key(), &bob.public_key(), message).unwrap(); + assert_eq!( + nip04::decrypt(bob.secret_key(), &alice.public_key(), encrypted).unwrap(), + message + ); + // Valid v2 prefix followed by a payload exceeding the codec's maximum. + // This must fail at the size gate, before allocation/decoding/HMAC work. + let oversized = format!("AgAA{}", "A".repeat(100_000)); + assert!(matches!( + nip44::decrypt(bob.secret_key(), &alice.public_key(), oversized), + Err(nip44::Error::MessageTooLong) + )); + for malformed in ["", "Ag==", "not base64!", "?iv=", "YQ==?iv=YQ=="] { + assert!(nip04::decrypt(bob.secret_key(), &alice.public_key(), malformed).is_err()); + assert!(nip44::decrypt(bob.secret_key(), &alice.public_key(), malformed).is_err()); + } +} + +#[tokio::test] +async fn relay_cannot_substitute_forged_fields_for_a_known_event_id() { + use futures_util::{SinkExt, StreamExt}; + use tokio::net::TcpListener; + use tokio_tungstenite::{accept_async, tungstenite::Message}; + let test = async { + let author = Keys::generate(); + let known = EventBuilder::text_note("already verified") + .sign_with_keys(&author) + .unwrap(); + let valid = EventBuilder::text_note("new legitimate message") + .sign_with_keys(&author) + .unwrap(); + let mut forged_content = known.clone(); + forged_content.content = "forged content".into(); + let mut forged_author = known.clone(); + forged_author.pubkey = Keys::generate().public_key(); + let mut forged_signature = known.clone(); + forged_signature.sig = valid.sig; + for forged in [&forged_content, &forged_author, &forged_signature] { + assert!(forged.verify().is_err()); + } + let listener = TcpListener::bind("127.0.0.1:0").await.unwrap(); + let url = format!("ws://{}", listener.local_addr().unwrap()); + let expected_id = valid.id; + let relay = tokio::spawn(async move { + let (socket, _) = listener.accept().await.unwrap(); + let mut socket = accept_async(socket).await.unwrap(); + while let Some(Ok(Message::Text(text))) = socket.next().await { + let message: serde_json::Value = serde_json::from_str(&text).unwrap(); + if message[0] != "REQ" { + continue; + } + let subscription = message[1].as_str().unwrap(); + for event in [&forged_content, &forged_author, &forged_signature, &valid] { + socket + .send(Message::Text( + serde_json::json!(["EVENT", subscription, event]).to_string(), + )) + .await + .unwrap(); + } + socket + .send(Message::Text( + serde_json::json!(["EOSE", subscription]).to_string(), + )) + .await + .unwrap(); + } + }); + let client = Client::new(Keys::generate()); + client.database().save_event(&known).await.unwrap(); + client.add_relay(&url).await.unwrap(); + client + .try_connect_relay(&url, std::time::Duration::from_secs(3)) + .await + .unwrap(); + let events = client + .fetch_events( + Filter::new().kind(Kind::TextNote), + std::time::Duration::from_secs(5), + ) + .await + .unwrap(); + assert_eq!( + events.len(), + 1, + "forged events must never reach SDK consumers" + ); + assert_eq!(events.iter().next().unwrap().id, expected_id); + client.disconnect().await; + relay.abort(); + }; + tokio::time::timeout(std::time::Duration::from_secs(15), test) + .await + .expect("local relay test timed out"); +}