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
BIN
View File
Binary file not shown.
+21
View File
@@ -0,0 +1,21 @@
[package]
name = "kv-crypto"
description = "Kestrove cryptography: 2SKD, AEAD envelopes, HPKE key sealing, keysets."
version.workspace = true
edition.workspace = true
license.workspace = true
publish.workspace = true
[dependencies]
kv-proto.workspace = true
uuid.workspace = true
thiserror.workspace = true
rand_core.workspace = true
zeroize.workspace = true
argon2.workspace = true
hkdf.workspace = true
sha2.workspace = true
chacha20poly1305.workspace = true
hpke.workspace = true
ed25519-dalek.workspace = true
unicode-normalization.workspace = true
+41
View File
@@ -0,0 +1,41 @@
//! Associated data that binds a ciphertext to its location (docs/02 "Envelope format").
use uuid::Uuid;
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum ObjectType {
Keyset,
VaultKey,
VaultMeta,
ItemKey,
Item,
}
impl ObjectType {
fn tag(self) -> &'static str {
match self {
ObjectType::Keyset => "keyset",
ObjectType::VaultKey => "vault-key",
ObjectType::VaultMeta => "vault-meta",
ObjectType::ItemKey => "item-key",
ObjectType::Item => "item",
}
}
}
pub struct Aad;
impl Aad {
/// `"kestrove/v1/" || type || 0x00 || account || 0x00 || vault || 0x00 || item`.
/// Absent ids are encoded as the nil UUID so the layout stays fixed.
pub fn build(ty: ObjectType, account: Uuid, vault: Option<Uuid>, item: Option<Uuid>) -> Vec<u8> {
let mut out = Vec::with_capacity(12 + 10 + 3 + 48);
out.extend_from_slice(b"kestrove/v1/");
out.extend_from_slice(ty.tag().as_bytes());
for id in [account, vault.unwrap_or(Uuid::nil()), item.unwrap_or(Uuid::nil())] {
out.push(0);
out.extend_from_slice(id.as_bytes());
}
out
}
}
+119
View File
@@ -0,0 +1,119 @@
//! XChaCha20-Poly1305 envelopes and item padding.
use chacha20poly1305::{
aead::{Aead, KeyInit, Payload},
Key, XChaCha20Poly1305, XNonce,
};
use kv_proto::{Alg, Envelope, FORMAT_VERSION};
use uuid::Uuid;
use zeroize::Zeroizing;
use crate::{random_bytes, CryptoError, Result};
/// 256-bit symmetric key with an id (the envelope `kid`).
pub struct SymKey {
pub id: Uuid,
bytes: Zeroizing<[u8; 32]>,
}
impl SymKey {
pub fn generate() -> Self {
Self { id: Uuid::new_v4(), bytes: Zeroizing::new(random_bytes()) }
}
pub fn from_bytes(id: Uuid, bytes: [u8; 32]) -> Self {
Self { id, bytes: Zeroizing::new(bytes) }
}
pub fn expose(&self) -> &[u8; 32] {
&self.bytes
}
pub fn seal(&self, plaintext: &[u8], aad: &[u8]) -> Result<Envelope> {
let cipher = XChaCha20Poly1305::new(Key::from_slice(&self.bytes[..]));
let nonce: [u8; 24] = random_bytes();
let ct = cipher
.encrypt(XNonce::from_slice(&nonce), Payload { msg: plaintext, aad })
.map_err(|_| CryptoError::Encrypt)?;
Ok(Envelope {
v: FORMAT_VERSION,
alg: Alg::XChaCha20Poly1305,
kid: self.id,
n: nonce.to_vec(),
ct,
enc: vec![],
})
}
pub fn open(&self, env: &Envelope, aad: &[u8]) -> Result<Zeroizing<Vec<u8>>> {
if env.v != FORMAT_VERSION || env.alg != Alg::XChaCha20Poly1305 || env.n.len() != 24 {
return Err(CryptoError::Unsupported);
}
let cipher = XChaCha20Poly1305::new(Key::from_slice(&self.bytes[..]));
cipher
.decrypt(XNonce::from_slice(&env.n), Payload { msg: &env.ct, aad })
.map(Zeroizing::new)
.map_err(|_| CryptoError::Decrypt)
}
}
const PAD_BLOCK: usize = 256;
/// `u32-le len || data || zeros` padded to a multiple of 256 bytes.
pub fn pad(data: &[u8]) -> Zeroizing<Vec<u8>> {
let len = data.len() + 4;
let total = len.div_ceil(PAD_BLOCK) * PAD_BLOCK;
let mut out = Zeroizing::new(Vec::with_capacity(total));
out.extend_from_slice(&(data.len() as u32).to_le_bytes());
out.extend_from_slice(data);
out.resize(total, 0);
out
}
pub fn unpad(padded: &[u8]) -> Result<Zeroizing<Vec<u8>>> {
if padded.len() < 4 {
return Err(CryptoError::Malformed);
}
let len = u32::from_le_bytes(padded[..4].try_into().unwrap()) as usize;
padded
.get(4..4 + len)
.map(|d| Zeroizing::new(d.to_vec()))
.ok_or(CryptoError::Malformed)
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn roundtrip() {
let k = SymKey::generate();
let env = k.seal(b"hello", b"aad").unwrap();
assert_eq!(&**k.open(&env, b"aad").unwrap(), b"hello");
}
#[test]
fn wrong_aad_fails() {
let k = SymKey::generate();
let env = k.seal(b"hello", b"aad-1").unwrap();
assert_eq!(k.open(&env, b"aad-2").unwrap_err(), CryptoError::Decrypt);
}
#[test]
fn tamper_fails() {
let k = SymKey::generate();
let mut env = k.seal(b"hello", b"").unwrap();
env.ct[0] ^= 1;
assert_eq!(k.open(&env, b"").unwrap_err(), CryptoError::Decrypt);
}
#[test]
fn padding() {
for n in [0usize, 1, 251, 252, 253, 1000] {
let data = vec![7u8; n];
let p = pad(&data);
assert_eq!(p.len() % PAD_BLOCK, 0);
assert_eq!(&**unpad(&p).unwrap(), &data[..]);
}
}
}
+141
View File
@@ -0,0 +1,141 @@
//! Two-secret key derivation (docs/02 "Derivation").
use argon2::{Algorithm, Argon2, Params, Version};
use hkdf::Hkdf;
use kv_proto::{KdfParams, MIN_KDF_M_KIB, MIN_KDF_T};
use sha2::Sha256;
use unicode_normalization::UnicodeNormalization;
use uuid::Uuid;
use zeroize::Zeroizing;
use crate::{random_bytes, secret_key::SecretKey, CryptoError, Result};
/// Keys derived from master password + Secret Key.
pub struct DerivedKeys {
/// Account Unlock Key: wraps the account keyset.
pub auk: Zeroizing<[u8; 32]>,
/// Fed to OPAQUE as the "password". Independent of `auk`.
pub auth_input: Zeroizing<[u8; 32]>,
}
/// Default parameters for new accounts: RFC 9106 second recommended option.
pub fn default_params() -> KdfParams {
KdfParams {
alg: "argon2id".into(),
m_kib: 64 * 1024,
t: 3,
p: 4,
salt: random_bytes::<16>().to_vec(),
}
}
/// Rejects parameters a malicious server might use to downgrade the KDF.
pub fn validate(params: &KdfParams) -> Result<()> {
if params.alg != "argon2id" || params.m_kib < MIN_KDF_M_KIB || params.t < MIN_KDF_T || params.p == 0 || params.salt.len() < 16 {
return Err(CryptoError::WeakKdfParams);
}
Ok(())
}
pub fn derive_keys(password: &str, secret_key: &SecretKey, account_id: Uuid, params: &KdfParams) -> Result<DerivedKeys> {
validate(params)?;
derive_keys_unchecked(password, secret_key, account_id, params)
}
/// Same as [`derive_keys`] without the minimum-parameter check. Only for tests and vectors.
pub(crate) fn derive_keys_unchecked(
password: &str,
secret_key: &SecretKey,
account_id: Uuid,
params: &KdfParams,
) -> Result<DerivedKeys> {
let normalized = Zeroizing::new(password.trim().nfkd().collect::<String>());
let mut argon_salt = [0u8; 16];
let mut info = b"kestrove/v1/argon-salt".to_vec();
info.extend_from_slice(account_id.as_bytes());
Hkdf::<Sha256>::new(None, &params.salt)
.expand(&info, &mut argon_salt)
.map_err(|_| CryptoError::Kdf)?;
let argon = Argon2::new(
Algorithm::Argon2id,
Version::V0x13,
Params::new(params.m_kib, params.t, params.p, Some(32)).map_err(|_| CryptoError::Kdf)?,
);
let mut pw_stretched = Zeroizing::new([0u8; 32]);
argon
.hash_password_into(normalized.as_bytes(), &argon_salt, pw_stretched.as_mut())
.map_err(|_| CryptoError::Kdf)?;
let mut sk_material = Zeroizing::new([0u8; 32]);
Hkdf::<Sha256>::new(Some(account_id.as_bytes()), secret_key.expose())
.expand(b"kestrove/v1/secret-key", sk_material.as_mut())
.map_err(|_| CryptoError::Kdf)?;
let mut ikm = Zeroizing::new([0u8; 64]);
ikm[..32].copy_from_slice(pw_stretched.as_ref());
ikm[32..].copy_from_slice(sk_material.as_ref());
let mut root = Zeroizing::new([0u8; 32]);
Hkdf::<Sha256>::new(Some(account_id.as_bytes()), ikm.as_ref())
.expand(b"kestrove/v1/root", root.as_mut())
.map_err(|_| CryptoError::Kdf)?;
let hk = Hkdf::<Sha256>::from_prk(root.as_ref()).map_err(|_| CryptoError::Kdf)?;
let mut auk = Zeroizing::new([0u8; 32]);
let mut auth_input = Zeroizing::new([0u8; 32]);
hk.expand(b"kestrove/v1/auk", auk.as_mut()).map_err(|_| CryptoError::Kdf)?;
hk.expand(b"kestrove/v1/opaque-pwd", auth_input.as_mut()).map_err(|_| CryptoError::Kdf)?;
Ok(DerivedKeys { auk, auth_input })
}
#[cfg(test)]
pub(crate) fn test_params() -> KdfParams {
KdfParams { alg: "argon2id".into(), m_kib: 256, t: 1, p: 1, salt: vec![9u8; 16] }
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn deterministic_and_separated() {
let sk = SecretKey::from_bytes([1u8; 16]);
let id = Uuid::nil();
let a = derive_keys_unchecked("correct horse", &sk, id, &test_params()).unwrap();
let b = derive_keys_unchecked("correct horse", &sk, id, &test_params()).unwrap();
assert_eq!(*a.auk, *b.auk);
assert_ne!(*a.auk, *a.auth_input);
}
#[test]
fn both_secrets_matter() {
let id = Uuid::nil();
let base = derive_keys_unchecked("pw", &SecretKey::from_bytes([1u8; 16]), id, &test_params()).unwrap();
let other_sk = derive_keys_unchecked("pw", &SecretKey::from_bytes([2u8; 16]), id, &test_params()).unwrap();
let other_pw = derive_keys_unchecked("pw2", &SecretKey::from_bytes([1u8; 16]), id, &test_params()).unwrap();
assert_ne!(*base.auk, *other_sk.auk);
assert_ne!(*base.auk, *other_pw.auk);
}
#[test]
fn unicode_normalization() {
let sk = SecretKey::from_bytes([1u8; 16]);
// "é" precomposed vs. e + combining acute.
let a = derive_keys_unchecked("caf\u{e9}", &sk, Uuid::nil(), &test_params()).unwrap();
let b = derive_keys_unchecked("cafe\u{301}", &sk, Uuid::nil(), &test_params()).unwrap();
assert_eq!(*a.auk, *b.auk);
}
#[test]
fn rejects_weak_params() {
let sk = SecretKey::from_bytes([1u8; 16]);
assert_eq!(
derive_keys("pw", &sk, Uuid::nil(), &test_params()).err(),
Some(CryptoError::WeakKdfParams)
);
assert!(validate(&default_params()).is_ok());
}
}
+173
View File
@@ -0,0 +1,173 @@
//! Key hierarchy (docs/02 "Key hierarchy"):
//! AUK → account keyset → (HPKE) vault key → item key → item payload.
use ed25519_dalek::{Signature, Signer, SigningKey, Verifier, VerifyingKey};
use kv_proto::Envelope;
use rand_core::OsRng;
use uuid::Uuid;
use zeroize::Zeroizing;
use crate::{
aad::{Aad, ObjectType},
aead::{pad, unpad, SymKey},
seal::{seal_to, EncKeypair},
CryptoError, Result,
};
/// The account's long-term keys. Only ever stored wrapped by the AUK (or a recovery code / LUK).
pub struct AccountKeyset {
pub account_id: Uuid,
pub enc: EncKeypair,
pub sig: SigningKey,
}
impl AccountKeyset {
pub fn generate(account_id: Uuid) -> Self {
Self { account_id, enc: EncKeypair::generate(), sig: SigningKey::generate(&mut OsRng) }
}
/// Layout: enc key id (16) || X25519 sk (32) || Ed25519 seed (32).
fn to_bytes(&self) -> Zeroizing<Vec<u8>> {
let mut out = Zeroizing::new(Vec::with_capacity(80));
out.extend_from_slice(self.enc.id.as_bytes());
out.extend_from_slice(&self.enc.secret_bytes());
out.extend_from_slice(self.sig.as_bytes());
out
}
fn from_bytes(account_id: Uuid, b: &[u8]) -> Result<Self> {
if b.len() != 80 {
return Err(CryptoError::Malformed);
}
let enc_id = Uuid::from_slice(&b[..16]).map_err(|_| CryptoError::Malformed)?;
let enc = EncKeypair::from_secret_bytes(enc_id, &b[16..48])?;
let seed: [u8; 32] = b[48..80].try_into().map_err(|_| CryptoError::Malformed)?;
Ok(Self { account_id, enc, sig: SigningKey::from_bytes(&seed) })
}
/// Wraps with a 32-byte key (AUK, local unlock key or recovery key).
pub fn wrap(&self, wrapping_key: &SymKey) -> Result<Envelope> {
wrapping_key.seal(&self.to_bytes(), &Aad::build(ObjectType::Keyset, self.account_id, None, None))
}
pub fn unwrap(account_id: Uuid, wrapping_key: &SymKey, env: &Envelope) -> Result<Self> {
let raw = wrapping_key.open(env, &Aad::build(ObjectType::Keyset, account_id, None, None))?;
Self::from_bytes(account_id, &raw)
}
pub fn sig_public(&self) -> [u8; 32] {
self.sig.verifying_key().to_bytes()
}
pub fn sign(&self, msg: &[u8]) -> [u8; 64] {
self.sig.sign(msg).to_bytes()
}
/// Creates a fresh vault key and seals it to this account.
pub fn new_vault_key(&self, vault_id: Uuid) -> Result<(SymKey, Envelope)> {
let vk = SymKey::generate();
let sealed = self.seal_vault_key(vault_id, &vk)?;
Ok((vk, sealed))
}
pub fn seal_vault_key(&self, vault_id: Uuid, vk: &SymKey) -> Result<Envelope> {
let mut pt = Zeroizing::new(Vec::with_capacity(48));
pt.extend_from_slice(vk.id.as_bytes());
pt.extend_from_slice(vk.expose());
seal_to(self.enc.id, &self.enc.public_bytes(), &pt, &Aad::build(ObjectType::VaultKey, self.account_id, Some(vault_id), None))
}
pub fn open_vault_key(&self, vault_id: Uuid, env: &Envelope) -> Result<SymKey> {
let pt = self.enc.open(env, &Aad::build(ObjectType::VaultKey, self.account_id, Some(vault_id), None))?;
if pt.len() != 48 {
return Err(CryptoError::Malformed);
}
let id = Uuid::from_slice(&pt[..16]).map_err(|_| CryptoError::Malformed)?;
let key: [u8; 32] = pt[16..].try_into().map_err(|_| CryptoError::Malformed)?;
Ok(SymKey::from_bytes(id, key))
}
}
pub fn verify_signature(public: &[u8; 32], msg: &[u8], sig: &[u8; 64]) -> Result<()> {
let vk = VerifyingKey::from_bytes(public).map_err(|_| CryptoError::Malformed)?;
vk.verify(msg, &Signature::from_bytes(sig)).map_err(|_| CryptoError::Decrypt)
}
/// An encrypted item as stored locally and on the server.
pub struct SealedItem {
pub wrapped_item_key: Envelope,
pub payload: Envelope,
}
/// Encrypts an item's plaintext (already serialized) with a fresh per-item key.
pub fn seal_item(account_id: Uuid, vault_id: Uuid, item_id: Uuid, vault_key: &SymKey, plaintext: &[u8]) -> Result<SealedItem> {
let item_key = SymKey::generate();
let mut key_pt = Zeroizing::new(Vec::with_capacity(48));
key_pt.extend_from_slice(item_key.id.as_bytes());
key_pt.extend_from_slice(item_key.expose());
let wrapped_item_key =
vault_key.seal(&key_pt, &Aad::build(ObjectType::ItemKey, account_id, Some(vault_id), Some(item_id)))?;
let payload = item_key.seal(&pad(plaintext), &Aad::build(ObjectType::Item, account_id, Some(vault_id), Some(item_id)))?;
Ok(SealedItem { wrapped_item_key, payload })
}
pub fn open_item(account_id: Uuid, vault_id: Uuid, item_id: Uuid, vault_key: &SymKey, sealed: &SealedItem) -> Result<Zeroizing<Vec<u8>>> {
let key_pt =
vault_key.open(&sealed.wrapped_item_key, &Aad::build(ObjectType::ItemKey, account_id, Some(vault_id), Some(item_id)))?;
if key_pt.len() != 48 {
return Err(CryptoError::Malformed);
}
let id = Uuid::from_slice(&key_pt[..16]).map_err(|_| CryptoError::Malformed)?;
let item_key = SymKey::from_bytes(id, key_pt[16..].try_into().map_err(|_| CryptoError::Malformed)?);
let padded = item_key.open(&sealed.payload, &Aad::build(ObjectType::Item, account_id, Some(vault_id), Some(item_id)))?;
unpad(&padded)
}
#[cfg(test)]
mod tests {
use super::*;
use crate::{kdf, secret_key::SecretKey};
#[test]
fn full_hierarchy() {
let account = Uuid::new_v4();
let sk = SecretKey::generate();
let derived = kdf::derive_keys_unchecked("hunter2", &sk, account, &kdf::test_params()).unwrap();
let auk = SymKey::from_bytes(Uuid::nil(), *derived.auk);
let keyset = AccountKeyset::generate(account);
let wrapped = keyset.wrap(&auk).unwrap();
let keyset = AccountKeyset::unwrap(account, &auk, &wrapped).unwrap();
let vault = Uuid::new_v4();
let (_, sealed_vk) = keyset.new_vault_key(vault).unwrap();
let vk = keyset.open_vault_key(vault, &sealed_vk).unwrap();
let item = Uuid::new_v4();
let sealed = seal_item(account, vault, item, &vk, br#"{"title":"bank"}"#).unwrap();
assert_eq!(&**open_item(account, vault, item, &vk, &sealed).unwrap(), br#"{"title":"bank"}"#);
// The server swaps the ciphertext into another item id, which is detected.
assert!(open_item(account, vault, Uuid::new_v4(), &vk, &sealed).is_err());
// A vault key sealed for one vault cannot be opened as another vault's key.
assert!(keyset.open_vault_key(Uuid::new_v4(), &sealed_vk).is_err());
}
#[test]
fn wrong_password_cannot_unwrap() {
let account = Uuid::new_v4();
let sk = SecretKey::generate();
let good = kdf::derive_keys_unchecked("right", &sk, account, &kdf::test_params()).unwrap();
let bad = kdf::derive_keys_unchecked("wrong", &sk, account, &kdf::test_params()).unwrap();
let wrapped = AccountKeyset::generate(account).wrap(&SymKey::from_bytes(Uuid::nil(), *good.auk)).unwrap();
assert!(AccountKeyset::unwrap(account, &SymKey::from_bytes(Uuid::nil(), *bad.auk), &wrapped).is_err());
}
#[test]
fn signatures() {
let ks = AccountKeyset::generate(Uuid::new_v4());
let sig = ks.sign(b"manifest");
assert!(verify_signature(&ks.sig_public(), b"manifest", &sig).is_ok());
assert!(verify_signature(&ks.sig_public(), b"manifesT", &sig).is_err());
}
}
+43
View File
@@ -0,0 +1,43 @@
//! Kestrove cryptography (kestrove-spec/docs/02-cryptography.md, format_version 1).
//!
//! Rules for this crate:
//! * Only audited RustCrypto / dalek / hpke primitives. No custom constructions.
//! * Every secret is held in a zeroizing type.
//! * Every AEAD call binds its location via [`aad::Aad`].
pub mod aad;
pub mod aead;
pub mod kdf;
pub mod keys;
pub mod seal;
pub mod secret_key;
use thiserror::Error;
#[derive(Debug, Error, PartialEq, Eq)]
pub enum CryptoError {
#[error("KDF parameters below the allowed minimum")]
WeakKdfParams,
#[error("KDF failure")]
Kdf,
#[error("decryption failed (wrong key or tampered data)")]
Decrypt,
#[error("encryption failed")]
Encrypt,
#[error("unsupported envelope version or algorithm")]
Unsupported,
#[error("malformed key or data")]
Malformed,
#[error("invalid Secret Key format or checksum")]
BadSecretKey,
}
pub type Result<T> = std::result::Result<T, CryptoError>;
/// Fills a fixed-size array from the OS CSPRNG.
pub fn random_bytes<const N: usize>() -> [u8; N] {
use rand_core::{OsRng, RngCore};
let mut b = [0u8; N];
OsRng.fill_bytes(&mut b);
b
}
+89
View File
@@ -0,0 +1,89 @@
//! HPKE (RFC 9180) sealing of small secrets (vault keys) to an account's X25519 public key.
use hpke::{
aead::ChaCha20Poly1305, kdf::HkdfSha256, kem::X25519HkdfSha256, Deserializable, Kem as KemTrait, OpModeR,
OpModeS, Serializable,
};
use kv_proto::{Alg, Envelope, FORMAT_VERSION};
use rand_core::OsRng;
use uuid::Uuid;
use zeroize::Zeroizing;
use crate::{CryptoError, Result};
type Kem = X25519HkdfSha256;
const INFO: &[u8] = b"kestrove/v1/hpke";
pub struct EncKeypair {
pub id: Uuid,
sk: <Kem as KemTrait>::PrivateKey,
pub pk: <Kem as KemTrait>::PublicKey,
}
impl EncKeypair {
pub fn generate() -> Self {
let (sk, pk) = Kem::gen_keypair(&mut OsRng);
Self { id: Uuid::new_v4(), sk, pk }
}
pub fn from_secret_bytes(id: Uuid, sk: &[u8]) -> Result<Self> {
let sk = <Kem as KemTrait>::PrivateKey::from_bytes(sk).map_err(|_| CryptoError::Malformed)?;
let pk = Kem::sk_to_pk(&sk);
Ok(Self { id, sk, pk })
}
pub fn secret_bytes(&self) -> Zeroizing<Vec<u8>> {
Zeroizing::new(self.sk.to_bytes().to_vec())
}
pub fn public_bytes(&self) -> Vec<u8> {
self.pk.to_bytes().to_vec()
}
pub fn open(&self, env: &Envelope, aad: &[u8]) -> Result<Zeroizing<Vec<u8>>> {
if env.v != FORMAT_VERSION || env.alg != Alg::HpkeX25519ChaCha {
return Err(CryptoError::Unsupported);
}
let encapped = <Kem as KemTrait>::EncappedKey::from_bytes(&env.enc).map_err(|_| CryptoError::Malformed)?;
hpke::single_shot_open::<ChaCha20Poly1305, HkdfSha256, Kem>(&OpModeR::Base, &self.sk, &encapped, INFO, &env.ct, aad)
.map(Zeroizing::new)
.map_err(|_| CryptoError::Decrypt)
}
}
/// Seals `plaintext` to a recipient public key (raw 32-byte X25519).
pub fn seal_to(recipient_id: Uuid, recipient_pk: &[u8], plaintext: &[u8], aad: &[u8]) -> Result<Envelope> {
let pk = <Kem as KemTrait>::PublicKey::from_bytes(recipient_pk).map_err(|_| CryptoError::Malformed)?;
let (encapped, ct) =
hpke::single_shot_seal::<ChaCha20Poly1305, HkdfSha256, Kem, _>(&OpModeS::Base, &pk, INFO, plaintext, aad, &mut OsRng)
.map_err(|_| CryptoError::Encrypt)?;
Ok(Envelope {
v: FORMAT_VERSION,
alg: Alg::HpkeX25519ChaCha,
kid: recipient_id,
n: vec![],
ct,
enc: encapped.to_bytes().to_vec(),
})
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn seal_open() {
let kp = EncKeypair::generate();
let env = seal_to(kp.id, &kp.public_bytes(), b"vault key", b"ctx").unwrap();
assert_eq!(&**kp.open(&env, b"ctx").unwrap(), b"vault key");
assert!(kp.open(&env, b"other").is_err());
assert!(EncKeypair::generate().open(&env, b"ctx").is_err());
}
#[test]
fn secret_roundtrip() {
let kp = EncKeypair::generate();
let kp2 = EncKeypair::from_secret_bytes(kp.id, &kp.secret_bytes()).unwrap();
assert_eq!(kp.public_bytes(), kp2.public_bytes());
}
}
+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());
}
}