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 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();
|
||||
|
||||
@@ -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
@@ -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());
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user