From 48c7e32d33a01a3f8e15f39b7248907ded911476 Mon Sep 17 00:00:00 2001 From: samialabri Date: Thu, 15 Oct 2026 21:22:03 +0900 Subject: [PATCH] kv-proto: OPAQUE suite, RFC 9421 signing profile and M1 auth wire types Adds the OpaqueSuite (Ristretto255 OPRF, TripleDH, SHA-512, identity KSF) shared by client and server, the httpsig signature-base builders, and the setup/credentials/account/device/SSE types for milestone M1. Cargo.lock changes only for kv-proto's new dependencies. Co-Authored-By: Claude Opus 5.5 --- Cargo.lock | 56 ++++++++++++++++ crates/kv-proto/Cargo.toml | 4 ++ crates/kv-proto/src/httpsig.rs | 116 +++++++++++++++++++++++++++++++++ crates/kv-proto/src/lib.rs | 101 ++++++++++++++++++++++++++++ crates/kv-proto/src/opaque.rs | 78 ++++++++++++++++++++++ 5 files changed, 355 insertions(+) create mode 100644 crates/kv-proto/src/httpsig.rs create mode 100644 crates/kv-proto/src/opaque.rs diff --git a/Cargo.lock b/Cargo.lock index 2fac2bc..85eecc3 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -395,6 +395,7 @@ dependencies = [ "curve25519-dalek-derive", "digest", "fiat-crypto", + "rand_core", "rustc_version", "subtle", "zeroize", @@ -427,6 +428,17 @@ dependencies = [ "zeroize", ] +[[package]] +name = "derive-where" +version = "1.7.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2e2b94854e8576378ccda7c8de8a66ed8b4e8acbd2c50ec3418ea6c8aaf4b567" +dependencies = [ + "proc-macro2", + "quote", + "syn 3.0.9", +] + [[package]] name = "digest" version = "0.10.7" @@ -569,8 +581,10 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "ff2abc00be7fca6ebc474524697ae276ad847ad0a6b3faa4bcb027e9a4614ad0" dependencies = [ "cfg-if", + "js-sys", "libc", "wasi", + "wasm-bindgen", ] [[package]] @@ -858,8 +872,11 @@ name = "kv-proto" version = "0.1.0" dependencies = [ "base64", + "opaque-ke", + "rand_core", "serde", "serde_json", + "sha2", "uuid", ] @@ -955,6 +972,27 @@ version = "0.3.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "c08d65885ee38876c4f86fa503fb49d7b507c2b62552df7c70b2fce627e06381" +[[package]] +name = "opaque-ke" +version = "3.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fb31f7b2c5760d8ffbc39652043f5cff14029b6249a2280b28ae6f0cd61f5098" +dependencies = [ + "curve25519-dalek", + "derive-where", + "digest", + "displaydoc", + "elliptic-curve", + "generic-array", + "getrandom 0.2.17", + "hkdf", + "hmac", + "rand", + "subtle", + "voprf", + "zeroize", +] + [[package]] name = "p256" version = "0.13.2" @@ -1630,6 +1668,24 @@ version = "0.9.5" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "0b928f33d975fc6ad9f86c8f283853ad26bdd5b10b7f1542aa2fa15e2289105a" +[[package]] +name = "voprf" +version = "0.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "28f59c30c76e2fea54cdece6a054e2662feffa7ab19658a7887524265ee39470" +dependencies = [ + "curve25519-dalek", + "derive-where", + "digest", + "displaydoc", + "elliptic-curve", + "generic-array", + "rand_core", + "sha2", + "subtle", + "zeroize", +] + [[package]] name = "wasi" version = "0.11.1+wasi-snapshot-preview1" diff --git a/crates/kv-proto/Cargo.toml b/crates/kv-proto/Cargo.toml index bbbfa92..c1c2d1f 100644 --- a/crates/kv-proto/Cargo.toml +++ b/crates/kv-proto/Cargo.toml @@ -10,6 +10,10 @@ publish.workspace = true serde.workspace = true uuid.workspace = true base64.workspace = true +sha2.workspace = true +# OPAQUE cipher suite shared by client and server (docs/02 "Authentication to the server"). +opaque-ke = { version = "3", default-features = false, features = ["ristretto255-voprf", "std"] } [dev-dependencies] serde_json.workspace = true +rand_core.workspace = true diff --git a/crates/kv-proto/src/httpsig.rs b/crates/kv-proto/src/httpsig.rs new file mode 100644 index 0000000..7793fb0 --- /dev/null +++ b/crates/kv-proto/src/httpsig.rs @@ -0,0 +1,116 @@ +//! The RFC 9421 request-signing profile for every authenticated request (docs/03). +//! +//! A client sends these headers on each call after login: +//! +//! ```text +//! Content-Digest: sha-256=:: +//! KV-Session: +//! KV-Ts: +//! Signature-Input: kv= +//! Signature: kv=:: +//! ``` +//! +//! The server rebuilds the base from the request it received and checks it with the +//! device's enrolled P-256 key. It rejects a `KV-Ts` more than [`MAX_SKEW_SECS`] off, +//! a reused nonce, and any deviation from this exact profile (label, components, order). + +use base64::{engine::general_purpose::STANDARD, Engine}; +use sha2::{Digest, Sha256}; +use uuid::Uuid; + +pub const HEADER_SESSION: &str = "kv-session"; +pub const HEADER_TS: &str = "kv-ts"; +pub const HEADER_CONTENT_DIGEST: &str = "content-digest"; +pub const HEADER_SIGNATURE_INPUT: &str = "signature-input"; +pub const HEADER_SIGNATURE: &str = "signature"; + +/// The only accepted signature label. +pub const LABEL: &str = "kv"; +pub const ALG: &str = "ecdsa-p256-sha256"; +/// Covered components, in this order. +pub const COVERED_COMPONENTS: &str = r#"("@method" "@target-uri" "content-digest" "kv-session" "kv-ts")"#; +pub const MAX_SKEW_SECS: i64 = 120; +/// Nonces are 16–64 characters of base64url (`A-Z a-z 0-9 - _`), e.g. 16 random bytes encoded. +pub const NONCE_MIN_LEN: usize = 16; +pub const NONCE_MAX_LEN: usize = 64; + +/// `Content-Digest` value (RFC 9530) for a request body. +pub fn content_digest(body: &[u8]) -> String { + format!("sha-256=:{}:", STANDARD.encode(Sha256::digest(body))) +} + +/// The `@signature-params` value. `created` must equal the `KV-Ts` header and `keyid` is the device id. +pub fn signature_params(created: i64, keyid: Uuid, nonce: &str) -> String { + format!(r#"{COVERED_COMPONENTS};created={created};keyid="{keyid}";alg="{ALG}";nonce="{nonce}""#) +} + +/// The RFC 9421 signature base the device key signs. +/// +/// `target_uri` is the absolute URI the client requested, e.g. +/// `https://vault.example.com/v1/vaults/…/changes?since=3`. Header values are passed exactly as sent. +pub fn signature_base( + method: &str, + target_uri: &str, + content_digest: &str, + session: &str, + ts: &str, + params: &str, +) -> String { + format!( + "\"@method\": {method}\n\"@target-uri\": {target_uri}\n\"content-digest\": {content_digest}\n\ + \"kv-session\": {session}\n\"kv-ts\": {ts}\n\"@signature-params\": {params}" + ) +} + +/// `Signature-Input` header value. +pub fn signature_input_header(params: &str) -> String { + format!("{LABEL}={params}") +} + +/// `Signature` header value for a raw 64-byte `r||s` signature. +pub fn signature_header(sig: &[u8]) -> String { + format!("{LABEL}=:{}:", STANDARD.encode(sig)) +} + +/// True if `nonce` fits the allowed alphabet and length. +pub fn valid_nonce(nonce: &str) -> bool { + (NONCE_MIN_LEN..=NONCE_MAX_LEN).contains(&nonce.len()) + && nonce.bytes().all(|b| b.is_ascii_alphanumeric() || b == b'-' || b == b'_') +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn empty_body_digest() { + assert_eq!(content_digest(b""), "sha-256=:47DEQpj8HBSa+/TImW+5JCeuQeRkm5NMpJWZG3hSuFU=:"); + } + + #[test] + fn base_matches_rfc9421_layout() { + let kid = Uuid::nil(); + let params = signature_params(1_700_000_000, kid, "AAAAAAAAAAAAAAAAAAAAAA"); + assert_eq!( + params, + r#"("@method" "@target-uri" "content-digest" "kv-session" "kv-ts");created=1700000000;keyid="00000000-0000-0000-0000-000000000000";alg="ecdsa-p256-sha256";nonce="AAAAAAAAAAAAAAAAAAAAAA""# + ); + let base = signature_base("GET", "https://v.example/v1/vaults", "sha-256=:x:", "s", "1700000000", ¶ms); + assert_eq!( + base, + format!( + "\"@method\": GET\n\"@target-uri\": https://v.example/v1/vaults\n\"content-digest\": sha-256=:x:\n\ + \"kv-session\": s\n\"kv-ts\": 1700000000\n\"@signature-params\": {params}" + ) + ); + assert_eq!(signature_input_header(¶ms), format!("kv={params}")); + assert_eq!(signature_header(&[0u8; 3]), "kv=:AAAA:"); + } + + #[test] + fn nonce_rules() { + assert!(valid_nonce("abcdefghijklmnop")); + assert!(!valid_nonce("short")); + assert!(!valid_nonce("abcdefghijklmno\"")); + } +} diff --git a/crates/kv-proto/src/lib.rs b/crates/kv-proto/src/lib.rs index 4a9ce72..bae488a 100644 --- a/crates/kv-proto/src/lib.rs +++ b/crates/kv-proto/src/lib.rs @@ -6,6 +6,9 @@ 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; @@ -93,11 +96,34 @@ pub struct DeviceEnroll { 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")] @@ -108,27 +134,35 @@ pub struct SetupRequest { pub device: DeviceEnroll, } +/// `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, } @@ -140,13 +174,80 @@ pub enum LoginStatus { 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, diff --git a/crates/kv-proto/src/opaque.rs b/crates/kv-proto/src/opaque.rs new file mode 100644 index 0000000..ca29dea --- /dev/null +++ b/crates/kv-proto/src/opaque.rs @@ -0,0 +1,78 @@ +//! The OPAQUE profile used for login, setup and password change (docs/02 "Authentication to the server"). +//! +//! Client and server MUST both use [`OpaqueSuite`] with these conventions, or login fails: +//! * password input: the 32-byte `AUTH_INPUT`, which the client has already stretched with Argon2id, +//! so the suite's key-stretching function is the identity; +//! * credential identifier (server side only): the 16 raw bytes of the account UUID; +//! * default parameters everywhere (`ClientRegistrationFinishParameters::default()`, +//! `ClientLoginFinishParameters::default()`, `ServerLoginStartParameters::default()`): +//! no custom identifiers and no context string. +//! +//! Messages travel base64url-encoded (no padding) inside the JSON bodies in this crate, using +//! opaque-ke's own `serialize()` / `deserialize()`. + +pub use opaque_ke; + +/// Ristretto255 OPRF, Ristretto255 + TripleDH key exchange, SHA-512, identity KSF. +pub struct OpaqueSuite; + +impl opaque_ke::CipherSuite for OpaqueSuite { + type OprfCs = opaque_ke::Ristretto255; + type KeGroup = opaque_ke::Ristretto255; + type KeyExchange = opaque_ke::key_exchange::tripledh::TripleDh; + type Ksf = opaque_ke::ksf::Identity; +} + +#[cfg(test)] +mod tests { + use opaque_ke::{ + ClientLogin, ClientLoginFinishParameters, ClientRegistration, ClientRegistrationFinishParameters, + CredentialFinalization, CredentialRequest, CredentialResponse, RegistrationRequest, RegistrationResponse, + RegistrationUpload, ServerLogin, ServerLoginStartParameters, ServerRegistration, ServerSetup, + }; + + use rand_core::OsRng; + + use super::OpaqueSuite; + + /// Registration then login across serialized messages, as the client and server exchange them. + #[test] + fn registration_and_login_roundtrip() { + let mut rng = OsRng; + let setup = ServerSetup::::new(&mut rng); + let cred_id = uuid::Uuid::new_v4(); + let auth_input = [7u8; 32]; + + let c = ClientRegistration::::start(&mut rng, &auth_input).unwrap(); + let req = RegistrationRequest::deserialize(&c.message.serialize()).unwrap(); + let s = ServerRegistration::::start(&setup, req, cred_id.as_bytes()).unwrap(); + let resp = RegistrationResponse::deserialize(&s.message.serialize()).unwrap(); + let fin = c.state.finish(&mut rng, &auth_input, resp, ClientRegistrationFinishParameters::default()).unwrap(); + let upload = RegistrationUpload::::deserialize(&fin.message.serialize()).unwrap(); + let record = ServerRegistration::finish(upload).serialize(); + + let login = |password: &[u8]| { + let c = ClientLogin::::start(&mut OsRng, password).unwrap(); + let ke1 = CredentialRequest::deserialize(&c.message.serialize()).unwrap(); + let record = ServerRegistration::deserialize(&record).unwrap(); + let s = ServerLogin::start( + &mut OsRng, + &setup, + Some(record), + ke1, + cred_id.as_bytes(), + ServerLoginStartParameters::default(), + ) + .unwrap(); + let ke2 = CredentialResponse::deserialize(&s.message.serialize()).unwrap(); + let Ok(done) = c.state.finish(password, ke2, ClientLoginFinishParameters::default()) else { + return false; + }; + let ke3 = CredentialFinalization::deserialize(&done.message.serialize()).unwrap(); + let server = s.state.finish(ke3).unwrap(); + server.session_key == done.session_key + }; + assert!(login(&auth_input)); + assert!(!login(&[8u8; 32])); + } +}