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.
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());
}
}
+25
View File
@@ -0,0 +1,25 @@
[package]
name = "kv-ffi"
description = "UniFFI bindings: the single API surface used by Swift, Kotlin and the Tauri app."
version.workspace = true
edition.workspace = true
license.workspace = true
publish.workspace = true
[lib]
crate-type = ["lib", "staticlib", "cdylib"]
name = "kv_ffi"
[[bin]]
name = "uniffi-bindgen"
path = "src/bin/uniffi-bindgen.rs"
[dependencies]
kv-proto.workspace = true
kv-crypto.workspace = true
kv-model.workspace = true
uuid.workspace = true
thiserror.workspace = true
serde_json.workspace = true
zeroize.workspace = true
uniffi = { workspace = true, features = ["cli"] }
+3
View File
@@ -0,0 +1,3 @@
fn main() {
uniffi::uniffi_bindgen_main()
}
+399
View File
@@ -0,0 +1,399 @@
//! The FFI contract between the core and every platform app (kestrove-spec/docs/07 "Work split").
//! Changing a public item here needs a spec PR.
//!
//! M0 ships an **in-memory** vault so UI work on Apple, Android and desktop can start
//! now. M1 swaps the storage for `kv-store` + `kv-sync` behind the same API.
use std::{
collections::HashMap,
sync::{Arc, Mutex},
time::{SystemTime, UNIX_EPOCH},
};
use kv_crypto::{
aead::SymKey,
kdf,
keys::{open_item, seal_item, AccountKeyset, SealedItem},
secret_key::SecretKey,
CryptoError,
};
use kv_model::{generator, matching, totp::Totp, Item, ItemKind};
use kv_proto::{Envelope, KdfParams};
use uuid::Uuid;
uniffi::setup_scaffolding!();
#[derive(Debug, thiserror::Error, uniffi::Error)]
pub enum CoreError {
#[error("vault is locked")]
Locked,
#[error("no account on this device")]
NoAccount,
#[error("wrong master password or Secret Key")]
BadCredentials,
#[error("invalid input: {msg}")]
Invalid { msg: String },
#[error("not found")]
NotFound,
#[error("internal error: {msg}")]
Internal { msg: String },
}
impl From<CryptoError> for CoreError {
fn from(e: CryptoError) -> Self {
match e {
CryptoError::Decrypt => CoreError::BadCredentials,
CryptoError::BadSecretKey => CoreError::Invalid { msg: "Secret Key".into() },
other => CoreError::Internal { msg: other.to_string() },
}
}
}
type Result<T> = std::result::Result<T, CoreError>;
#[derive(Debug, Clone, Copy, PartialEq, Eq, uniffi::Enum)]
pub enum ItemKindDto {
Login,
SecureNote,
Card,
Identity,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, uniffi::Enum)]
pub enum MatchLevelDto {
None,
Domain,
Host,
Exact,
}
#[derive(Debug, Clone, uniffi::Record)]
pub struct ItemSummary {
pub id: String,
pub kind: ItemKindDto,
pub title: String,
pub username: Option<String>,
pub urls: Vec<String>,
pub favorite: bool,
pub has_totp: bool,
}
#[derive(Debug, Clone, uniffi::Record)]
pub struct ItemDto {
/// Empty string means "new item".
pub id: String,
pub kind: ItemKindDto,
pub title: String,
pub username: Option<String>,
pub password: Option<String>,
pub urls: Vec<String>,
pub totp: Option<String>,
pub notes: Option<String>,
pub favorite: bool,
pub tags: Vec<String>,
pub modified_at: i64,
}
#[derive(Debug, Clone, uniffi::Record)]
pub struct TotpCode {
pub code: String,
pub seconds_remaining: u64,
pub period: u64,
}
struct Unlocked {
keyset: AccountKeyset,
vault_key: SymKey,
}
struct State {
account_id: Uuid,
kdf: KdfParams,
wrapped_keyset: Envelope,
vault_id: Uuid,
sealed_vault_key: Envelope,
items: HashMap<Uuid, SealedItem>,
unlocked: Option<Unlocked>,
}
/// One account's vault on this device.
#[derive(uniffi::Object)]
pub struct VaultCore {
state: Mutex<Option<State>>,
}
#[uniffi::export]
impl VaultCore {
#[uniffi::constructor]
pub fn new_in_memory() -> Arc<Self> {
Arc::new(Self { state: Mutex::new(None) })
}
/// Creates the account and its first vault, leaving it unlocked.
/// Returns the Secret Key display string for the Emergency Kit.
pub fn create_account(&self, master_password: String) -> Result<String> {
if master_password.chars().count() < 10 {
return Err(CoreError::Invalid { msg: "master password must be at least 10 characters".into() });
}
let account_id = Uuid::new_v4();
let secret_key = SecretKey::generate();
let params = kdf::default_params();
let derived = kdf::derive_keys(&master_password, &secret_key, account_id, &params)?;
let auk = SymKey::from_bytes(Uuid::nil(), *derived.auk);
let keyset = AccountKeyset::generate(account_id);
let wrapped_keyset = keyset.wrap(&auk)?;
let vault_id = Uuid::new_v4();
let (vault_key, sealed_vault_key) = keyset.new_vault_key(vault_id)?;
*self.lock_state() = Some(State {
account_id,
kdf: params,
wrapped_keyset,
vault_id,
sealed_vault_key,
items: HashMap::new(),
unlocked: Some(Unlocked { keyset, vault_key }),
});
Ok(secret_key.to_display().to_string())
}
pub fn unlock(&self, master_password: String, secret_key: String) -> Result<()> {
let mut guard = self.lock_state();
let st = guard.as_mut().ok_or(CoreError::NoAccount)?;
let sk = SecretKey::parse(&secret_key)?;
let derived = kdf::derive_keys(&master_password, &sk, st.account_id, &st.kdf)?;
let auk = SymKey::from_bytes(Uuid::nil(), *derived.auk);
let keyset = AccountKeyset::unwrap(st.account_id, &auk, &st.wrapped_keyset)?;
let vault_key = keyset.open_vault_key(st.vault_id, &st.sealed_vault_key)?;
st.unlocked = Some(Unlocked { keyset, vault_key });
Ok(())
}
/// Drops all decrypted key material (zeroized on drop).
pub fn lock(&self) {
if let Some(st) = self.lock_state().as_mut() {
st.unlocked = None;
}
}
pub fn is_locked(&self) -> bool {
self.lock_state().as_ref().is_none_or(|s| s.unlocked.is_none())
}
pub fn has_account(&self) -> bool {
self.lock_state().is_some()
}
pub fn list_items(&self) -> Result<Vec<ItemSummary>> {
self.with_unlocked(|st, u| {
let mut out = st
.items
.iter()
.map(|(id, sealed)| decrypt(st, u, *id, sealed).map(|i| summary(&i)))
.collect::<Result<Vec<_>>>()?;
out.sort_by(|a, b| b.favorite.cmp(&a.favorite).then_with(|| a.title.to_lowercase().cmp(&b.title.to_lowercase())));
Ok(out)
})
}
pub fn get_item(&self, id: String) -> Result<ItemDto> {
let id = parse_id(&id)?;
self.with_unlocked(|st, u| {
let sealed = st.items.get(&id).ok_or(CoreError::NotFound)?;
Ok(to_dto(&decrypt(st, u, id, sealed)?))
})
}
/// Inserts or updates an item. Returns its id.
pub fn save_item(&self, item: ItemDto) -> Result<String> {
let mut guard = self.lock_state();
let st = guard.as_mut().ok_or(CoreError::NoAccount)?;
let u = st.unlocked.as_ref().ok_or(CoreError::Locked)?;
let now = now();
let id = if item.id.is_empty() { Uuid::new_v4() } else { parse_id(&item.id)? };
let created_at = match st.items.get(&id) {
Some(sealed) => decrypt(st, u, id, sealed)?.created_at,
None => now,
};
let mut model = Item::new_login(item.title.clone(), created_at);
model.id = id;
model.kind = kind_from_dto(item.kind);
model.username = item.username;
model.password = item.password;
model.urls = item.urls;
model.totp = item.totp;
model.notes = item.notes;
model.favorite = item.favorite;
model.tags = item.tags;
model.modified_at = now;
let json = zeroize::Zeroizing::new(serde_json::to_vec(&model).map_err(|e| CoreError::Internal { msg: e.to_string() })?);
let sealed = seal_item(st.account_id, st.vault_id, id, &u.vault_key, &json)?;
st.items.insert(id, sealed);
Ok(id.to_string())
}
pub fn delete_item(&self, id: String) -> Result<()> {
let id = parse_id(&id)?;
let mut guard = self.lock_state();
let st = guard.as_mut().ok_or(CoreError::NoAccount)?;
st.unlocked.as_ref().ok_or(CoreError::Locked)?;
st.items.remove(&id).map(|_| ()).ok_or(CoreError::NotFound)
}
/// Autofill candidates for a page URL, best match first.
pub fn items_for_url(&self, page_url: String) -> Result<Vec<ItemSummary>> {
self.with_unlocked(|st, u| {
let mut scored = Vec::new();
for (id, sealed) in &st.items {
let item = decrypt(st, u, *id, sealed)?;
let best = item.urls.iter().map(|url| matching::match_url(url, &page_url)).max();
if let Some(level) = best.filter(|l| *l > matching::MatchLevel::None) {
scored.push((level, summary(&item)));
}
}
scored.sort_by(|a, b| b.0.cmp(&a.0).then_with(|| a.1.title.cmp(&b.1.title)));
Ok(scored.into_iter().map(|(_, s)| s).collect())
})
}
/// Ed25519 public key of the account (hex), shown when pairing devices.
pub fn account_fingerprint(&self) -> Result<String> {
self.with_unlocked(|_, u| Ok(u.keyset.sig_public().iter().map(|b| format!("{b:02x}")).collect()))
}
}
impl VaultCore {
fn lock_state(&self) -> std::sync::MutexGuard<'_, Option<State>> {
// A poisoned lock means a panic mid-operation; with panic=abort in release this can't happen.
self.state.lock().unwrap_or_else(|p| p.into_inner())
}
fn with_unlocked<T>(&self, f: impl FnOnce(&State, &Unlocked) -> Result<T>) -> Result<T> {
let guard = self.lock_state();
let st = guard.as_ref().ok_or(CoreError::NoAccount)?;
let u = st.unlocked.as_ref().ok_or(CoreError::Locked)?;
f(st, u)
}
}
#[uniffi::export]
pub fn generate_password(length: u32, symbols: bool) -> String {
generator::generate_password(&generator::PasswordOptions { length: length as usize, symbols, ..Default::default() })
}
#[uniffi::export]
pub fn totp_code(secret_or_uri: String, unix_time: u64) -> Result<TotpCode> {
let t = Totp::parse(&secret_or_uri).map_err(|e| CoreError::Invalid { msg: e.to_string() })?;
Ok(TotpCode { code: t.code_at(unix_time), seconds_remaining: t.remaining(unix_time), period: t.period })
}
#[uniffi::export]
pub fn match_url(item_url: String, page_url: String) -> MatchLevelDto {
match matching::match_url(&item_url, &page_url) {
matching::MatchLevel::None => MatchLevelDto::None,
matching::MatchLevel::Domain => MatchLevelDto::Domain,
matching::MatchLevel::Host => MatchLevelDto::Host,
matching::MatchLevel::Exact => MatchLevelDto::Exact,
}
}
#[uniffi::export]
pub fn core_version() -> String {
env!("CARGO_PKG_VERSION").to_string()
}
fn decrypt(st: &State, u: &Unlocked, id: Uuid, sealed: &SealedItem) -> Result<Item> {
let pt = open_item(st.account_id, st.vault_id, id, &u.vault_key, sealed)?;
serde_json::from_slice(&pt).map_err(|e| CoreError::Internal { msg: e.to_string() })
}
fn summary(i: &Item) -> ItemSummary {
ItemSummary {
id: i.id.to_string(),
kind: kind_to_dto(i.kind),
title: i.title.clone(),
username: i.username.clone(),
urls: i.urls.clone(),
favorite: i.favorite,
has_totp: i.totp.is_some(),
}
}
fn to_dto(i: &Item) -> ItemDto {
ItemDto {
id: i.id.to_string(),
kind: kind_to_dto(i.kind),
title: i.title.clone(),
username: i.username.clone(),
password: i.password.clone(),
urls: i.urls.clone(),
totp: i.totp.clone(),
notes: i.notes.clone(),
favorite: i.favorite,
tags: i.tags.clone(),
modified_at: i.modified_at,
}
}
fn kind_to_dto(k: ItemKind) -> ItemKindDto {
match k {
ItemKind::Login => ItemKindDto::Login,
ItemKind::SecureNote => ItemKindDto::SecureNote,
ItemKind::Card => ItemKindDto::Card,
ItemKind::Identity => ItemKindDto::Identity,
}
}
fn kind_from_dto(k: ItemKindDto) -> ItemKind {
match k {
ItemKindDto::Login => ItemKind::Login,
ItemKindDto::SecureNote => ItemKind::SecureNote,
ItemKindDto::Card => ItemKind::Card,
ItemKindDto::Identity => ItemKind::Identity,
}
}
fn parse_id(s: &str) -> Result<Uuid> {
Uuid::parse_str(s).map_err(|_| CoreError::Invalid { msg: "item id".into() })
}
fn now() -> i64 {
SystemTime::now().duration_since(UNIX_EPOCH).map(|d| d.as_secs() as i64).unwrap_or(0)
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn create_lock_unlock_crud() {
let core = VaultCore::new_in_memory();
let sk = core.create_account("a long master password".into()).unwrap();
let id = core
.save_item(ItemDto {
id: String::new(),
kind: ItemKindDto::Login,
title: "Example".into(),
username: Some("me".into()),
password: Some("s3cret".into()),
urls: vec!["https://example.com".into()],
totp: None,
notes: None,
favorite: false,
tags: vec![],
modified_at: 0,
})
.unwrap();
core.lock();
assert!(matches!(core.list_items(), Err(CoreError::Locked)));
assert!(matches!(core.unlock("wrong password!!".into(), sk.clone()), Err(CoreError::BadCredentials)));
core.unlock("a long master password".into(), sk).unwrap();
assert_eq!(core.get_item(id).unwrap().password.as_deref(), Some("s3cret"));
assert_eq!(core.items_for_url("https://login.example.com/x".into()).unwrap().len(), 1);
assert!(core.items_for_url("https://example.org".into()).unwrap().is_empty());
}
}
+21
View File
@@ -0,0 +1,21 @@
[package]
name = "kv-model"
description = "Kestrove vault items, URL matching, TOTP and password generation."
version.workspace = true
edition.workspace = true
license.workspace = true
publish.workspace = true
[dependencies]
serde.workspace = true
serde_json.workspace = true
uuid.workspace = true
thiserror.workspace = true
rand.workspace = true
zeroize.workspace = true
hmac.workspace = true
sha1.workspace = true
sha2.workspace = true
data-encoding.workspace = true
url.workspace = true
psl.workspace = true
+76
View File
@@ -0,0 +1,76 @@
//! Password and passphrase generation with the OS CSPRNG (uniform, no modulo bias).
use rand::{rngs::OsRng, seq::SliceRandom, Rng};
#[derive(Debug, Clone)]
pub struct PasswordOptions {
pub length: usize,
pub lowercase: bool,
pub uppercase: bool,
pub digits: bool,
pub symbols: bool,
/// Excludes look-alikes such as `0O1lI|`.
pub avoid_ambiguous: bool,
}
impl Default for PasswordOptions {
fn default() -> Self {
Self { length: 24, lowercase: true, uppercase: true, digits: true, symbols: true, avoid_ambiguous: true }
}
}
const LOWER: &str = "abcdefghijklmnopqrstuvwxyz";
const UPPER: &str = "ABCDEFGHIJKLMNOPQRSTUVWXYZ";
const DIGITS: &str = "0123456789";
const SYMBOLS: &str = "!@#$%^&*()-_=+[]{};:,.?/~";
const AMBIGUOUS: &str = "0O1lI|";
/// Generates a password with at least one character from each enabled class.
pub fn generate_password(opts: &PasswordOptions) -> String {
let classes: Vec<Vec<char>> = [
(opts.lowercase, LOWER),
(opts.uppercase, UPPER),
(opts.digits, DIGITS),
(opts.symbols, SYMBOLS),
]
.into_iter()
.filter(|(on, _)| *on)
.map(|(_, set)| set.chars().filter(|c| !(opts.avoid_ambiguous && AMBIGUOUS.contains(*c))).collect())
.collect();
let classes = if classes.is_empty() { vec![LOWER.chars().collect()] } else { classes };
let length = opts.length.clamp(classes.len().max(8), 128);
let all: Vec<char> = classes.iter().flatten().copied().collect();
let mut rng = OsRng;
let mut out: Vec<char> = classes.iter().map(|c| *c.choose(&mut rng).unwrap()).collect();
while out.len() < length {
out.push(all[rng.gen_range(0..all.len())]);
}
out.shuffle(&mut rng);
out.into_iter().collect()
}
/// Passphrase from a caller-supplied word list (ship the EFF large list, 7776 words ≈ 12.9 bits/word).
pub fn generate_passphrase(words: &[&str], count: usize, separator: &str) -> String {
let mut rng = OsRng;
(0..count.max(4)).map(|_| *words.choose(&mut rng).unwrap()).collect::<Vec<_>>().join(separator)
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn respects_classes_and_length() {
for _ in 0..100 {
let p = generate_password(&PasswordOptions::default());
assert_eq!(p.chars().count(), 24);
assert!(p.chars().any(|c| c.is_ascii_lowercase()));
assert!(p.chars().any(|c| c.is_ascii_uppercase()));
assert!(p.chars().any(|c| c.is_ascii_digit()));
assert!(!p.chars().any(|c| AMBIGUOUS.contains(c)));
}
let digits_only = PasswordOptions { length: 6, lowercase: false, uppercase: false, symbols: false, ..Default::default() };
assert_eq!(generate_password(&digits_only).len(), 8); // minimum length 8
}
}
+85
View File
@@ -0,0 +1,85 @@
use serde::{Deserialize, Serialize};
use uuid::Uuid;
use zeroize::Zeroize;
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "snake_case")]
pub enum ItemKind {
Login,
SecureNote,
Card,
Identity,
}
/// A stored WebAuthn credential (docs/05 "Passkeys").
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, Zeroize)]
pub struct Passkey {
pub credential_id: Vec<u8>,
pub rp_id: String,
pub user_handle: Vec<u8>,
pub user_name: String,
/// PKCS#8 DER of the P-256 private key.
pub private_key: Vec<u8>,
pub sign_count: u32,
pub created_at: i64,
}
/// One item. Field-level structure is what makes three-way merges possible (docs/03).
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub struct Item {
pub id: Uuid,
pub kind: ItemKind,
pub title: String,
#[serde(default)]
pub username: Option<String>,
#[serde(default)]
pub password: Option<String>,
#[serde(default)]
pub urls: Vec<String>,
/// `otpauth://` URI or a bare base32 secret.
#[serde(default)]
pub totp: Option<String>,
#[serde(default)]
pub notes: Option<String>,
#[serde(default)]
pub passkeys: Vec<Passkey>,
#[serde(default)]
pub tags: Vec<String>,
#[serde(default)]
pub favorite: bool,
/// Android package names this login may fill (verified via Digital Asset Links when possible).
#[serde(default)]
pub android_apps: Vec<String>,
pub created_at: i64,
pub modified_at: i64,
}
impl Item {
pub fn new_login(title: impl Into<String>, now: i64) -> Self {
Self {
id: Uuid::new_v4(),
kind: ItemKind::Login,
title: title.into(),
username: None,
password: None,
urls: vec![],
totp: None,
notes: None,
passkeys: vec![],
tags: vec![],
favorite: false,
android_apps: vec![],
created_at: now,
modified_at: now,
}
}
}
impl Drop for Item {
fn drop(&mut self) {
self.password.zeroize();
self.totp.zeroize();
self.notes.zeroize();
self.passkeys.zeroize();
}
}
+10
View File
@@ -0,0 +1,10 @@
//! Plaintext data model. Instances only exist inside an unlocked core and are
//! serialized to JSON before `kv_crypto::keys::seal_item`.
pub mod generator;
pub mod item;
pub mod matching;
pub mod totp;
pub use item::{Item, ItemKind, Passkey};
pub use matching::{match_url, MatchLevel};
+70
View File
@@ -0,0 +1,70 @@
//! URL matching for autofill (docs/05 "Matching rules").
use url::Url;
#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord)]
pub enum MatchLevel {
None = 0,
/// Same registrable domain (eTLD+1), different host.
Domain = 1,
/// Same host, different port.
Host = 2,
/// Same scheme, host and port.
Exact = 3,
}
/// Compares a saved item URL with the page URL. Never matches across schemes,
/// except that an `http` item may match an `https` page (an upgrade) but never the reverse.
pub fn match_url(item_url: &str, page_url: &str) -> MatchLevel {
let (Some(item), Some(page)) = (parse(item_url), parse(page_url)) else {
return MatchLevel::None;
};
let scheme_ok = item.scheme() == page.scheme() || (item.scheme() == "http" && page.scheme() == "https");
if !scheme_ok {
return MatchLevel::None;
}
let (Some(ih), Some(ph)) = (item.host_str(), page.host_str()) else {
return MatchLevel::None;
};
let (ih, ph) = (ih.to_ascii_lowercase(), ph.to_ascii_lowercase());
if ih == ph {
return if item.port_or_known_default() == page.port_or_known_default() && item.scheme() == page.scheme() {
MatchLevel::Exact
} else {
MatchLevel::Host
};
}
match (psl::domain_str(&ih), psl::domain_str(&ph)) {
(Some(a), Some(b)) if a == b => MatchLevel::Domain,
_ => MatchLevel::None,
}
}
fn parse(s: &str) -> Option<Url> {
let s = s.trim();
// Saved URLs are often stored without a scheme ("example.com/login").
if s.contains("://") {
Url::parse(s).ok()
} else {
Url::parse(&format!("https://{s}")).ok()
}
}
#[cfg(test)]
mod tests {
use super::*;
use MatchLevel::*;
#[test]
fn levels() {
assert_eq!(match_url("https://example.com", "https://example.com/login"), Exact);
assert_eq!(match_url("example.com", "https://example.com/"), Exact);
assert_eq!(match_url("https://example.com:8443", "https://example.com"), Host);
assert_eq!(match_url("https://example.com", "https://login.example.com"), Domain);
assert_eq!(match_url("https://a.github.io", "https://b.github.io"), None); // public suffix
assert_eq!(match_url("https://example.com", "https://example.com.evil.net"), None);
assert_eq!(match_url("https://example.com", "http://example.com"), None); // no downgrade
assert_eq!(match_url("http://example.com", "https://example.com"), Host); // upgrade allowed
assert_eq!(match_url("https://example.co.uk", "https://other.co.uk"), None);
}
}
+117
View File
@@ -0,0 +1,117 @@
//! RFC 6238 TOTP. Accepts `otpauth://totp/...` URIs or bare base32 secrets.
use data_encoding::BASE32_NOPAD;
use hmac::{Hmac, Mac};
use thiserror::Error;
use url::Url;
#[derive(Debug, Error, PartialEq, Eq)]
pub enum TotpError {
#[error("invalid TOTP secret or URI")]
Invalid,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum Algorithm {
Sha1,
Sha256,
Sha512,
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct Totp {
pub secret: Vec<u8>,
pub digits: u32,
pub period: u64,
pub algorithm: Algorithm,
}
impl Totp {
pub fn parse(input: &str) -> Result<Self, TotpError> {
let input = input.trim();
if input.starts_with("otpauth://") {
let url = Url::parse(input).map_err(|_| TotpError::Invalid)?;
if url.host_str() != Some("totp") {
return Err(TotpError::Invalid);
}
let mut t = Totp { secret: vec![], digits: 6, period: 30, algorithm: Algorithm::Sha1 };
for (k, v) in url.query_pairs() {
match k.to_ascii_lowercase().as_str() {
"secret" => t.secret = decode_b32(&v)?,
"digits" => t.digits = v.parse().map_err(|_| TotpError::Invalid)?,
"period" => t.period = v.parse().map_err(|_| TotpError::Invalid)?,
"algorithm" => {
t.algorithm = match v.to_ascii_uppercase().as_str() {
"SHA1" => Algorithm::Sha1,
"SHA256" => Algorithm::Sha256,
"SHA512" => Algorithm::Sha512,
_ => return Err(TotpError::Invalid),
}
}
_ => {}
}
}
if t.secret.is_empty() || !(6..=10).contains(&t.digits) || t.period == 0 {
return Err(TotpError::Invalid);
}
Ok(t)
} else {
Ok(Totp { secret: decode_b32(input)?, digits: 6, period: 30, algorithm: Algorithm::Sha1 })
}
}
pub fn code_at(&self, unix_time: u64) -> String {
let counter = (unix_time / self.period).to_be_bytes();
let mac = match self.algorithm {
Algorithm::Sha1 => hmac_bytes::<Hmac<sha1::Sha1>>(&self.secret, &counter),
Algorithm::Sha256 => hmac_bytes::<Hmac<sha2::Sha256>>(&self.secret, &counter),
Algorithm::Sha512 => hmac_bytes::<Hmac<sha2::Sha512>>(&self.secret, &counter),
};
let offset = (mac[mac.len() - 1] & 0x0f) as usize;
let bin = u32::from_be_bytes(mac[offset..offset + 4].try_into().unwrap()) & 0x7fff_ffff;
let code = bin as u64 % 10u64.pow(self.digits);
format!("{:0width$}", code, width = self.digits as usize)
}
/// Seconds remaining in the current period.
pub fn remaining(&self, unix_time: u64) -> u64 {
self.period - unix_time % self.period
}
}
fn hmac_bytes<M: Mac + hmac::digest::KeyInit>(key: &[u8], msg: &[u8]) -> Vec<u8> {
let mut m = <M as hmac::digest::KeyInit>::new_from_slice(key).expect("HMAC accepts any key length");
m.update(msg);
m.finalize().into_bytes().to_vec()
}
fn decode_b32(s: &str) -> Result<Vec<u8>, TotpError> {
let cleaned: String = s.chars().filter(|c| !c.is_whitespace() && *c != '=' && *c != '-').collect::<String>().to_ascii_uppercase();
BASE32_NOPAD.decode(cleaned.as_bytes()).map_err(|_| TotpError::Invalid)
}
#[cfg(test)]
mod tests {
use super::*;
// RFC 6238 Appendix B.
#[test]
fn rfc6238_vectors() {
let mk = |secret: &[u8], algorithm| Totp { secret: secret.to_vec(), digits: 8, period: 30, algorithm };
let sha1 = mk(b"12345678901234567890", Algorithm::Sha1);
let sha256 = mk(b"12345678901234567890123456789012", Algorithm::Sha256);
let sha512 = mk(b"1234567890123456789012345678901234567890123456789012345678901234", Algorithm::Sha512);
assert_eq!(sha1.code_at(59), "94287082");
assert_eq!(sha256.code_at(59), "46119246");
assert_eq!(sha512.code_at(59), "90693936");
assert_eq!(sha1.code_at(1111111109), "07081804");
assert_eq!(sha1.code_at(20000000000), "65353130");
}
#[test]
fn parses_uri() {
let t = Totp::parse("otpauth://totp/Ex:me?secret=GEZDGNBVGY3TQOJQGEZDGNBVGY3TQOJQ&issuer=Ex&digits=8").unwrap();
assert_eq!(t.secret, b"12345678901234567890");
assert_eq!(t.code_at(59), "94287082");
}
}
+15
View File
@@ -0,0 +1,15 @@
[package]
name = "kv-proto"
description = "Wire types shared by Kestrove clients and the sync server. No secret handling."
version.workspace = true
edition.workspace = true
license.workspace = true
publish.workspace = true
[dependencies]
serde.workspace = true
uuid.workspace = true
base64.workspace = true
[dev-dependencies]
serde_json.workspace = true
+218
View File
@@ -0,0 +1,218 @@
//! Wire format for the Kestrove sync API (see kestrove-spec/docs/03 and api/openapi.yaml).
//!
//! Everything here is either public metadata or opaque ciphertext. The server
//! depends on this crate and nothing else from the core.
use serde::{Deserialize, Serialize};
use uuid::Uuid;
pub const FORMAT_VERSION: u8 = 1;
pub const API_VERSION: u32 = 1;
/// Minimum Argon2id parameters a client accepts (anti-downgrade, docs/02).
pub const MIN_KDF_M_KIB: u32 = 64 * 1024;
pub const MIN_KDF_T: u32 = 3;
/// base64url (no padding) serde helper for byte fields.
pub mod b64 {
use base64::{engine::general_purpose::URL_SAFE_NO_PAD, Engine};
use serde::{Deserialize, Deserializer, Serializer};
pub fn serialize<S: Serializer>(bytes: &[u8], s: S) -> Result<S::Ok, S::Error> {
s.serialize_str(&URL_SAFE_NO_PAD.encode(bytes))
}
pub fn deserialize<'de, D: Deserializer<'de>>(d: D) -> Result<Vec<u8>, D::Error> {
let s = String::deserialize(d)?;
URL_SAFE_NO_PAD.decode(s).map_err(serde::de::Error::custom)
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
pub enum Alg {
/// XChaCha20-Poly1305 with a 24-byte random nonce.
#[serde(rename = "xc20p")]
XChaCha20Poly1305,
/// HPKE base mode, DHKEM(X25519, HKDF-SHA256), HKDF-SHA256, ChaCha20-Poly1305.
#[serde(rename = "hpke-x25519-chacha")]
HpkeX25519ChaCha,
}
/// Every encrypted object on the wire and on disk (docs/02 "Envelope format").
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub struct Envelope {
pub v: u8,
pub alg: Alg,
/// Id of the key that opens this envelope.
pub kid: Uuid,
#[serde(with = "b64", default, skip_serializing_if = "Vec::is_empty")]
pub n: Vec<u8>,
#[serde(with = "b64")]
pub ct: Vec<u8>,
/// HPKE encapsulated key (HPKE envelopes only).
#[serde(with = "b64", default, skip_serializing_if = "Vec::is_empty")]
pub enc: Vec<u8>,
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub struct KdfParams {
pub alg: String,
pub m_kib: u32,
pub t: u32,
pub p: u32,
#[serde(with = "b64")]
pub salt: Vec<u8>,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "lowercase")]
pub enum Platform {
Ios,
Macos,
Android,
Windows,
Linux,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct ServerInfo {
pub version: String,
pub api: u32,
pub single_tenant: bool,
pub setup_open: bool,
pub min_client: String,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct DeviceEnroll {
pub device_id: Uuid,
pub name: String,
pub platform: Platform,
/// SEC1 uncompressed P-256 public key.
#[serde(with = "b64")]
pub p256_pk: Vec<u8>,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct SetupRequest {
pub setup_token: String,
pub account_id: Uuid,
pub kdf: KdfParams,
#[serde(with = "b64")]
pub opaque_registration_upload: Vec<u8>,
#[serde(with = "b64")]
pub enc_pk: Vec<u8>,
#[serde(with = "b64")]
pub sig_pk: Vec<u8>,
pub wrapped_keyset: Envelope,
pub device: DeviceEnroll,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct LoginStart {
pub account_id: Uuid,
#[serde(with = "b64")]
pub ke1: Vec<u8>,
pub device: DeviceEnroll,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct LoginStartResponse {
pub login_id: String,
#[serde(with = "b64")]
pub ke2: Vec<u8>,
pub kdf: KdfParams,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct LoginFinish {
pub login_id: String,
#[serde(with = "b64")]
pub ke3: Vec<u8>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub totp: Option<String>,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "snake_case")]
pub enum LoginStatus {
Ok,
PendingApproval,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct LoginFinishResponse {
pub status: LoginStatus,
pub session: Option<String>,
pub expires_at: Option<String>,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct Vault {
pub vault_id: Uuid,
pub head_revision: u64,
pub sealed_key: Envelope,
pub meta: Envelope,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct ItemPut {
pub wrapped_item_key: Envelope,
pub payload: Envelope,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct ItemRecord {
pub item_id: Uuid,
pub revision: u64,
pub deleted: bool,
pub wrapped_item_key: Option<Envelope>,
pub payload: Option<Envelope>,
pub updated_by: Uuid,
pub updated_at: String,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct Changes {
pub items: Vec<ItemRecord>,
pub head_revision: u64,
pub more: bool,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct Manifest {
pub version: u64,
/// Canonical CBOR of the manifest body (docs/03 "Integrity").
#[serde(with = "b64")]
pub body: Vec<u8>,
#[serde(with = "b64")]
pub signature: Vec<u8>,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct ApiError {
pub code: String,
pub message: String,
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn envelope_json_roundtrip_uses_b64url() {
let env = Envelope {
v: FORMAT_VERSION,
alg: Alg::XChaCha20Poly1305,
kid: Uuid::nil(),
n: vec![0xff; 24],
ct: vec![1, 2, 3],
enc: vec![],
};
let json = serde_json::to_string(&env).unwrap();
assert!(json.contains("\"alg\":\"xc20p\""));
assert!(!json.contains("enc"));
assert!(!json.contains('+') && !json.contains('/'));
assert_eq!(serde_json::from_str::<Envelope>(&json).unwrap(), env);
}
}
+12
View File
@@ -0,0 +1,12 @@
[package]
name = "kv-store"
description = "Local encrypted replica (SQLite). Holds ciphertext only."
version.workspace = true
edition.workspace = true
license.workspace = true
publish.workspace = true
[dependencies]
kv-proto.workspace = true
uuid.workspace = true
thiserror.workspace = true
+42
View File
@@ -0,0 +1,42 @@
//! Local encrypted replica (milestone M1, kestrove-spec/docs/07).
//!
//! The store holds the same `Envelope` bytes the server holds, plus sync
//! bookkeeping. It never sees plaintext. Planned schema (rusqlite, bundled):
//!
//! ```sql
//! CREATE TABLE vault (vault_id BLOB PRIMARY KEY, sealed_key TEXT, meta TEXT, cursor INTEGER, manifest_version INTEGER, manifest_hash BLOB);
//! CREATE TABLE item (vault_id BLOB, item_id BLOB, revision INTEGER, deleted INTEGER,
//! wrapped_item_key TEXT, payload TEXT,
//! state TEXT CHECK(state IN ('clean','pending')), base_revision INTEGER, base_payload TEXT,
//! PRIMARY KEY (vault_id, item_id));
//! CREATE TABLE history (vault_id BLOB, item_id BLOB, revision INTEGER, wrapped_item_key TEXT, payload TEXT);
//! ```
use kv_proto::ItemRecord;
use uuid::Uuid;
#[derive(Debug, thiserror::Error)]
pub enum StoreError {
#[error("storage I/O: {0}")]
Io(String),
#[error("not found")]
NotFound,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum SyncState {
Clean,
/// Edited locally; `base_revision` is what the edit was made against.
Pending { base_revision: u64 },
}
/// Storage backend. The SQLite implementation and an in-memory one for tests come in M1.
pub trait Store: Send + Sync {
fn cursor(&self, vault: Uuid) -> Result<u64, StoreError>;
fn set_cursor(&self, vault: Uuid, revision: u64) -> Result<(), StoreError>;
fn put_remote(&self, vault: Uuid, record: &ItemRecord) -> Result<(), StoreError>;
fn put_local(&self, vault: Uuid, record: &ItemRecord, state: SyncState) -> Result<(), StoreError>;
fn pending(&self, vault: Uuid) -> Result<Vec<(ItemRecord, u64)>, StoreError>;
fn get(&self, vault: Uuid, item: Uuid) -> Result<(ItemRecord, SyncState), StoreError>;
fn list(&self, vault: Uuid) -> Result<Vec<ItemRecord>, StoreError>;
}
+12
View File
@@ -0,0 +1,12 @@
[package]
name = "kv-sync"
description = "Sync engine: OPAQUE login, RFC 9421 request signing, pull/push, merge, manifest."
version.workspace = true
edition.workspace = true
license.workspace = true
publish.workspace = true
[dependencies]
kv-proto.workspace = true
uuid.workspace = true
thiserror.workspace = true
+38
View File
@@ -0,0 +1,38 @@
//! Sync engine (milestone M1, kestrove-spec/docs/03).
//!
//! Responsibilities:
//! * `Transport`: HTTPS with rustls, TLS 1.3 only, SPKI pinning (primary + backup pin).
//! * OPAQUE login via `opaque-ke` (ristretto255 / SHA-512, identity KSF because the input is already Argon2id-stretched).
//! * RFC 9421 request signing with the device key. The key lives in the platform keystore, so signing is
//! a callback into platform code ([`DeviceSigner`]).
//! * Pull, then push with `If-Match`, then three-way field merge on 412, then manifest verify and publish.
use uuid::Uuid;
#[derive(Debug, thiserror::Error)]
pub enum SyncError {
#[error("network: {0}")]
Network(String),
#[error("server rejected request: {0}")]
Server(String),
#[error("integrity alarm on vault {0}: manifest rollback or tampering")]
Integrity(Uuid),
#[error("not logged in")]
NotAuthenticated,
}
/// Implemented by each platform: Secure Enclave, StrongBox, TPM or software fallback.
pub trait DeviceSigner: Send + Sync {
fn device_id(&self) -> Uuid;
/// SEC1 uncompressed P-256 public key.
fn public_key(&self) -> Vec<u8>;
/// ECDSA P-256 / SHA-256 signature (IEEE P1363 r||s) over `message`.
fn sign(&self, message: &[u8]) -> Result<Vec<u8>, SyncError>;
}
/// Server endpoint plus pinned SPKI SHA-256 hashes (base64).
#[derive(Debug, Clone)]
pub struct ServerConfig {
pub base_url: String,
pub spki_pins: Vec<String>,
}