kobe_primitives/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::DeriveError;
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, DeriveError> {
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, DeriveError> {
74 if !matches!(word_count, 12 | 15 | 18 | 21 | 24) {
75 return Err(DeriveError::Input(alloc::format!(
76 "word count must be 12, 15, 18, 21, or 24, got {word_count}"
77 )));
78 }
79
80 let mnemonic = Mnemonic::generate_in(language, word_count)?;
81 Ok(Self::from_parts(&mnemonic, language, passphrase))
82 }
83
84 /// Generate a new wallet with a custom random number generator.
85 ///
86 /// This is useful in `no_std` environments where you provide your own
87 /// cryptographically secure RNG instead of relying on the system RNG.
88 ///
89 /// # Arguments
90 ///
91 /// * `rng` - A cryptographically secure random number generator
92 /// * `language` - Language for the mnemonic word list
93 /// * `word_count` - Number of words (12, 15, 18, 21, or 24)
94 /// * `passphrase` - Optional BIP39 passphrase for additional security
95 ///
96 /// # Errors
97 ///
98 /// Returns an error if the word count is invalid.
99 ///
100 /// # Note
101 ///
102 /// This function requires the `rand_core` feature to be enabled.
103 #[cfg(feature = "rand_core")]
104 pub fn generate_in_with<R>(
105 rng: &mut R,
106 language: Language,
107 word_count: usize,
108 passphrase: Option<&str>,
109 ) -> Result<Self, DeriveError>
110 where
111 R: bip39::rand_core::RngCore + bip39::rand_core::CryptoRng,
112 {
113 if !matches!(word_count, 12 | 15 | 18 | 21 | 24) {
114 return Err(DeriveError::Input(alloc::format!(
115 "word count must be 12, 15, 18, 21, or 24, got {word_count}"
116 )));
117 }
118
119 let mnemonic = Mnemonic::generate_in_with(rng, language, word_count)?;
120 Ok(Self::from_parts(&mnemonic, language, passphrase))
121 }
122
123 /// Create a wallet from raw entropy bytes (English by default).
124 ///
125 /// This is useful in `no_std` environments where you provide your own entropy
126 /// source instead of relying on the system RNG.
127 ///
128 /// # Arguments
129 ///
130 /// * `entropy` - Raw entropy bytes (16, 20, 24, 28, or 32 bytes for 12-24 words)
131 /// * `passphrase` - Optional BIP39 passphrase for additional security
132 ///
133 /// # Errors
134 ///
135 /// Returns an error if the entropy length is invalid.
136 pub fn from_entropy(entropy: &[u8], passphrase: Option<&str>) -> Result<Self, DeriveError> {
137 Self::from_entropy_in(Language::English, entropy, passphrase)
138 }
139
140 /// Create a wallet from raw entropy bytes in the specified language.
141 ///
142 /// This is useful in `no_std` environments where you provide your own entropy
143 /// source instead of relying on the system RNG.
144 ///
145 /// # Arguments
146 ///
147 /// * `language` - Language for the mnemonic word list
148 /// * `entropy` - Raw entropy bytes (16, 20, 24, 28, or 32 bytes for 12-24 words)
149 /// * `passphrase` - Optional BIP39 passphrase for additional security
150 ///
151 /// # Errors
152 ///
153 /// Returns an error if the entropy length is invalid.
154 pub fn from_entropy_in(
155 language: Language,
156 entropy: &[u8],
157 passphrase: Option<&str>,
158 ) -> Result<Self, DeriveError> {
159 let mnemonic = Mnemonic::from_entropy_in(language, entropy)?;
160 Ok(Self::from_parts(&mnemonic, language, passphrase))
161 }
162
163 /// Create a wallet from an existing mnemonic phrase.
164 ///
165 /// The language will be automatically detected from the phrase.
166 ///
167 /// # Arguments
168 ///
169 /// * `phrase` - BIP39 mnemonic phrase
170 /// * `passphrase` - Optional BIP39 passphrase
171 ///
172 /// # Errors
173 ///
174 /// Returns an error if the mnemonic is invalid.
175 pub fn from_mnemonic(phrase: &str, passphrase: Option<&str>) -> Result<Self, DeriveError> {
176 let mnemonic: Mnemonic = phrase.parse()?;
177 let language = mnemonic.language();
178 Ok(Self::from_parts(&mnemonic, language, passphrase))
179 }
180
181 /// Create a wallet from an existing mnemonic phrase in the specified language.
182 ///
183 /// # Arguments
184 ///
185 /// * `language` - Language for the mnemonic word list
186 /// * `phrase` - BIP39 mnemonic phrase
187 /// * `passphrase` - Optional BIP39 passphrase
188 ///
189 /// # Errors
190 ///
191 /// Returns an error if the mnemonic is invalid.
192 pub fn from_mnemonic_in(
193 language: Language,
194 phrase: &str,
195 passphrase: Option<&str>,
196 ) -> Result<Self, DeriveError> {
197 let mnemonic = Mnemonic::parse_in(language, phrase)?;
198 Ok(Self::from_parts(&mnemonic, language, passphrase))
199 }
200
201 /// Build a wallet from a validated mnemonic, deriving the seed.
202 fn from_parts(mnemonic: &Mnemonic, language: Language, passphrase: Option<&str>) -> Self {
203 let passphrase_str = passphrase.unwrap_or("");
204 let seed_bytes = mnemonic.to_seed(passphrase_str);
205 Self {
206 mnemonic: Zeroizing::new(mnemonic.to_string()),
207 seed: Zeroizing::new(seed_bytes),
208 has_passphrase: passphrase.is_some(),
209 language,
210 }
211 }
212
213 /// Get the mnemonic phrase.
214 ///
215 /// **Security Warning**: Handle this value carefully as it can
216 /// reconstruct all derived keys.
217 #[inline]
218 #[must_use]
219 pub fn mnemonic(&self) -> &str {
220 &self.mnemonic
221 }
222
223 /// Get the 64-byte seed for key derivation, still wrapped in [`Zeroizing`].
224 ///
225 /// The returned reference makes the wallet's sensitivity explicit at the
226 /// type level: callers must keep the result borrowed or copy it into
227 /// another [`Zeroizing`] container to avoid leaking unsealed seed bytes
228 /// on the stack.
229 ///
230 /// Most chain derivers should prefer the feature-gated
231 /// [`derive_secp256k1`](Self::derive_secp256k1) and
232 /// [`derive_ed25519`](Self::derive_ed25519) shortcuts over reaching for
233 /// the raw seed.
234 #[inline]
235 #[must_use]
236 pub const fn seed(&self) -> &Zeroizing<[u8; 64]> {
237 &self.seed
238 }
239
240 /// Derive a secp256k1 key pair at the given BIP-32 path.
241 ///
242 /// Preferred entry point for chains that derive secp256k1 keys (EVM,
243 /// BTC fallback, Cosmos, Tron, Spark, Filecoin, XRP Ledger, Nostr).
244 /// Keeps the underlying seed encapsulated within [`Wallet`].
245 ///
246 /// # Errors
247 ///
248 /// Returns an error if the path is malformed or derivation fails.
249 #[cfg(feature = "bip32")]
250 #[inline]
251 pub fn derive_secp256k1(
252 &self,
253 path: &str,
254 ) -> Result<crate::bip32::DerivedSecp256k1Key, DeriveError> {
255 crate::bip32::DerivedSecp256k1Key::derive(self.seed(), path)
256 }
257
258 /// Derive an Ed25519 key pair at the given SLIP-10 path.
259 ///
260 /// Preferred entry point for chains that derive Ed25519 keys (Solana,
261 /// Sui, Aptos, TON). Keeps the underlying seed encapsulated within
262 /// [`Wallet`].
263 ///
264 /// # Errors
265 ///
266 /// Returns an error if the path is malformed or derivation fails.
267 #[cfg(feature = "slip10")]
268 #[inline]
269 pub fn derive_ed25519(
270 &self,
271 path: &str,
272 ) -> Result<crate::slip10::DerivedEd25519Key, DeriveError> {
273 crate::slip10::DerivedEd25519Key::derive_path(self.seed().as_slice(), path)
274 }
275
276 /// Check if a passphrase was supplied at construction time.
277 ///
278 /// Returns `true` whenever the caller passed `Some(_)` to the constructor,
279 /// even if the passphrase string itself was empty. Callers relying on
280 /// "non-empty passphrase" semantics must check the passphrase string before
281 /// constructing the wallet.
282 #[must_use]
283 pub const fn has_passphrase(&self) -> bool {
284 self.has_passphrase
285 }
286
287 /// Get the language of the mnemonic.
288 #[inline]
289 #[must_use]
290 pub const fn language(&self) -> Language {
291 self.language
292 }
293
294 /// Get the word count of the mnemonic.
295 #[inline]
296 #[must_use]
297 pub fn word_count(&self) -> usize {
298 self.mnemonic.split_whitespace().count()
299 }
300}
301
302#[cfg(test)]
303mod tests {
304 use super::*;
305
306 const TEST_MNEMONIC: &str = "abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon about";
307
308 #[cfg(feature = "rand")]
309 #[test]
310 fn test_generate_12_words() {
311 let wallet = Wallet::generate(12, None).unwrap();
312 assert_eq!(wallet.word_count(), 12);
313 assert!(!wallet.has_passphrase());
314 }
315
316 #[cfg(feature = "rand")]
317 #[test]
318 fn test_generate_24_words() {
319 let wallet = Wallet::generate(24, None).unwrap();
320 assert_eq!(wallet.word_count(), 24);
321 }
322
323 #[cfg(feature = "rand")]
324 #[test]
325 fn test_generate_with_passphrase() {
326 let wallet = Wallet::generate(12, Some("secret")).unwrap();
327 assert!(wallet.has_passphrase());
328 }
329
330 #[test]
331 fn test_invalid_entropy_length() {
332 // 15 bytes is invalid (should be 16, 20, 24, 28, or 32)
333 let result = Wallet::from_entropy(&[0u8; 15], None);
334 assert!(result.is_err());
335 }
336
337 #[test]
338 fn test_from_entropy() {
339 // 16 bytes = 12 words
340 let entropy = [0u8; 16];
341 let wallet = Wallet::from_entropy(&entropy, None).unwrap();
342 assert_eq!(wallet.word_count(), 12);
343 }
344
345 #[test]
346 fn test_from_mnemonic() {
347 let wallet = Wallet::from_mnemonic(TEST_MNEMONIC, None).unwrap();
348 assert_eq!(wallet.mnemonic(), TEST_MNEMONIC);
349 }
350
351 #[test]
352 fn test_passphrase_changes_seed() {
353 let wallet1 = Wallet::from_mnemonic(TEST_MNEMONIC, None).unwrap();
354 let wallet2 = Wallet::from_mnemonic(TEST_MNEMONIC, Some("password")).unwrap();
355
356 // Same mnemonic with different passphrase should produce different seeds
357 assert_ne!(wallet1.seed(), wallet2.seed());
358 }
359
360 #[test]
361 fn test_deterministic_seed() {
362 let wallet1 = Wallet::from_mnemonic(TEST_MNEMONIC, Some("test")).unwrap();
363 let wallet2 = Wallet::from_mnemonic(TEST_MNEMONIC, Some("test")).unwrap();
364 assert_eq!(wallet1.seed(), wallet2.seed());
365 }
366
367 #[test]
368 fn kat_bip39_seed_vector() {
369 // BIP-39 reference: "abandon...about" with empty passphrase
370 // Verified against Python pbkdf2_hmac + iancoleman.io
371 let wallet = Wallet::from_mnemonic(TEST_MNEMONIC, None).unwrap();
372 assert_eq!(
373 hex::encode(wallet.seed()),
374 "5eb00bbddcf069084889a8ab9155568165f5c453ccb85e70811aaed6f6da5fc1\
375 9a5ac40b389cd370d086206dec8aa6c43daea6690f20ad3d8d48b2d2ce9e38e4"
376 );
377 }
378
379 #[test]
380 fn kat_all_zero_entropy_produces_abandon_about() {
381 let wallet = Wallet::from_entropy(&[0u8; 16], None).unwrap();
382 assert_eq!(wallet.mnemonic(), TEST_MNEMONIC);
383 }
384}