ms_codec/decode.rs
1//! Public decoder. Applies SPEC §4 validity rules in order.
2//!
3//! v0.2.0: also hosts [`decode_with_correction`] — the BCH-error-correcting
4//! decode entry point per plan §1 D22 + §2.B.2. Parse → polymod-residue →
5//! (if non-zero) call [`crate::bch_decode::decode_regular_errors`] → apply
6//! corrections → run the existing [`decode`] path → return
7//! `(Tag, Payload, Vec<CorrectionDetail>)`. ms1 is single-chunk per codex32
8//! spec, so there is no atomic-multi-chunk variant (cf. md-codec's
9//! per-chunk-set version).
10
11use crate::codex32::Codex32String;
12use crate::consts::{
13 RESERVED_NOT_EMITTED_V01, TAG_ENTR, TAG_HASH, VALID_MNEM_STR_LENGTHS,
14 VALID_PREIMAGE_STR_LENGTHS, VALID_STR_LENGTHS,
15};
16use crate::envelope;
17use crate::error::{Error, Result};
18use crate::payload::{Payload, PayloadKind};
19use crate::tag::Tag;
20
21/// Union of all emittable string lengths (entr ∪ mnem). Used as the
22/// pre-dispatch gate in `decode` before kind-specific binding.
23fn is_known_length(len: usize) -> bool {
24 VALID_STR_LENGTHS.contains(&len) || VALID_MNEM_STR_LENGTHS.contains(&len)
25}
26
27/// Return the kind-appropriate allowed-length set for error reporting.
28fn allowed_for_kind(kind: PayloadKind) -> &'static [usize] {
29 match kind {
30 PayloadKind::Entr => VALID_STR_LENGTHS,
31 PayloadKind::Mnem => VALID_MNEM_STR_LENGTHS,
32 PayloadKind::Preimage => VALID_PREIMAGE_STR_LENGTHS,
33 }
34}
35
36/// Decode an ms1 string into `(Tag, Payload)`.
37///
38/// Rejects per SPEC §4 rules 1-10 (extended for v0.2 mnem):
39///
40/// - Rule 1: upstream codex32 parse failure (Codex32 variant).
41/// - Rules 2-4, 8: wire-invariant violations (delegated to envelope::discriminate).
42/// - Rules 5-7: tag-table membership rules (here).
43/// - Rule 9: total string length not in the union {entr lengths} ∪ {mnem lengths}
44/// (here, before parse); then bound to the discriminated kind post-dispatch.
45/// - Rule 10: payload byte length mismatch for the tag (here, via Payload::validate()).
46pub fn decode(s: &str) -> Result<(Tag, Payload)> {
47 // §4 rule 9 (pre-dispatch): total string length must be in the union set.
48 if !is_known_length(s.len()) {
49 return Err(Error::UnexpectedStringLength {
50 got: s.len(),
51 allowed: VALID_STR_LENGTHS, // report the entr set as the primary allowed set
52 });
53 }
54
55 // §4 rule 1: delegate parse + checksum to rust-codex32.
56 let c = Codex32String::from_string(s.to_string())?;
57
58 // §4 rules 2, 3, 4, 8 + tag-alphabet rule 5: envelope (returns typed Payload).
59 let (tag, payload) = envelope::discriminate(&c)?;
60
61 // §4 rule 9 (post-dispatch, bind to kind): length must be in the kind-appropriate set.
62 let kind_allowed = allowed_for_kind(payload.kind());
63 if !kind_allowed.contains(&s.len()) {
64 return Err(Error::UnexpectedStringLength {
65 got: s.len(),
66 allowed: kind_allowed,
67 });
68 }
69
70 // §4 rule 7: reserved-not-emitted tags.
71 if RESERVED_NOT_EMITTED_V01.contains(tag.as_bytes()) {
72 return Err(Error::ReservedTagNotEmittedInV01 {
73 got: *tag.as_bytes(),
74 });
75 }
76
77 // §4 rule 6: tag must be in the v0.2 accept set (currently {entr}).
78 // cycle-15 Lane M (slug #2): MOVE the decoded bytes straight into the
79 // public `Payload` rather than cloning out of a throwaway `Zeroizing`
80 // envelope. The prior code wrapped `data` in a Zeroizing envelope and then
81 // deref-cloned it into the live `Payload`, which only scrubbed the
82 // already-moved-from buffer while allocating an EXTRA un-scrubbed heap copy
83 // — net theater. The move is strictly fewer copies and byte-identical wire
84 // behavior (`Payload::Entr(Vec<u8>)` shape is unchanged — bare-by-design per
85 // the deferred public-API slug; callers wrap at their use site, see payload.rs).
86 let payload = match *tag.as_bytes() {
87 // Rule 6b (SPEC_ms_hashlock §1 rule 2): a single's tag must name the
88 // kind its prefix byte carries. Checked BEFORE the per-tag arms so a
89 // `hash` tag over a seed payload, or `entr` over a preimage, is refused
90 // rather than read as the other kind.
91 x if (x == TAG_ENTR || x == TAG_HASH) && tag != payload.kind().single_tag() => {
92 return Err(Error::TagKindMismatch {
93 tag: x,
94 prefix: crate::envelope::prefix_of(&payload),
95 });
96 }
97 x if x == TAG_HASH => {
98 // A preimage single: length is structural in the variant.
99 payload
100 }
101 x if x == TAG_ENTR => {
102 match payload {
103 Payload::Entr(data) => {
104 let p = Payload::Entr(data);
105 // §4 rule 10: validate payload length.
106 p.validate()?;
107 p
108 }
109 Payload::Mnem { language, entropy } => {
110 let p = Payload::Mnem { language, entropy };
111 // §4 rule 10: validate (language range + entropy length).
112 p.validate()?;
113 p
114 }
115 // Unreachable: rule 6b above refused the mismatch. Kept as a
116 // typed error, never a panic.
117 other => {
118 return Err(Error::TagKindMismatch {
119 tag: x,
120 prefix: crate::envelope::prefix_of(&other),
121 })
122 }
123 }
124 }
125 _ => {
126 return Err(Error::UnknownTag {
127 got: *tag.as_bytes(),
128 });
129 }
130 };
131
132 Ok((tag, payload))
133}
134
135// ---------------------------------------------------------------------------
136// v0.2.0: BCH-error-correcting decode (plan §1 D22 + §2.B.2).
137// ---------------------------------------------------------------------------
138
139/// Per-correction report emitted by [`decode_with_correction`]. One entry
140/// per repaired character. `position` is 0-indexed into the codex32
141/// data-part (i.e. the characters following the `ms1` HRP + separator);
142/// `was` is the original (corrupted) char from the input; `now` is the
143/// corrected char.
144///
145/// ms1 is single-chunk per codex32 spec, so there is no `chunk_index`
146/// field (cf. md-codec's `CorrectionDetail`).
147#[derive(Debug, Clone, PartialEq, Eq)]
148pub struct CorrectionDetail {
149 /// 0-indexed position of the corrected character within the codex32
150 /// data-part (post-HRP-and-separator).
151 pub position: usize,
152 /// The original (corrupted) character at this position.
153 pub was: char,
154 /// The corrected character at this position.
155 pub now: char,
156}
157
158/// Local codex32 alphabet (BIP 173 lowercase). Each char = one 5-bit
159/// symbol. Mirrors md-codec's `chunk.rs` local copy — kept private here so
160/// this module doesn't widen the codex32 public surface.
161const CODEX32_ALPHABET: &[u8; 32] = b"qpzry9x8gf2tvdw0s3jn54khce6mua7l";
162
163/// BIP 173 HRP for ms1 strings (HRP + separator).
164const HRP_PREFIX: &str = "ms1";
165
166/// Parse an ms1 string into its 5-bit data-part symbol vector. Returns
167/// the data-with-checksum symbols (i.e. all symbols after `ms1`). The
168/// returned symbol count includes the 13-symbol BCH checksum tail.
169///
170/// Returns [`Error::WrongHrp`] if the string does not start with `ms1`,
171/// or [`Error::Codex32`] (via a `crate::codex32::Error::InvalidChar`) if any
172/// data-part character is not in the codex32 alphabet.
173fn parse_ms1_symbols(s: &str) -> Result<Vec<u8>> {
174 let lower = s.to_ascii_lowercase();
175 if !lower.starts_with(HRP_PREFIX) {
176 // Report the observed HRP (everything before the last '1' separator)
177 // so the error is actionable. '1' is ASCII, so `rfind('1')` always
178 // returns a char boundary — slicing there is safe regardless of any
179 // multi-byte content elsewhere. When there is NO separator, the whole
180 // (malformed) string is the observed HRP; never slice at `len-1`,
181 // which can land inside a multi-byte char and panic (found by
182 // stress-Cycle-C fuzzing on a no-`'1'` lossy-UTF8 input).
183 //
184 // SECRET-LEAK BOUND (ms-codec-error-display-echoes-input, 0.4.4): a
185 // data-char→`'1'` mutation can stretch the "observed HRP" into a long
186 // secret prefix. Cap the stored `got` to the first 4 CHARS (not bytes —
187 // multibyte chars like "ñ"/"é"/"😀" would re-introduce the v0.4.3 panic
188 // on a byte slice). 4 < the 8-char leak window and still carries the
189 // "you typed mk1/lnbc not ms1" diagnostic. Construction-time bound so
190 // downstream re-echoers (ms-cli, toolkit) inherit it for free.
191 let observed = match lower.rfind('1') {
192 Some(i) => &lower[..i],
193 None => &lower,
194 };
195 let got = observed.chars().take(4).collect::<String>();
196 return Err(Error::WrongHrp { got });
197 }
198 let rest = &lower[HRP_PREFIX.len()..];
199 let mut symbols: Vec<u8> = Vec::with_capacity(rest.len());
200 // Non-alphabet characters can't appear in a valid v0.1 string. We
201 // can't fabricate a `crate::codex32::Error` value here (the upstream crate
202 // doesn't expose a constructor for `InvalidChar`), so we use
203 // `UnexpectedStringLength` as a stand-in: the existing `decode` path
204 // would have rejected the string for the same reason on a different
205 // axis. Toolkit-side helper at B.7 absorbs into `UnparseableInput`
206 // per plan §2.B.4 D29 error-mapping table.
207 for c in rest.chars() {
208 let lc = c as u8;
209 let sym =
210 CODEX32_ALPHABET
211 .iter()
212 .position(|&b| b == lc)
213 .ok_or(Error::UnexpectedStringLength {
214 got: s.len(),
215 allowed: VALID_STR_LENGTHS,
216 })? as u8;
217 symbols.push(sym);
218 }
219 Ok(symbols)
220}
221
222/// Re-encode a 5-bit data-part symbol vector as a complete ms1 string.
223fn encode_ms1_string(data_with_checksum: &[u8]) -> String {
224 let mut out = String::with_capacity(HRP_PREFIX.len() + data_with_checksum.len());
225 out.push_str(HRP_PREFIX);
226 for &v in data_with_checksum {
227 out.push(CODEX32_ALPHABET[(v & 0x1F) as usize] as char);
228 }
229 out
230}
231
232/// BCH-error-correcting decode for a single ms1 string.
233///
234/// Per plan §1 Q1 lock — full-decode semantics: this is the single entry
235/// point that callers needing both "did anything get repaired?" AND "the
236/// fully-decoded `(Tag, Payload)`" should use.
237///
238/// Algorithm:
239/// 1. Parse the input as ms1 (`ms1` HRP + codex32 data-part) into a
240/// 5-bit symbol vector.
241/// 2. Compute the BCH polymod residue
242/// (`hrp_expand("ms") || data_with_checksum`) XOR'd against
243/// [`crate::bch::MS_REGULAR_CONST`].
244/// 3. Residue `== 0` ⇒ clean codeword; pass through to the existing
245/// [`decode`] entry point unchanged.
246/// 4. Residue `!= 0` ⇒ invoke
247/// [`crate::bch_decode::decode_regular_errors`]. If `None`, return
248/// `Err(Error::TooManyErrors { bound: 8 })` per plan §2.B.4 D29
249/// error-mapping table.
250/// 5. Apply corrections to the symbol vector, re-verify via polymod (a
251/// defensive catch for pathological 5+-error patterns that fool BM
252/// into returning a degree-≤4 locator with 4 valid roots), and record
253/// one [`CorrectionDetail`] per repaired character.
254/// 6. Re-encode the corrected symbol vector as an ms1 string and forward
255/// it to the existing [`decode`] entry point.
256///
257/// Per Q1 lock + D29 error-mapping table, any §4-rule error from the
258/// full decode (orphan variants like `ThresholdNotZero`,
259/// `ReservedTagNotEmittedInV01`, etc.) surfaces directly; toolkit-side
260/// `repair_via_ms_codec` (B.7) absorbs these into
261/// `RepairError::PostCorrectionDecodeFailed`.
262///
263/// Returns `(Tag, Payload, Vec<CorrectionDetail>)` on success. The
264/// correction-detail vector is in ascending `position` order; an empty
265/// vector means the input was already a valid codeword.
266pub fn decode_with_correction(s: &str) -> Result<(Tag, Payload, Vec<CorrectionDetail>)> {
267 // Parse data-part symbols. Length checks live in `decode` proper
268 // (rule 9 is enforced there after we've potentially corrected, since
269 // BCH correction does not change the string length).
270 let symbols = parse_ms1_symbols(s)?;
271
272 // Polymod residue against ms1's target constant.
273 let mut input = crate::bch::hrp_expand("ms");
274 input.extend_from_slice(&symbols);
275 let residue = crate::bch::polymod_run(&input) ^ crate::bch::MS_REGULAR_CONST;
276
277 if residue == 0 {
278 // Already a valid codeword; pass through to the existing decoder.
279 let (tag, payload) = decode(s)?;
280 return Ok((tag, payload, Vec::new()));
281 }
282
283 // Attempt BCH correction.
284 let (positions, magnitudes) = crate::bch_decode::decode_regular_errors(residue, symbols.len())
285 .ok_or(Error::TooManyErrors { bound: 8 })?;
286
287 // Apply corrections; record (was, now) chars per position.
288 let mut corrected = symbols.clone();
289 let mut details: Vec<CorrectionDetail> = Vec::with_capacity(positions.len());
290 for (&pos, &mag) in positions.iter().zip(&magnitudes) {
291 if pos >= corrected.len() {
292 // Defensive: chien_search bounded pos to [0, L); but a
293 // pathological 5+-error pattern could in principle skirt
294 // that.
295 return Err(Error::TooManyErrors { bound: 8 });
296 }
297 let was_byte = corrected[pos];
298 let now_byte = was_byte ^ mag;
299 let was = CODEX32_ALPHABET[(was_byte & 0x1F) as usize] as char;
300 let now = CODEX32_ALPHABET[(now_byte & 0x1F) as usize] as char;
301 details.push(CorrectionDetail {
302 position: pos,
303 was,
304 now,
305 });
306 corrected[pos] = now_byte;
307 }
308
309 // Defensive re-verify (catches pathological 5+-error patterns that
310 // happen to produce a degree-≤4 locator with 4 valid roots).
311 let mut verify_input = crate::bch::hrp_expand("ms");
312 verify_input.extend_from_slice(&corrected);
313 let verify_residue = crate::bch::polymod_run(&verify_input) ^ crate::bch::MS_REGULAR_CONST;
314 if verify_residue != 0 {
315 return Err(Error::TooManyErrors { bound: 8 });
316 }
317
318 // Hand the corrected string to the existing decoder. Any §4-rule
319 // error surfaces directly per Q1 lock; toolkit helper at B.7 absorbs.
320 let corrected_str = encode_ms1_string(&corrected);
321 let (tag, payload) = decode(&corrected_str)?;
322 Ok((tag, payload, details))
323}
324
325#[cfg(test)]
326mod tests {
327 use super::*;
328 use crate::encode;
329
330 #[test]
331 fn round_trip_entr_all_lengths() {
332 for len in [16usize, 20, 24, 28, 32] {
333 let entropy = (0..len as u8)
334 .map(|i| i.wrapping_mul(7))
335 .collect::<Vec<_>>();
336 let p = Payload::Entr(entropy.clone());
337 let s = encode::encode(Tag::ENTR, &p).unwrap();
338 let (tag, recovered) = decode(&s).unwrap();
339 assert_eq!(tag, Tag::ENTR);
340 assert_eq!(recovered, p);
341 }
342 }
343
344 #[test]
345 fn decode_rejects_unexpected_length() {
346 // 52 chars is outside both the entr set [50,56,62,69,75]
347 // and the mnem set [51,58,64,70,77].
348 let s = "ms10entrsxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx";
349 assert_eq!(s.len(), 52, "test string must be 52 chars");
350 assert!(matches!(
351 decode(s),
352 Err(Error::UnexpectedStringLength { .. })
353 ));
354 }
355
356 #[test]
357 fn decode_routes_share_to_is_share_not_single_string() {
358 // A distributed share of an entr-16 secret is a 50-char string (same
359 // length as a v0.1 entr-16 single — disambiguated by the threshold char,
360 // not length). It passes the length gate, parses, then discriminate must
361 // route it → IsShareNotSingleString (NOT ThresholdNotZero).
362 use crate::shares::{encode_shares, Threshold};
363 let p = Payload::Entr(vec![0xAAu8; 16]);
364 let shares = encode_shares(Tag::ENTR, Threshold::new(2).unwrap(), 3, &p).unwrap();
365 let s = &shares[0];
366 assert_eq!(s.len(), 50, "threshold=2 entr-16 share must be 50 chars");
367 match decode(s) {
368 Err(Error::IsShareNotSingleString { threshold, .. }) => {
369 assert_eq!(threshold, '2');
370 }
371 other => panic!("expected IsShareNotSingleString, got {other:?}"),
372 }
373 }
374
375 #[test]
376 fn decode_v01_single_strings_still_ok() {
377 // v0.1 entr single + v0.2 mnem single both decode unchanged.
378 let entr = encode::encode(Tag::ENTR, &Payload::Entr(vec![0x11u8; 16])).unwrap();
379 assert!(decode(&entr).is_ok(), "v0.1 entr single must still decode");
380 let mnem = encode::encode(
381 Tag::ENTR,
382 &Payload::Mnem {
383 language: 1,
384 entropy: vec![0x22u8; 16],
385 },
386 )
387 .unwrap();
388 assert!(decode(&mnem).is_ok(), "mnem single must still decode");
389 }
390
391 #[test]
392 fn decode_rejects_short_seed_string_with_reserved_tag() {
393 // Hand-build a 50-char string with id="seed" — 16-B entropy worth.
394 // The string-length check passes; tag-rule 7 fails.
395 let mut data = vec![0x00u8];
396 data.extend_from_slice(&[0xAAu8; 16]);
397 let c = Codex32String::from_seed("ms", 0, "seed", crate::codex32::Fe::S, &data).unwrap();
398 let s = c.to_string();
399 assert_eq!(s.len(), 50, "expected str.len 50 for 16-B + prefix");
400 assert!(matches!(
401 decode(&s),
402 Err(Error::ReservedTagNotEmittedInV01 { .. })
403 ));
404 }
405
406 // Regression: `decode_with_correction` must NOT panic on a non-`ms1`
407 // input with no `'1'` separator. Found by stress-Cycle-C fuzzing
408 // (`ms1_decode`): `parse_ms1_symbols` sliced `lower[..len-1]`, which lands
409 // inside a multi-byte char when there is no separator → char-boundary
410 // panic. The minimized reproducer is a single `0xaa` byte, which
411 // `String::from_utf8_lossy` turns into the 3-byte U+FFFD.
412 #[test]
413 fn decode_with_correction_no_separator_multibyte_does_not_panic() {
414 // Each input has no `'1'`, and `len-1` lands inside a multi-byte
415 // char at a different offset (1-, 2-, 3-, 4-byte chars + a long run).
416 let cases = [
417 String::from_utf8_lossy(&[0xaa]).into_owned(), // U+FFFD, 3 bytes — the fuzz reproducer
418 "é".to_string(), // 2-byte
419 "añ".to_string(), // ascii + 2-byte
420 "€".to_string(), // 3-byte
421 "😀".to_string(), // 4-byte
422 "é".repeat(25), // 50-byte multi-byte run
423 "İ".to_string(), // dotted-capital-I (case-fold edge)
424 ];
425 for s in &cases {
426 // Must return cleanly, never panic. No `'1'` ⇒ WrongHrp, with the
427 // observed HRP CAPPED to the first 4 chars (the 0.4.4
428 // secret-leak bound; char-counted so multibyte cases don't panic).
429 match decode_with_correction(s) {
430 Err(Error::WrongHrp { got }) => {
431 assert_eq!(
432 got,
433 s.chars().take(4).collect::<String>().to_ascii_lowercase(),
434 "got is the first 4 chars of the no-separator input (capped)"
435 );
436 }
437 other => panic!("expected WrongHrp for {s:?}, got {other:?}"),
438 }
439 }
440 }
441
442 // Preservation: an input WITH a `'1'` but a wrong HRP still reports the
443 // pre-separator part as `got` (byte-identical to pre-fix behavior).
444 #[test]
445 fn decode_with_correction_wrong_hrp_with_separator_unchanged() {
446 match decode_with_correction("xy1qqq") {
447 Err(Error::WrongHrp { got }) => assert_eq!(got, "xy"),
448 other => panic!("expected WrongHrp {{ got: \"xy\" }}, got {other:?}"),
449 }
450 // A `'1'` deep in a multi-byte string still slices at the (ASCII) '1'
451 // boundary, never inside the preceding char.
452 match decode_with_correction("ñ1zzz") {
453 Err(Error::WrongHrp { got }) => assert_eq!(got, "ñ"),
454 other => panic!("expected WrongHrp {{ got: \"ñ\" }}, got {other:?}"),
455 }
456 }
457}