Files
wz-phone/crates/wzp-crypto/src/identity.rs
Siavash Sameni 59069bfba2 feat: complete all WZP-S integration tasks (S-4/5/6/7/9)
WZP-S-4: Room access control
- hash_room_name() in wzp-crypto: SHA-256("featherchat-group:"+name)[:16]
- CLI --room flag hashes before SNI, web bridge does the same
- RoomManager gains ACL: with_acl(), allow(), is_authorized()
- join() returns Result, rejects unauthorized fingerprints

WZP-S-5: Crypto handshake wired into all live paths
- CLI: perform_handshake() after connect, before any mode
- Relay: accept_handshake() after auth, before room join
- Web bridge: perform_handshake() after auth, before audio
- Relay generates ephemeral identity at startup

WZP-S-6: Web bridge featherChat auth
- --auth-url flag: browsers send {"type":"auth","token":"..."} as first WS msg
- Validates against featherChat, passes token to relay
- --cert/--key flags for production TLS (replaces self-signed)

WZP-S-7: wzp-proto standalone
- Cargo.toml uses explicit versions (no workspace inheritance)
- FC can use as git dependency

WZP-S-9: All 6 hardcoded assumptions resolved
- Auth, hashed rooms, mandatory handshake, real TLS certs,
  profile negotiation, token validation

CLI also gains --room and --token flags.
179 tests passing across all crates.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-28 09:59:05 +04:00

282 lines
8.9 KiB
Rust

//! 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<Self, String> {
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::<Sha256>::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<Self, String> {
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<Self, String> {
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(),
);
}
}