Onboarding core: setup payloads, enrollment tickets, scan-to-join (docs/10)

* kv-proto: ticket and join wire types, LoginFinish.ticket, b64_opt.
* kv-crypto: pairing.rs (HPKE AuthPSK seal/open with the QR secret as PSK, SAS code).
* kv-sync: onboarding.rs (kestrove://setup/v1 and kestrove://join/v1 codec with
  strict validation), ApiClient ticket/join calls and login_with_ticket, test
  server support for tickets and rendezvous.
* kv-ffi: parse/encode_setup_payload, setup_payload_for_kit, add_device_payload,
  revoke_enrollment_ticket, login_with_setup, join_start/poll/cancel,
  join_approve/send. The Secret Key is kept locally, HPKE-sealed to enc_pk.
* Tests: codec round trips and rejections, AuthPSK tamper cases, end-to-end
  Add-device QR and scan-to-join between two clients, forged bundles, bound
  tickets; live test extended with scan-to-join.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
This commit is contained in:
2026-10-17 23:49:48 +09:00
co-authored by Claude Opus 5.5
parent f20380d293
commit 266fb90e45
14 changed files with 1409 additions and 17 deletions
+77 -6
View File
@@ -36,6 +36,10 @@ mod passkeys;
pub use passkeys::{kestrove_aaguid, PasskeyAssertion, PasskeyRegistration, PasskeySummary};
mod import;
pub use import::{DuplicateHandling, ImportFormat, ImportOptions, ImportPreview, ImportReport};
mod onboarding;
pub use onboarding::{
encode_setup_payload, parse_setup_payload, AddDevicePayload, JoinConfirm, JoinProgress, JoinSession, SetupInfo,
};
#[derive(Debug, thiserror::Error, uniffi::Error)]
pub enum CoreError {
@@ -279,6 +283,10 @@ struct AccountRecord {
primary_vault: Uuid,
#[serde(default)]
local_unlock: Option<LocalUnlockRecord>,
/// The Secret Key, HPKE-sealed to `enc_pk` (docs/02 "Secrets the user holds"), so an unlocked device can
/// build setup payloads (docs/10). Absent on accounts created before this field until the next password unlock.
#[serde(default)]
secret_key: Option<Envelope>,
}
/// Keyset (and OPAQUE input) wrapped under the biometric-protected Local Unlock Key.
@@ -324,6 +332,8 @@ pub struct VaultCore {
store: Arc<dyn Store>,
inner: Mutex<Inner>,
sync_lock: tokio::sync::Mutex<()>,
/// In-flight scan-to-join, on either side (docs/10 §4).
join: Mutex<Option<onboarding::JoinState>>,
}
fn luk_subkey(luk: &[u8], info: &[u8]) -> Result<SymKey> {
@@ -332,6 +342,17 @@ fn luk_subkey(luk: &[u8], info: &[u8]) -> Result<SymKey> {
Ok(SymKey::from_bytes(Uuid::nil(), out))
}
fn secret_key_aad(account_id: Uuid) -> Vec<u8> {
let mut aad = b"kestrove/v1/local/secret-key".to_vec();
aad.extend_from_slice(account_id.as_bytes());
aad
}
/// Seals the Secret Key to the account's own encryption key, for local storage only.
fn seal_secret_key(account_id: Uuid, enc_pk: &[u8], sk: &SecretKey) -> Result<Envelope> {
Ok(kv_crypto::seal::seal_to(account_id, enc_pk, sk.expose(), &secret_key_aad(account_id))?)
}
fn auth_input_aad(account_id: Uuid) -> Vec<u8> {
let mut aad = b"kestrove/v1/local/auth-input".to_vec();
aad.extend_from_slice(account_id.as_bytes());
@@ -407,6 +428,7 @@ impl VaultCore {
sig_pk: keyset.sig_public().to_vec(),
primary_vault: vault_id,
local_unlock: None,
secret_key: Some(seal_secret_key(account_id, &keyset.enc.public_bytes(), &secret_key)?),
};
self.save_account(&account)?;
inner.account = Some(account);
@@ -426,6 +448,12 @@ impl VaultCore {
let auk = SymKey::from_bytes(Uuid::nil(), *derived.auk);
let keyset = AccountKeyset::unwrap(acct.account_id, &auk, &acct.wrapped_keyset)?;
let vault_key = self.open_vault_key(&keyset, acct.primary_vault)?;
if acct.secret_key.is_none() {
let mut acct = acct.clone();
acct.secret_key = Some(seal_secret_key(acct.account_id, &keyset.enc.public_bytes(), &sk)?);
self.save_account(&acct)?;
inner.account = Some(acct);
}
inner.unlocked = Some(Unlocked {
keyset: Arc::new(keyset),
vault_key: Arc::new(vault_key),
@@ -740,8 +768,28 @@ impl VaultCore {
secret_key: String,
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
}
/// Pull, merge, push and verify every vault. Renews an expired session automatically when the
/// master password was used to unlock in this session; otherwise fails with `NotAuthenticated`.
pub async fn sync_now(&self) -> Result<SyncReport> {
self.sync_now_inner().await
}
}
impl VaultCore {
/// `login`, optionally presenting an enrollment ticket and checking the server holds `expect_account`.
async fn login_inner(
&self,
master_password: String,
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();
// A single-tenant server tells a new device which account it holds; an existing device
// keeps using the parameters it already trusts (no downgrade by the server).
@@ -752,8 +800,13 @@ impl VaultCore {
(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() });
}
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_ticket(&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);
@@ -778,11 +831,16 @@ impl VaultCore {
sig_pk: info.sig_pk,
primary_vault: Uuid::nil(), // chosen after the first sync
local_unlock: None,
secret_key: None,
}
}
};
let keyset = AccountKeyset::unwrap(account_id, &auk, &acct.wrapped_keyset)?;
let vault_key = self.open_vault_key(&keyset, acct.primary_vault)?;
let mut acct = acct;
if acct.secret_key.is_none() {
acct.secret_key = Some(seal_secret_key(account_id, &keyset.enc.public_bytes(), &sk)?);
}
self.save_account(&acct)?;
let mut inner = self.lock_inner();
inner.account = Some(acct);
@@ -794,9 +852,7 @@ impl VaultCore {
Ok(LoginResult::Ok)
}
/// Pull, merge, push and verify every vault. Renews an expired session automatically when the
/// master password was used to unlock in this session; otherwise fails with `NotAuthenticated`.
pub async fn sync_now(&self) -> Result<SyncReport> {
async fn sync_now_inner(&self) -> Result<SyncReport> {
let _guard = self.sync_lock.lock().await;
let api = self.api()?;
let (_, u) = self.unlocked_snapshot()?;
@@ -825,7 +881,10 @@ impl VaultCore {
vaults_skipped: report.vaults_skipped.len() as u32,
})
}
}
#[uniffi::export(async_runtime = "tokio")]
impl VaultCore {
/// Devices enrolled on the account, including ones waiting for approval.
pub async fn list_devices(&self) -> Result<Vec<DeviceDto>> {
let devices = self.api()?.devices().await?;
@@ -889,6 +948,7 @@ impl VaultCore {
store,
inner: Mutex::new(Inner { account, server, ..Default::default() }),
sync_lock: tokio::sync::Mutex::new(()),
join: Mutex::new(None),
}))
}
@@ -960,9 +1020,20 @@ impl VaultCore {
account_id: Uuid,
auth_input: &[u8],
totp: Option<String>,
) -> Result<LoginResult> {
self.do_login_ticket(api, account_id, auth_input, totp, None).await
}
async fn do_login_ticket(
&self,
api: &ApiClient,
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)
+296
View File
@@ -0,0 +1,296 @@
//! Easy onboarding (kestrove-spec/docs/10): Setup QR / Setup Code, enrollment tickets and scan-to-join.
use base64::{engine::general_purpose::URL_SAFE_NO_PAD, Engine};
use kv_crypto::{pairing, pairing::PairingKeypair, random_bytes, secret_key::SecretKey};
use kv_proto::{DeviceEnroll, JoinState as WireJoinState};
use kv_sync::onboarding::{JoinQr, SetupPayload};
use sha2::{Digest, Sha256};
use zeroize::Zeroizing;
use crate::{
device_key_fingerprint, internal, parse_id, platform_to_dto, secret_key_aad, CoreError, LoginResult, PlatformDto,
Result, VaultCore,
};
/// A parsed, validated setup payload: everything a new device needs except the master password.
#[derive(Clone, PartialEq, Eq, uniffi::Record)]
pub struct SetupInfo {
/// Normalized server URL.
pub server_url: String,
/// Standard base64 SPKI pins, the format `configure_server` takes. Empty = WebPKI only.
pub spki_pins: Vec<String>,
pub account_id: String,
/// Display form `K1-…`. Store it like the app stores a typed Secret Key.
pub secret_key: String,
/// Enrollment ticket; `login_with_setup` presents it so no manual approval is needed.
pub ticket: Option<String>,
}
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, uniffi::Record)]
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 (desktop): a QR for a signed-in phone to scan.
#[derive(Debug, Clone, PartialEq, Eq, uniffi::Record)]
pub struct JoinSession {
pub qr: String,
/// Unix seconds.
pub expires_at: i64,
}
/// Result of `join_poll`. `code` appears once a phone has scanned the QR; show it so the user can compare.
/// `setup` appears once the phone sent the bundle: then ask for the master password and call `login_with_setup`.
#[derive(Debug, Clone, PartialEq, Eq, uniffi::Record)]
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, uniffi::Record)]
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,
}
/// Local state of an in-flight scan-to-join.
pub(crate) enum JoinState {
/// This device 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 device scanned a QR and said hello; waiting for the user to confirm the code.
Approving { qr: JoinQr, keypair: PairingKeypair, device: DeviceEnroll },
}
fn invalid(msg: &str) -> CoreError {
CoreError::Invalid { msg: msg.into() }
}
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: i.server_url.clone(),
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(),
})
}
/// Validates a scanned or pasted Setup Code (`kestrove://setup/v1?…`, docs/10 §2).
#[uniffi::export]
pub fn parse_setup_payload(payload: String) -> Result<SetupInfo> {
Ok(to_info(SetupPayload::parse(&payload)?))
}
/// Canonical encoding of a setup payload (for tools and tests; apps use `setup_payload_for_kit`).
#[uniffi::export]
pub fn encode_setup_payload(info: SetupInfo) -> Result<String> {
Ok(from_info(&info)?.encode()?.to_string())
}
#[uniffi::export(async_runtime = "tokio")]
impl VaultCore {
/// 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> {
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: String) -> Result<()> {
Ok(self.api()?.revoke_ticket(parse_id(&ticket_id)?).await?)
}
/// New device: configures the server and pins from `setup`, then logs in with the master password and the
/// Secret Key from `setup`, presenting its ticket (if any) so no manual approval is needed. Call
/// `set_device_signer` first. Then call `sync_now`.
pub async fn login_with_setup(&self, setup: SetupInfo, master_password: String) -> Result<LoginResult> {
let p = from_info(&setup)?;
if self.lock_inner().account.as_ref().is_some_and(|a| a.account_id != p.account_id) {
return Err(invalid("this device holds a different account"));
}
self.configure_server(p.server_url.clone(), 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 device (no camera): opens a rendezvous on `server_url` and returns the QR to show.
/// Then call `join_poll` every 2 seconds. Call `set_device_signer` first.
pub async fn join_start(&self, server_url: String) -> Result<JoinSession> {
if self.has_account() {
return Err(invalid("this device already has an account"));
}
// Unpinned for now: only ciphertext and public data cross this connection. The pins arrive in the bundle.
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 device: 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(kv_sync::SyncError::NotFound) => {
self.join_cancel();
return Err(CoreError::NotFound);
}
other => other?,
};
let mut guard = self.lock_join();
let Some(JoinState::Joining { keypair, qr_secret, sender_pub, .. }) = guard.as_mut() else {
return Err(invalid("join was cancelled"));
};
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 = 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 device (signed in, unlocked): answers a scanned 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: String) -> 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 device, 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()?;
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?)
}
}
impl VaultCore {
fn lock_join(&self) -> std::sync::MutexGuard<'_, Option<JoinState>> {
self.join.lock().unwrap_or_else(|p| p.into_inner())
}
/// This device'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)?;
let sealed = acct
.secret_key
.as_ref()
.ok_or_else(|| invalid("unlock with the master password and Secret Key once to enable setup codes"))?;
let raw = u.keyset.enc.open(sealed, &secret_key_aad(acct.account_id))?;
let raw: [u8; 16] = raw.as_slice().try_into().map_err(|_| internal("stored Secret Key length"))?;
Ok(SetupPayload {
server_url: server.url,
spki_pins: server.pins,
account_id: acct.account_id,
secret_key: SecretKey::from_bytes(raw).to_display(),
ticket,
})
}
}
+200
View File
@@ -355,3 +355,203 @@ async fn delete_item_seals_the_tombstone_and_forged_deletes_are_refused() {
}
assert_eq!(b.get_item(kept).unwrap().password.as_deref(), Some("pw1"));
}
// ---------------------------------------------------------------- onboarding (docs/10)
/// A signed-in first device "a" with one item, on a server that requires approval for new devices.
async fn phone_with_item(server: &TestServer) -> (Arc<VaultCore>, String, String) {
let a = VaultCore::new_in_memory();
let sk = a.create_account(PW.into()).unwrap();
connect(&a, server);
a.register_account(SETUP_TOKEN.into()).await.unwrap();
let id = a.save_item(login_item("Bank", "pw-a")).unwrap();
a.sync_now().await.unwrap();
server.state().require_approval = true;
(a, sk, id)
}
#[tokio::test]
async fn kit_payload_carries_the_secret_key_and_survives_biometric_unlock() {
let server = TestServer::start().await;
let a = VaultCore::new_in_memory();
assert!(matches!(a.setup_payload_for_kit(), Err(CoreError::NoAccount)));
let sk = a.create_account(PW.into()).unwrap();
assert!(matches!(a.setup_payload_for_kit(), Err(CoreError::NotConfigured)));
a.configure_server(server.url(), vec!["47DEQpj8HBSa+/TImW+5JCeuQeRkm5NMpJWZG3hSuFU=".into()]).unwrap();
let kit = a.setup_payload_for_kit().unwrap();
assert!(kit.starts_with("kestrove://setup/v1?s=http%3A%2F%2F127.0.0.1"), "{kit}");
let info = parse_setup_payload(kit.clone()).unwrap();
assert_eq!((info.secret_key.as_str(), info.ticket.as_ref()), (sk.as_str(), None));
assert_eq!(info.account_id, a.account_id().unwrap());
assert_eq!(info.spki_pins, vec!["47DEQpj8HBSa+/TImW+5JCeuQeRkm5NMpJWZG3hSuFU=".to_string()]);
assert_eq!(encode_setup_payload(info).unwrap(), kit);
// After a biometric unlock the core still knows the Secret Key; locked, it does not.
let luk = a.enable_local_unlock().unwrap();
a.lock();
assert!(matches!(a.setup_payload_for_kit(), Err(CoreError::Locked)));
a.unlock_with_local_key(luk).unwrap();
assert_eq!(a.setup_payload_for_kit().unwrap(), kit);
assert!(matches!(parse_setup_payload("kestrove://setup/v2?s=x".into()), Err(CoreError::Invalid { .. })));
}
#[tokio::test]
async fn add_device_qr_needs_only_the_password_and_no_approval() {
let server = TestServer::start().await;
let (a, sk, id) = phone_with_item(&server).await;
let add = a.add_device_payload().await.unwrap();
let setup = parse_setup_payload(add.payload.clone()).unwrap();
assert!(setup.ticket.is_some());
// The new device has nothing configured but its key; the QR and the password are enough.
let b = VaultCore::new_in_memory();
b.set_device_signer(
Arc::new(TestSigner(SoftwareSigner::generate(Uuid::new_v4()))),
"tablet".into(),
PlatformDto::Android,
)
.unwrap();
assert!(matches!(
b.login_with_setup(setup.clone(), "wrong password!!".into()).await,
Err(CoreError::BadCredentials)
));
assert_eq!(b.login_with_setup(setup.clone(), PW.into()).await.unwrap(), LoginResult::Ok);
b.sync_now().await.unwrap();
assert_eq!(b.get_item(id).unwrap().password.as_deref(), Some("pw-a"));
// B can now produce the same Kit payload.
assert_eq!(parse_setup_payload(b.setup_payload_for_kit().unwrap()).unwrap().secret_key, sk);
// Single use: a third device with the same QR waits for approval.
let c = VaultCore::new_in_memory();
connect(&c, &server);
assert_eq!(c.login_with_setup(setup, PW.into()).await.unwrap(), LoginResult::PendingApproval);
// A cancelled QR is revoked.
let add = a.add_device_payload().await.unwrap();
a.revoke_enrollment_ticket(add.ticket_id).await.unwrap();
let d = VaultCore::new_in_memory();
connect(&d, &server);
let setup = parse_setup_payload(add.payload).unwrap();
assert_eq!(d.login_with_setup(setup, PW.into()).await.unwrap(), LoginResult::PendingApproval);
}
#[tokio::test]
async fn scan_to_join_between_two_clients() {
let server = TestServer::start().await;
let (phone, sk, id) = phone_with_item(&server).await;
// Desktop: only knows the server URL.
let desktop = VaultCore::new_in_memory();
desktop
.set_device_signer(
Arc::new(TestSigner(SoftwareSigner::generate(Uuid::new_v4()))),
"Sami's MacBook".into(),
PlatformDto::Macos,
)
.unwrap();
assert!(matches!(desktop.join_poll().await, Err(CoreError::Invalid { .. })));
let session = desktop.join_start(format!("{}/", server.url())).await.unwrap();
assert!(session.qr.starts_with("kestrove://join/v1?") && session.qr.len() < 200);
assert_eq!(desktop.join_poll().await.unwrap(), JoinProgress { code: None, setup: None });
// Phone: a QR naming another server is refused before anything is sent.
let elsewhere = session.qr.replace("127.0.0.1", "localhost");
assert!(matches!(phone.join_approve(elsewhere).await, Err(CoreError::Invalid { .. })));
let confirm = phone.join_approve(session.qr.clone()).await.unwrap();
assert_eq!((confirm.device_name.as_str(), confirm.platform), ("Sami's MacBook", PlatformDto::Macos));
assert_eq!(confirm.key_fingerprint, desktop.device_key_fingerprint().unwrap());
// Both screens show the same code before anything secret moves.
let progress = desktop.join_poll().await.unwrap();
assert_eq!((progress.code.as_deref(), progress.setup.as_ref()), (Some(confirm.code.as_str()), None));
{
let st = server.state();
let rv = st.joins.values().next().unwrap();
assert!(rv.bundle.is_none() && rv.hello.is_some());
}
phone.join_send().await.unwrap();
assert!(matches!(phone.join_send().await, Err(CoreError::Invalid { .. })), "one send per approval");
{
// The server only ever holds ciphertext.
let st = server.state();
let bundle = st.joins.values().next().unwrap().bundle.as_ref().unwrap();
let raw = serde_json::to_string(bundle).unwrap();
assert!(!raw.contains("K1") && !raw.contains("kestrove://"));
assert_eq!(st.tickets.values().filter(|t| t.bound_key_sha256.is_some()).count(), 1);
}
let done = desktop.join_poll().await.unwrap();
let setup = done.setup.expect("bundle arrived");
assert_eq!((setup.secret_key.as_str(), setup.server_url.as_str()), (sk.as_str(), server.url().as_str()));
assert!(setup.ticket.is_some());
// One read: the rendezvous is gone.
assert!(matches!(desktop.join_poll().await, Err(CoreError::Invalid { .. })));
assert!(server.state().joins.is_empty());
// Only the master password is left to type, and no approval is needed.
assert_eq!(desktop.login_with_setup(setup, PW.into()).await.unwrap(), LoginResult::Ok);
desktop.sync_now().await.unwrap();
assert_eq!(desktop.get_item(id).unwrap().password.as_deref(), Some("pw-a"));
assert!(matches!(desktop.join_start(server.url()).await, Err(CoreError::Invalid { .. })));
}
#[tokio::test]
async fn scan_to_join_ticket_is_bound_to_the_joining_device() {
let server = TestServer::start().await;
let (phone, _, _) = phone_with_item(&server).await;
let desktop = VaultCore::new_in_memory();
connect(&desktop, &server);
let session = desktop.join_start(server.url()).await.unwrap();
phone.join_approve(session.qr).await.unwrap();
desktop.join_poll().await.unwrap();
phone.join_send().await.unwrap();
let setup = desktop.join_poll().await.unwrap().setup.unwrap();
// Someone who intercepted the setup (and knows the password) on another device key still needs approval.
let thief = VaultCore::new_in_memory();
connect(&thief, &server);
assert_eq!(thief.login_with_setup(setup.clone(), PW.into()).await.unwrap(), LoginResult::PendingApproval);
assert_eq!(desktop.login_with_setup(setup, PW.into()).await.unwrap(), LoginResult::Ok);
}
#[tokio::test]
async fn scan_to_join_rejects_a_forged_bundle() {
let server = TestServer::start().await;
let (phone, _, _) = phone_with_item(&server).await;
let desktop = VaultCore::new_in_memory();
connect(&desktop, &server);
// Round 1: the server keeps the phone's hello but substitutes a bundle it sealed itself (no QR secret).
let session = desktop.join_start(server.url()).await.unwrap();
phone.join_approve(session.qr.clone()).await.unwrap();
desktop.join_poll().await.unwrap();
{
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"x").unwrap();
server.state().joins.values_mut().next().unwrap().bundle = Some(forged);
}
assert!(matches!(desktop.join_poll().await, Err(CoreError::Invalid { .. })));
phone.join_cancel();
// Round 2: it also swaps the hello key for its own.
let session = desktop.join_start(server.url()).await.unwrap();
phone.join_approve(session.qr.clone()).await.unwrap();
desktop.join_poll().await.unwrap();
let qr = kv_sync::onboarding::JoinQr::parse(&session.qr).unwrap();
let forger = kv_crypto::pairing::PairingKeypair::generate();
let rid = base64::Engine::decode(&base64::engine::general_purpose::URL_SAFE_NO_PAD, &qr.rendezvous_id).unwrap();
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);
}
// The swapped hello key is noticed (the code was shown for the phone's key) and nothing is accepted.
assert!(matches!(desktop.join_poll().await, Err(CoreError::Invalid { .. })));
assert!(matches!(desktop.join_poll().await, Err(CoreError::Invalid { .. })), "state cleared");
}