Skip to main content

kobe_core/
wallet.rs

1//! Unified wallet type for multi-chain key derivation.
2
3use alloc::string::{String, ToString};
4
5use bip39::{Language, Mnemonic};
6use zeroize::Zeroizing;
7
8use crate::Error;
9
10/// A unified HD wallet that can derive keys for multiple cryptocurrencies.
11///
12/// This wallet holds a BIP39 mnemonic and derives a seed that can be used
13/// to generate addresses for Bitcoin, Ethereum, and other coins following
14/// BIP32/44/49/84 standards.
15///
16/// # Passphrase Support
17///
18/// The wallet supports an optional BIP39 passphrase (sometimes called "25th word").
19/// This provides an extra layer of security - the same mnemonic with different
20/// passphrases will produce completely different wallets.
21#[derive(Debug)]
22pub struct Wallet {
23    /// BIP39 mnemonic phrase.
24    mnemonic: Zeroizing<String>,
25    /// Seed derived from mnemonic + passphrase.
26    seed: Zeroizing<[u8; 64]>,
27    /// Whether a passphrase was used.
28    has_passphrase: bool,
29    /// Language of the mnemonic.
30    language: Language,
31}
32
33impl Wallet {
34    /// Generate a new wallet with a random mnemonic.
35    ///
36    /// # Arguments
37    ///
38    /// * `word_count` - Number of words (12, 15, 18, 21, or 24)
39    /// * `passphrase` - Optional BIP39 passphrase for additional security
40    ///
41    /// # Errors
42    ///
43    /// Returns an error if the word count is invalid.
44    ///
45    /// # Note
46    ///
47    /// This function requires the `rand` feature to be enabled.
48    #[cfg(feature = "rand")]
49    pub fn generate(word_count: usize, passphrase: Option<&str>) -> Result<Self, Error> {
50        Self::generate_in(Language::English, word_count, passphrase)
51    }
52
53    /// Generate a new wallet with a random mnemonic in the specified language.
54    ///
55    /// # Arguments
56    ///
57    /// * `language` - Language for the mnemonic word list
58    /// * `word_count` - Number of words (12, 15, 18, 21, or 24)
59    /// * `passphrase` - Optional BIP39 passphrase for additional security
60    ///
61    /// # Errors
62    ///
63    /// Returns an error if the word count is invalid.
64    ///
65    /// # Note
66    ///
67    /// This function requires the `rand` feature to be enabled.
68    #[cfg(feature = "rand")]
69    pub fn generate_in(
70        language: Language,
71        word_count: usize,
72        passphrase: Option<&str>,
73    ) -> Result<Self, Error> {
74        if !matches!(word_count, 12 | 15 | 18 | 21 | 24) {
75            return Err(Error::InvalidWordCount(word_count));
76        }
77
78        let mnemonic = Mnemonic::generate_in(language, word_count)?;
79        Ok(Self::from_parts(&mnemonic, language, passphrase))
80    }
81
82    /// Generate a new wallet with a custom random number generator.
83    ///
84    /// This is useful in `no_std` environments where you provide your own
85    /// cryptographically secure RNG instead of relying on the system RNG.
86    ///
87    /// # Arguments
88    ///
89    /// * `rng` - A cryptographically secure random number generator
90    /// * `language` - Language for the mnemonic word list
91    /// * `word_count` - Number of words (12, 15, 18, 21, or 24)
92    /// * `passphrase` - Optional BIP39 passphrase for additional security
93    ///
94    /// # Errors
95    ///
96    /// Returns an error if the word count is invalid.
97    ///
98    /// # Note
99    ///
100    /// This function requires the `rand_core` feature to be enabled.
101    #[cfg(feature = "rand_core")]
102    pub fn generate_in_with<R>(
103        rng: &mut R,
104        language: Language,
105        word_count: usize,
106        passphrase: Option<&str>,
107    ) -> Result<Self, Error>
108    where
109        R: bip39::rand_core::RngCore + bip39::rand_core::CryptoRng,
110    {
111        if !matches!(word_count, 12 | 15 | 18 | 21 | 24) {
112            return Err(Error::InvalidWordCount(word_count));
113        }
114
115        let mnemonic = Mnemonic::generate_in_with(rng, language, word_count)?;
116        Ok(Self::from_parts(&mnemonic, language, passphrase))
117    }
118
119    /// Create a wallet from raw entropy bytes (English by default).
120    ///
121    /// This is useful in `no_std` environments where you provide your own entropy
122    /// source instead of relying on the system RNG.
123    ///
124    /// # Arguments
125    ///
126    /// * `entropy` - Raw entropy bytes (16, 20, 24, 28, or 32 bytes for 12-24 words)
127    /// * `passphrase` - Optional BIP39 passphrase for additional security
128    ///
129    /// # Errors
130    ///
131    /// Returns an error if the entropy length is invalid.
132    pub fn from_entropy(entropy: &[u8], passphrase: Option<&str>) -> Result<Self, Error> {
133        Self::from_entropy_in(Language::English, entropy, passphrase)
134    }
135
136    /// Create a wallet from raw entropy bytes in the specified language.
137    ///
138    /// This is useful in `no_std` environments where you provide your own entropy
139    /// source instead of relying on the system RNG.
140    ///
141    /// # Arguments
142    ///
143    /// * `language` - Language for the mnemonic word list
144    /// * `entropy` - Raw entropy bytes (16, 20, 24, 28, or 32 bytes for 12-24 words)
145    /// * `passphrase` - Optional BIP39 passphrase for additional security
146    ///
147    /// # Errors
148    ///
149    /// Returns an error if the entropy length is invalid.
150    pub fn from_entropy_in(
151        language: Language,
152        entropy: &[u8],
153        passphrase: Option<&str>,
154    ) -> Result<Self, Error> {
155        let mnemonic = Mnemonic::from_entropy_in(language, entropy)?;
156        Ok(Self::from_parts(&mnemonic, language, passphrase))
157    }
158
159    /// Create a wallet from an existing mnemonic phrase.
160    ///
161    /// The language will be automatically detected from the phrase.
162    ///
163    /// # Arguments
164    ///
165    /// * `phrase` - BIP39 mnemonic phrase
166    /// * `passphrase` - Optional BIP39 passphrase
167    ///
168    /// # Errors
169    ///
170    /// Returns an error if the mnemonic is invalid.
171    pub fn from_mnemonic(phrase: &str, passphrase: Option<&str>) -> Result<Self, Error> {
172        let mnemonic: Mnemonic = phrase.parse()?;
173        let language = mnemonic.language();
174        Ok(Self::from_parts(&mnemonic, language, passphrase))
175    }
176
177    /// Create a wallet from an existing mnemonic phrase in the specified language.
178    ///
179    /// # Arguments
180    ///
181    /// * `language` - Language for the mnemonic word list
182    /// * `phrase` - BIP39 mnemonic phrase
183    /// * `passphrase` - Optional BIP39 passphrase
184    ///
185    /// # Errors
186    ///
187    /// Returns an error if the mnemonic is invalid.
188    pub fn from_mnemonic_in(
189        language: Language,
190        phrase: &str,
191        passphrase: Option<&str>,
192    ) -> Result<Self, Error> {
193        let mnemonic = Mnemonic::parse_in(language, phrase)?;
194        Ok(Self::from_parts(&mnemonic, language, passphrase))
195    }
196
197    /// Build a wallet from a validated mnemonic, deriving the seed.
198    fn from_parts(mnemonic: &Mnemonic, language: Language, passphrase: Option<&str>) -> Self {
199        let passphrase_str = passphrase.unwrap_or("");
200        let seed_bytes = mnemonic.to_seed(passphrase_str);
201        Self {
202            mnemonic: Zeroizing::new(mnemonic.to_string()),
203            seed: Zeroizing::new(seed_bytes),
204            has_passphrase: passphrase.is_some() && !passphrase_str.is_empty(),
205            language,
206        }
207    }
208
209    /// Get the mnemonic phrase.
210    ///
211    /// **Security Warning**: Handle this value carefully as it can
212    /// reconstruct all derived keys.
213    #[inline]
214    #[must_use]
215    pub fn mnemonic(&self) -> &str {
216        &self.mnemonic
217    }
218
219    /// Get the seed bytes for key derivation.
220    ///
221    /// This seed can be used by chain-specific derivers (Bitcoin, Ethereum, etc.)
222    /// to generate addresses following their respective standards.
223    #[inline]
224    #[must_use]
225    pub fn seed(&self) -> &[u8; 64] {
226        &self.seed
227    }
228
229    /// Check if a passphrase was used to derive the seed.
230    #[must_use]
231    pub const fn has_passphrase(&self) -> bool {
232        self.has_passphrase
233    }
234
235    /// Get the language of the mnemonic.
236    #[inline]
237    #[must_use]
238    pub const fn language(&self) -> Language {
239        self.language
240    }
241
242    /// Get the word count of the mnemonic.
243    #[inline]
244    #[must_use]
245    pub fn word_count(&self) -> usize {
246        self.mnemonic.split_whitespace().count()
247    }
248}
249
250#[cfg(test)]
251#[allow(clippy::unwrap_used)]
252mod tests {
253    use super::*;
254
255    const TEST_MNEMONIC: &str = "abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon about";
256
257    #[cfg(feature = "rand")]
258    #[test]
259    fn test_generate_12_words() {
260        let wallet = Wallet::generate(12, None).unwrap();
261        assert_eq!(wallet.word_count(), 12);
262        assert!(!wallet.has_passphrase());
263    }
264
265    #[cfg(feature = "rand")]
266    #[test]
267    fn test_generate_24_words() {
268        let wallet = Wallet::generate(24, None).unwrap();
269        assert_eq!(wallet.word_count(), 24);
270    }
271
272    #[cfg(feature = "rand")]
273    #[test]
274    fn test_generate_with_passphrase() {
275        let wallet = Wallet::generate(12, Some("secret")).unwrap();
276        assert!(wallet.has_passphrase());
277    }
278
279    #[test]
280    fn test_invalid_entropy_length() {
281        // 15 bytes is invalid (should be 16, 20, 24, 28, or 32)
282        let result = Wallet::from_entropy(&[0u8; 15], None);
283        assert!(result.is_err());
284    }
285
286    #[test]
287    fn test_from_entropy() {
288        // 16 bytes = 12 words
289        let entropy = [0u8; 16];
290        let wallet = Wallet::from_entropy(&entropy, None).unwrap();
291        assert_eq!(wallet.word_count(), 12);
292    }
293
294    #[test]
295    fn test_from_mnemonic() {
296        let wallet = Wallet::from_mnemonic(TEST_MNEMONIC, None).unwrap();
297        assert_eq!(wallet.mnemonic(), TEST_MNEMONIC);
298    }
299
300    #[test]
301    fn test_passphrase_changes_seed() {
302        let wallet1 = Wallet::from_mnemonic(TEST_MNEMONIC, None).unwrap();
303        let wallet2 = Wallet::from_mnemonic(TEST_MNEMONIC, Some("password")).unwrap();
304
305        // Same mnemonic with different passphrase should produce different seeds
306        assert_ne!(wallet1.seed(), wallet2.seed());
307    }
308
309    #[test]
310    fn test_deterministic_seed() {
311        let wallet1 = Wallet::from_mnemonic(TEST_MNEMONIC, Some("test")).unwrap();
312        let wallet2 = Wallet::from_mnemonic(TEST_MNEMONIC, Some("test")).unwrap();
313        assert_eq!(wallet1.seed(), wallet2.seed());
314    }
315
316    #[test]
317    fn kat_bip39_seed_vector() {
318        // BIP-39 reference: "abandon...about" with empty passphrase
319        // Verified against Python pbkdf2_hmac + iancoleman.io
320        let wallet = Wallet::from_mnemonic(TEST_MNEMONIC, None).unwrap();
321        assert_eq!(
322            hex::encode(wallet.seed()),
323            "5eb00bbddcf069084889a8ab9155568165f5c453ccb85e70811aaed6f6da5fc1\
324             9a5ac40b389cd370d086206dec8aa6c43daea6690f20ad3d8d48b2d2ce9e38e4"
325        );
326    }
327
328    #[test]
329    fn kat_all_zero_entropy_produces_abandon_about() {
330        let wallet = Wallet::from_entropy(&[0u8; 16], None).unwrap();
331        assert_eq!(wallet.mnemonic(), TEST_MNEMONIC);
332    }
333}