Initial scaffold for kestrove-core

Co-Authored-By: Claude Opus 5.5 <[email protected]>
This commit is contained in:
2026-10-15 18:30:14 +09:00
co-authored by Claude Opus 5.5
commit 462fbbc523
34 changed files with 2063 additions and 0 deletions
+123
View File
@@ -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());
}
}