Files
kestrove-core/crates/kv-proto/src/lib.rs
T
2026-10-15 18:30:14 +09:00

219 lines
5.8 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 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;
/// 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)
}
}
#[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>,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct SetupRequest {
pub setup_token: String,
pub account_id: Uuid,
pub kdf: KdfParams,
#[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,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct LoginStart {
pub account_id: Uuid,
#[serde(with = "b64")]
pub ke1: Vec<u8>,
pub device: DeviceEnroll,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct LoginStartResponse {
pub login_id: String,
#[serde(with = "b64")]
pub ke2: Vec<u8>,
pub kdf: KdfParams,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct LoginFinish {
pub login_id: String,
#[serde(with = "b64")]
pub ke3: Vec<u8>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub totp: Option<String>,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "snake_case")]
pub enum LoginStatus {
Ok,
PendingApproval,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct LoginFinishResponse {
pub status: LoginStatus,
pub session: Option<String>,
pub expires_at: Option<String>,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct Vault {
pub vault_id: Uuid,
pub head_revision: u64,
pub sealed_key: Envelope,
pub meta: Envelope,
}
#[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,
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>,
}
#[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);
}
}