use crate::error::{AptosError, AptosResult};
const HARDENED_OFFSET: u32 = 0x8000_0000;
const BIP44_PURPOSE: u32 = 44;
const APTOS_COIN_TYPE: u32 = 637;
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub struct PathComponent {
index: u32,
hardened: bool,
}
impl PathComponent {
pub fn try_new(index: u32, hardened: bool) -> AptosResult<Self> {
if index & HARDENED_OFFSET != 0 {
return Err(AptosError::KeyDerivation(format!(
"derivation index {index} exceeds 2^31 - 1; the hardened bit \
must come from the `hardened` flag, not the raw value"
)));
}
Ok(Self { index, hardened })
}
#[must_use]
pub fn index(self) -> u32 {
self.index
}
#[must_use]
pub fn hardened(self) -> bool {
self.hardened
}
#[must_use]
pub fn encoded(self) -> u32 {
if self.hardened {
self.index | HARDENED_OFFSET
} else {
self.index
}
}
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct DerivationPath {
components: Vec<PathComponent>,
}
impl DerivationPath {
#[must_use]
pub fn components(&self) -> &[PathComponent] {
&self.components
}
#[must_use]
pub fn is_fully_hardened(&self) -> bool {
self.components.iter().all(|c| c.hardened())
}
pub fn aptos_ed25519(address_index: u32) -> AptosResult<Self> {
let h = |i| PathComponent::try_new(i, true);
Ok(Self {
components: vec![
h(BIP44_PURPOSE)?,
h(APTOS_COIN_TYPE)?,
h(0)?,
h(0)?,
h(address_index)?,
],
})
}
pub fn aptos_secp256k1(address_index: u32) -> AptosResult<Self> {
let h = |i| PathComponent::try_new(i, true);
let u = |i| PathComponent::try_new(i, false);
Ok(Self {
components: vec![
h(BIP44_PURPOSE)?,
h(APTOS_COIN_TYPE)?,
h(0)?,
u(0)?,
u(address_index)?,
],
})
}
#[allow(clippy::should_implement_trait)] pub fn from_str(path: &str) -> AptosResult<Self> {
<Self as std::str::FromStr>::from_str(path)
}
}
impl std::str::FromStr for DerivationPath {
type Err = AptosError;
fn from_str(path: &str) -> AptosResult<Self> {
let mut parts = path.split('/');
let head = parts
.next()
.ok_or_else(|| AptosError::KeyDerivation("empty derivation path".to_string()))?;
if !matches!(head, "m" | "M") {
return Err(AptosError::KeyDerivation(format!(
"derivation path must start with 'm/', got: {path}"
)));
}
let mut components = Vec::new();
for raw in parts {
if raw.is_empty() {
return Err(AptosError::KeyDerivation(format!(
"empty component in derivation path: {path}"
)));
}
let (digits, hardened) = if let Some(rest) = raw.strip_suffix('\'') {
(rest, true)
} else if let Some(rest) = raw.strip_suffix('h') {
(rest, true)
} else {
(raw, false)
};
let index: u32 = digits.parse().map_err(|_| {
AptosError::KeyDerivation(format!(
"invalid numeric component '{raw}' in derivation path: {path}"
))
})?;
components.push(
PathComponent::try_new(index, hardened).map_err(|e| match e {
AptosError::KeyDerivation(msg) => {
AptosError::KeyDerivation(format!("{msg} in path: {path}"))
}
other => other,
})?,
);
}
if components.is_empty() {
return Err(AptosError::KeyDerivation(format!(
"derivation path has no components: {path}"
)));
}
Ok(Self { components })
}
}
impl std::fmt::Display for DerivationPath {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
f.write_str("m")?;
for c in &self.components {
if c.hardened() {
write!(f, "/{}'", c.index())?;
} else {
write!(f, "/{}", c.index())?;
}
}
Ok(())
}
}
#[derive(Clone)]
pub struct Mnemonic {
phrase: String,
}
impl Mnemonic {
pub fn generate(word_count: usize) -> AptosResult<Self> {
let entropy_bytes = match word_count {
12 => 16, 15 => 20, 18 => 24, 21 => 28, 24 => 32, _ => {
return Err(AptosError::InvalidMnemonic(format!(
"invalid word count: {word_count}, must be 12, 15, 18, 21, or 24"
)));
}
};
let mut entropy = vec![0u8; entropy_bytes];
rand::RngCore::fill_bytes(&mut rand::rngs::OsRng, &mut entropy);
let mnemonic = bip39::Mnemonic::from_entropy(&entropy)
.map_err(|e| AptosError::InvalidMnemonic(e.to_string()));
zeroize::Zeroize::zeroize(&mut entropy);
let mnemonic = mnemonic?;
Ok(Self {
phrase: mnemonic.to_string(),
})
}
pub fn from_phrase(phrase: &str) -> AptosResult<Self> {
let _mnemonic = bip39::Mnemonic::parse_normalized(phrase)
.map_err(|e| AptosError::InvalidMnemonic(e.to_string()))?;
Ok(Self {
phrase: phrase.to_string(),
})
}
pub fn phrase(&self) -> &str {
&self.phrase
}
pub fn to_seed(&self) -> AptosResult<[u8; 64]> {
self.to_seed_with_passphrase("")
}
pub fn to_seed_with_passphrase(&self, passphrase: &str) -> AptosResult<[u8; 64]> {
let mnemonic = bip39::Mnemonic::parse_normalized(&self.phrase).map_err(|e| {
AptosError::InvalidMnemonic(format!("internal error: mnemonic re-parse failed: {e}"))
})?;
Ok(mnemonic.to_seed(passphrase))
}
#[cfg(feature = "ed25519")]
pub fn derive_ed25519_key(&self, index: u32) -> AptosResult<crate::crypto::Ed25519PrivateKey> {
self.derive_ed25519_key_at_path(&DerivationPath::aptos_ed25519(index)?)
}
#[cfg(feature = "ed25519")]
pub fn derive_ed25519_key_at_path(
&self,
path: &DerivationPath,
) -> AptosResult<crate::crypto::Ed25519PrivateKey> {
if !path.is_fully_hardened() {
return Err(AptosError::KeyDerivation(format!(
"Ed25519 derivation requires every path component to be hardened; got {path}"
)));
}
let mut seed = self.to_seed()?;
let result = derive_ed25519_at_path(&seed, path);
zeroize::Zeroize::zeroize(&mut seed);
let mut key = result?;
let private_key = crate::crypto::Ed25519PrivateKey::from_bytes(&key);
zeroize::Zeroize::zeroize(&mut key);
private_key
}
#[cfg(feature = "secp256k1")]
pub fn derive_secp256k1_key(
&self,
index: u32,
) -> AptosResult<crate::crypto::Secp256k1PrivateKey> {
self.derive_secp256k1_key_at_path(&DerivationPath::aptos_secp256k1(index)?)
}
#[cfg(feature = "secp256k1")]
pub fn derive_secp256k1_key_at_path(
&self,
path: &DerivationPath,
) -> AptosResult<crate::crypto::Secp256k1PrivateKey> {
let mut seed = self.to_seed()?;
let result = derive_secp256k1_at_path(&seed, path);
zeroize::Zeroize::zeroize(&mut seed);
let mut bytes = result?;
let key = crate::crypto::Secp256k1PrivateKey::from_bytes(&bytes);
zeroize::Zeroize::zeroize(&mut bytes);
key
}
}
#[cfg(feature = "ed25519")]
fn derive_ed25519_at_path(seed: &[u8], path: &DerivationPath) -> AptosResult<[u8; 32]> {
use hmac::{Hmac, Mac};
use sha2::Sha512;
type HmacSha512 = Hmac<Sha512>;
let mut mac = HmacSha512::new_from_slice(b"ed25519 seed")
.map_err(|e| AptosError::KeyDerivation(e.to_string()))?;
mac.update(seed);
let result = mac.finalize().into_bytes();
let mut key = [0u8; 32];
let mut chain_code = [0u8; 32];
key.copy_from_slice(&result[..32]);
chain_code.copy_from_slice(&result[32..]);
for component in path.components() {
let mut data = vec![0u8];
data.extend_from_slice(&key);
data.extend_from_slice(&component.encoded().to_be_bytes());
let mut mac = HmacSha512::new_from_slice(&chain_code)
.map_err(|e| AptosError::KeyDerivation(e.to_string()))?;
mac.update(&data);
let result = mac.finalize().into_bytes();
key.copy_from_slice(&result[..32]);
chain_code.copy_from_slice(&result[32..]);
zeroize::Zeroize::zeroize(&mut data);
}
zeroize::Zeroize::zeroize(&mut chain_code);
Ok(key)
}
#[cfg(feature = "secp256k1")]
fn derive_secp256k1_at_path(seed: &[u8], path: &DerivationPath) -> AptosResult<[u8; 32]> {
use hmac::{Hmac, Mac};
use k256::elliptic_curve::sec1::ToEncodedPoint;
use k256::{NonZeroScalar, ProjectivePoint, PublicKey, Scalar, SecretKey};
use sha2::Sha512;
type HmacSha512 = Hmac<Sha512>;
let mut mac = HmacSha512::new_from_slice(b"Bitcoin seed")
.map_err(|e| AptosError::KeyDerivation(e.to_string()))?;
mac.update(seed);
let result = mac.finalize().into_bytes();
let mut key_bytes = [0u8; 32];
let mut chain_code = [0u8; 32];
key_bytes.copy_from_slice(&result[..32]);
chain_code.copy_from_slice(&result[32..]);
let mut parent = SecretKey::from_slice(&key_bytes)
.map_err(|e| AptosError::KeyDerivation(format!("invalid master scalar: {e}")))?;
for component in path.components() {
let encoded = component.encoded();
let (mut data, hardened) = if component.hardened() {
let mut buf = Vec::with_capacity(1 + 32 + 4);
buf.push(0u8);
buf.extend_from_slice(&parent.to_bytes());
buf.extend_from_slice(&encoded.to_be_bytes());
(buf, true)
} else {
let pub_key: PublicKey = parent.public_key();
let encoded_point = pub_key.to_encoded_point(true);
let mut buf = Vec::with_capacity(33 + 4);
buf.extend_from_slice(encoded_point.as_bytes());
buf.extend_from_slice(&encoded.to_be_bytes());
(buf, false)
};
let mut mac = HmacSha512::new_from_slice(&chain_code)
.map_err(|e| AptosError::KeyDerivation(e.to_string()))?;
mac.update(&data);
let result = mac.finalize().into_bytes();
if hardened {
zeroize::Zeroize::zeroize(&mut data);
}
let il_scalar = NonZeroScalar::try_from(&result[..32]).map_err(|e| {
AptosError::KeyDerivation(format!(
"BIP-32 derivation produced invalid intermediate scalar: {e}"
))
})?;
let parent_scalar: Scalar = *parent.to_nonzero_scalar().as_ref();
let child_scalar = *il_scalar.as_ref() + parent_scalar;
let child_nz =
Option::<NonZeroScalar>::from(NonZeroScalar::new(child_scalar)).ok_or_else(|| {
AptosError::KeyDerivation(
"BIP-32 derivation produced zero child scalar".to_string(),
)
})?;
parent = SecretKey::from(child_nz);
let _ = ProjectivePoint::GENERATOR;
chain_code.copy_from_slice(&result[32..]);
}
let mut out = [0u8; 32];
out.copy_from_slice(&parent.to_bytes());
zeroize::Zeroize::zeroize(&mut key_bytes);
zeroize::Zeroize::zeroize(&mut chain_code);
Ok(out)
}
impl std::fmt::Debug for Mnemonic {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
write!(f, "Mnemonic([REDACTED])")
}
}
#[cfg(test)]
mod tests {
use super::*;
const TEST_PHRASE: &str = "abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon about";
#[test]
fn test_generate_mnemonic() {
let mnemonic = Mnemonic::generate(12).unwrap();
assert_eq!(mnemonic.phrase().split_whitespace().count(), 12);
let mnemonic = Mnemonic::generate(24).unwrap();
assert_eq!(mnemonic.phrase().split_whitespace().count(), 24);
}
#[test]
fn test_invalid_word_count() {
assert!(Mnemonic::generate(13).is_err());
}
#[test]
fn test_parse_mnemonic() {
let mnemonic = Mnemonic::from_phrase(TEST_PHRASE).unwrap();
assert_eq!(mnemonic.phrase(), TEST_PHRASE);
}
#[test]
fn test_invalid_mnemonic() {
assert!(Mnemonic::from_phrase("invalid mnemonic phrase").is_err());
}
#[test]
fn test_path_from_str_hardened() {
let path = DerivationPath::from_str("m/44'/637'/0'/0'/0'").unwrap();
assert!(path.is_fully_hardened());
assert_eq!(path.components().len(), 5);
assert_eq!(path.to_string(), "m/44'/637'/0'/0'/0'");
}
#[test]
fn test_path_from_str_mixed() {
let path = DerivationPath::from_str("m/44'/637'/0'/0/0").unwrap();
assert!(!path.is_fully_hardened());
let comps = path.components();
assert!(comps[0].hardened && comps[1].hardened && comps[2].hardened);
assert!(!comps[3].hardened && !comps[4].hardened);
}
#[test]
fn test_path_from_str_h_marker() {
let path = DerivationPath::from_str("m/44h/637h/0h").unwrap();
assert!(path.is_fully_hardened());
}
#[test]
fn test_path_from_str_rejects_bad_prefix() {
assert!(DerivationPath::from_str("44'/637'").is_err());
assert!(DerivationPath::from_str("").is_err());
assert!(DerivationPath::from_str("m").is_err());
assert!(DerivationPath::from_str("m/44'/abc/0").is_err());
}
#[test]
fn test_path_from_str_rejects_oversize_index() {
assert!(DerivationPath::from_str("m/2147483648").is_err());
}
#[test]
fn test_aptos_default_paths() {
assert_eq!(
DerivationPath::aptos_ed25519(0).unwrap().to_string(),
"m/44'/637'/0'/0'/0'"
);
assert_eq!(
DerivationPath::aptos_secp256k1(0).unwrap().to_string(),
"m/44'/637'/0'/0/0"
);
assert_eq!(
DerivationPath::aptos_ed25519(5).unwrap().to_string(),
"m/44'/637'/0'/0'/5'",
"address_index belongs in the 5th component",
);
assert_eq!(
DerivationPath::aptos_secp256k1(5).unwrap().to_string(),
"m/44'/637'/0'/0/5",
"address_index belongs in the 5th component (non-hardened)",
);
}
#[test]
fn test_aptos_default_paths_reject_oversize_index() {
assert!(DerivationPath::aptos_ed25519(0x8000_0000).is_err());
assert!(DerivationPath::aptos_secp256k1(0x8000_0000).is_err());
}
#[test]
#[cfg(feature = "ed25519")]
fn test_derive_ed25519_key() {
let mnemonic = Mnemonic::from_phrase(TEST_PHRASE).unwrap();
let key1 = mnemonic.derive_ed25519_key(0).unwrap();
let key2 = mnemonic.derive_ed25519_key(0).unwrap();
assert_eq!(key1.to_bytes(), key2.to_bytes());
let key3 = mnemonic.derive_ed25519_key(1).unwrap();
assert_ne!(key1.to_bytes(), key3.to_bytes());
}
#[test]
#[cfg(feature = "ed25519")]
fn test_derive_ed25519_at_path_rejects_unhardened() {
let mnemonic = Mnemonic::from_phrase(TEST_PHRASE).unwrap();
let path = DerivationPath::from_str("m/44'/637'/0'/0/0").unwrap();
let err = mnemonic.derive_ed25519_key_at_path(&path).unwrap_err();
assert!(matches!(err, AptosError::KeyDerivation(_)));
}
#[test]
#[cfg(feature = "ed25519")]
fn test_derive_ed25519_default_matches_path() {
let mnemonic = Mnemonic::from_phrase(TEST_PHRASE).unwrap();
let via_index = mnemonic.derive_ed25519_key(3).unwrap();
let via_path = mnemonic
.derive_ed25519_key_at_path(&DerivationPath::aptos_ed25519(3).unwrap())
.unwrap();
assert_eq!(via_index.to_bytes(), via_path.to_bytes());
}
#[test]
#[cfg(feature = "secp256k1")]
fn test_derive_secp256k1_deterministic() {
let mnemonic = Mnemonic::from_phrase(TEST_PHRASE).unwrap();
let key1 = mnemonic.derive_secp256k1_key(0).unwrap();
let key2 = mnemonic.derive_secp256k1_key(0).unwrap();
assert_eq!(key1.to_bytes(), key2.to_bytes());
let key3 = mnemonic.derive_secp256k1_key(1).unwrap();
assert_ne!(key1.to_bytes(), key3.to_bytes());
}
#[test]
#[cfg(feature = "secp256k1")]
fn test_derive_secp256k1_pinned_aptos_vector() {
let mnemonic = Mnemonic::from_phrase(TEST_PHRASE).unwrap();
let key = mnemonic.derive_secp256k1_key(0).unwrap();
assert_eq!(
const_hex::encode(key.to_bytes()),
"4613c3acaffc152273c102a6b27f6f4209e1d54cac18ad0ac96b5892b7d7bf91",
);
}
#[test]
#[cfg(feature = "secp256k1")]
fn test_derive_secp256k1_bitcoin_reference_vector() {
let mnemonic = Mnemonic::from_phrase(TEST_PHRASE).unwrap();
let path = DerivationPath::from_str("m/44'/0'/0'/0/0").unwrap();
let key = mnemonic.derive_secp256k1_key_at_path(&path).unwrap();
assert_eq!(
const_hex::encode(key.to_bytes()),
"e284129cc0922579a535bbf4d1a3b25773090d28c909bc0fed73b5e0222cc372",
);
}
#[test]
fn test_path_component_encoded_sets_hardened_bit() {
let hardened = PathComponent::try_new(44, true).unwrap();
let unhardened = PathComponent::try_new(44, false).unwrap();
assert_eq!(hardened.encoded(), 0x8000_002C);
assert_eq!(unhardened.encoded(), 44);
assert_eq!(hardened.index(), 44);
assert!(hardened.hardened());
assert!(!unhardened.hardened());
}
#[test]
fn test_path_component_rejects_oversize_index() {
assert!(PathComponent::try_new(0x8000_0000, false).is_err());
assert!(PathComponent::try_new(0x8000_0000, true).is_err());
assert!(PathComponent::try_new(0xFFFF_FFFF, false).is_err());
assert!(PathComponent::try_new(0x7FFF_FFFF, true).is_ok());
}
#[test]
fn test_path_display_roundtrip() {
for s in ["m/44'/637'/0'/0/0", "m/44'/637'/3'/0'/0'", "m/0"] {
let path = DerivationPath::from_str(s).unwrap();
assert_eq!(path.to_string(), s, "roundtrip drifted for {s}");
}
}
#[test]
fn test_path_from_str_via_parse_trait() {
use std::str::FromStr;
let via_inherent = DerivationPath::from_str("m/44'/637'/0'/0/0").unwrap();
let via_trait: DerivationPath = "m/44'/637'/0'/0/0".parse().unwrap();
let via_fromstr = <DerivationPath as FromStr>::from_str("m/44'/637'/0'/0/0").unwrap();
assert_eq!(via_inherent, via_trait);
assert_eq!(via_inherent, via_fromstr);
}
#[test]
fn test_path_from_str_rejects_empty_component() {
assert!(DerivationPath::from_str("m//44'").is_err());
assert!(DerivationPath::from_str("m/44'/").is_err());
}
#[test]
#[cfg(feature = "ed25519")]
fn test_passphrase_changes_derived_key() {
let mnemonic = Mnemonic::from_phrase(TEST_PHRASE).unwrap();
let seed_default = mnemonic.to_seed().unwrap();
let seed_passphrase = mnemonic.to_seed_with_passphrase("hunter2").unwrap();
assert_ne!(seed_default, seed_passphrase);
let path = DerivationPath::aptos_ed25519(0).unwrap();
let key_default = derive_ed25519_at_path(&seed_default, &path).unwrap();
let key_passphrase = derive_ed25519_at_path(&seed_passphrase, &path).unwrap();
assert_ne!(
key_default, key_passphrase,
"BIP-39 passphrase must produce a distinct derived key"
);
}
#[test]
#[cfg(feature = "secp256k1")]
fn test_derive_secp256k1_different_paths_produce_different_keys() {
let m = Mnemonic::from_phrase(TEST_PHRASE).unwrap();
let k0 = m.derive_secp256k1_key(0).unwrap();
let k1 = m.derive_secp256k1_key(1).unwrap();
let k_bitcoin = m
.derive_secp256k1_key_at_path(&DerivationPath::from_str("m/44'/0'/0'/0/0").unwrap())
.unwrap();
assert_ne!(k0.to_bytes(), k1.to_bytes());
assert_ne!(k0.to_bytes(), k_bitcoin.to_bytes());
assert_ne!(k1.to_bytes(), k_bitcoin.to_bytes());
}
#[test]
#[cfg(feature = "secp256k1")]
fn test_derive_secp256k1_custom_path() {
let mnemonic = Mnemonic::from_phrase(TEST_PHRASE).unwrap();
let path = DerivationPath::from_str("m/44'/637'/0'/0/0").unwrap();
let via_path = mnemonic.derive_secp256k1_key_at_path(&path).unwrap();
let via_index = mnemonic.derive_secp256k1_key(0).unwrap();
assert_eq!(via_path.to_bytes(), via_index.to_bytes());
}
}