Initial scaffold for kestrove-core
Co-Authored-By: Claude Opus 5.5 <[email protected]>
This commit is contained in:
Vendored
BIN
Binary file not shown.
@@ -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
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
@@ -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[..]);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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, ¶ms.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());
|
||||
}
|
||||
}
|
||||
@@ -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());
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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());
|
||||
}
|
||||
}
|
||||
@@ -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