1use 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
21const CODEX32_ALPHABET: &[u8; 32] = b"qpzry9x8gf2tvdw0s3jn54khce6mua7l";
24
25fn 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
36fn 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 return String::from_utf8(id.to_vec()).expect("codex32 alphabet is ASCII");
53 }
54 }
55}
56
57#[derive(Debug, Clone, Copy, PartialEq, Eq)]
63pub struct Threshold(u8);
64
65impl Threshold {
66 pub const ZERO: Threshold = Threshold(0);
69
70 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 pub fn get(self) -> u8 {
82 self.0
83 }
84}
85
86pub 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 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 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 let secret_s = Codex32String::from_seed(HRP, k_usize, &id, Fe::S, &bytes[..])?;
131
132 let mut defining: Vec<Codex32String> = Vec::with_capacity(k_usize);
137 defining.push(secret_s);
138 for pool_idx in pool.iter().take(k_usize - 1) {
139 let mut filler: Zeroizing<Vec<u8>> = Zeroizing::new(vec![0u8; bytes.len()]);
140 getrandom::fill(&mut filler[..]).expect("getrandom::fill must not fail");
141 let share = Codex32String::from_seed(HRP, k_usize, &id, *pool_idx, &filler[..])?;
142 defining.push(share);
143 }
144
145 let mut distributed: Vec<String> = Vec::with_capacity(n);
149 for share in defining.iter().skip(1) {
150 distributed.push(share.to_string());
151 }
152 for pool_idx in pool.iter().take(n).skip(k_usize - 1) {
153 let derived = Codex32String::interpolate_at(&defining, *pool_idx)?;
154 distributed.push(derived.to_string());
155 }
156
157 debug_assert_eq!(distributed.len(), n);
158 Ok(distributed)
159}
160
161pub fn combine_shares(shares: &[String]) -> Result<(Tag, Payload)> {
187 let parsed: Vec<Codex32String> = shares
189 .iter()
190 .map(|s| Codex32String::from_string(s.clone()).map_err(Error::Codex32))
191 .collect::<Result<Vec<_>>>()?;
192
193 let parsed: Vec<Codex32String> = parsed
204 .iter()
205 .map(|c| {
206 Codex32String::from_string(c.to_string().to_ascii_lowercase())
207 .map_err(Error::Codex32)
208 })
209 .collect::<Result<Vec<_>>>()?;
210
211 if parsed.is_empty() {
212 return Err(Error::Codex32(codex32::Error::ThresholdNotPassed {
214 threshold: 1,
215 n_shares: 0,
216 }));
217 }
218
219 let fields: Vec<(u8, u8)> = parsed
225 .iter()
226 .map(|c| {
227 let s = wire_string(c);
228 extract_wire_fields(&s).map(|f| (f.threshold_byte, f.share_index_byte))
229 })
230 .collect::<Result<Vec<_>>>()?;
231
232 if fields.iter().any(|&(_, idx)| idx == SHARE_INDEX_V01) {
235 return Err(Error::SecretShareSuppliedToCombine);
236 }
237
238 let k = (fields[0].0 - b'0') as usize;
243 if parsed.len() < k {
244 return Err(Error::Codex32(codex32::Error::ThresholdNotPassed {
245 threshold: k,
246 n_shares: parsed.len(),
247 }));
248 }
249
250 for i in 0..fields.len() {
253 for j in (i + 1)..fields.len() {
254 if fields[i].1 == fields[j].1 {
255 let idx = Fe::from_char(fields[i].1 as char).map_err(Error::Codex32)?;
256 return Err(Error::Codex32(codex32::Error::RepeatedIndex(idx)));
257 }
258 }
259 }
260
261 let secret = Codex32String::interpolate_at(&parsed, Fe::S).map_err(Error::Codex32)?;
264
265 let data: Zeroizing<Vec<u8>> = Zeroizing::new(secret.parts().data());
268 let payload = dispatch_payload(&data)?;
269 Ok((Tag::ENTR, payload))
270}
271
272#[cfg(test)]
273mod tests {
274 use super::*;
275
276 #[test]
277 fn new_accepts_2_through_9() {
278 for k in 2u8..=9 {
279 let t = Threshold::new(k).unwrap_or_else(|e| panic!("new({k}) should be Ok, got {e:?}"));
280 assert_eq!(t.get(), k);
281 }
282 }
283
284 #[test]
285 fn new_rejects_zero() {
286 assert!(matches!(Threshold::new(0), Err(Error::InvalidThreshold(0))));
287 }
288
289 #[test]
290 fn new_rejects_one() {
291 assert!(matches!(Threshold::new(1), Err(Error::InvalidThreshold(1))));
292 }
293
294 #[test]
295 fn new_rejects_ten() {
296 assert!(matches!(Threshold::new(10), Err(Error::InvalidThreshold(10))));
297 }
298
299 #[test]
300 fn zero_const_get_is_zero() {
301 assert_eq!(Threshold::ZERO.get(), 0);
302 }
303
304 #[test]
305 fn new_five_get_is_five() {
306 assert_eq!(Threshold::new(5).unwrap().get(), 5);
307 }
308
309 use crate::consts::RESERVED_PREFIX;
312 use crate::encode::encode;
313 use crate::payload::Payload;
314 use crate::tag::Tag;
315 use codex32::{Codex32String, Fe};
316
317 fn entr_p() -> Payload {
318 Payload::Entr(vec![0xCDu8; 16])
319 }
320 fn mnem_p() -> Payload {
321 Payload::Mnem { language: 1, entropy: vec![0xCDu8; 16] }
322 }
323
324 fn share_header(s: &str) -> (char, char, String) {
326 let sep = s.rfind('1').unwrap();
327 let b = s.as_bytes();
328 let threshold = b[sep + 1] as char;
329 let id: String = s[sep + 2..sep + 6].to_string();
330 let index = b[sep + 6] as char;
331 (threshold, index, id)
332 }
333
334 #[test]
335 fn zero_share_is_byte_identical_to_encode_entr() {
336 let p = entr_p();
337 let shares = encode_shares(Tag::ENTR, Threshold::ZERO, 1, &p).unwrap();
338 assert_eq!(shares, vec![encode(Tag::ENTR, &p).unwrap()]);
339 }
340
341 #[test]
342 fn zero_share_is_byte_identical_to_encode_mnem() {
343 let p = mnem_p();
344 let shares = encode_shares(Tag::ENTR, Threshold::ZERO, 1, &p).unwrap();
345 assert_eq!(shares, vec![encode(Tag::ENTR, &p).unwrap()]);
346 }
347
348 #[test]
349 fn zero_share_requires_n_eq_1() {
350 let p = entr_p();
351 assert!(matches!(
352 encode_shares(Tag::ENTR, Threshold::ZERO, 2, &p),
353 Err(Error::InvalidShareCount { k: 0, n: 2 })
354 ));
355 }
356
357 #[test]
358 fn encode_shares_2_of_3_shape() {
359 let p = entr_p();
360 let shares = encode_shares(Tag::ENTR, Threshold::new(2).unwrap(), 3, &p).unwrap();
361 assert_eq!(shares.len(), 3);
362 let mut indices = Vec::new();
364 let mut ids = Vec::new();
365 for s in &shares {
366 Codex32String::from_string(s.clone()).expect("each share must parse");
367 let (thr, idx, id) = share_header(s);
368 assert_eq!(thr, '2', "threshold char");
369 assert_ne!(idx, 's', "distributed share must not be index s");
370 indices.push(idx);
371 ids.push(id);
372 }
373 let mut sorted = indices.clone();
375 sorted.sort_unstable();
376 sorted.dedup();
377 assert_eq!(sorted.len(), indices.len(), "indices must be distinct");
378 assert!(ids.windows(2).all(|w| w[0] == w[1]), "id must be shared");
380 }
381
382 #[test]
383 fn encode_shares_rejects_n_below_k() {
384 let p = entr_p();
385 assert!(matches!(
386 encode_shares(Tag::ENTR, Threshold::new(2).unwrap(), 1, &p),
387 Err(Error::InvalidShareCount { k: 2, n: 1 })
388 ));
389 }
390
391 #[test]
392 fn encode_shares_rejects_n_32() {
393 let p = entr_p();
394 assert!(matches!(
395 encode_shares(Tag::ENTR, Threshold::new(2).unwrap(), 32, &p),
396 Err(Error::InvalidShareCount { k: 2, n: 32 })
397 ));
398 }
399
400 #[test]
401 fn encode_shares_id_not_in_blocklist() {
402 let p = entr_p();
404 for _ in 0..64 {
405 let shares = encode_shares(Tag::ENTR, Threshold::new(2).unwrap(), 2, &p).unwrap();
406 let (_, _, id) = share_header(&shares[0]);
407 let id_bytes: [u8; 4] = id.as_bytes().try_into().unwrap();
408 assert!(
409 !crate::consts::RESERVED_ID_BLOCKLIST.contains(&id_bytes),
410 "id {id:?} must not be in RESERVED_ID_BLOCKLIST"
411 );
412 }
413 }
414
415 #[test]
418 fn encode_shares_round_trip_via_interpolate_entr_and_mnem() {
419 for p in [entr_p(), mnem_p()] {
420 let secret_wire = crate::envelope::payload_wire_bytes(&p);
421 for k in 2u8..=9 {
422 let n = (k as usize) + 2; let shares = encode_shares(Tag::ENTR, Threshold::new(k).unwrap(), n, &p).unwrap();
424 assert_eq!(shares.len(), n);
425 let parsed: Vec<Codex32String> = shares
426 .iter()
427 .map(|s| Codex32String::from_string(s.clone()).unwrap())
428 .collect();
429 for subset in [&parsed[..k as usize], &parsed[n - k as usize..]] {
431 let recovered = Codex32String::interpolate_at(subset, Fe::S).unwrap();
432 assert_eq!(
433 recovered.parts().data(),
434 secret_wire[..],
435 "k={k} n={n} kind={:?} must recover secret wire bytes",
436 p.kind()
437 );
438 }
439 }
440 }
441 }
442
443 #[test]
446 fn combine_round_trip_entr_and_mnem_all_lengths() {
447 for ent_len in [16usize, 20, 24, 28, 32] {
448 let entr = Payload::Entr(vec![0x37u8; ent_len]);
449 let mnem = Payload::Mnem { language: 7, entropy: vec![0x91u8; ent_len] };
450 for p in [entr, mnem] {
451 for k in 2u8..=9 {
452 let n = (k as usize) + 1;
453 let shares =
454 encode_shares(Tag::ENTR, Threshold::new(k).unwrap(), n, &p).unwrap();
455 for subset in [&shares[..k as usize], &shares[n - k as usize..]] {
457 let (tag, recovered) = combine_shares(subset).unwrap();
458 assert_eq!(tag, Tag::ENTR, "combine always returns Tag::ENTR");
459 assert_eq!(
460 recovered,
461 p,
462 "k={k} n={n} ent_len={ent_len} must recover the exact payload"
463 );
464 }
465 }
466 }
467 }
468 }
469
470 #[test]
471 fn combine_rejects_below_threshold() {
472 let p = entr_p();
473 let shares = encode_shares(Tag::ENTR, Threshold::new(3).unwrap(), 4, &p).unwrap();
474 let err = combine_shares(&shares[..2]).unwrap_err();
476 assert!(
477 matches!(err, Error::Codex32(codex32::Error::ThresholdNotPassed { .. })),
478 "expected ThresholdNotPassed, got {err:?}"
479 );
480 }
481
482 #[test]
483 fn combine_rejects_duplicate_index() {
484 let p = entr_p();
485 let shares = encode_shares(Tag::ENTR, Threshold::new(2).unwrap(), 3, &p).unwrap();
486 let dup = vec![shares[0].clone(), shares[0].clone()];
488 assert!(matches!(
489 combine_shares(&dup),
490 Err(Error::Codex32(codex32::Error::RepeatedIndex(_)))
491 ));
492 }
493
494 #[test]
495 fn combine_rejects_secret_share_index_s() {
496 let bytes = crate::envelope::payload_wire_bytes(&entr_p());
500 let secret_s = Codex32String::from_seed(HRP, 2, "tst7", Fe::S, &bytes[..])
501 .unwrap()
502 .to_string();
503 let p = entr_p();
507 let shares = encode_shares(Tag::ENTR, Threshold::new(2).unwrap(), 2, &p).unwrap();
508 let with_secret = vec![secret_s, shares[0].clone()];
509 assert!(matches!(
510 combine_shares(&with_secret),
511 Err(Error::SecretShareSuppliedToCombine)
512 ));
513 }
514
515 #[test]
516 fn combine_rejects_mismatched_threshold() {
517 let p = entr_p();
521 let set2 = encode_shares(Tag::ENTR, Threshold::new(2).unwrap(), 2, &p).unwrap();
522 let set3 = encode_shares(Tag::ENTR, Threshold::new(3).unwrap(), 3, &p).unwrap();
523 let mixed = vec![set2[0].clone(), set3[1].clone()];
524 let err = combine_shares(&mixed).unwrap_err();
525 assert!(
526 matches!(err, Error::Codex32(codex32::Error::MismatchedThreshold(..))),
527 "expected MismatchedThreshold, got {err:?}"
528 );
529 }
530
531 #[test]
532 fn combine_rejects_unparseable() {
533 let bad = vec!["not-an-ms1-string".to_string(), "also-bad".to_string()];
534 assert!(matches!(combine_shares(&bad), Err(Error::Codex32(_))));
535 }
536
537 fn nonstandard_entr_distributed(k: usize, n: usize, entropy_len: usize) -> Vec<String> {
547 let mut bytes = vec![RESERVED_PREFIX];
549 bytes.extend(std::iter::repeat(0xCDu8).take(entropy_len));
550 let id = "tst7";
551 let secret_s = Codex32String::from_seed(HRP, k, id, Fe::S, &bytes[..]).unwrap();
552 let pool = non_s_index_pool();
553 let mut defining = vec![secret_s];
554 for pidx in pool.iter().take(k - 1) {
555 let filler = vec![0u8; bytes.len()];
556 defining.push(Codex32String::from_seed(HRP, k, id, *pidx, &filler[..]).unwrap());
557 }
558 let mut out = Vec::new();
559 for s in defining.iter().skip(1) {
560 out.push(s.to_string());
561 }
562 for pidx in pool.iter().take(n).skip(k - 1) {
563 out.push(Codex32String::interpolate_at(&defining, *pidx).unwrap().to_string());
564 }
565 out
566 }
567
568 #[test]
569 fn combine_rejects_nonstandard_entr_length_not_panics() {
570 let shares = nonstandard_entr_distributed(2, 2, 17);
574 let res = combine_shares(&shares);
575 assert!(
576 matches!(res, Err(Error::PayloadLengthMismatch { got: 17, .. })),
577 "expected PayloadLengthMismatch{{got:17}}, got {res:?}"
578 );
579 }
580
581 #[test]
582 fn dispatch_payload_validates_entr_length() {
583 let mut bad = vec![RESERVED_PREFIX];
586 bad.extend(std::iter::repeat(0xCDu8).take(17));
587 assert!(
588 matches!(dispatch_payload(&bad), Err(Error::PayloadLengthMismatch { got: 17, .. })),
589 "non-standard Entr length must Err"
590 );
591 let mut good = vec![RESERVED_PREFIX];
593 good.extend(std::iter::repeat(0xCDu8).take(16));
594 assert!(
595 matches!(dispatch_payload(&good), Ok(Payload::Entr(_))),
596 "standard Entr length must Ok"
597 );
598 }
599}