feat: featherChat-compatible identity — seed, mnemonic, fingerprint
New identity module (wzp-crypto/src/identity.rs) mirrors featherChat's warzone-protocol identity.rs exactly: - Seed: 32 bytes, from hex or BIP39 mnemonic (24 words) - HKDF derivation: same salt (None), same info strings - Fingerprint: SHA-256(Ed25519 pub)[:16], same xxxx:xxxx format - Cross-verified: test proves identity module matches KeyExchange trait CLI flags: - --seed <64 hex chars>: use a specific identity - --mnemonic <24 words>: use BIP39 mnemonic from featherChat - Without either: generates ephemeral identity Also adds featherChat as git submodule at deps/featherchat for reference. 32 crypto tests passing (27 original + 5 identity tests). Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
3
.gitmodules
vendored
Normal file
3
.gitmodules
vendored
Normal file
@@ -0,0 +1,3 @@
|
|||||||
|
[submodule "deps/featherchat"]
|
||||||
|
path = deps/featherchat
|
||||||
|
url = ssh://git@git.manko.yoga:222/manawenuz/featherChat.git
|
||||||
@@ -40,6 +40,33 @@ struct CliArgs {
|
|||||||
send_file: Option<String>,
|
send_file: Option<String>,
|
||||||
record_file: Option<String>,
|
record_file: Option<String>,
|
||||||
echo_test_secs: Option<u32>,
|
echo_test_secs: Option<u32>,
|
||||||
|
seed_hex: Option<String>,
|
||||||
|
mnemonic: Option<String>,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl CliArgs {
|
||||||
|
/// Resolve the identity seed from --seed, --mnemonic, or generate a new one.
|
||||||
|
pub fn resolve_seed(&self) -> wzp_crypto::Seed {
|
||||||
|
if let Some(ref hex_str) = self.seed_hex {
|
||||||
|
let seed = wzp_crypto::Seed::from_hex(hex_str).expect("invalid --seed hex");
|
||||||
|
let id = seed.derive_identity();
|
||||||
|
let fp = id.public_identity().fingerprint;
|
||||||
|
info!(fingerprint = %fp, "identity from --seed");
|
||||||
|
seed
|
||||||
|
} else if let Some(ref words) = self.mnemonic {
|
||||||
|
let seed = wzp_crypto::Seed::from_mnemonic(words).expect("invalid --mnemonic");
|
||||||
|
let id = seed.derive_identity();
|
||||||
|
let fp = id.public_identity().fingerprint;
|
||||||
|
info!(fingerprint = %fp, "identity from --mnemonic");
|
||||||
|
seed
|
||||||
|
} else {
|
||||||
|
let seed = wzp_crypto::Seed::generate();
|
||||||
|
let id = seed.derive_identity();
|
||||||
|
let fp = id.public_identity().fingerprint;
|
||||||
|
info!(fingerprint = %fp, "generated ephemeral identity");
|
||||||
|
seed
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
fn parse_args() -> CliArgs {
|
fn parse_args() -> CliArgs {
|
||||||
@@ -49,6 +76,8 @@ fn parse_args() -> CliArgs {
|
|||||||
let mut send_file = None;
|
let mut send_file = None;
|
||||||
let mut record_file = None;
|
let mut record_file = None;
|
||||||
let mut echo_test_secs = None;
|
let mut echo_test_secs = None;
|
||||||
|
let mut seed_hex = None;
|
||||||
|
let mut mnemonic = None;
|
||||||
let mut relay_str = None;
|
let mut relay_str = None;
|
||||||
|
|
||||||
let mut i = 1;
|
let mut i = 1;
|
||||||
@@ -72,6 +101,21 @@ fn parse_args() -> CliArgs {
|
|||||||
.to_string(),
|
.to_string(),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
"--seed" => {
|
||||||
|
i += 1;
|
||||||
|
seed_hex = Some(args.get(i).expect("--seed requires hex string").to_string());
|
||||||
|
}
|
||||||
|
"--mnemonic" => {
|
||||||
|
// Consume all remaining words until next flag or end
|
||||||
|
i += 1;
|
||||||
|
let mut words = Vec::new();
|
||||||
|
while i < args.len() && !args[i].starts_with('-') {
|
||||||
|
words.push(args[i].clone());
|
||||||
|
i += 1;
|
||||||
|
}
|
||||||
|
i -= 1; // back up since outer loop will increment
|
||||||
|
mnemonic = Some(words.join(" "));
|
||||||
|
}
|
||||||
"--record" => {
|
"--record" => {
|
||||||
i += 1;
|
i += 1;
|
||||||
record_file = Some(
|
record_file = Some(
|
||||||
@@ -98,6 +142,8 @@ fn parse_args() -> CliArgs {
|
|||||||
eprintln!(" --send-file <file> Send a raw PCM file (48kHz mono s16le)");
|
eprintln!(" --send-file <file> Send a raw PCM file (48kHz mono s16le)");
|
||||||
eprintln!(" --record <file.raw> Record received audio to raw PCM file");
|
eprintln!(" --record <file.raw> Record received audio to raw PCM file");
|
||||||
eprintln!(" --echo-test <secs> Run automated echo quality test");
|
eprintln!(" --echo-test <secs> Run automated echo quality test");
|
||||||
|
eprintln!(" --seed <hex> Identity seed (64 hex chars, featherChat compatible)");
|
||||||
|
eprintln!(" --mnemonic <words...> Identity seed as BIP39 mnemonic (24 words)");
|
||||||
eprintln!(" (48kHz mono s16le, play with ffplay -f s16le -ar 48000 -ch_layout mono file.raw)");
|
eprintln!(" (48kHz mono s16le, play with ffplay -f s16le -ar 48000 -ch_layout mono file.raw)");
|
||||||
eprintln!();
|
eprintln!();
|
||||||
eprintln!("Default relay: 127.0.0.1:4433");
|
eprintln!("Default relay: 127.0.0.1:4433");
|
||||||
@@ -127,6 +173,8 @@ fn parse_args() -> CliArgs {
|
|||||||
send_file,
|
send_file,
|
||||||
record_file,
|
record_file,
|
||||||
echo_test_secs,
|
echo_test_secs,
|
||||||
|
seed_hex,
|
||||||
|
mnemonic,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -135,6 +183,7 @@ async fn main() -> anyhow::Result<()> {
|
|||||||
tracing_subscriber::fmt().init();
|
tracing_subscriber::fmt().init();
|
||||||
|
|
||||||
let cli = parse_args();
|
let cli = parse_args();
|
||||||
|
let _seed = cli.resolve_seed();
|
||||||
|
|
||||||
info!(
|
info!(
|
||||||
relay = %cli.relay_addr,
|
relay = %cli.relay_addr,
|
||||||
|
|||||||
@@ -15,5 +15,7 @@ hkdf = { workspace = true }
|
|||||||
sha2 = { workspace = true }
|
sha2 = { workspace = true }
|
||||||
rand = { workspace = true }
|
rand = { workspace = true }
|
||||||
tracing = { workspace = true }
|
tracing = { workspace = true }
|
||||||
|
bip39 = "2"
|
||||||
|
hex = "0.4"
|
||||||
|
|
||||||
[dev-dependencies]
|
[dev-dependencies]
|
||||||
|
|||||||
251
crates/wzp-crypto/src/identity.rs
Normal file
251
crates/wzp-crypto/src/identity.rs
Normal file
@@ -0,0 +1,251 @@
|
|||||||
|
//! 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,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[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 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(),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -9,12 +9,14 @@
|
|||||||
|
|
||||||
pub mod anti_replay;
|
pub mod anti_replay;
|
||||||
pub mod handshake;
|
pub mod handshake;
|
||||||
|
pub mod identity;
|
||||||
pub mod nonce;
|
pub mod nonce;
|
||||||
pub mod rekey;
|
pub mod rekey;
|
||||||
pub mod session;
|
pub mod session;
|
||||||
|
|
||||||
pub use anti_replay::AntiReplayWindow;
|
pub use anti_replay::AntiReplayWindow;
|
||||||
pub use handshake::WarzoneKeyExchange;
|
pub use handshake::WarzoneKeyExchange;
|
||||||
|
pub use identity::{Fingerprint, IdentityKeyPair, PublicIdentity, Seed};
|
||||||
pub use nonce::{build_nonce, Direction};
|
pub use nonce::{build_nonce, Direction};
|
||||||
pub use rekey::RekeyManager;
|
pub use rekey::RekeyManager;
|
||||||
pub use session::ChaChaSession;
|
pub use session::ChaChaSession;
|
||||||
|
|||||||
1
deps/featherchat
vendored
Submodule
1
deps/featherchat
vendored
Submodule
Submodule deps/featherchat added at 65f639052e
Reference in New Issue
Block a user