From f088eef809ff62c561006548186bf62f0196c860 Mon Sep 17 00:00:00 2001 From: GoDeskio Date: Thu, 5 Nov 2026 18:41:41 +0800 Subject: [PATCH] feat: land shamir for PR #14 --- crates/encryptoglyph/src/lib.rs | 1 + crates/encryptoglyph/src/shamir.rs | 111 +++++++++++++++++++++++++++ crates/encryptoglyph/tests/shamir.rs | 36 +++++++++ 3 files changed, 148 insertions(+) create mode 100644 crates/encryptoglyph/src/shamir.rs create mode 100644 crates/encryptoglyph/tests/shamir.rs diff --git a/crates/encryptoglyph/src/lib.rs b/crates/encryptoglyph/src/lib.rs index e7ab162..070e499 100644 --- a/crates/encryptoglyph/src/lib.rs +++ b/crates/encryptoglyph/src/lib.rs @@ -23,6 +23,7 @@ pub mod wasm; pub mod kek; pub mod index; pub mod stream; +pub mod shamir; pub const MAGIC: &[u8; 2] = b"EG"; pub const VERSION_LEGACY: u8 = 1; diff --git a/crates/encryptoglyph/src/shamir.rs b/crates/encryptoglyph/src/shamir.rs new file mode 100644 index 0000000..1740989 --- /dev/null +++ b/crates/encryptoglyph/src/shamir.rs @@ -0,0 +1,111 @@ +//! Shamir M-of-N secret sharing over GF(2^8) (backlog #15). Use it to split a root or KEK across +//! custodians for break-glass recovery, with no single holder able to decrypt on their own. +//! This is not an escrow or backdoor: shares exist only where the key owner hands them out. +//! +//! Share format: `b"EGSS" | ver=1 | threshold u8 | x u8 | y[len]`. +//! Field arithmetic uses the AES polynomial 0x11b and is branch-free (no lookup tables). +use crate::Error; +use zeroize::Zeroizing; + +const MAGIC: &[u8; 4] = b"EGSS"; +const VER: u8 = 1; +const HDR: usize = 7; + +fn mul(mut a: u8, mut b: u8) -> u8 { + let mut p = 0u8; + for _ in 0..8 { + p ^= a & 0u8.wrapping_sub(b & 1); + let hi = 0u8.wrapping_sub(a >> 7); + a = (a << 1) ^ (0x1b & hi); + b >>= 1; + } + p +} + +fn inv(a: u8) -> u8 { + // a^254 = a^-1 in GF(2^8). inv(0) = 0, which is never used. + let mut r = 1u8; + let mut x = a; + for bit in 0..8 { + if (254u8 >> bit) & 1 == 1 { + r = mul(r, x); + } + x = mul(x, x); + } + r +} + +/// Split `secret` into `n` shares, any `threshold` of which reconstruct it. +pub fn split(secret: &[u8], threshold: u8, n: u8) -> Result>, Error> { + if secret.is_empty() || threshold < 2 || n < threshold { + return Err(Error::InvalidArgument); + } + let t = threshold as usize; + let mut coeffs = Zeroizing::new(vec![0u8; secret.len() * (t - 1)]); + getrandom::getrandom(&mut coeffs).map_err(|_| Error::InvalidArgument)?; + let mut shares = Vec::with_capacity(n as usize); + for x in 1..=n { + let mut s = Vec::with_capacity(HDR + secret.len()); + s.extend_from_slice(MAGIC); + s.extend_from_slice(&[VER, threshold, x]); + for (i, &b) in secret.iter().enumerate() { + let c = &coeffs[i * (t - 1)..(i + 1) * (t - 1)]; + // Horner's method, highest coefficient first. + let mut y = 0u8; + for &k in c.iter().rev() { + y = mul(y, x) ^ k; + } + s.push(mul(y, x) ^ b); + } + shares.push(s); + } + Ok(shares) +} + +/// Recombine shares. Fails if there are fewer than the threshold, mixed lengths or thresholds, +/// duplicate x values, or bad headers. Wrong-but-well-formed shares yield wrong bytes, so verify +/// the result (for example, unwrap a key-check value) before trusting it. +pub fn combine(shares: &[&[u8]]) -> Result>, Error> { + let first = shares.first().ok_or(Error::InvalidArgument)?; + if first.len() <= HDR { + return Err(Error::InvalidArgument); + } + let t = first[5] as usize; + if shares.len() < t { + return Err(Error::InvalidArgument); + } + let use_ = &shares[..t]; + let mut xs = Vec::with_capacity(t); + for s in use_ { + if s.len() != first.len() || &s[..4] != MAGIC || s[4] != VER || s[5] as usize != t || s[6] == 0 || xs.contains(&s[6]) { + return Err(Error::InvalidArgument); + } + xs.push(s[6]); + } + let mut out = Zeroizing::new(vec![0u8; first.len() - HDR]); + for (j, s) in use_.iter().enumerate() { + // Lagrange basis at x = 0: prod x_m / (x_m - x_j), and subtraction is XOR. + let mut l = 1u8; + for (m, &xm) in xs.iter().enumerate() { + if m != j { + l = mul(l, mul(xm, inv(xm ^ xs[j]))); + } + } + for (o, &y) in out.iter_mut().zip(&s[HDR..]) { + *o ^= mul(l, y); + } + } + Ok(out) +} + +#[cfg(test)] +mod tests { + use super::*; + #[test] + fn field() { + assert_eq!(mul(0x57, 0x83), 0xc1); // FIPS-197 worked example + for a in 1..=255u8 { + assert_eq!(mul(a, inv(a)), 1); + } + } +} diff --git a/crates/encryptoglyph/tests/shamir.rs b/crates/encryptoglyph/tests/shamir.rs new file mode 100644 index 0000000..4e40a74 --- /dev/null +++ b/crates/encryptoglyph/tests/shamir.rs @@ -0,0 +1,36 @@ +use encryptoglyph::shamir::{combine, split}; +use encryptoglyph::Error; + +#[test] +fn any_m_of_n_recovers() { + let secret: Vec = (0..32u8).collect(); + let sh = split(&secret, 3, 5).unwrap(); + for a in 0..5 { + for b in a + 1..5 { + for c in b + 1..5 { + let got = combine(&[&sh[a], &sh[b], &sh[c]]).unwrap(); + assert_eq!(&got[..], &secret[..]); + } + } + } +} + +#[test] +fn below_threshold_and_bad_input_rejected() { + let sh = split(b"k", 3, 4).unwrap(); + assert_eq!(combine(&[&sh[0], &sh[1]]).unwrap_err(), Error::InvalidArgument); + assert_eq!(combine(&[&sh[0], &sh[0], &sh[1]]).unwrap_err(), Error::InvalidArgument); + assert_eq!(split(b"k", 1, 3).unwrap_err(), Error::InvalidArgument); + assert_eq!(split(b"k", 4, 3).unwrap_err(), Error::InvalidArgument); + assert_eq!(split(b"", 2, 3).unwrap_err(), Error::InvalidArgument); + let other = split(b"kk", 3, 4).unwrap(); + assert_eq!(combine(&[&sh[0], &sh[1], &other[2]]).unwrap_err(), Error::InvalidArgument); +} + +#[test] +fn two_shares_of_three_reveal_nothing_structural() { + // Splitting the same secret twice gives unrelated shares (fresh randomness each time). + let a = split(&[0u8; 16], 3, 3).unwrap(); + let b = split(&[0u8; 16], 3, 3).unwrap(); + assert_ne!(a[0], b[0]); +}