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}