feat(sdk): OPAQUE auth module with state persistence
This commit is contained in:
142
crates/quicproquo-sdk/src/auth.rs
Normal file
142
crates/quicproquo-sdk/src/auth.rs
Normal file
@@ -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<IdentityKeypair, SdkError> {
|
||||
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::<OpaqueSuite>::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::<OpaqueSuite>::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::<OpaqueSuite>::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<Vec<u8>, SdkError> {
|
||||
let mut rng = rand::rngs::OsRng;
|
||||
|
||||
// ── Step 1: Login Start ────────────────────────────────────────────────
|
||||
let login_start = ClientLogin::<OpaqueSuite>::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::<OpaqueSuite>::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::<OpaqueSuite>::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)
|
||||
}
|
||||
@@ -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<u8>) {
|
||||
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() {
|
||||
|
||||
@@ -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 },
|
||||
|
||||
|
||||
227
crates/quicproquo-sdk/src/state.rs
Normal file
227
crates/quicproquo-sdk/src/state.rs
Normal file
@@ -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<Vec<u8>>,
|
||||
pub hybrid_key: Option<Vec<u8>>,
|
||||
#[serde(default)]
|
||||
pub member_keys: Vec<Vec<u8>>,
|
||||
}
|
||||
|
||||
/// Derive a 32-byte key from a password and salt using Argon2id.
|
||||
fn derive_state_key(password: &str, salt: &[u8]) -> Result<Zeroizing<[u8; 32]>, 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<Vec<u8>, 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<Vec<u8>, 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<StoredState, SdkError> {
|
||||
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());
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user