ms_codec/payload.rs
1//! Payload type — v0.2: Entr (BIP-39 entropy) and Mnem (BIP-39 mnemonic with language).
2
3use crate::consts::VALID_ENTR_LENGTHS;
4use crate::error::{Error, Result};
5use crate::tag::Tag;
6
7/// v0.2 payload kind.
8#[derive(Debug, Clone, Copy, PartialEq, Eq)]
9#[non_exhaustive]
10pub enum PayloadKind {
11 /// BIP-39 entropy (16/20/24/28/32 B).
12 Entr,
13 /// BIP-39 mnemonic entropy with wordlist language tag (16/20/24/28/32 B entropy).
14 Mnem,
15}
16
17/// v0.1 payload.
18///
19/// **Caller-wrap contract (SPEC v0.9.0 §1 item 2):** the `Vec<u8>` inside
20/// `Payload::Entr` is NOT zeroize-wrapped — widening the public type to
21/// `Zeroizing<Vec<u8>>` is a breaking change deferred indefinitely per
22/// SPEC §3 OOS-2. Callers MUST wrap the byte buffer at the use site
23/// (e.g., `let bytes = Zeroizing::new((*p.as_bytes()).to_vec());`)
24/// so that the secret-material lifetime ends with a scrubbed drop.
25/// ms-codec internally minimizes the un-scrubbed lifetime: encode + decode
26/// path locals are `Zeroizing<Vec<u8>>`; only the public `Payload::Entr`
27/// boundary is unwrapped.
28#[derive(Debug, Clone, PartialEq, Eq)]
29#[non_exhaustive]
30pub enum Payload {
31 /// BIP-39 entropy. Length MUST be in {16, 20, 24, 28, 32} bytes
32 /// (bijective with BIP-39 word counts {12, 15, 18, 21, 24}).
33 ///
34 /// **Caller responsibility:** ms-codec does NOT check the statistical
35 /// quality of these bytes. Callers are responsible for sourcing entropy
36 /// from a vetted CSPRNG, or from a BIP-39 mnemonic the user already trusts.
37 /// FIPS-style entropy-quality checks would slow encoding and provide false
38 /// assurance — they cannot detect attacker-supplied "pseudo-random" seeds
39 /// crafted to pass standard randomness tests. See SPEC §3.6.
40 ///
41 /// **Caller-wrap reminder:** wrap this `Vec<u8>` in `Zeroizing` at the
42 /// use site so it scrubs on drop. ms-codec cannot wrap this for you
43 /// without a breaking public-API change.
44 Entr(Vec<u8>),
45 /// BIP-39 mnemonic entropy with wordlist language tag. On-wire payload:
46 /// `[0x02][language_byte][entropy:N]` where `language_byte` indexes into
47 /// `consts::MNEM_LANGUAGE_NAMES` (0 = English, 1 = Japanese, …, 9 = Portuguese).
48 /// Entropy length MUST be in {16, 20, 24, 28, 32} bytes.
49 ///
50 /// **Caller-wrap reminder:** wrap `entropy` in `Zeroizing` at the use site.
51 Mnem {
52 /// BIP-39 wordlist language index (0..=9).
53 language: u8,
54 /// BIP-39 entropy bytes (16/20/24/28/32 B).
55 entropy: Vec<u8>,
56 },
57}
58
59impl Payload {
60 /// Validate the payload's intrinsic structure (byte length for Entr/Mnem;
61 /// language code range for Mnem).
62 /// Encoder MUST call this before emitting; decoder calls it after extracting
63 /// the payload bytes following the prefix byte.
64 pub fn validate(&self) -> Result<()> {
65 match self {
66 Payload::Entr(data) => {
67 if !VALID_ENTR_LENGTHS.contains(&data.len()) {
68 return Err(Error::PayloadLengthMismatch {
69 tag: *Tag::ENTR.as_bytes(),
70 expected: VALID_ENTR_LENGTHS,
71 got: data.len(),
72 });
73 }
74 Ok(())
75 }
76 Payload::Mnem { language, entropy } => {
77 if *language >= 10 {
78 return Err(Error::MnemUnknownLanguage(*language));
79 }
80 if !VALID_ENTR_LENGTHS.contains(&entropy.len()) {
81 return Err(Error::PayloadLengthMismatch {
82 tag: *Tag::ENTR.as_bytes(),
83 expected: VALID_ENTR_LENGTHS,
84 got: entropy.len(),
85 });
86 }
87 Ok(())
88 }
89 }
90 }
91
92 /// The PayloadKind discriminant.
93 pub fn kind(&self) -> PayloadKind {
94 match self {
95 Payload::Entr(_) => PayloadKind::Entr,
96 Payload::Mnem { .. } => PayloadKind::Mnem,
97 }
98 }
99
100 /// Borrow the inner entropy byte slice.
101 /// For `Payload::Mnem`, returns the entropy bytes only (without prefix or language byte).
102 pub fn as_bytes(&self) -> &[u8] {
103 match self {
104 Payload::Entr(data) => data,
105 Payload::Mnem { entropy, .. } => entropy,
106 }
107 }
108}
109
110#[cfg(test)]
111mod tests {
112 use super::*;
113
114 // --- Mnem failing tests (written before impl per TDD) ---
115
116 #[test]
117 fn mnem_valid_language_and_entropy_accepts() {
118 let p = Payload::Mnem {
119 language: 1,
120 entropy: vec![0u8; 16],
121 };
122 assert!(matches!(p.validate(), Ok(())));
123 }
124
125 #[test]
126 fn mnem_language_10_rejects() {
127 let p = Payload::Mnem {
128 language: 10,
129 entropy: vec![0u8; 16],
130 };
131 assert!(matches!(p.validate(), Err(Error::MnemUnknownLanguage(10))));
132 }
133
134 #[test]
135 fn mnem_language_0x10_rejects() {
136 let p = Payload::Mnem {
137 language: 0x10,
138 entropy: vec![0u8; 16],
139 };
140 assert!(matches!(
141 p.validate(),
142 Err(Error::MnemUnknownLanguage(0x10))
143 ));
144 }
145
146 #[test]
147 fn mnem_bad_entropy_length_rejects() {
148 let p = Payload::Mnem {
149 language: 0,
150 entropy: vec![0u8; 17],
151 };
152 assert!(matches!(
153 p.validate(),
154 Err(Error::PayloadLengthMismatch { .. })
155 ));
156 }
157
158 #[test]
159 fn mnem_kind_returns_mnem() {
160 let p = Payload::Mnem {
161 language: 0,
162 entropy: vec![0u8; 16],
163 };
164 assert_eq!(p.kind(), PayloadKind::Mnem);
165 }
166
167 // --- Entr tests (pre-existing) ---
168
169 #[test]
170 fn entr_accepts_all_bip39_lengths() {
171 for len in [16usize, 20, 24, 28, 32] {
172 let p = Payload::Entr(vec![0u8; len]);
173 p.validate()
174 .unwrap_or_else(|e| panic!("expected ok for len {}, got {:?}", len, e));
175 }
176 }
177
178 #[test]
179 fn entr_rejects_off_by_one_lengths() {
180 for len in [15usize, 17, 19, 21, 23, 25, 31, 33] {
181 let p = Payload::Entr(vec![0u8; len]);
182 assert!(
183 matches!(p.validate(), Err(Error::PayloadLengthMismatch { .. })),
184 "expected reject for len {}",
185 len
186 );
187 }
188 }
189
190 #[test]
191 fn entr_rejects_zero_length() {
192 let p = Payload::Entr(vec![]);
193 assert!(matches!(
194 p.validate(),
195 Err(Error::PayloadLengthMismatch { .. })
196 ));
197 }
198
199 #[test]
200 fn kind_returns_entr() {
201 assert_eq!(Payload::Entr(vec![0u8; 16]).kind(), PayloadKind::Entr);
202 }
203}