1use crate::consts::{HRP, RESERVED_ID_BLOCKLIST, SHARE_INDEX_V01};
14use crate::envelope::{dispatch_payload, extract_wire_fields, payload_wire_bytes};
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)> {
181 let parsed: Vec<Codex32String> = shares
183 .iter()
184 .map(|s| Codex32String::from_string(s.clone()).map_err(Error::Codex32))
185 .collect::<Result<Vec<_>>>()?;
186
187 if parsed.is_empty() {
188 return Err(Error::Codex32(codex32::Error::ThresholdNotPassed {
190 threshold: 1,
191 n_shares: 0,
192 }));
193 }
194
195 let fields: Vec<(u8, u8)> = parsed
198 .iter()
199 .map(|c| {
200 let s = c.to_string();
201 extract_wire_fields(&s).map(|f| (f.threshold_byte, f.share_index_byte))
202 })
203 .collect::<Result<Vec<_>>>()?;
204
205 if fields.iter().any(|&(_, idx)| idx == SHARE_INDEX_V01) {
208 return Err(Error::SecretShareSuppliedToCombine);
209 }
210
211 let k = (fields[0].0 - b'0') as usize;
216 if parsed.len() < k {
217 return Err(Error::Codex32(codex32::Error::ThresholdNotPassed {
218 threshold: k,
219 n_shares: parsed.len(),
220 }));
221 }
222
223 for i in 0..fields.len() {
226 for j in (i + 1)..fields.len() {
227 if fields[i].1 == fields[j].1 {
228 let idx = Fe::from_char(fields[i].1 as char).map_err(Error::Codex32)?;
229 return Err(Error::Codex32(codex32::Error::RepeatedIndex(idx)));
230 }
231 }
232 }
233
234 let secret = Codex32String::interpolate_at(&parsed, Fe::S).map_err(Error::Codex32)?;
237
238 let data: Zeroizing<Vec<u8>> = Zeroizing::new(secret.parts().data());
241 let payload = dispatch_payload(&data)?;
242 Ok((Tag::ENTR, payload))
243}
244
245#[cfg(test)]
246mod tests {
247 use super::*;
248
249 #[test]
250 fn new_accepts_2_through_9() {
251 for k in 2u8..=9 {
252 let t = Threshold::new(k).unwrap_or_else(|e| panic!("new({k}) should be Ok, got {e:?}"));
253 assert_eq!(t.get(), k);
254 }
255 }
256
257 #[test]
258 fn new_rejects_zero() {
259 assert!(matches!(Threshold::new(0), Err(Error::InvalidThreshold(0))));
260 }
261
262 #[test]
263 fn new_rejects_one() {
264 assert!(matches!(Threshold::new(1), Err(Error::InvalidThreshold(1))));
265 }
266
267 #[test]
268 fn new_rejects_ten() {
269 assert!(matches!(Threshold::new(10), Err(Error::InvalidThreshold(10))));
270 }
271
272 #[test]
273 fn zero_const_get_is_zero() {
274 assert_eq!(Threshold::ZERO.get(), 0);
275 }
276
277 #[test]
278 fn new_five_get_is_five() {
279 assert_eq!(Threshold::new(5).unwrap().get(), 5);
280 }
281
282 use crate::consts::RESERVED_PREFIX;
285 use crate::encode::encode;
286 use crate::payload::Payload;
287 use crate::tag::Tag;
288 use codex32::{Codex32String, Fe};
289
290 fn entr_p() -> Payload {
291 Payload::Entr(vec![0xCDu8; 16])
292 }
293 fn mnem_p() -> Payload {
294 Payload::Mnem { language: 1, entropy: vec![0xCDu8; 16] }
295 }
296
297 fn share_header(s: &str) -> (char, char, String) {
299 let sep = s.rfind('1').unwrap();
300 let b = s.as_bytes();
301 let threshold = b[sep + 1] as char;
302 let id: String = s[sep + 2..sep + 6].to_string();
303 let index = b[sep + 6] as char;
304 (threshold, index, id)
305 }
306
307 #[test]
308 fn zero_share_is_byte_identical_to_encode_entr() {
309 let p = entr_p();
310 let shares = encode_shares(Tag::ENTR, Threshold::ZERO, 1, &p).unwrap();
311 assert_eq!(shares, vec![encode(Tag::ENTR, &p).unwrap()]);
312 }
313
314 #[test]
315 fn zero_share_is_byte_identical_to_encode_mnem() {
316 let p = mnem_p();
317 let shares = encode_shares(Tag::ENTR, Threshold::ZERO, 1, &p).unwrap();
318 assert_eq!(shares, vec![encode(Tag::ENTR, &p).unwrap()]);
319 }
320
321 #[test]
322 fn zero_share_requires_n_eq_1() {
323 let p = entr_p();
324 assert!(matches!(
325 encode_shares(Tag::ENTR, Threshold::ZERO, 2, &p),
326 Err(Error::InvalidShareCount { k: 0, n: 2 })
327 ));
328 }
329
330 #[test]
331 fn encode_shares_2_of_3_shape() {
332 let p = entr_p();
333 let shares = encode_shares(Tag::ENTR, Threshold::new(2).unwrap(), 3, &p).unwrap();
334 assert_eq!(shares.len(), 3);
335 let mut indices = Vec::new();
337 let mut ids = Vec::new();
338 for s in &shares {
339 Codex32String::from_string(s.clone()).expect("each share must parse");
340 let (thr, idx, id) = share_header(s);
341 assert_eq!(thr, '2', "threshold char");
342 assert_ne!(idx, 's', "distributed share must not be index s");
343 indices.push(idx);
344 ids.push(id);
345 }
346 let mut sorted = indices.clone();
348 sorted.sort_unstable();
349 sorted.dedup();
350 assert_eq!(sorted.len(), indices.len(), "indices must be distinct");
351 assert!(ids.windows(2).all(|w| w[0] == w[1]), "id must be shared");
353 }
354
355 #[test]
356 fn encode_shares_rejects_n_below_k() {
357 let p = entr_p();
358 assert!(matches!(
359 encode_shares(Tag::ENTR, Threshold::new(2).unwrap(), 1, &p),
360 Err(Error::InvalidShareCount { k: 2, n: 1 })
361 ));
362 }
363
364 #[test]
365 fn encode_shares_rejects_n_32() {
366 let p = entr_p();
367 assert!(matches!(
368 encode_shares(Tag::ENTR, Threshold::new(2).unwrap(), 32, &p),
369 Err(Error::InvalidShareCount { k: 2, n: 32 })
370 ));
371 }
372
373 #[test]
374 fn encode_shares_id_not_in_blocklist() {
375 let p = entr_p();
377 for _ in 0..64 {
378 let shares = encode_shares(Tag::ENTR, Threshold::new(2).unwrap(), 2, &p).unwrap();
379 let (_, _, id) = share_header(&shares[0]);
380 let id_bytes: [u8; 4] = id.as_bytes().try_into().unwrap();
381 assert!(
382 !crate::consts::RESERVED_ID_BLOCKLIST.contains(&id_bytes),
383 "id {id:?} must not be in RESERVED_ID_BLOCKLIST"
384 );
385 }
386 }
387
388 #[test]
391 fn encode_shares_round_trip_via_interpolate_entr_and_mnem() {
392 for p in [entr_p(), mnem_p()] {
393 let secret_wire = crate::envelope::payload_wire_bytes(&p);
394 for k in 2u8..=9 {
395 let n = (k as usize) + 2; let shares = encode_shares(Tag::ENTR, Threshold::new(k).unwrap(), n, &p).unwrap();
397 assert_eq!(shares.len(), n);
398 let parsed: Vec<Codex32String> = shares
399 .iter()
400 .map(|s| Codex32String::from_string(s.clone()).unwrap())
401 .collect();
402 for subset in [&parsed[..k as usize], &parsed[n - k as usize..]] {
404 let recovered = Codex32String::interpolate_at(subset, Fe::S).unwrap();
405 assert_eq!(
406 recovered.parts().data(),
407 secret_wire[..],
408 "k={k} n={n} kind={:?} must recover secret wire bytes",
409 p.kind()
410 );
411 }
412 }
413 }
414 }
415
416 #[test]
419 fn combine_round_trip_entr_and_mnem_all_lengths() {
420 for ent_len in [16usize, 20, 24, 28, 32] {
421 let entr = Payload::Entr(vec![0x37u8; ent_len]);
422 let mnem = Payload::Mnem { language: 7, entropy: vec![0x91u8; ent_len] };
423 for p in [entr, mnem] {
424 for k in 2u8..=9 {
425 let n = (k as usize) + 1;
426 let shares =
427 encode_shares(Tag::ENTR, Threshold::new(k).unwrap(), n, &p).unwrap();
428 for subset in [&shares[..k as usize], &shares[n - k as usize..]] {
430 let (tag, recovered) = combine_shares(subset).unwrap();
431 assert_eq!(tag, Tag::ENTR, "combine always returns Tag::ENTR");
432 assert_eq!(
433 recovered,
434 p,
435 "k={k} n={n} ent_len={ent_len} must recover the exact payload"
436 );
437 }
438 }
439 }
440 }
441 }
442
443 #[test]
444 fn combine_rejects_below_threshold() {
445 let p = entr_p();
446 let shares = encode_shares(Tag::ENTR, Threshold::new(3).unwrap(), 4, &p).unwrap();
447 let err = combine_shares(&shares[..2]).unwrap_err();
449 assert!(
450 matches!(err, Error::Codex32(codex32::Error::ThresholdNotPassed { .. })),
451 "expected ThresholdNotPassed, got {err:?}"
452 );
453 }
454
455 #[test]
456 fn combine_rejects_duplicate_index() {
457 let p = entr_p();
458 let shares = encode_shares(Tag::ENTR, Threshold::new(2).unwrap(), 3, &p).unwrap();
459 let dup = vec![shares[0].clone(), shares[0].clone()];
461 assert!(matches!(
462 combine_shares(&dup),
463 Err(Error::Codex32(codex32::Error::RepeatedIndex(_)))
464 ));
465 }
466
467 #[test]
468 fn combine_rejects_secret_share_index_s() {
469 let bytes = crate::envelope::payload_wire_bytes(&entr_p());
473 let secret_s = Codex32String::from_seed(HRP, 2, "tst7", Fe::S, &bytes[..])
474 .unwrap()
475 .to_string();
476 let p = entr_p();
480 let shares = encode_shares(Tag::ENTR, Threshold::new(2).unwrap(), 2, &p).unwrap();
481 let with_secret = vec![secret_s, shares[0].clone()];
482 assert!(matches!(
483 combine_shares(&with_secret),
484 Err(Error::SecretShareSuppliedToCombine)
485 ));
486 }
487
488 #[test]
489 fn combine_rejects_mismatched_threshold() {
490 let p = entr_p();
494 let set2 = encode_shares(Tag::ENTR, Threshold::new(2).unwrap(), 2, &p).unwrap();
495 let set3 = encode_shares(Tag::ENTR, Threshold::new(3).unwrap(), 3, &p).unwrap();
496 let mixed = vec![set2[0].clone(), set3[1].clone()];
497 let err = combine_shares(&mixed).unwrap_err();
498 assert!(
499 matches!(err, Error::Codex32(codex32::Error::MismatchedThreshold(..))),
500 "expected MismatchedThreshold, got {err:?}"
501 );
502 }
503
504 #[test]
505 fn combine_rejects_unparseable() {
506 let bad = vec!["not-an-ms1-string".to_string(), "also-bad".to_string()];
507 assert!(matches!(combine_shares(&bad), Err(Error::Codex32(_))));
508 }
509
510 fn nonstandard_entr_distributed(k: usize, n: usize, entropy_len: usize) -> Vec<String> {
520 let mut bytes = vec![RESERVED_PREFIX];
522 bytes.extend(std::iter::repeat(0xCDu8).take(entropy_len));
523 let id = "tst7";
524 let secret_s = Codex32String::from_seed(HRP, k, id, Fe::S, &bytes[..]).unwrap();
525 let pool = non_s_index_pool();
526 let mut defining = vec![secret_s];
527 for pidx in pool.iter().take(k - 1) {
528 let filler = vec![0u8; bytes.len()];
529 defining.push(Codex32String::from_seed(HRP, k, id, *pidx, &filler[..]).unwrap());
530 }
531 let mut out = Vec::new();
532 for s in defining.iter().skip(1) {
533 out.push(s.to_string());
534 }
535 for pidx in pool.iter().take(n).skip(k - 1) {
536 out.push(Codex32String::interpolate_at(&defining, *pidx).unwrap().to_string());
537 }
538 out
539 }
540
541 #[test]
542 fn combine_rejects_nonstandard_entr_length_not_panics() {
543 let shares = nonstandard_entr_distributed(2, 2, 17);
547 let res = combine_shares(&shares);
548 assert!(
549 matches!(res, Err(Error::PayloadLengthMismatch { got: 17, .. })),
550 "expected PayloadLengthMismatch{{got:17}}, got {res:?}"
551 );
552 }
553
554 #[test]
555 fn dispatch_payload_validates_entr_length() {
556 let mut bad = vec![RESERVED_PREFIX];
559 bad.extend(std::iter::repeat(0xCDu8).take(17));
560 assert!(
561 matches!(dispatch_payload(&bad), Err(Error::PayloadLengthMismatch { got: 17, .. })),
562 "non-standard Entr length must Err"
563 );
564 let mut good = vec![RESERVED_PREFIX];
566 good.extend(std::iter::repeat(0xCDu8).take(16));
567 assert!(
568 matches!(dispatch_payload(&good), Ok(Payload::Entr(_))),
569 "standard Entr length must Ok"
570 );
571 }
572}