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:
@@ -21,3 +21,5 @@ base64.workspace = true
|
||||
uuid.workspace = true
|
||||
zeroize.workspace = true
|
||||
chrono.workspace = true
|
||||
k256.workspace = true
|
||||
tiny-keccak.workspace = true
|
||||
|
||||
177
warzone/crates/warzone-protocol/src/ethereum.rs
Normal file
177
warzone/crates/warzone-protocol/src/ethereum.rs
Normal 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);
|
||||
}
|
||||
}
|
||||
@@ -11,3 +11,4 @@ pub mod session;
|
||||
pub mod store;
|
||||
pub mod history;
|
||||
pub mod sender_keys;
|
||||
pub mod ethereum;
|
||||
|
||||
Reference in New Issue
Block a user