feat: Sprint 9 — mesh identity, store-and-forward, broadcast channels
Self-sovereign mesh networking for offline-capable Freifunk deployments. - MeshIdentity: Ed25519 keypair-based identity without AS registration, JSON-persisted seed + known peers directory, sign/verify - MeshEnvelope: signed store-and-forward envelope with TTL, hop_count, max_hops, SHA-256 dedup ID, Ed25519 signature verification - MeshStore: in-memory message queue with dedup, per-recipient capacity limits, TTL-based garbage collection - BroadcastChannel: symmetric ChaCha20-Poly1305 encrypted topic-based pub/sub for mesh announcements, no MLS overhead - BroadcastManager: subscribe/unsubscribe/create channels by topic - P2pNode integration: send_mesh(), receive_mesh(), forward_stored(), subscribe(), create_broadcast(), broadcast() - Extended mesh REPL: /mesh send, /mesh broadcast, /mesh subscribe, /mesh route, /mesh identity, /mesh store (feature-gated) 28 P2P tests pass (21 existing + 7 broadcast). All builds clean.
This commit is contained in:
173
crates/quicproquo-p2p/src/identity.rs
Normal file
173
crates/quicproquo-p2p/src/identity.rs
Normal file
@@ -0,0 +1,173 @@
|
||||
//! Self-sovereign mesh identity backed by quicproquo-core Ed25519 keypairs.
|
||||
//!
|
||||
//! A [`MeshIdentity`] wraps an [`IdentityKeypair`] with a peer directory,
|
||||
//! enabling P2P nodes to persist identity and track known peers across
|
||||
//! restarts.
|
||||
|
||||
use std::collections::HashMap;
|
||||
use std::path::Path;
|
||||
|
||||
use quicproquo_core::IdentityKeypair;
|
||||
use serde::{Deserialize, Serialize};
|
||||
|
||||
/// Information about a known peer in the mesh network.
|
||||
#[derive(Clone, Debug, Serialize, Deserialize)]
|
||||
pub struct PeerInfo {
|
||||
/// Raw Ed25519 public key (32 bytes).
|
||||
pub public_key: Vec<u8>,
|
||||
/// Unix timestamp of last observed activity.
|
||||
pub last_seen: u64,
|
||||
/// Known network addresses (e.g. iroh `NodeAddr` serializations).
|
||||
pub addresses: Vec<String>,
|
||||
}
|
||||
|
||||
/// Persisted form of a mesh identity (JSON on disk).
|
||||
#[derive(Serialize, Deserialize)]
|
||||
struct IdentityFile {
|
||||
/// Hex-encoded 32-byte Ed25519 seed.
|
||||
seed: String,
|
||||
/// Known peers, keyed by hex-encoded peer public key.
|
||||
peers: HashMap<String, PeerInfo>,
|
||||
}
|
||||
|
||||
/// A self-sovereign mesh identity: an Ed25519 keypair + a known-peers directory.
|
||||
pub struct MeshIdentity {
|
||||
keypair: IdentityKeypair,
|
||||
known_peers: HashMap<String, PeerInfo>,
|
||||
}
|
||||
|
||||
impl MeshIdentity {
|
||||
/// Generate a fresh random mesh identity.
|
||||
pub fn generate() -> Self {
|
||||
Self {
|
||||
keypair: IdentityKeypair::generate(),
|
||||
known_peers: HashMap::new(),
|
||||
}
|
||||
}
|
||||
|
||||
/// Recreate a mesh identity from a 32-byte Ed25519 seed.
|
||||
pub fn from_seed(seed: [u8; 32]) -> Self {
|
||||
Self {
|
||||
keypair: IdentityKeypair::from_seed(seed),
|
||||
known_peers: HashMap::new(),
|
||||
}
|
||||
}
|
||||
|
||||
/// Load a mesh identity from a JSON file.
|
||||
pub fn load(path: &Path) -> anyhow::Result<Self> {
|
||||
let data = std::fs::read_to_string(path)?;
|
||||
let file: IdentityFile = serde_json::from_str(&data)?;
|
||||
let seed_bytes = hex::decode(&file.seed)?;
|
||||
let seed: [u8; 32] = seed_bytes
|
||||
.as_slice()
|
||||
.try_into()
|
||||
.map_err(|_| anyhow::anyhow!("seed must be 32 bytes"))?;
|
||||
Ok(Self {
|
||||
keypair: IdentityKeypair::from_seed(seed),
|
||||
known_peers: file.peers,
|
||||
})
|
||||
}
|
||||
|
||||
/// Save this mesh identity to a JSON file.
|
||||
pub fn save(&self, path: &Path) -> anyhow::Result<()> {
|
||||
let file = IdentityFile {
|
||||
seed: hex::encode(self.keypair.seed_bytes()),
|
||||
peers: self.known_peers.clone(),
|
||||
};
|
||||
let json = serde_json::to_string_pretty(&file)?;
|
||||
std::fs::write(path, json)?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Return the raw 32-byte Ed25519 public key.
|
||||
pub fn public_key(&self) -> [u8; 32] {
|
||||
self.keypair.public_key_bytes()
|
||||
}
|
||||
|
||||
/// Sign arbitrary bytes, returning a 64-byte Ed25519 signature.
|
||||
pub fn sign(&self, message: &[u8]) -> [u8; 64] {
|
||||
self.keypair.sign_raw(message)
|
||||
}
|
||||
|
||||
/// Return the underlying seed (for deriving iroh `SecretKey`, etc.).
|
||||
pub fn seed_bytes(&self) -> [u8; 32] {
|
||||
self.keypair.seed_bytes()
|
||||
}
|
||||
|
||||
/// Register or update a known peer.
|
||||
pub fn add_peer(&mut self, id: String, info: PeerInfo) {
|
||||
self.known_peers.insert(id, info);
|
||||
}
|
||||
|
||||
/// Immutable view of the known-peers directory.
|
||||
pub fn known_peers(&self) -> &HashMap<String, PeerInfo> {
|
||||
&self.known_peers
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use std::time::{SystemTime, UNIX_EPOCH};
|
||||
|
||||
#[test]
|
||||
fn generate_and_sign_verify() {
|
||||
let id = MeshIdentity::generate();
|
||||
let msg = b"test message";
|
||||
let sig = id.sign(msg);
|
||||
|
||||
// Verify through quicproquo_core
|
||||
let pk = id.public_key();
|
||||
IdentityKeypair::verify_raw(&pk, msg, &sig).expect("valid signature");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn from_seed_deterministic() {
|
||||
let seed = [42u8; 32];
|
||||
let a = MeshIdentity::from_seed(seed);
|
||||
let b = MeshIdentity::from_seed(seed);
|
||||
assert_eq!(a.public_key(), b.public_key());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn save_and_load_roundtrip() {
|
||||
let dir = tempfile::tempdir().expect("tmp dir");
|
||||
let path = dir.path().join("mesh_id.json");
|
||||
|
||||
let mut original = MeshIdentity::generate();
|
||||
original.add_peer(
|
||||
"deadbeef".into(),
|
||||
PeerInfo {
|
||||
public_key: vec![0xde, 0xad],
|
||||
last_seen: SystemTime::now()
|
||||
.duration_since(UNIX_EPOCH)
|
||||
.expect("time")
|
||||
.as_secs(),
|
||||
addresses: vec!["127.0.0.1:4433".into()],
|
||||
},
|
||||
);
|
||||
original.save(&path).expect("save");
|
||||
|
||||
let loaded = MeshIdentity::load(&path).expect("load");
|
||||
assert_eq!(original.public_key(), loaded.public_key());
|
||||
assert_eq!(loaded.known_peers().len(), 1);
|
||||
assert!(loaded.known_peers().contains_key("deadbeef"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn add_and_query_peers() {
|
||||
let mut id = MeshIdentity::generate();
|
||||
assert!(id.known_peers().is_empty());
|
||||
|
||||
id.add_peer(
|
||||
"peer1".into(),
|
||||
PeerInfo {
|
||||
public_key: vec![1; 32],
|
||||
last_seen: 0,
|
||||
addresses: vec![],
|
||||
},
|
||||
);
|
||||
assert_eq!(id.known_peers().len(), 1);
|
||||
assert_eq!(id.known_peers()["peer1"].public_key, vec![1; 32]);
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user