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:
Generated
+56
@@ -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"
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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", ¶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\""));
|
||||
}
|
||||
}
|
||||
@@ -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<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")]
|
||||
@@ -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<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>,
|
||||
}
|
||||
@@ -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<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,
|
||||
|
||||
@@ -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]));
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user