Skip to main content

kobe_primitives/
slip10.rs

1//! SLIP-0010 Ed25519 key derivation.
2//!
3//! Implements the SLIP-0010 standard for deriving Ed25519 keys from a BIP-39 seed.
4//! Used by Solana, Sui, TON, and other Ed25519-based chains.
5//!
6//! Reference: <https://github.com/satoshilabs/slips/blob/master/slip-0010.md>
7
8use alloc::format;
9use alloc::string::String;
10
11use ed25519_dalek::{SigningKey, VerifyingKey};
12use hmac::{Hmac, KeyInit, Mac};
13use sha2::Sha512;
14use zeroize::Zeroizing;
15
16use crate::DeriveError;
17
18/// HMAC-SHA512 type alias.
19type HmacSha512 = Hmac<Sha512>;
20
21/// Curve identifier for Ed25519 master key derivation.
22const ED25519_CURVE: &[u8] = b"ed25519 seed";
23
24/// A SLIP-0010 derived Ed25519 key pair.
25///
26/// Wraps the 32-byte private key plus the 32-byte chain code required for
27/// further child derivation. Both fields are held in [`Zeroizing`] and are
28/// wiped on drop. The struct is **opaque** — callers interact with it
29/// exclusively through the accessor methods below, which mirrors the
30/// secp256k1 sibling [`crate::bip32::DerivedSecp256k1Key`].
31#[non_exhaustive]
32pub struct DerivedEd25519Key {
33    private_key: Zeroizing<[u8; 32]>,
34    chain_code: Zeroizing<[u8; 32]>,
35}
36
37impl core::fmt::Debug for DerivedEd25519Key {
38    fn fmt(&self, f: &mut core::fmt::Formatter<'_>) -> core::fmt::Result {
39        f.debug_struct("DerivedEd25519Key")
40            .field("public_key", &hex::encode(self.public_key_bytes()))
41            .finish_non_exhaustive()
42    }
43}
44
45impl DerivedEd25519Key {
46    /// Derive the master key from a BIP-39 seed using SLIP-0010.
47    ///
48    /// # Errors
49    ///
50    /// Returns an error if the HMAC key is invalid (should not happen in practice).
51    pub fn from_seed(seed: &[u8]) -> Result<Self, DeriveError> {
52        let mut mac = HmacSha512::new_from_slice(ED25519_CURVE)
53            .map_err(|_| DeriveError::Crypto(String::from("slip10: invalid seed length")))?;
54        mac.update(seed);
55        let result = mac.finalize().into_bytes();
56
57        let (pk_bytes, cc_bytes) = result.split_at(32);
58        let mut private_key = Zeroizing::new([0u8; 32]);
59        let mut chain_code = Zeroizing::new([0u8; 32]);
60        private_key.copy_from_slice(pk_bytes);
61        chain_code.copy_from_slice(cc_bytes);
62
63        Ok(Self {
64            private_key,
65            chain_code,
66        })
67    }
68
69    /// Derive a child key at a hardened index.
70    ///
71    /// SLIP-0010 only supports hardened derivation for Ed25519.
72    /// The hardened flag (`0x8000_0000`) is applied automatically.
73    ///
74    /// # Errors
75    ///
76    /// Returns an error if the HMAC key is invalid.
77    pub fn derive_hardened(&self, index: u32) -> Result<Self, DeriveError> {
78        let hardened_index = index | 0x8000_0000;
79
80        let mut mac = HmacSha512::new_from_slice(&*self.chain_code).map_err(|_| {
81            DeriveError::Crypto(String::from("slip10: chain code HMAC init failed"))
82        })?;
83        mac.update(&[0x00]);
84        mac.update(&*self.private_key);
85        mac.update(&hardened_index.to_be_bytes());
86        let result = mac.finalize().into_bytes();
87
88        let (pk_bytes, cc_bytes) = result.split_at(32);
89        let mut private_key = Zeroizing::new([0u8; 32]);
90        let mut chain_code = Zeroizing::new([0u8; 32]);
91        private_key.copy_from_slice(pk_bytes);
92        chain_code.copy_from_slice(cc_bytes);
93
94        Ok(Self {
95            private_key,
96            chain_code,
97        })
98    }
99
100    /// Derive a key at an arbitrary SLIP-0010 path.
101    ///
102    /// Path format: `m/44'/501'/0'/0'` — **every component must be marked
103    /// hardened** with a trailing `'` or `h`. SLIP-0010 only defines
104    /// hardened derivation for Ed25519, so unhardened components (e.g.
105    /// `"m/44/0"`) are rejected up-front rather than silently promoted.
106    ///
107    /// # Errors
108    ///
109    /// Returns [`DeriveError::Path`] if the path is malformed, contains an
110    /// unhardened segment, or derivation fails.
111    pub fn derive_path(seed: &[u8], path: &str) -> Result<Self, DeriveError> {
112        let trimmed = path.trim();
113        let remainder = if trimmed == "m" {
114            ""
115        } else if let Some(rest) = trimmed.strip_prefix("m/") {
116            rest
117        } else {
118            return Err(DeriveError::Path(String::from(
119                "slip10: path must start with 'm/' or be exactly 'm'",
120            )));
121        };
122
123        let mut current = Self::from_seed(seed)?;
124        for component in remainder.split('/').filter(|s| !s.is_empty()) {
125            let index = parse_hardened_component(component)?;
126            current = current.derive_hardened(index)?;
127        }
128        Ok(current)
129    }
130
131    /// Convert the derived private key to an Ed25519 [`SigningKey`].
132    #[must_use]
133    pub fn to_signing_key(&self) -> SigningKey {
134        SigningKey::from_bytes(&self.private_key)
135    }
136
137    /// Derive the Ed25519 [`VerifyingKey`] (public key).
138    #[must_use]
139    pub fn verifying_key(&self) -> VerifyingKey {
140        self.to_signing_key().verifying_key()
141    }
142
143    /// Get the 32-byte raw private key. Returned copy is zeroized on drop.
144    #[must_use]
145    pub fn private_key_bytes(&self) -> Zeroizing<[u8; 32]> {
146        Zeroizing::new(*self.private_key)
147    }
148
149    /// Get the 32-byte private key as a lowercase hex string. The returned
150    /// [`String`] is zeroized on drop.
151    #[must_use]
152    pub fn private_key_hex(&self) -> Zeroizing<String> {
153        Zeroizing::new(hex::encode(*self.private_key))
154    }
155
156    /// Get the 32-byte Ed25519 public key.
157    #[must_use]
158    pub fn public_key_bytes(&self) -> [u8; 32] {
159        *self.verifying_key().as_bytes()
160    }
161
162    /// Get the 32-byte chain code. Returned copy is zeroized on drop.
163    ///
164    /// Callers rarely need this — it only exists for SLIP-0010 compliance
165    /// testing and advanced derivation schemes.
166    #[must_use]
167    pub fn chain_code_bytes(&self) -> Zeroizing<[u8; 32]> {
168        Zeroizing::new(*self.chain_code)
169    }
170}
171
172/// Parse a SLIP-0010 hardened path component like `"44'"` or `"501h"`.
173///
174/// Rejects unhardened components (no trailing `'` or `h`), because
175/// SLIP-0010 Ed25519 does not define unhardened derivation.
176fn parse_hardened_component(component: &str) -> Result<u32, DeriveError> {
177    let digits = component
178        .strip_suffix('\'')
179        .or_else(|| component.strip_suffix('h'))
180        .ok_or_else(|| {
181            DeriveError::Path(format!(
182                "slip10: path component '{component}' must be hardened (append ' or h); \
183                 Ed25519 does not support unhardened derivation"
184            ))
185        })?;
186
187    if digits.is_empty() {
188        return Err(DeriveError::Path(format!(
189            "slip10: empty index in path component '{component}'"
190        )));
191    }
192
193    let index: u32 = digits
194        .parse()
195        .map_err(|_| DeriveError::Path(format!("slip10: invalid path component: {component}")))?;
196
197    if index & 0x8000_0000 != 0 {
198        return Err(DeriveError::Path(format!(
199            "slip10: path component '{component}' exceeds maximum index 2^31 - 1"
200        )));
201    }
202
203    Ok(index)
204}
205
206#[cfg(test)]
207mod tests {
208    use super::*;
209
210    // SLIP-0010 Test Vector 1 for Ed25519
211    // Reference: https://github.com/satoshilabs/slips/blob/master/slip-0010.md
212    const TV1_SEED: &str = "000102030405060708090a0b0c0d0e0f";
213
214    #[test]
215    fn slip10_vector1_chain_m() {
216        let seed = hex::decode(TV1_SEED).unwrap();
217        let master = DerivedEd25519Key::from_seed(&seed).unwrap();
218        assert_eq!(
219            master.private_key_hex().as_str(),
220            "2b4be7f19ee27bbf30c667b642d5f4aa69fd169872f8fc3059c08ebae2eb19e7"
221        );
222        assert_eq!(
223            hex::encode(*master.chain_code_bytes()),
224            "90046a93de5380a72b5e45010748567d5ea02bbf6522f979e05c0d8d8ca9fffb"
225        );
226    }
227
228    #[test]
229    fn slip10_vector1_chain_m_0h() {
230        let seed = hex::decode(TV1_SEED).unwrap();
231        let derived = DerivedEd25519Key::derive_path(&seed, "m/0'").unwrap();
232        assert_eq!(
233            derived.private_key_hex().as_str(),
234            "68e0fe46dfb67e368c75379acec591dad19df3cde26e63b93a8e704f1dade7a3"
235        );
236        assert_eq!(
237            hex::encode(*derived.chain_code_bytes()),
238            "8b59aa11380b624e81507a27fedda59fea6d0b779a778918a2fd3590e16e9c69"
239        );
240    }
241
242    #[test]
243    fn slip10_vector1_chain_m_0h_1h() {
244        let seed = hex::decode(TV1_SEED).unwrap();
245        let derived = DerivedEd25519Key::derive_path(&seed, "m/0'/1'").unwrap();
246        assert_eq!(
247            derived.private_key_hex().as_str(),
248            "b1d0bad404bf35da785a64ca1ac54b2617211d2777696fbffaf208f746ae84f2"
249        );
250        assert_eq!(
251            hex::encode(*derived.chain_code_bytes()),
252            "a320425f77d1b5c2505a6b1b27382b37368ee640e3557c315416801243552f14"
253        );
254    }
255
256    #[test]
257    fn slip10_vector1_chain_m_0h_1h_2h() {
258        let seed = hex::decode(TV1_SEED).unwrap();
259        let derived = DerivedEd25519Key::derive_path(&seed, "m/0'/1'/2'").unwrap();
260        assert_eq!(
261            derived.private_key_hex().as_str(),
262            "92a5b23c0b8a99e37d07df3fb9966917f5d06e02ddbd909c7e184371463e9fc9"
263        );
264        assert_eq!(
265            hex::encode(*derived.chain_code_bytes()),
266            "2e69929e00b5ab250f49c3fb1c12f252de4fed2c1db88387094a0f8c4c9ccd6c"
267        );
268    }
269
270    #[test]
271    fn slip10_vector1_chain_m_0h_1h_2h_2h() {
272        let seed = hex::decode(TV1_SEED).unwrap();
273        let derived = DerivedEd25519Key::derive_path(&seed, "m/0'/1'/2'/2'").unwrap();
274        assert_eq!(
275            derived.private_key_hex().as_str(),
276            "30d1dc7e5fc04c31219ab25a27ae00b50f6fd66622f6e9c913253d6511d1e662"
277        );
278        assert_eq!(
279            hex::encode(*derived.chain_code_bytes()),
280            "8f6d87f93d750e0efccda017d662a1b31a266e4a6f5993b15f5c1f07f74dd5cc"
281        );
282    }
283
284    #[test]
285    fn slip10_vector1_chain_m_0h_1h_2h_2h_1000000000h() {
286        let seed = hex::decode(TV1_SEED).unwrap();
287        let derived = DerivedEd25519Key::derive_path(&seed, "m/0'/1'/2'/2'/1000000000'").unwrap();
288        assert_eq!(
289            derived.private_key_hex().as_str(),
290            "8f94d394a8e8fd6b1bc2f3f49f5c47e385281d5c17e65324b0f62483e37e8793"
291        );
292        assert_eq!(
293            hex::encode(*derived.chain_code_bytes()),
294            "68789923a0cac2cd5a29172a475fe9e0fb14cd6adb5ad98a3fa70333e7afa230"
295        );
296    }
297
298    // SLIP-0010 Test Vector 2 for Ed25519
299    const TV2_SEED: &str = "fffcf9f6f3f0edeae7e4e1dedbd8d5d2cfccc9c6c3c0bdbab7b4b1aeaba8a5a29f9c999693908d8a8784817e7b7875726f6c696663605d5a5754514e4b484542";
300
301    #[test]
302    fn slip10_vector2_chain_m() {
303        let seed = hex::decode(TV2_SEED).unwrap();
304        let master = DerivedEd25519Key::from_seed(&seed).unwrap();
305        assert_eq!(
306            master.private_key_hex().as_str(),
307            "171cb88b1b3c1db25add599712e36245d75bc65a1a5c9e18d76f9f2b1eab4012"
308        );
309        assert_eq!(
310            hex::encode(*master.chain_code_bytes()),
311            "ef70a74db9c3a5af931b5fe73ed8e1a53464133654fd55e7a66f8570b8e33c3b"
312        );
313    }
314
315    #[test]
316    fn slip10_vector2_chain_m_0h() {
317        let seed = hex::decode(TV2_SEED).unwrap();
318        let derived = DerivedEd25519Key::derive_path(&seed, "m/0'").unwrap();
319        assert_eq!(
320            derived.private_key_hex().as_str(),
321            "1559eb2bbec5790b0c65d8693e4d0875b1747f4970ae8b650486ed7470845635"
322        );
323        assert_eq!(
324            hex::encode(*derived.chain_code_bytes()),
325            "0b78a3226f915c082bf118f83618a618ab6dec793752624cbeb622acb562862d"
326        );
327    }
328
329    #[test]
330    fn master_only_path() {
331        let seed = hex::decode(TV1_SEED).unwrap();
332        let m = DerivedEd25519Key::from_seed(&seed).unwrap();
333        let m2 = DerivedEd25519Key::derive_path(&seed, "m").unwrap();
334        assert_eq!(*m.private_key_bytes(), *m2.private_key_bytes());
335        assert_eq!(*m.chain_code_bytes(), *m2.chain_code_bytes());
336    }
337
338    #[test]
339    fn h_suffix_accepted() {
340        let seed = hex::decode(TV1_SEED).unwrap();
341        let a = DerivedEd25519Key::derive_path(&seed, "m/0'").unwrap();
342        let b = DerivedEd25519Key::derive_path(&seed, "m/0h").unwrap();
343        assert_eq!(*a.private_key_bytes(), *b.private_key_bytes());
344    }
345
346    #[test]
347    fn different_indices_produce_different_keys() {
348        let seed = hex::decode(TV1_SEED).unwrap();
349        let k0 = DerivedEd25519Key::derive_path(&seed, "m/44'/501'/0'").unwrap();
350        let k1 = DerivedEd25519Key::derive_path(&seed, "m/44'/501'/1'").unwrap();
351        assert_ne!(*k0.private_key_bytes(), *k1.private_key_bytes());
352    }
353
354    #[test]
355    fn invalid_path_rejected() {
356        let seed = hex::decode(TV1_SEED).unwrap();
357        assert!(DerivedEd25519Key::derive_path(&seed, "bad/path").is_err());
358        assert!(DerivedEd25519Key::derive_path(&seed, "m/abc").is_err());
359    }
360
361    /// SLIP-0010 only defines hardened derivation for Ed25519. Paths with
362    /// an unhardened component (no trailing `'` or `h`) must be rejected
363    /// rather than silently promoted to hardened — otherwise the same path
364    /// string would derive different keys on SLIP-10 vs a BIP-32 secp256k1
365    /// implementation, confusing downstream users.
366    #[test]
367    fn unhardened_component_rejected() {
368        let seed = hex::decode(TV1_SEED).unwrap();
369        let err = DerivedEd25519Key::derive_path(&seed, "m/44/0").unwrap_err();
370        let DeriveError::Path(msg) = &err else {
371            unreachable!("expected DeriveError::Path, got {err:?}");
372        };
373        assert!(
374            msg.contains("must be hardened"),
375            "unexpected message: {msg}"
376        );
377    }
378
379    /// Mixed paths where *only one* segment is hardened must also be rejected.
380    #[test]
381    fn mixed_hardened_unhardened_rejected() {
382        let seed = hex::decode(TV1_SEED).unwrap();
383        assert!(DerivedEd25519Key::derive_path(&seed, "m/44'/0").is_err());
384        assert!(DerivedEd25519Key::derive_path(&seed, "m/44/0'").is_err());
385    }
386
387    /// Indices greater than `2^31 - 1` cannot fit in the hardened half of
388    /// the 32-bit child index space and must produce a descriptive error.
389    #[test]
390    fn index_overflow_rejected() {
391        let seed = hex::decode(TV1_SEED).unwrap();
392        let err = DerivedEd25519Key::derive_path(&seed, "m/2147483648'").unwrap_err();
393        let DeriveError::Path(msg) = &err else {
394            unreachable!("expected DeriveError::Path, got {err:?}");
395        };
396        assert!(msg.contains("exceeds maximum"), "unexpected message: {msg}");
397    }
398
399    /// A lone apostrophe with no digits is not a valid index.
400    #[test]
401    fn empty_index_rejected() {
402        let seed = hex::decode(TV1_SEED).unwrap();
403        assert!(DerivedEd25519Key::derive_path(&seed, "m/'").is_err());
404        assert!(DerivedEd25519Key::derive_path(&seed, "m/h").is_err());
405    }
406
407    #[test]
408    fn signing_key_roundtrip() {
409        let seed = hex::decode(TV1_SEED).unwrap();
410        let derived = DerivedEd25519Key::from_seed(&seed).unwrap();
411        let sk = derived.to_signing_key();
412        assert_eq!(sk.to_bytes(), *derived.private_key_bytes());
413    }
414
415    #[test]
416    fn verifying_key_matches_public_key_bytes() {
417        let seed = hex::decode(TV1_SEED).unwrap();
418        let derived = DerivedEd25519Key::from_seed(&seed).unwrap();
419        assert_eq!(
420            derived.verifying_key().as_bytes(),
421            &derived.public_key_bytes()
422        );
423    }
424}