refactor(bip32): split relative and absolute bip32 derivation paths
What changed, and why it matters
This commit refactors how Bitcoin BIP-32 derivation paths are represented in the rust-bitcoin library. It splits the old single DerivationPath type into two: DerivationPath (relative paths like 0'/1) and AbsoluteDerivationPath (master-prefixed paths like m/0'/1). The goal is to make wallet code more explicit about whether it is deriving from the master key or from an intermediate key. The change is a type-system/API hardening, not a fix for a known exploitable bug. It may prevent future misuse where a relative path is accidentally treated as absolute or vice versa, but the commit itself does not describe any active vulnerability.
Treat as a breaking API refactor with defensive-security benefits. Downstream users should audit call sites that parse or pass BIP-32 paths, because strings previously accepted by DerivationPath (e.g., 'm/0'/1') will now fail to parse. Consider whether any wallet logic relied on the ambiguous old behavior. No immediate patch or incident response is indicated by the supplied materials.
Security signals we found
API hardening: type-level separation of absolute vs relative BIP-32 paths
Behavior change: DerivationPath no longer silently accepts 'm/' prefix
New error variants for missing or unexpected master prefix
No mention of CVE, bug bounty, vulnerability, or exploit in commit message or diff
Evidence from the diff
The patch removes IntoDerivationPath and changes DerivationPath::from_str to reject strings starting with ‘m’ or ‘m/’ (new error UnexpectedMasterPrefix). It introduces AbsoluteDerivationPath whose FromStr requires the ‘m’ prefix (MissingMasterPrefix otherwise) and whose Display always emits ‘m’. Conversion between the two is explicit via as_relative/into_relative and From impls. Tests are updated to expect the new parse behavior. The commit is labeled a refactor and does not claim to fix a security issue.
Changed components
key_expression/src/bip32.rsDerivationPathnew AbsoluteDerivationPathParseDerivationPathErrorInspect captured patch +219 / −77
diff --git a/key_expression/src/bip32.rs b/key_expression/src/bip32.rs
index 56982f60..b93ef969 100644
--- a/key_expression/src/bip32.rs
+++ b/key_expression/src/bip32.rs
@@ -5,7 +5,6 @@
//! Implementation of BIP-0032 hierarchical deterministic wallets, as defined
//! at <https://github.com/bitcoin/bips/blob/master/bip-0032.mediawiki>.
-use alloc::string::String;
use alloc::vec;
use alloc::vec::Vec;
use core::ops::Index;
@@ -23,7 +22,7 @@ use network::NetworkKind;
#[doc(no_inline)]
pub use self::error::{
DerivationError, IndexOutOfRangeError, InvalidBase58PayloadLengthError,
- InvalidSeedLengthError, ParseChildNumberError, ParseError,
+ ParseChildNumberError, ParseDerivationPathError, ParseError, InvalidSeedLengthError
};
/// Version bytes for extended public keys on the Bitcoin network.
@@ -401,23 +400,12 @@ impl serde::Serialize for ChildNumber {
}
}
-/// Trait that allows possibly failable conversion from a type into a
-/// derivation path
-pub trait IntoDerivationPath {
- /// Converts a given type into a [`DerivationPath`] with possible error
- ///
- /// # Errors
- ///
- /// Errors if the child numbers in the derivation path cannot be parsed to a complete path.
- fn into_derivation_path(self) -> Result<DerivationPath, ParseChildNumberError>;
-}
-
-/// A BIP-0032 derivation path.
-#[derive(Clone, PartialEq, Eq, Ord, PartialOrd, Hash)]
+/// A relative BIP-0032 derivation path.
+#[derive(Default, Clone, PartialEq, Eq, Ord, PartialOrd, Hash)]
pub struct DerivationPath(Vec<ChildNumber>);
#[cfg(feature = "serde")]
-internals::serde_string_impl!(DerivationPath, "a BIP-0032 derivation path");
+internals::serde_string_impl!(DerivationPath, "a relative BIP-0032 derivation path");
impl<I> Index<I> for DerivationPath
where
@@ -429,27 +417,6 @@ where
fn index(&self, index: I) -> &Self::Output { &self.0[index] }
}
-impl Default for DerivationPath {
- fn default() -> Self { Self::master() }
-}
-
-impl<T> IntoDerivationPath for T
-where
- T: Into<DerivationPath>,
-{
- fn into_derivation_path(self) -> Result<DerivationPath, ParseChildNumberError> {
- Ok(self.into())
- }
-}
-
-impl IntoDerivationPath for String {
- fn into_derivation_path(self) -> Result<DerivationPath, ParseChildNumberError> { self.parse() }
-}
-
-impl IntoDerivationPath for &'_ str {
- fn into_derivation_path(self) -> Result<DerivationPath, ParseChildNumberError> { self.parse() }
-}
-
impl From<Vec<ChildNumber>> for DerivationPath {
fn from(numbers: Vec<ChildNumber>) -> Self { Self(numbers) }
}
@@ -483,21 +450,116 @@ impl AsRef<[ChildNumber]> for DerivationPath {
}
impl FromStr for DerivationPath {
- type Err = ParseChildNumberError;
+ type Err = ParseDerivationPathError;
fn from_str(path: &str) -> Result<Self, Self::Err> {
- if path.is_empty() || path == "m" || path == "m/" {
+ if path.is_empty() {
return Ok(vec![].into());
}
- let path = path.strip_prefix("m/").unwrap_or(path);
+ if path == "m" || path.starts_with("m/") {
+ return Err(ParseDerivationPathError::UnexpectedMasterPrefix);
+ }
+
+ let mut ret = Vec::new();
+ for part in path.split('/') {
+ if part.is_empty() {
+ return Err(ParseDerivationPathError::EmptyChild);
+ }
+ ret.push(part.parse()?);
+ }
+ Ok(Self(ret))
+ }
+}
+
+/// An absolute BIP-0032 derivation path, starting at the master key.
+///
+/// Conversion to [`DerivationPath`] is available through
+/// [`AbsoluteDerivationPath::as_relative`] or
+/// [`AbsoluteDerivationPath::into_relative`].
+///
+/// The leading `m` in BIP-0032 notation has historically been subtle in this crate:
+/// See [PR #2451] and [PR #2677] for prior discussion on the "m/" notation.
+///
+/// [PR #2451]: https://github.com/rust-bitcoin/rust-bitcoin/pull/2451
+/// [PR #2677]: https://github.com/rust-bitcoin/rust-bitcoin/pull/2677
+#[derive(Debug, Clone, PartialEq, Eq, Ord, PartialOrd, Hash)]
+pub struct AbsoluteDerivationPath(DerivationPath);
+
+#[cfg(feature = "serde")]
+internals::serde_string_impl!(AbsoluteDerivationPath, "an absolute BIP-0032 derivation path");
+
+impl AbsoluteDerivationPath {
+ /// Returns the absolute derivation path for a master key.
+ pub fn master() -> Self { Self(DerivationPath::default()) }
+
+ /// Returns `true` if this is the master path.
+ pub fn is_master(&self) -> bool { self.0.is_empty() }
+
+ /// Returns the relative path below the master key.
+ pub fn as_relative(&self) -> &DerivationPath { &self.0 }
+
+ /// Converts this absolute path into the relative path below the master key.
+ pub fn into_relative(self) -> DerivationPath { self.0 }
+
+ /// Returns length of the relative derivation path below the master key.
+ pub fn len(&self) -> usize { self.0.len() }
+
+ /// Returns `true` if this is the master path.
+ pub fn is_empty(&self) -> bool { self.0.is_empty() }
+
+ /// Returns `true` if the relative path below the master key contains a hardened child number.
+ pub fn contains_hardened_child(&self) -> bool { self.0.contains_hardened_child() }
+
+ /// Concatenate `self` with `path` and return the resulting new path.
+ #[must_use]
+ pub fn extend<T: AsRef<[ChildNumber]>>(&self, path: T) -> Self { Self(self.0.extend(path)) }
+}
+
+impl Default for AbsoluteDerivationPath {
+ fn default() -> Self { Self::master() }
+}
+
+impl FromStr for AbsoluteDerivationPath {
+ type Err = ParseDerivationPathError;
+
+ fn from_str(path: &str) -> Result<Self, Self::Err> {
+ if path == "m" {
+ return Ok(Self::master());
+ }
+
+ let path = path.strip_prefix("m/").ok_or(ParseDerivationPathError::MissingMasterPrefix)?;
+ if path.is_empty() {
+ return Err(ParseDerivationPathError::EmptyChild);
+ }
+
+ Ok(Self(path.parse()?))
+ }
+}
- let parts = path.split('/');
- let ret: Result<Vec<ChildNumber>, _> = parts.map(str::parse).collect();
- Ok(Self(ret?))
+impl fmt::Display for AbsoluteDerivationPath {
+ fn fmt(&self, f: &mut fmt::Formatter) -> fmt::Result {
+ f.write_str("m")?;
+ if !self.0.is_empty() {
+ f.write_str("/")?;
+ if f.alternate() {
+ write!(f, "{:#}", self.0)?;
+ } else {
+ write!(f, "{}", self.0)?;
+ }
+ }
+ Ok(())
}
}
+impl From<DerivationPath> for AbsoluteDerivationPath {
+ fn from(path: DerivationPath) -> Self { Self(path) }
+}
+
+impl From<AbsoluteDerivationPath> for DerivationPath {
+ fn from(path: AbsoluteDerivationPath) -> Self { path.0 }
+}
+
/// An iterator over children of a [`DerivationPath`].
///
/// It is returned by the methods [`DerivationPath::children_from`],
@@ -531,12 +593,8 @@ impl DerivationPath {
/// Returns `true` if the derivation path is empty
pub fn is_empty(&self) -> bool { self.0.is_empty() }
- /// Returns derivation path for a master key (i.e. empty derivation path)
- pub fn master() -> Self { Self(vec![]) }
-
- /// Returns whether derivation path represents master key (i.e. it's length
- /// is empty). True for `m` path.
- pub fn is_master(&self) -> bool { self.0.is_empty() }
+ /// Returns `true` if the derivation path contains a hardened child number.
+ pub fn contains_hardened_child(&self) -> bool { self.0.iter().any(ChildNumber::is_hardened) }
/// Constructs a new [`DerivationPath`] that is a child of this one.
#[must_use]
@@ -575,7 +633,7 @@ impl DerivationPath {
/// ```
/// use bitcoin_key_expression::bip32::{DerivationPath, ChildNumber};
///
- /// let base = "m/42".parse::<DerivationPath>().unwrap();
+ /// let base = "42".parse::<DerivationPath>().unwrap();
///
/// let deriv_1 = base.extend("0/1".parse::<DerivationPath>().unwrap());
/// let deriv_2 = base.extend(&[
@@ -599,7 +657,7 @@ impl DerivationPath {
/// ```
/// use bitcoin_key_expression::bip32::DerivationPath;
///
- /// let path = "m/84'/0'/0'/0/1".parse::<DerivationPath>().unwrap();
+ /// let path = "84'/0'/0'/0/1".parse::<DerivationPath>().unwrap();
/// const HARDENED: u32 = 0x80000000;
/// assert_eq!(path.to_u32_vec(), vec![84 + HARDENED, HARDENED, HARDENED, 0, 1]);
/// ```
@@ -1249,6 +1307,50 @@ pub mod error {
}
}
+ /// Error parsing a derivation path.
+ #[derive(Debug, Clone, PartialEq, Eq)]
+ pub enum ParseDerivationPathError {
+ /// Failed to parse a child number.
+ Child(ParseChildNumberError),
+ /// The path contained an empty child number.
+ EmptyChild,
+ /// The absolute path was missing the `m` master prefix.
+ MissingMasterPrefix,
+ /// The relative path unexpectedly contained the `m` master prefix.
+ UnexpectedMasterPrefix,
+ }
+
+ impl From<Infallible> for ParseDerivationPathError {
+ fn from(never: Infallible) -> Self { match never {} }
+ }
+
+ impl fmt::Display for ParseDerivationPathError {
+ fn fmt(&self, f: &mut fmt::Formatter) -> fmt::Result {
+ match *self {
+ Self::Child(ref e) => write_err!(f, "failed to parse child number"; e),
+ Self::EmptyChild => f.write_str("derivation path contains an empty child number"),
+ Self::MissingMasterPrefix =>
+ f.write_str("absolute derivation path is missing master prefix `m`"),
+ Self::UnexpectedMasterPrefix =>
+ f.write_str("relative derivation path contains unexpected master prefix `m`"),
+ }
+ }
+ }
+
+ #[cfg(feature = "std")]
+ impl std::error::Error for ParseDerivationPathError {
+ fn source(&self) -> Option<&(dyn std::error::Error + 'static)> {
+ match *self {
+ Self::Child(ref e) => Some(e),
+ Self::EmptyChild | Self::MissingMasterPrefix | Self::UnexpectedMasterPrefix => None,
+ }
+ }
+ }
+
+ impl From<ParseChildNumberError> for ParseDerivationPathError {
+ fn from(e: ParseChildNumberError) -> Self { Self::Child(e) }
+ }
+
/// Decoded base58 data was an invalid length.
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct InvalidBase58PayloadLengthError {
@@ -1305,6 +1407,13 @@ impl<'a> Arbitrary<'a> for DerivationPath {
}
}
+#[cfg(feature = "arbitrary")]
+impl<'a> Arbitrary<'a> for AbsoluteDerivationPath {
+ fn arbitrary(u: &mut Unstructured<'a>) -> arbitrary::Result<Self> {
+ Ok(Self(DerivationPath::arbitrary(u)?))
+ }
+}
+
#[cfg(feature = "arbitrary")]
impl<'a> Arbitrary<'a> for Fingerprint {
fn arbitrary(u: &mut Unstructured<'a>) -> arbitrary::Result<Self> {
@@ -1373,18 +1482,18 @@ mod tests {
#[test]
fn parse_derivation_path_invalid_format() {
- let invalid_paths = ["n/0'/0", "4/m/5", "//3/0'", "0h/0x"];
- for path in &invalid_paths {
+ for path in ["n/0'/0", "4/m/5", "0h/0x"] {
assert!(matches!(
path.parse::<DerivationPath>(),
- Err(ParseChildNumberError::ParseInt(..)),
+ Err(ParseDerivationPathError::Child(ParseChildNumberError::ParseInt(..))),
));
}
+ assert_eq!("//3/0'".parse::<DerivationPath>(), Err(ParseDerivationPathError::EmptyChild));
}
#[test]
fn test_derivation_path_display() {
- let path = DerivationPath::from_str("m/84'/0'/0'/0/0").unwrap();
+ let path = DerivationPath::from_str("84'/0'/0'/0/0").unwrap();
assert_eq!(format!("{}", path), "84'/0'/0'/0/0");
assert_eq!(format!("{:#}", path), "84h/0h/0h/0/0");
}
@@ -1442,23 +1551,28 @@ mod tests {
let invalid_path = "2147483648";
assert_eq!(
invalid_path.parse::<DerivationPath>(),
- Err(ParseChildNumberError::IndexOutOfRange(IndexOutOfRangeError {
- index: 2_147_483_648
- })),
+ Err(ParseDerivationPathError::Child(ParseChildNumberError::IndexOutOfRange(
+ IndexOutOfRangeError { index: 2_147_483_648 }
+ ))),
);
}
#[test]
- fn parse_derivation_path_valid_empty_master() {
+ fn parse_derivation_path_valid_empty() {
// Sanity checks.
- assert_eq!(DerivationPath::master(), DerivationPath(vec![]));
- assert_eq!(DerivationPath::master(), "".parse::<DerivationPath>().unwrap());
- assert_eq!(DerivationPath::master(), DerivationPath::default());
+ assert_eq!(DerivationPath::default(), DerivationPath(vec![]));
+ assert_eq!(DerivationPath::default(), "".parse::<DerivationPath>().unwrap());
- // Empty is the same as with an `m`.
+ // A relative path is empty without an `m`.
assert_eq!("".parse::<DerivationPath>().unwrap(), DerivationPath(vec![]));
- assert_eq!("m".parse::<DerivationPath>().unwrap(), DerivationPath(vec![]));
- assert_eq!("m/".parse::<DerivationPath>().unwrap(), DerivationPath(vec![]));
+ assert_eq!(
+ "m".parse::<DerivationPath>(),
+ Err(ParseDerivationPathError::UnexpectedMasterPrefix)
+ );
+ assert_eq!(
+ "m/".parse::<DerivationPath>(),
+ Err(ParseDerivationPathError::UnexpectedMasterPrefix)
+ );
}
#[test]
@@ -1497,21 +1611,41 @@ mod tests {
for (path, expected) in valid_paths {
// Access the inner private field so we don't have to clone expected.
assert_eq!(path.parse::<DerivationPath>().unwrap().0, expected);
- // Test with the leading `m` for good measure.
- let prefixed = format!("m/{}", path);
- assert_eq!(prefixed.parse::<DerivationPath>().unwrap().0, expected);
}
}
#[test]
- fn parse_derivation_path_same_as_into_derivation_path() {
- let s = "0'/50/3'/5/545456";
- assert_eq!(s.parse::<DerivationPath>(), s.into_derivation_path());
- assert_eq!(s.parse::<DerivationPath>(), s.to_string().into_derivation_path());
+ fn parse_absolute_derivation_path() {
+ let master = "m".parse::<AbsoluteDerivationPath>().unwrap();
+ assert_eq!(master, AbsoluteDerivationPath::master());
+ assert_eq!(master.to_string(), "m");
+ assert!(!master.contains_hardened_child());
+
+ let path = "m/0'/1".parse::<AbsoluteDerivationPath>().unwrap();
+ assert_eq!(path.as_relative(), &"0'/1".parse::<DerivationPath>().unwrap());
+ assert_eq!(path.to_string(), "m/0'/1");
+ assert_eq!(format!("{:#}", path), "m/0h/1");
+ assert!(path.contains_hardened_child());
- let s = "m/0'/50/3'/5/545456";
- assert_eq!(s.parse::<DerivationPath>(), s.into_derivation_path());
- assert_eq!(s.parse::<DerivationPath>(), s.to_string().into_derivation_path());
+ assert_eq!(
+ "".parse::<AbsoluteDerivationPath>(),
+ Err(ParseDerivationPathError::MissingMasterPrefix)
+ );
+ assert_eq!(
+ "0/1".parse::<AbsoluteDerivationPath>(),
+ Err(ParseDerivationPathError::MissingMasterPrefix)
+ );
+ assert_eq!(
+ "m/".parse::<AbsoluteDerivationPath>(),
+ Err(ParseDerivationPathError::EmptyChild)
+ );
+ }
+
+ #[test]
+ fn derivation_path_contains_hardened_child() {
+ assert!(!"".parse::<DerivationPath>().unwrap().contains_hardened_child());
+ assert!(!"0/1".parse::<DerivationPath>().unwrap().contains_hardened_child());
+ assert!("0'/1".parse::<DerivationPath>().unwrap().contains_hardened_child());
}
#[test]
@@ -1563,20 +1697,21 @@ mod tests {
fn test_path(
network: NetworkKind,
seed: &[u8],
- path: &DerivationPath,
+ path: &AbsoluteDerivationPath,
expected_sk: &str,
expected_pk: &str,
) {
let seed = <&Bip32Seed>::try_from(seed).unwrap();
let mut sk = Xpriv::new_master(network, seed);
let mut pk = Xpub::from_xpriv(&sk);
+ let path = path.as_relative();
// Check derivation convenience method for Xpriv
assert_eq!(&sk.derive_xpriv(path).unwrap().to_string()[..], expected_sk);
// Check derivation convenience method for Xpub, should error
// appropriately if any ChildNumber is hardened
- if path.0.iter().any(super::ChildNumber::is_hardened) {
+ if path.contains_hardened_child() {
assert_eq!(pk.derive_xpub(path), Err(DerivationError::CannotDeriveHardenedChild));
} else {
assert_eq!(&pk.derive_xpub(path).unwrap().to_string()[..], expected_pk);
@@ -1782,6 +1917,13 @@ mod tests {
serde_round_trip!(ChildNumber::from_hardened_idx((1 << 31) - 1).unwrap());
}
+ #[test]
+ #[cfg(feature = "serde")]
+ pub fn encode_decode_derivation_paths() {
+ serde_round_trip!("0'/1".parse::<DerivationPath>().unwrap());
+ serde_round_trip!("m/0'/1".parse::<AbsoluteDerivationPath>().unwrap());
+ }
+
#[test]
#[cfg(feature = "serde")]
pub fn encode_fingerprint_chaincode() {
Why this scored 34/100
Community notes
Notes can correct, qualify, or add evidence to the AI analysis. Every note shown here has been validated by a human moderator.
The AI analysis stands alone for now. Submit a note if you can add evidence or important context.