fix: address 16 architecture design flaws across all crates
Phase 1 — Foundation: - Constant-time token comparison via subtle::ConstantTimeEq (Fix 11) - Structured error codes E001–E020 in new error_codes.rs (Fix 15) - Remove dead envelope.capnp code and related types (Fix 16) Phase 2 — Auth Hardening: - Registration collision check via has_user_record() (Fix 5) - Auth required on uploadHybridKey/fetchHybridKey RPCs (Fix 1) - Identity-token binding at registration and login (Fix 2) - Session token expiry with 24h TTL and background reaper (Fix 3) - Bounded pending logins with 5-minute timeout (Fix 4) Phase 3 — Resource Limits: - Rate limiting: 100 enqueues/60s per token (Fix 6) - Queue depth cap at 1000 + 7-day message TTL/GC (Fix 7) - Partial queue drain via limit param on fetch/fetchWait (Fix 8) Phase 4 — Crypto Fixes: - OPAQUE KSF switched from Identity to Argon2id (Fix 10) - Random AEAD nonce in hybrid KEM instead of HKDF-derived (Fix 12) - Zeroize secret fields in HybridKeypairBytes (Fix 13) - Encrypted client state files via QPCE format (Fix 9) Phase 5 — Protocol: - Commit fan-out to all existing members on invite (Fix 14) - Add member_identities() to GroupMember Breaking: existing OPAQUE registrations invalidated (Argon2 KSF). Schema: added auth to hybrid key ops, identityKey to OPAQUE finish RPCs, limit to fetch/fetchWait. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
@@ -361,6 +361,21 @@ impl GroupMember {
|
||||
self.group.as_ref()
|
||||
}
|
||||
|
||||
/// Return the identity (credential) bytes of all current group members.
|
||||
///
|
||||
/// Each entry is the raw credential payload (Ed25519 public key bytes)
|
||||
/// extracted from the member's MLS leaf node.
|
||||
pub fn member_identities(&self) -> Vec<Vec<u8>> {
|
||||
let group = match self.group.as_ref() {
|
||||
Some(g) => g,
|
||||
None => return Vec::new(),
|
||||
};
|
||||
group
|
||||
.members()
|
||||
.map(|m| m.credential.identity().to_vec())
|
||||
.collect()
|
||||
}
|
||||
|
||||
// ── Private helpers ───────────────────────────────────────────────────────
|
||||
|
||||
fn make_credential_with_key(&self) -> Result<CredentialWithKey, CoreError> {
|
||||
|
||||
@@ -28,7 +28,7 @@ use ml_kem::{
|
||||
kem::{Decapsulate, Encapsulate},
|
||||
EncodedSizeUser, KemCore, MlKem768, MlKem768Params,
|
||||
};
|
||||
use rand::rngs::OsRng;
|
||||
use rand::{rngs::OsRng, RngCore};
|
||||
use serde::{Deserialize, Serialize};
|
||||
use sha2::Sha256;
|
||||
use x25519_dalek::{EphemeralSecret, PublicKey as X25519Public, StaticSecret};
|
||||
@@ -92,10 +92,13 @@ pub struct HybridKeypair {
|
||||
}
|
||||
|
||||
/// Serialisable form of a [`HybridKeypair`] for persistence.
|
||||
///
|
||||
/// Secret fields are wrapped in [`Zeroizing`] so they are securely erased
|
||||
/// when the struct is dropped.
|
||||
#[derive(Serialize, Deserialize)]
|
||||
pub struct HybridKeypairBytes {
|
||||
pub x25519_sk: [u8; 32],
|
||||
pub mlkem_dk: Vec<u8>,
|
||||
pub x25519_sk: Zeroizing<[u8; 32]>,
|
||||
pub mlkem_dk: Zeroizing<Vec<u8>>,
|
||||
pub mlkem_ek: Vec<u8>,
|
||||
}
|
||||
|
||||
@@ -123,7 +126,7 @@ impl HybridKeypair {
|
||||
|
||||
/// Reconstruct from serialised bytes.
|
||||
pub fn from_bytes(bytes: &HybridKeypairBytes) -> Result<Self, HybridKemError> {
|
||||
let x25519_sk = StaticSecret::from(bytes.x25519_sk);
|
||||
let x25519_sk = StaticSecret::from(*bytes.x25519_sk);
|
||||
let x25519_pk = X25519Public::from(&x25519_sk);
|
||||
|
||||
let mlkem_dk_arr = Array::try_from(bytes.mlkem_dk.as_slice())
|
||||
@@ -145,8 +148,8 @@ impl HybridKeypair {
|
||||
/// Serialise the keypair for persistence.
|
||||
pub fn to_bytes(&self) -> HybridKeypairBytes {
|
||||
HybridKeypairBytes {
|
||||
x25519_sk: self.x25519_sk.to_bytes(),
|
||||
mlkem_dk: self.mlkem_dk.as_bytes().to_vec(),
|
||||
x25519_sk: Zeroizing::new(self.x25519_sk.to_bytes()),
|
||||
mlkem_dk: Zeroizing::new(self.mlkem_dk.as_bytes().to_vec()),
|
||||
mlkem_ek: self.mlkem_ek.as_bytes().to_vec(),
|
||||
}
|
||||
}
|
||||
@@ -207,9 +210,13 @@ pub fn hybrid_encrypt(
|
||||
.encapsulate(&mut OsRng)
|
||||
.map_err(|_| HybridKemError::EncryptionFailed)?;
|
||||
|
||||
// 3. Combine shared secrets via HKDF
|
||||
let (aead_key, aead_nonce) =
|
||||
derive_aead_material(x25519_ss.as_bytes(), mlkem_ss.as_slice());
|
||||
// 3. Derive AEAD key from combined shared secrets
|
||||
let aead_key = derive_aead_key(x25519_ss.as_bytes(), mlkem_ss.as_slice());
|
||||
|
||||
// Generate a random 12-byte nonce (not derived from HKDF).
|
||||
let mut nonce_bytes = [0u8; 12];
|
||||
OsRng.fill_bytes(&mut nonce_bytes);
|
||||
let aead_nonce = *Nonce::from_slice(&nonce_bytes);
|
||||
|
||||
// 4. AEAD encrypt
|
||||
let cipher = ChaCha20Poly1305::new(&aead_key);
|
||||
@@ -275,7 +282,7 @@ pub fn hybrid_decrypt(
|
||||
.map_err(|_| HybridKemError::MlKemDecapsFailed)?;
|
||||
|
||||
// 3. Derive AEAD key
|
||||
let (aead_key, _) = derive_aead_material(x25519_ss.as_bytes(), mlkem_ss.as_slice());
|
||||
let aead_key = derive_aead_key(x25519_ss.as_bytes(), mlkem_ss.as_slice());
|
||||
|
||||
// 4. Decrypt
|
||||
let cipher = ChaCha20Poly1305::new(&aead_key);
|
||||
@@ -286,11 +293,12 @@ pub fn hybrid_decrypt(
|
||||
Ok(plaintext)
|
||||
}
|
||||
|
||||
/// Derive AEAD key + nonce from the combined X25519 + ML-KEM shared secrets.
|
||||
fn derive_aead_material(
|
||||
x25519_ss: &[u8],
|
||||
mlkem_ss: &[u8],
|
||||
) -> (Key, Nonce) {
|
||||
/// Derive AEAD key from the combined X25519 + ML-KEM shared secrets.
|
||||
///
|
||||
/// The nonce is generated randomly per-encryption rather than derived from
|
||||
/// HKDF, preventing nonce reuse when the same shared secret is (accidentally)
|
||||
/// used more than once.
|
||||
fn derive_aead_key(x25519_ss: &[u8], mlkem_ss: &[u8]) -> Key {
|
||||
let mut ikm = Zeroizing::new(vec![0u8; x25519_ss.len() + mlkem_ss.len()]);
|
||||
ikm[..x25519_ss.len()].copy_from_slice(x25519_ss);
|
||||
ikm[x25519_ss.len()..].copy_from_slice(mlkem_ss);
|
||||
@@ -301,11 +309,7 @@ fn derive_aead_material(
|
||||
hk.expand(HKDF_INFO, &mut *key_bytes)
|
||||
.expect("32 bytes is valid HKDF-SHA256 output length");
|
||||
|
||||
let mut nonce_bytes = [0u8; 12];
|
||||
hk.expand(b"quicnprotochat-hybrid-nonce-v1", &mut nonce_bytes)
|
||||
.expect("12 bytes is valid HKDF-SHA256 output length");
|
||||
|
||||
(*Key::from_slice(&*key_bytes), *Nonce::from_slice(&nonce_bytes))
|
||||
*Key::from_slice(&*key_bytes)
|
||||
}
|
||||
|
||||
// ── Tests ───────────────────────────────────────────────────────────────────
|
||||
|
||||
@@ -9,7 +9,7 @@ use opaque_ke::CipherSuite;
|
||||
///
|
||||
/// - **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)
|
||||
/// - **KSF**: Argon2id (memory-hard key stretching)
|
||||
pub struct OpaqueSuite;
|
||||
|
||||
impl CipherSuite for OpaqueSuite {
|
||||
@@ -18,5 +18,5 @@ impl CipherSuite for OpaqueSuite {
|
||||
opaque_ke::Ristretto255,
|
||||
sha2::Sha512,
|
||||
>;
|
||||
type Ksf = opaque_ke::ksf::Identity;
|
||||
type Ksf = argon2::Argon2<'static>;
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user