commit 462fbbc523ecc26ec2dd43e2f0d990c2dc99dbfa Author: samialabri Date: Thu Oct 15 18:30:14 2026 +0900 Initial scaffold for kestrove-core Co-Authored-By: Claude Opus 5.5 diff --git a/.DS_Store b/.DS_Store new file mode 100644 index 0000000..8f1873e Binary files /dev/null and b/.DS_Store differ diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml new file mode 100644 index 0000000..83051e7 --- /dev/null +++ b/.github/workflows/ci.yml @@ -0,0 +1,13 @@ +name: ci +on: [push, pull_request] +jobs: + test: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + - uses: dtolnay/rust-toolchain@stable + with: { components: "clippy,rustfmt" } + - run: cargo fmt --all --check + - run: cargo clippy --workspace --all-targets -- -D warnings + - run: cargo test --workspace + - uses: EmbarkStudios/cargo-deny-action@v2 diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..bd21d21 --- /dev/null +++ b/.gitignore @@ -0,0 +1,4 @@ +/target +/out +*.xcframework +/bindings diff --git a/Cargo.toml b/Cargo.toml new file mode 100644 index 0000000..8465b8e --- /dev/null +++ b/Cargo.toml @@ -0,0 +1,51 @@ +[workspace] +resolver = "2" +members = ["crates/*"] + +[workspace.package] +version = "0.1.0" +edition = "2021" +license = "Apache-2.0" +rust-version = "1.82" +publish = false + +[workspace.dependencies] +kv-proto = { path = "crates/kv-proto" } +kv-crypto = { path = "crates/kv-crypto" } +kv-model = { path = "crates/kv-model" } +kv-store = { path = "crates/kv-store" } +kv-sync = { path = "crates/kv-sync" } + +serde = { version = "1", features = ["derive"] } +serde_json = "1" +thiserror = "2" +uuid = { version = "1", features = ["v4", "serde"] } +base64 = "0.22" +rand = "0.8" +rand_core = { version = "0.6", features = ["getrandom"] } +zeroize = { version = "1", features = ["derive"] } +argon2 = "0.5" +hkdf = "0.12" +hmac = "0.12" +sha1 = "0.10" +sha2 = "0.10" +chacha20poly1305 = "0.10" +hpke = "0.12" +ed25519-dalek = { version = "2", features = ["rand_core", "zeroize"] } +unicode-normalization = "0.1" +data-encoding = "2" +url = "2" +psl = "2" +uniffi = "0.28" + +[profile.release] +lto = true +codegen-units = 1 +panic = "abort" +strip = true + +# Argon2 at 64 MiB is painfully slow unoptimized; keep debug test runs fast. +[profile.dev.package.argon2] +opt-level = 3 +[profile.dev.package.blake2] +opt-level = 3 diff --git a/README.md b/README.md new file mode 100644 index 0000000..ae65363 --- /dev/null +++ b/README.md @@ -0,0 +1,37 @@ +# kestrove-core + +Rust workspace that holds **all** Kestrove crypto and vault logic. Apple, +Android and desktop apps call it through UniFFI. The server uses only `kv-proto`. + +| Crate | Status | Purpose | +|---|---|---| +| `kv-proto` | โœ… M0 | Wire types (Envelope, KDF params, API bodies). No secrets. | +| `kv-crypto` | โœ… M0 | 2SKD (Argon2id + Secret Key), XChaCha20-Poly1305 envelopes, HPKE vault-key sealing, keyset, Ed25519, Secret Key format | +| `kv-model` | โœ… M0 | Items, URL matching (PSL), TOTP (RFC 6238), password generator | +| `kv-ffi` | โœ… M0 (in-memory) | The exported API (`VaultCore`, `totp_code`, `match_url`, `generate_password`) | +| `kv-store` | ๐Ÿ”œ M1 | SQLite replica (ciphertext only) | +| `kv-sync` | ๐Ÿ”œ M1 | OPAQUE login, RFC 9421 signing, pull/push/merge, signed manifest | + +Spec: `../kestrove-spec/docs/02-cryptography.md` and `03-sync-protocol.md`. + +## Setup + +```bash +curl --proto '=https' --tlsv1.2 -sSf https://sh.rustup.rs | sh # once +cargo test --workspace +``` + +## Bindings + +```bash +./scripts/build-apple.sh # โ†’ ../kestrove-apple/Packages/KestroveCore (XCFramework + Swift) +./scripts/build-android.sh # โ†’ ../kestrove-android/core-ffi (jniLibs + Kotlin); needs cargo-ndk +``` + +The desktop (Tauri) app depends on `kv-ffi` directly as a Rust crate. + +## Rules +* No `unsafe` outside generated FFI glue. +* Secrets live in `Zeroizing<_>`. Never log, `Debug`-print or `format!` a secret. +* Any change to formats needs a spec change first and new vectors in `kestrove-spec/vectors`. +* `cargo deny check` must pass. There are no GPL dependencies (App Store). diff --git a/crates/.DS_Store b/crates/.DS_Store new file mode 100644 index 0000000..9cc4c51 Binary files /dev/null and b/crates/.DS_Store differ diff --git a/crates/kv-crypto/.DS_Store b/crates/kv-crypto/.DS_Store new file mode 100644 index 0000000..d346246 Binary files /dev/null and b/crates/kv-crypto/.DS_Store differ diff --git a/crates/kv-crypto/Cargo.toml b/crates/kv-crypto/Cargo.toml new file mode 100644 index 0000000..12b0238 --- /dev/null +++ b/crates/kv-crypto/Cargo.toml @@ -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 diff --git a/crates/kv-crypto/src/aad.rs b/crates/kv-crypto/src/aad.rs new file mode 100644 index 0000000..a76452c --- /dev/null +++ b/crates/kv-crypto/src/aad.rs @@ -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, item: Option) -> Vec { + 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 + } +} diff --git a/crates/kv-crypto/src/aead.rs b/crates/kv-crypto/src/aead.rs new file mode 100644 index 0000000..fd1357f --- /dev/null +++ b/crates/kv-crypto/src/aead.rs @@ -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 { + 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>> { + 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> { + 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>> { + 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[..]); + } + } +} diff --git a/crates/kv-crypto/src/kdf.rs b/crates/kv-crypto/src/kdf.rs new file mode 100644 index 0000000..ff09a2d --- /dev/null +++ b/crates/kv-crypto/src/kdf.rs @@ -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 { + 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 { + let normalized = Zeroizing::new(password.trim().nfkd().collect::()); + + 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::::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::::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::::new(Some(account_id.as_bytes()), ikm.as_ref()) + .expand(b"kestrove/v1/root", root.as_mut()) + .map_err(|_| CryptoError::Kdf)?; + + let hk = Hkdf::::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()); + } +} diff --git a/crates/kv-crypto/src/keys.rs b/crates/kv-crypto/src/keys.rs new file mode 100644 index 0000000..5d1949a --- /dev/null +++ b/crates/kv-crypto/src/keys.rs @@ -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> { + 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 { + 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 { + 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 { + 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 { + 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 { + 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 { + 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>> { + 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()); + } +} diff --git a/crates/kv-crypto/src/lib.rs b/crates/kv-crypto/src/lib.rs new file mode 100644 index 0000000..55fb5db --- /dev/null +++ b/crates/kv-crypto/src/lib.rs @@ -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 = std::result::Result; + +/// Fills a fixed-size array from the OS CSPRNG. +pub fn random_bytes() -> [u8; N] { + use rand_core::{OsRng, RngCore}; + let mut b = [0u8; N]; + OsRng.fill_bytes(&mut b); + b +} diff --git a/crates/kv-crypto/src/seal.rs b/crates/kv-crypto/src/seal.rs new file mode 100644 index 0000000..6eb88bb --- /dev/null +++ b/crates/kv-crypto/src/seal.rs @@ -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: ::PrivateKey, + pub pk: ::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 { + let sk = ::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> { + Zeroizing::new(self.sk.to_bytes().to_vec()) + } + + pub fn public_bytes(&self) -> Vec { + self.pk.to_bytes().to_vec() + } + + pub fn open(&self, env: &Envelope, aad: &[u8]) -> Result>> { + if env.v != FORMAT_VERSION || env.alg != Alg::HpkeX25519ChaCha { + return Err(CryptoError::Unsupported); + } + let encapped = ::EncappedKey::from_bytes(&env.enc).map_err(|_| CryptoError::Malformed)?; + hpke::single_shot_open::(&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 { + let pk = ::PublicKey::from_bytes(recipient_pk).map_err(|_| CryptoError::Malformed)?; + let (encapped, ct) = + hpke::single_shot_seal::(&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()); + } +} diff --git a/crates/kv-crypto/src/secret_key.rs b/crates/kv-crypto/src/secret_key.rs new file mode 100644 index 0000000..8f9c9ac --- /dev/null +++ b/crates/kv-crypto/src/secret_key.rs @@ -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 { + 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 { + let cleaned: Zeroizing = 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 { + 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()); + } +} diff --git a/crates/kv-ffi/Cargo.toml b/crates/kv-ffi/Cargo.toml new file mode 100644 index 0000000..e48fa9a --- /dev/null +++ b/crates/kv-ffi/Cargo.toml @@ -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"] } diff --git a/crates/kv-ffi/src/bin/uniffi-bindgen.rs b/crates/kv-ffi/src/bin/uniffi-bindgen.rs new file mode 100644 index 0000000..f6cff6c --- /dev/null +++ b/crates/kv-ffi/src/bin/uniffi-bindgen.rs @@ -0,0 +1,3 @@ +fn main() { + uniffi::uniffi_bindgen_main() +} diff --git a/crates/kv-ffi/src/lib.rs b/crates/kv-ffi/src/lib.rs new file mode 100644 index 0000000..1d53fe9 --- /dev/null +++ b/crates/kv-ffi/src/lib.rs @@ -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 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 = std::result::Result; + +#[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, + pub urls: Vec, + 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, + pub password: Option, + pub urls: Vec, + pub totp: Option, + pub notes: Option, + pub favorite: bool, + pub tags: Vec, + 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, + unlocked: Option, +} + +/// One account's vault on this device. +#[derive(uniffi::Object)] +pub struct VaultCore { + state: Mutex>, +} + +#[uniffi::export] +impl VaultCore { + #[uniffi::constructor] + pub fn new_in_memory() -> Arc { + 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 { + 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, ¶ms)?; + 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> { + self.with_unlocked(|st, u| { + let mut out = st + .items + .iter() + .map(|(id, sealed)| decrypt(st, u, *id, sealed).map(|i| summary(&i))) + .collect::>>()?; + 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 { + 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 { + 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> { + 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 { + 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> { + // 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(&self, f: impl FnOnce(&State, &Unlocked) -> Result) -> Result { + 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 { + 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 { + 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::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()); + } +} diff --git a/crates/kv-model/Cargo.toml b/crates/kv-model/Cargo.toml new file mode 100644 index 0000000..9ff4980 --- /dev/null +++ b/crates/kv-model/Cargo.toml @@ -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 diff --git a/crates/kv-model/src/generator.rs b/crates/kv-model/src/generator.rs new file mode 100644 index 0000000..9439ef2 --- /dev/null +++ b/crates/kv-model/src/generator.rs @@ -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> = [ + (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 = classes.iter().flatten().copied().collect(); + + let mut rng = OsRng; + let mut out: Vec = 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::>().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 + } +} diff --git a/crates/kv-model/src/item.rs b/crates/kv-model/src/item.rs new file mode 100644 index 0000000..02c3142 --- /dev/null +++ b/crates/kv-model/src/item.rs @@ -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, + pub rp_id: String, + pub user_handle: Vec, + pub user_name: String, + /// PKCS#8 DER of the P-256 private key. + pub private_key: Vec, + 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, + #[serde(default)] + pub password: Option, + #[serde(default)] + pub urls: Vec, + /// `otpauth://` URI or a bare base32 secret. + #[serde(default)] + pub totp: Option, + #[serde(default)] + pub notes: Option, + #[serde(default)] + pub passkeys: Vec, + #[serde(default)] + pub tags: Vec, + #[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, + pub created_at: i64, + pub modified_at: i64, +} + +impl Item { + pub fn new_login(title: impl Into, 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(); + } +} diff --git a/crates/kv-model/src/lib.rs b/crates/kv-model/src/lib.rs new file mode 100644 index 0000000..9635351 --- /dev/null +++ b/crates/kv-model/src/lib.rs @@ -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}; diff --git a/crates/kv-model/src/matching.rs b/crates/kv-model/src/matching.rs new file mode 100644 index 0000000..59e511a --- /dev/null +++ b/crates/kv-model/src/matching.rs @@ -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 { + 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); + } +} diff --git a/crates/kv-model/src/totp.rs b/crates/kv-model/src/totp.rs new file mode 100644 index 0000000..4ea0fa6 --- /dev/null +++ b/crates/kv-model/src/totp.rs @@ -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, + pub digits: u32, + pub period: u64, + pub algorithm: Algorithm, +} + +impl Totp { + pub fn parse(input: &str) -> Result { + 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::>(&self.secret, &counter), + Algorithm::Sha256 => hmac_bytes::>(&self.secret, &counter), + Algorithm::Sha512 => hmac_bytes::>(&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(key: &[u8], msg: &[u8]) -> Vec { + let mut m = ::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, TotpError> { + let cleaned: String = s.chars().filter(|c| !c.is_whitespace() && *c != '=' && *c != '-').collect::().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"); + } +} diff --git a/crates/kv-proto/Cargo.toml b/crates/kv-proto/Cargo.toml new file mode 100644 index 0000000..bbbfa92 --- /dev/null +++ b/crates/kv-proto/Cargo.toml @@ -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 diff --git a/crates/kv-proto/src/lib.rs b/crates/kv-proto/src/lib.rs new file mode 100644 index 0000000..4a9ce72 --- /dev/null +++ b/crates/kv-proto/src/lib.rs @@ -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(bytes: &[u8], s: S) -> Result { + s.serialize_str(&URL_SAFE_NO_PAD.encode(bytes)) + } + + pub fn deserialize<'de, D: Deserializer<'de>>(d: D) -> Result, 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, + #[serde(with = "b64")] + pub ct: Vec, + /// HPKE encapsulated key (HPKE envelopes only). + #[serde(with = "b64", default, skip_serializing_if = "Vec::is_empty")] + pub enc: Vec, +} + +#[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, +} + +#[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, +} + +#[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, + #[serde(with = "b64")] + pub enc_pk: Vec, + #[serde(with = "b64")] + pub sig_pk: Vec, + 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, + pub device: DeviceEnroll, +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct LoginStartResponse { + pub login_id: String, + #[serde(with = "b64")] + pub ke2: Vec, + pub kdf: KdfParams, +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct LoginFinish { + pub login_id: String, + #[serde(with = "b64")] + pub ke3: Vec, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub totp: Option, +} + +#[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, + pub expires_at: Option, +} + +#[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, + pub payload: Option, + pub updated_by: Uuid, + pub updated_at: String, +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct Changes { + pub items: Vec, + 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, + #[serde(with = "b64")] + pub signature: Vec, +} + +#[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::(&json).unwrap(), env); + } +} diff --git a/crates/kv-store/Cargo.toml b/crates/kv-store/Cargo.toml new file mode 100644 index 0000000..c29014b --- /dev/null +++ b/crates/kv-store/Cargo.toml @@ -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 diff --git a/crates/kv-store/src/lib.rs b/crates/kv-store/src/lib.rs new file mode 100644 index 0000000..1619eb0 --- /dev/null +++ b/crates/kv-store/src/lib.rs @@ -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; + 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, StoreError>; + fn get(&self, vault: Uuid, item: Uuid) -> Result<(ItemRecord, SyncState), StoreError>; + fn list(&self, vault: Uuid) -> Result, StoreError>; +} diff --git a/crates/kv-sync/Cargo.toml b/crates/kv-sync/Cargo.toml new file mode 100644 index 0000000..7b02e05 --- /dev/null +++ b/crates/kv-sync/Cargo.toml @@ -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 diff --git a/crates/kv-sync/src/lib.rs b/crates/kv-sync/src/lib.rs new file mode 100644 index 0000000..a5ebe5d --- /dev/null +++ b/crates/kv-sync/src/lib.rs @@ -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; + /// ECDSA P-256 / SHA-256 signature (IEEE P1363 r||s) over `message`. + fn sign(&self, message: &[u8]) -> Result, SyncError>; +} + +/// Server endpoint plus pinned SPKI SHA-256 hashes (base64). +#[derive(Debug, Clone)] +pub struct ServerConfig { + pub base_url: String, + pub spki_pins: Vec, +} diff --git a/deny.toml b/deny.toml new file mode 100644 index 0000000..e816349 --- /dev/null +++ b/deny.toml @@ -0,0 +1,17 @@ +# cargo deny check โ€” enforces docs/06 ยง5 (no GPL in shipped apps) and advisories. +[advisories] +version = 2 +yanked = "deny" + +[licenses] +version = 2 +allow = ["MIT", "Apache-2.0", "Apache-2.0 WITH LLVM-exception", "BSD-2-Clause", "BSD-3-Clause", "ISC", "Unicode-3.0", "Unicode-DFS-2016", "Zlib", "CC0-1.0", "MPL-2.0"] +confidence-threshold = 0.9 + +[bans] +multiple-versions = "warn" +wildcards = "deny" + +[sources] +unknown-registry = "deny" +unknown-git = "deny" diff --git a/rust-toolchain.toml b/rust-toolchain.toml new file mode 100644 index 0000000..f9d17ee --- /dev/null +++ b/rust-toolchain.toml @@ -0,0 +1,7 @@ +[toolchain] +channel = "stable" +components = ["rustfmt", "clippy"] +targets = [ + "aarch64-apple-ios", "aarch64-apple-ios-sim", "aarch64-apple-darwin", "x86_64-apple-darwin", + "aarch64-linux-android", "armv7-linux-androideabi", "x86_64-linux-android", +] diff --git a/scripts/build-android.sh b/scripts/build-android.sh new file mode 100755 index 0000000..72ff910 --- /dev/null +++ b/scripts/build-android.sh @@ -0,0 +1,9 @@ +#!/usr/bin/env bash +# Builds jniLibs + Kotlin bindings into ../kestrove-android/core-ffi. Needs: cargo install cargo-ndk; ANDROID_NDK_HOME set. +set -euo pipefail +cd "$(dirname "$0")/.." +OUT=../kestrove-android/core-ffi/src/main +cargo ndk -t arm64-v8a -t armeabi-v7a -t x86_64 -o "$OUT/jniLibs" build -p kv-ffi --release +cargo build -p kv-ffi +cargo run -p kv-ffi --bin uniffi-bindgen -- generate --library target/debug/libkv_ffi.dylib --language kotlin --out-dir "$OUT/java" +echo "โœ… Android libs + bindings written to $OUT" diff --git a/scripts/build-apple.sh b/scripts/build-apple.sh new file mode 100755 index 0000000..81a47ba --- /dev/null +++ b/scripts/build-apple.sh @@ -0,0 +1,32 @@ +#!/usr/bin/env bash +# Builds KestroveCoreFFI.xcframework + Swift bindings into ../kestrove-apple/Packages/KestroveCore. +set -euo pipefail +cd "$(dirname "$0")/.." +OUT=../kestrove-apple/Packages/KestroveCore +LIB=libkv_ffi.a + +for t in aarch64-apple-ios aarch64-apple-ios-sim aarch64-apple-darwin x86_64-apple-darwin; do + cargo build -p kv-ffi --release --target "$t" +done + +cargo build -p kv-ffi # host dylib for library-mode bindgen +mkdir -p out/swift +cargo run -p kv-ffi --bin uniffi-bindgen -- generate --library target/debug/libkv_ffi.dylib --language swift --out-dir out/swift + +mkdir -p out/headers +cp out/swift/kv_ffiFFI.h out/headers/ +cp out/swift/kv_ffiFFI.modulemap out/headers/module.modulemap + +mkdir -p out/macos +lipo -create target/aarch64-apple-darwin/release/$LIB target/x86_64-apple-darwin/release/$LIB -output out/macos/$LIB + +rm -rf "$OUT/KestroveCoreFFI.xcframework" +xcodebuild -create-xcframework \ + -library target/aarch64-apple-ios/release/$LIB -headers out/headers \ + -library target/aarch64-apple-ios-sim/release/$LIB -headers out/headers \ + -library out/macos/$LIB -headers out/headers \ + -output "$OUT/KestroveCoreFFI.xcframework" + +mkdir -p "$OUT/Sources/KestroveCore" +cp out/swift/kv_ffi.swift "$OUT/Sources/KestroveCore/Generated.swift" +echo "โœ… XCFramework + bindings written to $OUT"