Merge branch 'wasm' into wasm-onboarding
This commit is contained in:
@@ -27,6 +27,22 @@ jobs:
|
||||
- run: cargo clippy --workspace --all-targets -- -D warnings
|
||||
- run: cargo test --workspace
|
||||
|
||||
# The web vault's core (crates/kv-wasm/README.md): wasm32 lints, in-browser tests (IndexedDB,
|
||||
# WebCrypto), the package build and a Node smoke test of the built package.
|
||||
wasm:
|
||||
runs-on: ubuntu-24.04
|
||||
steps:
|
||||
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
|
||||
with: { persist-credentials: false }
|
||||
- uses: dtolnay/rust-toolchain@683b588c487a69d01f6c6ddf40f036ff682cc2c8 # master, 2026-10
|
||||
with: { toolchain: stable, components: clippy, targets: wasm32-unknown-unknown }
|
||||
- uses: Swatinem/rust-cache@6323deb102c322ba6fcbdcafc7e3dddab59af2b6 # v2.9.2
|
||||
- run: cargo install wasm-pack --locked
|
||||
- run: cargo clippy -p kv-wasm --target wasm32-unknown-unknown --all-targets -- -D warnings
|
||||
- run: wasm-pack test --headless --chrome crates/kv-wasm
|
||||
- run: crates/kv-wasm/build.sh
|
||||
- run: node crates/kv-wasm/js/smoke.mjs
|
||||
|
||||
deny:
|
||||
runs-on: ubuntu-24.04
|
||||
steps:
|
||||
|
||||
Generated
+176
@@ -202,6 +202,17 @@ dependencies = [
|
||||
"tokio",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "async-trait"
|
||||
version = "0.1.92"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "82f6aeea286b8eb4dd3431a1be1b59d290ace00f5bfd8e2a159bc2a05e2c1667"
|
||||
dependencies = [
|
||||
"proc-macro2",
|
||||
"quote",
|
||||
"syn 3.0.9",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "atomic-waker"
|
||||
version = "1.1.2"
|
||||
@@ -337,6 +348,12 @@ dependencies = [
|
||||
"thiserror 1.0.69",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "cast"
|
||||
version = "0.3.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "37b2a672a2cb129a2e41c10b1224bb368f9f37a2b16b612598138befd7b37eb5"
|
||||
|
||||
[[package]]
|
||||
name = "cc"
|
||||
version = "1.8.0"
|
||||
@@ -1441,6 +1458,7 @@ dependencies = [
|
||||
"base64 0.22.1",
|
||||
"blake3",
|
||||
"ciborium",
|
||||
"js-sys",
|
||||
"kv-crypto",
|
||||
"kv-model",
|
||||
"kv-proto",
|
||||
@@ -1468,6 +1486,35 @@ dependencies = [
|
||||
"zeroize",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "kv-wasm"
|
||||
version = "0.1.0"
|
||||
dependencies = [
|
||||
"getrandom 0.2.17",
|
||||
"js-sys",
|
||||
"kv-crypto",
|
||||
"kv-ffi",
|
||||
"kv-import",
|
||||
"kv-model",
|
||||
"kv-proto",
|
||||
"kv-store",
|
||||
"kv-sync",
|
||||
"serde",
|
||||
"serde-wasm-bindgen",
|
||||
"serde_bytes",
|
||||
"serde_json",
|
||||
"sha2",
|
||||
"tempfile",
|
||||
"thiserror 2.0.21",
|
||||
"tokio",
|
||||
"uuid",
|
||||
"wasm-bindgen",
|
||||
"wasm-bindgen-futures",
|
||||
"wasm-bindgen-test",
|
||||
"web-sys",
|
||||
"zeroize",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "lazy_static"
|
||||
version = "1.5.1"
|
||||
@@ -1480,6 +1527,12 @@ version = "0.2.190"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "ce5d3ddc6d3fa000eb1536d85e147bfe31aacaba692ed6a876f95cb7c855be78"
|
||||
|
||||
[[package]]
|
||||
name = "libm"
|
||||
version = "0.2.16"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "b6d2cec3eae94f9f509c767b45932f1ada8350c4bdb85af2fcab4a3c14807981"
|
||||
|
||||
[[package]]
|
||||
name = "libsqlite3-sys"
|
||||
version = "0.30.1"
|
||||
@@ -1531,6 +1584,16 @@ dependencies = [
|
||||
"unicase",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "minicov"
|
||||
version = "0.3.8"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "4869b6a491569605d66d3952bcdf03df789e5b536e5f0cf7758a7f08a55ae24d"
|
||||
dependencies = [
|
||||
"cc",
|
||||
"walkdir",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "minimal-lexical"
|
||||
version = "0.2.1"
|
||||
@@ -1580,12 +1643,31 @@ dependencies = [
|
||||
"minimal-lexical",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "nu-ansi-term"
|
||||
version = "0.50.3"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "7957b9740744892f114936ab4a57b3f487491bbeafaf8083688b16841a4240e5"
|
||||
dependencies = [
|
||||
"windows-sys 0.61.2",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "num-conv"
|
||||
version = "0.2.2"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "521739c6d2bac4aa25192232afe6841231376b2b26d4d9fae5ecf8ca5772e441"
|
||||
|
||||
[[package]]
|
||||
name = "num-traits"
|
||||
version = "0.2.19"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "071dfc062690e90b734c0b2273ce72ad0ffa95f0c74596bc250dcfd960262841"
|
||||
dependencies = [
|
||||
"autocfg",
|
||||
"libm",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "num_cpus"
|
||||
version = "1.17.0"
|
||||
@@ -1608,6 +1690,12 @@ version = "1.70.2"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "384b8ab6d37215f3c5301a95a4accb5d64aa607f1fcb26a11b5303878451b4fe"
|
||||
|
||||
[[package]]
|
||||
name = "oorandom"
|
||||
version = "11.1.5"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "d6790f58c7ff633d8771f42965289203411a5e5c68388703c06e14f24770b41e"
|
||||
|
||||
[[package]]
|
||||
name = "opaque-debug"
|
||||
version = "0.3.1"
|
||||
@@ -2029,6 +2117,15 @@ version = "1.0.23"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "9774ba4a74de5f7b1c1451ed6cd5285a32eddb5cccb8cc655a4e50009e06477f"
|
||||
|
||||
[[package]]
|
||||
name = "same-file"
|
||||
version = "1.0.6"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "93fc1dc3aaa9bfed95e02e6eadabb4baf7e3078b0bd1b4d7b6b0b68378900502"
|
||||
dependencies = [
|
||||
"winapi-util",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "scroll"
|
||||
version = "0.12.0"
|
||||
@@ -2083,6 +2180,27 @@ dependencies = [
|
||||
"serde_derive",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "serde-wasm-bindgen"
|
||||
version = "0.6.5"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "8302e169f0eddcc139c70f139d19d6467353af16f9fce27e8c30158036a1e16b"
|
||||
dependencies = [
|
||||
"js-sys",
|
||||
"serde",
|
||||
"wasm-bindgen",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "serde_bytes"
|
||||
version = "0.11.19"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "a5d440709e79d88e51ac01c4b72fc6cb7314017bb7da9eeff678aa94c10e3ea8"
|
||||
dependencies = [
|
||||
"serde",
|
||||
"serde_core",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "serde_core"
|
||||
version = "1.0.229"
|
||||
@@ -2761,6 +2879,16 @@ dependencies = [
|
||||
"zeroize",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "walkdir"
|
||||
version = "2.5.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "29790946404f91d9c5d06f9874efddea1dc06c5efe94541a7d6863108e3a5e4b"
|
||||
dependencies = [
|
||||
"same-file",
|
||||
"winapi-util",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "want"
|
||||
version = "0.3.2"
|
||||
@@ -2832,6 +2960,45 @@ dependencies = [
|
||||
"unicode-ident",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "wasm-bindgen-test"
|
||||
version = "0.3.79"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "ae7499dfd45780a0a91d7ee6bb9ac51970a4479a41a89da443fdda5a39547d42"
|
||||
dependencies = [
|
||||
"async-trait",
|
||||
"cast",
|
||||
"js-sys",
|
||||
"libm",
|
||||
"minicov",
|
||||
"nu-ansi-term",
|
||||
"num-traits",
|
||||
"oorandom",
|
||||
"serde",
|
||||
"serde_json",
|
||||
"wasm-bindgen",
|
||||
"wasm-bindgen-futures",
|
||||
"wasm-bindgen-test-macro",
|
||||
"wasm-bindgen-test-shared",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "wasm-bindgen-test-macro"
|
||||
version = "0.3.79"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "3b84b5ac638bfb168196a1a461fcc8f46a294a18b1b6be52133b4e0db122cc9f"
|
||||
dependencies = [
|
||||
"proc-macro2",
|
||||
"quote",
|
||||
"syn 3.0.9",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "wasm-bindgen-test-shared"
|
||||
version = "0.2.129"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "4f692aa943ccd88363733b77063f32cfed5bc6cbea8e6e8b251b302f881606fe"
|
||||
|
||||
[[package]]
|
||||
name = "web-sys"
|
||||
version = "0.3.106"
|
||||
@@ -2869,6 +3036,15 @@ dependencies = [
|
||||
"nom",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "winapi-util"
|
||||
version = "0.1.11"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "c2a7b1c03c876122aa43f3020e6c3c3ee5c05081c9a00739faf7503aeba10d22"
|
||||
dependencies = [
|
||||
"windows-sys 0.61.2",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "windows-link"
|
||||
version = "0.2.1"
|
||||
|
||||
+12
-1
@@ -12,7 +12,8 @@ publish = false
|
||||
[workspace.dependencies]
|
||||
kv-proto = { path = "crates/kv-proto" }
|
||||
kv-crypto = { path = "crates/kv-crypto" }
|
||||
kv-model = { path = "crates/kv-model" }
|
||||
# The `psl` feature (eTLD+1 matching, ~0.9 MB of code) is enabled by kv-ffi; the web build leaves it out.
|
||||
kv-model = { path = "crates/kv-model", default-features = false }
|
||||
kv-store = { path = "crates/kv-store" }
|
||||
kv-sync = { path = "crates/kv-sync" }
|
||||
kv-import = { path = "crates/kv-import" }
|
||||
@@ -65,3 +66,13 @@ strip = true
|
||||
opt-level = 3
|
||||
[profile.dev.package.blake2]
|
||||
opt-level = 3
|
||||
|
||||
# Web vault (`wasm-pack build crates/kv-wasm --profile wasm-release`): optimize for download size,
|
||||
# but keep Argon2id's hot loop at full speed.
|
||||
[profile.wasm-release]
|
||||
inherits = "release"
|
||||
opt-level = "z"
|
||||
[profile.wasm-release.package.argon2]
|
||||
opt-level = 3
|
||||
[profile.wasm-release.package.blake2]
|
||||
opt-level = 3
|
||||
|
||||
@@ -17,7 +17,7 @@ path = "src/bin/uniffi-bindgen.rs"
|
||||
[dependencies]
|
||||
kv-proto.workspace = true
|
||||
kv-crypto.workspace = true
|
||||
kv-model.workspace = true
|
||||
kv-model = { workspace = true, features = ["psl"] }
|
||||
kv-store.workspace = true
|
||||
kv-sync.workspace = true
|
||||
kv-import.workspace = true
|
||||
|
||||
@@ -18,8 +18,14 @@ sha1.workspace = true
|
||||
sha2.workspace = true
|
||||
data-encoding.workspace = true
|
||||
url.workspace = true
|
||||
psl.workspace = true
|
||||
psl = { workspace = true, optional = true }
|
||||
p256.workspace = true
|
||||
|
||||
[features]
|
||||
# Public Suffix List for `MatchLevel::Domain`. Without it only same-host URLs match (the web vault,
|
||||
# where the list would be a third of the download and nothing autofills).
|
||||
default = ["psl"]
|
||||
psl = ["dep:psl"]
|
||||
|
||||
[dev-dependencies]
|
||||
ciborium.workspace = true
|
||||
|
||||
@@ -34,12 +34,24 @@ pub fn match_url(item_url: &str, page_url: &str) -> MatchLevel {
|
||||
MatchLevel::Host
|
||||
};
|
||||
}
|
||||
match (psl::domain_str(&ih), psl::domain_str(&ph)) {
|
||||
same_registrable_domain(&ih, &ph)
|
||||
}
|
||||
|
||||
#[cfg(feature = "psl")]
|
||||
fn same_registrable_domain(a: &str, b: &str) -> MatchLevel {
|
||||
match (psl::domain_str(a), psl::domain_str(b)) {
|
||||
(Some(a), Some(b)) if a == b => MatchLevel::Domain,
|
||||
_ => MatchLevel::None,
|
||||
}
|
||||
}
|
||||
|
||||
/// Without the Public Suffix List there is no safe way to tell `a.example.com` / `b.example.com`
|
||||
/// (same site) from `a.github.io` / `b.github.io` (different sites), so different hosts never match.
|
||||
#[cfg(not(feature = "psl"))]
|
||||
fn same_registrable_domain(_: &str, _: &str) -> MatchLevel {
|
||||
MatchLevel::None
|
||||
}
|
||||
|
||||
fn parse(s: &str) -> Option<Url> {
|
||||
let s = s.trim();
|
||||
// Saved URLs are often stored without a scheme ("example.com/login").
|
||||
|
||||
@@ -11,6 +11,9 @@ kv-proto.workspace = true
|
||||
uuid.workspace = true
|
||||
thiserror.workspace = true
|
||||
serde_json.workspace = true
|
||||
|
||||
# SQLite does not build for wasm32; the web vault (kv-wasm) brings its own IndexedDB-backed store.
|
||||
[target.'cfg(not(target_arch = "wasm32"))'.dependencies]
|
||||
rusqlite.workspace = true
|
||||
|
||||
[dev-dependencies]
|
||||
|
||||
+7
-326
@@ -1,5 +1,7 @@
|
||||
//! Local encrypted replica (milestone M1, kestrove-spec/docs/07).
|
||||
//!
|
||||
//! [`SqliteStore`] is native only; on wasm32 the web vault (`kv-wasm`) implements [`Store`] over IndexedDB.
|
||||
//!
|
||||
//! The store holds the same `Envelope` bytes the server holds, plus sync
|
||||
//! bookkeeping. It never sees plaintext. Schema (rusqlite, bundled):
|
||||
//!
|
||||
@@ -22,14 +24,17 @@
|
||||
|
||||
use std::{
|
||||
collections::{BTreeMap, HashMap},
|
||||
path::Path,
|
||||
sync::Mutex,
|
||||
};
|
||||
|
||||
use kv_proto::{Envelope, ItemRecord};
|
||||
use rusqlite::{params, Connection, OptionalExtension};
|
||||
use uuid::Uuid;
|
||||
|
||||
#[cfg(not(target_arch = "wasm32"))]
|
||||
mod sqlite;
|
||||
#[cfg(not(target_arch = "wasm32"))]
|
||||
pub use sqlite::SqliteStore;
|
||||
|
||||
/// Versions of an item kept in history.
|
||||
pub const HISTORY_LIMIT: usize = 10;
|
||||
|
||||
@@ -41,12 +46,6 @@ pub enum StoreError {
|
||||
NotFound,
|
||||
}
|
||||
|
||||
impl From<rusqlite::Error> for StoreError {
|
||||
fn from(e: rusqlite::Error) -> Self {
|
||||
StoreError::Io(e.to_string())
|
||||
}
|
||||
}
|
||||
|
||||
impl From<serde_json::Error> for StoreError {
|
||||
fn from(e: serde_json::Error) -> Self {
|
||||
StoreError::Io(format!("corrupt envelope: {e}"))
|
||||
@@ -164,324 +163,6 @@ pub trait Store: Send + Sync {
|
||||
}
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------- 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)]
|
||||
|
||||
@@ -0,0 +1,331 @@
|
||||
//! SQLite backend (devices). Not built for wasm32.
|
||||
|
||||
use std::{path::Path, sync::Mutex};
|
||||
|
||||
use kv_proto::{Envelope, ItemRecord};
|
||||
use rusqlite::{params, Connection, OptionalExtension};
|
||||
use uuid::Uuid;
|
||||
|
||||
use crate::{HistoryEntry, LocalItem, Result, Store, StoreError, SyncState, VaultRow, HISTORY_LIMIT};
|
||||
|
||||
impl From<rusqlite::Error> for StoreError {
|
||||
fn from(e: rusqlite::Error) -> Self {
|
||||
StoreError::Io(e.to_string())
|
||||
}
|
||||
}
|
||||
|
||||
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) })
|
||||
}
|
||||
|
||||
pub(crate) 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(())
|
||||
}
|
||||
}
|
||||
@@ -21,10 +21,6 @@ rand_core.workspace = true
|
||||
zeroize.workspace = true
|
||||
opaque-ke.workspace = true
|
||||
reqwest.workspace = true
|
||||
rustls.workspace = true
|
||||
webpki-roots.workspace = true
|
||||
rustls-webpki.workspace = true
|
||||
tokio.workspace = true
|
||||
ciborium.workspace = true
|
||||
blake3.workspace = true
|
||||
p256.workspace = true
|
||||
@@ -32,6 +28,17 @@ time.workspace = true
|
||||
url.workspace = true
|
||||
wiremock = { workspace = true, optional = true }
|
||||
|
||||
# Native transport: rustls with SPKI pinning. In the browser (wasm32) reqwest uses fetch and the
|
||||
# browser's TLS, so none of this applies there.
|
||||
[target.'cfg(not(target_arch = "wasm32"))'.dependencies]
|
||||
rustls.workspace = true
|
||||
webpki-roots.workspace = true
|
||||
rustls-webpki.workspace = true
|
||||
tokio.workspace = true
|
||||
|
||||
[target.'cfg(target_arch = "wasm32")'.dependencies]
|
||||
js-sys = "0.3"
|
||||
|
||||
[features]
|
||||
# In-memory fake of kestrove-server for tests (also used by kv-ffi's tests).
|
||||
test-server = ["dep:wiremock"]
|
||||
|
||||
@@ -1,9 +1,6 @@
|
||||
//! Typed HTTP client for the sync API (docs/03, api/openapi.yaml).
|
||||
|
||||
use std::{
|
||||
sync::{Arc, Mutex},
|
||||
time::{SystemTime, UNIX_EPOCH},
|
||||
};
|
||||
use std::sync::{Arc, Mutex};
|
||||
|
||||
use kv_proto::{
|
||||
AccountInfo, ApiError, Changes, DeviceEnroll, DeviceInfo, Envelope, ItemPut, ItemRecord, JoinBundle, JoinCreate,
|
||||
@@ -40,10 +37,6 @@ pub struct ApiClient {
|
||||
session: Mutex<Option<String>>,
|
||||
}
|
||||
|
||||
fn now_unix() -> u64 {
|
||||
SystemTime::now().duration_since(UNIX_EPOCH).map(|d| d.as_secs()).unwrap_or(0)
|
||||
}
|
||||
|
||||
impl ApiClient {
|
||||
pub fn new(cfg: &ServerConfig, signer: Arc<dyn DeviceSigner>) -> Result<Self, SyncError> {
|
||||
Ok(Self {
|
||||
@@ -104,7 +97,15 @@ impl ApiClient {
|
||||
}
|
||||
if signed {
|
||||
let session = self.session().ok_or(SyncError::NotAuthenticated)?;
|
||||
let h = signing::sign_request(self.signer.as_ref(), method.as_str(), &url, &body, &session, now_unix())?;
|
||||
let h = signing::sign_request_async(
|
||||
self.signer.as_ref(),
|
||||
method.as_str(),
|
||||
&url,
|
||||
&body,
|
||||
&session,
|
||||
crate::unix_now(),
|
||||
)
|
||||
.await?;
|
||||
for (k, v) in h.pairs() {
|
||||
req = req.header(k, v);
|
||||
}
|
||||
|
||||
@@ -4,8 +4,6 @@
|
||||
//! `If-Match` (on 412: pull, merge, retry), pull again, then publish a new manifest. Every pulled record is
|
||||
//! checked against the local base for per-item replay ([`crate::replay`]).
|
||||
|
||||
use std::time::{SystemTime, UNIX_EPOCH};
|
||||
|
||||
use kv_crypto::{
|
||||
aead::SymKey,
|
||||
keys::{open_item, seal_item, AccountKeyset, SealedItem},
|
||||
@@ -48,11 +46,15 @@ pub struct SyncContext<'a> {
|
||||
}
|
||||
|
||||
fn now() -> i64 {
|
||||
SystemTime::now().duration_since(UNIX_EPOCH).map(|d| d.as_secs() as i64).unwrap_or(0)
|
||||
crate::unix_now() as i64
|
||||
}
|
||||
|
||||
fn rfc3339_now() -> String {
|
||||
time::OffsetDateTime::now_utc().format(&time::format_description::well_known::Rfc3339).unwrap_or_default()
|
||||
// Not `OffsetDateTime::now_utc()`: it panics on wasm32-unknown-unknown.
|
||||
time::OffsetDateTime::from_unix_timestamp(now())
|
||||
.ok()
|
||||
.and_then(|t| t.format(&time::format_description::well_known::Rfc3339).ok())
|
||||
.unwrap_or_default()
|
||||
}
|
||||
|
||||
fn crypto(e: kv_crypto::CryptoError) -> SyncError {
|
||||
|
||||
@@ -1,7 +1,8 @@
|
||||
//! Sync engine (milestone M1, kestrove-spec/docs/03).
|
||||
//!
|
||||
//! Responsibilities:
|
||||
//! * [`transport`]: HTTPS with rustls, TLS 1.3 only, SPKI pinning (primary + backup pin).
|
||||
//! * [`transport`]: HTTPS with rustls, TLS 1.3 only, SPKI pinning (primary + backup pin). On wasm32 (the web
|
||||
//! vault) reqwest uses the browser's `fetch` and TLS instead, and pins do not apply.
|
||||
//! * [`opaque`]: OPAQUE login via `opaque-ke` (ristretto255 / SHA-512, identity KSF because the input is already
|
||||
//! Argon2id-stretched).
|
||||
//! * [`signing`]: RFC 9421 request signing with the device key. The key lives in the platform keystore, so signing is
|
||||
@@ -26,6 +27,8 @@ pub mod wire;
|
||||
|
||||
pub use client::{ApiClient, LoginOutcome};
|
||||
pub use engine::{SyncContext, SyncReport};
|
||||
use std::{future::Future, pin::Pin};
|
||||
|
||||
use uuid::Uuid;
|
||||
|
||||
#[derive(Debug, thiserror::Error)]
|
||||
@@ -73,13 +76,39 @@ impl From<kv_store::StoreError> for SyncError {
|
||||
}
|
||||
}
|
||||
|
||||
/// Implemented by each platform: Secure Enclave, StrongBox, TPM or software fallback.
|
||||
/// Future returned by [`DeviceSigner::sign_async`]. `Send` on native targets; not on wasm32, where
|
||||
/// it wraps a WebCrypto promise (the browser runs the core on one thread).
|
||||
#[cfg(not(target_arch = "wasm32"))]
|
||||
pub type SignFuture<'a> = Pin<Box<dyn Future<Output = Result<Vec<u8>, SyncError>> + Send + 'a>>;
|
||||
#[cfg(target_arch = "wasm32")]
|
||||
pub type SignFuture<'a> = Pin<Box<dyn Future<Output = Result<Vec<u8>, SyncError>> + 'a>>;
|
||||
|
||||
/// Implemented by each platform: Secure Enclave, StrongBox, TPM, WebCrypto or software fallback.
|
||||
pub trait DeviceSigner: Send + Sync {
|
||||
fn device_id(&self) -> Uuid;
|
||||
/// SEC1 uncompressed P-256 public key.
|
||||
fn public_key(&self) -> Vec<u8>;
|
||||
/// ECDSA P-256 / SHA-256 signature (IEEE P1363 r||s) over `message`.
|
||||
fn sign(&self, message: &[u8]) -> Result<Vec<u8>, SyncError>;
|
||||
/// Asynchronous signing, used by [`ApiClient`]. Defaults to [`DeviceSigner::sign`]; the web vault
|
||||
/// overrides it because WebCrypto only signs asynchronously.
|
||||
fn sign_async<'a>(&'a self, message: &'a [u8]) -> SignFuture<'a> {
|
||||
Box::pin(std::future::ready(self.sign(message)))
|
||||
}
|
||||
}
|
||||
|
||||
/// Seconds since the Unix epoch. `SystemTime::now()` panics on wasm32-unknown-unknown, so the
|
||||
/// browser build asks JavaScript's clock.
|
||||
pub fn unix_now() -> u64 {
|
||||
#[cfg(target_arch = "wasm32")]
|
||||
{
|
||||
(js_sys::Date::now() / 1000.0) as u64
|
||||
}
|
||||
#[cfg(not(target_arch = "wasm32"))]
|
||||
{
|
||||
use std::time::{SystemTime, UNIX_EPOCH};
|
||||
SystemTime::now().duration_since(UNIX_EPOCH).map(|d| d.as_secs()).unwrap_or(0)
|
||||
}
|
||||
}
|
||||
|
||||
/// Server endpoint plus pinned SPKI SHA-256 hashes (base64).
|
||||
|
||||
@@ -53,6 +53,21 @@ pub fn sign_request(
|
||||
sign_request_with_nonce(signer, method, target_uri, body, session, now_unix, &nonce)
|
||||
}
|
||||
|
||||
/// [`sign_request`] through [`DeviceSigner::sign_async`] (the path [`crate::ApiClient`] uses).
|
||||
pub async fn sign_request_async(
|
||||
signer: &dyn DeviceSigner,
|
||||
method: &str,
|
||||
target_uri: &str,
|
||||
body: &[u8],
|
||||
session: &str,
|
||||
now_unix: u64,
|
||||
) -> Result<SignedHeaders, SyncError> {
|
||||
let nonce = URL_SAFE_NO_PAD.encode(kv_crypto::random_bytes::<16>());
|
||||
let unsigned = Unsigned::new(signer, method, target_uri, body, session, now_unix, &nonce);
|
||||
let sig = signer.sign_async(unsigned.base.as_bytes()).await?;
|
||||
unsigned.finish(&sig)
|
||||
}
|
||||
|
||||
fn sign_request_with_nonce(
|
||||
signer: &dyn DeviceSigner,
|
||||
method: &str,
|
||||
@@ -62,21 +77,49 @@ fn sign_request_with_nonce(
|
||||
now_unix: u64,
|
||||
nonce: &str,
|
||||
) -> Result<SignedHeaders, SyncError> {
|
||||
let digest = content_digest(body);
|
||||
let ts = now_unix.to_string();
|
||||
let params = signature_params(now_unix as i64, signer.device_id(), nonce);
|
||||
let base = signature_base(&method.to_ascii_uppercase(), target_uri, &digest, session, &ts, ¶ms);
|
||||
let sig = signer.sign(base.as_bytes())?;
|
||||
if sig.len() != 64 {
|
||||
return Err(SyncError::Signer(format!("device signature must be 64 bytes (r||s), got {}", sig.len())));
|
||||
let unsigned = Unsigned::new(signer, method, target_uri, body, session, now_unix, nonce);
|
||||
let sig = signer.sign(unsigned.base.as_bytes())?;
|
||||
unsigned.finish(&sig)
|
||||
}
|
||||
|
||||
/// Everything but the signature: the signature base and the header values around it.
|
||||
struct Unsigned {
|
||||
digest: String,
|
||||
session: String,
|
||||
ts: String,
|
||||
params: String,
|
||||
base: String,
|
||||
}
|
||||
|
||||
impl Unsigned {
|
||||
fn new(
|
||||
signer: &dyn DeviceSigner,
|
||||
method: &str,
|
||||
target_uri: &str,
|
||||
body: &[u8],
|
||||
session: &str,
|
||||
now_unix: u64,
|
||||
nonce: &str,
|
||||
) -> Self {
|
||||
let digest = content_digest(body);
|
||||
let ts = now_unix.to_string();
|
||||
let params = signature_params(now_unix as i64, signer.device_id(), nonce);
|
||||
let base = signature_base(&method.to_ascii_uppercase(), target_uri, &digest, session, &ts, ¶ms);
|
||||
Self { digest, session: session.to_string(), ts, params, base }
|
||||
}
|
||||
|
||||
fn finish(self, sig: &[u8]) -> Result<SignedHeaders, SyncError> {
|
||||
if sig.len() != 64 {
|
||||
return Err(SyncError::Signer(format!("device signature must be 64 bytes (r||s), got {}", sig.len())));
|
||||
}
|
||||
Ok(SignedHeaders {
|
||||
content_digest: self.digest,
|
||||
session: self.session,
|
||||
timestamp: self.ts,
|
||||
signature_input: signature_input_header(&self.params),
|
||||
signature: signature_header(sig),
|
||||
})
|
||||
}
|
||||
Ok(SignedHeaders {
|
||||
content_digest: digest,
|
||||
session: session.to_string(),
|
||||
timestamp: ts,
|
||||
signature_input: signature_input_header(¶ms),
|
||||
signature: signature_header(&sig),
|
||||
})
|
||||
}
|
||||
|
||||
/// Software P-256 device key: for tests and as the desktop fallback when no TPM is available.
|
||||
|
||||
@@ -7,13 +7,18 @@
|
||||
//! a primary and a backup pin so a key rotation never locks the client out.
|
||||
//!
|
||||
//! Plain `http://` is accepted only for loopback hosts (local development and tests).
|
||||
//!
|
||||
//! On wasm32 (the web vault) reqwest sends through the browser's `fetch`: the browser does TLS and the
|
||||
//! page is served from the same origin as the API, so pinning and the rustls config are native only.
|
||||
|
||||
#[cfg(not(target_arch = "wasm32"))]
|
||||
use std::sync::Arc;
|
||||
|
||||
use base64::{
|
||||
engine::general_purpose::{STANDARD, URL_SAFE_NO_PAD},
|
||||
Engine,
|
||||
};
|
||||
#[cfg(not(target_arch = "wasm32"))]
|
||||
use rustls::{
|
||||
client::{
|
||||
danger::{HandshakeSignatureValid, ServerCertVerified, ServerCertVerifier},
|
||||
@@ -22,6 +27,7 @@ use rustls::{
|
||||
pki_types::{CertificateDer, ServerName, UnixTime},
|
||||
CertificateError, DigitallySignedStruct, OtherError, RootCertStore, SignatureScheme,
|
||||
};
|
||||
#[cfg(not(target_arch = "wasm32"))]
|
||||
use sha2::{Digest, Sha256};
|
||||
|
||||
use crate::{ServerConfig, SyncError};
|
||||
@@ -48,22 +54,26 @@ pub fn parse_pin(pin: &str) -> Result<[u8; 32], SyncError> {
|
||||
raw.try_into().map_err(|_| SyncError::Config(format!("pin is not a SHA-256 hash: {pin}")))
|
||||
}
|
||||
|
||||
#[cfg(not(target_arch = "wasm32"))]
|
||||
/// `base64(SHA-256(SPKI))` of a DER certificate. Useful for tooling and tests.
|
||||
pub fn spki_pin_of(cert_der: &[u8]) -> Result<String, SyncError> {
|
||||
Ok(STANDARD.encode(spki_hash(&CertificateDer::from(cert_der))?))
|
||||
}
|
||||
|
||||
#[cfg(not(target_arch = "wasm32"))]
|
||||
fn spki_hash(cert: &CertificateDer<'_>) -> Result<[u8; 32], SyncError> {
|
||||
let parsed = webpki::EndEntityCert::try_from(cert).map_err(|e| SyncError::Config(format!("certificate: {e}")))?;
|
||||
Ok(Sha256::digest(parsed.subject_public_key_info().as_ref()).into())
|
||||
}
|
||||
|
||||
#[cfg(not(target_arch = "wasm32"))]
|
||||
#[derive(Debug)]
|
||||
struct PinningVerifier {
|
||||
inner: Arc<WebPkiServerVerifier>,
|
||||
pins: Vec<[u8; 32]>,
|
||||
}
|
||||
|
||||
#[cfg(not(target_arch = "wasm32"))]
|
||||
impl ServerCertVerifier for PinningVerifier {
|
||||
fn verify_server_cert(
|
||||
&self,
|
||||
@@ -112,6 +122,7 @@ impl ServerCertVerifier for PinningVerifier {
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(not(target_arch = "wasm32"))]
|
||||
/// Builds the rustls config: TLS 1.3 only, ring provider, Mozilla roots plus `extra_roots`, pin check.
|
||||
pub fn tls_config(pins: &[String], extra_roots: &[Vec<u8>]) -> Result<rustls::ClientConfig, SyncError> {
|
||||
let pins = pins.iter().map(|p| parse_pin(p)).collect::<Result<Vec<_>, _>>()?;
|
||||
@@ -156,6 +167,7 @@ pub fn normalize_base_url(raw: &str) -> Result<String, SyncError> {
|
||||
Ok(url.as_str().trim_end_matches('/').to_string())
|
||||
}
|
||||
|
||||
#[cfg(not(target_arch = "wasm32"))]
|
||||
pub fn http_client(cfg: &ServerConfig) -> Result<reqwest::Client, SyncError> {
|
||||
reqwest::Client::builder()
|
||||
.use_preconfigured_tls(tls_config(&cfg.spki_pins, &cfg.extra_roots_der)?)
|
||||
@@ -167,6 +179,18 @@ pub fn http_client(cfg: &ServerConfig) -> Result<reqwest::Client, SyncError> {
|
||||
.map_err(|e| SyncError::Config(e.to_string()))
|
||||
}
|
||||
|
||||
/// Browser `fetch`: TLS, timeouts and certificate checks belong to the browser. Pins are ignored.
|
||||
#[cfg(target_arch = "wasm32")]
|
||||
pub fn http_client(_cfg: &ServerConfig) -> Result<reqwest::Client, SyncError> {
|
||||
reqwest::Client::builder().build().map_err(|e| SyncError::Config(e.to_string()))
|
||||
}
|
||||
|
||||
#[cfg(target_arch = "wasm32")]
|
||||
pub(crate) fn classify(e: reqwest::Error) -> SyncError {
|
||||
SyncError::Network(e.to_string())
|
||||
}
|
||||
|
||||
#[cfg(not(target_arch = "wasm32"))]
|
||||
/// Maps a reqwest error to `PinMismatch` when the handshake failed on our pin check.
|
||||
pub(crate) fn classify(e: reqwest::Error) -> SyncError {
|
||||
let mut src: Option<&(dyn std::error::Error + 'static)> = Some(&e);
|
||||
|
||||
@@ -0,0 +1,64 @@
|
||||
[package]
|
||||
name = "kv-wasm"
|
||||
description = "WebAssembly build of the Kestrove core for the web vault (IndexedDB store, WebCrypto device key)."
|
||||
version.workspace = true
|
||||
edition.workspace = true
|
||||
license.workspace = true
|
||||
publish.workspace = true
|
||||
|
||||
[lib]
|
||||
crate-type = ["cdylib", "rlib"]
|
||||
|
||||
[dependencies]
|
||||
kv-proto.workspace = true
|
||||
kv-crypto.workspace = true
|
||||
kv-model.workspace = true
|
||||
kv-store.workspace = true
|
||||
kv-sync.workspace = true
|
||||
kv-import.workspace = true
|
||||
serde.workspace = true
|
||||
serde_json.workspace = true
|
||||
thiserror.workspace = true
|
||||
uuid.workspace = true
|
||||
zeroize.workspace = true
|
||||
sha2.workspace = true
|
||||
serde_bytes = "0.11"
|
||||
|
||||
[target.'cfg(target_arch = "wasm32")'.dependencies]
|
||||
wasm-bindgen = "0.2"
|
||||
wasm-bindgen-futures = "0.4"
|
||||
js-sys = "0.3"
|
||||
serde-wasm-bindgen = "0.6"
|
||||
# Browser randomness (crypto.getRandomValues) for uuid and rand_core/OsRng.
|
||||
uuid = { workspace = true, features = ["js"] }
|
||||
getrandom = { version = "0.2", features = ["js"] }
|
||||
web-sys = { version = "0.3", features = [
|
||||
"Crypto", "SubtleCrypto", "CryptoKey", "CryptoKeyPair", "EcKeyGenParams", "EcdsaParams",
|
||||
"IdbFactory", "IdbDatabase", "IdbOpenDbRequest", "IdbRequest", "IdbRequestReadyState", "IdbTransaction",
|
||||
"IdbTransactionMode", "IdbObjectStore", "IdbObjectStoreParameters", "IdbVersionChangeEvent", "IdbCursorWithValue",
|
||||
"DomException", "DomStringList", "Window", "WorkerGlobalScope", "Event",
|
||||
] }
|
||||
|
||||
[target.'cfg(target_arch = "wasm32")'.dev-dependencies]
|
||||
wasm-bindgen-test = "0.3"
|
||||
|
||||
[target.'cfg(not(target_arch = "wasm32"))'.dev-dependencies]
|
||||
kv-ffi = { path = "../kv-ffi" }
|
||||
kv-store.workspace = true
|
||||
kv-sync = { workspace = true, features = ["test-server"] }
|
||||
tokio = { workspace = true }
|
||||
tempfile.workspace = true
|
||||
|
||||
# rustc targets these post-MVP features by default; wasm-opt must be told it may use them.
|
||||
# `custom` applies to `--profile wasm-release`.
|
||||
[package.metadata.wasm-pack.profile.release]
|
||||
wasm-opt = [
|
||||
"-Oz", "--enable-bulk-memory", "--enable-nontrapping-float-to-int", "--enable-sign-ext",
|
||||
"--enable-mutable-globals", "--enable-reference-types", "--enable-multivalue",
|
||||
]
|
||||
|
||||
[package.metadata.wasm-pack.profile.custom]
|
||||
wasm-opt = [
|
||||
"-Oz", "--enable-bulk-memory", "--enable-nontrapping-float-to-int", "--enable-sign-ext",
|
||||
"--enable-mutable-globals", "--enable-reference-types", "--enable-multivalue",
|
||||
]
|
||||
@@ -0,0 +1,120 @@
|
||||
# kv-wasm: the Kestrove core for the web vault
|
||||
|
||||
The web vault runs the same Rust core as the apps, compiled to WebAssembly: same 2SKD (Argon2id +
|
||||
Secret Key), same envelopes, same item model, same OPAQUE login, RFC 9421 request signing and sync
|
||||
engine (three-way merge, signed manifests, replay checks). The server stays zero-knowledge: only
|
||||
ciphertext leaves the browser.
|
||||
|
||||
| Concern | Apps (kv-ffi) | Web (kv-wasm) |
|
||||
|---|---|---|
|
||||
| Local replica | SQLite (`SqliteStore`) | `WebStore`: in-memory, each changed entry written to IndexedDB before the call resolves |
|
||||
| Transport | reqwest + rustls, TLS 1.3, SPKI pins | reqwest over `fetch`; the browser's TLS; same origin; pins accepted but not enforced |
|
||||
| Device key | Secure Enclave / StrongBox / TPM | non-extractable WebCrypto ECDSA P-256 key, kept in IndexedDB |
|
||||
| Randomness, clock | OS | `crypto.getRandomValues`, `Date.now()` |
|
||||
| Biometric unlock (LUK) | yes | **no**: every unlock needs master password + Secret Key |
|
||||
| URL matching | eTLD+1 via the Public Suffix List | same host only (the list would be a third of the download) |
|
||||
| Passkeys | create, assert, list, delete | list, delete |
|
||||
|
||||
## Build and test
|
||||
|
||||
```sh
|
||||
crates/kv-wasm/build.sh # pkg/: wasm-pack (size profile, wasm-opt -Oz) + worker files
|
||||
wasm-pack test --headless --chrome crates/kv-wasm # in Chrome: IndexedDB, WebCrypto, native fixture
|
||||
node crates/kv-wasm/js/smoke.mjs # the built package in Node (in-memory), Argon2 timing
|
||||
cargo test -p kv-wasm # native: store, fixtures vs kv-ffi, sync with kv-ffi via test server
|
||||
```
|
||||
|
||||
`wasm-pack build crates/kv-wasm --target web` alone also works (opt-level 3, a bit bigger).
|
||||
`smoke.mjs` runs a two-device sync round trip against a real server when `KESTROVE_SERVER_URL` and
|
||||
`KESTROVE_SETUP_TOKEN` are set (see the file header).
|
||||
|
||||
**Size** (`build.sh`): `kv_wasm_bg.wasm` 1.27 MB, **572 KB gzip**, 467 KB brotli. JS glue ~60 KB.
|
||||
Serve it with compression and `Content-Type: application/wasm`.
|
||||
|
||||
**Argon2id** (m = 64 MiB, t = 3, p = 4, the account default): about **100–120 ms** per derivation in
|
||||
Chrome and Node on an Apple Silicon Mac (native is in the same range). Expect several times that on low-end phones.
|
||||
It runs on the calling thread, hence the worker below.
|
||||
|
||||
**Envelope compatibility** is checked both ways with shared fixtures in `tests/fixtures/`:
|
||||
`native-vault.json` is made by kv-ffi and opened in Chrome; `wasm-vault.json` is made by the wasm
|
||||
package and opened by kv-ffi (`tests/fixture.rs`).
|
||||
|
||||
## JS API
|
||||
|
||||
The generated `pkg/kv_wasm.d.ts` is the reference. It mirrors kv-ffi's `VaultCore` in camelCase;
|
||||
records are plain objects with the same fields as kv-ffi's records (`ItemDto`, `ItemSummary`,
|
||||
`SyncReport`, `DeviceDto`, `PasskeySummary`, `ImportPreview`, …); enums are camelCase strings
|
||||
(`"login" | "secureNote" | "card" | "identity"`, `"ok" | "pendingApproval"`); bytes are `Uint8Array`;
|
||||
absent optionals come back as `null`. `ItemSummary` also carries `tags` and `modifiedAt` (web only). **Every `VaultCore` method returns a `Promise`.**
|
||||
|
||||
```ts
|
||||
class VaultCore {
|
||||
static open(dbName?: string | null): Promise<VaultCore>; // IndexedDB, default "kestrove"
|
||||
static newInMemory(): Promise<VaultCore>; // persists nothing
|
||||
createAccount(masterPassword): Promise<string>; // -> Secret Key. Argon2id
|
||||
unlock(masterPassword, secretKey): Promise<void>; // Argon2id
|
||||
lock(): Promise<void>; isLocked(); hasAccount(); accountId(); accountFingerprint();
|
||||
listItems(); getItem(id); saveItem(item: ItemDto): Promise<string>; deleteItem(id);
|
||||
itemHistory(id); itemsForUrl(pageUrl); listPasskeys(); deletePasskey(credentialId: Uint8Array);
|
||||
importPreview(data: Uint8Array, format: ImportFormat); importCommit(data, format, options);
|
||||
exportEncrypted(passphrase): Promise<Uint8Array>; // Argon2id
|
||||
configureServer(url, spkiPins?); setDevice(name, platform?); deviceId(); deviceKeyFingerprint();
|
||||
serverInfo(); registerAccount(setupToken); login(masterPassword, secretKey, totp?): Promise<LoginResult>;
|
||||
syncNow(): Promise<SyncReport>; listDevices(); approveDevice(deviceId); revokeDevice(deviceId);
|
||||
logout(); isLoggedIn(); wipe();
|
||||
}
|
||||
generatePassword(length, symbols): string; totpCode(secretOrUri, unixTime): TotpCode;
|
||||
matchUrl(itemUrl, pageUrl): MatchLevel; coreVersion(): string;
|
||||
```
|
||||
|
||||
Errors reject with an `Error` whose `name` is `"CoreError"` and whose `code` is one of kv-ffi's
|
||||
`CoreError` cases (`Locked`, `NoAccount`, `BadCredentials`, `Invalid`, `NotFound`, `Internal`,
|
||||
`NotConfigured`, `NotAuthenticated`, `PendingApproval`, `Network`, `Server` + `status`,
|
||||
`IntegrityAlarm` + `vaultId`/`reason`), plus `Busy` (a sync is already running) and, from the worker
|
||||
client, `InUse` (another tab has the vault open).
|
||||
|
||||
### Use it from a Web Worker
|
||||
|
||||
`createAccount`, `unlock`, `login`, `exportEncrypted` and importing a `.kvex` run Argon2id and block
|
||||
their thread. The package ships a worker and a typed client:
|
||||
|
||||
```ts
|
||||
import { connectVault } from "@kestrove/kv-wasm/client.js";
|
||||
const vault = await connectVault(); // RemoteVault: every VaultCore method + free functions, async
|
||||
await vault.configureServer(location.origin);
|
||||
if (await vault.hasAccount()) await vault.unlock(password, secretKey);
|
||||
const items = await vault.listItems();
|
||||
await vault.close(); // frees the core (zeroizing keys) and ends the worker
|
||||
```
|
||||
|
||||
The worker holds a Web Lock per database for its lifetime, so a second tab gets `InUse` instead of
|
||||
two replicas overwriting each other.
|
||||
|
||||
## What the web UI must handle
|
||||
|
||||
1. **Run the core in the worker** (`connectVault`), never on the main thread.
|
||||
2. **Same origin.** Serve the web vault from the server's origin and call
|
||||
`configureServer(location.origin)`. CSP needs `script-src 'self' 'wasm-unsafe-eval'` and
|
||||
`worker-src 'self'`; `connect-src 'self'`.
|
||||
3. **Enrollment.** The browser is a device. A first `login` returns `"pendingApproval"`: show
|
||||
`deviceKeyFingerprint()` and ask the user to approve it on an enrolled device (which shows the same
|
||||
fingerprint in `listDevices`), then call `login` again. `setDevice("Firefox on Mac")` names it;
|
||||
the platform defaults to the OS from the user agent because the server has no `web` platform yet.
|
||||
4. **Every unlock needs the master password and Secret Key.** There is no Local Unlock Key on the
|
||||
web: nothing secret is ever written to IndexedDB, so a reload, `lock()` or closing the tab
|
||||
leaves the vault locked. Lock on idle and on `visibilitychange`/`pagehide` as the UI sees fit;
|
||||
`close()` drops the worker and every key in it.
|
||||
5. **Secrets in JS.** Master password, Secret Key and decrypted `ItemDto`s are JS strings the
|
||||
core cannot wipe. Do not keep them in long-lived state, logs or URLs. The core zeroizes its own
|
||||
copies.
|
||||
6. **Sync.** Call `syncNow()` after login, on an interval and on focus. Handle `Busy` (skip),
|
||||
`NotAuthenticated` (ask for the password and `login` again), `PendingApproval`,
|
||||
`IntegrityAlarm` (stop and alert: the server rolled back or tampered with a vault) and
|
||||
`restoredItemIds` (tell the user an edit brought a deleted item back).
|
||||
7. **Storage.** Call `navigator.storage.persist()`. If the browser evicts IndexedDB, unsynced edits
|
||||
and the device key are lost and the browser must be approved again.
|
||||
8. **Sign out of this browser:** `logout()` (ends the server session), then `wipe()` (removes the
|
||||
replica and device key; the next login is a new device).
|
||||
9. **Import/export:** pass `new Uint8Array(await file.arrayBuffer())`; `exportEncrypted` resolves with
|
||||
the `.kvex` bytes for a `Blob` download.
|
||||
10. `itemsForUrl` / `matchUrl` match the same host only (no Public Suffix List in this build).
|
||||
Executable
+29
@@ -0,0 +1,29 @@
|
||||
#!/usr/bin/env bash
|
||||
# Builds the web vault package: wasm-pack (size profile, wasm-opt -Oz) plus the Web Worker wrapper.
|
||||
# crates/kv-wasm/build.sh -> crates/kv-wasm/pkg/
|
||||
# Then run the Node smoke test: node crates/kv-wasm/js/smoke.mjs
|
||||
set -euo pipefail
|
||||
here="$(cd "$(dirname "$0")" && pwd)"
|
||||
cd "$here"
|
||||
|
||||
wasm-pack build . --target web --profile wasm-release --out-dir pkg --scope kestrove --out-name kv_wasm
|
||||
cp js/worker.js js/client.js js/client.d.ts pkg/
|
||||
|
||||
# Ship the worker files and point the package's types at the client (it re-exports everything).
|
||||
node -e '
|
||||
const fs = require("fs");
|
||||
const p = JSON.parse(fs.readFileSync("pkg/package.json", "utf8"));
|
||||
p.files = Array.from(new Set([...(p.files || []), "worker.js", "client.js", "client.d.ts"]));
|
||||
p.exports = {
|
||||
".": { types: "./kv_wasm.d.ts", default: "./kv_wasm.js" },
|
||||
"./client.js": { types: "./client.d.ts", default: "./client.js" },
|
||||
"./worker.js": "./worker.js",
|
||||
"./kv_wasm_bg.wasm": "./kv_wasm_bg.wasm",
|
||||
};
|
||||
fs.writeFileSync("pkg/package.json", JSON.stringify(p, null, 2) + "\n");
|
||||
'
|
||||
|
||||
wasm="pkg/kv_wasm_bg.wasm"
|
||||
printf 'kv_wasm_bg.wasm: %s bytes, gzip -9 %s, brotli %s\n' \
|
||||
"$(wc -c <"$wasm" | tr -d ' ')" "$(gzip -9 -c "$wasm" | wc -c | tr -d ' ')" \
|
||||
"$( (command -v brotli >/dev/null && brotli -c -q 11 "$wasm" | wc -c | tr -d ' ') || echo n/a)"
|
||||
Vendored
+41
@@ -0,0 +1,41 @@
|
||||
import type {
|
||||
VaultCore,
|
||||
CoreError,
|
||||
CoreErrorCode,
|
||||
generatePassword,
|
||||
totpCode,
|
||||
matchUrl,
|
||||
coreVersion,
|
||||
} from "./kv_wasm.js";
|
||||
|
||||
export * from "./kv_wasm.js";
|
||||
|
||||
type Async<F> = F extends (...args: infer A) => infer R ? (...args: A) => Promise<Awaited<R>> : never;
|
||||
|
||||
type CoreMethods = {
|
||||
[K in keyof VaultCore as K extends "free" | typeof Symbol.dispose ? never : K]: Async<VaultCore[K]>;
|
||||
};
|
||||
|
||||
/** `VaultCore` running in a Web Worker: same methods, all async, plus the free functions. */
|
||||
export type RemoteVault = CoreMethods & {
|
||||
generatePassword: Async<typeof generatePassword>;
|
||||
totpCode: Async<typeof totpCode>;
|
||||
matchUrl: Async<typeof matchUrl>;
|
||||
coreVersion: Async<typeof coreVersion>;
|
||||
/** Frees the core (zeroizing keys), releases the tab lock and terminates the worker. */
|
||||
close(): Promise<void>;
|
||||
};
|
||||
|
||||
/** `"InUse"`: another tab already has this vault open (one tab per vault). */
|
||||
export type ClientErrorCode = CoreErrorCode | "InUse";
|
||||
|
||||
export interface ConnectOptions {
|
||||
/** IndexedDB database name. Default "kestrove". */
|
||||
dbName?: string;
|
||||
/** Persist nothing (previews, tests). */
|
||||
inMemory?: boolean;
|
||||
/** Bring your own worker (e.g. a bundler's `new Worker(new URL(...))`). It must run worker.js. */
|
||||
worker?: Worker;
|
||||
}
|
||||
|
||||
export function connectVault(options?: ConnectOptions): Promise<RemoteVault>;
|
||||
@@ -0,0 +1,60 @@
|
||||
// Page-side handle to the vault worker. Every VaultCore method (and the free functions) becomes an
|
||||
// async call into the worker; errors come back as `CoreError`s with the same `code`s.
|
||||
//
|
||||
// import { connectVault } from "@kestrove/core/client.js";
|
||||
// const vault = await connectVault(); // IndexedDB "kestrove"
|
||||
// if (!(await vault.hasAccount())) { ... }
|
||||
// await vault.unlock(password, secretKey); // Argon2id runs in the worker
|
||||
// const items = await vault.listItems();
|
||||
|
||||
export async function connectVault(options = {}) {
|
||||
const worker =
|
||||
options.worker ?? new Worker(new URL("./worker.js", import.meta.url), { type: "module", name: "kestrove-vault" });
|
||||
let next = 1;
|
||||
const pending = new Map();
|
||||
|
||||
worker.onmessage = ({ data: { id, ok, err } }) => {
|
||||
const p = pending.get(id);
|
||||
if (!p) return;
|
||||
pending.delete(id);
|
||||
if (err) p.reject(Object.assign(new Error(err.message), err));
|
||||
else p.resolve(ok);
|
||||
};
|
||||
worker.onerror = (event) => {
|
||||
const e = Object.assign(new Error(event.message || "vault worker failed"), { name: "CoreError", code: "Internal" });
|
||||
for (const p of pending.values()) p.reject(e);
|
||||
pending.clear();
|
||||
};
|
||||
|
||||
const call = (method, args) =>
|
||||
new Promise((resolve, reject) => {
|
||||
const id = next++;
|
||||
pending.set(id, { resolve, reject });
|
||||
worker.postMessage({ id, method, args });
|
||||
});
|
||||
|
||||
try {
|
||||
await call("open", [options.dbName ?? null, Boolean(options.inMemory)]);
|
||||
} catch (e) {
|
||||
worker.terminate(); // e.g. "InUse": the vault is open in another tab
|
||||
throw e;
|
||||
}
|
||||
|
||||
const handle = {
|
||||
/** Frees the core, releases the tab lock and stops the worker (key material goes with it). */
|
||||
async close() {
|
||||
try {
|
||||
await call("close", []);
|
||||
} finally {
|
||||
worker.terminate();
|
||||
}
|
||||
},
|
||||
};
|
||||
return new Proxy(handle, {
|
||||
get(target, prop) {
|
||||
if (prop in target) return target[prop];
|
||||
if (typeof prop !== "string" || prop === "then") return undefined;
|
||||
return (...args) => call(prop, args);
|
||||
},
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,151 @@
|
||||
// Node smoke test of the built package (pkg/, `--target web`): the generated JS glue, camelCase
|
||||
// records, CoreError codes, Argon2id timing. No IndexedDB in Node, so it uses in-memory vaults
|
||||
// (the IndexedDB path is covered by `wasm-pack test --headless --chrome`).
|
||||
//
|
||||
// node crates/kv-wasm/js/smoke.mjs # checks + timing
|
||||
// node crates/kv-wasm/js/smoke.mjs --write-fixture # also writes tests/fixtures/wasm-vault.json
|
||||
//
|
||||
// With a fresh local kestrove-server it also runs a two-device sync round trip, both devices in wasm
|
||||
// (see crates/kv-ffi/tests/live_server.rs for starting one; it takes ~2 minutes because of the
|
||||
// server's auth rate limit):
|
||||
//
|
||||
// KESTROVE_SERVER_URL=http://127.0.0.1:8080 KESTROVE_SETUP_TOKEN=<token> node crates/kv-wasm/js/smoke.mjs
|
||||
|
||||
import assert from "node:assert/strict";
|
||||
import { readFile, writeFile } from "node:fs/promises";
|
||||
import init, * as kv from "../pkg/kv_wasm.js";
|
||||
|
||||
const pkg = new URL("../pkg/", import.meta.url);
|
||||
await init({ module_or_path: await readFile(new URL("kv_wasm_bg.wasm", pkg)) });
|
||||
|
||||
const PW = "wasm fixture master password";
|
||||
const ms = (t) => `${Math.round(performance.now() - t)} ms`;
|
||||
|
||||
async function rejects(promise, code) {
|
||||
try {
|
||||
await promise;
|
||||
} catch (e) {
|
||||
assert.equal(e.name, "CoreError");
|
||||
assert.equal(e.code, code, e.message);
|
||||
return e;
|
||||
}
|
||||
assert.fail(`expected ${code}`);
|
||||
}
|
||||
|
||||
function login(title, password, extra = {}) {
|
||||
return {
|
||||
id: "",
|
||||
kind: "login",
|
||||
title,
|
||||
username: "[email protected]",
|
||||
password,
|
||||
urls: ["https://example.com/login"],
|
||||
totp: null,
|
||||
notes: null,
|
||||
favorite: false,
|
||||
tags: [],
|
||||
modifiedAt: 0,
|
||||
...extra,
|
||||
};
|
||||
}
|
||||
|
||||
// ------------------------------------------------------------ local checks, Argon2 timing
|
||||
|
||||
const a = await kv.VaultCore.newInMemory();
|
||||
await rejects(a.unlock(PW, "K1-AAAAAA-AAAAA-AAAAA-AAAAA-AAAAA-A"), "NoAccount");
|
||||
let t = performance.now();
|
||||
const secretKey = await a.createAccount(PW);
|
||||
console.log(`createAccount (Argon2id m=64 MiB t=3 p=4): ${ms(t)}`);
|
||||
await a.lock();
|
||||
t = performance.now();
|
||||
await a.unlock(PW, secretKey);
|
||||
console.log(`unlock (Argon2id m=64 MiB t=3 p=4): ${ms(t)}`);
|
||||
|
||||
const bankId = await a.saveItem(
|
||||
login("Bank", "first password", {
|
||||
totp: "otpauth://totp/Bank:ana?secret=JBSWY3DPEHPK3PXP&issuer=Bank",
|
||||
tags: ["finance", "ünïcode ✓"],
|
||||
favorite: true,
|
||||
}),
|
||||
);
|
||||
const bank = await a.getItem(bankId);
|
||||
assert.equal(bank.password, "first password");
|
||||
assert.equal(bank.notes, null);
|
||||
await a.saveItem({ ...bank, password: "second password" });
|
||||
await a.saveItem({ id: "", kind: "secureNote", title: "Recovery codes", notes: "multi\nline 🔐" });
|
||||
const list = await a.listItems();
|
||||
assert.deepEqual(
|
||||
list.map((i) => [i.title, i.kind, i.hasTotp]),
|
||||
[
|
||||
["Bank", "login", true],
|
||||
["Recovery codes", "secureNote", false],
|
||||
],
|
||||
);
|
||||
assert.equal((await a.itemHistory(bankId))[0].password, "first password");
|
||||
assert.deepEqual(list[0].tags, ["finance", "ünïcode ✓"]);
|
||||
assert.ok(list[0].modifiedAt >= bank.modifiedAt);
|
||||
await rejects(a.getItem("not-a-uuid"), "Invalid");
|
||||
const code = kv.totpCode("JBSWY3DPEHPK3PXP", Math.floor(Date.now() / 1000));
|
||||
assert.match(code.code, /^\d{6}$/);
|
||||
assert.equal(kv.matchUrl("example.com", "https://example.com/x"), "exact");
|
||||
assert.equal(kv.generatePassword(20, false).length, 20);
|
||||
const exported = await a.exportEncrypted("export passphrase");
|
||||
assert.ok(exported instanceof Uint8Array && exported.length > 100);
|
||||
|
||||
if (process.argv.includes("--write-fixture")) {
|
||||
const items = [];
|
||||
for (const s of await a.listItems()) items.push(await a.getItem(s.id));
|
||||
items.sort((x, y) => (x.title < y.title ? -1 : 1));
|
||||
const fixture = { masterPassword: PW, secretKey, entries: await a.__replicaEntries(), items };
|
||||
const path = new URL("../tests/fixtures/wasm-vault.json", import.meta.url);
|
||||
await writeFile(path, JSON.stringify(fixture, null, 2) + "\n");
|
||||
console.log(`wrote ${path.pathname}`);
|
||||
}
|
||||
|
||||
// ------------------------------------------------------------ live server round trip
|
||||
|
||||
const url = process.env.KESTROVE_SERVER_URL;
|
||||
const token = process.env.KESTROVE_SETUP_TOKEN;
|
||||
if (url && token) {
|
||||
const sleep = (s) => new Promise((r) => setTimeout(r, s * 1000));
|
||||
await a.setDevice("wasm device A", "linux");
|
||||
await a.configureServer(url, []);
|
||||
assert.equal((await a.serverInfo()).setupOpen, true, "server must be fresh");
|
||||
await a.registerAccount(token);
|
||||
let r = await a.syncNow();
|
||||
assert.equal(r.pushed, 2);
|
||||
console.log("A registered and pushed", r);
|
||||
|
||||
console.log("waiting 61 s for the server's auth rate limit…");
|
||||
await sleep(61);
|
||||
const b = await kv.VaultCore.newInMemory();
|
||||
await b.setDevice("wasm device B", "macos");
|
||||
await b.configureServer(url, []);
|
||||
let res = await b.login(PW, secretKey, null);
|
||||
if (res === "pendingApproval") {
|
||||
const pending = (await a.listDevices()).find((d) => !d.approved);
|
||||
assert.equal(pending.keyFingerprint, await b.deviceKeyFingerprint());
|
||||
await a.approveDevice(pending.deviceId);
|
||||
console.log("A approved B; waiting 61 s again…");
|
||||
await sleep(61);
|
||||
res = await b.login(PW, secretKey, null);
|
||||
}
|
||||
assert.equal(res, "ok");
|
||||
r = await b.syncNow();
|
||||
console.log("B synced", r);
|
||||
const bItems = await b.listItems();
|
||||
assert.deepEqual(bItems.map((i) => i.title).sort(), ["Bank", "Recovery codes"]);
|
||||
const onB = await b.getItem(bankId);
|
||||
assert.equal(onB.password, "second password");
|
||||
await b.saveItem({ ...onB, notes: "edited on B" });
|
||||
assert.equal((await b.syncNow()).pushed, 1);
|
||||
await a.syncNow();
|
||||
assert.equal((await a.getItem(bankId)).notes, "edited on B");
|
||||
await b.deleteItem((await b.listItems()).find((i) => i.title === "Recovery codes").id);
|
||||
await b.syncNow();
|
||||
await a.syncNow();
|
||||
assert.deepEqual((await a.listItems()).map((i) => i.title), ["Bank"]);
|
||||
console.log("live round trip ok");
|
||||
}
|
||||
|
||||
console.log("smoke ok");
|
||||
@@ -0,0 +1,84 @@
|
||||
// Kestrove vault worker: owns the wasm VaultCore so Argon2id (createAccount, unlock, login,
|
||||
// encrypted export/import) never blocks the page. Started by `connectVault()` in client.js.
|
||||
//
|
||||
// Protocol (structured clone): {id, method, args} -> {id, ok: value} | {id, err: {name, message, code, ...}}.
|
||||
// The first message must be {id, method: "open", args: [dbName | null, inMemory]}.
|
||||
|
||||
import init, * as kv from "./kv_wasm.js";
|
||||
|
||||
let core = null;
|
||||
let releaseLock = null;
|
||||
|
||||
function serializeError(e) {
|
||||
if (e && typeof e === "object") {
|
||||
return {
|
||||
name: e.name || "Error",
|
||||
message: e.message || String(e),
|
||||
code: e.code ?? "Internal",
|
||||
status: e.status,
|
||||
vaultId: e.vaultId,
|
||||
reason: e.reason,
|
||||
};
|
||||
}
|
||||
return { name: "CoreError", message: String(e), code: "Internal" };
|
||||
}
|
||||
|
||||
// One tab per vault: two in-memory replicas over the same IndexedDB database would overwrite
|
||||
// each other's writes. The lock is held for the worker's lifetime.
|
||||
async function acquireLock(dbName) {
|
||||
if (!self.navigator?.locks) return true; // very old browsers: best effort
|
||||
return new Promise((resolve) => {
|
||||
self.navigator.locks.request(`kestrove-vault:${dbName}`, { ifAvailable: true }, (lock) => {
|
||||
if (!lock) {
|
||||
resolve(false);
|
||||
return undefined;
|
||||
}
|
||||
resolve(true);
|
||||
return new Promise((release) => (releaseLock = release));
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
async function open(dbName, inMemory) {
|
||||
await init({ module_or_path: new URL("./kv_wasm_bg.wasm", import.meta.url) });
|
||||
if (inMemory) {
|
||||
core = await kv.VaultCore.newInMemory();
|
||||
return null;
|
||||
}
|
||||
const name = dbName || "kestrove";
|
||||
if (!(await acquireLock(name))) {
|
||||
const e = new Error("the vault is open in another tab");
|
||||
e.name = "CoreError";
|
||||
e.code = "InUse";
|
||||
throw e;
|
||||
}
|
||||
core = await kv.VaultCore.open(name);
|
||||
return null;
|
||||
}
|
||||
|
||||
const FREE_FUNCTIONS = new Set(["generatePassword", "totpCode", "matchUrl", "coreVersion"]);
|
||||
|
||||
self.onmessage = async ({ data: { id, method, args } }) => {
|
||||
try {
|
||||
let value;
|
||||
if (method === "open") {
|
||||
value = await open(...args);
|
||||
} else if (method === "close") {
|
||||
core?.free();
|
||||
core = null;
|
||||
releaseLock?.();
|
||||
value = null;
|
||||
} else if (FREE_FUNCTIONS.has(method)) {
|
||||
value = kv[method](...args);
|
||||
} else if (!core) {
|
||||
throw Object.assign(new Error("vault is not open"), { name: "CoreError", code: "NotConfigured" });
|
||||
} else if (typeof core[method] !== "function" || method.startsWith("_") || method === "free") {
|
||||
throw Object.assign(new Error(`unknown method ${method}`), { name: "CoreError", code: "Invalid" });
|
||||
} else {
|
||||
value = await core[method](...args);
|
||||
}
|
||||
self.postMessage({ id, ok: value });
|
||||
} catch (e) {
|
||||
self.postMessage({ id, err: serializeError(e) });
|
||||
}
|
||||
};
|
||||
@@ -0,0 +1,867 @@
|
||||
//! The web vault's `VaultCore`: the same operations as kv-ffi's, over [`WebStore`] and an injected
|
||||
//! [`kv_sync::DeviceSigner`]. Portable Rust (no JS types), so it is also tested natively, including
|
||||
//! against kv-sync's test server and against kv-ffi.
|
||||
//!
|
||||
//! It deliberately mirrors `kv-ffi/src/lib.rs` (and its `passkeys`/`import` modules); crypto, the
|
||||
//! item model, the store contract and the sync engine are the shared crates, so the bytes on the
|
||||
//! wire and in storage are identical. Differences from kv-ffi:
|
||||
//! * no Local Unlock Key (biometric unlock): the browser has no keystore that can gate a key behind
|
||||
//! user presence, so every unlock needs the master password and Secret Key;
|
||||
//! * no passkey authenticator calls (`create_passkey`/`get_assertion`): the web vault only lists and
|
||||
//! deletes passkeys;
|
||||
//! * no SPKI pins: the browser does TLS for the page's own origin;
|
||||
//! * `sync_now` fails with `Busy` instead of queueing when a sync is already running.
|
||||
|
||||
use std::sync::{
|
||||
atomic::{AtomicBool, Ordering},
|
||||
Arc, Mutex,
|
||||
};
|
||||
|
||||
use kv_crypto::{
|
||||
aad::{Aad, ObjectType},
|
||||
aead::SymKey,
|
||||
kdf,
|
||||
keys::{open_item, seal_item, AccountKeyset, SealedItem},
|
||||
secret_key::SecretKey,
|
||||
};
|
||||
use kv_model::{generator, matching, totp::Totp, Item, ItemKind, Passkey};
|
||||
use kv_proto::{b64, DeviceEnroll, Envelope, ItemRecord, KdfParams, Platform};
|
||||
use kv_store::{HistoryEntry, LocalItem, Store, VaultRow};
|
||||
use kv_sync::{ApiClient, DeviceSigner, LoginOutcome, ServerConfig, SyncContext, SyncError};
|
||||
use serde::{Deserialize, Serialize};
|
||||
use uuid::Uuid;
|
||||
use zeroize::Zeroizing;
|
||||
|
||||
use crate::{
|
||||
dto::*,
|
||||
error::{internal, CoreError, Result},
|
||||
store::WebStore,
|
||||
};
|
||||
|
||||
pub const META_ACCOUNT: &str = "account";
|
||||
pub const META_SERVER: &str = "server";
|
||||
pub const META_SESSION: &str = "session";
|
||||
|
||||
/// Everything needed to unlock: all public or wrapped. Same JSON as kv-ffi's `AccountRecord`
|
||||
/// (minus the native-only `local_unlock`), so a record moves between the two unchanged.
|
||||
#[derive(Clone, Serialize, Deserialize)]
|
||||
pub struct AccountRecord {
|
||||
pub account_id: Uuid,
|
||||
pub kdf: KdfParams,
|
||||
pub wrapped_keyset: Envelope,
|
||||
#[serde(with = "b64")]
|
||||
pub enc_pk: Vec<u8>,
|
||||
#[serde(with = "b64")]
|
||||
pub sig_pk: Vec<u8>,
|
||||
pub primary_vault: Uuid,
|
||||
}
|
||||
|
||||
#[derive(Clone, Serialize, Deserialize)]
|
||||
struct ServerRecord {
|
||||
url: String,
|
||||
pins: Vec<String>,
|
||||
}
|
||||
|
||||
/// Decrypted key material. Lives only in wasm memory; zeroized on drop (`lock`).
|
||||
#[derive(Clone)]
|
||||
struct Unlocked {
|
||||
keyset: Arc<AccountKeyset>,
|
||||
vault_key: Arc<SymKey>,
|
||||
/// OPAQUE password, kept while unlocked so an expired session can be renewed without prompting.
|
||||
auth_input: Option<Zeroizing<[u8; 32]>>,
|
||||
}
|
||||
|
||||
struct Device {
|
||||
signer: Arc<dyn DeviceSigner>,
|
||||
name: String,
|
||||
platform: Platform,
|
||||
}
|
||||
|
||||
#[derive(Default)]
|
||||
struct Inner {
|
||||
account: Option<AccountRecord>,
|
||||
unlocked: Option<Unlocked>,
|
||||
server: Option<ServerRecord>,
|
||||
device: Option<Device>,
|
||||
api: Option<Arc<ApiClient>>,
|
||||
}
|
||||
|
||||
pub struct WebCore {
|
||||
store: Arc<WebStore>,
|
||||
inner: Mutex<Inner>,
|
||||
syncing: AtomicBool,
|
||||
}
|
||||
|
||||
struct SyncGuard<'a>(&'a AtomicBool);
|
||||
|
||||
impl Drop for SyncGuard<'_> {
|
||||
fn drop(&mut self) {
|
||||
self.0.store(false, Ordering::Release);
|
||||
}
|
||||
}
|
||||
|
||||
pub fn now() -> i64 {
|
||||
kv_sync::unix_now() as i64
|
||||
}
|
||||
|
||||
impl WebCore {
|
||||
pub fn new(store: Arc<WebStore>) -> Result<Self> {
|
||||
let account = store
|
||||
.get_meta(META_ACCOUNT)?
|
||||
.map(|b| serde_json::from_slice::<AccountRecord>(&b))
|
||||
.transpose()
|
||||
.map_err(internal)?;
|
||||
let server = store.get_meta(META_SERVER)?.and_then(|b| serde_json::from_slice::<ServerRecord>(&b).ok());
|
||||
Ok(Self {
|
||||
store,
|
||||
inner: Mutex::new(Inner { account, server, ..Default::default() }),
|
||||
syncing: AtomicBool::new(false),
|
||||
})
|
||||
}
|
||||
|
||||
pub fn store(&self) -> &Arc<WebStore> {
|
||||
&self.store
|
||||
}
|
||||
|
||||
fn lock_inner(&self) -> std::sync::MutexGuard<'_, Inner> {
|
||||
self.inner.lock().unwrap_or_else(|p| p.into_inner())
|
||||
}
|
||||
|
||||
// ------------------------------------------------------------ account and session
|
||||
|
||||
/// 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: &str) -> Result<String> {
|
||||
if master_password.chars().count() < 10 {
|
||||
return Err(CoreError::Invalid { msg: "master password must be at least 10 characters".into() });
|
||||
}
|
||||
let mut inner = self.lock_inner();
|
||||
if inner.account.is_some() {
|
||||
return Err(CoreError::Invalid { msg: "an account already exists in this browser".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 = self.create_vault(&keyset, vault_id)?;
|
||||
let account = AccountRecord {
|
||||
account_id,
|
||||
kdf: params,
|
||||
wrapped_keyset,
|
||||
enc_pk: keyset.enc.public_bytes(),
|
||||
sig_pk: keyset.sig_public().to_vec(),
|
||||
primary_vault: vault_id,
|
||||
};
|
||||
self.save_account(&account)?;
|
||||
inner.account = Some(account);
|
||||
inner.unlocked = Some(Unlocked {
|
||||
keyset: Arc::new(keyset),
|
||||
vault_key: Arc::new(vault_key),
|
||||
auth_input: Some(derived.auth_input),
|
||||
});
|
||||
Ok(secret_key.to_display().to_string())
|
||||
}
|
||||
|
||||
pub fn unlock(&self, master_password: &str, secret_key: &str) -> Result<()> {
|
||||
let mut inner = self.lock_inner();
|
||||
let acct = inner.account.clone().ok_or(CoreError::NoAccount)?;
|
||||
let sk = SecretKey::parse(secret_key)?;
|
||||
let derived = kdf::derive_keys(master_password, &sk, acct.account_id, &acct.kdf)?;
|
||||
let auk = SymKey::from_bytes(Uuid::nil(), *derived.auk);
|
||||
let keyset = AccountKeyset::unwrap(acct.account_id, &auk, &acct.wrapped_keyset)?;
|
||||
let vault_key = self.open_vault_key(&keyset, acct.primary_vault)?;
|
||||
inner.unlocked = Some(Unlocked {
|
||||
keyset: Arc::new(keyset),
|
||||
vault_key: Arc::new(vault_key),
|
||||
auth_input: Some(derived.auth_input),
|
||||
});
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Drops all decrypted key material (keysets, vault key and OPAQUE input are zeroized on drop).
|
||||
pub fn lock(&self) {
|
||||
let mut inner = self.lock_inner();
|
||||
inner.unlocked = None;
|
||||
}
|
||||
|
||||
pub fn is_locked(&self) -> bool {
|
||||
self.lock_inner().unlocked.is_none()
|
||||
}
|
||||
|
||||
pub fn has_account(&self) -> bool {
|
||||
self.lock_inner().account.is_some()
|
||||
}
|
||||
|
||||
pub fn account_id(&self) -> Result<String> {
|
||||
Ok(self.lock_inner().account.as_ref().ok_or(CoreError::NoAccount)?.account_id.to_string())
|
||||
}
|
||||
|
||||
/// Ed25519 public key of the account (hex), shown when pairing devices.
|
||||
pub fn account_fingerprint(&self) -> Result<String> {
|
||||
let (_, u) = self.unlocked_snapshot()?;
|
||||
Ok(u.keyset.sig_public().iter().map(|b| format!("{b:02x}")).collect())
|
||||
}
|
||||
|
||||
/// Forgets everything stored for this browser (account record, replica, session). The device key
|
||||
/// is removed by the wasm layer.
|
||||
pub fn wipe(&self) -> Result<()> {
|
||||
let mut inner = self.lock_inner();
|
||||
self.store.wipe()?;
|
||||
*inner = Inner::default();
|
||||
Ok(())
|
||||
}
|
||||
|
||||
// ------------------------------------------------------------ items
|
||||
|
||||
pub fn list_items(&self) -> Result<Vec<ItemSummary>> {
|
||||
let (acct, u) = self.unlocked_snapshot()?;
|
||||
let mut out: Vec<ItemSummary> = self.all_items(&acct, &u)?.iter().map(summary).collect();
|
||||
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: &str) -> Result<ItemDto> {
|
||||
let id = parse_id(id)?;
|
||||
let (acct, u) = self.unlocked_snapshot()?;
|
||||
let li = self.store.get(acct.primary_vault, id)?.ok_or(CoreError::NotFound)?;
|
||||
Ok(to_dto(&decrypt(&acct, &u.vault_key, &li.record)?.ok_or(CoreError::NotFound)?))
|
||||
}
|
||||
|
||||
/// Inserts or updates an item. Returns its id. The previous version goes to the item's history.
|
||||
pub fn save_item(&self, item: ItemDto) -> Result<String> {
|
||||
let (acct, u) = self.unlocked_snapshot()?;
|
||||
let vault = acct.primary_vault;
|
||||
let now = now();
|
||||
let id = if item.id.is_empty() { Uuid::new_v4() } else { parse_id(&item.id)? };
|
||||
let existing = self.store.get(vault, id)?;
|
||||
let previous = match &existing {
|
||||
Some(li) => decrypt(&acct, &u.vault_key, &li.record)?,
|
||||
None => None,
|
||||
};
|
||||
let mut model = match &previous {
|
||||
// Keep fields the DTO does not carry (passkeys, android apps, unknown future fields).
|
||||
Some(p) => p.clone(),
|
||||
None => Item::new_login(item.title.clone(), now),
|
||||
};
|
||||
model.id = id;
|
||||
model.kind = kind_from_dto(item.kind);
|
||||
model.title = item.title;
|
||||
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.max(previous.as_ref().map_or(0, |p| p.modified_at + 1));
|
||||
self.write_item(&acct, &u, &mut model, existing.filter(|_| previous.is_some()), false)?;
|
||||
Ok(id.to_string())
|
||||
}
|
||||
|
||||
pub fn delete_item(&self, id: &str) -> Result<()> {
|
||||
let id = parse_id(id)?;
|
||||
let (acct, u) = self.unlocked_snapshot()?;
|
||||
let vault = acct.primary_vault;
|
||||
let li = self.store.get(vault, id)?.filter(|li| !li.record.deleted).ok_or(CoreError::NotFound)?;
|
||||
if li.base.is_none() {
|
||||
// Never reached the server: just forget it.
|
||||
return Ok(self.store.remove(vault, id)?);
|
||||
}
|
||||
let seq = kv_sync::engine::next_local_version(self.store.as_ref(), acct.account_id, vault, &u.vault_key, id)?;
|
||||
let tomb =
|
||||
kv_sync::engine::seal_tombstone(acct.account_id, vault, &u.vault_key, id, seq, li.record.updated_by)?;
|
||||
Ok(self.store.put_local(vault, &tomb)?)
|
||||
}
|
||||
|
||||
/// Earlier versions of an item (local edits and merge losers), newest first. At most 10.
|
||||
pub fn item_history(&self, id: &str) -> Result<Vec<ItemDto>> {
|
||||
let id = parse_id(id)?;
|
||||
let (acct, u) = self.unlocked_snapshot()?;
|
||||
let mut out = Vec::new();
|
||||
for h in self.store.history(acct.primary_vault, id)? {
|
||||
let rec = ItemRecord {
|
||||
item_id: id,
|
||||
revision: h.revision,
|
||||
deleted: false,
|
||||
wrapped_item_key: Some(h.wrapped_item_key),
|
||||
payload: Some(h.payload),
|
||||
updated_by: Uuid::nil(),
|
||||
updated_at: String::new(),
|
||||
};
|
||||
if let Some(item) = decrypt(&acct, &u.vault_key, &rec)? {
|
||||
out.push(to_dto(&item));
|
||||
}
|
||||
}
|
||||
Ok(out)
|
||||
}
|
||||
|
||||
/// Items whose URLs match a page URL, best match first.
|
||||
pub fn items_for_url(&self, page_url: &str) -> Result<Vec<ItemSummary>> {
|
||||
let (acct, u) = self.unlocked_snapshot()?;
|
||||
let mut scored = Vec::new();
|
||||
for item in self.all_items(&acct, &u)? {
|
||||
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())
|
||||
}
|
||||
|
||||
// ------------------------------------------------------------ passkeys (list / delete only)
|
||||
|
||||
pub fn list_passkeys(&self) -> Result<Vec<PasskeySummary>> {
|
||||
let (acct, u) = self.unlocked_snapshot()?;
|
||||
let mut out = Vec::new();
|
||||
for item in self.all_items(&acct, &u)? {
|
||||
out.extend(item.passkeys.iter().map(|pk| passkey_summary(&item, pk)));
|
||||
}
|
||||
Ok(out)
|
||||
}
|
||||
|
||||
/// Removes one passkey from its item (the item stays; the old version goes to history).
|
||||
pub fn delete_passkey(&self, credential_id: &[u8]) -> Result<()> {
|
||||
let (acct, u) = self.unlocked_snapshot()?;
|
||||
for li in self.store.list(acct.primary_vault)? {
|
||||
let Some(mut item) = decrypt(&acct, &u.vault_key, &li.record)? else { continue };
|
||||
if item.passkeys.iter().any(|pk| pk.credential_id == credential_id) {
|
||||
item.passkeys.retain(|pk| pk.credential_id != credential_id);
|
||||
return self.write_item(&acct, &u, &mut item, Some(li), true);
|
||||
}
|
||||
}
|
||||
Err(CoreError::NotFound)
|
||||
}
|
||||
|
||||
// ------------------------------------------------------------ import / export
|
||||
|
||||
pub fn import_preview(&self, data: &[u8], format: ImportFormat) -> Result<ImportPreview> {
|
||||
let (acct, u) = self.unlocked_snapshot()?;
|
||||
let mut parsed = parse_import(data, format)?;
|
||||
let existing = self.all_items(&acct, &u)?;
|
||||
let mut preview = ImportPreview {
|
||||
total: parsed.items.len() as u32,
|
||||
logins: parsed.count(ItemKind::Login),
|
||||
secure_notes: parsed.count(ItemKind::SecureNote),
|
||||
cards: parsed.count(ItemKind::Card),
|
||||
identities: parsed.count(ItemKind::Identity),
|
||||
duplicates: 0,
|
||||
attachments_skipped: parsed.attachments_skipped,
|
||||
warnings: std::mem::take(&mut parsed.warnings),
|
||||
};
|
||||
let items = std::mem::take(&mut parsed.items);
|
||||
preview.duplicates =
|
||||
kv_import::plan(items, existing.iter().map(kv_import::dedup::key), kv_import::DuplicatePolicy::Skip)
|
||||
.skipped;
|
||||
Ok(preview)
|
||||
}
|
||||
|
||||
pub fn import_commit(&self, data: &[u8], format: ImportFormat, options: ImportOptions) -> Result<ImportReport> {
|
||||
let (acct, u) = self.unlocked_snapshot()?;
|
||||
let mut parsed = parse_import(data, format)?;
|
||||
let existing = self.all_items(&acct, &u)?;
|
||||
let policy = match options.duplicates {
|
||||
DuplicateHandling::Skip => kv_import::DuplicatePolicy::Skip,
|
||||
DuplicateHandling::AddSuffix => kv_import::DuplicatePolicy::AddSuffix,
|
||||
};
|
||||
let plan =
|
||||
kv_import::plan(std::mem::take(&mut parsed.items), existing.iter().map(kv_import::dedup::key), policy);
|
||||
let mut item_ids = Vec::with_capacity(plan.to_add.len());
|
||||
for mut model in plan.to_add {
|
||||
// Every imported item is new: fresh id, seq 1, every model field kept.
|
||||
model.id = Uuid::new_v4();
|
||||
model.modified_at = model.modified_at.max(model.created_at);
|
||||
self.write_item(&acct, &u, &mut model, None, false)?;
|
||||
item_ids.push(model.id.to_string());
|
||||
}
|
||||
Ok(ImportReport {
|
||||
imported: item_ids.len() as u32,
|
||||
skipped_duplicates: plan.skipped,
|
||||
renamed_duplicates: plan.renamed,
|
||||
attachments_skipped: parsed.attachments_skipped,
|
||||
warnings: std::mem::take(&mut parsed.warnings),
|
||||
item_ids,
|
||||
})
|
||||
}
|
||||
|
||||
/// All items as a passphrase-encrypted `.kvex` file (restore with `KestroveEncrypted`).
|
||||
pub fn export_encrypted(&self, passphrase: &str) -> Result<Vec<u8>> {
|
||||
let passphrase = Zeroizing::new(passphrase.to_string());
|
||||
let (acct, u) = self.unlocked_snapshot()?;
|
||||
let items = self.all_items(&acct, &u)?;
|
||||
Ok(kv_import::export::encrypt(&items, &passphrase, now())?)
|
||||
}
|
||||
|
||||
// ------------------------------------------------------------ sync server
|
||||
|
||||
/// Sets the server URL (https; http only for localhost). Pins are stored for parity with the
|
||||
/// apps but not enforced in the browser.
|
||||
pub fn configure_server(&self, url: &str, spki_pins: Vec<String>) -> Result<()> {
|
||||
kv_sync::transport::normalize_base_url(url)?;
|
||||
for p in &spki_pins {
|
||||
kv_sync::transport::parse_pin(p)?;
|
||||
}
|
||||
let rec = ServerRecord { url: url.to_string(), pins: spki_pins };
|
||||
self.store.put_meta(META_SERVER, &serde_json::to_vec(&rec).map_err(internal)?)?;
|
||||
let mut inner = self.lock_inner();
|
||||
inner.server = Some(rec);
|
||||
inner.api = None;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub fn set_device_signer(&self, signer: Arc<dyn DeviceSigner>, name: &str, platform: Platform) {
|
||||
let mut inner = self.lock_inner();
|
||||
inner.device = Some(Device { signer, name: name.to_string(), platform });
|
||||
inner.api = None;
|
||||
}
|
||||
|
||||
pub fn device_id(&self) -> Option<Uuid> {
|
||||
self.lock_inner().device.as_ref().map(|d| d.signer.device_id())
|
||||
}
|
||||
|
||||
pub fn device_key_fingerprint(&self) -> Result<String> {
|
||||
let inner = self.lock_inner();
|
||||
let d = inner.device.as_ref().ok_or(CoreError::NotConfigured)?;
|
||||
Ok(device_key_fingerprint(&d.signer.public_key()))
|
||||
}
|
||||
|
||||
pub async fn server_info(&self) -> Result<ServerInfoDto> {
|
||||
let info = self.api()?.server_info().await?;
|
||||
Ok(ServerInfoDto {
|
||||
version: info.version,
|
||||
api: info.api,
|
||||
setup_open: info.setup_open,
|
||||
min_client: info.min_client,
|
||||
})
|
||||
}
|
||||
|
||||
/// First device only: registers the account with a fresh server using its one-time setup token.
|
||||
pub async fn register_account(&self, setup_token: &str) -> Result<()> {
|
||||
let api = self.api()?;
|
||||
let (acct, u) = self.unlocked_snapshot()?;
|
||||
let auth = u.auth_input.clone().ok_or(CoreError::NotAuthenticated)?;
|
||||
let device = self.enroll()?;
|
||||
api.register(
|
||||
setup_token,
|
||||
acct.account_id,
|
||||
&acct.kdf,
|
||||
auth.as_ref(),
|
||||
acct.enc_pk.clone(),
|
||||
acct.sig_pk.clone(),
|
||||
acct.wrapped_keyset.clone(),
|
||||
device,
|
||||
)
|
||||
.await?;
|
||||
self.do_login(&api, acct.account_id, auth.as_ref(), None).await.map(|_| ())
|
||||
}
|
||||
|
||||
/// Logs in with the master password and Secret Key. A browser with no account fetches it from the
|
||||
/// server; call `sync_now` afterwards to download the vault.
|
||||
pub async fn login(&self, master_password: &str, secret_key: &str, totp: Option<String>) -> Result<LoginResult> {
|
||||
let api = self.api()?;
|
||||
let sk = SecretKey::parse(secret_key)?;
|
||||
let local = self.lock_inner().account.clone();
|
||||
let (account_id, kdf_params) = match &local {
|
||||
Some(a) => (a.account_id, a.kdf.clone()),
|
||||
None => {
|
||||
let pre = api.prelogin().await?;
|
||||
(pre.account_id, pre.kdf)
|
||||
}
|
||||
};
|
||||
// A downgraded KDF from the server is refused before any work is done.
|
||||
kdf::validate(&kdf_params)?;
|
||||
let derived = kdf::derive_keys(master_password, &sk, account_id, &kdf_params)?;
|
||||
if self.do_login(&api, account_id, derived.auth_input.as_ref(), totp).await? == LoginResult::PendingApproval {
|
||||
return Ok(LoginResult::PendingApproval);
|
||||
}
|
||||
let auk = SymKey::from_bytes(Uuid::nil(), *derived.auk);
|
||||
let acct = match local {
|
||||
Some(a) => a,
|
||||
None => {
|
||||
let info = api.account().await?;
|
||||
if info.account_id != account_id {
|
||||
return Err(internal("server returned a different account"));
|
||||
}
|
||||
kdf::validate(&info.kdf)?;
|
||||
let keyset = AccountKeyset::unwrap(account_id, &auk, &info.wrapped_keyset)?;
|
||||
if keyset.sig_public().as_slice() != info.sig_pk || keyset.enc.public_bytes() != info.enc_pk {
|
||||
return Err(internal("server account keys do not match the keyset"));
|
||||
}
|
||||
AccountRecord {
|
||||
account_id,
|
||||
kdf: info.kdf,
|
||||
wrapped_keyset: info.wrapped_keyset,
|
||||
enc_pk: info.enc_pk,
|
||||
sig_pk: info.sig_pk,
|
||||
primary_vault: Uuid::nil(), // chosen after the first sync
|
||||
}
|
||||
}
|
||||
};
|
||||
let keyset = AccountKeyset::unwrap(account_id, &auk, &acct.wrapped_keyset)?;
|
||||
let vault_key = self.open_vault_key(&keyset, acct.primary_vault)?;
|
||||
self.save_account(&acct)?;
|
||||
let mut inner = self.lock_inner();
|
||||
inner.account = Some(acct);
|
||||
inner.unlocked = Some(Unlocked {
|
||||
keyset: Arc::new(keyset),
|
||||
vault_key: Arc::new(vault_key),
|
||||
auth_input: Some(derived.auth_input),
|
||||
});
|
||||
Ok(LoginResult::Ok)
|
||||
}
|
||||
|
||||
/// Pull, merge, push and verify every vault, renewing an expired session if possible.
|
||||
pub async fn sync_now(&self) -> Result<SyncReport> {
|
||||
if self.syncing.swap(true, Ordering::AcqRel) {
|
||||
return Err(CoreError::Busy);
|
||||
}
|
||||
let _guard = SyncGuard(&self.syncing);
|
||||
let api = self.api()?;
|
||||
let (_, u) = self.unlocked_snapshot()?;
|
||||
let device_id = api.signer().device_id();
|
||||
let store: &dyn Store = self.store.as_ref();
|
||||
let run = || async { SyncContext { api: &api, store, keyset: &u.keyset, device_id }.sync_all().await };
|
||||
let report = match run().await {
|
||||
Err(SyncError::NotAuthenticated) => {
|
||||
let auth = u.auth_input.clone().ok_or(CoreError::NotAuthenticated)?;
|
||||
if self.do_login(&api, u.keyset.account_id, auth.as_ref(), None).await? == LoginResult::PendingApproval
|
||||
{
|
||||
return Err(CoreError::PendingApproval);
|
||||
}
|
||||
run().await?
|
||||
}
|
||||
other => other?,
|
||||
};
|
||||
self.after_sync()?;
|
||||
Ok(SyncReport {
|
||||
pulled: report.pulled,
|
||||
pushed: report.pushed,
|
||||
merged: report.merged,
|
||||
restored_item_ids: report.restored.iter().map(Uuid::to_string).collect(),
|
||||
vaults_synced: report.vaults_synced,
|
||||
vaults_skipped: report.vaults_skipped.len() as u32,
|
||||
})
|
||||
}
|
||||
|
||||
pub async fn list_devices(&self) -> Result<Vec<DeviceDto>> {
|
||||
let devices = self.api()?.devices().await?;
|
||||
Ok(devices
|
||||
.into_iter()
|
||||
.map(|d| DeviceDto {
|
||||
device_id: d.device_id.to_string(),
|
||||
name: d.name,
|
||||
platform: platform_to_dto(d.platform),
|
||||
approved: d.approved,
|
||||
revoked: d.revoked_at.is_some(),
|
||||
current: d.current,
|
||||
created_at: d.created_at,
|
||||
key_fingerprint: device_key_fingerprint(&d.p256_pk),
|
||||
})
|
||||
.collect())
|
||||
}
|
||||
|
||||
pub async fn approve_device(&self, device_id: &str) -> Result<()> {
|
||||
Ok(self.api()?.approve_device(parse_id(device_id)?).await?)
|
||||
}
|
||||
|
||||
pub async fn revoke_device(&self, device_id: &str) -> Result<()> {
|
||||
Ok(self.api()?.revoke_device(parse_id(device_id)?).await?)
|
||||
}
|
||||
|
||||
/// Ends the server session (local data stays).
|
||||
pub async fn logout(&self) -> Result<()> {
|
||||
let api = self.api()?;
|
||||
let r = api.logout().await;
|
||||
self.store.delete_meta(META_SESSION)?;
|
||||
match r {
|
||||
Ok(()) | Err(SyncError::NotAuthenticated) => Ok(()),
|
||||
Err(e) => Err(e.into()),
|
||||
}
|
||||
}
|
||||
|
||||
pub fn is_logged_in(&self) -> bool {
|
||||
self.store.get_meta(META_SESSION).ok().flatten().is_some()
|
||||
}
|
||||
|
||||
// ------------------------------------------------------------ internals
|
||||
|
||||
fn unlocked_snapshot(&self) -> Result<(AccountRecord, Unlocked)> {
|
||||
let inner = self.lock_inner();
|
||||
let acct = inner.account.clone().ok_or(CoreError::NoAccount)?;
|
||||
let u = inner.unlocked.clone().ok_or(CoreError::Locked)?;
|
||||
Ok((acct, u))
|
||||
}
|
||||
|
||||
fn save_account(&self, acct: &AccountRecord) -> Result<()> {
|
||||
Ok(self.store.put_meta(META_ACCOUNT, &serde_json::to_vec(acct).map_err(internal)?)?)
|
||||
}
|
||||
|
||||
fn create_vault(&self, keyset: &AccountKeyset, vault_id: Uuid) -> Result<SymKey> {
|
||||
let (vault_key, sealed) = keyset.new_vault_key(vault_id)?;
|
||||
let meta = vault_key.seal(
|
||||
br#"{"name":"Personal"}"#,
|
||||
&Aad::build(ObjectType::VaultMeta, keyset.account_id, Some(vault_id), None),
|
||||
)?;
|
||||
self.store.put_vault(&VaultRow::new(vault_id, sealed, Some(meta)))?;
|
||||
Ok(vault_key)
|
||||
}
|
||||
|
||||
fn open_vault_key(&self, keyset: &AccountKeyset, vault_id: Uuid) -> Result<SymKey> {
|
||||
if vault_id.is_nil() {
|
||||
// Enrolled but the first sync has not brought the vault yet.
|
||||
return Ok(SymKey::from_bytes(Uuid::nil(), [0; 32]));
|
||||
}
|
||||
let row = self.store.vault(vault_id)?.ok_or_else(|| internal("primary vault missing"))?;
|
||||
Ok(keyset.open_vault_key(vault_id, &row.sealed_key)?)
|
||||
}
|
||||
|
||||
fn all_items(&self, acct: &AccountRecord, u: &Unlocked) -> Result<Vec<Item>> {
|
||||
let mut out = Vec::new();
|
||||
for li in self.store.list(acct.primary_vault)? {
|
||||
if let Some(item) = decrypt(acct, &u.vault_key, &li.record)? {
|
||||
out.push(item);
|
||||
}
|
||||
}
|
||||
Ok(out)
|
||||
}
|
||||
|
||||
/// Seals and stores an item like kv-ffi does: next `seq`, previous version to history, queued
|
||||
/// for push. `bump` advances `modified_at` for edits that did not set it themselves.
|
||||
fn write_item(
|
||||
&self,
|
||||
acct: &AccountRecord,
|
||||
u: &Unlocked,
|
||||
model: &mut Item,
|
||||
existing: Option<LocalItem>,
|
||||
bump: bool,
|
||||
) -> Result<()> {
|
||||
let vault = acct.primary_vault;
|
||||
let id = model.id;
|
||||
let now = now();
|
||||
if bump && existing.is_some() {
|
||||
model.modified_at = now.max(model.modified_at + 1);
|
||||
}
|
||||
model.seq = kv_sync::engine::next_local_version(self.store.as_ref(), acct.account_id, vault, &u.vault_key, id)?;
|
||||
let json = Zeroizing::new(serde_json::to_vec(model).map_err(internal)?);
|
||||
let sealed = seal_item(acct.account_id, vault, id, &u.vault_key, &json)?;
|
||||
if let Some(li) = existing.filter(|li| !li.record.deleted) {
|
||||
if let (Some(k), Some(p)) = (li.record.wrapped_item_key, li.record.payload) {
|
||||
self.store.push_history(
|
||||
vault,
|
||||
id,
|
||||
&HistoryEntry { revision: li.record.revision, wrapped_item_key: k, payload: p, saved_at: now },
|
||||
)?;
|
||||
}
|
||||
}
|
||||
let device = self.device_id().unwrap_or(Uuid::nil());
|
||||
let record = ItemRecord {
|
||||
item_id: id,
|
||||
revision: 0,
|
||||
deleted: false,
|
||||
wrapped_item_key: Some(sealed.wrapped_item_key),
|
||||
payload: Some(sealed.payload),
|
||||
updated_by: device,
|
||||
updated_at: String::new(),
|
||||
};
|
||||
Ok(self.store.put_local(vault, &record)?)
|
||||
}
|
||||
|
||||
fn enroll(&self) -> Result<DeviceEnroll> {
|
||||
let inner = self.lock_inner();
|
||||
let d = inner.device.as_ref().ok_or(CoreError::NotConfigured)?;
|
||||
Ok(DeviceEnroll {
|
||||
device_id: d.signer.device_id(),
|
||||
name: d.name.chars().take(64).collect(),
|
||||
platform: d.platform,
|
||||
p256_pk: d.signer.public_key(),
|
||||
})
|
||||
}
|
||||
|
||||
fn api(&self) -> Result<Arc<ApiClient>> {
|
||||
let mut inner = self.lock_inner();
|
||||
if let Some(api) = &inner.api {
|
||||
return Ok(api.clone());
|
||||
}
|
||||
let server = inner.server.clone().ok_or(CoreError::NotConfigured)?;
|
||||
let signer = inner.device.as_ref().ok_or(CoreError::NotConfigured)?.signer.clone();
|
||||
let api = Arc::new(ApiClient::new(&ServerConfig::new(server.url, server.pins), signer)?);
|
||||
if let Some(s) = self.store.get_meta(META_SESSION)? {
|
||||
api.set_session(Some(String::from_utf8_lossy(&s).into_owned()));
|
||||
}
|
||||
inner.api = Some(api.clone());
|
||||
Ok(api)
|
||||
}
|
||||
|
||||
async fn do_login(
|
||||
&self,
|
||||
api: &ApiClient,
|
||||
account_id: Uuid,
|
||||
auth_input: &[u8],
|
||||
totp: Option<String>,
|
||||
) -> Result<LoginResult> {
|
||||
let device = self.enroll()?;
|
||||
match api.login(account_id, auth_input, device, totp).await? {
|
||||
LoginOutcome::Ok { session } => {
|
||||
self.store.put_meta(META_SESSION, session.as_bytes())?;
|
||||
Ok(LoginResult::Ok)
|
||||
}
|
||||
LoginOutcome::PendingApproval => Ok(LoginResult::PendingApproval),
|
||||
}
|
||||
}
|
||||
|
||||
/// Persists a renewed session and picks the primary vault on a newly enrolled browser.
|
||||
fn after_sync(&self) -> Result<()> {
|
||||
let api_session = self.lock_inner().api.as_ref().and_then(|a| a.session());
|
||||
if let Some(s) = api_session {
|
||||
self.store.put_meta(META_SESSION, s.as_bytes())?;
|
||||
}
|
||||
let mut inner = self.lock_inner();
|
||||
let Some(mut acct) = inner.account.clone() else { return Ok(()) };
|
||||
if !acct.primary_vault.is_nil() {
|
||||
return Ok(());
|
||||
}
|
||||
let keyset = inner.unlocked.as_ref().ok_or(CoreError::Locked)?.keyset.clone();
|
||||
let vault_id = match self.store.vaults()?.first() {
|
||||
Some(v) => v.vault_id,
|
||||
None => {
|
||||
let id = Uuid::new_v4();
|
||||
self.create_vault(&keyset, id)?;
|
||||
id
|
||||
}
|
||||
};
|
||||
acct.primary_vault = vault_id;
|
||||
let vault_key = self.open_vault_key(&keyset, vault_id)?;
|
||||
self.save_account(&acct)?;
|
||||
inner.account = Some(acct);
|
||||
if let Some(u) = inner.unlocked.as_mut() {
|
||||
u.vault_key = Arc::new(vault_key);
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------- free functions
|
||||
|
||||
pub fn generate_password(length: u32, symbols: bool) -> String {
|
||||
generator::generate_password(&generator::PasswordOptions { length: length as usize, symbols, ..Default::default() })
|
||||
}
|
||||
|
||||
pub fn totp_code(secret_or_uri: &str, 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 })
|
||||
}
|
||||
|
||||
pub fn match_url(item_url: &str, page_url: &str) -> 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,
|
||||
}
|
||||
}
|
||||
|
||||
/// `None` for tombstones.
|
||||
pub fn decrypt(acct: &AccountRecord, vault_key: &SymKey, record: &ItemRecord) -> Result<Option<Item>> {
|
||||
let (Some(k), Some(p)) = (&record.wrapped_item_key, &record.payload) else { return Ok(None) };
|
||||
if record.deleted {
|
||||
return Ok(None);
|
||||
}
|
||||
let sealed = SealedItem { wrapped_item_key: k.clone(), payload: p.clone() };
|
||||
let pt = open_item(acct.account_id, acct.primary_vault, record.item_id, vault_key, &sealed)?;
|
||||
serde_json::from_slice(&pt).map(Some).map_err(internal)
|
||||
}
|
||||
|
||||
fn parse_import(data: &[u8], format: ImportFormat) -> Result<kv_import::Parsed> {
|
||||
let format = match format {
|
||||
ImportFormat::OnePux => kv_import::Format::OnePux,
|
||||
ImportFormat::Csv => kv_import::Format::Csv,
|
||||
// `Format` wipes the passphrase on drop.
|
||||
ImportFormat::KestroveEncrypted { passphrase } => kv_import::Format::Encrypted { passphrase },
|
||||
};
|
||||
Ok(kv_import::parse(data, &format, now())?)
|
||||
}
|
||||
|
||||
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(),
|
||||
tags: i.tags.clone(),
|
||||
modified_at: i.modified_at,
|
||||
}
|
||||
}
|
||||
|
||||
fn passkey_summary(item: &Item, pk: &Passkey) -> PasskeySummary {
|
||||
PasskeySummary {
|
||||
item_id: item.id.to_string(),
|
||||
credential_id: pk.credential_id.clone(),
|
||||
rp_id: pk.rp_id.clone(),
|
||||
user_handle: pk.user_handle.clone(),
|
||||
user_name: pk.user_name.clone(),
|
||||
created_at: pk.created_at,
|
||||
}
|
||||
}
|
||||
|
||||
pub 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 platform_to_dto(p: Platform) -> PlatformDto {
|
||||
match p {
|
||||
Platform::Ios => PlatformDto::Ios,
|
||||
Platform::Macos => PlatformDto::Macos,
|
||||
Platform::Android => PlatformDto::Android,
|
||||
Platform::Windows => PlatformDto::Windows,
|
||||
Platform::Linux => PlatformDto::Linux,
|
||||
}
|
||||
}
|
||||
|
||||
/// Hex of the first 16 bytes of SHA-256(P-256 public key), as kv-ffi shows it.
|
||||
pub fn device_key_fingerprint(p256_pk: &[u8]) -> String {
|
||||
use sha2::{Digest, Sha256};
|
||||
Sha256::digest(p256_pk)[..16].iter().map(|b| format!("{b:02x}")).collect()
|
||||
}
|
||||
|
||||
fn parse_id(s: &str) -> Result<Uuid> {
|
||||
Uuid::parse_str(s).map_err(|_| CoreError::Invalid { msg: "id must be a UUID".into() })
|
||||
}
|
||||
@@ -0,0 +1,183 @@
|
||||
//! Records crossing the JS boundary. Same fields as kv-ffi's UniFFI records, in camelCase; enums are
|
||||
//! camelCase strings (`"login"`, `"secureNote"`, `"pendingApproval"`); byte fields are `Uint8Array`.
|
||||
//! The TypeScript declarations for these live in `wasm.rs` (`TS_TYPES`); keep both in step.
|
||||
|
||||
use serde::{Deserialize, Serialize};
|
||||
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub enum ItemKindDto {
|
||||
Login,
|
||||
SecureNote,
|
||||
Card,
|
||||
Identity,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub enum MatchLevelDto {
|
||||
None,
|
||||
Domain,
|
||||
Host,
|
||||
Exact,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub enum PlatformDto {
|
||||
Ios,
|
||||
Macos,
|
||||
Android,
|
||||
Windows,
|
||||
Linux,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
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,
|
||||
/// Web only (not in kv-ffi's record): for tag filters and "recently modified" without `getItem`.
|
||||
pub tags: Vec<String>,
|
||||
pub modified_at: i64,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct ItemDto {
|
||||
/// Empty string means "new item".
|
||||
#[serde(default)]
|
||||
pub id: String,
|
||||
pub kind: ItemKindDto,
|
||||
pub title: String,
|
||||
#[serde(default)]
|
||||
pub username: Option<String>,
|
||||
#[serde(default)]
|
||||
pub password: Option<String>,
|
||||
#[serde(default)]
|
||||
pub urls: Vec<String>,
|
||||
#[serde(default)]
|
||||
pub totp: Option<String>,
|
||||
#[serde(default)]
|
||||
pub notes: Option<String>,
|
||||
#[serde(default)]
|
||||
pub favorite: bool,
|
||||
#[serde(default)]
|
||||
pub tags: Vec<String>,
|
||||
#[serde(default)]
|
||||
pub modified_at: i64,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct TotpCode {
|
||||
pub code: String,
|
||||
pub seconds_remaining: u64,
|
||||
pub period: u64,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct ServerInfoDto {
|
||||
pub version: String,
|
||||
pub api: u32,
|
||||
pub setup_open: bool,
|
||||
pub min_client: String,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub enum LoginResult {
|
||||
/// Logged in and unlocked.
|
||||
Ok,
|
||||
/// An enrolled device must approve this browser (or pass `totp`); call `login` again afterwards.
|
||||
PendingApproval,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct SyncReport {
|
||||
pub pulled: u32,
|
||||
pub pushed: u32,
|
||||
pub merged: u32,
|
||||
pub restored_item_ids: Vec<String>,
|
||||
pub vaults_synced: u32,
|
||||
pub vaults_skipped: u32,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct DeviceDto {
|
||||
pub device_id: String,
|
||||
pub name: String,
|
||||
pub platform: PlatformDto,
|
||||
pub approved: bool,
|
||||
pub revoked: bool,
|
||||
pub current: bool,
|
||||
pub created_at: String,
|
||||
pub key_fingerprint: String,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct PasskeySummary {
|
||||
pub item_id: String,
|
||||
#[serde(with = "serde_bytes")]
|
||||
pub credential_id: Vec<u8>,
|
||||
pub rp_id: String,
|
||||
#[serde(with = "serde_bytes")]
|
||||
pub user_handle: Vec<u8>,
|
||||
pub user_name: String,
|
||||
pub created_at: i64,
|
||||
}
|
||||
|
||||
/// `{ type: "onePux" } | { type: "csv" } | { type: "kestroveEncrypted", passphrase }`.
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
#[serde(tag = "type", rename_all = "camelCase")]
|
||||
pub enum ImportFormat {
|
||||
OnePux,
|
||||
Csv,
|
||||
KestroveEncrypted { passphrase: String },
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub enum DuplicateHandling {
|
||||
Skip,
|
||||
AddSuffix,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct ImportOptions {
|
||||
pub duplicates: DuplicateHandling,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct ImportPreview {
|
||||
pub total: u32,
|
||||
pub logins: u32,
|
||||
pub secure_notes: u32,
|
||||
pub cards: u32,
|
||||
pub identities: u32,
|
||||
pub duplicates: u32,
|
||||
pub attachments_skipped: u32,
|
||||
pub warnings: Vec<String>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct ImportReport {
|
||||
pub imported: u32,
|
||||
pub skipped_duplicates: u32,
|
||||
pub renamed_duplicates: u32,
|
||||
pub attachments_skipped: u32,
|
||||
pub warnings: Vec<String>,
|
||||
pub item_ids: Vec<String>,
|
||||
}
|
||||
@@ -0,0 +1,132 @@
|
||||
//! Same error cases as kv-ffi's `CoreError`. In JavaScript they arrive as a thrown `Error` whose
|
||||
//! `name` is `"CoreError"` and whose `code` is the variant name (see `wasm.rs`).
|
||||
|
||||
use kv_crypto::CryptoError;
|
||||
use kv_store::StoreError;
|
||||
use kv_sync::SyncError;
|
||||
|
||||
#[derive(Debug, Clone, PartialEq, Eq, thiserror::Error)]
|
||||
pub enum CoreError {
|
||||
#[error("vault is locked")]
|
||||
Locked,
|
||||
#[error("no account in this browser")]
|
||||
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 },
|
||||
/// `configure_server` has not been called, or the device key is missing.
|
||||
#[error("sync server is not configured")]
|
||||
NotConfigured,
|
||||
/// The session expired and the vault was not unlocked with the master password in this session.
|
||||
#[error("not logged in to the sync server")]
|
||||
NotAuthenticated,
|
||||
/// This browser is waiting for approval from an enrolled device.
|
||||
#[error("device approval pending")]
|
||||
PendingApproval,
|
||||
#[error("network error: {msg}")]
|
||||
Network { msg: String },
|
||||
/// Native only (TLS pinning); never raised in the browser. Kept so the code set matches kv-ffi.
|
||||
#[error("server certificate does not match the pinned keys")]
|
||||
PinMismatch,
|
||||
#[error("server error {status}: {msg}")]
|
||||
Server { status: u16, msg: String },
|
||||
#[error("integrity alarm on vault {vault_id}: {reason}")]
|
||||
IntegrityAlarm { vault_id: String, reason: String },
|
||||
/// Web only: another `sync_now` is still running in this vault.
|
||||
#[error("a sync is already running")]
|
||||
Busy,
|
||||
}
|
||||
|
||||
impl CoreError {
|
||||
/// The variant name, used as `error.code` in JavaScript.
|
||||
pub fn code(&self) -> &'static str {
|
||||
match self {
|
||||
CoreError::Locked => "Locked",
|
||||
CoreError::NoAccount => "NoAccount",
|
||||
CoreError::BadCredentials => "BadCredentials",
|
||||
CoreError::Invalid { .. } => "Invalid",
|
||||
CoreError::NotFound => "NotFound",
|
||||
CoreError::Internal { .. } => "Internal",
|
||||
CoreError::NotConfigured => "NotConfigured",
|
||||
CoreError::NotAuthenticated => "NotAuthenticated",
|
||||
CoreError::PendingApproval => "PendingApproval",
|
||||
CoreError::Network { .. } => "Network",
|
||||
CoreError::PinMismatch => "PinMismatch",
|
||||
CoreError::Server { .. } => "Server",
|
||||
CoreError::IntegrityAlarm { .. } => "IntegrityAlarm",
|
||||
CoreError::Busy => "Busy",
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
pub type Result<T> = std::result::Result<T, CoreError>;
|
||||
|
||||
pub fn internal(e: impl std::fmt::Display) -> CoreError {
|
||||
CoreError::Internal { msg: e.to_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() },
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl From<StoreError> for CoreError {
|
||||
fn from(e: StoreError) -> Self {
|
||||
match e {
|
||||
StoreError::NotFound => CoreError::NotFound,
|
||||
StoreError::Io(msg) => CoreError::Internal { msg },
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl From<SyncError> for CoreError {
|
||||
fn from(e: SyncError) -> Self {
|
||||
match e {
|
||||
SyncError::Network(msg) => CoreError::Network { msg },
|
||||
SyncError::PinMismatch => CoreError::PinMismatch,
|
||||
SyncError::Config(msg) => CoreError::Invalid { msg },
|
||||
SyncError::Server { status, code, message } => {
|
||||
CoreError::Server { status, msg: format!("{code}: {message}") }
|
||||
}
|
||||
SyncError::Integrity { vault_id, reason } => {
|
||||
CoreError::IntegrityAlarm { vault_id: vault_id.to_string(), reason }
|
||||
}
|
||||
SyncError::NotAuthenticated => CoreError::NotAuthenticated,
|
||||
SyncError::BadCredentials => CoreError::BadCredentials,
|
||||
SyncError::NotFound => CoreError::NotFound,
|
||||
other => CoreError::Internal { msg: other.to_string() },
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl From<kv_import::ImportError> for CoreError {
|
||||
fn from(e: kv_import::ImportError) -> Self {
|
||||
use kv_import::ImportError;
|
||||
match e {
|
||||
ImportError::BadPassphrase => CoreError::BadCredentials,
|
||||
ImportError::Crypto => CoreError::Internal { msg: e.to_string() },
|
||||
other => CoreError::Invalid { msg: other.to_string() },
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl From<kv_model::passkey::PasskeyError> for CoreError {
|
||||
fn from(e: kv_model::passkey::PasskeyError) -> Self {
|
||||
use kv_model::passkey::PasskeyError;
|
||||
match e {
|
||||
PasskeyError::RpMismatch => CoreError::NotFound,
|
||||
PasskeyError::BadKey => CoreError::Internal { msg: e.to_string() },
|
||||
other => CoreError::Invalid { msg: other.to_string() },
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,21 @@
|
||||
//! The Kestrove core for the web vault, compiled to WebAssembly (`wasm-pack build crates/kv-wasm`).
|
||||
//!
|
||||
//! The browser runs the same crypto, item model and sync engine as the apps; the server stays
|
||||
//! zero-knowledge. Platform pieces replaced for the browser:
|
||||
//! * storage: [`store::WebStore`], an in-memory replica persisted entry by entry to IndexedDB;
|
||||
//! * network: reqwest over `fetch` (the browser's TLS, same origin as the web vault; no SPKI pins);
|
||||
//! * device key: a non-extractable WebCrypto ECDSA P-256 key kept in IndexedDB;
|
||||
//! * randomness and time: `crypto.getRandomValues` and `Date.now()`.
|
||||
//!
|
||||
//! [`core::WebCore`] holds the logic and is plain Rust (tested natively too); the `wasm` module is
|
||||
//! the `wasm-bindgen` surface. See `README.md` for the JS API and how the UI should call it.
|
||||
|
||||
pub mod core;
|
||||
pub mod dto;
|
||||
pub mod error;
|
||||
pub mod store;
|
||||
#[cfg(target_arch = "wasm32")]
|
||||
mod wasm;
|
||||
|
||||
pub use crate::core::WebCore;
|
||||
pub use error::CoreError;
|
||||
@@ -0,0 +1,445 @@
|
||||
//! The web vault's [`Store`]: an in-memory replica that records which keys changed, so the wasm
|
||||
//! layer can write just those entries to IndexedDB after each call (and read them all back on
|
||||
//! start-up). [`Store`] is synchronous and IndexedDB is not, hence the split.
|
||||
//!
|
||||
//! Like the SQLite store it holds only ciphertext envelopes, ids and sync bookkeeping. Entries are
|
||||
//! JSON bytes keyed by:
|
||||
//!
|
||||
//! | key | value |
|
||||
//! |-----------------------------|---------------------------------------------|
|
||||
//! | `meta/<key>` | raw meta bytes (account record, server, session) |
|
||||
//! | `vault/<vault_id>` | [`VaultEntry`] |
|
||||
//! | `item/<vault_id>/<item_id>` | [`ItemEntry`] (working copy + base) |
|
||||
//! | `history/<vault_id>/<item_id>` | newest-first list of [`HistoryEntryJson`] |
|
||||
|
||||
use std::{
|
||||
collections::{BTreeMap, BTreeSet, HashMap},
|
||||
sync::Mutex,
|
||||
};
|
||||
|
||||
use kv_proto::{Envelope, ItemRecord};
|
||||
use kv_store::{HistoryEntry, LocalItem, Result, Store, StoreError, SyncState, VaultRow, HISTORY_LIMIT};
|
||||
use serde::{Deserialize, Serialize};
|
||||
use uuid::Uuid;
|
||||
|
||||
/// One write for the persistence layer.
|
||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||
pub enum Change {
|
||||
/// Delete every entry first (sign-out / wipe). Always the first change when present.
|
||||
Clear,
|
||||
Put(String, Vec<u8>),
|
||||
Delete(String),
|
||||
}
|
||||
|
||||
#[derive(Serialize, Deserialize)]
|
||||
struct VaultEntry {
|
||||
/// Insertion order, so `vaults()` lists in the same order after a reload (like SQLite's rowid).
|
||||
order: u64,
|
||||
vault_id: Uuid,
|
||||
sealed_key: Envelope,
|
||||
meta: Option<Envelope>,
|
||||
cursor: u64,
|
||||
manifest_version: u64,
|
||||
manifest_hash: Option<[u8; 32]>,
|
||||
integrity_alarm: bool,
|
||||
}
|
||||
|
||||
#[derive(Serialize, Deserialize)]
|
||||
struct ItemEntry {
|
||||
record: ItemRecord,
|
||||
pending: bool,
|
||||
base: Option<ItemRecord>,
|
||||
}
|
||||
|
||||
#[derive(Serialize, Deserialize)]
|
||||
struct HistoryEntryJson {
|
||||
revision: u64,
|
||||
wrapped_item_key: Envelope,
|
||||
payload: Envelope,
|
||||
saved_at: i64,
|
||||
}
|
||||
|
||||
#[derive(Default)]
|
||||
struct Mem {
|
||||
meta: HashMap<String, Vec<u8>>,
|
||||
/// (order, row)
|
||||
vaults: Vec<(u64, VaultRow)>,
|
||||
items: HashMap<Uuid, BTreeMap<Uuid, LocalItem>>,
|
||||
history: HashMap<(Uuid, Uuid), Vec<HistoryEntry>>,
|
||||
next_order: u64,
|
||||
dirty: BTreeSet<String>,
|
||||
cleared: bool,
|
||||
}
|
||||
|
||||
#[derive(Default)]
|
||||
pub struct WebStore {
|
||||
inner: Mutex<Mem>,
|
||||
}
|
||||
|
||||
fn meta_key(k: &str) -> String {
|
||||
format!("meta/{k}")
|
||||
}
|
||||
fn vault_key(v: Uuid) -> String {
|
||||
format!("vault/{v}")
|
||||
}
|
||||
fn item_key(v: Uuid, i: Uuid) -> String {
|
||||
format!("item/{v}/{i}")
|
||||
}
|
||||
fn history_key(v: Uuid, i: Uuid) -> String {
|
||||
format!("history/{v}/{i}")
|
||||
}
|
||||
|
||||
fn corrupt(key: &str, e: impl std::fmt::Display) -> StoreError {
|
||||
StoreError::Io(format!("corrupt entry {key}: {e}"))
|
||||
}
|
||||
|
||||
fn json<T: Serialize>(v: &T) -> Vec<u8> {
|
||||
// Only plain data structures are serialized here; this cannot fail.
|
||||
serde_json::to_vec(v).unwrap_or_default()
|
||||
}
|
||||
|
||||
fn two_ids(key: &str, rest: &str) -> Result<(Uuid, Uuid)> {
|
||||
let (a, b) = rest.split_once('/').ok_or_else(|| corrupt(key, "key"))?;
|
||||
Ok((Uuid::parse_str(a).map_err(|e| corrupt(key, e))?, Uuid::parse_str(b).map_err(|e| corrupt(key, e))?))
|
||||
}
|
||||
|
||||
impl WebStore {
|
||||
pub fn new() -> Self {
|
||||
Self::default()
|
||||
}
|
||||
|
||||
fn mem(&self) -> std::sync::MutexGuard<'_, Mem> {
|
||||
self.inner.lock().unwrap_or_else(|p| p.into_inner())
|
||||
}
|
||||
|
||||
/// Rebuilds the replica from persisted entries (start-up). Nothing is marked dirty.
|
||||
pub fn load(entries: impl IntoIterator<Item = (String, Vec<u8>)>) -> Result<Self> {
|
||||
let mut m = Mem::default();
|
||||
for (key, value) in entries {
|
||||
if let Some(k) = key.strip_prefix("meta/") {
|
||||
m.meta.insert(k.to_string(), value);
|
||||
} else if key.starts_with("vault/") {
|
||||
let e: VaultEntry = serde_json::from_slice(&value).map_err(|e| corrupt(&key, e))?;
|
||||
m.next_order = m.next_order.max(e.order + 1);
|
||||
m.vaults.push((
|
||||
e.order,
|
||||
VaultRow {
|
||||
vault_id: e.vault_id,
|
||||
sealed_key: e.sealed_key,
|
||||
meta: e.meta,
|
||||
cursor: e.cursor,
|
||||
manifest_version: e.manifest_version,
|
||||
manifest_hash: e.manifest_hash,
|
||||
integrity_alarm: e.integrity_alarm,
|
||||
},
|
||||
));
|
||||
} else if let Some(rest) = key.strip_prefix("item/") {
|
||||
let (v, i) = two_ids(&key, rest)?;
|
||||
let e: ItemEntry = serde_json::from_slice(&value).map_err(|e| corrupt(&key, e))?;
|
||||
if e.record.item_id != i {
|
||||
return Err(corrupt(&key, "item id"));
|
||||
}
|
||||
let state = if e.pending { SyncState::Pending } else { SyncState::Clean };
|
||||
m.items.entry(v).or_default().insert(i, LocalItem { record: e.record, state, base: e.base });
|
||||
} else if let Some(rest) = key.strip_prefix("history/") {
|
||||
let ids = two_ids(&key, rest)?;
|
||||
let h: Vec<HistoryEntryJson> = serde_json::from_slice(&value).map_err(|e| corrupt(&key, e))?;
|
||||
let h = h
|
||||
.into_iter()
|
||||
.map(|h| HistoryEntry {
|
||||
revision: h.revision,
|
||||
wrapped_item_key: h.wrapped_item_key,
|
||||
payload: h.payload,
|
||||
saved_at: h.saved_at,
|
||||
})
|
||||
.collect();
|
||||
m.history.insert(ids, h);
|
||||
}
|
||||
// Unknown prefixes come from a newer version; ignore them.
|
||||
}
|
||||
m.vaults.sort_by_key(|(o, _)| *o);
|
||||
Ok(Self { inner: Mutex::new(m) })
|
||||
}
|
||||
|
||||
/// Everything written since the last call, ready for IndexedDB.
|
||||
pub fn take_changes(&self) -> Vec<Change> {
|
||||
let mut m = self.mem();
|
||||
let mut out = Vec::new();
|
||||
if std::mem::take(&mut m.cleared) {
|
||||
out.push(Change::Clear);
|
||||
}
|
||||
let dirty = std::mem::take(&mut m.dirty);
|
||||
for key in dirty {
|
||||
let value = Self::encode(&m, &key);
|
||||
out.push(match value {
|
||||
Some(v) => Change::Put(key, v),
|
||||
None => Change::Delete(key),
|
||||
});
|
||||
}
|
||||
out
|
||||
}
|
||||
|
||||
/// True when [`WebStore::take_changes`] would return something.
|
||||
pub fn has_changes(&self) -> bool {
|
||||
let m = self.mem();
|
||||
m.cleared || !m.dirty.is_empty()
|
||||
}
|
||||
|
||||
/// Every entry, as [`WebStore::load`] takes them (tests, export of the raw replica).
|
||||
pub fn entries(&self) -> Vec<(String, Vec<u8>)> {
|
||||
let m = self.mem();
|
||||
let mut keys: Vec<String> = m.meta.keys().map(|k| meta_key(k)).collect();
|
||||
keys.extend(m.vaults.iter().map(|(_, v)| vault_key(v.vault_id)));
|
||||
for (v, items) in &m.items {
|
||||
keys.extend(items.keys().map(|i| item_key(*v, *i)));
|
||||
}
|
||||
keys.extend(m.history.keys().map(|(v, i)| history_key(*v, *i)));
|
||||
keys.into_iter().filter_map(|k| Self::encode(&m, &k).map(|v| (k, v))).collect()
|
||||
}
|
||||
|
||||
fn encode(m: &Mem, key: &str) -> Option<Vec<u8>> {
|
||||
if let Some(k) = key.strip_prefix("meta/") {
|
||||
return m.meta.get(k).cloned();
|
||||
}
|
||||
if let Some(id) = key.strip_prefix("vault/") {
|
||||
let id = Uuid::parse_str(id).ok()?;
|
||||
let (order, v) = m.vaults.iter().find(|(_, v)| v.vault_id == id)?;
|
||||
return Some(json(&VaultEntry {
|
||||
order: *order,
|
||||
vault_id: v.vault_id,
|
||||
sealed_key: v.sealed_key.clone(),
|
||||
meta: v.meta.clone(),
|
||||
cursor: v.cursor,
|
||||
manifest_version: v.manifest_version,
|
||||
manifest_hash: v.manifest_hash,
|
||||
integrity_alarm: v.integrity_alarm,
|
||||
}));
|
||||
}
|
||||
if let Some(rest) = key.strip_prefix("item/") {
|
||||
let (v, i) = two_ids(key, rest).ok()?;
|
||||
let li = m.items.get(&v)?.get(&i)?;
|
||||
return Some(json(&ItemEntry {
|
||||
record: li.record.clone(),
|
||||
pending: li.state == SyncState::Pending,
|
||||
base: li.base.clone(),
|
||||
}));
|
||||
}
|
||||
if let Some(rest) = key.strip_prefix("history/") {
|
||||
let ids = two_ids(key, rest).ok()?;
|
||||
let h = m.history.get(&ids)?;
|
||||
let h: Vec<HistoryEntryJson> = h
|
||||
.iter()
|
||||
.map(|h| HistoryEntryJson {
|
||||
revision: h.revision,
|
||||
wrapped_item_key: h.wrapped_item_key.clone(),
|
||||
payload: h.payload.clone(),
|
||||
saved_at: h.saved_at,
|
||||
})
|
||||
.collect();
|
||||
return Some(json(&h));
|
||||
}
|
||||
None
|
||||
}
|
||||
}
|
||||
|
||||
impl Store for WebStore {
|
||||
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<()> {
|
||||
let mut m = self.mem();
|
||||
m.meta.insert(key.into(), value.to_vec());
|
||||
m.dirty.insert(meta_key(key));
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn delete_meta(&self, key: &str) -> Result<()> {
|
||||
let mut m = self.mem();
|
||||
m.meta.remove(key);
|
||||
m.dirty.insert(meta_key(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 => {
|
||||
let order = m.next_order;
|
||||
m.next_order += 1;
|
||||
m.vaults.push((order, vault.clone()));
|
||||
}
|
||||
}
|
||||
m.dirty.insert(vault_key(vault.vault_id));
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn vault(&self, vault: Uuid) -> Result<Option<VaultRow>> {
|
||||
Ok(self.mem().vaults.iter().find(|(_, v)| v.vault_id == vault).map(|(_, v)| v.clone()))
|
||||
}
|
||||
|
||||
fn vaults(&self) -> Result<Vec<VaultRow>> {
|
||||
Ok(self.mem().vaults.iter().map(|(_, v)| v.clone()).collect())
|
||||
}
|
||||
|
||||
fn put(&self, vault: Uuid, item: &LocalItem) -> Result<()> {
|
||||
let mut m = self.mem();
|
||||
m.items.entry(vault).or_default().insert(item.record.item_id, item.clone());
|
||||
m.dirty.insert(item_key(vault, item.record.item_id));
|
||||
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<()> {
|
||||
let mut m = self.mem();
|
||||
if let Some(v) = m.items.get_mut(&vault) {
|
||||
v.remove(&item);
|
||||
}
|
||||
m.dirty.insert(item_key(vault, 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);
|
||||
m.dirty.insert(history_key(vault, item));
|
||||
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<()> {
|
||||
let mut m = self.mem();
|
||||
*m = Mem { cleared: true, ..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(),
|
||||
}
|
||||
}
|
||||
|
||||
/// Applies changes to a fake IndexedDB object store.
|
||||
fn apply(db: &mut BTreeMap<String, Vec<u8>>, changes: Vec<Change>) {
|
||||
for c in changes {
|
||||
match c {
|
||||
Change::Clear => db.clear(),
|
||||
Change::Put(k, v) => {
|
||||
db.insert(k, v);
|
||||
}
|
||||
Change::Delete(k) => {
|
||||
db.remove(&k);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn changes_round_trip_through_a_reload() {
|
||||
let s = WebStore::new();
|
||||
let mut db = BTreeMap::new();
|
||||
let (v1, v2) = (Uuid::from_u128(9), Uuid::from_u128(1)); // v2 sorts first but was added second
|
||||
s.put_vault(&VaultRow::new(v1, env(1), Some(env(2)))).unwrap();
|
||||
s.put_vault(&VaultRow::new(v2, env(3), None)).unwrap();
|
||||
s.put_meta("account", b"{}").unwrap();
|
||||
let id = Uuid::new_v4();
|
||||
s.put_remote(v1, &record(id, 4, 10)).unwrap();
|
||||
s.put_local(v1, &record(id, 0, 20)).unwrap();
|
||||
s.push_history(v1, id, &HistoryEntry { revision: 4, wrapped_item_key: env(10), payload: env(11), saved_at: 5 })
|
||||
.unwrap();
|
||||
let gone = Uuid::new_v4();
|
||||
s.put_local(v1, &record(gone, 0, 40)).unwrap();
|
||||
s.remove(v1, gone).unwrap();
|
||||
assert!(s.has_changes());
|
||||
apply(&mut db, s.take_changes());
|
||||
assert!(!s.has_changes() && s.take_changes().is_empty());
|
||||
assert!(!db.contains_key(&item_key(v1, gone)));
|
||||
|
||||
let r = WebStore::load(db.clone()).unwrap();
|
||||
assert_eq!(r.vaults().unwrap().iter().map(|v| v.vault_id).collect::<Vec<_>>(), vec![v1, v2]);
|
||||
assert_eq!(r.vault(v1).unwrap(), s.vault(v1).unwrap());
|
||||
let li = r.get(v1, id).unwrap().unwrap();
|
||||
assert_eq!(li.state, SyncState::Pending);
|
||||
assert_eq!(li.record.payload, Some(env(21)));
|
||||
assert_eq!(li.base.unwrap().revision, 4);
|
||||
assert_eq!(r.history(v1, id).unwrap(), s.history(v1, id).unwrap());
|
||||
assert_eq!(r.get_meta("account").unwrap().as_deref(), Some(&b"{}"[..]));
|
||||
let mut a = r.entries();
|
||||
a.sort();
|
||||
assert_eq!(a, db.clone().into_iter().collect::<Vec<_>>());
|
||||
|
||||
// A vault added after the reload goes last.
|
||||
let v3 = Uuid::from_u128(0);
|
||||
r.put_vault(&VaultRow::new(v3, env(5), None)).unwrap();
|
||||
r.delete_meta("account").unwrap();
|
||||
apply(&mut db, r.take_changes());
|
||||
let r2 = WebStore::load(db.clone()).unwrap();
|
||||
assert_eq!(r2.vaults().unwrap().last().unwrap().vault_id, v3);
|
||||
assert!(r2.get_meta("account").unwrap().is_none());
|
||||
|
||||
r2.wipe().unwrap();
|
||||
r2.put_meta("server", b"x").unwrap();
|
||||
let ch = r2.take_changes();
|
||||
assert_eq!(ch[0], Change::Clear);
|
||||
apply(&mut db, ch);
|
||||
assert_eq!(db.keys().collect::<Vec<_>>(), vec!["meta/server"]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn rejects_corrupt_entries_and_ignores_unknown_ones() {
|
||||
assert!(WebStore::load([("vault/x".to_string(), b"nope".to_vec())]).is_err());
|
||||
assert!(WebStore::load([("item/a/b".to_string(), b"{}".to_vec())]).is_err());
|
||||
assert!(WebStore::load([("future/x".to_string(), b"?".to_vec())]).unwrap().vaults().unwrap().is_empty());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn never_holds_plaintext_fields() {
|
||||
let s = WebStore::new();
|
||||
let v = Uuid::new_v4();
|
||||
s.put_vault(&VaultRow::new(v, env(1), None)).unwrap();
|
||||
s.put_local(v, &record(Uuid::new_v4(), 0, 3)).unwrap();
|
||||
for (_, value) in s.entries() {
|
||||
let text = String::from_utf8(value).unwrap();
|
||||
for f in ["title", "username", "password", "\"url", "notes"] {
|
||||
assert!(!text.contains(f), "{text}");
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,141 @@
|
||||
//! Minimal IndexedDB access: open, read every replica entry, apply a batch of changes in one
|
||||
//! transaction, and keep the device key record.
|
||||
//!
|
||||
//! Database `kestrove` (or the name passed to `VaultCore.open`), version 1:
|
||||
//! * `kv`: replica entries, string key → `Uint8Array` (ciphertext envelopes and bookkeeping only);
|
||||
//! * `device`: one record `"device"` → `{ deviceId, name, platform, privateKey: CryptoKey, publicKey }`.
|
||||
//! The private key is a non-extractable WebCrypto key: the browser stores it, script never sees its bytes.
|
||||
|
||||
use js_sys::{Array, Promise, Reflect, Uint8Array};
|
||||
use wasm_bindgen::{prelude::*, JsCast};
|
||||
use wasm_bindgen_futures::JsFuture;
|
||||
use web_sys::{
|
||||
IdbDatabase, IdbFactory, IdbObjectStore, IdbOpenDbRequest, IdbRequest, IdbTransaction, IdbTransactionMode,
|
||||
};
|
||||
|
||||
use crate::store::Change;
|
||||
|
||||
const VERSION: u32 = 1;
|
||||
pub const KV: &str = "kv";
|
||||
pub const DEVICE: &str = "device";
|
||||
pub const DEVICE_KEY: &str = "device";
|
||||
|
||||
fn factory() -> Result<IdbFactory, JsValue> {
|
||||
let f = Reflect::get(&js_sys::global(), &JsValue::from_str("indexedDB"))?;
|
||||
if f.is_undefined() || f.is_null() {
|
||||
return Err(JsValue::from_str("IndexedDB is not available in this context"));
|
||||
}
|
||||
f.dyn_into::<IdbFactory>()
|
||||
}
|
||||
|
||||
/// Resolves with `request.result` on success, rejects with `request.error`.
|
||||
async fn request(r: &IdbRequest) -> Result<JsValue, JsValue> {
|
||||
let promise = Promise::new(&mut |resolve, reject| {
|
||||
let ok_req = r.clone();
|
||||
let on_ok = Closure::once_into_js(move |_: web_sys::Event| {
|
||||
let _ = resolve.call1(&JsValue::NULL, &ok_req.result().unwrap_or(JsValue::UNDEFINED));
|
||||
});
|
||||
let err_req = r.clone();
|
||||
let on_err = Closure::once_into_js(move |_: web_sys::Event| {
|
||||
let e = err_req.error().ok().flatten().map(JsValue::from).unwrap_or_else(|| "IndexedDB error".into());
|
||||
let _ = reject.call1(&JsValue::NULL, &e);
|
||||
});
|
||||
r.set_onsuccess(Some(on_ok.unchecked_ref()));
|
||||
r.set_onerror(Some(on_err.unchecked_ref()));
|
||||
});
|
||||
JsFuture::from(promise).await
|
||||
}
|
||||
|
||||
/// Resolves when the transaction commits; rejects when it aborts or fails.
|
||||
async fn done(tx: &IdbTransaction) -> Result<(), JsValue> {
|
||||
let promise = Promise::new(&mut |resolve, reject| {
|
||||
let on_ok = Closure::once_into_js(move |_: web_sys::Event| {
|
||||
let _ = resolve.call0(&JsValue::NULL);
|
||||
});
|
||||
let err_tx = tx.clone();
|
||||
let reject2 = reject.clone();
|
||||
let on_err = Closure::once_into_js(move |_: web_sys::Event| {
|
||||
let e = err_tx.error().map(JsValue::from).unwrap_or_else(|| "IndexedDB transaction failed".into());
|
||||
let _ = reject.call1(&JsValue::NULL, &e);
|
||||
});
|
||||
let on_abort = Closure::once_into_js(move |_: web_sys::Event| {
|
||||
let _ = reject2.call1(&JsValue::NULL, &"IndexedDB transaction aborted".into());
|
||||
});
|
||||
tx.set_oncomplete(Some(on_ok.unchecked_ref()));
|
||||
tx.set_onerror(Some(on_err.unchecked_ref()));
|
||||
tx.set_onabort(Some(on_abort.unchecked_ref()));
|
||||
});
|
||||
JsFuture::from(promise).await.map(|_| ())
|
||||
}
|
||||
|
||||
pub async fn open(name: &str) -> Result<IdbDatabase, JsValue> {
|
||||
let req: IdbOpenDbRequest = factory()?.open_with_u32(name, VERSION)?;
|
||||
let upgrade_req = req.clone();
|
||||
let on_upgrade = Closure::once_into_js(move |_: web_sys::Event| {
|
||||
if let Ok(db) = upgrade_req.result().and_then(|r| r.dyn_into::<IdbDatabase>()) {
|
||||
let names = db.object_store_names();
|
||||
for store in [KV, DEVICE] {
|
||||
if !names.contains(store) {
|
||||
let _ = db.create_object_store(store);
|
||||
}
|
||||
}
|
||||
}
|
||||
});
|
||||
req.set_onupgradeneeded(Some(on_upgrade.unchecked_ref()));
|
||||
request(&req).await?.dyn_into::<IdbDatabase>()
|
||||
}
|
||||
|
||||
fn store(db: &IdbDatabase, name: &str, mode: IdbTransactionMode) -> Result<(IdbTransaction, IdbObjectStore), JsValue> {
|
||||
let tx = db.transaction_with_str_and_mode(name, mode)?;
|
||||
let s = tx.object_store(name)?;
|
||||
Ok((tx, s))
|
||||
}
|
||||
|
||||
/// Every replica entry.
|
||||
pub async fn load_entries(db: &IdbDatabase) -> Result<Vec<(String, Vec<u8>)>, JsValue> {
|
||||
let (_tx, s) = store(db, KV, IdbTransactionMode::Readonly)?;
|
||||
let keys: Array = request(&s.get_all_keys()?).await?.dyn_into()?;
|
||||
let values: Array = request(&s.get_all()?).await?.dyn_into()?;
|
||||
if keys.length() != values.length() {
|
||||
return Err(JsValue::from_str("IndexedDB changed while loading"));
|
||||
}
|
||||
let mut out = Vec::with_capacity(keys.length() as usize);
|
||||
for (k, v) in keys.iter().zip(values.iter()) {
|
||||
let k = k.as_string().ok_or_else(|| JsValue::from_str("non-string key in IndexedDB"))?;
|
||||
out.push((k, v.dyn_into::<Uint8Array>()?.to_vec()));
|
||||
}
|
||||
Ok(out)
|
||||
}
|
||||
|
||||
/// Applies changes atomically (one readwrite transaction).
|
||||
pub async fn apply(db: &IdbDatabase, changes: Vec<Change>) -> Result<(), JsValue> {
|
||||
if changes.is_empty() {
|
||||
return Ok(());
|
||||
}
|
||||
let (tx, s) = store(db, KV, IdbTransactionMode::Readwrite)?;
|
||||
for c in changes {
|
||||
match c {
|
||||
Change::Clear => {
|
||||
s.clear()?;
|
||||
}
|
||||
Change::Put(k, v) => {
|
||||
s.put_with_key(&Uint8Array::from(v.as_slice()), &JsValue::from_str(&k))?;
|
||||
}
|
||||
Change::Delete(k) => {
|
||||
s.delete(&JsValue::from_str(&k))?;
|
||||
}
|
||||
}
|
||||
}
|
||||
done(&tx).await
|
||||
}
|
||||
|
||||
pub async fn get_device(db: &IdbDatabase) -> Result<JsValue, JsValue> {
|
||||
let (_tx, s) = store(db, DEVICE, IdbTransactionMode::Readonly)?;
|
||||
request(&s.get(&JsValue::from_str(DEVICE_KEY))?).await
|
||||
}
|
||||
|
||||
pub async fn put_device(db: &IdbDatabase, record: &JsValue) -> Result<(), JsValue> {
|
||||
let (tx, s) = store(db, DEVICE, IdbTransactionMode::Readwrite)?;
|
||||
s.put_with_key(record, &JsValue::from_str(DEVICE_KEY))?;
|
||||
done(&tx).await
|
||||
}
|
||||
@@ -0,0 +1,671 @@
|
||||
//! `wasm-bindgen` surface: the `VaultCore` class and free functions, mirroring kv-ffi in camelCase.
|
||||
//!
|
||||
//! Every `VaultCore` method returns a `Promise` (writes are flushed to IndexedDB before it
|
||||
//! resolves). Errors reject with an `Error` whose `name` is `"CoreError"` and `code` is the variant.
|
||||
|
||||
mod idb;
|
||||
mod signer;
|
||||
|
||||
use std::{cell::RefCell, future::Future, rc::Rc, sync::Arc};
|
||||
|
||||
use js_sys::{Promise, Reflect, Uint8Array};
|
||||
use kv_proto::Platform;
|
||||
use serde::{de::DeserializeOwned, Serialize};
|
||||
use uuid::Uuid;
|
||||
use wasm_bindgen::{prelude::*, JsCast};
|
||||
use wasm_bindgen_futures::future_to_promise;
|
||||
use web_sys::{CryptoKey, IdbDatabase};
|
||||
|
||||
use self::signer::WebCryptoSigner;
|
||||
use crate::{
|
||||
core::{self, WebCore},
|
||||
dto::*,
|
||||
error::{internal, CoreError, Result},
|
||||
store::WebStore,
|
||||
};
|
||||
|
||||
#[wasm_bindgen(typescript_custom_section)]
|
||||
const TS_TYPES: &'static str = r#"
|
||||
export type ItemKind = "login" | "secureNote" | "card" | "identity";
|
||||
export type MatchLevel = "none" | "domain" | "host" | "exact";
|
||||
export type Platform = "ios" | "macos" | "android" | "windows" | "linux";
|
||||
export type LoginResult = "ok" | "pendingApproval";
|
||||
|
||||
export interface ItemSummary {
|
||||
id: string;
|
||||
kind: ItemKind;
|
||||
title: string;
|
||||
username: string | null;
|
||||
urls: string[];
|
||||
favorite: boolean;
|
||||
hasTotp: boolean;
|
||||
tags: string[];
|
||||
/** Unix seconds. */
|
||||
modifiedAt: number;
|
||||
}
|
||||
|
||||
/** `id: ""` creates a new item. Omitted optional fields default to null / [] / false / 0. */
|
||||
export interface ItemDto {
|
||||
id: string;
|
||||
kind: ItemKind;
|
||||
title: string;
|
||||
username: string | null;
|
||||
password: string | null;
|
||||
urls: string[];
|
||||
totp: string | null;
|
||||
notes: string | null;
|
||||
favorite: boolean;
|
||||
tags: string[];
|
||||
/** Unix seconds; set by the core on save. */
|
||||
modifiedAt: number;
|
||||
}
|
||||
|
||||
export interface TotpCode { code: string; secondsRemaining: number; period: number; }
|
||||
|
||||
export interface ServerInfoDto { version: string; api: number; setupOpen: boolean; minClient: string; }
|
||||
|
||||
export interface SyncReport {
|
||||
pulled: number;
|
||||
pushed: number;
|
||||
merged: number;
|
||||
/** Items an edit brought back after a delete elsewhere. Tell the user. */
|
||||
restoredItemIds: string[];
|
||||
vaultsSynced: number;
|
||||
vaultsSkipped: number;
|
||||
}
|
||||
|
||||
export interface DeviceDto {
|
||||
deviceId: string;
|
||||
name: string;
|
||||
platform: Platform;
|
||||
approved: boolean;
|
||||
revoked: boolean;
|
||||
current: boolean;
|
||||
createdAt: string;
|
||||
/** Hex of the first 16 bytes of SHA-256(P-256 public key). Compare on both screens before approving. */
|
||||
keyFingerprint: string;
|
||||
}
|
||||
|
||||
export interface PasskeySummary {
|
||||
itemId: string;
|
||||
credentialId: Uint8Array;
|
||||
rpId: string;
|
||||
userHandle: Uint8Array;
|
||||
userName: string;
|
||||
createdAt: number;
|
||||
}
|
||||
|
||||
export type ImportFormat =
|
||||
| { type: "onePux" }
|
||||
| { type: "csv" }
|
||||
| { type: "kestroveEncrypted"; passphrase: string };
|
||||
|
||||
export type DuplicateHandling = "skip" | "addSuffix";
|
||||
export interface ImportOptions { duplicates: DuplicateHandling; }
|
||||
|
||||
export interface ImportPreview {
|
||||
total: number;
|
||||
logins: number;
|
||||
secureNotes: number;
|
||||
cards: number;
|
||||
identities: number;
|
||||
duplicates: number;
|
||||
attachmentsSkipped: number;
|
||||
warnings: string[];
|
||||
}
|
||||
|
||||
export interface ImportReport {
|
||||
imported: number;
|
||||
skippedDuplicates: number;
|
||||
renamedDuplicates: number;
|
||||
attachmentsSkipped: number;
|
||||
warnings: string[];
|
||||
itemIds: string[];
|
||||
}
|
||||
|
||||
export type CoreErrorCode =
|
||||
| "Locked" | "NoAccount" | "BadCredentials" | "Invalid" | "NotFound" | "Internal" | "NotConfigured"
|
||||
| "NotAuthenticated" | "PendingApproval" | "Network" | "PinMismatch" | "Server" | "IntegrityAlarm" | "Busy";
|
||||
|
||||
/** What every rejected promise carries. `status` is set for "Server"; `vaultId`/`reason` for "IntegrityAlarm". */
|
||||
export interface CoreError extends Error {
|
||||
name: "CoreError";
|
||||
code: CoreErrorCode;
|
||||
status?: number;
|
||||
vaultId?: string;
|
||||
reason?: string;
|
||||
}
|
||||
"#;
|
||||
|
||||
// ---------------------------------------------------------------- conversions
|
||||
|
||||
fn to_js<T: Serialize>(v: &T) -> std::result::Result<JsValue, JsValue> {
|
||||
let ser = serde_wasm_bindgen::Serializer::new().serialize_missing_as_null(true).serialize_maps_as_objects(true);
|
||||
v.serialize(&ser).map_err(|e| core_error(&internal(e)))
|
||||
}
|
||||
|
||||
fn from_js<T: DeserializeOwned>(v: JsValue) -> Result<T> {
|
||||
serde_wasm_bindgen::from_value(v).map_err(|e| CoreError::Invalid { msg: e.to_string() })
|
||||
}
|
||||
|
||||
fn core_error(e: &CoreError) -> JsValue {
|
||||
let err = js_sys::Error::new(&e.to_string());
|
||||
err.set_name("CoreError");
|
||||
let set = |k: &str, v: JsValue| {
|
||||
let _ = Reflect::set(&err, &JsValue::from_str(k), &v);
|
||||
};
|
||||
set("code", e.code().into());
|
||||
match e {
|
||||
CoreError::Server { status, .. } => set("status", (*status).into()),
|
||||
CoreError::IntegrityAlarm { vault_id, reason } => {
|
||||
set("vaultId", vault_id.as_str().into());
|
||||
set("reason", reason.as_str().into());
|
||||
}
|
||||
_ => {}
|
||||
}
|
||||
err.into()
|
||||
}
|
||||
|
||||
fn js_internal(e: JsValue) -> CoreError {
|
||||
let msg = e
|
||||
.as_string()
|
||||
.or_else(|| e.dyn_ref::<js_sys::Error>().map(|e| String::from(e.message())))
|
||||
.or_else(|| e.dyn_ref::<web_sys::DomException>().map(|e| e.message()))
|
||||
.unwrap_or_else(|| format!("{e:?}"));
|
||||
CoreError::Internal { msg }
|
||||
}
|
||||
|
||||
fn platform_name(p: Platform) -> &'static str {
|
||||
match p {
|
||||
Platform::Ios => "ios",
|
||||
Platform::Macos => "macos",
|
||||
Platform::Android => "android",
|
||||
Platform::Windows => "windows",
|
||||
Platform::Linux => "linux",
|
||||
}
|
||||
}
|
||||
|
||||
fn platform_from_name(s: &str) -> Option<Platform> {
|
||||
Some(match s {
|
||||
"ios" => Platform::Ios,
|
||||
"macos" => Platform::Macos,
|
||||
"android" => Platform::Android,
|
||||
"windows" => Platform::Windows,
|
||||
"linux" => Platform::Linux,
|
||||
_ => return None,
|
||||
})
|
||||
}
|
||||
|
||||
/// The server's platform enum has no "web" yet, so report the OS the browser runs on.
|
||||
fn guess_platform() -> Platform {
|
||||
let ua = Reflect::get(&js_sys::global(), &"navigator".into())
|
||||
.and_then(|n| Reflect::get(&n, &"userAgent".into()))
|
||||
.ok()
|
||||
.and_then(|u| u.as_string())
|
||||
.unwrap_or_default();
|
||||
if ua.contains("iPhone") || ua.contains("iPad") {
|
||||
Platform::Ios
|
||||
} else if ua.contains("Android") {
|
||||
Platform::Android
|
||||
} else if ua.contains("Mac") {
|
||||
Platform::Macos
|
||||
} else if ua.contains("Windows") {
|
||||
Platform::Windows
|
||||
} else {
|
||||
Platform::Linux
|
||||
}
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------- VaultCore
|
||||
|
||||
struct DeviceState {
|
||||
signer: Arc<WebCryptoSigner>,
|
||||
private: CryptoKey,
|
||||
name: String,
|
||||
platform: Platform,
|
||||
}
|
||||
|
||||
struct Shared {
|
||||
core: WebCore,
|
||||
db: Option<IdbDatabase>,
|
||||
device: RefCell<DeviceState>,
|
||||
}
|
||||
|
||||
impl Drop for Shared {
|
||||
fn drop(&mut self) {
|
||||
// `free()` from JS: drop the signing key too. Account keys are zeroized by `WebCore`'s drop.
|
||||
self.device.borrow().signer.forget();
|
||||
if let Some(db) = &self.db {
|
||||
db.close();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl Shared {
|
||||
/// Writes everything the last operation changed to IndexedDB (no-op for in-memory vaults).
|
||||
async fn persist(&self) -> Result<()> {
|
||||
let changes = self.core.store().take_changes();
|
||||
match &self.db {
|
||||
Some(db) => idb::apply(db, changes).await.map_err(js_internal),
|
||||
None => Ok(()),
|
||||
}
|
||||
}
|
||||
|
||||
async fn save_device(&self) -> Result<()> {
|
||||
let Some(db) = &self.db else { return Ok(()) };
|
||||
let record = {
|
||||
let d = self.device.borrow();
|
||||
d.signer.record(&d.private, &d.name, platform_name(d.platform)).map_err(js_internal)?
|
||||
};
|
||||
idb::put_device(db, &record).await.map_err(js_internal)
|
||||
}
|
||||
|
||||
fn install_device(&self) {
|
||||
let d = self.device.borrow();
|
||||
self.core.set_device_signer(d.signer.clone(), &d.name, d.platform);
|
||||
}
|
||||
}
|
||||
|
||||
/// The vault in this browser. Create it with `VaultCore.open()` (IndexedDB) or
|
||||
/// `VaultCore.newInMemory()` (nothing persisted). Run it in a Web Worker: `createAccount`,
|
||||
/// `unlock`, `login` and the encrypted export/import run Argon2id (64 MiB) and block their thread.
|
||||
#[wasm_bindgen]
|
||||
pub struct VaultCore {
|
||||
shared: Rc<Shared>,
|
||||
}
|
||||
|
||||
const DEFAULT_DB: &str = "kestrove";
|
||||
const DEFAULT_DEVICE_NAME: &str = "Web vault";
|
||||
|
||||
async fn load_device(db: Option<&IdbDatabase>) -> std::result::Result<Option<DeviceState>, JsValue> {
|
||||
let Some(db) = db else { return Ok(None) };
|
||||
let rec = idb::get_device(db).await?;
|
||||
if rec.is_undefined() || rec.is_null() {
|
||||
return Ok(None);
|
||||
}
|
||||
let get = |k: &str| Reflect::get(&rec, &JsValue::from_str(k));
|
||||
let id = get("deviceId")?.as_string().and_then(|s| Uuid::parse_str(&s).ok());
|
||||
let name = get("name")?.as_string().unwrap_or_else(|| DEFAULT_DEVICE_NAME.into());
|
||||
let platform = get("platform")?.as_string().and_then(|p| platform_from_name(&p)).unwrap_or_else(guess_platform);
|
||||
let private: CryptoKey = get("privateKey")?.dyn_into()?;
|
||||
let public = get("publicKey")?.dyn_into::<Uint8Array>()?.to_vec();
|
||||
let id = id.ok_or_else(|| JsValue::from_str("stored device id is not a UUID"))?;
|
||||
let signer = WebCryptoSigner::restore(id, public, private.clone())?;
|
||||
Ok(Some(DeviceState { signer: Arc::new(signer), private, name, platform }))
|
||||
}
|
||||
|
||||
async fn new_device() -> std::result::Result<DeviceState, JsValue> {
|
||||
let (signer, private) = WebCryptoSigner::generate().await?;
|
||||
Ok(DeviceState { signer: Arc::new(signer), private, name: DEFAULT_DEVICE_NAME.into(), platform: guess_platform() })
|
||||
}
|
||||
|
||||
impl VaultCore {
|
||||
async fn build(db: Option<IdbDatabase>) -> Result<VaultCore> {
|
||||
let store = match &db {
|
||||
Some(db) => WebStore::load(idb::load_entries(db).await.map_err(js_internal)?)?,
|
||||
None => WebStore::new(),
|
||||
};
|
||||
Self::build_with(store, db).await
|
||||
}
|
||||
|
||||
async fn build_with(store: WebStore, db: Option<IdbDatabase>) -> Result<VaultCore> {
|
||||
let core = WebCore::new(Arc::new(store))?;
|
||||
let (device, fresh) = match load_device(db.as_ref()).await.map_err(js_internal)? {
|
||||
Some(d) => (d, false),
|
||||
None => (new_device().await.map_err(js_internal)?, true),
|
||||
};
|
||||
let shared = Rc::new(Shared { core, db, device: RefCell::new(device) });
|
||||
shared.install_device();
|
||||
if fresh {
|
||||
shared.save_device().await?;
|
||||
}
|
||||
Ok(VaultCore { shared })
|
||||
}
|
||||
|
||||
/// Runs `op` and resolves with its result serialized to JS.
|
||||
fn run<T, F, Fut>(&self, op: F) -> Promise
|
||||
where
|
||||
T: Serialize,
|
||||
F: FnOnce(Rc<Shared>) -> Fut + 'static,
|
||||
Fut: Future<Output = Result<T>> + 'static,
|
||||
{
|
||||
let s = self.shared.clone();
|
||||
future_to_promise(async move { op(s).await.map_err(|e| core_error(&e)).and_then(|v| to_js(&v)) })
|
||||
}
|
||||
|
||||
/// Like `run`, then flushes changed entries to IndexedDB, even when `op` failed part-way
|
||||
/// (a sync that pulled some changes before failing keeps them).
|
||||
fn write<T, F, Fut>(&self, op: F) -> Promise
|
||||
where
|
||||
T: Serialize,
|
||||
F: FnOnce(Rc<Shared>) -> Fut + 'static,
|
||||
Fut: Future<Output = Result<T>> + 'static,
|
||||
{
|
||||
self.run(move |s| async move {
|
||||
let r = op(s.clone()).await;
|
||||
let p = s.persist().await;
|
||||
let v = r?;
|
||||
p?;
|
||||
Ok(v)
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
#[wasm_bindgen]
|
||||
impl VaultCore {
|
||||
/// Opens (or creates) the vault stored in IndexedDB database `dbName` (default `"kestrove"`).
|
||||
/// Creates this browser's device key on first use.
|
||||
#[wasm_bindgen(js_name = open, unchecked_return_type = "Promise<VaultCore>")]
|
||||
pub fn open(#[wasm_bindgen(js_name = "dbName")] db_name: Option<String>) -> Promise {
|
||||
future_to_promise(async move {
|
||||
let name = db_name.unwrap_or_else(|| DEFAULT_DB.into());
|
||||
let db = idb::open(&name).await.map_err(|e| core_error(&js_internal(e)))?;
|
||||
VaultCore::build(Some(db)).await.map(JsValue::from).map_err(|e| core_error(&e))
|
||||
})
|
||||
}
|
||||
|
||||
/// A vault that persists nothing (previews, tests). Its device key lasts until the page closes.
|
||||
#[wasm_bindgen(js_name = newInMemory, unchecked_return_type = "Promise<VaultCore>")]
|
||||
pub fn new_in_memory() -> Promise {
|
||||
future_to_promise(async move { VaultCore::build(None).await.map(JsValue::from).map_err(|e| core_error(&e)) })
|
||||
}
|
||||
|
||||
// ------------------------------------------------------------ account
|
||||
|
||||
/// Creates the account and its first vault, leaving it unlocked. Resolves with the Secret Key
|
||||
/// for the Emergency Kit. Runs Argon2id.
|
||||
#[wasm_bindgen(js_name = createAccount, unchecked_return_type = "Promise<string>")]
|
||||
pub fn create_account(&self, #[wasm_bindgen(js_name = "masterPassword")] master_password: String) -> Promise {
|
||||
let pw = zeroize::Zeroizing::new(master_password);
|
||||
self.write(move |s| async move { s.core.create_account(&pw) })
|
||||
}
|
||||
|
||||
/// Runs Argon2id.
|
||||
#[wasm_bindgen(unchecked_return_type = "Promise<void>")]
|
||||
pub fn unlock(
|
||||
&self,
|
||||
#[wasm_bindgen(js_name = "masterPassword")] master_password: String,
|
||||
#[wasm_bindgen(js_name = "secretKey")] secret_key: String,
|
||||
) -> Promise {
|
||||
let (pw, sk) = (zeroize::Zeroizing::new(master_password), zeroize::Zeroizing::new(secret_key));
|
||||
self.run(move |s| async move { s.core.unlock(&pw, &sk) })
|
||||
}
|
||||
|
||||
/// Zeroizes the keyset, vault key and OPAQUE input. Nothing secret was ever persisted.
|
||||
#[wasm_bindgen(unchecked_return_type = "Promise<void>")]
|
||||
pub fn lock(&self) -> Promise {
|
||||
self.run(|s| async move {
|
||||
s.core.lock();
|
||||
Ok(())
|
||||
})
|
||||
}
|
||||
|
||||
#[wasm_bindgen(js_name = isLocked, unchecked_return_type = "Promise<boolean>")]
|
||||
pub fn is_locked(&self) -> Promise {
|
||||
self.run(|s| async move { Ok(s.core.is_locked()) })
|
||||
}
|
||||
|
||||
#[wasm_bindgen(js_name = hasAccount, unchecked_return_type = "Promise<boolean>")]
|
||||
pub fn has_account(&self) -> Promise {
|
||||
self.run(|s| async move { Ok(s.core.has_account()) })
|
||||
}
|
||||
|
||||
#[wasm_bindgen(js_name = accountId, unchecked_return_type = "Promise<string>")]
|
||||
pub fn account_id(&self) -> Promise {
|
||||
self.run(|s| async move { s.core.account_id() })
|
||||
}
|
||||
|
||||
#[wasm_bindgen(js_name = accountFingerprint, unchecked_return_type = "Promise<string>")]
|
||||
pub fn account_fingerprint(&self) -> Promise {
|
||||
self.run(|s| async move { s.core.account_fingerprint() })
|
||||
}
|
||||
|
||||
/// Removes this browser's account data, replica, session and device key. The browser becomes a
|
||||
/// new device. Log out first if the server session should end too.
|
||||
#[wasm_bindgen(unchecked_return_type = "Promise<void>")]
|
||||
pub fn wipe(&self) -> Promise {
|
||||
self.write(|s| async move {
|
||||
s.core.wipe()?;
|
||||
s.device.borrow().signer.forget();
|
||||
let fresh = new_device().await.map_err(js_internal)?;
|
||||
*s.device.borrow_mut() = fresh;
|
||||
s.install_device();
|
||||
s.save_device().await
|
||||
})
|
||||
}
|
||||
|
||||
// ------------------------------------------------------------ items
|
||||
|
||||
#[wasm_bindgen(js_name = listItems, unchecked_return_type = "Promise<ItemSummary[]>")]
|
||||
pub fn list_items(&self) -> Promise {
|
||||
self.run(|s| async move { s.core.list_items() })
|
||||
}
|
||||
|
||||
#[wasm_bindgen(js_name = getItem, unchecked_return_type = "Promise<ItemDto>")]
|
||||
pub fn get_item(&self, id: String) -> Promise {
|
||||
self.run(move |s| async move { s.core.get_item(&id) })
|
||||
}
|
||||
|
||||
/// Inserts (`id: ""`) or updates an item; resolves with its id.
|
||||
#[wasm_bindgen(js_name = saveItem, unchecked_return_type = "Promise<string>")]
|
||||
pub fn save_item(&self, #[wasm_bindgen(unchecked_param_type = "ItemDto")] item: JsValue) -> Promise {
|
||||
let item = from_js::<ItemDto>(item);
|
||||
self.write(move |s| async move { s.core.save_item(item?) })
|
||||
}
|
||||
|
||||
#[wasm_bindgen(js_name = deleteItem, unchecked_return_type = "Promise<void>")]
|
||||
pub fn delete_item(&self, id: String) -> Promise {
|
||||
self.write(move |s| async move { s.core.delete_item(&id) })
|
||||
}
|
||||
|
||||
#[wasm_bindgen(js_name = itemHistory, unchecked_return_type = "Promise<ItemDto[]>")]
|
||||
pub fn item_history(&self, id: String) -> Promise {
|
||||
self.run(move |s| async move { s.core.item_history(&id) })
|
||||
}
|
||||
|
||||
#[wasm_bindgen(js_name = itemsForUrl, unchecked_return_type = "Promise<ItemSummary[]>")]
|
||||
pub fn items_for_url(&self, #[wasm_bindgen(js_name = "pageUrl")] page_url: String) -> Promise {
|
||||
self.run(move |s| async move { s.core.items_for_url(&page_url) })
|
||||
}
|
||||
|
||||
// ------------------------------------------------------------ passkeys
|
||||
|
||||
#[wasm_bindgen(js_name = listPasskeys, unchecked_return_type = "Promise<PasskeySummary[]>")]
|
||||
pub fn list_passkeys(&self) -> Promise {
|
||||
self.run(|s| async move { s.core.list_passkeys() })
|
||||
}
|
||||
|
||||
#[wasm_bindgen(js_name = deletePasskey, unchecked_return_type = "Promise<void>")]
|
||||
pub fn delete_passkey(&self, #[wasm_bindgen(js_name = "credentialId")] credential_id: Vec<u8>) -> Promise {
|
||||
self.write(move |s| async move { s.core.delete_passkey(&credential_id) })
|
||||
}
|
||||
|
||||
// ------------------------------------------------------------ import / export
|
||||
|
||||
#[wasm_bindgen(js_name = importPreview, unchecked_return_type = "Promise<ImportPreview>")]
|
||||
pub fn import_preview(
|
||||
&self,
|
||||
data: Vec<u8>,
|
||||
#[wasm_bindgen(unchecked_param_type = "ImportFormat")] format: JsValue,
|
||||
) -> Promise {
|
||||
let (data, format) = (zeroize::Zeroizing::new(data), from_js::<ImportFormat>(format));
|
||||
self.run(move |s| async move { s.core.import_preview(&data, format?) })
|
||||
}
|
||||
|
||||
#[wasm_bindgen(js_name = importCommit, unchecked_return_type = "Promise<ImportReport>")]
|
||||
pub fn import_commit(
|
||||
&self,
|
||||
data: Vec<u8>,
|
||||
#[wasm_bindgen(unchecked_param_type = "ImportFormat")] format: JsValue,
|
||||
#[wasm_bindgen(unchecked_param_type = "ImportOptions")] options: JsValue,
|
||||
) -> Promise {
|
||||
let data = zeroize::Zeroizing::new(data);
|
||||
let (format, options) = (from_js::<ImportFormat>(format), from_js::<ImportOptions>(options));
|
||||
self.write(move |s| async move { s.core.import_commit(&data, format?, options?) })
|
||||
}
|
||||
|
||||
/// Resolves with the `.kvex` file bytes. Runs Argon2id.
|
||||
#[wasm_bindgen(js_name = exportEncrypted, unchecked_return_type = "Promise<Uint8Array>")]
|
||||
pub fn export_encrypted(&self, passphrase: String) -> Promise {
|
||||
let passphrase = zeroize::Zeroizing::new(passphrase);
|
||||
let s = self.shared.clone();
|
||||
future_to_promise(async move {
|
||||
let bytes = s.core.export_encrypted(&passphrase).map_err(|e| core_error(&e))?;
|
||||
Ok(Uint8Array::from(bytes.as_slice()).into())
|
||||
})
|
||||
}
|
||||
|
||||
// ------------------------------------------------------------ server and devices
|
||||
|
||||
/// Use the web vault's own origin (`location.origin`). Pins are accepted for API parity but the
|
||||
/// browser's TLS is what protects the connection.
|
||||
#[wasm_bindgen(js_name = configureServer, unchecked_return_type = "Promise<void>")]
|
||||
pub fn configure_server(
|
||||
&self,
|
||||
url: String,
|
||||
#[wasm_bindgen(js_name = "spkiPins")] spki_pins: Option<Vec<String>>,
|
||||
) -> Promise {
|
||||
self.write(move |s| async move { s.core.configure_server(&url, spki_pins.unwrap_or_default()) })
|
||||
}
|
||||
|
||||
/// Name (and optionally platform) the server shows for this browser. Takes effect at the next
|
||||
/// login/enrollment. Default: "Web vault" and the OS from the user agent.
|
||||
#[wasm_bindgen(js_name = setDevice, unchecked_return_type = "Promise<void>")]
|
||||
pub fn set_device(
|
||||
&self,
|
||||
name: String,
|
||||
#[wasm_bindgen(unchecked_optional_param_type = "Platform")] platform: Option<String>,
|
||||
) -> Promise {
|
||||
self.run(move |s| async move {
|
||||
let platform = match platform {
|
||||
Some(p) => Some(platform_from_name(&p).ok_or(CoreError::Invalid { msg: "unknown platform".into() })?),
|
||||
None => None,
|
||||
};
|
||||
{
|
||||
let mut d = s.device.borrow_mut();
|
||||
d.name = name;
|
||||
if let Some(p) = platform {
|
||||
d.platform = p;
|
||||
}
|
||||
}
|
||||
s.install_device();
|
||||
s.save_device().await
|
||||
})
|
||||
}
|
||||
|
||||
#[wasm_bindgen(js_name = deviceId, unchecked_return_type = "Promise<string>")]
|
||||
pub fn device_id(&self) -> Promise {
|
||||
self.run(|s| async move { s.core.device_id().map(|d| d.to_string()).ok_or(CoreError::NotConfigured) })
|
||||
}
|
||||
|
||||
#[wasm_bindgen(js_name = deviceKeyFingerprint, unchecked_return_type = "Promise<string>")]
|
||||
pub fn device_key_fingerprint(&self) -> Promise {
|
||||
self.run(|s| async move { s.core.device_key_fingerprint() })
|
||||
}
|
||||
|
||||
#[wasm_bindgen(js_name = serverInfo, unchecked_return_type = "Promise<ServerInfoDto>")]
|
||||
pub fn server_info(&self) -> Promise {
|
||||
self.run(|s| async move { s.core.server_info().await })
|
||||
}
|
||||
|
||||
/// First device only (the web vault is rarely that): registers the account with a fresh server.
|
||||
#[wasm_bindgen(js_name = registerAccount, unchecked_return_type = "Promise<void>")]
|
||||
pub fn register_account(&self, #[wasm_bindgen(js_name = "setupToken")] setup_token: String) -> Promise {
|
||||
self.write(move |s| async move { s.core.register_account(&setup_token).await })
|
||||
}
|
||||
|
||||
/// Logs in (and unlocks). Resolves `"pendingApproval"` until an enrolled device approves this
|
||||
/// browser; then call it again. Runs Argon2id.
|
||||
#[wasm_bindgen(unchecked_return_type = "Promise<LoginResult>")]
|
||||
pub fn login(
|
||||
&self,
|
||||
#[wasm_bindgen(js_name = "masterPassword")] master_password: String,
|
||||
#[wasm_bindgen(js_name = "secretKey")] secret_key: String,
|
||||
totp: Option<String>,
|
||||
) -> Promise {
|
||||
let (pw, sk) = (zeroize::Zeroizing::new(master_password), zeroize::Zeroizing::new(secret_key));
|
||||
self.write(move |s| async move { s.core.login(&pw, &sk, totp).await })
|
||||
}
|
||||
|
||||
#[wasm_bindgen(js_name = syncNow, unchecked_return_type = "Promise<SyncReport>")]
|
||||
pub fn sync_now(&self) -> Promise {
|
||||
self.write(|s| async move { s.core.sync_now().await })
|
||||
}
|
||||
|
||||
#[wasm_bindgen(js_name = listDevices, unchecked_return_type = "Promise<DeviceDto[]>")]
|
||||
pub fn list_devices(&self) -> Promise {
|
||||
self.run(|s| async move { s.core.list_devices().await })
|
||||
}
|
||||
|
||||
#[wasm_bindgen(js_name = approveDevice, unchecked_return_type = "Promise<void>")]
|
||||
pub fn approve_device(&self, #[wasm_bindgen(js_name = "deviceId")] device_id: String) -> Promise {
|
||||
self.run(move |s| async move { s.core.approve_device(&device_id).await })
|
||||
}
|
||||
|
||||
#[wasm_bindgen(js_name = revokeDevice, unchecked_return_type = "Promise<void>")]
|
||||
pub fn revoke_device(&self, #[wasm_bindgen(js_name = "deviceId")] device_id: String) -> Promise {
|
||||
self.run(move |s| async move { s.core.revoke_device(&device_id).await })
|
||||
}
|
||||
|
||||
#[wasm_bindgen(unchecked_return_type = "Promise<void>")]
|
||||
pub fn logout(&self) -> Promise {
|
||||
self.write(|s| async move { s.core.logout().await })
|
||||
}
|
||||
|
||||
#[wasm_bindgen(js_name = isLoggedIn, unchecked_return_type = "Promise<boolean>")]
|
||||
pub fn is_logged_in(&self) -> Promise {
|
||||
self.run(|s| async move { Ok(s.core.is_logged_in()) })
|
||||
}
|
||||
|
||||
/// Diagnostics and the cross-platform fixture (`js/smoke.mjs`): the replica entries exactly as
|
||||
/// IndexedDB holds them (ciphertext, ids, bookkeeping). Not part of the typed API.
|
||||
#[wasm_bindgen(js_name = __replicaEntries, skip_typescript)]
|
||||
pub fn replica_entries(&self) -> Promise {
|
||||
self.run(|s| async move {
|
||||
let entries: std::collections::BTreeMap<String, String> = s
|
||||
.core
|
||||
.store()
|
||||
.entries()
|
||||
.into_iter()
|
||||
.map(|(k, v)| (k, String::from_utf8_lossy(&v).into_owned()))
|
||||
.collect();
|
||||
Ok(entries)
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------- free functions
|
||||
|
||||
#[wasm_bindgen(js_name = generatePassword)]
|
||||
pub fn generate_password(length: u32, symbols: bool) -> String {
|
||||
core::generate_password(length, symbols)
|
||||
}
|
||||
|
||||
/// `unixTime` in seconds (e.g. `Math.floor(Date.now() / 1000)`).
|
||||
#[wasm_bindgen(js_name = totpCode, unchecked_return_type = "TotpCode")]
|
||||
pub fn totp_code(
|
||||
#[wasm_bindgen(js_name = "secretOrUri")] secret_or_uri: String,
|
||||
#[wasm_bindgen(js_name = "unixTime")] unix_time: f64,
|
||||
) -> std::result::Result<JsValue, JsValue> {
|
||||
if !(unix_time.is_finite() && unix_time >= 0.0) {
|
||||
return Err(core_error(&CoreError::Invalid { msg: "unixTime".into() }));
|
||||
}
|
||||
let code = core::totp_code(&secret_or_uri, unix_time as u64).map_err(|e| core_error(&e))?;
|
||||
to_js(&code)
|
||||
}
|
||||
|
||||
#[wasm_bindgen(js_name = matchUrl, unchecked_return_type = "MatchLevel")]
|
||||
pub fn match_url(
|
||||
#[wasm_bindgen(js_name = "itemUrl")] item_url: String,
|
||||
#[wasm_bindgen(js_name = "pageUrl")] page_url: String,
|
||||
) -> std::result::Result<JsValue, JsValue> {
|
||||
to_js(&core::match_url(&item_url, &page_url))
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests;
|
||||
|
||||
#[wasm_bindgen(js_name = coreVersion)]
|
||||
pub fn core_version() -> String {
|
||||
env!("CARGO_PKG_VERSION").to_string()
|
||||
}
|
||||
@@ -0,0 +1,110 @@
|
||||
//! The browser's device key: a non-extractable WebCrypto ECDSA P-256 key.
|
||||
//!
|
||||
//! WebCrypto's ECDSA signature is already IEEE P1363 `r || s` (64 bytes), the format the RFC 9421
|
||||
//! profile uses, so no DER conversion is needed. The `CryptoKey` is a JS object, so it stays in a
|
||||
//! thread-local registry keyed by device id (the wasm module runs on one thread; several vaults may
|
||||
//! share it) and the signer itself is plain data, which keeps it `Send + Sync` as
|
||||
//! `kv_sync::DeviceSigner` requires.
|
||||
|
||||
use std::{cell::RefCell, collections::HashMap};
|
||||
|
||||
use js_sys::{Array, Object, Reflect, Uint8Array};
|
||||
use kv_sync::{DeviceSigner, SignFuture, SyncError};
|
||||
use uuid::Uuid;
|
||||
use wasm_bindgen::{prelude::*, JsCast};
|
||||
use wasm_bindgen_futures::JsFuture;
|
||||
use web_sys::{Crypto, CryptoKey, EcKeyGenParams, EcdsaParams, SubtleCrypto};
|
||||
|
||||
thread_local! {
|
||||
static PRIVATE_KEYS: RefCell<HashMap<Uuid, CryptoKey>> = RefCell::new(HashMap::new());
|
||||
}
|
||||
|
||||
fn subtle() -> Result<SubtleCrypto, JsValue> {
|
||||
let crypto: Crypto = Reflect::get(&js_sys::global(), &JsValue::from_str("crypto"))?.dyn_into()?;
|
||||
Ok(crypto.subtle())
|
||||
}
|
||||
|
||||
pub struct WebCryptoSigner {
|
||||
id: Uuid,
|
||||
public_key: Vec<u8>,
|
||||
}
|
||||
|
||||
impl WebCryptoSigner {
|
||||
/// Generates a fresh non-extractable key pair. Returns the signer and the private `CryptoKey`
|
||||
/// (to store in IndexedDB; structured clone keeps it non-extractable).
|
||||
pub async fn generate() -> Result<(Self, CryptoKey), JsValue> {
|
||||
let subtle = subtle()?;
|
||||
let params = EcKeyGenParams::new("ECDSA", "P-256");
|
||||
let usages = Array::of1(&JsValue::from_str("sign"));
|
||||
let pair = JsFuture::from(subtle.generate_key_with_object(¶ms, false, &usages)?).await?;
|
||||
let private: CryptoKey = Reflect::get(&pair, &JsValue::from_str("privateKey"))?.dyn_into()?;
|
||||
let public: CryptoKey = Reflect::get(&pair, &JsValue::from_str("publicKey"))?.dyn_into()?;
|
||||
// Public keys are always exportable; "raw" is the 65-byte SEC1 uncompressed point.
|
||||
let raw = JsFuture::from(subtle.export_key("raw", &public)?).await?;
|
||||
let public_key = Uint8Array::new(&raw).to_vec();
|
||||
let signer = Self { id: Uuid::new_v4(), public_key };
|
||||
signer.activate(private.clone());
|
||||
Ok((signer, private))
|
||||
}
|
||||
|
||||
/// Restores a stored key.
|
||||
pub fn restore(id: Uuid, public_key: Vec<u8>, private: CryptoKey) -> Result<Self, JsValue> {
|
||||
if public_key.len() != 65 || public_key[0] != 4 {
|
||||
return Err(JsValue::from_str("stored device public key is not an uncompressed P-256 point"));
|
||||
}
|
||||
let signer = Self { id, public_key };
|
||||
signer.activate(private);
|
||||
Ok(signer)
|
||||
}
|
||||
|
||||
fn activate(&self, private: CryptoKey) {
|
||||
PRIVATE_KEYS.with(|k| k.borrow_mut().insert(self.id, private));
|
||||
}
|
||||
|
||||
/// Drops this device's key from the registry (wipe, or the vault was freed).
|
||||
pub fn forget(&self) {
|
||||
PRIVATE_KEYS.with(|k| k.borrow_mut().remove(&self.id));
|
||||
}
|
||||
|
||||
/// The IndexedDB record for this key.
|
||||
pub fn record(&self, private: &CryptoKey, name: &str, platform: &str) -> Result<JsValue, JsValue> {
|
||||
let o = Object::new();
|
||||
Reflect::set(&o, &"deviceId".into(), &self.id.to_string().into())?;
|
||||
Reflect::set(&o, &"name".into(), &name.into())?;
|
||||
Reflect::set(&o, &"platform".into(), &platform.into())?;
|
||||
Reflect::set(&o, &"privateKey".into(), private)?;
|
||||
Reflect::set(&o, &"publicKey".into(), &Uint8Array::from(self.public_key.as_slice()))?;
|
||||
Ok(o.into())
|
||||
}
|
||||
|
||||
async fn sign_js(&self, message: &[u8]) -> Result<Vec<u8>, JsValue> {
|
||||
let key = PRIVATE_KEYS
|
||||
.with(|k| k.borrow().get(&self.id).cloned())
|
||||
.ok_or_else(|| JsValue::from_str("device key is not loaded"))?;
|
||||
let params = EcdsaParams::new("ECDSA", &JsValue::from_str("SHA-256"));
|
||||
let sig = JsFuture::from(subtle()?.sign_with_object_and_u8_array(¶ms, &key, message)?).await?;
|
||||
Ok(Uint8Array::new(&sig).to_vec())
|
||||
}
|
||||
}
|
||||
|
||||
fn js_err(e: JsValue) -> SyncError {
|
||||
SyncError::Signer(e.as_string().unwrap_or_else(|| format!("{e:?}")))
|
||||
}
|
||||
|
||||
impl DeviceSigner for WebCryptoSigner {
|
||||
fn device_id(&self) -> Uuid {
|
||||
self.id
|
||||
}
|
||||
|
||||
fn public_key(&self) -> Vec<u8> {
|
||||
self.public_key.clone()
|
||||
}
|
||||
|
||||
fn sign(&self, _message: &[u8]) -> Result<Vec<u8>, SyncError> {
|
||||
Err(SyncError::Signer("WebCrypto signs asynchronously; use sign_async".into()))
|
||||
}
|
||||
|
||||
fn sign_async<'a>(&'a self, message: &'a [u8]) -> SignFuture<'a> {
|
||||
Box::pin(async move { self.sign_js(message).await.map_err(js_err) })
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,199 @@
|
||||
//! In-browser tests (`wasm-pack test --headless --chrome crates/kv-wasm`). They drive the same
|
||||
//! `VaultCore` methods JavaScript calls (promises, serde conversion, IndexedDB, WebCrypto).
|
||||
|
||||
use std::collections::BTreeMap;
|
||||
|
||||
use js_sys::{Promise, Reflect};
|
||||
use serde_json::{json, Value};
|
||||
use wasm_bindgen::JsValue;
|
||||
use wasm_bindgen_futures::JsFuture;
|
||||
use wasm_bindgen_test::*;
|
||||
|
||||
use super::*;
|
||||
|
||||
wasm_bindgen_test_configure!(run_in_browser);
|
||||
|
||||
const PW: &str = "correct horse battery staple";
|
||||
|
||||
async fn ok(p: Promise) -> JsValue {
|
||||
match JsFuture::from(p).await {
|
||||
Ok(v) => v,
|
||||
Err(e) => panic!("promise rejected: {:?}", Reflect::get(&e, &"message".into()).unwrap_or(e)),
|
||||
}
|
||||
}
|
||||
|
||||
async fn err_code(p: Promise) -> String {
|
||||
let e = JsFuture::from(p).await.expect_err("expected a rejection");
|
||||
assert_eq!(Reflect::get(&e, &"name".into()).unwrap().as_string().as_deref(), Some("CoreError"));
|
||||
Reflect::get(&e, &"code".into()).unwrap().as_string().unwrap()
|
||||
}
|
||||
|
||||
fn js(v: Value) -> JsValue {
|
||||
to_js(&v).unwrap()
|
||||
}
|
||||
|
||||
fn de<T: DeserializeOwned>(v: JsValue) -> T {
|
||||
serde_wasm_bindgen::from_value(v).unwrap()
|
||||
}
|
||||
|
||||
fn now_ms() -> f64 {
|
||||
js_sys::Date::now()
|
||||
}
|
||||
|
||||
async fn open_db(name: &str) -> VaultCore {
|
||||
VaultCore::build(Some(idb::open(name).await.unwrap())).await.unwrap()
|
||||
}
|
||||
|
||||
fn login(title: &str, password: &str) -> Value {
|
||||
json!({
|
||||
"id": "", "kind": "login", "title": title, "username": "ana", "password": password,
|
||||
"urls": ["https://example.com/login"], "totp": null, "notes": null, "favorite": false, "tags": [],
|
||||
"modifiedAt": 0
|
||||
})
|
||||
}
|
||||
|
||||
#[wasm_bindgen_test]
|
||||
async fn account_lifecycle_persists_in_indexeddb() {
|
||||
let db_name = format!("kv-test-{}", Uuid::new_v4());
|
||||
let core = open_db(&db_name).await;
|
||||
assert!(!ok(core.has_account()).await.as_bool().unwrap());
|
||||
let device_id = ok(core.device_id()).await.as_string().unwrap();
|
||||
|
||||
let t = now_ms();
|
||||
let secret_key = ok(core.create_account(PW.into())).await.as_string().unwrap();
|
||||
console_log!("createAccount (Argon2id 64 MiB, t=3, p=4): {:.0} ms", now_ms() - t);
|
||||
assert!(secret_key.starts_with("K1-"));
|
||||
|
||||
let id = ok(core.save_item(js(login("Bank", "hunter2-secret")))).await.as_string().unwrap();
|
||||
// Minimal input: omitted optional fields default.
|
||||
let note = ok(core.save_item(js(json!({"id": "", "kind": "secureNote", "title": "Note", "notes": "n"}))))
|
||||
.await
|
||||
.as_string()
|
||||
.unwrap();
|
||||
let list: Vec<ItemSummary> = de(ok(core.list_items()).await);
|
||||
assert_eq!(list.iter().map(|i| i.title.as_str()).collect::<Vec<_>>(), ["Bank", "Note"]);
|
||||
assert!(list[0].tags.is_empty() && list[0].modified_at > 1_700_000_000);
|
||||
let mut item: ItemDto = de(ok(core.get_item(id.clone())).await);
|
||||
assert_eq!(item.password.as_deref(), Some("hunter2-secret"));
|
||||
item.password = Some("hunter3-secret".into());
|
||||
ok(core.save_item(to_js(&item).unwrap())).await;
|
||||
let history: Vec<ItemDto> = de(ok(core.item_history(id.clone())).await);
|
||||
assert_eq!(history[0].password.as_deref(), Some("hunter2-secret"));
|
||||
ok(core.delete_item(note)).await;
|
||||
let for_url: Vec<ItemSummary> = de(ok(core.items_for_url("https://example.com/other".into())).await);
|
||||
assert_eq!(for_url.len(), 1);
|
||||
|
||||
// Lock: every read needs the keys again.
|
||||
ok(core.lock()).await;
|
||||
assert!(ok(core.is_locked()).await.as_bool().unwrap());
|
||||
assert_eq!(err_code(core.list_items()).await, "Locked");
|
||||
assert_eq!(err_code(core.unlock("wrong password!".into(), secret_key.clone())).await, "BadCredentials");
|
||||
assert_eq!(err_code(core.save_item(js(json!({"kind": "nope"})))).await, "Invalid");
|
||||
|
||||
// IndexedDB holds ciphertext only.
|
||||
let db = core.shared.db.clone().unwrap();
|
||||
for (key, value) in idb::load_entries(&db).await.unwrap() {
|
||||
let text = String::from_utf8_lossy(&value);
|
||||
for secret in ["hunter2", "hunter3", "Bank", "ana", "example.com", PW] {
|
||||
assert!(!text.contains(secret), "{key} contains {secret}");
|
||||
}
|
||||
}
|
||||
db.close();
|
||||
drop(core);
|
||||
|
||||
// A new page load: same device key, account still there, locked.
|
||||
let core = open_db(&db_name).await;
|
||||
assert_eq!(ok(core.device_id()).await.as_string().unwrap(), device_id);
|
||||
assert!(ok(core.has_account()).await.as_bool().unwrap());
|
||||
assert!(ok(core.is_locked()).await.as_bool().unwrap());
|
||||
let t = now_ms();
|
||||
ok(core.unlock(PW.into(), secret_key.clone())).await;
|
||||
console_log!("unlock (Argon2id 64 MiB): {:.0} ms", now_ms() - t);
|
||||
let item: ItemDto = de(ok(core.get_item(id.clone())).await);
|
||||
assert_eq!(item.password.as_deref(), Some("hunter3-secret"));
|
||||
let list: Vec<ItemSummary> = de(ok(core.list_items()).await);
|
||||
assert_eq!(list.len(), 1);
|
||||
|
||||
// Wipe: a new device, nothing left.
|
||||
ok(core.wipe()).await;
|
||||
assert!(!ok(core.has_account()).await.as_bool().unwrap());
|
||||
assert_ne!(ok(core.device_id()).await.as_string().unwrap(), device_id);
|
||||
let db = core.shared.db.clone().unwrap();
|
||||
assert!(idb::load_entries(&db).await.unwrap().is_empty());
|
||||
db.close();
|
||||
}
|
||||
|
||||
#[wasm_bindgen_test]
|
||||
async fn webcrypto_device_key_signs_rfc9421_requests() {
|
||||
let (signer, _private) = WebCryptoSigner::generate().await.unwrap();
|
||||
let pk = kv_sync::DeviceSigner::public_key(&signer);
|
||||
assert_eq!((pk.len(), pk[0]), (65, 4));
|
||||
let url = "https://vault.example.com/v1/vaults";
|
||||
let h = kv_sync::signing::sign_request_async(&signer, "PUT", url, b"{\"a\":1}", "session", 1_700_000_000)
|
||||
.await
|
||||
.unwrap();
|
||||
let lookup = |name: &str| h.pairs().iter().find(|(k, _)| *k == name).map(|(_, v)| v.to_string());
|
||||
// The server-side verifier (p256) accepts the WebCrypto signature as is (raw r||s).
|
||||
let id = kv_sync::signing::verify_request(&pk, "PUT", url, b"{\"a\":1}", &lookup).unwrap();
|
||||
assert_eq!(id, kv_sync::DeviceSigner::device_id(&signer));
|
||||
assert!(kv_sync::signing::verify_request(&pk, "PUT", url, b"{}", &lookup).is_err());
|
||||
}
|
||||
|
||||
/// Items written by kv-ffi (native, SQLite) open in the browser.
|
||||
#[wasm_bindgen_test]
|
||||
async fn native_fixture_opens_in_wasm() {
|
||||
let f: Value = serde_json::from_str(include_str!("../../tests/fixtures/native-vault.json")).unwrap();
|
||||
let entries: BTreeMap<String, String> = serde_json::from_value(f["entries"].clone()).unwrap();
|
||||
let store = WebStore::load(entries.into_iter().map(|(k, v)| (k, v.into_bytes()))).unwrap();
|
||||
let core = VaultCore::build_with(store, None).await.unwrap();
|
||||
let (pw, sk) = (f["masterPassword"].as_str().unwrap(), f["secretKey"].as_str().unwrap());
|
||||
ok(core.unlock(pw.into(), sk.into())).await;
|
||||
let expected: Vec<ItemDto> = serde_json::from_value(f["items"].clone()).unwrap();
|
||||
for e in &expected {
|
||||
let got: ItemDto = de(ok(core.get_item(e.id.clone())).await);
|
||||
assert_eq!(&got, e);
|
||||
}
|
||||
let bank = expected.iter().find(|i| i.title == "Bank").unwrap();
|
||||
let history: Vec<ItemDto> = de(ok(core.item_history(bank.id.clone())).await);
|
||||
assert_eq!(history[0].password.as_deref(), Some("first password"));
|
||||
// Edits made here keep working with the native-made keys.
|
||||
let mut edit = bank.clone();
|
||||
edit.notes = Some("edited in the browser".into());
|
||||
ok(core.save_item(to_js(&edit).unwrap())).await;
|
||||
let got: ItemDto = de(ok(core.get_item(bank.id.clone())).await);
|
||||
assert_eq!(got.notes.as_deref(), Some("edited in the browser"));
|
||||
}
|
||||
|
||||
#[wasm_bindgen_test]
|
||||
async fn in_memory_vault_import_export_and_helpers() {
|
||||
let core: VaultCore = VaultCore::build(None).await.unwrap();
|
||||
assert_eq!(err_code(core.list_items()).await, "NoAccount");
|
||||
assert_eq!(err_code(core.create_account("short".into())).await, "Invalid");
|
||||
ok(core.create_account(PW.into())).await;
|
||||
let csv = b"title,url,username,password,otp,notes\nGit,https://git.example.com,me,pw1,,\n".to_vec();
|
||||
let preview: ImportPreview = de(ok(core.import_preview(csv.clone(), js(json!({"type": "csv"})))).await);
|
||||
assert_eq!((preview.total, preview.logins), (1, 1));
|
||||
let report: ImportReport =
|
||||
de(ok(core.import_commit(csv, js(json!({"type": "csv"})), js(json!({"duplicates": "skip"})))).await);
|
||||
assert_eq!(report.imported, 1);
|
||||
let t = now_ms();
|
||||
let blob = ok(core.export_encrypted("export passphrase".into())).await;
|
||||
console_log!("exportEncrypted (Argon2id): {:.0} ms", now_ms() - t);
|
||||
let blob = js_sys::Uint8Array::new(&blob).to_vec();
|
||||
let fmt = js(json!({"type": "kestroveEncrypted", "passphrase": "export passphrase"}));
|
||||
let preview: ImportPreview = de(ok(core.import_preview(blob, fmt)).await);
|
||||
assert_eq!((preview.total, preview.duplicates), (1, 1));
|
||||
let passkeys: Vec<PasskeySummary> = de(ok(core.list_passkeys()).await);
|
||||
assert!(passkeys.is_empty());
|
||||
assert_eq!(err_code(core.delete_passkey(vec![1, 2, 3])).await, "NotFound");
|
||||
// Not configured yet: network calls fail cleanly.
|
||||
assert_eq!(err_code(core.sync_now()).await, "NotConfigured");
|
||||
assert_eq!(err_code(core.configure_server("http://example.com".into(), None)).await, "Invalid");
|
||||
|
||||
assert_eq!(generate_password(24, true).chars().count(), 24);
|
||||
let code: TotpCode = de(totp_code("JBSWY3DPEHPK3PXP".into(), 59.0).unwrap());
|
||||
assert_eq!((code.code.len(), code.period, code.seconds_remaining), (6, 30, 1));
|
||||
assert_eq!(match_url("https://a.example.com".into(), "https://a.example.com/x".into()).unwrap(), "exact");
|
||||
// No Public Suffix List in the web build: different hosts never match.
|
||||
assert_eq!(match_url("https://a.example.com".into(), "https://b.example.com".into()).unwrap(), "none");
|
||||
}
|
||||
@@ -0,0 +1,223 @@
|
||||
//! Envelope compatibility between the apps (kv-ffi) and the web vault (kv-wasm), through two shared
|
||||
//! fixtures in `tests/fixtures/`:
|
||||
//!
|
||||
//! * `native-vault.json`: made by kv-ffi (SQLite). The wasm tests (`src/wasm/tests.rs`, in Chrome)
|
||||
//! unlock it and read its items.
|
||||
//! * `wasm-vault.json`: made by the real wasm package (`js/smoke.mjs`, in Node). Here kv-ffi unlocks it
|
||||
//! and reads its items.
|
||||
//!
|
||||
//! Both hold the replica entries in the web store's format (ciphertext only), the credentials and the
|
||||
//! expected plaintext. Regenerate the native one with
|
||||
//! `cargo test -p kv-wasm --test fixture -- --ignored generate_native_fixture`.
|
||||
#![cfg(not(target_arch = "wasm32"))]
|
||||
|
||||
use std::{collections::BTreeMap, path::PathBuf, sync::Arc};
|
||||
|
||||
use kv_store::{SqliteStore, Store};
|
||||
use kv_wasm::{
|
||||
dto::{ItemDto, ItemKindDto},
|
||||
store::WebStore,
|
||||
WebCore,
|
||||
};
|
||||
use serde::{Deserialize, Serialize};
|
||||
|
||||
#[derive(Serialize, Deserialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
struct Fixture {
|
||||
master_password: String,
|
||||
secret_key: String,
|
||||
/// Replica entries, values as UTF-8 (they are JSON or ASCII).
|
||||
entries: BTreeMap<String, String>,
|
||||
/// Expected plaintext, sorted by title.
|
||||
items: Vec<ItemDto>,
|
||||
}
|
||||
|
||||
fn path(name: &str) -> PathBuf {
|
||||
PathBuf::from(env!("CARGO_MANIFEST_DIR")).join("tests/fixtures").join(name)
|
||||
}
|
||||
|
||||
fn load(name: &str) -> Fixture {
|
||||
serde_json::from_slice(&std::fs::read(path(name)).unwrap()).unwrap()
|
||||
}
|
||||
|
||||
/// Copies a native SQLite replica into the web store format.
|
||||
fn sqlite_to_web(src: &SqliteStore) -> WebStore {
|
||||
let dst = WebStore::new();
|
||||
for key in ["account", "server", "session"] {
|
||||
if let Some(v) = src.get_meta(key).unwrap() {
|
||||
dst.put_meta(key, &v).unwrap();
|
||||
}
|
||||
}
|
||||
for v in src.vaults().unwrap() {
|
||||
dst.put_vault(&v).unwrap();
|
||||
for item in src.list(v.vault_id).unwrap() {
|
||||
dst.put(v.vault_id, &item).unwrap();
|
||||
for h in src.history(v.vault_id, item.record.item_id).unwrap().into_iter().rev() {
|
||||
dst.push_history(v.vault_id, item.record.item_id, &h).unwrap();
|
||||
}
|
||||
}
|
||||
}
|
||||
dst
|
||||
}
|
||||
|
||||
/// The reverse: a web replica written into a fresh SQLite database kv-ffi can open.
|
||||
fn web_to_sqlite(src: &WebStore, dst: &SqliteStore) {
|
||||
for key in ["account", "server", "session"] {
|
||||
if let Some(v) = src.get_meta(key).unwrap() {
|
||||
dst.put_meta(key, &v).unwrap();
|
||||
}
|
||||
}
|
||||
for v in src.vaults().unwrap() {
|
||||
dst.put_vault(&v).unwrap();
|
||||
for item in src.list(v.vault_id).unwrap() {
|
||||
dst.put(v.vault_id, &item).unwrap();
|
||||
for h in src.history(v.vault_id, item.record.item_id).unwrap().into_iter().rev() {
|
||||
dst.push_history(v.vault_id, item.record.item_id, &h).unwrap();
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn web_store(f: &Fixture) -> WebStore {
|
||||
WebStore::load(f.entries.iter().map(|(k, v)| (k.clone(), v.as_bytes().to_vec()))).unwrap()
|
||||
}
|
||||
|
||||
/// Items as kv-ffi sees them, sorted by title, `modified_at` included.
|
||||
fn read_with_kv_ffi(f: &Fixture) -> Vec<ItemDto> {
|
||||
let dir = tempfile::tempdir().unwrap();
|
||||
let db = dir.path().join("vault.db");
|
||||
web_to_sqlite(&web_store(f), &SqliteStore::open(&db).unwrap());
|
||||
let core = kv_ffi::VaultCore::open(db.to_string_lossy().into_owned()).unwrap();
|
||||
assert!(core.is_locked());
|
||||
core.unlock(f.master_password.clone(), f.secret_key.clone()).unwrap();
|
||||
let mut out: Vec<ItemDto> = core
|
||||
.list_items()
|
||||
.unwrap()
|
||||
.into_iter()
|
||||
.map(|s| {
|
||||
let d = core.get_item(s.id).unwrap();
|
||||
ItemDto {
|
||||
id: d.id,
|
||||
kind: match d.kind {
|
||||
kv_ffi::ItemKindDto::Login => ItemKindDto::Login,
|
||||
kv_ffi::ItemKindDto::SecureNote => ItemKindDto::SecureNote,
|
||||
kv_ffi::ItemKindDto::Card => ItemKindDto::Card,
|
||||
kv_ffi::ItemKindDto::Identity => ItemKindDto::Identity,
|
||||
},
|
||||
title: d.title,
|
||||
username: d.username,
|
||||
password: d.password,
|
||||
urls: d.urls,
|
||||
totp: d.totp,
|
||||
notes: d.notes,
|
||||
favorite: d.favorite,
|
||||
tags: d.tags,
|
||||
modified_at: d.modified_at,
|
||||
}
|
||||
})
|
||||
.collect();
|
||||
out.sort_by(|a, b| a.title.cmp(&b.title));
|
||||
out
|
||||
}
|
||||
|
||||
fn read_with_web_core(f: &Fixture) -> Vec<ItemDto> {
|
||||
let core = WebCore::new(Arc::new(web_store(f))).unwrap();
|
||||
core.unlock(&f.master_password, &f.secret_key).unwrap();
|
||||
let mut out: Vec<ItemDto> = core.list_items().unwrap().into_iter().map(|s| core.get_item(&s.id).unwrap()).collect();
|
||||
out.sort_by(|a, b| a.title.cmp(&b.title));
|
||||
out
|
||||
}
|
||||
|
||||
fn ffi_item(title: &str, kind: kv_ffi::ItemKindDto) -> kv_ffi::ItemDto {
|
||||
kv_ffi::ItemDto {
|
||||
id: String::new(),
|
||||
kind,
|
||||
title: title.into(),
|
||||
username: None,
|
||||
password: None,
|
||||
urls: vec![],
|
||||
totp: None,
|
||||
notes: None,
|
||||
favorite: false,
|
||||
tags: vec![],
|
||||
modified_at: 0,
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
#[ignore = "writes tests/fixtures/native-vault.json"]
|
||||
fn generate_native_fixture() {
|
||||
let dir = tempfile::tempdir().unwrap();
|
||||
let db = dir.path().join("vault.db");
|
||||
let pw = "native fixture master password";
|
||||
let core = kv_ffi::VaultCore::open(db.to_string_lossy().into_owned()).unwrap();
|
||||
let secret_key = core.create_account(pw.into()).unwrap();
|
||||
let bank = core
|
||||
.save_item(kv_ffi::ItemDto {
|
||||
username: Some("[email protected]".into()),
|
||||
password: Some("first password".into()),
|
||||
urls: vec!["https://bank.example.com/login".into()],
|
||||
totp: Some("otpauth://totp/Bank:ana?secret=JBSWY3DPEHPK3PXP&issuer=Bank".into()),
|
||||
tags: vec!["finance".into(), "ünïcode ✓".into()],
|
||||
favorite: true,
|
||||
..ffi_item("Bank", kv_ffi::ItemKindDto::Login)
|
||||
})
|
||||
.unwrap();
|
||||
// A second version, so the history entry crosses over too.
|
||||
let mut edit = core.get_item(bank.clone()).unwrap();
|
||||
edit.password = Some("second password".into());
|
||||
core.save_item(edit).unwrap();
|
||||
core.save_item(kv_ffi::ItemDto {
|
||||
notes: Some("multi\nline\nnote with emoji 🔐".into()),
|
||||
..ffi_item("Recovery codes", kv_ffi::ItemKindDto::SecureNote)
|
||||
})
|
||||
.unwrap();
|
||||
drop(core);
|
||||
|
||||
let web = sqlite_to_web(&SqliteStore::open(&db).unwrap());
|
||||
let mut f = Fixture {
|
||||
master_password: pw.into(),
|
||||
secret_key,
|
||||
entries: web.entries().into_iter().map(|(k, v)| (k, String::from_utf8(v).unwrap())).collect(),
|
||||
items: vec![],
|
||||
};
|
||||
f.items = read_with_kv_ffi(&f);
|
||||
std::fs::write(path("native-vault.json"), serde_json::to_string_pretty(&f).unwrap() + "\n").unwrap();
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn native_fixture_opens_in_kv_ffi_and_web_core() {
|
||||
let f = load("native-vault.json");
|
||||
assert_eq!(f.items.len(), 2);
|
||||
assert_eq!(read_with_kv_ffi(&f), f.items);
|
||||
assert_eq!(read_with_web_core(&f), f.items);
|
||||
// History made by kv-ffi decrypts in the web core.
|
||||
let core = WebCore::new(Arc::new(web_store(&f))).unwrap();
|
||||
core.unlock(&f.master_password, &f.secret_key).unwrap();
|
||||
let bank = f.items.iter().find(|i| i.title == "Bank").unwrap();
|
||||
let h = core.item_history(&bank.id).unwrap();
|
||||
assert_eq!(h[0].password.as_deref(), Some("first password"));
|
||||
}
|
||||
|
||||
/// Made by the wasm build (`node crates/kv-wasm/js/smoke.mjs`), opened by kv-ffi.
|
||||
#[test]
|
||||
fn wasm_fixture_opens_in_kv_ffi() {
|
||||
let f = load("wasm-vault.json");
|
||||
assert!(!f.items.is_empty());
|
||||
assert_eq!(read_with_kv_ffi(&f), f.items);
|
||||
// kv-ffi can keep using the vault: edit, and the web core reads the edit back.
|
||||
let dir = tempfile::tempdir().unwrap();
|
||||
let db = dir.path().join("vault.db");
|
||||
web_to_sqlite(&web_store(&f), &SqliteStore::open(&db).unwrap());
|
||||
let core = kv_ffi::VaultCore::open(db.to_string_lossy().into_owned()).unwrap();
|
||||
core.unlock(f.master_password.clone(), f.secret_key.clone()).unwrap();
|
||||
let first = core.list_items().unwrap().remove(0);
|
||||
let mut d = core.get_item(first.id.clone()).unwrap();
|
||||
d.notes = Some("edited natively".into());
|
||||
core.save_item(d).unwrap();
|
||||
drop(core);
|
||||
let back = sqlite_to_web(&SqliteStore::open(&db).unwrap());
|
||||
let web = WebCore::new(Arc::new(back)).unwrap();
|
||||
web.unlock(&f.master_password, &f.secret_key).unwrap();
|
||||
assert_eq!(web.get_item(&first.id).unwrap().notes.as_deref(), Some("edited natively"));
|
||||
}
|
||||
+44
@@ -0,0 +1,44 @@
|
||||
{
|
||||
"masterPassword": "native fixture master password",
|
||||
"secretKey": "K1-0JSWZQ-71PJT-HDDP5-W5R5A-S543N-A",
|
||||
"entries": {
|
||||
"history/09bc9fac-9097-49d3-a027-ac1b12289315/61170456-ac16-4423-b2b6-0bf2547a7b0f": "[{\"revision\":0,\"wrapped_item_key\":{\"v\":1,\"alg\":\"xc20p\",\"kid\":\"5cf1e174-64f9-4ad0-ac4c-c68b0841f392\",\"n\":\"h0g0YkMpqSAq0_f80dzNsmMhxVoxr6EU\",\"ct\":\"vKZuEoDiQXFb0psz9lUlywflLhy9sL0brz8Iddnf2s71nONknLLxL06GOfTsBaOTvV4zZdiiU9QuqbFoT1Uqvw\"},\"payload\":{\"v\":1,\"alg\":\"xc20p\",\"kid\":\"6a2268d0-14f7-4d7b-86ae-afe2b858825b\",\"n\":\"RCD8IcJMCc7Mxy9yfnYtlekRzb9rZlRe\",\"ct\":\"gdTqTakbO2cvUazkIRPP80zkI6giI-lSX4fOGfgKtVDpT8Wu5b2NU0CkynEYVLIgHKHzMDhGvnuLouoz8liuAXGXJr-1o1HoByY5_-UYhuhMiNKDg82p6cRqBgYgUTUo0_W9BdJv33EDUPEMFT44QeqoWlkzDAmN0ROfpH0NGoxyrt31vNlwIiWI_2ddYadLtwT3oMPWvkkZhNBDtoPnkcVYbGrU74ZPBqQkODPuqXSJqDUKP_NPXFAsG--LEdmdNJ5phBoENTg7NpADtSbnB3chYcvJQW73_rm3vrG-zk78-K7PmZvjB9eMO3X8Q5WpHbA_q6qwikTj6as2QGz2UzjqmPczMEVRdTT26gjwTSc-xX8UOibJUYpZQU8Bl3tKEEQwcp8nvf8Fa_9WhGl1vRGSnjzWv_csvzDxmLDs77BsXCDZzef4f77Xk9LTUo2n0XiNUDB-P4rUHvUtcaEma9CB6fugPwyUaXNXToFnQl397mTGSlzRna895sbarasr0bbY4zS5mlpS2aaqGRDzQgMRON4NHdYXC6T4cqJfZ2pUHRCQqAkad2PgySCc9HzazCC66Boxf8YGQybeDSLQtR0O0tJcEoCcVq7Hepk6ZGru-2O_BqIqLFXDEx7eFmwlcwiMSy0CzgxR20S5ZmO2KZVlL6lcXPx_Rux0XfP4MBM2pSj9aUYX_7PCOlRc9iUK\"},\"saved_at\":1792248470}]",
|
||||
"item/09bc9fac-9097-49d3-a027-ac1b12289315/61170456-ac16-4423-b2b6-0bf2547a7b0f": "{\"record\":{\"item_id\":\"61170456-ac16-4423-b2b6-0bf2547a7b0f\",\"revision\":0,\"deleted\":false,\"wrapped_item_key\":{\"v\":1,\"alg\":\"xc20p\",\"kid\":\"5cf1e174-64f9-4ad0-ac4c-c68b0841f392\",\"n\":\"dQEJouOYVrAGZo_gjw7n-ueKZDAM9Vcg\",\"ct\":\"jLVb2Qv0HKtvFePieovP95tQ2ZYfmiIm_N-vo5oP9LAdJy69TAi7R1iFotHOqC0yKgjhfoug8uzcAnWZgyhBtA\"},\"payload\":{\"v\":1,\"alg\":\"xc20p\",\"kid\":\"57c966d8-d411-439b-a10a-87c109b7b242\",\"n\":\"voEowviUuPIwSdAHvXWwX63s6uVPIZQ2\",\"ct\":\"qxvaBMYpqGYe1uVH-Ac3DtWGBE6JJJ3eg_A1Zv04cFf-mTqHlSSEc3hcW3HVsqp3urIZMMgndY3diI-nDIlWo1I7tlREPfYt4k6OomgOYjKoaQsN7WTgoBZtbn8L4CvUWSq6EzfcTWaSFYkUZPVn1qu2ftLjnduDEURF_-TvFmjXA1cRzgwDShKi9j_tFatS-JUYLxKvAKEej9Nw0tGnfY2vyDDBr4x86zWRoHXsnXuNDSpPVU9kkLkG12QM3TiJ6VeM2ccMerFr5w0hZ6v3EBhZNSJbRDYRmh-j9Atd1t1Ho4i0XiThCOeW4bK_kUG4M_rnDPDEAb0os06S8dzrVZWkHoWPXydIwxdtk8RqZLWZOja_o57uyNB41S30WCXhUR6H-rXsbc6sOlRKsh_SHs4z6D5q947nFGA7wB9HrXa-cr1t1hLMwp11QzSksDKtg23Kks0wT9yaOrPQRK_IVE-8XLvOU9HBjXsJGCXkIBBdghGeFQDQNPNizPWdlIGgdHjLaqmdiXbTYBieASmKosyYO9s--edXavhq3pqgvZd40pk9p5SZJLxmDgZ13_QSXDPnvuxdOa-_w3FvfV8pSjaE8fh2nRYCpxzdU1PFynXu7zgRBMcEVedz9bkKryXTO6OAt0kht6XdbGTTLzQm5ySy2UrRAu1jkG8wLAvvFywILpdkbPabdmHv6G1U7b4w\"},\"updated_by\":\"00000000-0000-0000-0000-000000000000\",\"updated_at\":\"\"},\"pending\":true,\"base\":null}",
|
||||
"item/09bc9fac-9097-49d3-a027-ac1b12289315/8ef000ff-0408-4112-bde7-d71ac42ab11a": "{\"record\":{\"item_id\":\"8ef000ff-0408-4112-bde7-d71ac42ab11a\",\"revision\":0,\"deleted\":false,\"wrapped_item_key\":{\"v\":1,\"alg\":\"xc20p\",\"kid\":\"5cf1e174-64f9-4ad0-ac4c-c68b0841f392\",\"n\":\"Pw3nJki2b8OV_9Y3yOJ7-i_W501biHv7\",\"ct\":\"4kUtn9S1Ix6DZwrlTovYlEiROW_D_GE4QzJLeKdKdqEedRK-76zDRyYxS6lw5uQ-a__5F2vtZdd08wiu7OqxtA\"},\"payload\":{\"v\":1,\"alg\":\"xc20p\",\"kid\":\"017484e0-cb67-4626-9e44-4ff105381284\",\"n\":\"gDpm8Xwv5WuM1t_3_u2yVFASzvobN5Rv\",\"ct\":\"XheIvhovpfi2MDWGef7qJnpNOvYZ1c3_4nd5l9PYsX_PW0gX3Q9vVZlC0BX-TAfTAK40oW7_yWVnFriwtzZM3Zt-AC3LxlD7JLU5O295tEJ9BXXsdF_2dcHtgzY6pPoFSmptHYnO9tKaLpqmpmhVIVzEvCWYhsYOWemPIRHtfd1SDE3n_akDQT2XiyhUpAfGmZzXmfY80ws0w3ySAJCjnLKfUVGxPVdKFQfYZQwpLqILSB4mT6kytv69CkJJNS092p7d5_k5xDOTNCs4jV1-G7QTR9KqZVflXl5rRai8OwVRMFjXSjhplnOkuULKRNlMYlNsuT4gYMTYCFLr2REOTJ-TNCQIKf9sEFJCHD7i1ei04Gn5lPWP-qH1z4ITfVWcckQ1Ua6ZVfLG9FPMc8RQ9m4ufwI7TxVIb3hPNIYG69Jhrso7NDu8HOCvmIBbl-THM7baXn2A7qDqwpfqPK5BFffEWAEQxDBaYshl0vhmgJic92oGkutJkKhgzrKT2CxC9PC68EkqW8WzPaM8e4S4KnN6TolVKOhE0_x2aciZdvhczA3XuKbue4_dTmZwJMalR_4UxYye6m2lEcfl0q49cLQvF6aQKcZ4dzhMahIlSBypp1E7q2zDh3pImWWfJ72h7wfz6oRaEVW34ZhZ4ig-tclbsaqM5y2e_JzSktZNn0886Y77KX35ZCFau2Lb_BhO\"},\"updated_by\":\"00000000-0000-0000-0000-000000000000\",\"updated_at\":\"\"},\"pending\":true,\"base\":null}",
|
||||
"meta/account": "{\"account_id\":\"a714f389-eb58-439a-8279-1ed4a4979c74\",\"kdf\":{\"alg\":\"argon2id\",\"m_kib\":65536,\"t\":3,\"p\":4,\"salt\":\"uEVvTFReXc97eGv3TnA62Q\"},\"wrapped_keyset\":{\"v\":1,\"alg\":\"xc20p\",\"kid\":\"00000000-0000-0000-0000-000000000000\",\"n\":\"mzoSaCwceqqTDxSMxKe0zmoARPB66xoJ\",\"ct\":\"fwR718RcRj2i7sFLmAmR6AdMzpXCzYMRzS7lR1ql8m-zcsafEpLxYTSlX6Q3spJCZC0OhIUMPpymrUwtl2dBNhQilZ82ApJlUzG83InrumKJyUmNXZ0p6-HSnaximaJh\"},\"enc_pk\":\"gXPhqX70l2c99hXgK1mCAS-h1NB95RDwm_DYBH-55WQ\",\"sig_pk\":\"Yi6sDydfkyYKc6mWA_BwLxrguV8mZuJo62ojenlWRQ4\",\"primary_vault\":\"09bc9fac-9097-49d3-a027-ac1b12289315\",\"local_unlock\":null}",
|
||||
"vault/09bc9fac-9097-49d3-a027-ac1b12289315": "{\"order\":0,\"vault_id\":\"09bc9fac-9097-49d3-a027-ac1b12289315\",\"sealed_key\":{\"v\":1,\"alg\":\"hpke-x25519-chacha\",\"kid\":\"c4fcad32-d960-43ea-b6e3-12ba55446630\",\"ct\":\"khzLBMbHxTLB6I34O8nLMzAYnMDGlUtjfEqL-5JpG6MCldyAYXaAXQT3q2TY-8seE7zTMxyuKuxFtPxZi7n_PA\",\"enc\":\"gxEK0S49G9vdZfveY6f8rdGhLFzZpusugGVgnpVcqiw\"},\"meta\":{\"v\":1,\"alg\":\"xc20p\",\"kid\":\"5cf1e174-64f9-4ad0-ac4c-c68b0841f392\",\"n\":\"zOXWfjwdhCGkYsxT2V1N7y4PJ8tLJJek\",\"ct\":\"YIvV9YIOO4HrYMYhrvSQesV3LO40pgzoDlT8VskS2wOGntM\"},\"cursor\":0,\"manifest_version\":0,\"manifest_hash\":null,\"integrity_alarm\":false}"
|
||||
},
|
||||
"items": [
|
||||
{
|
||||
"id": "61170456-ac16-4423-b2b6-0bf2547a7b0f",
|
||||
"kind": "login",
|
||||
"title": "Bank",
|
||||
"username": "[email protected]",
|
||||
"password": "second password",
|
||||
"urls": [
|
||||
"https://bank.example.com/login"
|
||||
],
|
||||
"totp": "otpauth://totp/Bank:ana?secret=JBSWY3DPEHPK3PXP&issuer=Bank",
|
||||
"notes": null,
|
||||
"favorite": true,
|
||||
"tags": [
|
||||
"finance",
|
||||
"ünïcode ✓"
|
||||
],
|
||||
"modifiedAt": 1792248471
|
||||
},
|
||||
{
|
||||
"id": "8ef000ff-0408-4112-bde7-d71ac42ab11a",
|
||||
"kind": "secureNote",
|
||||
"title": "Recovery codes",
|
||||
"username": null,
|
||||
"password": null,
|
||||
"urls": [],
|
||||
"totp": null,
|
||||
"notes": "multi\nline\nnote with emoji 🔐",
|
||||
"favorite": false,
|
||||
"tags": [],
|
||||
"modifiedAt": 1792248470
|
||||
}
|
||||
]
|
||||
}
|
||||
+44
@@ -0,0 +1,44 @@
|
||||
{
|
||||
"masterPassword": "wasm fixture master password",
|
||||
"secretKey": "K1-6GNZNR-FQ720-HQEA6-BANYY-9P3JB-3",
|
||||
"entries": {
|
||||
"history/77bf9af1-8425-4f3a-b020-b50398e84c4a/bde76585-2df5-4a60-9c7c-1da5388ea1d7": "[{\"revision\":0,\"wrapped_item_key\":{\"v\":1,\"alg\":\"xc20p\",\"kid\":\"87860105-0dae-41e6-acdc-de5d27328150\",\"n\":\"zAbO4kpoXLfeUOlIxvo42N9QB7X6ej2b\",\"ct\":\"OYerG-HYJvp-BFirouL0gH5PLF1qM9XjZ3F_VCFy4ALjwypl1i2KvlXAItbD5R7xsAMFihKB-J8GR1iAyRolkQ\"},\"payload\":{\"v\":1,\"alg\":\"xc20p\",\"kid\":\"6b367eeb-379c-47b7-93bc-b8d13834e919\",\"n\":\"yGKnW3p62ZbvGsKfgxHZH1lVO2Y_b5mZ\",\"ct\":\"6m52soFGxtwrG4vWvHVsH3sMrYvVxk2rV0KzYgfnLMYIOnUmo9X17L8l3pGfYI5KlVGr3g1mCdUjE8fbzSMsP1WrAG9X4jWwOvQztnvlAWGl58g2PL-tXmdmEnmAHvymkDA6BED5LSDjxaDbLxROn_uRw0rfYr-cCF-ydRrrRreqSgSeEQFJyXP2JZg5pboV8B3vxiDVxz7-k9ROJ4Chl2D4Ob5sFMdzZTr7XDLDgAR0QFQyZb9SweyRM4kcFkbSAsb4tWms1V8nPlGl4cJcmT5GMgxpO4JRQP4BTt6Ansbxx7TsRw4tfE6aRUyhDSao_sbPFMveTqCeAXeJ6y0r98PzfIWaXHNyizHrBSG3dvbU-R47LFh6f-lFwNrACmrGynmCamsZq1T8poTI9Uij4BFYxGZfOS1wYu1r9YgQJ0DgLqyQLgiMxt-eryvyia9jY0cgSCPubmcMiZxRCeIA4kYeD2xwzAl_x2JYOZCsjMUBtxefunmTMLVb-kAijWH7fVUi3BVl_ZHGKiRWFA4za7Cw1sdEVKyy9vNhWnyIDCTW3kCpPKezu8yu1LRKayKbUQtUWDPMwBglX1nAYEUC87QdO7YYpTMh6-zIg2qqxd4ZXXNJ6p8dKBNFC9N-Okkkt-OA3Rq8UAIyjkC6oWtF9G7GcjiVnuH1ZV-KFZDk8KXoCid4D8edWtSpBdnMXilv\"},\"saved_at\":1792248736}]",
|
||||
"item/77bf9af1-8425-4f3a-b020-b50398e84c4a/bde76585-2df5-4a60-9c7c-1da5388ea1d7": "{\"record\":{\"item_id\":\"bde76585-2df5-4a60-9c7c-1da5388ea1d7\",\"revision\":0,\"deleted\":false,\"wrapped_item_key\":{\"v\":1,\"alg\":\"xc20p\",\"kid\":\"87860105-0dae-41e6-acdc-de5d27328150\",\"n\":\"e0asucuWiR5PPnhaedwkFrsx298veqn0\",\"ct\":\"70o8Z96lsKyHXqT5XYBSzhN97uxqDkwwXnQAfMOchm0k2Quy0BoSZjXD2x5onL1clO-NAnKYq74ClMaVJiVJrA\"},\"payload\":{\"v\":1,\"alg\":\"xc20p\",\"kid\":\"39f2b3d0-0aa4-4a5d-b9fa-6f33f6317d36\",\"n\":\"d44oekvGNJ0vfTv1chcHKfG5x8CybPpI\",\"ct\":\"biJng_k6ficUap0Ah_0QrfOq_lRyUxd8SL2kBeOB1r0YLiC4yCh0zsiiuC3xm-kN6q3mK0OFONnxv0egWY2dR8Krt3RyADRQWsyZki34UKoB9B39AQwWGb2NsAZb32ZK7MHiuP9aIk2-eXpEv03xLIfj46W_RDjhxfeYsuoq4ecN4XqkeZ_spafr6FBM6j_wGP_XJ-7D9QKkN2p7yYdu7a2qJThNchHL-Fq1kM4lLDqbUl4_ZGFqvNVIqt2t8BwiayfEpjFM6thItoTpcqOzuiBJKsdD9EwLCUnq-OIq-luK1BIvXb-LESHrYALEVCFMDVtZACyEe40m1DrOsPkX42RYEDTY23ac2rH5uHRw6U_TdRJehAcf1SXkr_Tx63LLSDyIpsT8K3HG4hxtrQuK_9glNkOZdTe4zNNngcAb9MpYmj-xvGB-znR7O77L_XbuuljaWg2kT20tJZMI7TQJElETdv9SJW36nr1sg-p6kqCYta-cGWBffBAjxveEUEBBahvldmQwtZAvUMSGsl15MCFWxD6kuKlJ_pXgkqZGIiEs7ZX4LPKMocowp5kwOfb4YZyksGt3qUGAy6Ums4brwuzQLaxsbYdS8kDh_lYa2_MZRRmjxag64rD39tyEvNyo7K-XWA7Kq9c8HklR45Gllv2SEy1OJYlQ6XLpQOw6AY3Vfw2tVuHuroQjmqYR6p95\"},\"updated_by\":\"5e1a2bb8-96bf-4f1b-bc78-d42e64242324\",\"updated_at\":\"\"},\"pending\":true,\"base\":null}",
|
||||
"item/77bf9af1-8425-4f3a-b020-b50398e84c4a/c359f9cb-b6bd-4dc1-ba4d-65f20c8c09f2": "{\"record\":{\"item_id\":\"c359f9cb-b6bd-4dc1-ba4d-65f20c8c09f2\",\"revision\":0,\"deleted\":false,\"wrapped_item_key\":{\"v\":1,\"alg\":\"xc20p\",\"kid\":\"87860105-0dae-41e6-acdc-de5d27328150\",\"n\":\"fFnoERKlD5iiHVnGSNd-VCs3lX4V8E94\",\"ct\":\"WyypQdTxHin3kuxANJiTfnp8FxkaDGFgcfvxD-pCykP5qJzT-wBRm4c98yRRb4dWWFxZzbKzGIOJdjVcOyrEXg\"},\"payload\":{\"v\":1,\"alg\":\"xc20p\",\"kid\":\"5176bdbf-e432-470b-a18e-5c5f9cb749cc\",\"n\":\"lnQE8ko6PV8IjPXxD8leSgafKzuw3knn\",\"ct\":\"Fl8dbTKVVU3wkKsL4Y_gFQGtrezYxTJOb4EvZozFZttElzw2ZGwYVOtVV8slBOrv91YriOkQNctbh1pUf_KqTuH39ApIFuLsCjIlDeLcUaUwmHG4_PiKsHYb1YRUeoVai-GeEqHBG2UKv3ZY2lW6afGmK0GqZ07d8cJfznN4dYjjwbG_unmzLSK1W93nXe2qytd4VgZDWNMSEpBgY0FLiuQ2dA7wHNp06BiNFjqlfsQX6996jT5L1x59AYgWEzj8XseMG0qtS538R5441f3g5v1CF69wkrZFvwhiVozYz5E9An35dGFCQ_KJ2OvGiGieg0skInBI9t1BUhvQvn1qqh4f_YXa7G-DZSkUQHWBgU2EBvU5imuXhkc3BORJhEOVoKWIppMZAG_NwB6shsexZt2rKDGywS7NXKGZOc0G0PQE2kBoKNiUrjccd5SEfCiB4IQOsIUO4M1OB2RQgXbHUgrv0qsv1Vc99-ilxdmTcKcgM0SN78vbWWanYjFbMioye1jDxV-97z8qoQIGdTF4dX4nS3d1er0CEYnM4KAgFqkGOTIVO-JdsL55ZN5SmlIDU0Jf47K6WyDtB5dTTInSB_p32uMybZRyl26Scnu98xrGnN7TpsedtnolHSpwlRnlOLN0L0CTuil0cBrWM5VmMpWCLk9GZ1j15REiCceLK_tAn92OaVS8qN37OpCYyTO0\"},\"updated_by\":\"5e1a2bb8-96bf-4f1b-bc78-d42e64242324\",\"updated_at\":\"\"},\"pending\":true,\"base\":null}",
|
||||
"meta/account": "{\"account_id\":\"129c6f80-0158-4a13-989c-29a5b2abe694\",\"kdf\":{\"alg\":\"argon2id\",\"m_kib\":65536,\"t\":3,\"p\":4,\"salt\":\"oqb0I2509nnbJ0Qq5TgPcw\"},\"wrapped_keyset\":{\"v\":1,\"alg\":\"xc20p\",\"kid\":\"00000000-0000-0000-0000-000000000000\",\"n\":\"hq-x85RmOPjOt1wvnFLud607znrd1mCW\",\"ct\":\"io9yERv5aZjOVHeSTIOoADDuQk3uLIFy1dmKaJxlB1NGubg2c_1p8TX9QKc7oNHUjXAhr00Sb2lGsSH-u3wQxE3SO_bFsn8mMrF1T32Tn83O3qfr8AYGZY5M8Zxe6k4k\"},\"enc_pk\":\"kiIK8-JcITBogJ_gaKoi_A4crq4MdHe0VKXEn9JPvgQ\",\"sig_pk\":\"KTg9NI4TVqD1j7jI-QrEFgtXB9LQCAbShUUUGEVZXg0\",\"primary_vault\":\"77bf9af1-8425-4f3a-b020-b50398e84c4a\"}",
|
||||
"vault/77bf9af1-8425-4f3a-b020-b50398e84c4a": "{\"order\":0,\"vault_id\":\"77bf9af1-8425-4f3a-b020-b50398e84c4a\",\"sealed_key\":{\"v\":1,\"alg\":\"hpke-x25519-chacha\",\"kid\":\"425c4baf-3212-4051-8ac6-33c67bbffbbc\",\"ct\":\"Tqb_MLv36AugKkt62YqO6LnPMszQZAR1bfsX3795SvUYuX26lP6t7bQKGLrlOk7rtqpvaWBLwfZI6fAjfl1rkA\",\"enc\":\"kZFb9JM0km6_lhgsyt1kATs8kEOTRQ6n-3AS62m9KFQ\"},\"meta\":{\"v\":1,\"alg\":\"xc20p\",\"kid\":\"87860105-0dae-41e6-acdc-de5d27328150\",\"n\":\"WmSg8g8d6XQCfMMlxfzuaHsYOd_P1ERZ\",\"ct\":\"8cGuse-26ebJBQ_ormfZA3vvVPE9zFImZtfpZYVTga7LBMI\"},\"cursor\":0,\"manifest_version\":0,\"manifest_hash\":null,\"integrity_alarm\":false}"
|
||||
},
|
||||
"items": [
|
||||
{
|
||||
"id": "bde76585-2df5-4a60-9c7c-1da5388ea1d7",
|
||||
"kind": "login",
|
||||
"title": "Bank",
|
||||
"username": "[email protected]",
|
||||
"password": "second password",
|
||||
"urls": [
|
||||
"https://example.com/login"
|
||||
],
|
||||
"totp": "otpauth://totp/Bank:ana?secret=JBSWY3DPEHPK3PXP&issuer=Bank",
|
||||
"notes": null,
|
||||
"favorite": true,
|
||||
"tags": [
|
||||
"finance",
|
||||
"ünïcode ✓"
|
||||
],
|
||||
"modifiedAt": 1792248737
|
||||
},
|
||||
{
|
||||
"id": "c359f9cb-b6bd-4dc1-ba4d-65f20c8c09f2",
|
||||
"kind": "secureNote",
|
||||
"title": "Recovery codes",
|
||||
"username": null,
|
||||
"password": null,
|
||||
"urls": [],
|
||||
"totp": null,
|
||||
"notes": "multi\nline 🔐",
|
||||
"favorite": false,
|
||||
"tags": [],
|
||||
"modifiedAt": 1792248736
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -0,0 +1,165 @@
|
||||
//! The web core (compiled natively here, same code as in the browser) syncing with an app device
|
||||
//! (kv-ffi) through kv-sync's in-memory test server: enrollment by approval, both directions of
|
||||
//! edits, deletes, and a restart from the persisted replica.
|
||||
#![cfg(not(target_arch = "wasm32"))]
|
||||
|
||||
use std::sync::Arc;
|
||||
|
||||
use kv_ffi::{DeviceSigner, PlatformDto};
|
||||
use kv_proto::Platform;
|
||||
use kv_sync::{
|
||||
signing::SoftwareSigner,
|
||||
test_server::{TestServer, SETUP_TOKEN},
|
||||
};
|
||||
use kv_wasm::{
|
||||
dto::{ItemDto, ItemKindDto, LoginResult},
|
||||
store::WebStore,
|
||||
CoreError, WebCore,
|
||||
};
|
||||
use uuid::Uuid;
|
||||
|
||||
const PW: &str = "a long master password";
|
||||
|
||||
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>, kv_ffi::CoreError> {
|
||||
kv_sync::DeviceSigner::sign(&self.0, &message).map_err(|e| kv_ffi::CoreError::Internal { msg: e.to_string() })
|
||||
}
|
||||
}
|
||||
|
||||
fn web_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,
|
||||
}
|
||||
}
|
||||
|
||||
fn web_core(store: Arc<WebStore>, url: &str, signer: Arc<SoftwareSigner>) -> WebCore {
|
||||
let core = WebCore::new(store).unwrap();
|
||||
core.configure_server(url, vec![]).unwrap();
|
||||
core.set_device_signer(signer, "Web vault", Platform::Macos);
|
||||
core
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn web_vault_enrolls_and_syncs_with_an_app_device() {
|
||||
let server = TestServer::start().await;
|
||||
|
||||
// The app (kv-ffi) is the first device.
|
||||
let app = kv_ffi::VaultCore::new_in_memory();
|
||||
let sk = app.create_account(PW.into()).unwrap();
|
||||
app.configure_server(server.url(), vec![]).unwrap();
|
||||
app.set_device_signer(Arc::new(Signer(SoftwareSigner::generate(Uuid::new_v4()))), "phone".into(), PlatformDto::Ios)
|
||||
.unwrap();
|
||||
app.register_account(SETUP_TOKEN.into()).await.unwrap();
|
||||
let mut on_app = kv_ffi::ItemDto {
|
||||
id: String::new(),
|
||||
kind: kv_ffi::ItemKindDto::Login,
|
||||
title: "Bank".into(),
|
||||
username: Some("me".into()),
|
||||
password: Some("from the app".into()),
|
||||
urls: vec![],
|
||||
totp: None,
|
||||
notes: None,
|
||||
favorite: false,
|
||||
tags: vec![],
|
||||
modified_at: 0,
|
||||
};
|
||||
let bank = app.save_item(on_app.clone()).unwrap();
|
||||
app.sync_now().await.unwrap();
|
||||
|
||||
// The browser logs in as a new device and waits for approval.
|
||||
server.state().require_approval = true;
|
||||
let store = Arc::new(WebStore::new());
|
||||
let signer = Arc::new(SoftwareSigner::generate(Uuid::new_v4()));
|
||||
let web = web_core(store.clone(), &server.url(), signer.clone());
|
||||
assert!(matches!(web.sync_now().await, Err(CoreError::NoAccount | CoreError::Locked)));
|
||||
assert_eq!(web.login(PW, &sk, None).await.unwrap(), LoginResult::PendingApproval);
|
||||
assert!(!web.has_account());
|
||||
let pending = app.list_devices().await.unwrap().into_iter().find(|d| !d.approved).unwrap();
|
||||
assert_eq!(pending.key_fingerprint, web.device_key_fingerprint().unwrap());
|
||||
app.approve_device(pending.device_id).await.unwrap();
|
||||
assert_eq!(web.login(PW, &sk, None).await.unwrap(), LoginResult::Ok);
|
||||
let r = web.sync_now().await.unwrap();
|
||||
assert_eq!((r.pulled, r.vaults_synced), (1, 1));
|
||||
assert_eq!(web.get_item(&bank).unwrap().password.as_deref(), Some("from the app"));
|
||||
|
||||
// Browser edit and new item reach the app.
|
||||
let mut edit = web.get_item(&bank).unwrap();
|
||||
edit.notes = Some("edited in the browser".into());
|
||||
web.save_item(edit).unwrap();
|
||||
let new_id = web.save_item(web_item("Mail", "from the web")).unwrap();
|
||||
assert_eq!(web.sync_now().await.unwrap().pushed, 2);
|
||||
app.sync_now().await.unwrap();
|
||||
assert_eq!(app.get_item(bank.clone()).unwrap().notes.as_deref(), Some("edited in the browser"));
|
||||
assert_eq!(app.get_item(new_id.clone()).unwrap().password.as_deref(), Some("from the web"));
|
||||
|
||||
// Concurrent edits of different fields merge.
|
||||
on_app = app.get_item(bank.clone()).unwrap();
|
||||
on_app.password = Some("rotated on the app".into());
|
||||
app.save_item(on_app).unwrap();
|
||||
let mut edit = web.get_item(&bank).unwrap();
|
||||
edit.tags = vec!["web".into()];
|
||||
web.save_item(edit).unwrap();
|
||||
app.sync_now().await.unwrap();
|
||||
let r = web.sync_now().await.unwrap();
|
||||
assert_eq!(r.merged, 1);
|
||||
app.sync_now().await.unwrap();
|
||||
for item in [web.get_item(&bank).unwrap(), {
|
||||
let d = app.get_item(bank.clone()).unwrap();
|
||||
ItemDto { password: d.password, tags: d.tags, ..web.get_item(&bank).unwrap() }
|
||||
}] {
|
||||
assert_eq!(item.password.as_deref(), Some("rotated on the app"));
|
||||
assert_eq!(item.tags, vec!["web".to_string()]);
|
||||
}
|
||||
|
||||
// Delete in the browser; the app sees it.
|
||||
web.delete_item(&new_id).unwrap();
|
||||
web.sync_now().await.unwrap();
|
||||
app.sync_now().await.unwrap();
|
||||
assert!(matches!(app.get_item(new_id), Err(kv_ffi::CoreError::NotFound)));
|
||||
|
||||
// A page reload: the replica comes back from its persisted entries, locked, session kept.
|
||||
let reloaded = Arc::new(WebStore::load(store.entries()).unwrap());
|
||||
let web = web_core(reloaded, &server.url(), signer);
|
||||
assert!(web.has_account() && web.is_locked() && web.is_logged_in());
|
||||
assert!(matches!(web.list_items(), Err(CoreError::Locked)));
|
||||
web.unlock(PW, &sk).unwrap();
|
||||
assert_eq!(web.sync_now().await.unwrap().pulled, 0);
|
||||
assert_eq!(web.list_items().unwrap().len(), 1);
|
||||
|
||||
// Revoked by the app: the browser can no longer sync.
|
||||
let me = web.device_id().unwrap().to_string();
|
||||
app.revoke_device(me).await.unwrap();
|
||||
assert!(web.sync_now().await.is_err());
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn concurrent_sync_is_refused_not_interleaved() {
|
||||
let server = TestServer::start().await;
|
||||
let web = web_core(Arc::new(WebStore::new()), &server.url(), Arc::new(SoftwareSigner::generate(Uuid::new_v4())));
|
||||
let sk = web.create_account(PW).unwrap();
|
||||
web.register_account(SETUP_TOKEN).await.unwrap();
|
||||
web.save_item(web_item("A", "1")).unwrap();
|
||||
let (a, b) = tokio::join!(web.sync_now(), web.sync_now());
|
||||
assert!(matches!((&a, &b), (Ok(_), Err(CoreError::Busy)) | (Err(CoreError::Busy), Ok(_))), "{a:?} {b:?}");
|
||||
assert!(web.sync_now().await.is_ok());
|
||||
web.lock();
|
||||
web.unlock(PW, &sk).unwrap();
|
||||
}
|
||||
Reference in New Issue
Block a user