Skip to content

Commit 6bec15b

Browse files
committed
feat: added crate kdk-mnemonic
1 parent 1ea28e0 commit 6bec15b

17 files changed

Lines changed: 1177 additions & 0 deletions

File tree

‎crates/kdk-mnemonic/Cargo.toml‎

Lines changed: 36 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,36 @@
1+
[package]
2+
name = "kdk-mnemonic"
3+
version = "0.0.1"
4+
edition.workspace = true
5+
license.workspace = true
6+
repository.workspace = true
7+
description = "BIP39 mnemonic conversion"
8+
9+
[dependencies]
10+
kdk-zeroize = { path = "../kdk-zeroize" }
11+
kdk-entropy = { path = "../kdk-entropy" }
12+
bip39 = { version = "2", default-features = false, features = ["alloc"] }
13+
14+
[features]
15+
default = ["words-12", "words-24", "dice"]
16+
words-12 = [] # 12-word mnemonic, 16-byte entropy
17+
words-15 = [] # 15-word, 20-byte
18+
words-18 = [] # 18-word, 24-byte
19+
words-21 = [] # 21-word, 28-byte
20+
words-24 = [] # 24-word, 32-byte
21+
extended = ["words-15", "words-18", "words-21"]
22+
dice = []
23+
coin = []
24+
deck = []
25+
26+
[[example]]
27+
name = "games"
28+
required-features = ["coin", "dice", "deck", "words-12", "words-24"]
29+
30+
[[example]]
31+
name = "games_extended"
32+
required-features = ["coin", "extended"]
33+
34+
[[example]]
35+
name = "secret_leak"
36+
required-features = ["words-12"]
Lines changed: 63 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,63 @@
1+
//! Demo: generate a BIP39 mnemonic from each kdk-mnemonic game
2+
//! pipeline (coin, dice, deck) at the default word counts (12, 24).
3+
//!
4+
//! Mnemonic contents stay redacted via `GameMnemonic`'s
5+
//! `Debug`/`Display` impls (see `examples/secret_leak.rs` for the
6+
//! leak-surface analysis).
7+
//!
8+
//! For 15/18/21-word ("extended") word counts, see
9+
//! `examples/games_extended.rs` — `coin` is the only game whose
10+
//! entropy table covers those byte sizes today.
11+
//!
12+
//! Run: `cargo run --example games -p kdk-mnemonic --features "coin,dice,deck,words-12,words-24"`
13+
14+
use kdk_mnemonic::{coin_mnemonic, deck_mnemonic, dice_mnemonic};
15+
16+
macro_rules! demo {
17+
($w:literal, $call:expr) => {{
18+
let m = $call.expect("valid input");
19+
// `word_count()` reads the const generic — never touches the
20+
// inner secret. Reserve `expose_secret()` for code that
21+
// actually needs the words.
22+
let words = m.word_count();
23+
assert_eq!(words, $w, "word count mismatch for {}", stringify!($call));
24+
println!(" {}: {m:?} (words={words})", stringify!($call));
25+
}};
26+
}
27+
28+
const fn ascending<const K: usize>() -> [u8; K] {
29+
let mut arr = [0u8; K];
30+
let mut i = 0;
31+
while i < K {
32+
arr[i] = i as u8;
33+
i += 1;
34+
}
35+
arr
36+
}
37+
38+
fn main() {
39+
println!("== coin (FACES=2: each flip = 1 bit) ==");
40+
demo!(12, coin_mnemonic::<12>(&[0u8; 128]));
41+
demo!(24, coin_mnemonic::<24>(&[1u8; 256]));
42+
43+
println!();
44+
println!("== dice D6 ==");
45+
demo!(12, dice_mnemonic::<6, 12>(&[1u8; 50]));
46+
demo!(24, dice_mnemonic::<6, 24>(&[1u8; 99]));
47+
48+
println!();
49+
println!("== dice D20 ==");
50+
demo!(12, dice_mnemonic::<20, 12>(&[1u8; 30]));
51+
demo!(24, dice_mnemonic::<20, 24>(&[1u8; 60]));
52+
53+
println!();
54+
println!("== deck 52-card (standard) ==");
55+
demo!(12, deck_mnemonic::<52, 12>(&ascending::<25>()));
56+
57+
println!();
58+
println!("== deck 78-card (tarot) ==");
59+
demo!(24, deck_mnemonic::<78, 24>(&ascending::<45>()));
60+
61+
println!();
62+
println!("PASS: all six game pipelines produced mnemonics with the expected word count.");
63+
}
Lines changed: 30 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,30 @@
1+
//! Demo: generate 15/18/21-word ("extended") mnemonics via the coin
2+
//! pipeline. `dice` and `deck` entropy tables in `kdk-entropy` cover
3+
//! only the Krux-blessed `(FACES/CARDS, 16/32)` rows today, so the
4+
//! extended word counts dispatch to byte lengths those games don't
5+
//! yet support — only `coin` works end-to-end here.
6+
//!
7+
//! Run: `cargo run --example games_extended -p kdk-mnemonic --features "coin,extended"`
8+
9+
use kdk_mnemonic::coin_mnemonic;
10+
11+
macro_rules! demo {
12+
($w:literal, $call:expr) => {{
13+
let m = $call.expect("valid input");
14+
// `word_count()` reads the const generic — never touches the
15+
// inner secret.
16+
let words = m.word_count();
17+
assert_eq!(words, $w, "word count mismatch for {}", stringify!($call));
18+
println!(" {}: {m:?} (words={words})", stringify!($call));
19+
}};
20+
}
21+
22+
fn main() {
23+
println!("== coin extended (15 / 18 / 21 words) ==");
24+
demo!(15, coin_mnemonic::<15>(&[0u8; 160]));
25+
demo!(18, coin_mnemonic::<18>(&[0u8; 192]));
26+
demo!(21, coin_mnemonic::<21>(&[0u8; 224]));
27+
28+
println!();
29+
println!("PASS: extended coin pipelines produced 15-, 18-, and 21-word mnemonics.");
30+
}
Lines changed: 164 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,164 @@
1+
//! Check of the secret-leak boundary between `bip39`'s plain types and
2+
//! `kdk-mnemonic`'s wrappers.
3+
//!
4+
//! `kdk-mnemonic` does not eliminate copies; it try to guarantees the sensitive
5+
//! copy is wiped on drop and removes the legal path to read the source
6+
//! copy after move.
7+
//!
8+
//! Run: `cargo run --example secret_leak -p kdk-mnemonic`
9+
//!
10+
//! # Security notice
11+
//!
12+
//! `bip39::Mnemonic::from_entropy(&entropy)` borrows the entropy: the
13+
//! caller keeps their copy alongside bip39's internal one.
14+
//! `SensitiveBytes::new(entropy_bytes)` *moves* the entropy in: the
15+
//! compiler bans further reads of the source. This does not eliminate the
16+
//! physical bytes in the source frame until LLVM reuses that slot.
17+
18+
use bip39::Mnemonic;
19+
use kdk_mnemonic::{entropy_to_mnemonic, GameMnemonic};
20+
use kdk_zeroize::{prelude::*, SensitiveBytes};
21+
22+
enum Vector {}
23+
24+
fn main() {
25+
let entropy = [0u8; 16]; // canonical "abandon × 11 + about" 12-word vector
26+
27+
println!("== bip39 (reference: Debug redacts, Display leaks) ==");
28+
let bip_mnemonic = Mnemonic::from_entropy(&entropy).expect("valid entropy");
29+
let dbg = format!("{:?}", bip_mnemonic);
30+
let disp = format!("{}", bip_mnemonic);
31+
println!(" Debug: {dbg}");
32+
println!(" Display: {disp}");
33+
assert!(
34+
!dbg.contains("abandon"),
35+
"bip39 Debug unexpectedly leaked the word list"
36+
);
37+
assert!(
38+
disp.contains("about"),
39+
"expected bip39 Display to leak the word list"
40+
);
41+
42+
println!();
43+
println!("== kdk-mnemonic (Debug AND Display both redact) ==");
44+
let kdk_entropy = SensitiveBytes::<16, Vector>::new(entropy);
45+
let kdk_mnemonic: GameMnemonic<Vector, 12> =
46+
entropy_to_mnemonic(&kdk_entropy).expect("entropy length matches W=12");
47+
let dbg = format!("{:?}", kdk_mnemonic);
48+
let disp = format!("{}", kdk_mnemonic);
49+
println!(" Debug: {dbg}");
50+
println!(" Display: {disp}");
51+
// Real evidence: no word from the canonical 12-word vector leaks
52+
// on either channel. The literal "GameMnemonic(REDACTED)" string
53+
// is our own impl's output — asserting it equals itself adds no
54+
// information.
55+
assert!(!dbg.contains("abandon") && !dbg.contains("about"));
56+
assert!(!disp.contains("abandon") && !disp.contains("about"));
57+
58+
println!();
59+
println!("== bip39-style raw [u8; 16] ==");
60+
let raw = [0xAAu8; 16];
61+
let raw_dbg = format!("{:?}", raw);
62+
println!(" Debug: {raw_dbg}");
63+
assert!(
64+
raw_dbg.contains("170"), // 0xAA = 170 decimal
65+
"raw array Debug should print byte values"
66+
);
67+
// Display is not implemented for `[u8; N]`; the line below would
68+
// fail with E0277 ("the trait `Display` is not implemented for `[u8; 16]`"):
69+
// let _ = format!("{}", raw);
70+
println!(" Display: <not implemented on [u8; N] — would not compile>");
71+
72+
println!();
73+
println!("== kdk SensitiveBytes<16> ==");
74+
let wrapped = SensitiveBytes::<16, Vector>::new(raw);
75+
let wrapped_dbg = format!("{:?}", wrapped);
76+
let wrapped_disp = format!("{}", wrapped);
77+
println!(" Debug: {wrapped_dbg}");
78+
println!(" Display: {wrapped_disp}");
79+
// Real evidence: the original byte value (170 decimal = 0xAA)
80+
// does not appear in either channel.
81+
assert!(!wrapped_dbg.contains("170") && !wrapped_disp.contains("170"));
82+
83+
println!();
84+
println!("== bip39::Mnemonic Drop ==");
85+
let bip_ptr: *mut Mnemonic;
86+
{
87+
// <internal code>
88+
let boxed = Box::new(Mnemonic::from_entropy(&[0xAAu8; 16]).expect("valid entropy"));
89+
bip_ptr = Box::into_raw(boxed);
90+
}
91+
92+
unsafe {
93+
let size = core::mem::size_of::<Mnemonic>();
94+
let pre: Vec<u8> = (0..size)
95+
.map(|i| core::ptr::read_volatile((bip_ptr as *const u8).add(i)))
96+
.collect();
97+
core::ptr::drop_in_place(bip_ptr);
98+
let post: Vec<u8> = (0..size)
99+
.map(|i| core::ptr::read_volatile((bip_ptr as *const u8).add(i)))
100+
.collect();
101+
assert!(
102+
!post.iter().all(|&b| b == 0),
103+
"bip39 Mnemonic unexpectedly zeroed on Drop"
104+
);
105+
println!(
106+
" bytes pre-drop (first 16): {:02x?}",
107+
&pre[..16.min(size)]
108+
);
109+
println!(
110+
" bytes post-drop (first 16): {:02x?}",
111+
&post[..16.min(size)]
112+
);
113+
std::alloc::dealloc(bip_ptr as *mut u8, std::alloc::Layout::new::<Mnemonic>());
114+
}
115+
116+
println!();
117+
println!("== SensitiveBytes Drop ==");
118+
let raw_ptr: *mut SensitiveBytes<16, Vector>;
119+
{
120+
// <internal code>
121+
// Heap-alloc so the address survives the inner scope and stays
122+
// valid for post-drop inspection. `Box::into_raw` consumes the
123+
// Box without running its destructor — the SensitiveBytes is
124+
// still alive in memory, ownership transferred to `raw_ptr`.
125+
let boxed = Box::new(SensitiveBytes::<16, Vector>::new([0xAAu8; 16]));
126+
raw_ptr = Box::into_raw(boxed);
127+
}
128+
129+
unsafe {
130+
let mut pre = [0u8; 16];
131+
for (i, slot) in pre.iter_mut().enumerate() {
132+
*slot = core::ptr::read_volatile((raw_ptr as *const u8).add(i));
133+
}
134+
core::ptr::drop_in_place(raw_ptr);
135+
let mut post = [0u8; 16];
136+
for (i, slot) in post.iter_mut().enumerate() {
137+
*slot = core::ptr::read_volatile((raw_ptr as *const u8).add(i));
138+
}
139+
assert_eq!(post, [0u8; 16], "Drop did NOT wipe the bytes");
140+
println!(" bytes pre-drop: {:02x?}", pre);
141+
println!(" bytes post-drop: {:02x?}", post);
142+
std::alloc::dealloc(
143+
raw_ptr as *mut u8,
144+
std::alloc::Layout::new::<SensitiveBytes<16, Vector>>(),
145+
);
146+
}
147+
let leaked = format!("{}", kdk_mnemonic.expose_secret());
148+
let seed = kdk_mnemonic.expose_secret().to_seed("");
149+
let seed_dbg = format!("{:?}", seed);
150+
assert!(leaked.contains("abandon"));
151+
assert!(seed_dbg.starts_with("["), "bare seed prints its bytes");
152+
println!();
153+
println!(" > You can deliberately expose secret `.expose_secret()` and/or");
154+
println!(" > use `Display` to leak it:");
155+
println!(" >");
156+
println!(" >");
157+
println!(" > {leaked}");
158+
println!(" >");
159+
println!(" >");
160+
println!(" > Or use `to_seed()` and slice it:");
161+
println!(" >");
162+
println!(" >");
163+
println!(" > {:?}", &seed[..8]);
164+
}

‎crates/kdk-mnemonic/src/coin.rs‎

Lines changed: 37 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,37 @@
1+
use kdk_entropy::{coin_to_entropy, Coin, EntropyError};
2+
use kdk_zeroize::SensitiveBytes;
3+
4+
use crate::error::MnemonicError;
5+
use crate::game::{GameMnemonic, SensitiveGame};
6+
7+
/// Coin-flip BIP39 mnemonic.
8+
pub type CoinMnemonic<const W: u8> = GameMnemonic<Coin, W>;
9+
10+
impl SensitiveGame for Coin {
11+
fn to_entropy<const N: usize>(input: &[u8]) -> Result<SensitiveBytes<N, Self>, EntropyError> {
12+
coin_to_entropy::<N>(input)
13+
}
14+
}
15+
16+
/// Coin flips to BIP39 mnemonic. `W` selects the word count and must
17+
/// have a matching `words-<W>` Cargo feature enabled.
18+
///
19+
/// Each `W` count maps to a coin-entropy byte length `N` (`N = W * 4 / 3`):
20+
///
21+
/// - `coin_mnemonic::<12>(&flips) -> coin_to_entropy::<16>(&flips)`: 128 flips
22+
/// - `coin_mnemonic::<15>(&flips) -> coin_to_entropy::<20>(&flips)`: 160 flips
23+
/// - `coin_mnemonic::<18>(&flips) -> coin_to_entropy::<24>(&flips)`: 192 flips
24+
/// - `coin_mnemonic::<21>(&flips) -> coin_to_entropy::<28>(&flips)`: 224 flips
25+
/// - `coin_mnemonic::<24>(&flips) -> coin_to_entropy::<32>(&flips)`: 256 flips
26+
///
27+
/// # Example
28+
///
29+
/// ```
30+
/// use kdk_mnemonic::{coin_mnemonic, CoinMnemonic};
31+
///
32+
/// let flips = [0u8; 128];
33+
/// let _: CoinMnemonic<12> = coin_mnemonic::<12>(&flips).unwrap();
34+
/// ```
35+
pub fn coin_mnemonic<const W: u8>(flips: &[u8]) -> Result<CoinMnemonic<W>, MnemonicError> {
36+
Coin::mnemonic::<W>(flips)
37+
}

‎crates/kdk-mnemonic/src/deck.rs‎

Lines changed: 38 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,38 @@
1+
use kdk_entropy::{deck_to_entropy, Deck, EntropyError};
2+
use kdk_zeroize::SensitiveBytes;
3+
4+
use crate::error::MnemonicError;
5+
use crate::game::{GameMnemonic, SensitiveGame};
6+
7+
/// Card-draw BIP39 mnemonic. `CARDS` is the deck size (e.g. 52);
8+
/// `W` is the word count `in [12, 15, 18, 21, 24]`.
9+
pub type DeckMnemonic<const CARDS: u8, const W: u8> = GameMnemonic<Deck<CARDS>, W>;
10+
11+
impl<const CARDS: u8> SensitiveGame for Deck<CARDS> {
12+
fn to_entropy<const N: usize>(input: &[u8]) -> Result<SensitiveBytes<N, Self>, EntropyError> {
13+
deck_to_entropy::<CARDS, N>(input)
14+
}
15+
}
16+
17+
/// Card draws to BIP39 mnemonic. `CARDS` is the deck size and `W` the
18+
/// word count; the matching `words-<W>` Cargo feature must be enabled.
19+
///
20+
/// Requires the `deck` and `words-<W>` features.
21+
///
22+
/// # Example
23+
///
24+
/// ```
25+
/// use kdk_mnemonic::{deck_mnemonic, DeckMnemonic};
26+
///
27+
/// let cards: [u8; 25] = [
28+
/// 0, 1, 2, 3, 4, 5, 6, 7, 8, 9,
29+
/// 10, 11, 12, 13, 14, 15, 16, 17, 18, 19,
30+
/// 20, 21, 22, 23, 24,
31+
/// ];
32+
/// let _: DeckMnemonic<52, 12> = deck_mnemonic::<52, 12>(&cards).unwrap();
33+
/// ```
34+
pub fn deck_mnemonic<const CARDS: u8, const W: u8>(
35+
cards: &[u8],
36+
) -> Result<DeckMnemonic<CARDS, W>, MnemonicError> {
37+
<Deck<CARDS> as SensitiveGame>::mnemonic::<W>(cards)
38+
}

0 commit comments

Comments
 (0)