Files
kestrove-core/crates/kv-crypto/src/secret_key.rs
T
samiandClaude Opus 5.5 cee40af622 kv-crypto: SecretKey::parse rejects non-ASCII input instead of panicking
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]>
2026-10-17 00:31:16 +09:00

142 lines
4.7 KiB
Rust
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
//! 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());
}
}