From 266fb90e45c58cd5a6aac0deb8b91a6f16029aba Mon Sep 17 00:00:00 2001 From: samialabri Date: Sat, 17 Oct 2026 23:49:48 +0900 Subject: [PATCH] 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 --- Cargo.lock | 1 + crates/kv-crypto/src/lib.rs | 1 + crates/kv-crypto/src/pairing.rs | 157 +++++++++++++++ crates/kv-ffi/Cargo.toml | 1 + crates/kv-ffi/src/lib.rs | 83 +++++++- crates/kv-ffi/src/onboarding.rs | 296 ++++++++++++++++++++++++++++ crates/kv-ffi/src/tests.rs | 200 +++++++++++++++++++ crates/kv-ffi/tests/live_server.rs | 26 ++- crates/kv-proto/src/lib.rs | 138 +++++++++++++ crates/kv-sync/src/client.rs | 74 ++++++- crates/kv-sync/src/lib.rs | 2 + crates/kv-sync/src/onboarding.rs | 300 +++++++++++++++++++++++++++++ crates/kv-sync/src/test_server.rs | 145 +++++++++++++- crates/kv-sync/src/wire.rs | 2 + 14 files changed, 1409 insertions(+), 17 deletions(-) create mode 100644 crates/kv-crypto/src/pairing.rs create mode 100644 crates/kv-ffi/src/onboarding.rs create mode 100644 crates/kv-sync/src/onboarding.rs diff --git a/Cargo.lock b/Cargo.lock index 3d04d69..e91dabe 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -1347,6 +1347,7 @@ dependencies = [ name = "kv-ffi" version = "0.1.0" dependencies = [ + "base64 0.22.1", "ciborium", "hkdf", "kv-crypto", diff --git a/crates/kv-crypto/src/lib.rs b/crates/kv-crypto/src/lib.rs index 55fb5db..7eb056f 100644 --- a/crates/kv-crypto/src/lib.rs +++ b/crates/kv-crypto/src/lib.rs @@ -9,6 +9,7 @@ pub mod aad; pub mod aead; pub mod kdf; pub mod keys; +pub mod pairing; pub mod seal; pub mod secret_key; diff --git a/crates/kv-crypto/src/pairing.rs b/crates/kv-crypto/src/pairing.rs new file mode 100644 index 0000000..16c1ae6 --- /dev/null +++ b/crates/kv-crypto/src/pairing.rs @@ -0,0 +1,157 @@ +//! Scan-to-join encryption (kestrove-spec/docs/10 §4): HPKE (RFC 9180) in AuthPSK mode with the suite of +//! [`crate::seal`], plus the short authentication string both screens show. +//! +//! * The joining device's ephemeral key and the PSK (`qr_secret`) travel only through the QR code, so the +//! server can neither read nor forge a bundle. +//! * Auth mode binds the bundle to the approving device's ephemeral key from the `hello`, which the code +//! is computed over. + +use hpke::{ + aead::ChaCha20Poly1305, kdf::HkdfSha256, kem::X25519HkdfSha256, Deserializable, Kem as KemTrait, OpModeR, OpModeS, + PskBundle, Serializable, +}; +use kv_proto::{Alg, Envelope, FORMAT_VERSION}; +use rand_core::OsRng; +use sha2::{Digest, Sha256}; +use uuid::Uuid; +use zeroize::Zeroizing; + +use crate::{CryptoError, Result}; + +type Kem = X25519HkdfSha256; +const INFO: &[u8] = b"kestrove/v1/join"; +const SAS_LABEL: &[u8] = b"kestrove/v1/join/sas"; +/// Length of `qr_secret`, the HPKE PSK. +pub const PSK_LEN: usize = 32; + +/// An ephemeral X25519 key for one join attempt (either side). +pub struct PairingKeypair { + sk: ::PrivateKey, + pk: ::PublicKey, +} + +impl PairingKeypair { + pub fn generate() -> Self { + let (sk, pk) = Kem::gen_keypair(&mut OsRng); + Self { sk, pk } + } + + pub fn public_bytes(&self) -> [u8; 32] { + self.pk.to_bytes().into() + } +} + +fn aad(rid: &[u8]) -> Vec { + let mut aad = INFO.to_vec(); + aad.push(0); + aad.extend_from_slice(rid); + aad +} + +fn public(bytes: &[u8]) -> Result<::PublicKey> { + ::PublicKey::from_bytes(bytes).map_err(|_| CryptoError::Malformed) +} + +/// Approving device: seals `plaintext` to the joining device's key from the QR. +pub fn seal( + sender: &PairingKeypair, + recipient_pub: &[u8], + psk: &[u8], + rid: &[u8], + plaintext: &[u8], +) -> Result { + if psk.len() != PSK_LEN { + return Err(CryptoError::Malformed); + } + let mode = OpModeS::AuthPsk((sender.sk.clone(), sender.pk.clone()), PskBundle { psk, psk_id: rid }); + let (encapped, ct) = hpke::single_shot_seal::( + &mode, + &public(recipient_pub)?, + INFO, + plaintext, + &aad(rid), + &mut OsRng, + ) + .map_err(|_| CryptoError::Encrypt)?; + Ok(Envelope { + v: FORMAT_VERSION, + alg: Alg::HpkeX25519ChaCha, + kid: Uuid::nil(), + n: vec![], + ct, + enc: encapped.to_bytes().to_vec(), + }) +} + +/// Joining device: opens a bundle from the sender whose key arrived in the `hello`. +pub fn open( + recipient: &PairingKeypair, + sender_pub: &[u8], + psk: &[u8], + rid: &[u8], + env: &Envelope, +) -> Result>> { + if env.v != FORMAT_VERSION || env.alg != Alg::HpkeX25519ChaCha { + return Err(CryptoError::Unsupported); + } + let encapped = ::EncappedKey::from_bytes(&env.enc).map_err(|_| CryptoError::Malformed)?; + let mode = OpModeR::AuthPsk(public(sender_pub)?, PskBundle { psk, psk_id: rid }); + hpke::single_shot_open::( + &mode, + &recipient.sk, + &encapped, + INFO, + &env.ct, + &aad(rid), + ) + .map(Zeroizing::new) + .map_err(|_| CryptoError::Decrypt) +} + +/// The 6-digit code both screens show: `SHA-256(label || 0x00 || rid || D_pub || P_pub)`, first 4 bytes as a +/// big-endian u32, mod 10^6, formatted `"123 456"`. +pub fn sas(rid: &[u8], joining_pub: &[u8], approving_pub: &[u8]) -> String { + let mut h = Sha256::new(); + h.update(SAS_LABEL); + h.update([0]); + h.update(rid); + h.update(joining_pub); + h.update(approving_pub); + let d = h.finalize(); + let n = u32::from_be_bytes([d[0], d[1], d[2], d[3]]) % 1_000_000; + format!("{:03} {:03}", n / 1000, n % 1000) +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn seal_open_and_tamper() { + let (desk, phone) = (PairingKeypair::generate(), PairingKeypair::generate()); + let (psk, rid) = ([3u8; 32], b"rendezvous-id-16"); + let env = seal(&phone, &desk.public_bytes(), &psk, rid, b"setup payload").unwrap(); + assert_eq!(&**open(&desk, &phone.public_bytes(), &psk, rid, &env).unwrap(), b"setup payload"); + + // Wrong PSK (no QR), wrong sender (server swapped the hello), other rendezvous, other recipient. + assert!(open(&desk, &phone.public_bytes(), &[4u8; 32], rid, &env).is_err()); + assert!(open(&desk, &PairingKeypair::generate().public_bytes(), &psk, rid, &env).is_err()); + assert!(open(&desk, &phone.public_bytes(), &psk, b"another-rid-0000", &env).is_err()); + assert!(open(&PairingKeypair::generate(), &phone.public_bytes(), &psk, rid, &env).is_err()); + let mut bad = env.clone(); + bad.ct[0] ^= 1; + assert!(open(&desk, &phone.public_bytes(), &psk, rid, &bad).is_err()); + assert!(seal(&phone, &desk.public_bytes(), &[1u8; 16], rid, b"x").is_err(), "short psk"); + assert!(seal(&phone, &[0u8; 5], &psk, rid, b"x").is_err()); + } + + #[test] + fn sas_is_stable_and_order_sensitive() { + // kestrove-spec/vectors/onboarding.json + assert_eq!(sas(&[1; 16], &[2; 32], &[3; 32]), "238 148"); + assert_ne!(sas(&[1; 16], &[2; 32], &[3; 32]), sas(&[1; 16], &[3; 32], &[2; 32])); + let code = sas(&[1; 16], &[2; 32], &[3; 32]); + assert_eq!(code.len(), 7); + assert!(code.chars().enumerate().all(|(i, c)| if i == 3 { c == ' ' } else { c.is_ascii_digit() })); + } +} diff --git a/crates/kv-ffi/Cargo.toml b/crates/kv-ffi/Cargo.toml index fb2d959..dc56391 100644 --- a/crates/kv-ffi/Cargo.toml +++ b/crates/kv-ffi/Cargo.toml @@ -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 } diff --git a/crates/kv-ffi/src/lib.rs b/crates/kv-ffi/src/lib.rs index a87d53c..93366f5 100644 --- a/crates/kv-ffi/src/lib.rs +++ b/crates/kv-ffi/src/lib.rs @@ -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, + /// 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, } /// Keyset (and OPAQUE input) wrapped under the biometric-protected Local Unlock Key. @@ -324,6 +332,8 @@ pub struct VaultCore { store: Arc, inner: Mutex, sync_lock: tokio::sync::Mutex<()>, + /// In-flight scan-to-join, on either side (docs/10 §4). + join: Mutex>, } fn luk_subkey(luk: &[u8], info: &[u8]) -> Result { @@ -332,6 +342,17 @@ fn luk_subkey(luk: &[u8], info: &[u8]) -> Result { Ok(SymKey::from_bytes(Uuid::nil(), out)) } +fn secret_key_aad(account_id: Uuid) -> Vec { + 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 { + 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 { 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, ) -> Result { - 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 { + 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, + ticket: Option, + expect_account: Option, + ) -> Result { + 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 { + async fn sync_now_inner(&self) -> Result { 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> { 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, + ) -> Result { + 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, + ticket: Option, ) -> Result { 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) diff --git a/crates/kv-ffi/src/onboarding.rs b/crates/kv-ffi/src/onboarding.rs new file mode 100644 index 0000000..ae23fdd --- /dev/null +++ b/crates/kv-ffi/src/onboarding.rs @@ -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, + 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.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 { + 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 = 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> { + 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, + }) + } +} diff --git a/crates/kv-ffi/src/tests.rs b/crates/kv-ffi/src/tests.rs index e75306f..c30f044 100644 --- a/crates/kv-ffi/src/tests.rs +++ b/crates/kv-ffi/src/tests.rs @@ -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, 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"); +} diff --git a/crates/kv-ffi/tests/live_server.rs b/crates/kv-ffi/tests/live_server.rs index 85060f1..cc6d8b2 100644 --- a/crates/kv-ffi/tests/live_server.rs +++ b/crates/kv-ffi/tests/live_server.rs @@ -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= \ +//! 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}"); } diff --git a/crates/kv-proto/src/lib.rs b/crates/kv-proto/src/lib.rs index d607128..0cc65a3 100644 --- a/crates/kv-proto/src/lib.rs +++ b/crates/kv-proto/src/lib.rs @@ -16,6 +16,17 @@ pub const API_VERSION: u32 = 1; pub const MIN_KDF_M_KIB: u32 = 64 * 1024; pub const MIN_KDF_T: u32 = 3; +/// Enrollment tickets live this long (docs/10 §3). +pub const TICKET_TTL_SECS: i64 = 600; +/// At most this many unused, unexpired, unrevoked tickets at once. +pub const MAX_LIVE_TICKETS: i64 = 5; +/// A scan-to-join rendezvous lives this long (docs/10 §4). +pub const JOIN_TTL_SECS: i64 = 300; +/// Largest serialized bundle a rendezvous accepts. +pub const JOIN_MAX_BUNDLE: usize = 4096; +/// Header carrying the joining device's `read_token`. +pub const HEADER_JOIN_TOKEN: &str = "kv-join-token"; + /// base64url (no padding) serde helper for byte fields. pub mod b64 { use base64::{engine::general_purpose::URL_SAFE_NO_PAD, Engine}; @@ -31,6 +42,25 @@ pub mod b64 { } } +/// base64url (no padding) serde helper for optional byte fields (absent = `None`). +pub mod b64_opt { + use base64::{engine::general_purpose::URL_SAFE_NO_PAD, Engine}; + use serde::{Deserialize, Deserializer, Serializer}; + + pub fn serialize(bytes: &Option>, s: S) -> Result { + match bytes { + Some(b) => s.serialize_str(&URL_SAFE_NO_PAD.encode(b)), + None => s.serialize_none(), + } + } + + pub fn deserialize<'de, D: Deserializer<'de>>(d: D) -> Result>, D::Error> { + Option::::deserialize(d)? + .map(|s| URL_SAFE_NO_PAD.decode(s).map_err(serde::de::Error::custom)) + .transpose() + } +} + #[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)] pub enum Alg { /// XChaCha20-Poly1305 with a 24-byte random nonce. @@ -173,6 +203,9 @@ pub struct LoginFinish { /// Reserved for the TOTP enrollment path; not implemented by the server yet. #[serde(default, skip_serializing_if = "Option::is_none")] pub totp: Option, + /// Enrollment ticket (docs/10 §3). Checked only after KE3 verifies; a valid one approves a new device. + #[serde(default, skip_serializing_if = "Option::is_none")] + pub ticket: Option, } #[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)] @@ -301,6 +334,96 @@ pub struct Manifest { pub signature: Vec, } +// ---------------------------------------------------------------- onboarding (docs/10) + +/// `POST /v1/devices/tickets` (signed by an approved device). +#[derive(Debug, Clone, Default, Serialize, Deserialize)] +pub struct TicketCreate { + /// Optional binding: SHA-256 of the new device's SEC1 P-256 public key. + #[serde(with = "b64_opt", default, skip_serializing_if = "Option::is_none")] + pub device_key_sha256: Option>, +} + +/// Reply to `POST /v1/devices/tickets`. `ticket` is shown once; the server keeps only its hash. +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct TicketCreated { + pub ticket_id: Uuid, + /// base64url of 32 random bytes. + pub ticket: String, + /// Unix seconds. + pub expires_at: i64, +} + +/// An entry of `GET /v1/devices/tickets` (live tickets only). +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct TicketInfo { + pub ticket_id: Uuid, + pub issued_by: Uuid, + /// True when bound to one device key. + pub bound: bool, + pub expires_at: i64, + pub created_at: i64, +} + +/// `POST /v1/join`: the joining device opens a rendezvous (unauthenticated, rate limited). +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct JoinCreate { + pub device: DeviceEnroll, +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct JoinCreated { + /// base64url of 16 random bytes; goes into the QR. + pub rendezvous_id: String, + /// base64url of 32 random bytes; send as `KV-Join-Token`. Never put it in the QR. + pub read_token: String, + /// Unix seconds. + pub expires_at: i64, +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "snake_case")] +pub enum JoinState { + /// Nobody has answered yet. + Waiting, + /// An approving device posted its key. + Hello, + /// The bundle is here (returned once, then the rendezvous is gone). + Sealed, +} + +/// Reply to `GET /v1/join/{rid}`. +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct JoinPoll { + pub state: JoinState, + #[serde(with = "b64_opt", default, skip_serializing_if = "Option::is_none")] + pub sender_pub: Option>, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub bundle: Option, + pub expires_at: i64, +} + +/// `POST /v1/join/{rid}/hello` (signed by an approved device). +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct JoinHello { + /// X25519 public key, 32 bytes. + #[serde(with = "b64")] + pub sender_pub: Vec, +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct JoinHelloResponse { + /// The device that opened the rendezvous: show its name, platform and key fingerprint. + pub device: DeviceEnroll, + pub expires_at: i64, +} + +/// `PUT /v1/join/{rid}/bundle` (signed by the device that sent the hello). +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct JoinBundle { + pub bundle: Envelope, +} + #[derive(Debug, Clone, Serialize, Deserialize)] pub struct ApiError { pub code: String, @@ -327,4 +450,19 @@ mod tests { assert!(!json.contains('+') && !json.contains('/')); assert_eq!(serde_json::from_str::(&json).unwrap(), env); } + + #[test] + fn optional_bytes_and_ticket_field() { + let none = serde_json::to_string(&TicketCreate::default()).unwrap(); + assert_eq!(none, "{}"); + let some = TicketCreate { device_key_sha256: Some(vec![0xfb; 32]) }; + let json = serde_json::to_string(&some).unwrap(); + assert!(json.contains("-_") && !json.contains('=')); + assert_eq!(serde_json::from_str::(&json).unwrap().device_key_sha256, some.device_key_sha256); + // Old clients omit `ticket`; old servers ignore it. + let fin: LoginFinish = serde_json::from_str(r#"{"login_id":"x","ke3":"AAAA"}"#).unwrap(); + assert!(fin.ticket.is_none() && fin.totp.is_none()); + let poll: JoinPoll = serde_json::from_str(r#"{"state":"waiting","expires_at":1}"#).unwrap(); + assert_eq!((poll.state, poll.sender_pub, poll.bundle), (JoinState::Waiting, None, None)); + } } diff --git a/crates/kv-sync/src/client.rs b/crates/kv-sync/src/client.rs index ca239e3..cc65018 100644 --- a/crates/kv-sync/src/client.rs +++ b/crates/kv-sync/src/client.rs @@ -6,9 +6,10 @@ use std::{ }; use kv_proto::{ - AccountInfo, ApiError, Changes, DeviceEnroll, DeviceInfo, ItemPut, ItemRecord, KdfParams, LoginFinish, - LoginFinishResponse, LoginStart, LoginStartResponse, LoginStatus, Manifest, PreloginResponse, - RegistrationStartResponse, ServerInfo, SetupFinish, SetupStart, Vault, + AccountInfo, ApiError, Changes, DeviceEnroll, DeviceInfo, Envelope, ItemPut, ItemRecord, JoinBundle, JoinCreate, + JoinCreated, JoinHello, JoinHelloResponse, JoinPoll, KdfParams, LoginFinish, LoginFinishResponse, LoginStart, + LoginStartResponse, LoginStatus, Manifest, PreloginResponse, RegistrationStartResponse, ServerInfo, SetupFinish, + SetupStart, TicketCreate, TicketCreated, TicketInfo, Vault, HEADER_JOIN_TOKEN, }; use reqwest::{Method, StatusCode}; use serde::{de::DeserializeOwned, Serialize}; @@ -76,10 +77,25 @@ impl ApiClient { body: Option>, if_match: Option, signed: bool, + ) -> Result<(StatusCode, Vec), SyncError> { + self.send_ext(method, path, body, if_match, signed, None).await + } + + async fn send_ext( + &self, + method: Method, + path: &str, + body: Option>, + if_match: Option, + signed: bool, + extra_header: Option<(&str, &str)>, ) -> Result<(StatusCode, Vec), SyncError> { let url = format!("{}{}", self.base, path); let body = body.unwrap_or_default(); let mut req = self.http.request(method.clone(), &url); + if let Some((k, v)) = extra_header { + req = req.header(k, v); + } if !body.is_empty() { req = req.header("content-type", "application/json"); } @@ -188,12 +204,25 @@ impl ApiClient { auth_input: &[u8], device: DeviceEnroll, totp: Option, + ) -> Result { + self.login_with_ticket(account_id, auth_input, device, totp, None).await + } + + /// OPAQUE login presenting an enrollment ticket (docs/10 §3). A valid ticket approves a new device at once; + /// an invalid one is ignored by the server (the outcome is then `PendingApproval`). + pub async fn login_with_ticket( + &self, + account_id: Uuid, + auth_input: &[u8], + device: DeviceEnroll, + totp: Option, + ticket: Option, ) -> Result { let start = opaque::login_start(auth_input)?; let req = LoginStart { account_id, ke1: start.ke1.clone(), device }; let resp: LoginStartResponse = self.json(Method::POST, PATH_LOGIN_START, Some(&req), None, false).await?; let done = start.finish(auth_input, &resp.ke2)?; - let fin = LoginFinish { login_id: resp.login_id, ke3: done.ke3, totp }; + let fin = LoginFinish { login_id: resp.login_id, ke3: done.ke3, totp, ticket }; let resp: LoginFinishResponse = match self.json(Method::POST, PATH_LOGIN_FINISH, Some(&fin), None, false).await { Err(SyncError::Server { status: 401, .. }) => return Err(SyncError::BadCredentials), @@ -209,8 +238,45 @@ impl ApiClient { } } + /// Opens a scan-to-join rendezvous for this (not yet enrolled) device (docs/10 §4). + pub async fn join_create(&self, device: DeviceEnroll) -> Result { + self.json(Method::POST, PATH_JOIN, Some(&JoinCreate { device }), None, false).await + } + + /// Polls a rendezvous with its read token. `NotFound` once it expired or the bundle was read. + pub async fn join_poll(&self, rendezvous_id: &str, read_token: &str) -> Result { + let path = format!("{PATH_JOIN}/{rendezvous_id}"); + let (_, bytes) = + self.send_ext(Method::GET, &path, None, None, false, Some((HEADER_JOIN_TOKEN, read_token))).await?; + serde_json::from_slice(&bytes).map_err(|e| SyncError::Protocol(format!("{path}: {e}"))) + } + // ---------------------------------------------------------------- authenticated + /// Issues an enrollment ticket, optionally bound to SHA-256 of the new device's key (docs/10 §3). + pub async fn create_ticket(&self, device_key_sha256: Option>) -> Result { + self.json(Method::POST, PATH_TICKETS, Some(&TicketCreate { device_key_sha256 }), None, true).await + } + + pub async fn tickets(&self) -> Result, SyncError> { + self.json(Method::GET, PATH_TICKETS, None::<&()>, None, true).await + } + + pub async fn revoke_ticket(&self, ticket_id: Uuid) -> Result<(), SyncError> { + self.no_content(Method::DELETE, &format!("{PATH_TICKETS}/{ticket_id}"), None::<&()>, true).await + } + + /// Answers a rendezvous with this device's ephemeral key; returns the joining device's details. + pub async fn join_hello(&self, rendezvous_id: &str, sender_pub: Vec) -> Result { + let path = format!("{PATH_JOIN}/{rendezvous_id}/hello"); + self.json(Method::POST, &path, Some(&JoinHello { sender_pub }), None, true).await + } + + pub async fn join_send(&self, rendezvous_id: &str, bundle: Envelope) -> Result<(), SyncError> { + let path = format!("{PATH_JOIN}/{rendezvous_id}/bundle"); + self.no_content(Method::PUT, &path, Some(&JoinBundle { bundle }), true).await + } + pub async fn logout(&self) -> Result<(), SyncError> { let r = self.no_content(Method::POST, PATH_LOGOUT, None::<&()>, true).await; self.set_session(None); diff --git a/crates/kv-sync/src/lib.rs b/crates/kv-sync/src/lib.rs index c21ff33..59cb63a 100644 --- a/crates/kv-sync/src/lib.rs +++ b/crates/kv-sync/src/lib.rs @@ -9,11 +9,13 @@ //! * [`engine`]: pull, then push with `If-Match`, then three-way field merge on 412 ([`merge`]), then manifest verify //! and publish ([`manifest`]). //! * [`replay`]: per-item replay check on pull (the `seq` field sealed into every item version and tombstone). +//! * [`onboarding`]: setup payloads and scan-to-join QR codes (docs/10). pub mod client; pub mod engine; pub mod manifest; pub mod merge; +pub mod onboarding; pub mod opaque; pub mod replay; pub mod signing; diff --git a/crates/kv-sync/src/onboarding.rs b/crates/kv-sync/src/onboarding.rs new file mode 100644 index 0000000..1ade264 --- /dev/null +++ b/crates/kv-sync/src/onboarding.rs @@ -0,0 +1,300 @@ +//! Setup payloads and scan-to-join QR codes (kestrove-spec/docs/10 §2 and §4). Pure encoding and validation, +//! no I/O. +//! +//! ```text +//! kestrove://setup/v1?s=&a=&k=[&p=]*[&t=] +//! kestrove://join/v1?s=&r=&k=&c= +//! ``` + +use base64::{engine::general_purpose::URL_SAFE_NO_PAD, Engine}; +use kv_crypto::secret_key::SecretKey; +use url::form_urlencoded; +use uuid::Uuid; +use zeroize::Zeroizing; + +use crate::{ + transport::{normalize_base_url, parse_pin}, + SyncError, +}; + +/// Longest payload a parser accepts (fits a version 13-M QR code with room to spare). +pub const MAX_PAYLOAD_LEN: usize = 512; +/// Longest server URL in a payload. +pub const MAX_SERVER_URL_LEN: usize = 200; +/// Most pins in one payload. +pub const MAX_PINS: usize = 4; + +const SETUP_PREFIX: &str = "kestrove://setup/"; +const JOIN_PREFIX: &str = "kestrove://join/"; +const VERSION: &str = "v1"; + +/// Everything a new device needs except the master password. +#[derive(Clone, PartialEq, Eq)] +pub struct SetupPayload { + /// Normalized base URL (as `normalize_base_url` returns it). + pub server_url: String, + /// Standard base64 SPKI SHA-256 pins, the format `configure_server` takes. + pub spki_pins: Vec, + pub account_id: Uuid, + /// Display form `K1-XXXXXX-…`. + pub secret_key: Zeroizing, + /// Enrollment ticket (base64url, 32 bytes), if any. + pub ticket: Option, +} + +impl std::fmt::Debug for SetupPayload { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + f.debug_struct("SetupPayload") + .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() + } +} + +/// What the joining device shows as a QR code. +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct JoinQr { + pub server_url: String, + /// base64url, 16 bytes. + pub rendezvous_id: String, + /// X25519 public key of the joining device. + pub public_key: [u8; 32], + /// HPKE PSK; only ever in the QR. + pub qr_secret: Zeroizing<[u8; 32]>, +} + +fn bad(field: &str) -> SyncError { + SyncError::Config(format!("setup code: invalid {field}")) +} + +fn enc(s: &str) -> String { + form_urlencoded::byte_serialize(s.as_bytes()).collect() +} + +/// Splits `kestrove:///v1?query` into key/value pairs after checking kind, version and size. +fn query_pairs(input: &str, prefix: &str) -> Result)>, SyncError> { + let input = input.trim(); + if input.len() > MAX_PAYLOAD_LEN { + return Err(SyncError::Config("setup code is too long".into())); + } + let (head, query) = input.split_once('?').ok_or_else(|| bad("format"))?; + let rest = head + .get(..prefix.len()) + .filter(|p| p.eq_ignore_ascii_case(prefix)) + .map(|_| &head[prefix.len()..]) + .ok_or_else(|| SyncError::Config("not a Kestrove setup code".into()))?; + if rest != VERSION { + return Err(SyncError::Config("this code needs a newer version of Kestrove".into())); + } + Ok(form_urlencoded::parse(query.as_bytes()) + .map(|(k, v)| (k.into_owned(), Zeroizing::new(v.into_owned()))) + .collect()) +} + +/// The single value of `key`; an error if it is repeated, `None` if absent. +fn single<'a>(pairs: &'a [(String, Zeroizing)], key: &str) -> Result, SyncError> { + let mut it = pairs.iter().filter(|(k, _)| k == key); + match (it.next(), it.next()) { + (None, _) => Ok(None), + (Some((_, v)), None) => Ok(Some(v.as_str())), + _ => Err(SyncError::Config(format!("setup code: repeated {key}"))), + } +} + +fn server(pairs: &[(String, Zeroizing)]) -> Result { + let s = single(pairs, "s")?.ok_or_else(|| bad("server"))?; + if s.len() > MAX_SERVER_URL_LEN { + return Err(bad("server")); + } + normalize_base_url(s) +} + +fn b64_exact(s: &str, field: &str) -> Result<[u8; N], SyncError> { + URL_SAFE_NO_PAD.decode(s).ok().and_then(|v| v.try_into().ok()).ok_or_else(|| bad(field)) +} + +impl SetupPayload { + /// Canonical encoding: keys in the order `s, a, k, p…, t`. + pub fn encode(&self) -> Result, SyncError> { + let server = normalize_base_url(&self.server_url)?; + if server.len() > MAX_SERVER_URL_LEN || self.spki_pins.len() > MAX_PINS { + return Err(SyncError::Config("server URL or pin list too long for a setup code".into())); + } + let sk = SecretKey::parse(&self.secret_key).map_err(|_| bad("Secret Key"))?; + let compact: Zeroizing = Zeroizing::new(sk.to_display().replace('-', "")); + let mut out = Zeroizing::new(format!( + "{SETUP_PREFIX}{VERSION}?s={}&a={}&k={}", + enc(&server), + self.account_id.simple(), + compact.as_str() + )); + for pin in &self.spki_pins { + out.push_str("&p="); + out.push_str(&URL_SAFE_NO_PAD.encode(parse_pin(pin)?)); + } + if let Some(t) = &self.ticket { + b64_exact::<32>(t, "ticket")?; + out.push_str("&t="); + out.push_str(t); + } + Ok(out) + } + + /// Strict parse and validation (docs/10 §2). Unknown keys are ignored. + pub fn parse(input: &str) -> Result { + let pairs = query_pairs(input, SETUP_PREFIX)?; + let server_url = server(&pairs)?; + let account_id = single(&pairs, "a")? + .filter(|a| a.len() == 32) + .and_then(|a| Uuid::try_parse(a).ok()) + .ok_or_else(|| bad("account id"))?; + let k = single(&pairs, "k")?.ok_or_else(|| bad("Secret Key"))?; + let secret_key = SecretKey::parse(k).map_err(|_| bad("Secret Key"))?.to_display(); + let pins: Vec<&str> = pairs.iter().filter(|(k, _)| k == "p").map(|(_, v)| v.as_str()).collect(); + if pins.len() > MAX_PINS { + return Err(bad("pins")); + } + let spki_pins = pins + .into_iter() + .map(|p| b64_exact::<32>(p, "pin").map(|raw| base64::engine::general_purpose::STANDARD.encode(raw))) + .collect::>()?; + let ticket = single(&pairs, "t")? + .map(|t| b64_exact::<32>(t, "ticket").map(|raw| URL_SAFE_NO_PAD.encode(raw))) + .transpose()?; + Ok(SetupPayload { server_url, spki_pins, account_id, secret_key, ticket }) + } +} + +impl JoinQr { + pub fn encode(&self) -> Result { + let server = normalize_base_url(&self.server_url)?; + if server.len() > MAX_SERVER_URL_LEN { + return Err(bad("server")); + } + b64_exact::<16>(&self.rendezvous_id, "rendezvous id")?; + Ok(format!( + "{JOIN_PREFIX}{VERSION}?s={}&r={}&k={}&c={}", + enc(&server), + self.rendezvous_id, + URL_SAFE_NO_PAD.encode(self.public_key), + URL_SAFE_NO_PAD.encode(self.qr_secret.as_ref()) + )) + } + + pub fn parse(input: &str) -> Result { + let pairs = query_pairs(input, JOIN_PREFIX)?; + let server_url = server(&pairs)?; + let rid = single(&pairs, "r")?.ok_or_else(|| bad("rendezvous id"))?; + b64_exact::<16>(rid, "rendezvous id")?; + let public_key = b64_exact::<32>(single(&pairs, "k")?.ok_or_else(|| bad("key"))?, "key")?; + let qr_secret = Zeroizing::new(b64_exact::<32>(single(&pairs, "c")?.ok_or_else(|| bad("secret"))?, "secret")?); + Ok(JoinQr { server_url, rendezvous_id: rid.to_owned(), public_key, qr_secret }) + } +} + +#[cfg(test)] +mod tests { + use super::*; + + const PIN_A: &str = "47DEQpj8HBSa+/TImW+5JCeuQeRkm5NMpJWZG3hSuFU="; + const PIN_B: &str = "n4bQgYhMfWWaL+qgxVrQFaO/TxsrC4Is0V1sFbDwCgg="; + + fn sample(ticket: bool) -> SetupPayload { + SetupPayload { + server_url: "https://kestrove.samialabri.com".into(), + spki_pins: vec![PIN_A.into(), PIN_B.into()], + account_id: Uuid::parse_str("6f1c2b9a-4e0d-4c7f-9b3a-8e2d1c0b9a87").unwrap(), + secret_key: SecretKey::from_bytes([0x5a; 16]).to_display(), + ticket: ticket.then(|| URL_SAFE_NO_PAD.encode([0xc3; 32])), + } + } + + #[test] + fn setup_roundtrip_and_canonical_form() { + for with_ticket in [false, true] { + let p = sample(with_ticket); + let s = p.encode().unwrap(); + assert!(s.starts_with( + "kestrove://setup/v1?s=https%3A%2F%2Fkestrove.samialabri.com&a=6f1c2b9a4e0d4c7f9b3a8e2d1c0b9a87&k=K1" + )); + assert_eq!(s.contains("&t="), with_ticket); + assert!(s.len() <= 300, "{} bytes", s.len()); + assert_eq!(SetupPayload::parse(&s).unwrap(), p); + // Encoding is canonical: parse → encode gives the same string. + assert_eq!(*SetupPayload::parse(&s).unwrap().encode().unwrap(), *s); + } + // No pins; http on loopback; Secret Key with hyphens and lowercase; keys in another order; unknown keys. + let sk = SecretKey::from_bytes([1; 16]).to_display().to_lowercase(); + let s = format!( + "KESTROVE://setup/v1?a=6f1c2b9a4e0d4c7f9b3a8e2d1c0b9a87&x=future&k={sk}&s=http%3A%2F%2F127.0.0.1%3A8080%2F" + ); + let p = SetupPayload::parse(&s).unwrap(); + assert_eq!((p.server_url.as_str(), p.spki_pins.len(), p.ticket.as_ref()), ("http://127.0.0.1:8080", 0, None)); + assert_eq!(SecretKey::parse(&p.secret_key).unwrap().expose(), &[1; 16]); + // The Debug form never shows secrets. + let dbg = format!("{:?}", sample(true)); + assert!(!dbg.contains("K1") && !dbg.contains(&URL_SAFE_NO_PAD.encode([0xc3; 32]))); + } + + #[test] + fn canonical_vector() { + // kestrove-spec/vectors/onboarding.json + const V: &str = "kestrove://setup/v1?s=https%3A%2F%2Fkestrove.samialabri.com&a=6f1c2b9a4e0d4c7f9b3a8e2d1c0b9a87&k=K12TB9D5MPJTB9D5MPJTB9D5MPJTW&p=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU&p=n4bQgYhMfWWaL-qgxVrQFaO_TxsrC4Is0V1sFbDwCgg&t=w8PDw8PDw8PDw8PDw8PDw8PDw8PDw8PDw8PDw8PDw8M"; + assert_eq!(sample(true).encode().unwrap().as_str(), V); + assert_eq!(SetupPayload::parse(V).unwrap(), sample(true)); + } + + #[test] + fn setup_rejects_malformed_input() { + let good = sample(true).encode().unwrap().to_string(); + let swap = |from: &str, to: &str| good.replacen(from, to, 1); + let k = SecretKey::from_bytes([0x5a; 16]).to_display().replace('-', ""); + let mut typo = k.clone(); + typo.replace_range(5..6, if &k[5..6] == "2" { "3" } else { "2" }); + let cases = [ + ("empty", String::new()), + ("no query", "kestrove://setup/v1".into()), + ("other scheme", swap("kestrove://", "https://")), + ("join instead of setup", swap("kestrove://setup/", "kestrove://join/")), + ("future version", swap("/v1?", "/v2?")), + ("plain http", swap("https%3A", "http%3A")), + ("server with query", swap("samialabri.com&", "samialabri.com%3Fx%3D1&")), + ("account not hex", swap("&a=6f1c", "&a=zz1c")), + ("account with hyphens", swap("6f1c2b9a4e0d", "6f1c2b9a-4e0d")), + ("secret key checksum", good.replace(&k, &typo)), + ("missing secret key", good.replace(&format!("&k={k}"), "")), + ("short pin", swap("&p=47DEQ", "&p=47DE")), + ("short ticket", good[..good.len() - 2].to_string()), + ("repeated account", format!("{good}&a=6f1c2b9a4e0d4c7f9b3a8e2d1c0b9a87")), + ("repeated ticket", format!("{good}&t={}", URL_SAFE_NO_PAD.encode([1u8; 32]))), + ("too many pins", format!("{good}&p={0}&p={0}&p={0}", URL_SAFE_NO_PAD.encode([2u8; 32]))), + ("too long", format!("{good}&x={}", "a".repeat(400))), + ("non-ascii", swap("&a=", "&a=é")), + ]; + for (name, input) in cases { + assert!(SetupPayload::parse(&input).is_err(), "{name} was accepted: {input}"); + } + } + + #[test] + fn join_qr_roundtrip_and_rejects() { + let qr = JoinQr { + server_url: "https://kestrove.samialabri.com/".into(), + rendezvous_id: URL_SAFE_NO_PAD.encode([7u8; 16]), + public_key: [8; 32], + qr_secret: Zeroizing::new([9; 32]), + }; + let s = qr.encode().unwrap(); + assert!(s.starts_with("kestrove://join/v1?s=https%3A%2F%2Fkestrove.samialabri.com&r=")); + assert!(s.len() < 200, "{} bytes", s.len()); + let back = JoinQr::parse(&s).unwrap(); + assert_eq!(back, JoinQr { server_url: "https://kestrove.samialabri.com".into(), ..qr }); + assert!(JoinQr::parse(&s.replace("/v1?", "/v9?")).is_err()); + assert!(JoinQr::parse(&s.replace("&c=", "&c=AA")).is_err()); + assert!(JoinQr::parse(&s.replace("kestrove://join/", "kestrove://setup/")).is_err()); + assert!(SetupPayload::parse(&s).is_err()); + } +} diff --git a/crates/kv-sync/src/test_server.rs b/crates/kv-sync/src/test_server.rs index fe4159c..2063379 100644 --- a/crates/kv-sync/src/test_server.rs +++ b/crates/kv-sync/src/test_server.rs @@ -12,9 +12,10 @@ use std::{ }; use kv_proto::{ - AccountInfo, ApiError, Changes, DeviceInfo, Envelope, ItemPut, ItemRecord, KdfParams, LoginFinish, - LoginFinishResponse, LoginStart, LoginStartResponse, LoginStatus, Manifest, PreloginResponse, - RegistrationStartResponse, ServerInfo, SetupFinish, SetupStart, Vault, + AccountInfo, ApiError, Changes, DeviceEnroll, DeviceInfo, Envelope, ItemPut, ItemRecord, JoinBundle, JoinCreate, + JoinCreated, JoinHello, JoinHelloResponse, JoinPoll, JoinState, KdfParams, LoginFinish, LoginFinishResponse, + LoginStart, LoginStartResponse, LoginStatus, Manifest, PreloginResponse, RegistrationStartResponse, ServerInfo, + SetupFinish, SetupStart, TicketCreate, TicketCreated, TicketInfo, Vault, HEADER_JOIN_TOKEN, }; use opaque_ke::{ CredentialFinalization, CredentialRequest, RegistrationRequest, RegistrationUpload, ServerLogin, @@ -54,6 +55,24 @@ pub struct Device { pub revoked: bool, } +/// An enrollment ticket (docs/10 §3). Expiry is not simulated; set `expired` to test it. +pub struct Ticket { + pub id: Uuid, + pub issuer: Uuid, + pub bound_key_sha256: Option>, + pub used_by: Option, + pub revoked: bool, + pub expired: bool, +} + +/// A scan-to-join rendezvous (docs/10 §4). TTL and poll interval are not simulated. +pub struct Rendezvous { + pub read_token: String, + pub device: DeviceEnroll, + pub hello: Option<(Uuid, Vec)>, + pub bundle: Option, +} + pub struct State { pub setup: ServerSetup, pub setup_token: Option, @@ -61,6 +80,10 @@ pub struct State { pub devices: HashMap, pub sessions: HashMap, logins: HashMap, Uuid)>, + /// Keyed by the ticket secret. + pub tickets: HashMap, + /// Keyed by rendezvous id. + pub joins: HashMap, pub vaults: BTreeMap, /// New devices need approval (else they are auto-approved). pub require_approval: bool, @@ -84,6 +107,8 @@ impl State { devices: HashMap::new(), sessions: HashMap::new(), logins: HashMap::new(), + tickets: HashMap::new(), + joins: HashMap::new(), vaults: BTreeMap::new(), require_approval: false, log: vec![], @@ -268,6 +293,9 @@ fn handle(st: &mut State, req: &Request) -> Result::deserialize(&state).map_err(|_| err(401, "unauthorized"))?; let ke3 = CredentialFinalization::deserialize(&r.ke3).map_err(|_| err(401, "unauthorized"))?; login.finish(ke3).map_err(|_| err(401, "unauthorized"))?; + if let Some(secret) = &r.ticket { + redeem_ticket(st, secret, device); + } if !st.devices.get(&device).is_some_and(|d| d.approved) { return Ok(json( 200, @@ -281,6 +309,30 @@ fn handle(st: &mut State, req: &Request) -> Result { + let r: JoinCreate = parse(req)?; + let rid = random_b64::<16>(); + let read_token = random_b64::<32>(); + st.joins.insert( + rid.clone(), + Rendezvous { read_token: read_token.clone(), device: r.device, hello: None, bundle: None }, + ); + Ok(json(201, &JoinCreated { rendezvous_id: rid, read_token, expires_at: 0 })) + } + ("GET", ["v1", "join", rid]) => { + let token = req.headers.get(HEADER_JOIN_TOKEN).and_then(|v| v.to_str().ok()).unwrap_or_default(); + let j = st.joins.get(*rid).filter(|j| j.read_token == token).ok_or_else(|| err(404, "not_found"))?; + let sender_pub = j.hello.as_ref().map(|(_, k)| k.clone()); + if j.bundle.is_some() { + let j = st.joins.remove(*rid).expect("present"); + return Ok(json( + 200, + &JoinPoll { state: JoinState::Sealed, sender_pub, bundle: j.bundle, expires_at: 0 }, + )); + } + let state = if sender_pub.is_some() { JoinState::Hello } else { JoinState::Waiting }; + Ok(json(200, &JoinPoll { state, sender_pub, bundle: None, expires_at: 0 })) + } _ => { let device = authenticate(st, req)?; authed(st, req, device, &method, &segs) @@ -288,6 +340,33 @@ fn handle(st: &mut State, req: &Request) -> Result() -> String { + use base64::{engine::general_purpose::URL_SAFE_NO_PAD, Engine}; + use rand_core::RngCore; + let mut b = [0u8; N]; + OsRng.fill_bytes(&mut b); + URL_SAFE_NO_PAD.encode(b) +} + +/// Approves `device` if `secret` is a live ticket valid for its key (docs/10 §3). +fn redeem_ticket(st: &mut State, secret: &str, device: Uuid) { + use sha2::Digest; + let Some(dev) = st.devices.get(&device) else { return }; + let key_hash = sha2::Sha256::digest(&dev.p256_pk).to_vec(); + let Some(t) = st.tickets.get_mut(secret) else { return }; + let issuer_ok = st.devices.get(&t.issuer).is_some_and(|d| d.approved && !d.revoked); + if t.used_by.is_some() || t.revoked || t.expired || !issuer_ok { + return; + } + if t.bound_key_sha256.as_ref().is_some_and(|b| *b != key_hash) { + return; + } + t.used_by = Some(device); + if let Some(d) = st.devices.get_mut(&device).filter(|d| !d.revoked) { + d.approved = true; + } +} + fn authed( st: &mut State, req: &Request, @@ -296,6 +375,66 @@ fn authed( segs: &[&str], ) -> Result { match (method, segs) { + ("POST", ["v1", "devices", "tickets"]) => { + let r: TicketCreate = parse(req)?; + let secret = random_b64::<32>(); + let id = Uuid::new_v4(); + st.tickets.insert( + secret.clone(), + Ticket { + id, + issuer: device, + bound_key_sha256: r.device_key_sha256, + used_by: None, + revoked: false, + expired: false, + }, + ); + Ok(json(201, &TicketCreated { ticket_id: id, ticket: secret, expires_at: 0 })) + } + ("GET", ["v1", "devices", "tickets"]) => { + let list: Vec = st + .tickets + .values() + .filter(|t| t.used_by.is_none() && !t.revoked && !t.expired) + .map(|t| TicketInfo { + ticket_id: t.id, + issued_by: t.issuer, + bound: t.bound_key_sha256.is_some(), + expires_at: 0, + created_at: 0, + }) + .collect(); + Ok(json(200, &list)) + } + ("DELETE", ["v1", "devices", "tickets", id]) => { + let id = Uuid::parse_str(id).map_err(|_| err(404, "not_found"))?; + let t = st + .tickets + .values_mut() + .find(|t| t.id == id && t.used_by.is_none() && !t.revoked) + .ok_or_else(|| err(404, "not_found"))?; + t.revoked = true; + Ok(ResponseTemplate::new(204)) + } + ("POST", ["v1", "join", rid, "hello"]) => { + let r: JoinHello = parse(req)?; + let j = st.joins.get_mut(*rid).ok_or_else(|| err(404, "not_found"))?; + if j.hello.is_some() { + return Err(err(409, "conflict")); + } + j.hello = Some((device, r.sender_pub)); + Ok(json(200, &JoinHelloResponse { device: j.device.clone(), expires_at: 0 })) + } + ("PUT", ["v1", "join", rid, "bundle"]) => { + let r: JoinBundle = parse(req)?; + let j = st.joins.get_mut(*rid).ok_or_else(|| err(404, "not_found"))?; + if j.bundle.is_some() || j.hello.as_ref().map(|(d, _)| *d) != Some(device) { + return Err(err(409, "conflict")); + } + j.bundle = Some(r.bundle); + Ok(ResponseTemplate::new(204)) + } ("POST", ["v1", "auth", "logout"]) => { st.sessions.retain(|_, d| *d != device); Ok(ResponseTemplate::new(204)) diff --git a/crates/kv-sync/src/wire.rs b/crates/kv-sync/src/wire.rs index 9b4fac7..efa9cd2 100644 --- a/crates/kv-sync/src/wire.rs +++ b/crates/kv-sync/src/wire.rs @@ -10,3 +10,5 @@ pub const PATH_LOGOUT: &str = "/v1/auth/logout"; pub const PATH_ACCOUNT: &str = "/v1/account"; pub const PATH_VAULTS: &str = "/v1/vaults"; pub const PATH_DEVICES: &str = "/v1/devices"; +pub const PATH_TICKETS: &str = "/v1/devices/tickets"; +pub const PATH_JOIN: &str = "/v1/join";