Skip to main content

vti_common/slip10/
path.rs

1//! BIP-32 style derivation paths (`m/44'/0'/0'`).
2//!
3//! This is the parsing half of [`crate::slip10`]. It replaces the
4//! `derivation-path` crate, which reached us transitively through
5//! `ed25519-dalek-bip32`. Only the subset SLIP-0010 needs is kept: the
6//! BIP-44/BIP-49 constructors and the `DerivationPathType` classifier that
7//! crate also carried have no consumer in this workspace.
8//!
9//! Parsing semantics are deliberately byte-for-byte identical to
10//! `derivation-path` 0.2.0 — a path string that parsed before must parse to
11//! the same `ChildIndex` sequence now, and one that failed before must still
12//! fail. Operators have these strings baked into stored key records, so a
13//! parser that is merely "equivalent for sensible inputs" is not good enough.
14
15use std::fmt;
16use std::str::FromStr;
17
18/// A single element of a derivation path.
19///
20/// The distinction is load-bearing for SLIP-0010: Ed25519 supports *only*
21/// hardened derivation, so [`ChildIndex::Normal`] is representable (it has to
22/// be — operators can type it) but is rejected at derivation time by
23/// [`crate::slip10::ExtendedSigningKey::derive_child`].
24#[derive(Copy, Clone, Debug, Eq, PartialEq, Ord, PartialOrd)]
25pub enum ChildIndex {
26    /// A non-hardened index. Parsed and displayed, never derivable on Ed25519.
27    Normal(u32),
28    /// A hardened index — written with a trailing `'`.
29    Hardened(u32),
30}
31
32/// Failure building a [`ChildIndex`] from a raw number.
33#[derive(Debug, Clone, thiserror::Error)]
34pub enum ChildIndexError {
35    /// The index set bit 31, which is reserved to encode hardening.
36    #[error("number too large: {0}")]
37    NumberTooLarge(u32),
38}
39
40/// Failure parsing a [`ChildIndex`] from a string.
41#[derive(Debug, Clone, thiserror::Error)]
42pub enum ChildIndexParseError {
43    /// The digits were not a valid `u32`.
44    #[error("could not parse child index: {0}")]
45    ParseInt(#[from] std::num::ParseIntError),
46    /// The number parsed but was out of range.
47    #[error("invalid child index: {0}")]
48    ChildIndex(#[from] ChildIndexError),
49}
50
51impl ChildIndex {
52    /// Build a hardened index. Fails if `num` has bit 31 set.
53    pub fn hardened(num: u32) -> Result<Self, ChildIndexError> {
54        Ok(Self::Hardened(Self::check_size(num)?))
55    }
56
57    /// Build a normal (non-hardened) index. Fails if `num` has bit 31 set.
58    pub fn normal(num: u32) -> Result<Self, ChildIndexError> {
59        Ok(Self::Normal(Self::check_size(num)?))
60    }
61
62    fn check_size(num: u32) -> Result<u32, ChildIndexError> {
63        if num & (1 << 31) == 0 {
64            Ok(num)
65        } else {
66            Err(ChildIndexError::NumberTooLarge(num))
67        }
68    }
69
70    /// The index without its hardening flag.
71    #[inline]
72    pub fn to_u32(self) -> u32 {
73        match self {
74            Self::Hardened(index) | Self::Normal(index) => index,
75        }
76    }
77
78    /// The wire encoding: bit 31 set for hardened, clear for normal.
79    ///
80    /// This is what gets fed to the HMAC in SLIP-0010 child derivation, so it
81    /// must stay big-endian-serialised exactly as BIP-32 specifies.
82    #[inline]
83    pub fn to_bits(self) -> u32 {
84        match self {
85            Self::Hardened(index) => (1 << 31) | index,
86            Self::Normal(index) => index,
87        }
88    }
89
90    /// Inverse of [`ChildIndex::to_bits`].
91    #[inline]
92    pub fn from_bits(bits: u32) -> Self {
93        if bits & (1 << 31) == 0 {
94            Self::Normal(bits)
95        } else {
96            Self::Hardened(bits & !(1 << 31))
97        }
98    }
99
100    /// Whether this index is hardened.
101    #[inline]
102    pub fn is_hardened(self) -> bool {
103        matches!(self, Self::Hardened(_))
104    }
105
106    /// Whether this index is non-hardened.
107    #[inline]
108    pub fn is_normal(self) -> bool {
109        matches!(self, Self::Normal(_))
110    }
111}
112
113impl fmt::Display for ChildIndex {
114    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
115        fmt::Display::fmt(&self.to_u32(), f)?;
116        if self.is_hardened() {
117            f.write_str("'")?;
118        }
119        Ok(())
120    }
121}
122
123impl FromStr for ChildIndex {
124    type Err = ChildIndexParseError;
125
126    fn from_str(s: &str) -> Result<Self, Self::Err> {
127        let mut chars = s.chars();
128        Ok(match chars.next_back() {
129            Some('\'') => Self::hardened(u32::from_str(chars.as_str())?)?,
130            // Includes the empty-string case, which falls through to a
131            // `ParseIntError` — same as `derivation-path` 0.2.0.
132            _ => Self::normal(u32::from_str(s)?)?,
133        })
134    }
135}
136
137/// Failure parsing a [`DerivationPath`] from a string.
138#[derive(Debug, Clone, thiserror::Error)]
139pub enum DerivationPathParseError {
140    /// The input was empty.
141    #[error("empty")]
142    Empty,
143    /// The path did not start with `m`.
144    #[error("invalid prefix: {0}")]
145    InvalidPrefix(String),
146    /// One of the `/`-separated segments was not a valid index.
147    #[error("invalid child index: {0}")]
148    InvalidChildIndex(#[from] ChildIndexParseError),
149}
150
151/// An ordered list of [`ChildIndex`] items, e.g. `m/26'/2'/0'/1'`.
152#[derive(Clone, Debug, Eq, PartialEq)]
153pub struct DerivationPath(Box<[ChildIndex]>);
154
155impl DerivationPath {
156    /// Build a path from a list of indexes.
157    #[inline]
158    pub fn new<P: Into<Box<[ChildIndex]>>>(path: P) -> Self {
159        Self(path.into())
160    }
161
162    /// The indexes, in order from the master key outward.
163    #[inline]
164    pub fn path(&self) -> &[ChildIndex] {
165        &self.0
166    }
167
168    /// Number of derivation steps. `m` alone has length 0.
169    #[inline]
170    pub fn len(&self) -> usize {
171        self.0.len()
172    }
173
174    /// Whether this is the bare master path `m`.
175    #[inline]
176    pub fn is_empty(&self) -> bool {
177        self.0.is_empty()
178    }
179}
180
181impl fmt::Display for DerivationPath {
182    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
183        f.write_str("m")?;
184        for index in self.path() {
185            f.write_str("/")?;
186            fmt::Display::fmt(index, f)?;
187        }
188        Ok(())
189    }
190}
191
192impl FromStr for DerivationPath {
193    type Err = DerivationPathParseError;
194
195    fn from_str(s: &str) -> Result<Self, Self::Err> {
196        if s.is_empty() {
197            return Err(DerivationPathParseError::Empty);
198        }
199        let mut parts = s.split('/');
200        // `split` on a non-empty string always yields at least one item.
201        match parts.next().expect("split yields at least one segment") {
202            "m" => (),
203            prefix => return Err(DerivationPathParseError::InvalidPrefix(prefix.to_owned())),
204        }
205        let path = parts
206            .map(|part| ChildIndex::from_str(part).map_err(DerivationPathParseError::from))
207            .collect::<Result<Box<[ChildIndex]>, _>>()?;
208        Ok(Self::new(path))
209    }
210}
211
212impl AsRef<[ChildIndex]> for DerivationPath {
213    fn as_ref(&self) -> &[ChildIndex] {
214        self.path()
215    }
216}
217
218impl<'a> IntoIterator for &'a DerivationPath {
219    type IntoIter = std::slice::Iter<'a, ChildIndex>;
220    type Item = &'a ChildIndex;
221
222    fn into_iter(self) -> Self::IntoIter {
223        self.path().iter()
224    }
225}
226
227#[cfg(test)]
228mod tests {
229    use super::*;
230
231    #[test]
232    fn parses_the_workspace_key_hierarchy_shape() {
233        // The real shape from `vta-keys::paths`: m/26'/2'/<ctx>'/<key>'.
234        let path: DerivationPath = "m/26'/2'/0'/17'".parse().unwrap();
235        assert_eq!(
236            path.path(),
237            &[
238                ChildIndex::Hardened(26),
239                ChildIndex::Hardened(2),
240                ChildIndex::Hardened(0),
241                ChildIndex::Hardened(17),
242            ]
243        );
244        assert_eq!(path.to_string(), "m/26'/2'/0'/17'");
245    }
246
247    #[test]
248    fn round_trips_mixed_hardened_and_normal() {
249        let path: DerivationPath = "m/44'/0'/0'/1/0".parse().unwrap();
250        assert_eq!(path.path()[3], ChildIndex::Normal(1));
251        assert_eq!(path.path()[4], ChildIndex::Normal(0));
252        assert_eq!(path.to_string(), "m/44'/0'/0'/1/0");
253    }
254
255    #[test]
256    fn bare_master_path_is_empty() {
257        let path: DerivationPath = "m".parse().unwrap();
258        assert!(path.is_empty());
259        assert_eq!(path.to_string(), "m");
260    }
261
262    #[test]
263    fn rejects_empty_input() {
264        assert!(matches!(
265            "".parse::<DerivationPath>(),
266            Err(DerivationPathParseError::Empty)
267        ));
268    }
269
270    #[test]
271    fn rejects_a_missing_or_wrong_prefix() {
272        // `derivation-path` 0.2.0 rejected both of these; so must we.
273        assert!(matches!(
274            "44'/0'/0'".parse::<DerivationPath>(),
275            Err(DerivationPathParseError::InvalidPrefix(_))
276        ));
277        assert!(matches!(
278            "not/a/valid/path".parse::<DerivationPath>(),
279            Err(DerivationPathParseError::InvalidPrefix(_))
280        ));
281        assert!(matches!(
282            "M/44'".parse::<DerivationPath>(),
283            Err(DerivationPathParseError::InvalidPrefix(_))
284        ));
285    }
286
287    #[test]
288    fn rejects_a_trailing_separator() {
289        // "m/" splits to ["m", ""] and the empty segment must fail to parse.
290        assert!("m/".parse::<DerivationPath>().is_err());
291    }
292
293    #[test]
294    fn rejects_an_index_with_bit_31_set() {
295        // 2^31 is not representable: that bit encodes hardening.
296        assert!("m/2147483648'".parse::<DerivationPath>().is_err());
297        assert!("m/2147483648".parse::<DerivationPath>().is_err());
298        // 2^31 - 1 is the largest legal index.
299        let path: DerivationPath = "m/2147483647'".parse().unwrap();
300        assert_eq!(path.path()[0], ChildIndex::Hardened(2147483647));
301    }
302
303    #[test]
304    fn rejects_non_numeric_segments() {
305        assert!("m/abc'".parse::<DerivationPath>().is_err());
306        assert!("m/-1".parse::<DerivationPath>().is_err());
307        assert!("m/1''".parse::<DerivationPath>().is_err());
308    }
309
310    #[test]
311    fn bit_encoding_round_trips() {
312        for index in [
313            ChildIndex::Normal(0),
314            ChildIndex::Normal(2147483647),
315            ChildIndex::Hardened(0),
316            ChildIndex::Hardened(26),
317            ChildIndex::Hardened(2147483647),
318        ] {
319            assert_eq!(ChildIndex::from_bits(index.to_bits()), index);
320        }
321        assert_eq!(ChildIndex::Hardened(0).to_bits(), 0x8000_0000);
322        assert_eq!(ChildIndex::Normal(0).to_bits(), 0);
323    }
324
325    #[test]
326    fn rejects_out_of_range_constructors() {
327        assert!(ChildIndex::hardened(1 << 31).is_err());
328        assert!(ChildIndex::normal(1 << 31).is_err());
329        assert!(ChildIndex::hardened((1 << 31) - 1).is_ok());
330    }
331}