parse() checked the cleaned key's byte length and then split it at byte 26, which panics when a multibyte character straddles that index (found by fuzz/secret_key_parse). Under panic = "abort" this would crash the app on a mistyped or pasted Secret Key. Co-Authored-By: Claude Opus 5.5 <[email protected]>
142 lines
4.7 KiB
Rust
142 lines
4.7 KiB
Rust
//! 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<String> {
|
||
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<Self> {
|
||
let cleaned: Zeroizing<String> = 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<usize> {
|
||
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());
|
||
}
|
||
}
|