* 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]>
469 lines
15 KiB
Rust
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));
|
|
}
|
|
}
|