//! 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, 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, } 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", &"") .field("ticket", &self.ticket.as_ref().map(|_| "")) .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, pub setup: Option, } /// 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>, }, /// 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 { 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 { 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 { 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 { 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 { let created = self.revoked(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<()> { self.revoked(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 { 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 { 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 { 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 { 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 = self.revoked(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 = self.revoked(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())?; self.revoked(api.join_send(&qr.rendezvous_id, bundle).await) } } impl VaultCore { fn lock_join(&self) -> std::sync::MutexGuard<'_, Option> { 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) -> Result { 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, }) } }