Initial scaffold for kestrove-core
Co-Authored-By: Claude Opus 5.5 <[email protected]>
This commit is contained in:
@@ -0,0 +1,123 @@
|
||||
//! 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)?;
|
||||
if 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;
|
||||
if i == 0 && v > 0b11 {
|
||||
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 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 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());
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user