Files
quicproquo/crates/quicproquo-p2p/src/identity.rs

188 lines
5.6 KiB
Rust

//! 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};
#[cfg(unix)]
use std::os::unix::fs::PermissionsExt;
/// 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 with restrictive permissions.
///
/// On Unix, the file is set to `0o600` (owner read/write only) since it
/// contains the Ed25519 seed in the clear.
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)?;
// Restrict permissions to owner-only on Unix.
#[cfg(unix)]
{
let perms = std::fs::Permissions::from_mode(0o600);
std::fs::set_permissions(path, perms)?;
}
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]);
}
}