kv-wasm: onboarding (setup codes, enrollment tickets, scan-to-join) for the web vault

Mirrors kv-ffi's docs/10 §5 API in WebCore and binds it under the names the web app codes
against: setupPayloadForKit, addDevicePayload, revokeEnrollmentTicket, loginWithSetup,
joinStart/joinPoll/joinCancel, joinApprove, joinSend, and the free function parseSetupPayload
(also callable through the worker client).

Unlike kv-ffi, the Secret Key is not persisted: every unlock on the web takes it, so it is
kept zeroized in the unlocked state and dropped on lock. loginWithSetup refuses a code for a
different server than the configured origin (the browser only talks to its own origin).

Tests: ticket enrollment and scan-to-join in both roles against kv-ffi via the kv-sync test
server, a swapped-approver rejection, a Chrome test (IndexedDB never holds the Secret Key,
JS shapes, error codes) and a Node smoke check of the built package.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
This commit is contained in:
2026-10-18 06:58:13 +09:00
co-authored by Claude Opus 5.5
parent d70beef92c
commit 2e2c8cd26f
11 changed files with 779 additions and 14 deletions
+270 -10
View File
@@ -10,25 +10,34 @@
//! * 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.
//! * `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, KdfParams, Platform};
use kv_proto::{b64, DeviceEnroll, Envelope, ItemRecord, JoinState as WireJoinState, KdfParams, Platform};
use kv_store::{HistoryEntry, LocalItem, Store, VaultRow};
use kv_sync::{ApiClient, DeviceSigner, LoginOutcome, ServerConfig, SyncContext, SyncError};
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;
@@ -43,7 +52,8 @@ pub const META_SERVER: &str = "server";
pub const META_SESSION: &str = "session";
/// Everything needed to unlock: all public or wrapped. Same JSON as kv-ffi's `AccountRecord`
/// (minus the native-only `local_unlock`), so a record moves between the two unchanged.
/// (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,
@@ -69,6 +79,8 @@ struct Unlocked {
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).
secret_key: Zeroizing<[u8; 16]>,
}
struct Device {
@@ -77,6 +89,20 @@ struct Device {
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>,
@@ -90,6 +116,8 @@ 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);
@@ -116,6 +144,7 @@ impl WebCore {
store,
inner: Mutex::new(Inner { account, server, ..Default::default() }),
syncing: AtomicBool::new(false),
join: Mutex::new(None),
})
}
@@ -162,6 +191,7 @@ impl WebCore {
keyset: Arc::new(keyset),
vault_key: Arc::new(vault_key),
auth_input: Some(derived.auth_input),
secret_key: Zeroizing::new(*secret_key.expose()),
});
Ok(secret_key.to_display().to_string())
}
@@ -178,6 +208,7 @@ impl WebCore {
keyset: Arc::new(keyset),
vault_key: Arc::new(vault_key),
auth_input: Some(derived.auth_input),
secret_key: Zeroizing::new(*sk.expose()),
});
Ok(())
}
@@ -212,6 +243,7 @@ impl WebCore {
let mut inner = self.lock_inner();
self.store.wipe()?;
*inner = Inner::default();
self.join_cancel();
Ok(())
}
@@ -458,14 +490,26 @@ impl WebCore {
device,
)
.await?;
self.do_login(&api, acct.account_id, auth.as_ref(), None).await.map(|_| ())
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>) -> Result<LoginResult> {
let api = self.api()?;
let sk = SecretKey::parse(secret_key)?;
self.login_inner(master_password, sk, totp, None, None).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>,
) -> 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()),
@@ -474,10 +518,15 @@ impl WebCore {
(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).await? == LoginResult::PendingApproval {
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);
@@ -512,6 +561,7 @@ impl WebCore {
keyset: Arc::new(keyset),
vault_key: Arc::new(vault_key),
auth_input: Some(derived.auth_input),
secret_key: Zeroizing::new(*sk.expose()),
});
Ok(LoginResult::Ok)
}
@@ -530,7 +580,8 @@ impl WebCore {
let report = match run().await {
Err(SyncError::NotAuthenticated) => {
let auth = u.auth_input.clone().ok_or(CoreError::NotAuthenticated)?;
if self.do_login(&api, u.keyset.account_id, auth.as_ref(), None).await? == LoginResult::PendingApproval
if self.do_login(&api, u.keyset.account_id, auth.as_ref(), None, None).await?
== LoginResult::PendingApproval
{
return Err(CoreError::PendingApproval);
}
@@ -589,8 +640,188 @@ impl WebCore {
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 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)).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 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).to_display(),
ticket,
})
}
fn unlocked_snapshot(&self) -> Result<(AccountRecord, Unlocked)> {
let inner = self.lock_inner();
let acct = inner.account.clone().ok_or(CoreError::NoAccount)?;
@@ -704,9 +935,10 @@ impl WebCore {
account_id: Uuid,
auth_input: &[u8],
totp: Option<String>,
ticket: Option<String>,
) -> Result<LoginResult> {
let device = self.enroll()?;
match api.login(account_id, auth_input, device, totp).await? {
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)
@@ -748,6 +980,35 @@ impl WebCore {
// ---------------------------------------------------------------- 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() })
}
@@ -858,7 +1119,6 @@ fn platform_to_dto(p: Platform) -> PlatformDto {
/// Hex of the first 16 bytes of SHA-256(P-256 public key), as kv-ffi shows it.
pub fn device_key_fingerprint(p256_pk: &[u8]) -> String {
use sha2::{Digest, Sha256};
Sha256::digest(p256_pk)[..16].iter().map(|b| format!("{b:02x}")).collect()
}