use ed25519_bip32::XPrv;
use hmac::{Hmac, Mac};
use sha2::Sha512;
use zeroize::Zeroize;
type HmacSha512 = Hmac<Sha512>;
#[allow(non_snake_case)]
pub mod PATHS {
pub const IDENTITY: &str = "m/74'/0'/0'/0'";
pub const DEVICE_PREFIX: &str = "m/74'/0'/0'";
pub const SSH_HOST: &str = "m/74'/0'/1'/0'";
pub const ENCRYPTION: &str = "m/74'/2'/0'/0'";
pub const ETHEREUM: &str = "m/44'/60'/0'/0/0";
}
pub fn device_path(index: u32) -> String {
format!("m/74'/0'/0'/{}'", index)
}
pub fn encryption_path_for_version(version: u32) -> Result<String, DerivationError> {
if version < 2 {
return Err(DerivationError::InvalidPath(format!(
"key version {version} has no derivable path (v1 is TS PBKDF2 legacy)"
)));
}
Ok(format!("m/74'/2'/0'/{}'", version - 2))
}
#[derive(Clone, Zeroize)]
#[zeroize(drop)]
pub struct ExtendedPrivKey {
private_key: Vec<u8>,
public_key: Vec<u8>,
chain_code: Vec<u8>,
path: String,
}
impl ExtendedPrivKey {
pub fn private_key(&self) -> &[u8] {
&self.private_key
}
pub fn public_key(&self) -> &[u8] {
&self.public_key
}
pub fn path(&self) -> &str {
&self.path
}
}
pub fn derive_path_from_seed(seed: &[u8], path: &str) -> Result<ExtendedPrivKey, DerivationError> {
let indices = parse_derivation_path(path)?;
let xprv = derive_master_key(seed)?;
let mut current = xprv;
for index in indices {
current = current.derive(ed25519_bip32::DerivationScheme::V2, index);
}
let public_key = current.public();
Ok(ExtendedPrivKey {
private_key: current.extended_secret_key_bytes()[..32].to_vec(),
public_key: public_key.as_ref()[..32].to_vec(),
chain_code: current.chain_code().to_vec(),
path: path.to_string(),
})
}
fn derive_master_key(seed: &[u8]) -> Result<XPrv, DerivationError> {
let mut mac = HmacSha512::new_from_slice(b"ed25519 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 mut priv_bytes = [0u8; 32];
priv_bytes.copy_from_slice(private_key_bytes);
let mut cc_bytes = [0u8; 32];
cc_bytes.copy_from_slice(chain_code_bytes);
Ok(XPrv::from_nonextended_force(&priv_bytes, &cc_bytes))
}
pub fn parse_derivation_path(path: &str) -> Result<Vec<u32>, DerivationError> {
if !path.starts_with('m') {
return Err(DerivationError::InvalidPath(
"path must start with 'm'".to_string(),
));
}
let mut indices = Vec::new();
let parts: Vec<&str> = path.split('/').skip(1).collect();
for part in parts {
let hardened = part.ends_with('\'') || part.ends_with('h');
let index_str = part.trim_end_matches('\'').trim_end_matches('h');
let index: u32 = index_str
.parse()
.map_err(|_| DerivationError::InvalidPath(format!("invalid index: {part}")))?;
if hardened {
indices.push(index + 0x80000000);
} else {
indices.push(index);
}
}
Ok(indices)
}
#[derive(Debug, thiserror::Error)]
pub enum DerivationError {
#[error("invalid derivation path: {0}")]
InvalidPath(String),
#[error("HMAC error: {0}")]
Hmac(String),
#[error("key derivation error: {0}")]
KeyDerivation(String),
#[error("seed is not unlocked")]
Locked,
#[error("secp256k1 error: {0}")]
Secp256k1(String),
#[error("unsupported key type")]
UnsupportedKeyType,
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_parse_derivation_path_hardened() {
let indices = parse_derivation_path("m/74'/0'/0'/0'").unwrap();
assert_eq!(
indices,
vec![0x80000000 + 74, 0x80000000, 0x80000000, 0x80000000]
);
}
#[test]
fn test_parse_derivation_path_mixed() {
let indices = parse_derivation_path("m/44'/60'/0'/0/0").unwrap();
assert_eq!(
indices,
vec![0x80000000 + 44, 0x80000000 + 60, 0x80000000, 0, 0]
);
}
#[test]
fn test_parse_rejects_no_m_prefix() {
let result = parse_derivation_path("74'/0'/0'/0'");
assert!(result.is_err());
}
#[test]
fn test_path_constants() {
assert_eq!(PATHS::IDENTITY, "m/74'/0'/0'/0'");
assert_eq!(PATHS::ENCRYPTION, "m/74'/2'/0'/0'");
assert_eq!(PATHS::SSH_HOST, "m/74'/0'/1'/0'");
assert_eq!(PATHS::ETHEREUM, "m/44'/60'/0'/0/0");
}
#[test]
fn test_device_path() {
assert_eq!(device_path(0), "m/74'/0'/0'/0'");
assert_eq!(device_path(1), "m/74'/0'/0'/1'");
}
#[test]
fn test_encryption_path_for_version_v2() {
assert_eq!(encryption_path_for_version(2).unwrap(), PATHS::ENCRYPTION);
}
#[test]
fn test_encryption_path_for_version_v3() {
assert_eq!(encryption_path_for_version(3).unwrap(), "m/74'/2'/0'/1'");
}
#[test]
fn test_encryption_path_for_version_v4() {
assert_eq!(encryption_path_for_version(4).unwrap(), "m/74'/2'/0'/2'");
}
#[test]
fn test_encryption_path_for_version_rejects_v1() {
assert!(matches!(
encryption_path_for_version(1),
Err(DerivationError::InvalidPath(_))
));
}
#[test]
fn test_encryption_path_for_version_rejects_v0() {
assert!(matches!(
encryption_path_for_version(0),
Err(DerivationError::InvalidPath(_))
));
}
#[test]
fn test_derive_master_key_from_seed() {
let seed = [0xABu8; 64];
let result = derive_master_key(&seed);
assert!(result.is_ok());
}
#[test]
fn test_derive_identity_key_from_random_seed() {
let mnemonic = crate::mnemonic::Mnemonic::generate(24).unwrap();
let seed = mnemonic.to_seed(None);
let key = derive_path_from_seed(seed.as_bytes(), PATHS::IDENTITY);
assert!(key.is_ok());
let key = key.unwrap();
assert_eq!(key.private_key().len(), 32);
assert_eq!(key.public_key().len(), 32);
assert_eq!(key.path(), PATHS::IDENTITY);
}
#[test]
fn test_deterministic_derivation() {
let mnemonic = crate::mnemonic::Mnemonic::generate(24).unwrap();
let seed = mnemonic.to_seed(None);
let key1 = derive_path_from_seed(seed.as_bytes(), PATHS::IDENTITY).unwrap();
let key2 = derive_path_from_seed(seed.as_bytes(), PATHS::IDENTITY).unwrap();
assert_eq!(key1.private_key(), key2.private_key());
assert_eq!(key1.public_key(), key2.public_key());
}
#[test]
fn test_different_paths_different_keys() {
let mnemonic = crate::mnemonic::Mnemonic::generate(24).unwrap();
let seed = mnemonic.to_seed(None);
let identity = derive_path_from_seed(seed.as_bytes(), PATHS::IDENTITY).unwrap();
let ssh = derive_path_from_seed(seed.as_bytes(), PATHS::SSH_HOST).unwrap();
assert_ne!(identity.private_key(), ssh.private_key());
assert_ne!(identity.public_key(), ssh.public_key());
}
}