Files
kestrove-core/crates/kv-ffi/src/onboarding.rs
T
samiandClaude Opus 5.5 83d56d43e2 Removed devices: DeviceRevoked, identity reset and one retry with the ticket
kv-sync: SyncError::DeviceRevoked for 403 device_revoked (drops the session);
the test server answers it after OPAQUE (before the ticket) and for valid
signatures by a revoked device's key.

kv-ffi: CoreError::DeviceRevoked; the foreign DeviceSigner gains reset() (new key
and id). On device_revoked the core resets the identity, forgets the session
and the kept OPAQUE input (no silent re-enrolment) and keeps the vault data; a
login carrying a ticket retries once as the new device. reset_device_identity()
runs the reset on demand. Signed calls (sync, devices, tickets, join) reset too.

kv-wasm: DeviceRevoked; the browser binding replaces the WebCrypto key and id in
IndexedDB (same name and platform), loginWithSetup retries once with a ticket;
resetDeviceIdentity().

Tests: end to end revoked -> reset -> re-enrol with the same ticket (kv-ffi and
web core), no-ticket path, in-browser IndexedDB reset.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-20 22:25:03 +09:00

297 lines
13 KiB
Rust

//! 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.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<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 = 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<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,
})
}
}