Skip to main content

vti_common/slip10/
mod.rs

1//! SLIP-0010 hierarchical deterministic key derivation for Ed25519.
2//!
3//! This is the derivation scheme the whole workspace's key hierarchy rests on:
4//! every VTA key lives at some `m/26'/2'/<ctx>'/<key>'` under the master seed,
5//! and the VTA re-derives them from that seed on every restart. Output here is
6//! therefore a compatibility surface with every DID we have ever published —
7//! see the byte-exactness note under "Provenance" below.
8//!
9//! # Why this exists in-tree
10//!
11//! This replaces the `ed25519-dalek-bip32` crate (plus its `derivation-path`
12//! dependency), which pinned `ed25519-dalek 2.x` and so straddled the
13//! workspace's move to `ed25519-dalek 3` / `curve25519-dalek 5`: the derived
14//! `SigningKey` was a *different type* from the `SigningKey` every consumer
15//! used, and only passed between them because both expose raw `[u8; 32]`. That
16//! type-punning is now gone — this module derives directly into the workspace's
17//! own `ed25519-dalek`, and uses `hmac 0.13` / `sha2 0.11` rather than the
18//! `hmac 0.12` / `sha2 0.10` the old crate held.
19//!
20//! # Scheme
21//!
22//! SLIP-0010 for the `ed25519` curve, which is BIP-32 adapted to Ed25519:
23//!
24//! - Master key: `HMAC-SHA512(key = "ed25519 seed", data = seed)`, split into
25//!   a 32-byte key and a 32-byte chain code.
26//! - Child key: `HMAC-SHA512(key = chain_code, data = 0x00 || key || index_be)`,
27//!   split the same way.
28//! - **Hardened derivation only.** Ed25519 has no public-key child derivation,
29//!   so a non-hardened index is a hard error, not a silent hardening. That is a
30//!   security property worth stating: it means a leaked extended *public* key
31//!   can never be walked to sibling or child keys.
32//!
33//! Note that this is *not* the scheme in the similarly-named `ed25519-bip32`
34//! crate, which implements BIP32-Ed25519 (Khovratovich–Law, as used by
35//! Cardano). That scheme derives different key material from the same seed and
36//! path, and its extended keys have no 32-byte seed to hand to `did:key`.
37//! Swapping to it would re-key every VTA. Do not "upgrade" to it.
38//!
39//! # Provenance
40//!
41//! Correctness is pinned to the published SLIP-0010 Ed25519 test vectors
42//! (<https://github.com/satoshilabs/slips/blob/master/slip-0010.md>), reproduced
43//! verbatim in this module's tests. The spec is frozen, so those vectors are a
44//! permanent oracle — this code should never need to change to stay correct.
45//!
46//! Byte-exactness against the crate this replaces is pinned separately, and by
47//! something stronger than a parity test: `vta-keys/src/derivation.rs` already
48//! asserts hard-coded multibase key strings (e.g.
49//! `z6MkestKNR7EyyB8yojbPcRoG8rF6iX4uXYkyVbDBsM9Fj5i` at `m/44'/0'/0'`) that
50//! were generated by `ed25519-dalek-bip32` before this module existed. Those
51//! are the workspace's own derived values rather than a synthetic comparison,
52//! so if this module ever diverges, real VTA keys fail to re-derive and those
53//! tests go red. Do not weaken or regenerate them.
54
55mod path;
56
57pub use path::{
58    ChildIndex, ChildIndexError, ChildIndexParseError, DerivationPath, DerivationPathParseError,
59};
60
61use ed25519_dalek::{SigningKey, VerifyingKey};
62use hmac::{Hmac, KeyInit, Mac};
63use sha2::Sha512;
64use std::fmt;
65use zeroize::Zeroizing;
66
67type HmacSha512 = Hmac<Sha512>;
68
69/// SLIP-0010's fixed HMAC key for the Ed25519 curve. Changing this changes
70/// every key the workspace has ever derived.
71const ED25519_CURVE: &[u8] = b"ed25519 seed";
72
73/// Minimum master-seed length, in bytes.
74///
75/// SLIP-0010 specifies a seed of 128–512 bits. We enforce only the floor: a
76/// shorter seed is unambiguously a security defect and should fail loudly,
77/// whereas rejecting a *longer* one would break an existing store for no
78/// security benefit (HMAC accepts a key of any length). In practice the
79/// workspace feeds this either 32 random bytes or a 64-byte BIP-39 seed.
80pub const MIN_SEED_LEN: usize = 16;
81
82/// Failure during SLIP-0010 derivation.
83#[derive(Debug, thiserror::Error)]
84pub enum Slip10Error {
85    /// A non-hardened index was used. Ed25519 supports hardened derivation
86    /// only; this is refused rather than silently hardened.
87    #[error("expected hardened child index: {0}")]
88    ExpectedHardenedIndex(ChildIndex),
89    /// The master seed was shorter than [`MIN_SEED_LEN`].
90    #[error("master seed is {got} bytes, need at least {min}")]
91    SeedTooShort {
92        /// Length actually supplied.
93        got: usize,
94        /// Required minimum ([`MIN_SEED_LEN`]).
95        min: usize,
96    },
97}
98
99/// An Ed25519 signing key plus the chain code needed to derive its children.
100///
101/// `signing_key` holds the 32-byte *seed* form of the Ed25519 key (what
102/// `SigningKey::as_bytes` returns), which is what `did:key` encoding and
103/// `Secret::generate_ed25519` consume — the reason SLIP-0010 fits this
104/// workspace and BIP32-Ed25519 does not.
105pub struct ExtendedSigningKey {
106    /// Derivation depth: 0 for the master key.
107    pub depth: u8,
108    /// The index this key was derived under. `Normal(0)` for the master key,
109    /// which matches SLIP-0010 and the crate this replaces.
110    pub child_index: ChildIndex,
111    /// The Ed25519 signing key at this path.
112    pub signing_key: SigningKey,
113    /// The chain code, mixed into every child derivation.
114    pub chain_code: [u8; 32],
115}
116
117impl ExtendedSigningKey {
118    /// Derive the master key from a seed.
119    ///
120    /// Errors if the seed is shorter than [`MIN_SEED_LEN`].
121    pub fn from_seed(seed: &[u8]) -> Result<Self, Slip10Error> {
122        if seed.len() < MIN_SEED_LEN {
123            return Err(Slip10Error::SeedTooShort {
124                got: seed.len(),
125                min: MIN_SEED_LEN,
126            });
127        }
128
129        let mut mac = <HmacSha512 as KeyInit>::new_from_slice(ED25519_CURVE)
130            .expect("HMAC accepts a key of any length");
131        mac.update(seed);
132
133        Ok(Self::split(&mac_output(mac), 0, ChildIndex::Normal(0)))
134    }
135
136    /// Derive along a whole path. An empty path returns a clone of `self`.
137    pub fn derive<P: AsRef<[ChildIndex]>>(&self, path: &P) -> Result<Self, Slip10Error> {
138        let mut next = self.clone();
139        for index in path.as_ref() {
140            next = next.derive_child(*index)?;
141        }
142        Ok(next)
143    }
144
145    /// Derive a single child.
146    ///
147    /// Refuses non-hardened indexes — Ed25519 has no normal derivation.
148    pub fn derive_child(&self, index: ChildIndex) -> Result<Self, Slip10Error> {
149        if index.is_normal() {
150            return Err(Slip10Error::ExpectedHardenedIndex(index));
151        }
152
153        let mut mac = <HmacSha512 as KeyInit>::new_from_slice(&self.chain_code)
154            .expect("HMAC accepts a key of any length");
155        // SLIP-0010 hardened child data: 0x00 || key || ser32(index).
156        mac.update(&[0u8]);
157        mac.update(self.signing_key.as_bytes());
158        mac.update(&index.to_bits().to_be_bytes());
159
160        Ok(Self::split(
161            &mac_output(mac),
162            self.depth.wrapping_add(1),
163            index,
164        ))
165    }
166
167    /// The public key for this signing key.
168    #[inline]
169    pub fn verifying_key(&self) -> VerifyingKey {
170        self.signing_key.verifying_key()
171    }
172
173    /// Split a 64-byte HMAC output into `(key, chain_code)`.
174    fn split(bytes: &[u8; 64], depth: u8, child_index: ChildIndex) -> Self {
175        let mut key = Zeroizing::new([0u8; 32]);
176        key.copy_from_slice(&bytes[..32]);
177
178        let mut chain_code = [0u8; 32];
179        chain_code.copy_from_slice(&bytes[32..]);
180
181        Self {
182            depth,
183            child_index,
184            signing_key: SigningKey::from_bytes(&key),
185            chain_code,
186        }
187    }
188}
189
190impl Clone for ExtendedSigningKey {
191    fn clone(&self) -> Self {
192        Self {
193            depth: self.depth,
194            child_index: self.child_index,
195            signing_key: SigningKey::from_bytes(self.signing_key.as_bytes()),
196            chain_code: self.chain_code,
197        }
198    }
199}
200
201/// Redacted on purpose: the crate this replaces derived `Debug`, which prints
202/// the private key bytes. Anything that logged an `ExtendedSigningKey` — or a
203/// struct containing one — leaked a key into the log.
204impl fmt::Debug for ExtendedSigningKey {
205    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
206        f.debug_struct("ExtendedSigningKey")
207            .field("depth", &self.depth)
208            .field("child_index", &self.child_index)
209            .field("signing_key", &"<redacted>")
210            .field("chain_code", &"<redacted>")
211            .finish()
212    }
213}
214
215/// Take the 64-byte HMAC output into a zeroizing buffer.
216///
217/// The `finalize()` temporary is not itself zeroized (its type does not
218/// implement `Zeroize`), but it dies at the end of this expression, and the
219/// copy every caller actually holds is wiped on drop.
220fn mac_output(mac: HmacSha512) -> Zeroizing<[u8; 64]> {
221    let mut out = Zeroizing::new([0u8; 64]);
222    out.copy_from_slice(&mac.finalize().into_bytes());
223    out
224}
225
226#[cfg(test)]
227mod tests {
228    use super::*;
229
230    fn hex32(s: &str) -> [u8; 32] {
231        let v = hex::decode(s).expect("valid hex");
232        v.try_into().expect("32 bytes")
233    }
234
235    fn root(seed_hex: &str) -> ExtendedSigningKey {
236        ExtendedSigningKey::from_seed(&hex::decode(seed_hex).expect("valid hex")).expect("root key")
237    }
238
239    /// Assert one step of a published SLIP-0010 vector.
240    fn assert_node(
241        node: &ExtendedSigningKey,
242        depth: u8,
243        child_index: ChildIndex,
244        chain_code: &str,
245        secret: &str,
246        public: &str,
247    ) {
248        assert_eq!(node.depth, depth, "depth");
249        assert_eq!(node.child_index, child_index, "child index");
250        assert_eq!(node.chain_code, hex32(chain_code), "chain code");
251        assert_eq!(node.signing_key.to_bytes(), hex32(secret), "private key");
252        assert_eq!(node.verifying_key().to_bytes(), hex32(public), "public key");
253    }
254
255    // ---------------------------------------------------------------------
256    // Published SLIP-0010 test vectors for the ed25519 curve.
257    // Source: https://github.com/satoshilabs/slips/blob/master/slip-0010.md
258    // These are the permanent correctness oracle for this module.
259    // ---------------------------------------------------------------------
260
261    #[test]
262    fn slip10_test_vector_1_ed25519() {
263        let node = root("000102030405060708090a0b0c0d0e0f");
264        assert_node(
265            &node,
266            0,
267            ChildIndex::Normal(0),
268            "90046a93de5380a72b5e45010748567d5ea02bbf6522f979e05c0d8d8ca9fffb",
269            "2b4be7f19ee27bbf30c667b642d5f4aa69fd169872f8fc3059c08ebae2eb19e7",
270            "a4b2856bfec510abab89753fac1ac0e1112364e7d250545963f135f2a33188ed",
271        );
272
273        let node = node.derive_child(ChildIndex::Hardened(0)).unwrap();
274        assert_node(
275            &node,
276            1,
277            ChildIndex::Hardened(0),
278            "8b59aa11380b624e81507a27fedda59fea6d0b779a778918a2fd3590e16e9c69",
279            "68e0fe46dfb67e368c75379acec591dad19df3cde26e63b93a8e704f1dade7a3",
280            "8c8a13df77a28f3445213a0f432fde644acaa215fc72dcdf300d5efaa85d350c",
281        );
282
283        let node = node.derive_child(ChildIndex::Hardened(1)).unwrap();
284        assert_node(
285            &node,
286            2,
287            ChildIndex::Hardened(1),
288            "a320425f77d1b5c2505a6b1b27382b37368ee640e3557c315416801243552f14",
289            "b1d0bad404bf35da785a64ca1ac54b2617211d2777696fbffaf208f746ae84f2",
290            "1932a5270f335bed617d5b935c80aedb1a35bd9fc1e31acafd5372c30f5c1187",
291        );
292
293        let node = node.derive_child(ChildIndex::Hardened(2)).unwrap();
294        assert_node(
295            &node,
296            3,
297            ChildIndex::Hardened(2),
298            "2e69929e00b5ab250f49c3fb1c12f252de4fed2c1db88387094a0f8c4c9ccd6c",
299            "92a5b23c0b8a99e37d07df3fb9966917f5d06e02ddbd909c7e184371463e9fc9",
300            "ae98736566d30ed0e9d2f4486a64bc95740d89c7db33f52121f8ea8f76ff0fc1",
301        );
302
303        let node = node.derive_child(ChildIndex::Hardened(2)).unwrap();
304        assert_node(
305            &node,
306            4,
307            ChildIndex::Hardened(2),
308            "8f6d87f93d750e0efccda017d662a1b31a266e4a6f5993b15f5c1f07f74dd5cc",
309            "30d1dc7e5fc04c31219ab25a27ae00b50f6fd66622f6e9c913253d6511d1e662",
310            "8abae2d66361c879b900d204ad2cc4984fa2aa344dd7ddc46007329ac76c429c",
311        );
312
313        let node = node.derive_child(ChildIndex::Hardened(1000000000)).unwrap();
314        assert_node(
315            &node,
316            5,
317            ChildIndex::Hardened(1000000000),
318            "68789923a0cac2cd5a29172a475fe9e0fb14cd6adb5ad98a3fa70333e7afa230",
319            "8f94d394a8e8fd6b1bc2f3f49f5c47e385281d5c17e65324b0f62483e37e8793",
320            "3c24da049451555d51a7014a37337aa4e12d41e485abccfa46b47dfb2af54b7a",
321        );
322    }
323
324    #[test]
325    fn slip10_test_vector_2_ed25519() {
326        let node = root(
327            "fffcf9f6f3f0edeae7e4e1dedbd8d5d2cfccc9c6c3c0bdbab7b4b1aeaba8a5a29f9c99969390\
328             8d8a8784817e7b7875726f6c696663605d5a5754514e4b484542",
329        );
330        assert_node(
331            &node,
332            0,
333            ChildIndex::Normal(0),
334            "ef70a74db9c3a5af931b5fe73ed8e1a53464133654fd55e7a66f8570b8e33c3b",
335            "171cb88b1b3c1db25add599712e36245d75bc65a1a5c9e18d76f9f2b1eab4012",
336            "8fe9693f8fa62a4305a140b9764c5ee01e455963744fe18204b4fb948249308a",
337        );
338
339        let node = node.derive_child(ChildIndex::Hardened(0)).unwrap();
340        assert_node(
341            &node,
342            1,
343            ChildIndex::Hardened(0),
344            "0b78a3226f915c082bf118f83618a618ab6dec793752624cbeb622acb562862d",
345            "1559eb2bbec5790b0c65d8693e4d0875b1747f4970ae8b650486ed7470845635",
346            "86fab68dcb57aa196c77c5f264f215a112c22a912c10d123b0d03c3c28ef1037",
347        );
348
349        let node = node.derive_child(ChildIndex::Hardened(2147483647)).unwrap();
350        assert_node(
351            &node,
352            2,
353            ChildIndex::Hardened(2147483647),
354            "138f0b2551bcafeca6ff2aa88ba8ed0ed8de070841f0c4ef0165df8181eaad7f",
355            "ea4f5bfe8694d8bb74b7b59404632fd5968b774ed545e810de9c32a4fb4192f4",
356            "5ba3b9ac6e90e83effcd25ac4e58a1365a9e35a3d3ae5eb07b9e4d90bcf7506d",
357        );
358
359        let node = node.derive_child(ChildIndex::Hardened(1)).unwrap();
360        assert_node(
361            &node,
362            3,
363            ChildIndex::Hardened(1),
364            "73bd9fff1cfbde33a1b846c27085f711c0fe2d66fd32e139d3ebc28e5a4a6b90",
365            "3757c7577170179c7868353ada796c839135b3d30554bbb74a4b1e4a5a58505c",
366            "2e66aa57069c86cc18249aecf5cb5a9cebbfd6fadeab056254763874a9352b45",
367        );
368
369        let node = node.derive_child(ChildIndex::Hardened(2147483646)).unwrap();
370        assert_node(
371            &node,
372            4,
373            ChildIndex::Hardened(2147483646),
374            "0902fe8a29f9140480a00ef244bd183e8a13288e4412d8389d140aac1794825a",
375            "5837736c89570de861ebc173b1086da4f505d4adb387c6a1b1342d5e4ac9ec72",
376            "e33c0f7d81d843c572275f287498e8d408654fdf0d1e065b84e2e6f157aab09b",
377        );
378
379        let node = node.derive_child(ChildIndex::Hardened(2)).unwrap();
380        assert_node(
381            &node,
382            5,
383            ChildIndex::Hardened(2),
384            "5d70af781f3a37b829f0d060924d5e960bdc02e85423494afc0b1a41bbe196d4",
385            "551d333177df541ad876a60ea71f00447931c0a9da16f227c11ea080d7391b8d",
386            "47150c75db263559a70d5778bf36abbab30fb061ad69f69ece61a72b0cfa4fc0",
387        );
388    }
389
390    /// Walking a whole `DerivationPath` must equal stepping child-by-child.
391    #[test]
392    fn derive_by_path_matches_stepwise_derivation() {
393        let node = root("000102030405060708090a0b0c0d0e0f");
394
395        let path: DerivationPath = "m/0'/1'/2'/2'/1000000000'".parse().unwrap();
396        let by_path = node.derive(&path).unwrap();
397
398        let mut stepwise = node.clone();
399        for index in [0, 1, 2, 2, 1000000000] {
400            stepwise = stepwise.derive_child(ChildIndex::Hardened(index)).unwrap();
401        }
402
403        assert_eq!(
404            by_path.signing_key.to_bytes(),
405            stepwise.signing_key.to_bytes()
406        );
407        assert_eq!(by_path.chain_code, stepwise.chain_code);
408        assert_eq!(by_path.depth, 5);
409    }
410
411    #[test]
412    fn the_empty_path_is_the_master_key() {
413        let node = root("000102030405060708090a0b0c0d0e0f");
414        let path: DerivationPath = "m".parse().unwrap();
415        let derived = node.derive(&path).unwrap();
416
417        assert_eq!(derived.signing_key.to_bytes(), node.signing_key.to_bytes());
418        assert_eq!(derived.chain_code, node.chain_code);
419        assert_eq!(derived.depth, 0);
420    }
421
422    // ---------------------------------------------------------------------
423    // Hardened-only enforcement. Ed25519 cannot do public child derivation,
424    // so a normal index must be an error and never a silent hardening.
425    // ---------------------------------------------------------------------
426
427    #[test]
428    fn a_normal_child_index_is_refused() {
429        let node = root("000102030405060708090a0b0c0d0e0f");
430
431        assert!(matches!(
432            node.derive_child(ChildIndex::Normal(0)),
433            Err(Slip10Error::ExpectedHardenedIndex(ChildIndex::Normal(0)))
434        ));
435        assert!(matches!(
436            node.derive_child(ChildIndex::Normal(100000)),
437            Err(Slip10Error::ExpectedHardenedIndex(ChildIndex::Normal(
438                100000
439            )))
440        ));
441    }
442
443    #[test]
444    fn a_normal_index_mid_path_is_refused() {
445        let node = root("000102030405060708090a0b0c0d0e0f");
446        let soft_path: DerivationPath = "m/0'/1'/2'/3/4'".parse().unwrap();
447
448        assert!(matches!(
449            node.derive(&soft_path),
450            Err(Slip10Error::ExpectedHardenedIndex(ChildIndex::Normal(3)))
451        ));
452    }
453
454    // ---------------------------------------------------------------------
455    // Seed handling.
456    // ---------------------------------------------------------------------
457
458    #[test]
459    fn a_short_seed_is_refused() {
460        let err = ExtendedSigningKey::from_seed(&[0u8; 15]).unwrap_err();
461        assert!(matches!(
462            err,
463            Slip10Error::SeedTooShort { got: 15, min: 16 }
464        ));
465        // The floor itself is accepted.
466        assert!(ExtendedSigningKey::from_seed(&[0u8; 16]).is_ok());
467    }
468
469    #[test]
470    fn the_seed_lengths_this_workspace_actually_uses_are_accepted() {
471        // 32 random bytes (`load_or_generate_seed`) and a 64-byte BIP-39 seed.
472        assert!(ExtendedSigningKey::from_seed(&[7u8; 32]).is_ok());
473        assert!(ExtendedSigningKey::from_seed(&[7u8; 64]).is_ok());
474    }
475
476    #[test]
477    fn different_seeds_give_different_master_keys() {
478        let a = ExtendedSigningKey::from_seed(&[1u8; 32]).unwrap();
479        let b = ExtendedSigningKey::from_seed(&[2u8; 32]).unwrap();
480        assert_ne!(a.signing_key.to_bytes(), b.signing_key.to_bytes());
481        assert_ne!(a.chain_code, b.chain_code);
482    }
483
484    #[test]
485    fn derivation_is_deterministic_across_calls() {
486        let path: DerivationPath = "m/26'/2'/0'/1'".parse().unwrap();
487        let first = ExtendedSigningKey::from_seed(&[9u8; 32])
488            .unwrap()
489            .derive(&path)
490            .unwrap();
491        let second = ExtendedSigningKey::from_seed(&[9u8; 32])
492            .unwrap()
493            .derive(&path)
494            .unwrap();
495
496        assert_eq!(first.signing_key.to_bytes(), second.signing_key.to_bytes());
497        assert_eq!(first.chain_code, second.chain_code);
498    }
499
500    #[test]
501    fn sibling_paths_give_independent_keys() {
502        let root = ExtendedSigningKey::from_seed(&[3u8; 32]).unwrap();
503        let a = root
504            .derive(&"m/26'/2'/0'/0'".parse::<DerivationPath>().unwrap())
505            .unwrap();
506        let b = root
507            .derive(&"m/26'/2'/0'/1'".parse::<DerivationPath>().unwrap())
508            .unwrap();
509
510        assert_ne!(a.signing_key.to_bytes(), b.signing_key.to_bytes());
511        assert_ne!(a.chain_code, b.chain_code);
512    }
513
514    // ---------------------------------------------------------------------
515    // Hygiene.
516    // ---------------------------------------------------------------------
517
518    #[test]
519    fn clone_preserves_the_whole_node() {
520        let node = root("000102030405060708090a0b0c0d0e0f")
521            .derive_child(ChildIndex::Hardened(26))
522            .unwrap();
523        let cloned = node.clone();
524
525        assert_eq!(cloned.depth, node.depth);
526        assert_eq!(cloned.child_index, node.child_index);
527        assert_eq!(cloned.chain_code, node.chain_code);
528        assert_eq!(cloned.signing_key.to_bytes(), node.signing_key.to_bytes());
529    }
530
531    /// A key must never reach a log through `Debug`.
532    #[test]
533    fn debug_does_not_leak_key_material() {
534        let node = root("000102030405060708090a0b0c0d0e0f");
535        let rendered = format!("{node:?}");
536
537        assert!(rendered.contains("<redacted>"));
538        assert!(
539            !rendered.contains("2b4be7f1"),
540            "private key leaked into Debug output: {rendered}"
541        );
542        // The chain code is a derivation secret too.
543        assert!(
544            !rendered.contains("90046a93"),
545            "chain code leaked into Debug output: {rendered}"
546        );
547    }
548}