1use crate::error::{AptosError, AptosResult};
24
25const HARDENED_OFFSET: u32 = 0x8000_0000;
27const BIP44_PURPOSE: u32 = 44;
29const APTOS_COIN_TYPE: u32 = 637;
31
32#[derive(Debug, Clone, Copy, PartialEq, Eq)]
42pub struct PathComponent {
43 index: u32,
45 hardened: bool,
48}
49
50impl PathComponent {
51 pub fn try_new(index: u32, hardened: bool) -> AptosResult<Self> {
60 if index & HARDENED_OFFSET != 0 {
61 return Err(AptosError::KeyDerivation(format!(
62 "derivation index {index} exceeds 2^31 - 1; the hardened bit \
63 must come from the `hardened` flag, not the raw value"
64 )));
65 }
66 Ok(Self { index, hardened })
67 }
68
69 #[must_use]
71 pub fn index(self) -> u32 {
72 self.index
73 }
74
75 #[must_use]
78 pub fn hardened(self) -> bool {
79 self.hardened
80 }
81
82 #[must_use]
85 pub fn encoded(self) -> u32 {
86 if self.hardened {
87 self.index | HARDENED_OFFSET
88 } else {
89 self.index
90 }
91 }
92}
93
94#[derive(Debug, Clone, PartialEq, Eq)]
100pub struct DerivationPath {
101 components: Vec<PathComponent>,
102}
103
104impl DerivationPath {
105 #[must_use]
107 pub fn components(&self) -> &[PathComponent] {
108 &self.components
109 }
110
111 #[must_use]
115 pub fn is_fully_hardened(&self) -> bool {
116 self.components.iter().all(|c| c.hardened())
117 }
118
119 pub fn aptos_ed25519(address_index: u32) -> AptosResult<Self> {
137 let h = |i| PathComponent::try_new(i, true);
138 Ok(Self {
139 components: vec![
140 h(BIP44_PURPOSE)?,
141 h(APTOS_COIN_TYPE)?,
142 h(0)?,
143 h(0)?,
144 h(address_index)?,
145 ],
146 })
147 }
148
149 pub fn aptos_secp256k1(address_index: u32) -> AptosResult<Self> {
162 let h = |i| PathComponent::try_new(i, true);
163 let u = |i| PathComponent::try_new(i, false);
164 Ok(Self {
165 components: vec![
166 h(BIP44_PURPOSE)?,
167 h(APTOS_COIN_TYPE)?,
168 h(0)?,
169 u(0)?,
170 u(address_index)?,
171 ],
172 })
173 }
174
175 #[allow(clippy::should_implement_trait)] pub fn from_str(path: &str) -> AptosResult<Self> {
190 <Self as std::str::FromStr>::from_str(path)
191 }
192}
193
194impl std::str::FromStr for DerivationPath {
195 type Err = AptosError;
196
197 fn from_str(path: &str) -> AptosResult<Self> {
198 let mut parts = path.split('/');
199 let head = parts
200 .next()
201 .ok_or_else(|| AptosError::KeyDerivation("empty derivation path".to_string()))?;
202 if !matches!(head, "m" | "M") {
203 return Err(AptosError::KeyDerivation(format!(
204 "derivation path must start with 'm/', got: {path}"
205 )));
206 }
207
208 let mut components = Vec::new();
209 for raw in parts {
210 if raw.is_empty() {
211 return Err(AptosError::KeyDerivation(format!(
212 "empty component in derivation path: {path}"
213 )));
214 }
215 let (digits, hardened) = if let Some(rest) = raw.strip_suffix('\'') {
216 (rest, true)
217 } else if let Some(rest) = raw.strip_suffix('h') {
218 (rest, true)
219 } else {
220 (raw, false)
221 };
222
223 let index: u32 = digits.parse().map_err(|_| {
224 AptosError::KeyDerivation(format!(
225 "invalid numeric component '{raw}' in derivation path: {path}"
226 ))
227 })?;
228
229 components.push(
230 PathComponent::try_new(index, hardened).map_err(|e| match e {
231 AptosError::KeyDerivation(msg) => {
232 AptosError::KeyDerivation(format!("{msg} in path: {path}"))
233 }
234 other => other,
235 })?,
236 );
237 }
238
239 if components.is_empty() {
240 return Err(AptosError::KeyDerivation(format!(
241 "derivation path has no components: {path}"
242 )));
243 }
244
245 Ok(Self { components })
246 }
247}
248
249impl std::fmt::Display for DerivationPath {
250 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
251 f.write_str("m")?;
252 for c in &self.components {
253 if c.hardened() {
254 write!(f, "/{}'", c.index())?;
255 } else {
256 write!(f, "/{}", c.index())?;
257 }
258 }
259 Ok(())
260 }
261}
262
263#[derive(Clone)]
278pub struct Mnemonic {
279 phrase: String,
280}
281
282impl Mnemonic {
283 pub fn generate(word_count: usize) -> AptosResult<Self> {
294 let entropy_bytes = match word_count {
295 12 => 16, 15 => 20, 18 => 24, 21 => 28, 24 => 32, _ => {
301 return Err(AptosError::InvalidMnemonic(format!(
302 "invalid word count: {word_count}, must be 12, 15, 18, 21, or 24"
303 )));
304 }
305 };
306
307 let mut entropy = vec![0u8; entropy_bytes];
308 rand::RngCore::fill_bytes(&mut rand::rngs::OsRng, &mut entropy);
309
310 let mnemonic = bip39::Mnemonic::from_entropy(&entropy)
311 .map_err(|e| AptosError::InvalidMnemonic(e.to_string()));
312
313 zeroize::Zeroize::zeroize(&mut entropy);
316
317 let mnemonic = mnemonic?;
318
319 Ok(Self {
320 phrase: mnemonic.to_string(),
321 })
322 }
323
324 pub fn from_phrase(phrase: &str) -> AptosResult<Self> {
330 let _mnemonic = bip39::Mnemonic::parse_normalized(phrase)
332 .map_err(|e| AptosError::InvalidMnemonic(e.to_string()))?;
333
334 Ok(Self {
335 phrase: phrase.to_string(),
336 })
337 }
338
339 pub fn phrase(&self) -> &str {
341 &self.phrase
342 }
343
344 pub fn to_seed(&self) -> AptosResult<[u8; 64]> {
353 self.to_seed_with_passphrase("")
354 }
355
356 pub fn to_seed_with_passphrase(&self, passphrase: &str) -> AptosResult<[u8; 64]> {
364 let mnemonic = bip39::Mnemonic::parse_normalized(&self.phrase).map_err(|e| {
365 AptosError::InvalidMnemonic(format!("internal error: mnemonic re-parse failed: {e}"))
366 })?;
367
368 Ok(mnemonic.to_seed(passphrase))
369 }
370
371 #[cfg(feature = "ed25519")]
379 pub fn derive_ed25519_key(&self, index: u32) -> AptosResult<crate::crypto::Ed25519PrivateKey> {
380 self.derive_ed25519_key_at_path(&DerivationPath::aptos_ed25519(index)?)
381 }
382
383 #[cfg(feature = "ed25519")]
394 pub fn derive_ed25519_key_at_path(
395 &self,
396 path: &DerivationPath,
397 ) -> AptosResult<crate::crypto::Ed25519PrivateKey> {
398 if !path.is_fully_hardened() {
399 return Err(AptosError::KeyDerivation(format!(
400 "Ed25519 derivation requires every path component to be hardened; got {path}"
401 )));
402 }
403
404 let mut seed = self.to_seed()?;
405 let result = derive_ed25519_at_path(&seed, path);
406 zeroize::Zeroize::zeroize(&mut seed);
408 let mut key = result?;
409 let private_key = crate::crypto::Ed25519PrivateKey::from_bytes(&key);
410 zeroize::Zeroize::zeroize(&mut key);
412 private_key
413 }
414
415 #[cfg(feature = "secp256k1")]
427 pub fn derive_secp256k1_key(
428 &self,
429 index: u32,
430 ) -> AptosResult<crate::crypto::Secp256k1PrivateKey> {
431 self.derive_secp256k1_key_at_path(&DerivationPath::aptos_secp256k1(index)?)
432 }
433
434 #[cfg(feature = "secp256k1")]
444 pub fn derive_secp256k1_key_at_path(
445 &self,
446 path: &DerivationPath,
447 ) -> AptosResult<crate::crypto::Secp256k1PrivateKey> {
448 let mut seed = self.to_seed()?;
449 let result = derive_secp256k1_at_path(&seed, path);
450 zeroize::Zeroize::zeroize(&mut seed);
451 let mut bytes = result?;
452 let key = crate::crypto::Secp256k1PrivateKey::from_bytes(&bytes);
453 zeroize::Zeroize::zeroize(&mut bytes);
454 key
455 }
456}
457
458#[cfg(feature = "ed25519")]
460fn derive_ed25519_at_path(seed: &[u8], path: &DerivationPath) -> AptosResult<[u8; 32]> {
461 use hmac::{Hmac, Mac};
462 use sha2::Sha512;
463
464 type HmacSha512 = Hmac<Sha512>;
465
466 let mut mac = HmacSha512::new_from_slice(b"ed25519 seed")
468 .map_err(|e| AptosError::KeyDerivation(e.to_string()))?;
469 mac.update(seed);
470 let result = mac.finalize().into_bytes();
471
472 let mut key = [0u8; 32];
473 let mut chain_code = [0u8; 32];
474 key.copy_from_slice(&result[..32]);
475 chain_code.copy_from_slice(&result[32..]);
476
477 for component in path.components() {
478 let mut data = vec![0u8];
479 data.extend_from_slice(&key);
480 data.extend_from_slice(&component.encoded().to_be_bytes());
481
482 let mut mac = HmacSha512::new_from_slice(&chain_code)
483 .map_err(|e| AptosError::KeyDerivation(e.to_string()))?;
484 mac.update(&data);
485 let result = mac.finalize().into_bytes();
486
487 key.copy_from_slice(&result[..32]);
488 chain_code.copy_from_slice(&result[32..]);
489
490 zeroize::Zeroize::zeroize(&mut data);
492 }
493
494 zeroize::Zeroize::zeroize(&mut chain_code);
496
497 Ok(key)
498}
499
500#[cfg(feature = "secp256k1")]
506fn derive_secp256k1_at_path(seed: &[u8], path: &DerivationPath) -> AptosResult<[u8; 32]> {
507 use hmac::{Hmac, Mac};
508 use k256::elliptic_curve::sec1::ToEncodedPoint;
509 use k256::{NonZeroScalar, ProjectivePoint, PublicKey, Scalar, SecretKey};
510 use sha2::Sha512;
511
512 type HmacSha512 = Hmac<Sha512>;
513
514 let mut mac = HmacSha512::new_from_slice(b"Bitcoin seed")
516 .map_err(|e| AptosError::KeyDerivation(e.to_string()))?;
517 mac.update(seed);
518 let result = mac.finalize().into_bytes();
519
520 let mut key_bytes = [0u8; 32];
521 let mut chain_code = [0u8; 32];
522 key_bytes.copy_from_slice(&result[..32]);
523 chain_code.copy_from_slice(&result[32..]);
524
525 let mut parent = SecretKey::from_slice(&key_bytes)
527 .map_err(|e| AptosError::KeyDerivation(format!("invalid master scalar: {e}")))?;
528
529 for component in path.components() {
530 let encoded = component.encoded();
531 let (mut data, hardened) = if component.hardened() {
532 let mut buf = Vec::with_capacity(1 + 32 + 4);
534 buf.push(0u8);
535 buf.extend_from_slice(&parent.to_bytes());
536 buf.extend_from_slice(&encoded.to_be_bytes());
537 (buf, true)
538 } else {
539 let pub_key: PublicKey = parent.public_key();
541 let encoded_point = pub_key.to_encoded_point(true);
542 let mut buf = Vec::with_capacity(33 + 4);
543 buf.extend_from_slice(encoded_point.as_bytes());
544 buf.extend_from_slice(&encoded.to_be_bytes());
545 (buf, false)
546 };
547
548 let mut mac = HmacSha512::new_from_slice(&chain_code)
549 .map_err(|e| AptosError::KeyDerivation(e.to_string()))?;
550 mac.update(&data);
551 let result = mac.finalize().into_bytes();
552 if hardened {
553 zeroize::Zeroize::zeroize(&mut data);
556 }
557
558 let il_scalar = NonZeroScalar::try_from(&result[..32]).map_err(|e| {
565 AptosError::KeyDerivation(format!(
566 "BIP-32 derivation produced invalid intermediate scalar: {e}"
567 ))
568 })?;
569
570 let parent_scalar: Scalar = *parent.to_nonzero_scalar().as_ref();
572 let child_scalar = *il_scalar.as_ref() + parent_scalar;
573
574 let child_nz =
575 Option::<NonZeroScalar>::from(NonZeroScalar::new(child_scalar)).ok_or_else(|| {
576 AptosError::KeyDerivation(
577 "BIP-32 derivation produced zero child scalar".to_string(),
578 )
579 })?;
580 parent = SecretKey::from(child_nz);
581
582 let _ = ProjectivePoint::GENERATOR;
588
589 chain_code.copy_from_slice(&result[32..]);
591 }
592
593 let mut out = [0u8; 32];
594 out.copy_from_slice(&parent.to_bytes());
595
596 zeroize::Zeroize::zeroize(&mut key_bytes);
598 zeroize::Zeroize::zeroize(&mut chain_code);
599
600 Ok(out)
601}
602
603impl std::fmt::Debug for Mnemonic {
604 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
605 write!(f, "Mnemonic([REDACTED])")
606 }
607}
608
609#[cfg(test)]
610mod tests {
611 use super::*;
612
613 const TEST_PHRASE: &str = "abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon about";
614
615 #[test]
616 fn test_generate_mnemonic() {
617 let mnemonic = Mnemonic::generate(12).unwrap();
618 assert_eq!(mnemonic.phrase().split_whitespace().count(), 12);
619
620 let mnemonic = Mnemonic::generate(24).unwrap();
621 assert_eq!(mnemonic.phrase().split_whitespace().count(), 24);
622 }
623
624 #[test]
625 fn test_invalid_word_count() {
626 assert!(Mnemonic::generate(13).is_err());
627 }
628
629 #[test]
630 fn test_parse_mnemonic() {
631 let mnemonic = Mnemonic::from_phrase(TEST_PHRASE).unwrap();
632 assert_eq!(mnemonic.phrase(), TEST_PHRASE);
633 }
634
635 #[test]
636 fn test_invalid_mnemonic() {
637 assert!(Mnemonic::from_phrase("invalid mnemonic phrase").is_err());
638 }
639
640 #[test]
641 fn test_path_from_str_hardened() {
642 let path = DerivationPath::from_str("m/44'/637'/0'/0'/0'").unwrap();
643 assert!(path.is_fully_hardened());
644 assert_eq!(path.components().len(), 5);
645 assert_eq!(path.to_string(), "m/44'/637'/0'/0'/0'");
646 }
647
648 #[test]
649 fn test_path_from_str_mixed() {
650 let path = DerivationPath::from_str("m/44'/637'/0'/0/0").unwrap();
651 assert!(!path.is_fully_hardened());
652 let comps = path.components();
653 assert!(comps[0].hardened && comps[1].hardened && comps[2].hardened);
654 assert!(!comps[3].hardened && !comps[4].hardened);
655 }
656
657 #[test]
658 fn test_path_from_str_h_marker() {
659 let path = DerivationPath::from_str("m/44h/637h/0h").unwrap();
661 assert!(path.is_fully_hardened());
662 }
663
664 #[test]
665 fn test_path_from_str_rejects_bad_prefix() {
666 assert!(DerivationPath::from_str("44'/637'").is_err());
667 assert!(DerivationPath::from_str("").is_err());
668 assert!(DerivationPath::from_str("m").is_err());
669 assert!(DerivationPath::from_str("m/44'/abc/0").is_err());
670 }
671
672 #[test]
673 fn test_path_from_str_rejects_oversize_index() {
674 assert!(DerivationPath::from_str("m/2147483648").is_err());
676 }
677
678 #[test]
679 fn test_aptos_default_paths() {
680 assert_eq!(
684 DerivationPath::aptos_ed25519(0).unwrap().to_string(),
685 "m/44'/637'/0'/0'/0'"
686 );
687 assert_eq!(
688 DerivationPath::aptos_secp256k1(0).unwrap().to_string(),
689 "m/44'/637'/0'/0/0"
690 );
691 assert_eq!(
692 DerivationPath::aptos_ed25519(5).unwrap().to_string(),
693 "m/44'/637'/0'/0'/5'",
694 "address_index belongs in the 5th component",
695 );
696 assert_eq!(
697 DerivationPath::aptos_secp256k1(5).unwrap().to_string(),
698 "m/44'/637'/0'/0/5",
699 "address_index belongs in the 5th component (non-hardened)",
700 );
701 }
702
703 #[test]
704 fn test_aptos_default_paths_reject_oversize_index() {
705 assert!(DerivationPath::aptos_ed25519(0x8000_0000).is_err());
707 assert!(DerivationPath::aptos_secp256k1(0x8000_0000).is_err());
708 }
709
710 #[test]
711 #[cfg(feature = "ed25519")]
712 fn test_derive_ed25519_key() {
713 let mnemonic = Mnemonic::from_phrase(TEST_PHRASE).unwrap();
714
715 let key1 = mnemonic.derive_ed25519_key(0).unwrap();
716 let key2 = mnemonic.derive_ed25519_key(0).unwrap();
717 assert_eq!(key1.to_bytes(), key2.to_bytes());
718
719 let key3 = mnemonic.derive_ed25519_key(1).unwrap();
720 assert_ne!(key1.to_bytes(), key3.to_bytes());
721 }
722
723 #[test]
724 #[cfg(feature = "ed25519")]
725 fn test_derive_ed25519_at_path_rejects_unhardened() {
726 let mnemonic = Mnemonic::from_phrase(TEST_PHRASE).unwrap();
727 let path = DerivationPath::from_str("m/44'/637'/0'/0/0").unwrap();
728 let err = mnemonic.derive_ed25519_key_at_path(&path).unwrap_err();
729 assert!(matches!(err, AptosError::KeyDerivation(_)));
730 }
731
732 #[test]
733 #[cfg(feature = "ed25519")]
734 fn test_derive_ed25519_default_matches_path() {
735 let mnemonic = Mnemonic::from_phrase(TEST_PHRASE).unwrap();
736 let via_index = mnemonic.derive_ed25519_key(3).unwrap();
737 let via_path = mnemonic
738 .derive_ed25519_key_at_path(&DerivationPath::aptos_ed25519(3).unwrap())
739 .unwrap();
740 assert_eq!(via_index.to_bytes(), via_path.to_bytes());
741 }
742
743 #[test]
744 #[cfg(feature = "secp256k1")]
745 fn test_derive_secp256k1_deterministic() {
746 let mnemonic = Mnemonic::from_phrase(TEST_PHRASE).unwrap();
747 let key1 = mnemonic.derive_secp256k1_key(0).unwrap();
748 let key2 = mnemonic.derive_secp256k1_key(0).unwrap();
749 assert_eq!(key1.to_bytes(), key2.to_bytes());
750
751 let key3 = mnemonic.derive_secp256k1_key(1).unwrap();
752 assert_ne!(key1.to_bytes(), key3.to_bytes());
753 }
754
755 #[test]
761 #[cfg(feature = "secp256k1")]
762 fn test_derive_secp256k1_pinned_aptos_vector() {
763 let mnemonic = Mnemonic::from_phrase(TEST_PHRASE).unwrap();
764 let key = mnemonic.derive_secp256k1_key(0).unwrap();
765 assert_eq!(
766 const_hex::encode(key.to_bytes()),
767 "4613c3acaffc152273c102a6b27f6f4209e1d54cac18ad0ac96b5892b7d7bf91",
768 );
769 }
770
771 #[test]
779 #[cfg(feature = "secp256k1")]
780 fn test_derive_secp256k1_bitcoin_reference_vector() {
781 let mnemonic = Mnemonic::from_phrase(TEST_PHRASE).unwrap();
782 let path = DerivationPath::from_str("m/44'/0'/0'/0/0").unwrap();
783 let key = mnemonic.derive_secp256k1_key_at_path(&path).unwrap();
784 assert_eq!(
785 const_hex::encode(key.to_bytes()),
786 "e284129cc0922579a535bbf4d1a3b25773090d28c909bc0fed73b5e0222cc372",
787 );
788 }
789
790 #[test]
791 fn test_path_component_encoded_sets_hardened_bit() {
792 let hardened = PathComponent::try_new(44, true).unwrap();
793 let unhardened = PathComponent::try_new(44, false).unwrap();
794 assert_eq!(hardened.encoded(), 0x8000_002C);
795 assert_eq!(unhardened.encoded(), 44);
796 assert_eq!(hardened.index(), 44);
797 assert!(hardened.hardened());
798 assert!(!unhardened.hardened());
799 }
800
801 #[test]
802 fn test_path_component_rejects_oversize_index() {
803 assert!(PathComponent::try_new(0x8000_0000, false).is_err());
807 assert!(PathComponent::try_new(0x8000_0000, true).is_err());
808 assert!(PathComponent::try_new(0xFFFF_FFFF, false).is_err());
809 assert!(PathComponent::try_new(0x7FFF_FFFF, true).is_ok());
811 }
812
813 #[test]
814 fn test_path_display_roundtrip() {
815 for s in ["m/44'/637'/0'/0/0", "m/44'/637'/3'/0'/0'", "m/0"] {
816 let path = DerivationPath::from_str(s).unwrap();
817 assert_eq!(path.to_string(), s, "roundtrip drifted for {s}");
818 }
819 }
820
821 #[test]
822 fn test_path_from_str_via_parse_trait() {
823 use std::str::FromStr;
826 let via_inherent = DerivationPath::from_str("m/44'/637'/0'/0/0").unwrap();
827 let via_trait: DerivationPath = "m/44'/637'/0'/0/0".parse().unwrap();
828 let via_fromstr = <DerivationPath as FromStr>::from_str("m/44'/637'/0'/0/0").unwrap();
829 assert_eq!(via_inherent, via_trait);
830 assert_eq!(via_inherent, via_fromstr);
831 }
832
833 #[test]
834 fn test_path_from_str_rejects_empty_component() {
835 assert!(DerivationPath::from_str("m//44'").is_err());
837 assert!(DerivationPath::from_str("m/44'/").is_err());
838 }
839
840 #[test]
841 #[cfg(feature = "ed25519")]
842 fn test_passphrase_changes_derived_key() {
843 let mnemonic = Mnemonic::from_phrase(TEST_PHRASE).unwrap();
848 let seed_default = mnemonic.to_seed().unwrap();
849 let seed_passphrase = mnemonic.to_seed_with_passphrase("hunter2").unwrap();
850 assert_ne!(seed_default, seed_passphrase);
851
852 let path = DerivationPath::aptos_ed25519(0).unwrap();
853 let key_default = derive_ed25519_at_path(&seed_default, &path).unwrap();
854 let key_passphrase = derive_ed25519_at_path(&seed_passphrase, &path).unwrap();
855 assert_ne!(
856 key_default, key_passphrase,
857 "BIP-39 passphrase must produce a distinct derived key"
858 );
859 }
860
861 #[test]
862 #[cfg(feature = "secp256k1")]
863 fn test_derive_secp256k1_different_paths_produce_different_keys() {
864 let m = Mnemonic::from_phrase(TEST_PHRASE).unwrap();
867 let k0 = m.derive_secp256k1_key(0).unwrap();
868 let k1 = m.derive_secp256k1_key(1).unwrap();
869 let k_bitcoin = m
870 .derive_secp256k1_key_at_path(&DerivationPath::from_str("m/44'/0'/0'/0/0").unwrap())
871 .unwrap();
872 assert_ne!(k0.to_bytes(), k1.to_bytes());
873 assert_ne!(k0.to_bytes(), k_bitcoin.to_bytes());
874 assert_ne!(k1.to_bytes(), k_bitcoin.to_bytes());
875 }
876
877 #[test]
878 #[cfg(feature = "secp256k1")]
879 fn test_derive_secp256k1_custom_path() {
880 let mnemonic = Mnemonic::from_phrase(TEST_PHRASE).unwrap();
881 let path = DerivationPath::from_str("m/44'/637'/0'/0/0").unwrap();
882 let via_path = mnemonic.derive_secp256k1_key_at_path(&path).unwrap();
883 let via_index = mnemonic.derive_secp256k1_key(0).unwrap();
884 assert_eq!(via_path.to_bytes(), via_index.to_bytes());
885 }
886}