Initial scaffold for kestrove-core

Co-Authored-By: Claude Opus 5.5 <[email protected]>
This commit is contained in:
2026-10-15 18:30:14 +09:00
co-authored by Claude Opus 5.5
commit 462fbbc523
34 changed files with 2063 additions and 0 deletions
+25
View File
@@ -0,0 +1,25 @@
[package]
name = "kv-ffi"
description = "UniFFI bindings: the single API surface used by Swift, Kotlin and the Tauri app."
version.workspace = true
edition.workspace = true
license.workspace = true
publish.workspace = true
[lib]
crate-type = ["lib", "staticlib", "cdylib"]
name = "kv_ffi"
[[bin]]
name = "uniffi-bindgen"
path = "src/bin/uniffi-bindgen.rs"
[dependencies]
kv-proto.workspace = true
kv-crypto.workspace = true
kv-model.workspace = true
uuid.workspace = true
thiserror.workspace = true
serde_json.workspace = true
zeroize.workspace = true
uniffi = { workspace = true, features = ["cli"] }
+3
View File
@@ -0,0 +1,3 @@
fn main() {
uniffi::uniffi_bindgen_main()
}
+399
View File
@@ -0,0 +1,399 @@
//! The FFI contract between the core and every platform app (kestrove-spec/docs/07 "Work split").
//! Changing a public item here needs a spec PR.
//!
//! M0 ships an **in-memory** vault so UI work on Apple, Android and desktop can start
//! now. M1 swaps the storage for `kv-store` + `kv-sync` behind the same API.
use std::{
collections::HashMap,
sync::{Arc, Mutex},
time::{SystemTime, UNIX_EPOCH},
};
use kv_crypto::{
aead::SymKey,
kdf,
keys::{open_item, seal_item, AccountKeyset, SealedItem},
secret_key::SecretKey,
CryptoError,
};
use kv_model::{generator, matching, totp::Totp, Item, ItemKind};
use kv_proto::{Envelope, KdfParams};
use uuid::Uuid;
uniffi::setup_scaffolding!();
#[derive(Debug, thiserror::Error, uniffi::Error)]
pub enum CoreError {
#[error("vault is locked")]
Locked,
#[error("no account on this device")]
NoAccount,
#[error("wrong master password or Secret Key")]
BadCredentials,
#[error("invalid input: {msg}")]
Invalid { msg: String },
#[error("not found")]
NotFound,
#[error("internal error: {msg}")]
Internal { msg: String },
}
impl From<CryptoError> for CoreError {
fn from(e: CryptoError) -> Self {
match e {
CryptoError::Decrypt => CoreError::BadCredentials,
CryptoError::BadSecretKey => CoreError::Invalid { msg: "Secret Key".into() },
other => CoreError::Internal { msg: other.to_string() },
}
}
}
type Result<T> = std::result::Result<T, CoreError>;
#[derive(Debug, Clone, Copy, PartialEq, Eq, uniffi::Enum)]
pub enum ItemKindDto {
Login,
SecureNote,
Card,
Identity,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, uniffi::Enum)]
pub enum MatchLevelDto {
None,
Domain,
Host,
Exact,
}
#[derive(Debug, Clone, uniffi::Record)]
pub struct ItemSummary {
pub id: String,
pub kind: ItemKindDto,
pub title: String,
pub username: Option<String>,
pub urls: Vec<String>,
pub favorite: bool,
pub has_totp: bool,
}
#[derive(Debug, Clone, uniffi::Record)]
pub struct ItemDto {
/// Empty string means "new item".
pub id: String,
pub kind: ItemKindDto,
pub title: String,
pub username: Option<String>,
pub password: Option<String>,
pub urls: Vec<String>,
pub totp: Option<String>,
pub notes: Option<String>,
pub favorite: bool,
pub tags: Vec<String>,
pub modified_at: i64,
}
#[derive(Debug, Clone, uniffi::Record)]
pub struct TotpCode {
pub code: String,
pub seconds_remaining: u64,
pub period: u64,
}
struct Unlocked {
keyset: AccountKeyset,
vault_key: SymKey,
}
struct State {
account_id: Uuid,
kdf: KdfParams,
wrapped_keyset: Envelope,
vault_id: Uuid,
sealed_vault_key: Envelope,
items: HashMap<Uuid, SealedItem>,
unlocked: Option<Unlocked>,
}
/// One account's vault on this device.
#[derive(uniffi::Object)]
pub struct VaultCore {
state: Mutex<Option<State>>,
}
#[uniffi::export]
impl VaultCore {
#[uniffi::constructor]
pub fn new_in_memory() -> Arc<Self> {
Arc::new(Self { state: Mutex::new(None) })
}
/// Creates the account and its first vault, leaving it unlocked.
/// Returns the Secret Key display string for the Emergency Kit.
pub fn create_account(&self, master_password: String) -> Result<String> {
if master_password.chars().count() < 10 {
return Err(CoreError::Invalid { msg: "master password must be at least 10 characters".into() });
}
let account_id = Uuid::new_v4();
let secret_key = SecretKey::generate();
let params = kdf::default_params();
let derived = kdf::derive_keys(&master_password, &secret_key, account_id, &params)?;
let auk = SymKey::from_bytes(Uuid::nil(), *derived.auk);
let keyset = AccountKeyset::generate(account_id);
let wrapped_keyset = keyset.wrap(&auk)?;
let vault_id = Uuid::new_v4();
let (vault_key, sealed_vault_key) = keyset.new_vault_key(vault_id)?;
*self.lock_state() = Some(State {
account_id,
kdf: params,
wrapped_keyset,
vault_id,
sealed_vault_key,
items: HashMap::new(),
unlocked: Some(Unlocked { keyset, vault_key }),
});
Ok(secret_key.to_display().to_string())
}
pub fn unlock(&self, master_password: String, secret_key: String) -> Result<()> {
let mut guard = self.lock_state();
let st = guard.as_mut().ok_or(CoreError::NoAccount)?;
let sk = SecretKey::parse(&secret_key)?;
let derived = kdf::derive_keys(&master_password, &sk, st.account_id, &st.kdf)?;
let auk = SymKey::from_bytes(Uuid::nil(), *derived.auk);
let keyset = AccountKeyset::unwrap(st.account_id, &auk, &st.wrapped_keyset)?;
let vault_key = keyset.open_vault_key(st.vault_id, &st.sealed_vault_key)?;
st.unlocked = Some(Unlocked { keyset, vault_key });
Ok(())
}
/// Drops all decrypted key material (zeroized on drop).
pub fn lock(&self) {
if let Some(st) = self.lock_state().as_mut() {
st.unlocked = None;
}
}
pub fn is_locked(&self) -> bool {
self.lock_state().as_ref().is_none_or(|s| s.unlocked.is_none())
}
pub fn has_account(&self) -> bool {
self.lock_state().is_some()
}
pub fn list_items(&self) -> Result<Vec<ItemSummary>> {
self.with_unlocked(|st, u| {
let mut out = st
.items
.iter()
.map(|(id, sealed)| decrypt(st, u, *id, sealed).map(|i| summary(&i)))
.collect::<Result<Vec<_>>>()?;
out.sort_by(|a, b| b.favorite.cmp(&a.favorite).then_with(|| a.title.to_lowercase().cmp(&b.title.to_lowercase())));
Ok(out)
})
}
pub fn get_item(&self, id: String) -> Result<ItemDto> {
let id = parse_id(&id)?;
self.with_unlocked(|st, u| {
let sealed = st.items.get(&id).ok_or(CoreError::NotFound)?;
Ok(to_dto(&decrypt(st, u, id, sealed)?))
})
}
/// Inserts or updates an item. Returns its id.
pub fn save_item(&self, item: ItemDto) -> Result<String> {
let mut guard = self.lock_state();
let st = guard.as_mut().ok_or(CoreError::NoAccount)?;
let u = st.unlocked.as_ref().ok_or(CoreError::Locked)?;
let now = now();
let id = if item.id.is_empty() { Uuid::new_v4() } else { parse_id(&item.id)? };
let created_at = match st.items.get(&id) {
Some(sealed) => decrypt(st, u, id, sealed)?.created_at,
None => now,
};
let mut model = Item::new_login(item.title.clone(), created_at);
model.id = id;
model.kind = kind_from_dto(item.kind);
model.username = item.username;
model.password = item.password;
model.urls = item.urls;
model.totp = item.totp;
model.notes = item.notes;
model.favorite = item.favorite;
model.tags = item.tags;
model.modified_at = now;
let json = zeroize::Zeroizing::new(serde_json::to_vec(&model).map_err(|e| CoreError::Internal { msg: e.to_string() })?);
let sealed = seal_item(st.account_id, st.vault_id, id, &u.vault_key, &json)?;
st.items.insert(id, sealed);
Ok(id.to_string())
}
pub fn delete_item(&self, id: String) -> Result<()> {
let id = parse_id(&id)?;
let mut guard = self.lock_state();
let st = guard.as_mut().ok_or(CoreError::NoAccount)?;
st.unlocked.as_ref().ok_or(CoreError::Locked)?;
st.items.remove(&id).map(|_| ()).ok_or(CoreError::NotFound)
}
/// Autofill candidates for a page URL, best match first.
pub fn items_for_url(&self, page_url: String) -> Result<Vec<ItemSummary>> {
self.with_unlocked(|st, u| {
let mut scored = Vec::new();
for (id, sealed) in &st.items {
let item = decrypt(st, u, *id, sealed)?;
let best = item.urls.iter().map(|url| matching::match_url(url, &page_url)).max();
if let Some(level) = best.filter(|l| *l > matching::MatchLevel::None) {
scored.push((level, summary(&item)));
}
}
scored.sort_by(|a, b| b.0.cmp(&a.0).then_with(|| a.1.title.cmp(&b.1.title)));
Ok(scored.into_iter().map(|(_, s)| s).collect())
})
}
/// Ed25519 public key of the account (hex), shown when pairing devices.
pub fn account_fingerprint(&self) -> Result<String> {
self.with_unlocked(|_, u| Ok(u.keyset.sig_public().iter().map(|b| format!("{b:02x}")).collect()))
}
}
impl VaultCore {
fn lock_state(&self) -> std::sync::MutexGuard<'_, Option<State>> {
// A poisoned lock means a panic mid-operation; with panic=abort in release this can't happen.
self.state.lock().unwrap_or_else(|p| p.into_inner())
}
fn with_unlocked<T>(&self, f: impl FnOnce(&State, &Unlocked) -> Result<T>) -> Result<T> {
let guard = self.lock_state();
let st = guard.as_ref().ok_or(CoreError::NoAccount)?;
let u = st.unlocked.as_ref().ok_or(CoreError::Locked)?;
f(st, u)
}
}
#[uniffi::export]
pub fn generate_password(length: u32, symbols: bool) -> String {
generator::generate_password(&generator::PasswordOptions { length: length as usize, symbols, ..Default::default() })
}
#[uniffi::export]
pub fn totp_code(secret_or_uri: String, unix_time: u64) -> Result<TotpCode> {
let t = Totp::parse(&secret_or_uri).map_err(|e| CoreError::Invalid { msg: e.to_string() })?;
Ok(TotpCode { code: t.code_at(unix_time), seconds_remaining: t.remaining(unix_time), period: t.period })
}
#[uniffi::export]
pub fn match_url(item_url: String, page_url: String) -> MatchLevelDto {
match matching::match_url(&item_url, &page_url) {
matching::MatchLevel::None => MatchLevelDto::None,
matching::MatchLevel::Domain => MatchLevelDto::Domain,
matching::MatchLevel::Host => MatchLevelDto::Host,
matching::MatchLevel::Exact => MatchLevelDto::Exact,
}
}
#[uniffi::export]
pub fn core_version() -> String {
env!("CARGO_PKG_VERSION").to_string()
}
fn decrypt(st: &State, u: &Unlocked, id: Uuid, sealed: &SealedItem) -> Result<Item> {
let pt = open_item(st.account_id, st.vault_id, id, &u.vault_key, sealed)?;
serde_json::from_slice(&pt).map_err(|e| CoreError::Internal { msg: e.to_string() })
}
fn summary(i: &Item) -> ItemSummary {
ItemSummary {
id: i.id.to_string(),
kind: kind_to_dto(i.kind),
title: i.title.clone(),
username: i.username.clone(),
urls: i.urls.clone(),
favorite: i.favorite,
has_totp: i.totp.is_some(),
}
}
fn to_dto(i: &Item) -> ItemDto {
ItemDto {
id: i.id.to_string(),
kind: kind_to_dto(i.kind),
title: i.title.clone(),
username: i.username.clone(),
password: i.password.clone(),
urls: i.urls.clone(),
totp: i.totp.clone(),
notes: i.notes.clone(),
favorite: i.favorite,
tags: i.tags.clone(),
modified_at: i.modified_at,
}
}
fn kind_to_dto(k: ItemKind) -> ItemKindDto {
match k {
ItemKind::Login => ItemKindDto::Login,
ItemKind::SecureNote => ItemKindDto::SecureNote,
ItemKind::Card => ItemKindDto::Card,
ItemKind::Identity => ItemKindDto::Identity,
}
}
fn kind_from_dto(k: ItemKindDto) -> ItemKind {
match k {
ItemKindDto::Login => ItemKind::Login,
ItemKindDto::SecureNote => ItemKind::SecureNote,
ItemKindDto::Card => ItemKind::Card,
ItemKindDto::Identity => ItemKind::Identity,
}
}
fn parse_id(s: &str) -> Result<Uuid> {
Uuid::parse_str(s).map_err(|_| CoreError::Invalid { msg: "item id".into() })
}
fn now() -> i64 {
SystemTime::now().duration_since(UNIX_EPOCH).map(|d| d.as_secs() as i64).unwrap_or(0)
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn create_lock_unlock_crud() {
let core = VaultCore::new_in_memory();
let sk = core.create_account("a long master password".into()).unwrap();
let id = core
.save_item(ItemDto {
id: String::new(),
kind: ItemKindDto::Login,
title: "Example".into(),
username: Some("me".into()),
password: Some("s3cret".into()),
urls: vec!["https://example.com".into()],
totp: None,
notes: None,
favorite: false,
tags: vec![],
modified_at: 0,
})
.unwrap();
core.lock();
assert!(matches!(core.list_items(), Err(CoreError::Locked)));
assert!(matches!(core.unlock("wrong password!!".into(), sk.clone()), Err(CoreError::BadCredentials)));
core.unlock("a long master password".into(), sk).unwrap();
assert_eq!(core.get_item(id).unwrap().password.as_deref(), Some("s3cret"));
assert_eq!(core.items_for_url("https://login.example.com/x".into()).unwrap().len(), 1);
assert!(core.items_for_url("https://example.org".into()).unwrap().is_empty());
}
}