v0.0.14: Ethereum-compatible identity (secp256k1 + Keccak-256)

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) <noreply@anthropic.com>
This commit is contained in:
Siavash Sameni
2026-03-27 13:30:25 +04:00
parent 86da52acc4
commit 661de47552
6 changed files with 343 additions and 6 deletions

154
warzone/Cargo.lock generated
View File

@@ -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",

View File

@@ -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"

View File

@@ -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?;
}

View File

@@ -21,3 +21,5 @@ base64.workspace = true
uuid.workspace = true
zeroize.workspace = true
chrono.workspace = true
k256.workspace = true
tiny-keccak.workspace = true

View File

@@ -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<Self, crate::errors::ProtocolError> {
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<u8> {
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);
}
}

View File

@@ -11,3 +11,4 @@ pub mod session;
pub mod store;
pub mod history;
pub mod sender_keys;
pub mod ethereum;