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:
@@ -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);
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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::{
|
||||
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))
|
||||
|
||||
@@ -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";
|
||||
|
||||
Reference in New Issue
Block a user