//! featherChat-compatible identity module. //! //! Mirrors `warzone-protocol/src/identity.rs` and `warzone-protocol/src/mnemonic.rs` //! from featherChat. Same seed → same keys → same fingerprint in both codebases. //! //! Source of truth: deps/featherchat/warzone/crates/warzone-protocol/src/identity.rs use ed25519_dalek::{SigningKey, VerifyingKey}; use hkdf::Hkdf; use sha2::{Digest, Sha256}; use x25519_dalek::StaticSecret; /// The root secret — 32 bytes from which all keys are derived. /// Displayed to users as a BIP39 mnemonic (24 words). /// /// Mirrors: `warzone-protocol::identity::Seed` pub struct Seed(pub [u8; 32]); impl Seed { /// Generate a new random seed. pub fn generate() -> Self { let mut bytes = [0u8; 32]; rand::RngCore::fill_bytes(&mut rand::rngs::OsRng, &mut bytes); Seed(bytes) } /// Create seed from raw bytes. pub fn from_bytes(bytes: [u8; 32]) -> Self { Seed(bytes) } /// Create seed from hex string (64 hex chars). pub fn from_hex(hex_str: &str) -> Result { let bytes = hex::decode(hex_str).map_err(|e| format!("invalid hex: {e}"))?; if bytes.len() != 32 { return Err(format!("expected 32 bytes, got {}", bytes.len())); } let mut seed = [0u8; 32]; seed.copy_from_slice(&bytes); Ok(Seed(seed)) } /// Derive the full identity keypair from this seed. /// /// Uses identical HKDF derivation as featherChat: /// - Ed25519: `HKDF(seed, salt=None, info="warzone-ed25519")` /// - X25519: `HKDF(seed, salt=None, info="warzone-x25519")` pub fn derive_identity(&self) -> IdentityKeyPair { let hk = Hkdf::::new(None, &self.0); let mut ed_bytes = [0u8; 32]; hk.expand(b"warzone-ed25519", &mut ed_bytes) .expect("HKDF expand for Ed25519"); let signing = SigningKey::from_bytes(&ed_bytes); ed_bytes.fill(0); let mut x_bytes = [0u8; 32]; hk.expand(b"warzone-x25519", &mut x_bytes) .expect("HKDF expand for X25519"); let encryption = StaticSecret::from(x_bytes); x_bytes.fill(0); IdentityKeyPair { signing, encryption, } } /// Convert to BIP39 mnemonic (24 words). /// /// Mirrors: `warzone-protocol::mnemonic::seed_to_mnemonic` pub fn to_mnemonic(&self) -> String { let mnemonic = bip39::Mnemonic::from_entropy(&self.0).expect("32 bytes is valid BIP39 entropy"); mnemonic.to_string() } /// Recover seed from BIP39 mnemonic (24 words). /// /// Mirrors: `warzone-protocol::mnemonic::mnemonic_to_seed` pub fn from_mnemonic(words: &str) -> Result { let mnemonic: bip39::Mnemonic = words.parse().map_err(|e| format!("invalid mnemonic: {e}"))?; let entropy = mnemonic.to_entropy(); if entropy.len() != 32 { return Err(format!("expected 32 bytes entropy, got {}", entropy.len())); } let mut seed = [0u8; 32]; seed.copy_from_slice(&entropy); Ok(Seed(seed)) } } impl Drop for Seed { fn drop(&mut self) { self.0.fill(0); // zeroize on drop } } /// The full identity keypair derived from a seed. /// /// Mirrors: `warzone-protocol::identity::IdentityKeyPair` pub struct IdentityKeyPair { pub signing: SigningKey, pub encryption: StaticSecret, } impl IdentityKeyPair { /// Get the public identity (safe to share). pub fn public_identity(&self) -> PublicIdentity { let verifying = self.signing.verifying_key(); let encryption_pub = x25519_dalek::PublicKey::from(&self.encryption); let fingerprint = Fingerprint::from_verifying_key(&verifying); PublicIdentity { signing: verifying, encryption: encryption_pub, fingerprint, } } } /// Truncated SHA-256 hash of the Ed25519 public key (16 bytes). /// Displayed as `xxxx:xxxx:xxxx:xxxx:xxxx:xxxx:xxxx:xxxx`. /// /// Mirrors: `warzone-protocol::types::Fingerprint` #[derive(Clone, Copy, PartialEq, Eq, Hash)] pub struct Fingerprint(pub [u8; 16]); impl Fingerprint { pub fn from_verifying_key(key: &VerifyingKey) -> Self { let hash = Sha256::digest(key.as_bytes()); let mut fp = [0u8; 16]; fp.copy_from_slice(&hash[..16]); Fingerprint(fp) } /// Parse from hex string (with or without colons). pub fn from_hex(s: &str) -> Result { let clean: String = s.chars().filter(|c| c.is_ascii_hexdigit()).collect(); let bytes = hex::decode(&clean).map_err(|e| format!("invalid hex: {e}"))?; if bytes.len() < 16 { return Err("fingerprint too short".to_string()); } let mut fp = [0u8; 16]; fp.copy_from_slice(&bytes[..16]); Ok(Fingerprint(fp)) } /// As raw bytes. pub fn as_bytes(&self) -> &[u8; 16] { &self.0 } /// As hex string without colons. pub fn to_hex(&self) -> String { hex::encode(self.0) } } impl std::fmt::Display for Fingerprint { fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { write!( f, "{:04x}:{:04x}:{:04x}:{:04x}:{:04x}:{:04x}:{:04x}:{:04x}", u16::from_be_bytes([self.0[0], self.0[1]]), u16::from_be_bytes([self.0[2], self.0[3]]), u16::from_be_bytes([self.0[4], self.0[5]]), u16::from_be_bytes([self.0[6], self.0[7]]), u16::from_be_bytes([self.0[8], self.0[9]]), u16::from_be_bytes([self.0[10], self.0[11]]), u16::from_be_bytes([self.0[12], self.0[13]]), u16::from_be_bytes([self.0[14], self.0[15]]), ) } } impl std::fmt::Debug for Fingerprint { fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { write!(f, "Fingerprint({})", self) } } /// The public portion of an identity — safe to share with anyone. pub struct PublicIdentity { pub signing: VerifyingKey, pub encryption: x25519_dalek::PublicKey, pub fingerprint: Fingerprint, } /// Hash a human-readable room/group name into an opaque hex string. /// Used as QUIC SNI to prevent leaking group names to network observers. /// /// `hash_room_name("my-group")` → 32 hex chars (16 bytes of SHA-256). /// /// Mirrors the convention in featherChat WZP-FC-5: /// `SHA-256("featherchat-group:" + group_name)[:16]` pub fn hash_room_name(group_name: &str) -> String { use sha2::{Digest, Sha256}; let mut hasher = Sha256::new(); hasher.update(b"featherchat-group:"); hasher.update(group_name.as_bytes()); let hash = hasher.finalize(); hex::encode(&hash[..16]) } #[cfg(test)] mod tests { use super::*; #[test] fn deterministic_derivation() { let seed = Seed::from_bytes([42u8; 32]); let id1 = seed.derive_identity(); let id2 = seed.derive_identity(); assert_eq!( id1.signing.verifying_key().as_bytes(), id2.signing.verifying_key().as_bytes(), ); } #[test] fn mnemonic_roundtrip() { let seed = Seed::generate(); let words = seed.to_mnemonic(); let word_count = words.split_whitespace().count(); assert_eq!(word_count, 24); let recovered = Seed::from_mnemonic(&words).unwrap(); assert_eq!(seed.0, recovered.0); } #[test] fn hex_roundtrip() { let seed = Seed::generate(); let hex_str = hex::encode(seed.0); let recovered = Seed::from_hex(&hex_str).unwrap(); assert_eq!(seed.0, recovered.0); } #[test] fn fingerprint_format() { let seed = Seed::generate(); let id = seed.derive_identity(); let pub_id = id.public_identity(); let fp_str = pub_id.fingerprint.to_string(); // Format: xxxx:xxxx:xxxx:xxxx:xxxx:xxxx:xxxx:xxxx assert_eq!(fp_str.len(), 39); assert_eq!(fp_str.chars().filter(|c| *c == ':').count(), 7); } #[test] fn hash_room_name_deterministic() { let h1 = hash_room_name("my-group"); let h2 = hash_room_name("my-group"); assert_eq!(h1, h2); assert_eq!(h1.len(), 32); // 16 bytes = 32 hex chars assert!(h1.chars().all(|c| c.is_ascii_hexdigit())); } #[test] fn hash_room_name_different_inputs() { assert_ne!(hash_room_name("alpha"), hash_room_name("beta")); } #[test] fn matches_handshake_derivation() { use wzp_proto::KeyExchange; // Verify identity module matches the KeyExchange trait implementation let seed = [99u8; 32]; let id = Seed::from_bytes(seed).derive_identity(); let kx = crate::WarzoneKeyExchange::from_identity_seed(&seed); assert_eq!( id.signing.verifying_key().as_bytes(), &kx.identity_public_key(), ); assert_eq!( id.public_identity().fingerprint.as_bytes(), &kx.fingerprint(), ); } }