Skip to main content

kobe_primitives/
camouflage.rs

1//! Mnemonic camouflage via entropy-layer XOR encryption.
2//!
3//! This module provides a way to disguise a real BIP-39 mnemonic as another
4//! valid BIP-39 mnemonic using password-based encryption at the entropy layer.
5//!
6//! # How It Works
7//!
8//! 1. The real mnemonic is decoded into its raw entropy bytes.
9//! 2. A 256-bit key is derived from the user's password via PBKDF2-HMAC-SHA256.
10//! 3. The entropy is `XORed` with the derived key to produce new entropy.
11//! 4. The new entropy is re-encoded as a valid BIP-39 mnemonic (with correct checksum).
12//!
13//! The resulting "camouflaged" mnemonic is indistinguishable from any other valid
14//! BIP-39 mnemonic. Decryption uses the exact same process (XOR is its own inverse).
15//!
16//! # Versioning
17//!
18//! The salt and iteration count are tagged by [`Version`]. [`Version::V1`] is
19//! the only variant today and is the default everywhere. Future algorithm
20//! changes (e.g. Argon2id) will land as new enum variants, never by silently
21//! mutating the constants — this lets downstream users continue to decrypt
22//! older ciphertexts with [`decrypt_with`].
23//!
24//! # Security
25//!
26//! - The camouflaged mnemonic is a fully valid BIP-39 mnemonic.
27//! - Without the password, it is computationally infeasible to recover the original.
28//! - Security strength is bounded by the password entropy.
29//! - PBKDF2 with 600,000 iterations provides strong resistance to brute-force attacks.
30//!
31//! # Operational safety
32//!
33//! The [`DeriveError::Input`] returned on invalid
34//! phrases may repeat user-supplied tokens verbatim (for diagnostic
35//! purposes). **Never log the raw `Display` / `Debug` output of camouflage
36//! errors in production** — hash or drop them first. Use the typed variant
37//! for programmatic handling instead of scraping the human-readable message.
38
39use alloc::string::{String, ToString};
40
41use bip39::{Language, Mnemonic};
42use hmac::{Hmac, KeyInit, Mac};
43use sha2::Sha256;
44use zeroize::Zeroizing;
45
46use crate::DeriveError;
47
48/// Camouflage algorithm / parameter version.
49///
50/// Bump a new variant whenever the KDF, iteration count, or salt changes.
51/// Old ciphertexts must remain decryptable via their original version.
52#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Default)]
53#[non_exhaustive]
54pub enum Version {
55    /// v1: PBKDF2-HMAC-SHA256, 600 000 iterations, salt `"kobe-mnemonic-camouflage-v1"`.
56    #[default]
57    V1,
58}
59
60impl Version {
61    /// PBKDF2 iteration count for this version.
62    #[must_use]
63    pub const fn iterations(self) -> u32 {
64        match self {
65            Self::V1 => 600_000,
66        }
67    }
68
69    /// PBKDF2 salt for this version.
70    #[must_use]
71    pub const fn salt(self) -> &'static [u8] {
72        match self {
73            Self::V1 => b"kobe-mnemonic-camouflage-v1",
74        }
75    }
76}
77
78/// Maximum supported entropy length in bytes (256 bits for 24-word mnemonic).
79const MAX_ENTROPY_LEN: usize = 32;
80
81/// Encrypt a mnemonic phrase with the current default [`Version`].
82///
83/// The output is a valid BIP-39 mnemonic indistinguishable from any other.
84/// Supports 12, 15, 18, 21, and 24-word mnemonics.
85///
86/// # Errors
87///
88/// Returns an error if the mnemonic is invalid, the password is empty, or
89/// key derivation fails.
90pub fn encrypt(phrase: &str, password: &str) -> Result<Zeroizing<String>, DeriveError> {
91    transform(Language::English, phrase, password, Version::default())
92}
93
94/// Encrypt in the specified language with the current default [`Version`].
95///
96/// # Errors
97///
98/// Returns an error if the mnemonic is invalid, the password is empty, or
99/// key derivation fails.
100pub fn encrypt_in(
101    language: Language,
102    phrase: &str,
103    password: &str,
104) -> Result<Zeroizing<String>, DeriveError> {
105    transform(language, phrase, password, Version::default())
106}
107
108/// Encrypt with an explicit [`Version`] (use for forward compatibility tests
109/// or when pinning to a specific KDF parameter set).
110///
111/// # Errors
112///
113/// Returns an error if the mnemonic is invalid, the password is empty, or
114/// key derivation fails.
115pub fn encrypt_with(
116    language: Language,
117    phrase: &str,
118    password: &str,
119    version: Version,
120) -> Result<Zeroizing<String>, DeriveError> {
121    transform(language, phrase, password, version)
122}
123
124/// Decrypt a camouflaged mnemonic with the current default [`Version`].
125///
126/// Functionally identical to [`encrypt`] because XOR is self-inverse; the
127/// separate name is kept for API clarity.
128///
129/// # Errors
130///
131/// Returns an error if the mnemonic is invalid, the password is empty, or
132/// key derivation fails.
133pub fn decrypt(camouflaged: &str, password: &str) -> Result<Zeroizing<String>, DeriveError> {
134    transform(Language::English, camouflaged, password, Version::default())
135}
136
137/// Decrypt in the specified language with the current default [`Version`].
138///
139/// # Errors
140///
141/// Returns an error if the mnemonic is invalid, the password is empty, or
142/// key derivation fails.
143pub fn decrypt_in(
144    language: Language,
145    camouflaged: &str,
146    password: &str,
147) -> Result<Zeroizing<String>, DeriveError> {
148    transform(language, camouflaged, password, Version::default())
149}
150
151/// Decrypt with an explicit [`Version`]. Required for decrypting ciphertexts
152/// produced by a non-default version.
153///
154/// # Errors
155///
156/// Returns an error if the mnemonic is invalid, the password is empty, or
157/// key derivation fails.
158pub fn decrypt_with(
159    language: Language,
160    camouflaged: &str,
161    password: &str,
162    version: Version,
163) -> Result<Zeroizing<String>, DeriveError> {
164    transform(language, camouflaged, password, version)
165}
166
167/// Core transformation: XOR the mnemonic's entropy with a password-derived
168/// key. Since XOR is self-inverse, this single function handles both encrypt
169/// and decrypt, parameterised by [`Version`].
170fn transform(
171    language: Language,
172    phrase: &str,
173    password: &str,
174    version: Version,
175) -> Result<Zeroizing<String>, DeriveError> {
176    if password.is_empty() {
177        return Err(DeriveError::Input(String::from(
178            "password must not be empty",
179        )));
180    }
181
182    let mnemonic = Mnemonic::parse_in(language, phrase)?;
183    let entropy = Zeroizing::new(mnemonic.to_entropy());
184    let entropy_len = entropy.len();
185
186    let key = derive_key(password, entropy_len, version)?;
187
188    let mut new_entropy = Zeroizing::new([0u8; MAX_ENTROPY_LEN]);
189    for (dst, (ent, kb)) in new_entropy
190        .iter_mut()
191        .zip(entropy.iter().zip(key.iter()))
192        .take(entropy_len)
193    {
194        *dst = ent ^ kb;
195    }
196
197    let new_mnemonic = Mnemonic::from_entropy_in(
198        language,
199        new_entropy.get(..entropy_len).ok_or_else(|| {
200            DeriveError::Crypto(String::from("camouflage: entropy truncation failed"))
201        })?,
202    )?;
203    Ok(Zeroizing::new(new_mnemonic.to_string()))
204}
205
206/// HMAC-SHA256 output size in bytes.
207const HMAC_SHA256_LEN: usize = 32;
208
209/// XOR `src` into `dest` (`dest[i] ^= src[i]`).
210///
211/// Handles the case where `dest` is shorter than `src` (last-block truncation).
212#[inline]
213fn xor_buf(dest: &mut [u8], src: &[u8]) {
214    dest.iter_mut().zip(src).for_each(|(d, s)| *d ^= s);
215}
216
217/// PBKDF2-HMAC-SHA256 (RFC 8018 §5.2).
218///
219/// Self-contained implementation verified against RFC 7914 §11 test vectors.
220/// Structurally identical to the `RustCrypto` `pbkdf2` crate.
221///
222/// The `pbkdf2` crate is not used because its stable release (0.12) depends on
223/// `digest 0.10`, which is incompatible with `hmac 0.13` / `sha2 0.11`
224/// (`digest 0.11`). The `0.13` release is still in RC.
225//
226// TODO(security): once `pbkdf2 0.13` reaches a stable release, replace this
227// hand-rolled loop with `pbkdf2::pbkdf2_hmac::<Sha256>` and drop the RFC 7914
228// test vectors embedded in `#[cfg(test)]` below. Hand-rolled PBKDF2 is
229// audit-sensitive; the wrapper crate is the long-term home.
230fn pbkdf2_hmac_sha256(
231    password: &[u8],
232    salt: &[u8],
233    iterations: u32,
234    output: &mut [u8],
235) -> Result<(), DeriveError> {
236    let prf = Hmac::<Sha256>::new_from_slice(password)
237        .map_err(|_| DeriveError::Crypto(String::from("pbkdf2: HMAC key init failed")))?;
238
239    for (i, chunk) in output.chunks_mut(HMAC_SHA256_LEN).enumerate() {
240        chunk.fill(0);
241
242        // U_1 = PRF(password, salt || INT(i + 1))
243        let mut mac = prf.clone();
244        mac.update(salt);
245        let block_num = u32::try_from(i + 1)
246            .map_err(|_| DeriveError::Crypto(String::from("pbkdf2: block counter overflow")))?;
247        mac.update(&block_num.to_be_bytes());
248        let mut u = mac.finalize().into_bytes();
249        chunk.copy_from_slice(
250            u.get(..chunk.len()).ok_or_else(|| {
251                DeriveError::Crypto(String::from("pbkdf2: output buffer overrun"))
252            })?,
253        );
254
255        // U_2 .. U_c
256        for _ in 1..iterations {
257            let mut inner_mac = prf.clone();
258            inner_mac.update(&u);
259            u = inner_mac.finalize().into_bytes();
260            xor_buf(chunk, &u);
261        }
262    }
263
264    Ok(())
265}
266
267/// Derive a key from a password using [`pbkdf2_hmac_sha256`] parameterised
268/// by a [`Version`].
269///
270/// Returns a [`Zeroizing`] buffer of exactly `len` bytes.
271fn derive_key(
272    password: &str,
273    len: usize,
274    version: Version,
275) -> Result<Zeroizing<[u8; MAX_ENTROPY_LEN]>, DeriveError> {
276    let mut key = Zeroizing::new([0u8; MAX_ENTROPY_LEN]);
277    pbkdf2_hmac_sha256(
278        password.as_bytes(),
279        version.salt(),
280        version.iterations(),
281        key.get_mut(..len).ok_or_else(|| {
282            DeriveError::Crypto(String::from("pbkdf2: key buffer truncation failed"))
283        })?,
284    )?;
285    Ok(key)
286}
287
288#[cfg(test)]
289mod tests {
290    use super::*;
291
292    const TEST_12: &str = "abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon about";
293    const TEST_15: &str = "abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon address";
294    const TEST_18: &str = "abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon agent";
295    const TEST_21: &str = "abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon admit";
296    const TEST_24: &str = "abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon art";
297    const PASSWORD: &str = "my-secret-password-2024";
298
299    #[test]
300    fn roundtrip_24_words() {
301        let camouflaged = encrypt(TEST_24, PASSWORD).unwrap();
302
303        // Camouflaged mnemonic must differ from original.
304        assert_ne!(camouflaged.as_str(), TEST_24);
305
306        // Camouflaged mnemonic must be a valid BIP-39 phrase.
307        assert!(Mnemonic::parse_in(Language::English, camouflaged.as_str()).is_ok());
308
309        // Decryption must recover the original.
310        let recovered = decrypt(&camouflaged, PASSWORD).unwrap();
311        assert_eq!(recovered.as_str(), TEST_24);
312    }
313
314    #[test]
315    fn roundtrip_12_words() {
316        let camouflaged = encrypt(TEST_12, PASSWORD).unwrap();
317        assert_ne!(camouflaged.as_str(), TEST_12);
318
319        let recovered = decrypt(&camouflaged, PASSWORD).unwrap();
320        assert_eq!(recovered.as_str(), TEST_12);
321    }
322
323    #[test]
324    fn roundtrip_15_words() {
325        let camouflaged = encrypt(TEST_15, PASSWORD).unwrap();
326        assert_ne!(camouflaged.as_str(), TEST_15);
327        assert!(Mnemonic::parse_in(Language::English, camouflaged.as_str()).is_ok());
328
329        let recovered = decrypt(&camouflaged, PASSWORD).unwrap();
330        assert_eq!(recovered.as_str(), TEST_15);
331    }
332
333    #[test]
334    fn roundtrip_18_words() {
335        let camouflaged = encrypt(TEST_18, PASSWORD).unwrap();
336        assert_ne!(camouflaged.as_str(), TEST_18);
337        assert!(Mnemonic::parse_in(Language::English, camouflaged.as_str()).is_ok());
338
339        let recovered = decrypt(&camouflaged, PASSWORD).unwrap();
340        assert_eq!(recovered.as_str(), TEST_18);
341    }
342
343    #[test]
344    fn roundtrip_21_words() {
345        let camouflaged = encrypt(TEST_21, PASSWORD).unwrap();
346        assert_ne!(camouflaged.as_str(), TEST_21);
347        assert!(Mnemonic::parse_in(Language::English, camouflaged.as_str()).is_ok());
348
349        let recovered = decrypt(&camouflaged, PASSWORD).unwrap();
350        assert_eq!(recovered.as_str(), TEST_21);
351    }
352
353    #[test]
354    fn different_passwords_produce_different_results() {
355        let c1 = encrypt(TEST_24, "password-alpha").unwrap();
356        let c2 = encrypt(TEST_24, "password-beta").unwrap();
357        assert_ne!(c1.as_str(), c2.as_str());
358    }
359
360    #[test]
361    fn wrong_password_does_not_recover() {
362        let camouflaged = encrypt(TEST_24, PASSWORD).unwrap();
363        let wrong = decrypt(&camouflaged, "wrong-password").unwrap();
364        assert_ne!(wrong.as_str(), TEST_24);
365    }
366
367    #[test]
368    fn deterministic_output() {
369        let c1 = encrypt(TEST_24, PASSWORD).unwrap();
370        let c2 = encrypt(TEST_24, PASSWORD).unwrap();
371        assert_eq!(c1.as_str(), c2.as_str());
372    }
373
374    #[test]
375    fn camouflaged_is_valid_mnemonic() {
376        let camouflaged = encrypt(TEST_24, PASSWORD).unwrap();
377        let wallet = crate::Wallet::from_mnemonic(&camouflaged, None);
378        assert!(
379            wallet.is_ok(),
380            "camouflaged mnemonic must produce a valid wallet"
381        );
382    }
383
384    #[test]
385    fn empty_password_rejected() {
386        let result = encrypt(TEST_24, "");
387        assert!(result.is_err());
388    }
389
390    #[test]
391    fn preserves_word_count() {
392        for (phrase, expected_words) in [
393            (TEST_12, 12),
394            (TEST_15, 15),
395            (TEST_18, 18),
396            (TEST_21, 21),
397            (TEST_24, 24),
398        ] {
399            let camouflaged = encrypt(phrase, PASSWORD).unwrap();
400            let word_count = camouflaged.split_whitespace().count();
401            assert_eq!(word_count, expected_words);
402        }
403    }
404
405    /// RFC 7914 §11 — PBKDF2-HMAC-SHA256("passwd", "salt", c=1, dkLen=64)
406    ///
407    /// Multi-block vector (64 bytes = 2 × HMAC-SHA256 blocks).
408    #[test]
409    fn pbkdf2_rfc7914_vector1() {
410        #[rustfmt::skip]
411        let expected: [u8; 64] = [
412            0x55, 0xac, 0x04, 0x6e, 0x56, 0xe3, 0x08, 0x9f,
413            0xec, 0x16, 0x91, 0xc2, 0x25, 0x44, 0xb6, 0x05,
414            0xf9, 0x41, 0x85, 0x21, 0x6d, 0xde, 0x04, 0x65,
415            0xe6, 0x8b, 0x9d, 0x57, 0xc2, 0x0d, 0xac, 0xbc,
416            0x49, 0xca, 0x9c, 0xcc, 0xf1, 0x79, 0xb6, 0x45,
417            0x99, 0x16, 0x64, 0xb3, 0x9d, 0x77, 0xef, 0x31,
418            0x7c, 0x71, 0xb8, 0x45, 0xb1, 0xe3, 0x0b, 0xd5,
419            0x09, 0x11, 0x20, 0x41, 0xd3, 0xa1, 0x97, 0x83,
420        ];
421        let mut dk = [0u8; 64];
422        pbkdf2_hmac_sha256(b"passwd", b"salt", 1, &mut dk).unwrap();
423        assert_eq!(dk, expected);
424    }
425
426    /// RFC 7914 §11 — PBKDF2-HMAC-SHA256("Password", "`NaCl`", c=80000, dkLen=64)
427    #[test]
428    fn pbkdf2_rfc7914_vector2() {
429        #[rustfmt::skip]
430        let expected: [u8; 64] = [
431            0x4d, 0xdc, 0xd8, 0xf6, 0x0b, 0x98, 0xbe, 0x21,
432            0x83, 0x0c, 0xee, 0x5e, 0xf2, 0x27, 0x01, 0xf9,
433            0x64, 0x1a, 0x44, 0x18, 0xd0, 0x4c, 0x04, 0x14,
434            0xae, 0xff, 0x08, 0x87, 0x6b, 0x34, 0xab, 0x56,
435            0xa1, 0xd4, 0x25, 0xa1, 0x22, 0x58, 0x33, 0x54,
436            0x9a, 0xdb, 0x84, 0x1b, 0x51, 0xc9, 0xb3, 0x17,
437            0x6a, 0x27, 0x2b, 0xde, 0xbb, 0xa1, 0xd0, 0x78,
438            0x47, 0x8f, 0x62, 0xb3, 0x97, 0xf3, 0x3c, 0x8d,
439        ];
440        let mut dk = [0u8; 64];
441        pbkdf2_hmac_sha256(b"Password", b"NaCl", 80_000, &mut dk).unwrap();
442        assert_eq!(dk, expected);
443    }
444
445    /// Single-block output (32 bytes) — verifies first-block-only path.
446    #[test]
447    fn pbkdf2_rfc7914_vector1_single_block() {
448        #[rustfmt::skip]
449        let expected: [u8; 32] = [
450            0x55, 0xac, 0x04, 0x6e, 0x56, 0xe3, 0x08, 0x9f,
451            0xec, 0x16, 0x91, 0xc2, 0x25, 0x44, 0xb6, 0x05,
452            0xf9, 0x41, 0x85, 0x21, 0x6d, 0xde, 0x04, 0x65,
453            0xe6, 0x8b, 0x9d, 0x57, 0xc2, 0x0d, 0xac, 0xbc,
454        ];
455        let mut dk = [0u8; 32];
456        pbkdf2_hmac_sha256(b"passwd", b"salt", 1, &mut dk).unwrap();
457        assert_eq!(dk, expected);
458    }
459
460    /// Truncated output (20 bytes) — verifies partial-block extraction.
461    #[test]
462    fn pbkdf2_truncated_output() {
463        #[rustfmt::skip]
464        let expected: [u8; 20] = [
465            0x55, 0xac, 0x04, 0x6e, 0x56, 0xe3, 0x08, 0x9f,
466            0xec, 0x16, 0x91, 0xc2, 0x25, 0x44, 0xb6, 0x05,
467            0xf9, 0x41, 0x85, 0x21,
468        ];
469        let mut dk = [0u8; 20];
470        pbkdf2_hmac_sha256(b"passwd", b"salt", 1, &mut dk).unwrap();
471        assert_eq!(dk, expected);
472    }
473
474    /// Empty output — degenerate case, must not panic.
475    #[test]
476    fn pbkdf2_empty_output() {
477        let mut dk = [0u8; 0];
478        pbkdf2_hmac_sha256(b"passwd", b"salt", 1, &mut dk).unwrap();
479    }
480}