An easy-to-host PDS on the ATProtocol, MacOS. Grandma-approved.

feat(crypto): implement Shamir 2-of-3 secret sharing for DID rotation key recovery

Adds split_secret and combine_shares to crates/crypto using GF(2^8)
arithmetic (AES irreducible polynomial 0x11b). Any 2 of the 3 returned
shares reconstruct the original 32-byte secret; a single share reveals
nothing (information-theoretic security). Share data is zeroized on drop.

Closes MM-93

authored by malpercio.dev and committed by

Tangled 26db06ee 76642f61

+365 -4
+11 -4
crates/crypto/CLAUDE.md
··· 1 1 # Crypto Crate 2 2 3 - Last verified: 2026-03-11 3 + Last verified: 2026-03-12 4 4 5 5 ## Purpose 6 6 Provides cryptographic primitives for the ezpds workspace: P-256 key generation, 7 - did:key derivation, and AES-256-GCM encryption/decryption of private key material. 7 + did:key derivation, AES-256-GCM encryption/decryption of private key material, 8 + and Shamir Secret Sharing for DID rotation key recovery. 8 9 This is a pure functional core -- no I/O, no database, no config. 9 10 10 11 ## Contracts 11 - - **Exposes**: `generate_p256_keypair() -> Result<P256Keypair, CryptoError>`, `encrypt_private_key(&[u8; 32], &[u8; 32]) -> Result<String, CryptoError>`, `decrypt_private_key(&str, &[u8; 32]) -> Result<Zeroizing<[u8; 32]>, CryptoError>`, `P256Keypair`, `CryptoError` 12 + - **Exposes**: `generate_p256_keypair() -> Result<P256Keypair, CryptoError>`, `encrypt_private_key(&[u8; 32], &[u8; 32]) -> Result<String, CryptoError>`, `decrypt_private_key(&str, &[u8; 32]) -> Result<Zeroizing<[u8; 32]>, CryptoError>`, `split_secret(&[u8; 32]) -> Result<[ShamirShare; 3], CryptoError>`, `combine_shares(&ShamirShare, &ShamirShare) -> Result<Zeroizing<[u8; 32]>, CryptoError>`, `P256Keypair`, `ShamirShare`, `CryptoError` 12 13 - **P256Keypair fields**: `key_id` (full `did:key:z...` URI), `public_key` (multibase base58btc compressed point, no did:key: prefix), `private_key_bytes` (`Zeroizing<[u8; 32]>` -- zeroized on drop) 14 + - **ShamirShare fields**: `index` (u8, 1/2/3 -- not secret), `data` (`Zeroizing<[u8; 32]>` -- zeroized on drop) 13 15 - **Encryption format**: `base64(nonce(12) || ciphertext(32) || tag(16))` = 80 base64 chars. Fresh 12-byte nonce from OS RNG per call. 14 16 - **did:key format**: P-256 multicodec varint `[0x80, 0x24]` + compressed public key, multibase base58btc encoded 15 - - **CryptoError variants**: `KeyGeneration`, `Encryption`, `Decryption` 17 + - **CryptoError variants**: `KeyGeneration`, `Encryption`, `Decryption`, `SecretSharing`, `SecretReconstruction` 16 18 17 19 ## Dependencies 18 20 - **Uses**: p256 (ECDSA/key generation), aes-gcm (AES-256-GCM), multibase (base58btc encoding), rand_core (OS RNG), base64 (storage encoding), zeroize (secret cleanup) ··· 22 24 - Private key bytes are always wrapped in `Zeroizing` -- callers must not copy them into non-zeroizing storage 23 25 - `encrypt_private_key` always generates a fresh nonce; two calls with identical input produce different ciphertext 24 26 - `decrypt_private_key` returns a single opaque `CryptoError::Decryption` for all failure modes (no oracle) 27 + - `ShamirShare.data` is zeroized on drop -- callers must not copy share bytes into non-zeroizing storage 28 + - `split_secret` polynomial coefficients are fresh OS RNG per call; information-theoretic security (a single share reveals nothing) 29 + - `combine_shares` requires exactly 2 shares with distinct indices in [1, 3]; returns `CryptoError::SecretReconstruction` otherwise 30 + - GF(2^8) arithmetic uses the AES irreducible polynomial (0x11b); secret bytes are always the first argument to `gf_mul` (non-branching position) 25 31 26 32 ## Key Files 27 33 - `src/lib.rs` - Re-exports public API 28 34 - `src/keys.rs` - P-256 key generation, AES-256-GCM encrypt/decrypt 35 + - `src/shamir.rs` - Shamir Secret Sharing (split/combine, GF(2^8) arithmetic) 29 36 - `src/error.rs` - CryptoError enum
+4
crates/crypto/src/error.rs
··· 6 6 Encryption(String), 7 7 #[error("decryption failed: {0}")] 8 8 Decryption(String), 9 + #[error("secret sharing failed: {0}")] 10 + SecretSharing(String), 11 + #[error("secret reconstruction failed: {0}")] 12 + SecretReconstruction(String), 9 13 }
+2
crates/crypto/src/lib.rs
··· 2 2 3 3 pub mod error; 4 4 pub mod keys; 5 + pub mod shamir; 5 6 6 7 pub use error::CryptoError; 7 8 pub use keys::{ 8 9 decrypt_private_key, encrypt_private_key, generate_p256_keypair, DidKeyUri, P256Keypair, 9 10 }; 11 + pub use shamir::{combine_shares, split_secret, ShamirShare};
+348
crates/crypto/src/shamir.rs
··· 1 + use rand_core::{OsRng, RngCore}; 2 + use zeroize::Zeroizing; 3 + 4 + use crate::CryptoError; 5 + 6 + /// A single Shamir secret share for a 32-byte secret. 7 + /// 8 + /// `data` contains secret material and is zeroized on drop. 9 + #[derive(Clone)] 10 + pub struct ShamirShare { 11 + /// Share index: 1, 2, or 3. Not secret. 12 + pub index: u8, 13 + /// 32 bytes of share data. Zeroized on drop. 14 + pub data: Zeroizing<[u8; 32]>, 15 + } 16 + 17 + /// Split a 32-byte secret into 3 Shamir shares using a 2-of-3 threshold. 18 + /// 19 + /// Any 2 of the 3 returned shares can reconstruct the original secret via 20 + /// [`combine_shares`]. A single share reveals nothing about the secret 21 + /// (information-theoretic security). 22 + /// 23 + /// Uses GF(2^8) arithmetic with the AES irreducible polynomial 24 + /// p(x) = x^8 + x^4 + x^3 + x + 1 (0x11b). 25 + pub fn split_secret(secret: &[u8; 32]) -> Result<[ShamirShare; 3], CryptoError> { 26 + let mut coeffs = Zeroizing::new([0u8; 32]); 27 + OsRng.fill_bytes(coeffs.as_mut()); 28 + 29 + let mut s1 = Zeroizing::new([0u8; 32]); 30 + let mut s2 = Zeroizing::new([0u8; 32]); 31 + let mut s3 = Zeroizing::new([0u8; 32]); 32 + 33 + // Polynomial: f(x) = secret[i] + coeffs[i]·x in GF(2^8). 34 + // f(0) = secret[i]. Shares are f(1), f(2), f(3). 35 + // 36 + // The secret byte is in the first argument of gf_mul so that the 37 + // second argument (the constant share index) controls branching — 38 + // no timing side-channel on the secret value. 39 + for i in 0..32 { 40 + let s = secret[i]; 41 + let a = coeffs[i]; 42 + s1[i] = gf_add(s, gf_mul(a, 1)); 43 + s2[i] = gf_add(s, gf_mul(a, 2)); 44 + s3[i] = gf_add(s, gf_mul(a, 3)); 45 + } 46 + 47 + Ok([ 48 + ShamirShare { index: 1, data: s1 }, 49 + ShamirShare { index: 2, data: s2 }, 50 + ShamirShare { index: 3, data: s3 }, 51 + ]) 52 + } 53 + 54 + /// Reconstruct the original secret from any 2 Shamir shares. 55 + /// 56 + /// The two shares must have distinct indices in the range [1, 3]. 57 + /// Returns [`CryptoError::SecretReconstruction`] for invalid input. 58 + /// 59 + /// # Algorithm 60 + /// 61 + /// Lagrange interpolation at x=0 in GF(2^8): 62 + /// 63 + /// ```text 64 + /// f(0) = y_a · (x_b / (x_a ⊕ x_b)) ⊕ y_b · (x_a / (x_a ⊕ x_b)) 65 + /// ``` 66 + /// 67 + /// In GF(2^8): subtraction = XOR, and (0 − x) = x. 68 + pub fn combine_shares( 69 + a: &ShamirShare, 70 + b: &ShamirShare, 71 + ) -> Result<Zeroizing<[u8; 32]>, CryptoError> { 72 + if a.index == 0 || b.index == 0 { 73 + return Err(CryptoError::SecretReconstruction( 74 + "share index must be in [1, 3]".into(), 75 + )); 76 + } 77 + if a.index == b.index { 78 + return Err(CryptoError::SecretReconstruction( 79 + "shares must have distinct indices".into(), 80 + )); 81 + } 82 + 83 + let x_a = a.index; 84 + let x_b = b.index; 85 + // x_a ⊕ x_b is guaranteed nonzero since x_a ≠ x_b. 86 + let denom = gf_add(x_a, x_b); 87 + // Lagrange basis values at x=0. These are derived from public indices, 88 + // so leaking their value through timing (as the `b` arg in gf_mul) is fine. 89 + let l_a = gf_div(x_b, denom)?; 90 + let l_b = gf_div(x_a, denom)?; 91 + 92 + let mut secret = Zeroizing::new([0u8; 32]); 93 + for i in 0..32 { 94 + // Secret share bytes are in the first (non-branching) argument of gf_mul. 95 + secret[i] = gf_add(gf_mul(a.data[i], l_a), gf_mul(b.data[i], l_b)); 96 + } 97 + Ok(secret) 98 + } 99 + 100 + // ── GF(2^8) arithmetic ──────────────────────────────────────────────────────── 101 + 102 + /// GF(2^8) addition — XOR in any characteristic-2 field. 103 + fn gf_add(a: u8, b: u8) -> u8 { 104 + a ^ b 105 + } 106 + 107 + /// GF(2^8) multiplication using the AES irreducible polynomial 108 + /// p(x) = x^8 + x^4 + x^3 + x + 1 (represented as 0x11b; low byte 0x1b). 109 + /// 110 + /// Use the "Russian peasant" (double-and-add) algorithm: 111 + /// 112 + /// For each of the 8 bits of `b` (LSB first): 113 + /// 1. If the current bit of `b` is 1, XOR `a` into `result`. 114 + /// 2. Check whether `a`'s high bit (0x80) is set before shifting. 115 + /// 3. Left-shift `a` by 1. 116 + /// 4. If the high bit was set, XOR `a` with 0x1b to reduce modulo p(x). 117 + /// 5. Right-shift `b` by 1. 118 + /// 119 + /// The loop runs exactly 8 times (constant-time with respect to `a`, 120 + /// which is where secret values are placed by convention). 121 + fn gf_mul(mut a: u8, mut b: u8) -> u8 { 122 + let mut result = 0u8; 123 + for _ in 0..8 { 124 + if b & 1 != 0 { 125 + result ^= a; 126 + } 127 + // GF(2^8) "doubling": left-shift + conditional reduction. 128 + // This is NOT gf_add(a, a) — that would always be 0 in characteristic 2. 129 + let high_bit = (a & 0x80) != 0; 130 + a <<= 1; 131 + if high_bit { 132 + a ^= 0x1b; // reduce mod x^8 + x^4 + x^3 + x + 1 133 + } 134 + b >>= 1; 135 + } 136 + result 137 + } 138 + 139 + /// Multiplicative inverse in GF(2^8) via Fermat's little theorem: 140 + /// a^(2^8 − 2) = a^254 = a^(−1) (since |GF(2^8)*| = 255). 141 + /// 142 + /// Computed via repeated squaring on top of `gf_mul`. 143 + fn gf_inv(a: u8) -> Result<u8, CryptoError> { 144 + if a == 0 { 145 + return Err(CryptoError::SecretReconstruction( 146 + "GF(2^8) inverse of 0 is undefined".into(), 147 + )); 148 + } 149 + // a^254 by repeated squaring (254 = 0b11111110). 150 + let mut result = 1u8; 151 + let mut base = a; 152 + let mut exp = 254u8; 153 + while exp > 0 { 154 + if exp & 1 != 0 { 155 + result = gf_mul(result, base); 156 + } 157 + base = gf_mul(base, base); 158 + exp >>= 1; 159 + } 160 + Ok(result) 161 + } 162 + 163 + /// GF(2^8) division: a / b = a · b^(−1). 164 + fn gf_div(a: u8, b: u8) -> Result<u8, CryptoError> { 165 + Ok(gf_mul(a, gf_inv(b)?)) 166 + } 167 + 168 + // ── Tests ───────────────────────────────────────────────────────────────────── 169 + 170 + #[cfg(test)] 171 + mod tests { 172 + use super::*; 173 + 174 + // ── AC: Splitting a 32-byte secret produces 3 shares ───────────────────── 175 + 176 + #[test] 177 + fn split_shares_have_correct_indices() { 178 + let secret = [0x42_u8; 32]; 179 + let shares = split_secret(&secret).unwrap(); 180 + assert_eq!(shares[0].index, 1); 181 + assert_eq!(shares[1].index, 2); 182 + assert_eq!(shares[2].index, 3); 183 + } 184 + 185 + #[test] 186 + fn split_shares_are_32_bytes() { 187 + let secret = [0x42_u8; 32]; 188 + let shares = split_secret(&secret).unwrap(); 189 + for share in &shares { 190 + assert_eq!(share.data.len(), 32); 191 + } 192 + } 193 + 194 + // ── AC: Any 2 shares reconstruct the original secret ───────────────────── 195 + 196 + #[test] 197 + fn combine_shares_1_and_2_reconstructs_secret() { 198 + let secret = [0x42_u8; 32]; 199 + let shares = split_secret(&secret).unwrap(); 200 + let recovered = combine_shares(&shares[0], &shares[1]).unwrap(); 201 + assert_eq!(*recovered, secret); 202 + } 203 + 204 + #[test] 205 + fn combine_shares_1_and_3_reconstructs_secret() { 206 + let secret = [0x42_u8; 32]; 207 + let shares = split_secret(&secret).unwrap(); 208 + let recovered = combine_shares(&shares[0], &shares[2]).unwrap(); 209 + assert_eq!(*recovered, secret); 210 + } 211 + 212 + #[test] 213 + fn combine_shares_2_and_3_reconstructs_secret() { 214 + let secret = [0x42_u8; 32]; 215 + let shares = split_secret(&secret).unwrap(); 216 + let recovered = combine_shares(&shares[1], &shares[2]).unwrap(); 217 + assert_eq!(*recovered, secret); 218 + } 219 + 220 + #[test] 221 + fn combine_is_commutative() { 222 + let secret = [0xAB_u8; 32]; 223 + let shares = split_secret(&secret).unwrap(); 224 + let r1 = combine_shares(&shares[0], &shares[1]).unwrap(); 225 + let r2 = combine_shares(&shares[1], &shares[0]).unwrap(); 226 + assert_eq!(*r1, *r2); 227 + } 228 + 229 + #[test] 230 + fn round_trip_all_zeros() { 231 + let secret = [0x00_u8; 32]; 232 + let shares = split_secret(&secret).unwrap(); 233 + let recovered = combine_shares(&shares[0], &shares[1]).unwrap(); 234 + assert_eq!(*recovered, secret); 235 + } 236 + 237 + #[test] 238 + fn round_trip_all_ones() { 239 + let secret = [0xFF_u8; 32]; 240 + let shares = split_secret(&secret).unwrap(); 241 + let recovered = combine_shares(&shares[0], &shares[1]).unwrap(); 242 + assert_eq!(*recovered, secret); 243 + } 244 + 245 + /// Integration test: all three pair combinations reconstruct the same secret. 246 + #[test] 247 + fn round_trip_all_pairs() { 248 + let secret: [u8; 32] = core::array::from_fn(|i| (i * 17 + 3) as u8); 249 + let shares = split_secret(&secret).unwrap(); 250 + for i in 0..3 { 251 + for j in (i + 1)..3 { 252 + let recovered = combine_shares(&shares[i], &shares[j]).unwrap(); 253 + assert_eq!(*recovered, secret, "pair ({i}, {j}) failed to reconstruct"); 254 + } 255 + } 256 + } 257 + 258 + // ── AC: Single share reveals nothing ───────────────────────────────────── 259 + 260 + /// Sanity check: with overwhelming probability, share data ≠ plaintext. 261 + /// (Not a proof of information-theoretic security; the math guarantees that.) 262 + #[test] 263 + fn shares_are_not_plaintext() { 264 + let secret = [0x42_u8; 32]; 265 + let shares = split_secret(&secret).unwrap(); 266 + // P(all three shares equal secret) ≈ 0 (requires coeffs[i]=0 for all i). 267 + assert!( 268 + *shares[0].data != secret || *shares[1].data != secret || *shares[2].data != secret, 269 + "at least one share must differ from the plaintext secret" 270 + ); 271 + } 272 + 273 + // ── Error handling ──────────────────────────────────────────────────────── 274 + 275 + #[test] 276 + fn combine_duplicate_indices_fails() { 277 + let secret = [0x42_u8; 32]; 278 + let shares = split_secret(&secret).unwrap(); 279 + let result = combine_shares(&shares[0], &shares[0]); 280 + assert!( 281 + matches!(result, Err(CryptoError::SecretReconstruction(_))), 282 + "expected SecretReconstruction for duplicate indices" 283 + ); 284 + } 285 + 286 + #[test] 287 + fn combine_with_index_zero_fails() { 288 + let zero_share = ShamirShare { 289 + index: 0, 290 + data: Zeroizing::new([0u8; 32]), 291 + }; 292 + let shares = split_secret(&[0x42_u8; 32]).unwrap(); 293 + let result = combine_shares(&zero_share, &shares[0]); 294 + assert!(matches!(result, Err(CryptoError::SecretReconstruction(_)))); 295 + } 296 + 297 + // ── GF(2^8) arithmetic invariants ──────────────────────────────────────── 298 + 299 + #[test] 300 + fn gf_mul_by_zero_is_zero() { 301 + for a in 0_u8..=255 { 302 + assert_eq!(gf_mul(a, 0), 0, "gf_mul({a}, 0) must be 0"); 303 + assert_eq!(gf_mul(0, a), 0, "gf_mul(0, {a}) must be 0"); 304 + } 305 + } 306 + 307 + #[test] 308 + fn gf_mul_by_one_is_identity() { 309 + for a in 0_u8..=255 { 310 + assert_eq!(gf_mul(a, 1), a, "gf_mul({a}, 1) must equal {a}"); 311 + assert_eq!(gf_mul(1, a), a, "gf_mul(1, {a}) must equal {a}"); 312 + } 313 + } 314 + 315 + #[test] 316 + fn gf_mul_is_commutative() { 317 + for a in [0x00_u8, 0x01, 0x02, 0x03, 0x0A, 0x53, 0xCA, 0xFF] { 318 + for b in [0x00_u8, 0x01, 0x02, 0x03, 0x0A, 0x53, 0xCA, 0xFF] { 319 + assert_eq!( 320 + gf_mul(a, b), 321 + gf_mul(b, a), 322 + "gf_mul({a:#04x}, {b:#04x}) not commutative" 323 + ); 324 + } 325 + } 326 + } 327 + 328 + /// Every non-zero element has a multiplicative inverse: a · a^(−1) = 1. 329 + #[test] 330 + fn gf_inv_produces_correct_inverse() { 331 + for a in 1_u8..=255 { 332 + let inv = gf_inv(a).unwrap(); 333 + assert_eq!( 334 + gf_mul(a, inv), 335 + 1, 336 + "a={a:#04x}, inv={inv:#04x}: a·inv must equal 1" 337 + ); 338 + } 339 + } 340 + 341 + #[test] 342 + fn gf_inv_of_zero_fails() { 343 + assert!(matches!( 344 + gf_inv(0), 345 + Err(CryptoError::SecretReconstruction(_)) 346 + )); 347 + } 348 + }