Skip to main content

ms_codec/
shares.rs

1//! K-of-N codex32 Shamir share encoding (ms v0.2).
2//!
3//! A secret (`entr` or `mnem`) splits into N shares, any K of which recombine
4//! to the original — using codex32's *native* threshold(k)+index Shamir
5//! mechanism, NOT a payload byte (SPEC_ms_v0_2_kofn §1). The codex32 header
6//! threshold char is the share-vs-single discriminator; the prefix byte
7//! (`0x00`=entr / `0x02`=mnem) remains the payload-KIND discriminator, recovered
8//! only on the secret-at-S after interpolation.
9//!
10//! v0.1/mnem single-strings stay byte-identical: `encode_shares(tag, ZERO, 1, &p)`
11//! reduces to the exact `package()`/`encode()` construction (the Phase-0 gate).
12
13use crate::consts::{HRP, RESERVED_ID_BLOCKLIST, SHARE_INDEX_V01};
14use crate::envelope::{dispatch_payload, extract_wire_fields, payload_wire_bytes, wire_string};
15use crate::error::{Error, Result};
16use crate::payload::Payload;
17use crate::tag::Tag;
18use codex32::{Codex32String, Fe};
19use zeroize::Zeroizing;
20
21/// The codex32 bech32 alphabet (32 chars). Index `s` (position 16) is the
22/// secret-at-S index — never a distributed-share index.
23const CODEX32_ALPHABET: &[u8; 32] = b"qpzry9x8gf2tvdw0s3jn54khce6mua7l";
24
25/// The 31 valid non-`s` share indices, taken from the bech32 alphabet in its
26/// own order with `s` removed (deterministic, front-to-back). `n <= 31` is
27/// enforced by `encode_shares`, so this pool never runs out.
28fn non_s_index_pool() -> Vec<Fe> {
29    CODEX32_ALPHABET
30        .iter()
31        .filter(|&&b| b != b's')
32        .map(|&b| Fe::from_char(b as char).expect("alphabet char is a valid Fe"))
33        .collect()
34}
35
36/// Generate a random 4-char codex32-alphabet `id`, re-rolling while it lands in
37/// `RESERVED_ID_BLOCKLIST` (a v0.1 type-tag-shaped value). Uses `getrandom`
38/// (0.3.x `getrandom::fill`) — no injected-RNG param (the `mk_codec::encode`
39/// precedent).
40fn random_id() -> String {
41    loop {
42        let mut raw = [0u8; 4];
43        getrandom::fill(&mut raw).expect("getrandom::fill must not fail");
44        let id: [u8; 4] = [
45            CODEX32_ALPHABET[(raw[0] & 0x1f) as usize],
46            CODEX32_ALPHABET[(raw[1] & 0x1f) as usize],
47            CODEX32_ALPHABET[(raw[2] & 0x1f) as usize],
48            CODEX32_ALPHABET[(raw[3] & 0x1f) as usize],
49        ];
50        if !RESERVED_ID_BLOCKLIST.contains(&id) {
51            // Every byte is a codex32-alphabet ASCII char → always valid UTF-8.
52            return String::from_utf8(id.to_vec()).expect("codex32 alphabet is ASCII");
53        }
54    }
55}
56
57/// A codex32 share threshold.
58///
59/// `ZERO` is the unshared v0.1 single-string sentinel (codex32 threshold `0`,
60/// share-index `s`); `new(k)` accepts a K-of-N share threshold `k in 2..=9`
61/// (codex32 `from_seed` accepts threshold `0` or `2..=9` only — `1` is invalid).
62#[derive(Debug, Clone, Copy, PartialEq, Eq)]
63pub struct Threshold(u8);
64
65impl Threshold {
66    /// The unshared single-string sentinel (threshold `0`). A const, NOT
67    /// `new(0)` — `new` only admits the K-of-N share range `2..=9`.
68    pub const ZERO: Threshold = Threshold(0);
69
70    /// Construct a K-of-N share threshold. `k` MUST be in `2..=9`, else
71    /// `Error::InvalidThreshold(k)`.
72    pub fn new(k: u8) -> Result<Threshold> {
73        if (2..=9).contains(&k) {
74            Ok(Threshold(k))
75        } else {
76            Err(Error::InvalidThreshold(k))
77        }
78    }
79
80    /// The threshold value (`0` for `ZERO`, `2..=9` for a share threshold).
81    pub fn get(self) -> u8 {
82        self.0
83    }
84}
85
86/// Split a secret (`entr` or `mnem`) into `n` codex32 K-of-N shares.
87///
88/// - `threshold == ZERO`: `n` MUST be 1; returns a single string **byte-identical**
89///   to `encode(tag, secret)` — the v0.1 single-string construction
90///   (`from_seed(HRP, 0, tag, Fe::S, [prefix]||payload)`, deterministic). The
91///   `id` stays the type `tag` (NOT random) — load-bearing for byte-identity.
92/// - `threshold == k ∈ 2..=9`: validate `k <= n <= 31` (else `InvalidShareCount`).
93///   A random 4-char `id` (not in `RESERVED_ID_BLOCKLIST`) keys the share-set.
94///   The secret-at-S (`Fe::S`) holds the real payload; `k-1` random **defining
95///   shares** at fixed canonical non-`s` indices + `interpolate_at` for the
96///   remaining `n-(k-1)` indices produce the `n` **distributed** shares. The
97///   secret-at-S is NEVER returned (it is the recovery target only).
98///
99/// Works identically for `entr` and `mnem` (byte-agnostic); language survives a
100/// `mnem` split (it rides the secret-at-S wire bytes).
101pub fn encode_shares(
102    tag: Tag,
103    threshold: Threshold,
104    n: usize,
105    secret: &Payload,
106) -> Result<Vec<String>> {
107    secret.validate()?;
108    let bytes = payload_wire_bytes(secret);
109
110    if threshold == Threshold::ZERO {
111        // Unshared single-string: must be n==1; byte-identical to encode().
112        if n != 1 {
113            return Err(Error::InvalidShareCount { k: 0, n });
114        }
115        let single = Codex32String::from_seed(HRP, 0, tag.as_str(), Fe::S, &bytes[..])?;
116        return Ok(vec![single.to_string()]);
117    }
118
119    let k = threshold.get();
120    let k_usize = k as usize;
121    // Bounds (SPEC §1): 2 <= k <= n <= 31 (31 valid non-`s` indices).
122    if !(k_usize <= n && n <= 31) {
123        return Err(Error::InvalidShareCount { k, n });
124    }
125
126    let id = random_id();
127    let pool = non_s_index_pool();
128
129    // cycle-15 Lane M (slug #3, PARTIAL / Q2 HOLD): the secret-bearing
130    // `Codex32String`/`Vec<Codex32String>`/`Vec<String>` bindings below
131    // (`secret_s`, `defining`, `distributed`) are `String`-backed foreign types
132    // in the dormant `codex32-0.1.0` crate with NO Drop/Zeroize — they CANNOT be
133    // wrapped in `Zeroizing` in-repo without vendoring/forking codex32 (out of
134    // scope this cycle; tracked by `rust-codex32-zeroize-upstream` +
135    // `codex32-upstream-dormant-vendor-vs-accept-decision`). What IS scrubbed:
136    // the `Vec<u8>` CSPRNG `filler` below stays `Zeroizing`. Lifetime-min: `secret_s`
137    // is consumed into `defining[0]` immediately (already minimal); the residual
138    // `String` surface is the `defining`/`distributed` vectors, dropped at fn return.
139    //
140    // 1. secret-at-S carries the real payload at index `s`, threshold `k`.
141    let secret_s = Codex32String::from_seed(HRP, k_usize, &id, Fe::S, &bytes[..])?;
142
143    // 2. k-1 random DEFINING shares at the first k-1 pool indices. Each gets a
144    //    CSPRNG payload of the SAME byte length as the secret (Zeroizing scrub).
145    //    The defining set [secret_s, def_1..def_{k-1}] is k points → fully
146    //    determines the Shamir polynomial.
147    let mut defining: Vec<Codex32String> = Vec::with_capacity(k_usize);
148    defining.push(secret_s);
149    for pool_idx in pool.iter().take(k_usize - 1) {
150        let mut filler: Zeroizing<Vec<u8>> = Zeroizing::new(vec![0u8; bytes.len()]);
151        getrandom::fill(&mut filler[..]).expect("getrandom::fill must not fail");
152        let share = Codex32String::from_seed(HRP, k_usize, &id, *pool_idx, &filler[..])?;
153        defining.push(share);
154    }
155
156    // 3. The n DISTRIBUTED shares: the k-1 defining shares (indices 0..k-1) plus
157    //    interpolation-derived shares at the remaining n-(k-1) pool indices.
158    //    The secret-at-S (defining[0]) is NEVER distributed.
159    let mut distributed: Vec<String> = Vec::with_capacity(n);
160    for share in defining.iter().skip(1) {
161        distributed.push(share.to_string());
162    }
163    for pool_idx in pool.iter().take(n).skip(k_usize - 1) {
164        let derived = Codex32String::interpolate_at(&defining, *pool_idx)?;
165        distributed.push(derived.to_string());
166    }
167
168    debug_assert_eq!(distributed.len(), n);
169    Ok(distributed)
170}
171
172/// Recombine `k` (or more) distributed shares of a K-of-N share-set into the
173/// original secret `(Tag, Payload)`.
174///
175/// Pre-validation runs BEFORE `interpolate_at` because codex32's
176/// `interpolate_at` short-circuits when the target index (`s`) is among the
177/// inputs (`lib.rs:262`) — bypassing its own payload validation. Order:
178/// 1. parse each share (`Error::Codex32` on failure — preserves the
179///    within-one-string mixed-case `InvalidCase` rejection), then re-parse the
180///    lowercased copy into the CANONICAL vector (BIP-173 uppercase QR form
181///    folds to canonical lowercase; codex32's `interpolate_at` does raw
182///    case-sensitive cross-share hrp/id compares, so canonicalization here —
183///    not field extraction — is what makes an uppercase or mixed-case SET
184///    combine, and what lets the index-`s` guard below see `b's'`);
185/// 2. **reject any share at index `s`** → `SecretShareSuppliedToCombine` (C1 —
186///    the secret-at-S is the recovery target, never a combine input);
187/// 3. `shares.len() >= k` (the first share's threshold) else surface
188///    `ThresholdNotPassed`;
189/// 4. distinct share indices else `RepeatedIndex` (codex32's own check is lazy);
190/// 5. recover the secret-at-S from EXACTLY the first `k` shares (which define
191///    the polynomial) via `interpolate_at(&parsed[..k], Fe::S)` (surfaces
192///    `Mismatched{Hrp,Id,Threshold,Length}` on a header-inconsistent k-set),
193///    then verify every EXTRA supplied share lies on that same polynomial
194///    (`interpolate_at(k_set, idx)` re-derived value must equal the supplied
195///    share) → `InconsistentShareSet` on any mismatch. (M6 — codex32 K-of-N
196///    carries no digest share; a same-id but cross-polynomial set previously
197///    combined to a SILENT WRONG secret. A valid exactly-k or n>k all-consistent
198///    combine is bit-identical to the prior all-shares interpolation.)
199///
200/// Returns **`(Tag::ENTR, …)`** always: the recovered secret-at-S carries the
201/// share-set's RANDOM `id` (NOT a type tag); the payload KIND is the prefix byte
202/// (via `dispatch_payload`), so the random id is discarded. (We do NOT route
203/// through `discriminate` — it would rebuild a `Tag` from the random id.)
204pub fn combine_shares(shares: &[String]) -> Result<(Tag, Payload)> {
205    // 1. Parse each share (map codex32 parse/checksum failure via Error::Codex32).
206    let parsed: Vec<Codex32String> = shares
207        .iter()
208        .map(|s| Codex32String::from_string(s.clone()).map_err(Error::Codex32))
209        .collect::<Result<Vec<_>>>()?;
210
211    // 1b. Canonicalize: re-parse each share's lowercased wire copy (NEVER
212    //     lowercase before the first parse above — that would launder the
213    //     within-one-string mixed-case `InvalidCase` rejection). codex32's
214    //     checksum engine case-folds, so this re-parse is infallible in
215    //     practice (probe-proven byte-identical for lowercase input); still
216    //     route the Result via `?`. The canonical vector feeds both the field
217    //     extraction below AND `interpolate_at` (whose raw case-sensitive
218    //     cross-share hrp/id compares are why extraction-side lowercasing
219    //     alone cannot fix combine) — it also makes the recovered output
220    //     lowercase.
221    let parsed: Vec<Codex32String> = parsed
222        .iter()
223        .map(|c| {
224            Codex32String::from_string(c.to_string().to_ascii_lowercase())
225                .map_err(Error::Codex32)
226        })
227        .collect::<Result<Vec<_>>>()?;
228
229    if parsed.is_empty() {
230        // No shares → surface as below-threshold (k unknown; report 1/0).
231        return Err(Error::Codex32(codex32::Error::ThresholdNotPassed {
232            threshold: 1,
233            n_shares: 0,
234        }));
235    }
236
237    // Re-parse wire fields for each → (threshold_byte, share_index_byte). Both
238    // are `u8` (Copy), so this owns nothing that borrows the per-share string.
239    // `wire_string` is subsumed by the canonical vector above (already
240    // lowercase) — kept as harmless defense-in-depth; the canonical vector is
241    // the load-bearing mechanism for combine.
242    let fields: Vec<(u8, u8)> = parsed
243        .iter()
244        .map(|c| {
245            let s = wire_string(c);
246            extract_wire_fields(&s).map(|f| (f.threshold_byte, f.share_index_byte))
247        })
248        .collect::<Result<Vec<_>>>()?;
249
250    // 2. C1: reject any input at index `s` BEFORE interpolate_at (the
251    //    short-circuit at codex32 lib.rs:262 would otherwise bypass validation).
252    if fields.iter().any(|&(_, idx)| idx == SHARE_INDEX_V01) {
253        return Err(Error::SecretShareSuppliedToCombine);
254    }
255
256    // 3. count >= k (the first share's threshold char). codex32 thresholds are
257    //    single ASCII digits ('2'..'9'); '0' (an unshared single) here means the
258    //    caller passed a v0.1 single-string into combine — also below any share
259    //    threshold, surfaced as ThresholdNotPassed.
260    let k = (fields[0].0 - b'0') as usize;
261    if parsed.len() < k {
262        return Err(Error::Codex32(codex32::Error::ThresholdNotPassed {
263            threshold: k,
264            n_shares: parsed.len(),
265        }));
266    }
267
268    // 4. distinct share indices (codex32's RepeatedIndex check is lazy — only
269    //    fires for the i==j Lagrange term — so pre-check exhaustively).
270    for i in 0..fields.len() {
271        for j in (i + 1)..fields.len() {
272            if fields[i].1 == fields[j].1 {
273                let idx = Fe::from_char(fields[i].1 as char).map_err(Error::Codex32)?;
274                return Err(Error::Codex32(codex32::Error::RepeatedIndex(idx)));
275            }
276        }
277    }
278
279    // 5. Recover the secret-at-S from EXACTLY k shares, then verify every
280    //    EXTRA supplied share lies on that same polynomial (M6 — beyond-BIP-93
281    //    defense-in-depth: codex32 K-of-N carries no digest share, so a same-id
282    //    [same hrp/id/threshold/length] but cross-polynomial set would otherwise
283    //    interpolate to a SILENT WRONG secret). The first k shares define the
284    //    polynomial; recovery surfaces Mismatched{Hrp,Id,Threshold,Length} via
285    //    Error::Codex32 on a header-inconsistent k-set, exactly as before.
286    //
287    //    Hard invariant (BRAINSTORM §6.0): a valid exactly-k combine is
288    //    bit-identical to the prior `interpolate_at(&parsed, Fe::S)` (k == n →
289    //    k_set == parsed, empty membership loop), and a valid n>k all-consistent
290    //    combine recovers the same secret (every extra lies on the curve).
291    let k_set = &parsed[..k];
292    let secret = Codex32String::interpolate_at(k_set, Fe::S).map_err(Error::Codex32)?;
293
294    // For each EXTRA supplied share, re-derive the polynomial's value at that
295    // share's index from the k-set and require it to equal the supplied share
296    // (full canonical lowercased Codex32String compare — header fields are
297    // already cross-checked by interpolate_at; this adds the polynomial/data
298    // dimension). The share-index char comes from the already-extracted `fields`
299    // (codex32's `Parts::share_index` is private); reuse the same `Fe::from_char`
300    // conversion as the distinct-index check above. Any mismatch ⇒ the set is
301    // not all from one split.
302    for j in k..parsed.len() {
303        let idx = Fe::from_char(fields[j].1 as char).map_err(Error::Codex32)?;
304        let derived = Codex32String::interpolate_at(k_set, idx).map_err(Error::Codex32)?;
305        if derived != parsed[j] {
306            return Err(Error::InconsistentShareSet);
307        }
308    }
309
310    // Payload KIND is the recovered prefix byte; the id is random → discard it
311    // and always return Tag::ENTR (the kind lives in the Payload, NOT the tag).
312    //
313    // cycle-15 Lane M (slug #3, PARTIAL / Q2 HOLD): `parsed`/`k_set` and the
314    // recovered `secret` are `Codex32String` (String-backed, no Drop — see the
315    // upstream-blocked note in `encode_shares`). The recovered secret WIRE BYTES
316    // are scrubbed below via the `Zeroizing<Vec<u8>>` wrap; the `Codex32String`
317    // `parsed` vector is dropped at fn return (lifetime already minimal).
318    let data: Zeroizing<Vec<u8>> = Zeroizing::new(secret.parts().data());
319    let payload = dispatch_payload(&data)?;
320    Ok((Tag::ENTR, payload))
321}
322
323#[cfg(test)]
324mod tests {
325    use super::*;
326
327    #[test]
328    fn new_accepts_2_through_9() {
329        for k in 2u8..=9 {
330            let t = Threshold::new(k).unwrap_or_else(|e| panic!("new({k}) should be Ok, got {e:?}"));
331            assert_eq!(t.get(), k);
332        }
333    }
334
335    #[test]
336    fn new_rejects_zero() {
337        assert!(matches!(Threshold::new(0), Err(Error::InvalidThreshold(0))));
338    }
339
340    #[test]
341    fn new_rejects_one() {
342        assert!(matches!(Threshold::new(1), Err(Error::InvalidThreshold(1))));
343    }
344
345    #[test]
346    fn new_rejects_ten() {
347        assert!(matches!(Threshold::new(10), Err(Error::InvalidThreshold(10))));
348    }
349
350    #[test]
351    fn zero_const_get_is_zero() {
352        assert_eq!(Threshold::ZERO.get(), 0);
353    }
354
355    #[test]
356    fn new_five_get_is_five() {
357        assert_eq!(Threshold::new(5).unwrap().get(), 5);
358    }
359
360    // --- encode_shares tests (Task 1.3) ---
361
362    use crate::consts::RESERVED_PREFIX;
363    use crate::encode::encode;
364    use crate::payload::Payload;
365    use crate::tag::Tag;
366    use codex32::{Codex32String, Fe};
367
368    fn entr_p() -> Payload {
369        Payload::Entr(vec![0xCDu8; 16])
370    }
371    fn mnem_p() -> Payload {
372        Payload::Mnem { language: 1, entropy: vec![0xCDu8; 16] }
373    }
374
375    /// Re-parse a share string and return (threshold_char, share_index_char, id).
376    fn share_header(s: &str) -> (char, char, String) {
377        let sep = s.rfind('1').unwrap();
378        let b = s.as_bytes();
379        let threshold = b[sep + 1] as char;
380        let id: String = s[sep + 2..sep + 6].to_string();
381        let index = b[sep + 6] as char;
382        (threshold, index, id)
383    }
384
385    #[test]
386    fn zero_share_is_byte_identical_to_encode_entr() {
387        let p = entr_p();
388        let shares = encode_shares(Tag::ENTR, Threshold::ZERO, 1, &p).unwrap();
389        assert_eq!(shares, vec![encode(Tag::ENTR, &p).unwrap()]);
390    }
391
392    #[test]
393    fn zero_share_is_byte_identical_to_encode_mnem() {
394        let p = mnem_p();
395        let shares = encode_shares(Tag::ENTR, Threshold::ZERO, 1, &p).unwrap();
396        assert_eq!(shares, vec![encode(Tag::ENTR, &p).unwrap()]);
397    }
398
399    #[test]
400    fn zero_share_requires_n_eq_1() {
401        let p = entr_p();
402        assert!(matches!(
403            encode_shares(Tag::ENTR, Threshold::ZERO, 2, &p),
404            Err(Error::InvalidShareCount { k: 0, n: 2 })
405        ));
406    }
407
408    #[test]
409    fn encode_shares_2_of_3_shape() {
410        let p = entr_p();
411        let shares = encode_shares(Tag::ENTR, Threshold::new(2).unwrap(), 3, &p).unwrap();
412        assert_eq!(shares.len(), 3);
413        // Each parses, threshold char '2', distinct non-`s` indices, same id.
414        let mut indices = Vec::new();
415        let mut ids = Vec::new();
416        for s in &shares {
417            Codex32String::from_string(s.clone()).expect("each share must parse");
418            let (thr, idx, id) = share_header(s);
419            assert_eq!(thr, '2', "threshold char");
420            assert_ne!(idx, 's', "distributed share must not be index s");
421            indices.push(idx);
422            ids.push(id);
423        }
424        // Distinct indices.
425        let mut sorted = indices.clone();
426        sorted.sort_unstable();
427        sorted.dedup();
428        assert_eq!(sorted.len(), indices.len(), "indices must be distinct");
429        // Same id across the set.
430        assert!(ids.windows(2).all(|w| w[0] == w[1]), "id must be shared");
431    }
432
433    #[test]
434    fn encode_shares_rejects_n_below_k() {
435        let p = entr_p();
436        assert!(matches!(
437            encode_shares(Tag::ENTR, Threshold::new(2).unwrap(), 1, &p),
438            Err(Error::InvalidShareCount { k: 2, n: 1 })
439        ));
440    }
441
442    #[test]
443    fn encode_shares_rejects_n_32() {
444        let p = entr_p();
445        assert!(matches!(
446            encode_shares(Tag::ENTR, Threshold::new(2).unwrap(), 32, &p),
447            Err(Error::InvalidShareCount { k: 2, n: 32 })
448        ));
449    }
450
451    #[test]
452    fn encode_shares_id_not_in_blocklist() {
453        // Statistical: across many splits, the random id never lands in the blocklist.
454        let p = entr_p();
455        for _ in 0..64 {
456            let shares = encode_shares(Tag::ENTR, Threshold::new(2).unwrap(), 2, &p).unwrap();
457            let (_, _, id) = share_header(&shares[0]);
458            let id_bytes: [u8; 4] = id.as_bytes().try_into().unwrap();
459            assert!(
460                !crate::consts::RESERVED_ID_BLOCKLIST.contains(&id_bytes),
461                "id {id:?} must not be in RESERVED_ID_BLOCKLIST"
462            );
463        }
464    }
465
466    /// Inline round-trip (combine_shares lands in Task 1.4): any k of the n
467    /// distributed shares, interpolated at S, recover the secret wire bytes.
468    #[test]
469    fn encode_shares_round_trip_via_interpolate_entr_and_mnem() {
470        for p in [entr_p(), mnem_p()] {
471            let secret_wire = crate::envelope::payload_wire_bytes(&p);
472            for k in 2u8..=9 {
473                let n = (k as usize) + 2; // exercise interpolation-derived shares
474                let shares = encode_shares(Tag::ENTR, Threshold::new(k).unwrap(), n, &p).unwrap();
475                assert_eq!(shares.len(), n);
476                let parsed: Vec<Codex32String> = shares
477                    .iter()
478                    .map(|s| Codex32String::from_string(s.clone()).unwrap())
479                    .collect();
480                // First k and last k subsets both recover the secret.
481                for subset in [&parsed[..k as usize], &parsed[n - k as usize..]] {
482                    let recovered = Codex32String::interpolate_at(subset, Fe::S).unwrap();
483                    assert_eq!(
484                        recovered.parts().data(),
485                        secret_wire[..],
486                        "k={k} n={n} kind={:?} must recover secret wire bytes",
487                        p.kind()
488                    );
489                }
490            }
491        }
492    }
493
494    // --- combine_shares tests (Task 1.4) ---
495
496    #[test]
497    fn combine_round_trip_entr_and_mnem_all_lengths() {
498        for ent_len in [16usize, 20, 24, 28, 32] {
499            let entr = Payload::Entr(vec![0x37u8; ent_len]);
500            let mnem = Payload::Mnem { language: 7, entropy: vec![0x91u8; ent_len] };
501            for p in [entr, mnem] {
502                for k in 2u8..=9 {
503                    let n = (k as usize) + 1;
504                    let shares =
505                        encode_shares(Tag::ENTR, Threshold::new(k).unwrap(), n, &p).unwrap();
506                    // First k and last k subsets both combine back to the secret.
507                    for subset in [&shares[..k as usize], &shares[n - k as usize..]] {
508                        let (tag, recovered) = combine_shares(subset).unwrap();
509                        assert_eq!(tag, Tag::ENTR, "combine always returns Tag::ENTR");
510                        assert_eq!(
511                            recovered,
512                            p,
513                            "k={k} n={n} ent_len={ent_len} must recover the exact payload"
514                        );
515                    }
516                }
517            }
518        }
519    }
520
521    #[test]
522    fn combine_rejects_below_threshold() {
523        let p = entr_p();
524        let shares = encode_shares(Tag::ENTR, Threshold::new(3).unwrap(), 4, &p).unwrap();
525        // Only 2 of a 3-of-4 set.
526        let err = combine_shares(&shares[..2]).unwrap_err();
527        assert!(
528            matches!(err, Error::Codex32(codex32::Error::ThresholdNotPassed { .. })),
529            "expected ThresholdNotPassed, got {err:?}"
530        );
531    }
532
533    #[test]
534    fn combine_rejects_duplicate_index() {
535        let p = entr_p();
536        let shares = encode_shares(Tag::ENTR, Threshold::new(2).unwrap(), 3, &p).unwrap();
537        // Same share twice → duplicate index.
538        let dup = vec![shares[0].clone(), shares[0].clone()];
539        assert!(matches!(
540            combine_shares(&dup),
541            Err(Error::Codex32(codex32::Error::RepeatedIndex(_)))
542        ));
543    }
544
545    #[test]
546    fn combine_rejects_secret_share_index_s() {
547        // Hand-build the secret-at-S directly (index `s`, threshold 2). It must
548        // be rejected BEFORE interpolate_at (C1 — the short-circuit would
549        // otherwise bypass payload validation).
550        let bytes = crate::envelope::payload_wire_bytes(&entr_p());
551        let secret_s = Codex32String::from_seed(HRP, 2, "tst7", Fe::S, &bytes[..])
552            .unwrap()
553            .to_string();
554        // Need >= k shares to get past the count check and reach the index check;
555        // but the index-s check runs first regardless, so a single secret-s input
556        // is rejected on the index axis.
557        let p = entr_p();
558        let shares = encode_shares(Tag::ENTR, Threshold::new(2).unwrap(), 2, &p).unwrap();
559        let with_secret = vec![secret_s, shares[0].clone()];
560        assert!(matches!(
561            combine_shares(&with_secret),
562            Err(Error::SecretShareSuppliedToCombine)
563        ));
564    }
565
566    #[test]
567    fn combine_rejects_mismatched_threshold() {
568        // Two shares from different-threshold sets, at DISTINCT indices (so the
569        // distinct-index pre-check passes and interpolate_at's eager
570        // MismatchedThreshold check fires). set2[0]=index q; set3[1]=index p.
571        let p = entr_p();
572        let set2 = encode_shares(Tag::ENTR, Threshold::new(2).unwrap(), 2, &p).unwrap();
573        let set3 = encode_shares(Tag::ENTR, Threshold::new(3).unwrap(), 3, &p).unwrap();
574        let mixed = vec![set2[0].clone(), set3[1].clone()];
575        let err = combine_shares(&mixed).unwrap_err();
576        assert!(
577            matches!(err, Error::Codex32(codex32::Error::MismatchedThreshold(..))),
578            "expected MismatchedThreshold, got {err:?}"
579        );
580    }
581
582    #[test]
583    fn combine_rejects_unparseable() {
584        let bad = vec!["not-an-ms1-string".to_string(), "also-bad".to_string()];
585        assert!(matches!(combine_shares(&bad), Err(Error::Codex32(_))));
586    }
587
588    // --- audit I9: combine must REJECT (not panic on) a non-standard-length
589    // Entr share set. The encode path validates length up front, but codex32
590    // share strings are an open format — an externally-constructed valid-checksum
591    // set with a non-standard payload length must surface a clean error, not abort.
592
593    /// Build a valid-checksum K-of-N Entr share set whose recovered payload has a
594    /// NON-STANDARD entropy length, bypassing `encode_shares`' `secret.validate()`
595    /// guard (which would reject it). Mirrors `encode_shares`' codex32
596    /// construction with a fixed id for determinism.
597    fn nonstandard_entr_distributed(k: usize, n: usize, entropy_len: usize) -> Vec<String> {
598        // wire payload = [RESERVED_PREFIX] || entropy
599        let mut bytes = vec![RESERVED_PREFIX];
600        bytes.extend(std::iter::repeat(0xCDu8).take(entropy_len));
601        let id = "tst7";
602        let secret_s = Codex32String::from_seed(HRP, k, id, Fe::S, &bytes[..]).unwrap();
603        let pool = non_s_index_pool();
604        let mut defining = vec![secret_s];
605        for pidx in pool.iter().take(k - 1) {
606            let filler = vec![0u8; bytes.len()];
607            defining.push(Codex32String::from_seed(HRP, k, id, *pidx, &filler[..]).unwrap());
608        }
609        let mut out = Vec::new();
610        for s in defining.iter().skip(1) {
611            out.push(s.to_string());
612        }
613        for pidx in pool.iter().take(n).skip(k - 1) {
614            out.push(Codex32String::interpolate_at(&defining, *pidx).unwrap().to_string());
615        }
616        out
617    }
618
619    #[test]
620    fn combine_rejects_nonstandard_entr_length_not_panics() {
621        // 17-byte entropy ∉ VALID_ENTR_LENGTHS. Pre-fix `combine_shares` returned
622        // Ok(unvalidated Entr) and `ms combine`'s from_entropy_in panicked
623        // (exit 101). Post-fix: a clean PayloadLengthMismatch, no panic.
624        let shares = nonstandard_entr_distributed(2, 2, 17);
625        let res = combine_shares(&shares);
626        assert!(
627            matches!(res, Err(Error::PayloadLengthMismatch { got: 17, .. })),
628            "expected PayloadLengthMismatch{{got:17}}, got {res:?}"
629        );
630    }
631
632    #[test]
633    fn dispatch_payload_validates_entr_length() {
634        // Unit-level: the Entr arm now validates length (parity with the Mnem arm
635        // and this fn's doc contract). Audit I9.
636        let mut bad = vec![RESERVED_PREFIX];
637        bad.extend(std::iter::repeat(0xCDu8).take(17));
638        assert!(
639            matches!(dispatch_payload(&bad), Err(Error::PayloadLengthMismatch { got: 17, .. })),
640            "non-standard Entr length must Err"
641        );
642        // Positive control: a standard length (16) still decodes Ok — no over-rejection.
643        let mut good = vec![RESERVED_PREFIX];
644        good.extend(std::iter::repeat(0xCDu8).take(16));
645        assert!(
646            matches!(dispatch_payload(&good), Ok(Payload::Entr(_))),
647            "standard Entr length must Ok"
648        );
649    }
650
651    // --- M6: cross-share polynomial-consistency check in combine_shares ---
652    //
653    // Beyond-BIP-93 defense-in-depth (BRAINSTORM §6.0): codex32 K-of-N has no
654    // digest share, so combining a same-id (same hrp/id/threshold/length) but
655    // DIFFERENT-polynomial share set silently returns a WRONG secret. The check
656    // truncates to the first k shares (which define the polynomial), recovers
657    // the secret from them, then verifies every EXTRA supplied share lies on
658    // that polynomial. Valid combines (exactly-k, or n>k all-consistent) MUST
659    // stay bit-identical.
660
661    /// Build a valid-checksum 2-of-`n` distributed share set carrying a STANDARD
662    /// 16-byte Entr secret, with a CALLER-FIXED `id` and a caller-chosen secret
663    /// entropy byte (→ a distinct Shamir polynomial). Two sets with the same
664    /// `id` but different `secret_byte` are same-id-but-inconsistent: their
665    /// shares pairwise lie on DIFFERENT polynomials. Mirrors `encode_shares`'
666    /// codex32 construction (deterministic filler, no CSPRNG → reproducible).
667    fn same_id_2_of_n(id: &str, secret_byte: u8, filler_byte: u8, n: usize) -> Vec<String> {
668        let k = 2usize;
669        // wire payload = [RESERVED_PREFIX] || 16-byte entropy (a STANDARD length,
670        // so a clean combine recovers a valid Entr payload).
671        let mut bytes = vec![RESERVED_PREFIX];
672        bytes.extend(std::iter::repeat(secret_byte).take(16));
673        let secret_s = Codex32String::from_seed(HRP, k, id, Fe::S, &bytes[..]).unwrap();
674        let pool = non_s_index_pool();
675        let mut defining = vec![secret_s];
676        for pidx in pool.iter().take(k - 1) {
677            let filler = vec![filler_byte; bytes.len()];
678            defining.push(Codex32String::from_seed(HRP, k, id, *pidx, &filler[..]).unwrap());
679        }
680        let mut out = Vec::new();
681        for s in defining.iter().skip(1) {
682            out.push(s.to_string());
683        }
684        for pidx in pool.iter().take(n).skip(k - 1) {
685            out.push(Codex32String::interpolate_at(&defining, *pidx).unwrap().to_string());
686        }
687        out
688    }
689
690    #[test]
691    fn combine_inconsistent_same_id_set_rejected() {
692        // Two DIFFERENT secrets A, B split 2-of-3 with the SAME id/threshold/
693        // length. Supply an over-threshold (n>k) same-id set [A1, A2, B3]:
694        // distinct indices, same header, but B3 is NOT on A's polynomial. RED
695        // today: combine interpolates over all three and returns a WRONG
696        // (garbage) secret with no error. Post-fix: the membership check derives
697        // A's value at B3's index from {A1,A2} and finds it ≠ B3 →
698        // Error::InconsistentShareSet. (BRAINSTORM §6.5 test #1, n>k extras form.)
699        //
700        // NOTE the spec's documented irreducible limit (§6.2 edge cases): an
701        // EXACTLY-k mixed pair [A1, B2] is NOT detectable — any k points define
702        // *a* polynomial, so there is no extra share to cross-check. M6 closes
703        // only the detectable case (any over-threshold set not all-on-one-curve).
704        let set_a = same_id_2_of_n("aaaa", 0x11, 0x22, 3);
705        let set_b = same_id_2_of_n("aaaa", 0x33, 0x44, 3);
706        // A's first two distributed shares (the consistent k-set) + B's third.
707        let mixed = vec![set_a[0].clone(), set_a[1].clone(), set_b[2].clone()];
708        let res = combine_shares(&mixed);
709        assert!(
710            matches!(res, Err(Error::InconsistentShareSet)),
711            "expected InconsistentShareSet for a same-id mixed-polynomial set, got {res:?}"
712        );
713    }
714
715    #[test]
716    fn combine_valid_exactly_k_unchanged() {
717        // Positive control (BRAINSTORM §6.0 hard invariant): a clean 2-of-3,
718        // supply exactly k=2 consistent shares → recovers the correct secret A,
719        // byte-identical to the current behavior. MUST stay GREEN.
720        let p = Payload::Entr(vec![0xCDu8; 16]);
721        let shares = encode_shares(Tag::ENTR, Threshold::new(2).unwrap(), 3, &p).unwrap();
722        let (tag, recovered) = combine_shares(&shares[..2]).unwrap();
723        assert_eq!(tag, Tag::ENTR);
724        assert_eq!(recovered, p, "exactly-k combine must recover the exact payload");
725    }
726
727    #[test]
728    fn combine_valid_n_gt_k_all_consistent() {
729        // Positive control: supply all 3 consistent shares of A (n > k) → the
730        // extra share passes the membership check → recovers A unchanged. MUST
731        // stay GREEN (no regression on the over-supplied legitimate case).
732        let p = Payload::Entr(vec![0xCDu8; 16]);
733        let shares = encode_shares(Tag::ENTR, Threshold::new(2).unwrap(), 3, &p).unwrap();
734        let (tag, recovered) = combine_shares(&shares).unwrap();
735        assert_eq!(tag, Tag::ENTR);
736        assert_eq!(recovered, p, "n>k all-consistent combine must recover the exact payload");
737    }
738
739    #[test]
740    fn combine_inconsistent_extra_share_rejected() {
741        // 2 consistent A-shares (the k-set) + a consistent A-extra + a same-id
742        // B-extra, with the INCONSISTENT extra in a NON-terminal position
743        // [A1, A2, B3, A4]: the first k recover A and the membership loop must
744        // catch the B-share even though it is not the last extra. RED today
745        // (combine interpolates over all 4 → garbage). Post-fix:
746        // Error::InconsistentShareSet.
747        // id chars must be in the codex32 (bech32) alphabet — 'b'/'i'/'o'/'1'
748        // are excluded, so use 'cqcq'.
749        let set_a = same_id_2_of_n("cqcq", 0x55, 0x66, 4);
750        let set_b = same_id_2_of_n("cqcq", 0x77, 0x88, 4);
751        // k-set [A1, A2] (pool indices 0,1) + B's index-2 share (inconsistent,
752        // a non-terminal extra) + A's index-3 share (consistent, terminal).
753        let mixed = vec![
754            set_a[0].clone(),
755            set_a[1].clone(),
756            set_b[2].clone(),
757            set_a[3].clone(),
758        ];
759        let res = combine_shares(&mixed);
760        assert!(
761            matches!(res, Err(Error::InconsistentShareSet)),
762            "expected InconsistentShareSet for a consistent-k + inconsistent-extra set, got {res:?}"
763        );
764    }
765}