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:
+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()
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user