Implements announce-based KeyPackage distribution for serverless MLS: - MeshAnnounce now includes optional `keypackage_hash` field (8 bytes) - CAP_MLS_READY capability flag for nodes with KeyPackages - KeyPackageCache for storing received KeyPackages: - Indexed by mesh address - Multiple per address (for rotation) - TTL-based expiry - Capacity-bounded with LRU eviction - Mesh protocol messages: - KeyPackageRequest (request by address or hash) - KeyPackageResponse (KeyPackage + hash) - KeyPackageUnavailable (negative response) Protocol flow: 1. Bob announces with keypackage_hash 2. Alice requests KeyPackage via mesh 3. Bob (or relay) responds with full KeyPackage 4. Alice creates MLS Welcome, sends to Bob via mesh
317 lines
11 KiB
Rust
317 lines
11 KiB
Rust
//! Mesh announce protocol for self-organizing network discovery.
|
|
//!
|
|
//! Nodes periodically broadcast signed [`MeshAnnounce`] packets. These propagate
|
|
//! through the mesh, building each node's [`RoutingTable`](crate::routing_table::RoutingTable).
|
|
|
|
use serde::{Deserialize, Serialize};
|
|
use sha2::{Digest, Sha256};
|
|
use std::time::{SystemTime, UNIX_EPOCH};
|
|
|
|
use crate::identity::MeshIdentity;
|
|
|
|
/// Capability flag: node can relay messages for others.
|
|
pub const CAP_RELAY: u16 = 0x0001;
|
|
/// Capability flag: node has store-and-forward.
|
|
pub const CAP_STORE: u16 = 0x0002;
|
|
/// Capability flag: node is connected to Internet/server.
|
|
pub const CAP_GATEWAY: u16 = 0x0004;
|
|
/// Capability flag: node is on a low-bandwidth transport only.
|
|
pub const CAP_CONSTRAINED: u16 = 0x0008;
|
|
/// Capability flag: node has KeyPackages available for MLS group invites.
|
|
pub const CAP_MLS_READY: u16 = 0x0010;
|
|
|
|
/// A signed mesh node announcement.
|
|
#[derive(Clone, Debug, Serialize, Deserialize)]
|
|
pub struct MeshAnnounce {
|
|
/// Ed25519 public key of the announcing node (32 bytes).
|
|
pub identity_key: Vec<u8>,
|
|
/// Truncated address: SHA-256(identity_key)[0..16] — used for routing.
|
|
pub address: [u8; 16],
|
|
/// Capability bitfield.
|
|
pub capabilities: u16,
|
|
/// Monotonically increasing sequence number (per node).
|
|
pub sequence: u64,
|
|
/// Unix timestamp of creation.
|
|
pub timestamp: u64,
|
|
/// Transports this node is reachable on: Vec<(transport_name, serialized_addr)>.
|
|
pub reachable_via: Vec<(String, Vec<u8>)>,
|
|
/// Current hop count (incremented on re-broadcast).
|
|
pub hop_count: u8,
|
|
/// Maximum propagation hops.
|
|
pub max_hops: u8,
|
|
/// Optional hash of current KeyPackage (SHA-256, truncated to 8 bytes).
|
|
/// Present when CAP_MLS_READY is set. Peers can request the full KeyPackage.
|
|
#[serde(default, skip_serializing_if = "Option::is_none")]
|
|
pub keypackage_hash: Option<[u8; 8]>,
|
|
/// Ed25519 signature over all fields except signature and hop_count.
|
|
pub signature: Vec<u8>,
|
|
}
|
|
|
|
/// Compute the 16-byte mesh address from an Ed25519 public key.
|
|
///
|
|
/// The address is the first 16 bytes of SHA-256(identity_key).
|
|
pub fn compute_address(identity_key: &[u8]) -> [u8; 16] {
|
|
let hash = Sha256::digest(identity_key);
|
|
let mut addr = [0u8; 16];
|
|
addr.copy_from_slice(&hash[..16]);
|
|
addr
|
|
}
|
|
|
|
/// Compute the 8-byte truncated hash of a KeyPackage for announce inclusion.
|
|
///
|
|
/// This hash is used to identify which KeyPackage version a node has available.
|
|
pub fn compute_keypackage_hash(keypackage_bytes: &[u8]) -> [u8; 8] {
|
|
let hash = Sha256::digest(keypackage_bytes);
|
|
let mut kp_hash = [0u8; 8];
|
|
kp_hash.copy_from_slice(&hash[..8]);
|
|
kp_hash
|
|
}
|
|
|
|
impl MeshAnnounce {
|
|
/// Create and sign a new mesh announcement.
|
|
pub fn new(
|
|
identity: &MeshIdentity,
|
|
capabilities: u16,
|
|
reachable_via: Vec<(String, Vec<u8>)>,
|
|
max_hops: u8,
|
|
) -> Self {
|
|
Self::with_keypackage(identity, capabilities, reachable_via, max_hops, None)
|
|
}
|
|
|
|
/// Create announcement with an optional KeyPackage hash.
|
|
pub fn with_keypackage(
|
|
identity: &MeshIdentity,
|
|
capabilities: u16,
|
|
reachable_via: Vec<(String, Vec<u8>)>,
|
|
max_hops: u8,
|
|
keypackage_hash: Option<[u8; 8]>,
|
|
) -> Self {
|
|
let identity_key = identity.public_key().to_vec();
|
|
let address = compute_address(&identity_key);
|
|
let timestamp = SystemTime::now()
|
|
.duration_since(UNIX_EPOCH)
|
|
.unwrap_or_default()
|
|
.as_secs();
|
|
|
|
let mut announce = Self {
|
|
identity_key,
|
|
address,
|
|
capabilities,
|
|
sequence: 0,
|
|
timestamp,
|
|
reachable_via,
|
|
hop_count: 0,
|
|
max_hops,
|
|
keypackage_hash,
|
|
signature: Vec::new(),
|
|
};
|
|
|
|
let signable = announce.signable_bytes();
|
|
announce.signature = identity.sign(&signable).to_vec();
|
|
announce
|
|
}
|
|
|
|
/// Create and sign with a specific sequence number.
|
|
pub fn with_sequence(
|
|
identity: &MeshIdentity,
|
|
capabilities: u16,
|
|
reachable_via: Vec<(String, Vec<u8>)>,
|
|
max_hops: u8,
|
|
sequence: u64,
|
|
) -> Self {
|
|
let mut announce = Self::new(identity, capabilities, reachable_via, max_hops);
|
|
announce.sequence = sequence;
|
|
// Re-sign with the correct sequence number.
|
|
let signable = announce.signable_bytes();
|
|
announce.signature = identity.sign(&signable).to_vec();
|
|
announce
|
|
}
|
|
|
|
/// Assemble the byte string that is signed / verified.
|
|
///
|
|
/// `hop_count` and `signature` are excluded: forwarding nodes increment
|
|
/// hop_count without re-signing (same design as [`MeshEnvelope`]).
|
|
fn signable_bytes(&self) -> Vec<u8> {
|
|
let mut buf = Vec::with_capacity(
|
|
self.identity_key.len() + 16 + 2 + 8 + 8 + self.reachable_via.len() * 32 + 1 + 9,
|
|
);
|
|
buf.extend_from_slice(&self.identity_key);
|
|
buf.extend_from_slice(&self.address);
|
|
buf.extend_from_slice(&self.capabilities.to_le_bytes());
|
|
buf.extend_from_slice(&self.sequence.to_le_bytes());
|
|
buf.extend_from_slice(&self.timestamp.to_le_bytes());
|
|
for (name, addr) in &self.reachable_via {
|
|
buf.extend_from_slice(name.as_bytes());
|
|
buf.extend_from_slice(addr);
|
|
}
|
|
buf.push(self.max_hops);
|
|
// Include keypackage_hash in signature if present
|
|
if let Some(kp_hash) = &self.keypackage_hash {
|
|
buf.push(1); // presence marker
|
|
buf.extend_from_slice(kp_hash);
|
|
} else {
|
|
buf.push(0); // absence marker
|
|
}
|
|
buf
|
|
}
|
|
|
|
/// Verify the Ed25519 signature on this announcement.
|
|
pub fn verify(&self) -> bool {
|
|
let identity_key: [u8; 32] = match self.identity_key.as_slice().try_into() {
|
|
Ok(k) => k,
|
|
Err(_) => return false,
|
|
};
|
|
let sig: [u8; 64] = match self.signature.as_slice().try_into() {
|
|
Ok(s) => s,
|
|
Err(_) => return false,
|
|
};
|
|
let signable = self.signable_bytes();
|
|
quicprochat_core::IdentityKeypair::verify_raw(&identity_key, &signable, &sig).is_ok()
|
|
}
|
|
|
|
/// Check whether this announce has expired relative to a maximum age.
|
|
pub fn is_expired(&self, max_age_secs: u64) -> bool {
|
|
let now = SystemTime::now()
|
|
.duration_since(UNIX_EPOCH)
|
|
.unwrap_or_default()
|
|
.as_secs();
|
|
now.saturating_sub(self.timestamp) > max_age_secs
|
|
}
|
|
|
|
/// Create a forwarded copy with `hop_count` incremented by one.
|
|
///
|
|
/// The signature remains the original — forwarding nodes do not re-sign.
|
|
pub fn forwarded(&self) -> Self {
|
|
let mut copy = self.clone();
|
|
copy.hop_count = copy.hop_count.saturating_add(1);
|
|
copy
|
|
}
|
|
|
|
/// Whether this announce can still propagate (under hop limit and not expired).
|
|
///
|
|
/// Uses a generous default max age of 1800 seconds (30 minutes) for the
|
|
/// expiry check. Callers that need a different max age should check
|
|
/// [`is_expired`](Self::is_expired) separately.
|
|
pub fn can_propagate(&self) -> bool {
|
|
self.hop_count < self.max_hops && !self.is_expired(1800)
|
|
}
|
|
|
|
/// Serialize to compact CBOR binary format (for wire transmission).
|
|
pub fn to_wire(&self) -> Vec<u8> {
|
|
let mut buf = Vec::new();
|
|
ciborium::into_writer(self, &mut buf).expect("CBOR serialization should not fail");
|
|
buf
|
|
}
|
|
|
|
/// Deserialize from CBOR binary format.
|
|
pub fn from_wire(bytes: &[u8]) -> anyhow::Result<Self> {
|
|
let announce: Self = ciborium::from_reader(bytes)?;
|
|
Ok(announce)
|
|
}
|
|
}
|
|
|
|
#[cfg(test)]
|
|
mod tests {
|
|
use super::*;
|
|
|
|
fn test_identity() -> MeshIdentity {
|
|
MeshIdentity::generate()
|
|
}
|
|
|
|
#[test]
|
|
fn create_and_verify() {
|
|
let id = test_identity();
|
|
let announce = MeshAnnounce::new(
|
|
&id,
|
|
CAP_RELAY | CAP_STORE,
|
|
vec![("tcp".into(), b"127.0.0.1:9000".to_vec())],
|
|
8,
|
|
);
|
|
|
|
assert!(announce.verify(), "freshly created announce must verify");
|
|
assert_eq!(announce.hop_count, 0);
|
|
assert_eq!(announce.identity_key, id.public_key().to_vec());
|
|
assert_eq!(announce.capabilities, CAP_RELAY | CAP_STORE);
|
|
assert_eq!(announce.max_hops, 8);
|
|
}
|
|
|
|
#[test]
|
|
fn tampered_fails_verify() {
|
|
let id = test_identity();
|
|
let mut announce = MeshAnnounce::new(&id, CAP_RELAY, vec![], 4);
|
|
announce.capabilities = CAP_GATEWAY; // tamper
|
|
assert!(
|
|
!announce.verify(),
|
|
"tampered announce must fail verification"
|
|
);
|
|
}
|
|
|
|
#[test]
|
|
fn forwarded_still_verifies() {
|
|
let id = test_identity();
|
|
let announce = MeshAnnounce::new(&id, CAP_RELAY, vec![], 8);
|
|
assert!(announce.verify());
|
|
|
|
let fwd = announce.forwarded();
|
|
assert_eq!(fwd.hop_count, 1);
|
|
assert!(
|
|
fwd.verify(),
|
|
"forwarded announce must still verify (hop_count excluded from signature)"
|
|
);
|
|
|
|
let fwd2 = fwd.forwarded();
|
|
assert_eq!(fwd2.hop_count, 2);
|
|
assert!(fwd2.verify(), "double-forwarded must still verify");
|
|
}
|
|
|
|
#[test]
|
|
fn expired_announce() {
|
|
let id = test_identity();
|
|
let mut announce = MeshAnnounce::new(&id, 0, vec![], 4);
|
|
// Set timestamp far in the past.
|
|
announce.timestamp = 0;
|
|
assert!(announce.is_expired(60), "announce from epoch should be expired with 60s max age");
|
|
}
|
|
|
|
#[test]
|
|
fn address_from_key_deterministic() {
|
|
let key = [42u8; 32];
|
|
let addr1 = compute_address(&key);
|
|
let addr2 = compute_address(&key);
|
|
assert_eq!(addr1, addr2, "same key must produce same address");
|
|
|
|
// Different key produces different address.
|
|
let other_key = [99u8; 32];
|
|
let other_addr = compute_address(&other_key);
|
|
assert_ne!(addr1, other_addr);
|
|
}
|
|
|
|
#[test]
|
|
fn cbor_roundtrip() {
|
|
let id = test_identity();
|
|
let announce = MeshAnnounce::new(
|
|
&id,
|
|
CAP_RELAY | CAP_GATEWAY,
|
|
vec![
|
|
("tcp".into(), b"127.0.0.1:9000".to_vec()),
|
|
("lora".into(), vec![0x01, 0x02, 0x03, 0x04]),
|
|
],
|
|
6,
|
|
);
|
|
|
|
let wire = announce.to_wire();
|
|
let restored = MeshAnnounce::from_wire(&wire).expect("CBOR deserialize");
|
|
|
|
assert_eq!(announce.identity_key, restored.identity_key);
|
|
assert_eq!(announce.address, restored.address);
|
|
assert_eq!(announce.capabilities, restored.capabilities);
|
|
assert_eq!(announce.sequence, restored.sequence);
|
|
assert_eq!(announce.timestamp, restored.timestamp);
|
|
assert_eq!(announce.reachable_via, restored.reachable_via);
|
|
assert_eq!(announce.hop_count, restored.hop_count);
|
|
assert_eq!(announce.max_hops, restored.max_hops);
|
|
assert_eq!(announce.signature, restored.signature);
|
|
assert!(restored.verify());
|
|
}
|
|
}
|