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:
2026-10-17 23:49:48 +09:00
co-authored by Claude Opus 5.5
parent f20380d293
commit 266fb90e45
14 changed files with 1409 additions and 17 deletions
+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";