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