- kv-proto: email on setup, AccountInfo.email/is_admin, ServerInfo.registration, admin DTOs. - kv-sync: prelogin by email or account id (setup QR / Emergency Kit), register with email, set_email, /v1/admin/* client calls. - kv-ffi: register_account(code, email), login(.., email), account_details, set_account_email, admin_create_invite. kv-wasm: the same plus the full admin API for the web vault. - Live test: two users on one server stay isolated; a new device signs in by email. Co-Authored-By: Claude Opus 5.5 <[email protected]>
853 lines
35 KiB
Rust
853 lines
35 KiB
Rust
//! End-to-end sync tests against the in-memory fake server (wiremock).
|
|
|
|
use std::sync::Arc;
|
|
|
|
use kv_crypto::{
|
|
aad::{Aad, ObjectType},
|
|
aead::SymKey,
|
|
keys::AccountKeyset,
|
|
};
|
|
use kv_proto::{DeviceEnroll, ItemRecord, Platform};
|
|
use kv_store::{MemoryStore, Store, SyncState, VaultRow};
|
|
use kv_sync::{
|
|
engine::{next_local_version, open_record, open_sealed, seal_record, seal_tombstone},
|
|
replay,
|
|
signing::SoftwareSigner,
|
|
test_server::{TestServer, SETUP_TOKEN},
|
|
ApiClient, DeviceSigner, LoginOutcome, ServerConfig, SyncContext, SyncError, SyncReport,
|
|
};
|
|
use serde_json::{json, Value};
|
|
use uuid::Uuid;
|
|
|
|
const AUTH: [u8; 32] = [7; 32];
|
|
|
|
fn test_kdf() -> kv_proto::KdfParams {
|
|
kv_crypto::kdf::default_params()
|
|
}
|
|
|
|
struct Device {
|
|
store: MemoryStore,
|
|
api: ApiClient,
|
|
keyset: AccountKeyset,
|
|
signer: Arc<SoftwareSigner>,
|
|
}
|
|
|
|
fn copy_keyset(ks: &AccountKeyset) -> AccountKeyset {
|
|
let k = SymKey::generate();
|
|
AccountKeyset::unwrap(ks.account_id, &k, &ks.wrap(&k).unwrap()).unwrap()
|
|
}
|
|
|
|
fn enroll(signer: &SoftwareSigner) -> DeviceEnroll {
|
|
DeviceEnroll {
|
|
device_id: signer.device_id(),
|
|
name: "test".into(),
|
|
platform: Platform::Linux,
|
|
p256_pk: signer.public_key(),
|
|
}
|
|
}
|
|
|
|
impl Device {
|
|
fn new(server: &TestServer, keyset: AccountKeyset) -> Self {
|
|
let signer = Arc::new(SoftwareSigner::generate(Uuid::new_v4()));
|
|
let api = ApiClient::new(&ServerConfig::new(server.url(), vec![]), signer.clone()).unwrap();
|
|
Self { store: MemoryStore::new(), api, keyset, signer }
|
|
}
|
|
|
|
/// First device: registers the account and creates a vault locally.
|
|
async fn first(server: &TestServer) -> (Self, Uuid) {
|
|
let keyset = AccountKeyset::generate(Uuid::new_v4());
|
|
let d = Self::new(server, keyset);
|
|
let wrapped = d.keyset.wrap(&SymKey::generate()).unwrap();
|
|
d.api
|
|
.register(
|
|
SETUP_TOKEN,
|
|
None,
|
|
d.keyset.account_id,
|
|
&test_kdf(),
|
|
&AUTH,
|
|
d.keyset.enc.public_bytes(),
|
|
d.keyset.sig_public().to_vec(),
|
|
wrapped,
|
|
enroll(&d.signer),
|
|
)
|
|
.await
|
|
.unwrap();
|
|
assert!(matches!(d.login().await, LoginOutcome::Ok { .. }));
|
|
let vid = Uuid::new_v4();
|
|
let (vk, sealed) = d.keyset.new_vault_key(vid).unwrap();
|
|
let meta = vk
|
|
.seal(br#"{"name":"Personal"}"#, &Aad::build(ObjectType::VaultMeta, d.keyset.account_id, Some(vid), None))
|
|
.unwrap();
|
|
d.store.put_vault(&VaultRow::new(vid, sealed, Some(meta))).unwrap();
|
|
(d, vid)
|
|
}
|
|
|
|
async fn second(server: &TestServer, first: &Device) -> Self {
|
|
let d = Self::new(server, copy_keyset(&first.keyset));
|
|
assert!(matches!(d.login().await, LoginOutcome::Ok { .. }));
|
|
d
|
|
}
|
|
|
|
async fn login(&self) -> LoginOutcome {
|
|
self.api.login(self.keyset.account_id, &AUTH, enroll(&self.signer), None).await.unwrap()
|
|
}
|
|
|
|
fn ctx(&self) -> SyncContext<'_> {
|
|
SyncContext { api: &self.api, store: &self.store, keyset: &self.keyset, device_id: self.signer.device_id() }
|
|
}
|
|
|
|
async fn sync(&self) -> Result<SyncReport, SyncError> {
|
|
self.ctx().sync_all().await
|
|
}
|
|
|
|
fn vk(&self, vid: Uuid) -> SymKey {
|
|
self.keyset.open_vault_key(vid, &self.store.vault(vid).unwrap().unwrap().sealed_key).unwrap()
|
|
}
|
|
|
|
/// Saves a local edit the way a client does: with the next `seq` stamped in.
|
|
fn save(&self, vid: Uuid, id: Uuid, mut item: Value) {
|
|
let seq = next_local_version(&self.store, self.keyset.account_id, vid, &self.vk(vid), id).unwrap();
|
|
replay::stamp(&mut item, seq);
|
|
self.save_raw(vid, id, item);
|
|
}
|
|
|
|
/// Saves exactly `item`, e.g. a legacy item without `seq`.
|
|
fn save_raw(&self, vid: Uuid, id: Uuid, item: Value) {
|
|
let rec = seal_record(self.keyset.account_id, vid, &self.vk(vid), id, &item, self.signer.device_id()).unwrap();
|
|
self.store.put_local(vid, &rec).unwrap();
|
|
}
|
|
|
|
/// Deletes the way a client does: a sealed tombstone with the next `seq`.
|
|
fn delete(&self, vid: Uuid, id: Uuid) {
|
|
let (acct, vk) = (self.keyset.account_id, self.vk(vid));
|
|
let seq = next_local_version(&self.store, acct, vid, &vk, id).unwrap();
|
|
let tomb = seal_tombstone(acct, vid, &vk, id, seq, self.signer.device_id()).unwrap();
|
|
self.store.put_local(vid, &tomb).unwrap();
|
|
}
|
|
|
|
/// A pending delete as stored before tombstones were sealed (no ciphertext).
|
|
fn delete_legacy(&self, vid: Uuid, id: Uuid) {
|
|
let mut cur = self.store.get(vid, id).unwrap().unwrap().record;
|
|
cur.deleted = true;
|
|
cur.wrapped_item_key = None;
|
|
cur.payload = None;
|
|
self.store.put_local(vid, &cur).unwrap();
|
|
}
|
|
|
|
/// The sealed plaintext of the item's base (live or tombstone).
|
|
fn read_base(&self, vid: Uuid, id: Uuid) -> Option<Value> {
|
|
let base = self.store.get(vid, id).unwrap()?.base?;
|
|
open_sealed(self.keyset.account_id, vid, &self.vk(vid), &base).unwrap()
|
|
}
|
|
|
|
fn read(&self, vid: Uuid, id: Uuid) -> Option<Value> {
|
|
let item = self.store.get(vid, id).unwrap()?;
|
|
open_record(self.keyset.account_id, vid, &self.vk(vid), &item.record).unwrap()
|
|
}
|
|
}
|
|
|
|
fn item(id: Uuid, title: &str, password: &str, modified_at: i64) -> Value {
|
|
json!({"id": id, "kind": "login", "title": title, "password": password, "created_at": 1, "modified_at": modified_at})
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn two_devices_roundtrip_and_manifest_chain() {
|
|
let server = TestServer::start().await;
|
|
let (a, vid) = Device::first(&server).await;
|
|
let id = Uuid::new_v4();
|
|
a.save(vid, id, item(id, "Bank", "pw1", 10));
|
|
|
|
let r = a.sync().await.unwrap();
|
|
assert_eq!(r.pushed, 1);
|
|
assert_eq!(a.store.get(vid, id).unwrap().unwrap().state, SyncState::Clean);
|
|
let m1 = server.state().vaults[&vid].manifest.clone().unwrap();
|
|
// First sync publishes v1 (new vault) …
|
|
assert_eq!(m1.version, 1);
|
|
|
|
let b = Device::second(&server, &a).await;
|
|
let r = b.sync().await.unwrap();
|
|
assert_eq!(r.vaults_synced, 1);
|
|
assert_eq!(b.read(vid, id).unwrap()["password"], "pw1");
|
|
assert_eq!(b.store.vault(vid).unwrap().unwrap().manifest_version, 1);
|
|
|
|
b.save(vid, id, item(id, "Bank", "pw2", 20));
|
|
assert_eq!(b.sync().await.unwrap().pushed, 1);
|
|
assert_eq!(server.state().vaults[&vid].manifest.as_ref().unwrap().version, 2);
|
|
|
|
a.sync().await.unwrap();
|
|
assert_eq!(a.read(vid, id).unwrap()["password"], "pw2");
|
|
assert_eq!(a.store.vault(vid).unwrap().unwrap().manifest_version, 2);
|
|
// Nothing to do: no new manifest is published.
|
|
a.sync().await.unwrap();
|
|
assert_eq!(server.state().vaults[&vid].manifest.as_ref().unwrap().version, 2);
|
|
// The server only ever saw ciphertext.
|
|
let raw = serde_json::to_string(&server.state().vaults[&vid].items).unwrap();
|
|
assert!(!raw.contains("pw2") && !raw.contains("Bank"));
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn concurrent_edits_merge_field_by_field() {
|
|
let server = TestServer::start().await;
|
|
let (a, vid) = Device::first(&server).await;
|
|
let id = Uuid::new_v4();
|
|
a.save(vid, id, json!({"id": id, "kind": "login", "title": "Mail", "username": "me", "password": "p0", "notes": "n0", "created_at": 1, "modified_at": 10}));
|
|
a.sync().await.unwrap();
|
|
let b = Device::second(&server, &a).await;
|
|
b.sync().await.unwrap();
|
|
|
|
// A changes username + password (later), B changes notes + password (earlier).
|
|
a.save(vid, id, json!({"id": id, "kind": "login", "title": "Mail", "username": "me2", "password": "pA", "notes": "n0", "created_at": 1, "modified_at": 30}));
|
|
b.save(vid, id, json!({"id": id, "kind": "login", "title": "Mail", "username": "me", "password": "pB", "notes": "nB", "created_at": 1, "modified_at": 20}));
|
|
a.sync().await.unwrap();
|
|
|
|
let r = b.sync().await.unwrap();
|
|
assert_eq!(r.merged, 1, "B hit 412 and merged");
|
|
assert_eq!(r.pushed, 1);
|
|
let merged = b.read(vid, id).unwrap();
|
|
assert_eq!(merged["username"], "me2");
|
|
assert_eq!(merged["notes"], "nB");
|
|
assert_eq!(merged["password"], "pA", "later modified_at wins the same field");
|
|
assert_eq!(merged["modified_at"], 30);
|
|
// B's losing version is kept in history.
|
|
let hist = b.store.history(vid, id).unwrap();
|
|
assert_eq!(hist.len(), 1);
|
|
let lost = open_record(
|
|
b.keyset.account_id,
|
|
vid,
|
|
&b.vk(vid),
|
|
&ItemRecord {
|
|
item_id: id,
|
|
revision: hist[0].revision,
|
|
deleted: false,
|
|
wrapped_item_key: Some(hist[0].wrapped_item_key.clone()),
|
|
payload: Some(hist[0].payload.clone()),
|
|
updated_by: Uuid::nil(),
|
|
updated_at: String::new(),
|
|
},
|
|
)
|
|
.unwrap()
|
|
.unwrap();
|
|
assert_eq!(lost["password"], "pB");
|
|
|
|
a.sync().await.unwrap();
|
|
assert_eq!(a.read(vid, id).unwrap(), merged);
|
|
assert!(a.store.pending(vid).unwrap().is_empty() && b.store.pending(vid).unwrap().is_empty());
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn edit_beats_delete_both_ways() {
|
|
let server = TestServer::start().await;
|
|
let (a, vid) = Device::first(&server).await;
|
|
let (x, y) = (Uuid::new_v4(), Uuid::new_v4());
|
|
a.save(vid, x, item(x, "X", "x", 1));
|
|
a.save(vid, y, item(y, "Y", "y", 1));
|
|
a.sync().await.unwrap();
|
|
let b = Device::second(&server, &a).await;
|
|
b.sync().await.unwrap();
|
|
|
|
// x: A deletes, B edits (B syncs second → B's edit is pushed over the tombstone).
|
|
// y: A edits, B deletes (B syncs second → A's edit wins, B restores y).
|
|
a.delete(vid, x);
|
|
a.save(vid, y, item(y, "Y", "y-edited", 5));
|
|
a.sync().await.unwrap();
|
|
b.save(vid, x, item(x, "X", "x-edited", 5));
|
|
b.delete(vid, y);
|
|
let r = b.sync().await.unwrap();
|
|
assert_eq!(r.restored.len(), 2);
|
|
assert_eq!(b.read(vid, x).unwrap()["password"], "x-edited");
|
|
assert_eq!(b.read(vid, y).unwrap()["password"], "y-edited");
|
|
|
|
a.sync().await.unwrap();
|
|
assert_eq!(a.read(vid, x).unwrap()["password"], "x-edited");
|
|
assert!(!server.state().vaults[&vid].items[&x].deleted);
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn deleting_and_never_synced_items() {
|
|
let server = TestServer::start().await;
|
|
let (a, vid) = Device::first(&server).await;
|
|
let (x, local_only) = (Uuid::new_v4(), Uuid::new_v4());
|
|
a.save(vid, x, item(x, "X", "x", 1));
|
|
a.sync().await.unwrap();
|
|
a.delete(vid, x);
|
|
a.save(vid, local_only, item(local_only, "L", "l", 1));
|
|
a.delete(vid, local_only);
|
|
a.sync().await.unwrap();
|
|
let tomb = server.state().vaults[&vid].items[&x].clone();
|
|
assert!(tomb.deleted && tomb.payload.is_some(), "the server stores the sealed tombstone");
|
|
assert!(!server.state().vaults[&vid].items.contains_key(&local_only));
|
|
assert!(a.store.get(vid, local_only).unwrap().is_none());
|
|
let b = Device::second(&server, &a).await;
|
|
b.sync().await.unwrap();
|
|
assert_eq!(b.read_base(vid, x).unwrap(), json!({"id": x, "seq": 2, "deleted": true}));
|
|
assert!(b.read(vid, x).is_none());
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn manifest_rollback_is_an_integrity_alarm() {
|
|
let server = TestServer::start().await;
|
|
let (a, vid) = Device::first(&server).await;
|
|
let id = Uuid::new_v4();
|
|
for (i, pw) in ["1", "2", "3"].iter().enumerate() {
|
|
a.save(vid, id, item(id, "T", pw, i as i64));
|
|
a.sync().await.unwrap();
|
|
}
|
|
let history = server.state().vaults[&vid].manifest_history.clone();
|
|
assert_eq!(history.len(), 3);
|
|
server.state().serve_manifest = Some(history[0].clone());
|
|
|
|
let err = a.sync().await.unwrap_err();
|
|
assert!(
|
|
matches!(err, SyncError::Integrity { vault_id, ref reason } if vault_id == vid && reason.contains("rollback")),
|
|
"{err}"
|
|
);
|
|
assert!(a.store.vault(vid).unwrap().unwrap().integrity_alarm);
|
|
|
|
// Syncing that vault stays stopped even after the server "fixes" itself.
|
|
server.state().serve_manifest = None;
|
|
a.save(vid, id, item(id, "T", "4", 9));
|
|
assert!(matches!(a.sync().await, Err(SyncError::Integrity { .. })));
|
|
assert!(server.state().vaults[&vid].items[&id].revision < 4, "nothing was pushed");
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn forged_or_foreign_manifest_is_rejected() {
|
|
let server = TestServer::start().await;
|
|
let (a, vid) = Device::first(&server).await;
|
|
a.sync().await.unwrap();
|
|
|
|
let mut forged = server.state().vaults[&vid].manifest.clone().unwrap();
|
|
forged.signature[0] ^= 1;
|
|
server.state().serve_manifest = Some(forged);
|
|
let err = a.sync().await.unwrap_err();
|
|
assert!(matches!(err, SyncError::Integrity { ref reason, .. } if reason.contains("signature")), "{err}");
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn hidden_item_breaks_merkle_root() {
|
|
let server = TestServer::start().await;
|
|
let (a, vid) = Device::first(&server).await;
|
|
let (x, y) = (Uuid::new_v4(), Uuid::new_v4());
|
|
a.save(vid, x, item(x, "X", "x", 1));
|
|
a.save(vid, y, item(y, "Y", "y", 1));
|
|
a.sync().await.unwrap();
|
|
|
|
let b = Device::new(&server, copy_keyset(&a.keyset));
|
|
b.login().await;
|
|
server.state().hide_items = vec![y];
|
|
let err = b.sync().await.unwrap_err();
|
|
assert!(matches!(err, SyncError::Integrity { ref reason, .. } if reason.contains("Merkle")), "{err}");
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn unsigned_remote_vault_is_ignored() {
|
|
let server = TestServer::start().await;
|
|
let (a, vid) = Device::first(&server).await;
|
|
a.sync().await.unwrap();
|
|
// The server injects a vault whose key it sealed to our public key itself.
|
|
let evil = Uuid::new_v4();
|
|
let sealed = kv_crypto::seal::seal_to(
|
|
a.keyset.enc.id,
|
|
&a.keyset.enc.public_bytes(),
|
|
&[0u8; 48],
|
|
&Aad::build(ObjectType::VaultKey, a.keyset.account_id, Some(evil), None),
|
|
)
|
|
.unwrap();
|
|
{
|
|
let mut st = server.state();
|
|
let v = st.vaults.entry(evil).or_default();
|
|
v.sealed_key = Some(sealed.clone());
|
|
v.meta = Some(sealed);
|
|
}
|
|
let r = a.sync().await.unwrap();
|
|
assert_eq!(r.vaults_skipped, vec![evil]);
|
|
assert!(a.store.vault(evil).unwrap().is_none());
|
|
assert!(a.store.vault(vid).unwrap().is_some());
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn pagination_and_writes_from_elsewhere() {
|
|
let server = TestServer::start().await;
|
|
let (a, vid) = Device::first(&server).await;
|
|
a.sync().await.unwrap();
|
|
// 520 items written by "another device" directly into the server.
|
|
let vk = a.vk(vid);
|
|
let ids: Vec<Uuid> = (0..520).map(|_| Uuid::new_v4()).collect();
|
|
for id in &ids {
|
|
let rec = seal_record(a.keyset.account_id, vid, &vk, *id, &item(*id, "bulk", "p", 1), Uuid::nil()).unwrap();
|
|
server.state().write_item(vid, rec);
|
|
}
|
|
let r = a.sync().await.unwrap();
|
|
assert_eq!(r.pulled, 520);
|
|
assert_eq!(a.store.list(vid).unwrap().len(), 520);
|
|
assert_eq!(a.store.cursor(vid).unwrap(), 520);
|
|
let changes_calls = server.state().log.iter().filter(|l| l.contains("/changes")).count();
|
|
assert!(changes_calls >= 2, "paged");
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn repeated_412_is_bounded() {
|
|
let server = TestServer::start().await;
|
|
let (a, vid) = Device::first(&server).await;
|
|
let id = Uuid::new_v4();
|
|
a.save(vid, id, item(id, "T", "p", 1));
|
|
server.state().force_412 = 100;
|
|
let r = a.sync().await.unwrap();
|
|
assert_eq!(r.pushed, 0);
|
|
assert_eq!(a.store.pending(vid).unwrap().len(), 1, "still pending for the next sync");
|
|
server.state().force_412 = 0;
|
|
assert_eq!(a.sync().await.unwrap().pushed, 1);
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn auth_errors() {
|
|
let server = TestServer::start().await;
|
|
let (a, _) = Device::first(&server).await;
|
|
// Wrong OPAQUE password.
|
|
let err = a.api.login(a.keyset.account_id, &[0u8; 32], enroll(&a.signer), None).await.unwrap_err();
|
|
assert!(matches!(err, SyncError::BadCredentials), "{err}");
|
|
// Setup is closed after the first account.
|
|
let err = a
|
|
.api
|
|
.register(
|
|
SETUP_TOKEN,
|
|
None,
|
|
Uuid::new_v4(),
|
|
&test_kdf(),
|
|
&AUTH,
|
|
vec![],
|
|
vec![],
|
|
a.keyset.wrap(&SymKey::generate()).unwrap(),
|
|
enroll(&a.signer),
|
|
)
|
|
.await
|
|
.unwrap_err();
|
|
assert!(matches!(err, SyncError::NotFound | SyncError::Server { .. }), "{err}");
|
|
|
|
// No session → NotAuthenticated without touching the network.
|
|
let fresh = Device::new(&server, copy_keyset(&a.keyset));
|
|
assert!(matches!(fresh.sync().await, Err(SyncError::NotAuthenticated)));
|
|
// A revoked session → 401 → NotAuthenticated, and the session is dropped.
|
|
server.state().sessions.clear();
|
|
assert!(matches!(a.sync().await, Err(SyncError::NotAuthenticated)));
|
|
assert!(a.api.session().is_none());
|
|
|
|
// New device needing approval.
|
|
server.state().require_approval = true;
|
|
let c = Device::new(&server, copy_keyset(&a.keyset));
|
|
assert_eq!(c.login().await, LoginOutcome::PendingApproval);
|
|
server.state().devices.get_mut(&c.signer.device_id()).unwrap().approved = true;
|
|
assert!(matches!(c.login().await, LoginOutcome::Ok { .. }));
|
|
// The account endpoint gives a new device what it needs to unlock.
|
|
let info = c.api.account().await.unwrap();
|
|
assert_eq!(info.sig_pk, a.keyset.sig_public().to_vec());
|
|
|
|
// Removed device (docs/03 "Removed devices"): its signed calls and its logins say DeviceRevoked, but only with
|
|
// the right password, and the refused login leaves the ticket unused.
|
|
assert!(a.login().await != LoginOutcome::PendingApproval);
|
|
a.api.revoke_device(c.signer.device_id()).await.unwrap();
|
|
assert!(matches!(c.sync().await, Err(SyncError::DeviceRevoked)));
|
|
assert!(c.api.session().is_none());
|
|
let err = c.api.login(c.keyset.account_id, &[0u8; 32], enroll(&c.signer), None).await.unwrap_err();
|
|
assert!(matches!(err, SyncError::BadCredentials), "{err}");
|
|
let ticket = a.api.create_ticket(None).await.unwrap();
|
|
let err = c
|
|
.api
|
|
.login_with_ticket(c.keyset.account_id, &AUTH, enroll(&c.signer), None, Some(ticket.ticket.clone()))
|
|
.await
|
|
.unwrap_err();
|
|
assert!(matches!(err, SyncError::DeviceRevoked), "{err}");
|
|
assert_eq!(a.api.tickets().await.unwrap().len(), 1);
|
|
}
|
|
|
|
// ---------------------------------------------------------------- TLS pinning
|
|
|
|
mod tls {
|
|
use std::sync::Arc;
|
|
|
|
use kv_sync::{signing::SoftwareSigner, transport::spki_pin_of, ApiClient, ServerConfig, SyncError};
|
|
use rustls::pki_types::{CertificateDer, PrivateKeyDer, PrivatePkcs8KeyDer};
|
|
use tokio::{
|
|
io::{AsyncReadExt, AsyncWriteExt},
|
|
net::TcpListener,
|
|
};
|
|
use uuid::Uuid;
|
|
|
|
struct Pki {
|
|
ca_der: Vec<u8>,
|
|
leaf_der: Vec<u8>,
|
|
leaf_key: Vec<u8>,
|
|
}
|
|
|
|
fn pki() -> Pki {
|
|
let ca_key = rcgen::KeyPair::generate().unwrap();
|
|
let mut ca = rcgen::CertificateParams::new(vec![]).unwrap();
|
|
ca.is_ca = rcgen::IsCa::Ca(rcgen::BasicConstraints::Unconstrained);
|
|
ca.distinguished_name.push(rcgen::DnType::CommonName, "Kestrove Test CA");
|
|
let ca_cert = ca.self_signed(&ca_key).unwrap();
|
|
let leaf_key = rcgen::KeyPair::generate().unwrap();
|
|
let leaf = rcgen::CertificateParams::new(vec!["localhost".into()]).unwrap();
|
|
let leaf_cert = leaf.signed_by(&leaf_key, &ca_cert, &ca_key).unwrap();
|
|
Pki { ca_der: ca_cert.der().to_vec(), leaf_der: leaf_cert.der().to_vec(), leaf_key: leaf_key.serialize_der() }
|
|
}
|
|
|
|
/// Minimal HTTPS server answering every request with a ServerInfo JSON. Returns the port.
|
|
async fn serve(p: &Pki, tls13_only: bool) -> u16 {
|
|
let provider = Arc::new(rustls::crypto::ring::default_provider());
|
|
let versions: &[&rustls::SupportedProtocolVersion] =
|
|
if tls13_only { &[&rustls::version::TLS13] } else { &[&rustls::version::TLS12] };
|
|
let cfg = rustls::ServerConfig::builder_with_provider(provider)
|
|
.with_protocol_versions(versions)
|
|
.unwrap()
|
|
.with_no_client_auth()
|
|
.with_single_cert(
|
|
// Like a real server, send the chain (here leaf + issuing CA).
|
|
vec![CertificateDer::from(p.leaf_der.clone()), CertificateDer::from(p.ca_der.clone())],
|
|
PrivateKeyDer::Pkcs8(PrivatePkcs8KeyDer::from(p.leaf_key.clone())),
|
|
)
|
|
.unwrap();
|
|
let acceptor = tokio_rustls::TlsAcceptor::from(Arc::new(cfg));
|
|
let listener = TcpListener::bind("127.0.0.1:0").await.unwrap();
|
|
let port = listener.local_addr().unwrap().port();
|
|
tokio::spawn(async move {
|
|
while let Ok((sock, _)) = listener.accept().await {
|
|
let acceptor = acceptor.clone();
|
|
tokio::spawn(async move {
|
|
let Ok(mut tls) = acceptor.accept(sock).await else { return };
|
|
let mut buf = [0u8; 4096];
|
|
let _ = tls.read(&mut buf).await;
|
|
let body =
|
|
r#"{"version":"t","api":1,"single_tenant":true,"setup_open":false,"min_client":"0.1.0"}"#;
|
|
let resp = format!(
|
|
"HTTP/1.1 200 OK\r\ncontent-type: application/json\r\ncontent-length: {}\r\nconnection: close\r\n\r\n{body}",
|
|
body.len()
|
|
);
|
|
let _ = tls.write_all(resp.as_bytes()).await;
|
|
let _ = tls.shutdown().await;
|
|
});
|
|
}
|
|
});
|
|
port
|
|
}
|
|
|
|
fn client(p: &Pki, port: u16, pins: Vec<String>) -> ApiClient {
|
|
let cfg = ServerConfig {
|
|
base_url: format!("https://localhost:{port}"),
|
|
spki_pins: pins,
|
|
extra_roots_der: vec![p.ca_der.clone()],
|
|
};
|
|
ApiClient::new(&cfg, Arc::new(SoftwareSigner::generate(Uuid::new_v4()))).unwrap()
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn pins() {
|
|
let p = pki();
|
|
let port = serve(&p, true).await;
|
|
let leaf_pin = spki_pin_of(&p.leaf_der).unwrap();
|
|
let ca_pin = spki_pin_of(&p.ca_der).unwrap();
|
|
let wrong = spki_pin_of(&pki().leaf_der).unwrap();
|
|
|
|
// Primary pin on the leaf.
|
|
assert!(client(&p, port, vec![leaf_pin.clone()]).server_info().await.is_ok());
|
|
// Backup pin matches (primary is a stale key).
|
|
assert!(client(&p, port, vec![wrong.clone(), leaf_pin]).server_info().await.is_ok());
|
|
// Pinning the issuing CA key (it is in the presented chain).
|
|
assert!(client(&p, port, vec![ca_pin]).server_info().await.is_ok());
|
|
// Mismatch.
|
|
let err = client(&p, port, vec![wrong.clone()]).server_info().await.unwrap_err();
|
|
assert!(matches!(err, SyncError::PinMismatch), "{err:?}");
|
|
// Without our CA, chain validation fails before pins are even considered.
|
|
let cfg = ServerConfig::new(format!("https://localhost:{port}"), vec![wrong]);
|
|
let c = ApiClient::new(&cfg, Arc::new(SoftwareSigner::generate(Uuid::new_v4()))).unwrap();
|
|
assert!(matches!(c.server_info().await, Err(SyncError::Network(_))));
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn tls12_is_refused() {
|
|
let p = pki();
|
|
let port = serve(&p, false).await;
|
|
let pin = spki_pin_of(&p.leaf_der).unwrap();
|
|
assert!(matches!(client(&p, port, vec![pin]).server_info().await, Err(SyncError::Network(_))));
|
|
}
|
|
}
|
|
|
|
// ---------------------------------------------------------------- per-item replay (docs/03)
|
|
|
|
fn assert_replay_alarm(r: Result<SyncReport, SyncError>, vid: Uuid) {
|
|
let err = r.unwrap_err();
|
|
assert!(
|
|
matches!(err, SyncError::Integrity { vault_id, ref reason } if vault_id == vid && reason.contains("older version")),
|
|
"{err}"
|
|
);
|
|
}
|
|
|
|
/// The server re-serves an old, authentic envelope of an item under a new, higher revision.
|
|
#[tokio::test]
|
|
async fn replayed_old_envelope_is_an_integrity_alarm() {
|
|
let server = TestServer::start().await;
|
|
let (a, vid) = Device::first(&server).await;
|
|
let id = Uuid::new_v4();
|
|
a.save(vid, id, item(id, "Bank", "pw1", 1));
|
|
a.sync().await.unwrap();
|
|
let old = server.state().vaults[&vid].items[&id].clone();
|
|
for (i, pw) in ["pw2", "pw3"].iter().enumerate() {
|
|
a.save(vid, id, item(id, "Bank", pw, 2 + i as i64));
|
|
a.sync().await.unwrap();
|
|
}
|
|
let b = Device::second(&server, &a).await;
|
|
b.sync().await.unwrap();
|
|
assert_eq!(b.read(vid, id).unwrap()["seq"], 3);
|
|
let c = Device::second(&server, &a).await;
|
|
c.sync().await.unwrap();
|
|
let manifest_version = server.state().vaults[&vid].manifest.as_ref().unwrap().version;
|
|
|
|
let replayed_at = server.state().write_item(vid, old);
|
|
// A clean device and a device with a pending edit of the item both refuse it.
|
|
c.save(vid, id, item(id, "Bank", "pw-c", 9));
|
|
assert_replay_alarm(b.sync().await, vid);
|
|
assert_replay_alarm(c.sync().await, vid);
|
|
assert_replay_alarm(a.sync().await, vid);
|
|
for d in [&a, &b] {
|
|
assert_eq!(d.read(vid, id).unwrap()["password"], "pw3", "not rolled back");
|
|
assert!(d.store.vault(vid).unwrap().unwrap().integrity_alarm);
|
|
}
|
|
// Nobody pushed or signed over the rolled-back state.
|
|
let st = server.state();
|
|
assert_eq!(st.vaults[&vid].items[&id].revision, replayed_at);
|
|
assert_eq!(st.vaults[&vid].manifest.as_ref().unwrap().version, manifest_version);
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn replay_of_the_current_version_is_harmless() {
|
|
let server = TestServer::start().await;
|
|
let (a, vid) = Device::first(&server).await;
|
|
let id = Uuid::new_v4();
|
|
a.save(vid, id, item(id, "Bank", "pw1", 1));
|
|
a.sync().await.unwrap();
|
|
let b = Device::second(&server, &a).await;
|
|
b.sync().await.unwrap();
|
|
let current = server.state().vaults[&vid].items[&id].clone();
|
|
server.state().write_item(vid, current);
|
|
a.sync().await.unwrap();
|
|
b.sync().await.unwrap();
|
|
assert_eq!(b.read(vid, id).unwrap()["password"], "pw1");
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn tampered_envelope_is_an_integrity_alarm() {
|
|
let server = TestServer::start().await;
|
|
let (a, vid) = Device::first(&server).await;
|
|
let id = Uuid::new_v4();
|
|
a.save(vid, id, item(id, "Bank", "pw1", 1));
|
|
a.sync().await.unwrap();
|
|
let b = Device::second(&server, &a).await;
|
|
let mut rec = server.state().vaults[&vid].items[&id].clone();
|
|
rec.payload.as_mut().unwrap().ct[0] ^= 1;
|
|
server.state().write_item(vid, rec);
|
|
let err = b.sync().await.unwrap_err();
|
|
assert!(matches!(err, SyncError::Integrity { ref reason, .. } if reason.contains("authenticate")), "{err}");
|
|
}
|
|
|
|
/// Concurrent edits from the same base carry the same `seq`; that is a merge, not an alarm, on the two
|
|
/// writers and on a third device that sees the versions one by one or only the last.
|
|
#[tokio::test]
|
|
async fn concurrent_equal_seq_edits_are_not_an_alarm() {
|
|
let server = TestServer::start().await;
|
|
let (a, vid) = Device::first(&server).await;
|
|
let id = Uuid::new_v4();
|
|
a.save(vid, id, item(id, "Mail", "p0", 1));
|
|
a.sync().await.unwrap();
|
|
let b = Device::second(&server, &a).await;
|
|
let c = Device::second(&server, &a).await;
|
|
let d = Device::second(&server, &a).await;
|
|
for x in [&b, &c, &d] {
|
|
x.sync().await.unwrap();
|
|
}
|
|
|
|
a.save(vid, id, item(id, "Mail", "pA", 30));
|
|
b.save(vid, id, json!({"id": id, "kind": "login", "title": "Mail", "password": "p0", "notes": "nB", "created_at": 1, "modified_at": 20}));
|
|
assert_eq!(a.read(vid, id).unwrap()["seq"], 2);
|
|
assert_eq!(b.read(vid, id).unwrap()["seq"], 2);
|
|
a.sync().await.unwrap();
|
|
c.sync().await.unwrap(); // sees A's seq 2
|
|
assert_eq!(b.sync().await.unwrap().merged, 1);
|
|
let merged = b.read(vid, id).unwrap();
|
|
assert_eq!(merged["seq"], 3);
|
|
for x in [&a, &c, &d] {
|
|
x.sync().await.unwrap(); // d jumps from seq 1 straight to 3
|
|
assert_eq!(x.read(vid, id).unwrap(), merged);
|
|
}
|
|
// Merging into exactly the remote content adopts it without writing a new version.
|
|
a.save(vid, id, item(id, "Mail", "same", 40));
|
|
c.save(vid, id, item(id, "Mail", "same", 40));
|
|
a.sync().await.unwrap();
|
|
let head = server.state().vaults[&vid].head;
|
|
c.sync().await.unwrap();
|
|
assert_eq!(server.state().vaults[&vid].head, head, "c adopted a's version");
|
|
assert!(c.store.pending(vid).unwrap().is_empty());
|
|
}
|
|
|
|
/// An edit that beats a delete is written above the tombstone's `seq`, so every device sees `seq` grow,
|
|
/// including one that never saw the tombstone. Replays of the tombstone or of the restore are refused.
|
|
#[tokio::test]
|
|
async fn edit_beats_delete_is_not_an_alarm_but_its_replay_is() {
|
|
let server = TestServer::start().await;
|
|
let (a, vid) = Device::first(&server).await;
|
|
let id = Uuid::new_v4();
|
|
a.save(vid, id, item(id, "X", "x1", 1));
|
|
a.sync().await.unwrap();
|
|
let b = Device::second(&server, &a).await;
|
|
b.sync().await.unwrap(); // b holds seq 1
|
|
for (i, pw) in ["x2", "x3", "x4"].iter().enumerate() {
|
|
a.save(vid, id, item(id, "X", pw, 2 + i as i64));
|
|
a.sync().await.unwrap();
|
|
}
|
|
let c = Device::second(&server, &a).await;
|
|
c.sync().await.unwrap(); // c holds seq 4 and will not see the tombstone
|
|
a.delete(vid, id);
|
|
a.sync().await.unwrap();
|
|
let tombstone = server.state().vaults[&vid].items[&id].clone();
|
|
let d = Device::second(&server, &a).await;
|
|
d.sync().await.unwrap(); // d holds the tombstone (seq 5)
|
|
assert_eq!(d.read_base(vid, id).unwrap()["seq"], 5);
|
|
|
|
b.save(vid, id, item(id, "X", "x-b", 9));
|
|
let r = b.sync().await.unwrap();
|
|
assert_eq!(r.restored, vec![id]);
|
|
let restored = b.read(vid, id).unwrap();
|
|
assert_eq!(restored["seq"], 6, "above the tombstone's seq");
|
|
let restore_envelope = server.state().vaults[&vid].items[&id].clone();
|
|
|
|
for x in [&a, &c, &d] {
|
|
x.sync().await.unwrap();
|
|
assert_eq!(x.read(vid, id).unwrap()["password"], "x-b");
|
|
}
|
|
c.save(vid, id, item(id, "X", "x-c", 10));
|
|
assert_eq!(c.read(vid, id).unwrap()["seq"], 7);
|
|
c.sync().await.unwrap();
|
|
a.sync().await.unwrap();
|
|
d.sync().await.unwrap();
|
|
|
|
// Re-serving the tombstone or the restore later is a rollback.
|
|
server.state().write_item(vid, tombstone);
|
|
assert_replay_alarm(a.sync().await, vid);
|
|
server.state().write_item(vid, restore_envelope);
|
|
assert_replay_alarm(d.sync().await, vid);
|
|
}
|
|
|
|
/// The server forges a delete. A tombstone without ciphertext, or an item envelope relabelled as deleted, is an
|
|
/// integrity alarm; the item stays visible.
|
|
#[tokio::test]
|
|
async fn forged_tombstone_is_an_integrity_alarm() {
|
|
let server = TestServer::start().await;
|
|
let (a, vid) = Device::first(&server).await;
|
|
let id = Uuid::new_v4();
|
|
a.save(vid, id, item(id, "Bank", "pw1", 1));
|
|
a.sync().await.unwrap();
|
|
let b = Device::second(&server, &a).await;
|
|
b.sync().await.unwrap();
|
|
let live = server.state().vaults[&vid].items[&id].clone();
|
|
let manifest_version = server.state().vaults[&vid].manifest.as_ref().unwrap().version;
|
|
|
|
// An unauthenticated tombstone, as the server could always write before.
|
|
let forged = ItemRecord { deleted: true, wrapped_item_key: None, payload: None, ..live.clone() };
|
|
server.state().write_item(vid, forged);
|
|
let err = b.sync().await.unwrap_err();
|
|
assert!(
|
|
matches!(err, SyncError::Integrity { vault_id, ref reason } if vault_id == vid && reason.contains("unauthenticated delete")),
|
|
"{err}"
|
|
);
|
|
assert_eq!(b.read(vid, id).unwrap()["password"], "pw1", "not hidden");
|
|
assert!(b.store.vault(vid).unwrap().unwrap().integrity_alarm);
|
|
// A device that never held the item refuses it too.
|
|
let c = Device::second(&server, &a).await;
|
|
assert!(matches!(c.sync().await, Err(SyncError::Integrity { .. })));
|
|
|
|
// An authentic item envelope re-served with the deleted flag set.
|
|
let relabelled = ItemRecord { deleted: true, ..live };
|
|
server.state().write_item(vid, relabelled);
|
|
let err = a.sync().await.unwrap_err();
|
|
assert!(matches!(err, SyncError::Integrity { ref reason, .. } if reason.contains("deleted flag")), "{err}");
|
|
assert_eq!(a.read(vid, id).unwrap()["password"], "pw1");
|
|
assert_eq!(server.state().vaults[&vid].manifest.as_ref().unwrap().version, manifest_version);
|
|
}
|
|
|
|
/// Delete-then-replay: after a device pulls a (genuine) tombstone, an old live envelope re-served on top of it
|
|
/// is refused, because the tombstone's `seq` is authenticated.
|
|
#[tokio::test]
|
|
async fn replay_after_a_tombstone_is_an_integrity_alarm() {
|
|
let server = TestServer::start().await;
|
|
let (a, vid) = Device::first(&server).await;
|
|
let id = Uuid::new_v4();
|
|
a.save(vid, id, item(id, "Bank", "pw1", 1));
|
|
a.sync().await.unwrap();
|
|
let old = server.state().vaults[&vid].items[&id].clone();
|
|
a.save(vid, id, item(id, "Bank", "pw2", 2));
|
|
a.sync().await.unwrap();
|
|
let b = Device::second(&server, &a).await;
|
|
b.sync().await.unwrap();
|
|
a.delete(vid, id);
|
|
a.sync().await.unwrap();
|
|
b.sync().await.unwrap();
|
|
assert!(b.read(vid, id).is_none());
|
|
|
|
server.state().write_item(vid, old);
|
|
assert_replay_alarm(b.sync().await, vid);
|
|
assert!(b.read(vid, id).is_none(), "the deleted item did not come back");
|
|
}
|
|
|
|
/// A pending delete stored before tombstones were sealed is sealed when it is pushed.
|
|
#[tokio::test]
|
|
async fn legacy_pending_delete_is_sealed_on_push() {
|
|
let server = TestServer::start().await;
|
|
let (a, vid) = Device::first(&server).await;
|
|
let id = Uuid::new_v4();
|
|
a.save(vid, id, item(id, "X", "x", 1));
|
|
a.sync().await.unwrap();
|
|
a.delete_legacy(vid, id);
|
|
a.sync().await.unwrap();
|
|
let tomb = server.state().vaults[&vid].items[&id].clone();
|
|
assert!(tomb.deleted && tomb.payload.is_some());
|
|
let b = Device::second(&server, &a).await;
|
|
b.sync().await.unwrap();
|
|
assert_eq!(b.read_base(vid, id).unwrap(), json!({"id": id, "seq": 2, "deleted": true}));
|
|
}
|
|
|
|
/// Items written before `seq` existed read as seq 0; the first edit writes seq 1.
|
|
#[tokio::test]
|
|
async fn legacy_items_without_seq() {
|
|
let server = TestServer::start().await;
|
|
let (a, vid) = Device::first(&server).await;
|
|
let id = Uuid::new_v4();
|
|
a.save_raw(vid, id, item(id, "Old", "p", 1));
|
|
a.sync().await.unwrap();
|
|
let b = Device::second(&server, &a).await;
|
|
b.sync().await.unwrap();
|
|
assert!(b.read(vid, id).unwrap().get("seq").is_none());
|
|
b.save(vid, id, item(id, "Old", "p2", 2));
|
|
assert_eq!(b.read(vid, id).unwrap()["seq"], 1);
|
|
b.sync().await.unwrap();
|
|
a.sync().await.unwrap();
|
|
assert_eq!(a.read(vid, id).unwrap()["password"], "p2");
|
|
}
|
|
|
|
/// The Merkle root is also checked when the cursor is past the manifest's head but nothing held is newer.
|
|
#[tokio::test]
|
|
async fn hidden_item_is_caught_when_cursor_is_past_manifest_head() {
|
|
let server = TestServer::start().await;
|
|
let (a, vid) = Device::first(&server).await;
|
|
let (x, y) = (Uuid::new_v4(), Uuid::new_v4());
|
|
a.save(vid, x, item(x, "X", "x", 1));
|
|
a.save(vid, y, item(y, "Y", "y", 1));
|
|
a.sync().await.unwrap();
|
|
// Another device writes y without publishing a manifest yet, and the server hides y.
|
|
let mut rec = server.state().vaults[&vid].items[&y].clone();
|
|
rec.updated_by = Uuid::nil();
|
|
server.state().write_item(vid, rec);
|
|
server.state().hide_items = vec![y];
|
|
|
|
let b = Device::second(&server, &a).await;
|
|
let err = b.sync().await.unwrap_err();
|
|
assert!(matches!(err, SyncError::Integrity { ref reason, .. } if reason.contains("Merkle")), "{err}");
|
|
}
|