diff --git a/src/bin/test_net.rs b/src/bin/test_net.rs index 11eac9e..c4271d8 100644 --- a/src/bin/test_net.rs +++ b/src/bin/test_net.rs @@ -14,7 +14,7 @@ async fn main() -> Result<(), Box> { let lookup_a = iroh::address_lookup::memory::MemoryLookup::new(); let secret_a = iroh::SecretKey::generate(); let endpoint_a = Endpoint::builder(presets::N0) - .secret_key(secret_a) + .secret_key(secret_a.clone()) .address_lookup(lookup_a.clone()) .bind() .await?; @@ -27,13 +27,13 @@ async fn main() -> Result<(), Box> { .accept(iroh_gossip::net::GOSSIP_ALPN, gossip_a.clone()) .spawn(); - let room_a = IrohGossipState::new(endpoint_a.clone(), gossip_a.clone(), lookup_a.clone()); + let room_a = IrohGossipState::new(endpoint_a.clone(), gossip_a.clone(), lookup_a.clone(), secret_a); // 2. Node B (Client) Setup let lookup_b = iroh::address_lookup::memory::MemoryLookup::new(); let secret_b = iroh::SecretKey::generate(); let endpoint_b = Endpoint::builder(presets::N0) - .secret_key(secret_b) + .secret_key(secret_b.clone()) .address_lookup(lookup_b.clone()) .bind() .await?; @@ -46,7 +46,7 @@ async fn main() -> Result<(), Box> { .accept(iroh_gossip::net::GOSSIP_ALPN, gossip_b.clone()) .spawn(); - let room_b = IrohGossipState::new(endpoint_b.clone(), gossip_b.clone(), lookup_b.clone()); + let room_b = IrohGossipState::new(endpoint_b.clone(), gossip_b.clone(), lookup_b.clone(), secret_b); // 3. Create room on Node A let topic_id = rand::random(); diff --git a/src/core/mod.rs b/src/core/mod.rs index 96426a2..678fa96 100644 --- a/src/core/mod.rs +++ b/src/core/mod.rs @@ -518,6 +518,7 @@ async fn run_core_loop( endpoint.clone(), gossip.clone(), memory_lookup.clone(), + secret_key.clone(), )); // Fresh join starts not sharing; clear any stale share ticket. diff --git a/src/network/gossip.rs b/src/network/gossip.rs index 23ffb01..9ab154b 100644 --- a/src/network/gossip.rs +++ b/src/network/gossip.rs @@ -1,5 +1,5 @@ use crate::network::{RoomState, NetError, PeerState, RoomEvent, PeerSpeakTicket}; -use iroh::{Endpoint, EndpointId}; +use iroh::{Endpoint, EndpointId, SecretKey, Signature}; use iroh_gossip::net::Gossip; use iroh_gossip::proto::TopicId; use tokio::sync::mpsc; @@ -10,10 +10,96 @@ use async_trait::async_trait; use tokio_stream::StreamExt; use serde::{Serialize, Deserialize}; -#[derive(Serialize, Deserialize, Clone, Debug)] +/// Domain-separation tag mixed into every signed gossip payload so a signature +/// can never be lifted out of this protocol/version into another context. +const GOSSIP_SIG_DOMAIN: &str = "peerspeak-gossip-v1"; + +/// How far a payload's sender-stamped timestamp may differ from local time +/// before it's rejected as stale (replayed) or implausibly future. Bounds the +/// window in which a captured *genuine* signed payload could be replayed; within +/// it, a replay is byte-identical (the signature fixes the bytes) so the gossip +/// swarm's own in-flight de-duplication suppresses it. 2 minutes tolerates +/// reasonable cross-peer clock skew without leaving a wide replay window. +const GOSSIP_FRESHNESS_MS: u64 = 120_000; + +/// A gossip message plus the authentication envelope that proves who sent it. +/// `author` is the claimed sender (an `EndpointId`, which *is* an ed25519 public +/// key); `sig` is that key's signature over [`signable_bytes`], so a forged +/// `author` can't validate (the attacker lacks the victim's secret key). `ts` +/// (sender-stamped unix-millis) is covered by the signature and gates replay +/// freshness — distinct from `GossipMessage::Chat.ts`, which is only for display. +#[derive(Serialize, Deserialize, Clone)] pub struct GossipPayload { pub author: EndpointId, + pub ts: u64, pub msg: GossipMessage, + pub sig: Signature, +} + +// `iroh::Signature` doesn't implement `Debug`, so derive everything else and +// hand-write a `Debug` that omits the (noisy, non-Debug) signature. +impl std::fmt::Debug for GossipPayload { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + f.debug_struct("GossipPayload") + .field("author", &self.author) + .field("ts", &self.ts) + .field("msg", &self.msg) + .finish_non_exhaustive() + } +} + +/// Current unix time in milliseconds (0 on the impossible pre-epoch error). +fn now_millis() -> u64 { + std::time::SystemTime::now() + .duration_since(std::time::UNIX_EPOCH) + .map(|d| d.as_millis() as u64) + .unwrap_or(0) +} + +/// The exact bytes an ed25519 gossip signature covers: domain tag + room topic + +/// claimed author + timestamp + message. Binding the **topic** prevents replay +/// into another room; binding the **timestamp** bounds temporal replay; binding +/// the **author** means a signature only validates for the key it claims to come +/// from. Deterministic (same inputs → same bytes on both ends). +fn signable_bytes(topic: &[u8; 32], author: &EndpointId, ts: u64, msg: &GossipMessage) -> Vec { + serde_json::to_vec(&(GOSSIP_SIG_DOMAIN, topic, author, ts, msg)).unwrap_or_default() +} + +/// Build a signed payload from our secret key for the given room topic. +fn sign_gossip(secret: &SecretKey, topic: &[u8; 32], ts: u64, msg: GossipMessage) -> GossipPayload { + let author = secret.public(); + let sig = secret.sign(&signable_bytes(topic, &author, ts, &msg)); + GossipPayload { author, ts, msg, sig } +} + +/// Why a received gossip payload was rejected (logging + tests). +#[derive(Debug, PartialEq, Eq)] +enum GossipReject { + /// Signature didn't validate for the claimed `author` — a forgery/spoof. + BadSignature, + /// Timestamp outside the freshness window — stale (replay) or implausibly future. + OutOfWindow, +} + +/// Authenticate a received payload against the room topic and local clock. The +/// signature must validate for the claimed `author` (closing the author-spoofing +/// vector, security S2), and the timestamp must be within `window_ms` of +/// `now_ms`. Pure — no I/O — so it's directly unit-testable. +fn verify_gossip( + payload: &GossipPayload, + topic: &[u8; 32], + now_ms: u64, + window_ms: u64, +) -> Result<(), GossipReject> { + let bytes = signable_bytes(topic, &payload.author, payload.ts, &payload.msg); + payload + .author + .verify(&bytes, &payload.sig) + .map_err(|_| GossipReject::BadSignature)?; + if now_ms.abs_diff(payload.ts) > window_ms { + return Err(GossipReject::OutOfWindow); + } + Ok(()) } #[derive(Serialize, Deserialize, Clone, Debug)] @@ -29,12 +115,18 @@ pub struct IrohGossipState { _endpoint: Endpoint, gossip: Gossip, address_lookup: iroh::address_lookup::memory::MemoryLookup, + /// Our node secret key, used to sign every outgoing gossip payload so peers + /// can authenticate us (security S2). Same key the endpoint binds with. + secret_key: SecretKey, self_state: Arc>>, peers: Arc>>, event_tx: mpsc::Sender, event_rx: Mutex>>, active_topic: Mutex>>, active_topic_id: Mutex>, + /// Raw 32-byte id of the joined topic, retained so the out-of-task signers + /// (`update_self_state`/`send_chat`/`leave`) can bind signatures to the room. + active_topic_bytes: Mutex>, active_sender: Mutex>, } @@ -43,18 +135,21 @@ impl IrohGossipState { endpoint: Endpoint, gossip: Gossip, address_lookup: iroh::address_lookup::memory::MemoryLookup, + secret_key: SecretKey, ) -> Self { let (event_tx, event_rx) = mpsc::channel(100); Self { _endpoint: endpoint, gossip, address_lookup, + secret_key, self_state: Arc::new(Mutex::new(None)), peers: Arc::new(Mutex::new(HashMap::new())), event_tx, event_rx: Mutex::new(Some(event_rx)), active_topic: Mutex::new(None), active_topic_id: Mutex::new(None), + active_topic_bytes: Mutex::new(None), active_sender: Mutex::new(None), } } @@ -96,6 +191,7 @@ impl RoomState for IrohGossipState { *self.self_state.lock().unwrap() = Some(self_state.clone()); *self.active_topic_id.lock().unwrap() = Some(topic_id); + *self.active_topic_bytes.lock().unwrap() = Some(ticket.topic_id); *self.active_sender.lock().unwrap() = Some(gossip_sender.clone()); let event_tx = self.event_tx.clone(); @@ -104,6 +200,10 @@ impl RoomState for IrohGossipState { let self_state_clone = self.self_state.clone(); let gossip_sender_clone = gossip_sender.clone(); let self_id = self_state.addr.id; + // The signing key + room topic the spawned loop needs to sign our own + // broadcasts and authenticate every inbound payload (security S2). + let secret_key = self.secret_key.clone(); + let topic_bytes = ticket.topic_id; let handle = tokio::spawn(async move { crate::log_msg(&format!("Spawned gossip topic loop for self_id={:?}", self_id)); @@ -111,10 +211,12 @@ impl RoomState for IrohGossipState { // Broadcast initial state let initial_payload = { let guard = self_state_clone.lock().unwrap(); - guard.as_ref().map(|s| GossipPayload { - author: s.addr.id, - msg: GossipMessage::Announce(s.clone()), - }) + guard.as_ref().map(|s| sign_gossip( + &secret_key, + &topic_bytes, + now_millis(), + GossipMessage::Announce(s.clone()), + )) }; if let Some(payload) = initial_payload @@ -130,6 +232,19 @@ impl RoomState for IrohGossipState { crate::log_msg(&format!("Gossip received Event::Received from delivery={:?}", msg.delivered_from)); match serde_json::from_slice::(&msg.content) { Ok(payload) => { + // Authenticate before trusting `author` for ANY + // action: a forged/stale payload is dropped here + // so it can't impersonate, evict, or poison + // presence/address-book (security S2). + if let Err(reason) = + verify_gossip(&payload, &topic_bytes, now_millis(), GOSSIP_FRESHNESS_MS) + { + crate::log_msg(&format!( + "Gossip dropped unauthenticated/stale payload claiming author={:?}: {:?}", + payload.author, reason + )); + continue; + } let our_id = { self_state_clone.lock().unwrap() .as_ref() @@ -196,10 +311,12 @@ impl RoomState for IrohGossipState { // Resend state on new neighbor connection to guarantee synchronization let payload_opt = { let guard = self_state_clone.lock().unwrap(); - guard.as_ref().map(|state| GossipPayload { - author: state.addr.id, - msg: GossipMessage::Announce(state.clone()), - }) + guard.as_ref().map(|state| sign_gossip( + &secret_key, + &topic_bytes, + now_millis(), + GossipMessage::Announce(state.clone()), + )) }; if let Some(payload) = payload_opt && let Ok(bytes) = serde_json::to_vec(&payload) { @@ -242,11 +359,14 @@ impl RoomState for IrohGossipState { *self.self_state.lock().unwrap() = Some(self_state.clone()); let sender_opt = self.active_sender.lock().unwrap().clone(); - if let Some(sender) = sender_opt { - let payload = GossipPayload { - author: self_state.addr.id, - msg: GossipMessage::Announce(self_state), - }; + let topic_opt = *self.active_topic_bytes.lock().unwrap(); + if let (Some(sender), Some(topic)) = (sender_opt, topic_opt) { + let payload = sign_gossip( + &self.secret_key, + &topic, + now_millis(), + GossipMessage::Announce(self_state), + ); if let Ok(bytes) = serde_json::to_vec(&payload) { crate::log_msg("Broadcasting updated self state to gossip"); sender.broadcast(bytes.into()).await @@ -257,24 +377,24 @@ impl RoomState for IrohGossipState { } async fn send_chat(&self, text: String) -> Result<(), NetError> { - let (name, author) = { + let name = { let guard = self.self_state.lock().unwrap(); match guard.as_ref() { - Some(s) => (s.name.clone(), s.addr.id), + Some(s) => s.name.clone(), None => return Err(NetError::Other("Not in a room".to_string())), } }; - let ts = std::time::SystemTime::now() - .duration_since(std::time::UNIX_EPOCH) - .map(|d| d.as_millis() as u64) - .unwrap_or(0); + let ts = now_millis(); let sender_opt = self.active_sender.lock().unwrap().clone(); - if let Some(sender) = sender_opt { - let payload = GossipPayload { - author, - msg: GossipMessage::Chat { name, text, ts }, - }; + let topic_opt = *self.active_topic_bytes.lock().unwrap(); + if let (Some(sender), Some(topic)) = (sender_opt, topic_opt) { + let payload = sign_gossip( + &self.secret_key, + &topic, + ts, + GossipMessage::Chat { name, text, ts }, + ); if let Ok(bytes) = serde_json::to_vec(&payload) { sender.broadcast(bytes.into()).await .map_err(|e| NetError::Gossip(e.to_string()))?; @@ -294,19 +414,19 @@ impl RoomState for IrohGossipState { } *self.active_topic_id.lock().unwrap() = None; + let topic_opt = self.active_topic_bytes.lock().unwrap().take(); let sender_opt = self.active_sender.lock().unwrap().take(); - if let Some(sender) = sender_opt { - let self_state_opt = self.self_state.lock().unwrap().clone(); - if let Some(self_state) = self_state_opt { - let payload = GossipPayload { - author: self_state.addr.id, - msg: GossipMessage::Leave, - }; - if let Ok(bytes) = serde_json::to_vec(&payload) { - crate::log_msg("Broadcasting Leave message to gossip"); - let _ = sender.broadcast(bytes.into()).await; - } + if let (Some(sender), Some(topic)) = (sender_opt, topic_opt) { + let payload = sign_gossip( + &self.secret_key, + &topic, + now_millis(), + GossipMessage::Leave, + ); + if let Ok(bytes) = serde_json::to_vec(&payload) { + crate::log_msg("Broadcasting Leave message to gossip"); + let _ = sender.broadcast(bytes.into()).await; } } @@ -357,17 +477,18 @@ mod tests { #[test] fn test_gossip_payload_announce_round_trip() { + let secret = SecretKey::generate(); + let topic = [9u8; 32]; let peer_state = sample_peer_state(); - let author = peer_state.addr.id; - let payload = GossipPayload { - author, - msg: GossipMessage::Announce(peer_state.clone()), - }; + let payload = sign_gossip(&secret, &topic, 1000, GossipMessage::Announce(peer_state.clone())); let serialized = serde_json::to_string(&payload).unwrap(); let deserialized: GossipPayload = serde_json::from_str(&serialized).unwrap(); - assert_eq!(deserialized.author, author); + // Author is derived from the signing key, and the signed payload still + // verifies after a serde round-trip. + assert_eq!(deserialized.author, secret.public()); + assert!(verify_gossip(&deserialized, &topic, 1000, GOSSIP_FRESHNESS_MS).is_ok()); match deserialized.msg { GossipMessage::Announce(state) => { assert_eq!(state, peer_state); @@ -415,21 +536,23 @@ mod tests { #[test] fn test_gossip_payload_chat_round_trip() { - let peer_state = sample_peer_state(); - let author = peer_state.addr.id; - let payload = GossipPayload { - author, - msg: GossipMessage::Chat { + let secret = SecretKey::generate(); + let topic = [3u8; 32]; + let payload = sign_gossip( + &secret, + &topic, + 987654321, + GossipMessage::Chat { name: "Bob".to_string(), text: "Hi there".to_string(), ts: 987654321, }, - }; + ); let serialized = serde_json::to_string(&payload).unwrap(); let deserialized: GossipPayload = serde_json::from_str(&serialized).unwrap(); - assert_eq!(deserialized.author, author); + assert_eq!(deserialized.author, secret.public()); if let GossipMessage::Chat { name, text, ts } = deserialized.msg { assert_eq!(name, "Bob"); assert_eq!(text, "Hi there"); @@ -456,5 +579,74 @@ mod tests { panic!("Expected GossipMessage::Chat"); } } + + // --- S2 authentication (verify_gossip) --- + + #[test] + fn verify_accepts_a_genuine_signed_payload() { + let secret = SecretKey::generate(); + let topic = [1u8; 32]; + let p = sign_gossip(&secret, &topic, 5_000, GossipMessage::Leave); + assert_eq!(verify_gossip(&p, &topic, 5_000, GOSSIP_FRESHNESS_MS), Ok(())); + } + + #[test] + fn verify_rejects_a_forged_author() { + // Attacker signs with their OWN key but claims to be the victim — the + // classic S2 spoof. The signature can't validate against the victim key. + let attacker = SecretKey::generate(); + let victim = SecretKey::generate(); + let topic = [2u8; 32]; + let mut p = sign_gossip(&attacker, &topic, 5_000, GossipMessage::Leave); + p.author = victim.public(); // forge the claimed author + assert_eq!( + verify_gossip(&p, &topic, 5_000, GOSSIP_FRESHNESS_MS), + Err(GossipReject::BadSignature) + ); + } + + #[test] + fn verify_rejects_a_tampered_message() { + // Flipping the message after signing must invalidate the signature. + let secret = SecretKey::generate(); + let topic = [4u8; 32]; + let mut p = sign_gossip(&secret, &topic, 5_000, GossipMessage::Leave); + p.msg = GossipMessage::Chat { name: "x".into(), text: "y".into(), ts: 5_000 }; + assert_eq!( + verify_gossip(&p, &topic, 5_000, GOSSIP_FRESHNESS_MS), + Err(GossipReject::BadSignature) + ); + } + + #[test] + fn verify_rejects_cross_room_replay() { + // A payload signed for one topic must not validate against another room + // (the topic is bound into the signed bytes). + let secret = SecretKey::generate(); + let p = sign_gossip(&secret, &[7u8; 32], 5_000, GossipMessage::Leave); + assert_eq!( + verify_gossip(&p, &[8u8; 32], 5_000, GOSSIP_FRESHNESS_MS), + Err(GossipReject::BadSignature) + ); + } + + #[test] + fn verify_rejects_stale_and_future_timestamps() { + let secret = SecretKey::generate(); + let topic = [5u8; 32]; + let p = sign_gossip(&secret, &topic, 1_000_000, GossipMessage::Leave); + // Far in the past relative to "now" → stale (replay). + assert_eq!( + verify_gossip(&p, &topic, 1_000_000 + GOSSIP_FRESHNESS_MS + 1, GOSSIP_FRESHNESS_MS), + Err(GossipReject::OutOfWindow) + ); + // Implausibly future. + assert_eq!( + verify_gossip(&p, &topic, 1_000_000 - GOSSIP_FRESHNESS_MS - 1, GOSSIP_FRESHNESS_MS), + Err(GossipReject::OutOfWindow) + ); + // Within the window (clock skew tolerance) → accepted. + assert!(verify_gossip(&p, &topic, 1_000_000 + GOSSIP_FRESHNESS_MS - 1, GOSSIP_FRESHNESS_MS).is_ok()); + } }