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::encode::encode;
285 use crate::payload::Payload;
286 use crate::tag::Tag;
287 use codex32::{Codex32String, Fe};
288
289 fn entr_p() -> Payload {
290 Payload::Entr(vec![0xCDu8; 16])
291 }
292 fn mnem_p() -> Payload {
293 Payload::Mnem { language: 1, entropy: vec![0xCDu8; 16] }
294 }
295
296 fn share_header(s: &str) -> (char, char, String) {
298 let sep = s.rfind('1').unwrap();
299 let b = s.as_bytes();
300 let threshold = b[sep + 1] as char;
301 let id: String = s[sep + 2..sep + 6].to_string();
302 let index = b[sep + 6] as char;
303 (threshold, index, id)
304 }
305
306 #[test]
307 fn zero_share_is_byte_identical_to_encode_entr() {
308 let p = entr_p();
309 let shares = encode_shares(Tag::ENTR, Threshold::ZERO, 1, &p).unwrap();
310 assert_eq!(shares, vec![encode(Tag::ENTR, &p).unwrap()]);
311 }
312
313 #[test]
314 fn zero_share_is_byte_identical_to_encode_mnem() {
315 let p = mnem_p();
316 let shares = encode_shares(Tag::ENTR, Threshold::ZERO, 1, &p).unwrap();
317 assert_eq!(shares, vec![encode(Tag::ENTR, &p).unwrap()]);
318 }
319
320 #[test]
321 fn zero_share_requires_n_eq_1() {
322 let p = entr_p();
323 assert!(matches!(
324 encode_shares(Tag::ENTR, Threshold::ZERO, 2, &p),
325 Err(Error::InvalidShareCount { k: 0, n: 2 })
326 ));
327 }
328
329 #[test]
330 fn encode_shares_2_of_3_shape() {
331 let p = entr_p();
332 let shares = encode_shares(Tag::ENTR, Threshold::new(2).unwrap(), 3, &p).unwrap();
333 assert_eq!(shares.len(), 3);
334 let mut indices = Vec::new();
336 let mut ids = Vec::new();
337 for s in &shares {
338 Codex32String::from_string(s.clone()).expect("each share must parse");
339 let (thr, idx, id) = share_header(s);
340 assert_eq!(thr, '2', "threshold char");
341 assert_ne!(idx, 's', "distributed share must not be index s");
342 indices.push(idx);
343 ids.push(id);
344 }
345 let mut sorted = indices.clone();
347 sorted.sort_unstable();
348 sorted.dedup();
349 assert_eq!(sorted.len(), indices.len(), "indices must be distinct");
350 assert!(ids.windows(2).all(|w| w[0] == w[1]), "id must be shared");
352 }
353
354 #[test]
355 fn encode_shares_rejects_n_below_k() {
356 let p = entr_p();
357 assert!(matches!(
358 encode_shares(Tag::ENTR, Threshold::new(2).unwrap(), 1, &p),
359 Err(Error::InvalidShareCount { k: 2, n: 1 })
360 ));
361 }
362
363 #[test]
364 fn encode_shares_rejects_n_32() {
365 let p = entr_p();
366 assert!(matches!(
367 encode_shares(Tag::ENTR, Threshold::new(2).unwrap(), 32, &p),
368 Err(Error::InvalidShareCount { k: 2, n: 32 })
369 ));
370 }
371
372 #[test]
373 fn encode_shares_id_not_in_blocklist() {
374 let p = entr_p();
376 for _ in 0..64 {
377 let shares = encode_shares(Tag::ENTR, Threshold::new(2).unwrap(), 2, &p).unwrap();
378 let (_, _, id) = share_header(&shares[0]);
379 let id_bytes: [u8; 4] = id.as_bytes().try_into().unwrap();
380 assert!(
381 !crate::consts::RESERVED_ID_BLOCKLIST.contains(&id_bytes),
382 "id {id:?} must not be in RESERVED_ID_BLOCKLIST"
383 );
384 }
385 }
386
387 #[test]
390 fn encode_shares_round_trip_via_interpolate_entr_and_mnem() {
391 for p in [entr_p(), mnem_p()] {
392 let secret_wire = crate::envelope::payload_wire_bytes(&p);
393 for k in 2u8..=9 {
394 let n = (k as usize) + 2; let shares = encode_shares(Tag::ENTR, Threshold::new(k).unwrap(), n, &p).unwrap();
396 assert_eq!(shares.len(), n);
397 let parsed: Vec<Codex32String> = shares
398 .iter()
399 .map(|s| Codex32String::from_string(s.clone()).unwrap())
400 .collect();
401 for subset in [&parsed[..k as usize], &parsed[n - k as usize..]] {
403 let recovered = Codex32String::interpolate_at(subset, Fe::S).unwrap();
404 assert_eq!(
405 recovered.parts().data(),
406 secret_wire[..],
407 "k={k} n={n} kind={:?} must recover secret wire bytes",
408 p.kind()
409 );
410 }
411 }
412 }
413 }
414
415 #[test]
418 fn combine_round_trip_entr_and_mnem_all_lengths() {
419 for ent_len in [16usize, 20, 24, 28, 32] {
420 let entr = Payload::Entr(vec![0x37u8; ent_len]);
421 let mnem = Payload::Mnem { language: 7, entropy: vec![0x91u8; ent_len] };
422 for p in [entr, mnem] {
423 for k in 2u8..=9 {
424 let n = (k as usize) + 1;
425 let shares =
426 encode_shares(Tag::ENTR, Threshold::new(k).unwrap(), n, &p).unwrap();
427 for subset in [&shares[..k as usize], &shares[n - k as usize..]] {
429 let (tag, recovered) = combine_shares(subset).unwrap();
430 assert_eq!(tag, Tag::ENTR, "combine always returns Tag::ENTR");
431 assert_eq!(
432 recovered,
433 p,
434 "k={k} n={n} ent_len={ent_len} must recover the exact payload"
435 );
436 }
437 }
438 }
439 }
440 }
441
442 #[test]
443 fn combine_rejects_below_threshold() {
444 let p = entr_p();
445 let shares = encode_shares(Tag::ENTR, Threshold::new(3).unwrap(), 4, &p).unwrap();
446 let err = combine_shares(&shares[..2]).unwrap_err();
448 assert!(
449 matches!(err, Error::Codex32(codex32::Error::ThresholdNotPassed { .. })),
450 "expected ThresholdNotPassed, got {err:?}"
451 );
452 }
453
454 #[test]
455 fn combine_rejects_duplicate_index() {
456 let p = entr_p();
457 let shares = encode_shares(Tag::ENTR, Threshold::new(2).unwrap(), 3, &p).unwrap();
458 let dup = vec![shares[0].clone(), shares[0].clone()];
460 assert!(matches!(
461 combine_shares(&dup),
462 Err(Error::Codex32(codex32::Error::RepeatedIndex(_)))
463 ));
464 }
465
466 #[test]
467 fn combine_rejects_secret_share_index_s() {
468 let bytes = crate::envelope::payload_wire_bytes(&entr_p());
472 let secret_s = Codex32String::from_seed(HRP, 2, "tst7", Fe::S, &bytes[..])
473 .unwrap()
474 .to_string();
475 let p = entr_p();
479 let shares = encode_shares(Tag::ENTR, Threshold::new(2).unwrap(), 2, &p).unwrap();
480 let with_secret = vec![secret_s, shares[0].clone()];
481 assert!(matches!(
482 combine_shares(&with_secret),
483 Err(Error::SecretShareSuppliedToCombine)
484 ));
485 }
486
487 #[test]
488 fn combine_rejects_mismatched_threshold() {
489 let p = entr_p();
493 let set2 = encode_shares(Tag::ENTR, Threshold::new(2).unwrap(), 2, &p).unwrap();
494 let set3 = encode_shares(Tag::ENTR, Threshold::new(3).unwrap(), 3, &p).unwrap();
495 let mixed = vec![set2[0].clone(), set3[1].clone()];
496 let err = combine_shares(&mixed).unwrap_err();
497 assert!(
498 matches!(err, Error::Codex32(codex32::Error::MismatchedThreshold(..))),
499 "expected MismatchedThreshold, got {err:?}"
500 );
501 }
502
503 #[test]
504 fn combine_rejects_unparseable() {
505 let bad = vec!["not-an-ms1-string".to_string(), "also-bad".to_string()];
506 assert!(matches!(combine_shares(&bad), Err(Error::Codex32(_))));
507 }
508}