WIP: add OPAQUE password-authenticated key exchange

Add opaque-ke (v4, ristretto255) for password-based registration and
login. Extend NodeService schema with opaqueRegisterStart/Finish and
opaqueLoginStart/Finish RPCs. Add Store trait methods for OPAQUE server
setup and user records. Initial e2e integration test scaffolding.

Note: FileBackedStore does not yet implement the new Store trait
methods — server compilation is temporarily broken.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
2026-02-22 08:25:34 +01:00
parent 9fdb37876a
commit 8d5c1b3b9b
11 changed files with 557 additions and 31 deletions

View File

@@ -26,6 +26,10 @@ serde = { workspace = true }
serde_json = { workspace = true }
bincode = { workspace = true }
# Crypto — OPAQUE PAKE
opaque-ke = { workspace = true }
rand = { workspace = true }
# Error handling
anyhow = { workspace = true }
thiserror = { workspace = true }
@@ -44,5 +48,7 @@ tracing-subscriber = { workspace = true }
clap = { workspace = true }
[dev-dependencies]
# Integration tests use quicnprotochat-core, quicnprotochat-proto, and capnp-rpc directly.
dashmap = { workspace = true }
assert_cmd = "2"
tempfile = "3"
portpicker = "0.1"

View File

@@ -0,0 +1,164 @@
use std::{path::PathBuf, process::Command, time::Duration};
use assert_cmd::cargo::cargo_bin;
use portpicker::pick_unused_port;
use tempfile::TempDir;
use tokio::time::sleep;
use quicnprotochat_client::{
cmd_create_group, cmd_invite, cmd_join, cmd_ping, cmd_register_state, cmd_send, ClientAuth,
connect_node, fetch_wait, init_auth,
};
use quicnprotochat_core::IdentityKeypair;
fn hex_encode(bytes: &[u8]) -> String {
bytes.iter().map(|b| format!("{b:02x}")).collect()
}
#[derive(serde::Deserialize)]
struct StoredStateCompat {
identity_seed: [u8; 32],
#[allow(dead_code)]
group: Option<Vec<u8>>,
}
async fn wait_for_health(server: &str, ca_cert: &PathBuf, server_name: &str) -> anyhow::Result<()> {
let local = tokio::task::LocalSet::new();
for _ in 0..30 {
if local
.run_until(cmd_ping(server, ca_cert, server_name))
.await
.is_ok()
{
return Ok(());
}
sleep(Duration::from_millis(200)).await;
}
anyhow::bail!("server health never became ready")
}
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
async fn e2e_happy_path_register_invite_join_send_recv() -> anyhow::Result<()> {
let temp = TempDir::new()?;
let base = temp.path();
let port = pick_unused_port().expect("free port");
let listen = format!("127.0.0.1:{port}");
let server = listen.clone();
let ca_cert = base.join("server-cert.der");
let tls_key = base.join("server-key.der");
let data_dir = base.join("data");
let auth_token = "devtoken";
// Spawn server binary.
let server_bin = cargo_bin("quicnprotochat-server");
let mut child = Command::new(server_bin)
.arg("--listen")
.arg(&listen)
.arg("--data-dir")
.arg(&data_dir)
.arg("--tls-cert")
.arg(&ca_cert)
.arg("--tls-key")
.arg(&tls_key)
.arg("--auth-token")
.arg(auth_token)
.spawn()
.expect("spawn server");
// Ensure we always terminate the child.
struct ChildGuard(std::process::Child);
impl Drop for ChildGuard {
fn drop(&mut self) {
let _ = self.0.kill();
}
}
let child_guard = ChildGuard(child);
let _ = child_guard;
// Wait for server to be healthy and certs to be generated.
wait_for_health(&server, &ca_cert, "localhost").await?;
// Set client auth context.
init_auth(ClientAuth::from_parts(auth_token.to_string(), None));
// LocalSet for capnp !Send operations.
let local = tokio::task::LocalSet::new();
let alice_state = base.join("alice.bin");
let bob_state = base.join("bob.bin");
local
.run_until(cmd_register_state(
&alice_state,
&server,
&ca_cert,
"localhost",
))
.await?;
local
.run_until(cmd_register_state(
&bob_state,
&server,
&ca_cert,
"localhost",
))
.await?;
local
.run_until(cmd_create_group(
&alice_state,
&server,
"test-group",
))
.await?;
// Load Bob identity key from persisted state to use as peer key.
let bob_bytes = std::fs::read(&bob_state)?;
let bob_state_compat: StoredStateCompat = bincode::deserialize(&bob_bytes)?;
let bob_identity = IdentityKeypair::from_seed(bob_state_compat.identity_seed);
let bob_pk_hex = hex_encode(&bob_identity.public_key_bytes());
local
.run_until(cmd_invite(
&alice_state,
&server,
&ca_cert,
"localhost",
&bob_pk_hex,
))
.await?;
local
.run_until(cmd_join(
&bob_state,
&server,
&ca_cert,
"localhost",
))
.await?;
// Send Alice -> Bob.
local
.run_until(cmd_send(
&alice_state,
&server,
&ca_cert,
"localhost",
&bob_pk_hex,
"hello bob",
))
.await?;
// Confirm Bob can fetch at least one payload.
local
.run_until(async {
let client = connect_node(&server, &ca_cert, "localhost").await?;
let payloads = fetch_wait(&client, &bob_identity.public_key_bytes(), 1000).await?;
anyhow::ensure!(!payloads.is_empty(), "no payloads delivered to Bob");
Ok::<(), anyhow::Error>(())
})
.await?;
Ok(())
}

View File

@@ -18,6 +18,9 @@ rand = { workspace = true }
# Crypto — post-quantum hybrid KEM (M7)
ml-kem = { workspace = true }
# Crypto — OPAQUE password-authenticated key exchange
opaque-ke = { workspace = true }
# Crypto — MLS (M2)
openmls = { workspace = true }
openmls_rust_crypto = { workspace = true }

View File

@@ -18,6 +18,7 @@ pub mod hybrid_kem;
mod identity;
mod keypackage;
mod keystore;
pub mod opaque_auth;
// ── Public API ────────────────────────────────────────────────────────────────

View File

@@ -0,0 +1,22 @@
//! Shared OPAQUE (RFC 9497) cipher suite configuration.
//!
//! Both client and server import this module to ensure they use exactly
//! the same cryptographic parameters during registration and login.
use opaque_ke::CipherSuite;
/// OPAQUE cipher suite for quicnprotochat.
///
/// - **OPRF**: Ristretto255 (curve25519-based, ~128-bit security)
/// - **Key exchange**: Triple-DH (3DH) over Ristretto255 with SHA-512
/// - **KSF**: Identity (no key stretching; upgrade to Argon2 later)
pub struct OpaqueSuite;
impl CipherSuite for OpaqueSuite {
type OprfCs = opaque_ke::Ristretto255;
type KeyExchange = opaque_ke::key_exchange::tripledh::TripleDh<
opaque_ke::Ristretto255,
sha2::Sha512,
>;
type Ksf = opaque_ke::ksf::Identity;
}

View File

@@ -32,6 +32,10 @@ quinn-proto = { workspace = true }
rustls = { workspace = true }
rcgen = { workspace = true }
# Crypto — OPAQUE PAKE
opaque-ke = { workspace = true }
rand = { workspace = true }
# Database
rusqlite = { workspace = true }

View File

@@ -75,7 +75,18 @@ impl SqlStore {
ON key_packages(identity_key);
CREATE INDEX IF NOT EXISTS idx_del_recipient_channel
ON deliveries(recipient_key, channel_id);",
ON deliveries(recipient_key, channel_id);
CREATE TABLE IF NOT EXISTS server_setup (
id INTEGER PRIMARY KEY CHECK (id = 1),
setup_data BLOB NOT NULL
);
CREATE TABLE IF NOT EXISTS users (
username TEXT PRIMARY KEY,
opaque_record BLOB NOT NULL,
created_at INTEGER DEFAULT (strftime('%s','now'))
);",
)
.map_err(|e| StorageError::Db(e.to_string()))?;
Ok(())
@@ -203,6 +214,48 @@ impl Store for SqlStore {
.optional()
.map_err(|e| StorageError::Db(e.to_string()))
}
fn store_server_setup(&self, setup: Vec<u8>) -> Result<(), StorageError> {
let conn = self.conn.lock().unwrap();
conn.execute(
"INSERT OR REPLACE INTO server_setup (id, setup_data) VALUES (1, ?1)",
params![setup],
)
.map_err(|e| StorageError::Db(e.to_string()))?;
Ok(())
}
fn get_server_setup(&self) -> Result<Option<Vec<u8>>, StorageError> {
let conn = self.conn.lock().unwrap();
let mut stmt = conn
.prepare("SELECT setup_data FROM server_setup WHERE id = 1")
.map_err(|e| StorageError::Db(e.to_string()))?;
stmt.query_row([], |row| row.get(0))
.optional()
.map_err(|e| StorageError::Db(e.to_string()))
}
fn store_user_record(&self, username: &str, record: Vec<u8>) -> Result<(), StorageError> {
let conn = self.conn.lock().unwrap();
conn.execute(
"INSERT OR REPLACE INTO users (username, opaque_record) VALUES (?1, ?2)",
params![username, record],
)
.map_err(|e| StorageError::Db(e.to_string()))?;
Ok(())
}
fn get_user_record(&self, username: &str) -> Result<Option<Vec<u8>>, StorageError> {
let conn = self.conn.lock().unwrap();
let mut stmt = conn
.prepare("SELECT opaque_record FROM users WHERE username = ?1")
.map_err(|e| StorageError::Db(e.to_string()))?;
stmt.query_row(params![username], |row| row.get(0))
.optional()
.map_err(|e| StorageError::Db(e.to_string()))
}
}
/// Convenience extension for `rusqlite::OptionalExtension`.

View File

@@ -50,6 +50,18 @@ pub trait Store: Send + Sync {
) -> Result<(), StorageError>;
fn fetch_hybrid_key(&self, identity_key: &[u8]) -> Result<Option<Vec<u8>>, StorageError>;
/// Store the OPAQUE `ServerSetup` (generated once, loaded on restart).
fn store_server_setup(&self, setup: Vec<u8>) -> Result<(), StorageError>;
/// Load the persisted `ServerSetup`, if any.
fn get_server_setup(&self) -> Result<Option<Vec<u8>>, StorageError>;
/// Store an OPAQUE user record (serialized `ServerRegistration`).
fn store_user_record(&self, username: &str, record: Vec<u8>) -> Result<(), StorageError>;
/// Retrieve an OPAQUE user record by username.
fn get_user_record(&self, username: &str) -> Result<Option<Vec<u8>>, StorageError>;
}
// ── ChannelKey ───────────────────────────────────────────────────────────────
@@ -86,9 +98,12 @@ pub struct FileBackedStore {
kp_path: PathBuf,
ds_path: PathBuf,
hk_path: PathBuf,
setup_path: PathBuf,
users_path: PathBuf,
key_packages: Mutex<HashMap<Vec<u8>, VecDeque<Vec<u8>>>>,
deliveries: Mutex<HashMap<ChannelKey, VecDeque<Vec<u8>>>>,
hybrid_keys: Mutex<HashMap<Vec<u8>, Vec<u8>>>,
users: Mutex<HashMap<String, Vec<u8>>>,
}
impl FileBackedStore {
@@ -100,18 +115,24 @@ impl FileBackedStore {
let kp_path = dir.join("keypackages.bin");
let ds_path = dir.join("deliveries.bin");
let hk_path = dir.join("hybridkeys.bin");
let setup_path = dir.join("server_setup.bin");
let users_path = dir.join("users.bin");
let key_packages = Mutex::new(Self::load_kp_map(&kp_path)?);
let deliveries = Mutex::new(Self::load_delivery_map(&ds_path)?);
let hybrid_keys = Mutex::new(Self::load_hybrid_keys(&hk_path)?);
let users = Mutex::new(Self::load_users(&users_path)?);
Ok(Self {
kp_path,
ds_path,
hk_path,
setup_path,
users_path,
key_packages,
deliveries,
hybrid_keys,
users,
})
}
@@ -201,6 +222,29 @@ impl FileBackedStore {
}
fs::write(path, bytes).map_err(|e| StorageError::Io(e.to_string()))
}
fn load_users(path: &Path) -> Result<HashMap<String, Vec<u8>>, StorageError> {
if !path.exists() {
return Ok(HashMap::new());
}
let bytes = fs::read(path).map_err(|e| StorageError::Io(e.to_string()))?;
if bytes.is_empty() {
return Ok(HashMap::new());
}
bincode::deserialize(&bytes).map_err(|_| StorageError::Serde)
}
fn flush_users(
&self,
path: &Path,
map: &HashMap<String, Vec<u8>>,
) -> Result<(), StorageError> {
let bytes = bincode::serialize(map).map_err(|_| StorageError::Serde)?;
if let Some(parent) = path.parent() {
fs::create_dir_all(parent).map_err(|e| StorageError::Io(e.to_string()))?;
}
fs::write(path, bytes).map_err(|e| StorageError::Io(e.to_string()))
}
}
impl Store for FileBackedStore {
@@ -272,4 +316,33 @@ impl Store for FileBackedStore {
let map = self.hybrid_keys.lock().unwrap();
Ok(map.get(identity_key).cloned())
}
fn store_server_setup(&self, setup: Vec<u8>) -> Result<(), StorageError> {
if let Some(parent) = self.setup_path.parent() {
fs::create_dir_all(parent).map_err(|e| StorageError::Io(e.to_string()))?;
}
fs::write(&self.setup_path, setup).map_err(|e| StorageError::Io(e.to_string()))
}
fn get_server_setup(&self) -> Result<Option<Vec<u8>>, StorageError> {
if !self.setup_path.exists() {
return Ok(None);
}
let bytes = fs::read(&self.setup_path).map_err(|e| StorageError::Io(e.to_string()))?;
if bytes.is_empty() {
return Ok(None);
}
Ok(Some(bytes))
}
fn store_user_record(&self, username: &str, record: Vec<u8>) -> Result<(), StorageError> {
let mut map = self.users.lock().unwrap();
map.insert(username.to_string(), record);
self.flush_users(&self.users_path, &*map)
}
fn get_user_record(&self, username: &str) -> Result<Option<Vec<u8>>, StorageError> {
let map = self.users.lock().unwrap();
Ok(map.get(username).cloned())
}
}