M1: persistent vault, local unlock key, sync client, test vectors
kv-store: SQLite (bundled) and in-memory Store holding only ciphertext envelopes, with base copies for three-way merges and a 10-version history. kv-ffi: VaultCore::open(path); persisted account record; local unlock key (enable/unlock_with_local_key/disable); async sync API (configure_server, set_device_signer, register_account, login, sync_now, logout, devices); DeviceSigner foreign trait; IntegrityAlarm/PinMismatch/... error variants. kv-sync: reqwest + rustls (TLS 1.3 only, ring) with SPKI pinning; OPAQUE client on kv-proto's OpaqueSuite; RFC 9421 signing per kv_proto::httpsig; pull/push with If-Match, field-level three-way merge, edit-beats-delete; signed manifest (canonical CBOR, Merkle root, prev chain, Ed25519) with rollback/fork/Merkle checks as hard integrity alarms. Tested against a wiremock fake server, plus an #[ignore] test against kestrove-server. kv-crypto: gen-vectors example and tests reproducing kestrove-spec/vectors. Co-Authored-By: Claude Opus 5.5 <[email protected]>
This commit is contained in:
@@ -18,8 +18,18 @@ path = "src/bin/uniffi-bindgen.rs"
|
||||
kv-proto.workspace = true
|
||||
kv-crypto.workspace = true
|
||||
kv-model.workspace = true
|
||||
kv-store.workspace = true
|
||||
kv-sync.workspace = true
|
||||
serde.workspace = true
|
||||
hkdf.workspace = true
|
||||
sha2.workspace = true
|
||||
tokio = { workspace = true }
|
||||
uuid.workspace = true
|
||||
thiserror.workspace = true
|
||||
serde_json.workspace = true
|
||||
zeroize.workspace = true
|
||||
uniffi = { workspace = true, features = ["cli"] }
|
||||
uniffi = { workspace = true, features = ["cli", "tokio"] }
|
||||
|
||||
[dev-dependencies]
|
||||
kv-sync = { workspace = true, features = ["test-server"] }
|
||||
tempfile.workspace = true
|
||||
|
||||
+814
-123
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,283 @@
|
||||
use kv_sync::{
|
||||
signing::SoftwareSigner,
|
||||
test_server::{TestServer, SETUP_TOKEN},
|
||||
};
|
||||
|
||||
use super::*;
|
||||
|
||||
const PW: &str = "a long master password";
|
||||
|
||||
fn login_item(title: &str, password: &str) -> ItemDto {
|
||||
ItemDto {
|
||||
id: String::new(),
|
||||
kind: ItemKindDto::Login,
|
||||
title: title.into(),
|
||||
username: Some("me".into()),
|
||||
password: Some(password.into()),
|
||||
urls: vec!["https://example.com".into()],
|
||||
totp: None,
|
||||
notes: None,
|
||||
favorite: false,
|
||||
tags: vec![],
|
||||
modified_at: 0,
|
||||
}
|
||||
}
|
||||
|
||||
struct TestSigner(SoftwareSigner);
|
||||
|
||||
impl DeviceSigner for TestSigner {
|
||||
fn device_id(&self) -> String {
|
||||
kv_sync::DeviceSigner::device_id(&self.0).to_string()
|
||||
}
|
||||
fn public_key(&self) -> Vec<u8> {
|
||||
kv_sync::DeviceSigner::public_key(&self.0)
|
||||
}
|
||||
fn sign(&self, message: Vec<u8>) -> Result<Vec<u8>> {
|
||||
kv_sync::DeviceSigner::sign(&self.0, &message).map_err(internal)
|
||||
}
|
||||
}
|
||||
|
||||
fn connect(core: &VaultCore, server: &TestServer) {
|
||||
core.configure_server(server.url(), vec![]).unwrap();
|
||||
core.set_device_signer(
|
||||
Arc::new(TestSigner(SoftwareSigner::generate(Uuid::new_v4()))),
|
||||
"test".into(),
|
||||
PlatformDto::Macos,
|
||||
)
|
||||
.unwrap();
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn create_lock_unlock_crud() {
|
||||
let core = VaultCore::new_in_memory();
|
||||
let sk = core.create_account(PW.into()).unwrap();
|
||||
let id = core.save_item(login_item("Example", "s3cret")).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(PW.into(), sk).unwrap();
|
||||
assert_eq!(core.get_item(id.clone()).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());
|
||||
|
||||
// Edits keep history; deletes hide the item.
|
||||
let mut dto = core.get_item(id.clone()).unwrap();
|
||||
dto.password = Some("n3w".into());
|
||||
core.save_item(dto).unwrap();
|
||||
assert_eq!(core.item_history(id.clone()).unwrap()[0].password.as_deref(), Some("s3cret"));
|
||||
core.delete_item(id.clone()).unwrap();
|
||||
assert!(matches!(core.get_item(id.clone()), Err(CoreError::NotFound)));
|
||||
assert!(core.list_items().unwrap().is_empty());
|
||||
assert!(matches!(core.delete_item(id), Err(CoreError::NotFound)));
|
||||
assert!(matches!(core.create_account(PW.into()), Err(CoreError::Invalid { .. })));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn persistent_vault_survives_reopen() {
|
||||
let dir = tempfile::tempdir().unwrap();
|
||||
let path = dir.path().join("vault.sqlite").to_string_lossy().into_owned();
|
||||
let (sk, id, account) = {
|
||||
let core = VaultCore::open(path.clone()).unwrap();
|
||||
assert!(!core.has_account());
|
||||
let sk = core.create_account(PW.into()).unwrap();
|
||||
let id = core.save_item(login_item("Bank", "pw")).unwrap();
|
||||
(sk, id, core.account_id().unwrap())
|
||||
};
|
||||
let core = VaultCore::open(path.clone()).unwrap();
|
||||
assert!(core.has_account() && core.is_locked());
|
||||
assert_eq!(core.account_id().unwrap(), account);
|
||||
core.unlock(PW.into(), sk).unwrap();
|
||||
assert_eq!(core.get_item(id).unwrap().title, "Bank");
|
||||
|
||||
// Only ciphertext is on disk.
|
||||
for f in [path.clone(), format!("{path}-wal")] {
|
||||
let raw = std::fs::read(&f).unwrap_or_default();
|
||||
let hay = String::from_utf8_lossy(&raw);
|
||||
assert!(!hay.contains("Bank") && !hay.contains(PW), "{f}");
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn local_unlock_key() {
|
||||
let dir = tempfile::tempdir().unwrap();
|
||||
let path = dir.path().join("v.sqlite").to_string_lossy().into_owned();
|
||||
let core = VaultCore::open(path.clone()).unwrap();
|
||||
core.create_account(PW.into()).unwrap();
|
||||
let id = core.save_item(login_item("Mail", "pw")).unwrap();
|
||||
assert!(!core.has_local_unlock());
|
||||
|
||||
let luk = core.enable_local_unlock().unwrap();
|
||||
assert_eq!(luk.len(), 32);
|
||||
assert!(core.has_local_unlock());
|
||||
core.lock();
|
||||
assert!(matches!(core.enable_local_unlock(), Err(CoreError::Locked)));
|
||||
assert!(matches!(core.unlock_with_local_key(vec![0; 32]), Err(CoreError::BadCredentials)));
|
||||
assert!(matches!(core.unlock_with_local_key(vec![0; 16]), Err(CoreError::Invalid { .. })));
|
||||
core.unlock_with_local_key(luk.clone()).unwrap();
|
||||
assert_eq!(core.get_item(id.clone()).unwrap().title, "Mail");
|
||||
drop(core);
|
||||
|
||||
// Persisted, and the AutoFill extension (another process) can use it.
|
||||
let core = VaultCore::open(path.clone()).unwrap();
|
||||
assert!(core.has_local_unlock());
|
||||
core.unlock_with_local_key(luk.clone()).unwrap();
|
||||
assert_eq!(core.list_items().unwrap().len(), 1);
|
||||
|
||||
// Re-enabling rotates the key.
|
||||
let luk2 = core.enable_local_unlock().unwrap();
|
||||
assert_ne!(luk, luk2);
|
||||
core.lock();
|
||||
assert!(core.unlock_with_local_key(luk).is_err());
|
||||
core.unlock_with_local_key(luk2.clone()).unwrap();
|
||||
|
||||
core.disable_local_unlock().unwrap();
|
||||
core.lock();
|
||||
assert!(matches!(core.unlock_with_local_key(luk2), Err(CoreError::Invalid { .. })));
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn sync_needs_configuration() {
|
||||
let core = VaultCore::new_in_memory();
|
||||
core.create_account(PW.into()).unwrap();
|
||||
assert!(matches!(core.sync_now().await, Err(CoreError::NotConfigured)));
|
||||
assert!(core.configure_server("http://vault.example.com".into(), vec![]).is_err());
|
||||
assert!(core.configure_server("https://vault.example.com".into(), vec!["not a pin".into()]).is_err());
|
||||
core.configure_server("https://vault.example.com".into(), vec![]).unwrap();
|
||||
assert!(matches!(core.sync_now().await, Err(CoreError::NotConfigured)), "no device signer yet");
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn two_devices_sync_through_server() {
|
||||
let server = TestServer::start().await;
|
||||
let dir = tempfile::tempdir().unwrap();
|
||||
|
||||
// Device A: create, register, add an item, sync.
|
||||
let a = VaultCore::open(dir.path().join("a.sqlite").to_string_lossy().into_owned()).unwrap();
|
||||
let sk = a.create_account(PW.into()).unwrap();
|
||||
connect(&a, &server);
|
||||
assert!(a.server_info().await.unwrap().setup_open);
|
||||
a.register_account(SETUP_TOKEN.into()).await.unwrap();
|
||||
assert!(a.is_logged_in());
|
||||
let id = a.save_item(login_item("Bank", "pw-a")).unwrap();
|
||||
let r = a.sync_now().await.unwrap();
|
||||
assert_eq!((r.pushed, r.vaults_synced), (1, 1));
|
||||
|
||||
// Device B: brand new, logs in with account id + password + Secret Key.
|
||||
let b_path = dir.path().join("b.sqlite").to_string_lossy().into_owned();
|
||||
let b = VaultCore::open(b_path.clone()).unwrap();
|
||||
connect(&b, &server);
|
||||
assert!(matches!(b.login("wrong password!!".into(), sk.clone(), None).await, Err(CoreError::BadCredentials)));
|
||||
assert_eq!(b.login(PW.into(), sk.clone(), None).await.unwrap(), LoginResult::Ok);
|
||||
assert!(b.has_account() && !b.is_locked());
|
||||
b.sync_now().await.unwrap();
|
||||
assert_eq!(b.get_item(id.clone()).unwrap().password.as_deref(), Some("pw-a"));
|
||||
assert_eq!(b.account_fingerprint().unwrap(), a.account_fingerprint().unwrap());
|
||||
|
||||
// Concurrent edits to different fields merge.
|
||||
let mut on_a = a.get_item(id.clone()).unwrap();
|
||||
on_a.notes = Some("from A".into());
|
||||
a.save_item(on_a).unwrap();
|
||||
let mut on_b = b.get_item(id.clone()).unwrap();
|
||||
on_b.username = Some("from-b".into());
|
||||
b.save_item(on_b).unwrap();
|
||||
a.sync_now().await.unwrap();
|
||||
assert_eq!(b.sync_now().await.unwrap().merged, 1);
|
||||
a.sync_now().await.unwrap();
|
||||
for core in [&a, &b] {
|
||||
let it = core.get_item(id.clone()).unwrap();
|
||||
assert_eq!(it.notes.as_deref(), Some("from A"));
|
||||
assert_eq!(it.username.as_deref(), Some("from-b"));
|
||||
}
|
||||
|
||||
// B enables biometrics, restarts, unlocks with the LUK; an expired session renews itself.
|
||||
let luk = b.enable_local_unlock().unwrap();
|
||||
drop(b);
|
||||
let b = VaultCore::open(b_path).unwrap();
|
||||
connect(&b, &server);
|
||||
b.unlock_with_local_key(luk).unwrap();
|
||||
server.state().sessions.clear();
|
||||
b.save_item(login_item("Second", "x")).unwrap();
|
||||
assert_eq!(b.sync_now().await.unwrap().pushed, 1);
|
||||
a.sync_now().await.unwrap();
|
||||
assert_eq!(a.list_items().unwrap().len(), 2);
|
||||
|
||||
// Delete propagates.
|
||||
a.delete_item(id.clone()).unwrap();
|
||||
a.sync_now().await.unwrap();
|
||||
b.sync_now().await.unwrap();
|
||||
assert!(matches!(b.get_item(id), Err(CoreError::NotFound)));
|
||||
|
||||
b.logout().await.unwrap();
|
||||
assert!(!b.is_logged_in());
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn integrity_alarm_reaches_the_app() {
|
||||
let server = TestServer::start().await;
|
||||
let a = VaultCore::new_in_memory();
|
||||
a.create_account(PW.into()).unwrap();
|
||||
connect(&a, &server);
|
||||
a.register_account(SETUP_TOKEN.into()).await.unwrap();
|
||||
for i in 0..2 {
|
||||
a.save_item(login_item(&format!("item {i}"), "p")).unwrap();
|
||||
a.sync_now().await.unwrap();
|
||||
}
|
||||
let vid = *server.state().vaults.keys().next().unwrap();
|
||||
let first = server.state().vaults[&vid].manifest_history[0].clone();
|
||||
server.state().serve_manifest = Some(first);
|
||||
match a.sync_now().await {
|
||||
Err(CoreError::IntegrityAlarm { vault_id, reason }) => {
|
||||
assert_eq!(vault_id, vid.to_string());
|
||||
assert!(reason.contains("rollback"));
|
||||
}
|
||||
other => panic!("expected integrity alarm, got {other:?}"),
|
||||
}
|
||||
// The vault stays readable locally.
|
||||
assert_eq!(a.list_items().unwrap().len(), 2);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn pending_approval_and_biometric_session_expiry() {
|
||||
let server = TestServer::start().await;
|
||||
let a = VaultCore::new_in_memory();
|
||||
let sk = a.create_account(PW.into()).unwrap();
|
||||
connect(&a, &server);
|
||||
a.register_account(SETUP_TOKEN.into()).await.unwrap();
|
||||
a.sync_now().await.unwrap();
|
||||
|
||||
server.state().require_approval = true;
|
||||
let b = VaultCore::new_in_memory();
|
||||
connect(&b, &server);
|
||||
assert_eq!(b.login(PW.into(), sk.clone(), None).await.unwrap(), LoginResult::PendingApproval);
|
||||
assert!(!b.has_account());
|
||||
// A approves B after comparing fingerprints, then B's next login succeeds.
|
||||
let devices = a.list_devices().await.unwrap();
|
||||
let pending = devices.iter().find(|d| !d.approved).unwrap();
|
||||
assert_eq!(pending.key_fingerprint, b.device_key_fingerprint().unwrap());
|
||||
assert!(devices.iter().any(|d| d.current && d.approved));
|
||||
a.approve_device(pending.device_id.clone()).await.unwrap();
|
||||
assert_eq!(b.login(PW.into(), sk, None).await.unwrap(), LoginResult::Ok);
|
||||
b.sync_now().await.unwrap();
|
||||
// Revoking B kills its session.
|
||||
a.revoke_device(pending.device_id.clone()).await.unwrap();
|
||||
assert!(matches!(b.sync_now().await, Err(CoreError::NotAuthenticated | CoreError::PendingApproval)));
|
||||
|
||||
// A biometric unlock without a stored OPAQUE input cannot renew the session: NotAuthenticated.
|
||||
let c = VaultCore::new_in_memory();
|
||||
let sk_c = c.create_account(PW.into()).unwrap();
|
||||
let luk = {
|
||||
// Simulate an old LUK record without auth input by unlocking via LUK after a fresh enable.
|
||||
c.lock();
|
||||
c.unlock(PW.into(), sk_c).unwrap();
|
||||
let luk = c.enable_local_unlock().unwrap();
|
||||
let mut inner = c.lock_inner();
|
||||
inner.account.as_mut().unwrap().local_unlock.as_mut().unwrap().auth_input = None;
|
||||
luk
|
||||
};
|
||||
c.lock();
|
||||
c.unlock_with_local_key(luk).unwrap();
|
||||
connect(&c, &server);
|
||||
assert!(matches!(c.register_account("x".into()).await, Err(CoreError::NotAuthenticated)));
|
||||
}
|
||||
@@ -0,0 +1,102 @@
|
||||
//! Two devices against a real, freshly started kestrove-server. Ignored by default.
|
||||
//!
|
||||
//! ```sh
|
||||
//! # in kestrove-server (empty database, so it prints SETUP_TOKEN=… on boot):
|
||||
//! KV_DATABASE=sqlite::memory: KV_BIND=127.0.0.1:8080 KV_PUBLIC_URL=http://127.0.0.1:8080 cargo run
|
||||
//! # here:
|
||||
//! KESTROVE_SERVER_URL=http://127.0.0.1:8080 KESTROVE_SETUP_TOKEN=<token> \
|
||||
//! cargo test -p kv-ffi --test live_server -- --ignored --nocapture
|
||||
//! ```
|
||||
//! The setup token works once, so restart the server with an empty database for every run.
|
||||
|
||||
use std::sync::Arc;
|
||||
|
||||
use kv_ffi::{CoreError, DeviceSigner, ItemDto, ItemKindDto, LoginResult, PlatformDto, VaultCore};
|
||||
use kv_sync::signing::SoftwareSigner;
|
||||
use uuid::Uuid;
|
||||
|
||||
struct Signer(SoftwareSigner);
|
||||
|
||||
impl DeviceSigner for Signer {
|
||||
fn device_id(&self) -> String {
|
||||
kv_sync::DeviceSigner::device_id(&self.0).to_string()
|
||||
}
|
||||
fn public_key(&self) -> Vec<u8> {
|
||||
kv_sync::DeviceSigner::public_key(&self.0)
|
||||
}
|
||||
fn sign(&self, message: Vec<u8>) -> Result<Vec<u8>, CoreError> {
|
||||
kv_sync::DeviceSigner::sign(&self.0, &message).map_err(|e| CoreError::Internal { msg: e.to_string() })
|
||||
}
|
||||
}
|
||||
|
||||
fn connect(core: &VaultCore, url: &str, name: &str) {
|
||||
core.configure_server(url.into(), vec![]).unwrap();
|
||||
core.set_device_signer(Arc::new(Signer(SoftwareSigner::generate(Uuid::new_v4()))), name.into(), PlatformDto::Linux)
|
||||
.unwrap();
|
||||
}
|
||||
|
||||
fn item(title: &str, password: &str) -> ItemDto {
|
||||
ItemDto {
|
||||
id: String::new(),
|
||||
kind: ItemKindDto::Login,
|
||||
title: title.into(),
|
||||
username: Some("me".into()),
|
||||
password: Some(password.into()),
|
||||
urls: vec!["https://example.com".into()],
|
||||
totp: None,
|
||||
notes: None,
|
||||
favorite: false,
|
||||
tags: vec![],
|
||||
modified_at: 0,
|
||||
}
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
#[ignore = "needs a fresh local kestrove-server; see the module docs"]
|
||||
async fn two_devices_against_local_server() {
|
||||
let url = std::env::var("KESTROVE_SERVER_URL").unwrap_or_else(|_| "http://127.0.0.1:8080".into());
|
||||
let token =
|
||||
std::env::var("KESTROVE_SETUP_TOKEN").expect("set KESTROVE_SETUP_TOKEN to the token the server printed");
|
||||
let pw = "a long master password for the live test";
|
||||
|
||||
let a = VaultCore::new_in_memory();
|
||||
let sk = a.create_account(pw.into()).unwrap();
|
||||
connect(&a, &url, "device A");
|
||||
assert!(a.server_info().await.unwrap().setup_open, "server must be fresh (setup open)");
|
||||
a.register_account(token).await.unwrap();
|
||||
let id = a.save_item(item("Bank", "pw-a")).unwrap();
|
||||
let r = a.sync_now().await.unwrap();
|
||||
assert_eq!(r.pushed, 1);
|
||||
|
||||
// The server allows 5 unauthenticated auth calls per minute per IP and both devices share one.
|
||||
tokio::time::sleep(std::time::Duration::from_secs(61)).await;
|
||||
let b = VaultCore::new_in_memory();
|
||||
connect(&b, &url, "device B");
|
||||
if b.login(pw.into(), sk.clone(), None).await.unwrap() == LoginResult::PendingApproval {
|
||||
let devices = a.list_devices().await.unwrap();
|
||||
let pending = devices.iter().find(|d| !d.approved).expect("B listed as pending");
|
||||
assert_eq!(pending.key_fingerprint, b.device_key_fingerprint().unwrap());
|
||||
a.approve_device(pending.device_id.clone()).await.unwrap();
|
||||
tokio::time::sleep(std::time::Duration::from_secs(61)).await; // rate limit again
|
||||
assert_eq!(b.login(pw.into(), sk.clone(), None).await.unwrap(), LoginResult::Ok);
|
||||
}
|
||||
b.sync_now().await.unwrap();
|
||||
assert_eq!(b.get_item(id.clone()).unwrap().password.as_deref(), Some("pw-a"));
|
||||
|
||||
// Conflicting edits merge.
|
||||
let mut on_a = a.get_item(id.clone()).unwrap();
|
||||
on_a.notes = Some("A".into());
|
||||
a.save_item(on_a).unwrap();
|
||||
let mut on_b = b.get_item(id.clone()).unwrap();
|
||||
on_b.username = Some("b".into());
|
||||
b.save_item(on_b).unwrap();
|
||||
a.sync_now().await.unwrap();
|
||||
assert_eq!(b.sync_now().await.unwrap().merged, 1);
|
||||
a.sync_now().await.unwrap();
|
||||
let (ia, ib) = (a.get_item(id.clone()).unwrap(), b.get_item(id).unwrap());
|
||||
assert_eq!((ia.notes.as_deref(), ia.username.as_deref()), (Some("A"), Some("b")));
|
||||
assert_eq!((ib.notes, ib.username), (ia.notes, ia.username));
|
||||
|
||||
b.logout().await.unwrap();
|
||||
println!("live sync OK against {url}");
|
||||
}
|
||||
Reference in New Issue
Block a user