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 <[email protected]>
This commit is contained in:
2026-10-15 21:22:03 +09:00
co-authored by Claude Opus 5.5
parent 4a152bde11
commit 48c7e32d33
5 changed files with 355 additions and 0 deletions
Generated
+56
View File
@@ -395,6 +395,7 @@ dependencies = [
"curve25519-dalek-derive", "curve25519-dalek-derive",
"digest", "digest",
"fiat-crypto", "fiat-crypto",
"rand_core",
"rustc_version", "rustc_version",
"subtle", "subtle",
"zeroize", "zeroize",
@@ -427,6 +428,17 @@ dependencies = [
"zeroize", "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]] [[package]]
name = "digest" name = "digest"
version = "0.10.7" version = "0.10.7"
@@ -569,8 +581,10 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "ff2abc00be7fca6ebc474524697ae276ad847ad0a6b3faa4bcb027e9a4614ad0" checksum = "ff2abc00be7fca6ebc474524697ae276ad847ad0a6b3faa4bcb027e9a4614ad0"
dependencies = [ dependencies = [
"cfg-if", "cfg-if",
"js-sys",
"libc", "libc",
"wasi", "wasi",
"wasm-bindgen",
] ]
[[package]] [[package]]
@@ -858,8 +872,11 @@ name = "kv-proto"
version = "0.1.0" version = "0.1.0"
dependencies = [ dependencies = [
"base64", "base64",
"opaque-ke",
"rand_core",
"serde", "serde",
"serde_json", "serde_json",
"sha2",
"uuid", "uuid",
] ]
@@ -955,6 +972,27 @@ version = "0.3.1"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "c08d65885ee38876c4f86fa503fb49d7b507c2b62552df7c70b2fce627e06381" 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]] [[package]]
name = "p256" name = "p256"
version = "0.13.2" version = "0.13.2"
@@ -1630,6 +1668,24 @@ version = "0.9.5"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "0b928f33d975fc6ad9f86c8f283853ad26bdd5b10b7f1542aa2fa15e2289105a" 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]] [[package]]
name = "wasi" name = "wasi"
version = "0.11.1+wasi-snapshot-preview1" version = "0.11.1+wasi-snapshot-preview1"
+4
View File
@@ -10,6 +10,10 @@ publish.workspace = true
serde.workspace = true serde.workspace = true
uuid.workspace = true uuid.workspace = true
base64.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] [dev-dependencies]
serde_json.workspace = true serde_json.workspace = true
rand_core.workspace = true
+116
View File
@@ -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=:<base64 SHA-256 of the body, also for an empty body>:
//! KV-Session: <session id from login>
//! KV-Ts: <unix seconds>
//! Signature-Input: kv=<signature_params(...)>
//! Signature: kv=:<base64 of the 64-byte P-256 ECDSA signature r||s over signature_base(...)>:
//! ```
//!
//! 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", &params);
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(&params), 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\""));
}
}
+101
View File
@@ -6,6 +6,9 @@
use serde::{Deserialize, Serialize}; use serde::{Deserialize, Serialize};
use uuid::Uuid; use uuid::Uuid;
pub mod httpsig;
pub mod opaque;
pub const FORMAT_VERSION: u8 = 1; pub const FORMAT_VERSION: u8 = 1;
pub const API_VERSION: u32 = 1; pub const API_VERSION: u32 = 1;
@@ -93,11 +96,34 @@ pub struct DeviceEnroll {
pub p256_pk: Vec<u8>, 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)] #[derive(Debug, Clone, Serialize, Deserialize)]
pub struct SetupRequest { pub struct SetupRequest {
pub setup_token: String, pub setup_token: String,
pub account_id: Uuid, pub account_id: Uuid,
pub kdf: KdfParams, pub kdf: KdfParams,
/// Serialized opaque-ke `RegistrationUpload`.
#[serde(with = "b64")] #[serde(with = "b64")]
pub opaque_registration_upload: Vec<u8>, pub opaque_registration_upload: Vec<u8>,
#[serde(with = "b64")] #[serde(with = "b64")]
@@ -108,27 +134,35 @@ pub struct SetupRequest {
pub device: DeviceEnroll, pub device: DeviceEnroll,
} }
/// `POST /v1/auth/login/start`.
#[derive(Debug, Clone, Serialize, Deserialize)] #[derive(Debug, Clone, Serialize, Deserialize)]
pub struct LoginStart { pub struct LoginStart {
pub account_id: Uuid, pub account_id: Uuid,
/// Serialized opaque-ke `CredentialRequest`.
#[serde(with = "b64")] #[serde(with = "b64")]
pub ke1: Vec<u8>, pub ke1: Vec<u8>,
/// The logging-in device. An unknown `device_id` is enrolled as pending after a successful login.
pub device: DeviceEnroll, pub device: DeviceEnroll,
} }
#[derive(Debug, Clone, Serialize, Deserialize)] #[derive(Debug, Clone, Serialize, Deserialize)]
pub struct LoginStartResponse { pub struct LoginStartResponse {
/// Handle for `/v1/auth/login/finish`; expires after 60 s.
pub login_id: String, pub login_id: String,
/// Serialized opaque-ke `CredentialResponse`.
#[serde(with = "b64")] #[serde(with = "b64")]
pub ke2: Vec<u8>, pub ke2: Vec<u8>,
pub kdf: KdfParams, pub kdf: KdfParams,
} }
/// `POST /v1/auth/login/finish`.
#[derive(Debug, Clone, Serialize, Deserialize)] #[derive(Debug, Clone, Serialize, Deserialize)]
pub struct LoginFinish { pub struct LoginFinish {
pub login_id: String, pub login_id: String,
/// Serialized opaque-ke `CredentialFinalization`.
#[serde(with = "b64")] #[serde(with = "b64")]
pub ke3: Vec<u8>, pub ke3: Vec<u8>,
/// Reserved for the TOTP enrollment path; not implemented by the server yet.
#[serde(default, skip_serializing_if = "Option::is_none")] #[serde(default, skip_serializing_if = "Option::is_none")]
pub totp: Option<String>, pub totp: Option<String>,
} }
@@ -140,13 +174,80 @@ pub enum LoginStatus {
PendingApproval, 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)] #[derive(Debug, Clone, Serialize, Deserialize)]
pub struct LoginFinishResponse { pub struct LoginFinishResponse {
pub status: LoginStatus, pub status: LoginStatus,
pub session: Option<String>, pub session: Option<String>,
/// RFC 3339. Rolling: every authenticated request extends it by 12 h.
pub expires_at: Option<String>, 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)] #[derive(Debug, Clone, Serialize, Deserialize)]
pub struct Vault { pub struct Vault {
pub vault_id: Uuid, pub vault_id: Uuid,
+78
View File
@@ -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::<OpaqueSuite>::new(&mut rng);
let cred_id = uuid::Uuid::new_v4();
let auth_input = [7u8; 32];
let c = ClientRegistration::<OpaqueSuite>::start(&mut rng, &auth_input).unwrap();
let req = RegistrationRequest::deserialize(&c.message.serialize()).unwrap();
let s = ServerRegistration::<OpaqueSuite>::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::<OpaqueSuite>::deserialize(&fin.message.serialize()).unwrap();
let record = ServerRegistration::finish(upload).serialize();
let login = |password: &[u8]| {
let c = ClientLogin::<OpaqueSuite>::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]));
}
}