feat: add delivery sequence numbers + major server/client refactor
Delivery sequence numbers (MLS epoch ordering fix):
- schemas/node.capnp: add Envelope{seq,data} struct; enqueue returns seq:UInt64;
fetch/fetchWait return List(Envelope) instead of List(Data)
- storage.rs: Store trait enqueue returns u64; fetch/fetch_limited return
Vec<(u64, Vec<u8>)>; FileBackedStore gains QueueMapV3 with per-inbox seq
counters and V2→V3 on-disk migration
- migrations/002_add_seq.sql: seq column, delivery_seq_counters table, index
- sql_store.rs: atomic UPSERT counter via RETURNING, ORDER BY seq, SCHEMA_VERSION→3
- node_service/delivery.rs: builds Envelope list; returns seq from enqueue
- client/rpc.rs: enqueue→u64, fetch_all/fetch_wait→Vec<(u64,Vec<u8>)>
- client/commands.rs: sort-by-seq before MLS processing; retry loop in cmd_recv
and receive_pending_plaintexts for correct epoch ordering
Server refactor:
- Split monolithic main.rs into node_service/{mod,delivery,auth_ops,key_ops,p2p_ops}
- Add auth.rs (token validation, rate limiting), config.rs, metrics.rs, tls.rs
- Add SQL migrations runner (001_initial.sql, 002_add_seq.sql)
- OPAQUE PAKE login/registration, sealed-sender mode, queue depth limit (1000)
Client refactor:
- Split lib.rs into client/{commands,rpc,state,retry,hex,mod}
- Add cmd_whoami, cmd_health, cmd_check_key, cmd_ping subcommands
- Add cmd_register_user, cmd_login (OPAQUE), cmd_refresh_keypackage
- Hybrid PQ envelope (X25519 + ML-KEM-768) on all send/recv paths
- E2E test suite expanded
Other:
- quicnprotochat-gui: Tauri 2 desktop GUI skeleton (backend + HTML UI)
- quicnprotochat-p2p: iroh-based P2P transport stub
- quicnprotochat-core: app_message, hybrid_crypto modules; GroupMember API updates
- .github/workflows/size-lint.yml: binary size regression check
- docs: protocol comparison, roadmap updates, fully-operational checklist
This commit is contained in:
258
crates/quicnprotochat-core/src/app_message.rs
Normal file
258
crates/quicnprotochat-core/src/app_message.rs
Normal file
@@ -0,0 +1,258 @@
|
||||
//! 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]>) -> Vec<u8> {
|
||||
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);
|
||||
serialize(MessageType::Chat, &payload)
|
||||
}
|
||||
|
||||
/// Serialize a Reply message.
|
||||
pub fn serialize_reply(ref_msg_id: [u8; 16], body: &[u8]) -> Vec<u8> {
|
||||
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);
|
||||
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);
|
||||
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);
|
||||
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"),
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user