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
Generated
+1
View File
@@ -1490,6 +1490,7 @@ dependencies = [
name = "kv-wasm"
version = "0.1.0"
dependencies = [
"base64 0.22.1",
"getrandom 0.2.17",
"js-sys",
"kv-crypto",
+1
View File
@@ -22,6 +22,7 @@ thiserror.workspace = true
uuid.workspace = true
zeroize.workspace = true
sha2.workspace = true
base64.workspace = true
serde_bytes = "0.11"
[target.'cfg(target_arch = "wasm32")'.dependencies]
+32 -3
View File
@@ -14,6 +14,7 @@ ciphertext leaves the browser.
| Biometric unlock (LUK) | yes | **no**: every unlock needs master password + Secret Key |
| URL matching | eTLD+1 via the Public Suffix List | same host only (the list would be a third of the download) |
| Passkeys | create, assert, list, delete | list, delete |
| Secret Key for setup codes | HPKE-sealed in the account record | in memory while unlocked, never persisted |
## Build and test
@@ -21,14 +22,14 @@ ciphertext leaves the browser.
crates/kv-wasm/build.sh # pkg/: wasm-pack (size profile, wasm-opt -Oz) + worker files
wasm-pack test --headless --chrome crates/kv-wasm # in Chrome: IndexedDB, WebCrypto, native fixture
node crates/kv-wasm/js/smoke.mjs # the built package in Node (in-memory), Argon2 timing
cargo test -p kv-wasm # native: store, fixtures vs kv-ffi, sync with kv-ffi via test server
cargo test -p kv-wasm # native: store, fixtures vs kv-ffi, sync + onboarding with kv-ffi via test server
```
`wasm-pack build crates/kv-wasm --target web` alone also works (opt-level 3, a bit bigger).
`smoke.mjs` runs a two-device sync round trip against a real server when `KESTROVE_SERVER_URL` and
`KESTROVE_SETUP_TOKEN` are set (see the file header).
**Size** (`build.sh`): `kv_wasm_bg.wasm` 1.27 MB, **572 KB gzip**, 467 KB brotli. JS glue ~60 KB.
**Size** (`build.sh`): `kv_wasm_bg.wasm` 1.34 MB, **599 KB gzip**, 488 KB brotli. JS glue ~60 KB.
Serve it with compression and `Content-Type: application/wasm`.
**Argon2id** (m = 64 MiB, t = 3, p = 4, the account default): about **100–120 ms** per derivation in
@@ -62,9 +63,19 @@ class VaultCore {
serverInfo(); registerAccount(setupToken); login(masterPassword, secretKey, totp?): Promise<LoginResult>;
syncNow(): Promise<SyncReport>; listDevices(); approveDevice(deviceId); revokeDevice(deviceId);
logout(); isLoggedIn(); wipe();
// onboarding (kestrove-spec docs/10 §5)
setupPayloadForKit(): Promise<string>; // Emergency Kit Setup Code
addDevicePayload(): Promise<AddDevicePayload>; // {payload, ticketId, expiresAt}
revokeEnrollmentTicket(ticketId);
loginWithSetup(setup: SetupInfo, masterPassword): Promise<LoginResult>; // Argon2id
joinStart(serverUrl): Promise<JoinSession>; // {qr, expiresAt}
joinPoll(): Promise<JoinProgress>; // {code, setup}
joinCancel(); joinApprove(qr): Promise<JoinConfirm>; // {code, deviceName, platform, keyFingerprint}
joinSend();
}
generatePassword(length, symbols): string; totpCode(secretOrUri, unixTime): TotpCode;
matchUrl(itemUrl, pageUrl): MatchLevel; coreVersion(): string;
parseSetupPayload(s): SetupInfo; // {serverUrl, spkiPins, accountId, secretKey, ticket}
```
Errors reject with an `Error` whose `name` is `"CoreError"` and whose `code` is one of kv-ffi's
@@ -102,7 +113,8 @@ two replicas overwriting each other.
the platform defaults to the OS from the user agent because the server has no `web` platform yet.
4. **Every unlock needs the master password and Secret Key.** There is no Local Unlock Key on the
web: nothing secret is ever written to IndexedDB, so a reload, `lock()` or closing the tab
leaves the vault locked. Lock on idle and on `visibilitychange`/`pagehide` as the UI sees fit;
leaves the vault locked. (The Secret Key that setup codes carry is kept in wasm memory while
unlocked and dropped on `lock()`, unlike kv-ffi, which keeps a sealed copy on disk.) Lock on idle and on `visibilitychange`/`pagehide` as the UI sees fit;
`close()` drops the worker and every key in it.
5. **Secrets in JS.** Master password, Secret Key and decrypted `ItemDto`s are JS strings the
core cannot wipe. Do not keep them in long-lived state, logs or URLs. The core zeroizes its own
@@ -118,3 +130,20 @@ two replicas overwriting each other.
9. **Import/export:** pass `new Uint8Array(await file.arrayBuffer())`; `exportEncrypted` resolves with
the `.kvex` bytes for a `Blob` download.
10. `itemsForUrl` / `matchUrl` match the same host only (no Public Suffix List in this build).
11. **Onboarding** (docs/10). Same flows and names as kv-ffi; `expiresAt` is Unix **seconds**
and codes look like `"123 456"`, as in kv-ffi.
* *Sign in with a setup code*: `parseSetupPayload(text)` (scanned or pasted; it is a secret,
so never put it in a URL or register `kestrove://` as a handler), then
`loginWithSetup(setup, password)` and `syncNow()`. A code for a different server than this
web vault's origin is refused with `Invalid` (the browser only talks to its own origin);
tell the user to open the web vault at `setup.serverUrl`.
* *Emergency Kit*: `setupPayloadForKit()` needs an unlocked vault.
* *Add a device*: show `addDevicePayload().payload` as a QR until `expiresAt`; on Cancel call
`revokeEnrollmentTicket(ticketId)`.
* *Join from this browser* (no account yet): `joinStart(location.origin)`, show `qr`, call
`joinPoll()` every 2 s; show `code` when it appears, and when `setup` arrives ask for the
master password and `loginWithSetup`. `NotFound` = expired: start again. `joinCancel()` on
leave.
* *Approve another device's join* (signed in, unlocked): `joinApprove(qrText)` → show `code`,
`deviceName`, `keyFingerprint`; after the user confirms the codes match, `joinSend()`.
The join state lives in the worker: a reload or `close()` cancels it.
+2
View File
@@ -6,6 +6,7 @@ import type {
totpCode,
matchUrl,
coreVersion,
parseSetupPayload,
} from "./kv_wasm.js";
export * from "./kv_wasm.js";
@@ -22,6 +23,7 @@ export type RemoteVault = CoreMethods & {
totpCode: Async<typeof totpCode>;
matchUrl: Async<typeof matchUrl>;
coreVersion: Async<typeof coreVersion>;
parseSetupPayload: Async<typeof parseSetupPayload>;
/** Frees the core (zeroizing keys), releases the tab lock and terminates the worker. */
close(): Promise<void>;
};
+15
View File
@@ -92,6 +92,21 @@ assert.equal(kv.generatePassword(20, false).length, 20);
const exported = await a.exportEncrypted("export passphrase");
assert.ok(exported instanceof Uint8Array && exported.length > 100);
// Onboarding (docs/10): the Kit's Setup Code round-trips through the free parser.
const k = await kv.VaultCore.newInMemory();
const kSecret = await k.createAccount(PW);
await k.configureServer("https://vault.example.org", []);
const setup = kv.parseSetupPayload(await k.setupPayloadForKit());
assert.deepEqual(setup, {
serverUrl: "https://vault.example.org",
spkiPins: [],
accountId: await k.accountId(),
secretKey: kSecret,
ticket: null,
});
await rejects(k.joinPoll(), "Invalid");
k.free();
if (process.argv.includes("--write-fixture")) {
const items = [];
for (const s of await a.listItems()) items.push(await a.getItem(s.id));
+1 -1
View File
@@ -56,7 +56,7 @@ async function open(dbName, inMemory) {
return null;
}
const FREE_FUNCTIONS = new Set(["generatePassword", "totpCode", "matchUrl", "coreVersion"]);
const FREE_FUNCTIONS = new Set(["generatePassword", "totpCode", "matchUrl", "coreVersion", "parseSetupPayload"]);
self.onmessage = async ({ data: { id, method, args } }) => {
try {
+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()
}
+78
View File
@@ -181,3 +181,81 @@ pub struct ImportReport {
pub warnings: Vec<String>,
pub item_ids: Vec<String>,
}
// ---------------------------------------------------------------- onboarding (docs/10)
/// A parsed, validated setup payload: everything a new device needs except the master password.
#[derive(Clone, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct SetupInfo {
/// Normalized server URL.
pub server_url: String,
/// Standard base64 SPKI pins, the format `configure_server` takes. Empty = WebPKI only.
#[serde(default)]
pub spki_pins: Vec<String>,
pub account_id: String,
/// Display form `K1-…`.
pub secret_key: String,
/// Enrollment ticket; `login_with_setup` presents it so no manual approval is needed.
#[serde(default)]
pub ticket: Option<String>,
}
impl zeroize::Zeroize for SetupInfo {
fn zeroize(&mut self) {
self.secret_key.zeroize();
self.ticket.zeroize();
}
}
impl std::fmt::Debug for SetupInfo {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
f.debug_struct("SetupInfo")
.field("server_url", &self.server_url)
.field("spki_pins", &self.spki_pins)
.field("account_id", &self.account_id)
.field("secret_key", &"<redacted>")
.field("ticket", &self.ticket.as_ref().map(|_| "<redacted>"))
.finish()
}
}
/// The "Add a device" QR: a setup payload with a fresh, unbound ticket.
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct AddDevicePayload {
pub payload: String,
/// For `revoke_enrollment_ticket` when the user cancels.
pub ticket_id: String,
/// Unix seconds; hide the QR after this.
pub expires_at: i64,
}
/// Shown by the joining device: a QR for a signed-in phone to scan.
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct JoinSession {
pub qr: String,
/// Unix seconds.
pub expires_at: i64,
}
/// Result of `join_poll`: `code` once a phone has scanned the QR, `setup` once it sent the bundle.
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct JoinProgress {
pub code: Option<String>,
pub setup: Option<SetupInfo>,
}
/// Shown by the approving device after scanning: the code to compare and who is asking.
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct JoinConfirm {
/// `"123 456"`. The joining device shows the same code.
pub code: String,
pub device_name: String,
pub platform: PlatformDto,
/// Same format as `DeviceDto.key_fingerprint`.
pub key_fingerprint: String,
}
+128
View File
@@ -123,6 +123,52 @@ export interface ImportReport {
itemIds: string[];
}
/** A parsed setup payload (`kestrove://setup/v1?…`): everything a new device needs but the master password. */
export interface SetupInfo {
/** Normalized server URL. */
serverUrl: string;
/** Standard base64 SPKI SHA-256 pins. Stored but not enforced in the browser. */
spkiPins: string[];
accountId: string;
/** Display form `K1-…`. A secret: do not keep it in long-lived state. */
secretKey: string;
/** Enrollment ticket ("Add a device", scan-to-join); null on the Emergency Kit. */
ticket: string | null;
}
export interface AddDevicePayload {
/** Text for the QR (a secret view): the setup payload with a fresh 10-minute, single-use ticket. */
payload: string;
/** For `revokeEnrollmentTicket` when the user cancels. */
ticketId: string;
/** Unix seconds. */
expiresAt: number;
}
/** Joining side of scan-to-join: the QR to show (`kestrove://join/v1?…`). */
export interface JoinSession {
qr: string;
/** Unix seconds. */
expiresAt: number;
}
export interface JoinProgress {
/** `"123 456"`, once the approving device scanned the QR. Show it for comparison. */
code: string | null;
/** Set once the bundle arrived: ask for the master password and call `loginWithSetup`. */
setup: SetupInfo | null;
}
/** Approving side of scan-to-join: what the user checks before confirming. */
export interface JoinConfirm {
/** `"123 456"`; the joining device shows the same code. */
code: string;
deviceName: string;
platform: Platform;
/** Same format as `DeviceDto.keyFingerprint`. */
keyFingerprint: string;
}
export type CoreErrorCode =
| "Locked" | "NoAccount" | "BadCredentials" | "Invalid" | "NotFound" | "Internal" | "NotConfigured"
| "NotAuthenticated" | "PendingApproval" | "Network" | "PinMismatch" | "Server" | "IntegrityAlarm" | "Busy";
@@ -617,6 +663,80 @@ impl VaultCore {
self.run(|s| async move { Ok(s.core.is_logged_in()) })
}
// ------------------------------------------------------------ onboarding (docs/10)
/// The Emergency Kit's Setup Code (server, account id, Secret Key; no ticket). Needs an unlocked
/// vault. Show it as a secret.
#[wasm_bindgen(js_name = setupPayloadForKit, unchecked_return_type = "Promise<string>")]
pub fn setup_payload_for_kit(&self) -> Promise {
self.run(|s| async move { s.core.setup_payload_for_kit() })
}
/// The "Add a device" QR: a setup payload with a fresh 10-minute, single-use ticket, so the new
/// device needs only the master password and no approval. On Cancel call `revokeEnrollmentTicket`.
#[wasm_bindgen(js_name = addDevicePayload, unchecked_return_type = "Promise<AddDevicePayload>")]
pub fn add_device_payload(&self) -> Promise {
self.run(|s| async move { s.core.add_device_payload().await })
}
#[wasm_bindgen(js_name = revokeEnrollmentTicket, unchecked_return_type = "Promise<void>")]
pub fn revoke_enrollment_ticket(&self, #[wasm_bindgen(js_name = "ticketId")] ticket_id: String) -> Promise {
self.run(move |s| async move { s.core.revoke_enrollment_ticket(&ticket_id).await })
}
/// Logs in with a setup code (from `parseSetupPayload` or `joinPoll`) and the master password,
/// presenting its ticket so no approval is needed. Refuses a code for another server than this
/// web vault's. Then call `syncNow`. Runs Argon2id.
#[wasm_bindgen(js_name = loginWithSetup, unchecked_return_type = "Promise<LoginResult>")]
pub fn login_with_setup(
&self,
#[wasm_bindgen(unchecked_param_type = "SetupInfo")] setup: JsValue,
#[wasm_bindgen(js_name = "masterPassword")] master_password: String,
) -> Promise {
let (setup, pw) = (from_js::<SetupInfo>(setup), zeroize::Zeroizing::new(master_password));
self.write(move |s| async move {
let setup = zeroize::Zeroizing::new(setup?);
s.core.login_with_setup(&setup, &pw).await
})
}
/// Joining browser (no account yet): opens a rendezvous and resolves with the QR to show. Then
/// call `joinPoll` every 2 seconds.
#[wasm_bindgen(js_name = joinStart, unchecked_return_type = "Promise<JoinSession>")]
pub fn join_start(&self, #[wasm_bindgen(js_name = "serverUrl")] server_url: String) -> Promise {
self.write(move |s| async move { s.core.join_start(&server_url).await })
}
/// Joining browser: `code` appears once a phone scanned the QR, `setup` once it sent the bundle.
/// Rejects with `NotFound` when the rendezvous expired (start again).
#[wasm_bindgen(js_name = joinPoll, unchecked_return_type = "Promise<JoinProgress>")]
pub fn join_poll(&self) -> Promise {
self.run(|s| async move { s.core.join_poll().await })
}
/// Forgets the local join state on either side.
#[wasm_bindgen(js_name = joinCancel, unchecked_return_type = "Promise<void>")]
pub fn join_cancel(&self) -> Promise {
self.run(|s| async move {
s.core.join_cancel();
Ok(())
})
}
/// Approving browser (signed in, unlocked): answers a join QR and resolves with the code to
/// compare. Nothing secret is sent until `joinSend`.
#[wasm_bindgen(js_name = joinApprove, unchecked_return_type = "Promise<JoinConfirm>")]
pub fn join_approve(&self, qr: String) -> Promise {
self.run(move |s| async move { s.core.join_approve(&qr).await })
}
/// Approving browser, after the user confirmed the code: sends the sealed setup bundle with a
/// ticket bound to the joining device's key.
#[wasm_bindgen(js_name = joinSend, unchecked_return_type = "Promise<void>")]
pub fn join_send(&self) -> Promise {
self.run(|s| async move { s.core.join_send().await })
}
/// Diagnostics and the cross-platform fixture (`js/smoke.mjs`): the replica entries exactly as
/// IndexedDB holds them (ciphertext, ids, bookkeeping). Not part of the typed API.
#[wasm_bindgen(js_name = __replicaEntries, skip_typescript)]
@@ -662,6 +782,14 @@ pub fn match_url(
to_js(&core::match_url(&item_url, &page_url))
}
/// Validates a scanned or pasted Setup Code (`kestrove://setup/v1?…`). Throws `CoreError` `Invalid`.
#[wasm_bindgen(js_name = parseSetupPayload, unchecked_return_type = "SetupInfo")]
pub fn parse_setup_payload(payload: String) -> std::result::Result<JsValue, JsValue> {
let payload = zeroize::Zeroizing::new(payload);
let info = zeroize::Zeroizing::new(core::parse_setup_payload(&payload).map_err(|e| core_error(&e))?);
to_js(&*info)
}
#[cfg(test)]
mod tests;
+54
View File
@@ -197,3 +197,57 @@ async fn in_memory_vault_import_export_and_helpers() {
// No Public Suffix List in the web build: different hosts never match.
assert_eq!(match_url("https://a.example.com".into(), "https://b.example.com".into()).unwrap(), "none");
}
#[wasm_bindgen_test]
async fn setup_codes_in_the_browser_keep_the_secret_key_in_memory_only() {
let db_name = format!("kv-test-{}", Uuid::new_v4());
let core = open_db(&db_name).await;
assert_eq!(err_code(core.setup_payload_for_kit()).await, "NoAccount");
let secret_key = ok(core.create_account(PW.into())).await.as_string().unwrap();
assert_eq!(err_code(core.setup_payload_for_kit()).await, "NotConfigured");
ok(core.configure_server("https://vault.example.org/".into(), None)).await;
// The Kit's Setup Code, parsed by the free function into a plain camelCase object.
let kit = ok(core.setup_payload_for_kit()).await.as_string().unwrap();
assert!(kit.starts_with("kestrove://setup/v1?"), "{kit}");
let info = parse_setup_payload(kit.clone()).unwrap();
let get = |k: &str| Reflect::get(&info, &k.into()).unwrap();
assert_eq!(get("serverUrl").as_string().as_deref(), Some("https://vault.example.org"));
assert_eq!(get("secretKey").as_string(), Some(secret_key.clone()));
assert_eq!(get("accountId").as_string(), ok(core.account_id()).await.as_string());
assert!(get("ticket").is_null() && js_sys::Array::is_array(&get("spkiPins")));
let e = parse_setup_payload("kestrove://setup/v2?s=x".into()).unwrap_err();
assert_eq!(Reflect::get(&e, &"code".into()).unwrap().as_string().as_deref(), Some("Invalid"));
// A setup code for another server is refused (connect-src 'self'), as is a malformed object.
let mut other: Value = serde_wasm_bindgen::from_value(info.clone()).unwrap();
other["serverUrl"] = json!("https://elsewhere.example.net");
assert_eq!(err_code(core.login_with_setup(js(other), PW.into())).await, "Invalid");
assert_eq!(err_code(core.login_with_setup(js(json!({"serverUrl": 1})), PW.into())).await, "Invalid");
// Join calls without a join in progress, or with a bad QR.
assert_eq!(err_code(core.join_poll()).await, "Invalid");
assert_eq!(err_code(core.join_send()).await, "Invalid");
assert_eq!(err_code(core.join_approve("kestrove://join/v1?s=x".into())).await, "Invalid");
assert_eq!(err_code(core.join_start("https://vault.example.org".into())).await, "Invalid", "has an account");
ok(core.join_cancel()).await;
// Nothing in IndexedDB holds the Secret Key, in either form.
let compact: String = secret_key.chars().filter(|c| *c != '-').collect();
let db = core.shared.db.clone().unwrap();
for (key, value) in idb::load_entries(&db).await.unwrap() {
let text = String::from_utf8_lossy(&value);
assert!(!text.contains(&secret_key) && !text.contains(&compact[2..]), "{key} holds the Secret Key");
}
ok(core.lock()).await;
assert_eq!(err_code(core.setup_payload_for_kit()).await, "Locked");
db.close();
drop(core);
// After a reload the code comes back with the unlock (which takes the Secret Key).
let core = open_db(&db_name).await;
assert_eq!(err_code(core.setup_payload_for_kit()).await, "Locked");
ok(core.unlock(PW.into(), secret_key)).await;
assert_eq!(ok(core.setup_payload_for_kit()).await.as_string(), Some(kit));
ok(core.wipe()).await;
}
+197
View File
@@ -163,3 +163,200 @@ async fn concurrent_sync_is_refused_not_interleaved() {
web.lock();
web.unlock(PW, &sk).unwrap();
}
// ---------------------------------------------------------------- onboarding (docs/10)
/// An app device (kv-ffi) signed in with one item, on a server that requires approval for new devices.
async fn app_with_item(server: &TestServer) -> (Arc<kv_ffi::VaultCore>, String, String) {
let app = kv_ffi::VaultCore::new_in_memory();
let sk = app.create_account(PW.into()).unwrap();
app.configure_server(server.url(), vec![]).unwrap();
app.set_device_signer(Arc::new(Signer(SoftwareSigner::generate(Uuid::new_v4()))), "phone".into(), PlatformDto::Ios)
.unwrap();
app.register_account(SETUP_TOKEN.into()).await.unwrap();
let id = app
.save_item(kv_ffi::ItemDto {
id: String::new(),
kind: kv_ffi::ItemKindDto::Login,
title: "Bank".into(),
username: None,
password: Some("from the app".into()),
urls: vec![],
totp: None,
notes: None,
favorite: false,
tags: vec![],
modified_at: 0,
})
.unwrap();
app.sync_now().await.unwrap();
server.state().require_approval = true;
(app, sk, id)
}
fn new_web(server: &TestServer) -> (Arc<WebStore>, WebCore) {
let store = Arc::new(WebStore::new());
let web = web_core(store.clone(), &server.url(), Arc::new(SoftwareSigner::generate(Uuid::new_v4())));
(store, web)
}
/// Nothing secret reaches the persisted replica: not the Secret Key in either form.
fn assert_no_secret_key(store: &WebStore, sk: &str) {
let compact: String = sk.chars().filter(|c| *c != '-').collect();
for (k, v) in store.entries() {
let v = String::from_utf8_lossy(&v);
assert!(!v.contains(sk) && !v.contains(&compact[2..]), "Secret Key persisted under {k}");
}
}
#[tokio::test]
async fn web_vault_enrolls_by_ticket_both_ways() {
let server = TestServer::start().await;
let (app, sk, bank) = app_with_item(&server).await;
// App -> browser: the "Add a device" QR plus the master password, no approval.
let add = app.add_device_payload().await.unwrap();
let setup = kv_wasm::core::parse_setup_payload(&add.payload).unwrap();
assert_eq!((setup.secret_key.as_str(), setup.server_url.as_str()), (sk.as_str(), server.url().as_str()));
assert!(setup.ticket.is_some() && !format!("{setup:?}").contains(&sk));
// A code for another server is refused before anything is sent (the browser only talks to its origin).
let (_, other) = new_web(&server);
other.configure_server("https://vault.example.org", vec![]).unwrap();
assert!(matches!(other.login_with_setup(&setup, PW).await, Err(CoreError::Invalid { .. })));
let (store, web) = new_web(&server);
assert!(matches!(web.login_with_setup(&setup, "wrong password!!").await, Err(CoreError::BadCredentials)));
assert_eq!(web.login_with_setup(&setup, PW).await.unwrap(), LoginResult::Ok);
web.sync_now().await.unwrap();
assert_eq!(web.get_item(&bank).unwrap().password.as_deref(), Some("from the app"));
assert!(app.list_devices().await.unwrap().iter().all(|d| d.approved));
// The browser's Kit payload matches the app's; it lives only in memory.
let kit = web.setup_payload_for_kit().unwrap();
assert_eq!(kit, app.setup_payload_for_kit().unwrap());
assert_eq!(kv_wasm::core::parse_setup_payload(&kit).unwrap().ticket, None);
assert_no_secret_key(&store, &sk);
web.lock();
assert!(matches!(web.setup_payload_for_kit(), Err(CoreError::Locked)));
assert!(matches!(web.add_device_payload().await, Err(CoreError::Locked)));
// A reload: unlocking (which takes the Secret Key) brings it back.
let reloaded = web_core(Arc::new(WebStore::load(store.entries()).unwrap()), &server.url(), {
Arc::new(SoftwareSigner::generate(Uuid::new_v4()))
});
reloaded.unlock(PW, &sk).unwrap();
assert_eq!(reloaded.setup_payload_for_kit().unwrap(), kit);
web.unlock(PW, &sk).unwrap();
// Single use: another browser with the same QR waits for approval.
let (_, third) = new_web(&server);
assert_eq!(third.login_with_setup(&setup, PW).await.unwrap(), LoginResult::PendingApproval);
// Browser -> app: the web vault's "Add a device" QR enrolls a new app device.
let add = web.add_device_payload().await.unwrap();
let tablet = kv_ffi::VaultCore::new_in_memory();
tablet
.set_device_signer(
Arc::new(Signer(SoftwareSigner::generate(Uuid::new_v4()))),
"tablet".into(),
PlatformDto::Android,
)
.unwrap();
let info = kv_ffi::parse_setup_payload(add.payload).unwrap();
assert_eq!(tablet.login_with_setup(info, PW.into()).await.unwrap(), kv_ffi::LoginResult::Ok);
tablet.sync_now().await.unwrap();
assert_eq!(tablet.get_item(bank).unwrap().password.as_deref(), Some("from the app"));
// A cancelled QR is revoked.
let add = web.add_device_payload().await.unwrap();
web.revoke_enrollment_ticket(&add.ticket_id).await.unwrap();
let (_, late) = new_web(&server);
let setup = kv_wasm::core::parse_setup_payload(&add.payload).unwrap();
assert_eq!(late.login_with_setup(&setup, PW).await.unwrap(), LoginResult::PendingApproval);
}
#[tokio::test]
async fn web_vault_scan_to_join_both_roles() {
let server = TestServer::start().await;
let (phone, sk, bank) = app_with_item(&server).await;
// The browser joins: it shows the QR, the phone scans it.
let (store, web) = new_web(&server);
assert!(matches!(web.join_poll().await, Err(CoreError::Invalid { .. })));
let session = web.join_start(&server.url()).await.unwrap();
assert!(session.qr.starts_with("kestrove://join/v1?"));
assert_eq!(web.join_poll().await.unwrap().code, None);
let confirm = phone.join_approve(session.qr.clone()).await.unwrap();
assert_eq!(confirm.key_fingerprint, web.device_key_fingerprint().unwrap());
assert_eq!(confirm.device_name, "Web vault");
let progress = web.join_poll().await.unwrap();
assert_eq!((progress.code.as_deref(), progress.setup.as_ref()), (Some(confirm.code.as_str()), None));
phone.join_send().await.unwrap();
let setup = web.join_poll().await.unwrap().setup.expect("bundle arrived");
assert_eq!(setup.secret_key, sk);
assert!(matches!(web.join_poll().await, Err(CoreError::Invalid { .. })), "one read");
assert_eq!(web.login_with_setup(&setup, PW).await.unwrap(), LoginResult::Ok);
web.sync_now().await.unwrap();
assert_eq!(web.get_item(&bank).unwrap().password.as_deref(), Some("from the app"));
assert_no_secret_key(&store, &sk);
assert!(matches!(web.join_start(&server.url()).await, Err(CoreError::Invalid { .. })));
// The browser approves: an app desktop joins through it.
let desktop = kv_ffi::VaultCore::new_in_memory();
desktop
.set_device_signer(
Arc::new(Signer(SoftwareSigner::generate(Uuid::new_v4()))),
"desktop".into(),
PlatformDto::Linux,
)
.unwrap();
let session = desktop.join_start(server.url()).await.unwrap();
assert!(matches!(web.join_send().await, Err(CoreError::Invalid { .. })));
let elsewhere = session.qr.replace("127.0.0.1", "localhost");
assert!(matches!(web.join_approve(&elsewhere).await, Err(CoreError::Invalid { .. })));
let confirm = web.join_approve(&session.qr).await.unwrap();
assert_eq!((confirm.device_name.as_str(), confirm.platform), ("desktop", kv_wasm::dto::PlatformDto::Linux));
assert_eq!(confirm.key_fingerprint, desktop.device_key_fingerprint().unwrap());
assert_eq!(desktop.join_poll().await.unwrap().code.as_deref(), Some(confirm.code.as_str()));
web.join_send().await.unwrap();
assert!(matches!(web.join_send().await, Err(CoreError::Invalid { .. })), "one send per approval");
{
let st = server.state();
let raw = serde_json::to_string(st.joins.values().next().unwrap().bundle.as_ref().unwrap()).unwrap();
assert!(!raw.contains("K1") && !raw.contains("kestrove://"));
}
let setup = desktop.join_poll().await.unwrap().setup.unwrap();
assert_eq!(desktop.login_with_setup(setup, PW.into()).await.unwrap(), kv_ffi::LoginResult::Ok);
desktop.sync_now().await.unwrap();
assert_eq!(desktop.get_item(bank).unwrap().password.as_deref(), Some("from the app"));
}
#[tokio::test]
async fn web_vault_join_refuses_a_swapped_approver() {
let server = TestServer::start().await;
let (phone, _, _) = app_with_item(&server).await;
let (_, web) = new_web(&server);
let session = web.join_start(&server.url()).await.unwrap();
phone.join_approve(session.qr.clone()).await.unwrap();
web.join_poll().await.unwrap();
// The server swaps the hello key and bundle for its own (it never had the QR secret).
let qr = kv_sync::onboarding::JoinQr::parse(&session.qr).unwrap();
let rid = base64::Engine::decode(&base64::engine::general_purpose::URL_SAFE_NO_PAD, &qr.rendezvous_id).unwrap();
let forger = kv_crypto::pairing::PairingKeypair::generate();
let forged =
kv_crypto::pairing::seal(&forger, &qr.public_key, &[0u8; 32], &rid, b"kestrove://setup/v1?evil").unwrap();
{
let mut st = server.state();
let rv = st.joins.values_mut().next().unwrap();
rv.hello = Some((Uuid::new_v4(), forger.public_bytes().to_vec()));
rv.bundle = Some(forged);
}
assert!(matches!(web.join_poll().await, Err(CoreError::Invalid { .. })));
assert!(matches!(web.join_poll().await, Err(CoreError::Invalid { .. })), "state cleared");
// Cancelled on the joining side; a fresh start works.
web.join_start(&server.url()).await.unwrap();
web.join_cancel();
assert!(matches!(web.join_poll().await, Err(CoreError::Invalid { .. })));
}