Skip to main content

kdk_entropy/
dice.rs

1use kdk_zeroize::prelude::*;
2use sha2::{Digest, Sha256};
3
4use crate::error::EntropyError;
5use crate::utils::check_length;
6use crate::DiceEntropy;
7
8/// Minimum rolls required to fill an `N`-byte entropy buffer from a
9/// `FACES`-die. Values match Krux upstream byte-for-byte.
10///
11/// For more, see [https://selfcustody.github.io/krux/getting-started/usage/generating-a-mnemonic/]
12pub const fn min_rolls<const FACES: u8, const N: usize>() -> Result<usize, EntropyError> {
13    match (FACES, N) {
14        (6, 16) => Ok(50),
15        (6, 32) => Ok(99),
16        (20, 16) => Ok(30),
17        (20, 32) => Ok(60),
18        _ => Err(EntropyError::UnsupportedConfig(FACES, N)),
19    }
20}
21
22/// Practical upper bound on roll count — 2× [`min_rolls`].
23pub const fn max_rolls<const FACES: u8, const N: usize>() -> Result<usize, EntropyError> {
24    match min_rolls::<FACES, N>() {
25        Ok(m) => Ok(m * 2),
26        Err(e) => Err(e),
27    }
28}
29
30/// Fold a sequence of dice rolls into an `N`-byte entropy buffer,
31/// byte-for-byte compatible with Krux upstream.
32///
33/// based on `src/krux/pages/new_mnemonic/dice_rolls.py`
34/// rolls are formatted as a string (concatenated for `FACES < 10`,
35/// hyphen-separated otherwise), SHA-256 hashed, then the first `N`
36/// bytes of the digest become the entropy.
37///
38/// # Example
39///
40/// ```
41/// use kdk_entropy::{dice_to_entropy, DiceEntropy};
42/// use kdk_zeroize::prelude::*;
43///
44/// // 50 d6 rolls (all "1") → SHA-256("1"*50) truncated to 16 bytes.
45/// // Same bytes Krux produces for the same input.
46/// let rolls = [1u8; 50];
47/// let e: DiceEntropy<6, 16> = dice_to_entropy(&rolls).unwrap();
48/// assert_eq!(
49///     e.expose_secret(),
50///     &[
51///         0x3d, 0xac, 0x51, 0xa6, 0x5e, 0xc9, 0xfc, 0xfc,
52///         0x40, 0x9a, 0x1b, 0x5f, 0x1d, 0xef, 0xe9, 0x2b,
53///     ]
54/// );
55/// ```
56pub fn dice_to_entropy<const FACES: u8, const N: usize>(
57    rolls: &[u8],
58) -> Result<DiceEntropy<FACES, N>, EntropyError> {
59    let required = min_rolls::<FACES, N>()?;
60    let allowed = max_rolls::<FACES, N>()?;
61    check_length(rolls.len(), required, allowed)?;
62
63    // Validate before hashing — Krux relies on the UI to constrain
64    // input, KDK enforces the contract explicitly.
65    for (i, &roll) in rolls.iter().enumerate() {
66        if roll == 0 || roll > FACES {
67            return Err(EntropyError::RollOutOfRange(roll, i));
68        }
69    }
70
71    let mut hasher = Sha256::new();
72    if FACES < 10 {
73        // d6 path: ASCII digits concatenated, no separator.
74        for &roll in rolls {
75            hasher.update([b'0' + roll]);
76        }
77    } else {
78        // d20 path: variable-width decimals, hyphen-separated.
79        for (i, &roll) in rolls.iter().enumerate() {
80            if i > 0 {
81                hasher.update(b"-");
82            }
83            if roll >= 10 {
84                hasher.update([b'0' + roll / 10]);
85            }
86            hasher.update([b'0' + roll % 10]);
87        }
88    }
89    let digest = hasher.finalize();
90
91    let mut acc: DiceEntropy<FACES, N> = DiceEntropy::new([0u8; N]);
92    acc.expose_secret_mut().copy_from_slice(&digest[..N]);
93    Ok(acc)
94}