//! Node identity: persistent Ed25519 key for private identification. //! Enables future P2P features (file transfer, streaming, ecash/Lightning). //! Supports did:key (W3C) for Web5/DID interoperability. use anyhow::{Context, Result}; use ed25519_dalek::{Signature, Signer, SigningKey, Verifier, VerifyingKey}; use rand::rngs::OsRng; use std::path::{Path, PathBuf}; use tokio::fs; const NODE_KEY_FILE: &str = "node_key"; const NODE_KEY_PUB_FILE: &str = "node_key.pub"; /// Persistent node identity (Ed25519 keypair). /// Survives reboots; used for signing, verification, and node address. pub struct NodeIdentity { signing_key: SigningKey, identity_dir: PathBuf, } impl NodeIdentity { /// Load existing identity or create and persist a new one. pub async fn load_or_create(identity_dir: &Path) -> Result { fs::create_dir_all(identity_dir) .await .context("Failed to create identity directory")?; let key_path = identity_dir.join(NODE_KEY_FILE); let pub_path = identity_dir.join(NODE_KEY_PUB_FILE); let signing_key = if key_path.exists() { let bytes = fs::read(&key_path) .await .context("Failed to read node key")?; let arr: [u8; 32] = bytes .try_into() .map_err(|_| anyhow::anyhow!("Invalid node key length"))?; SigningKey::from_bytes(&arr) } else { let signing_key = SigningKey::generate(&mut OsRng); fs::write(&key_path, signing_key.to_bytes()) .await .context("Failed to write node key")?; #[cfg(unix)] { use std::os::unix::fs::PermissionsExt; fs::set_permissions(&key_path, std::fs::Permissions::from_mode(0o600)) .await .context("Failed to set key permissions")?; } fs::write(&pub_path, signing_key.verifying_key().as_bytes()) .await .context("Failed to write node public key")?; tracing::info!("🔑 Generated new node identity at {}", identity_dir.display()); signing_key }; Ok(Self { signing_key, identity_dir: identity_dir.to_path_buf(), }) } /// Public key as hex string (for ServerInfo, Nostr, etc.) pub fn pubkey_hex(&self) -> String { hex::encode(self.signing_key.verifying_key().as_bytes()) } /// Stable node ID derived from pubkey (first 16 chars of hex). pub fn node_id(&self) -> String { self.pubkey_hex().chars().take(16).collect() } /// Sign data; returns hex-encoded signature. pub fn sign(&self, data: &[u8]) -> String { hex::encode(self.signing_key.sign(data).to_bytes()) } /// Verify a signature from a peer (pubkey hex, data, signature hex). pub fn verify(pubkey_hex: &str, data: &[u8], sig_hex: &str) -> Result { let bytes = hex::decode(pubkey_hex).context("Invalid pubkey hex")?; let verifying_key = VerifyingKey::from_bytes( bytes .as_slice() .try_into() .map_err(|_| anyhow::anyhow!("Invalid pubkey length"))?, )?; let sig_bytes = hex::decode(sig_hex).context("Invalid signature hex")?; let sig = Signature::from_bytes( sig_bytes .as_slice() .try_into() .map_err(|_| anyhow::anyhow!("Invalid signature length"))?, ); Ok(verifying_key.verify(data, &sig).is_ok()) } /// Node address format for invites: archipelago://# pub fn node_address(&self, onion: &str) -> String { format!("archipelago://{}#{}", onion.trim_end_matches('/'), self.pubkey_hex()) } /// DID in did:key format (W3C did:key method, Ed25519). /// Format: did:key:z<base58btc(multicodec_ed25519_pub + 32-byte pubkey)> pub fn did_key(&self) -> String { did_key_from_pubkey_hex(&self.pubkey_hex()).expect("pubkey_hex is valid") } } /// Convert Ed25519 pubkey (hex) to did:key format. /// Used by RPC when identity is loaded from state. pub fn did_key_from_pubkey_hex(pubkey_hex: &str) -> Result { let bytes = hex::decode(pubkey_hex).context("Invalid pubkey hex")?; if bytes.len() != 32 { return Err(anyhow::anyhow!("Invalid pubkey length")); } let mut multicodec_pubkey = [0u8; 34]; multicodec_pubkey[0] = 0xed; multicodec_pubkey[1] = 0x01; multicodec_pubkey[2..34].copy_from_slice(&bytes); Ok(format!("did:key:z{}", bs58::encode(multicodec_pubkey).into_string())) } #[cfg(test)] mod tests { use super::*; #[tokio::test] async fn test_load_or_create_generates_new_identity() { let dir = tempfile::tempdir().unwrap(); let identity_dir = dir.path().join("identity"); let identity = NodeIdentity::load_or_create(&identity_dir).await.unwrap(); // pubkey_hex should be 64 hex chars (32 bytes) assert_eq!(identity.pubkey_hex().len(), 64); // node_id should be first 16 chars of pubkey_hex assert_eq!(identity.node_id(), &identity.pubkey_hex()[..16]); } #[tokio::test] async fn test_load_or_create_persists_and_reloads() { let dir = tempfile::tempdir().unwrap(); let identity_dir = dir.path().join("identity"); let identity1 = NodeIdentity::load_or_create(&identity_dir).await.unwrap(); let pubkey1 = identity1.pubkey_hex(); let identity2 = NodeIdentity::load_or_create(&identity_dir).await.unwrap(); let pubkey2 = identity2.pubkey_hex(); assert_eq!(pubkey1, pubkey2); } #[tokio::test] async fn test_sign_and_verify() { let dir = tempfile::tempdir().unwrap(); let identity = NodeIdentity::load_or_create(&dir.path().join("id")).await.unwrap(); let data = b"hello world"; let sig = identity.sign(data); let valid = NodeIdentity::verify(&identity.pubkey_hex(), data, &sig).unwrap(); assert!(valid); } #[tokio::test] async fn test_verify_wrong_data() { let dir = tempfile::tempdir().unwrap(); let identity = NodeIdentity::load_or_create(&dir.path().join("id")).await.unwrap(); let sig = identity.sign(b"hello"); let valid = NodeIdentity::verify(&identity.pubkey_hex(), b"wrong", &sig).unwrap(); assert!(!valid); } #[tokio::test] async fn test_did_key_format() { let dir = tempfile::tempdir().unwrap(); let identity = NodeIdentity::load_or_create(&dir.path().join("id")).await.unwrap(); let did = identity.did_key(); assert!(did.starts_with("did:key:z")); } #[test] fn test_did_key_from_pubkey_hex() { // 32-byte all-zeros pubkey in hex let hex = "0000000000000000000000000000000000000000000000000000000000000000"; let did = did_key_from_pubkey_hex(hex).unwrap(); assert!(did.starts_with("did:key:z")); } #[test] fn test_did_key_from_invalid_hex() { assert!(did_key_from_pubkey_hex("not_hex").is_err()); } #[test] fn test_did_key_from_wrong_length() { assert!(did_key_from_pubkey_hex("0011").is_err()); } #[tokio::test] async fn test_node_address_format() { let dir = tempfile::tempdir().unwrap(); let identity = NodeIdentity::load_or_create(&dir.path().join("id")).await.unwrap(); let addr = identity.node_address("abc123.onion"); assert!(addr.starts_with("archipelago://abc123.onion#")); assert!(addr.contains(&identity.pubkey_hex())); } }