- kv-proto: email on setup, AccountInfo.email/is_admin, ServerInfo.registration, admin DTOs. - kv-sync: prelogin by email or account id (setup QR / Emergency Kit), register with email, set_email, /v1/admin/* client calls. - kv-ffi: register_account(code, email), login(.., email), account_details, set_account_email, admin_create_invite. kv-wasm: the same plus the full admin API for the web vault. - Live test: two users on one server stay isolated; a new device signs in by email. Co-Authored-By: Claude Opus 5.5 <[email protected]>
1285 lines
51 KiB
Rust
1285 lines
51 KiB
Rust
//! 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;
|
|
//! * the Secret Key (for setup payloads, docs/10) is held only in memory while unlocked, never
|
|
//! persisted: every unlock on the web takes it anyway, so kv-ffi's sealed copy is not needed.
|
|
|
|
use std::sync::{
|
|
atomic::{AtomicBool, Ordering},
|
|
Arc, Mutex,
|
|
};
|
|
|
|
use base64::{engine::general_purpose::URL_SAFE_NO_PAD, Engine};
|
|
use kv_crypto::{
|
|
aad::{Aad, ObjectType},
|
|
aead::SymKey,
|
|
kdf,
|
|
keys::{open_item, seal_item, AccountKeyset, SealedItem},
|
|
pairing::{self, PairingKeypair},
|
|
random_bytes,
|
|
secret_key::SecretKey,
|
|
};
|
|
use kv_model::{generator, matching, totp::Totp, Item, ItemKind, Passkey};
|
|
use kv_proto::{b64, DeviceEnroll, Envelope, ItemRecord, JoinState as WireJoinState, KdfParams, Platform};
|
|
use kv_store::{HistoryEntry, LocalItem, Store, VaultRow};
|
|
use kv_sync::{
|
|
onboarding::{JoinQr, SetupPayload},
|
|
ApiClient, DeviceSigner, LoginOutcome, ServerConfig, SyncContext, SyncError,
|
|
};
|
|
use serde::{Deserialize, Serialize};
|
|
use sha2::{Digest, Sha256};
|
|
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` and the sealed `secret_key`), 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]>>,
|
|
/// The Secret Key, for setup payloads (Emergency Kit, "Add a device", scan-to-join). Set only
|
|
/// once an unlock or login proved it (the keyset unwrapped); dropped on `lock`, `logout` and any
|
|
/// failed unlock or login. Never persisted in any form, and no getter exposes it.
|
|
secret_key: Option<Zeroizing<[u8; 16]>>,
|
|
}
|
|
|
|
struct Device {
|
|
signer: Arc<dyn DeviceSigner>,
|
|
name: String,
|
|
platform: Platform,
|
|
}
|
|
|
|
/// Local state of an in-flight scan-to-join (same as kv-ffi's).
|
|
enum JoinState {
|
|
/// This browser is joining (it showed the QR).
|
|
Joining {
|
|
rendezvous_id: String,
|
|
read_token: String,
|
|
keypair: PairingKeypair,
|
|
qr_secret: Zeroizing<[u8; 32]>,
|
|
sender_pub: Option<Vec<u8>>,
|
|
},
|
|
/// This browser scanned a QR and said hello; waiting for the user to confirm the code.
|
|
Approving { qr: JoinQr, keypair: PairingKeypair, device: DeviceEnroll },
|
|
}
|
|
|
|
#[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,
|
|
/// In-flight scan-to-join, on either side (docs/10 §4).
|
|
join: Mutex<Option<JoinState>>,
|
|
}
|
|
|
|
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),
|
|
join: Mutex::new(None),
|
|
})
|
|
}
|
|
|
|
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),
|
|
secret_key: Some(Zeroizing::new(*secret_key.expose())),
|
|
});
|
|
Ok(secret_key.to_display().to_string())
|
|
}
|
|
|
|
pub fn unlock(&self, master_password: &str, secret_key: &str) -> Result<()> {
|
|
self.unlock_inner(master_password, secret_key).inspect_err(|_| self.forget_secret_key())
|
|
}
|
|
|
|
fn unlock_inner(&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),
|
|
secret_key: Some(Zeroizing::new(*sk.expose())),
|
|
});
|
|
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();
|
|
self.join_cancel();
|
|
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())
|
|
}
|
|
|
|
/// After `DeviceRevoked` (docs/03 "Removed devices"): forgets the session and the kept OPAQUE input, so that
|
|
/// the new identity the caller installs next (`set_device_signer`) is enrolled only by an explicit login.
|
|
/// Local vault data stays. The browser binding calls this and replaces the WebCrypto key; it then retries a
|
|
/// ticket login once.
|
|
pub fn forget_revoked_device(&self) -> Result<()> {
|
|
self.store.delete_meta(META_SESSION)?;
|
|
let mut inner = self.lock_inner();
|
|
inner.api = None;
|
|
if let Some(u) = inner.unlocked.as_mut() {
|
|
u.auth_input = None;
|
|
}
|
|
Ok(())
|
|
}
|
|
|
|
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,
|
|
registration: info.registration.map(registration_name),
|
|
})
|
|
}
|
|
|
|
pub async fn account_details(&self) -> Result<AccountDetailsDto> {
|
|
let info = self.api()?.account().await?;
|
|
Ok(AccountDetailsDto { email: info.email, is_admin: info.is_admin })
|
|
}
|
|
|
|
pub async fn set_account_email(&self, email: &str) -> Result<()> {
|
|
Ok(self.api()?.set_email(email).await?)
|
|
}
|
|
|
|
// ------------------------------------------------------------ admin (docs/03 "Accounts")
|
|
|
|
pub async fn admin_users(&self) -> Result<Vec<AdminUserDto>> {
|
|
Ok(self
|
|
.api()?
|
|
.admin_users()
|
|
.await?
|
|
.into_iter()
|
|
.map(|u| AdminUserDto {
|
|
account_id: u.account_id.to_string(),
|
|
email: u.email,
|
|
is_admin: u.is_admin,
|
|
disabled: u.disabled,
|
|
created_at: u.created_at,
|
|
last_seen: u.last_seen,
|
|
devices: u.devices,
|
|
items: u.items,
|
|
storage_bytes: u.storage_bytes,
|
|
})
|
|
.collect())
|
|
}
|
|
|
|
pub async fn admin_update_user(
|
|
&self,
|
|
account_id: &str,
|
|
disabled: Option<bool>,
|
|
is_admin: Option<bool>,
|
|
) -> Result<()> {
|
|
let update = kv_proto::AdminUserUpdate { disabled, is_admin };
|
|
Ok(self.api()?.admin_update_user(parse_id(account_id)?, &update).await?)
|
|
}
|
|
|
|
pub async fn admin_delete_user(&self, account_id: &str) -> Result<()> {
|
|
Ok(self.api()?.admin_delete_user(parse_id(account_id)?).await?)
|
|
}
|
|
|
|
pub async fn admin_invites(&self) -> Result<Vec<InviteDto>> {
|
|
Ok(self
|
|
.api()?
|
|
.admin_invites()
|
|
.await?
|
|
.into_iter()
|
|
.map(|i| InviteDto {
|
|
invite_id: i.invite_id.to_string(),
|
|
email: i.email,
|
|
created_at: i.created_at,
|
|
expires_at: i.expires_at,
|
|
used: i.used,
|
|
revoked: i.revoked,
|
|
})
|
|
.collect())
|
|
}
|
|
|
|
pub async fn admin_create_invite(&self, email: Option<String>, days: Option<u32>) -> Result<InviteCreatedDto> {
|
|
let c = self.api()?.admin_create_invite(&kv_proto::InviteCreate { email, days }).await?;
|
|
Ok(InviteCreatedDto { invite_id: c.invite_id.to_string(), code: c.code, expires_at: c.expires_at })
|
|
}
|
|
|
|
pub async fn admin_revoke_invite(&self, invite_id: &str) -> Result<()> {
|
|
Ok(self.api()?.admin_revoke_invite(parse_id(invite_id)?).await?)
|
|
}
|
|
|
|
pub async fn admin_settings(&self) -> Result<AdminSettingsDto> {
|
|
let s = self.api()?.admin_settings().await?;
|
|
Ok(AdminSettingsDto { registration: registration_name(s.registration), allowed_domains: s.allowed_domains })
|
|
}
|
|
|
|
pub async fn admin_put_settings(
|
|
&self,
|
|
registration: &str,
|
|
allowed_domains: Vec<String>,
|
|
) -> Result<AdminSettingsDto> {
|
|
let registration = match registration {
|
|
"open" => kv_proto::Registration::Open,
|
|
"invite" => kv_proto::Registration::Invite,
|
|
other => return Err(CoreError::Invalid { msg: format!("unknown sign-up mode {other}") }),
|
|
};
|
|
let s = self.api()?.admin_put_settings(&kv_proto::AdminSettings { registration, allowed_domains }).await?;
|
|
Ok(AdminSettingsDto { registration: registration_name(s.registration), allowed_domains: s.allowed_domains })
|
|
}
|
|
|
|
/// Registers the account: `setup_token` is the server's setup code (first account), an invite code, or empty
|
|
/// with open sign-up; `email` is required once the server has an account.
|
|
pub async fn register_account(&self, setup_token: &str, email: Option<String>) -> 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,
|
|
email.as_deref(),
|
|
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, 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>,
|
|
email: Option<String>,
|
|
) -> Result<LoginResult> {
|
|
let sk = SecretKey::parse(secret_key).inspect_err(|_| self.forget_secret_key())?;
|
|
self.login_inner(master_password, sk, totp, None, None, email).await
|
|
}
|
|
|
|
/// `login`, optionally presenting an enrollment ticket and checking the server holds `expect_account`.
|
|
async fn login_inner(
|
|
&self,
|
|
master_password: &str,
|
|
sk: SecretKey,
|
|
totp: Option<String>,
|
|
ticket: Option<String>,
|
|
expect_account: Option<Uuid>,
|
|
email: Option<String>,
|
|
) -> Result<LoginResult> {
|
|
let r = self.login_unchecked(master_password, sk, totp, ticket, expect_account, email).await;
|
|
r.inspect_err(|_| self.forget_secret_key())
|
|
}
|
|
|
|
async fn login_unchecked(
|
|
&self,
|
|
master_password: &str,
|
|
sk: SecretKey,
|
|
totp: Option<String>,
|
|
ticket: Option<String>,
|
|
expect_account: Option<Uuid>,
|
|
email: Option<String>,
|
|
) -> Result<LoginResult> {
|
|
let api = self.api()?;
|
|
let local = self.lock_inner().account.clone();
|
|
let (account_id, kdf_params) = match &local {
|
|
Some(a) => (a.account_id, a.kdf.clone()),
|
|
None => {
|
|
let by = match (expect_account, email.as_deref().map(str::trim).filter(|e| !e.is_empty())) {
|
|
(Some(id), _) => kv_sync::PreloginBy::Account(id),
|
|
(None, Some(e)) => kv_sync::PreloginBy::Email(e),
|
|
(None, None) => kv_sync::PreloginBy::OnlyAccount,
|
|
};
|
|
let pre = api.prelogin(by).await?;
|
|
(pre.account_id, pre.kdf)
|
|
}
|
|
};
|
|
if expect_account.is_some_and(|want| want != account_id) {
|
|
return Err(CoreError::Invalid { msg: "the server holds a different account than the setup code".into() });
|
|
}
|
|
// 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, ticket).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),
|
|
secret_key: Some(Zeroizing::new(*sk.expose())),
|
|
});
|
|
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, 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) and forgets the Secret Key.
|
|
pub async fn logout(&self) -> Result<()> {
|
|
self.forget_secret_key();
|
|
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()
|
|
}
|
|
|
|
// ------------------------------------------------------------ onboarding (docs/10)
|
|
|
|
/// The Setup Code for the Emergency Kit: server, pins, account id and Secret Key, no ticket.
|
|
/// Needs an unlocked vault and a configured server.
|
|
pub fn setup_payload_for_kit(&self) -> Result<String> {
|
|
Ok(self.setup_payload(None)?.encode()?.to_string())
|
|
}
|
|
|
|
/// The "Add a device" QR: issues a 10-minute, single-use, unbound enrollment ticket and returns
|
|
/// the payload. Show it as a secret view; on Cancel call `revoke_enrollment_ticket`.
|
|
pub async fn add_device_payload(&self) -> Result<AddDevicePayload> {
|
|
self.unlocked_snapshot()?;
|
|
let created = self.api()?.create_ticket(None).await?;
|
|
let payload = self.setup_payload(Some(created.ticket))?.encode()?.to_string();
|
|
Ok(AddDevicePayload { payload, ticket_id: created.ticket_id.to_string(), expires_at: created.expires_at })
|
|
}
|
|
|
|
pub async fn revoke_enrollment_ticket(&self, ticket_id: &str) -> Result<()> {
|
|
Ok(self.api()?.revoke_ticket(parse_id(ticket_id)?).await?)
|
|
}
|
|
|
|
/// New browser: logs in with the master password and the Secret Key from `setup`, presenting its
|
|
/// ticket (if any) so no manual approval is needed. Then call `sync_now`.
|
|
///
|
|
/// Web only: the browser talks to its own origin (`connect-src 'self'`), so a setup code for
|
|
/// another server than the configured one is refused instead of switching servers.
|
|
pub async fn login_with_setup(&self, setup: &SetupInfo, master_password: &str) -> Result<LoginResult> {
|
|
let r = self.login_with_setup_inner(setup, master_password).await;
|
|
r.inspect_err(|_| self.forget_secret_key())
|
|
}
|
|
|
|
async fn login_with_setup_inner(&self, setup: &SetupInfo, master_password: &str) -> Result<LoginResult> {
|
|
let p = from_info(setup)?;
|
|
let (account, server) = {
|
|
let inner = self.lock_inner();
|
|
(inner.account.as_ref().map(|a| a.account_id), inner.server.clone())
|
|
};
|
|
if account.is_some_and(|a| a != p.account_id) {
|
|
return Err(invalid("this browser holds a different account"));
|
|
}
|
|
if let Some(server) = server {
|
|
if kv_sync::transport::normalize_base_url(&server.url)? != p.server_url {
|
|
return Err(invalid(&format!("this setup code is for {}; open the web vault there", p.server_url)));
|
|
}
|
|
}
|
|
self.configure_server(&p.server_url, p.spki_pins.clone())?;
|
|
let sk = SecretKey::parse(&p.secret_key)?;
|
|
self.login_inner(master_password, sk, None, p.ticket.clone(), Some(p.account_id), None).await
|
|
}
|
|
|
|
/// Joining browser: opens a rendezvous on `server_url` and returns the QR to show. Then call
|
|
/// `join_poll` every 2 seconds.
|
|
pub async fn join_start(&self, server_url: &str) -> Result<JoinSession> {
|
|
if self.has_account() {
|
|
return Err(invalid("this browser already has an account"));
|
|
}
|
|
// Only ciphertext and public data cross this connection; the setup arrives sealed.
|
|
self.configure_server(server_url, vec![])?;
|
|
let api = self.api()?;
|
|
let created = api.join_create(self.enroll()?).await?;
|
|
let keypair = PairingKeypair::generate();
|
|
let qr_secret = Zeroizing::new(random_bytes::<32>());
|
|
let qr = JoinQr {
|
|
server_url: api.base_url().to_string(),
|
|
rendezvous_id: created.rendezvous_id.clone(),
|
|
public_key: keypair.public_bytes(),
|
|
qr_secret: qr_secret.clone(),
|
|
}
|
|
.encode()?;
|
|
*self.lock_join() = Some(JoinState::Joining {
|
|
rendezvous_id: created.rendezvous_id,
|
|
read_token: created.read_token,
|
|
keypair,
|
|
qr_secret,
|
|
sender_pub: None,
|
|
});
|
|
Ok(JoinSession { qr, expires_at: created.expires_at })
|
|
}
|
|
|
|
/// Joining browser: polls the rendezvous. `NotFound` means it expired (start again).
|
|
pub async fn join_poll(&self) -> Result<JoinProgress> {
|
|
let (rid, token) = match &*self.lock_join() {
|
|
Some(JoinState::Joining { rendezvous_id, read_token, .. }) => (rendezvous_id.clone(), read_token.clone()),
|
|
_ => return Err(invalid("no join in progress")),
|
|
};
|
|
let poll = match self.api()?.join_poll(&rid, &token).await {
|
|
Err(SyncError::NotFound) => {
|
|
self.join_cancel();
|
|
return Err(CoreError::NotFound);
|
|
}
|
|
other => other?,
|
|
};
|
|
let mut guard = self.lock_join();
|
|
let Some(JoinState::Joining { rendezvous_id, keypair, qr_secret, sender_pub, .. }) = guard.as_mut() else {
|
|
return Err(invalid("join was cancelled"));
|
|
};
|
|
if *rendezvous_id != rid {
|
|
// Cancelled and restarted while this poll was in flight.
|
|
return Err(invalid("join was restarted"));
|
|
}
|
|
let rid_bytes = URL_SAFE_NO_PAD.decode(&rid).map_err(internal)?;
|
|
if let Some(p) = &poll.sender_pub {
|
|
// The code was shown for this key; a different one now means someone swapped it.
|
|
if sender_pub.as_ref().is_some_and(|known| known != p) {
|
|
*guard = None;
|
|
return Err(invalid("the approving device changed; start again"));
|
|
}
|
|
*sender_pub = Some(p.clone());
|
|
}
|
|
let code = sender_pub.as_ref().map(|p| pairing::sas(&rid_bytes, &keypair.public_bytes(), p));
|
|
if poll.state != WireJoinState::Sealed {
|
|
return Ok(JoinProgress { code, setup: None });
|
|
}
|
|
let (Some(bundle), Some(p)) = (&poll.bundle, sender_pub.as_ref()) else {
|
|
*guard = None;
|
|
return Err(internal("sealed rendezvous without bundle or sender key"));
|
|
};
|
|
let opened = pairing::open(keypair, p, qr_secret.as_ref(), &rid_bytes, bundle);
|
|
*guard = None;
|
|
let plaintext =
|
|
Zeroizing::new(opened.map_err(|_| invalid("the setup bundle could not be opened; start again"))?);
|
|
let text = std::str::from_utf8(&plaintext).map_err(|_| invalid("malformed setup bundle"))?;
|
|
Ok(JoinProgress { code, setup: Some(to_info(SetupPayload::parse(text)?)) })
|
|
}
|
|
|
|
/// Forgets the local join state on either side.
|
|
pub fn join_cancel(&self) {
|
|
*self.lock_join() = None;
|
|
}
|
|
|
|
/// Approving browser (signed in, unlocked): answers a scanned or pasted join QR and returns the
|
|
/// code to compare. Refuses a QR for a different server. Nothing secret is sent until `join_send`.
|
|
pub async fn join_approve(&self, qr: &str) -> Result<JoinConfirm> {
|
|
let qr = JoinQr::parse(qr)?;
|
|
let api = self.api()?;
|
|
if kv_sync::transport::normalize_base_url(&qr.server_url)? != api.base_url() {
|
|
return Err(invalid("this computer is connecting to a different server"));
|
|
}
|
|
self.unlocked_snapshot()?;
|
|
let keypair = PairingKeypair::generate();
|
|
let hello = api.join_hello(&qr.rendezvous_id, keypair.public_bytes().to_vec()).await?;
|
|
let rid_bytes = URL_SAFE_NO_PAD.decode(&qr.rendezvous_id).map_err(internal)?;
|
|
let confirm = JoinConfirm {
|
|
code: pairing::sas(&rid_bytes, &qr.public_key, &keypair.public_bytes()),
|
|
device_name: hello.device.name.clone(),
|
|
platform: platform_to_dto(hello.device.platform),
|
|
key_fingerprint: device_key_fingerprint(&hello.device.p256_pk),
|
|
};
|
|
*self.lock_join() = Some(JoinState::Approving { qr, keypair, device: hello.device });
|
|
Ok(confirm)
|
|
}
|
|
|
|
/// Approving browser, after the user confirmed the code: issues a ticket bound to the joining
|
|
/// device's key and sends the sealed setup bundle.
|
|
pub async fn join_send(&self) -> Result<()> {
|
|
let Some(JoinState::Approving { qr, keypair, device }) = self.lock_join().take() else {
|
|
return Err(invalid("no join to approve"));
|
|
};
|
|
let api = self.api()?;
|
|
// Fail before issuing a ticket if the vault was locked meanwhile.
|
|
self.setup_payload(None)?;
|
|
let ticket = api.create_ticket(Some(Sha256::digest(&device.p256_pk).to_vec())).await?;
|
|
let payload = self.setup_payload(Some(ticket.ticket))?.encode()?;
|
|
let rid_bytes = URL_SAFE_NO_PAD.decode(&qr.rendezvous_id).map_err(internal)?;
|
|
let bundle = pairing::seal(&keypair, &qr.public_key, qr.qr_secret.as_ref(), &rid_bytes, payload.as_bytes())?;
|
|
Ok(api.join_send(&qr.rendezvous_id, bundle).await?)
|
|
}
|
|
|
|
// ------------------------------------------------------------ internals
|
|
|
|
fn forget_secret_key(&self) {
|
|
if let Some(u) = self.lock_inner().unlocked.as_mut() {
|
|
u.secret_key = None;
|
|
}
|
|
}
|
|
|
|
fn lock_join(&self) -> std::sync::MutexGuard<'_, Option<JoinState>> {
|
|
self.join.lock().unwrap_or_else(|p| p.into_inner())
|
|
}
|
|
|
|
/// This browser's account as a setup payload (unlocked, server configured).
|
|
fn setup_payload(&self, ticket: Option<String>) -> Result<SetupPayload> {
|
|
let (acct, u) = self.unlocked_snapshot()?;
|
|
let server = self.lock_inner().server.clone().ok_or(CoreError::NotConfigured)?;
|
|
Ok(SetupPayload {
|
|
server_url: kv_sync::transport::normalize_base_url(&server.url)?,
|
|
spki_pins: server.pins,
|
|
account_id: acct.account_id,
|
|
secret_key: SecretKey::from_bytes(**u.secret_key.as_ref().ok_or(CoreError::Locked)?).to_display(),
|
|
ticket,
|
|
})
|
|
}
|
|
|
|
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>,
|
|
ticket: Option<String>,
|
|
) -> Result<LoginResult> {
|
|
let device = self.enroll()?;
|
|
match api.login_with_ticket(account_id, auth_input, device, totp, ticket).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
|
|
|
|
/// Validates a scanned or pasted Setup Code (`kestrove://setup/v1?…`, docs/10 §2).
|
|
pub fn parse_setup_payload(payload: &str) -> Result<SetupInfo> {
|
|
Ok(to_info(SetupPayload::parse(payload)?))
|
|
}
|
|
|
|
fn to_info(p: SetupPayload) -> SetupInfo {
|
|
SetupInfo {
|
|
server_url: p.server_url,
|
|
spki_pins: p.spki_pins,
|
|
account_id: p.account_id.to_string(),
|
|
secret_key: p.secret_key.to_string(),
|
|
ticket: p.ticket,
|
|
}
|
|
}
|
|
|
|
fn from_info(i: &SetupInfo) -> Result<SetupPayload> {
|
|
Ok(SetupPayload {
|
|
server_url: kv_sync::transport::normalize_base_url(&i.server_url)?,
|
|
spki_pins: i.spki_pins.clone(),
|
|
account_id: parse_id(&i.account_id)?,
|
|
secret_key: Zeroizing::new(i.secret_key.clone()),
|
|
ticket: i.ticket.clone(),
|
|
})
|
|
}
|
|
|
|
fn invalid(msg: &str) -> CoreError {
|
|
CoreError::Invalid { msg: msg.into() }
|
|
}
|
|
|
|
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 {
|
|
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() })
|
|
}
|
|
|
|
fn registration_name(r: kv_proto::Registration) -> String {
|
|
match r {
|
|
kv_proto::Registration::Invite => "invite".into(),
|
|
kv_proto::Registration::Open => "open".into(),
|
|
}
|
|
}
|