kobe_primitives/
slip10.rs1use 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
18type HmacSha512 = Hmac<Sha512>;
20
21const ED25519_CURVE: &[u8] = b"ed25519 seed";
23
24#[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 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 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 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 #[must_use]
133 pub fn to_signing_key(&self) -> SigningKey {
134 SigningKey::from_bytes(&self.private_key)
135 }
136
137 #[must_use]
139 pub fn verifying_key(&self) -> VerifyingKey {
140 self.to_signing_key().verifying_key()
141 }
142
143 #[must_use]
145 pub fn private_key_bytes(&self) -> Zeroizing<[u8; 32]> {
146 Zeroizing::new(*self.private_key)
147 }
148
149 #[must_use]
152 pub fn private_key_hex(&self) -> Zeroizing<String> {
153 Zeroizing::new(hex::encode(*self.private_key))
154 }
155
156 #[must_use]
158 pub fn public_key_bytes(&self) -> [u8; 32] {
159 *self.verifying_key().as_bytes()
160 }
161
162 #[must_use]
167 pub fn chain_code_bytes(&self) -> Zeroizing<[u8; 32]> {
168 Zeroizing::new(*self.chain_code)
169 }
170}
171
172fn 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 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 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 #[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 #[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 #[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 #[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}