Skip to main content

confium_tc_keys/
threshold_bip32.rs

1//! Threshold BIP-32 HD key derivation.
2//!
3//! BIP-32 hierarchical deterministic key derivation in the threshold
4//! setting. Each party derives child key shares without reconstructing
5//! the parent key.
6
7use p256::elliptic_curve::PrimeField;
8use p256::{FieldBytes, Scalar};
9use serde::{Deserialize, Serialize};
10use sha2::{Digest, Sha256};
11
12/// A derivation path component (index + hardened flag).
13#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
14pub struct PathIndex {
15    pub index: u32,
16    pub hardened: bool,
17}
18
19/// A BIP-32 derivation path.
20#[derive(Debug, Clone, Serialize, Deserialize)]
21pub struct DerivationPath {
22    pub components: Vec<PathIndex>,
23}
24
25impl DerivationPath {
26    pub fn new() -> Self {
27        Self {
28            components: Vec::new(),
29        }
30    }
31
32    pub fn push(&mut self, index: u32, hardened: bool) -> &mut Self {
33        self.components.push(PathIndex { index, hardened });
34        self
35    }
36
37    pub fn depth(&self) -> usize {
38        self.components.len()
39    }
40}
41
42impl Default for DerivationPath {
43    fn default() -> Self {
44        Self::new()
45    }
46}
47
48/// Derive a child scalar from a parent scalar and path component.
49/// Uses HMAC-SHA256 based derivation (BIP-32 style adapted for P-256).
50/// Reduce 32 bytes to a scalar by rejection sampling with re-hash.
51/// Never falls back to a constant: a zero result here would void the
52/// derivation or proof guarantees.
53fn reduce_to_scalar(mut bytes: [u8; 32]) -> Scalar {
54    loop {
55        if let Some(s) = Option::<Scalar>::from(Scalar::from_repr(FieldBytes::from(bytes))) {
56            return s;
57        }
58        let mut h = Sha256::new();
59        h.update(b"confium-scalar-reduce-v1");
60        h.update(bytes);
61        bytes = h.finalize().into();
62    }
63}
64
65pub fn derive_child_scalar(parent: &Scalar, component: &PathIndex) -> Scalar {
66    let parent_bytes = parent.to_repr();
67    let mut hasher = Sha256::new();
68    hasher.update(b"confium-bip32-v1");
69    hasher.update(parent_bytes);
70    if component.hardened {
71        hasher.update(b"H");
72    } else {
73        hasher.update(b"N");
74    }
75    hasher.update(component.index.to_be_bytes());
76    let hash = hasher.finalize();
77
78    let bytes: [u8; 32] = hash.into();
79    reduce_to_scalar(bytes)
80}
81
82/// Derive a scalar from a parent following a full path.
83pub fn derive_path(parent: &Scalar, path: &DerivationPath) -> Scalar {
84    let mut current = *parent;
85    for component in &path.components {
86        current = derive_child_scalar(&current, component);
87    }
88    current
89}
90
91/// Each party in a threshold quorum derives their child share
92/// independently. Since derivation is deterministic, all parties
93/// at the same path index derive consistently.
94pub fn derive_party_share(parent_share: &Scalar, party_idx: u32, path: &DerivationPath) -> Scalar {
95    let mut current = *parent_share;
96    for component in &path.components {
97        let mut c = *component;
98        // Mix in party index so each party gets a distinct child share
99        let mixed_index = c.index.wrapping_add(party_idx);
100        c.index = mixed_index;
101        current = derive_child_scalar(&current, &c);
102    }
103    current
104}
105
106#[cfg(test)]
107mod tests {
108    use super::*;
109    use getrandom::SysRng;
110    use p256::elliptic_curve::Field;
111    use p256::elliptic_curve::rand_core::UnwrapErr;
112
113    fn random_scalar() -> Scalar {
114        Scalar::random(&mut UnwrapErr(SysRng))
115    }
116
117    #[test]
118    fn derivation_is_deterministic() {
119        let parent = random_scalar();
120        let mut path = DerivationPath::new();
121        path.push(0, false).push(1, true);
122        let child1 = derive_path(&parent, &path);
123        let child2 = derive_path(&parent, &path);
124        assert_eq!(child1, child2);
125    }
126
127    #[test]
128    fn different_parents_different_children() {
129        let p1 = random_scalar();
130        let p2 = random_scalar();
131        let path = DerivationPath::new();
132        let c1 = derive_path(&p1, &path);
133        let c2 = derive_path(&p2, &path);
134        assert_ne!(c1, c2);
135    }
136
137    #[test]
138    fn different_paths_different_children() {
139        let parent = random_scalar();
140        let mut path1 = DerivationPath::new();
141        path1.push(0, false);
142        let mut path2 = DerivationPath::new();
143        path2.push(1, false);
144        assert_ne!(derive_path(&parent, &path1), derive_path(&parent, &path2));
145    }
146
147    #[test]
148    fn hardened_vs_unhardened_differ() {
149        let parent = random_scalar();
150        let mut h = DerivationPath::new();
151        h.push(0, true);
152        let mut n = DerivationPath::new();
153        n.push(0, false);
154        assert_ne!(derive_path(&parent, &h), derive_path(&parent, &n));
155    }
156
157    #[test]
158    fn empty_path_returns_parent() {
159        let parent = random_scalar();
160        let path = DerivationPath::new();
161        assert_eq!(derive_path(&parent, &path), parent);
162    }
163
164    #[test]
165    fn depth_counts_components() {
166        let mut path = DerivationPath::new();
167        assert_eq!(path.depth(), 0);
168        path.push(0, false);
169        assert_eq!(path.depth(), 1);
170        path.push(1, true);
171        assert_eq!(path.depth(), 2);
172    }
173
174    #[test]
175    fn party_shares_differ_by_party() {
176        let parent = random_scalar();
177        let mut path = DerivationPath::new();
178        path.push(0, false);
179        let s1 = derive_party_share(&parent, 1, &path);
180        let s2 = derive_party_share(&parent, 2, &path);
181        assert_ne!(s1, s2);
182    }
183
184    #[test]
185    fn party_share_deterministic() {
186        let parent = random_scalar();
187        let mut path = DerivationPath::new();
188        path.push(42, true);
189        let s1 = derive_party_share(&parent, 1, &path);
190        let s2 = derive_party_share(&parent, 1, &path);
191        assert_eq!(s1, s2);
192    }
193
194    #[test]
195    fn deep_path_works() {
196        let parent = random_scalar();
197        let mut path = DerivationPath::new();
198        for i in 0..10 {
199            path.push(i, i % 2 == 0);
200        }
201        let child = derive_path(&parent, &path);
202        // Just verify it doesn't panic and produces a valid scalar
203        let _bytes = child.to_repr();
204    }
205
206    #[test]
207    fn path_serializes() {
208        let mut path = DerivationPath::new();
209        path.push(0, false).push(1, true);
210        let json = serde_json::to_string(&path).unwrap();
211        assert!(json.contains("components"));
212    }
213}