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;
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/// SLIP-0010 derived Ed25519 key pair.
25///
26/// Contains a 32-byte private key and chain code. All sensitive fields
27/// are wrapped in [`Zeroizing`] for automatic secure cleanup on drop.
28#[non_exhaustive]
29pub struct DerivedKey {
30    /// 32-byte Ed25519 private key.
31    pub private_key: Zeroizing<[u8; 32]>,
32    /// 32-byte chain code for further derivation.
33    pub chain_code: Zeroizing<[u8; 32]>,
34}
35
36impl core::fmt::Debug for DerivedKey {
37    fn fmt(&self, f: &mut core::fmt::Formatter<'_>) -> core::fmt::Result {
38        let pk = self.to_signing_key().verifying_key();
39        f.debug_struct("DerivedKey")
40            .field("public_key", &hex::encode(pk.as_bytes()))
41            .finish_non_exhaustive()
42    }
43}
44
45impl DerivedKey {
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'` — all components are treated as
103    /// hardened (Ed25519 requirement). Trailing `'` or `h` markers are optional.
104    ///
105    /// # Errors
106    ///
107    /// Returns an error if the path is malformed or derivation fails.
108    pub fn derive_path(seed: &[u8], path: &str) -> Result<Self, DeriveError> {
109        let trimmed = path.trim();
110        let remainder = if trimmed == "m" {
111            ""
112        } else if let Some(rest) = trimmed.strip_prefix("m/") {
113            rest
114        } else {
115            return Err(DeriveError::Path(String::from(
116                "slip10: path must start with 'm/' or be exactly 'm'",
117            )));
118        };
119
120        let mut current = Self::from_seed(seed)?;
121        for component in remainder.split('/').filter(|s| !s.is_empty()) {
122            let index = parse_path_component(component)?;
123            current = current.derive_hardened(index)?;
124        }
125        Ok(current)
126    }
127
128    /// Convert the derived private key to an Ed25519 [`SigningKey`].
129    #[must_use]
130    pub fn to_signing_key(&self) -> SigningKey {
131        SigningKey::from_bytes(&self.private_key)
132    }
133}
134
135/// Parse a single path component like `"44'"` or `"501h"` into a u32 index.
136fn parse_path_component(component: &str) -> Result<u32, DeriveError> {
137    let stripped = component.trim_end_matches('\'').trim_end_matches('h');
138    stripped
139        .parse::<u32>()
140        .map_err(|_| DeriveError::Path(format!("slip10: invalid path component: {component}")))
141}
142
143#[cfg(test)]
144mod tests {
145    use super::*;
146
147    // SLIP-0010 Test Vector 1 for Ed25519
148    // Reference: https://github.com/satoshilabs/slips/blob/master/slip-0010.md
149    const TV1_SEED: &str = "000102030405060708090a0b0c0d0e0f";
150
151    #[test]
152    fn slip10_vector1_chain_m() {
153        let seed = hex::decode(TV1_SEED).unwrap();
154        let master = DerivedKey::from_seed(&seed).unwrap();
155        assert_eq!(
156            hex::encode(*master.private_key),
157            "2b4be7f19ee27bbf30c667b642d5f4aa69fd169872f8fc3059c08ebae2eb19e7"
158        );
159        assert_eq!(
160            hex::encode(*master.chain_code),
161            "90046a93de5380a72b5e45010748567d5ea02bbf6522f979e05c0d8d8ca9fffb"
162        );
163    }
164
165    #[test]
166    fn slip10_vector1_chain_m_0h() {
167        let seed = hex::decode(TV1_SEED).unwrap();
168        let derived = DerivedKey::derive_path(&seed, "m/0'").unwrap();
169        assert_eq!(
170            hex::encode(*derived.private_key),
171            "68e0fe46dfb67e368c75379acec591dad19df3cde26e63b93a8e704f1dade7a3"
172        );
173        assert_eq!(
174            hex::encode(*derived.chain_code),
175            "8b59aa11380b624e81507a27fedda59fea6d0b779a778918a2fd3590e16e9c69"
176        );
177    }
178
179    #[test]
180    fn slip10_vector1_chain_m_0h_1h() {
181        let seed = hex::decode(TV1_SEED).unwrap();
182        let derived = DerivedKey::derive_path(&seed, "m/0'/1'").unwrap();
183        assert_eq!(
184            hex::encode(*derived.private_key),
185            "b1d0bad404bf35da785a64ca1ac54b2617211d2777696fbffaf208f746ae84f2"
186        );
187        assert_eq!(
188            hex::encode(*derived.chain_code),
189            "a320425f77d1b5c2505a6b1b27382b37368ee640e3557c315416801243552f14"
190        );
191    }
192
193    #[test]
194    fn slip10_vector1_chain_m_0h_1h_2h() {
195        let seed = hex::decode(TV1_SEED).unwrap();
196        let derived = DerivedKey::derive_path(&seed, "m/0'/1'/2'").unwrap();
197        assert_eq!(
198            hex::encode(*derived.private_key),
199            "92a5b23c0b8a99e37d07df3fb9966917f5d06e02ddbd909c7e184371463e9fc9"
200        );
201        assert_eq!(
202            hex::encode(*derived.chain_code),
203            "2e69929e00b5ab250f49c3fb1c12f252de4fed2c1db88387094a0f8c4c9ccd6c"
204        );
205    }
206
207    #[test]
208    fn slip10_vector1_chain_m_0h_1h_2h_2h() {
209        let seed = hex::decode(TV1_SEED).unwrap();
210        let derived = DerivedKey::derive_path(&seed, "m/0'/1'/2'/2'").unwrap();
211        assert_eq!(
212            hex::encode(*derived.private_key),
213            "30d1dc7e5fc04c31219ab25a27ae00b50f6fd66622f6e9c913253d6511d1e662"
214        );
215        assert_eq!(
216            hex::encode(*derived.chain_code),
217            "8f6d87f93d750e0efccda017d662a1b31a266e4a6f5993b15f5c1f07f74dd5cc"
218        );
219    }
220
221    #[test]
222    fn slip10_vector1_chain_m_0h_1h_2h_2h_1000000000h() {
223        let seed = hex::decode(TV1_SEED).unwrap();
224        let derived = DerivedKey::derive_path(&seed, "m/0'/1'/2'/2'/1000000000'").unwrap();
225        assert_eq!(
226            hex::encode(*derived.private_key),
227            "8f94d394a8e8fd6b1bc2f3f49f5c47e385281d5c17e65324b0f62483e37e8793"
228        );
229        assert_eq!(
230            hex::encode(*derived.chain_code),
231            "68789923a0cac2cd5a29172a475fe9e0fb14cd6adb5ad98a3fa70333e7afa230"
232        );
233    }
234
235    // SLIP-0010 Test Vector 2 for Ed25519
236    const TV2_SEED: &str = "fffcf9f6f3f0edeae7e4e1dedbd8d5d2cfccc9c6c3c0bdbab7b4b1aeaba8a5a29f9c999693908d8a8784817e7b7875726f6c696663605d5a5754514e4b484542";
237
238    #[test]
239    fn slip10_vector2_chain_m() {
240        let seed = hex::decode(TV2_SEED).unwrap();
241        let master = DerivedKey::from_seed(&seed).unwrap();
242        assert_eq!(
243            hex::encode(*master.private_key),
244            "171cb88b1b3c1db25add599712e36245d75bc65a1a5c9e18d76f9f2b1eab4012"
245        );
246        assert_eq!(
247            hex::encode(*master.chain_code),
248            "ef70a74db9c3a5af931b5fe73ed8e1a53464133654fd55e7a66f8570b8e33c3b"
249        );
250    }
251
252    #[test]
253    fn slip10_vector2_chain_m_0h() {
254        let seed = hex::decode(TV2_SEED).unwrap();
255        let derived = DerivedKey::derive_path(&seed, "m/0'").unwrap();
256        assert_eq!(
257            hex::encode(*derived.private_key),
258            "1559eb2bbec5790b0c65d8693e4d0875b1747f4970ae8b650486ed7470845635"
259        );
260        assert_eq!(
261            hex::encode(*derived.chain_code),
262            "0b78a3226f915c082bf118f83618a618ab6dec793752624cbeb622acb562862d"
263        );
264    }
265
266    #[test]
267    fn master_only_path() {
268        let seed = hex::decode(TV1_SEED).unwrap();
269        let m = DerivedKey::from_seed(&seed).unwrap();
270        let m2 = DerivedKey::derive_path(&seed, "m").unwrap();
271        assert_eq!(*m.private_key, *m2.private_key);
272        assert_eq!(*m.chain_code, *m2.chain_code);
273    }
274
275    #[test]
276    fn h_suffix_accepted() {
277        let seed = hex::decode(TV1_SEED).unwrap();
278        let a = DerivedKey::derive_path(&seed, "m/0'").unwrap();
279        let b = DerivedKey::derive_path(&seed, "m/0h").unwrap();
280        assert_eq!(*a.private_key, *b.private_key);
281    }
282
283    #[test]
284    fn different_indices_produce_different_keys() {
285        let seed = hex::decode(TV1_SEED).unwrap();
286        let k0 = DerivedKey::derive_path(&seed, "m/44'/501'/0'").unwrap();
287        let k1 = DerivedKey::derive_path(&seed, "m/44'/501'/1'").unwrap();
288        assert_ne!(*k0.private_key, *k1.private_key);
289    }
290
291    #[test]
292    fn invalid_path_rejected() {
293        let seed = hex::decode(TV1_SEED).unwrap();
294        assert!(DerivedKey::derive_path(&seed, "bad/path").is_err());
295        assert!(DerivedKey::derive_path(&seed, "m/abc").is_err());
296    }
297
298    #[test]
299    fn signing_key_roundtrip() {
300        let seed = hex::decode(TV1_SEED).unwrap();
301        let derived = DerivedKey::from_seed(&seed).unwrap();
302        let sk = derived.to_signing_key();
303        assert_eq!(sk.to_bytes(), *derived.private_key);
304    }
305}