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