2026-09-21 11:07:31 +01:00
|
|
|
//! What a WireGuard peer tells the network about itself.
|
|
|
|
|
//!
|
|
|
|
|
//! This is the opaque payload the control plane carries in a
|
2026-09-21 19:55:30 +01:00
|
|
|
//! [`tsunagi::dataplane::PluginCapability`]. The agent core never parses it —
|
2026-09-21 11:07:31 +01:00
|
|
|
//! only this module does, and only after bounding every field.
|
|
|
|
|
//!
|
2026-09-21 11:55:20 +01:00
|
|
|
//! The announcement is deliberately tiny: a participant says **who it is**,
|
|
|
|
|
//! not **where it is**. Reachability is the data plane transport's job, and
|
|
|
|
|
//! the transport already solves it — see
|
2026-09-21 19:55:30 +01:00
|
|
|
//! [`tsunagi::dataplane::transport`]. A plugin that also tried to advertise
|
2026-09-21 11:55:20 +01:00
|
|
|
//! addresses would be reimplementing NAT traversal badly.
|
2026-09-21 11:07:31 +01:00
|
|
|
|
|
|
|
|
use serde::{Deserialize, Serialize};
|
|
|
|
|
|
2026-09-21 19:55:30 +01:00
|
|
|
use tsunagi::dataplane::PluginError;
|
|
|
|
|
use tsunagi::identity::NetworkId;
|
2026-09-21 11:07:31 +01:00
|
|
|
|
2026-09-21 19:55:30 +01:00
|
|
|
use crate::keys::WgPublicKey;
|
2026-09-21 11:07:31 +01:00
|
|
|
|
|
|
|
|
/// Version of the announcement format.
|
2026-09-21 13:11:09 +01:00
|
|
|
///
|
2026-09-21 13:43:01 +01:00
|
|
|
/// Version 3 dropped the IPv4 range again: overlay addressing moved to the
|
2026-09-21 19:55:30 +01:00
|
|
|
/// signed records in [`tsunagi::state`], which carry the range and survive a
|
2026-09-21 13:43:01 +01:00
|
|
|
/// participant being away. postcard is not self-describing, so an older peer
|
|
|
|
|
/// cannot read a newer announcement; the mismatch is reported, not misparsed.
|
2026-09-21 18:40:49 +01:00
|
|
|
pub const ANNOUNCEMENT_VERSION: u16 = 4;
|
2026-09-21 11:07:31 +01:00
|
|
|
|
|
|
|
|
/// What one participant advertises for the WireGuard data plane.
|
|
|
|
|
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
|
|
|
|
|
pub struct WgAnnouncement {
|
|
|
|
|
/// Announcement format version.
|
|
|
|
|
pub version: u16,
|
2026-09-21 18:40:49 +01:00
|
|
|
/// The peer's WireGuard public key.
|
2026-09-21 11:07:31 +01:00
|
|
|
///
|
2026-09-21 18:40:49 +01:00
|
|
|
/// The whole announcement, now that addresses belong to the system
|
|
|
|
|
/// level: this says *who* is at the other end of a tunnel, and nothing
|
|
|
|
|
/// about where.
|
|
|
|
|
pub public_key: [u8; 32],
|
|
|
|
|
/// The network this key is for.
|
|
|
|
|
///
|
|
|
|
|
/// Strictly redundant — a capability arrives on a session that already
|
|
|
|
|
/// proved membership of one network — and kept anyway, because the
|
|
|
|
|
/// binding used to be a side effect of checking a derived address and
|
|
|
|
|
/// losing it silently when that check went would be the wrong way to
|
|
|
|
|
/// lose it.
|
|
|
|
|
pub network: [u8; 32],
|
2026-09-21 11:07:31 +01:00
|
|
|
}
|
|
|
|
|
|
|
|
|
|
/// A peer announcement that has been validated against a specific network.
|
|
|
|
|
#[derive(Debug, Clone, PartialEq, Eq)]
|
|
|
|
|
pub struct ValidatedAnnouncement {
|
|
|
|
|
/// The peer's WireGuard public key.
|
|
|
|
|
pub public_key: WgPublicKey,
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
impl WgAnnouncement {
|
|
|
|
|
/// Builds this agent's announcement.
|
2026-09-21 13:43:01 +01:00
|
|
|
pub fn new(network: NetworkId, public_key: &WgPublicKey) -> Self {
|
2026-09-21 11:07:31 +01:00
|
|
|
Self {
|
|
|
|
|
version: ANNOUNCEMENT_VERSION,
|
|
|
|
|
public_key: *public_key.as_bytes(),
|
2026-09-21 18:40:49 +01:00
|
|
|
network: *network.as_bytes(),
|
2026-09-21 11:07:31 +01:00
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
/// Encodes the announcement into the opaque capability payload.
|
|
|
|
|
pub fn encode(&self) -> Result<Vec<u8>, PluginError> {
|
|
|
|
|
postcard::to_stdvec(self)
|
|
|
|
|
.map_err(|err| PluginError::Other(format!("cannot encode announcement: {err}")))
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
/// Decodes and validates a payload received from a peer.
|
|
|
|
|
///
|
|
|
|
|
/// `network` and `local_key` scope the checks: an announcement is only
|
|
|
|
|
/// meaningful inside one network, and a peer must not claim our own key.
|
|
|
|
|
pub fn decode_and_validate(
|
|
|
|
|
payload: &[u8],
|
|
|
|
|
network: NetworkId,
|
|
|
|
|
local_key: &WgPublicKey,
|
|
|
|
|
) -> Result<ValidatedAnnouncement, PluginError> {
|
|
|
|
|
let announcement: Self = postcard::from_bytes(payload)
|
|
|
|
|
.map_err(|_| PluginError::Rejected("malformed WireGuard announcement".into()))?;
|
|
|
|
|
announcement.validate(network, local_key)
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
fn validate(
|
|
|
|
|
self,
|
|
|
|
|
network: NetworkId,
|
|
|
|
|
local_key: &WgPublicKey,
|
|
|
|
|
) -> Result<ValidatedAnnouncement, PluginError> {
|
|
|
|
|
if self.version != ANNOUNCEMENT_VERSION {
|
|
|
|
|
return Err(PluginError::Rejected(format!(
|
2026-09-21 13:11:09 +01:00
|
|
|
"peer speaks WireGuard announcement version {} but this build speaks \
|
|
|
|
|
{ANNOUNCEMENT_VERSION}; one of the two needs updating",
|
2026-09-21 11:07:31 +01:00
|
|
|
self.version
|
|
|
|
|
)));
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
let public_key = WgPublicKey::from_bytes(self.public_key);
|
|
|
|
|
if public_key.is_zero() {
|
|
|
|
|
return Err(PluginError::Rejected(
|
|
|
|
|
"WireGuard public key is all zeroes".into(),
|
|
|
|
|
));
|
|
|
|
|
}
|
|
|
|
|
if &public_key == local_key {
|
|
|
|
|
return Err(PluginError::Rejected(
|
|
|
|
|
"peer announced this agent's own WireGuard key".into(),
|
|
|
|
|
));
|
|
|
|
|
}
|
2026-09-21 18:40:49 +01:00
|
|
|
if self.network != *network.as_bytes() {
|
2026-09-21 11:07:31 +01:00
|
|
|
return Err(PluginError::Rejected(
|
2026-09-21 18:40:49 +01:00
|
|
|
"announcement is for a different network".into(),
|
2026-09-21 11:07:31 +01:00
|
|
|
));
|
|
|
|
|
}
|
|
|
|
|
|
2026-09-21 18:40:49 +01:00
|
|
|
Ok(ValidatedAnnouncement { public_key })
|
2026-09-21 11:07:31 +01:00
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
#[cfg(test)]
|
|
|
|
|
mod tests {
|
|
|
|
|
#![allow(clippy::unwrap_used, clippy::expect_used, clippy::panic)]
|
|
|
|
|
|
|
|
|
|
use super::*;
|
2026-09-21 19:55:30 +01:00
|
|
|
use tsunagi::identity::{NetworkKeys, NetworkName, NetworkSecret};
|
2026-09-21 11:07:31 +01:00
|
|
|
|
|
|
|
|
use super::super::keys::WgSecretKey;
|
|
|
|
|
|
|
|
|
|
fn network(name: &str) -> NetworkId {
|
|
|
|
|
NetworkKeys::derive(
|
|
|
|
|
&NetworkName::new(name).unwrap(),
|
|
|
|
|
&NetworkSecret::from_bytes(vec![3u8; 32]).unwrap(),
|
|
|
|
|
)
|
|
|
|
|
.network_id()
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
#[test]
|
|
|
|
|
fn a_well_formed_announcement_round_trips() {
|
|
|
|
|
let id = network("round-trip");
|
|
|
|
|
let peer = WgSecretKey::generate().public();
|
|
|
|
|
let local = WgSecretKey::generate().public();
|
|
|
|
|
|
2026-09-21 13:43:01 +01:00
|
|
|
let payload = WgAnnouncement::new(id, &peer).encode().unwrap();
|
2026-09-21 11:07:31 +01:00
|
|
|
let validated = WgAnnouncement::decode_and_validate(&payload, id, &local).unwrap();
|
|
|
|
|
|
|
|
|
|
assert_eq!(validated.public_key, peer);
|
2026-09-21 11:55:20 +01:00
|
|
|
}
|
|
|
|
|
|
|
|
|
|
#[test]
|
|
|
|
|
fn the_announcement_says_who_not_where() {
|
|
|
|
|
// Reachability belongs to the transport. Nothing address-like is
|
|
|
|
|
// carried here, so there is nothing for a peer to lie about.
|
|
|
|
|
let id = network("identity-only");
|
|
|
|
|
let peer = WgSecretKey::generate().public();
|
2026-09-21 13:43:01 +01:00
|
|
|
let payload = WgAnnouncement::new(id, &peer).encode().unwrap();
|
2026-09-21 11:55:20 +01:00
|
|
|
assert!(
|
|
|
|
|
payload.len() < 80,
|
|
|
|
|
"the announcement should stay tiny, got {} bytes",
|
|
|
|
|
payload.len()
|
|
|
|
|
);
|
2026-09-21 11:07:31 +01:00
|
|
|
}
|
|
|
|
|
|
|
|
|
|
#[test]
|
2026-09-21 18:40:49 +01:00
|
|
|
fn there_is_nothing_address_like_to_forge() {
|
|
|
|
|
// Addresses belong to the system level, are allocated there and are
|
|
|
|
|
// signed by the member that holds one. A protocol announcement
|
|
|
|
|
// carries no address at all, so this is not a thing a peer can lie
|
|
|
|
|
// about here — and a peer sending traffic from an address it does
|
|
|
|
|
// not hold is rejected by the agreed address, not by anything it
|
|
|
|
|
// said in this message.
|
2026-09-21 11:07:31 +01:00
|
|
|
let id = network("no-hijack");
|
2026-09-21 18:40:49 +01:00
|
|
|
let peer = WgSecretKey::generate().public();
|
2026-09-21 11:07:31 +01:00
|
|
|
let local = WgSecretKey::generate().public();
|
|
|
|
|
|
2026-09-21 18:40:49 +01:00
|
|
|
let announcement = WgAnnouncement::new(id, &peer);
|
|
|
|
|
let validated =
|
|
|
|
|
WgAnnouncement::decode_and_validate(&announcement.encode().unwrap(), id, &local)
|
|
|
|
|
.unwrap();
|
|
|
|
|
assert_eq!(validated.public_key, peer);
|
2026-09-21 11:07:31 +01:00
|
|
|
|
2026-09-21 18:40:49 +01:00
|
|
|
// The validated form has one field, and it is an identity.
|
|
|
|
|
assert_eq!(
|
|
|
|
|
std::mem::size_of_val(&validated),
|
|
|
|
|
std::mem::size_of::<WgPublicKey>()
|
2026-09-21 11:07:31 +01:00
|
|
|
);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
#[test]
|
|
|
|
|
fn an_announcement_from_another_network_does_not_validate() {
|
|
|
|
|
let here = network("here");
|
|
|
|
|
let there = network("there");
|
|
|
|
|
let peer = WgSecretKey::generate().public();
|
|
|
|
|
let local = WgSecretKey::generate().public();
|
|
|
|
|
|
2026-09-21 13:43:01 +01:00
|
|
|
let payload = WgAnnouncement::new(there, &peer).encode().unwrap();
|
2026-09-21 11:07:31 +01:00
|
|
|
assert!(WgAnnouncement::decode_and_validate(&payload, here, &local).is_err());
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
#[test]
|
|
|
|
|
fn hostile_payloads_are_rejected_without_panicking() {
|
|
|
|
|
let id = network("hostile");
|
|
|
|
|
let local = WgSecretKey::generate().public();
|
|
|
|
|
let peer = WgSecretKey::generate().public();
|
|
|
|
|
|
|
|
|
|
assert!(WgAnnouncement::decode_and_validate(&[0xff; 64], id, &local).is_err());
|
|
|
|
|
assert!(WgAnnouncement::decode_and_validate(&[], id, &local).is_err());
|
|
|
|
|
|
|
|
|
|
let wrong_version = WgAnnouncement {
|
|
|
|
|
version: ANNOUNCEMENT_VERSION + 1,
|
2026-09-21 13:43:01 +01:00
|
|
|
..WgAnnouncement::new(id, &peer)
|
2026-09-21 11:07:31 +01:00
|
|
|
};
|
|
|
|
|
assert!(
|
|
|
|
|
WgAnnouncement::decode_and_validate(&wrong_version.encode().unwrap(), id, &local)
|
|
|
|
|
.is_err()
|
|
|
|
|
);
|
|
|
|
|
|
|
|
|
|
let zero_key = WgAnnouncement {
|
|
|
|
|
public_key: [0u8; 32],
|
2026-09-21 13:43:01 +01:00
|
|
|
..WgAnnouncement::new(id, &peer)
|
2026-09-21 11:07:31 +01:00
|
|
|
};
|
|
|
|
|
assert!(
|
|
|
|
|
WgAnnouncement::decode_and_validate(&zero_key.encode().unwrap(), id, &local).is_err()
|
|
|
|
|
);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
#[test]
|
|
|
|
|
fn a_peer_cannot_claim_our_own_key() {
|
|
|
|
|
let id = network("self");
|
|
|
|
|
let local = WgSecretKey::generate().public();
|
2026-09-21 13:43:01 +01:00
|
|
|
let payload = WgAnnouncement::new(id, &local).encode().unwrap();
|
2026-09-21 11:07:31 +01:00
|
|
|
assert!(WgAnnouncement::decode_and_validate(&payload, id, &local).is_err());
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
#[test]
|
|
|
|
|
fn announcements_stay_well_under_the_capability_payload_limit() {
|
|
|
|
|
let id = network("size");
|
|
|
|
|
let peer = WgSecretKey::generate().public();
|
2026-09-21 13:43:01 +01:00
|
|
|
let payload = WgAnnouncement::new(id, &peer).encode().unwrap();
|
2026-09-21 11:07:31 +01:00
|
|
|
assert!(
|
2026-09-21 19:55:30 +01:00
|
|
|
payload.len() < tsunagi::config::Limits::default().max_capability_data_len,
|
2026-09-21 11:07:31 +01:00
|
|
|
"announcement is {} bytes",
|
|
|
|
|
payload.len()
|
|
|
|
|
);
|
|
|
|
|
}
|
|
|
|
|
}
|