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