Files
kestrove-core/crates/kv-wasm/src/core.rs
T
samiandClaude Opus 5.5 c84015f67b Multi-user: email sign-in, invite/open registration, admin calls
- 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]>
2026-10-24 07:35:23 +07:00

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, &params)?;
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(),
}
}