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 /// Create a wallet from an existing mnemonic phrase in the specified language.
205 ///
206 /// # Arguments
207 ///
208 /// * `language` - Language for the mnemonic word list
209 /// * `phrase` - BIP39 mnemonic phrase
210 /// * `passphrase` - Optional BIP39 passphrase
211 ///
212 /// # Errors
213 ///
214 /// Returns an error if the mnemonic is invalid.
215 pub fn from_mnemonic_in(
216 language: Language,
217 phrase: &str,
218 passphrase: Option<&str>,
219 ) -> Result<Self, DeriveError> {
220 let mnemonic = Mnemonic::parse_in(language, phrase)?;
221 Ok(Self::from_parts(&mnemonic, language, passphrase))
222 }
223
224 /// Build a wallet from a validated mnemonic, deriving the seed.
225 fn from_parts(mnemonic: &Mnemonic, language: Language, passphrase: Option<&str>) -> Self {
226 let passphrase_str = passphrase.unwrap_or("");
227 let seed_bytes = mnemonic.to_seed(passphrase_str);
228 Self {
229 mnemonic: Zeroizing::new(mnemonic.to_string()),
230 seed: Zeroizing::new(seed_bytes),
231 has_passphrase: passphrase.is_some(),
232 language,
233 }
234 }
235
236 /// Get the mnemonic phrase.
237 ///
238 /// **Security Warning**: Handle this value carefully as it can
239 /// reconstruct all derived keys.
240 #[inline]
241 #[must_use]
242 pub fn mnemonic(&self) -> &str {
243 &self.mnemonic
244 }
245
246 /// Get the 64-byte BIP-39 seed, still wrapped in [`Zeroizing`].
247 ///
248 /// **Gated on the `raw-seed` feature** (off by default). Preferred
249 /// entry points for key material are
250 /// [`derive_secp256k1`](Self::derive_secp256k1) and
251 /// [`derive_ed25519`](Self::derive_ed25519), which keep the seed inside
252 /// [`Wallet`].
253 ///
254 /// Callers that enable `raw-seed` must treat the returned reference as
255 /// highly sensitive: keep it borrowed or copy into another
256 /// [`Zeroizing`] container.
257 #[cfg(any(feature = "raw-seed", test))]
258 #[inline]
259 #[must_use]
260 pub const fn seed(&self) -> &Zeroizing<[u8; 64]> {
261 &self.seed
262 }
263
264 /// Derive a secp256k1 key pair at the given BIP-32 path.
265 ///
266 /// Preferred entry point for chains that derive secp256k1 keys (EVM,
267 /// BTC, Cosmos, Tron, Spark, Filecoin, XRP Ledger, Nostr).
268 /// Keeps the underlying seed encapsulated within [`Wallet`].
269 ///
270 /// # Errors
271 ///
272 /// Returns an error if the path is malformed or derivation fails.
273 #[cfg(feature = "bip32")]
274 #[inline]
275 pub fn derive_secp256k1(
276 &self,
277 path: &str,
278 ) -> Result<crate::bip32::DerivedSecp256k1Key, DeriveError> {
279 crate::bip32::DerivedSecp256k1Key::derive(&self.seed, path)
280 }
281
282 /// Derive an Ed25519 key pair at the given SLIP-10 path.
283 ///
284 /// Preferred entry point for chains that derive Ed25519 keys (Solana,
285 /// Sui, Aptos, TON). Keeps the underlying seed encapsulated within
286 /// [`Wallet`].
287 ///
288 /// # Errors
289 ///
290 /// Returns an error if the path is malformed or derivation fails.
291 #[cfg(feature = "slip10")]
292 #[inline]
293 pub fn derive_ed25519(
294 &self,
295 path: &str,
296 ) -> Result<crate::slip10::DerivedEd25519Key, DeriveError> {
297 crate::slip10::DerivedEd25519Key::derive_path(self.seed.as_slice(), path)
298 }
299
300 /// Check if a passphrase was supplied at construction time.
301 ///
302 /// Returns `true` whenever the caller passed `Some(_)` to the constructor,
303 /// even if the passphrase string itself was empty. Callers relying on
304 /// "non-empty passphrase" semantics must check the passphrase string before
305 /// constructing the wallet.
306 #[must_use]
307 pub const fn has_passphrase(&self) -> bool {
308 self.has_passphrase
309 }
310
311 /// Get the language of the mnemonic.
312 #[inline]
313 #[must_use]
314 pub const fn language(&self) -> Language {
315 self.language
316 }
317
318 /// Get the word count of the mnemonic.
319 #[inline]
320 #[must_use]
321 pub fn word_count(&self) -> usize {
322 self.mnemonic.split_whitespace().count()
323 }
324}
325
326#[cfg(test)]
327mod tests {
328 use super::*;
329
330 const TEST_MNEMONIC: &str = "abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon about";
331
332 #[cfg(feature = "rand")]
333 #[test]
334 fn test_generate_12_words() {
335 let wallet = Wallet::generate(12, None).unwrap();
336 assert_eq!(wallet.word_count(), 12);
337 assert!(!wallet.has_passphrase());
338 }
339
340 #[cfg(feature = "rand")]
341 #[test]
342 fn test_generate_24_words() {
343 let wallet = Wallet::generate(24, None).unwrap();
344 assert_eq!(wallet.word_count(), 24);
345 }
346
347 #[cfg(feature = "rand")]
348 #[test]
349 fn test_generate_with_passphrase() {
350 let wallet = Wallet::generate(12, Some("secret")).unwrap();
351 assert!(wallet.has_passphrase());
352 }
353
354 #[test]
355 fn test_invalid_entropy_length() {
356 // 15 bytes is invalid (should be 16, 20, 24, 28, or 32)
357 let result = Wallet::from_entropy(&[0u8; 15], None);
358 assert!(result.is_err());
359 }
360
361 #[test]
362 fn test_from_entropy() {
363 // 16 bytes = 12 words
364 let entropy = [0u8; 16];
365 let wallet = Wallet::from_entropy(&entropy, None).unwrap();
366 assert_eq!(wallet.word_count(), 12);
367 }
368
369 #[test]
370 fn test_from_mnemonic() {
371 let wallet = Wallet::from_mnemonic(TEST_MNEMONIC, None).unwrap();
372 assert_eq!(wallet.mnemonic(), TEST_MNEMONIC);
373 }
374
375 #[test]
376 fn test_passphrase_changes_seed() {
377 let wallet1 = Wallet::from_mnemonic(TEST_MNEMONIC, None).unwrap();
378 let wallet2 = Wallet::from_mnemonic(TEST_MNEMONIC, Some("password")).unwrap();
379
380 // Same mnemonic with different passphrase should produce different seeds
381 assert_ne!(wallet1.seed(), wallet2.seed());
382 }
383
384 #[test]
385 fn test_deterministic_seed() {
386 let wallet1 = Wallet::from_mnemonic(TEST_MNEMONIC, Some("test")).unwrap();
387 let wallet2 = Wallet::from_mnemonic(TEST_MNEMONIC, Some("test")).unwrap();
388 assert_eq!(wallet1.seed(), wallet2.seed());
389 }
390
391 #[test]
392 fn kat_bip39_seed_vector() {
393 // BIP-39 reference: "abandon...about" with empty passphrase
394 // Verified against Python pbkdf2_hmac + iancoleman.io
395 let wallet = Wallet::from_mnemonic(TEST_MNEMONIC, None).unwrap();
396 assert_eq!(
397 hex::encode(wallet.seed()),
398 "5eb00bbddcf069084889a8ab9155568165f5c453ccb85e70811aaed6f6da5fc1\
399 9a5ac40b389cd370d086206dec8aa6c43daea6690f20ad3d8d48b2d2ce9e38e4"
400 );
401 }
402
403 #[test]
404 fn debug_redacts_mnemonic_and_seed() {
405 let wallet = Wallet::from_mnemonic(TEST_MNEMONIC, None).unwrap();
406 let dbg = alloc::format!("{wallet:?}");
407 assert!(
408 dbg.contains("[REDACTED]"),
409 "expected redaction markers: {dbg}"
410 );
411 assert!(
412 !dbg.contains("abandon"),
413 "Debug must not leak mnemonic words: {dbg}"
414 );
415 // BIP-39 seed hex prefix for abandon…about
416 assert!(
417 !dbg.contains("5eb00bbddcf06908"),
418 "Debug must not leak seed bytes: {dbg}"
419 );
420 }
421
422 #[test]
423 fn kat_all_zero_entropy_produces_abandon_about() {
424 let wallet = Wallet::from_entropy(&[0u8; 16], None).unwrap();
425 assert_eq!(wallet.mnemonic(), TEST_MNEMONIC);
426 }
427}