188 lines
5.6 KiB
Rust
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]);
|
|
}
|
|
}
|