//! The 128-bit Secret Key and its printable form `K1-XXXXXX-XXXXX-XXXXX-XXXXX-XXXXX-C`. //! //! Body: 26 Crockford base32 characters (128 bits, MSB first, zero-padded). //! Check: one Crockford symbol = first byte of SHA-256(raw key) mod 32. use sha2::{Digest, Sha256}; use zeroize::Zeroizing; use crate::{random_bytes, CryptoError, Result}; const ALPHABET: &[u8; 32] = b"0123456789ABCDEFGHJKMNPQRSTVWXYZ"; const PREFIX: &str = "K1"; const GROUPS: [usize; 5] = [6, 5, 5, 5, 5]; pub struct SecretKey(Zeroizing<[u8; 16]>); impl SecretKey { pub fn generate() -> Self { Self(Zeroizing::new(random_bytes())) } pub fn from_bytes(b: [u8; 16]) -> Self { Self(Zeroizing::new(b)) } pub fn expose(&self) -> &[u8; 16] { &self.0 } pub fn to_display(&self) -> Zeroizing { let body = encode(self.expose()); let mut out = String::from(PREFIX); let mut i = 0; for g in GROUPS { out.push('-'); out.push_str(&body[i..i + g]); i += g; } out.push('-'); out.push(ALPHABET[checksum(self.expose())] as char); Zeroizing::new(out) } /// Accepts any case, spaces or hyphens, and the usual Crockford confusables (O→0, I/L→1). pub fn parse(s: &str) -> Result { let cleaned: Zeroizing = Zeroizing::new( s.chars().filter(|c| !c.is_whitespace() && *c != '-').map(|c| c.to_ascii_uppercase()).collect(), ); let rest = cleaned.strip_prefix(PREFIX).ok_or(CryptoError::BadSecretKey)?; // ASCII only: `split_at` below works on bytes and must not land inside a character. if !rest.is_ascii() || rest.len() != 27 { return Err(CryptoError::BadSecretKey); } let (body, check) = rest.split_at(26); let bytes = decode(body)?; let check_val = symbol_value(check.as_bytes()[0])?; if check_val != checksum(&bytes) { return Err(CryptoError::BadSecretKey); } Ok(Self::from_bytes(bytes)) } } fn checksum(b: &[u8; 16]) -> usize { (Sha256::digest(b)[0] % 32) as usize } fn encode(b: &[u8; 16]) -> String { let n = u128::from_be_bytes(*b); // 26 symbols * 5 bits = 130 bits; the top two bits are zero. (0..26).rev().map(|i| ALPHABET[((n >> (i * 5)) & 0x1f) as usize] as char).collect() } fn symbol_value(c: u8) -> Result { let c = match c { b'O' => b'0', b'I' | b'L' => b'1', other => other, }; ALPHABET.iter().position(|&a| a == c).ok_or(CryptoError::BadSecretKey) } fn decode(s: &str) -> Result<[u8; 16]> { let mut n: u128 = 0; for (i, c) in s.bytes().enumerate() { let v = symbol_value(c)? as u128; // 26 symbols carry 130 bits; the 2 spare bits sit at the top, so the first symbol holds 3 data bits. if i == 0 && v > 0b111 { return Err(CryptoError::BadSecretKey); // would overflow 128 bits } n = (n << 5) | v; } Ok(n.to_be_bytes()) } #[cfg(test)] mod tests { use super::*; #[test] fn non_ascii_is_rejected_without_panicking() { // Found by fuzz/secret_key_parse: 27 bytes whose 26th..27th byte is a Cyrillic 'е'. assert!(SecretKey::parse("K1-10Y5-00Y0-000000p00-000000p0\u{435}-").is_err()); assert!(SecretKey::parse("K1-000000-00000-00000-00000-0000\u{e9}-0").is_err()); } #[test] fn roundtrip() { for _ in 0..200 { let k = SecretKey::generate(); let s = k.to_display(); assert_eq!(s.len(), "K1-XXXXXX-XXXXX-XXXXX-XXXXX-XXXXX-X".len()); assert_eq!(SecretKey::parse(&s).unwrap().expose(), k.expose()); assert_eq!(SecretKey::parse(&s.to_lowercase()).unwrap().expose(), k.expose()); } } #[test] fn extremes_roundtrip() { for b in [[0u8; 16], [0xff; 16], [0x80; 16]] { let k = SecretKey::from_bytes(b); assert_eq!(SecretKey::parse(&k.to_display()).unwrap().expose(), &b); } // First symbol 8 would need bit 128: reject. let max = SecretKey::from_bytes([0xff; 16]).to_display().to_string(); assert!(max.starts_with("K1-7")); assert!(SecretKey::parse(&max.replacen("K1-7", "K1-8", 1)).is_err()); } #[test] fn detects_typo() { let k = SecretKey::from_bytes([0xAB; 16]); let mut s = k.to_display().to_string(); let pos = 5; let orig = s.as_bytes()[pos]; let replacement = if orig == b'2' { "3" } else { "2" }; s.replace_range(pos..pos + 1, replacement); // A typo is either rejected (31/32 of the time) or yields a different key, never the same one. assert!(SecretKey::parse(&s).is_err() || SecretKey::parse(&s).unwrap().expose() != k.expose()); } }