From 67983c7a402f59d558e2dc126a992869faf642ae Mon Sep 17 00:00:00 2001 From: Christian Nennemann Date: Wed, 4 Mar 2026 12:39:33 +0100 Subject: [PATCH] feat(sdk): OPAQUE auth module with state persistence --- crates/quicproquo-sdk/Cargo.toml | 4 + crates/quicproquo-sdk/src/auth.rs | 142 +++++++++++++++++ crates/quicproquo-sdk/src/client.rs | 60 ++++++++ crates/quicproquo-sdk/src/events.rs | 9 ++ crates/quicproquo-sdk/src/state.rs | 227 ++++++++++++++++++++++++++++ 5 files changed, 442 insertions(+) create mode 100644 crates/quicproquo-sdk/src/auth.rs create mode 100644 crates/quicproquo-sdk/src/state.rs diff --git a/crates/quicproquo-sdk/Cargo.toml b/crates/quicproquo-sdk/Cargo.toml index fec4348..9a81e61 100644 --- a/crates/quicproquo-sdk/Cargo.toml +++ b/crates/quicproquo-sdk/Cargo.toml @@ -24,6 +24,10 @@ rand = { workspace = true } sha2 = { workspace = true } rustls = { workspace = true } quinn = { workspace = true } +opaque-ke = { workspace = true } +chacha20poly1305 = { workspace = true } +bytes = { workspace = true } +prost = { workspace = true } [dev-dependencies] tokio = { workspace = true, features = ["test-util"] } diff --git a/crates/quicproquo-sdk/src/auth.rs b/crates/quicproquo-sdk/src/auth.rs new file mode 100644 index 0000000..4ddf130 --- /dev/null +++ b/crates/quicproquo-sdk/src/auth.rs @@ -0,0 +1,142 @@ +//! OPAQUE authentication — register and login via the v2 RPC protocol. +//! +//! Wraps the `opaque-ke` crate to perform the OPAQUE 3-message flow against +//! the quicproquo server using prost-encoded protobuf messages over `RpcClient::call`. + +use bytes::Bytes; +use opaque_ke::{ + ClientLogin, ClientLoginFinishParameters, ClientRegistration, + ClientRegistrationFinishParameters, CredentialResponse, RegistrationResponse, +}; +use prost::Message; +use quicproquo_core::{opaque_auth::OpaqueSuite, IdentityKeypair}; +use quicproquo_proto::{method_ids, qpq::v1}; +use quicproquo_rpc::client::RpcClient; + +use crate::error::SdkError; + +/// Register a new user account via the OPAQUE protocol. +/// +/// If `identity` is `None`, a fresh Ed25519 keypair is generated. +/// Returns the identity keypair bound to this account. +pub async fn opaque_register( + rpc: &RpcClient, + username: &str, + password: &str, + identity: Option<&IdentityKeypair>, +) -> Result { + let mut rng = rand::rngs::OsRng; + + // Generate or use provided identity. + let keypair = match identity { + Some(kp) => IdentityKeypair::from_seed(*kp.seed_bytes()), + None => IdentityKeypair::generate(), + }; + + // ── Step 1: Registration Start ───────────────────────────────────────── + let reg_start = ClientRegistration::::start(&mut rng, password.as_bytes()) + .map_err(|e| SdkError::AuthFailed(format!("OPAQUE register start: {e}")))?; + + let start_req = v1::OpaqueRegisterStartRequest { + username: username.to_string(), + request: reg_start.message.serialize().to_vec(), + }; + let resp_bytes = rpc + .call(method_ids::OPAQUE_REGISTER_START, Bytes::from(start_req.encode_to_vec())) + .await?; + + let start_resp = v1::OpaqueRegisterStartResponse::decode(resp_bytes) + .map_err(|e| SdkError::AuthFailed(format!("decode register_start response: {e}")))?; + + let reg_response = RegistrationResponse::::deserialize(&start_resp.response) + .map_err(|e| SdkError::AuthFailed(format!("invalid registration response: {e}")))?; + + // ── Step 2: Registration Finish ──────────────────────────────────────── + let reg_finish = reg_start + .state + .finish( + &mut rng, + password.as_bytes(), + reg_response, + ClientRegistrationFinishParameters::::default(), + ) + .map_err(|e| SdkError::AuthFailed(format!("OPAQUE register finish: {e}")))?; + + let finish_req = v1::OpaqueRegisterFinishRequest { + username: username.to_string(), + upload: reg_finish.message.serialize().to_vec(), + identity_key: keypair.public_key_bytes().to_vec(), + }; + let resp_bytes = rpc + .call(method_ids::OPAQUE_REGISTER_FINISH, Bytes::from(finish_req.encode_to_vec())) + .await?; + + let finish_resp = v1::OpaqueRegisterFinishResponse::decode(resp_bytes) + .map_err(|e| SdkError::AuthFailed(format!("decode register_finish response: {e}")))?; + + if !finish_resp.success { + return Err(SdkError::AuthFailed("server rejected registration".into())); + } + + Ok(keypair) +} + +/// Log in via the OPAQUE protocol and receive a session token. +/// +/// `identity_key` is the 32-byte Ed25519 public key to bind to this session. +/// Returns the session token bytes. +pub async fn opaque_login( + rpc: &RpcClient, + username: &str, + password: &str, + identity_key: &[u8], +) -> Result, SdkError> { + let mut rng = rand::rngs::OsRng; + + // ── Step 1: Login Start ──────────────────────────────────────────────── + let login_start = ClientLogin::::start(&mut rng, password.as_bytes()) + .map_err(|e| SdkError::AuthFailed(format!("OPAQUE login start: {e}")))?; + + let start_req = v1::OpaqueLoginStartRequest { + username: username.to_string(), + request: login_start.message.serialize().to_vec(), + }; + let resp_bytes = rpc + .call(method_ids::OPAQUE_LOGIN_START, Bytes::from(start_req.encode_to_vec())) + .await?; + + let start_resp = v1::OpaqueLoginStartResponse::decode(resp_bytes) + .map_err(|e| SdkError::AuthFailed(format!("decode login_start response: {e}")))?; + + let credential_response = CredentialResponse::::deserialize(&start_resp.response) + .map_err(|e| SdkError::AuthFailed(format!("invalid credential response: {e}")))?; + + // ── Step 2: Login Finish ─────────────────────────────────────────────── + let login_finish = login_start + .state + .finish( + &mut rng, + password.as_bytes(), + credential_response, + ClientLoginFinishParameters::::default(), + ) + .map_err(|e| SdkError::AuthFailed(format!("OPAQUE login finish (bad password?): {e}")))?; + + let finish_req = v1::OpaqueLoginFinishRequest { + username: username.to_string(), + finalization: login_finish.message.serialize().to_vec(), + identity_key: identity_key.to_vec(), + }; + let resp_bytes = rpc + .call(method_ids::OPAQUE_LOGIN_FINISH, Bytes::from(finish_req.encode_to_vec())) + .await?; + + let finish_resp = v1::OpaqueLoginFinishResponse::decode(resp_bytes) + .map_err(|e| SdkError::AuthFailed(format!("decode login_finish response: {e}")))?; + + if finish_resp.session_token.is_empty() { + return Err(SdkError::AuthFailed("server returned empty session token".into())); + } + + Ok(finish_resp.session_token) +} diff --git a/crates/quicproquo-sdk/src/client.rs b/crates/quicproquo-sdk/src/client.rs index 877a03d..dabf3f1 100644 --- a/crates/quicproquo-sdk/src/client.rs +++ b/crates/quicproquo-sdk/src/client.rs @@ -104,6 +104,66 @@ impl QpqClient { .ok_or(SdkError::NotConnected) } + /// Register a new user account via OPAQUE. + /// + /// Generates a fresh identity keypair, registers it with the server, and + /// stores the identity key locally. + pub async fn register(&mut self, username: &str, password: &str) -> Result<(), SdkError> { + let rpc = self.rpc.as_ref().ok_or(SdkError::NotConnected)?; + let keypair = crate::auth::opaque_register(rpc, username, password, None).await?; + self.identity_key = Some(keypair.public_key_bytes().to_vec()); + self.emit(ClientEvent::Registered { + username: username.to_string(), + }); + info!(username, "registered"); + Ok(()) + } + + /// Log in via OPAQUE and store the session token. + /// + /// Requires an identity key to be set (either from a previous `register()` + /// call or loaded from state). After login, the client is authenticated + /// and subsequent RPC calls include the session token. + pub async fn login(&mut self, username: &str, password: &str) -> Result<(), SdkError> { + let identity_key = self + .identity_key + .as_ref() + .ok_or_else(|| SdkError::AuthFailed("no identity key — register or load state first".into()))? + .clone(); + + let rpc = self.rpc.as_ref().ok_or(SdkError::NotConnected)?; + let session_token = crate::auth::opaque_login(rpc, username, password, &identity_key).await?; + + self.session_token = Some(session_token); + self.username = Some(username.to_string()); + self.emit(ClientEvent::LoggedIn { + username: username.to_string(), + }); + info!(username, "logged in"); + Ok(()) + } + + /// Clear authentication state (session token, username). + pub fn logout(&mut self) -> Result<(), SdkError> { + self.session_token = None; + let username = self.username.take(); + self.emit(ClientEvent::LoggedOut { + username: username.unwrap_or_default(), + }); + info!("logged out"); + Ok(()) + } + + /// Set the identity key directly (e.g. after loading from state). + pub fn set_identity_key(&mut self, key: Vec) { + self.identity_key = Some(key); + } + + /// Get the session token, if authenticated. + pub fn session_token(&self) -> Option<&[u8]> { + self.session_token.as_deref() + } + /// Disconnect from the server. pub fn disconnect(&mut self) { if let Some(rpc) = self.rpc.take() { diff --git a/crates/quicproquo-sdk/src/events.rs b/crates/quicproquo-sdk/src/events.rs index 47a95d8..c4509d4 100644 --- a/crates/quicproquo-sdk/src/events.rs +++ b/crates/quicproquo-sdk/src/events.rs @@ -9,6 +9,15 @@ pub enum ClientEvent { /// Disconnected from the server. Disconnected { reason: String }, + /// Registration succeeded. + Registered { username: String }, + + /// Login succeeded. + LoggedIn { username: String }, + + /// Logged out. + LoggedOut { username: String }, + /// Authentication succeeded. Authenticated { username: String }, diff --git a/crates/quicproquo-sdk/src/state.rs b/crates/quicproquo-sdk/src/state.rs new file mode 100644 index 0000000..e4e1401 --- /dev/null +++ b/crates/quicproquo-sdk/src/state.rs @@ -0,0 +1,227 @@ +//! Encrypted client state — identity key, MLS group, and hybrid KEM persistence. +//! +//! State is stored with the QPCE format: +//! `QPCE` magic (4 bytes) + salt (16 bytes) + nonce (12 bytes) + ciphertext. +//! Key derivation uses Argon2id (m=19456 KiB, t=2, p=1). + +use std::path::Path; + +use argon2::{Algorithm, Argon2, Params, Version}; +use chacha20poly1305::{ + aead::{Aead, KeyInit}, + ChaCha20Poly1305, Key, Nonce, +}; +use rand::RngCore; +use serde::{Deserialize, Serialize}; +use zeroize::Zeroizing; + +use crate::error::SdkError; + +/// Magic bytes for encrypted client state files. +const STATE_MAGIC: &[u8; 4] = b"QPCE"; +const STATE_SALT_LEN: usize = 16; +const STATE_NONCE_LEN: usize = 12; + +/// Argon2id parameters for client state key derivation. +const ARGON2_STATE_M_COST: u32 = 19 * 1024; +const ARGON2_STATE_T_COST: u32 = 2; +const ARGON2_STATE_P_COST: u32 = 1; + +/// Encrypted client state (identity + MLS group + hybrid keys). +#[derive(Serialize, Deserialize)] +pub struct StoredState { + pub identity_seed: [u8; 32], + pub group: Option>, + pub hybrid_key: Option>, + #[serde(default)] + pub member_keys: Vec>, +} + +/// Derive a 32-byte key from a password and salt using Argon2id. +fn derive_state_key(password: &str, salt: &[u8]) -> Result, SdkError> { + let params = Params::new(ARGON2_STATE_M_COST, ARGON2_STATE_T_COST, ARGON2_STATE_P_COST, Some(32)) + .map_err(|e| SdkError::Crypto(format!("argon2 params: {e}")))?; + let argon2 = Argon2::new(Algorithm::Argon2id, Version::default(), params); + let mut key = [0u8; 32]; + argon2 + .hash_password_into(password.as_bytes(), salt, &mut key) + .map_err(|e| SdkError::Crypto(format!("argon2 key derivation failed: {e}")))?; + Ok(Zeroizing::new(key)) +} + +/// Encrypt `plaintext` with the QPCE format: magic(4) | salt(16) | nonce(12) | ciphertext. +pub fn encrypt_state(password: &str, plaintext: &[u8]) -> Result, SdkError> { + let mut salt = [0u8; STATE_SALT_LEN]; + rand::rngs::OsRng.fill_bytes(&mut salt); + + let mut nonce_bytes = [0u8; STATE_NONCE_LEN]; + rand::rngs::OsRng.fill_bytes(&mut nonce_bytes); + + let key = derive_state_key(password, &salt)?; + let cipher = ChaCha20Poly1305::new(Key::from_slice(&*key)); + let nonce = Nonce::from_slice(&nonce_bytes); + + let ciphertext = cipher + .encrypt(nonce, plaintext) + .map_err(|e| SdkError::Crypto(format!("state encryption failed: {e}")))?; + + let mut out = Vec::with_capacity(4 + STATE_SALT_LEN + STATE_NONCE_LEN + ciphertext.len()); + out.extend_from_slice(STATE_MAGIC); + out.extend_from_slice(&salt); + out.extend_from_slice(&nonce_bytes); + out.extend_from_slice(&ciphertext); + Ok(out) +} + +/// Decrypt a QPCE-formatted state file. +pub fn decrypt_state(password: &str, data: &[u8]) -> Result, SdkError> { + let header_len = 4 + STATE_SALT_LEN + STATE_NONCE_LEN; + if data.len() <= header_len { + return Err(SdkError::Crypto(format!( + "encrypted state file too short ({} bytes)", + data.len() + ))); + } + + if &data[..4] != STATE_MAGIC { + return Err(SdkError::Crypto("invalid state file magic header".into())); + } + + let salt = &data[4..4 + STATE_SALT_LEN]; + let nonce_bytes = &data[4 + STATE_SALT_LEN..header_len]; + let ciphertext = &data[header_len..]; + + let key = derive_state_key(password, salt)?; + let cipher = ChaCha20Poly1305::new(Key::from_slice(&*key)); + let nonce = Nonce::from_slice(nonce_bytes); + + cipher + .decrypt(nonce, ciphertext) + .map_err(|_| SdkError::Crypto("state decryption failed (wrong password?)".into())) +} + +/// Save state to disk, optionally encrypted with `password`. +pub fn save_state(path: &Path, state: &StoredState, password: Option<&str>) -> Result<(), SdkError> { + if let Some(parent) = path.parent() { + std::fs::create_dir_all(parent) + .map_err(|e| SdkError::Storage(format!("create dir {}: {e}", parent.display())))?; + } + + let plaintext = bincode::serialize(state) + .map_err(|e| SdkError::Storage(format!("encode state: {e}")))?; + + let bytes = match password { + Some(pw) => encrypt_state(pw, &plaintext)?, + None => plaintext, + }; + + let tmp = path.with_extension("tmp"); + std::fs::write(&tmp, &bytes) + .map_err(|e| SdkError::Storage(format!("write state temp {}: {e}", tmp.display())))?; + std::fs::rename(&tmp, path) + .map_err(|e| SdkError::Storage(format!("rename state: {e}")))?; + Ok(()) +} + +/// Load state from disk, decrypting if necessary. +pub fn load_state(path: &Path, password: Option<&str>) -> Result { + let bytes = std::fs::read(path) + .map_err(|e| SdkError::Storage(format!("read state file {}: {e}", path.display())))?; + + let is_encrypted = bytes.len() >= 4 && &bytes[..4] == STATE_MAGIC; + + if is_encrypted { + let pw = password + .ok_or_else(|| SdkError::Crypto("state file is encrypted; password required".into()))?; + let plaintext = decrypt_state(pw, &bytes)?; + bincode::deserialize(&plaintext) + .map_err(|e| SdkError::Storage(format!("decode encrypted state: {e}"))) + } else { + bincode::deserialize(&bytes) + .map_err(|e| SdkError::Storage(format!("decode state: {e}"))) + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn encrypt_decrypt_roundtrip() { + let plaintext = b"test state data"; + let password = "test-password"; + let encrypted = encrypt_state(password, plaintext).unwrap(); + assert_eq!(&encrypted[..4], STATE_MAGIC); + let decrypted = decrypt_state(password, &encrypted).unwrap(); + assert_eq!(decrypted, plaintext); + } + + #[test] + fn wrong_password_fails() { + let plaintext = b"test state data"; + let encrypted = encrypt_state("correct", plaintext).unwrap(); + assert!(decrypt_state("wrong", &encrypted).is_err()); + } + + #[test] + fn state_serialize_roundtrip() { + let state = StoredState { + identity_seed: [42u8; 32], + group: None, + hybrid_key: None, + member_keys: Vec::new(), + }; + let password = "test-password"; + let plaintext = bincode::serialize(&state).unwrap(); + let encrypted = encrypt_state(password, &plaintext).unwrap(); + let decrypted = decrypt_state(password, &encrypted).unwrap(); + let recovered: StoredState = bincode::deserialize(&decrypted).unwrap(); + assert_eq!(recovered.identity_seed, state.identity_seed); + assert!(recovered.group.is_none()); + assert!(recovered.hybrid_key.is_none()); + } + + #[test] + fn save_load_roundtrip() { + let dir = std::env::temp_dir().join("qpq_sdk_state_test"); + std::fs::create_dir_all(&dir).unwrap(); + let path = dir.join("test.state"); + + let state = StoredState { + identity_seed: [7u8; 32], + group: Some(vec![1, 2, 3]), + hybrid_key: Some(vec![4, 5, 6]), + member_keys: vec![vec![10, 11]], + }; + + // Test unencrypted. + save_state(&path, &state, None).unwrap(); + let loaded = load_state(&path, None).unwrap(); + assert_eq!(loaded.identity_seed, state.identity_seed); + assert_eq!(loaded.group, state.group); + + // Test encrypted. + save_state(&path, &state, Some("pw")).unwrap(); + let loaded = load_state(&path, Some("pw")).unwrap(); + assert_eq!(loaded.identity_seed, state.identity_seed); + assert_eq!(loaded.hybrid_key, state.hybrid_key); + + // Encrypted but no password fails. + assert!(load_state(&path, None).is_err()); + + std::fs::remove_dir_all(&dir).ok(); + } + + #[test] + fn too_short_data_fails() { + assert!(decrypt_state("pw", b"QPCE").is_err()); + assert!(decrypt_state("pw", &[]).is_err()); + } + + #[test] + fn invalid_magic_fails() { + let mut data = vec![0u8; 100]; + data[..4].copy_from_slice(b"NOPE"); + assert!(decrypt_state("pw", &data).is_err()); + } +}