Merge branch 'onboarding' into wasm-onboarding

This commit is contained in:
2026-10-18 06:45:47 +09:00
14 changed files with 1409 additions and 17 deletions
Generated
+1
View File
@@ -1347,6 +1347,7 @@ dependencies = [
name = "kv-ffi"
version = "0.1.0"
dependencies = [
"base64 0.22.1",
"ciborium",
"hkdf",
"kv-crypto",
+1
View File
@@ -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;
+157
View File
@@ -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() }));
}
}
+1
View File
@@ -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 }
+77 -6
View File
@@ -36,6 +36,10 @@ mod passkeys;
pub use passkeys::{kestrove_aaguid, PasskeyAssertion, PasskeyRegistration, PasskeySummary};
mod import;
pub use import::{DuplicateHandling, ImportFormat, ImportOptions, ImportPreview, ImportReport};
mod onboarding;
pub use onboarding::{
encode_setup_payload, parse_setup_payload, AddDevicePayload, JoinConfirm, JoinProgress, JoinSession, SetupInfo,
};
#[derive(Debug, thiserror::Error, uniffi::Error)]
pub enum CoreError {
@@ -279,6 +283,10 @@ struct AccountRecord {
primary_vault: Uuid,
#[serde(default)]
local_unlock: Option<LocalUnlockRecord>,
/// The Secret Key, HPKE-sealed to `enc_pk` (docs/02 "Secrets the user holds"), so an unlocked device can
/// build setup payloads (docs/10). Absent on accounts created before this field until the next password unlock.
#[serde(default)]
secret_key: Option<Envelope>,
}
/// Keyset (and OPAQUE input) wrapped under the biometric-protected Local Unlock Key.
@@ -324,6 +332,8 @@ pub struct VaultCore {
store: Arc<dyn Store>,
inner: Mutex<Inner>,
sync_lock: tokio::sync::Mutex<()>,
/// In-flight scan-to-join, on either side (docs/10 §4).
join: Mutex<Option<onboarding::JoinState>>,
}
fn luk_subkey(luk: &[u8], info: &[u8]) -> Result<SymKey> {
@@ -332,6 +342,17 @@ fn luk_subkey(luk: &[u8], info: &[u8]) -> Result<SymKey> {
Ok(SymKey::from_bytes(Uuid::nil(), out))
}
fn secret_key_aad(account_id: Uuid) -> Vec<u8> {
let mut aad = b"kestrove/v1/local/secret-key".to_vec();
aad.extend_from_slice(account_id.as_bytes());
aad
}
/// Seals the Secret Key to the account's own encryption key, for local storage only.
fn seal_secret_key(account_id: Uuid, enc_pk: &[u8], sk: &SecretKey) -> Result<Envelope> {
Ok(kv_crypto::seal::seal_to(account_id, enc_pk, sk.expose(), &secret_key_aad(account_id))?)
}
fn auth_input_aad(account_id: Uuid) -> Vec<u8> {
let mut aad = b"kestrove/v1/local/auth-input".to_vec();
aad.extend_from_slice(account_id.as_bytes());
@@ -407,6 +428,7 @@ impl VaultCore {
sig_pk: keyset.sig_public().to_vec(),
primary_vault: vault_id,
local_unlock: None,
secret_key: Some(seal_secret_key(account_id, &keyset.enc.public_bytes(), &secret_key)?),
};
self.save_account(&account)?;
inner.account = Some(account);
@@ -426,6 +448,12 @@ impl VaultCore {
let auk = SymKey::from_bytes(Uuid::nil(), *derived.auk);
let keyset = AccountKeyset::unwrap(acct.account_id, &auk, &acct.wrapped_keyset)?;
let vault_key = self.open_vault_key(&keyset, acct.primary_vault)?;
if acct.secret_key.is_none() {
let mut acct = acct.clone();
acct.secret_key = Some(seal_secret_key(acct.account_id, &keyset.enc.public_bytes(), &sk)?);
self.save_account(&acct)?;
inner.account = Some(acct);
}
inner.unlocked = Some(Unlocked {
keyset: Arc::new(keyset),
vault_key: Arc::new(vault_key),
@@ -740,8 +768,28 @@ impl VaultCore {
secret_key: String,
totp: Option<String>,
) -> Result<LoginResult> {
let api = self.api()?;
let sk = SecretKey::parse(&secret_key)?;
self.login_inner(master_password, sk, totp, None, None).await
}
/// Pull, merge, push and verify every vault. Renews an expired session automatically when the
/// master password was used to unlock in this session; otherwise fails with `NotAuthenticated`.
pub async fn sync_now(&self) -> Result<SyncReport> {
self.sync_now_inner().await
}
}
impl VaultCore {
/// `login`, optionally presenting an enrollment ticket and checking the server holds `expect_account`.
async fn login_inner(
&self,
master_password: String,
sk: SecretKey,
totp: Option<String>,
ticket: Option<String>,
expect_account: Option<Uuid>,
) -> Result<LoginResult> {
let api = self.api()?;
let local = self.lock_inner().account.clone();
// A single-tenant server tells a new device which account it holds; an existing device
// keeps using the parameters it already trusts (no downgrade by the server).
@@ -752,8 +800,13 @@ impl VaultCore {
(pre.account_id, pre.kdf)
}
};
if expect_account.is_some_and(|want| want != account_id) {
return Err(CoreError::Invalid { msg: "the server holds a different account than the setup code".into() });
}
let derived = kdf::derive_keys(&master_password, &sk, account_id, &kdf_params)?;
if self.do_login(&api, account_id, derived.auth_input.as_ref(), totp).await? == LoginResult::PendingApproval {
if self.do_login_ticket(&api, account_id, derived.auth_input.as_ref(), totp, ticket).await?
== LoginResult::PendingApproval
{
return Ok(LoginResult::PendingApproval);
}
let auk = SymKey::from_bytes(Uuid::nil(), *derived.auk);
@@ -778,11 +831,16 @@ impl VaultCore {
sig_pk: info.sig_pk,
primary_vault: Uuid::nil(), // chosen after the first sync
local_unlock: None,
secret_key: None,
}
}
};
let keyset = AccountKeyset::unwrap(account_id, &auk, &acct.wrapped_keyset)?;
let vault_key = self.open_vault_key(&keyset, acct.primary_vault)?;
let mut acct = acct;
if acct.secret_key.is_none() {
acct.secret_key = Some(seal_secret_key(account_id, &keyset.enc.public_bytes(), &sk)?);
}
self.save_account(&acct)?;
let mut inner = self.lock_inner();
inner.account = Some(acct);
@@ -794,9 +852,7 @@ impl VaultCore {
Ok(LoginResult::Ok)
}
/// Pull, merge, push and verify every vault. Renews an expired session automatically when the
/// master password was used to unlock in this session; otherwise fails with `NotAuthenticated`.
pub async fn sync_now(&self) -> Result<SyncReport> {
async fn sync_now_inner(&self) -> Result<SyncReport> {
let _guard = self.sync_lock.lock().await;
let api = self.api()?;
let (_, u) = self.unlocked_snapshot()?;
@@ -825,7 +881,10 @@ impl VaultCore {
vaults_skipped: report.vaults_skipped.len() as u32,
})
}
}
#[uniffi::export(async_runtime = "tokio")]
impl VaultCore {
/// Devices enrolled on the account, including ones waiting for approval.
pub async fn list_devices(&self) -> Result<Vec<DeviceDto>> {
let devices = self.api()?.devices().await?;
@@ -889,6 +948,7 @@ impl VaultCore {
store,
inner: Mutex::new(Inner { account, server, ..Default::default() }),
sync_lock: tokio::sync::Mutex::new(()),
join: Mutex::new(None),
}))
}
@@ -960,9 +1020,20 @@ impl VaultCore {
account_id: Uuid,
auth_input: &[u8],
totp: Option<String>,
) -> Result<LoginResult> {
self.do_login_ticket(api, account_id, auth_input, totp, None).await
}
async fn do_login_ticket(
&self,
api: &ApiClient,
account_id: Uuid,
auth_input: &[u8],
totp: Option<String>,
ticket: Option<String>,
) -> Result<LoginResult> {
let device = self.enroll()?;
match api.login(account_id, auth_input, device, totp).await? {
match api.login_with_ticket(account_id, auth_input, device, totp, ticket).await? {
LoginOutcome::Ok { session } => {
self.store.put_meta(META_SESSION, session.as_bytes())?;
Ok(LoginResult::Ok)
+296
View File
@@ -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,
})
}
}
+200
View File
@@ -355,3 +355,203 @@ async fn delete_item_seals_the_tombstone_and_forged_deletes_are_refused() {
}
assert_eq!(b.get_item(kept).unwrap().password.as_deref(), Some("pw1"));
}
// ---------------------------------------------------------------- onboarding (docs/10)
/// A signed-in first device "a" with one item, on a server that requires approval for new devices.
async fn phone_with_item(server: &TestServer) -> (Arc<VaultCore>, String, String) {
let a = VaultCore::new_in_memory();
let sk = a.create_account(PW.into()).unwrap();
connect(&a, server);
a.register_account(SETUP_TOKEN.into()).await.unwrap();
let id = a.save_item(login_item("Bank", "pw-a")).unwrap();
a.sync_now().await.unwrap();
server.state().require_approval = true;
(a, sk, id)
}
#[tokio::test]
async fn kit_payload_carries_the_secret_key_and_survives_biometric_unlock() {
let server = TestServer::start().await;
let a = VaultCore::new_in_memory();
assert!(matches!(a.setup_payload_for_kit(), Err(CoreError::NoAccount)));
let sk = a.create_account(PW.into()).unwrap();
assert!(matches!(a.setup_payload_for_kit(), Err(CoreError::NotConfigured)));
a.configure_server(server.url(), vec!["47DEQpj8HBSa+/TImW+5JCeuQeRkm5NMpJWZG3hSuFU=".into()]).unwrap();
let kit = a.setup_payload_for_kit().unwrap();
assert!(kit.starts_with("kestrove://setup/v1?s=http%3A%2F%2F127.0.0.1"), "{kit}");
let info = parse_setup_payload(kit.clone()).unwrap();
assert_eq!((info.secret_key.as_str(), info.ticket.as_ref()), (sk.as_str(), None));
assert_eq!(info.account_id, a.account_id().unwrap());
assert_eq!(info.spki_pins, vec!["47DEQpj8HBSa+/TImW+5JCeuQeRkm5NMpJWZG3hSuFU=".to_string()]);
assert_eq!(encode_setup_payload(info).unwrap(), kit);
// After a biometric unlock the core still knows the Secret Key; locked, it does not.
let luk = a.enable_local_unlock().unwrap();
a.lock();
assert!(matches!(a.setup_payload_for_kit(), Err(CoreError::Locked)));
a.unlock_with_local_key(luk).unwrap();
assert_eq!(a.setup_payload_for_kit().unwrap(), kit);
assert!(matches!(parse_setup_payload("kestrove://setup/v2?s=x".into()), Err(CoreError::Invalid { .. })));
}
#[tokio::test]
async fn add_device_qr_needs_only_the_password_and_no_approval() {
let server = TestServer::start().await;
let (a, sk, id) = phone_with_item(&server).await;
let add = a.add_device_payload().await.unwrap();
let setup = parse_setup_payload(add.payload.clone()).unwrap();
assert!(setup.ticket.is_some());
// The new device has nothing configured but its key; the QR and the password are enough.
let b = VaultCore::new_in_memory();
b.set_device_signer(
Arc::new(TestSigner(SoftwareSigner::generate(Uuid::new_v4()))),
"tablet".into(),
PlatformDto::Android,
)
.unwrap();
assert!(matches!(
b.login_with_setup(setup.clone(), "wrong password!!".into()).await,
Err(CoreError::BadCredentials)
));
assert_eq!(b.login_with_setup(setup.clone(), PW.into()).await.unwrap(), LoginResult::Ok);
b.sync_now().await.unwrap();
assert_eq!(b.get_item(id).unwrap().password.as_deref(), Some("pw-a"));
// B can now produce the same Kit payload.
assert_eq!(parse_setup_payload(b.setup_payload_for_kit().unwrap()).unwrap().secret_key, sk);
// Single use: a third device with the same QR waits for approval.
let c = VaultCore::new_in_memory();
connect(&c, &server);
assert_eq!(c.login_with_setup(setup, PW.into()).await.unwrap(), LoginResult::PendingApproval);
// A cancelled QR is revoked.
let add = a.add_device_payload().await.unwrap();
a.revoke_enrollment_ticket(add.ticket_id).await.unwrap();
let d = VaultCore::new_in_memory();
connect(&d, &server);
let setup = parse_setup_payload(add.payload).unwrap();
assert_eq!(d.login_with_setup(setup, PW.into()).await.unwrap(), LoginResult::PendingApproval);
}
#[tokio::test]
async fn scan_to_join_between_two_clients() {
let server = TestServer::start().await;
let (phone, sk, id) = phone_with_item(&server).await;
// Desktop: only knows the server URL.
let desktop = VaultCore::new_in_memory();
desktop
.set_device_signer(
Arc::new(TestSigner(SoftwareSigner::generate(Uuid::new_v4()))),
"Sami's MacBook".into(),
PlatformDto::Macos,
)
.unwrap();
assert!(matches!(desktop.join_poll().await, Err(CoreError::Invalid { .. })));
let session = desktop.join_start(format!("{}/", server.url())).await.unwrap();
assert!(session.qr.starts_with("kestrove://join/v1?") && session.qr.len() < 200);
assert_eq!(desktop.join_poll().await.unwrap(), JoinProgress { code: None, setup: None });
// Phone: a QR naming another server is refused before anything is sent.
let elsewhere = session.qr.replace("127.0.0.1", "localhost");
assert!(matches!(phone.join_approve(elsewhere).await, Err(CoreError::Invalid { .. })));
let confirm = phone.join_approve(session.qr.clone()).await.unwrap();
assert_eq!((confirm.device_name.as_str(), confirm.platform), ("Sami's MacBook", PlatformDto::Macos));
assert_eq!(confirm.key_fingerprint, desktop.device_key_fingerprint().unwrap());
// Both screens show the same code before anything secret moves.
let progress = desktop.join_poll().await.unwrap();
assert_eq!((progress.code.as_deref(), progress.setup.as_ref()), (Some(confirm.code.as_str()), None));
{
let st = server.state();
let rv = st.joins.values().next().unwrap();
assert!(rv.bundle.is_none() && rv.hello.is_some());
}
phone.join_send().await.unwrap();
assert!(matches!(phone.join_send().await, Err(CoreError::Invalid { .. })), "one send per approval");
{
// The server only ever holds ciphertext.
let st = server.state();
let bundle = st.joins.values().next().unwrap().bundle.as_ref().unwrap();
let raw = serde_json::to_string(bundle).unwrap();
assert!(!raw.contains("K1") && !raw.contains("kestrove://"));
assert_eq!(st.tickets.values().filter(|t| t.bound_key_sha256.is_some()).count(), 1);
}
let done = desktop.join_poll().await.unwrap();
let setup = done.setup.expect("bundle arrived");
assert_eq!((setup.secret_key.as_str(), setup.server_url.as_str()), (sk.as_str(), server.url().as_str()));
assert!(setup.ticket.is_some());
// One read: the rendezvous is gone.
assert!(matches!(desktop.join_poll().await, Err(CoreError::Invalid { .. })));
assert!(server.state().joins.is_empty());
// Only the master password is left to type, and no approval is needed.
assert_eq!(desktop.login_with_setup(setup, PW.into()).await.unwrap(), LoginResult::Ok);
desktop.sync_now().await.unwrap();
assert_eq!(desktop.get_item(id).unwrap().password.as_deref(), Some("pw-a"));
assert!(matches!(desktop.join_start(server.url()).await, Err(CoreError::Invalid { .. })));
}
#[tokio::test]
async fn scan_to_join_ticket_is_bound_to_the_joining_device() {
let server = TestServer::start().await;
let (phone, _, _) = phone_with_item(&server).await;
let desktop = VaultCore::new_in_memory();
connect(&desktop, &server);
let session = desktop.join_start(server.url()).await.unwrap();
phone.join_approve(session.qr).await.unwrap();
desktop.join_poll().await.unwrap();
phone.join_send().await.unwrap();
let setup = desktop.join_poll().await.unwrap().setup.unwrap();
// Someone who intercepted the setup (and knows the password) on another device key still needs approval.
let thief = VaultCore::new_in_memory();
connect(&thief, &server);
assert_eq!(thief.login_with_setup(setup.clone(), PW.into()).await.unwrap(), LoginResult::PendingApproval);
assert_eq!(desktop.login_with_setup(setup, PW.into()).await.unwrap(), LoginResult::Ok);
}
#[tokio::test]
async fn scan_to_join_rejects_a_forged_bundle() {
let server = TestServer::start().await;
let (phone, _, _) = phone_with_item(&server).await;
let desktop = VaultCore::new_in_memory();
connect(&desktop, &server);
// Round 1: the server keeps the phone's hello but substitutes a bundle it sealed itself (no QR secret).
let session = desktop.join_start(server.url()).await.unwrap();
phone.join_approve(session.qr.clone()).await.unwrap();
desktop.join_poll().await.unwrap();
{
let qr = kv_sync::onboarding::JoinQr::parse(&session.qr).unwrap();
let rid = base64::Engine::decode(&base64::engine::general_purpose::URL_SAFE_NO_PAD, &qr.rendezvous_id).unwrap();
let forger = kv_crypto::pairing::PairingKeypair::generate();
let forged = kv_crypto::pairing::seal(&forger, &qr.public_key, &[0u8; 32], &rid, b"x").unwrap();
server.state().joins.values_mut().next().unwrap().bundle = Some(forged);
}
assert!(matches!(desktop.join_poll().await, Err(CoreError::Invalid { .. })));
phone.join_cancel();
// Round 2: it also swaps the hello key for its own.
let session = desktop.join_start(server.url()).await.unwrap();
phone.join_approve(session.qr.clone()).await.unwrap();
desktop.join_poll().await.unwrap();
let qr = kv_sync::onboarding::JoinQr::parse(&session.qr).unwrap();
let forger = kv_crypto::pairing::PairingKeypair::generate();
let rid = base64::Engine::decode(&base64::engine::general_purpose::URL_SAFE_NO_PAD, &qr.rendezvous_id).unwrap();
let forged =
kv_crypto::pairing::seal(&forger, &qr.public_key, &[0u8; 32], &rid, b"kestrove://setup/v1?evil").unwrap();
{
let mut st = server.state();
let rv = st.joins.values_mut().next().unwrap();
rv.hello = Some((Uuid::new_v4(), forger.public_bytes().to_vec()));
rv.bundle = Some(forged);
}
// The swapped hello key is noticed (the code was shown for the phone's key) and nothing is accepted.
assert!(matches!(desktop.join_poll().await, Err(CoreError::Invalid { .. })));
assert!(matches!(desktop.join_poll().await, Err(CoreError::Invalid { .. })), "state cleared");
}
+22 -4
View File
@@ -1,13 +1,15 @@
//! Two devices against a real, freshly started kestrove-server. Ignored by default.
//!
//! ```sh
//! # in kestrove-server (empty database, so it prints SETUP_TOKEN=… on boot):
//! KV_DATABASE=sqlite::memory: KV_BIND=127.0.0.1:8080 KV_PUBLIC_URL=http://127.0.0.1:8080 cargo run
//! # in kestrove-server (empty database; without KV_SETUP_CODE it prints SETUP_TOKEN=… on boot instead):
//! KV_DATABASE=sqlite::memory: KV_BIND=127.0.0.1:8080 KV_PUBLIC_URL=http://127.0.0.1:8080 \
//! KV_SETUP_CODE=live-test-setup-code cargo run
//! # here:
//! KESTROVE_SERVER_URL=http://127.0.0.1:8080 KESTROVE_SETUP_TOKEN=<token> \
//! KESTROVE_SERVER_URL=http://127.0.0.1:8080 KESTROVE_SETUP_TOKEN=live-test-setup-code \
//! cargo test -p kv-ffi --test live_server -- --ignored --nocapture
//! ```
//! The setup token works once, so restart the server with an empty database for every run.
//! The setup code works once, so restart the server with an empty database for every run. The run takes about
//! four minutes because it waits out the server's rate limit on the login endpoints.
use std::sync::Arc;
@@ -107,6 +109,22 @@ async fn two_devices_against_local_server() {
b.sync_now().await.unwrap();
assert!(matches!(b.get_item(temp), Err(CoreError::NotFound)));
// Scan-to-join (docs/10 §4): a third device that knows only the server URL needs no approval.
tokio::time::sleep(std::time::Duration::from_secs(61)).await; // rate limit again
let c = VaultCore::new_in_memory();
connect(&c, &url, "device C");
let session = c.join_start(url.clone()).await.unwrap();
let confirm = a.join_approve(session.qr).await.unwrap();
assert_eq!(confirm.device_name, "device C");
assert_eq!(c.join_poll().await.unwrap().code.as_deref(), Some(confirm.code.as_str()));
a.join_send().await.unwrap();
tokio::time::sleep(std::time::Duration::from_millis(1100)).await; // the server allows one poll per second
let setup = c.join_poll().await.unwrap().setup.expect("bundle");
assert_eq!(setup.secret_key, sk);
assert_eq!(c.login_with_setup(setup, pw.into()).await.unwrap(), LoginResult::Ok);
c.sync_now().await.unwrap();
assert!(c.list_items().unwrap().iter().any(|i| i.title == "Bank"));
b.logout().await.unwrap();
println!("live sync OK against {url}");
}
+138
View File
@@ -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<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)]
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<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)]
@@ -301,6 +334,96 @@ pub struct Manifest {
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)]
pub struct ApiError {
pub code: String,
@@ -327,4 +450,19 @@ mod tests {
assert!(!json.contains('+') && !json.contains('/'));
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));
}
}
+70 -4
View File
@@ -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<Vec<u8>>,
if_match: Option<u64>,
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> {
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<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> {
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<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
/// 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> {
let r = self.no_content(Method::POST, PATH_LOGOUT, None::<&()>, true).await;
self.set_session(None);
+2
View File
@@ -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;
+300
View File
@@ -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());
}
}
+142 -3
View File
@@ -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<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 setup: ServerSetup<Suite>,
pub setup_token: Option<String>,
@@ -61,6 +80,10 @@ pub struct State {
pub devices: HashMap<Uuid, Device>,
pub sessions: HashMap<String, 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>,
/// 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<ResponseTemplate, ResponseTem
let login = ServerLogin::<Suite>::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<ResponseTemplate, ResponseTem
&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)?;
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(
st: &mut State,
req: &Request,
@@ -296,6 +375,66 @@ fn authed(
segs: &[&str],
) -> Result<ResponseTemplate, ResponseTemplate> {
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"]) => {
st.sessions.retain(|_, d| *d != device);
Ok(ResponseTemplate::new(204))
+2
View File
@@ -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";