Skip to main content

winterwallet_core/
keypair.rs

1use crate::{WinternitzError, WinternitzPrivkey};
2use hmac::{Hmac, Mac};
3use sha2::{Digest, Sha256, Sha512};
4use zeroize::{Zeroize, ZeroizeOnDrop};
5
6const MAX_PASSPHRASE_LEN: usize = 256;
7
8/// Hierarchical Winternitz keypair derived from a BIP-39 mnemonic.
9///
10/// Derivation is hardened-only HMAC-SHA512 (SLIP-0010-style) with magic string
11/// `"Winternitz seed"`, **not** BIP-32. The four hardened levels are
12/// `master / wallet' / parent' / child'`, with `N + 2` further hardened leaves
13/// at indices `0..N+2` for each Winternitz scalar.
14///
15/// **Security:** each `(wallet, parent, child)` position must sign at most one
16/// message. Use [`Self::sign_and_increment`] to enforce this. Calling
17/// [`Self::derive`] twice at the same position produces the same privkey
18/// (intentional — for inspection); signing twice with that privkey is a
19/// catastrophic key compromise.
20///
21/// All secret material is zeroized on drop.
22#[derive(Default, Zeroize, ZeroizeOnDrop)]
23pub struct WinternitzKeypair {
24    key: [u8; 32],
25    chain_code: [u8; 32],
26    wallet: u32,
27    parent: u32,
28    child: u32,
29}
30
31impl WinternitzKeypair {
32    /// The English BIP-39 wordlist (2048 newline-separated words).
33    pub const WORDLIST: &str = include_str!("wordlist.txt");
34
35    /// Build a keypair from a BIP-39 mnemonic with the given wallet index.
36    /// Initial position is `(wallet, parent=0, child=0)`. The mnemonic is
37    /// validated against the BIP-39 wordlist and checksum.
38    pub fn from_mnemonic(
39        mnemonic: &str,
40        wallet: u32,
41    ) -> Result<WinternitzKeypair, WinternitzError> {
42        Self::validate(mnemonic)?;
43        Ok(Self::from_mnemonic_unchecked(mnemonic, wallet))
44    }
45
46    /// Build a keypair at an arbitrary `(wallet, parent, child)` position.
47    /// Use this to resume a CLI session from persisted position state.
48    pub fn from_mnemonic_at(
49        mnemonic: &str,
50        wallet: u32,
51        parent: u32,
52        child: u32,
53    ) -> Result<WinternitzKeypair, WinternitzError> {
54        Self::validate(mnemonic)?;
55        let mut kp = Self::from_mnemonic_unchecked(mnemonic, wallet);
56        kp.parent = parent;
57        kp.child = child;
58        Ok(kp)
59    }
60
61    /// Current wallet index.
62    pub fn wallet(&self) -> u32 {
63        self.wallet
64    }
65
66    /// Current parent index.
67    pub fn parent(&self) -> u32 {
68        self.parent
69    }
70
71    /// Current child index.
72    pub fn child(&self) -> u32 {
73        self.child
74    }
75
76    /// Convert 32 bytes of caller-supplied entropy into a 24-word BIP-39
77    /// mnemonic. The caller is responsible for sourcing entropy from a CSPRNG.
78    pub fn generate_mnemonic(entropy: [u8; 32]) -> [&'static str; 24] {
79        let mut bits = [0u8; 33];
80        bits[..32].copy_from_slice(&entropy);
81        bits[32] = Sha256::digest(entropy)[0];
82
83        let mut words: [&'static str; 24] = [""; 24];
84        let mut bit_pos = 0usize;
85        for slot in &mut words {
86            let mut idx = 0u16;
87            for _ in 0..11 {
88                idx = (idx << 1) | (((bits[bit_pos / 8] >> (7 - (bit_pos % 8))) & 1) as u16);
89                bit_pos += 1;
90            }
91            *slot = Self::WORDLIST
92                .lines()
93                .nth(idx as usize)
94                .expect("11-bit index always < 2048");
95        }
96        words
97    }
98
99    fn validate(mnemonic: &str) -> Result<(), WinternitzError> {
100        let count = mnemonic.split_ascii_whitespace().count();
101        let total_bits = match count {
102            12 => 132,
103            15 => 165,
104            18 => 198,
105            21 => 231,
106            24 => 264,
107            _ => return Err(WinternitzError::InvalidMnemonic),
108        };
109        let entropy_bits = total_bits * 32 / 33;
110        let cs_bits = total_bits - entropy_bits;
111
112        let mut bits = [0u8; 33];
113        let mut bit_pos = 0usize;
114        for word in mnemonic.split_ascii_whitespace() {
115            let idx = Self::WORDLIST
116                .lines()
117                .position(|line| line == word)
118                .ok_or(WinternitzError::InvalidMnemonic)? as u16;
119            for b in (0..11).rev() {
120                if (idx >> b) & 1 == 1 {
121                    bits[bit_pos / 8] |= 1 << (7 - (bit_pos % 8));
122                }
123                bit_pos += 1;
124            }
125        }
126
127        let entropy_bytes = entropy_bits / 8;
128        let hash = Sha256::digest(&bits[..entropy_bytes]);
129        let mask = 0xFFu8 << (8 - cs_bits);
130        if (bits[entropy_bytes] & mask) != (hash[0] & mask) {
131            return Err(WinternitzError::InvalidMnemonic);
132        }
133        Ok(())
134    }
135
136    /// Compute the BIP-39 seed for a mnemonic with no passphrase. Equivalent
137    /// to `seed_with_passphrase(mnemonic, "")`.
138    pub fn seed(mnemonic: &str) -> Result<[u8; 64], WinternitzError> {
139        Self::seed_with_passphrase(mnemonic, "")
140    }
141
142    /// Compute the BIP-39 seed via PBKDF2-HMAC-SHA512 (2048 iterations) with
143    /// salt `"mnemonic" + passphrase`. Returns
144    /// [`WinternitzError::InvalidLength`] if the passphrase exceeds 256 bytes.
145    pub fn seed_with_passphrase(
146        mnemonic: &str,
147        passphrase: &str,
148    ) -> Result<[u8; 64], WinternitzError> {
149        if passphrase.len() > MAX_PASSPHRASE_LEN {
150            return Err(WinternitzError::InvalidLength);
151        }
152        Self::validate(mnemonic)?;
153        Ok(Self::raw_seed(mnemonic, passphrase))
154    }
155
156    fn raw_seed(mnemonic: &str, passphrase: &str) -> [u8; 64] {
157        debug_assert!(passphrase.len() <= MAX_PASSPHRASE_LEN);
158        const PREFIX: &[u8] = b"mnemonic";
159        let mut salt = [0u8; PREFIX.len() + MAX_PASSPHRASE_LEN];
160        salt[..PREFIX.len()].copy_from_slice(PREFIX);
161        salt[PREFIX.len()..PREFIX.len() + passphrase.len()].copy_from_slice(passphrase.as_bytes());
162        let mut seed = [0u8; 64];
163        pbkdf2::pbkdf2_hmac::<Sha512>(
164            mnemonic.as_bytes(),
165            &salt[..PREFIX.len() + passphrase.len()],
166            2048,
167            &mut seed,
168        );
169        seed
170    }
171
172    fn from_mnemonic_unchecked(mnemonic: &str, wallet: u32) -> WinternitzKeypair {
173        let seed = Self::raw_seed(mnemonic, "");
174
175        let mut mac = <Hmac<Sha512>>::new_from_slice(b"Winternitz seed")
176            .expect("HMAC accepts any key length");
177        mac.update(&seed);
178        let i = mac.finalize().into_bytes();
179
180        let mut key = [0u8; 32];
181        let mut chain_code = [0u8; 32];
182        key.copy_from_slice(&i[..32]);
183        chain_code.copy_from_slice(&i[32..]);
184
185        WinternitzKeypair {
186            key,
187            chain_code,
188            wallet,
189            parent: 0,
190            child: 0,
191        }
192    }
193
194    fn derive_child(key: &[u8; 32], chain_code: &[u8; 32], index: u32) -> ([u8; 32], [u8; 32]) {
195        let mut mac =
196            <Hmac<Sha512>>::new_from_slice(chain_code).expect("HMAC accepts any key length");
197        mac.update(&[0u8]);
198        mac.update(key);
199        mac.update(&(index | 0x8000_0000).to_be_bytes());
200        let i = mac.finalize().into_bytes();
201
202        let mut child_key = [0u8; 32];
203        let mut child_chain = [0u8; 32];
204        child_key.copy_from_slice(&i[..32]);
205        child_chain.copy_from_slice(&i[32..]);
206        (child_key, child_chain)
207    }
208
209    /// Derive the privkey at the current `(wallet, parent, child)` position.
210    /// Walks four hardened HMAC levels then derives `N + 2` further hardened
211    /// children at indices `0..N+2`. Idempotent — calling twice at the same
212    /// position returns the same scalars.
213    pub fn derive<const N: usize>(&self) -> WinternitzPrivkey<N> {
214        const { crate::assert_n::<N>() };
215        let (mut k, mut c) = (self.key, self.chain_code);
216        for idx in [self.wallet, self.parent, self.child] {
217            let (nk, nc) = Self::derive_child(&k, &c, idx);
218            k = nk;
219            c = nc;
220        }
221
222        let mut scalars = [[0u8; 32]; N];
223        for (i, slot) in scalars.iter_mut().enumerate() {
224            *slot = Self::derive_child(&k, &c, i as u32).0;
225        }
226        let checksum = [
227            Self::derive_child(&k, &c, N as u32).0,
228            Self::derive_child(&k, &c, (N + 1) as u32).0,
229        ];
230
231        WinternitzPrivkey::new(scalars, checksum)
232    }
233
234    /// Derive the privkey at the current position, sign, then advance to the
235    /// next position. This is the safe sign primitive — once called, the
236    /// privkey at that position is consumed and the keypair cannot produce
237    /// another signature at the same position via this method.
238    pub fn sign_and_increment<const N: usize>(
239        &mut self,
240        message: &[&[u8]],
241    ) -> crate::WinternitzSignature<N> {
242        let sig = self.derive::<N>().sign(message);
243        self.increment_child();
244        sig
245    }
246
247    /// Advance to the next child position. On `child == u32::MAX` overflow,
248    /// resets child to 0 and increments parent (which itself panics on
249    /// overflow).
250    pub fn increment_child(&mut self) {
251        match self.child.checked_add(1) {
252            Some(c) => self.child = c,
253            None => {
254                self.child = 0;
255                self.increment_parent()
256            }
257        }
258    }
259
260    /// Advance to the next parent position and reset child to 0. Panics on
261    /// `parent == u32::MAX` overflow.
262    pub fn increment_parent(&mut self) {
263        self.parent = self.parent.checked_add(1).expect("parent overflow");
264        self.child = 0;
265    }
266}