feat(mesh): add KeyPackage distribution over mesh

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
This commit is contained in:
2026-04-01 08:57:49 +02:00
parent 5d1688d89f
commit eee1e9f278
4 changed files with 667 additions and 1 deletions

View File

@@ -17,6 +17,8 @@ pub const CAP_STORE: u16 = 0x0002;
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)]
@@ -37,6 +39,10 @@ pub struct MeshAnnounce {
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>,
}
@@ -51,6 +57,16 @@ pub fn compute_address(identity_key: &[u8]) -> [u8; 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(
@@ -58,6 +74,17 @@ impl MeshAnnounce {
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);
@@ -75,6 +102,7 @@ impl MeshAnnounce {
reachable_via,
hop_count: 0,
max_hops,
keypackage_hash,
signature: Vec::new(),
};
@@ -105,7 +133,7 @@ impl MeshAnnounce {
/// 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,
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);
@@ -117,6 +145,13 @@ impl MeshAnnounce {
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
}

View File

@@ -0,0 +1,360 @@
//! KeyPackage cache for mesh-based MLS group setup.
//!
//! The [`KeyPackageCache`] stores MLS KeyPackages received from other nodes,
//! enabling group creation without a central server. KeyPackages are:
//!
//! - Indexed by the node's 16-byte mesh address
//! - Hashed (8 bytes) for announce inclusion
//! - TTL-managed for expiry (MLS KeyPackages are single-use but we cache N of them)
//! - Bounded by capacity to prevent memory exhaustion
//!
//! # Protocol Flow
//!
//! 1. Bob generates KeyPackage, computes hash, includes hash in MeshAnnounce
//! 2. Bob broadcasts full KeyPackage periodically (or on request)
//! 3. Alice receives Bob's KeyPackage, stores in cache
//! 4. Alice wants to create group with Bob: fetches from cache, creates Welcome
//! 5. Alice sends Welcome to Bob via mesh routing
use std::collections::HashMap;
use std::time::{Duration, Instant};
use crate::address::MeshAddress;
use crate::announce::compute_keypackage_hash;
/// Default TTL for cached KeyPackages (24 hours).
const DEFAULT_TTL: Duration = Duration::from_secs(24 * 60 * 60);
/// Default maximum KeyPackages per address (allow rotation).
const DEFAULT_MAX_PER_ADDRESS: usize = 3;
/// A cached KeyPackage entry.
#[derive(Clone, Debug)]
pub struct CachedKeyPackage {
/// The serialized MLS KeyPackage bytes.
pub bytes: Vec<u8>,
/// 8-byte truncated hash for matching against announces.
pub hash: [u8; 8],
/// When this entry was stored.
pub stored_at: Instant,
/// When this entry expires.
pub expires_at: Instant,
}
impl CachedKeyPackage {
/// Create a new cached entry with default TTL.
pub fn new(bytes: Vec<u8>) -> Self {
Self::with_ttl(bytes, DEFAULT_TTL)
}
/// Create with custom TTL.
pub fn with_ttl(bytes: Vec<u8>, ttl: Duration) -> Self {
let hash = compute_keypackage_hash(&bytes);
let now = Instant::now();
Self {
bytes,
hash,
stored_at: now,
expires_at: now + ttl,
}
}
/// Check if this entry has expired.
pub fn is_expired(&self) -> bool {
Instant::now() > self.expires_at
}
}
/// Cache for KeyPackages received from mesh peers.
pub struct KeyPackageCache {
/// Address -> list of cached KeyPackages (multiple for rotation).
entries: HashMap<MeshAddress, Vec<CachedKeyPackage>>,
/// Maximum KeyPackages stored per address.
max_per_address: usize,
/// Total capacity (max addresses).
max_addresses: usize,
}
impl KeyPackageCache {
/// Create a new cache with default settings.
pub fn new() -> Self {
Self::with_capacity(1000, DEFAULT_MAX_PER_ADDRESS)
}
/// Create with custom capacity.
pub fn with_capacity(max_addresses: usize, max_per_address: usize) -> Self {
Self {
entries: HashMap::new(),
max_per_address,
max_addresses,
}
}
/// Store a KeyPackage for a given address.
///
/// Returns `true` if stored, `false` if rejected (at capacity or duplicate hash).
pub fn store(&mut self, address: MeshAddress, keypackage_bytes: Vec<u8>) -> bool {
let entry = CachedKeyPackage::new(keypackage_bytes);
self.store_entry(address, entry)
}
/// Store a KeyPackage entry.
fn store_entry(&mut self, address: MeshAddress, entry: CachedKeyPackage) -> bool {
// Check if we already have this exact KeyPackage
if let Some(existing) = self.entries.get(&address) {
if existing.iter().any(|e| e.hash == entry.hash) {
return false; // Duplicate
}
}
// Check total capacity
if !self.entries.contains_key(&address) && self.entries.len() >= self.max_addresses {
// Evict oldest entry
self.evict_oldest();
}
let list = self.entries.entry(address).or_default();
// Enforce per-address limit
while list.len() >= self.max_per_address {
list.remove(0); // Remove oldest
}
list.push(entry);
true
}
/// Get the newest KeyPackage for an address.
pub fn get(&self, address: &MeshAddress) -> Option<&CachedKeyPackage> {
self.entries
.get(address)
.and_then(|list| list.iter().rev().find(|e| !e.is_expired()))
}
/// Get a KeyPackage by its hash.
pub fn get_by_hash(&self, address: &MeshAddress, hash: &[u8; 8]) -> Option<&CachedKeyPackage> {
self.entries.get(address).and_then(|list| {
list.iter()
.rev()
.find(|e| &e.hash == hash && !e.is_expired())
})
}
/// Get the newest KeyPackage bytes for an address.
pub fn get_bytes(&self, address: &MeshAddress) -> Option<Vec<u8>> {
self.get(address).map(|e| e.bytes.clone())
}
/// Check if we have a KeyPackage matching a given hash.
pub fn has_hash(&self, address: &MeshAddress, hash: &[u8; 8]) -> bool {
self.get_by_hash(address, hash).is_some()
}
/// Remove all expired entries. Returns count removed.
pub fn gc_expired(&mut self) -> usize {
let mut removed = 0;
self.entries.retain(|_, list| {
let before = list.len();
list.retain(|e| !e.is_expired());
removed += before - list.len();
!list.is_empty()
});
removed
}
/// Evict the oldest entry across all addresses.
fn evict_oldest(&mut self) {
let oldest_addr = self
.entries
.iter()
.filter_map(|(addr, list)| {
list.first().map(|e| (addr.clone(), e.stored_at))
})
.min_by_key(|(_, stored)| *stored)
.map(|(addr, _)| addr);
if let Some(addr) = oldest_addr {
if let Some(list) = self.entries.get_mut(&addr) {
list.remove(0);
if list.is_empty() {
self.entries.remove(&addr);
}
}
}
}
/// Number of addresses with cached KeyPackages.
pub fn len(&self) -> usize {
self.entries.len()
}
/// Whether the cache is empty.
pub fn is_empty(&self) -> bool {
self.entries.is_empty()
}
/// Total number of cached KeyPackages.
pub fn total_keypackages(&self) -> usize {
self.entries.values().map(|v| v.len()).sum()
}
/// Consume a KeyPackage (remove after use, as MLS KeyPackages are single-use).
///
/// Returns the KeyPackage bytes if found.
pub fn consume(&mut self, address: &MeshAddress, hash: &[u8; 8]) -> Option<Vec<u8>> {
let list = self.entries.get_mut(address)?;
let idx = list.iter().position(|e| &e.hash == hash)?;
let entry = list.remove(idx);
if list.is_empty() {
self.entries.remove(address);
}
Some(entry.bytes)
}
}
impl Default for KeyPackageCache {
fn default() -> Self {
Self::new()
}
}
#[cfg(test)]
mod tests {
use super::*;
fn make_keypackage(seed: u8) -> Vec<u8> {
vec![seed; 100 + seed as usize]
}
fn make_address(seed: u8) -> MeshAddress {
MeshAddress::from_bytes([seed; 16])
}
#[test]
fn store_and_retrieve() {
let mut cache = KeyPackageCache::new();
let addr = make_address(1);
let kp = make_keypackage(1);
let hash = compute_keypackage_hash(&kp);
assert!(cache.store(addr, kp.clone()));
assert_eq!(cache.len(), 1);
let retrieved = cache.get(&addr).expect("should exist");
assert_eq!(retrieved.bytes, kp);
assert_eq!(retrieved.hash, hash);
}
#[test]
fn reject_duplicate() {
let mut cache = KeyPackageCache::new();
let addr = make_address(2);
let kp = make_keypackage(2);
assert!(cache.store(addr, kp.clone()));
assert!(!cache.store(addr, kp), "duplicate should be rejected");
assert_eq!(cache.total_keypackages(), 1);
}
#[test]
fn multiple_per_address() {
let mut cache = KeyPackageCache::with_capacity(100, 3);
let addr = make_address(3);
assert!(cache.store(addr, make_keypackage(1)));
assert!(cache.store(addr, make_keypackage(2)));
assert!(cache.store(addr, make_keypackage(3)));
assert_eq!(cache.total_keypackages(), 3);
// Fourth should evict first
assert!(cache.store(addr, make_keypackage(4)));
assert_eq!(cache.total_keypackages(), 3);
// First should be gone
let hash1 = compute_keypackage_hash(&make_keypackage(1));
assert!(!cache.has_hash(&addr, &hash1));
// Fourth should be present
let hash4 = compute_keypackage_hash(&make_keypackage(4));
assert!(cache.has_hash(&addr, &hash4));
}
#[test]
fn consume_removes_keypackage() {
let mut cache = KeyPackageCache::new();
let addr = make_address(4);
let kp = make_keypackage(4);
let hash = compute_keypackage_hash(&kp);
cache.store(addr, kp.clone());
assert!(cache.has_hash(&addr, &hash));
let consumed = cache.consume(&addr, &hash).expect("should consume");
assert_eq!(consumed, kp);
assert!(!cache.has_hash(&addr, &hash));
assert!(cache.is_empty());
}
#[test]
fn get_by_hash() {
let mut cache = KeyPackageCache::new();
let addr = make_address(5);
let kp1 = make_keypackage(51);
let kp2 = make_keypackage(52);
let hash1 = compute_keypackage_hash(&kp1);
let hash2 = compute_keypackage_hash(&kp2);
cache.store(addr, kp1.clone());
cache.store(addr, kp2.clone());
let found1 = cache.get_by_hash(&addr, &hash1).expect("hash1");
assert_eq!(found1.bytes, kp1);
let found2 = cache.get_by_hash(&addr, &hash2).expect("hash2");
assert_eq!(found2.bytes, kp2);
let wrong_hash = [0xFFu8; 8];
assert!(cache.get_by_hash(&addr, &wrong_hash).is_none());
}
#[test]
fn capacity_eviction() {
let mut cache = KeyPackageCache::with_capacity(2, 1);
let addr1 = make_address(1);
let addr2 = make_address(2);
let addr3 = make_address(3);
cache.store(addr1, make_keypackage(1));
cache.store(addr2, make_keypackage(2));
assert_eq!(cache.len(), 2);
// Third should evict oldest (addr1)
cache.store(addr3, make_keypackage(3));
assert_eq!(cache.len(), 2);
assert!(cache.get(&addr1).is_none());
assert!(cache.get(&addr2).is_some());
assert!(cache.get(&addr3).is_some());
}
#[test]
fn expiry() {
let mut cache = KeyPackageCache::new();
let addr = make_address(6);
// Create entry with very short TTL
let kp = make_keypackage(6);
let entry = CachedKeyPackage::with_ttl(kp, Duration::from_millis(1));
cache.store_entry(addr, entry);
assert_eq!(cache.total_keypackages(), 1);
// Wait for expiry
std::thread::sleep(Duration::from_millis(10));
// GC should remove it
let removed = cache.gc_expired();
assert_eq!(removed, 1);
assert!(cache.is_empty());
}
}

View File

@@ -20,6 +20,8 @@ pub mod fapp_router;
pub mod broadcast;
pub mod envelope;
pub mod envelope_v2;
pub mod keypackage_cache;
pub mod mesh_protocol;
pub mod mls_lite;
pub mod identity;
pub mod link;

View File

@@ -0,0 +1,269 @@
//! Mesh protocol messages for peer-to-peer communication.
//!
//! This module defines the control messages used for mesh coordination:
//! - KeyPackage request/response for MLS group setup
//! - Future: route requests, capability queries, etc.
use serde::{Deserialize, Serialize};
use crate::address::MeshAddress;
/// Protocol message type discriminator.
#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize, Deserialize)]
#[repr(u8)]
pub enum MessageType {
/// Request a KeyPackage from a node.
KeyPackageRequest = 0x10,
/// Response with KeyPackage data.
KeyPackageResponse = 0x11,
/// Node has no KeyPackage available.
KeyPackageUnavailable = 0x12,
}
/// Request a KeyPackage from a peer.
#[derive(Clone, Debug, Serialize, Deserialize)]
pub struct KeyPackageRequest {
/// Who is requesting.
pub requester_addr: MeshAddress,
/// Whose KeyPackage is requested.
pub target_addr: MeshAddress,
/// Optional: specific hash to request (from announce).
pub hash: Option<[u8; 8]>,
/// Request ID for correlation.
pub request_id: u32,
}
impl KeyPackageRequest {
/// Create a new request.
pub fn new(requester: MeshAddress, target: MeshAddress) -> Self {
Self {
requester_addr: requester,
target_addr: target,
hash: None,
request_id: rand::random(),
}
}
/// Create with specific hash.
pub fn with_hash(requester: MeshAddress, target: MeshAddress, hash: [u8; 8]) -> Self {
Self {
requester_addr: requester,
target_addr: target,
hash: Some(hash),
request_id: rand::random(),
}
}
/// Serialize to CBOR.
pub fn to_wire(&self) -> Vec<u8> {
let mut buf = Vec::new();
buf.push(MessageType::KeyPackageRequest as u8);
ciborium::into_writer(self, &mut buf).expect("CBOR serialization");
buf
}
/// Deserialize from CBOR (after type byte).
pub fn from_wire(bytes: &[u8]) -> anyhow::Result<Self> {
if bytes.is_empty() || bytes[0] != MessageType::KeyPackageRequest as u8 {
anyhow::bail!("not a KeyPackageRequest");
}
let req: Self = ciborium::from_reader(&bytes[1..])?;
Ok(req)
}
}
/// Response with KeyPackage data.
#[derive(Clone, Debug, Serialize, Deserialize)]
pub struct KeyPackageResponse {
/// Whose KeyPackage this is.
pub owner_addr: MeshAddress,
/// The serialized MLS KeyPackage.
pub keypackage_bytes: Vec<u8>,
/// Hash of the KeyPackage (for verification).
pub hash: [u8; 8],
/// Matching request ID.
pub request_id: u32,
}
impl KeyPackageResponse {
/// Create a new response.
pub fn new(
owner: MeshAddress,
keypackage_bytes: Vec<u8>,
request_id: u32,
) -> Self {
let hash = crate::announce::compute_keypackage_hash(&keypackage_bytes);
Self {
owner_addr: owner,
keypackage_bytes,
hash,
request_id,
}
}
/// Serialize to CBOR.
pub fn to_wire(&self) -> Vec<u8> {
let mut buf = Vec::new();
buf.push(MessageType::KeyPackageResponse as u8);
ciborium::into_writer(self, &mut buf).expect("CBOR serialization");
buf
}
/// Deserialize from CBOR (after type byte).
pub fn from_wire(bytes: &[u8]) -> anyhow::Result<Self> {
if bytes.is_empty() || bytes[0] != MessageType::KeyPackageResponse as u8 {
anyhow::bail!("not a KeyPackageResponse");
}
let resp: Self = ciborium::from_reader(&bytes[1..])?;
Ok(resp)
}
/// Verify the hash matches the KeyPackage.
pub fn verify_hash(&self) -> bool {
let computed = crate::announce::compute_keypackage_hash(&self.keypackage_bytes);
computed == self.hash
}
}
/// Response indicating no KeyPackage available.
#[derive(Clone, Debug, Serialize, Deserialize)]
pub struct KeyPackageUnavailable {
/// Whose KeyPackage was requested.
pub target_addr: MeshAddress,
/// Matching request ID.
pub request_id: u32,
}
impl KeyPackageUnavailable {
/// Create a new unavailable response.
pub fn new(target: MeshAddress, request_id: u32) -> Self {
Self {
target_addr: target,
request_id,
}
}
/// Serialize to CBOR.
pub fn to_wire(&self) -> Vec<u8> {
let mut buf = Vec::new();
buf.push(MessageType::KeyPackageUnavailable as u8);
ciborium::into_writer(self, &mut buf).expect("CBOR serialization");
buf
}
/// Deserialize from CBOR (after type byte).
pub fn from_wire(bytes: &[u8]) -> anyhow::Result<Self> {
if bytes.is_empty() || bytes[0] != MessageType::KeyPackageUnavailable as u8 {
anyhow::bail!("not a KeyPackageUnavailable");
}
let resp: Self = ciborium::from_reader(&bytes[1..])?;
Ok(resp)
}
}
/// Parse the message type from wire bytes.
pub fn parse_message_type(bytes: &[u8]) -> Option<MessageType> {
if bytes.is_empty() {
return None;
}
match bytes[0] {
0x10 => Some(MessageType::KeyPackageRequest),
0x11 => Some(MessageType::KeyPackageResponse),
0x12 => Some(MessageType::KeyPackageUnavailable),
_ => None,
}
}
#[cfg(test)]
mod tests {
use super::*;
fn make_address(seed: u8) -> MeshAddress {
MeshAddress::from_bytes([seed; 16])
}
#[test]
fn request_roundtrip() {
let req = KeyPackageRequest::new(make_address(1), make_address(2));
let wire = req.to_wire();
let restored = KeyPackageRequest::from_wire(&wire).expect("parse");
assert_eq!(req.requester_addr, restored.requester_addr);
assert_eq!(req.target_addr, restored.target_addr);
assert_eq!(req.request_id, restored.request_id);
}
#[test]
fn request_with_hash_roundtrip() {
let hash = [0xAB; 8];
let req = KeyPackageRequest::with_hash(make_address(1), make_address(2), hash);
let wire = req.to_wire();
let restored = KeyPackageRequest::from_wire(&wire).expect("parse");
assert_eq!(req.hash, restored.hash);
assert_eq!(Some(hash), restored.hash);
}
#[test]
fn response_roundtrip() {
let kp_bytes = vec![0x42; 100];
let resp = KeyPackageResponse::new(make_address(3), kp_bytes.clone(), 12345);
let wire = resp.to_wire();
let restored = KeyPackageResponse::from_wire(&wire).expect("parse");
assert_eq!(resp.owner_addr, restored.owner_addr);
assert_eq!(resp.keypackage_bytes, restored.keypackage_bytes);
assert_eq!(resp.hash, restored.hash);
assert_eq!(resp.request_id, restored.request_id);
assert!(restored.verify_hash());
}
#[test]
fn unavailable_roundtrip() {
let resp = KeyPackageUnavailable::new(make_address(4), 99999);
let wire = resp.to_wire();
let restored = KeyPackageUnavailable::from_wire(&wire).expect("parse");
assert_eq!(resp.target_addr, restored.target_addr);
assert_eq!(resp.request_id, restored.request_id);
}
#[test]
fn parse_message_type_works() {
let req = KeyPackageRequest::new(make_address(1), make_address(2));
let wire = req.to_wire();
assert_eq!(parse_message_type(&wire), Some(MessageType::KeyPackageRequest));
let resp = KeyPackageResponse::new(make_address(3), vec![0x42], 1);
let wire = resp.to_wire();
assert_eq!(parse_message_type(&wire), Some(MessageType::KeyPackageResponse));
let unavail = KeyPackageUnavailable::new(make_address(4), 2);
let wire = unavail.to_wire();
assert_eq!(parse_message_type(&wire), Some(MessageType::KeyPackageUnavailable));
assert_eq!(parse_message_type(&[]), None);
assert_eq!(parse_message_type(&[0xFF]), None);
}
#[test]
fn measure_protocol_overhead() {
let req = KeyPackageRequest::new(make_address(1), make_address(2));
let wire = req.to_wire();
println!("KeyPackageRequest: {} bytes", wire.len());
let kp_bytes = vec![0x42; 306]; // Typical MLS KeyPackage size
let resp = KeyPackageResponse::new(make_address(3), kp_bytes.clone(), 12345);
let wire = resp.to_wire();
println!("KeyPackageResponse (306B payload): {} bytes", wire.len());
println!("Response overhead: {} bytes", wire.len() - 306);
let unavail = KeyPackageUnavailable::new(make_address(4), 99999);
let wire = unavail.to_wire();
println!("KeyPackageUnavailable: {} bytes", wire.len());
// Assertions
assert!(req.to_wire().len() < 100, "request should be compact");
assert!(unavail.to_wire().len() < 50, "unavailable should be compact");
}
}