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