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 BIP-39 mnemonic and a derived 64-byte seed used by
13/// [`Self::derive_secp256k1`] / [`Self::derive_ed25519`] (and the chain
14/// derivers built on them). The raw seed is **not** part of the default
15/// public API; enable the `raw-seed` feature only if an advanced caller
16/// truly needs [`Self::seed`].
17///
18/// # Passphrase Support
19///
20/// The wallet supports an optional BIP39 passphrase (sometimes called "25th word").
21/// This provides an extra layer of security - the same mnemonic with different
22/// passphrases will produce completely different wallets.
23pub struct Wallet {
24 /// BIP39 mnemonic phrase.
25 mnemonic: Zeroizing<String>,
26 /// Seed derived from mnemonic + passphrase.
27 ///
28 /// Read via [`Self::derive_secp256k1`] / [`Self::derive_ed25519`] or the
29 /// feature-gated [`Self::seed`]. Marked `allow(dead_code)` so a minimal
30 /// `alloc`-only build (no bip32/slip10/raw-seed) still retains the seed
31 /// for future derive calls without a false-positive lint.
32 #[allow(
33 dead_code,
34 reason = "read by derive_* / raw-seed; retained when those features are off"
35 )]
36 seed: Zeroizing<[u8; 64]>,
37 /// Whether a passphrase was used.
38 has_passphrase: bool,
39 /// Language of the mnemonic.
40 language: Language,
41}
42
43impl core::fmt::Debug for Wallet {
44 fn fmt(&self, f: &mut core::fmt::Formatter<'_>) -> core::fmt::Result {
45 // Never print mnemonic or seed — Zeroizing's Debug is not redacting.
46 f.debug_struct("Wallet")
47 .field("mnemonic", &"[REDACTED]")
48 .field("seed", &"[REDACTED]")
49 .field("has_passphrase", &self.has_passphrase)
50 .field("language", &self.language)
51 .field("word_count", &self.word_count())
52 .finish()
53 }
54}
55
56impl Wallet {
57 /// Generate a new wallet with a random mnemonic.
58 ///
59 /// # Arguments
60 ///
61 /// * `word_count` - Number of words (12, 15, 18, 21, or 24)
62 /// * `passphrase` - Optional BIP39 passphrase for additional security
63 ///
64 /// # Errors
65 ///
66 /// Returns an error if the word count is invalid.
67 ///
68 /// # Note
69 ///
70 /// This function requires the `rand` feature to be enabled.
71 #[cfg(feature = "rand")]
72 pub fn generate(word_count: usize, passphrase: Option<&str>) -> Result<Self, DeriveError> {
73 Self::generate_in(Language::English, word_count, passphrase)
74 }
75
76 /// Generate a new wallet with a random mnemonic in the specified language.
77 ///
78 /// # Arguments
79 ///
80 /// * `language` - Language for the mnemonic word list
81 /// * `word_count` - Number of words (12, 15, 18, 21, or 24)
82 /// * `passphrase` - Optional BIP39 passphrase for additional security
83 ///
84 /// # Errors
85 ///
86 /// Returns an error if the word count is invalid.
87 ///
88 /// # Note
89 ///
90 /// This function requires the `rand` feature to be enabled.
91 #[cfg(feature = "rand")]
92 pub fn generate_in(
93 language: Language,
94 word_count: usize,
95 passphrase: Option<&str>,
96 ) -> Result<Self, DeriveError> {
97 if !matches!(word_count, 12 | 15 | 18 | 21 | 24) {
98 return Err(DeriveError::Input(alloc::format!(
99 "word count must be 12, 15, 18, 21, or 24, got {word_count}"
100 )));
101 }
102
103 let mnemonic = Mnemonic::generate_in(language, word_count)?;
104 Ok(Self::from_parts(&mnemonic, language, passphrase))
105 }
106
107 /// Generate a new wallet with a custom random number generator.
108 ///
109 /// This is useful in `no_std` environments where you provide your own
110 /// cryptographically secure RNG instead of relying on the system RNG.
111 ///
112 /// # Arguments
113 ///
114 /// * `rng` - A cryptographically secure random number generator
115 /// * `language` - Language for the mnemonic word list
116 /// * `word_count` - Number of words (12, 15, 18, 21, or 24)
117 /// * `passphrase` - Optional BIP39 passphrase for additional security
118 ///
119 /// # Errors
120 ///
121 /// Returns an error if the word count is invalid.
122 ///
123 /// # Note
124 ///
125 /// This function requires the `rand_core` feature to be enabled.
126 #[cfg(feature = "rand_core")]
127 pub fn generate_in_with<R>(
128 rng: &mut R,
129 language: Language,
130 word_count: usize,
131 passphrase: Option<&str>,
132 ) -> Result<Self, DeriveError>
133 where
134 R: bip39::rand_core::RngCore + bip39::rand_core::CryptoRng,
135 {
136 if !matches!(word_count, 12 | 15 | 18 | 21 | 24) {
137 return Err(DeriveError::Input(alloc::format!(
138 "word count must be 12, 15, 18, 21, or 24, got {word_count}"
139 )));
140 }
141
142 let mnemonic = Mnemonic::generate_in_with(rng, language, word_count)?;
143 Ok(Self::from_parts(&mnemonic, language, passphrase))
144 }
145
146 /// Create a wallet from raw entropy bytes (English by default).
147 ///
148 /// This is useful in `no_std` environments where you provide your own entropy
149 /// source instead of relying on the system RNG.
150 ///
151 /// # Arguments
152 ///
153 /// * `entropy` - Raw entropy bytes (16, 20, 24, 28, or 32 bytes for 12-24 words)
154 /// * `passphrase` - Optional BIP39 passphrase for additional security
155 ///
156 /// # Errors
157 ///
158 /// Returns an error if the entropy length is invalid.
159 pub fn from_entropy(entropy: &[u8], passphrase: Option<&str>) -> Result<Self, DeriveError> {
160 Self::from_entropy_in(Language::English, entropy, passphrase)
161 }
162
163 /// Create a wallet from raw entropy bytes in the specified language.
164 ///
165 /// This is useful in `no_std` environments where you provide your own entropy
166 /// source instead of relying on the system RNG.
167 ///
168 /// # Arguments
169 ///
170 /// * `language` - Language for the mnemonic word list
171 /// * `entropy` - Raw entropy bytes (16, 20, 24, 28, or 32 bytes for 12-24 words)
172 /// * `passphrase` - Optional BIP39 passphrase for additional security
173 ///
174 /// # Errors
175 ///
176 /// Returns an error if the entropy length is invalid.
177 pub fn from_entropy_in(
178 language: Language,
179 entropy: &[u8],
180 passphrase: Option<&str>,
181 ) -> Result<Self, DeriveError> {
182 let mnemonic = Mnemonic::from_entropy_in(language, entropy)?;
183 Ok(Self::from_parts(&mnemonic, language, passphrase))
184 }
185
186 /// Create a wallet from an existing mnemonic phrase.
187 ///
188 /// The language will be automatically detected from the phrase.
189 ///
190 /// # Arguments
191 ///
192 /// * `phrase` - BIP39 mnemonic phrase
193 /// * `passphrase` - Optional BIP39 passphrase
194 ///
195 /// # Errors
196 ///
197 /// Returns an error if the mnemonic is invalid.
198 pub fn from_mnemonic(phrase: &str, passphrase: Option<&str>) -> Result<Self, DeriveError> {
199 let mnemonic: Mnemonic = phrase.parse()?;
200 let language = mnemonic.language();
201 Ok(Self::from_parts(&mnemonic, language, passphrase))
202 }
203
204 /// Expand 4-letter BIP-39 English prefixes then import (same path as CLI `import`).
205 ///
206 /// Full words pass through [`mnemonic::expand`](crate::mnemonic::expand) unchanged.
207 ///
208 /// # Errors
209 ///
210 /// Returns an error if expansion or BIP-39 parse fails.
211 pub fn from_mnemonic_expanded(
212 phrase: &str,
213 passphrase: Option<&str>,
214 ) -> Result<Self, DeriveError> {
215 let expanded = crate::mnemonic::expand(phrase)?;
216 Self::from_mnemonic(&expanded, passphrase)
217 }
218
219 /// Create a wallet from an existing mnemonic phrase in the specified language.
220 ///
221 /// # Arguments
222 ///
223 /// * `language` - Language for the mnemonic word list
224 /// * `phrase` - BIP39 mnemonic phrase
225 /// * `passphrase` - Optional BIP39 passphrase
226 ///
227 /// # Errors
228 ///
229 /// Returns an error if the mnemonic is invalid.
230 pub fn from_mnemonic_in(
231 language: Language,
232 phrase: &str,
233 passphrase: Option<&str>,
234 ) -> Result<Self, DeriveError> {
235 let mnemonic = Mnemonic::parse_in(language, phrase)?;
236 Ok(Self::from_parts(&mnemonic, language, passphrase))
237 }
238
239 /// Build a wallet from a validated mnemonic, deriving the seed.
240 fn from_parts(mnemonic: &Mnemonic, language: Language, passphrase: Option<&str>) -> Self {
241 let passphrase_str = passphrase.unwrap_or("");
242 let seed_bytes = mnemonic.to_seed(passphrase_str);
243 Self {
244 mnemonic: Zeroizing::new(mnemonic.to_string()),
245 seed: Zeroizing::new(seed_bytes),
246 has_passphrase: passphrase.is_some(),
247 language,
248 }
249 }
250
251 /// Get the mnemonic phrase.
252 ///
253 /// **Security Warning**: Handle this value carefully as it can
254 /// reconstruct all derived keys.
255 #[inline]
256 #[must_use]
257 pub fn mnemonic(&self) -> &str {
258 &self.mnemonic
259 }
260
261 /// Get the 64-byte BIP-39 seed, still wrapped in [`Zeroizing`].
262 ///
263 /// **Gated on the `raw-seed` feature** (off by default). Preferred
264 /// entry points for key material are
265 /// [`derive_secp256k1`](Self::derive_secp256k1) and
266 /// [`derive_ed25519`](Self::derive_ed25519), which keep the seed inside
267 /// [`Wallet`].
268 ///
269 /// Callers that enable `raw-seed` must treat the returned reference as
270 /// highly sensitive: keep it borrowed or copy into another
271 /// [`Zeroizing`] container.
272 #[cfg(any(feature = "raw-seed", test))]
273 #[inline]
274 #[must_use]
275 pub const fn seed(&self) -> &Zeroizing<[u8; 64]> {
276 &self.seed
277 }
278
279 /// Derive a secp256k1 key pair at the given BIP-32 path.
280 ///
281 /// Preferred entry point for chains that derive secp256k1 keys (EVM,
282 /// BTC, Cosmos, Tron, Spark, Filecoin, XRP Ledger, Nostr).
283 /// Keeps the underlying seed encapsulated within [`Wallet`].
284 ///
285 /// # Errors
286 ///
287 /// Returns an error if the path is malformed or derivation fails.
288 #[cfg(feature = "bip32")]
289 #[inline]
290 pub fn derive_secp256k1(
291 &self,
292 path: &str,
293 ) -> Result<crate::bip32::DerivedSecp256k1Key, DeriveError> {
294 crate::bip32::DerivedSecp256k1Key::derive(&self.seed, path)
295 }
296
297 /// Derive an Ed25519 key pair at the given SLIP-10 path.
298 ///
299 /// Preferred entry point for chains that derive Ed25519 keys (Solana,
300 /// Sui, Aptos, TON). Keeps the underlying seed encapsulated within
301 /// [`Wallet`].
302 ///
303 /// # Errors
304 ///
305 /// Returns an error if the path is malformed or derivation fails.
306 #[cfg(feature = "slip10")]
307 #[inline]
308 pub fn derive_ed25519(
309 &self,
310 path: &str,
311 ) -> Result<crate::slip10::DerivedEd25519Key, DeriveError> {
312 crate::slip10::DerivedEd25519Key::derive_path(self.seed.as_slice(), path)
313 }
314
315 /// Check if a passphrase was supplied at construction time.
316 ///
317 /// Returns `true` whenever the caller passed `Some(_)` to the constructor,
318 /// even if the passphrase string itself was empty. Callers relying on
319 /// "non-empty passphrase" semantics must check the passphrase string before
320 /// constructing the wallet.
321 #[must_use]
322 pub const fn has_passphrase(&self) -> bool {
323 self.has_passphrase
324 }
325
326 /// Get the language of the mnemonic.
327 #[inline]
328 #[must_use]
329 pub const fn language(&self) -> Language {
330 self.language
331 }
332
333 /// Get the word count of the mnemonic.
334 #[inline]
335 #[must_use]
336 pub fn word_count(&self) -> usize {
337 self.mnemonic.split_whitespace().count()
338 }
339}
340
341#[cfg(test)]
342mod tests {
343 use super::*;
344
345 const TEST_MNEMONIC: &str = "abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon about";
346
347 #[cfg(feature = "rand")]
348 #[test]
349 fn test_generate_12_words() {
350 let wallet = Wallet::generate(12, None).unwrap();
351 assert_eq!(wallet.word_count(), 12);
352 assert!(!wallet.has_passphrase());
353 }
354
355 #[cfg(feature = "rand")]
356 #[test]
357 fn test_generate_24_words() {
358 let wallet = Wallet::generate(24, None).unwrap();
359 assert_eq!(wallet.word_count(), 24);
360 }
361
362 #[cfg(feature = "rand")]
363 #[test]
364 fn test_generate_with_passphrase() {
365 let wallet = Wallet::generate(12, Some("secret")).unwrap();
366 assert!(wallet.has_passphrase());
367 }
368
369 #[test]
370 fn test_invalid_entropy_length() {
371 // 15 bytes is invalid (should be 16, 20, 24, 28, or 32)
372 let result = Wallet::from_entropy(&[0u8; 15], None);
373 assert!(result.is_err());
374 }
375
376 #[test]
377 fn test_from_entropy() {
378 // 16 bytes = 12 words
379 let entropy = [0u8; 16];
380 let wallet = Wallet::from_entropy(&entropy, None).unwrap();
381 assert_eq!(wallet.word_count(), 12);
382 }
383
384 #[test]
385 fn test_from_mnemonic() {
386 let wallet = Wallet::from_mnemonic(TEST_MNEMONIC, None).unwrap();
387 assert_eq!(wallet.mnemonic(), TEST_MNEMONIC);
388 }
389
390 #[test]
391 fn test_passphrase_changes_seed() {
392 let wallet1 = Wallet::from_mnemonic(TEST_MNEMONIC, None).unwrap();
393 let wallet2 = Wallet::from_mnemonic(TEST_MNEMONIC, Some("password")).unwrap();
394
395 // Same mnemonic with different passphrase should produce different seeds
396 assert_ne!(wallet1.seed(), wallet2.seed());
397 }
398
399 #[test]
400 fn test_deterministic_seed() {
401 let wallet1 = Wallet::from_mnemonic(TEST_MNEMONIC, Some("test")).unwrap();
402 let wallet2 = Wallet::from_mnemonic(TEST_MNEMONIC, Some("test")).unwrap();
403 assert_eq!(wallet1.seed(), wallet2.seed());
404 }
405
406 #[test]
407 fn kat_bip39_seed_vector() {
408 // BIP-39 reference: "abandon...about" with empty passphrase
409 // Verified against Python pbkdf2_hmac + iancoleman.io
410 let wallet = Wallet::from_mnemonic(TEST_MNEMONIC, None).unwrap();
411 assert_eq!(
412 hex::encode(wallet.seed()),
413 "5eb00bbddcf069084889a8ab9155568165f5c453ccb85e70811aaed6f6da5fc1\
414 9a5ac40b389cd370d086206dec8aa6c43daea6690f20ad3d8d48b2d2ce9e38e4"
415 );
416 }
417
418 #[test]
419 fn debug_redacts_mnemonic_and_seed() {
420 let wallet = Wallet::from_mnemonic(TEST_MNEMONIC, None).unwrap();
421 let dbg = alloc::format!("{wallet:?}");
422 assert!(
423 dbg.contains("[REDACTED]"),
424 "expected redaction markers: {dbg}"
425 );
426 assert!(
427 !dbg.contains("abandon"),
428 "Debug must not leak mnemonic words: {dbg}"
429 );
430 // BIP-39 seed hex prefix for abandon…about
431 assert!(
432 !dbg.contains("5eb00bbddcf06908"),
433 "Debug must not leak seed bytes: {dbg}"
434 );
435 }
436
437 #[test]
438 fn kat_all_zero_entropy_produces_abandon_about() {
439 let wallet = Wallet::from_entropy(&[0u8; 16], None).unwrap();
440 assert_eq!(wallet.mnemonic(), TEST_MNEMONIC);
441 }
442}