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:
+4
-4
@@ -14,7 +14,7 @@ async fn main() -> Result<(), Box<dyn std::error::Error>> {
|
|||||||
let lookup_a = iroh::address_lookup::memory::MemoryLookup::new();
|
let lookup_a = iroh::address_lookup::memory::MemoryLookup::new();
|
||||||
let secret_a = iroh::SecretKey::generate();
|
let secret_a = iroh::SecretKey::generate();
|
||||||
let endpoint_a = Endpoint::builder(presets::N0)
|
let endpoint_a = Endpoint::builder(presets::N0)
|
||||||
.secret_key(secret_a)
|
.secret_key(secret_a.clone())
|
||||||
.address_lookup(lookup_a.clone())
|
.address_lookup(lookup_a.clone())
|
||||||
.bind()
|
.bind()
|
||||||
.await?;
|
.await?;
|
||||||
@@ -27,13 +27,13 @@ async fn main() -> Result<(), Box<dyn std::error::Error>> {
|
|||||||
.accept(iroh_gossip::net::GOSSIP_ALPN, gossip_a.clone())
|
.accept(iroh_gossip::net::GOSSIP_ALPN, gossip_a.clone())
|
||||||
.spawn();
|
.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
|
// 2. Node B (Client) Setup
|
||||||
let lookup_b = iroh::address_lookup::memory::MemoryLookup::new();
|
let lookup_b = iroh::address_lookup::memory::MemoryLookup::new();
|
||||||
let secret_b = iroh::SecretKey::generate();
|
let secret_b = iroh::SecretKey::generate();
|
||||||
let endpoint_b = Endpoint::builder(presets::N0)
|
let endpoint_b = Endpoint::builder(presets::N0)
|
||||||
.secret_key(secret_b)
|
.secret_key(secret_b.clone())
|
||||||
.address_lookup(lookup_b.clone())
|
.address_lookup(lookup_b.clone())
|
||||||
.bind()
|
.bind()
|
||||||
.await?;
|
.await?;
|
||||||
@@ -46,7 +46,7 @@ async fn main() -> Result<(), Box<dyn std::error::Error>> {
|
|||||||
.accept(iroh_gossip::net::GOSSIP_ALPN, gossip_b.clone())
|
.accept(iroh_gossip::net::GOSSIP_ALPN, gossip_b.clone())
|
||||||
.spawn();
|
.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
|
// 3. Create room on Node A
|
||||||
let topic_id = rand::random();
|
let topic_id = rand::random();
|
||||||
|
|||||||
@@ -518,6 +518,7 @@ async fn run_core_loop(
|
|||||||
endpoint.clone(),
|
endpoint.clone(),
|
||||||
gossip.clone(),
|
gossip.clone(),
|
||||||
memory_lookup.clone(),
|
memory_lookup.clone(),
|
||||||
|
secret_key.clone(),
|
||||||
));
|
));
|
||||||
|
|
||||||
// Fresh join starts not sharing; clear any stale share ticket.
|
// Fresh join starts not sharing; clear any stale share ticket.
|
||||||
|
|||||||
+242
-50
@@ -1,5 +1,5 @@
|
|||||||
use crate::network::{RoomState, NetError, PeerState, RoomEvent, PeerSpeakTicket};
|
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::net::Gossip;
|
||||||
use iroh_gossip::proto::TopicId;
|
use iroh_gossip::proto::TopicId;
|
||||||
use tokio::sync::mpsc;
|
use tokio::sync::mpsc;
|
||||||
@@ -10,10 +10,96 @@ use async_trait::async_trait;
|
|||||||
use tokio_stream::StreamExt;
|
use tokio_stream::StreamExt;
|
||||||
use serde::{Serialize, Deserialize};
|
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 struct GossipPayload {
|
||||||
pub author: EndpointId,
|
pub author: EndpointId,
|
||||||
|
pub ts: u64,
|
||||||
pub msg: GossipMessage,
|
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)]
|
#[derive(Serialize, Deserialize, Clone, Debug)]
|
||||||
@@ -29,12 +115,18 @@ pub struct IrohGossipState {
|
|||||||
_endpoint: Endpoint,
|
_endpoint: Endpoint,
|
||||||
gossip: Gossip,
|
gossip: Gossip,
|
||||||
address_lookup: iroh::address_lookup::memory::MemoryLookup,
|
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>>>,
|
self_state: Arc<Mutex<Option<PeerState>>>,
|
||||||
peers: Arc<Mutex<HashMap<EndpointId, PeerState>>>,
|
peers: Arc<Mutex<HashMap<EndpointId, PeerState>>>,
|
||||||
event_tx: mpsc::Sender<RoomEvent>,
|
event_tx: mpsc::Sender<RoomEvent>,
|
||||||
event_rx: Mutex<Option<mpsc::Receiver<RoomEvent>>>,
|
event_rx: Mutex<Option<mpsc::Receiver<RoomEvent>>>,
|
||||||
active_topic: Mutex<Option<tokio::task::JoinHandle<()>>>,
|
active_topic: Mutex<Option<tokio::task::JoinHandle<()>>>,
|
||||||
active_topic_id: Mutex<Option<TopicId>>,
|
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>>,
|
active_sender: Mutex<Option<iroh_gossip::api::GossipSender>>,
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -43,18 +135,21 @@ impl IrohGossipState {
|
|||||||
endpoint: Endpoint,
|
endpoint: Endpoint,
|
||||||
gossip: Gossip,
|
gossip: Gossip,
|
||||||
address_lookup: iroh::address_lookup::memory::MemoryLookup,
|
address_lookup: iroh::address_lookup::memory::MemoryLookup,
|
||||||
|
secret_key: SecretKey,
|
||||||
) -> Self {
|
) -> Self {
|
||||||
let (event_tx, event_rx) = mpsc::channel(100);
|
let (event_tx, event_rx) = mpsc::channel(100);
|
||||||
Self {
|
Self {
|
||||||
_endpoint: endpoint,
|
_endpoint: endpoint,
|
||||||
gossip,
|
gossip,
|
||||||
address_lookup,
|
address_lookup,
|
||||||
|
secret_key,
|
||||||
self_state: Arc::new(Mutex::new(None)),
|
self_state: Arc::new(Mutex::new(None)),
|
||||||
peers: Arc::new(Mutex::new(HashMap::new())),
|
peers: Arc::new(Mutex::new(HashMap::new())),
|
||||||
event_tx,
|
event_tx,
|
||||||
event_rx: Mutex::new(Some(event_rx)),
|
event_rx: Mutex::new(Some(event_rx)),
|
||||||
active_topic: Mutex::new(None),
|
active_topic: Mutex::new(None),
|
||||||
active_topic_id: Mutex::new(None),
|
active_topic_id: Mutex::new(None),
|
||||||
|
active_topic_bytes: Mutex::new(None),
|
||||||
active_sender: 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.self_state.lock().unwrap() = Some(self_state.clone());
|
||||||
*self.active_topic_id.lock().unwrap() = Some(topic_id);
|
*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());
|
*self.active_sender.lock().unwrap() = Some(gossip_sender.clone());
|
||||||
|
|
||||||
let event_tx = self.event_tx.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 self_state_clone = self.self_state.clone();
|
||||||
let gossip_sender_clone = gossip_sender.clone();
|
let gossip_sender_clone = gossip_sender.clone();
|
||||||
let self_id = self_state.addr.id;
|
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 {
|
let handle = tokio::spawn(async move {
|
||||||
crate::log_msg(&format!("Spawned gossip topic loop for self_id={:?}", self_id));
|
crate::log_msg(&format!("Spawned gossip topic loop for self_id={:?}", self_id));
|
||||||
@@ -111,10 +211,12 @@ impl RoomState for IrohGossipState {
|
|||||||
// Broadcast initial state
|
// Broadcast initial state
|
||||||
let initial_payload = {
|
let initial_payload = {
|
||||||
let guard = self_state_clone.lock().unwrap();
|
let guard = self_state_clone.lock().unwrap();
|
||||||
guard.as_ref().map(|s| GossipPayload {
|
guard.as_ref().map(|s| sign_gossip(
|
||||||
author: s.addr.id,
|
&secret_key,
|
||||||
msg: GossipMessage::Announce(s.clone()),
|
&topic_bytes,
|
||||||
})
|
now_millis(),
|
||||||
|
GossipMessage::Announce(s.clone()),
|
||||||
|
))
|
||||||
};
|
};
|
||||||
|
|
||||||
if let Some(payload) = initial_payload
|
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));
|
crate::log_msg(&format!("Gossip received Event::Received from delivery={:?}", msg.delivered_from));
|
||||||
match serde_json::from_slice::<GossipPayload>(&msg.content) {
|
match serde_json::from_slice::<GossipPayload>(&msg.content) {
|
||||||
Ok(payload) => {
|
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 = {
|
let our_id = {
|
||||||
self_state_clone.lock().unwrap()
|
self_state_clone.lock().unwrap()
|
||||||
.as_ref()
|
.as_ref()
|
||||||
@@ -196,10 +311,12 @@ impl RoomState for IrohGossipState {
|
|||||||
// Resend state on new neighbor connection to guarantee synchronization
|
// Resend state on new neighbor connection to guarantee synchronization
|
||||||
let payload_opt = {
|
let payload_opt = {
|
||||||
let guard = self_state_clone.lock().unwrap();
|
let guard = self_state_clone.lock().unwrap();
|
||||||
guard.as_ref().map(|state| GossipPayload {
|
guard.as_ref().map(|state| sign_gossip(
|
||||||
author: state.addr.id,
|
&secret_key,
|
||||||
msg: GossipMessage::Announce(state.clone()),
|
&topic_bytes,
|
||||||
})
|
now_millis(),
|
||||||
|
GossipMessage::Announce(state.clone()),
|
||||||
|
))
|
||||||
};
|
};
|
||||||
if let Some(payload) = payload_opt
|
if let Some(payload) = payload_opt
|
||||||
&& let Ok(bytes) = serde_json::to_vec(&payload) {
|
&& 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());
|
*self.self_state.lock().unwrap() = Some(self_state.clone());
|
||||||
|
|
||||||
let sender_opt = self.active_sender.lock().unwrap().clone();
|
let sender_opt = self.active_sender.lock().unwrap().clone();
|
||||||
if let Some(sender) = sender_opt {
|
let topic_opt = *self.active_topic_bytes.lock().unwrap();
|
||||||
let payload = GossipPayload {
|
if let (Some(sender), Some(topic)) = (sender_opt, topic_opt) {
|
||||||
author: self_state.addr.id,
|
let payload = sign_gossip(
|
||||||
msg: GossipMessage::Announce(self_state),
|
&self.secret_key,
|
||||||
};
|
&topic,
|
||||||
|
now_millis(),
|
||||||
|
GossipMessage::Announce(self_state),
|
||||||
|
);
|
||||||
if let Ok(bytes) = serde_json::to_vec(&payload) {
|
if let Ok(bytes) = serde_json::to_vec(&payload) {
|
||||||
crate::log_msg("Broadcasting updated self state to gossip");
|
crate::log_msg("Broadcasting updated self state to gossip");
|
||||||
sender.broadcast(bytes.into()).await
|
sender.broadcast(bytes.into()).await
|
||||||
@@ -257,24 +377,24 @@ impl RoomState for IrohGossipState {
|
|||||||
}
|
}
|
||||||
|
|
||||||
async fn send_chat(&self, text: String) -> Result<(), NetError> {
|
async fn send_chat(&self, text: String) -> Result<(), NetError> {
|
||||||
let (name, author) = {
|
let name = {
|
||||||
let guard = self.self_state.lock().unwrap();
|
let guard = self.self_state.lock().unwrap();
|
||||||
match guard.as_ref() {
|
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())),
|
None => return Err(NetError::Other("Not in a room".to_string())),
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
let ts = std::time::SystemTime::now()
|
let ts = now_millis();
|
||||||
.duration_since(std::time::UNIX_EPOCH)
|
|
||||||
.map(|d| d.as_millis() as u64)
|
|
||||||
.unwrap_or(0);
|
|
||||||
|
|
||||||
let sender_opt = self.active_sender.lock().unwrap().clone();
|
let sender_opt = self.active_sender.lock().unwrap().clone();
|
||||||
if let Some(sender) = sender_opt {
|
let topic_opt = *self.active_topic_bytes.lock().unwrap();
|
||||||
let payload = GossipPayload {
|
if let (Some(sender), Some(topic)) = (sender_opt, topic_opt) {
|
||||||
author,
|
let payload = sign_gossip(
|
||||||
msg: GossipMessage::Chat { name, text, ts },
|
&self.secret_key,
|
||||||
};
|
&topic,
|
||||||
|
ts,
|
||||||
|
GossipMessage::Chat { name, text, ts },
|
||||||
|
);
|
||||||
if let Ok(bytes) = serde_json::to_vec(&payload) {
|
if let Ok(bytes) = serde_json::to_vec(&payload) {
|
||||||
sender.broadcast(bytes.into()).await
|
sender.broadcast(bytes.into()).await
|
||||||
.map_err(|e| NetError::Gossip(e.to_string()))?;
|
.map_err(|e| NetError::Gossip(e.to_string()))?;
|
||||||
@@ -294,19 +414,19 @@ impl RoomState for IrohGossipState {
|
|||||||
}
|
}
|
||||||
|
|
||||||
*self.active_topic_id.lock().unwrap() = None;
|
*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();
|
let sender_opt = self.active_sender.lock().unwrap().take();
|
||||||
if let Some(sender) = sender_opt {
|
if let (Some(sender), Some(topic)) = (sender_opt, topic_opt) {
|
||||||
let self_state_opt = self.self_state.lock().unwrap().clone();
|
let payload = sign_gossip(
|
||||||
if let Some(self_state) = self_state_opt {
|
&self.secret_key,
|
||||||
let payload = GossipPayload {
|
&topic,
|
||||||
author: self_state.addr.id,
|
now_millis(),
|
||||||
msg: GossipMessage::Leave,
|
GossipMessage::Leave,
|
||||||
};
|
);
|
||||||
if let Ok(bytes) = serde_json::to_vec(&payload) {
|
if let Ok(bytes) = serde_json::to_vec(&payload) {
|
||||||
crate::log_msg("Broadcasting Leave message to gossip");
|
crate::log_msg("Broadcasting Leave message to gossip");
|
||||||
let _ = sender.broadcast(bytes.into()).await;
|
let _ = sender.broadcast(bytes.into()).await;
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -357,17 +477,18 @@ mod tests {
|
|||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn test_gossip_payload_announce_round_trip() {
|
fn test_gossip_payload_announce_round_trip() {
|
||||||
|
let secret = SecretKey::generate();
|
||||||
|
let topic = [9u8; 32];
|
||||||
let peer_state = sample_peer_state();
|
let peer_state = sample_peer_state();
|
||||||
let author = peer_state.addr.id;
|
let payload = sign_gossip(&secret, &topic, 1000, GossipMessage::Announce(peer_state.clone()));
|
||||||
let payload = GossipPayload {
|
|
||||||
author,
|
|
||||||
msg: GossipMessage::Announce(peer_state.clone()),
|
|
||||||
};
|
|
||||||
|
|
||||||
let serialized = serde_json::to_string(&payload).unwrap();
|
let serialized = serde_json::to_string(&payload).unwrap();
|
||||||
let deserialized: GossipPayload = serde_json::from_str(&serialized).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 {
|
match deserialized.msg {
|
||||||
GossipMessage::Announce(state) => {
|
GossipMessage::Announce(state) => {
|
||||||
assert_eq!(state, peer_state);
|
assert_eq!(state, peer_state);
|
||||||
@@ -415,21 +536,23 @@ mod tests {
|
|||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn test_gossip_payload_chat_round_trip() {
|
fn test_gossip_payload_chat_round_trip() {
|
||||||
let peer_state = sample_peer_state();
|
let secret = SecretKey::generate();
|
||||||
let author = peer_state.addr.id;
|
let topic = [3u8; 32];
|
||||||
let payload = GossipPayload {
|
let payload = sign_gossip(
|
||||||
author,
|
&secret,
|
||||||
msg: GossipMessage::Chat {
|
&topic,
|
||||||
|
987654321,
|
||||||
|
GossipMessage::Chat {
|
||||||
name: "Bob".to_string(),
|
name: "Bob".to_string(),
|
||||||
text: "Hi there".to_string(),
|
text: "Hi there".to_string(),
|
||||||
ts: 987654321,
|
ts: 987654321,
|
||||||
},
|
},
|
||||||
};
|
);
|
||||||
|
|
||||||
let serialized = serde_json::to_string(&payload).unwrap();
|
let serialized = serde_json::to_string(&payload).unwrap();
|
||||||
let deserialized: GossipPayload = serde_json::from_str(&serialized).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 {
|
if let GossipMessage::Chat { name, text, ts } = deserialized.msg {
|
||||||
assert_eq!(name, "Bob");
|
assert_eq!(name, "Bob");
|
||||||
assert_eq!(text, "Hi there");
|
assert_eq!(text, "Hi there");
|
||||||
@@ -456,5 +579,74 @@ mod tests {
|
|||||||
panic!("Expected GossipMessage::Chat");
|
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());
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user