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:
Generated
+1
@@ -1347,6 +1347,7 @@ dependencies = [
|
|||||||
name = "kv-ffi"
|
name = "kv-ffi"
|
||||||
version = "0.1.0"
|
version = "0.1.0"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
|
"base64 0.22.1",
|
||||||
"ciborium",
|
"ciborium",
|
||||||
"hkdf",
|
"hkdf",
|
||||||
"kv-crypto",
|
"kv-crypto",
|
||||||
|
|||||||
@@ -9,6 +9,7 @@ pub mod aad;
|
|||||||
pub mod aead;
|
pub mod aead;
|
||||||
pub mod kdf;
|
pub mod kdf;
|
||||||
pub mod keys;
|
pub mod keys;
|
||||||
|
pub mod pairing;
|
||||||
pub mod seal;
|
pub mod seal;
|
||||||
pub mod secret_key;
|
pub mod secret_key;
|
||||||
|
|
||||||
|
|||||||
@@ -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: <Kem as KemTrait>::PrivateKey,
|
||||||
|
pk: <Kem as KemTrait>::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<u8> {
|
||||||
|
let mut aad = INFO.to_vec();
|
||||||
|
aad.push(0);
|
||||||
|
aad.extend_from_slice(rid);
|
||||||
|
aad
|
||||||
|
}
|
||||||
|
|
||||||
|
fn public(bytes: &[u8]) -> Result<<Kem as KemTrait>::PublicKey> {
|
||||||
|
<Kem as KemTrait>::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<Envelope> {
|
||||||
|
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::<ChaCha20Poly1305, HkdfSha256, Kem, _>(
|
||||||
|
&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<Zeroizing<Vec<u8>>> {
|
||||||
|
if env.v != FORMAT_VERSION || env.alg != Alg::HpkeX25519ChaCha {
|
||||||
|
return Err(CryptoError::Unsupported);
|
||||||
|
}
|
||||||
|
let encapped = <Kem as KemTrait>::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::<ChaCha20Poly1305, HkdfSha256, Kem>(
|
||||||
|
&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() }));
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -22,6 +22,7 @@ kv-store.workspace = true
|
|||||||
kv-sync.workspace = true
|
kv-sync.workspace = true
|
||||||
kv-import.workspace = true
|
kv-import.workspace = true
|
||||||
serde.workspace = true
|
serde.workspace = true
|
||||||
|
base64.workspace = true
|
||||||
hkdf.workspace = true
|
hkdf.workspace = true
|
||||||
sha2.workspace = true
|
sha2.workspace = true
|
||||||
tokio = { workspace = true }
|
tokio = { workspace = true }
|
||||||
|
|||||||
@@ -36,6 +36,10 @@ mod passkeys;
|
|||||||
pub use passkeys::{kestrove_aaguid, PasskeyAssertion, PasskeyRegistration, PasskeySummary};
|
pub use passkeys::{kestrove_aaguid, PasskeyAssertion, PasskeyRegistration, PasskeySummary};
|
||||||
mod import;
|
mod import;
|
||||||
pub use import::{DuplicateHandling, ImportFormat, ImportOptions, ImportPreview, ImportReport};
|
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)]
|
#[derive(Debug, thiserror::Error, uniffi::Error)]
|
||||||
pub enum CoreError {
|
pub enum CoreError {
|
||||||
@@ -279,6 +283,10 @@ struct AccountRecord {
|
|||||||
primary_vault: Uuid,
|
primary_vault: Uuid,
|
||||||
#[serde(default)]
|
#[serde(default)]
|
||||||
local_unlock: Option<LocalUnlockRecord>,
|
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.
|
/// Keyset (and OPAQUE input) wrapped under the biometric-protected Local Unlock Key.
|
||||||
@@ -324,6 +332,8 @@ pub struct VaultCore {
|
|||||||
store: Arc<dyn Store>,
|
store: Arc<dyn Store>,
|
||||||
inner: Mutex<Inner>,
|
inner: Mutex<Inner>,
|
||||||
sync_lock: tokio::sync::Mutex<()>,
|
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> {
|
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))
|
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> {
|
fn auth_input_aad(account_id: Uuid) -> Vec<u8> {
|
||||||
let mut aad = b"kestrove/v1/local/auth-input".to_vec();
|
let mut aad = b"kestrove/v1/local/auth-input".to_vec();
|
||||||
aad.extend_from_slice(account_id.as_bytes());
|
aad.extend_from_slice(account_id.as_bytes());
|
||||||
@@ -407,6 +428,7 @@ impl VaultCore {
|
|||||||
sig_pk: keyset.sig_public().to_vec(),
|
sig_pk: keyset.sig_public().to_vec(),
|
||||||
primary_vault: vault_id,
|
primary_vault: vault_id,
|
||||||
local_unlock: None,
|
local_unlock: None,
|
||||||
|
secret_key: Some(seal_secret_key(account_id, &keyset.enc.public_bytes(), &secret_key)?),
|
||||||
};
|
};
|
||||||
self.save_account(&account)?;
|
self.save_account(&account)?;
|
||||||
inner.account = Some(account);
|
inner.account = Some(account);
|
||||||
@@ -426,6 +448,12 @@ impl VaultCore {
|
|||||||
let auk = SymKey::from_bytes(Uuid::nil(), *derived.auk);
|
let auk = SymKey::from_bytes(Uuid::nil(), *derived.auk);
|
||||||
let keyset = AccountKeyset::unwrap(acct.account_id, &auk, &acct.wrapped_keyset)?;
|
let keyset = AccountKeyset::unwrap(acct.account_id, &auk, &acct.wrapped_keyset)?;
|
||||||
let vault_key = self.open_vault_key(&keyset, acct.primary_vault)?;
|
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 {
|
inner.unlocked = Some(Unlocked {
|
||||||
keyset: Arc::new(keyset),
|
keyset: Arc::new(keyset),
|
||||||
vault_key: Arc::new(vault_key),
|
vault_key: Arc::new(vault_key),
|
||||||
@@ -740,8 +768,28 @@ impl VaultCore {
|
|||||||
secret_key: String,
|
secret_key: String,
|
||||||
totp: Option<String>,
|
totp: Option<String>,
|
||||||
) -> Result<LoginResult> {
|
) -> Result<LoginResult> {
|
||||||
let api = self.api()?;
|
|
||||||
let sk = SecretKey::parse(&secret_key)?;
|
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();
|
let local = self.lock_inner().account.clone();
|
||||||
// A single-tenant server tells a new device which account it holds; an existing device
|
// 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).
|
// keeps using the parameters it already trusts (no downgrade by the server).
|
||||||
@@ -752,8 +800,13 @@ impl VaultCore {
|
|||||||
(pre.account_id, pre.kdf)
|
(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)?;
|
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);
|
return Ok(LoginResult::PendingApproval);
|
||||||
}
|
}
|
||||||
let auk = SymKey::from_bytes(Uuid::nil(), *derived.auk);
|
let auk = SymKey::from_bytes(Uuid::nil(), *derived.auk);
|
||||||
@@ -778,11 +831,16 @@ impl VaultCore {
|
|||||||
sig_pk: info.sig_pk,
|
sig_pk: info.sig_pk,
|
||||||
primary_vault: Uuid::nil(), // chosen after the first sync
|
primary_vault: Uuid::nil(), // chosen after the first sync
|
||||||
local_unlock: None,
|
local_unlock: None,
|
||||||
|
secret_key: None,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
let keyset = AccountKeyset::unwrap(account_id, &auk, &acct.wrapped_keyset)?;
|
let keyset = AccountKeyset::unwrap(account_id, &auk, &acct.wrapped_keyset)?;
|
||||||
let vault_key = self.open_vault_key(&keyset, acct.primary_vault)?;
|
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)?;
|
self.save_account(&acct)?;
|
||||||
let mut inner = self.lock_inner();
|
let mut inner = self.lock_inner();
|
||||||
inner.account = Some(acct);
|
inner.account = Some(acct);
|
||||||
@@ -794,9 +852,7 @@ impl VaultCore {
|
|||||||
Ok(LoginResult::Ok)
|
Ok(LoginResult::Ok)
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Pull, merge, push and verify every vault. Renews an expired session automatically when the
|
async fn sync_now_inner(&self) -> Result<SyncReport> {
|
||||||
/// master password was used to unlock in this session; otherwise fails with `NotAuthenticated`.
|
|
||||||
pub async fn sync_now(&self) -> Result<SyncReport> {
|
|
||||||
let _guard = self.sync_lock.lock().await;
|
let _guard = self.sync_lock.lock().await;
|
||||||
let api = self.api()?;
|
let api = self.api()?;
|
||||||
let (_, u) = self.unlocked_snapshot()?;
|
let (_, u) = self.unlocked_snapshot()?;
|
||||||
@@ -825,7 +881,10 @@ impl VaultCore {
|
|||||||
vaults_skipped: report.vaults_skipped.len() as u32,
|
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.
|
/// Devices enrolled on the account, including ones waiting for approval.
|
||||||
pub async fn list_devices(&self) -> Result<Vec<DeviceDto>> {
|
pub async fn list_devices(&self) -> Result<Vec<DeviceDto>> {
|
||||||
let devices = self.api()?.devices().await?;
|
let devices = self.api()?.devices().await?;
|
||||||
@@ -889,6 +948,7 @@ impl VaultCore {
|
|||||||
store,
|
store,
|
||||||
inner: Mutex::new(Inner { account, server, ..Default::default() }),
|
inner: Mutex::new(Inner { account, server, ..Default::default() }),
|
||||||
sync_lock: tokio::sync::Mutex::new(()),
|
sync_lock: tokio::sync::Mutex::new(()),
|
||||||
|
join: Mutex::new(None),
|
||||||
}))
|
}))
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -960,9 +1020,20 @@ impl VaultCore {
|
|||||||
account_id: Uuid,
|
account_id: Uuid,
|
||||||
auth_input: &[u8],
|
auth_input: &[u8],
|
||||||
totp: Option<String>,
|
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> {
|
) -> Result<LoginResult> {
|
||||||
let device = self.enroll()?;
|
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 } => {
|
LoginOutcome::Ok { session } => {
|
||||||
self.store.put_meta(META_SESSION, session.as_bytes())?;
|
self.store.put_meta(META_SESSION, session.as_bytes())?;
|
||||||
Ok(LoginResult::Ok)
|
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"));
|
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.
|
//! Two devices against a real, freshly started kestrove-server. Ignored by default.
|
||||||
//!
|
//!
|
||||||
//! ```sh
|
//! ```sh
|
||||||
//! # in kestrove-server (empty database, so it prints SETUP_TOKEN=… on boot):
|
//! # 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 cargo run
|
//! 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:
|
//! # 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
|
//! 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;
|
use std::sync::Arc;
|
||||||
|
|
||||||
@@ -107,6 +109,22 @@ async fn two_devices_against_local_server() {
|
|||||||
b.sync_now().await.unwrap();
|
b.sync_now().await.unwrap();
|
||||||
assert!(matches!(b.get_item(temp), Err(CoreError::NotFound)));
|
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();
|
b.logout().await.unwrap();
|
||||||
println!("live sync OK against {url}");
|
println!("live sync OK against {url}");
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -16,6 +16,17 @@ pub const API_VERSION: u32 = 1;
|
|||||||
pub const MIN_KDF_M_KIB: u32 = 64 * 1024;
|
pub const MIN_KDF_M_KIB: u32 = 64 * 1024;
|
||||||
pub const MIN_KDF_T: u32 = 3;
|
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.
|
/// base64url (no padding) serde helper for byte fields.
|
||||||
pub mod b64 {
|
pub mod b64 {
|
||||||
use base64::{engine::general_purpose::URL_SAFE_NO_PAD, Engine};
|
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<S: Serializer>(bytes: &Option<Vec<u8>>, s: S) -> Result<S::Ok, S::Error> {
|
||||||
|
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<Option<Vec<u8>>, D::Error> {
|
||||||
|
Option::<String>::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)]
|
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
|
||||||
pub enum Alg {
|
pub enum Alg {
|
||||||
/// XChaCha20-Poly1305 with a 24-byte random nonce.
|
/// 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.
|
/// Reserved for the TOTP enrollment path; not implemented by the server yet.
|
||||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||||
pub totp: Option<String>,
|
pub totp: Option<String>,
|
||||||
|
/// 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<String>,
|
||||||
}
|
}
|
||||||
|
|
||||||
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
|
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
|
||||||
@@ -301,6 +334,96 @@ pub struct Manifest {
|
|||||||
pub signature: Vec<u8>,
|
pub signature: Vec<u8>,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------- 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<Vec<u8>>,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 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<Vec<u8>>,
|
||||||
|
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||||
|
pub bundle: Option<Envelope>,
|
||||||
|
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<u8>,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[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)]
|
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||||
pub struct ApiError {
|
pub struct ApiError {
|
||||||
pub code: String,
|
pub code: String,
|
||||||
@@ -327,4 +450,19 @@ mod tests {
|
|||||||
assert!(!json.contains('+') && !json.contains('/'));
|
assert!(!json.contains('+') && !json.contains('/'));
|
||||||
assert_eq!(serde_json::from_str::<Envelope>(&json).unwrap(), env);
|
assert_eq!(serde_json::from_str::<Envelope>(&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::<TicketCreate>(&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));
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -6,9 +6,10 @@ use std::{
|
|||||||
};
|
};
|
||||||
|
|
||||||
use kv_proto::{
|
use kv_proto::{
|
||||||
AccountInfo, ApiError, Changes, DeviceEnroll, DeviceInfo, ItemPut, ItemRecord, KdfParams, LoginFinish,
|
AccountInfo, ApiError, Changes, DeviceEnroll, DeviceInfo, Envelope, ItemPut, ItemRecord, JoinBundle, JoinCreate,
|
||||||
LoginFinishResponse, LoginStart, LoginStartResponse, LoginStatus, Manifest, PreloginResponse,
|
JoinCreated, JoinHello, JoinHelloResponse, JoinPoll, KdfParams, LoginFinish, LoginFinishResponse, LoginStart,
|
||||||
RegistrationStartResponse, ServerInfo, SetupFinish, SetupStart, Vault,
|
LoginStartResponse, LoginStatus, Manifest, PreloginResponse, RegistrationStartResponse, ServerInfo, SetupFinish,
|
||||||
|
SetupStart, TicketCreate, TicketCreated, TicketInfo, Vault, HEADER_JOIN_TOKEN,
|
||||||
};
|
};
|
||||||
use reqwest::{Method, StatusCode};
|
use reqwest::{Method, StatusCode};
|
||||||
use serde::{de::DeserializeOwned, Serialize};
|
use serde::{de::DeserializeOwned, Serialize};
|
||||||
@@ -76,10 +77,25 @@ impl ApiClient {
|
|||||||
body: Option<Vec<u8>>,
|
body: Option<Vec<u8>>,
|
||||||
if_match: Option<u64>,
|
if_match: Option<u64>,
|
||||||
signed: bool,
|
signed: bool,
|
||||||
|
) -> Result<(StatusCode, Vec<u8>), SyncError> {
|
||||||
|
self.send_ext(method, path, body, if_match, signed, None).await
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn send_ext(
|
||||||
|
&self,
|
||||||
|
method: Method,
|
||||||
|
path: &str,
|
||||||
|
body: Option<Vec<u8>>,
|
||||||
|
if_match: Option<u64>,
|
||||||
|
signed: bool,
|
||||||
|
extra_header: Option<(&str, &str)>,
|
||||||
) -> Result<(StatusCode, Vec<u8>), SyncError> {
|
) -> Result<(StatusCode, Vec<u8>), SyncError> {
|
||||||
let url = format!("{}{}", self.base, path);
|
let url = format!("{}{}", self.base, path);
|
||||||
let body = body.unwrap_or_default();
|
let body = body.unwrap_or_default();
|
||||||
let mut req = self.http.request(method.clone(), &url);
|
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() {
|
if !body.is_empty() {
|
||||||
req = req.header("content-type", "application/json");
|
req = req.header("content-type", "application/json");
|
||||||
}
|
}
|
||||||
@@ -188,12 +204,25 @@ impl ApiClient {
|
|||||||
auth_input: &[u8],
|
auth_input: &[u8],
|
||||||
device: DeviceEnroll,
|
device: DeviceEnroll,
|
||||||
totp: Option<String>,
|
totp: Option<String>,
|
||||||
|
) -> Result<LoginOutcome, SyncError> {
|
||||||
|
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<String>,
|
||||||
|
ticket: Option<String>,
|
||||||
) -> Result<LoginOutcome, SyncError> {
|
) -> Result<LoginOutcome, SyncError> {
|
||||||
let start = opaque::login_start(auth_input)?;
|
let start = opaque::login_start(auth_input)?;
|
||||||
let req = LoginStart { account_id, ke1: start.ke1.clone(), device };
|
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 resp: LoginStartResponse = self.json(Method::POST, PATH_LOGIN_START, Some(&req), None, false).await?;
|
||||||
let done = start.finish(auth_input, &resp.ke2)?;
|
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
|
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),
|
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<JoinCreated, SyncError> {
|
||||||
|
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<JoinPoll, SyncError> {
|
||||||
|
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
|
// ---------------------------------------------------------------- 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<Vec<u8>>) -> Result<TicketCreated, SyncError> {
|
||||||
|
self.json(Method::POST, PATH_TICKETS, Some(&TicketCreate { device_key_sha256 }), None, true).await
|
||||||
|
}
|
||||||
|
|
||||||
|
pub async fn tickets(&self) -> Result<Vec<TicketInfo>, 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<u8>) -> Result<JoinHelloResponse, SyncError> {
|
||||||
|
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> {
|
pub async fn logout(&self) -> Result<(), SyncError> {
|
||||||
let r = self.no_content(Method::POST, PATH_LOGOUT, None::<&()>, true).await;
|
let r = self.no_content(Method::POST, PATH_LOGOUT, None::<&()>, true).await;
|
||||||
self.set_session(None);
|
self.set_session(None);
|
||||||
|
|||||||
@@ -9,11 +9,13 @@
|
|||||||
//! * [`engine`]: pull, then push with `If-Match`, then three-way field merge on 412 ([`merge`]), then manifest verify
|
//! * [`engine`]: pull, then push with `If-Match`, then three-way field merge on 412 ([`merge`]), then manifest verify
|
||||||
//! and publish ([`manifest`]).
|
//! and publish ([`manifest`]).
|
||||||
//! * [`replay`]: per-item replay check on pull (the `seq` field sealed into every item version and tombstone).
|
//! * [`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 client;
|
||||||
pub mod engine;
|
pub mod engine;
|
||||||
pub mod manifest;
|
pub mod manifest;
|
||||||
pub mod merge;
|
pub mod merge;
|
||||||
|
pub mod onboarding;
|
||||||
pub mod opaque;
|
pub mod opaque;
|
||||||
pub mod replay;
|
pub mod replay;
|
||||||
pub mod signing;
|
pub mod signing;
|
||||||
|
|||||||
@@ -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=<server>&a=<account hex>&k=<Secret Key, no hyphens>[&p=<pin>]*[&t=<ticket>]
|
||||||
|
//! kestrove://join/v1?s=<server>&r=<rendezvous id>&k=<X25519 public key>&c=<qr secret>
|
||||||
|
//! ```
|
||||||
|
|
||||||
|
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<String>,
|
||||||
|
pub account_id: Uuid,
|
||||||
|
/// Display form `K1-XXXXXX-…`.
|
||||||
|
pub secret_key: Zeroizing<String>,
|
||||||
|
/// Enrollment ticket (base64url, 32 bytes), if any.
|
||||||
|
pub ticket: Option<String>,
|
||||||
|
}
|
||||||
|
|
||||||
|
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", &"<redacted>")
|
||||||
|
.field("ticket", &self.ticket.as_ref().map(|_| "<redacted>"))
|
||||||
|
.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://<kind>/v1?query` into key/value pairs after checking kind, version and size.
|
||||||
|
fn query_pairs(input: &str, prefix: &str) -> Result<Vec<(String, Zeroizing<String>)>, 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<String>)], key: &str) -> Result<Option<&'a str>, 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<String>)]) -> Result<String, SyncError> {
|
||||||
|
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<const N: usize>(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<Zeroizing<String>, 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<String> = 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<Self, SyncError> {
|
||||||
|
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::<Result<_, _>>()?;
|
||||||
|
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<String, SyncError> {
|
||||||
|
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<Self, SyncError> {
|
||||||
|
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());
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -12,9 +12,10 @@ use std::{
|
|||||||
};
|
};
|
||||||
|
|
||||||
use kv_proto::{
|
use kv_proto::{
|
||||||
AccountInfo, ApiError, Changes, DeviceInfo, Envelope, ItemPut, ItemRecord, KdfParams, LoginFinish,
|
AccountInfo, ApiError, Changes, DeviceEnroll, DeviceInfo, Envelope, ItemPut, ItemRecord, JoinBundle, JoinCreate,
|
||||||
LoginFinishResponse, LoginStart, LoginStartResponse, LoginStatus, Manifest, PreloginResponse,
|
JoinCreated, JoinHello, JoinHelloResponse, JoinPoll, JoinState, KdfParams, LoginFinish, LoginFinishResponse,
|
||||||
RegistrationStartResponse, ServerInfo, SetupFinish, SetupStart, Vault,
|
LoginStart, LoginStartResponse, LoginStatus, Manifest, PreloginResponse, RegistrationStartResponse, ServerInfo,
|
||||||
|
SetupFinish, SetupStart, TicketCreate, TicketCreated, TicketInfo, Vault, HEADER_JOIN_TOKEN,
|
||||||
};
|
};
|
||||||
use opaque_ke::{
|
use opaque_ke::{
|
||||||
CredentialFinalization, CredentialRequest, RegistrationRequest, RegistrationUpload, ServerLogin,
|
CredentialFinalization, CredentialRequest, RegistrationRequest, RegistrationUpload, ServerLogin,
|
||||||
@@ -54,6 +55,24 @@ pub struct Device {
|
|||||||
pub revoked: bool,
|
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<Vec<u8>>,
|
||||||
|
pub used_by: Option<Uuid>,
|
||||||
|
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<u8>)>,
|
||||||
|
pub bundle: Option<Envelope>,
|
||||||
|
}
|
||||||
|
|
||||||
pub struct State {
|
pub struct State {
|
||||||
pub setup: ServerSetup<Suite>,
|
pub setup: ServerSetup<Suite>,
|
||||||
pub setup_token: Option<String>,
|
pub setup_token: Option<String>,
|
||||||
@@ -61,6 +80,10 @@ pub struct State {
|
|||||||
pub devices: HashMap<Uuid, Device>,
|
pub devices: HashMap<Uuid, Device>,
|
||||||
pub sessions: HashMap<String, Uuid>,
|
pub sessions: HashMap<String, Uuid>,
|
||||||
logins: HashMap<String, (Vec<u8>, Uuid)>,
|
logins: HashMap<String, (Vec<u8>, Uuid)>,
|
||||||
|
/// Keyed by the ticket secret.
|
||||||
|
pub tickets: HashMap<String, Ticket>,
|
||||||
|
/// Keyed by rendezvous id.
|
||||||
|
pub joins: HashMap<String, Rendezvous>,
|
||||||
pub vaults: BTreeMap<Uuid, VaultState>,
|
pub vaults: BTreeMap<Uuid, VaultState>,
|
||||||
/// New devices need approval (else they are auto-approved).
|
/// New devices need approval (else they are auto-approved).
|
||||||
pub require_approval: bool,
|
pub require_approval: bool,
|
||||||
@@ -84,6 +107,8 @@ impl State {
|
|||||||
devices: HashMap::new(),
|
devices: HashMap::new(),
|
||||||
sessions: HashMap::new(),
|
sessions: HashMap::new(),
|
||||||
logins: HashMap::new(),
|
logins: HashMap::new(),
|
||||||
|
tickets: HashMap::new(),
|
||||||
|
joins: HashMap::new(),
|
||||||
vaults: BTreeMap::new(),
|
vaults: BTreeMap::new(),
|
||||||
require_approval: false,
|
require_approval: false,
|
||||||
log: vec![],
|
log: vec![],
|
||||||
@@ -268,6 +293,9 @@ fn handle(st: &mut State, req: &Request) -> Result<ResponseTemplate, ResponseTem
|
|||||||
let login = ServerLogin::<Suite>::deserialize(&state).map_err(|_| err(401, "unauthorized"))?;
|
let login = ServerLogin::<Suite>::deserialize(&state).map_err(|_| err(401, "unauthorized"))?;
|
||||||
let ke3 = CredentialFinalization::deserialize(&r.ke3).map_err(|_| err(401, "unauthorized"))?;
|
let ke3 = CredentialFinalization::deserialize(&r.ke3).map_err(|_| err(401, "unauthorized"))?;
|
||||||
login.finish(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) {
|
if !st.devices.get(&device).is_some_and(|d| d.approved) {
|
||||||
return Ok(json(
|
return Ok(json(
|
||||||
200,
|
200,
|
||||||
@@ -281,6 +309,30 @@ fn handle(st: &mut State, req: &Request) -> Result<ResponseTemplate, ResponseTem
|
|||||||
&LoginFinishResponse { status: LoginStatus::Ok, session: Some(session), expires_at: Some(now()) },
|
&LoginFinishResponse { status: LoginStatus::Ok, session: Some(session), expires_at: Some(now()) },
|
||||||
))
|
))
|
||||||
}
|
}
|
||||||
|
("POST", ["v1", "join"]) => {
|
||||||
|
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)?;
|
let device = authenticate(st, req)?;
|
||||||
authed(st, req, device, &method, &segs)
|
authed(st, req, device, &method, &segs)
|
||||||
@@ -288,6 +340,33 @@ fn handle(st: &mut State, req: &Request) -> Result<ResponseTemplate, ResponseTem
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
fn random_b64<const N: usize>() -> 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(
|
fn authed(
|
||||||
st: &mut State,
|
st: &mut State,
|
||||||
req: &Request,
|
req: &Request,
|
||||||
@@ -296,6 +375,66 @@ fn authed(
|
|||||||
segs: &[&str],
|
segs: &[&str],
|
||||||
) -> Result<ResponseTemplate, ResponseTemplate> {
|
) -> Result<ResponseTemplate, ResponseTemplate> {
|
||||||
match (method, segs) {
|
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<TicketInfo> = 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"]) => {
|
("POST", ["v1", "auth", "logout"]) => {
|
||||||
st.sessions.retain(|_, d| *d != device);
|
st.sessions.retain(|_, d| *d != device);
|
||||||
Ok(ResponseTemplate::new(204))
|
Ok(ResponseTemplate::new(204))
|
||||||
|
|||||||
@@ -10,3 +10,5 @@ pub const PATH_LOGOUT: &str = "/v1/auth/logout";
|
|||||||
pub const PATH_ACCOUNT: &str = "/v1/account";
|
pub const PATH_ACCOUNT: &str = "/v1/account";
|
||||||
pub const PATH_VAULTS: &str = "/v1/vaults";
|
pub const PATH_VAULTS: &str = "/v1/vaults";
|
||||||
pub const PATH_DEVICES: &str = "/v1/devices";
|
pub const PATH_DEVICES: &str = "/v1/devices";
|
||||||
|
pub const PATH_TICKETS: &str = "/v1/devices/tickets";
|
||||||
|
pub const PATH_JOIN: &str = "/v1/join";
|
||||||
|
|||||||
Reference in New Issue
Block a user