chore: rename project quicnprotochat -> quicproquo (binaries: qpq)

Rename the entire workspace:
- Crate packages: quicnprotochat-{core,proto,server,client,gui,p2p,mobile} -> quicproquo-*
- Binary names: quicnprotochat -> qpq, quicnprotochat-server -> qpq-server,
  quicnprotochat-gui -> qpq-gui
- Default files: *-state.bin -> qpq-state.bin, *-server.toml -> qpq-server.toml,
  *.db -> qpq.db
- Environment variable prefix: QUICNPROTOCHAT_* -> QPQ_*
- App identifier: chat.quicnproto.gui -> chat.quicproquo.gui
- Proto package: quicnprotochat.bench -> quicproquo.bench
- All documentation, Docker, CI, and script references updated

HKDF domain-separation strings and P2P ALPN remain unchanged for
backward compatibility with existing encrypted state and wire protocol.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
2026-03-01 20:11:51 +01:00
parent 553de3a2b7
commit 853ca4fec0
152 changed files with 4070 additions and 788 deletions

View File

@@ -0,0 +1,327 @@
//! Rich application-layer message format for MLS application payloads.
//!
//! The server sees only opaque ciphertext; structure lives in this client-defined
//! plaintext schema. All messages use: version byte (1) + message_type byte + type-specific payload.
//!
//! # Message ID
//!
//! `message_id` is assigned by the sender (16 random bytes) and included in the
//! serialized payload for Chat (and implied for Reply/Reaction/ReadReceipt via ref_msg_id).
//! Recipients can store message_ids to reference them in replies or reactions.
use crate::error::CoreError;
use rand::RngCore;
/// Current schema version.
pub const VERSION: u8 = 1;
/// Message type discriminant (one byte).
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
#[repr(u8)]
pub enum MessageType {
Chat = 0x01,
Reply = 0x02,
Reaction = 0x03,
ReadReceipt = 0x04,
Typing = 0x05,
}
impl MessageType {
fn from_byte(b: u8) -> Option<Self> {
match b {
0x01 => Some(MessageType::Chat),
0x02 => Some(MessageType::Reply),
0x03 => Some(MessageType::Reaction),
0x04 => Some(MessageType::ReadReceipt),
0x05 => Some(MessageType::Typing),
_ => None,
}
}
}
/// Parsed application message (one of the rich types).
#[derive(Clone, Debug, PartialEq, Eq)]
pub enum AppMessage {
/// Plain chat: body (UTF-8). message_id is included so recipients can store and reference it.
Chat {
message_id: [u8; 16],
body: Vec<u8>,
},
Reply {
ref_msg_id: [u8; 16],
body: Vec<u8>,
},
Reaction {
ref_msg_id: [u8; 16],
emoji: Vec<u8>,
},
ReadReceipt {
msg_id: [u8; 16],
},
Typing {
/// 0 = stopped, 1 = typing
active: u8,
},
}
/// Generate a new 16-byte message ID (e.g. for Chat/Reply so recipients can reference it).
pub fn generate_message_id() -> [u8; 16] {
let mut id = [0u8; 16];
rand::rngs::OsRng.fill_bytes(&mut id);
id
}
// ── Layout (minimal, no Cap'n Proto) ─────────────────────────────────────────
//
// All messages: [version: 1][type: 1][payload...]
//
// Chat: [msg_id: 16][body_len: 2 BE][body]
// Reply: [ref_msg_id: 16][body_len: 2 BE][body]
// Reaction: [ref_msg_id: 16][emoji_len: 1][emoji]
// ReadReceipt: [msg_id: 16]
// Typing: [active: 1] 0 = stopped, 1 = typing
/// Serialize a rich message into the application payload format.
pub fn serialize(msg_type: MessageType, payload: &[u8]) -> Vec<u8> {
let mut out = Vec::with_capacity(2 + payload.len());
out.push(VERSION);
out.push(msg_type as u8);
out.extend_from_slice(payload);
out
}
/// Serialize a Chat message (generates message_id internally; pass None to generate, or Some(id) when replying with a known id).
pub fn serialize_chat(body: &[u8], message_id: Option<[u8; 16]>) -> Result<Vec<u8>, CoreError> {
if body.len() > u16::MAX as usize {
return Err(CoreError::AppMessage("chat body exceeds maximum length (65535 bytes)".into()));
}
let id = message_id.unwrap_or_else(generate_message_id);
let mut payload = Vec::with_capacity(16 + 2 + body.len());
payload.extend_from_slice(&id);
payload.extend_from_slice(&(body.len() as u16).to_be_bytes());
payload.extend_from_slice(body);
Ok(serialize(MessageType::Chat, &payload))
}
/// Serialize a Reply message.
pub fn serialize_reply(ref_msg_id: [u8; 16], body: &[u8]) -> Result<Vec<u8>, CoreError> {
if body.len() > u16::MAX as usize {
return Err(CoreError::AppMessage("reply body exceeds maximum length (65535 bytes)".into()));
}
let mut payload = Vec::with_capacity(16 + 2 + body.len());
payload.extend_from_slice(&ref_msg_id);
payload.extend_from_slice(&(body.len() as u16).to_be_bytes());
payload.extend_from_slice(body);
Ok(serialize(MessageType::Reply, &payload))
}
/// Serialize a Reaction message.
pub fn serialize_reaction(ref_msg_id: [u8; 16], emoji: &[u8]) -> Result<Vec<u8>, CoreError> {
if emoji.len() > 255 {
return Err(CoreError::AppMessage("emoji length > 255".into()));
}
let mut payload = Vec::with_capacity(16 + 1 + emoji.len());
payload.extend_from_slice(&ref_msg_id);
payload.push(emoji.len() as u8);
payload.extend_from_slice(emoji);
Ok(serialize(MessageType::Reaction, &payload))
}
/// Serialize a ReadReceipt message.
pub fn serialize_read_receipt(msg_id: [u8; 16]) -> Vec<u8> {
serialize(MessageType::ReadReceipt, &msg_id)
}
/// Serialize a Typing message (active: 0 = stopped, 1 = typing).
pub fn serialize_typing(active: u8) -> Vec<u8> {
let payload = [active];
serialize(MessageType::Typing, &payload)
}
/// Parse bytes into (MessageType, AppMessage). Fails if version/type unknown or payload too short.
pub fn parse(bytes: &[u8]) -> Result<(MessageType, AppMessage), CoreError> {
if bytes.len() < 2 {
return Err(CoreError::AppMessage("payload too short (need version + type)".into()));
}
let version = bytes[0];
if version != VERSION {
return Err(CoreError::AppMessage(format!("unsupported version {version}").into()));
}
let msg_type = MessageType::from_byte(bytes[1])
.ok_or_else(|| CoreError::AppMessage(format!("unknown message type {}", bytes[1]).into()))?;
let payload = &bytes[2..];
let app = match msg_type {
MessageType::Chat => parse_chat(payload)?,
MessageType::Reply => parse_reply(payload)?,
MessageType::Reaction => parse_reaction(payload)?,
MessageType::ReadReceipt => parse_read_receipt(payload)?,
MessageType::Typing => parse_typing(payload)?,
};
Ok((msg_type, app))
}
fn parse_chat(payload: &[u8]) -> Result<AppMessage, CoreError> {
if payload.len() < 16 + 2 {
return Err(CoreError::AppMessage("Chat payload too short".into()));
}
let mut message_id = [0u8; 16];
message_id.copy_from_slice(&payload[..16]);
let body_len = u16::from_be_bytes([payload[16], payload[17]]) as usize;
if payload.len() < 18 + body_len {
return Err(CoreError::AppMessage("Chat body length exceeds payload".into()));
}
let body = payload[18..18 + body_len].to_vec();
Ok(AppMessage::Chat { message_id, body })
}
fn parse_reply(payload: &[u8]) -> Result<AppMessage, CoreError> {
if payload.len() < 16 + 2 {
return Err(CoreError::AppMessage("Reply payload too short".into()));
}
let mut ref_msg_id = [0u8; 16];
ref_msg_id.copy_from_slice(&payload[..16]);
let body_len = u16::from_be_bytes([payload[16], payload[17]]) as usize;
if payload.len() < 18 + body_len {
return Err(CoreError::AppMessage("Reply body length exceeds payload".into()));
}
let body = payload[18..18 + body_len].to_vec();
Ok(AppMessage::Reply { ref_msg_id, body })
}
fn parse_reaction(payload: &[u8]) -> Result<AppMessage, CoreError> {
if payload.len() < 16 + 1 {
return Err(CoreError::AppMessage("Reaction payload too short".into()));
}
let mut ref_msg_id = [0u8; 16];
ref_msg_id.copy_from_slice(&payload[..16]);
let emoji_len = payload[16] as usize;
if payload.len() < 17 + emoji_len {
return Err(CoreError::AppMessage("Reaction emoji length exceeds payload".into()));
}
let emoji = payload[17..17 + emoji_len].to_vec();
Ok(AppMessage::Reaction { ref_msg_id, emoji })
}
fn parse_read_receipt(payload: &[u8]) -> Result<AppMessage, CoreError> {
if payload.len() < 16 {
return Err(CoreError::AppMessage("ReadReceipt payload too short".into()));
}
let mut msg_id = [0u8; 16];
msg_id.copy_from_slice(&payload[..16]);
Ok(AppMessage::ReadReceipt { msg_id })
}
fn parse_typing(payload: &[u8]) -> Result<AppMessage, CoreError> {
if payload.is_empty() {
return Err(CoreError::AppMessage("Typing payload empty".into()));
}
Ok(AppMessage::Typing { active: payload[0] })
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn roundtrip_chat() {
let body = b"hello";
let encoded = serialize_chat(body, None).unwrap();
let (t, msg) = parse(&encoded).unwrap();
assert_eq!(t, MessageType::Chat);
match &msg {
AppMessage::Chat { message_id: _, body: b } => assert_eq!(b.as_slice(), body),
_ => panic!("expected Chat"),
}
}
#[test]
fn roundtrip_reply() {
let ref_id = [1u8; 16];
let body = b"reply text";
let encoded = serialize_reply(ref_id, body).unwrap();
let (t, msg) = parse(&encoded).unwrap();
assert_eq!(t, MessageType::Reply);
match &msg {
AppMessage::Reply { ref_msg_id, body: b } => {
assert_eq!(ref_msg_id, &ref_id);
assert_eq!(b.as_slice(), body);
}
_ => panic!("expected Reply"),
}
}
#[test]
fn roundtrip_typing() {
let encoded = serialize_typing(1);
let (t, msg) = parse(&encoded).unwrap();
assert_eq!(t, MessageType::Typing);
match &msg {
AppMessage::Typing { active } => assert_eq!(*active, 1),
_ => panic!("expected Typing"),
}
}
#[test]
fn roundtrip_reaction() {
let ref_id = [2u8; 16];
let emoji = "\u{1f44d}".as_bytes();
let encoded = serialize_reaction(ref_id, emoji).unwrap();
let (t, msg) = parse(&encoded).unwrap();
assert_eq!(t, MessageType::Reaction);
match &msg {
AppMessage::Reaction { ref_msg_id, emoji: e } => {
assert_eq!(ref_msg_id, &ref_id);
assert_eq!(e.as_slice(), emoji);
}
_ => panic!("expected Reaction"),
}
}
#[test]
fn roundtrip_read_receipt() {
let msg_id = [3u8; 16];
let encoded = serialize_read_receipt(msg_id);
let (t, msg) = parse(&encoded).unwrap();
assert_eq!(t, MessageType::ReadReceipt);
match &msg {
AppMessage::ReadReceipt { msg_id: id } => assert_eq!(id, &msg_id),
_ => panic!("expected ReadReceipt"),
}
}
#[test]
fn parse_empty_fails() {
assert!(parse(&[]).is_err());
}
#[test]
fn parse_bad_version_fails() {
assert!(parse(&[99, 0x01]).is_err());
}
#[test]
fn parse_bad_type_fails() {
assert!(parse(&[1, 0xFF]).is_err());
}
#[test]
fn chat_body_too_long() {
let body = vec![0u8; 65536]; // exceeds u16::MAX
assert!(serialize_chat(&body, None).is_err());
}
#[test]
fn reaction_emoji_too_long() {
let emoji = vec![0u8; 256];
assert!(serialize_reaction([0; 16], &emoji).is_err());
}
#[test]
fn parse_truncated_chat_payload() {
// Version + type + only 10 bytes of payload (needs 18 minimum for chat)
let mut data = vec![1, 0x01];
data.extend_from_slice(&[0u8; 10]);
assert!(parse(&data).is_err());
}
}

View File

@@ -0,0 +1,29 @@
//! Error types for `quicproquo-core`.
use thiserror::Error;
/// Errors produced by core cryptographic and MLS operations.
#[derive(Debug, Error)]
pub enum CoreError {
/// Cap'n Proto serialisation or deserialisation failed.
#[error("Cap'n Proto error: {0}")]
Capnp(#[from] capnp::Error),
/// An MLS operation failed.
///
/// The inner string is the debug representation of the openmls error.
#[error("MLS error: {0}")]
Mls(String),
/// A hybrid KEM (X25519 + ML-KEM-768) operation failed.
#[error("hybrid KEM error: {0}")]
HybridKem(#[from] crate::hybrid_kem::HybridKemError),
/// IO or persistence failure.
#[error("io error: {0}")]
Io(String),
/// Application message (rich payload) parse or serialisation error.
#[error("app message: {0}")]
AppMessage(String),
}

View File

@@ -0,0 +1,587 @@
//! MLS group state machine.
//!
//! # Design
//!
//! [`GroupMember`] wraps an openmls [`MlsGroup`] plus the per-client
//! [`HybridCryptoProvider`] backend. The backend is **persistent** — it holds
//! the in-memory key store that maps init-key references to HPKE private keys.
//! openmls's `new_from_welcome` reads those private keys from the key store to
//! decrypt the Welcome, so the same backend instance must be used from
//! `generate_key_package` through `join_group`.
//!
//! # Hybrid post-quantum mode
//!
//! When `hybrid = true`, the backend's `derive_hpke_keypair` produces hybrid
//! (X25519 + ML-KEM-768) init keys. KeyPackages from hybrid groups contain
//! 1216-byte public keys instead of 32-byte X25519 keys. Both sender and
//! receiver must use hybrid mode for the same group.
//!
//! # Wire format
//!
//! All MLS messages are serialised/deserialised using TLS presentation language
//! encoding (`tls_codec`). The resulting byte vectors are what the transport
//! layer (and the Delivery Service) sees.
//!
//! # MLS ciphersuite
//!
//! `MLS_128_DHKEMX25519_AES128GCM_SHA256_Ed25519` — same as M2.
//!
//! # Ratchet tree
//!
//! `use_ratchet_tree_extension = true` so that the ratchet tree is embedded
//! in Welcome messages. `new_from_welcome` is called with `ratchet_tree = None`;
//! openmls extracts the tree from the Welcome's `GroupInfo` extension.
use std::{path::Path, sync::Arc};
use openmls::prelude::{
Ciphersuite, Credential, CredentialType, CredentialWithKey, CryptoConfig, GroupId, KeyPackage,
KeyPackageIn, MlsGroup, MlsGroupConfig, MlsMessageInBody, MlsMessageOut,
ProcessedMessageContent, ProtocolMessage, ProtocolVersion, TlsDeserializeTrait,
TlsSerializeTrait,
};
use openmls_traits::OpenMlsCryptoProvider;
use crate::{
error::CoreError,
hybrid_crypto::HybridCryptoProvider,
identity::IdentityKeypair,
keystore::DiskKeyStore,
};
// ── Constants ─────────────────────────────────────────────────────────────────
const CIPHERSUITE: Ciphersuite = Ciphersuite::MLS_128_DHKEMX25519_AES128GCM_SHA256_Ed25519;
// ── GroupMember ───────────────────────────────────────────────────────────────
/// Per-client MLS state: identity keypair, crypto backend, and optional group.
///
/// # Lifecycle
///
/// ```text
/// GroupMember::new(identity)
/// ├─ generate_key_package() → upload to AS
/// ├─ create_group(group_id) → become sole member
/// │ └─ add_member(kp) → invite a peer; returns (commit, welcome)
/// └─ join_group(welcome) → join after receiving a Welcome
/// ├─ send_message(msg) → encrypt application data
/// └─ receive_message(b) → decrypt; returns Some(plaintext) or None
/// ```
pub struct GroupMember {
/// Persistent crypto backend (hybrid or classical). Holds the in-memory key
/// store with HPKE private keys created during `generate_key_package`.
backend: HybridCryptoProvider,
/// Long-term Ed25519 identity keypair. Also used as the MLS `Signer`.
identity: Arc<IdentityKeypair>,
/// Active MLS group, if any.
group: Option<MlsGroup>,
/// Shared group configuration (wire format, ratchet tree extension, etc.).
config: MlsGroupConfig,
/// Whether this member uses hybrid (X25519 + ML-KEM-768) HPKE keys.
hybrid: bool,
}
impl GroupMember {
/// Create a new `GroupMember` with a fresh classical crypto backend.
pub fn new(identity: Arc<IdentityKeypair>) -> Self {
Self::new_with_state(identity, DiskKeyStore::ephemeral(), None, false)
}
/// Create a new `GroupMember` with hybrid post-quantum crypto backend.
pub fn new_hybrid(identity: Arc<IdentityKeypair>) -> Self {
Self::new_with_state(identity, DiskKeyStore::ephemeral(), None, true)
}
/// Create a `GroupMember` with a persistent keystore at `path`.
pub fn new_persistent(
identity: Arc<IdentityKeypair>,
path: impl AsRef<Path>,
) -> Result<Self, CoreError> {
let key_store = DiskKeyStore::persistent(path)
.map_err(|e| CoreError::Io(format!("keystore: {e}")))?;
Ok(Self::new_with_state(identity, key_store, None, false))
}
/// Create a `GroupMember` from pre-existing state (identity + optional group + store).
///
/// When `hybrid` is `true`, the backend uses hybrid (X25519 + ML-KEM-768)
/// keys for HPKE operations. When `false`, standard X25519 keys are used.
pub fn new_with_state(
identity: Arc<IdentityKeypair>,
key_store: DiskKeyStore,
group: Option<MlsGroup>,
hybrid: bool,
) -> Self {
let config = MlsGroupConfig::builder()
.use_ratchet_tree_extension(true)
.build();
let backend = if hybrid {
HybridCryptoProvider::new_hybrid(key_store)
} else {
HybridCryptoProvider::new_classical(key_store)
};
Self {
backend,
identity,
group,
config,
hybrid,
}
}
// ── KeyPackage ────────────────────────────────────────────────────────────
/// Generate a fresh single-use MLS KeyPackage.
///
/// The HPKE init private key is stored in `self.backend`'s key store.
/// **The same `GroupMember` instance must later call `join_group`** so
/// that `new_from_welcome` can retrieve the private key.
///
/// # Returns
///
/// TLS-encoded KeyPackage bytes, ready for upload to the Authentication
/// Service.
///
/// # Errors
///
/// Returns [`CoreError::Mls`] if openmls fails to create the KeyPackage.
pub fn generate_key_package(&mut self) -> Result<Vec<u8>, CoreError> {
let credential_with_key = self.make_credential_with_key()?;
let key_package = KeyPackage::builder()
.build(
CryptoConfig::with_default_version(CIPHERSUITE),
&self.backend,
self.identity.as_ref(),
credential_with_key,
)
.map_err(|e| CoreError::Mls(format!("{e:?}")))?;
key_package
.tls_serialize_detached()
.map_err(|e| CoreError::Mls(format!("{e:?}")))
}
// ── Group creation ────────────────────────────────────────────────────────
/// Create a new MLS group with `group_id` as the group identifier.
///
/// The caller becomes the sole member (epoch 0). Use `add_member` to
/// invite additional members.
///
/// `group_id` can be any non-empty byte string; SHA-256 of a human-readable
/// name is a good choice.
///
/// # Errors
///
/// Returns [`CoreError::Mls`] if the group already exists or openmls fails.
pub fn create_group(&mut self, group_id: &[u8]) -> Result<(), CoreError> {
let credential_with_key = self.make_credential_with_key()?;
let mls_id = GroupId::from_slice(group_id);
let group = MlsGroup::new_with_group_id(
&self.backend,
self.identity.as_ref(),
&self.config,
mls_id,
credential_with_key,
)
.map_err(|e| CoreError::Mls(format!("{e:?}")))?;
self.group = Some(group);
Ok(())
}
// ── Membership ────────────────────────────────────────────────────────────
/// Add a new member by their TLS-encoded KeyPackage bytes.
///
/// Produces a Commit (to update existing members' state) and a Welcome
/// (to bootstrap the new member). The caller is responsible for
/// distributing these:
///
/// - Send `commit_bytes` to all **existing** group members via the DS.
/// (In the 2-party case where the creator is the only member, this can
/// be discarded — the creator applies it locally via this method.)
/// - Send `welcome_bytes` to the **new** member via the DS.
///
/// This method also merges the pending Commit into the local group state
/// (advancing the epoch), so the caller is immediately ready to encrypt.
///
/// # Returns
///
/// `(commit_bytes, welcome_bytes)` — both TLS-encoded MLS messages.
///
/// # Errors
///
/// Returns [`CoreError::Mls`] if the KeyPackage is malformed, no active
/// group exists, or openmls fails.
pub fn add_member(
&mut self,
mut key_package_bytes: &[u8],
) -> Result<(Vec<u8>, Vec<u8>), CoreError> {
let group = self
.group
.as_mut()
.ok_or_else(|| CoreError::Mls("no active group".into()))?;
// Deserialise and validate the peer's KeyPackage. KeyPackage only derives
// TlsSerialize; KeyPackageIn derives TlsDeserialize and provides validate()
// which verifies the signature and returns a trusted KeyPackage.
let key_package: KeyPackage =
KeyPackageIn::tls_deserialize(&mut key_package_bytes)
.map_err(|e| CoreError::Mls(format!("KeyPackage deserialise: {e:?}")))?
.validate(self.backend.crypto(), ProtocolVersion::Mls10)
.map_err(|e| CoreError::Mls(format!("KeyPackage validate: {e:?}")))?;
// Create the Commit + Welcome. The third return value (GroupInfo) is for
// external commits and is not needed here.
let (commit_out, welcome_out, _group_info) = group
.add_members(&self.backend, self.identity.as_ref(), &[key_package])
.map_err(|e| CoreError::Mls(format!("add_members: {e:?}")))?;
// Merge the pending Commit into our own state, advancing the epoch.
group
.merge_pending_commit(&self.backend)
.map_err(|e| CoreError::Mls(format!("merge_pending_commit: {e:?}")))?;
let commit_bytes = commit_out
.to_bytes()
.map_err(|e| CoreError::Mls(format!("commit serialise: {e:?}")))?;
let welcome_bytes = welcome_out
.to_bytes()
.map_err(|e| CoreError::Mls(format!("welcome serialise: {e:?}")))?;
Ok((commit_bytes, welcome_bytes))
}
/// Join an existing MLS group from a TLS-encoded Welcome message.
///
/// The caller must have previously called [`generate_key_package`] on
/// **this same instance** so that the HPKE init private key is in the
/// backend's key store.
///
/// # Errors
///
/// Returns [`CoreError::Mls`] if the Welcome does not match any known
/// KeyPackage, or openmls validation fails.
///
/// [`generate_key_package`]: Self::generate_key_package
pub fn join_group(&mut self, mut welcome_bytes: &[u8]) -> Result<(), CoreError> {
// Deserialise MlsMessageIn, then extract the inner Welcome.
let msg_in = openmls::prelude::MlsMessageIn::tls_deserialize(&mut welcome_bytes)
.map_err(|e| CoreError::Mls(format!("Welcome deserialise: {e:?}")))?;
// into_welcome() is feature-gated in openmls 0.5; extract() is public.
let welcome = match msg_in.extract() {
MlsMessageInBody::Welcome(w) => w,
_ => return Err(CoreError::Mls("expected a Welcome message".into())),
};
// ratchet_tree = None because use_ratchet_tree_extension = true embeds
// the tree inside the Welcome's GroupInfo extension.
let group = MlsGroup::new_from_welcome(&self.backend, &self.config, welcome, None)
.map_err(|e| CoreError::Mls(format!("new_from_welcome: {e:?}")))?;
self.group = Some(group);
Ok(())
}
// ── Application messages ──────────────────────────────────────────────────
/// Encrypt `plaintext` as an MLS Application message.
///
/// # Returns
///
/// TLS-encoded `MlsMessageOut` bytes (PrivateMessage variant).
///
/// # Errors
///
/// Returns [`CoreError::Mls`] if there is no active group or encryption fails.
pub fn send_message(&mut self, plaintext: &[u8]) -> Result<Vec<u8>, CoreError> {
let group = self
.group
.as_mut()
.ok_or_else(|| CoreError::Mls("no active group".into()))?;
let mls_msg: MlsMessageOut = group
.create_message(&self.backend, self.identity.as_ref(), plaintext)
.map_err(|e| CoreError::Mls(format!("create_message: {e:?}")))?;
mls_msg
.to_bytes()
.map_err(|e| CoreError::Mls(format!("message serialise: {e:?}")))
}
/// Process an incoming TLS-encoded MLS message.
///
/// # Returns
///
/// - `Ok(Some(plaintext))` for Application messages.
/// - `Ok(None)` for Commit messages (group state is updated internally).
///
/// # Errors
///
/// Returns [`CoreError::Mls`] if the message is malformed, fails
/// authentication, or the group state is inconsistent.
pub fn receive_message(&mut self, mut bytes: &[u8]) -> Result<Option<Vec<u8>>, CoreError> {
let group = self
.group
.as_mut()
.ok_or_else(|| CoreError::Mls("no active group".into()))?;
let msg_in = openmls::prelude::MlsMessageIn::tls_deserialize(&mut bytes)
.map_err(|e| CoreError::Mls(format!("message deserialise: {e:?}")))?;
// into_protocol_message() is feature-gated; extract() + manual construction is not.
let protocol_message = match msg_in.extract() {
MlsMessageInBody::PrivateMessage(m) => ProtocolMessage::PrivateMessage(m),
MlsMessageInBody::PublicMessage(m) => ProtocolMessage::PublicMessage(m),
_ => return Err(CoreError::Mls("not a protocol message".into())),
};
let processed = group
.process_message(&self.backend, protocol_message)
.map_err(|e| CoreError::Mls(format!("process_message: {e:?}")))?;
match processed.into_content() {
ProcessedMessageContent::ApplicationMessage(app) => Ok(Some(app.into_bytes())),
ProcessedMessageContent::StagedCommitMessage(staged) => {
// Merge the Commit into the local state (epoch advances).
group
.merge_staged_commit(&self.backend, *staged)
.map_err(|e| CoreError::Mls(format!("merge_staged_commit: {e:?}")))?;
Ok(None)
}
// Proposals are stored for a later Commit; nothing to return yet.
ProcessedMessageContent::ProposalMessage(proposal) => {
group.store_pending_proposal(*proposal);
Ok(None)
}
ProcessedMessageContent::ExternalJoinProposalMessage(proposal) => {
group.store_pending_proposal(*proposal);
Ok(None)
}
}
}
/// Process an incoming TLS-encoded MLS message and return sender identity + plaintext for application messages.
///
/// Same as [`receive_message`], but for Application messages returns
/// `Some((sender_identity_bytes, plaintext))` so the client can display who sent the message.
/// `sender_identity_bytes` is the MLS credential identity (e.g. Ed25519 public key for Basic credential).
///
/// Returns `Ok(None)` for Commit and Proposal messages (group state is updated internally).
pub fn receive_message_with_sender(
&mut self,
mut bytes: &[u8],
) -> Result<Option<(Vec<u8>, Vec<u8>)>, CoreError> {
let group = self
.group
.as_mut()
.ok_or_else(|| CoreError::Mls("no active group".into()))?;
let msg_in = openmls::prelude::MlsMessageIn::tls_deserialize(&mut bytes)
.map_err(|e| CoreError::Mls(format!("message deserialise: {e:?}")))?;
let protocol_message = match msg_in.extract() {
MlsMessageInBody::PrivateMessage(m) => ProtocolMessage::PrivateMessage(m),
MlsMessageInBody::PublicMessage(m) => ProtocolMessage::PublicMessage(m),
_ => return Err(CoreError::Mls("not a protocol message".into())),
};
let processed = group
.process_message(&self.backend, protocol_message)
.map_err(|e| CoreError::Mls(format!("process_message: {e:?}")))?;
let sender_identity = processed.credential().identity().to_vec();
match processed.into_content() {
ProcessedMessageContent::ApplicationMessage(app) => {
Ok(Some((sender_identity, app.into_bytes())))
}
ProcessedMessageContent::StagedCommitMessage(staged) => {
group
.merge_staged_commit(&self.backend, *staged)
.map_err(|e| CoreError::Mls(format!("merge_staged_commit: {e:?}")))?;
Ok(None)
}
ProcessedMessageContent::ProposalMessage(proposal) => {
group.store_pending_proposal(*proposal);
Ok(None)
}
ProcessedMessageContent::ExternalJoinProposalMessage(proposal) => {
group.store_pending_proposal(*proposal);
Ok(None)
}
}
}
// ── Accessors ─────────────────────────────────────────────────────────────
/// Return the MLS group ID bytes, or `None` if no group is active.
pub fn group_id(&self) -> Option<Vec<u8>> {
self.group
.as_ref()
.map(|g| g.group_id().as_slice().to_vec())
}
/// Return a reference to the identity keypair.
pub fn identity(&self) -> &IdentityKeypair {
&self.identity
}
/// Return the private seed of the identity (for persistence).
pub fn identity_seed(&self) -> [u8; 32] {
self.identity.seed_bytes()
}
/// Return a reference to the underlying crypto backend.
pub fn backend(&self) -> &HybridCryptoProvider {
&self.backend
}
/// Whether this member uses hybrid post-quantum HPKE keys.
pub fn is_hybrid(&self) -> bool {
self.hybrid
}
/// Return a reference to the MLS group, if active.
pub fn group_ref(&self) -> Option<&MlsGroup> {
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> {
let credential = Credential::new(
self.identity.public_key_bytes().to_vec(),
CredentialType::Basic,
)
.map_err(|e| CoreError::Mls(format!("{e:?}")))?;
Ok(CredentialWithKey {
credential,
signature_key: self.identity.public_key_bytes().to_vec().into(),
})
}
}
// ── Unit tests ────────────────────────────────────────────────────────────────
#[cfg(test)]
mod tests {
use super::*;
/// Full two-party MLS round-trip: creator creates group, adds joiner, then they exchange messages.
#[test]
fn two_party_mls_round_trip() {
let creator_id = Arc::new(IdentityKeypair::generate());
let joiner_id = Arc::new(IdentityKeypair::generate());
let mut creator = GroupMember::new(Arc::clone(&creator_id));
let mut joiner = GroupMember::new(Arc::clone(&joiner_id));
let joiner_kp = joiner
.generate_key_package()
.expect("joiner KeyPackage");
creator
.create_group(b"test-group-m3")
.expect("creator create group");
let (_, welcome) = creator
.add_member(&joiner_kp)
.expect("creator add joiner");
joiner.join_group(&welcome).expect("joiner join group");
let ct_creator = creator.send_message(b"hello").expect("creator send");
let pt_joiner = joiner
.receive_message(&ct_creator)
.expect("joiner recv")
.expect("application message");
assert_eq!(pt_joiner, b"hello");
let ct_joiner = joiner.send_message(b"hello back").expect("joiner send");
let pt_creator = creator
.receive_message(&ct_joiner)
.expect("creator recv")
.expect("application message");
assert_eq!(pt_creator, b"hello back");
}
/// Full two-party hybrid MLS round-trip with post-quantum HPKE keys.
#[test]
fn two_party_hybrid_mls_round_trip() {
let creator_id = Arc::new(IdentityKeypair::generate());
let joiner_id = Arc::new(IdentityKeypair::generate());
let mut creator = GroupMember::new_hybrid(Arc::clone(&creator_id));
let mut joiner = GroupMember::new_hybrid(Arc::clone(&joiner_id));
assert!(creator.is_hybrid());
assert!(joiner.is_hybrid());
let joiner_kp = joiner
.generate_key_package()
.expect("joiner hybrid KeyPackage");
creator
.create_group(b"test-hybrid-group")
.expect("creator create hybrid group");
let (_, welcome) = creator
.add_member(&joiner_kp)
.expect("creator add joiner with hybrid KP");
joiner.join_group(&welcome).expect("joiner join hybrid group");
let ct_creator = creator.send_message(b"hello PQ").expect("creator send");
let pt_joiner = joiner
.receive_message(&ct_creator)
.expect("joiner recv")
.expect("application message");
assert_eq!(pt_joiner, b"hello PQ");
let ct_joiner = joiner.send_message(b"quantum safe!").expect("joiner send");
let pt_creator = creator
.receive_message(&ct_joiner)
.expect("creator recv")
.expect("application message");
assert_eq!(pt_creator, b"quantum safe!");
}
/// `group_id()` returns None before create_group, Some afterwards.
#[test]
fn group_id_lifecycle() {
let id = Arc::new(IdentityKeypair::generate());
let mut member = GroupMember::new(id);
assert!(member.group_id().is_none(), "no group before create");
member.create_group(b"gid").unwrap();
assert_eq!(
member.group_id().unwrap(),
b"gid".as_slice(),
"group_id must match what was passed"
);
}
}

View File

@@ -0,0 +1,552 @@
//! Post-quantum hybrid crypto provider for OpenMLS (M7 PoC).
//!
//! Uses X25519 + ML-KEM-768 hybrid KEM for HPKE operations where openmls
//! would use DHKEM(X25519), and delegates all other operations (AEAD, hash,
//! signatures, KDF, randomness) to `openmls_rust_crypto::RustCrypto`.
//!
//! # Key format
//!
//! When the provider sees a **hybrid public key** (length `HYBRID_PUBLIC_KEY_LEN` =
//! 32 + 1184 bytes) or **hybrid private key** (length `HYBRID_PRIVATE_KEY_LEN` =
//! 32 + 2400 bytes), it uses `hybrid_kem` for HPKE. Otherwise it delegates to
//! RustCrypto (classical X25519 HPKE).
//!
//! # MLS compatibility
//!
//! The current MLS ciphersuite (MLS_128_DHKEMX25519_AES128GCM_SHA256_Ed25519)
//! uses 32-byte X25519 init keys in the wire format. This provider can produce
//! and consume **hybrid** init keys (1216-byte public, 2432-byte private), but
//! that is a non-standard extension: other MLS implementations will not
//! accept KeyPackages with hybrid init keys unless they implement the same
//! extension. This PoC validates that the OpenMLS trait surface is satisfiable
//! with a custom HPKE backend; full interoperability would require a new
//! ciphersuite or protocol extension.
use openmls_rust_crypto::RustCrypto;
use openmls_traits::{
crypto::OpenMlsCrypto,
types::{
CryptoError, ExporterSecret, HpkeCiphertext, HpkeConfig, HpkeKeyPair, HpkeKemType,
},
OpenMlsCryptoProvider,
};
use tls_codec::SecretVLBytes;
use crate::hybrid_kem::{
hybrid_decapsulate_only, hybrid_decrypt, hybrid_encapsulate_only, hybrid_encrypt,
hybrid_export, HybridKeypair, HybridPublicKey,
HYBRID_KEM_OUTPUT_LEN, HYBRID_PRIVATE_KEY_LEN, HYBRID_PUBLIC_KEY_LEN,
};
use crate::keystore::DiskKeyStore;
// Re-export types used by OpenMlsCrypto (full path for clarity).
use openmls_traits::types::{
AeadType, Ciphersuite, HashType, SignatureScheme,
};
/// Crypto backend that uses hybrid KEM for HPKE when keys are in hybrid format,
/// and delegates everything else to RustCrypto.
///
/// When `hybrid_enabled` is `true`, `derive_hpke_keypair` produces hybrid keys
/// (1216-byte public, 2432-byte private). When `false`, it delegates to
/// RustCrypto and produces classical 32-byte X25519 keys.
///
/// The `hpke_seal` / `hpke_open` methods always detect the key format by length,
/// so they work correctly regardless of the flag — a hybrid-length key will use
/// hybrid KEM, a classical-length key will use RustCrypto.
#[derive(Debug)]
pub struct HybridCrypto {
rust_crypto: RustCrypto,
/// When true, `derive_hpke_keypair` produces hybrid (X25519 + ML-KEM-768)
/// keys. When false, it produces classical X25519 keys via RustCrypto.
hybrid_enabled: bool,
}
impl HybridCrypto {
/// Create a hybrid-enabled crypto backend (derive_hpke_keypair produces hybrid keys).
pub fn new() -> Self {
Self {
rust_crypto: RustCrypto::default(),
hybrid_enabled: true,
}
}
/// Alias for `new()` — hybrid mode enabled.
pub fn new_hybrid() -> Self {
Self::new()
}
/// Create a classical crypto backend (derive_hpke_keypair produces standard
/// X25519 keys, but seal/open still accept hybrid keys by length detection).
pub fn new_classical() -> Self {
Self {
rust_crypto: RustCrypto::default(),
hybrid_enabled: false,
}
}
/// Whether this backend produces hybrid keys from `derive_hpke_keypair`.
pub fn is_hybrid_enabled(&self) -> bool {
self.hybrid_enabled
}
/// Expose the underlying RustCrypto for rand() and delegation.
pub fn rust_crypto(&self) -> &RustCrypto {
&self.rust_crypto
}
fn is_hybrid_public_key(pk_r: &[u8]) -> bool {
pk_r.len() == HYBRID_PUBLIC_KEY_LEN
}
fn is_hybrid_private_key(sk_r: &[u8]) -> bool {
sk_r.len() == HYBRID_PRIVATE_KEY_LEN
}
}
impl Default for HybridCrypto {
fn default() -> Self {
Self::new()
}
}
impl OpenMlsCrypto for HybridCrypto {
fn supports(&self, ciphersuite: Ciphersuite) -> Result<(), CryptoError> {
self.rust_crypto.supports(ciphersuite)
}
fn supported_ciphersuites(&self) -> Vec<Ciphersuite> {
self.rust_crypto.supported_ciphersuites()
}
fn hkdf_extract(
&self,
hash_type: HashType,
salt: &[u8],
ikm: &[u8],
) -> Result<SecretVLBytes, CryptoError> {
self.rust_crypto.hkdf_extract(hash_type, salt, ikm)
}
fn hkdf_expand(
&self,
hash_type: HashType,
prk: &[u8],
info: &[u8],
okm_len: usize,
) -> Result<SecretVLBytes, CryptoError> {
self.rust_crypto.hkdf_expand(hash_type, prk, info, okm_len)
}
fn hash(&self, hash_type: HashType, data: &[u8]) -> Result<Vec<u8>, CryptoError> {
self.rust_crypto.hash(hash_type, data)
}
fn aead_encrypt(
&self,
alg: AeadType,
key: &[u8],
data: &[u8],
nonce: &[u8],
aad: &[u8],
) -> Result<Vec<u8>, CryptoError> {
self.rust_crypto.aead_encrypt(alg, key, data, nonce, aad)
}
fn aead_decrypt(
&self,
alg: AeadType,
key: &[u8],
ct_tag: &[u8],
nonce: &[u8],
aad: &[u8],
) -> Result<Vec<u8>, CryptoError> {
self.rust_crypto.aead_decrypt(alg, key, ct_tag, nonce, aad)
}
fn signature_key_gen(&self, alg: SignatureScheme) -> Result<(Vec<u8>, Vec<u8>), CryptoError> {
self.rust_crypto.signature_key_gen(alg)
}
fn verify_signature(
&self,
alg: SignatureScheme,
data: &[u8],
pk: &[u8],
signature: &[u8],
) -> Result<(), CryptoError> {
self.rust_crypto.verify_signature(alg, data, pk, signature)
}
fn sign(&self, alg: SignatureScheme, data: &[u8], key: &[u8]) -> Result<Vec<u8>, CryptoError> {
self.rust_crypto.sign(alg, data, key)
}
fn hpke_seal(
&self,
config: HpkeConfig,
pk_r: &[u8],
info: &[u8],
aad: &[u8],
ptxt: &[u8],
) -> HpkeCiphertext {
if Self::is_hybrid_public_key(pk_r) {
let recipient_pk = match HybridPublicKey::from_bytes(pk_r) {
Ok(pk) => pk,
// Key parsed as hybrid length but failed to deserialize — this is
// a real error, not a reason to silently fall back to classical HPKE.
Err(_) => return HpkeCiphertext {
kem_output: Vec::new().into(),
ciphertext: Vec::new().into(),
},
};
// Pass HPKE info and aad through for proper context binding (RFC 9180).
match hybrid_encrypt(&recipient_pk, ptxt, info, aad) {
Ok(envelope) => {
let kem_output = envelope[..HYBRID_KEM_OUTPUT_LEN].to_vec();
let ciphertext = envelope[HYBRID_KEM_OUTPUT_LEN..].to_vec();
HpkeCiphertext {
kem_output: kem_output.into(),
ciphertext: ciphertext.into(),
}
}
// Encryption failed with a hybrid key — return empty ciphertext
// rather than silently falling back to classical HPKE with an
// incompatible key.
Err(_) => HpkeCiphertext {
kem_output: Vec::new().into(),
ciphertext: Vec::new().into(),
},
}
} else {
self.rust_crypto.hpke_seal(config, pk_r, info, aad, ptxt)
}
}
fn hpke_open(
&self,
config: HpkeConfig,
input: &HpkeCiphertext,
sk_r: &[u8],
info: &[u8],
aad: &[u8],
) -> Result<Vec<u8>, CryptoError> {
if Self::is_hybrid_private_key(sk_r) {
let keypair = HybridKeypair::from_private_bytes(sk_r)
.map_err(|_| CryptoError::HpkeDecryptionError)?;
let envelope: Vec<u8> = input
.kem_output.as_slice()
.iter()
.chain(input.ciphertext.as_slice())
.copied()
.collect();
// Pass HPKE info and aad through for proper context binding (RFC 9180).
hybrid_decrypt(&keypair, &envelope, info, aad)
.map_err(|_| CryptoError::HpkeDecryptionError)
} else {
self.rust_crypto.hpke_open(config, input, sk_r, info, aad)
}
}
fn hpke_setup_sender_and_export(
&self,
config: HpkeConfig,
pk_r: &[u8],
info: &[u8],
exporter_context: &[u8],
exporter_length: usize,
) -> Result<(Vec<u8>, ExporterSecret), CryptoError> {
if Self::is_hybrid_public_key(pk_r) {
let recipient_pk = match HybridPublicKey::from_bytes(pk_r) {
Ok(pk) => pk,
Err(_) => {
return self.rust_crypto.hpke_setup_sender_and_export(
config, pk_r, info, exporter_context, exporter_length,
)
}
};
let (kem_output, shared_secret) =
hybrid_encapsulate_only(&recipient_pk).map_err(|_| CryptoError::SenderSetupError)?;
let exported = hybrid_export(&shared_secret, exporter_context, exporter_length);
Ok((kem_output, exported.into()))
} else {
self.rust_crypto.hpke_setup_sender_and_export(
config, pk_r, info, exporter_context, exporter_length,
)
}
}
fn hpke_setup_receiver_and_export(
&self,
config: HpkeConfig,
enc: &[u8],
sk_r: &[u8],
info: &[u8],
exporter_context: &[u8],
exporter_length: usize,
) -> Result<ExporterSecret, CryptoError> {
if Self::is_hybrid_private_key(sk_r) {
let keypair = HybridKeypair::from_private_bytes(sk_r)
.map_err(|_| CryptoError::ReceiverSetupError)?;
let shared_secret =
hybrid_decapsulate_only(&keypair, enc).map_err(|_| CryptoError::ReceiverSetupError)?;
let exported = hybrid_export(&shared_secret, exporter_context, exporter_length);
Ok(exported.into())
} else {
self.rust_crypto.hpke_setup_receiver_and_export(
config, enc, sk_r, info, exporter_context, exporter_length,
)
}
}
fn derive_hpke_keypair(&self, config: HpkeConfig, ikm: &[u8]) -> HpkeKeyPair {
if self.hybrid_enabled && config.0 == HpkeKemType::DhKem25519 {
let kp = HybridKeypair::derive_from_ikm(ikm);
HpkeKeyPair {
private: kp.private_to_bytes().into(),
public: kp.public_key().to_bytes(),
}
} else {
self.rust_crypto.derive_hpke_keypair(config, ikm)
}
}
}
/// OpenMLS crypto provider that uses hybrid KEM for HPKE (when keys are in
/// hybrid format) and delegates the rest to RustCrypto.
#[derive(Debug)]
pub struct HybridCryptoProvider {
crypto: HybridCrypto,
key_store: DiskKeyStore,
}
impl HybridCryptoProvider {
/// Create a hybrid-enabled provider (KeyPackages will contain hybrid init keys).
pub fn new(key_store: DiskKeyStore) -> Self {
Self {
crypto: HybridCrypto::new_hybrid(),
key_store,
}
}
/// Alias for `new()` — hybrid mode enabled.
pub fn new_hybrid(key_store: DiskKeyStore) -> Self {
Self::new(key_store)
}
/// Create a classical-mode provider (KeyPackages use standard X25519 init keys,
/// but seal/open still accept hybrid keys by length detection).
pub fn new_classical(key_store: DiskKeyStore) -> Self {
Self {
crypto: HybridCrypto::new_classical(),
key_store,
}
}
/// Whether this provider produces hybrid keys from `derive_hpke_keypair`.
pub fn is_hybrid_enabled(&self) -> bool {
self.crypto.is_hybrid_enabled()
}
}
impl Default for HybridCryptoProvider {
fn default() -> Self {
Self::new(DiskKeyStore::ephemeral())
}
}
impl OpenMlsCryptoProvider for HybridCryptoProvider {
type CryptoProvider = HybridCrypto;
type RandProvider = RustCrypto;
type KeyStoreProvider = DiskKeyStore;
fn crypto(&self) -> &Self::CryptoProvider {
&self.crypto
}
fn rand(&self) -> &Self::RandProvider {
self.crypto.rust_crypto()
}
fn key_store(&self) -> &Self::KeyStoreProvider {
&self.key_store
}
}
// ── Tests ───────────────────────────────────────────────────────────────────
#[cfg(test)]
mod tests {
use super::*;
use openmls_traits::types::HpkeKdfType;
fn hpke_config_dhkem_x25519() -> HpkeConfig {
HpkeConfig(
HpkeKemType::DhKem25519,
HpkeKdfType::HkdfSha256,
openmls_traits::types::HpkeAeadType::AesGcm128,
)
}
/// HPKE path with hybrid keys: derive_hpke_keypair (hybrid) -> hpke_seal -> hpke_open.
#[test]
fn hybrid_hpke_seal_open_round_trip() {
let crypto = HybridCrypto::new();
let ikm = b"test-ikm-for-hybrid-hpke-keypair";
let keypair = crypto.derive_hpke_keypair(hpke_config_dhkem_x25519(), ikm);
assert_eq!(keypair.public.len(), HYBRID_PUBLIC_KEY_LEN);
assert_eq!(keypair.private.as_ref().len(), HYBRID_PRIVATE_KEY_LEN);
let plaintext = b"hello post-quantum MLS";
let info = b"mls 1.0 test";
let aad = b"additional data";
let ct = crypto.hpke_seal(
hpke_config_dhkem_x25519(),
&keypair.public,
info,
aad,
plaintext,
);
assert!(!ct.kem_output.as_slice().is_empty());
assert!(!ct.ciphertext.as_slice().is_empty());
let decrypted = crypto
.hpke_open(
hpke_config_dhkem_x25519(),
&ct,
keypair.private.as_ref(),
info,
aad,
)
.expect("hpke_open with hybrid keys");
assert_eq!(decrypted.as_slice(), plaintext);
}
/// HPKE exporter path: setup_sender_and_export then setup_receiver_and_export.
#[test]
fn hybrid_hpke_setup_sender_receiver_export() {
let crypto = HybridCrypto::new();
let ikm = b"exporter-ikm";
let keypair = crypto.derive_hpke_keypair(hpke_config_dhkem_x25519(), ikm);
let info = b"";
let exporter_context = b"MLS 1.0 external init";
let exporter_length = 32;
let (kem_output, sender_exported) = crypto
.hpke_setup_sender_and_export(
hpke_config_dhkem_x25519(),
&keypair.public,
info,
exporter_context,
exporter_length,
)
.expect("sender and export");
assert_eq!(kem_output.len(), HYBRID_KEM_OUTPUT_LEN);
assert_eq!(sender_exported.as_ref().len(), exporter_length);
let receiver_exported = crypto
.hpke_setup_receiver_and_export(
hpke_config_dhkem_x25519(),
&kem_output,
keypair.private.as_ref(),
info,
exporter_context,
exporter_length,
)
.expect("receiver and export");
assert_eq!(sender_exported.as_ref(), receiver_exported.as_ref());
}
/// Classical mode: derive_hpke_keypair produces standard 32-byte X25519 keys.
#[test]
fn classical_mode_produces_standard_keys() {
let crypto = HybridCrypto::new_classical();
let ikm = b"test-ikm-for-classical-hpke";
let keypair = crypto.derive_hpke_keypair(hpke_config_dhkem_x25519(), ikm);
// Classical X25519 keys are 32 bytes
assert_eq!(keypair.public.len(), 32);
assert_eq!(keypair.private.as_ref().len(), 32);
}
/// Classical mode round-trip: seal/open works with classical keys.
#[test]
fn classical_mode_seal_open_round_trip() {
let crypto = HybridCrypto::new_classical();
let ikm = b"test-ikm-for-classical-round-trip";
let keypair = crypto.derive_hpke_keypair(hpke_config_dhkem_x25519(), ikm);
assert_eq!(keypair.public.len(), 32); // classical key
let plaintext = b"hello classical MLS";
let info = b"mls 1.0 test";
let aad = b"additional data";
let ct = crypto.hpke_seal(
hpke_config_dhkem_x25519(),
&keypair.public,
info,
aad,
plaintext,
);
assert!(!ct.kem_output.as_slice().is_empty());
let decrypted = crypto
.hpke_open(
hpke_config_dhkem_x25519(),
&ct,
keypair.private.as_ref(),
info,
aad,
)
.expect("hpke_open with classical keys");
assert_eq!(decrypted.as_slice(), plaintext);
}
/// KeyPackage generation with HybridCryptoProvider (validates full HPKE path in MLS).
#[test]
fn key_package_generation_with_hybrid_provider() {
use openmls::prelude::{
Credential, CredentialType, CredentialWithKey, CryptoConfig, KeyPackage,
};
use std::sync::Arc;
use tls_codec::Serialize;
use crate::identity::IdentityKeypair;
const CIPHERSUITE: Ciphersuite =
Ciphersuite::MLS_128_DHKEMX25519_AES128GCM_SHA256_Ed25519;
let provider = HybridCryptoProvider::default();
let identity = Arc::new(IdentityKeypair::generate());
let credential = Credential::new(
identity.public_key_bytes().to_vec(),
CredentialType::Basic,
)
.unwrap();
let credential_with_key = CredentialWithKey {
credential,
signature_key: identity.public_key_bytes().to_vec().into(),
};
let key_package = KeyPackage::builder()
.build(
CryptoConfig::with_default_version(CIPHERSUITE),
&provider,
identity.as_ref(),
credential_with_key,
)
.expect("KeyPackage with hybrid HPKE");
let bytes = key_package
.tls_serialize_detached()
.expect("serialize KeyPackage");
assert!(!bytes.is_empty());
}
}

View File

@@ -0,0 +1,629 @@
//! Post-quantum hybrid KEM: X25519 + ML-KEM-768.
//!
//! Wraps MLS payloads in an outer encryption layer using a hybrid key
//! encapsulation mechanism. The X25519 component provides classical
//! ECDH security; the ML-KEM-768 component (FIPS 203) provides
//! post-quantum security.
//!
//! # Wire format
//!
//! ```text
//! version(1) | x25519_eph_pk(32) | mlkem_ct(1088) | aead_nonce(12) | aead_ct(var)
//! ```
//!
//! # Key derivation
//!
//! ```text
//! ikm = X25519_shared(32) || ML-KEM_shared(32)
//! key = HKDF-SHA256(salt=[], ikm, info="quicnprotochat-hybrid-v1", L=32)
//! ```
use chacha20poly1305::{
aead::{Aead, KeyInit},
ChaCha20Poly1305, Key, Nonce,
};
use hkdf::Hkdf;
use ml_kem::{
array::Array,
kem::{Decapsulate, Encapsulate},
EncodedSizeUser, KemCore, MlKem768, MlKem768Params,
};
use rand::{rngs::OsRng, rngs::StdRng, CryptoRng, RngCore, SeedableRng};
use serde::{Deserialize, Serialize};
use sha2::Sha256;
use x25519_dalek::{EphemeralSecret, PublicKey as X25519Public, StaticSecret};
use zeroize::Zeroizing;
// Re-import the concrete key types from the kem sub-module.
use ml_kem::kem::{DecapsulationKey, EncapsulationKey};
/// Current hybrid envelope version byte.
const HYBRID_VERSION: u8 = 0x01;
/// HKDF info string for domain separation.
/// Frozen at the original project name for backward compatibility with existing
/// encrypted state files and messages. Do not change.
const HKDF_INFO: &[u8] = b"quicnprotochat-hybrid-v1";
/// HKDF salt for domain separation (defence-in-depth; IKM already has 64 bytes of entropy).
/// Frozen — see [`HKDF_INFO`].
const HKDF_SALT: &[u8] = b"quicnprotochat-hybrid-v1-salt";
/// ML-KEM-768 ciphertext size in bytes.
const MLKEM_CT_LEN: usize = 1088;
/// ML-KEM-768 encapsulation key size in bytes.
pub const MLKEM_EK_LEN: usize = 1184;
/// ML-KEM-768 decapsulation key size in bytes.
pub const MLKEM_DK_LEN: usize = 2400;
/// Envelope header: version(1) + x25519 eph pk(32) + mlkem ct(1088) + nonce(12).
const HEADER_LEN: usize = 1 + 32 + MLKEM_CT_LEN + 12;
/// KEM output length (version + x25519 eph pk + mlkem ct) for HPKE adapter.
pub const HYBRID_KEM_OUTPUT_LEN: usize = 1 + 32 + MLKEM_CT_LEN;
/// Hybrid public key length: x25519(32) + mlkem_ek(1184). Used to detect hybrid keys in MLS.
pub const HYBRID_PUBLIC_KEY_LEN: usize = 32 + MLKEM_EK_LEN;
/// Hybrid private key length: x25519(32) + mlkem_dk(2400). Used to detect hybrid keys in MLS.
pub const HYBRID_PRIVATE_KEY_LEN: usize = 32 + MLKEM_DK_LEN;
// ── Error type ──────────────────────────────────────────────────────────────
#[derive(Debug, thiserror::Error)]
pub enum HybridKemError {
#[error("AEAD encryption failed")]
EncryptionFailed,
#[error("AEAD decryption failed (wrong recipient or tampered)")]
DecryptionFailed,
#[error("unsupported hybrid envelope version: {0}")]
UnsupportedVersion(u8),
#[error("envelope too short ({0} bytes, minimum {HEADER_LEN})")]
TooShort(usize),
#[error("invalid ML-KEM encapsulation key")]
InvalidMlKemKey,
#[error("ML-KEM decapsulation failed")]
MlKemDecapsFailed,
}
// ── Keypair types ───────────────────────────────────────────────────────────
/// A hybrid keypair combining X25519 (classical) + ML-KEM-768 (post-quantum).
///
/// Each peer holds one of these. The public portion is distributed so
/// senders can encrypt payloads with post-quantum protection.
pub struct HybridKeypair {
x25519_sk: StaticSecret,
x25519_pk: X25519Public,
mlkem_dk: DecapsulationKey<MlKem768Params>,
mlkem_ek: EncapsulationKey<MlKem768Params>,
}
/// 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: Zeroizing<[u8; 32]>,
pub mlkem_dk: Zeroizing<Vec<u8>>,
pub mlkem_ek: Vec<u8>,
}
/// The public portion of a hybrid keypair, sent to peers.
#[derive(Clone, Debug, Serialize, Deserialize)]
pub struct HybridPublicKey {
pub x25519_pk: [u8; 32],
pub mlkem_ek: Vec<u8>,
}
/// HKDF info for deriving HPKE keypair seed from IKM (MLS compatibility).
/// Frozen — see [`HKDF_INFO`].
const HKDF_INFO_HPKE_KEYPAIR: &[u8] = b"quicnprotochat-hybrid-hpke-keypair-v1";
impl HybridKeypair {
/// Generate a fresh hybrid keypair from OS CSPRNG.
pub fn generate() -> Self {
Self::generate_from_rng(&mut OsRng)
}
/// Generate a hybrid keypair from a seeded RNG (deterministic).
pub fn generate_from_rng<R: RngCore + CryptoRng>(rng: &mut R) -> Self {
let x25519_sk = StaticSecret::random_from_rng(&mut *rng);
let x25519_pk = X25519Public::from(&x25519_sk);
let (mlkem_dk, mlkem_ek) = MlKem768::generate(rng);
Self {
x25519_sk,
x25519_pk,
mlkem_dk,
mlkem_ek,
}
}
/// Derive a deterministic hybrid keypair from IKM (for MLS HPKE key schedule).
pub fn derive_from_ikm(ikm: &[u8]) -> Self {
let mut seed = [0u8; 32];
let hk = Hkdf::<Sha256>::new(None, ikm);
hk.expand(HKDF_INFO_HPKE_KEYPAIR, &mut seed)
.expect("32 bytes is valid HKDF output");
let mut rng = StdRng::from_seed(seed);
Self::generate_from_rng(&mut rng)
}
/// Serialise private key for MLS key store: x25519_sk(32) || mlkem_dk(2400).
pub fn private_to_bytes(&self) -> Vec<u8> {
let mut out = Vec::with_capacity(HYBRID_PRIVATE_KEY_LEN);
out.extend_from_slice(self.x25519_sk.as_bytes());
out.extend_from_slice(self.mlkem_dk.as_bytes().as_slice());
out
}
/// Reconstruct a hybrid keypair from private key bytes (from MLS key store).
pub fn from_private_bytes(bytes: &[u8]) -> Result<Self, HybridKemError> {
if bytes.len() != HYBRID_PRIVATE_KEY_LEN {
return Err(HybridKemError::TooShort(bytes.len()));
}
let x25519_sk = StaticSecret::from(<[u8; 32]>::try_from(&bytes[0..32])
.expect("slice is exactly 32 bytes (guaranteed by HYBRID_PRIVATE_KEY_LEN check)"));
let x25519_pk = X25519Public::from(&x25519_sk);
let mlkem_dk_arr = Array::try_from(&bytes[32..32 + MLKEM_DK_LEN])
.map_err(|_| HybridKemError::InvalidMlKemKey)?;
let mlkem_dk = DecapsulationKey::<MlKem768Params>::from_bytes(&mlkem_dk_arr);
let mlkem_ek = mlkem_dk.encapsulation_key().clone();
Ok(Self {
x25519_sk,
x25519_pk,
mlkem_dk,
mlkem_ek,
})
}
/// Reconstruct from serialised bytes.
pub fn from_bytes(bytes: &HybridKeypairBytes) -> Result<Self, HybridKemError> {
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())
.map_err(|_| HybridKemError::InvalidMlKemKey)?;
let mlkem_dk = DecapsulationKey::<MlKem768Params>::from_bytes(&mlkem_dk_arr);
let mlkem_ek_arr = Array::try_from(bytes.mlkem_ek.as_slice())
.map_err(|_| HybridKemError::InvalidMlKemKey)?;
let mlkem_ek = EncapsulationKey::<MlKem768Params>::from_bytes(&mlkem_ek_arr);
Ok(Self {
x25519_sk,
x25519_pk,
mlkem_dk,
mlkem_ek,
})
}
/// Serialise the keypair for persistence.
pub fn to_bytes(&self) -> HybridKeypairBytes {
HybridKeypairBytes {
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(),
}
}
/// Extract the public portion for distribution to peers.
pub fn public_key(&self) -> HybridPublicKey {
HybridPublicKey {
x25519_pk: self.x25519_pk.to_bytes(),
mlkem_ek: self.mlkem_ek.as_bytes().to_vec(),
}
}
}
impl HybridPublicKey {
/// Serialise to a single byte blob: x25519_pk(32) || mlkem_ek(1184).
pub fn to_bytes(&self) -> Vec<u8> {
let mut out = Vec::with_capacity(32 + self.mlkem_ek.len());
out.extend_from_slice(&self.x25519_pk);
out.extend_from_slice(&self.mlkem_ek);
out
}
/// Deserialise from a single byte blob.
pub fn from_bytes(bytes: &[u8]) -> Result<Self, HybridKemError> {
if bytes.len() < 32 + MLKEM_EK_LEN {
return Err(HybridKemError::TooShort(bytes.len()));
}
let mut x25519_pk = [0u8; 32];
x25519_pk.copy_from_slice(&bytes[..32]);
let mlkem_ek = bytes[32..32 + MLKEM_EK_LEN].to_vec();
Ok(Self {
x25519_pk,
mlkem_ek,
})
}
}
// ── Encrypt / Decrypt ───────────────────────────────────────────────────────
/// Encrypt `plaintext` to `recipient_pk` using X25519 + ML-KEM-768 hybrid KEM.
///
/// `info` is optional HPKE context info incorporated into key derivation.
/// `aad` is optional additional authenticated data bound to the AEAD ciphertext.
///
/// Returns the complete hybrid envelope as a byte vector.
pub fn hybrid_encrypt(
recipient_pk: &HybridPublicKey,
plaintext: &[u8],
info: &[u8],
aad: &[u8],
) -> Result<Vec<u8>, HybridKemError> {
// 1. Ephemeral X25519 DH
let eph_secret = EphemeralSecret::random_from_rng(OsRng);
let eph_public = X25519Public::from(&eph_secret);
let x25519_recipient = X25519Public::from(recipient_pk.x25519_pk);
let x25519_ss = eph_secret.diffie_hellman(&x25519_recipient);
// 2. ML-KEM-768 encapsulation
let mlkem_ek_arr = Array::try_from(recipient_pk.mlkem_ek.as_slice())
.map_err(|_| HybridKemError::InvalidMlKemKey)?;
let mlkem_ek = EncapsulationKey::<MlKem768Params>::from_bytes(&mlkem_ek_arr);
let (mlkem_ct, mlkem_ss) = mlkem_ek
.encapsulate(&mut OsRng)
.map_err(|_| HybridKemError::EncryptionFailed)?;
// 3. Derive AEAD key from combined shared secrets (with caller info for context binding)
let aead_key = derive_aead_key(x25519_ss.as_bytes(), mlkem_ss.as_slice(), info);
// 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 with caller-supplied AAD
let cipher = ChaCha20Poly1305::new(&aead_key);
let aead_payload = chacha20poly1305::aead::Payload { msg: plaintext, aad };
let ct = cipher
.encrypt(&aead_nonce, aead_payload)
.map_err(|_| HybridKemError::EncryptionFailed)?;
// 5. Assemble envelope: version || x25519_eph_pk || mlkem_ct || nonce || aead_ct
let mut out = Vec::with_capacity(HEADER_LEN + ct.len());
out.push(HYBRID_VERSION);
out.extend_from_slice(&eph_public.to_bytes());
out.extend_from_slice(mlkem_ct.as_slice());
out.extend_from_slice(aead_nonce.as_slice());
out.extend_from_slice(&ct);
Ok(out)
}
/// Decrypt a hybrid envelope using the recipient's private key.
///
/// `info` and `aad` must match what was passed to `hybrid_encrypt`.
pub fn hybrid_decrypt(
keypair: &HybridKeypair,
envelope: &[u8],
info: &[u8],
aad: &[u8],
) -> Result<Vec<u8>, HybridKemError> {
if envelope.len() < HEADER_LEN + 16 {
// 16 = minimum AEAD tag
return Err(HybridKemError::TooShort(envelope.len()));
}
let version = envelope[0];
if version != HYBRID_VERSION {
return Err(HybridKemError::UnsupportedVersion(version));
}
let mut cursor = 1;
// X25519 ephemeral public key
let mut eph_pk_bytes = [0u8; 32];
eph_pk_bytes.copy_from_slice(&envelope[cursor..cursor + 32]);
cursor += 32;
// ML-KEM ciphertext
let mlkem_ct_bytes = &envelope[cursor..cursor + MLKEM_CT_LEN];
cursor += MLKEM_CT_LEN;
// AEAD nonce
let nonce = Nonce::from_slice(&envelope[cursor..cursor + 12]);
cursor += 12;
// AEAD ciphertext
let aead_ct = &envelope[cursor..];
// 1. X25519 DH with ephemeral public key
let eph_pk = X25519Public::from(eph_pk_bytes);
let x25519_ss = keypair.x25519_sk.diffie_hellman(&eph_pk);
// 2. ML-KEM decapsulation — convert bytes to the ciphertext array type
// that `DecapsulationKey::decapsulate` expects.
let mlkem_ct_arr =
Array::try_from(mlkem_ct_bytes).map_err(|_| HybridKemError::MlKemDecapsFailed)?;
let mlkem_ss = keypair
.mlkem_dk
.decapsulate(&mlkem_ct_arr)
.map_err(|_| HybridKemError::MlKemDecapsFailed)?;
// 3. Derive AEAD key (with caller info for context binding)
let aead_key = derive_aead_key(x25519_ss.as_bytes(), mlkem_ss.as_slice(), info);
// 4. Decrypt with caller-supplied AAD
let cipher = ChaCha20Poly1305::new(&aead_key);
let aead_payload = chacha20poly1305::aead::Payload { msg: aead_ct, aad };
let plaintext = cipher
.decrypt(nonce, aead_payload)
.map_err(|_| HybridKemError::DecryptionFailed)?;
Ok(plaintext)
}
/// Encapsulate only: compute shared secret and KEM output (no AEAD).
/// Returns `(kem_output, shared_secret)` where `kem_output` is the first
/// `HYBRID_KEM_OUTPUT_LEN` bytes of the hybrid envelope and `shared_secret`
/// is the 32-byte derived key (same as used for AEAD in `hybrid_encrypt`).
/// Used by MLS HPKE exporter (setup_sender_and_export).
pub fn hybrid_encapsulate_only(
recipient_pk: &HybridPublicKey,
) -> Result<(Vec<u8>, [u8; 32]), HybridKemError> {
let eph_secret = EphemeralSecret::random_from_rng(OsRng);
let eph_public = X25519Public::from(&eph_secret);
let x25519_recipient = X25519Public::from(recipient_pk.x25519_pk);
let x25519_ss = eph_secret.diffie_hellman(&x25519_recipient);
let mlkem_ek_arr = Array::try_from(recipient_pk.mlkem_ek.as_slice())
.map_err(|_| HybridKemError::InvalidMlKemKey)?;
let mlkem_ek = EncapsulationKey::<MlKem768Params>::from_bytes(&mlkem_ek_arr);
let (mlkem_ct, mlkem_ss) = mlkem_ek
.encapsulate(&mut OsRng)
.map_err(|_| HybridKemError::EncryptionFailed)?;
let aead_key = derive_aead_key(x25519_ss.as_bytes(), mlkem_ss.as_slice(), b"");
let shared_secret: [u8; 32] = aead_key.as_slice().try_into()
.expect("AEAD key is always exactly 32 bytes");
let mut kem_output = Vec::with_capacity(HYBRID_KEM_OUTPUT_LEN);
kem_output.push(HYBRID_VERSION);
kem_output.extend_from_slice(&eph_public.to_bytes());
kem_output.extend_from_slice(mlkem_ct.as_slice());
Ok((kem_output, shared_secret))
}
/// Decapsulate only: recover shared secret from KEM output (no AEAD).
/// Used by MLS HPKE exporter (setup_receiver_and_export).
pub fn hybrid_decapsulate_only(
keypair: &HybridKeypair,
kem_output: &[u8],
) -> Result<[u8; 32], HybridKemError> {
if kem_output.len() < HYBRID_KEM_OUTPUT_LEN {
return Err(HybridKemError::TooShort(kem_output.len()));
}
if kem_output[0] != HYBRID_VERSION {
return Err(HybridKemError::UnsupportedVersion(kem_output[0]));
}
let eph_pk_bytes: [u8; 32] = kem_output[1..33].try_into()
.expect("slice is exactly 32 bytes (guaranteed by HYBRID_KEM_OUTPUT_LEN check)");
let eph_pk = X25519Public::from(eph_pk_bytes);
let x25519_ss = keypair.x25519_sk.diffie_hellman(&eph_pk);
let mlkem_ct_arr = Array::try_from(&kem_output[33..33 + MLKEM_CT_LEN])
.map_err(|_| HybridKemError::MlKemDecapsFailed)?;
let mlkem_ss = keypair
.mlkem_dk
.decapsulate(&mlkem_ct_arr)
.map_err(|_| HybridKemError::MlKemDecapsFailed)?;
let aead_key = derive_aead_key(x25519_ss.as_bytes(), mlkem_ss.as_slice(), b"");
Ok(aead_key.as_slice().try_into()
.expect("AEAD key is always exactly 32 bytes"))
}
/// Export a secret from shared secret (MLS HPKE exporter compatibility).
/// Uses HKDF-Expand(prk, exporter_context, length) with prk = HKDF-Extract(0, shared_secret).
pub fn hybrid_export(
shared_secret: &[u8; 32],
exporter_context: &[u8],
length: usize,
) -> Vec<u8> {
let hk = Hkdf::<Sha256>::new(Some(HKDF_SALT), shared_secret);
let mut out = vec![0u8; length];
hk.expand(exporter_context, &mut out).expect("valid length");
out
}
/// Derive AEAD key from the combined X25519 + ML-KEM shared secrets.
///
/// `extra_info` is optional caller-supplied context (e.g. HPKE `info`) that is
/// appended to the domain-separation label for additional binding.
///
/// 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], extra_info: &[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);
let hk = Hkdf::<Sha256>::new(Some(HKDF_SALT), &ikm);
// Combine domain-separation label with caller-supplied context.
let mut info = Vec::with_capacity(HKDF_INFO.len() + extra_info.len());
info.extend_from_slice(HKDF_INFO);
info.extend_from_slice(extra_info);
let mut key_bytes = Zeroizing::new([0u8; 32]);
hk.expand(&info, &mut *key_bytes)
.expect("32 bytes is valid HKDF-SHA256 output length");
*Key::from_slice(&*key_bytes)
}
// ── Tests ───────────────────────────────────────────────────────────────────
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn keygen_produces_valid_public_key() {
let kp = HybridKeypair::generate();
let pk = kp.public_key();
assert_eq!(pk.x25519_pk.len(), 32);
assert_eq!(pk.mlkem_ek.len(), MLKEM_EK_LEN);
}
#[test]
fn encrypt_decrypt_round_trip() {
let kp = HybridKeypair::generate();
let pk = kp.public_key();
let plaintext = b"hello post-quantum world!";
let envelope = hybrid_encrypt(&pk, plaintext, b"", b"").unwrap();
let recovered = hybrid_decrypt(&kp, &envelope, b"", b"").unwrap();
assert_eq!(recovered, plaintext);
}
#[test]
fn encrypt_decrypt_with_info_aad() {
let kp = HybridKeypair::generate();
let pk = kp.public_key();
let plaintext = b"context-bound payload";
let info = b"mls epoch 42";
let aad = b"group-id-abc";
let envelope = hybrid_encrypt(&pk, plaintext, info, aad).unwrap();
let recovered = hybrid_decrypt(&kp, &envelope, info, aad).unwrap();
assert_eq!(recovered, plaintext);
// Mismatched info must fail
assert!(hybrid_decrypt(&kp, &envelope, b"wrong info", aad).is_err());
// Mismatched aad must fail
assert!(hybrid_decrypt(&kp, &envelope, info, b"wrong aad").is_err());
}
#[test]
fn wrong_key_decryption_fails() {
let kp_sender_target = HybridKeypair::generate();
let kp_wrong = HybridKeypair::generate();
let pk = kp_sender_target.public_key();
let envelope = hybrid_encrypt(&pk, b"secret", b"", b"").unwrap();
let result = hybrid_decrypt(&kp_wrong, &envelope, b"", b"");
assert!(result.is_err());
}
#[test]
fn tampered_aead_ciphertext_fails() {
let kp = HybridKeypair::generate();
let pk = kp.public_key();
let mut envelope = hybrid_encrypt(&pk, b"payload", b"", b"").unwrap();
let last = envelope.len() - 1;
envelope[last] ^= 0x01;
assert!(matches!(
hybrid_decrypt(&kp, &envelope, b"", b""),
Err(HybridKemError::DecryptionFailed)
));
}
#[test]
fn tampered_mlkem_ct_fails() {
let kp = HybridKeypair::generate();
let pk = kp.public_key();
let mut envelope = hybrid_encrypt(&pk, b"payload", b"", b"").unwrap();
// Flip a byte in the ML-KEM ciphertext region (starts at offset 33)
envelope[40] ^= 0xFF;
assert!(hybrid_decrypt(&kp, &envelope, b"", b"").is_err());
}
#[test]
fn tampered_x25519_eph_pk_fails() {
let kp = HybridKeypair::generate();
let pk = kp.public_key();
let mut envelope = hybrid_encrypt(&pk, b"payload", b"", b"").unwrap();
// Flip a byte in the X25519 ephemeral pk region (offset 1..33)
envelope[5] ^= 0xFF;
assert!(hybrid_decrypt(&kp, &envelope, b"", b"").is_err());
}
#[test]
fn unsupported_version_rejected() {
let kp = HybridKeypair::generate();
let pk = kp.public_key();
let mut envelope = hybrid_encrypt(&pk, b"payload", b"", b"").unwrap();
envelope[0] = 0xFF;
assert!(matches!(
hybrid_decrypt(&kp, &envelope, b"", b""),
Err(HybridKemError::UnsupportedVersion(0xFF))
));
}
#[test]
fn envelope_too_short_rejected() {
let kp = HybridKeypair::generate();
assert!(matches!(
hybrid_decrypt(&kp, &[0x01; 10], b"", b""),
Err(HybridKemError::TooShort(10))
));
}
#[test]
fn keypair_serialisation_round_trip() {
let kp = HybridKeypair::generate();
let bytes = kp.to_bytes();
let restored = HybridKeypair::from_bytes(&bytes).unwrap();
assert_eq!(kp.x25519_pk.to_bytes(), restored.x25519_pk.to_bytes());
assert_eq!(kp.public_key().mlkem_ek, restored.public_key().mlkem_ek);
// Verify restored keypair can decrypt
let pk = kp.public_key();
let ct = hybrid_encrypt(&pk, b"test", b"", b"").unwrap();
let pt = hybrid_decrypt(&restored, &ct, b"", b"").unwrap();
assert_eq!(pt, b"test");
}
#[test]
fn public_key_serialisation_round_trip() {
let kp = HybridKeypair::generate();
let pk = kp.public_key();
let bytes = pk.to_bytes();
let restored = HybridPublicKey::from_bytes(&bytes).unwrap();
assert_eq!(pk.x25519_pk, restored.x25519_pk);
assert_eq!(pk.mlkem_ek, restored.mlkem_ek);
}
#[test]
fn large_payload_round_trip() {
let kp = HybridKeypair::generate();
let pk = kp.public_key();
let plaintext = vec![0xAB; 50_000]; // 50 KB
let envelope = hybrid_encrypt(&pk, &plaintext, b"", b"").unwrap();
let recovered = hybrid_decrypt(&kp, &envelope, b"", b"").unwrap();
assert_eq!(recovered, plaintext);
}
}

View File

@@ -0,0 +1,161 @@
//! Ed25519 identity keypair for MLS credentials and AS registration.
//!
//! The [`IdentityKeypair`] is the long-term identity key embedded in MLS
//! `BasicCredential`s. It is used for signing MLS messages and as the
//! indexing key for the Authentication Service.
//!
//! # Zeroize
//!
//! The 32-byte private seed is stored as `Zeroizing<[u8; 32]>`, which zeroes
//! the bytes on drop. `[u8; 32]` is `Copy + Default` and satisfies zeroize's
//! `DefaultIsZeroes` constraint, avoiding a conflict with ed25519-dalek's
//! `SigningKey` zeroize impl.
//!
//! # Fingerprint
//!
//! A 32-byte SHA-256 digest of the raw public key bytes is used as a compact,
//! collision-resistant identifier for logging.
use ed25519_dalek::{Signer as DalekSigner, SigningKey, VerifyingKey};
use openmls_traits::signatures::Signer;
use openmls_traits::types::{Error as MlsError, SignatureScheme};
use serde::{Deserialize, Serialize};
use sha2::{Digest, Sha256};
use zeroize::Zeroizing;
/// An Ed25519 identity keypair.
///
/// Created with [`IdentityKeypair::generate`]. The private signing key seed
/// is zeroed when this struct is dropped.
pub struct IdentityKeypair {
/// Raw 32-byte private seed — zeroized on drop.
///
/// Stored as bytes rather than `SigningKey` to satisfy zeroize's
/// `DefaultIsZeroes` bound on `Zeroizing<T>`.
seed: Zeroizing<[u8; 32]>,
/// Corresponding 32-byte public verifying key.
verifying: VerifyingKey,
}
impl IdentityKeypair {
/// Recreate an identity keypair from a 32-byte seed.
pub fn from_seed(seed: [u8; 32]) -> Self {
let signing = SigningKey::from_bytes(&seed);
let verifying = signing.verifying_key();
Self {
seed: Zeroizing::new(seed),
verifying,
}
}
/// Return the raw 32-byte private seed (for persistence).
pub fn seed_bytes(&self) -> [u8; 32] {
*self.seed
}
}
impl IdentityKeypair {
/// Generate a fresh random Ed25519 identity keypair.
pub fn generate() -> Self {
use rand::rngs::OsRng;
let signing = SigningKey::generate(&mut OsRng);
let verifying = signing.verifying_key();
let seed = Zeroizing::new(signing.to_bytes());
Self { seed, verifying }
}
/// Return the raw 32-byte Ed25519 public key.
///
/// This is the byte array used as `identityKey` in `auth.capnp` calls.
pub fn public_key_bytes(&self) -> [u8; 32] {
self.verifying.to_bytes()
}
/// Return the SHA-256 fingerprint of the public key (32 bytes).
pub fn fingerprint(&self) -> [u8; 32] {
let mut hasher = Sha256::new();
hasher.update(self.verifying.to_bytes());
hasher.finalize().into()
}
/// Reconstruct the `SigningKey` from the stored seed bytes.
fn signing_key(&self) -> SigningKey {
SigningKey::from_bytes(&self.seed)
}
}
/// Implement the openmls `Signer` trait so `IdentityKeypair` can be passed
/// directly to `KeyPackage::builder().build(...)` without needing the external
/// `openmls_basic_credential` crate.
impl Signer for IdentityKeypair {
fn sign(&self, payload: &[u8]) -> Result<Vec<u8>, MlsError> {
let sk = self.signing_key();
let sig: ed25519_dalek::Signature = sk.sign(payload);
Ok(sig.to_bytes().to_vec())
}
fn signature_scheme(&self) -> SignatureScheme {
SignatureScheme::ED25519
}
}
impl IdentityKeypair {
/// Sign arbitrary bytes with the Ed25519 key and return the 64-byte signature.
///
/// Used by sealed sender to sign the inner payload for recipient verification.
pub fn sign_raw(&self, payload: &[u8]) -> [u8; 64] {
let sk = self.signing_key();
let sig: ed25519_dalek::Signature = sk.sign(payload);
sig.to_bytes()
}
/// Verify an Ed25519 signature over `payload` using the given public key.
pub fn verify_raw(
public_key: &[u8; 32],
payload: &[u8],
signature: &[u8; 64],
) -> Result<(), crate::error::CoreError> {
use ed25519_dalek::Verifier;
let vk = VerifyingKey::from_bytes(public_key)
.map_err(|e| crate::error::CoreError::Mls(format!("invalid public key: {e}")))?;
let sig = ed25519_dalek::Signature::from_bytes(signature);
vk.verify(payload, &sig)
.map_err(|e| crate::error::CoreError::Mls(format!("signature verification failed: {e}")))
}
}
impl Serialize for IdentityKeypair {
fn serialize<S>(&self, serializer: S) -> Result<S::Ok, S::Error>
where
S: serde::Serializer,
{
serializer.serialize_bytes(&self.seed[..])
}
}
impl<'de> Deserialize<'de> for IdentityKeypair {
fn deserialize<D>(deserializer: D) -> Result<Self, D::Error>
where
D: serde::Deserializer<'de>,
{
let bytes: Vec<u8> = serde::Deserialize::deserialize(deserializer)?;
let seed: [u8; 32] = bytes
.as_slice()
.try_into()
.map_err(|_| serde::de::Error::custom("identity seed must be 32 bytes"))?;
Ok(IdentityKeypair::from_seed(seed))
}
}
impl std::fmt::Debug for IdentityKeypair {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
let fp = self.fingerprint();
f.debug_struct("IdentityKeypair")
.field(
"fingerprint",
&format!("{:02x}{:02x}{:02x}{:02x}", fp[0], fp[1], fp[2], fp[3]),
)
.finish_non_exhaustive()
}
}

View File

@@ -0,0 +1,109 @@
//! MLS KeyPackage generation and TLS serialisation.
//!
//! # Ciphersuite
//!
//! `MLS_128_DHKEMX25519_AES128GCM_SHA256_Ed25519` (ciphersuite ID `0x0001`).
//! This is the RECOMMENDED ciphersuite from RFC 9420 §17.1.
//!
//! # Single-use semantics
//!
//! Per RFC 9420 §10.1, each KeyPackage MUST be used at most once. The
//! Authentication Service enforces this by atomically removing a package on
//! fetch.
//!
//! # Wire format
//!
//! KeyPackages are TLS-encoded using `tls_codec` (same version as openmls).
//! The resulting bytes are opaque to the quicproquo transport layer.
use openmls::prelude::{
Ciphersuite, Credential, CredentialType, CredentialWithKey, CryptoConfig, KeyPackage,
KeyPackageIn, TlsDeserializeTrait, TlsSerializeTrait,
};
use openmls_rust_crypto::OpenMlsRustCrypto;
use sha2::{Digest, Sha256};
use crate::{error::CoreError, identity::IdentityKeypair};
/// The MLS ciphersuite used throughout quicproquo (RFC 9420 §17.1).
pub const ALLOWED_CIPHERSUITE: Ciphersuite =
Ciphersuite::MLS_128_DHKEMX25519_AES128GCM_SHA256_Ed25519;
/// Wire value of the allowed ciphersuite (KeyPackage TLS encoding: version 2B, ciphersuite 2B).
const ALLOWED_CIPHERSUITE_WIRE: u16 = 0x0001;
const CIPHERSUITE: Ciphersuite = ALLOWED_CIPHERSUITE;
/// Validates that the KeyPackage bytes use an allowed ciphersuite (Phase 2: ciphersuite allowlist).
///
/// Parses the TLS-encoded KeyPackage and rejects if the ciphersuite is not
/// `MLS_128_DHKEMX25519_AES128GCM_SHA256_Ed25519`. Does not verify signatures;
/// the server uses this only to enforce policy before storing.
pub fn validate_keypackage_ciphersuite(bytes: &[u8]) -> Result<(), CoreError> {
if bytes.len() < 4 {
return Err(CoreError::Mls("KeyPackage too short for version+ciphersuite".into()));
}
let cs_wire = u16::from_be_bytes([bytes[2], bytes[3]]);
if cs_wire != ALLOWED_CIPHERSUITE_WIRE {
return Err(CoreError::Mls(format!(
"KeyPackage ciphersuite {:#06x} not in allowlist (only {:#06x} allowed)",
cs_wire, ALLOWED_CIPHERSUITE_WIRE
)));
}
// Optionally confirm full parse so we don't accept garbage that happens to have 0x0001 at offset 2.
let mut cursor = bytes;
let _kp = KeyPackageIn::tls_deserialize(&mut cursor)
.map_err(|e| CoreError::Mls(format!("KeyPackage parse: {e:?}")))?;
Ok(())
}
/// Generate a fresh MLS KeyPackage for `identity` and serialise it.
///
/// # Returns
///
/// `(tls_bytes, sha256_fingerprint)` where:
/// - `tls_bytes` is the TLS-encoded KeyPackage blob, suitable for uploading.
/// - `sha256_fingerprint` is the SHA-256 digest of `tls_bytes` for tamper detection.
///
/// # Errors
///
/// Returns [`CoreError::Mls`] if openmls fails to create the KeyPackage or if
/// TLS serialisation fails.
pub fn generate_key_package(identity: &IdentityKeypair) -> Result<(Vec<u8>, Vec<u8>), CoreError> {
let backend = OpenMlsRustCrypto::default();
// Build a BasicCredential using the raw Ed25519 public key bytes as the
// MLS identity. Per RFC 9420, any byte string may serve as the identity.
let credential = Credential::new(identity.public_key_bytes().to_vec(), CredentialType::Basic)
.map_err(|e| CoreError::Mls(format!("{e:?}")))?;
// The `signature_key` in CredentialWithKey is the Ed25519 public key that
// will be used to verify the KeyPackage's leaf node signature.
// `SignaturePublicKey` implements `From<Vec<u8>>`.
let credential_with_key = CredentialWithKey {
credential,
signature_key: identity.public_key_bytes().to_vec().into(),
};
// `IdentityKeypair` implements `openmls_traits::signatures::Signer`
// so it can be passed directly to the builder.
let key_package = KeyPackage::builder()
.build(
CryptoConfig::with_default_version(CIPHERSUITE),
&backend,
identity,
credential_with_key,
)
.map_err(|e| CoreError::Mls(format!("{e:?}")))?;
// TLS-encode the KeyPackage using the trait from the openmls prelude.
// This uses tls_codec 0.3 (the same version openmls uses internally),
// avoiding a duplicate-trait conflict with tls_codec 0.4.
let tls_bytes = key_package
.tls_serialize_detached()
.map_err(|e| CoreError::Mls(format!("{e:?}")))?;
let fingerprint: Vec<u8> = Sha256::digest(&tls_bytes).to_vec();
Ok((tls_bytes, fingerprint))
}

View File

@@ -0,0 +1,147 @@
use std::{
collections::HashMap,
fs,
path::{Path, PathBuf},
sync::RwLock,
};
use openmls_rust_crypto::RustCrypto;
use openmls_traits::{
key_store::{MlsEntity, OpenMlsKeyStore},
OpenMlsCryptoProvider,
};
/// A disk-backed key store implementing `OpenMlsKeyStore`.
///
/// In-memory when `path` is `None`; otherwise flushes the entire map to disk on
/// every store/delete so HPKE init keys survive process restarts.
#[derive(Debug)]
pub struct DiskKeyStore {
path: Option<PathBuf>,
values: RwLock<HashMap<Vec<u8>, Vec<u8>>>,
}
#[derive(thiserror::Error, Debug, PartialEq, Eq)]
pub enum DiskKeyStoreError {
#[error("serialization error")]
Serialization,
#[error("io error: {0}")]
Io(String),
}
impl DiskKeyStore {
/// In-memory keystore (no persistence).
pub fn ephemeral() -> Self {
Self {
path: None,
values: RwLock::new(HashMap::new()),
}
}
/// Persistent keystore backed by `path`. Creates an empty store if missing.
pub fn persistent(path: impl AsRef<Path>) -> Result<Self, DiskKeyStoreError> {
let path = path.as_ref().to_path_buf();
let values = if path.exists() {
let bytes = fs::read(&path).map_err(|e| DiskKeyStoreError::Io(e.to_string()))?;
if bytes.is_empty() {
HashMap::new()
} else {
bincode::deserialize(&bytes).map_err(|_| DiskKeyStoreError::Serialization)?
}
} else {
HashMap::new()
};
Ok(Self {
path: Some(path),
values: RwLock::new(values),
})
}
fn flush(&self) -> Result<(), DiskKeyStoreError> {
let Some(path) = &self.path else {
return Ok(());
};
let values = self.values.read().map_err(|_| DiskKeyStoreError::Io("lock poisoned".into()))?;
let bytes = bincode::serialize(&*values).map_err(|_| DiskKeyStoreError::Serialization)?;
if let Some(parent) = path.parent() {
fs::create_dir_all(parent).map_err(|e| DiskKeyStoreError::Io(e.to_string()))?;
}
fs::write(path, bytes).map_err(|e| DiskKeyStoreError::Io(e.to_string()))
}
}
impl Default for DiskKeyStore {
fn default() -> Self {
Self::ephemeral()
}
}
impl OpenMlsKeyStore for DiskKeyStore {
type Error = DiskKeyStoreError;
fn store<V: MlsEntity>(&self, k: &[u8], v: &V) -> Result<(), Self::Error> {
let value = serde_json::to_vec(v).map_err(|_| DiskKeyStoreError::Serialization)?;
let mut values = self.values.write().map_err(|_| DiskKeyStoreError::Io("lock poisoned".into()))?;
values.insert(k.to_vec(), value);
drop(values);
self.flush()
}
fn read<V: MlsEntity>(&self, k: &[u8]) -> Option<V> {
let values = match self.values.read() {
Ok(v) => v,
Err(_) => return None,
};
values
.get(k)
.and_then(|bytes| serde_json::from_slice(bytes).ok())
}
fn delete<V: MlsEntity>(&self, k: &[u8]) -> Result<(), Self::Error> {
let mut values = self.values.write().map_err(|_| DiskKeyStoreError::Io("lock poisoned".into()))?;
values.remove(k);
drop(values);
self.flush()
}
}
/// Crypto provider that couples RustCrypto with a disk-backed key store.
#[derive(Debug)]
pub struct StoreCrypto {
crypto: RustCrypto,
key_store: DiskKeyStore,
}
impl StoreCrypto {
pub fn new(key_store: DiskKeyStore) -> Self {
Self {
crypto: RustCrypto::default(),
key_store,
}
}
}
impl Default for StoreCrypto {
fn default() -> Self {
Self::new(DiskKeyStore::ephemeral())
}
}
impl OpenMlsCryptoProvider for StoreCrypto {
type CryptoProvider = RustCrypto;
type RandProvider = RustCrypto;
type KeyStoreProvider = DiskKeyStore;
fn crypto(&self) -> &Self::CryptoProvider {
&self.crypto
}
fn rand(&self) -> &Self::RandProvider {
&self.crypto
}
fn key_store(&self) -> &Self::KeyStoreProvider {
&self.key_store
}
}

View File

@@ -0,0 +1,43 @@
//! Core cryptographic primitives, MLS group state machine, and hybrid
//! post-quantum KEM for quicproquo.
//!
//! # Module layout
//!
//! | Module | Responsibility |
//! |---------------|------------------------------------------------------------------|
//! | `app_message` | Rich application payload (Chat, Reply, Reaction, ReadReceipt, Typing) |
//! | `error` | [`CoreError`] type |
//! | `identity` | [`IdentityKeypair`] — Ed25519 identity key for MLS credentials |
//! | `keypackage` | [`generate_key_package`] — standalone KeyPackage generation |
//! | `group` | [`GroupMember`] — MLS group lifecycle (create/join/send/recv) |
//! | `hybrid_kem` | Hybrid X25519 + ML-KEM-768 key encapsulation |
//! | `keystore` | [`DiskKeyStore`] — OpenMLS key store with optional persistence |
mod app_message;
mod error;
mod group;
mod hybrid_crypto;
mod hybrid_kem;
mod identity;
mod keypackage;
mod keystore;
pub mod opaque_auth;
pub mod padding;
pub mod sealed_sender;
// ── Public API ────────────────────────────────────────────────────────────────
pub use app_message::{
serialize, serialize_chat, serialize_reaction, serialize_read_receipt, serialize_reply,
serialize_typing, parse, generate_message_id, AppMessage, MessageType, VERSION as APP_MESSAGE_VERSION,
};
pub use error::CoreError;
pub use group::GroupMember;
pub use hybrid_kem::{
hybrid_decrypt, hybrid_encrypt, HybridKemError, HybridKeypair, HybridKeypairBytes,
HybridPublicKey,
};
pub use hybrid_crypto::{HybridCrypto, HybridCryptoProvider};
pub use identity::IdentityKeypair;
pub use keypackage::{generate_key_package, validate_keypackage_ciphersuite};
pub use keystore::DiskKeyStore;

View File

@@ -0,0 +1,20 @@
//! 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 quicproquo.
///
/// - **OPRF**: Ristretto255 (curve25519-based, ~128-bit security)
/// - **Key exchange**: Triple-DH (3DH) over Ristretto255 with SHA-512
/// - **KSF**: Argon2id (memory-hard key stretching)
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 = argon2::Argon2<'static>;
}

View File

@@ -0,0 +1,144 @@
//! Message padding to hide plaintext lengths from the server.
//!
//! Pads payloads to fixed bucket sizes before MLS encryption so that the
//! ciphertext does not reveal the actual message length.
//!
//! # Wire format
//!
//! ```text
//! [real_length: 4 bytes LE (u32)][payload: real_length bytes][random padding]
//! ```
//!
//! The total padded output is always one of the bucket sizes: 256, 1024, 4096, 16384 bytes.
//! For payloads larger than 16380 bytes, rounds up to the nearest 16384-byte multiple.
use rand::RngCore;
use crate::error::CoreError;
/// Bucket sizes in bytes. The smallest (256) accommodates a sealed sender
/// envelope (99 bytes overhead) plus a short message.
const BUCKETS: &[usize] = &[256, 1024, 4096, 16384];
/// Select the smallest bucket that fits `content_len + 4` (the 4-byte length prefix).
fn bucket_for(content_len: usize) -> usize {
let total = content_len + 4;
for &b in BUCKETS {
if total <= b {
return b;
}
}
// Larger than biggest bucket: round up to nearest 16384-byte multiple.
((total + 16383) / 16384) * 16384
}
/// Pad a payload to the next bucket boundary with cryptographic random bytes.
pub fn pad(payload: &[u8]) -> Vec<u8> {
let bucket = bucket_for(payload.len());
let mut out = Vec::with_capacity(bucket);
out.extend_from_slice(&(payload.len() as u32).to_le_bytes());
out.extend_from_slice(payload);
let pad_len = bucket - 4 - payload.len();
if pad_len > 0 {
let mut padding = vec![0u8; pad_len];
rand::rngs::OsRng.fill_bytes(&mut padding);
out.extend_from_slice(&padding);
}
out
}
/// Remove padding and return the original payload.
pub fn unpad(padded: &[u8]) -> Result<Vec<u8>, CoreError> {
if padded.len() < 4 {
return Err(CoreError::AppMessage("padded message too short".into()));
}
let real_len = u32::from_le_bytes([padded[0], padded[1], padded[2], padded[3]]) as usize;
if 4 + real_len > padded.len() {
return Err(CoreError::AppMessage(
"padded real_length exceeds buffer".into(),
));
}
Ok(padded[4..4 + real_len].to_vec())
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn round_trip_small() {
let msg = b"hello";
let padded = pad(msg);
assert_eq!(padded.len(), 256); // smallest bucket
let unpadded = unpad(&padded).unwrap();
assert_eq!(unpadded, msg);
}
#[test]
fn round_trip_medium() {
let msg = vec![0xAB; 300];
let padded = pad(&msg);
assert_eq!(padded.len(), 1024); // second bucket
let unpadded = unpad(&padded).unwrap();
assert_eq!(unpadded, msg);
}
#[test]
fn round_trip_large() {
let msg = vec![0xCD; 2000];
let padded = pad(&msg);
assert_eq!(padded.len(), 4096); // third bucket
let unpadded = unpad(&padded).unwrap();
assert_eq!(unpadded, msg);
}
#[test]
fn round_trip_very_large() {
let msg = vec![0xEF; 10000];
let padded = pad(&msg);
assert_eq!(padded.len(), 16384); // largest bucket
let unpadded = unpad(&padded).unwrap();
assert_eq!(unpadded, msg);
}
#[test]
fn round_trip_oversized() {
let msg = vec![0xFF; 20000];
let padded = pad(&msg);
assert_eq!(padded.len(), 32768); // 2 * 16384
let unpadded = unpad(&padded).unwrap();
assert_eq!(unpadded, msg);
}
#[test]
fn round_trip_empty() {
let msg = b"";
let padded = pad(msg);
assert_eq!(padded.len(), 256); // smallest bucket
let unpadded = unpad(&padded).unwrap();
assert_eq!(unpadded, msg);
}
#[test]
fn exactly_at_bucket_boundary() {
// 252 + 4 = 256 → fits in 256 bucket exactly
let msg = vec![0x42; 252];
let padded = pad(&msg);
assert_eq!(padded.len(), 256);
let unpadded = unpad(&padded).unwrap();
assert_eq!(unpadded, msg);
}
#[test]
fn unpad_too_short_fails() {
assert!(unpad(&[0, 0]).is_err());
}
#[test]
fn unpad_invalid_length_fails() {
// Claims 1000 bytes but only has 10
let mut bad = (1000u32).to_le_bytes().to_vec();
bad.extend_from_slice(&[0u8; 10]);
assert!(unpad(&bad).is_err());
}
}

View File

@@ -0,0 +1,154 @@
//! Sealed sender: embed sender identity + Ed25519 signature inside the MLS
//! application payload so recipients can verify the sender from decrypted
//! content, independent of MLS framing.
//!
//! # Wire format
//!
//! ```text
//! [magic: 1 byte (0x53 = 'S')]
//! [sender_identity_key: 32 bytes (Ed25519 public key)]
//! [signature: 64 bytes (Ed25519)]
//! [inner_payload: variable (the original app_message bytes)]
//! ```
//!
//! The signature covers: `magic || sender_identity_key || inner_payload`.
//! Total overhead: 1 + 32 + 64 = 97 bytes per message.
use crate::error::CoreError;
use crate::identity::IdentityKeypair;
/// Magic byte identifying a sealed sender envelope.
pub const SEALED_MAGIC: u8 = 0x53; // 'S'
/// Fixed overhead: magic(1) + sender_key(32) + signature(64).
const SEALED_OVERHEAD: usize = 1 + 32 + 64;
/// Wrap an app_message payload in a sealed sender envelope.
///
/// Signs `magic || sender_key || payload` with the sender's Ed25519 key.
pub fn seal(identity: &IdentityKeypair, app_message_bytes: &[u8]) -> Vec<u8> {
let sender_key = identity.public_key_bytes();
// Build signing input
let mut sign_input = Vec::with_capacity(1 + 32 + app_message_bytes.len());
sign_input.push(SEALED_MAGIC);
sign_input.extend_from_slice(&sender_key);
sign_input.extend_from_slice(app_message_bytes);
let signature = identity.sign_raw(&sign_input);
let mut out = Vec::with_capacity(SEALED_OVERHEAD + app_message_bytes.len());
out.push(SEALED_MAGIC);
out.extend_from_slice(&sender_key);
out.extend_from_slice(&signature);
out.extend_from_slice(app_message_bytes);
out
}
/// Unseal: verify the Ed25519 signature, return `(sender_identity_key, inner_app_message_bytes)`.
pub fn unseal(bytes: &[u8]) -> Result<([u8; 32], Vec<u8>), CoreError> {
if bytes.len() < SEALED_OVERHEAD {
return Err(CoreError::AppMessage(
"sealed sender envelope too short".into(),
));
}
if bytes[0] != SEALED_MAGIC {
return Err(CoreError::AppMessage(format!(
"sealed sender: expected magic 0x{:02X}, got 0x{:02X}",
SEALED_MAGIC, bytes[0]
)));
}
let mut sender_key = [0u8; 32];
sender_key.copy_from_slice(&bytes[1..33]);
let mut signature = [0u8; 64];
signature.copy_from_slice(&bytes[33..97]);
let inner_payload = &bytes[97..];
// Reconstruct signing input: magic || sender_key || inner_payload
let mut sign_input = Vec::with_capacity(1 + 32 + inner_payload.len());
sign_input.push(SEALED_MAGIC);
sign_input.extend_from_slice(&sender_key);
sign_input.extend_from_slice(inner_payload);
IdentityKeypair::verify_raw(&sender_key, &sign_input, &signature)?;
Ok((sender_key, inner_payload.to_vec()))
}
/// Check if bytes start with the sealed sender magic byte.
pub fn is_sealed(bytes: &[u8]) -> bool {
bytes.first() == Some(&SEALED_MAGIC)
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn seal_unseal_round_trip() {
let identity = IdentityKeypair::generate();
let payload = b"hello sealed sender";
let sealed = seal(&identity, payload);
assert!(is_sealed(&sealed));
let (sender_key, inner) = unseal(&sealed).unwrap();
assert_eq!(sender_key, identity.public_key_bytes());
assert_eq!(inner, payload);
}
#[test]
fn unseal_tampered_payload_fails() {
let identity = IdentityKeypair::generate();
let payload = b"hello";
let mut sealed = seal(&identity, payload);
// Tamper with the inner payload
if let Some(last) = sealed.last_mut() {
*last ^= 0xFF;
}
assert!(unseal(&sealed).is_err());
}
#[test]
fn unseal_wrong_sender_fails() {
let alice = IdentityKeypair::generate();
let bob = IdentityKeypair::generate();
let payload = b"from alice";
let mut sealed = seal(&alice, payload);
// Replace sender key with Bob's
let bob_key = bob.public_key_bytes();
sealed[1..33].copy_from_slice(&bob_key);
assert!(unseal(&sealed).is_err());
}
#[test]
fn unseal_too_short_fails() {
assert!(unseal(&[SEALED_MAGIC; 10]).is_err());
}
#[test]
fn unseal_wrong_magic_fails() {
let identity = IdentityKeypair::generate();
let mut sealed = seal(&identity, b"test");
sealed[0] = 0x00;
assert!(unseal(&sealed).is_err());
}
#[test]
fn non_sealed_detected() {
assert!(!is_sealed(b"\x01\x01hello"));
assert!(is_sealed(&[SEALED_MAGIC, 0, 0]));
}
#[test]
fn empty_payload_round_trip() {
let identity = IdentityKeypair::generate();
let sealed = seal(&identity, b"");
let (sender_key, inner) = unseal(&sealed).unwrap();
assert_eq!(sender_key, identity.public_key_bytes());
assert!(inner.is_empty());
}
}