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]>
297 lines
13 KiB
Rust
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,
|
|
})
|
|
}
|
|
}
|