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
}