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
+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));
}
}