Initial scaffold for kestrove-core
Co-Authored-By: Claude Opus 5.5 <[email protected]>
This commit is contained in:
@@ -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());
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user