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::codex32::{Codex32String, Fe};
14use crate::consts::{HRP, RESERVED_ID_BLOCKLIST, SHARE_INDEX_V01};
15use crate::envelope::{dispatch_payload, extract_wire_fields, payload_wire_bytes, wire_string};
16use crate::error::{Error, Result};
17use crate::payload::Payload;
18use crate::tag::Tag;
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) — RESOLVED in Cycle-B: codex32 is now VENDORED
130 // (crate::codex32, shape A) and `Codex32String` derives `ZeroizeOnDrop`, so
131 // the secret-bearing `Codex32String` bindings below (`secret_s`, `defining`)
132 // auto-scrub their inner String on drop — no `Zeroizing` wrapper, they own
133 // their scrub. The `Vec<u8>` CSPRNG `filler` below also stays `Zeroizing`.
134 // The IRREDUCIBLE residue is `distributed: Vec<String>` — it is the function
135 // RETURN value (the wire form is `String`), so it MUST outlive the function
136 // and cannot be wrapped without changing the public return type. Per the
137 // caller-wrap contract (same discipline as `Payload::Entr(Vec<u8>)`,
138 // documented in payload.rs + enforced by lint_zeroize_discipline), the
139 // CALLER owns the scrub of the returned share strings. The `Codex32String`
140 // SOURCE of each `.to_string()` copy IS drop-scrubbed; only the `String`
141 // copy handed out is the caller's responsibility. (Honest, not papered over.)
142 //
143 // 1. secret-at-S carries the real payload at index `s`, threshold `k`.
144 let secret_s = Codex32String::from_seed(HRP, k_usize, &id, Fe::S, &bytes[..])?;
145
146 // 2. k-1 random DEFINING shares at the first k-1 pool indices. Each gets a
147 // CSPRNG payload of the SAME byte length as the secret (Zeroizing scrub).
148 // The defining set [secret_s, def_1..def_{k-1}] is k points → fully
149 // determines the Shamir polynomial.
150 let mut defining: Vec<Codex32String> = Vec::with_capacity(k_usize);
151 defining.push(secret_s);
152 for pool_idx in pool.iter().take(k_usize - 1) {
153 let mut filler: Zeroizing<Vec<u8>> = Zeroizing::new(vec![0u8; bytes.len()]);
154 getrandom::fill(&mut filler[..]).expect("getrandom::fill must not fail");
155 let share = Codex32String::from_seed(HRP, k_usize, &id, *pool_idx, &filler[..])?;
156 defining.push(share);
157 }
158
159 // 3. The n DISTRIBUTED shares: the k-1 defining shares (indices 0..k-1) plus
160 // interpolation-derived shares at the remaining n-(k-1) pool indices.
161 // The secret-at-S (defining[0]) is NEVER distributed.
162 let mut distributed: Vec<String> = Vec::with_capacity(n);
163 for share in defining.iter().skip(1) {
164 distributed.push(share.to_string());
165 }
166 for pool_idx in pool.iter().take(n).skip(k_usize - 1) {
167 let derived = Codex32String::interpolate_at(&defining, *pool_idx)?;
168 distributed.push(derived.to_string());
169 }
170
171 debug_assert_eq!(distributed.len(), n);
172 Ok(distributed)
173}
174
175/// Recombine `k` (or more) distributed shares of a K-of-N share-set into the
176/// original secret `(Tag, Payload)`.
177///
178/// Pre-validation runs BEFORE `interpolate_at` because codex32's
179/// `interpolate_at` short-circuits when the target index (`s`) is among the
180/// inputs (`lib.rs:262`) — bypassing its own payload validation. Order:
181/// 1. parse each share (`Error::Codex32` on failure — preserves the
182/// within-one-string mixed-case `InvalidCase` rejection), then re-parse the
183/// lowercased copy into the CANONICAL vector (BIP-173 uppercase QR form
184/// folds to canonical lowercase; codex32's `interpolate_at` does raw
185/// case-sensitive cross-share hrp/id compares, so canonicalization here —
186/// not field extraction — is what makes an uppercase or mixed-case SET
187/// combine, and what lets the index-`s` guard below see `b's'`);
188/// 2. **reject any share at index `s`** → `SecretShareSuppliedToCombine` (C1 —
189/// the secret-at-S is the recovery target, never a combine input);
190/// 3. `shares.len() >= k` (the first share's threshold) else surface
191/// `ThresholdNotPassed`;
192/// 4. distinct share indices else `RepeatedIndex` (codex32's own check is lazy);
193/// 5. recover the secret-at-S from EXACTLY the first `k` shares (which define
194/// the polynomial) via `interpolate_at(&parsed[..k], Fe::S)` (surfaces
195/// `Mismatched{Hrp,Id,Threshold,Length}` on a header-inconsistent k-set),
196/// then verify every EXTRA supplied share lies on that same polynomial
197/// (`interpolate_at(k_set, idx)` re-derived value must equal the supplied
198/// share) → `InconsistentShareSet` on any mismatch. (M6 — codex32 K-of-N
199/// carries no digest share; a same-id but cross-polynomial set previously
200/// combined to a SILENT WRONG secret. A valid exactly-k or n>k all-consistent
201/// combine is bit-identical to the prior all-shares interpolation.)
202///
203/// Returns **`(Tag::ENTR, …)`** always: the recovered secret-at-S carries the
204/// share-set's RANDOM `id` (NOT a type tag); the payload KIND is the prefix byte
205/// (via `dispatch_payload`), so the random id is discarded. (We do NOT route
206/// through `discriminate` — it would rebuild a `Tag` from the random id.)
207pub fn combine_shares(shares: &[String]) -> Result<(Tag, Payload)> {
208 // 1. Parse each share (map codex32 parse/checksum failure via Error::Codex32).
209 let parsed: Vec<Codex32String> = shares
210 .iter()
211 .map(|s| Codex32String::from_string(s.clone()).map_err(Error::Codex32))
212 .collect::<Result<Vec<_>>>()?;
213
214 // 1b. Canonicalize: re-parse each share's lowercased wire copy (NEVER
215 // lowercase before the first parse above — that would launder the
216 // within-one-string mixed-case `InvalidCase` rejection). codex32's
217 // checksum engine case-folds, so this re-parse is infallible in
218 // practice (probe-proven byte-identical for lowercase input); still
219 // route the Result via `?`. The canonical vector feeds both the field
220 // extraction below AND `interpolate_at` (whose raw case-sensitive
221 // cross-share hrp/id compares are why extraction-side lowercasing
222 // alone cannot fix combine) — it also makes the recovered output
223 // lowercase.
224 let parsed: Vec<Codex32String> = parsed
225 .iter()
226 .map(|c| {
227 Codex32String::from_string(c.to_string().to_ascii_lowercase()).map_err(Error::Codex32)
228 })
229 .collect::<Result<Vec<_>>>()?;
230
231 if parsed.is_empty() {
232 // No shares → surface as below-threshold (k unknown; report 1/0).
233 return Err(Error::Codex32(crate::codex32::Error::ThresholdNotPassed {
234 threshold: 1,
235 n_shares: 0,
236 }));
237 }
238
239 // Re-parse wire fields for each → (threshold_byte, share_index_byte). Both
240 // are `u8` (Copy), so this owns nothing that borrows the per-share string.
241 // `wire_string` is subsumed by the canonical vector above (already
242 // lowercase) — kept as harmless defense-in-depth; the canonical vector is
243 // the load-bearing mechanism for combine.
244 let fields: Vec<(u8, u8)> = parsed
245 .iter()
246 .map(|c| {
247 let s = wire_string(c);
248 extract_wire_fields(&s).map(|f| (f.threshold_byte, f.share_index_byte))
249 })
250 .collect::<Result<Vec<_>>>()?;
251
252 // 2. C1: reject any input at index `s` BEFORE interpolate_at (the
253 // short-circuit at codex32 lib.rs:262 would otherwise bypass validation).
254 if fields.iter().any(|&(_, idx)| idx == SHARE_INDEX_V01) {
255 return Err(Error::SecretShareSuppliedToCombine);
256 }
257
258 // 3. count >= k (the first share's threshold char). codex32 thresholds are
259 // single ASCII digits ('2'..'9'); '0' (an unshared single) here means the
260 // caller passed a v0.1 single-string into combine — also below any share
261 // threshold, surfaced as ThresholdNotPassed.
262 let k = (fields[0].0 - b'0') as usize;
263 if parsed.len() < k {
264 return Err(Error::Codex32(crate::codex32::Error::ThresholdNotPassed {
265 threshold: k,
266 n_shares: parsed.len(),
267 }));
268 }
269
270 // 4. distinct share indices (codex32's RepeatedIndex check is lazy — only
271 // fires for the i==j Lagrange term — so pre-check exhaustively).
272 for i in 0..fields.len() {
273 for j in (i + 1)..fields.len() {
274 if fields[i].1 == fields[j].1 {
275 let idx = Fe::from_char(fields[i].1 as char).map_err(Error::Codex32)?;
276 return Err(Error::Codex32(crate::codex32::Error::RepeatedIndex(idx)));
277 }
278 }
279 }
280
281 // 5. Recover the secret-at-S from EXACTLY k shares, then verify every
282 // EXTRA supplied share lies on that same polynomial (M6 — beyond-BIP-93
283 // defense-in-depth: codex32 K-of-N carries no digest share, so a same-id
284 // [same hrp/id/threshold/length] but cross-polynomial set would otherwise
285 // interpolate to a SILENT WRONG secret). The first k shares define the
286 // polynomial; recovery surfaces Mismatched{Hrp,Id,Threshold,Length} via
287 // Error::Codex32 on a header-inconsistent k-set, exactly as before.
288 //
289 // Hard invariant (BRAINSTORM §6.0): a valid exactly-k combine is
290 // bit-identical to the prior `interpolate_at(&parsed, Fe::S)` (k == n →
291 // k_set == parsed, empty membership loop), and a valid n>k all-consistent
292 // combine recovers the same secret (every extra lies on the curve).
293 let k_set = &parsed[..k];
294 let secret = Codex32String::interpolate_at(k_set, Fe::S).map_err(Error::Codex32)?;
295
296 // For each EXTRA supplied share, re-derive the polynomial's value at that
297 // share's index from the k-set and require it to equal the supplied share
298 // (full canonical lowercased Codex32String compare — header fields are
299 // already cross-checked by interpolate_at; this adds the polynomial/data
300 // dimension). The share-index char comes from the already-extracted `fields`
301 // (codex32's `Parts::share_index` is private); reuse the same `Fe::from_char`
302 // conversion as the distinct-index check above. Any mismatch ⇒ the set is
303 // not all from one split.
304 for j in k..parsed.len() {
305 let idx = Fe::from_char(fields[j].1 as char).map_err(Error::Codex32)?;
306 let derived = Codex32String::interpolate_at(k_set, idx).map_err(Error::Codex32)?;
307 if derived != parsed[j] {
308 return Err(Error::InconsistentShareSet);
309 }
310 }
311
312 // Payload KIND is the recovered prefix byte; the id is random → discard it
313 // and always return Tag::ENTR (the kind lives in the Payload, NOT the tag).
314 //
315 // cycle-15 Lane M (slug #3) — RESOLVED in Cycle-B: `parsed`/`k_set` and the
316 // recovered `secret` are `Codex32String`, which now derives `ZeroizeOnDrop`
317 // (vendored codex32, shape A) — each scrubs its inner String when the `Vec`
318 // / binding drops at fn return. The recovered secret WIRE BYTES are also
319 // scrubbed below via the `Zeroizing<Vec<u8>>` wrap (belt-and-suspenders).
320 let data: Zeroizing<Vec<u8>> = Zeroizing::new(secret.parts().data());
321 let payload = dispatch_payload(&data)?;
322 Ok((Tag::ENTR, payload))
323}
324
325#[cfg(test)]
326mod tests {
327 use super::*;
328
329 #[test]
330 fn new_accepts_2_through_9() {
331 for k in 2u8..=9 {
332 let t =
333 Threshold::new(k).unwrap_or_else(|e| panic!("new({k}) should be Ok, got {e:?}"));
334 assert_eq!(t.get(), k);
335 }
336 }
337
338 #[test]
339 fn new_rejects_zero() {
340 assert!(matches!(Threshold::new(0), Err(Error::InvalidThreshold(0))));
341 }
342
343 #[test]
344 fn new_rejects_one() {
345 assert!(matches!(Threshold::new(1), Err(Error::InvalidThreshold(1))));
346 }
347
348 #[test]
349 fn new_rejects_ten() {
350 assert!(matches!(
351 Threshold::new(10),
352 Err(Error::InvalidThreshold(10))
353 ));
354 }
355
356 #[test]
357 fn zero_const_get_is_zero() {
358 assert_eq!(Threshold::ZERO.get(), 0);
359 }
360
361 #[test]
362 fn new_five_get_is_five() {
363 assert_eq!(Threshold::new(5).unwrap().get(), 5);
364 }
365
366 // --- encode_shares tests (Task 1.3) ---
367
368 use crate::codex32::{Codex32String, Fe};
369 use crate::consts::RESERVED_PREFIX;
370 use crate::encode::encode;
371 use crate::payload::Payload;
372 use crate::tag::Tag;
373
374 fn entr_p() -> Payload {
375 Payload::Entr(vec![0xCDu8; 16])
376 }
377 fn mnem_p() -> Payload {
378 Payload::Mnem {
379 language: 1,
380 entropy: vec![0xCDu8; 16],
381 }
382 }
383
384 /// Re-parse a share string and return (threshold_char, share_index_char, id).
385 fn share_header(s: &str) -> (char, char, String) {
386 let sep = s.rfind('1').unwrap();
387 let b = s.as_bytes();
388 let threshold = b[sep + 1] as char;
389 let id: String = s[sep + 2..sep + 6].to_string();
390 let index = b[sep + 6] as char;
391 (threshold, index, id)
392 }
393
394 #[test]
395 fn zero_share_is_byte_identical_to_encode_entr() {
396 let p = entr_p();
397 let shares = encode_shares(Tag::ENTR, Threshold::ZERO, 1, &p).unwrap();
398 assert_eq!(shares, vec![encode(Tag::ENTR, &p).unwrap()]);
399 }
400
401 #[test]
402 fn zero_share_is_byte_identical_to_encode_mnem() {
403 let p = mnem_p();
404 let shares = encode_shares(Tag::ENTR, Threshold::ZERO, 1, &p).unwrap();
405 assert_eq!(shares, vec![encode(Tag::ENTR, &p).unwrap()]);
406 }
407
408 #[test]
409 fn zero_share_requires_n_eq_1() {
410 let p = entr_p();
411 assert!(matches!(
412 encode_shares(Tag::ENTR, Threshold::ZERO, 2, &p),
413 Err(Error::InvalidShareCount { k: 0, n: 2 })
414 ));
415 }
416
417 #[test]
418 fn encode_shares_2_of_3_shape() {
419 let p = entr_p();
420 let shares = encode_shares(Tag::ENTR, Threshold::new(2).unwrap(), 3, &p).unwrap();
421 assert_eq!(shares.len(), 3);
422 // Each parses, threshold char '2', distinct non-`s` indices, same id.
423 let mut indices = Vec::new();
424 let mut ids = Vec::new();
425 for s in &shares {
426 Codex32String::from_string(s.clone()).expect("each share must parse");
427 let (thr, idx, id) = share_header(s);
428 assert_eq!(thr, '2', "threshold char");
429 assert_ne!(idx, 's', "distributed share must not be index s");
430 indices.push(idx);
431 ids.push(id);
432 }
433 // Distinct indices.
434 let mut sorted = indices.clone();
435 sorted.sort_unstable();
436 sorted.dedup();
437 assert_eq!(sorted.len(), indices.len(), "indices must be distinct");
438 // Same id across the set.
439 assert!(ids.windows(2).all(|w| w[0] == w[1]), "id must be shared");
440 }
441
442 #[test]
443 fn encode_shares_rejects_n_below_k() {
444 let p = entr_p();
445 assert!(matches!(
446 encode_shares(Tag::ENTR, Threshold::new(2).unwrap(), 1, &p),
447 Err(Error::InvalidShareCount { k: 2, n: 1 })
448 ));
449 }
450
451 #[test]
452 fn encode_shares_rejects_n_32() {
453 let p = entr_p();
454 assert!(matches!(
455 encode_shares(Tag::ENTR, Threshold::new(2).unwrap(), 32, &p),
456 Err(Error::InvalidShareCount { k: 2, n: 32 })
457 ));
458 }
459
460 #[test]
461 fn encode_shares_id_not_in_blocklist() {
462 // Statistical: across many splits, the random id never lands in the blocklist.
463 let p = entr_p();
464 for _ in 0..64 {
465 let shares = encode_shares(Tag::ENTR, Threshold::new(2).unwrap(), 2, &p).unwrap();
466 let (_, _, id) = share_header(&shares[0]);
467 let id_bytes: [u8; 4] = id.as_bytes().try_into().unwrap();
468 assert!(
469 !crate::consts::RESERVED_ID_BLOCKLIST.contains(&id_bytes),
470 "id {id:?} must not be in RESERVED_ID_BLOCKLIST"
471 );
472 }
473 }
474
475 // --- T1-a (#12, funds-critical): n=31 (max) K-of-N split-index secret-
476 // disclosure hardening. `non_s_index_pool` (:28) is the code under test;
477 // the oracle here is a DIRECT wire-position re-parse via
478 // `extract_wire_fields` (envelope.rs) — independent of the pool helper —
479 // so a regression in the pool's `!= 's'` filter cannot escape undetected
480 // via a same-stack tautology. See SPEC_test_hardening_T1_ms_funds_safety.md.
481
482 /// n=31 (max n): every emitted distributed-share index char must never be
483 /// the secret-at-S index `'s'`, all 31 indices must be distinct, and
484 /// several k-subsets must recover the exact payload — for entr and mnem,
485 /// across a spread of thresholds (k=2 min, k=5 mid, k=9 max).
486 #[test]
487 fn encode_shares_n31_max_no_secret_index_leak() {
488 for p in [entr_p(), mnem_p()] {
489 for k in [2u8, 5, 9] {
490 let n = 31usize;
491 let shares = encode_shares(Tag::ENTR, Threshold::new(k).unwrap(), n, &p).unwrap();
492 assert_eq!(shares.len(), n, "k={k} n={n} must emit exactly n shares");
493
494 // (i) every emitted share-index char != 's' — oracle is the
495 // direct wire-position parse, NOT non_s_index_pool.
496 let mut indices: Vec<u8> = Vec::with_capacity(n);
497 for s in &shares {
498 let fields = extract_wire_fields(s).unwrap();
499 assert_ne!(
500 fields.share_index_byte, b's',
501 "k={k} n={n}: a distributed share must never carry the \
502 secret-at-S index; share={s}"
503 );
504 indices.push(fields.share_index_byte);
505 }
506
507 // (ii) all 31 share-index chars distinct.
508 let mut sorted = indices.clone();
509 sorted.sort_unstable();
510 sorted.dedup();
511 assert_eq!(
512 sorted.len(),
513 n,
514 "k={k} n={n}: all {n} share indices must be distinct, got {indices:?}"
515 );
516
517 // (iii) several k-subsets recover the exact payload.
518 let k_usize = k as usize;
519 let mid = (n - k_usize) / 2;
520 for subset in [
521 &shares[..k_usize],
522 &shares[n - k_usize..],
523 &shares[mid..mid + k_usize],
524 ] {
525 let (tag, recovered) = combine_shares(subset).unwrap();
526 assert_eq!(tag, Tag::ENTR, "combine always returns Tag::ENTR");
527 assert_eq!(
528 recovered, p,
529 "k={k} n={n}: k-subset must recover the exact payload"
530 );
531 }
532 }
533 }
534 }
535
536 /// Boundary n=16: the LAST n whose distributed-share loop never reaches
537 /// codex32-alphabet pool position 16 (the position `'s'` occupies before
538 /// `non_s_index_pool`'s filter removes it) — safe even under the RED-proof
539 /// mutation below. Pins the exact threshold alongside the n=17 test.
540 #[test]
541 fn encode_shares_n16_boundary_below_s_pool_position() {
542 let p = entr_p();
543 let n = 16usize;
544 let shares = encode_shares(Tag::ENTR, Threshold::new(2).unwrap(), n, &p).unwrap();
545 assert_eq!(shares.len(), n);
546 for s in &shares {
547 let fields = extract_wire_fields(s).unwrap();
548 assert_ne!(fields.share_index_byte, b's', "n=16 boundary: share={s}");
549 }
550 }
551
552 /// Boundary n=17: the FIRST n whose distributed-share loop touches
553 /// codex32-alphabet pool position 16. Under unmutated code this still
554 /// passes (the filter already excludes `'s'` from the pool); this test
555 /// exists to pin the exact threshold and is the RED-proof boundary case
556 /// (see the mutation note on `encode_shares_n31_max_no_secret_index_leak`
557 /// and the SPEC): dropping `non_s_index_pool`'s `!= 's'` filter makes
558 /// assertion (i) fail FIRST at this n.
559 #[test]
560 fn encode_shares_n17_boundary_touches_s_pool_position() {
561 let p = entr_p();
562 let n = 17usize;
563 let shares = encode_shares(Tag::ENTR, Threshold::new(2).unwrap(), n, &p).unwrap();
564 assert_eq!(shares.len(), n);
565 for s in &shares {
566 let fields = extract_wire_fields(s).unwrap();
567 assert_ne!(fields.share_index_byte, b's', "n=17 boundary: share={s}");
568 }
569 }
570
571 /// Inline round-trip (combine_shares lands in Task 1.4): any k of the n
572 /// distributed shares, interpolated at S, recover the secret wire bytes.
573 #[test]
574 fn encode_shares_round_trip_via_interpolate_entr_and_mnem() {
575 for p in [entr_p(), mnem_p()] {
576 let secret_wire = crate::envelope::payload_wire_bytes(&p);
577 for k in 2u8..=9 {
578 let n = (k as usize) + 2; // exercise interpolation-derived shares
579 let shares = encode_shares(Tag::ENTR, Threshold::new(k).unwrap(), n, &p).unwrap();
580 assert_eq!(shares.len(), n);
581 let parsed: Vec<Codex32String> = shares
582 .iter()
583 .map(|s| Codex32String::from_string(s.clone()).unwrap())
584 .collect();
585 // First k and last k subsets both recover the secret.
586 for subset in [&parsed[..k as usize], &parsed[n - k as usize..]] {
587 let recovered = Codex32String::interpolate_at(subset, Fe::S).unwrap();
588 assert_eq!(
589 recovered.parts().data(),
590 secret_wire[..],
591 "k={k} n={n} kind={:?} must recover secret wire bytes",
592 p.kind()
593 );
594 }
595 }
596 }
597 }
598
599 // --- combine_shares tests (Task 1.4) ---
600
601 #[test]
602 fn combine_round_trip_entr_and_mnem_all_lengths() {
603 for ent_len in [16usize, 20, 24, 28, 32] {
604 let entr = Payload::Entr(vec![0x37u8; ent_len]);
605 let mnem = Payload::Mnem {
606 language: 7,
607 entropy: vec![0x91u8; ent_len],
608 };
609 for p in [entr, mnem] {
610 for k in 2u8..=9 {
611 let n = (k as usize) + 1;
612 let shares =
613 encode_shares(Tag::ENTR, Threshold::new(k).unwrap(), n, &p).unwrap();
614 // First k and last k subsets both combine back to the secret.
615 for subset in [&shares[..k as usize], &shares[n - k as usize..]] {
616 let (tag, recovered) = combine_shares(subset).unwrap();
617 assert_eq!(tag, Tag::ENTR, "combine always returns Tag::ENTR");
618 assert_eq!(
619 recovered, p,
620 "k={k} n={n} ent_len={ent_len} must recover the exact payload"
621 );
622 }
623 }
624 }
625 }
626 }
627
628 #[test]
629 fn combine_rejects_below_threshold() {
630 let p = entr_p();
631 let shares = encode_shares(Tag::ENTR, Threshold::new(3).unwrap(), 4, &p).unwrap();
632 // Only 2 of a 3-of-4 set.
633 let err = combine_shares(&shares[..2]).unwrap_err();
634 assert!(
635 matches!(
636 err,
637 Error::Codex32(crate::codex32::Error::ThresholdNotPassed { .. })
638 ),
639 "expected ThresholdNotPassed, got {err:?}"
640 );
641 }
642
643 #[test]
644 fn combine_rejects_duplicate_index() {
645 let p = entr_p();
646 let shares = encode_shares(Tag::ENTR, Threshold::new(2).unwrap(), 3, &p).unwrap();
647 // Same share twice → duplicate index.
648 let dup = vec![shares[0].clone(), shares[0].clone()];
649 assert!(matches!(
650 combine_shares(&dup),
651 Err(Error::Codex32(crate::codex32::Error::RepeatedIndex(_)))
652 ));
653 }
654
655 #[test]
656 fn combine_rejects_secret_share_index_s() {
657 // Hand-build the secret-at-S directly (index `s`, threshold 2). It must
658 // be rejected BEFORE interpolate_at (C1 — the short-circuit would
659 // otherwise bypass payload validation).
660 let bytes = crate::envelope::payload_wire_bytes(&entr_p());
661 let secret_s = Codex32String::from_seed(HRP, 2, "tst7", Fe::S, &bytes[..])
662 .unwrap()
663 .to_string();
664 // Need >= k shares to get past the count check and reach the index check;
665 // but the index-s check runs first regardless, so a single secret-s input
666 // is rejected on the index axis.
667 let p = entr_p();
668 let shares = encode_shares(Tag::ENTR, Threshold::new(2).unwrap(), 2, &p).unwrap();
669 let with_secret = vec![secret_s, shares[0].clone()];
670 assert!(matches!(
671 combine_shares(&with_secret),
672 Err(Error::SecretShareSuppliedToCombine)
673 ));
674 }
675
676 #[test]
677 fn combine_rejects_mismatched_threshold() {
678 // Two shares from different-threshold sets, at DISTINCT indices (so the
679 // distinct-index pre-check passes and interpolate_at's eager
680 // MismatchedThreshold check fires). set2[0]=index q; set3[1]=index p.
681 let p = entr_p();
682 let set2 = encode_shares(Tag::ENTR, Threshold::new(2).unwrap(), 2, &p).unwrap();
683 let set3 = encode_shares(Tag::ENTR, Threshold::new(3).unwrap(), 3, &p).unwrap();
684 let mixed = vec![set2[0].clone(), set3[1].clone()];
685 let err = combine_shares(&mixed).unwrap_err();
686 assert!(
687 matches!(
688 err,
689 Error::Codex32(crate::codex32::Error::MismatchedThreshold(..))
690 ),
691 "expected MismatchedThreshold, got {err:?}"
692 );
693 }
694
695 #[test]
696 fn combine_rejects_unparseable() {
697 let bad = vec!["not-an-ms1-string".to_string(), "also-bad".to_string()];
698 assert!(matches!(combine_shares(&bad), Err(Error::Codex32(_))));
699 }
700
701 // --- audit I9: combine must REJECT (not panic on) a non-standard-length
702 // Entr share set. The encode path validates length up front, but codex32
703 // share strings are an open format — an externally-constructed valid-checksum
704 // set with a non-standard payload length must surface a clean error, not abort.
705
706 /// Build a valid-checksum K-of-N Entr share set whose recovered payload has a
707 /// NON-STANDARD entropy length, bypassing `encode_shares`' `secret.validate()`
708 /// guard (which would reject it). Mirrors `encode_shares`' codex32
709 /// construction with a fixed id for determinism.
710 fn nonstandard_entr_distributed(k: usize, n: usize, entropy_len: usize) -> Vec<String> {
711 // wire payload = [RESERVED_PREFIX] || entropy
712 let mut bytes = vec![RESERVED_PREFIX];
713 bytes.extend(std::iter::repeat(0xCDu8).take(entropy_len));
714 let id = "tst7";
715 let secret_s = Codex32String::from_seed(HRP, k, id, Fe::S, &bytes[..]).unwrap();
716 let pool = non_s_index_pool();
717 let mut defining = vec![secret_s];
718 for pidx in pool.iter().take(k - 1) {
719 let filler = vec![0u8; bytes.len()];
720 defining.push(Codex32String::from_seed(HRP, k, id, *pidx, &filler[..]).unwrap());
721 }
722 let mut out = Vec::new();
723 for s in defining.iter().skip(1) {
724 out.push(s.to_string());
725 }
726 for pidx in pool.iter().take(n).skip(k - 1) {
727 out.push(
728 Codex32String::interpolate_at(&defining, *pidx)
729 .unwrap()
730 .to_string(),
731 );
732 }
733 out
734 }
735
736 #[test]
737 fn combine_rejects_nonstandard_entr_length_not_panics() {
738 // 17-byte entropy ∉ VALID_ENTR_LENGTHS. Pre-fix `combine_shares` returned
739 // Ok(unvalidated Entr) and `ms combine`'s from_entropy_in panicked
740 // (exit 101). Post-fix: a clean PayloadLengthMismatch, no panic.
741 let shares = nonstandard_entr_distributed(2, 2, 17);
742 let res = combine_shares(&shares);
743 assert!(
744 matches!(res, Err(Error::PayloadLengthMismatch { got: 17, .. })),
745 "expected PayloadLengthMismatch{{got:17}}, got {res:?}"
746 );
747 }
748
749 #[test]
750 fn dispatch_payload_validates_entr_length() {
751 // Unit-level: the Entr arm now validates length (parity with the Mnem arm
752 // and this fn's doc contract). Audit I9.
753 let mut bad = vec![RESERVED_PREFIX];
754 bad.extend(std::iter::repeat(0xCDu8).take(17));
755 assert!(
756 matches!(
757 dispatch_payload(&bad),
758 Err(Error::PayloadLengthMismatch { got: 17, .. })
759 ),
760 "non-standard Entr length must Err"
761 );
762 // Positive control: a standard length (16) still decodes Ok — no over-rejection.
763 let mut good = vec![RESERVED_PREFIX];
764 good.extend(std::iter::repeat(0xCDu8).take(16));
765 assert!(
766 matches!(dispatch_payload(&good), Ok(Payload::Entr(_))),
767 "standard Entr length must Ok"
768 );
769 }
770
771 // --- M6: cross-share polynomial-consistency check in combine_shares ---
772 //
773 // Beyond-BIP-93 defense-in-depth (BRAINSTORM §6.0): codex32 K-of-N has no
774 // digest share, so combining a same-id (same hrp/id/threshold/length) but
775 // DIFFERENT-polynomial share set silently returns a WRONG secret. The check
776 // truncates to the first k shares (which define the polynomial), recovers
777 // the secret from them, then verifies every EXTRA supplied share lies on
778 // that polynomial. Valid combines (exactly-k, or n>k all-consistent) MUST
779 // stay bit-identical.
780
781 /// Build a valid-checksum 2-of-`n` distributed share set carrying a STANDARD
782 /// 16-byte Entr secret, with a CALLER-FIXED `id` and a caller-chosen secret
783 /// entropy byte (→ a distinct Shamir polynomial). Two sets with the same
784 /// `id` but different `secret_byte` are same-id-but-inconsistent: their
785 /// shares pairwise lie on DIFFERENT polynomials. Mirrors `encode_shares`'
786 /// codex32 construction (deterministic filler, no CSPRNG → reproducible).
787 fn same_id_2_of_n(id: &str, secret_byte: u8, filler_byte: u8, n: usize) -> Vec<String> {
788 let k = 2usize;
789 // wire payload = [RESERVED_PREFIX] || 16-byte entropy (a STANDARD length,
790 // so a clean combine recovers a valid Entr payload).
791 let mut bytes = vec![RESERVED_PREFIX];
792 bytes.extend(std::iter::repeat(secret_byte).take(16));
793 let secret_s = Codex32String::from_seed(HRP, k, id, Fe::S, &bytes[..]).unwrap();
794 let pool = non_s_index_pool();
795 let mut defining = vec![secret_s];
796 for pidx in pool.iter().take(k - 1) {
797 let filler = vec![filler_byte; bytes.len()];
798 defining.push(Codex32String::from_seed(HRP, k, id, *pidx, &filler[..]).unwrap());
799 }
800 let mut out = Vec::new();
801 for s in defining.iter().skip(1) {
802 out.push(s.to_string());
803 }
804 for pidx in pool.iter().take(n).skip(k - 1) {
805 out.push(
806 Codex32String::interpolate_at(&defining, *pidx)
807 .unwrap()
808 .to_string(),
809 );
810 }
811 out
812 }
813
814 #[test]
815 fn combine_inconsistent_same_id_set_rejected() {
816 // Two DIFFERENT secrets A, B split 2-of-3 with the SAME id/threshold/
817 // length. Supply an over-threshold (n>k) same-id set [A1, A2, B3]:
818 // distinct indices, same header, but B3 is NOT on A's polynomial. RED
819 // today: combine interpolates over all three and returns a WRONG
820 // (garbage) secret with no error. Post-fix: the membership check derives
821 // A's value at B3's index from {A1,A2} and finds it ≠ B3 →
822 // Error::InconsistentShareSet. (BRAINSTORM §6.5 test #1, n>k extras form.)
823 //
824 // NOTE the spec's documented irreducible limit (§6.2 edge cases): an
825 // EXACTLY-k mixed pair [A1, B2] is NOT detectable — any k points define
826 // *a* polynomial, so there is no extra share to cross-check. M6 closes
827 // only the detectable case (any over-threshold set not all-on-one-curve).
828 let set_a = same_id_2_of_n("aaaa", 0x11, 0x22, 3);
829 let set_b = same_id_2_of_n("aaaa", 0x33, 0x44, 3);
830 // A's first two distributed shares (the consistent k-set) + B's third.
831 let mixed = vec![set_a[0].clone(), set_a[1].clone(), set_b[2].clone()];
832 let res = combine_shares(&mixed);
833 assert!(
834 matches!(res, Err(Error::InconsistentShareSet)),
835 "expected InconsistentShareSet for a same-id mixed-polynomial set, got {res:?}"
836 );
837 }
838
839 #[test]
840 fn combine_valid_exactly_k_unchanged() {
841 // Positive control (BRAINSTORM §6.0 hard invariant): a clean 2-of-3,
842 // supply exactly k=2 consistent shares → recovers the correct secret A,
843 // byte-identical to the current behavior. MUST stay GREEN.
844 let p = Payload::Entr(vec![0xCDu8; 16]);
845 let shares = encode_shares(Tag::ENTR, Threshold::new(2).unwrap(), 3, &p).unwrap();
846 let (tag, recovered) = combine_shares(&shares[..2]).unwrap();
847 assert_eq!(tag, Tag::ENTR);
848 assert_eq!(
849 recovered, p,
850 "exactly-k combine must recover the exact payload"
851 );
852 }
853
854 #[test]
855 fn combine_valid_n_gt_k_all_consistent() {
856 // Positive control: supply all 3 consistent shares of A (n > k) → the
857 // extra share passes the membership check → recovers A unchanged. MUST
858 // stay GREEN (no regression on the over-supplied legitimate case).
859 let p = Payload::Entr(vec![0xCDu8; 16]);
860 let shares = encode_shares(Tag::ENTR, Threshold::new(2).unwrap(), 3, &p).unwrap();
861 let (tag, recovered) = combine_shares(&shares).unwrap();
862 assert_eq!(tag, Tag::ENTR);
863 assert_eq!(
864 recovered, p,
865 "n>k all-consistent combine must recover the exact payload"
866 );
867 }
868
869 #[test]
870 fn combine_inconsistent_extra_share_rejected() {
871 // 2 consistent A-shares (the k-set) + a consistent A-extra + a same-id
872 // B-extra, with the INCONSISTENT extra in a NON-terminal position
873 // [A1, A2, B3, A4]: the first k recover A and the membership loop must
874 // catch the B-share even though it is not the last extra. RED today
875 // (combine interpolates over all 4 → garbage). Post-fix:
876 // Error::InconsistentShareSet.
877 // id chars must be in the codex32 (bech32) alphabet — 'b'/'i'/'o'/'1'
878 // are excluded, so use 'cqcq'.
879 let set_a = same_id_2_of_n("cqcq", 0x55, 0x66, 4);
880 let set_b = same_id_2_of_n("cqcq", 0x77, 0x88, 4);
881 // k-set [A1, A2] (pool indices 0,1) + B's index-2 share (inconsistent,
882 // a non-terminal extra) + A's index-3 share (consistent, terminal).
883 let mixed = vec![
884 set_a[0].clone(),
885 set_a[1].clone(),
886 set_b[2].clone(),
887 set_a[3].clone(),
888 ];
889 let res = combine_shares(&mixed);
890 assert!(
891 matches!(res, Err(Error::InconsistentShareSet)),
892 "expected InconsistentShareSet for a consistent-k + inconsistent-extra set, got {res:?}"
893 );
894 }
895}