diff --git a/crates/alknet-secret/src/derivation.rs b/crates/alknet-secret/src/derivation.rs index be60bd7..71c7b55 100644 --- a/crates/alknet-secret/src/derivation.rs +++ b/crates/alknet-secret/src/derivation.rs @@ -156,12 +156,12 @@ fn derive_master_key(seed: &[u8]) -> Result { Ok(XPrv::from_nonextended_force(&priv_bytes, &cc_bytes)) } -/// Parse a derivation path string into hardened child indices. +/// Parse a derivation path string into child indices. /// /// Path format: `m/74'/0'/0'/0'` -/// Each component must be a hardened index (with `'` or `h` suffix). -/// Unhardened indices are allowed for BIP-0032 paths (e.g., Ethereum `m/44'/60'/0'/0/0`). -fn parse_derivation_path(path: &str) -> Result, DerivationError> { +/// Hardened indices have `'` or `h` suffix. Unhardened indices are allowed +/// for BIP-0032 paths (e.g., Ethereum `m/44'/60'/0'/0/0`). +pub fn parse_derivation_path(path: &str) -> Result, DerivationError> { if !path.starts_with('m') { return Err(DerivationError::InvalidPath( "path must start with 'm'".to_string(), @@ -199,6 +199,10 @@ pub enum DerivationError { KeyDerivation(String), #[error("seed is not unlocked")] Locked, + #[error("secp256k1 error: {0}")] + Secp256k1(String), + #[error("unsupported key type")] + UnsupportedKeyType, } #[cfg(test)] diff --git a/crates/alknet-secret/src/ethereum.rs b/crates/alknet-secret/src/ethereum.rs new file mode 100644 index 0000000..83d6cb3 --- /dev/null +++ b/crates/alknet-secret/src/ethereum.rs @@ -0,0 +1,246 @@ +//! BIP-0032 secp256k1 HD key derivation for Ethereum keys. +//! +//! This module implements hierarchical deterministic key derivation following +//! BIP-0032 for secp256k1 curves. It is gated behind the `secp256k1` feature flag. +//! +//! Unlike SLIP-0010 (Ed25519), BIP-0032 supports both hardened and unhardened +//! child derivation and uses HMAC-SHA512 with the key "Bitcoin seed" (not +//! "ed25519 seed"). +//! +//! # Ethereum Path +//! +//! The standard Ethereum derivation path is `m/44'/60'/0'/0/0` (EIP-84). +//! The last two indices (`0/0`) are unhardened, which SLIP-0010 cannot handle. + +use hmac::{Hmac, Mac}; +use secp256k1::{PublicKey, Secp256k1, SecretKey}; +use sha2::Sha512; +use zeroize::Zeroize; + +use crate::derivation::{parse_derivation_path, DerivationError}; + +type HmacSha512 = Hmac; + +const HARDENED_OFFSET: u32 = 0x80000000; + +/// An extended private key for BIP-0032 secp256k1 derivation. +/// +/// Contains the private key, compressed public key (33 bytes), and chain code +/// for further child derivation. +#[derive(Zeroize)] +#[zeroize(drop)] +pub struct Secp256k1ExtendedPrivKey { + /// The secp256k1 private key bytes (32 bytes). + #[zeroize] + private_key: Vec, + /// The compressed public key bytes (33 bytes). + public_key: Vec, + /// The chain code for child derivation (32 bytes). + chain_code: Vec, +} + +impl Secp256k1ExtendedPrivKey { + /// Returns the private key bytes (32 bytes). + pub fn private_key(&self) -> &[u8] { + &self.private_key + } + + /// Returns the compressed public key bytes (33 bytes). + pub fn public_key(&self) -> &[u8] { + &self.public_key + } + + /// Returns the chain code bytes (32 bytes). + pub fn chain_code(&self) -> &[u8] { + &self.chain_code + } +} + +/// Derive the BIP-0032 secp256k1 master key from a seed. +/// +/// Uses HMAC-SHA512 with key "Bitcoin seed" over the seed bytes, +/// following the BIP-0032 specification. +pub fn derive_secp256k1_master_key( + seed: &[u8], +) -> Result { + let mut mac = HmacSha512::new_from_slice(b"Bitcoin seed") + .map_err(|e| DerivationError::Hmac(e.to_string()))?; + mac.update(seed); + let result = mac.finalize().into_bytes(); + + let private_key_bytes = &result[..32]; + let chain_code_bytes = &result[32..]; + + let secp = Secp256k1::new(); + let secret_key = SecretKey::from_slice(private_key_bytes) + .map_err(|e| DerivationError::Secp256k1(e.to_string()))?; + let public_key = PublicKey::from_secret_key(&secp, &secret_key); + + Ok(Secp256k1ExtendedPrivKey { + private_key: secret_key.secret_bytes().to_vec(), + public_key: public_key.serialize().to_vec(), + chain_code: chain_code_bytes.to_vec(), + }) +} + +/// Derive a child extended private key from a parent key at the given index. +/// +/// For hardened indices (>= 0x80000000), uses the parent private key in the HMAC. +/// For unhardened indices (< 0x80000000), uses the parent public key in the HMAC. +fn derive_child( + parent: &Secp256k1ExtendedPrivKey, + index: u32, +) -> Result { + let secp = Secp256k1::new(); + + let mut mac = HmacSha512::new_from_slice(parent.chain_code()) + .map_err(|e| DerivationError::Hmac(e.to_string()))?; + + if index >= HARDENED_OFFSET { + // Hardened child: HMAC-SHA512(Key = parent chain code, Data = 0x00 || parent private key || index) + mac.update(&[0x00]); + mac.update(parent.private_key()); + } else { + // Unhardened child: HMAC-SHA512(Key = parent chain code, Data = parent public key || index) + mac.update(parent.public_key()); + } + mac.update(&index.to_be_bytes()); + + let result = mac.finalize().into_bytes(); + let child_key_bytes = &result[..32]; + let child_chain_code = &result[32..]; + + // Add parent private key to child key bytes (mod n, the curve order) + let parent_secret = SecretKey::from_slice(parent.private_key()) + .map_err(|e| DerivationError::Secp256k1(e.to_string()))?; + let child_key_raw = SecretKey::from_slice(child_key_bytes) + .map_err(|e| DerivationError::Secp256k1(e.to_string()))?; + + // Tweak: child_key = (parent_key + tweak) mod n + let child_secret = parent_secret + .add_tweak(&child_key_raw.into()) + .map_err(|e| DerivationError::Secp256k1(e.to_string()))?; + + let child_public = PublicKey::from_secret_key(&secp, &child_secret); + + Ok(Secp256k1ExtendedPrivKey { + private_key: child_secret.secret_bytes().to_vec(), + public_key: child_public.serialize().to_vec(), + chain_code: child_chain_code.to_vec(), + }) +} + +/// Derive a secp256k1 extended private key from a seed and derivation path. +/// +/// This is the primary entry point for BIP-0032 secp256k1 derivation. +/// Supports both hardened and unhardened indices. +/// +/// # Example +/// +/// ```ignore +/// use alknet_secret::ethereum::derive_secp256k1_path; +/// use alknet_secret::derivation::PATHS; +/// +/// let key = derive_secp256k1_path(seed, PATHS::ETHEREUM).unwrap(); +/// assert_eq!(key.private_key().len(), 32); +/// assert_eq!(key.public_key().len(), 33); // compressed +/// ``` +pub fn derive_secp256k1_path( + seed: &[u8], + path: &str, +) -> Result { + let indices = parse_derivation_path(path)?; + let master = derive_secp256k1_master_key(seed)?; + + let mut current = master; + for index in indices { + current = derive_child(¤t, index)?; + } + + Ok(current) +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::PATHS; + + #[test] + fn test_bip32_master_key_vector() { + // BIP-0032 test vector 1: seed "000102030405060708090a0b0c0d0e0f" + let seed = hex::decode("000102030405060708090a0b0c0d0e0f").unwrap(); + let master = derive_secp256k1_master_key(&seed).unwrap(); + + // Expected master private key from BIP-0032 test vector 1 + let expected_priv = + hex::decode("e8f32e723decf4051aefac8e2c93c9c5b214313817cdb01a1494b917c8436b35") + .unwrap(); + assert_eq!(master.private_key(), expected_priv.as_slice()); + + // Expected master public key (compressed) from BIP-0032 test vector 1 + let expected_pub = + hex::decode("0339a36013301597daef41fbe593a02cc513d0b55527ec2df1050e2e8ff49c85c2") + .unwrap(); + assert_eq!(master.public_key(), expected_pub.as_slice()); + + // Expected chain code from BIP-0032 test vector 1 + let expected_cc = + hex::decode("873dff81c02f525623fd1fe5167eac3a55a049de3d314bb42ee227ffed37d508") + .unwrap(); + assert_eq!(master.chain_code(), expected_cc.as_slice()); + } + + #[test] + fn test_bip32_derive_m_44h_60h_0h_0_0() { + let seed = hex::decode("000102030405060708090a0b0c0d0e0f").unwrap(); + let key = derive_secp256k1_path(&seed, "m/44'/60'/0'/0/0").unwrap(); + assert_eq!(key.private_key().len(), 32); + assert_eq!(key.public_key().len(), 33); + } + + #[test] + fn test_ethereum_keypair_is_valid() { + let mnemonic = crate::mnemonic::Mnemonic::generate(24).unwrap(); + let seed = mnemonic.to_seed(None); + let key = derive_secp256k1_path(seed.as_bytes(), PATHS::ETHEREUM).unwrap(); + + let secp = Secp256k1::new(); + let secret_key = SecretKey::from_slice(key.private_key()).unwrap(); + let public_key = PublicKey::from_secret_key(&secp, &secret_key); + assert_eq!(key.public_key(), public_key.serialize().as_slice()); + } + + #[test] + fn test_ethereum_differs_from_ed25519() { + let mnemonic = crate::mnemonic::Mnemonic::generate(24).unwrap(); + let seed = mnemonic.to_seed(None); + + let eth_key = derive_secp256k1_path(seed.as_bytes(), PATHS::ETHEREUM).unwrap(); + let ed_key = + crate::derivation::derive_path_from_seed(seed.as_bytes(), PATHS::ETHEREUM).unwrap(); + + assert_ne!(eth_key.private_key(), ed_key.private_key()); + } + + #[test] + fn test_deterministic_derivation() { + let mnemonic = crate::mnemonic::Mnemonic::generate(24).unwrap(); + let seed = mnemonic.to_seed(None); + + let key1 = derive_secp256k1_path(seed.as_bytes(), PATHS::ETHEREUM).unwrap(); + let key2 = derive_secp256k1_path(seed.as_bytes(), PATHS::ETHEREUM).unwrap(); + + assert_eq!(key1.private_key(), key2.private_key()); + assert_eq!(key1.public_key(), key2.public_key()); + } + + #[test] + fn test_compressed_public_key_is_33_bytes() { + let mnemonic = crate::mnemonic::Mnemonic::generate(24).unwrap(); + let seed = mnemonic.to_seed(None); + let key = derive_secp256k1_path(seed.as_bytes(), PATHS::ETHEREUM).unwrap(); + assert_eq!(key.public_key().len(), 33); + // Compressed public key starts with 0x02 or 0x03 + assert!(key.public_key()[0] == 0x02 || key.public_key()[0] == 0x03); + } +} diff --git a/crates/alknet-secret/src/lib.rs b/crates/alknet-secret/src/lib.rs index f3e5e10..c23ad96 100644 --- a/crates/alknet-secret/src/lib.rs +++ b/crates/alknet-secret/src/lib.rs @@ -26,6 +26,7 @@ //! - [`encryption`] — AES-256-GCM encrypt/decrypt and `EncryptedData` type //! - [`protocol`] — `SecretProtocol` irpc service enum, `DerivedKey`, `KeyType` //! - [`service`] — `SecretService` implementation with Unlock/Lock lifecycle +//! - [`ethereum`] — BIP-0032 secp256k1 HD key derivation (behind `secp256k1` feature) pub mod derivation; pub mod encryption; @@ -33,9 +34,15 @@ pub mod mnemonic; pub mod protocol; pub mod service; +#[cfg(feature = "secp256k1")] +pub mod ethereum; + // Re-export primary public API -pub use derivation::{ExtendedPrivKey, PATHS}; +pub use derivation::{DerivationError, ExtendedPrivKey, PATHS}; pub use encryption::{EncryptedData, EncryptionError}; pub use mnemonic::{Language, Mnemonic, Seed}; pub use protocol::{DerivedKey, KeyType, SecretMessage, SecretProtocol}; pub use service::{SecretService, SecretServiceError, SecretServiceHandle}; + +#[cfg(feature = "secp256k1")] +pub use ethereum::Secp256k1ExtendedPrivKey; diff --git a/crates/alknet-secret/src/service.rs b/crates/alknet-secret/src/service.rs index 1e26971..8f74d34 100644 --- a/crates/alknet-secret/src/service.rs +++ b/crates/alknet-secret/src/service.rs @@ -73,6 +73,8 @@ pub enum SecretServiceError { Encryption(String), #[error("invalid path: {0}")] InvalidPath(String), + #[error("unsupported key type")] + UnsupportedKeyType, } impl From for SecretServiceError { @@ -200,22 +202,35 @@ impl SecretServiceHandle { } /// Derive a secp256k1 (Ethereum) keypair at the given path. + /// + /// Uses BIP-0032 derivation (HMAC-SHA512 with "Bitcoin seed") when the + /// `secp256k1` feature is enabled. Returns `UnsupportedKeyType` when the + /// feature is disabled. pub fn derive_ethereum_key(&self, path: &str) -> Result { - let inner = self.inner.read().unwrap(); - if !inner.unlocked { - return Err(SecretServiceError::ServiceLocked); - } - let seed = inner - .seed - .as_ref() - .ok_or(SecretServiceError::ServiceLocked)?; + #[cfg(feature = "secp256k1")] + { + let inner = self.inner.read().unwrap(); + if !inner.unlocked { + return Err(SecretServiceError::ServiceLocked); + } + let seed = inner + .seed + .as_ref() + .ok_or(SecretServiceError::ServiceLocked)?; - let key = derivation::derive_path_from_seed(seed.as_bytes(), path)?; - Ok(DerivedKey { - key_type: KeyType::Secp256k1, - private_key: key.private_key().to_vec(), - public_key: key.public_key().to_vec(), - }) + let key = crate::ethereum::derive_secp256k1_path(seed.as_bytes(), path)?; + Ok(DerivedKey { + key_type: KeyType::Secp256k1, + private_key: key.private_key().to_vec(), + public_key: key.public_key().to_vec(), + }) + } + + #[cfg(not(feature = "secp256k1"))] + { + let _ = path; + Err(SecretServiceError::UnsupportedKeyType) + } } /// Encrypt plaintext using the derived encryption key. @@ -399,4 +414,41 @@ mod tests { service.lock(); assert!(service.decrypt(&encrypted).is_err()); } + + #[cfg(feature = "secp256k1")] + #[test] + fn test_derive_ethereum_key_bip32() { + let service = SecretServiceHandle::new(); + service.unlock_new(24).unwrap(); + + let key = service.derive_ethereum_key(PATHS::ETHEREUM).unwrap(); + assert_eq!(key.key_type, KeyType::Secp256k1); + assert_eq!(key.private_key.len(), 32); + assert_eq!(key.public_key.len(), 33); + } + + #[cfg(feature = "secp256k1")] + #[test] + fn test_ethereum_key_differs_from_ed25519() { + let service = SecretServiceHandle::new(); + service.unlock_new(24).unwrap(); + + let eth_key = service.derive_ethereum_key(PATHS::ETHEREUM).unwrap(); + let ed_key = service.derive_ed25519(PATHS::IDENTITY).unwrap(); + + assert_ne!(eth_key.private_key, ed_key.private_key); + } + + #[cfg(not(feature = "secp256k1"))] + #[test] + fn test_derive_ethereum_key_unsupported_without_feature() { + let service = SecretServiceHandle::new(); + service.unlock_new(24).unwrap(); + + let result = service.derive_ethereum_key(PATHS::ETHEREUM); + assert!(matches!( + result, + Err(SecretServiceError::UnsupportedKeyType) + )); + } }