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:
Generated
+1
@@ -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",
|
||||
|
||||
@@ -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]
|
||||
|
||||
@@ -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.
|
||||
|
||||
Vendored
+2
@@ -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>;
|
||||
};
|
||||
|
||||
@@ -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));
|
||||
|
||||
@@ -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
@@ -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()
|
||||
}
|
||||
|
||||
|
||||
@@ -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,
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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 { .. })));
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user