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:
@@ -10,3 +10,8 @@ publish.workspace = true
|
||||
kv-proto.workspace = true
|
||||
uuid.workspace = true
|
||||
thiserror.workspace = true
|
||||
serde_json.workspace = true
|
||||
rusqlite.workspace = true
|
||||
|
||||
[dev-dependencies]
|
||||
tempfile.workspace = true
|
||||
|
||||
+693
-24
@@ -1,20 +1,38 @@
|
||||
//! 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):
|
||||
//! bookkeeping. It never sees plaintext. 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);
|
||||
//! CREATE TABLE meta (key TEXT PRIMARY KEY, value BLOB NOT NULL);
|
||||
//! CREATE TABLE vault (vault_id BLOB PRIMARY KEY, sealed_key TEXT, meta TEXT, cursor INTEGER,
|
||||
//! manifest_version INTEGER, manifest_hash BLOB, integrity_alarm INTEGER);
|
||||
//! CREATE TABLE item (vault_id BLOB, item_id BLOB, revision INTEGER, deleted INTEGER,
|
||||
//! wrapped_item_key TEXT, payload TEXT, updated_by BLOB, updated_at TEXT,
|
||||
//! state TEXT CHECK(state IN ('clean','pending')),
|
||||
//! base_revision INTEGER, base_deleted INTEGER, base_wrapped_item_key TEXT, base_payload TEXT,
|
||||
//! PRIMARY KEY (vault_id, item_id));
|
||||
//! CREATE TABLE history (seq INTEGER PRIMARY KEY, vault_id BLOB, item_id BLOB, revision INTEGER,
|
||||
//! wrapped_item_key TEXT, payload TEXT, saved_at INTEGER);
|
||||
//! ```
|
||||
//!
|
||||
//! Envelopes are stored as their JSON form (the same bytes the server stores).
|
||||
//! The `base_*` columns hold the last version of the item the server confirmed;
|
||||
//! they are the common ancestor for three-way merges and the input to the manifest's Merkle root.
|
||||
|
||||
use kv_proto::ItemRecord;
|
||||
use std::{
|
||||
collections::{BTreeMap, HashMap},
|
||||
path::Path,
|
||||
sync::Mutex,
|
||||
};
|
||||
|
||||
use kv_proto::{Envelope, ItemRecord};
|
||||
use rusqlite::{params, Connection, OptionalExtension};
|
||||
use uuid::Uuid;
|
||||
|
||||
/// Versions of an item kept in history.
|
||||
pub const HISTORY_LIMIT: usize = 10;
|
||||
|
||||
#[derive(Debug, thiserror::Error)]
|
||||
pub enum StoreError {
|
||||
#[error("storage I/O: {0}")]
|
||||
@@ -23,22 +41,673 @@ pub enum StoreError {
|
||||
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,
|
||||
},
|
||||
impl From<rusqlite::Error> for StoreError {
|
||||
fn from(e: rusqlite::Error) -> Self {
|
||||
StoreError::Io(e.to_string())
|
||||
}
|
||||
}
|
||||
|
||||
/// 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>;
|
||||
impl From<serde_json::Error> for StoreError {
|
||||
fn from(e: serde_json::Error) -> Self {
|
||||
StoreError::Io(format!("corrupt envelope: {e}"))
|
||||
}
|
||||
}
|
||||
|
||||
pub type Result<T> = std::result::Result<T, StoreError>;
|
||||
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||
pub enum SyncState {
|
||||
/// Matches the server's version (`base`).
|
||||
Clean,
|
||||
/// Edited locally; not yet accepted by the server. The edit was made against `base`.
|
||||
Pending,
|
||||
}
|
||||
|
||||
/// One item as replicated on this device.
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct LocalItem {
|
||||
/// The working copy. For pending items `record.revision` equals the base revision.
|
||||
pub record: ItemRecord,
|
||||
pub state: SyncState,
|
||||
/// Last version confirmed by the server. `None` for items created here and never pushed.
|
||||
pub base: Option<ItemRecord>,
|
||||
}
|
||||
|
||||
impl LocalItem {
|
||||
pub fn base_revision(&self) -> u64 {
|
||||
self.base.as_ref().map_or(0, |b| b.revision)
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||
pub struct VaultRow {
|
||||
pub vault_id: Uuid,
|
||||
pub sealed_key: Envelope,
|
||||
pub meta: Option<Envelope>,
|
||||
/// Highest server revision pulled.
|
||||
pub cursor: u64,
|
||||
/// Highest manifest version seen and the blake3 hash of its body.
|
||||
pub manifest_version: u64,
|
||||
pub manifest_hash: Option<[u8; 32]>,
|
||||
/// Set on a manifest rollback or tampering; syncing this vault stops.
|
||||
pub integrity_alarm: bool,
|
||||
}
|
||||
|
||||
impl VaultRow {
|
||||
pub fn new(vault_id: Uuid, sealed_key: Envelope, meta: Option<Envelope>) -> Self {
|
||||
Self { vault_id, sealed_key, meta, cursor: 0, manifest_version: 0, manifest_hash: None, integrity_alarm: false }
|
||||
}
|
||||
}
|
||||
|
||||
/// An earlier version of an item (local edit or merge loser).
|
||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||
pub struct HistoryEntry {
|
||||
pub revision: u64,
|
||||
pub wrapped_item_key: Envelope,
|
||||
pub payload: Envelope,
|
||||
pub saved_at: i64,
|
||||
}
|
||||
|
||||
/// Storage backend: [`SqliteStore`] on devices, [`MemoryStore`] in tests.
|
||||
pub trait Store: Send + Sync {
|
||||
fn get_meta(&self, key: &str) -> Result<Option<Vec<u8>>>;
|
||||
fn put_meta(&self, key: &str, value: &[u8]) -> Result<()>;
|
||||
fn delete_meta(&self, key: &str) -> Result<()>;
|
||||
|
||||
/// Inserts or replaces a vault row.
|
||||
fn put_vault(&self, vault: &VaultRow) -> Result<()>;
|
||||
fn vault(&self, vault: Uuid) -> Result<Option<VaultRow>>;
|
||||
fn vaults(&self) -> Result<Vec<VaultRow>>;
|
||||
|
||||
/// Inserts or replaces an item.
|
||||
fn put(&self, vault: Uuid, item: &LocalItem) -> Result<()>;
|
||||
fn get(&self, vault: Uuid, item: Uuid) -> Result<Option<LocalItem>>;
|
||||
/// All items, including tombstones.
|
||||
fn list(&self, vault: Uuid) -> Result<Vec<LocalItem>>;
|
||||
fn remove(&self, vault: Uuid, item: Uuid) -> Result<()>;
|
||||
|
||||
/// Appends a version and keeps only the newest [`HISTORY_LIMIT`].
|
||||
fn push_history(&self, vault: Uuid, item: Uuid, entry: &HistoryEntry) -> Result<()>;
|
||||
/// Newest first.
|
||||
fn history(&self, vault: Uuid, item: Uuid) -> Result<Vec<HistoryEntry>>;
|
||||
|
||||
/// Deletes everything (sign-out / reset).
|
||||
fn wipe(&self) -> Result<()>;
|
||||
|
||||
// ---- convenience ----
|
||||
|
||||
fn cursor(&self, vault: Uuid) -> Result<u64> {
|
||||
Ok(self.vault(vault)?.ok_or(StoreError::NotFound)?.cursor)
|
||||
}
|
||||
|
||||
fn set_cursor(&self, vault: Uuid, revision: u64) -> Result<()> {
|
||||
let mut v = self.vault(vault)?.ok_or(StoreError::NotFound)?;
|
||||
v.cursor = revision;
|
||||
self.put_vault(&v)
|
||||
}
|
||||
|
||||
/// Stores a server-confirmed record as clean (it becomes its own base).
|
||||
fn put_remote(&self, vault: Uuid, record: &ItemRecord) -> Result<()> {
|
||||
self.put(vault, &LocalItem { record: record.clone(), state: SyncState::Clean, base: Some(record.clone()) })
|
||||
}
|
||||
|
||||
/// Stores a local edit as pending against the current base.
|
||||
fn put_local(&self, vault: Uuid, record: &ItemRecord) -> Result<()> {
|
||||
let base = self.get(vault, record.item_id)?.and_then(|i| i.base);
|
||||
let mut record = record.clone();
|
||||
record.revision = base.as_ref().map_or(0, |b| b.revision);
|
||||
self.put(vault, &LocalItem { record, state: SyncState::Pending, base })
|
||||
}
|
||||
|
||||
fn pending(&self, vault: Uuid) -> Result<Vec<LocalItem>> {
|
||||
Ok(self.list(vault)?.into_iter().filter(|i| i.state == SyncState::Pending).collect())
|
||||
}
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------- SQLite
|
||||
|
||||
const SCHEMA: &str = "
|
||||
CREATE TABLE IF NOT EXISTS meta (key TEXT PRIMARY KEY, value BLOB NOT NULL);
|
||||
CREATE TABLE IF NOT EXISTS vault (
|
||||
vault_id BLOB PRIMARY KEY, sealed_key TEXT NOT NULL, meta TEXT,
|
||||
cursor INTEGER NOT NULL DEFAULT 0, manifest_version INTEGER NOT NULL DEFAULT 0, manifest_hash BLOB,
|
||||
integrity_alarm INTEGER NOT NULL DEFAULT 0);
|
||||
CREATE TABLE IF NOT EXISTS item (
|
||||
vault_id BLOB NOT NULL, item_id BLOB NOT NULL, revision INTEGER NOT NULL, deleted INTEGER NOT NULL,
|
||||
wrapped_item_key TEXT, payload TEXT, updated_by BLOB NOT NULL, updated_at TEXT NOT NULL,
|
||||
state TEXT NOT NULL CHECK(state IN ('clean','pending')),
|
||||
base_revision INTEGER NOT NULL DEFAULT 0, base_deleted INTEGER, base_wrapped_item_key TEXT, base_payload TEXT,
|
||||
base_updated_by BLOB, base_updated_at TEXT,
|
||||
PRIMARY KEY (vault_id, item_id));
|
||||
CREATE TABLE IF NOT EXISTS history (
|
||||
seq INTEGER PRIMARY KEY AUTOINCREMENT, vault_id BLOB NOT NULL, item_id BLOB NOT NULL, revision INTEGER NOT NULL,
|
||||
wrapped_item_key TEXT NOT NULL, payload TEXT NOT NULL, saved_at INTEGER NOT NULL);
|
||||
CREATE INDEX IF NOT EXISTS history_by_item ON history (vault_id, item_id, seq);
|
||||
";
|
||||
|
||||
const SCHEMA_VERSION: i64 = 1;
|
||||
|
||||
pub struct SqliteStore {
|
||||
conn: Mutex<Connection>,
|
||||
}
|
||||
|
||||
impl SqliteStore {
|
||||
pub fn open(path: impl AsRef<Path>) -> Result<Self> {
|
||||
Self::init(Connection::open(path)?)
|
||||
}
|
||||
|
||||
pub fn open_in_memory() -> Result<Self> {
|
||||
Self::init(Connection::open_in_memory()?)
|
||||
}
|
||||
|
||||
fn init(conn: Connection) -> Result<Self> {
|
||||
conn.pragma_update(None, "journal_mode", "WAL")?;
|
||||
conn.pragma_update(None, "synchronous", "FULL")?;
|
||||
conn.pragma_update(None, "secure_delete", "ON")?;
|
||||
let version: i64 = conn.pragma_query_value(None, "user_version", |r| r.get(0))?;
|
||||
if version > SCHEMA_VERSION {
|
||||
return Err(StoreError::Io(format!("database schema {version} is newer than this app")));
|
||||
}
|
||||
conn.execute_batch(SCHEMA)?;
|
||||
conn.pragma_update(None, "user_version", SCHEMA_VERSION)?;
|
||||
Ok(Self { conn: Mutex::new(conn) })
|
||||
}
|
||||
|
||||
fn conn(&self) -> std::sync::MutexGuard<'_, Connection> {
|
||||
self.conn.lock().unwrap_or_else(|p| p.into_inner())
|
||||
}
|
||||
}
|
||||
|
||||
fn env_to_sql(e: &Option<Envelope>) -> Result<Option<String>> {
|
||||
e.as_ref().map(serde_json::to_string).transpose().map_err(Into::into)
|
||||
}
|
||||
|
||||
fn env_from_sql(s: Option<String>) -> Result<Option<Envelope>> {
|
||||
s.as_deref().map(serde_json::from_str).transpose().map_err(Into::into)
|
||||
}
|
||||
|
||||
fn uuid_from_sql(b: Vec<u8>) -> Result<Uuid> {
|
||||
Uuid::from_slice(&b).map_err(|e| StoreError::Io(e.to_string()))
|
||||
}
|
||||
|
||||
fn hash_from_sql(b: Option<Vec<u8>>) -> Result<Option<[u8; 32]>> {
|
||||
b.map(|b| b.try_into().map_err(|_| StoreError::Io("bad manifest hash".into()))).transpose()
|
||||
}
|
||||
|
||||
const ITEM_COLS: &str = "item_id, revision, deleted, wrapped_item_key, payload, updated_by, updated_at, state,
|
||||
base_revision, base_deleted, base_wrapped_item_key, base_payload, base_updated_by, base_updated_at";
|
||||
|
||||
type RawItem = (
|
||||
Vec<u8>,
|
||||
i64,
|
||||
bool,
|
||||
Option<String>,
|
||||
Option<String>,
|
||||
Vec<u8>,
|
||||
String,
|
||||
String,
|
||||
i64,
|
||||
Option<bool>,
|
||||
Option<String>,
|
||||
Option<String>,
|
||||
Option<Vec<u8>>,
|
||||
Option<String>,
|
||||
);
|
||||
|
||||
fn raw_item(r: &rusqlite::Row<'_>) -> rusqlite::Result<RawItem> {
|
||||
Ok((
|
||||
r.get(0)?,
|
||||
r.get(1)?,
|
||||
r.get(2)?,
|
||||
r.get(3)?,
|
||||
r.get(4)?,
|
||||
r.get(5)?,
|
||||
r.get(6)?,
|
||||
r.get(7)?,
|
||||
r.get(8)?,
|
||||
r.get(9)?,
|
||||
r.get(10)?,
|
||||
r.get(11)?,
|
||||
r.get(12)?,
|
||||
r.get(13)?,
|
||||
))
|
||||
}
|
||||
|
||||
fn item_from_raw(raw: RawItem) -> Result<LocalItem> {
|
||||
let (id, rev, deleted, wik, payload, by, at, state, brev, bdel, bwik, bpayload, bby, bat) = raw;
|
||||
let item_id = uuid_from_sql(id)?;
|
||||
let record = ItemRecord {
|
||||
item_id,
|
||||
revision: rev as u64,
|
||||
deleted,
|
||||
wrapped_item_key: env_from_sql(wik)?,
|
||||
payload: env_from_sql(payload)?,
|
||||
updated_by: uuid_from_sql(by)?,
|
||||
updated_at: at,
|
||||
};
|
||||
let base = if brev > 0 {
|
||||
Some(ItemRecord {
|
||||
item_id,
|
||||
revision: brev as u64,
|
||||
deleted: bdel.unwrap_or(false),
|
||||
wrapped_item_key: env_from_sql(bwik)?,
|
||||
payload: env_from_sql(bpayload)?,
|
||||
updated_by: bby.map(uuid_from_sql).transpose()?.unwrap_or_default(),
|
||||
updated_at: bat.unwrap_or_default(),
|
||||
})
|
||||
} else {
|
||||
None
|
||||
};
|
||||
let state = if state == "pending" { SyncState::Pending } else { SyncState::Clean };
|
||||
Ok(LocalItem { record, state, base })
|
||||
}
|
||||
|
||||
fn vault_from_row(r: &rusqlite::Row<'_>) -> rusqlite::Result<RawVault> {
|
||||
Ok((r.get(0)?, r.get(1)?, r.get(2)?, r.get(3)?, r.get(4)?, r.get(5)?, r.get(6)?))
|
||||
}
|
||||
|
||||
type RawVault = (Vec<u8>, String, Option<String>, i64, i64, Option<Vec<u8>>, bool);
|
||||
|
||||
fn vault_from_raw(raw: RawVault) -> Result<VaultRow> {
|
||||
let (id, sealed, meta, cursor, mver, mhash, alarm) = raw;
|
||||
Ok(VaultRow {
|
||||
vault_id: uuid_from_sql(id)?,
|
||||
sealed_key: serde_json::from_str(&sealed)?,
|
||||
meta: env_from_sql(meta)?,
|
||||
cursor: cursor as u64,
|
||||
manifest_version: mver as u64,
|
||||
manifest_hash: hash_from_sql(mhash)?,
|
||||
integrity_alarm: alarm,
|
||||
})
|
||||
}
|
||||
|
||||
const VAULT_COLS: &str = "vault_id, sealed_key, meta, cursor, manifest_version, manifest_hash, integrity_alarm";
|
||||
|
||||
impl Store for SqliteStore {
|
||||
fn get_meta(&self, key: &str) -> Result<Option<Vec<u8>>> {
|
||||
Ok(self.conn().query_row("SELECT value FROM meta WHERE key = ?", [key], |r| r.get(0)).optional()?)
|
||||
}
|
||||
|
||||
fn put_meta(&self, key: &str, value: &[u8]) -> Result<()> {
|
||||
self.conn().execute("INSERT OR REPLACE INTO meta (key, value) VALUES (?, ?)", params![key, value])?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn delete_meta(&self, key: &str) -> Result<()> {
|
||||
self.conn().execute("DELETE FROM meta WHERE key = ?", [key])?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn put_vault(&self, v: &VaultRow) -> Result<()> {
|
||||
self.conn().execute(
|
||||
&format!("INSERT OR REPLACE INTO vault ({VAULT_COLS}) VALUES (?,?,?,?,?,?,?)"),
|
||||
params![
|
||||
v.vault_id.as_bytes().as_slice(),
|
||||
serde_json::to_string(&v.sealed_key)?,
|
||||
env_to_sql(&v.meta)?,
|
||||
v.cursor as i64,
|
||||
v.manifest_version as i64,
|
||||
v.manifest_hash.as_ref().map(|h| h.as_slice()),
|
||||
v.integrity_alarm,
|
||||
],
|
||||
)?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn vault(&self, vault: Uuid) -> Result<Option<VaultRow>> {
|
||||
let raw = self
|
||||
.conn()
|
||||
.query_row(
|
||||
&format!("SELECT {VAULT_COLS} FROM vault WHERE vault_id = ?"),
|
||||
[vault.as_bytes().as_slice()],
|
||||
vault_from_row,
|
||||
)
|
||||
.optional()?;
|
||||
raw.map(vault_from_raw).transpose()
|
||||
}
|
||||
|
||||
fn vaults(&self) -> Result<Vec<VaultRow>> {
|
||||
let conn = self.conn();
|
||||
let mut stmt = conn.prepare(&format!("SELECT {VAULT_COLS} FROM vault ORDER BY rowid"))?;
|
||||
let raws = stmt.query_map([], vault_from_row)?.collect::<rusqlite::Result<Vec<_>>>()?;
|
||||
raws.into_iter().map(vault_from_raw).collect()
|
||||
}
|
||||
|
||||
fn put(&self, vault: Uuid, item: &LocalItem) -> Result<()> {
|
||||
let r = &item.record;
|
||||
let b = item.base.as_ref();
|
||||
self.conn().execute(
|
||||
&format!("INSERT OR REPLACE INTO item (vault_id, {ITEM_COLS}) VALUES (?,?,?,?,?,?,?,?,?,?,?,?,?,?,?)"),
|
||||
params![
|
||||
vault.as_bytes().as_slice(),
|
||||
r.item_id.as_bytes().as_slice(),
|
||||
r.revision as i64,
|
||||
r.deleted,
|
||||
env_to_sql(&r.wrapped_item_key)?,
|
||||
env_to_sql(&r.payload)?,
|
||||
r.updated_by.as_bytes().as_slice(),
|
||||
r.updated_at,
|
||||
match item.state {
|
||||
SyncState::Clean => "clean",
|
||||
SyncState::Pending => "pending",
|
||||
},
|
||||
b.map_or(0, |b| b.revision as i64),
|
||||
b.map(|b| b.deleted),
|
||||
env_to_sql(&b.and_then(|b| b.wrapped_item_key.clone()))?,
|
||||
env_to_sql(&b.and_then(|b| b.payload.clone()))?,
|
||||
b.map(|b| b.updated_by.as_bytes().to_vec()),
|
||||
b.map(|b| b.updated_at.clone()),
|
||||
],
|
||||
)?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn get(&self, vault: Uuid, item: Uuid) -> Result<Option<LocalItem>> {
|
||||
let raw = self
|
||||
.conn()
|
||||
.query_row(
|
||||
&format!("SELECT {ITEM_COLS} FROM item WHERE vault_id = ? AND item_id = ?"),
|
||||
[vault.as_bytes().as_slice(), item.as_bytes().as_slice()],
|
||||
raw_item,
|
||||
)
|
||||
.optional()?;
|
||||
raw.map(item_from_raw).transpose()
|
||||
}
|
||||
|
||||
fn list(&self, vault: Uuid) -> Result<Vec<LocalItem>> {
|
||||
let conn = self.conn();
|
||||
let mut stmt = conn.prepare(&format!("SELECT {ITEM_COLS} FROM item WHERE vault_id = ? ORDER BY item_id"))?;
|
||||
let raws = stmt.query_map([vault.as_bytes().as_slice()], raw_item)?.collect::<rusqlite::Result<Vec<_>>>()?;
|
||||
raws.into_iter().map(item_from_raw).collect()
|
||||
}
|
||||
|
||||
fn remove(&self, vault: Uuid, item: Uuid) -> Result<()> {
|
||||
self.conn().execute(
|
||||
"DELETE FROM item WHERE vault_id = ? AND item_id = ?",
|
||||
[vault.as_bytes().as_slice(), item.as_bytes().as_slice()],
|
||||
)?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn push_history(&self, vault: Uuid, item: Uuid, e: &HistoryEntry) -> Result<()> {
|
||||
let mut conn = self.conn();
|
||||
let tx = conn.transaction()?;
|
||||
let (v, i) = (vault.as_bytes().as_slice(), item.as_bytes().as_slice());
|
||||
tx.execute(
|
||||
"INSERT INTO history (vault_id, item_id, revision, wrapped_item_key, payload, saved_at) VALUES (?,?,?,?,?,?)",
|
||||
params![
|
||||
v,
|
||||
i,
|
||||
e.revision as i64,
|
||||
serde_json::to_string(&e.wrapped_item_key)?,
|
||||
serde_json::to_string(&e.payload)?,
|
||||
e.saved_at
|
||||
],
|
||||
)?;
|
||||
tx.execute(
|
||||
"DELETE FROM history WHERE vault_id = ?1 AND item_id = ?2 AND seq NOT IN
|
||||
(SELECT seq FROM history WHERE vault_id = ?1 AND item_id = ?2 ORDER BY seq DESC LIMIT ?3)",
|
||||
params![v, i, HISTORY_LIMIT as i64],
|
||||
)?;
|
||||
tx.commit()?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn history(&self, vault: Uuid, item: Uuid) -> Result<Vec<HistoryEntry>> {
|
||||
let conn = self.conn();
|
||||
let mut stmt = conn.prepare(
|
||||
"SELECT revision, wrapped_item_key, payload, saved_at FROM history
|
||||
WHERE vault_id = ? AND item_id = ? ORDER BY seq DESC",
|
||||
)?;
|
||||
let raws = stmt
|
||||
.query_map([vault.as_bytes().as_slice(), item.as_bytes().as_slice()], |r| {
|
||||
Ok((r.get::<_, i64>(0)?, r.get::<_, String>(1)?, r.get::<_, String>(2)?, r.get::<_, i64>(3)?))
|
||||
})?
|
||||
.collect::<rusqlite::Result<Vec<_>>>()?;
|
||||
raws.into_iter()
|
||||
.map(|(rev, wik, payload, at)| {
|
||||
Ok(HistoryEntry {
|
||||
revision: rev as u64,
|
||||
wrapped_item_key: serde_json::from_str(&wik)?,
|
||||
payload: serde_json::from_str(&payload)?,
|
||||
saved_at: at,
|
||||
})
|
||||
})
|
||||
.collect()
|
||||
}
|
||||
|
||||
fn wipe(&self) -> Result<()> {
|
||||
self.conn().execute_batch("DELETE FROM history; DELETE FROM item; DELETE FROM vault; DELETE FROM meta;")?;
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------- in-memory
|
||||
|
||||
#[derive(Default)]
|
||||
struct Mem {
|
||||
meta: HashMap<String, Vec<u8>>,
|
||||
vaults: Vec<VaultRow>,
|
||||
items: HashMap<Uuid, BTreeMap<Uuid, LocalItem>>,
|
||||
history: HashMap<(Uuid, Uuid), Vec<HistoryEntry>>,
|
||||
}
|
||||
|
||||
/// Non-persistent store for tests and `VaultCore::new_in_memory`.
|
||||
#[derive(Default)]
|
||||
pub struct MemoryStore {
|
||||
inner: Mutex<Mem>,
|
||||
}
|
||||
|
||||
impl MemoryStore {
|
||||
pub fn new() -> Self {
|
||||
Self::default()
|
||||
}
|
||||
|
||||
fn mem(&self) -> std::sync::MutexGuard<'_, Mem> {
|
||||
self.inner.lock().unwrap_or_else(|p| p.into_inner())
|
||||
}
|
||||
}
|
||||
|
||||
impl Store for MemoryStore {
|
||||
fn get_meta(&self, key: &str) -> Result<Option<Vec<u8>>> {
|
||||
Ok(self.mem().meta.get(key).cloned())
|
||||
}
|
||||
|
||||
fn put_meta(&self, key: &str, value: &[u8]) -> Result<()> {
|
||||
self.mem().meta.insert(key.into(), value.to_vec());
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn delete_meta(&self, key: &str) -> Result<()> {
|
||||
self.mem().meta.remove(key);
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn put_vault(&self, vault: &VaultRow) -> Result<()> {
|
||||
let mut m = self.mem();
|
||||
match m.vaults.iter_mut().find(|v| v.vault_id == vault.vault_id) {
|
||||
Some(v) => *v = vault.clone(),
|
||||
None => m.vaults.push(vault.clone()),
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn vault(&self, vault: Uuid) -> Result<Option<VaultRow>> {
|
||||
Ok(self.mem().vaults.iter().find(|v| v.vault_id == vault).cloned())
|
||||
}
|
||||
|
||||
fn vaults(&self) -> Result<Vec<VaultRow>> {
|
||||
Ok(self.mem().vaults.clone())
|
||||
}
|
||||
|
||||
fn put(&self, vault: Uuid, item: &LocalItem) -> Result<()> {
|
||||
self.mem().items.entry(vault).or_default().insert(item.record.item_id, item.clone());
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn get(&self, vault: Uuid, item: Uuid) -> Result<Option<LocalItem>> {
|
||||
Ok(self.mem().items.get(&vault).and_then(|v| v.get(&item)).cloned())
|
||||
}
|
||||
|
||||
fn list(&self, vault: Uuid) -> Result<Vec<LocalItem>> {
|
||||
Ok(self.mem().items.get(&vault).map(|v| v.values().cloned().collect()).unwrap_or_default())
|
||||
}
|
||||
|
||||
fn remove(&self, vault: Uuid, item: Uuid) -> Result<()> {
|
||||
if let Some(v) = self.mem().items.get_mut(&vault) {
|
||||
v.remove(&item);
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn push_history(&self, vault: Uuid, item: Uuid, entry: &HistoryEntry) -> Result<()> {
|
||||
let mut m = self.mem();
|
||||
let h = m.history.entry((vault, item)).or_default();
|
||||
h.insert(0, entry.clone());
|
||||
h.truncate(HISTORY_LIMIT);
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn history(&self, vault: Uuid, item: Uuid) -> Result<Vec<HistoryEntry>> {
|
||||
Ok(self.mem().history.get(&(vault, item)).cloned().unwrap_or_default())
|
||||
}
|
||||
|
||||
fn wipe(&self) -> Result<()> {
|
||||
*self.mem() = Mem::default();
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use kv_proto::{Alg, FORMAT_VERSION};
|
||||
|
||||
use super::*;
|
||||
|
||||
fn env(tag: u8) -> Envelope {
|
||||
Envelope {
|
||||
v: FORMAT_VERSION,
|
||||
alg: Alg::XChaCha20Poly1305,
|
||||
kid: Uuid::nil(),
|
||||
n: vec![tag; 24],
|
||||
ct: vec![tag; 20],
|
||||
enc: vec![],
|
||||
}
|
||||
}
|
||||
|
||||
fn record(id: Uuid, rev: u64, tag: u8) -> ItemRecord {
|
||||
ItemRecord {
|
||||
item_id: id,
|
||||
revision: rev,
|
||||
deleted: false,
|
||||
wrapped_item_key: Some(env(tag)),
|
||||
payload: Some(env(tag + 1)),
|
||||
updated_by: Uuid::nil(),
|
||||
updated_at: "2026-01-01T00:00:00Z".into(),
|
||||
}
|
||||
}
|
||||
|
||||
fn exercise(store: &dyn Store) {
|
||||
let vid = Uuid::new_v4();
|
||||
assert!(store.vault(vid).unwrap().is_none());
|
||||
store.put_vault(&VaultRow::new(vid, env(1), Some(env(2)))).unwrap();
|
||||
store.set_cursor(vid, 7).unwrap();
|
||||
let mut v = store.vault(vid).unwrap().unwrap();
|
||||
assert_eq!(v.cursor, 7);
|
||||
v.manifest_version = 3;
|
||||
v.manifest_hash = Some([5; 32]);
|
||||
store.put_vault(&v).unwrap();
|
||||
assert_eq!(store.vaults().unwrap(), vec![v]);
|
||||
|
||||
store.put_meta("account", b"{}").unwrap();
|
||||
assert_eq!(store.get_meta("account").unwrap().as_deref(), Some(&b"{}"[..]));
|
||||
store.delete_meta("account").unwrap();
|
||||
assert!(store.get_meta("account").unwrap().is_none());
|
||||
|
||||
// Remote record, then a local edit keeps the base for three-way merge.
|
||||
let id = Uuid::new_v4();
|
||||
store.put_remote(vid, &record(id, 4, 10)).unwrap();
|
||||
assert!(store.pending(vid).unwrap().is_empty());
|
||||
let mut edit = record(id, 999, 20);
|
||||
edit.updated_at = "later".into();
|
||||
store.put_local(vid, &edit).unwrap();
|
||||
let got = store.get(vid, id).unwrap().unwrap();
|
||||
assert_eq!(got.state, SyncState::Pending);
|
||||
assert_eq!(got.record.revision, 4);
|
||||
assert_eq!(got.record.payload, Some(env(21)));
|
||||
assert_eq!(got.base.as_ref().unwrap().payload, Some(env(11)));
|
||||
assert_eq!(got.base_revision(), 4);
|
||||
assert_eq!(store.pending(vid).unwrap().len(), 1);
|
||||
|
||||
// A brand-new local item has no base.
|
||||
let new_id = Uuid::new_v4();
|
||||
store.put_local(vid, &record(new_id, 0, 30)).unwrap();
|
||||
assert!(store.get(vid, new_id).unwrap().unwrap().base.is_none());
|
||||
|
||||
// Tombstones round-trip with no envelopes.
|
||||
let tomb = ItemRecord { deleted: true, wrapped_item_key: None, payload: None, ..record(id, 9, 0) };
|
||||
store.put_remote(vid, &tomb).unwrap();
|
||||
let got = store.get(vid, id).unwrap().unwrap();
|
||||
assert!(got.record.deleted && got.record.payload.is_none() && got.base.unwrap().deleted);
|
||||
assert_eq!(store.list(vid).unwrap().len(), 2);
|
||||
store.remove(vid, new_id).unwrap();
|
||||
assert_eq!(store.list(vid).unwrap().len(), 1);
|
||||
|
||||
for i in 0..15u64 {
|
||||
store
|
||||
.push_history(
|
||||
vid,
|
||||
id,
|
||||
&HistoryEntry { revision: i, wrapped_item_key: env(1), payload: env(2), saved_at: 0 },
|
||||
)
|
||||
.unwrap();
|
||||
}
|
||||
let h = store.history(vid, id).unwrap();
|
||||
assert_eq!(h.len(), HISTORY_LIMIT);
|
||||
assert_eq!(h[0].revision, 14);
|
||||
assert_eq!(h[9].revision, 5);
|
||||
assert!(store.history(vid, Uuid::new_v4()).unwrap().is_empty());
|
||||
|
||||
store.wipe().unwrap();
|
||||
assert!(store.vaults().unwrap().is_empty());
|
||||
assert!(store.list(vid).unwrap().is_empty());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn memory_store() {
|
||||
exercise(&MemoryStore::new());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn sqlite_store() {
|
||||
exercise(&SqliteStore::open_in_memory().unwrap());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn sqlite_persists_across_reopen() {
|
||||
let dir = tempfile::tempdir().unwrap();
|
||||
let path = dir.path().join("vault.db");
|
||||
let vid = Uuid::new_v4();
|
||||
let id = Uuid::new_v4();
|
||||
{
|
||||
let s = SqliteStore::open(&path).unwrap();
|
||||
s.put_vault(&VaultRow::new(vid, env(1), None)).unwrap();
|
||||
s.put_remote(vid, &record(id, 2, 3)).unwrap();
|
||||
}
|
||||
let s = SqliteStore::open(&path).unwrap();
|
||||
assert_eq!(s.vault(vid).unwrap().unwrap().sealed_key, env(1));
|
||||
assert_eq!(s.get(vid, id).unwrap().unwrap().record.revision, 2);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn sqlite_never_stores_plaintext_columns() {
|
||||
// Guard against accidentally adding a plaintext column: only ciphertext/ids/bookkeeping exist.
|
||||
let s = SqliteStore::open_in_memory().unwrap();
|
||||
let conn = s.conn();
|
||||
let mut stmt = conn.prepare("SELECT name FROM pragma_table_info('item')").unwrap();
|
||||
let cols: Vec<String> = stmt.query_map([], |r| r.get(0)).unwrap().map(|c| c.unwrap()).collect();
|
||||
for c in &cols {
|
||||
assert!(!["title", "username", "password", "url", "notes"].contains(&c.as_str()), "{c}");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user