Files
kestrove-core/crates/kv-proto/src/lib.rs
T
samiandClaude Opus 5.5 266fb90e45 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]>
2026-10-17 23:49:48 +09:00

469 lines
15 KiB
Rust

//! Wire format for the Kestrove sync API (see kestrove-spec/docs/03 and api/openapi.yaml).
//!
//! Everything here is either public metadata or opaque ciphertext. The server
//! depends on this crate and nothing else from the core.
use serde::{Deserialize, Serialize};
use uuid::Uuid;
pub mod httpsig;
pub mod opaque;
pub const FORMAT_VERSION: u8 = 1;
pub const API_VERSION: u32 = 1;
/// Minimum Argon2id parameters a client accepts (anti-downgrade, docs/02).
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};
use serde::{Deserialize, Deserializer, Serializer};
pub fn serialize<S: Serializer>(bytes: &[u8], s: S) -> Result<S::Ok, S::Error> {
s.serialize_str(&URL_SAFE_NO_PAD.encode(bytes))
}
pub fn deserialize<'de, D: Deserializer<'de>>(d: D) -> Result<Vec<u8>, D::Error> {
let s = String::deserialize(d)?;
URL_SAFE_NO_PAD.decode(s).map_err(serde::de::Error::custom)
}
}
/// 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.
#[serde(rename = "xc20p")]
XChaCha20Poly1305,
/// HPKE base mode, DHKEM(X25519, HKDF-SHA256), HKDF-SHA256, ChaCha20-Poly1305.
#[serde(rename = "hpke-x25519-chacha")]
HpkeX25519ChaCha,
}
/// Every encrypted object on the wire and on disk (docs/02 "Envelope format").
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub struct Envelope {
pub v: u8,
pub alg: Alg,
/// Id of the key that opens this envelope.
pub kid: Uuid,
#[serde(with = "b64", default, skip_serializing_if = "Vec::is_empty")]
pub n: Vec<u8>,
#[serde(with = "b64")]
pub ct: Vec<u8>,
/// HPKE encapsulated key (HPKE envelopes only).
#[serde(with = "b64", default, skip_serializing_if = "Vec::is_empty")]
pub enc: Vec<u8>,
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub struct KdfParams {
pub alg: String,
pub m_kib: u32,
pub t: u32,
pub p: u32,
#[serde(with = "b64")]
pub salt: Vec<u8>,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "lowercase")]
pub enum Platform {
Ios,
Macos,
Android,
Windows,
Linux,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct ServerInfo {
pub version: String,
pub api: u32,
pub single_tenant: bool,
pub setup_open: bool,
pub min_client: String,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct DeviceEnroll {
pub device_id: Uuid,
pub name: String,
pub platform: Platform,
/// SEC1 uncompressed P-256 public key.
#[serde(with = "b64")]
pub p256_pk: Vec<u8>,
}
/// `POST /v1/setup/start`: OPAQUE registration, first message. Needs the setup token.
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct SetupStart {
pub setup_token: String,
pub account_id: Uuid,
/// Serialized opaque-ke `RegistrationRequest`.
#[serde(with = "b64")]
pub registration_request: Vec<u8>,
}
/// Reply to `POST /v1/setup/start` and `POST /v1/account/credentials/start`.
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct RegistrationStartResponse {
/// Serialized opaque-ke `RegistrationResponse`.
#[serde(with = "b64")]
pub registration_response: Vec<u8>,
}
/// `POST /v1/setup/finish`. `account_id` must match the one sent to `/v1/setup/start`.
pub type SetupFinish = SetupRequest;
/// Body of `POST /v1/setup/finish` (alias [`SetupFinish`]).
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct SetupRequest {
pub setup_token: String,
pub account_id: Uuid,
pub kdf: KdfParams,
/// Serialized opaque-ke `RegistrationUpload`.
#[serde(with = "b64")]
pub opaque_registration_upload: Vec<u8>,
#[serde(with = "b64")]
pub enc_pk: Vec<u8>,
#[serde(with = "b64")]
pub sig_pk: Vec<u8>,
pub wrapped_keyset: Envelope,
pub device: DeviceEnroll,
}
/// `GET /v1/auth/prelogin`: the public inputs a new device needs to derive `AUTH_INPUT`
/// before it can build `ke1` (docs/02 "Key derivation"). Unauthenticated, rate limited.
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct PreloginResponse {
pub account_id: Uuid,
pub kdf: KdfParams,
}
/// `POST /v1/auth/login/start`.
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct LoginStart {
pub account_id: Uuid,
/// Serialized opaque-ke `CredentialRequest`.
#[serde(with = "b64")]
pub ke1: Vec<u8>,
/// The logging-in device. An unknown `device_id` is enrolled as pending after a successful login.
pub device: DeviceEnroll,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct LoginStartResponse {
/// Handle for `/v1/auth/login/finish`; expires after 60 s.
pub login_id: String,
/// Serialized opaque-ke `CredentialResponse`.
#[serde(with = "b64")]
pub ke2: Vec<u8>,
pub kdf: KdfParams,
}
/// `POST /v1/auth/login/finish`.
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct LoginFinish {
pub login_id: String,
/// Serialized opaque-ke `CredentialFinalization`.
#[serde(with = "b64")]
pub ke3: Vec<u8>,
/// 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)]
#[serde(rename_all = "snake_case")]
pub enum LoginStatus {
Ok,
PendingApproval,
}
/// `status: ok` carries a session (put it in `KV-Session`); `pending_approval` carries none:
/// approve the device from an enrolled one, then log in again.
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct LoginFinishResponse {
pub status: LoginStatus,
pub session: Option<String>,
/// RFC 3339. Rolling: every authenticated request extends it by 12 h.
pub expires_at: Option<String>,
}
/// `GET /v1/account`.
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct AccountInfo {
pub account_id: Uuid,
pub kdf: KdfParams,
#[serde(with = "b64")]
pub enc_pk: Vec<u8>,
#[serde(with = "b64")]
pub sig_pk: Vec<u8>,
pub wrapped_keyset: Envelope,
}
/// `POST /v1/account/credentials/start`: OPAQUE re-registration for a password change.
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct CredentialsStart {
/// Serialized opaque-ke `RegistrationRequest` for the new `AUTH_INPUT`.
#[serde(with = "b64")]
pub registration_request: Vec<u8>,
}
/// `PUT /v1/account/credentials`: replaces the OPAQUE record, KDF params and wrapped keyset atomically.
/// Sessions of all other devices are ended.
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct CredentialsChange {
pub kdf: KdfParams,
/// Serialized opaque-ke `RegistrationUpload`.
#[serde(with = "b64")]
pub registration_upload: Vec<u8>,
pub wrapped_keyset: Envelope,
}
/// An entry of `GET /v1/devices`.
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct DeviceInfo {
pub device_id: Uuid,
pub name: String,
pub platform: Platform,
/// SEC1 uncompressed P-256 public key; show a fingerprint before approving.
#[serde(with = "b64")]
pub p256_pk: Vec<u8>,
pub approved: bool,
pub revoked_at: Option<String>,
pub created_at: String,
/// True for the device that made the request.
pub current: bool,
}
/// Server-Sent Events on `GET /v1/events`. The SSE event name equals `type`.
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
#[serde(tag = "type", rename_all = "snake_case")]
pub enum ServerEvent {
VaultChanged { vault_id: Uuid, head: u64 },
DevicePending { device_id: Uuid },
}
impl ServerEvent {
pub fn name(&self) -> &'static str {
match self {
ServerEvent::VaultChanged { .. } => "vault_changed",
ServerEvent::DevicePending { .. } => "device_pending",
}
}
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct Vault {
pub vault_id: Uuid,
pub head_revision: u64,
pub sealed_key: Envelope,
pub meta: Envelope,
}
/// Body of `PUT /v1/vaults/{vid}/items/{iid}` (the sealed item) and of `DELETE` on the same path (the sealed
/// tombstone `{id, seq, deleted: true}`, same AAD as the item; docs/03 "Per-item replay protection").
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct ItemPut {
pub wrapped_item_key: Envelope,
pub payload: Envelope,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct ItemRecord {
pub item_id: Uuid,
pub revision: u64,
pub deleted: bool,
/// Present on live records and sealed tombstones. Absent only on legacy tombstones, which clients refuse.
pub wrapped_item_key: Option<Envelope>,
pub payload: Option<Envelope>,
pub updated_by: Uuid,
pub updated_at: String,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct Changes {
pub items: Vec<ItemRecord>,
pub head_revision: u64,
pub more: bool,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct Manifest {
pub version: u64,
/// Canonical CBOR of the manifest body (docs/03 "Integrity").
#[serde(with = "b64")]
pub body: Vec<u8>,
#[serde(with = "b64")]
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,
pub message: String,
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn envelope_json_roundtrip_uses_b64url() {
let env = Envelope {
v: FORMAT_VERSION,
alg: Alg::XChaCha20Poly1305,
kid: Uuid::nil(),
n: vec![0xff; 24],
ct: vec![1, 2, 3],
enc: vec![],
};
let json = serde_json::to_string(&env).unwrap();
assert!(json.contains("\"alg\":\"xc20p\""));
assert!(!json.contains("enc"));
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));
}
}