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:
@@ -22,6 +22,7 @@ kv-store.workspace = true
|
||||
kv-sync.workspace = true
|
||||
kv-import.workspace = true
|
||||
serde.workspace = true
|
||||
base64.workspace = true
|
||||
hkdf.workspace = true
|
||||
sha2.workspace = true
|
||||
tokio = { workspace = true }
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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,
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -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");
|
||||
}
|
||||
|
||||
@@ -1,13 +1,15 @@
|
||||
//! Two devices against a real, freshly started kestrove-server. Ignored by default.
|
||||
//!
|
||||
//! ```sh
|
||||
//! # in kestrove-server (empty database, so it prints SETUP_TOKEN=… on boot):
|
||||
//! KV_DATABASE=sqlite::memory: KV_BIND=127.0.0.1:8080 KV_PUBLIC_URL=http://127.0.0.1:8080 cargo run
|
||||
//! # in kestrove-server (empty database; without KV_SETUP_CODE it prints SETUP_TOKEN=… on boot instead):
|
||||
//! KV_DATABASE=sqlite::memory: KV_BIND=127.0.0.1:8080 KV_PUBLIC_URL=http://127.0.0.1:8080 \
|
||||
//! KV_SETUP_CODE=live-test-setup-code cargo run
|
||||
//! # here:
|
||||
//! KESTROVE_SERVER_URL=http://127.0.0.1:8080 KESTROVE_SETUP_TOKEN=<token> \
|
||||
//! KESTROVE_SERVER_URL=http://127.0.0.1:8080 KESTROVE_SETUP_TOKEN=live-test-setup-code \
|
||||
//! cargo test -p kv-ffi --test live_server -- --ignored --nocapture
|
||||
//! ```
|
||||
//! The setup token works once, so restart the server with an empty database for every run.
|
||||
//! The setup code works once, so restart the server with an empty database for every run. The run takes about
|
||||
//! four minutes because it waits out the server's rate limit on the login endpoints.
|
||||
|
||||
use std::sync::Arc;
|
||||
|
||||
@@ -107,6 +109,22 @@ async fn two_devices_against_local_server() {
|
||||
b.sync_now().await.unwrap();
|
||||
assert!(matches!(b.get_item(temp), Err(CoreError::NotFound)));
|
||||
|
||||
// Scan-to-join (docs/10 §4): a third device that knows only the server URL needs no approval.
|
||||
tokio::time::sleep(std::time::Duration::from_secs(61)).await; // rate limit again
|
||||
let c = VaultCore::new_in_memory();
|
||||
connect(&c, &url, "device C");
|
||||
let session = c.join_start(url.clone()).await.unwrap();
|
||||
let confirm = a.join_approve(session.qr).await.unwrap();
|
||||
assert_eq!(confirm.device_name, "device C");
|
||||
assert_eq!(c.join_poll().await.unwrap().code.as_deref(), Some(confirm.code.as_str()));
|
||||
a.join_send().await.unwrap();
|
||||
tokio::time::sleep(std::time::Duration::from_millis(1100)).await; // the server allows one poll per second
|
||||
let setup = c.join_poll().await.unwrap().setup.expect("bundle");
|
||||
assert_eq!(setup.secret_key, sk);
|
||||
assert_eq!(c.login_with_setup(setup, pw.into()).await.unwrap(), LoginResult::Ok);
|
||||
c.sync_now().await.unwrap();
|
||||
assert!(c.list_items().unwrap().iter().any(|i| i.title == "Bank"));
|
||||
|
||||
b.logout().await.unwrap();
|
||||
println!("live sync OK against {url}");
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user