From 661de475523b7decc60c3ebbe9705542bc3509fd Mon Sep 17 00:00:00 2001 From: Siavash Sameni Date: Fri, 27 Mar 2026 13:30:25 +0400 Subject: [PATCH] v0.0.14: Ethereum-compatible identity (secp256k1 + Keccak-256) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Protocol (ethereum.rs): - derive_eth_identity(): HKDF from seed (info="warzone-secp256k1") - secp256k1 signing key (k256 crate) - Ethereum address: Keccak-256(uncompressed_pubkey[1..])[-20:] - EIP-55 checksum address formatting - eth_sign() / eth_verify() for secp256k1 ECDSA - EthAddress type with Display, hex parsing, checksum - 5 tests: deterministic, format, checksum, sign/verify, uniqueness CLI: - `warzone eth` — show Ethereum address alongside Warzone fingerprint - Same seed produces both identities (dual-curve) Dual identity model: - Ed25519 + X25519 for Warzone messaging (fast, small signatures) - secp256k1 for Ethereum compatibility (MetaMask, ENS, Ledger/Trezor) - Both derived from the same BIP39 seed via different HKDF paths 28/28 protocol tests pass. Co-Authored-By: Claude Opus 4.6 (1M context) --- warzone/Cargo.lock | 154 ++++++++++++++- warzone/Cargo.toml | 6 +- warzone/crates/warzone-client/src/main.rs | 9 + warzone/crates/warzone-protocol/Cargo.toml | 2 + .../crates/warzone-protocol/src/ethereum.rs | 177 ++++++++++++++++++ warzone/crates/warzone-protocol/src/lib.rs | 1 + 6 files changed, 343 insertions(+), 6 deletions(-) create mode 100644 warzone/crates/warzone-protocol/src/ethereum.rs diff --git a/warzone/Cargo.lock b/warzone/Cargo.lock index 6ed439e..681918c 100644 --- a/warzone/Cargo.lock +++ b/warzone/Cargo.lock @@ -191,6 +191,12 @@ dependencies = [ "tracing", ] +[[package]] +name = "base16ct" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4c7f02d4ea65f2c1853089ffd8d2787bdbc63de2f0d29dedbcf8ccdfa0ccd4cf" + [[package]] name = "base64" version = "0.22.1" @@ -510,6 +516,24 @@ dependencies = [ "winapi", ] +[[package]] +name = "crunchy" +version = "0.2.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "460fbee9c2c2f33933d720630a6a0bac33ba7053db5344fac858d4b8952d77d5" + +[[package]] +name = "crypto-bigint" +version = "0.5.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0dc92fb57ca44df6db8059111ab3af99a63d5d0f8375d9972e319a379c6bab76" +dependencies = [ + "generic-array", + "rand_core", + "subtle", + "zeroize", +] + [[package]] name = "crypto-common" version = "0.1.7" @@ -606,6 +630,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "9ed9a281f7bc9b7576e61468ba615a66a5c8cfdff42420a70aa82701a3b1e292" dependencies = [ "block-buffer", + "const-oid", "crypto-common", "subtle", ] @@ -621,6 +646,21 @@ dependencies = [ "syn", ] +[[package]] +name = "ecdsa" +version = "0.16.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ee27f32b5c5292967d2d4a9d7f1e0b0aed2c15daded5a60300e4abb9d8020bca" +dependencies = [ + "der", + "digest", + "elliptic-curve", + "rfc6979", + "serdect", + "signature", + "spki", +] + [[package]] name = "ed25519" version = "2.2.3" @@ -653,6 +693,26 @@ version = "1.15.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "48c757948c5ede0e46177b7add2e67155f70e33c07fea8284df6576da70b3719" +[[package]] +name = "elliptic-curve" +version = "0.13.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b5e6043086bf7973472e0c7dff2142ea0b680d30e18d9cc40f267efbf222bd47" +dependencies = [ + "base16ct", + "crypto-bigint", + "digest", + "ff", + "generic-array", + "group", + "pkcs8", + "rand_core", + "sec1", + "serdect", + "subtle", + "zeroize", +] + [[package]] name = "encoding_rs" version = "0.8.35" @@ -684,6 +744,16 @@ version = "2.3.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "37909eebbb50d72f9059c3b6d82c0463f2ff062c9e95845c43a6c9c0355411be" +[[package]] +name = "ff" +version = "0.13.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c0b50bfb653653f9ca9095b427bed08ab8d75a137839d9ad64eb11810d5b6393" +dependencies = [ + "rand_core", + "subtle", +] + [[package]] name = "fiat-crypto" version = "0.2.9" @@ -811,6 +881,7 @@ checksum = "85649ca51fd72272d7821adaf274ad91c288277713d9c18820d8499a7ff69e9a" dependencies = [ "typenum", "version_check", + "zeroize", ] [[package]] @@ -839,6 +910,17 @@ dependencies = [ "wasip3", ] +[[package]] +name = "group" +version = "0.13.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f0f9ef7462f7c099f518d754361858f86d8a07af53ba9af0fe635bbccb151a63" +dependencies = [ + "ff", + "rand_core", + "subtle", +] + [[package]] name = "h2" version = "0.4.13" @@ -1276,6 +1358,21 @@ dependencies = [ "wasm-bindgen", ] +[[package]] +name = "k256" +version = "0.13.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f6e3919bbaa2945715f0bb6d3934a173d1e9a59ac23767fbaaef277265a7411b" +dependencies = [ + "cfg-if", + "ecdsa", + "elliptic-curve", + "once_cell", + "serdect", + "sha2", + "signature", +] + [[package]] name = "lazy_static" version = "1.5.0" @@ -1760,6 +1857,16 @@ dependencies = [ "web-sys", ] +[[package]] +name = "rfc6979" +version = "0.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f8dd2a808d456c4a54e300a23e9f5a67e122c3024119acbfd73e3bf664491cb2" +dependencies = [ + "hmac", + "subtle", +] + [[package]] name = "ring" version = "0.17.14" @@ -1869,6 +1976,21 @@ version = "1.2.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "94143f37725109f92c262ed2cf5e59bce7498c01bcc1502d7b9afe439a4e9f49" +[[package]] +name = "sec1" +version = "0.7.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d3e97a565f76233a6003f9f5c54be1d9c5bdfa3eccfb189469f11ec4901c47dc" +dependencies = [ + "base16ct", + "der", + "generic-array", + "pkcs8", + "serdect", + "subtle", + "zeroize", +] + [[package]] name = "security-framework" version = "3.7.0" @@ -1964,6 +2086,16 @@ dependencies = [ "serde", ] +[[package]] +name = "serdect" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a84f14a19e9a014bb9f4512488d9829a68e04ecabffb0f9904cd1ace94598177" +dependencies = [ + "base16ct", + "serde", +] + [[package]] name = "sha1" version = "0.10.6" @@ -2038,6 +2170,7 @@ version = "2.2.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "77549399552de45a898a580c1b41d445bf730df867cc44e6c0233bbc4b8329de" dependencies = [ + "digest", "rand_core", ] @@ -2249,6 +2382,15 @@ dependencies = [ "cfg-if", ] +[[package]] +name = "tiny-keccak" +version = "2.0.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2c9d3793400a45f954c52e73d068316d76b6f4e36977e3fcebb13a2721e80237" +dependencies = [ + "crunchy", +] + [[package]] name = "tinystr" version = "0.8.2" @@ -2647,7 +2789,7 @@ dependencies = [ [[package]] name = "warzone-client" -version = "0.0.12" +version = "0.0.13" dependencies = [ "anyhow", "argon2", @@ -2680,7 +2822,7 @@ dependencies = [ [[package]] name = "warzone-mule" -version = "0.0.12" +version = "0.0.13" dependencies = [ "anyhow", "clap", @@ -2689,7 +2831,7 @@ dependencies = [ [[package]] name = "warzone-protocol" -version = "0.0.12" +version = "0.0.13" dependencies = [ "base64", "bincode", @@ -2700,11 +2842,13 @@ dependencies = [ "ed25519-dalek", "hex", "hkdf", + "k256", "rand", "serde", "serde_json", "sha2", "thiserror 2.0.18", + "tiny-keccak", "uuid", "x25519-dalek", "zeroize", @@ -2712,7 +2856,7 @@ dependencies = [ [[package]] name = "warzone-server" -version = "0.0.12" +version = "0.0.13" dependencies = [ "anyhow", "axum", @@ -2739,7 +2883,7 @@ dependencies = [ [[package]] name = "warzone-wasm" -version = "0.0.12" +version = "0.0.13" dependencies = [ "base64", "bincode", diff --git a/warzone/Cargo.toml b/warzone/Cargo.toml index 1387dd2..e82b144 100644 --- a/warzone/Cargo.toml +++ b/warzone/Cargo.toml @@ -9,7 +9,7 @@ members = [ ] [workspace.package] -version = "0.0.13" +version = "0.0.14" edition = "2021" license = "MIT" rust-version = "1.75" @@ -25,6 +25,10 @@ sha2 = "0.10" argon2 = "0.5" rand = "0.8" +# Ethereum compatibility +k256 = { version = "0.13", features = ["ecdsa", "serde"] } +tiny-keccak = { version = "2", features = ["keccak"] } + # BIP39 bip39 = "2" diff --git a/warzone/crates/warzone-client/src/main.rs b/warzone/crates/warzone-client/src/main.rs index 98bbf5c..c631acb 100644 --- a/warzone/crates/warzone-client/src/main.rs +++ b/warzone/crates/warzone-client/src/main.rs @@ -31,6 +31,8 @@ enum Commands { #[arg(short, long, default_value = "http://localhost:7700")] server: String, }, + /// Show Ethereum-compatible address derived from your seed + Eth, /// Send an encrypted message Send { /// Recipient fingerprint (e.g. a3f8:c912:...) or @alias @@ -94,6 +96,13 @@ async fn main() -> anyhow::Result<()> { println!("Signing key: {}", hex::encode(pub_id.signing.as_bytes())); println!("Encryption key: {}", hex::encode(pub_id.encryption.as_bytes())); } + Commands::Eth => { + let eth_id = warzone_protocol::ethereum::derive_eth_identity(&seed.0); + let pub_id = identity.public_identity(); + println!("Warzone fingerprint: {}", pub_id.fingerprint); + println!("Ethereum address: {}", eth_id.address.to_checksum()); + println!("Same seed, dual identity."); + } Commands::Register { server } => { cli::init::register_with_server_identity(&server, &identity).await?; } diff --git a/warzone/crates/warzone-protocol/Cargo.toml b/warzone/crates/warzone-protocol/Cargo.toml index 66aa8d8..3509634 100644 --- a/warzone/crates/warzone-protocol/Cargo.toml +++ b/warzone/crates/warzone-protocol/Cargo.toml @@ -21,3 +21,5 @@ base64.workspace = true uuid.workspace = true zeroize.workspace = true chrono.workspace = true +k256.workspace = true +tiny-keccak.workspace = true diff --git a/warzone/crates/warzone-protocol/src/ethereum.rs b/warzone/crates/warzone-protocol/src/ethereum.rs new file mode 100644 index 0000000..67e8055 --- /dev/null +++ b/warzone/crates/warzone-protocol/src/ethereum.rs @@ -0,0 +1,177 @@ +//! Ethereum-compatible identity: secp256k1 keypair + Ethereum address. +//! +//! From the same BIP39 seed, derive: +//! - secp256k1 keypair (Ethereum-compatible signing) +//! - Ethereum address = Keccak-256(uncompressed_pubkey[1..])[-20:] +//! - The Ethereum address can serve as the user's public identity/fingerprint +//! +//! This enables: +//! - MetaMask/Rabby wallet connect (sign challenge) +//! - ENS resolution (@vitalik.eth → 0xd8dA... → Warzone identity) +//! - Hardware wallet support (Ledger/Trezor already support secp256k1) + +use k256::ecdsa::{SigningKey, VerifyingKey, Signature, signature::Signer, signature::Verifier}; +use serde::{Deserialize, Serialize}; +use tiny_keccak::{Hasher, Keccak}; + +use crate::crypto::hkdf_derive; + +/// An Ethereum-compatible identity derived from a Warzone seed. +#[derive(Clone)] +pub struct EthIdentity { + pub signing_key: SigningKey, + pub verifying_key: VerifyingKey, + pub address: EthAddress, +} + +/// An Ethereum address (20 bytes). +#[derive(Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)] +pub struct EthAddress(pub [u8; 20]); + +impl std::fmt::Display for EthAddress { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + write!(f, "0x{}", hex::encode(self.0)) + } +} + +impl std::fmt::Debug for EthAddress { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + write!(f, "EthAddress({})", self) + } +} + +impl EthAddress { + /// Parse from hex string (with or without 0x prefix). + pub fn from_hex(s: &str) -> Result { + let clean = s.trim_start_matches("0x").trim_start_matches("0X"); + let bytes = hex::decode(clean) + .map_err(|_| crate::errors::ProtocolError::InvalidFingerprint)?; + if bytes.len() != 20 { + return Err(crate::errors::ProtocolError::InvalidFingerprint); + } + let mut addr = [0u8; 20]; + addr.copy_from_slice(&bytes); + Ok(EthAddress(addr)) + } + + /// EIP-55 checksum address. + pub fn to_checksum(&self) -> String { + let hex_addr = hex::encode(self.0); + let mut hasher = Keccak::v256(); + hasher.update(hex_addr.as_bytes()); + let mut hash = [0u8; 32]; + hasher.finalize(&mut hash); + + let mut result = String::from("0x"); + for (i, c) in hex_addr.chars().enumerate() { + let nibble = (hash[i / 2] >> (if i % 2 == 0 { 4 } else { 0 })) & 0x0f; + if nibble >= 8 { + result.push(c.to_uppercase().next().unwrap()); + } else { + result.push(c); + } + } + result + } +} + +/// Derive an Ethereum identity from a Warzone seed. +/// Uses HKDF with info="warzone-secp256k1" for domain separation. +pub fn derive_eth_identity(seed: &[u8; 32]) -> EthIdentity { + let derived = hkdf_derive(seed, b"", b"warzone-secp256k1", 32); + let mut key_bytes = [0u8; 32]; + key_bytes.copy_from_slice(&derived); + + let signing_key = SigningKey::from_bytes((&key_bytes).into()) + .expect("valid secp256k1 key"); + let verifying_key = *signing_key.verifying_key(); + + // Ethereum address: Keccak-256 of uncompressed public key (without 0x04 prefix) + let pubkey_uncompressed = verifying_key.to_encoded_point(false); + let pubkey_bytes = &pubkey_uncompressed.as_bytes()[1..]; // skip 0x04 prefix + + let mut hasher = Keccak::v256(); + hasher.update(pubkey_bytes); + let mut hash = [0u8; 32]; + hasher.finalize(&mut hash); + + let mut address = [0u8; 20]; + address.copy_from_slice(&hash[12..]); // last 20 bytes + + EthIdentity { + signing_key, + verifying_key, + address: EthAddress(address), + } +} + +/// Sign a message with the Ethereum identity (produces a secp256k1 ECDSA signature). +pub fn eth_sign(identity: &EthIdentity, message: &[u8]) -> Vec { + let signature: Signature = identity.signing_key.sign(message); + signature.to_bytes().to_vec() +} + +/// Verify a secp256k1 signature. +pub fn eth_verify(verifying_key: &VerifyingKey, message: &[u8], signature: &[u8]) -> bool { + if let Ok(sig) = Signature::from_slice(signature) { + verifying_key.verify(message, &sig).is_ok() + } else { + false + } +} + +/// Recover the Ethereum address from a Warzone fingerprint. +/// This allows mapping: Warzone fingerprint ↔ Ethereum address (from same seed). +pub fn fingerprint_to_eth_address(seed: &[u8; 32]) -> EthAddress { + derive_eth_identity(seed).address +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn derive_deterministic() { + let seed = [42u8; 32]; + let id1 = derive_eth_identity(&seed); + let id2 = derive_eth_identity(&seed); + assert_eq!(id1.address.0, id2.address.0); + } + + #[test] + fn address_format() { + let seed = [42u8; 32]; + let id = derive_eth_identity(&seed); + let addr = id.address.to_string(); + assert!(addr.starts_with("0x")); + assert_eq!(addr.len(), 42); // 0x + 40 hex chars + } + + #[test] + fn checksum_address() { + let seed = [42u8; 32]; + let id = derive_eth_identity(&seed); + let checksum = id.address.to_checksum(); + assert!(checksum.starts_with("0x")); + assert_eq!(checksum.len(), 42); + // Should have mixed case (EIP-55) + assert!(checksum[2..].chars().any(|c| c.is_uppercase())); + } + + #[test] + fn sign_verify() { + let seed = [42u8; 32]; + let id = derive_eth_identity(&seed); + let msg = b"hello ethereum"; + let sig = eth_sign(&id, msg); + assert!(eth_verify(&id.verifying_key, msg, &sig)); + assert!(!eth_verify(&id.verifying_key, b"wrong", &sig)); + } + + #[test] + fn different_seeds_different_addresses() { + let id1 = derive_eth_identity(&[1u8; 32]); + let id2 = derive_eth_identity(&[2u8; 32]); + assert_ne!(id1.address.0, id2.address.0); + } +} diff --git a/warzone/crates/warzone-protocol/src/lib.rs b/warzone/crates/warzone-protocol/src/lib.rs index ade9404..71b8ce2 100644 --- a/warzone/crates/warzone-protocol/src/lib.rs +++ b/warzone/crates/warzone-protocol/src/lib.rs @@ -11,3 +11,4 @@ pub mod session; pub mod store; pub mod history; pub mod sender_keys; +pub mod ethereum;