//! 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(bytes: &[u8], s: S) -> Result { s.serialize_str(&URL_SAFE_NO_PAD.encode(bytes)) } pub fn deserialize<'de, D: Deserializer<'de>>(d: D) -> Result, 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(bytes: &Option>, s: S) -> Result { 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>, D::Error> { Option::::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, #[serde(with = "b64")] pub ct: Vec, /// HPKE encapsulated key (HPKE envelopes only). #[serde(with = "b64", default, skip_serializing_if = "Vec::is_empty")] pub enc: Vec, } #[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, } #[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, } /// `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, } /// 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, } /// `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, #[serde(with = "b64")] pub enc_pk: Vec, #[serde(with = "b64")] pub sig_pk: Vec, 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, /// 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, 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, /// Reserved for the TOTP enrollment path; not implemented by the server yet. #[serde(default, skip_serializing_if = "Option::is_none")] pub totp: Option, /// 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, } #[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, /// RFC 3339. Rolling: every authenticated request extends it by 12 h. pub expires_at: Option, } /// `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, #[serde(with = "b64")] pub sig_pk: Vec, 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, } /// `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, 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, pub approved: bool, pub revoked_at: Option, 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, pub payload: Option, pub updated_by: Uuid, pub updated_at: String, } #[derive(Debug, Clone, Serialize, Deserialize)] pub struct Changes { pub items: Vec, 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, #[serde(with = "b64")] pub signature: Vec, } // ---------------------------------------------------------------- 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>, } /// 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>, #[serde(default, skip_serializing_if = "Option::is_none")] pub bundle: Option, 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, } #[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::(&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::(&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)); } }