fix(security): authenticate gossip payloads (S2 — sign + verify, replay-bound)

The gossip `author` field was self-asserted, so an in-room member could forge
it to impersonate (chat), force-evict (Leave), or poison presence + the address
book (Announce). Now every GossipPayload is signed with the node's ed25519
secret key and verified on receipt; a forged author can't validate because the
attacker lacks the victim's key.

Done as the complete fix (forgery + replay):
- GossipPayload gains `ts` (sender-stamped) + `sig` (iroh::Signature, serde-64B);
  Debug is hand-written since Signature has none.
- Signature covers domain tag + room topic_id + author + ts + msg
  (`signable_bytes`): topic binding blocks cross-room replay, ts + a 2-min
  freshness window block temporal replay (within-window replays are byte-
  identical and de-duped by the swarm), author binding makes spoofing fail.
- New pure seams `sign_gossip` / `verify_gossip` (+ `GossipReject`); all four
  outgoing broadcasts sign, the receive loop verifies-then-trusts (drops
  unauthenticated/stale before any peer-map / event / address-book action).
- IrohGossipState now holds the node SecretKey + active topic bytes; callers
  (core, test_net) updated.

NOTE: breaking gossip wire change — all peers must run this build (the staged
friend release + dopedart need rebuild). Node identity is ephemeral, so no
migration concern beyond rebuild.

+5 unit tests (genuine accept; forged author, tampered msg, cross-room, stale/
future all rejected). 163 lib tests (was 158), clippy --all-targets clean.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
2026-06-13 21:42:02 -04:00
co-authored by Claude Opus 4.8
parent fe627166d5
commit 2b52a48efe
3 changed files with 247 additions and 54 deletions
+4 -4
View File
@@ -14,7 +14,7 @@ async fn main() -> Result<(), Box<dyn std::error::Error>> {
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<dyn std::error::Error>> {
.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<dyn std::error::Error>> {
.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();
+1
View File
@@ -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.
+242 -50
View File
@@ -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<u8> {
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<Mutex<Option<PeerState>>>,
peers: Arc<Mutex<HashMap<EndpointId, PeerState>>>,
event_tx: mpsc::Sender<RoomEvent>,
event_rx: Mutex<Option<mpsc::Receiver<RoomEvent>>>,
active_topic: Mutex<Option<tokio::task::JoinHandle<()>>>,
active_topic_id: Mutex<Option<TopicId>>,
/// 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<Option<[u8; 32]>>,
active_sender: Mutex<Option<iroh_gossip::api::GossipSender>>,
}
@@ -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::<GossipPayload>(&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());
}
}