Gossip Message Migration to SignedEnvelope - Analysis Report
Executive Summary
The ICN gossip system currently sends GossipMessage types (Announce, Request, Response, etc.) wrapped in NetworkMessage envelopes over QUIC. To add cryptographic authentication and replay protection, we need to migrate gossip messages to use SignedEnvelope, which adds:
- 64-byte Ed25519 signature per message
- ~40-byte envelope overhead (from, sequence, timestamp, payload_type)
- Sequence tracking per sender for replay detection
- Timestamp-based freshness checks (300s max age)
This analysis identifies the current architecture, serialization approach, and migration challenges.
1. GossipMessage Type Definition
Location: /home/matt/projects/icn/icn/crates/icn-gossip/src/types.rs (lines 111-164)
Message Variants
pub enum GossipMessage {
// Push announcement
Announce {
hash: ContentHash, // 32 bytes
author: Did, // ~60 bytes (did:icn:...)
clock: VectorClock, // ~100 bytes (HashMap<Did, u64>)
topic: String, // variable
},
// Pull request
Request { hash: ContentHash },
// Full entry response
Response { entry: GossipEntry },
// Anti-entropy: request Bloom filter
RequestBloomFilter { topic: String },
// Anti-entropy: send Bloom filter
SendBloomFilter {
topic: String,
filter: BloomFilterData, // ~10KB for 10K entries
},
// Request missing entries by hash
RequestMissing { hashes: Vec<ContentHash> },
// Enhanced anti-entropy: digest with Bloom + vector clock
Digest {
topic: String,
vector: VectorClock,
bloom: BloomFilterData,
hint_count: u32,
nonce: u64,
},
// Targeted pull request
PullRequest {
topic: String,
want_ids: Vec<ContentHash>,
max_bytes: u32,
nonce: u64,
},
// Pull response (may be truncated)
PullResponse {
topic: String,
entries: Vec<GossipEntry>,
truncated: bool,
nonce: u64,
},
}
GossipEntry Structure
pub struct GossipEntry {
pub hash: ContentHash, // 32 bytes
pub author: Did, // ~60 bytes
pub clock: VectorClock, // ~100 bytes
pub topic: String, // variable
pub data: Vec<u8>, // variable (compressed if > 1KB)
pub compressed: bool, // 1 byte
pub timestamp: u64, // 8 bytes
}
2. Current Serialization Format
Location: /home/matt/projects/icn/icn/crates/icn-net/src/protocol.rs (lines 146-172)
Wire Format
Serialization Method: bincode (binary encoding)
impl NetworkMessage {
/// Serialize to bytes using bincode
pub fn to_bytes(&self) -> Result<Vec<u8>> {
let bytes = bincode::serialize(self)?;
if bytes.len() > MAX_MESSAGE_SIZE {
bail!("Message too large: {} bytes (max {})", bytes.len(), 10MB);
}
Ok(bytes)
}
/// Deserialize from bytes using bincode
pub fn from_bytes(bytes: &[u8]) -> Result<Self> {
bincode::deserialize(bytes)?
}
}
Payload Structure
pub enum MessagePayload {
Gossip(GossipMessage), // Currently unencrypted
Ping,
Pong,
Subscribe { topics: Vec<String> },
Unsubscribe { topics: Vec<String> },
SubscribeAck { topics: Vec<String> },
Hello { binding_info: BindingInfo, topology_info: Option<TopologyInfo> },
Handshake { region, cluster_id, role },
HandshakeAck,
Signed(SignedEnvelope), // For authenticated messages
}
Encoding Overview:
- NetworkMessage frame serialized with bincode
- GossipMessage serialized within that frame
- No per-message authentication or sequence tracking yet
3. Current Message Flow: Send/Receive
Send Path
Location: /home/matt/projects/icn/icn/crates/icn-core/src/supervisor.rs (lines 314-343)
GossipActor.publish()
→ send_message(recipient, GossipMessage)
→ SendMessageCallback closure
→ NetworkMessage::gossip(from, to, gossip_msg)
→ network_handle.send_message(target, net_msg)
→ QUIC/TLS transmission
The Send Callback (set at lines 314-343):
let send_callback: icn_gossip::SendMessageCallback =
Arc::new(move |recipient, gossip_msg| {
// Track metrics
match &gossip_msg {
GossipMessage::Announce { .. } => announces_sent_inc(),
GossipMessage::Request { .. } => requests_sent_inc(),
GossipMessage::Response { .. } => responses_sent_inc(),
_ => {}
}
// Spawn async task
tokio::spawn(async move {
let net_msg = if let Some(target_did) = recipient {
// Unicast
NetworkMessage::gossip(from_did, Some(target_did), gossip_msg)
} else {
// Broadcast
NetworkMessage::gossip(from_did, None, gossip_msg)
};
net_handle.send_message(target_did, net_msg).await
});
});
gossip.set_send_callback(send_callback);
Key Observations:
- SendMessageCallback signature:
Arc<dyn Fn(Option<Did>, GossipMessage) + Send + Sync> - Messages are wrapped in NetworkMessage immediately before sending
- No sequence tracking per sender
- No signature generation
Receive Path
Location: /home/matt/projects/icn/icn/crates/icn-core/src/supervisor.rs (lines 160-174)
NetworkActor receives bytes via QUIC
→ NetworkMessage::from_bytes()
→ incoming_handler callback
→ MessagePayload::Gossip(gossip_msg)
→ gossip_handle.write()
→ gossip.handle_message(sender_did, gossip_msg)
The Incoming Handler (lines 164-174):
match net_msg.payload {
icn_net::MessagePayload::Gossip(gossip_msg) => {
let gossip_handle = gossip_handle_clone.clone();
let sender = sender_did.clone();
tokio::spawn(async move {
let mut gossip = gossip_handle.write().await;
if let Err(e) = gossip.handle_message(&sender, gossip_msg) {
warn!("Failed to handle gossip message: {}", e);
}
});
}
// ... other payload types
icn_net::MessagePayload::Signed(ref envelope) => {
info!("Received verified signed message from {} (seq={})",
envelope.from, envelope.sequence);
// Currently just logged - no payload handling yet
}
}
Key Observations:
- Messages arrive with
fromfield from NetworkMessage (NOT authenticated) - No replay protection
- No signature verification
- Signed messages exist but are not yet used for gossip
4. Message Size Estimation
Typical Message Sizes (Bincode Encoded)
Announce Message:
hash: 32 bytes
author: ~60 bytes (DID serialization)
clock: ~100 bytes (VectorClock with map entries)
topic: variable (e.g., "global:identity" = 16 bytes)
────────────────────────
Total: ~210 bytes + topic length
Typical: ~230 bytes
Request Message:
hash: 32 bytes
────────────
Total: 32 bytes
Response Message (small):
entry.hash: 32 bytes
entry.author: ~60 bytes
entry.clock: ~100 bytes
entry.topic: variable
entry.data: variable (usually >1KB, compressed)
entry.timestamp: 8 bytes
entry.compressed: 1 byte
────────────────────────
Small (512B data): ~210 bytes + data
Medium (2KB data): ~250 bytes + data
Large (10KB data): ~300 bytes + compressed_data (~500-1000B after zstd)
Typical range: 300 bytes - 2KB
Digest Message:
topic: variable
vector: ~100 bytes
bloom: ~10KB (for 10K entries)
hint_count: 4 bytes
nonce: 8 bytes
────────────
Typical: ~10KB
PullResponse Message:
Per entry: 300-2000 bytes (as above)
For N entries: N * (300-2000)
Typical N=10: 3-20KB
NetworkMessage Wrapper Overhead
version: 4 bytes (u32)
from: ~60 bytes (Did)
to: ~60 bytes (Option<Did>)
payload: bincode variant tag (1-2 bytes) + payload bytes
────────────────────────
Overhead: ~125 bytes
SignedEnvelope Overhead
from: ~60 bytes (Did)
sequence: 8 bytes (u64)
timestamp: 8 bytes (u64)
payload_type: 1 byte (u8)
payload: variable (original message bytes)
signature: 64 bytes (Ed25519)
────────────────────────
Envelope overhead: 141 bytes + payload
Size Impact Examples:
- Announce: 230 → 230 + 141 = 371 bytes (+61%)
- Request: 32 → 32 + 141 = 173 bytes (+441%, but absolute impact small)
- Small Response: 300 → 300 + 141 = 441 bytes (+47%)
- Large Response: 2KB → 2000 + 141 = 2141 bytes (+7%)
5. SignedEnvelope Implementation (Existing)
Location: /home/matt/projects/icn/icn/crates/icn-net/src/envelope.rs
SignedEnvelope Structure
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct SignedEnvelope {
pub from: Did, // Authenticated sender
pub sequence: u64, // Monotonic per-sender
pub timestamp: u64, // Milliseconds since Unix epoch
pub payload_type: PayloadType, // Discriminator (Gossip, Ledger, etc.)
pub payload: Vec<u8>, // Serialized payload (bincode)
pub signature: Vec<u8>, // Ed25519 signature (64 bytes)
}
pub enum PayloadType {
Gossip = 1,
Ledger = 2,
Trust = 3,
Contract = 4,
Rpc = 5,
Control = 6,
}
impl SignedEnvelope {
/// Create and sign a new envelope
pub fn new(
from: &Did,
keypair: &KeyPair,
sequence: u64,
payload_type: PayloadType,
payload: Vec<u8>,
) -> Result<Self> {
let timestamp = SystemTime::now()
.duration_since(UNIX_EPOCH)?
.as_millis() as u64;
let mut envelope = SignedEnvelope {
from: from.clone(),
sequence,
timestamp,
payload_type,
payload,
signature: Vec::new(),
};
let sig_input = envelope.canonical_encoding();
envelope.signature = keypair.sign(&sig_input).to_vec();
Ok(envelope)
}
/// Verify signature and age
pub fn verify(&self, max_age_secs: u64) -> Result<()> {
// 1. Verify Ed25519 signature
let sig_input = self.canonical_encoding();
let verifying_key = self.from.to_verifying_key()?;
let signature = ed25519_dalek::Signature::from_slice(&self.signature)?;
verifying_key.verify(&sig_input, &signature)?;
// 2. Verify age (default: 300s clock skew allowed)
let now = SystemTime::now().duration_since(UNIX_EPOCH)?.as_millis() as u64;
let age_ms = now.saturating_sub(self.timestamp);
let max_age_ms = max_age_secs * 1000;
if age_ms > max_age_ms {
bail!("Message too old: {}ms (max {}ms)", age_ms, max_age_ms);
}
if self.timestamp > now + max_age_ms {
bail!("Message from future (clock skew)");
}
Ok(())
}
/// Canonical encoding for signatures
fn canonical_encoding(&self) -> Vec<u8> {
// sequence (8 BE) || timestamp (8 BE) || payload_type (1) || payload
let mut buf = Vec::with_capacity(17 + self.payload.len());
buf.extend_from_slice(&self.sequence.to_be_bytes());
buf.extend_from_slice(&self.timestamp.to_be_bytes());
buf.push(self.payload_type as u8);
buf.extend_from_slice(&self.payload);
buf
}
/// Helper to serialize and create envelope
pub fn from_payload<T: serde::Serialize>(
from: &Did,
keypair: &KeyPair,
sequence: u64,
payload_type: PayloadType,
payload: &T,
) -> Result<Self> {
let payload_bytes = bincode::serialize(payload)?;
Self::new(from, keypair, sequence, payload_type, payload_bytes)
}
}
Verification Integration in NetworkActor
The NetworkActor already verifies SignedEnvelopes when received:
// In incoming_handler (supervisor.rs):
icn_net::MessagePayload::Signed(ref envelope) => {
info!("Received verified signed message from {} (seq={})",
envelope.from, envelope.sequence);
// Verification already done by NetworkActor
}
6. ReplayGuard (Existing)
Location: /home/matt/projects/icn/icn/crates/icn-net/src/replay_guard.rs
How It Works
pub struct ReplayGuard {
sequences: HashMap<Did, SequenceWindow>,
max_clock_skew: u64,
max_peer_age_secs: u64,
}
struct SequenceWindow {
max_seq: u64, // Highest sequence seen
recent: BloomFilter, // ~10KB for 10K sequences
last_update: Instant,
}
impl ReplayGuard {
pub fn check(&mut self, envelope: &SignedEnvelope) -> Result<()> {
// 1. Verify signature and age
envelope.verify(self.max_clock_skew)?;
// 2. Get or create sequence window for sender
let window = self.sequences
.entry(envelope.from.clone())
.or_insert_with(SequenceWindow::new);
// 3. Check sequence number
if envelope.sequence <= window.max_seq {
// Out-of-order or replay
if window.recent.contains(&hash_sequence(envelope.sequence)) {
bail!("Replay detected: seq {} already seen", envelope.sequence);
}
// Not in filter: accept as out-of-order
} else {
// In-order: update max_seq
window.max_seq = envelope.sequence;
}
// 4. Update Bloom filter and timestamp
window.recent.insert(&hash_sequence(envelope.sequence));
window.last_update = Instant::now();
Ok(())
}
}
7. Current Message Flow Architecture
Supervisor Setup (Key Points)
GossipActor Creation (line 110):
- Created with trust lookup for access control
- No sequence counter tracking yet
Send Callback Setup (lines 314-343):
- Wraps GossipMessage in NetworkMessage::gossip()
- No signing happens
- Async spawn for network send
Receive Handler Setup (lines 160-174):
- Unwraps MessagePayload::Gossip
- Passes to gossip.handle_message()
- No signature verification for gossip
SignedEnvelope Support (line 265-273):
- Receives and logs verified signed messages
- But doesn't route payload to gossip yet
- Payload type discriminator exists but unused
8. Migration Challenges and Requirements
Challenge 1: Sequence Number Management
Problem: GossipActor needs to track outgoing sequence numbers per peer (or globally).
Current State: No sequence tracking in GossipActor.
Solution Required:
- Add
sequence_counter: u64to GossipActor - Increment before sending each message
- Store in a persistent KV store (Sled) for restart recovery
Challenge 2: SendMessageCallback Signature Change
Current:
pub type SendMessageCallback = Arc<dyn Fn(Option<Did>, GossipMessage) + Send + Sync>;
Needed for Signed Messages:
pub type SendMessageCallback = Arc<dyn Fn(Option<Did>, GossipMessage) + Send + Sync>;
// But the callback needs to sign and wrap in SignedEnvelope
Options: A. Keep callback signature, add signing logic inside callback (requires keypair) B. Change callback to return Result, let GossipActor handle signing C. Add second callback for signed sends
Best Option: Approach C - Keep existing callback, add new signing flow that:
- GossipActor has access to keypair (from IdentityBundle)
- Callback receives already-signed NetworkMessage::Signed(envelope)
- Or: GossipActor constructs SignedEnvelope internally
Challenge 3: Backward Compatibility
Current: Gossip messages sent as MessagePayload::Gossip(GossipMessage)
New: Gossip messages sent as MessagePayload::Signed(SignedEnvelope) with PayloadType::Gossip
Migration Strategy:
- Phase 1: Accept both formats on receive
- Phase 2: Send all new messages as Signed
- Phase 3: Deprecate raw Gossip messages (after timeout)
Challenge 4: Deserialization in Handler
Current Receive Path:
MessagePayload::Gossip(gossip_msg) => {
gossip.handle_message(&sender_did, gossip_msg)?
}
New Receive Path:
MessagePayload::Signed(envelope) => {
// Verify signature (already done by NetworkActor)
if envelope.payload_type == PayloadType::Gossip {
let gossip_msg: GossipMessage = envelope.decode_payload()?;
gossip.handle_message(&envelope.from, gossip_msg)?
}
}
Key Issue: sender_did comes from envelope.from (authenticated), not net_msg.from (untrusted)
Challenge 5: Metrics Tracking
Current: Metrics tracked in send_callback before sending
New: Need to track in two places:
- After signing (for signed messages)
- Or defer to handle_message (for consistency)
Challenge 6: Gossip Actor IdentityBundle Access
Problem: GossipActor doesn't currently have access to the keypair for signing.
Current Flow:
GossipActor.publish()
→ send_callback
→ (callback wraps and sends - but can't sign)
Solution Required: Either: A. Pass IdentityBundle (or just keypair) to GossipActor at creation B. Change sender to sign gossip messages (requires supervisor to handle) C. Have GossipActor return unsigned messages, let supervisor sign
9. Test Coverage Observation
Location: icn/crates/icn-gossip/src/gossip.rs (integration tests)
Current test pattern:
#[test]
fn test_announce_and_request_response() {
let sent_messages = Arc::new(std::sync::Mutex::new(Vec::new()));
let sent_messages_clone = sent_messages.clone();
// Set callback to capture messages
gossip2.set_send_callback(Arc::new(move |recipient, msg| {
sent_messages_clone.lock().unwrap().push((recipient, msg));
}));
// Verify messages sent
let messages = sent_messages.lock().unwrap();
assert_eq!(messages.len(), 1);
if let (Some(recipient), GossipMessage::Request { hash: req_hash }) = &messages[0] {
assert_eq!(recipient, &did1);
}
}
Migration Impact: Tests must be updated to expect SignedEnvelope in callback messages, or callback must transparently handle signing.
10. Summary Table
| Aspect | Current | With SignedEnvelope |
|---|---|---|
| Serialization | bincode | bincode (inside envelope) |
| Payload Type | MessagePayload::Gossip | MessagePayload::Signed with PayloadType::Gossip |
| Authentication | None (trusts from field) | Ed25519 signature + verification |
| Replay Protection | None | ReplayGuard with sequence tracking |
| Message Size Overhead | ~125 bytes (NetworkMessage) | ~141 bytes (envelope) |
| Send Path | gossip.publish() → callback → network | gossip.publish() → sign → callback → network |
| Receive Path | network → unwrap Gossip → handle | network → verify → unwrap Gossip → handle |
| Sequence Tracking | None | Per-sender via ReplayGuard |
| Metrics | 3 message types tracked | Need to update for signed variants |
Key Files Involved in Migration
Files to Modify
icn-gossip/src/gossip.rs
- Add IdentityBundle/keypair field
- Add sequence counter
- Modify send_message() to accept signed envelopes
- Update tests
icn-gossip/src/types.rs
- SendMessageCallback signature (or add new callback type)
icn-core/src/supervisor.rs
- Pass IdentityBundle to GossipActor
- Update send_callback to handle signed messages
- Update incoming_handler to route Signed messages with PayloadType::Gossip
icn-net/src/protocol.rs
- Add NetworkMessage::signed_gossip() helper
icn-net/src/actor.rs
- Integrate ReplayGuard verification for gossip messages
Files Already Supporting This
icn-net/src/envelope.rs
- SignedEnvelope structure and signing logic (complete)
icn-net/src/replay_guard.rs
- ReplayGuard implementation (complete)
icn-net/src/protocol.rs
- MessagePayload::Signed variant (exists)
- PayloadType::Gossip discriminator (exists)
Estimated Effort
- Sequence tracking: 2-3 hours
- IdentityBundle integration into GossipActor: 1-2 hours
- Send path changes: 2-3 hours
- Receive path integration: 2-3 hours
- Test updates: 2-3 hours
- Metrics updates: 1 hour
- Backward compatibility (if needed): 2-3 hours
- Integration testing: 3-4 hours
Total: ~16-24 hours of focused development