Skip to main content

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}