Skip to main content

ms_codec/
error.rs

1//! ms-codec error taxonomy. Variants mirror SPEC §4 decoder validity rules
2//! plus the encoder-side validation surface from SPEC §3.5 / §3.5.1.
3
4use std::fmt;
5
6/// ms-codec error type.
7///
8/// `Debug` is hand-implemented (NOT derived) so that neither `Display` nor
9/// `Debug` of this type can echo ≥8 contiguous chars of secret input
10/// (`ms-codec-error-display-echoes-input`, 0.4.4). A derived `Debug` would
11/// print every field — including the raw input carried by the inner
12/// `crate::codex32::Error` (`InvalidChecksum`/`MismatchedHrp`/`MismatchedId`) and the
13/// `WrongHrp.got` HRP — so it is replaced by a delegation to the sanitized
14/// `Display`. This is load-bearing for downstream `#[derive(Debug)]` wrappers
15/// (toolkit `ToolkitError`/`CliError`) whose `{:?}` transitively renders this
16/// type via panics / `expect` / logging. Replacing the derive is NOT a SemVer
17/// break (the `Debug` IMPL is preserved; its exact output is not contractual).
18#[non_exhaustive]
19pub enum Error {
20    /// codex32 parse / checksum failure, delegated from the vendored codex32
21    /// module (`crate::codex32::Error`; Cycle-B inlined the formerly-external
22    /// `codex32 = "=0.1.0"` crate). The variant NAME and field SHAPE are
23    /// unchanged from the external-dep era; only the inner type's crate-path
24    /// moved (`codex32::Error` → `crate::codex32::Error`) — a pre-1.0 breaking
25    /// change for any downstream matcher that named the old extern type (the
26    /// toolkit's friendly.rs is exactly such a matcher → its paired bump).
27    Codex32(crate::codex32::Error),
28    /// Mnem wordlist-language byte was not in the valid range 0..=9 (SPEC v0.2 §3).
29    MnemUnknownLanguage(u8),
30    /// HRP was not "ms" (SPEC §4 rule 2).
31    WrongHrp {
32        /// The HRP that was observed.
33        got: String,
34    },
35    /// Threshold was not 0 (SPEC §4 rule 3).
36    ThresholdNotZero {
37        /// The threshold-position byte (ASCII digit) that was observed.
38        got: u8,
39    },
40    /// Share-index was not 's' — BIP-93 requires 's' for threshold=0 (SPEC §4 rule 4).
41    ShareIndexNotSecret {
42        /// The share-index character that was observed.
43        got: char,
44    },
45    /// Tag bytes were not in the codex32 alphabet (SPEC §4 rule 5).
46    TagInvalidAlphabet {
47        /// The 4-byte id-field bytes that failed alphabet validation.
48        got: [u8; 4],
49    },
50    /// Tag was structurally valid but not in RESERVED_TAG_TABLE (SPEC §4 rule 6).
51    UnknownTag {
52        /// The 4-byte tag that was not recognized.
53        got: [u8; 4],
54    },
55    /// Tag was in RESERVED_TAG_TABLE but reserved-not-emitted in v0.1 (SPEC §4 rule 7,
56    /// SPEC §3.5.1 encoder symmetry).
57    ReservedTagNotEmittedInV01 {
58        /// The 4-byte reserved tag (one of seed/xprv/mnem/prvk in v0.1).
59        got: [u8; 4],
60    },
61    /// Reserved-prefix byte was not 0x00 (SPEC §4 rule 8).
62    ReservedPrefixViolation {
63        /// The non-zero prefix byte that was observed.
64        got: u8,
65    },
66    /// Total string length was outside the v0.1 emittable set (SPEC §4 rule 9).
67    UnexpectedStringLength {
68        /// The total string length that was observed.
69        got: usize,
70        /// The set of v0.1-emittable lengths.
71        allowed: &'static [usize],
72    },
73    /// Payload byte length did not match the tag's spec (SPEC §3.5, §4 rule 10).
74    PayloadLengthMismatch {
75        /// The 4-byte tag whose length set was checked against.
76        tag: [u8; 4],
77        /// The set of valid byte lengths for this tag.
78        expected: &'static [usize],
79        /// The observed payload byte length (after stripping the prefix byte).
80        got: usize,
81    },
82    /// BCH error-correction (`bch_decode`) reported the input is uncorrectable
83    /// — the number of symbol errors exceeds the regular code's `t = 4`
84    /// correction capacity (singleton bound `d = 8`). Surfaced by
85    /// [`crate::decode_with_correction`] when `bch_decode::decode_regular_errors`
86    /// returns `None`, or when a post-correction re-verification step fails
87    /// (catches pathological 5+-error patterns that fool the decoder into
88    /// producing a "consistent" but invalid locator). Added v0.2.0 per plan
89    /// §1 D29 + §2.B.2.
90    ///
91    /// `bound = 8` is the BCH(93,80,8) singleton bound. ms1 is single-chunk
92    /// only — no `chunk_index` field (cf. md-codec's `TooManyErrors` which
93    /// carries chunk-set context).
94    TooManyErrors {
95        /// Singleton bound for the BCH regular code (always 8).
96        bound: u8,
97    },
98
99    // --- v0.2 K-of-N share variants (SPEC_ms_v0_2_kofn §2) ---
100    //
101    // Inserted alphabetically AMONG THEMSELVES (the pre-existing v0.1 variants
102    // above are NOT retro-sorted — mirrors the toolkit's
103    // `error-rs-retroactive-alphabetical-sort` deferral). These carry `Display`
104    // arms only: `ms_codec::Error` has no `exit_code`/`kind` methods — the
105    // exit-code/message mapping is ms-cli's `CliError` job.
106    /// Share count `n` was outside the valid range for threshold `k` (need
107    /// `k <= n <= 31`; there are exactly 31 valid non-`s` share indices).
108    InvalidShareCount {
109        /// The threshold `k` that was requested.
110        k: u8,
111        /// The share count `n` that was requested (out of range).
112        n: usize,
113    },
114    /// Threshold `k` was not in the valid share range `2..=9`
115    /// (`Threshold::ZERO` is the unshared single-string sentinel, a const).
116    InvalidThreshold(u8),
117    /// A single-string `decode` was handed one share of a K-of-N share-set
118    /// (threshold char `2..9`). Use `ms combine` to recombine K shares.
119    IsShareNotSingleString {
120        /// The threshold char observed on the wire (`'2'..'9'`).
121        threshold: char,
122        /// The share-index char observed on the wire.
123        index: char,
124    },
125    /// `combine_shares` was handed the secret-at-S (index `s`) as an input.
126    /// The secret-at-S is the recovery target, never a combine input; codex32's
127    /// `interpolate_at` would short-circuit on it and bypass validation (C1).
128    SecretShareSuppliedToCombine,
129    /// `combine_shares` was handed a same-id (same hrp/id/threshold/length) but
130    /// cross-polynomial share set: the first `k` shares define one polynomial,
131    /// but at least one EXTRA supplied share does not lie on it. Beyond-BIP-93
132    /// defense-in-depth (codex32 K-of-N carries no digest share) — without this
133    /// check the combine would silently return a WRONG secret. The supplied
134    /// shares are not all from the same split.
135    InconsistentShareSet,
136}
137
138impl fmt::Display for Error {
139    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
140        match self {
141            // SECRET-LEAK BOUND (ms-codec-error-display-echoes-input, 0.4.4):
142            // codex32-0.1.0's `Error` is `derive(Debug)`-only (NO `Display`), so
143            // a manual variant match is mandatory. Exactly 3 of its 16 variants
144            // carry the raw input string and MUST be intercepted EXPLICITLY (no
145            // generic `{:?}` fallback for them, so a future codex32 bump can't
146            // silently route a new leaky variant through):
147            //   * `InvalidChecksum { checksum, string }` — `string` is the FULL
148            //     input; `checksum` is a `&'static "short"/"long"` (safe).
149            //   * `MismatchedHrp(String, String)` — both dropped.
150            //   * `MismatchedId(String, String)` — both dropped.
151            // (MismatchedHrp/Id are provenance-bounded SAFE for ms1 — from
152            // `interpolate_at` on valid Codex32String, hrp="ms"/id=4 chars — but
153            // dropped for robustness.) The other 13 carry only
154            // `&'static str`/`usize`/`char`/`Case`/`Fe`/`field::Error` (all
155            // ≤1 echoed char < the 8-char window) and are rendered structurally
156            // via `{:?}` on the inner error AFTER the 3 leaky arms are peeled off.
157            Error::Codex32(e) => match e {
158                crate::codex32::Error::InvalidChecksum { checksum, .. } => {
159                    write!(f, "invalid {checksum} checksum (input withheld)")
160                }
161                crate::codex32::Error::MismatchedHrp(..) => {
162                    write!(f, "mismatched HRP across shares")
163                }
164                crate::codex32::Error::MismatchedId(..) => {
165                    write!(f, "mismatched ID across shares")
166                }
167                // Safe variants only reach here (the 3 leaky ones are peeled off
168                // above), so `{:?}` of the inner error echoes no secret window.
169                safe => write!(f, "codex32 parse error: {safe:?}"),
170            },
171            Error::MnemUnknownLanguage(code) => {
172                write!(f, "unknown mnem wordlist-language code: {0}", code)
173            }
174            Error::WrongHrp { got } => write!(f, "wrong HRP: got {:?}, expected \"ms\"", got),
175            Error::ThresholdNotZero { got } => {
176                write!(
177                    f,
178                    "threshold not 0 (got '{}'); v0.1 is single-string only",
179                    *got as char
180                )
181            }
182            Error::ShareIndexNotSecret { got } => {
183                write!(
184                    f,
185                    "share-index not 's' (got '{}'); BIP-93 requires 's' for threshold=0",
186                    got
187                )
188            }
189            Error::TagInvalidAlphabet { got } => {
190                write!(f, "tag bytes not in codex32 alphabet: {:?}", got)
191            }
192            Error::UnknownTag { got } => write!(
193                f,
194                "unknown tag {:?}; not a member of RESERVED_TAG_TABLE",
195                std::str::from_utf8(got).unwrap_or("<non-utf8>")
196            ),
197            Error::ReservedTagNotEmittedInV01 { got } => write!(
198                f,
199                "tag {:?} reserved-not-emitted in v0.1; deferred to v0.2+",
200                std::str::from_utf8(got).unwrap_or("<non-utf8>")
201            ),
202            Error::ReservedPrefixViolation { got } => {
203                write!(f, "reserved-prefix byte was 0x{:02x}, expected 0x00", got)
204            }
205            Error::UnexpectedStringLength { got, allowed } => {
206                write!(f, "string length {} outside v0.1 set {:?}", got, allowed)
207            }
208            Error::PayloadLengthMismatch { tag, expected, got } => write!(
209                f,
210                "tag {:?} payload length {} not in expected set {:?}",
211                std::str::from_utf8(tag).unwrap_or("<non-utf8>"),
212                got,
213                expected
214            ),
215            Error::TooManyErrors { bound } => {
216                write!(f, "more than {} errors; uncorrectable", bound)
217            }
218            Error::InvalidShareCount { k, n } => write!(
219                f,
220                "invalid share count n={} for threshold k={}; require k <= n <= 31",
221                n, k
222            ),
223            Error::InvalidThreshold(k) => write!(
224                f,
225                "invalid threshold {}; K-of-N shares require k in 2..=9",
226                k
227            ),
228            Error::IsShareNotSingleString { threshold, index } => write!(
229                f,
230                "this is one share of a K-of-N set (threshold '{}', index '{}'); \
231                 use `ms combine` to recombine K shares",
232                threshold, index
233            ),
234            Error::SecretShareSuppliedToCombine => write!(
235                f,
236                "the secret share (index 's') cannot be supplied to combine; \
237                 supply only distributed shares (the secret is the recovery target)"
238            ),
239            Error::InconsistentShareSet => write!(
240                f,
241                "one or more shares are not from the same split; the supplied \
242                 shares do not all lie on a single Shamir polynomial"
243            ),
244        }
245    }
246}
247
248impl fmt::Debug for Error {
249    /// Hand-rolled to match `Display`'s sanitization — see the type doc.
250    /// Delegates to the (non-echoing) `Display` so the leaky inner
251    /// `crate::codex32::Error` String fields and the (already construction-bounded)
252    /// `WrongHrp.got` can never reach a derived field dump. Wrapped as
253    /// `Error("…")` so the output still reads as a debug value.
254    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
255        write!(f, "Error(\"{self}\")")
256    }
257}
258
259impl std::error::Error for Error {
260    fn source(&self) -> Option<&(dyn std::error::Error + 'static)> {
261        // The vendored crate::codex32::Error does not impl std::error::Error
262        // (it didn't upstream either); the chain stops here.
263        None
264    }
265}
266
267impl From<crate::codex32::Error> for Error {
268    fn from(e: crate::codex32::Error) -> Self {
269        Error::Codex32(e)
270    }
271}
272
273/// Result alias for ms-codec.
274pub type Result<T> = std::result::Result<T, Error>;
275
276#[cfg(test)]
277mod no_echo_tests {
278    //! Red-first leak tests for the `ms-codec-error-display-echoes-input` fix
279    //! (0.4.4). Neither `Display` NOR `Debug` of `ms_codec::Error` may contain
280    //! any ≥8-char contiguous window of secret input, for ALL reachable inputs.
281    //! These tests construct/trigger the three leaky surfaces (codex32
282    //! `InvalidChecksum`/`MismatchedHrp`/`MismatchedId` + `WrongHrp`) and assert
283    //! the rendered strings carry no 8-char window of the secret.
284    use super::*;
285    use crate::{decode, decode_with_correction};
286
287    /// The contiguous-window length the fuzz oracle scans (8 chars = 40 bits
288    /// over the 32-symbol codex32 alphabet). Mirror it here.
289    const WINDOW: usize = 8;
290
291    /// Does `haystack` contain any ≥WINDOW-char contiguous window of `needle`?
292    fn contains_window(haystack: &str, needle: &str) -> Option<String> {
293        let n: Vec<char> = needle.chars().collect();
294        if n.len() < WINDOW {
295            return None;
296        }
297        for w in n.windows(WINDOW) {
298            let win: String = w.iter().collect();
299            if haystack.contains(&win) {
300                return Some(win);
301            }
302        }
303        None
304    }
305
306    /// Assert neither Display nor Debug of `e` carries an 8-char window of
307    /// `secret`.
308    fn assert_no_leak(e: &Error, secret: &str, label: &str) {
309        let display = format!("{e}");
310        let debug = format!("{e:?}");
311        if let Some(hit) = contains_window(&display, secret) {
312            panic!(
313                "{label}: Display leaked an {WINDOW}-char window of the secret: \
314                 hit={hit:?}\n  rendered: {display:?}"
315            );
316        }
317        if let Some(hit) = contains_window(&debug, secret) {
318            panic!(
319                "{label}: Debug leaked an {WINDOW}-char window of the secret: \
320                 hit={hit:?}\n  rendered: {debug:?}"
321            );
322        }
323    }
324
325    /// A 50-char codex32-alphabet "secret" data-part for the constructed cases.
326    const SECRET_50: &str = "qpzry9x8gf2tvdw0s3jn54khce6mua7lqpzry9x8gf2tvdw0s3";
327
328    /// (1) `Codex32(InvalidChecksum)` reached via a real `decode` — take a
329    /// valid 50-char ms1 string and flip one data char so the checksum fails.
330    /// codex32-0.1.0's `InvalidChecksum.string` carries the FULL input, so
331    /// pre-fix this leaks the whole secret data-part.
332    #[test]
333    fn codex32_invalid_checksum_from_decode_does_not_leak() {
334        // Verified-valid 50-char ms1 vector (decodes OK at HEAD).
335        let valid = "ms10entrsqgqqc83yukgh23xkvmp59xf2eldpk4cdrq2y4h82yz";
336        assert!(
337            decode(valid).is_ok(),
338            "fixture must decode: {:?}",
339            decode(valid)
340        );
341        let mut chars: Vec<char> = valid.chars().collect();
342        // Flip a data char (well past the `ms10entrs` prefix) → checksum fails.
343        let i = 14;
344        chars[i] = if chars[i] == 'q' { 'p' } else { 'q' };
345        let flipped: String = chars.iter().collect();
346        let e = decode(&flipped).unwrap_err();
347        // Must be the leaky Codex32(InvalidChecksum) arm.
348        assert!(
349            matches!(
350                e,
351                Error::Codex32(crate::codex32::Error::InvalidChecksum { .. })
352            ),
353            "expected Codex32(InvalidChecksum), got {e:?}"
354        );
355        // The secret is the data-part of the flipped string (after `ms1`).
356        let secret = flipped.strip_prefix("ms1").unwrap();
357        assert_no_leak(&e, secret, "codex32_invalid_checksum_from_decode");
358    }
359
360    /// (1b) `Codex32(InvalidChecksum)` constructed directly with a 50-char
361    /// secret string — the construction-side red-first cell.
362    #[test]
363    fn codex32_invalid_checksum_constructed_does_not_leak() {
364        let e = Error::Codex32(crate::codex32::Error::InvalidChecksum {
365            checksum: "short",
366            string: format!("ms1{SECRET_50}"),
367        });
368        assert_no_leak(&e, SECRET_50, "codex32_invalid_checksum_constructed");
369    }
370
371    /// (2) `WrongHrp` reached via a real `decode_with_correction` of a
372    /// no-separator 50-char secret-shaped input — pre-fix the whole input
373    /// rides in `got` (this is the path `parse_ms1_symbols` reaches directly;
374    /// `decode`/`inspect` length/codex32-validate first and route a
375    /// codex32-alphabet 50-char string to the checksum path instead).
376    #[test]
377    fn wrong_hrp_no_separator_does_not_leak() {
378        // 50 codex32-alphabet chars, NO `'1'` separator → the whole string is
379        // the observed HRP at the construction site (capped to 4 by the fix).
380        let secret = "qpzry9x8gf2tvdw0s3jn54khce6mua7lqpzry9x8gf2tvdw0s3";
381        assert!(!secret.contains('1'), "fixture must have no '1' separator");
382        let e = decode_with_correction(secret).unwrap_err();
383        assert!(
384            matches!(e, Error::WrongHrp { .. }),
385            "expected WrongHrp, got {e:?}"
386        );
387        assert_no_leak(&e, secret, "wrong_hrp_no_separator");
388    }
389
390    /// (3) `Codex32(MismatchedHrp)` constructed directly with secret strings.
391    #[test]
392    fn codex32_mismatched_hrp_does_not_leak() {
393        let e = Error::Codex32(crate::codex32::Error::MismatchedHrp(
394            SECRET_50.to_string(),
395            SECRET_50.to_string(),
396        ));
397        assert_no_leak(&e, SECRET_50, "codex32_mismatched_hrp");
398    }
399
400    /// (4) `Codex32(MismatchedId)` constructed directly with secret strings.
401    #[test]
402    fn codex32_mismatched_id_does_not_leak() {
403        let e = Error::Codex32(crate::codex32::Error::MismatchedId(
404            SECRET_50.to_string(),
405            SECRET_50.to_string(),
406        ));
407        assert_no_leak(&e, SECRET_50, "codex32_mismatched_id");
408    }
409}