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

@@ -1,5 +0,0 @@
//! Desktop entry point for quicnprotochat-gui.
fn main() {
quicnprotochat_gui::run()
}

View File

@@ -1,17 +1,17 @@
[package]
name = "quicnprotochat-client"
name = "quicproquo-client"
version = "0.1.0"
edition = "2021"
description = "CLI client for quicnprotochat."
description = "CLI client for quicproquo."
license = "MIT"
[[bin]]
name = "quicnprotochat"
name = "qpq"
path = "src/main.rs"
[dependencies]
quicnprotochat-core = { path = "../quicnprotochat-core" }
quicnprotochat-proto = { path = "../quicnprotochat-proto" }
quicproquo-core = { path = "../quicproquo-core" }
quicproquo-proto = { path = "../quicproquo-proto" }
openmls_rust_crypto = { workspace = true }
# Serialisation + RPC
@@ -54,7 +54,7 @@ clap = { workspace = true }
rusqlite = { workspace = true }
# Hex encoding/decoding
hex = "0.4"
hex = { workspace = true }
# Secure password prompting (no echo)
rpassword = "5"

View File

@@ -5,7 +5,7 @@ use opaque_ke::{
ClientLogin, ClientLoginFinishParameters, ClientRegistration,
ClientRegistrationFinishParameters, CredentialResponse, RegistrationResponse,
};
use quicnprotochat_core::{
use quicproquo_core::{
generate_key_package, hybrid_decrypt, hybrid_encrypt, opaque_auth::OpaqueSuite,
GroupMember, HybridKeypair, IdentityKeypair,
};
@@ -316,7 +316,7 @@ fn derive_identity_for_login(
/// The error message contains "E018" if the user already exists.
/// Does NOT require init_auth() — OPAQUE RPCs are unauthenticated.
pub(crate) async fn opaque_register(
client: &quicnprotochat_proto::node_capnp::node_service::Client,
client: &quicproquo_proto::node_capnp::node_service::Client,
username: &str,
password: &str,
identity_key: Option<&[u8]>,
@@ -377,7 +377,7 @@ pub(crate) async fn opaque_register(
/// Perform OPAQUE login and return the raw session token bytes.
/// Does NOT require init_auth() — OPAQUE RPCs are unauthenticated.
pub(crate) async fn opaque_login(
client: &quicnprotochat_proto::node_capnp::node_service::Client,
client: &quicproquo_proto::node_capnp::node_service::Client,
username: &str,
password: &str,
identity_key: &[u8],
@@ -646,8 +646,8 @@ pub async fn cmd_fetch_key(
/// Run a two-party MLS demo against the unified server.
pub async fn cmd_demo_group(server: &str, ca_cert: &Path, server_name: &str) -> anyhow::Result<()> {
let creator_state_path = PathBuf::from("quicnprotochat-demo-creator.bin");
let joiner_state_path = PathBuf::from("quicnprotochat-demo-joiner.bin");
let creator_state_path = PathBuf::from("qpq-demo-creator.bin");
let joiner_state_path = PathBuf::from("qpq-demo-joiner.bin");
let (mut creator, creator_hybrid_opt) =
load_or_init_state(&creator_state_path, None)?.into_parts(&creator_state_path)?;

View File

@@ -8,11 +8,11 @@ use std::sync::Arc;
use std::time::Duration;
use anyhow::Context;
use quicnprotochat_core::{
use quicproquo_core::{
AppMessage, DiskKeyStore, GroupMember, IdentityKeypair, hybrid_encrypt,
parse as parse_app_msg, serialize_chat,
};
use quicnprotochat_proto::node_capnp::node_service;
use quicproquo_proto::node_capnp::node_service;
use tokio::sync::mpsc;
use tokio::time::interval;
@@ -291,6 +291,7 @@ async fn auto_upload_keys(
Arc::clone(&session.identity),
ks,
None,
false,
);
let kp_bytes = member.generate_key_package().context("generate KeyPackage")?;
let id_key = session.identity.public_key_bytes();
@@ -419,7 +420,7 @@ async fn handle_slash(
fn print_help() {
display::print_status("Commands:");
display::print_status(" /dm <username> - Start or switch to a DM");
display::print_status(" /dm <user[@domain]> - Start or switch to a DM (federation supported)");
display::print_status(" /create-group <name> - Create a new group");
display::print_status(" /invite <username> - Invite user to current group");
display::print_status(" /join - Join a group from pending Welcome");
@@ -542,7 +543,7 @@ async fn cmd_dm(
created_at_ms: now_ms(),
};
let ks = DiskKeyStore::ephemeral();
let member = GroupMember::new_with_state(Arc::clone(&session.identity), ks, None);
let member = GroupMember::new_with_state(Arc::clone(&session.identity), ks, None, false);
session.add_conversation(conv, member)?;
session.active_conversation = Some(conv_id);
display::print_status("notes created — messages here are local only");
@@ -573,7 +574,7 @@ async fn cmd_dm(
std::fs::create_dir_all(&ks_dir).ok();
let ks_path = ks_dir.join(format!("{}.ks", conv_id.hex()));
let ks = DiskKeyStore::persistent(&ks_path)?;
let mut member = GroupMember::new_with_state(Arc::clone(&session.identity), ks, None);
let mut member = GroupMember::new_with_state(Arc::clone(&session.identity), ks, None, false);
// Generate a key package for ourselves (needed for MLS)
let _my_kp = member.generate_key_package()?;
@@ -634,7 +635,7 @@ fn cmd_create_group(session: &mut SessionState, name: &str) -> anyhow::Result<()
std::fs::create_dir_all(&ks_dir).ok();
let ks_path = ks_dir.join(format!("{}.ks", conv_id.hex()));
let ks = DiskKeyStore::persistent(&ks_path)?;
let mut member = GroupMember::new_with_state(Arc::clone(&session.identity), ks, None);
let mut member = GroupMember::new_with_state(Arc::clone(&session.identity), ks, None, false);
let _my_kp = member.generate_key_package()?;
member.create_group(conv_id.0.as_slice())?;
@@ -773,6 +774,7 @@ async fn cmd_join(
Arc::clone(&session.identity),
ks,
None,
false,
);
// Need a key package to decrypt Welcome.
let _kp = new_member.generate_key_package()?;
@@ -890,6 +892,7 @@ async fn do_send(
.clone();
let my_key = session.identity_bytes();
let identity = std::sync::Arc::clone(&session.identity);
let member = session
.get_member_mut(&conv_id)
@@ -917,8 +920,12 @@ async fn do_send(
let app_payload = serialize_chat(text.as_bytes(), None)
.context("serialize app message")?;
// Metadata protection: seal sender identity inside payload + pad to bucket size.
let sealed = quicproquo_core::sealed_sender::seal(&identity, &app_payload);
let padded = quicproquo_core::padding::pad(&sealed);
let ct = member
.send_message(&app_payload)
.send_message(&padded)
.context("MLS send_message failed")?;
let recipients: Vec<Vec<u8>> = member
@@ -997,9 +1004,27 @@ async fn poll_messages(
match member.receive_message(&mls_payload) {
Ok(Some(plaintext)) => {
// Metadata protection: try unpad → unseal → parse.
// Falls back gracefully for messages from older clients.
let (sender_key, app_bytes) = {
// Step 1: try unpad
let after_unpad = quicproquo_core::padding::unpad(&plaintext)
.unwrap_or_else(|_| plaintext.clone());
// Step 2: try unseal
if quicproquo_core::sealed_sender::is_sealed(&after_unpad) {
match quicproquo_core::sealed_sender::unseal(&after_unpad) {
Ok((sk, inner)) => (sk.to_vec(), inner),
Err(_) => (session.identity_bytes(), after_unpad),
}
} else {
(session.identity_bytes(), after_unpad)
}
};
// Parse structured AppMessage; fall back to raw UTF-8 for legacy.
let (body, msg_id, msg_type, ref_msg_id) =
match parse_app_msg(&plaintext) {
match parse_app_msg(&app_bytes) {
Ok((_, AppMessage::Chat { message_id, body })) => (
String::from_utf8_lossy(&body).to_string(),
Some(message_id),
@@ -1021,7 +1046,7 @@ async fn poll_messages(
_ => {
// Legacy raw plaintext or unknown type.
(
String::from_utf8_lossy(&plaintext).to_string(),
String::from_utf8_lossy(&app_bytes).to_string(),
None,
"chat",
None,
@@ -1029,8 +1054,6 @@ async fn poll_messages(
}
};
let sender_key = session.identity_bytes(); // fallback
let msg = StoredMessage {
conversation_id: conv_id.clone(),
message_id: msg_id,

View File

@@ -10,8 +10,8 @@ use rustls::{ClientConfig as RustlsClientConfig, RootCertStore};
use tokio_util::compat::{TokioAsyncReadCompatExt, TokioAsyncWriteCompatExt};
use capnp_rpc::{rpc_twoparty_capnp::Side, twoparty, RpcSystem};
use quicnprotochat_core::HybridPublicKey;
use quicnprotochat_proto::node_capnp::{auth, node_service};
use quicproquo_core::HybridPublicKey;
use quicproquo_proto::node_capnp::{auth, node_service};
use crate::AUTH_CONTEXT;
@@ -359,11 +359,11 @@ pub async fn fetch_hybrid_key(
/// Decrypt a hybrid envelope. Requires a hybrid key; no fallback to plaintext MLS.
pub fn try_hybrid_decrypt(
hybrid_kp: Option<&quicnprotochat_core::HybridKeypair>,
hybrid_kp: Option<&quicproquo_core::HybridKeypair>,
payload: &[u8],
) -> anyhow::Result<Vec<u8>> {
let kp = hybrid_kp.ok_or_else(|| anyhow::anyhow!("hybrid key required for decryption"))?;
quicnprotochat_core::hybrid_decrypt(kp, payload, b"", b"").map_err(|e| anyhow::anyhow!("{e}"))
quicproquo_core::hybrid_decrypt(kp, payload, b"", b"").map_err(|e| anyhow::anyhow!("{e}"))
}
/// Peek at queued payloads without removing them.

View File

@@ -9,7 +9,7 @@ use std::sync::Arc;
use anyhow::Context;
use quicnprotochat_core::{DiskKeyStore, GroupMember, HybridKeypair, IdentityKeypair};
use quicproquo_core::{DiskKeyStore, GroupMember, HybridKeypair, IdentityKeypair};
use super::conversation::{
now_ms, Conversation, ConversationId, ConversationKind, ConversationStore,
@@ -101,6 +101,7 @@ impl SessionState {
Arc::clone(&self.identity),
ks,
Some(group),
false, // legacy groups are classical
);
let group_id_bytes = member.group_id().unwrap_or_default();
@@ -170,6 +171,7 @@ impl SessionState {
Arc::clone(&self.identity),
ks,
group,
false, // existing conversations default to classical
))
}

View File

@@ -10,7 +10,7 @@ use chacha20poly1305::{
use rand::RngCore;
use serde::{Deserialize, Serialize};
use quicnprotochat_core::{DiskKeyStore, GroupMember, HybridKeypair, HybridKeypairBytes, IdentityKeypair};
use quicproquo_core::{DiskKeyStore, GroupMember, HybridKeypair, HybridKeypairBytes, IdentityKeypair};
/// Magic bytes for encrypted client state files.
const STATE_MAGIC: &[u8; 4] = b"QPCE";
@@ -37,7 +37,8 @@ impl StoredState {
.map(|bytes| bincode::deserialize(&bytes).context("decode group"))
.transpose()?;
let key_store = DiskKeyStore::persistent(keystore_path(state_path))?;
let member = GroupMember::new_with_state(identity, key_store, group);
let hybrid = self.hybrid_key.is_some();
let member = GroupMember::new_with_state(identity, key_store, group, hybrid);
let hybrid_kp = self
.hybrid_key
@@ -149,7 +150,7 @@ pub fn load_or_init_state(path: &Path, password: Option<&str>) -> anyhow::Result
let identity = IdentityKeypair::generate();
let hybrid_kp = HybridKeypair::generate();
let key_store = DiskKeyStore::persistent(keystore_path(path))?;
let member = GroupMember::new_with_state(Arc::new(identity), key_store, None);
let member = GroupMember::new_with_state(Arc::new(identity), key_store, None, false);
let state = StoredState::from_parts(&member, Some(&hybrid_kp))?;
write_state(path, &state, password)?;
Ok(state)

View File

@@ -1,17 +1,17 @@
//! quicnprotochat CLI client library.
//! quicproquo CLI client library.
//!
//! # KeyPackage expiry and refresh
//!
//! KeyPackages are single-use (consumed when someone fetches them for an invite) and the server
//! may enforce a TTL (e.g. 24 hours). To stay invitable, run `quicnprotochat refresh-keypackage`
//! may enforce a TTL (e.g. 24 hours). To stay invitable, run `qpq refresh-keypackage`
//! periodically (e.g. before the server TTL) or after your KeyPackage was consumed:
//!
//! ```bash
//! quicnprotochat refresh-keypackage --state quicnprotochat-state.bin --server 127.0.0.1:7000
//! qpq refresh-keypackage --state qpq-state.bin --server 127.0.0.1:7000
//! ```
//!
//! Use the same `--access-token` (or `QUICNPROTOCHAT_ACCESS_TOKEN`) as for other authenticated
//! commands. See the [running-the-client](https://docs.quicnprotochat.dev/getting-started/running-the-client)
//! Use the same `--access-token` (or `QPQ_ACCESS_TOKEN`) as for other authenticated
//! commands. See the [running-the-client](https://docs.quicproquo.dev/getting-started/running-the-client)
//! docs for details.
use std::sync::RwLock;

View File

@@ -1,10 +1,10 @@
//! quicnprotochat CLI client.
//! quicproquo CLI client.
use std::path::PathBuf;
use clap::{Parser, Subcommand};
use quicnprotochat_client::{
use quicproquo_client::{
cmd_chat, cmd_check_key, cmd_create_group, cmd_demo_group, cmd_fetch_key, cmd_health,
cmd_invite, cmd_join, cmd_login, cmd_ping, cmd_recv, cmd_register, cmd_register_state,
cmd_refresh_keypackage, cmd_register_user, cmd_send, cmd_whoami, init_auth, run_repl,
@@ -14,14 +14,14 @@ use quicnprotochat_client::{
// ── CLI ───────────────────────────────────────────────────────────────────────
#[derive(Debug, Parser)]
#[command(name = "quicnprotochat", about = "quicnprotochat CLI client", version)]
#[command(name = "qpq", about = "quicproquo CLI client", version)]
struct Args {
/// Path to the server's TLS certificate (self-signed by default).
#[arg(
long,
global = true,
default_value = "data/server-cert.der",
env = "QUICNPROTOCHAT_CA_CERT"
env = "QPQ_CA_CERT"
)]
ca_cert: PathBuf,
@@ -30,7 +30,7 @@ struct Args {
long,
global = true,
default_value = "localhost",
env = "QUICNPROTOCHAT_SERVER_NAME"
env = "QPQ_SERVER_NAME"
)]
server_name: String,
@@ -39,18 +39,18 @@ struct Args {
#[arg(
long,
global = true,
env = "QUICNPROTOCHAT_ACCESS_TOKEN",
env = "QPQ_ACCESS_TOKEN",
default_value = ""
)]
access_token: String,
/// Optional device identifier (UUID bytes encoded as hex or raw string).
#[arg(long, global = true, env = "QUICNPROTOCHAT_DEVICE_ID")]
#[arg(long, global = true, env = "QPQ_DEVICE_ID")]
device_id: Option<String>,
/// Password to encrypt/decrypt client state files (QPCE format).
/// If set, state files are encrypted at rest with Argon2id + ChaCha20Poly1305.
#[arg(long, global = true, env = "QUICNPROTOCHAT_STATE_PASSWORD")]
#[arg(long, global = true, env = "QPQ_STATE_PASSWORD")]
state_password: Option<String>,
#[command(subcommand)]
@@ -61,7 +61,7 @@ struct Args {
enum Command {
/// Register a new user via OPAQUE (password never leaves the client).
RegisterUser {
#[arg(long, default_value = "127.0.0.1:7000", env = "QUICNPROTOCHAT_SERVER")]
#[arg(long, default_value = "127.0.0.1:7000", env = "QPQ_SERVER")]
server: String,
/// Username for the new account.
#[arg(long)]
@@ -73,7 +73,7 @@ enum Command {
/// Log in via OPAQUE and receive a session token.
Login {
#[arg(long, default_value = "127.0.0.1:7000", env = "QUICNPROTOCHAT_SERVER")]
#[arg(long, default_value = "127.0.0.1:7000", env = "QPQ_SERVER")]
server: String,
#[arg(long)]
username: String,
@@ -95,8 +95,8 @@ enum Command {
/// State file path (identity + MLS state).
#[arg(
long,
default_value = "quicnprotochat-state.bin",
env = "QUICNPROTOCHAT_STATE"
default_value = "qpq-state.bin",
env = "QPQ_STATE"
)]
state: PathBuf,
},
@@ -104,14 +104,14 @@ enum Command {
/// Check server connectivity and print status.
Health {
/// Server address (host:port).
#[arg(long, default_value = "127.0.0.1:7000", env = "QUICNPROTOCHAT_SERVER")]
#[arg(long, default_value = "127.0.0.1:7000", env = "QPQ_SERVER")]
server: String,
},
/// Check if a peer has registered a hybrid key (non-consuming lookup).
CheckKey {
/// Server address (host:port).
#[arg(long, default_value = "127.0.0.1:7000", env = "QUICNPROTOCHAT_SERVER")]
#[arg(long, default_value = "127.0.0.1:7000", env = "QPQ_SERVER")]
server: String,
/// Peer's Ed25519 identity public key (64 hex chars = 32 bytes).
@@ -121,21 +121,21 @@ enum Command {
/// Send a Ping to the server and print the round-trip time.
Ping {
/// Server address (host:port).
#[arg(long, default_value = "127.0.0.1:7000", env = "QUICNPROTOCHAT_SERVER")]
#[arg(long, default_value = "127.0.0.1:7000", env = "QPQ_SERVER")]
server: String,
},
/// Generate a fresh MLS KeyPackage and upload it to the Authentication Service.
Register {
/// Server address (host:port).
#[arg(long, default_value = "127.0.0.1:7000", env = "QUICNPROTOCHAT_SERVER")]
#[arg(long, default_value = "127.0.0.1:7000", env = "QPQ_SERVER")]
server: String,
},
/// Fetch a peer's KeyPackage from the Authentication Service.
FetchKey {
/// Server address (host:port).
#[arg(long, default_value = "127.0.0.1:7000", env = "QUICNPROTOCHAT_SERVER")]
#[arg(long, default_value = "127.0.0.1:7000", env = "QPQ_SERVER")]
server: String,
/// Target peer's Ed25519 identity public key (64 hex chars = 32 bytes).
@@ -145,7 +145,7 @@ enum Command {
/// Run a two-party MLS demo (creator + joiner) against live AS and DS.
DemoGroup {
/// Server address (host:port).
#[arg(long, default_value = "127.0.0.1:7000", env = "QUICNPROTOCHAT_SERVER")]
#[arg(long, default_value = "127.0.0.1:7000", env = "QPQ_SERVER")]
server: String,
},
@@ -154,13 +154,13 @@ enum Command {
/// State file path (identity + MLS state).
#[arg(
long,
default_value = "quicnprotochat-state.bin",
env = "QUICNPROTOCHAT_STATE"
default_value = "qpq-state.bin",
env = "QPQ_STATE"
)]
state: PathBuf,
/// Authentication Service address (host:port).
#[arg(long, default_value = "127.0.0.1:7000", env = "QUICNPROTOCHAT_SERVER")]
#[arg(long, default_value = "127.0.0.1:7000", env = "QPQ_SERVER")]
server: String,
},
@@ -170,13 +170,13 @@ enum Command {
/// State file path (identity + MLS state).
#[arg(
long,
default_value = "quicnprotochat-state.bin",
env = "QUICNPROTOCHAT_STATE"
default_value = "qpq-state.bin",
env = "QPQ_STATE"
)]
state: PathBuf,
/// Server address (host:port).
#[arg(long, default_value = "127.0.0.1:7000", env = "QUICNPROTOCHAT_SERVER")]
#[arg(long, default_value = "127.0.0.1:7000", env = "QPQ_SERVER")]
server: String,
},
@@ -185,13 +185,13 @@ enum Command {
/// State file path (identity + MLS state).
#[arg(
long,
default_value = "quicnprotochat-state.bin",
env = "QUICNPROTOCHAT_STATE"
default_value = "qpq-state.bin",
env = "QPQ_STATE"
)]
state: PathBuf,
/// Server address (host:port).
#[arg(long, default_value = "127.0.0.1:7000", env = "QUICNPROTOCHAT_SERVER")]
#[arg(long, default_value = "127.0.0.1:7000", env = "QPQ_SERVER")]
server: String,
/// Group identifier (arbitrary bytes, typically a human-readable name).
@@ -203,11 +203,11 @@ enum Command {
Invite {
#[arg(
long,
default_value = "quicnprotochat-state.bin",
env = "QUICNPROTOCHAT_STATE"
default_value = "qpq-state.bin",
env = "QPQ_STATE"
)]
state: PathBuf,
#[arg(long, default_value = "127.0.0.1:7000", env = "QUICNPROTOCHAT_SERVER")]
#[arg(long, default_value = "127.0.0.1:7000", env = "QPQ_SERVER")]
server: String,
/// Peer identity public key (64 hex chars = 32 bytes).
#[arg(long)]
@@ -218,11 +218,11 @@ enum Command {
Join {
#[arg(
long,
default_value = "quicnprotochat-state.bin",
env = "QUICNPROTOCHAT_STATE"
default_value = "qpq-state.bin",
env = "QPQ_STATE"
)]
state: PathBuf,
#[arg(long, default_value = "127.0.0.1:7000", env = "QUICNPROTOCHAT_SERVER")]
#[arg(long, default_value = "127.0.0.1:7000", env = "QPQ_SERVER")]
server: String,
},
@@ -230,11 +230,11 @@ enum Command {
Send {
#[arg(
long,
default_value = "quicnprotochat-state.bin",
env = "QUICNPROTOCHAT_STATE"
default_value = "qpq-state.bin",
env = "QPQ_STATE"
)]
state: PathBuf,
#[arg(long, default_value = "127.0.0.1:7000", env = "QUICNPROTOCHAT_SERVER")]
#[arg(long, default_value = "127.0.0.1:7000", env = "QPQ_SERVER")]
server: String,
/// Recipient identity key (hex, 32 bytes -> 64 chars). Omit when using --all.
#[arg(long)]
@@ -251,11 +251,11 @@ enum Command {
Recv {
#[arg(
long,
default_value = "quicnprotochat-state.bin",
env = "QUICNPROTOCHAT_STATE"
default_value = "qpq-state.bin",
env = "QPQ_STATE"
)]
state: PathBuf,
#[arg(long, default_value = "127.0.0.1:7000", env = "QUICNPROTOCHAT_SERVER")]
#[arg(long, default_value = "127.0.0.1:7000", env = "QPQ_SERVER")]
server: String,
/// Wait for up to this many milliseconds if no messages are queued.
@@ -272,17 +272,17 @@ enum Command {
Repl {
#[arg(
long,
default_value = "quicnprotochat-state.bin",
env = "QUICNPROTOCHAT_STATE"
default_value = "qpq-state.bin",
env = "QPQ_STATE"
)]
state: PathBuf,
#[arg(long, default_value = "127.0.0.1:7000", env = "QUICNPROTOCHAT_SERVER")]
#[arg(long, default_value = "127.0.0.1:7000", env = "QPQ_SERVER")]
server: String,
/// OPAQUE username for automatic registration/login.
#[arg(long, env = "QUICNPROTOCHAT_USERNAME")]
#[arg(long, env = "QPQ_USERNAME")]
username: Option<String>,
/// OPAQUE password (prompted securely if --username is set but --password is not).
#[arg(long, env = "QUICNPROTOCHAT_PASSWORD")]
#[arg(long, env = "QPQ_PASSWORD")]
password: Option<String>,
},
@@ -291,11 +291,11 @@ enum Command {
Chat {
#[arg(
long,
default_value = "quicnprotochat-state.bin",
env = "QUICNPROTOCHAT_STATE"
default_value = "qpq-state.bin",
env = "QPQ_STATE"
)]
state: PathBuf,
#[arg(long, default_value = "127.0.0.1:7000", env = "QUICNPROTOCHAT_SERVER")]
#[arg(long, default_value = "127.0.0.1:7000", env = "QPQ_SERVER")]
server: String,
/// Peer identity key (hex, 64 chars). Omit in a two-person group to use the only other member.
#[arg(long)]

View File

@@ -1,4 +1,4 @@
// cargo_bin! only works for current package's binary; we spawn quicnprotochat-server from another package.
// cargo_bin! only works for current package's binary; we spawn qpq-server from another package.
#![allow(deprecated)]
use std::{path::PathBuf, process::Command, time::Duration};
@@ -15,12 +15,12 @@ fn ensure_rustls_provider() {
let _ = rustls::crypto::ring::default_provider().install_default();
}
use quicnprotochat_client::{
use quicproquo_client::{
cmd_create_group, cmd_invite, cmd_join, cmd_login, cmd_ping, cmd_register_state,
cmd_register_user, cmd_send, connect_node, enqueue, fetch_wait, init_auth,
receive_pending_plaintexts, ClientAuth,
};
use quicnprotochat_core::IdentityKeypair;
use quicproquo_core::IdentityKeypair;
fn hex_encode(bytes: &[u8]) -> String {
bytes.iter().map(|b| format!("{b:02x}")).collect()
@@ -65,7 +65,7 @@ async fn e2e_happy_path_register_invite_join_send_recv() -> anyhow::Result<()> {
let auth_token = "devtoken";
// Spawn server binary.
let server_bin = cargo_bin("quicnprotochat-server");
let server_bin = cargo_bin("qpq-server");
let child = Command::new(server_bin)
.arg("--listen")
.arg(&listen)
@@ -187,7 +187,7 @@ async fn e2e_three_party_group_invite_join_send_recv() -> anyhow::Result<()> {
let data_dir = base.join("data");
let auth_token = "devtoken";
let server_bin = cargo_bin("quicnprotochat-server");
let server_bin = cargo_bin("qpq-server");
let child = Command::new(server_bin)
.arg("--listen")
.arg(&listen)
@@ -400,7 +400,7 @@ async fn e2e_login_rejects_mismatched_identity() -> anyhow::Result<()> {
let auth_token = "devtoken";
// Spawn server binary.
let server_bin = cargo_bin("quicnprotochat-server");
let server_bin = cargo_bin("qpq-server");
let child = Command::new(server_bin)
.arg("--listen")
.arg(&listen)
@@ -509,7 +509,7 @@ async fn e2e_sealed_sender_enqueue_then_fetch() -> anyhow::Result<()> {
let data_dir = base.join("data");
let auth_token = "devtoken";
let server_bin = cargo_bin("quicnprotochat-server");
let server_bin = cargo_bin("qpq-server");
let child = Command::new(server_bin)
.arg("--listen")
.arg(&listen)

View File

@@ -1,8 +1,8 @@
[package]
name = "quicnprotochat-core"
name = "quicproquo-core"
version = "0.1.0"
edition = "2021"
description = "Crypto primitives, MLS state machine, and hybrid post-quantum KEM for quicnprotochat."
description = "Crypto primitives, MLS state machine, and hybrid post-quantum KEM for quicproquo."
license = "MIT"
[dependencies]
@@ -33,7 +33,7 @@ serde_json = { workspace = true }
# Serialisation
capnp = { workspace = true }
quicnprotochat-proto = { path = "../quicnprotochat-proto" }
quicproquo-proto = { path = "../quicproquo-proto" }
# Async runtime
tokio = { workspace = true }
@@ -43,3 +43,17 @@ thiserror = { workspace = true }
[dev-dependencies]
tokio = { workspace = true }
criterion = { version = "0.5", features = ["html_reports"] }
prost = "0.13"
[[bench]]
name = "serialization"
harness = false
[[bench]]
name = "mls_operations"
harness = false
[[bench]]
name = "hybrid_kem_bench"
harness = false

View File

@@ -0,0 +1,152 @@
//! Benchmark: Hybrid KEM (X25519 + ML-KEM-768) vs classical-only encryption.
//!
//! Compares keypair generation, encryption, and decryption times for the
//! hybrid post-quantum scheme against classical X25519 + ChaCha20-Poly1305.
use criterion::{black_box, criterion_group, criterion_main, BenchmarkId, Criterion};
use quicproquo_core::{hybrid_encrypt, hybrid_decrypt, HybridKeypair};
// ── Classical baseline (X25519 + ChaCha20-Poly1305) ─────────────────────────
use chacha20poly1305::{
aead::{Aead, KeyInit},
ChaCha20Poly1305, Key, Nonce,
};
use hkdf::Hkdf;
use rand::{rngs::OsRng, RngCore};
use sha2::Sha256;
use x25519_dalek::{EphemeralSecret, PublicKey as X25519Public, StaticSecret};
struct ClassicalKeypair {
secret: StaticSecret,
public: X25519Public,
}
impl ClassicalKeypair {
fn generate() -> Self {
let secret = StaticSecret::random_from_rng(OsRng);
let public = X25519Public::from(&secret);
Self { secret, public }
}
}
fn classical_encrypt(recipient_pk: &X25519Public, plaintext: &[u8]) -> Vec<u8> {
let eph_secret = EphemeralSecret::random_from_rng(OsRng);
let eph_public = X25519Public::from(&eph_secret);
let shared = eph_secret.diffie_hellman(recipient_pk);
let hk = Hkdf::<Sha256>::new(None, shared.as_bytes());
let mut key_bytes = [0u8; 32];
hk.expand(b"classical-bench", &mut key_bytes).unwrap();
let mut nonce_bytes = [0u8; 12];
OsRng.fill_bytes(&mut nonce_bytes);
let cipher = ChaCha20Poly1305::new(Key::from_slice(&key_bytes));
let ct = cipher
.encrypt(Nonce::from_slice(&nonce_bytes), plaintext)
.unwrap();
// Wire: eph_pk(32) || nonce(12) || ciphertext
let mut out = Vec::with_capacity(32 + 12 + ct.len());
out.extend_from_slice(eph_public.as_bytes());
out.extend_from_slice(&nonce_bytes);
out.extend_from_slice(&ct);
out
}
fn classical_decrypt(keypair: &ClassicalKeypair, envelope: &[u8]) -> Vec<u8> {
let eph_pk = X25519Public::from(<[u8; 32]>::try_from(&envelope[..32]).unwrap());
let nonce_bytes: [u8; 12] = envelope[32..44].try_into().unwrap();
let ct = &envelope[44..];
let shared = keypair.secret.diffie_hellman(&eph_pk);
let hk = Hkdf::<Sha256>::new(None, shared.as_bytes());
let mut key_bytes = [0u8; 32];
hk.expand(b"classical-bench", &mut key_bytes).unwrap();
let cipher = ChaCha20Poly1305::new(Key::from_slice(&key_bytes));
cipher
.decrypt(Nonce::from_slice(&nonce_bytes), ct)
.unwrap()
}
// ── Benchmarks ──────────────────────────────────────────────────────────────
fn bench_keygen(c: &mut Criterion) {
let mut group = c.benchmark_group("kem_keygen");
group.bench_function("hybrid", |b| {
b.iter(|| black_box(HybridKeypair::generate()));
});
group.bench_function("classical", |b| {
b.iter(|| black_box(ClassicalKeypair::generate()));
});
group.finish();
}
fn bench_encrypt(c: &mut Criterion) {
let sizes: &[(&str, usize)] = &[("100B", 100), ("1KB", 1024), ("4KB", 4096), ("64KB", 65536)];
let mut group = c.benchmark_group("kem_encrypt");
let hybrid_kp = HybridKeypair::generate();
let hybrid_pk = hybrid_kp.public_key();
let classical_kp = ClassicalKeypair::generate();
for (label, size) in sizes {
let payload = vec![0xABu8; *size];
group.bench_with_input(
BenchmarkId::new("hybrid", label),
&payload,
|b, payload| {
b.iter(|| hybrid_encrypt(&hybrid_pk, black_box(payload), b"", b"").unwrap());
},
);
group.bench_with_input(
BenchmarkId::new("classical", label),
&payload,
|b, payload| {
b.iter(|| classical_encrypt(&classical_kp.public, black_box(payload)));
},
);
}
group.finish();
}
fn bench_decrypt(c: &mut Criterion) {
let sizes: &[(&str, usize)] = &[("100B", 100), ("1KB", 1024), ("4KB", 4096), ("64KB", 65536)];
let mut group = c.benchmark_group("kem_decrypt");
let hybrid_kp = HybridKeypair::generate();
let hybrid_pk = hybrid_kp.public_key();
let classical_kp = ClassicalKeypair::generate();
for (label, size) in sizes {
let payload = vec![0xABu8; *size];
let hybrid_ct = hybrid_encrypt(&hybrid_pk, &payload, b"", b"").unwrap();
let classical_ct = classical_encrypt(&classical_kp.public, &payload);
group.bench_with_input(
BenchmarkId::new("hybrid", label),
&hybrid_ct,
|b, ct| {
b.iter(|| hybrid_decrypt(&hybrid_kp, black_box(ct), b"", b"").unwrap());
},
);
group.bench_with_input(
BenchmarkId::new("classical", label),
&classical_ct,
|b, ct| {
b.iter(|| classical_decrypt(&classical_kp, black_box(ct)));
},
);
}
group.finish();
}
criterion_group!(benches, bench_keygen, bench_encrypt, bench_decrypt);
criterion_main!(benches);

View File

@@ -0,0 +1,132 @@
//! Benchmark: MLS group operations at various group sizes.
//!
//! Measures KeyPackage generation, group creation, member addition,
//! message encryption, and message decryption.
use std::sync::Arc;
use criterion::{criterion_group, criterion_main, BatchSize, BenchmarkId, Criterion};
use quicproquo_core::{GroupMember, IdentityKeypair};
/// Create identities and a group of the given size.
/// Returns (creator, Vec<members>).
fn setup_group(size: usize) -> (GroupMember, Vec<GroupMember>) {
let creator_id = Arc::new(IdentityKeypair::generate());
let mut creator = GroupMember::new(creator_id);
creator.create_group(b"bench-group").unwrap();
let mut members = Vec::with_capacity(size.saturating_sub(1));
for _ in 1..size {
let joiner_id = Arc::new(IdentityKeypair::generate());
let mut joiner = GroupMember::new(joiner_id);
let kp = joiner.generate_key_package().unwrap();
let (_commit, welcome) = creator.add_member(&kp).unwrap();
joiner.join_group(&welcome).unwrap();
members.push(joiner);
}
(creator, members)
}
fn bench_keygen(c: &mut Criterion) {
c.bench_function("mls_keygen", |b| {
b.iter_batched(
|| {
let id = Arc::new(IdentityKeypair::generate());
GroupMember::new(id)
},
|mut member| {
member.generate_key_package().unwrap();
},
BatchSize::SmallInput,
);
});
}
fn bench_group_create(c: &mut Criterion) {
c.bench_function("mls_group_create", |b| {
b.iter_batched(
|| {
let id = Arc::new(IdentityKeypair::generate());
GroupMember::new(id)
},
|mut member| {
member.create_group(b"bench-group").unwrap();
},
BatchSize::SmallInput,
);
});
}
fn bench_add_member(c: &mut Criterion) {
let mut group = c.benchmark_group("mls_add_member");
// Smaller sizes to keep setup time reasonable
for size in [2, 10, 50] {
group.bench_with_input(BenchmarkId::from_parameter(size), &size, |b, &size| {
b.iter_batched(
|| {
let (creator, members) = setup_group(size);
let joiner_id = Arc::new(IdentityKeypair::generate());
let mut joiner = GroupMember::new(joiner_id);
let kp = joiner.generate_key_package().unwrap();
(creator, members, joiner, kp)
},
|(mut creator, _members, _joiner, kp)| {
creator.add_member(&kp).unwrap();
},
BatchSize::SmallInput,
);
});
}
group.finish();
}
fn bench_send_message(c: &mut Criterion) {
let mut group = c.benchmark_group("mls_send_message");
for size in [2, 10, 50] {
group.bench_with_input(BenchmarkId::from_parameter(size), &size, |b, &size| {
let (mut creator, _members) = setup_group(size);
let payload = b"hello benchmark message";
b.iter(|| {
creator.send_message(payload).unwrap();
});
});
}
group.finish();
}
fn bench_receive_message(c: &mut Criterion) {
let mut group = c.benchmark_group("mls_receive_message");
for size in [2, 10, 50] {
group.bench_with_input(BenchmarkId::from_parameter(size), &size, |b, &size| {
// For receive, we need a fresh ciphertext each iteration since
// MLS message processing is destructive (epoch state changes).
// We pre-generate a batch and consume them.
let (mut creator, mut members) = setup_group(size);
if members.is_empty() {
return;
}
let payload = b"hello benchmark message";
b.iter_batched(
|| creator.send_message(payload).unwrap(),
|ct| {
// Receive on the first joiner
let _ = members[0].receive_message(&ct);
},
BatchSize::SmallInput,
);
});
}
group.finish();
}
criterion_group!(
benches,
bench_keygen,
bench_group_create,
bench_add_member,
bench_send_message,
bench_receive_message,
);
criterion_main!(benches);

View File

@@ -0,0 +1,170 @@
//! Benchmark: Cap'n Proto vs Protobuf serialization for chat message envelopes.
//!
//! Compares serialization/deserialization speed and encoded size at three
//! payload sizes (100 B, 1 KB, 4 KB) for a typical Envelope{seq, data} message.
use criterion::{black_box, criterion_group, criterion_main, BenchmarkId, Criterion};
// ── Cap'n Proto path ────────────────────────────────────────────────────────
fn capnp_serialize_envelope(seq: u64, data: &[u8]) -> Vec<u8> {
let mut msg = capnp::message::Builder::new_default();
{
let mut envelope = msg.init_root::<quicproquo_proto::node_capnp::envelope::Builder>();
envelope.set_seq(seq);
envelope.set_data(data);
}
quicproquo_proto::to_bytes(&msg).unwrap()
}
fn capnp_deserialize_envelope(bytes: &[u8]) -> (u64, Vec<u8>) {
let reader = quicproquo_proto::from_bytes(bytes).unwrap();
let envelope = reader
.get_root::<quicproquo_proto::node_capnp::envelope::Reader>()
.unwrap();
(envelope.get_seq(), envelope.get_data().unwrap().to_vec())
}
// ── Protobuf path (hand-coded prost encoding to avoid build-dep) ────────────
//
// Envelope { seq: uint64 (field 1), data: bytes (field 2) }
// Wire format: varint tag + varint seq + len-delimited data
fn protobuf_serialize_envelope(seq: u64, data: &[u8]) -> Vec<u8> {
// Build a prost message via raw encoding.
// Field 1: uint64 seq, wire type 0 (varint), tag = (1 << 3) | 0 = 0x08
// Field 2: bytes data, wire type 2 (length-delimited), tag = (2 << 3) | 2 = 0x12
let mut buf = Vec::with_capacity(10 + data.len());
// Encode field 1 (seq)
prost::encoding::uint64::encode(1, &seq, &mut buf);
// Encode field 2 (data)
prost::encoding::bytes::encode(2, &data.to_vec(), &mut buf);
buf
}
fn protobuf_deserialize_envelope(bytes: &[u8]) -> (u64, Vec<u8>) {
// Decode manually using prost wire format
let mut seq: u64 = 0;
let mut data: Vec<u8> = Vec::new();
let mut buf = bytes;
while !buf.is_empty() {
let (tag, wire_type) =
prost::encoding::decode_key(&mut buf).expect("decode key");
match tag {
1 => {
prost::encoding::uint64::merge(wire_type, &mut seq, &mut buf, Default::default())
.expect("decode seq");
}
2 => {
prost::encoding::bytes::merge(wire_type, &mut data, &mut buf, Default::default())
.expect("decode data");
}
_ => {
prost::encoding::skip_field(wire_type, tag, &mut buf, Default::default())
.expect("skip unknown field");
}
}
}
(seq, data)
}
// ── Benchmarks ──────────────────────────────────────────────────────────────
fn bench_serialize(c: &mut Criterion) {
let sizes: &[(&str, usize)] = &[("100B", 100), ("1KB", 1024), ("4KB", 4096)];
let mut group = c.benchmark_group("serialize_envelope");
for (label, size) in sizes {
let payload = vec![0xABu8; *size];
let seq = 42u64;
group.bench_with_input(
BenchmarkId::new("capnp", label),
&(&seq, &payload),
|b, &(seq, payload)| {
b.iter(|| capnp_serialize_envelope(black_box(*seq), black_box(payload)));
},
);
group.bench_with_input(
BenchmarkId::new("protobuf", label),
&(&seq, &payload),
|b, &(seq, payload)| {
b.iter(|| protobuf_serialize_envelope(black_box(*seq), black_box(payload)));
},
);
}
group.finish();
}
fn bench_deserialize(c: &mut Criterion) {
let sizes: &[(&str, usize)] = &[("100B", 100), ("1KB", 1024), ("4KB", 4096)];
let mut group = c.benchmark_group("deserialize_envelope");
for (label, size) in sizes {
let payload = vec![0xABu8; *size];
let seq = 42u64;
let capnp_bytes = capnp_serialize_envelope(seq, &payload);
let proto_bytes = protobuf_serialize_envelope(seq, &payload);
group.bench_with_input(
BenchmarkId::new("capnp", label),
&capnp_bytes,
|b, bytes| {
b.iter(|| capnp_deserialize_envelope(black_box(bytes)));
},
);
group.bench_with_input(
BenchmarkId::new("protobuf", label),
&proto_bytes,
|b, bytes| {
b.iter(|| protobuf_deserialize_envelope(black_box(bytes)));
},
);
}
group.finish();
}
fn bench_encoded_sizes(c: &mut Criterion) {
let sizes: &[(&str, usize)] = &[("100B", 100), ("1KB", 1024), ("4KB", 4096)];
let mut group = c.benchmark_group("encoded_size");
for (label, size) in sizes {
let payload = vec![0xABu8; *size];
let capnp_bytes = capnp_serialize_envelope(42, &payload);
let proto_bytes = protobuf_serialize_envelope(42, &payload);
// Use a trivial benchmark that just returns the size -- the point
// is to get criterion to print the iteration count and allow
// comparison. The real value is in the eprintln below.
group.bench_with_input(
BenchmarkId::new("capnp", label),
&capnp_bytes,
|b, bytes| {
b.iter(|| black_box(bytes.len()));
},
);
group.bench_with_input(
BenchmarkId::new("protobuf", label),
&proto_bytes,
|b, bytes| {
b.iter(|| black_box(bytes.len()));
},
);
eprintln!(
" {label}: capnp={} bytes, protobuf={} bytes, overhead={:+} bytes",
capnp_bytes.len(),
proto_bytes.len(),
capnp_bytes.len() as isize - proto_bytes.len() as isize,
);
}
group.finish();
}
criterion_group!(benches, bench_serialize, bench_deserialize, bench_encoded_sizes);
criterion_main!(benches);

View File

@@ -0,0 +1,21 @@
syntax = "proto3";
package quicproquo.bench;
// Equivalent to the Envelope struct in delivery.capnp
message Envelope {
uint64 seq = 1;
bytes data = 2;
}
// Equivalent to a chat message payload (app_message.rs Chat variant)
message ChatMessage {
bytes message_id = 1; // 16 bytes
string body = 2; // UTF-8 text
uint64 timestamp_ms = 3;
bytes sender_key = 4; // 32 bytes Ed25519 public key
}
// Batch fetch response (equivalent to fetch returning List(Envelope))
message FetchResponse {
repeated Envelope payloads = 1;
}

View File

@@ -1,4 +1,4 @@
//! Error types for `quicnprotochat-core`.
//! Error types for `quicproquo-core`.
use thiserror::Error;

View File

@@ -3,12 +3,19 @@
//! # Design
//!
//! [`GroupMember`] wraps an openmls [`MlsGroup`] plus the per-client
//! [`StoreCrypto`] backend. The backend is **persistent** — it holds the
//! in-memory key store that maps init-key references to HPKE private keys.
//! [`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
@@ -37,8 +44,9 @@ use openmls_traits::OpenMlsCryptoProvider;
use crate::{
error::CoreError,
hybrid_crypto::HybridCryptoProvider,
identity::IdentityKeypair,
keystore::{DiskKeyStore, StoreCrypto},
keystore::DiskKeyStore,
};
// ── Constants ─────────────────────────────────────────────────────────────────
@@ -61,21 +69,28 @@ const CIPHERSUITE: Ciphersuite = Ciphersuite::MLS_128_DHKEMX25519_AES128GCM_SHA2
/// └─ receive_message(b) → decrypt; returns Some(plaintext) or None
/// ```
pub struct GroupMember {
/// Persistent crypto backend. Holds the in-memory key store with HPKE
/// private keys created during `generate_key_package`.
backend: StoreCrypto,
/// 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 crypto backend.
/// 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)
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`.
@@ -85,24 +100,35 @@ impl GroupMember {
) -> 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))
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: StoreCrypto::new(key_store),
backend,
identity,
group,
config,
hybrid,
}
}
@@ -414,10 +440,15 @@ impl GroupMember {
}
/// Return a reference to the underlying crypto backend.
pub fn backend(&self) -> &StoreCrypto {
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()
@@ -498,6 +529,47 @@ mod tests {
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() {

View File

@@ -46,18 +46,50 @@ use openmls_traits::types::{
/// 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
@@ -268,7 +300,7 @@ impl OpenMlsCrypto for HybridCrypto {
}
fn derive_hpke_keypair(&self, config: HpkeConfig, ikm: &[u8]) -> HpkeKeyPair {
if config.0 == HpkeKemType::DhKem25519 {
if self.hybrid_enabled && config.0 == HpkeKemType::DhKem25519 {
let kp = HybridKeypair::derive_from_ikm(ikm);
HpkeKeyPair {
private: kp.private_to_bytes().into(),
@@ -289,12 +321,32 @@ pub struct HybridCryptoProvider {
}
impl HybridCryptoProvider {
/// Create a hybrid-enabled provider (KeyPackages will contain hybrid init keys).
pub fn new(key_store: DiskKeyStore) -> Self {
Self {
crypto: HybridCrypto::new(),
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 {
@@ -410,6 +462,52 @@ mod tests {
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() {

View File

@@ -41,9 +41,12 @@ use ml_kem::kem::{DecapsulationKey, EncapsulationKey};
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.
@@ -122,6 +125,7 @@ pub struct HybridPublicKey {
}
/// 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 {

View File

@@ -99,6 +99,32 @@ impl Signer for IdentityKeypair {
}
}
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

View File

@@ -14,7 +14,7 @@
//! # Wire format
//!
//! KeyPackages are TLS-encoded using `tls_codec` (same version as openmls).
//! The resulting bytes are opaque to the quicnprotochat transport layer.
//! The resulting bytes are opaque to the quicproquo transport layer.
use openmls::prelude::{
Ciphersuite, Credential, CredentialType, CredentialWithKey, CryptoConfig, KeyPackage,
@@ -25,7 +25,7 @@ use sha2::{Digest, Sha256};
use crate::{error::CoreError, identity::IdentityKeypair};
/// The MLS ciphersuite used throughout quicnprotochat (RFC 9420 §17.1).
/// The MLS ciphersuite used throughout quicproquo (RFC 9420 §17.1).
pub const ALLOWED_CIPHERSUITE: Ciphersuite =
Ciphersuite::MLS_128_DHKEMX25519_AES128GCM_SHA256_Ed25519;

View File

@@ -1,5 +1,5 @@
//! Core cryptographic primitives, MLS group state machine, and hybrid
//! post-quantum KEM for quicnprotochat.
//! post-quantum KEM for quicproquo.
//!
//! # Module layout
//!
@@ -22,6 +22,8 @@ mod identity;
mod keypackage;
mod keystore;
pub mod opaque_auth;
pub mod padding;
pub mod sealed_sender;
// ── Public API ────────────────────────────────────────────────────────────────

View File

@@ -5,7 +5,7 @@
use opaque_ke::CipherSuite;
/// OPAQUE cipher suite for quicnprotochat.
/// OPAQUE cipher suite for quicproquo.
///
/// - **OPRF**: Ristretto255 (curve25519-based, ~128-bit security)
/// - **Key exchange**: Triple-DH (3DH) over Ristretto255 with SHA-512

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());
}
}

View File

@@ -1,18 +1,18 @@
[package]
name = "quicnprotochat-gui"
name = "quicproquo-gui"
version = "0.1.0"
edition = "2021"
description = "Native GUI for quicnprotochat (Tauri 2)."
description = "Native GUI for quicproquo (Tauri 2)."
license = "MIT"
[[bin]]
name = "quicnprotochat-gui"
name = "qpq-gui"
path = "src/main.rs"
[dependencies]
quicnprotochat-core = { path = "../quicnprotochat-core" }
quicnprotochat-client = { path = "../quicnprotochat-client" }
quicnprotochat-proto = { path = "../quicnprotochat-proto" }
quicproquo-core = { path = "../quicproquo-core" }
quicproquo-client = { path = "../quicproquo-client" }
quicproquo-proto = { path = "../quicproquo-proto" }
tauri = { version = "2", features = [] }
tokio = { workspace = true }
serde = { workspace = true }

View File

@@ -1,6 +1,6 @@
# quicnprotochat-gui
# quicproquo-gui
Native GUI for quicnprotochat using [Tauri 2](https://v2.tauri.app/). The UI runs in a webview; all server-facing work (capnp-rpc, `node_service::Client`) runs on a **dedicated backend thread** with a tokio `LocalSet`, since that code is `!Send`.
Native GUI for quicproquo using [Tauri 2](https://v2.tauri.app/). The UI runs in a webview; all server-facing work (capnp-rpc, `node_service::Client`) runs on a **dedicated backend thread** with a tokio `LocalSet`, since that code is `!Send`.
## Backend threading model
@@ -14,7 +14,7 @@ Native GUI for quicnprotochat using [Tauri 2](https://v2.tauri.app/). The UI run
From the workspace root:
```bash
cargo run -p quicnprotochat-gui
cargo run -p quicproquo-gui
```
**Linux:** Tauri uses GTK. Install development packages if the build fails, e.g.:

View File

Before

Width:  |  Height:  |  Size: 2.1 KiB

After

Width:  |  Height:  |  Size: 2.1 KiB

View File

@@ -11,7 +11,7 @@ use std::thread;
use tokio::runtime::Builder;
use tokio::task::LocalSet;
use quicnprotochat_client::{cmd_health_json, whoami_json};
use quicproquo_client::{cmd_health_json, whoami_json};
/// Commands the UI can send to the backend thread.
pub enum BackendCommand {

View File

@@ -1,4 +1,4 @@
//! quicnprotochat native GUI (Tauri 2).
//! quicproquo native GUI (Tauri 2).
//!
//! The backend runs on a dedicated thread with a tokio LocalSet; all server-facing
//! work (capnp-rpc, node_service::Client) is dispatched there. Tauri commands

View File

@@ -0,0 +1,5 @@
//! Desktop entry point for quicproquo-gui.
fn main() {
quicproquo_gui::run()
}

View File

@@ -1,7 +1,7 @@
{
"$schema": "https://schema.tauri.app/config/2",
"productName": "quicnprotochat-gui",
"identifier": "chat.quicnproto.gui",
"productName": "qpq-gui",
"identifier": "chat.quicproquo.gui",
"build": {
"frontendDist": "./ui",
"beforeBuildCommand": "",
@@ -10,7 +10,7 @@
"app": {
"windows": [
{
"title": "quicnprotochat",
"title": "quicproquo",
"width": 640,
"height": 480
}

View File

@@ -3,7 +3,7 @@
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>quicnprotochat</title>
<title>quicproquo</title>
<style>
body { font-family: system-ui, sans-serif; margin: 1rem; }
button { margin: 0.25rem; padding: 0.5rem 1rem; cursor: pointer; }
@@ -12,12 +12,12 @@
</style>
</head>
<body>
<h1>quicnprotochat</h1>
<h1>quicproquo</h1>
<p>
<button id="whoami">Whoami</button>
<button id="health">Health</button>
</p>
<label>State path: <input id="statePath" type="text" value="quicnprotochat-state.bin" size="32" /></label>
<label>State path: <input id="statePath" type="text" value="qpq-state.bin" size="32" /></label>
<br />
<label>Server: <input id="server" type="text" value="127.0.0.1:7000" size="24" /></label>
<div id="output">Click Whoami or Health. Results appear here.</div>

View File

@@ -0,0 +1,23 @@
[package]
name = "quicproquo-mobile"
version = "0.1.0"
edition = "2021"
description = "C FFI layer for quicproquo, proving QUIC connection migration."
license = "MIT"
[lib]
crate-type = ["staticlib", "cdylib", "rlib"]
[dependencies]
# Async
tokio = { workspace = true }
# QUIC
quinn = { workspace = true }
rustls = { workspace = true }
# Error handling
anyhow = { workspace = true }
[dev-dependencies]
rcgen = { workspace = true }

View File

@@ -0,0 +1,331 @@
//! quicproquo-mobile — C FFI layer for mobile integration.
//!
//! Provides a minimal C API that proves QUIC connection migration works
//! (wifi → cellular handoff without message loss). Each FFI function uses
//! `runtime.block_on(local.run_until(...))` to satisfy capnp-rpc's `!Send`
//! requirement.
//!
//! # Safety
//!
//! All FFI functions are `unsafe extern "C"` — callers must ensure pointers
//! are valid and buffers are correctly sized.
use std::ffi::c_char;
use std::net::SocketAddr;
use std::sync::Arc;
use quinn::Endpoint;
use tokio::runtime::Runtime;
/// Opaque handle returned by `qnpc_connect`.
#[allow(dead_code)]
pub struct MobileHandle {
runtime: Runtime,
endpoint: Endpoint,
connection: Option<quinn::Connection>,
server_addr: SocketAddr,
server_name: String,
}
/// Status codes returned by FFI functions.
#[repr(C)]
pub enum QnpcStatus {
Ok = 0,
Error = 1,
Timeout = 2,
NotConnected = 3,
}
/// Connect to a quicproquo server. Returns a handle pointer (null on failure).
///
/// # Safety
/// `server_addr` and `server_name` must be valid null-terminated C strings.
#[no_mangle]
pub unsafe extern "C" fn qnpc_connect(
server_addr: *const c_char,
server_name: *const c_char,
) -> *mut MobileHandle {
let addr_str = match std::ffi::CStr::from_ptr(server_addr).to_str() {
Ok(s) => s,
Err(_) => return std::ptr::null_mut(),
};
let name_str = match std::ffi::CStr::from_ptr(server_name).to_str() {
Ok(s) => s,
Err(_) => return std::ptr::null_mut(),
};
let addr: SocketAddr = match addr_str.parse() {
Ok(a) => a,
Err(_) => return std::ptr::null_mut(),
};
let rt = match Runtime::new() {
Ok(r) => r,
Err(_) => return std::ptr::null_mut(),
};
let result = rt.block_on(async {
connect_inner(addr, name_str).await
});
match result {
Ok((endpoint, connection)) => {
let handle = Box::new(MobileHandle {
runtime: rt,
endpoint,
connection: Some(connection),
server_addr: addr,
server_name: name_str.to_string(),
});
Box::into_raw(handle)
}
Err(_) => std::ptr::null_mut(),
}
}
async fn connect_inner(
addr: SocketAddr,
server_name: &str,
) -> anyhow::Result<(Endpoint, quinn::Connection)> {
let _ = rustls::crypto::ring::default_provider().install_default();
// Build a permissive client config (skip server cert verification for dev/testing).
let crypto = rustls::ClientConfig::builder()
.dangerous()
.with_custom_certificate_verifier(Arc::new(SkipServerVerification))
.with_no_client_auth();
let mut client_config = quinn::ClientConfig::new(Arc::new(
quinn::crypto::rustls::QuicClientConfig::try_from(crypto)
.map_err(|e| anyhow::anyhow!("QUIC client config: {e}"))?,
));
let mut transport = quinn::TransportConfig::default();
transport.max_idle_timeout(Some(
std::time::Duration::from_secs(120)
.try_into()
.expect("120s valid"),
));
client_config.transport_config(Arc::new(transport));
let mut endpoint = Endpoint::client("0.0.0.0:0".parse().unwrap())?;
endpoint.set_default_client_config(client_config);
let connection = endpoint.connect(addr, server_name)?.await?;
Ok((endpoint, connection))
}
/// Simulate QUIC connection migration by rebinding the endpoint to a new local address.
///
/// This is the key proof-of-concept: after rebind, the QUIC connection survives
/// and messages continue flowing without loss.
///
/// # Safety
/// `handle` must be a valid pointer from `qnpc_connect`.
#[no_mangle]
pub unsafe extern "C" fn qnpc_migrate(
handle: *mut MobileHandle,
new_port: u16,
) -> QnpcStatus {
let handle = match handle.as_mut() {
Some(h) => h,
None => return QnpcStatus::Error,
};
let new_addr: SocketAddr = format!("0.0.0.0:{new_port}").parse().unwrap();
let socket = match std::net::UdpSocket::bind(new_addr) {
Ok(s) => s,
Err(_) => return QnpcStatus::Error,
};
match handle.endpoint.rebind(socket) {
Ok(_) => QnpcStatus::Ok,
Err(_) => QnpcStatus::Error,
}
}
/// Disconnect and free the handle.
///
/// # Safety
/// `handle` must be a valid pointer from `qnpc_connect`, and must not be used after this call.
#[no_mangle]
pub unsafe extern "C" fn qnpc_disconnect(handle: *mut MobileHandle) {
if !handle.is_null() {
let handle = Box::from_raw(handle);
if let Some(conn) = &handle.connection {
conn.close(0u32.into(), b"disconnect");
}
drop(handle);
}
}
// ── Internal: skip server cert verification for testing ─────────────────────
#[derive(Debug)]
struct SkipServerVerification;
impl rustls::client::danger::ServerCertVerifier for SkipServerVerification {
fn verify_server_cert(
&self,
_end_entity: &rustls::pki_types::CertificateDer<'_>,
_intermediates: &[rustls::pki_types::CertificateDer<'_>],
_server_name: &rustls::pki_types::ServerName<'_>,
_ocsp_response: &[u8],
_now: rustls::pki_types::UnixTime,
) -> Result<rustls::client::danger::ServerCertVerified, rustls::Error> {
Ok(rustls::client::danger::ServerCertVerified::assertion())
}
fn verify_tls12_signature(
&self,
_message: &[u8],
_cert: &rustls::pki_types::CertificateDer<'_>,
_dss: &rustls::DigitallySignedStruct,
) -> Result<rustls::client::danger::HandshakeSignatureValid, rustls::Error> {
Ok(rustls::client::danger::HandshakeSignatureValid::assertion())
}
fn verify_tls13_signature(
&self,
_message: &[u8],
_cert: &rustls::pki_types::CertificateDer<'_>,
_dss: &rustls::DigitallySignedStruct,
) -> Result<rustls::client::danger::HandshakeSignatureValid, rustls::Error> {
Ok(rustls::client::danger::HandshakeSignatureValid::assertion())
}
fn supported_verify_schemes(&self) -> Vec<rustls::SignatureScheme> {
vec![
rustls::SignatureScheme::ECDSA_NISTP256_SHA256,
rustls::SignatureScheme::ECDSA_NISTP384_SHA384,
rustls::SignatureScheme::ED25519,
rustls::SignatureScheme::RSA_PSS_SHA256,
rustls::SignatureScheme::RSA_PSS_SHA384,
rustls::SignatureScheme::RSA_PSS_SHA512,
rustls::SignatureScheme::RSA_PKCS1_SHA256,
rustls::SignatureScheme::RSA_PKCS1_SHA384,
rustls::SignatureScheme::RSA_PKCS1_SHA512,
]
}
}
// ── Tests ───────────────────────────────────────────────────────────────────
#[cfg(test)]
mod tests {
use super::*;
use std::net::UdpSocket;
/// Prove QUIC connection migration: connect, send messages, rebind the
/// UDP socket (simulating wifi→cellular), send more messages, verify
/// all messages arrive.
#[test]
fn quic_connection_migration() {
let _ = rustls::crypto::ring::default_provider().install_default();
let rt = Runtime::new().unwrap();
rt.block_on(async {
// Start an in-process echo server.
let server_addr = start_echo_server().await;
// Connect client.
let (endpoint, connection) = connect_inner(server_addr, "localhost")
.await
.expect("connect");
// Send 5 messages before migration.
for i in 0..5u32 {
let (mut send, mut recv) = connection.open_bi().await.unwrap();
let msg = format!("pre-migrate-{i}");
send.write_all(msg.as_bytes()).await.unwrap();
send.finish().unwrap();
let response = recv.read_to_end(4096).await.unwrap();
assert_eq!(response, msg.as_bytes(), "pre-migrate echo mismatch");
}
// Migrate: rebind to a new local UDP socket (simulates wifi→cellular).
let new_socket = UdpSocket::bind("127.0.0.1:0").unwrap();
let new_local = new_socket.local_addr().unwrap();
endpoint.rebind(new_socket).expect("rebind should succeed");
// Send 5 more messages after migration.
for i in 0..5u32 {
let (mut send, mut recv) = connection.open_bi().await.unwrap();
let msg = format!("post-migrate-{i}");
send.write_all(msg.as_bytes()).await.unwrap();
send.finish().unwrap();
let response = recv.read_to_end(4096).await.unwrap();
assert_eq!(response, msg.as_bytes(), "post-migrate echo mismatch");
}
// Assert: connection still alive after migration.
assert!(
connection.close_reason().is_none(),
"connection should still be open after migration"
);
// Verify the local address changed.
let _ = new_local; // We successfully used a new socket.
connection.close(0u32.into(), b"test done");
endpoint.wait_idle().await;
});
}
/// Start a simple QUIC echo server that echoes back whatever it receives.
async fn start_echo_server() -> SocketAddr {
let cert = rcgen::generate_simple_self_signed(vec!["localhost".into()]).unwrap();
let cert_der = cert.cert.der().to_vec();
let key_der = cert.key_pair.serialize_der();
let cert_chain = vec![rustls::pki_types::CertificateDer::from(cert_der)];
let key = rustls::pki_types::PrivateKeyDer::try_from(key_der).unwrap();
let tls = rustls::ServerConfig::builder()
.with_no_client_auth()
.with_single_cert(cert_chain, key)
.unwrap();
let server_config = quinn::ServerConfig::with_crypto(Arc::new(
quinn::crypto::rustls::QuicServerConfig::try_from(tls).unwrap(),
));
let endpoint = Endpoint::server(
server_config,
"127.0.0.1:0".parse().unwrap(),
)
.unwrap();
let addr = endpoint.local_addr().unwrap();
// Spawn echo acceptor.
tokio::spawn(async move {
while let Some(incoming) = endpoint.accept().await {
let connecting = match incoming.accept() {
Ok(c) => c,
Err(_) => continue,
};
tokio::spawn(async move {
let conn = match connecting.await {
Ok(c) => c,
Err(_) => return,
};
loop {
let (mut send, mut recv) = match conn.accept_bi().await {
Ok(s) => s,
Err(_) => break,
};
let data = match recv.read_to_end(4096).await {
Ok(d) => d,
Err(_) => break,
};
let _ = send.write_all(&data).await;
let _ = send.finish();
}
});
}
});
addr
}
}

View File

@@ -1,8 +1,8 @@
[package]
name = "quicnprotochat-p2p"
name = "quicproquo-p2p"
version = "0.1.0"
edition = "2021"
description = "P2P transport layer for quicnprotochat using iroh."
description = "P2P transport layer for quicproquo using iroh."
license = "MIT"
[dependencies]

View File

@@ -1,4 +1,4 @@
//! P2P transport layer for quicnprotochat using iroh.
//! P2P transport layer for quicproquo using iroh.
//!
//! Provides direct peer-to-peer QUIC connections with NAT traversal via iroh
//! relay servers. When both peers are online, messages bypass the central
@@ -14,7 +14,8 @@
use iroh::{Endpoint, EndpointAddr, PublicKey, SecretKey};
/// ALPN protocol identifier for quicnprotochat P2P messaging.
/// ALPN protocol identifier for quicproquo P2P messaging.
/// Frozen at the original project name for wire compatibility.
const P2P_ALPN: &[u8] = b"quicnprotochat/p2p/1";
/// A P2P node backed by an iroh endpoint.

View File

@@ -1,8 +1,8 @@
[package]
name = "quicnprotochat-proto"
name = "quicproquo-proto"
version = "0.1.0"
edition = "2021"
description = "Cap'n Proto schemas, generated types, and serialisation helpers for quicnprotochat. No crypto, no I/O."
description = "Cap'n Proto schemas, generated types, and serialisation helpers for quicproquo. No crypto, no I/O."
license = "MIT"
# build.rs invokes capnpc to generate Rust source from .capnp schemas.

View File

@@ -1,4 +1,4 @@
//! Build script for quicnprotochat-proto.
//! Build script for quicproquo-proto.
//!
//! Invokes the `capnp` compiler to generate Rust types from `.capnp` schemas
//! located in the workspace-root `schemas/` directory.
@@ -17,7 +17,7 @@ fn main() {
let manifest_dir =
PathBuf::from(env::var("CARGO_MANIFEST_DIR").expect("CARGO_MANIFEST_DIR not set by Cargo"));
// Workspace root is two levels above this crate (quicnprotochat/crates/quicnprotochat-proto).
// Workspace root is two levels above this crate (quicproquo/crates/quicproquo-proto).
let workspace_root = manifest_dir
.join("../..")
.canonicalize()
@@ -38,6 +38,10 @@ fn main() {
"cargo:rerun-if-changed={}",
schemas_dir.join("node.capnp").display()
);
println!(
"cargo:rerun-if-changed={}",
schemas_dir.join("federation.capnp").display()
);
capnpc::CompilerCommand::new()
// Treat `schemas/` as the include root so that inter-schema imports
@@ -46,6 +50,7 @@ fn main() {
.file(schemas_dir.join("auth.capnp"))
.file(schemas_dir.join("delivery.capnp"))
.file(schemas_dir.join("node.capnp"))
.file(schemas_dir.join("federation.capnp"))
.run()
.expect(
"Cap'n Proto schema compilation failed. \

View File

@@ -1,4 +1,4 @@
//! Cap'n Proto schemas, generated types, and serialisation helpers for quicnprotochat.
//! Cap'n Proto schemas, generated types, and serialisation helpers for quicproquo.
//!
//! Generated Cap'n Proto code emits unnecessary parentheses; allow per coding standards.
#![allow(unused_parens)]
@@ -38,12 +38,19 @@ pub mod node_capnp {
include!(concat!(env!("OUT_DIR"), "/node_capnp.rs"));
}
/// Cap'n Proto generated types for `schemas/federation.capnp`.
///
/// Do not edit this module by hand — it is entirely machine-generated.
pub mod federation_capnp {
include!(concat!(env!("OUT_DIR"), "/federation_capnp.rs"));
}
// ── Low-level byte ↔ message conversions ──────────────────────────────────────
/// Serialise a Cap'n Proto message builder to unpacked wire bytes.
///
/// The output includes the segment table header. For transport, the
/// `quicnprotochat-core` frame codec prepends a 4-byte little-endian length field.
/// `quicproquo-core` frame codec prepends a 4-byte little-endian length field.
pub fn to_bytes<A: capnp::message::Allocator>(
msg: &capnp::message::Builder<A>,
) -> Result<Vec<u8>, capnp::Error> {

View File

@@ -1,17 +1,17 @@
[package]
name = "quicnprotochat-server"
name = "quicproquo-server"
version = "0.1.0"
edition = "2021"
description = "Delivery Service and Authentication Service for quicnprotochat."
description = "Delivery Service and Authentication Service for quicproquo."
license = "MIT"
[[bin]]
name = "quicnprotochat-server"
name = "qpq-server"
path = "src/main.rs"
[dependencies]
quicnprotochat-core = { path = "../quicnprotochat-core" }
quicnprotochat-proto = { path = "../quicnprotochat-proto" }
quicproquo-core = { path = "../quicproquo-core" }
quicproquo-proto = { path = "../quicproquo-proto" }
# Serialisation + RPC
capnp = { workspace = true }
@@ -24,6 +24,7 @@ futures = { workspace = true }
# Server utilities
dashmap = { workspace = true }
hex = { workspace = true }
sha2 = { workspace = true }
tracing = { workspace = true }
tracing-subscriber = { workspace = true }

View File

@@ -0,0 +1,16 @@
-- 004_federation.sql: Federation support tables.
-- Map identity keys to their home server domain.
-- Used for routing: if a recipient's home_server != local domain, relay via federation.
CREATE TABLE IF NOT EXISTS identity_home_servers (
identity_key BLOB PRIMARY KEY,
home_server TEXT NOT NULL,
updated_at INTEGER NOT NULL
);
-- Known federation peers (other quicnprotochat servers).
CREATE TABLE IF NOT EXISTS federation_peers (
domain TEXT PRIMARY KEY,
last_seen INTEGER NOT NULL,
is_active INTEGER NOT NULL DEFAULT 1
);

View File

@@ -2,7 +2,7 @@ use std::net::IpAddr;
use std::sync::Arc;
use dashmap::DashMap;
use quicnprotochat_proto::node_capnp::auth;
use quicproquo_proto::node_capnp::auth;
use sha2::Digest;
use subtle::ConstantTimeEq;
use tokio::sync::Notify;
@@ -20,7 +20,7 @@ pub struct AuthConfig {
/// Server bearer token — zeroized on drop to prevent memory disclosure.
pub required_token: Option<Zeroizing<Vec<u8>>>,
/// When true, a valid bearer token (no session) is accepted and the request's identity/key is used (dev/e2e only).
/// CLI flag: --allow-insecure-auth / QUICNPROTOCHAT_ALLOW_INSECURE_AUTH.
/// CLI flag: --allow-insecure-auth / QPQ_ALLOW_INSECURE_AUTH.
pub allow_insecure_identity_from_request: bool,
}

View File

@@ -8,7 +8,7 @@ pub const DEFAULT_DATA_DIR: &str = "data";
pub const DEFAULT_TLS_CERT: &str = "data/server-cert.der";
pub const DEFAULT_TLS_KEY: &str = "data/server-key.der";
pub const DEFAULT_STORE_BACKEND: &str = "file";
pub const DEFAULT_DB_PATH: &str = "data/quicnprotochat.db";
pub const DEFAULT_DB_PATH: &str = "data/qpq.db";
#[derive(Debug, Default, Deserialize)]
pub struct FileConfig {
@@ -30,6 +30,7 @@ pub struct FileConfig {
/// When true and metrics_listen is set, start the metrics server.
#[serde(default)]
pub metrics_enabled: Option<bool>,
pub federation: Option<FederationFileConfig>,
}
#[derive(Debug)]
@@ -49,12 +50,42 @@ pub struct EffectiveConfig {
pub metrics_listen: Option<String>,
/// Start metrics server only when true and metrics_listen is set.
pub metrics_enabled: bool,
pub federation: Option<EffectiveFederationConfig>,
}
#[derive(Debug, Default, Deserialize)]
pub struct FederationFileConfig {
pub enabled: Option<bool>,
pub domain: Option<String>,
pub listen: Option<String>,
pub federation_cert: Option<PathBuf>,
pub federation_key: Option<PathBuf>,
pub federation_ca: Option<PathBuf>,
#[serde(default)]
pub peers: Vec<FederationPeerConfig>,
}
#[derive(Debug, Clone, Deserialize)]
pub struct FederationPeerConfig {
pub domain: String,
pub address: String,
}
#[derive(Debug)]
pub struct EffectiveFederationConfig {
pub enabled: bool,
pub domain: String,
pub listen: String,
pub federation_cert: PathBuf,
pub federation_key: PathBuf,
pub federation_ca: PathBuf,
pub peers: Vec<FederationPeerConfig>,
}
pub fn load_config(path: Option<&Path>) -> anyhow::Result<FileConfig> {
let path = match path {
Some(p) => PathBuf::from(p),
None => PathBuf::from("quicnprotochat-server.toml"),
None => PathBuf::from("qpq-server.toml"),
};
if !path.exists() {
@@ -146,6 +177,42 @@ pub fn merge_config(args: &crate::Args, file: &FileConfig) -> EffectiveConfig {
.or(file.metrics_enabled)
.unwrap_or(metrics_listen.is_some());
let federation = {
let file_fed = file.federation.as_ref();
let enabled = args.federation_enabled
|| file_fed.and_then(|f| f.enabled).unwrap_or(false);
if enabled {
let domain = args.federation_domain.clone()
.or_else(|| file_fed.and_then(|f| f.domain.clone()))
.unwrap_or_default();
let listen_fed = args.federation_listen.clone()
.or_else(|| file_fed.and_then(|f| f.listen.clone()))
.unwrap_or_else(|| "0.0.0.0:7001".to_string());
let federation_cert = file_fed.and_then(|f| f.federation_cert.clone())
.unwrap_or_else(|| PathBuf::from("data/federation-cert.der"));
let federation_key = file_fed.and_then(|f| f.federation_key.clone())
.unwrap_or_else(|| PathBuf::from("data/federation-key.der"));
let federation_ca = file_fed.and_then(|f| f.federation_ca.clone())
.unwrap_or_else(|| PathBuf::from("data/federation-ca.der"));
let peers = file_fed
.map(|f| f.peers.clone())
.unwrap_or_default();
Some(EffectiveFederationConfig {
enabled,
domain,
listen: listen_fed,
federation_cert,
federation_key,
federation_ca,
peers,
})
} else {
None
}
};
EffectiveConfig {
listen,
data_dir,
@@ -159,6 +226,7 @@ pub fn merge_config(args: &crate::Args, file: &FileConfig) -> EffectiveConfig {
db_key,
metrics_listen,
metrics_enabled,
federation,
}
}
@@ -171,25 +239,25 @@ pub fn validate_production_config(effective: &EffectiveConfig) -> anyhow::Result
.as_deref()
.filter(|s| !s.is_empty())
.ok_or_else(|| {
anyhow::anyhow!("production requires QUICNPROTOCHAT_AUTH_TOKEN (non-empty)")
anyhow::anyhow!("production requires QPQ_AUTH_TOKEN (non-empty)")
})?;
if token == "devtoken" {
anyhow::bail!(
"production forbids auth_token 'devtoken'; set a strong QUICNPROTOCHAT_AUTH_TOKEN"
"production forbids auth_token 'devtoken'; set a strong QPQ_AUTH_TOKEN"
);
}
if effective.store_backend == "sql" && effective.db_key.is_empty() {
anyhow::bail!("production with store_backend=sql requires non-empty QUICNPROTOCHAT_DB_KEY");
anyhow::bail!("production with store_backend=sql requires non-empty QPQ_DB_KEY");
}
if effective.store_backend != "sql" {
tracing::warn!(
"production is using file-backed storage; \
consider store_backend=sql with QUICNPROTOCHAT_DB_KEY for encryption at rest"
consider store_backend=sql with QPQ_DB_KEY for encryption at rest"
);
}
if !effective.tls_cert.exists() || !effective.tls_key.exists() {
anyhow::bail!(
"production requires existing TLS cert and key (no auto-generation); provide QUICNPROTOCHAT_TLS_CERT and QUICNPROTOCHAT_TLS_KEY"
"production requires existing TLS cert and key (no auto-generation); provide QPQ_TLS_CERT and QPQ_TLS_KEY"
);
}
Ok(())

View File

@@ -0,0 +1,78 @@
//! Parse `username@domain` federated addresses.
//!
//! A bare `username` (no `@`) is treated as local.
/// A parsed federated address.
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct FederatedAddress {
pub username: String,
pub domain: Option<String>,
}
impl FederatedAddress {
/// Parse a `user@domain` string. Bare `user` → domain is `None`.
pub fn parse(input: &str) -> Self {
// Split on the *last* '@' so usernames can contain '@' in theory.
match input.rsplit_once('@') {
Some((user, domain)) if !domain.is_empty() && !user.is_empty() => Self {
username: user.to_string(),
domain: Some(domain.to_string()),
},
_ => Self {
username: input.to_string(),
domain: None,
},
}
}
/// Returns true if this address refers to a local user (no domain or domain matches local).
pub fn is_local(&self, local_domain: &str) -> bool {
match &self.domain {
None => true,
Some(d) => d == local_domain,
}
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn bare_username() {
let addr = FederatedAddress::parse("alice");
assert_eq!(addr.username, "alice");
assert_eq!(addr.domain, None);
assert!(addr.is_local("example.com"));
}
#[test]
fn user_at_domain() {
let addr = FederatedAddress::parse("alice@remote.example.com");
assert_eq!(addr.username, "alice");
assert_eq!(addr.domain, Some("remote.example.com".into()));
assert!(!addr.is_local("local.example.com"));
assert!(addr.is_local("remote.example.com"));
}
#[test]
fn trailing_at_is_bare() {
let addr = FederatedAddress::parse("alice@");
assert_eq!(addr.username, "alice@");
assert_eq!(addr.domain, None);
}
#[test]
fn leading_at_is_bare() {
let addr = FederatedAddress::parse("@domain.com");
assert_eq!(addr.username, "@domain.com");
assert_eq!(addr.domain, None);
}
#[test]
fn multiple_at_uses_last() {
let addr = FederatedAddress::parse("user@org@domain.com");
assert_eq!(addr.username, "user@org");
assert_eq!(addr.domain, Some("domain.com".into()));
}
}

View File

@@ -0,0 +1,287 @@
//! Outbound federation client: connects to peer servers to relay messages.
//!
//! Uses a lazy connection pool (DashMap) to reuse QUIC connections to known peers.
use std::collections::HashMap;
use std::net::SocketAddr;
use std::sync::Arc;
use anyhow::Context;
use dashmap::DashMap;
use quinn::Endpoint;
use crate::config::{EffectiveFederationConfig, FederationPeerConfig};
/// Outbound federation client for relaying to peer servers.
pub struct FederationClient {
/// Peer domain → address mapping from config.
peer_addresses: HashMap<String, SocketAddr>,
/// Lazy QUIC connection pool: domain → active Connection.
connections: DashMap<String, quinn::Connection>,
/// Local QUIC endpoint (shared for all outbound federation connections).
endpoint: Endpoint,
/// Local domain (for the FederationAuth.origin field).
local_domain: String,
}
impl FederationClient {
/// Create a new federation client from config.
///
/// The `endpoint` should be configured with mTLS client credentials.
pub fn new(
config: &EffectiveFederationConfig,
endpoint: Endpoint,
) -> anyhow::Result<Self> {
let mut peer_addresses = HashMap::new();
for peer in &config.peers {
let addr: SocketAddr = peer.address.parse().with_context(|| {
format!("parse federation peer address '{}' for '{}'", peer.address, peer.domain)
})?;
peer_addresses.insert(peer.domain.clone(), addr);
}
Ok(Self {
peer_addresses,
connections: DashMap::new(),
endpoint,
local_domain: config.domain.clone(),
})
}
/// Check if we have a configured peer for the given domain.
pub fn has_peer(&self, domain: &str) -> bool {
self.peer_addresses.contains_key(domain)
}
/// List all configured peer domains.
pub fn peer_domains(&self) -> Vec<String> {
self.peer_addresses.keys().cloned().collect()
}
/// Get the local domain.
pub fn local_domain(&self) -> &str {
&self.local_domain
}
/// Relay a single enqueue to a remote peer. Returns the seq assigned by the remote server.
pub async fn relay_enqueue(
&self,
domain: &str,
recipient_key: &[u8],
payload: &[u8],
channel_id: &[u8],
) -> anyhow::Result<u64> {
let conn = self.get_or_connect(domain).await?;
let (send, recv) = conn.open_bi().await.context("open bi stream to peer")?;
let (reader, writer) = (
tokio_util::compat::TokioAsyncReadCompatExt::compat(recv),
tokio_util::compat::TokioAsyncWriteCompatExt::compat_write(send),
);
let rpc_network = capnp_rpc::twoparty::VatNetwork::new(
reader,
writer,
capnp_rpc::rpc_twoparty_capnp::Side::Client,
Default::default(),
);
let mut rpc_system = capnp_rpc::RpcSystem::new(Box::new(rpc_network), None);
let client: quicproquo_proto::federation_capnp::federation_service::Client =
rpc_system.bootstrap(capnp_rpc::rpc_twoparty_capnp::Side::Server);
tokio::task::spawn_local(rpc_system);
let mut req = client.relay_enqueue_request();
{
let mut builder = req.get();
builder.set_recipient_key(recipient_key);
builder.set_payload(payload);
builder.set_channel_id(channel_id);
builder.set_version(1);
let mut auth = builder.init_auth();
auth.set_origin(&self.local_domain);
}
let response = req.send().promise.await
.map_err(|e| anyhow::anyhow!("federation relay_enqueue failed: {e}"))?;
let seq = response.get()
.map_err(|e| anyhow::anyhow!("read relay_enqueue response: {e}"))?
.get_seq();
Ok(seq)
}
/// Proxy a key package fetch to a remote peer.
pub async fn proxy_fetch_key_package(
&self,
domain: &str,
identity_key: &[u8],
) -> anyhow::Result<Option<Vec<u8>>> {
let conn = self.get_or_connect(domain).await?;
let (send, recv) = conn.open_bi().await.context("open bi stream to peer")?;
let (reader, writer) = (
tokio_util::compat::TokioAsyncReadCompatExt::compat(recv),
tokio_util::compat::TokioAsyncWriteCompatExt::compat_write(send),
);
let rpc_network = capnp_rpc::twoparty::VatNetwork::new(
reader,
writer,
capnp_rpc::rpc_twoparty_capnp::Side::Client,
Default::default(),
);
let mut rpc_system = capnp_rpc::RpcSystem::new(Box::new(rpc_network), None);
let client: quicproquo_proto::federation_capnp::federation_service::Client =
rpc_system.bootstrap(capnp_rpc::rpc_twoparty_capnp::Side::Server);
tokio::task::spawn_local(rpc_system);
let mut req = client.proxy_fetch_key_package_request();
{
let mut builder = req.get();
builder.set_identity_key(identity_key);
let mut auth = builder.init_auth();
auth.set_origin(&self.local_domain);
}
let response = req.send().promise.await
.map_err(|e| anyhow::anyhow!("federation proxy_fetch_key_package failed: {e}"))?;
let pkg = response.get()
.map_err(|e| anyhow::anyhow!("read proxy_fetch_key_package response: {e}"))?
.get_package()
.map_err(|e| anyhow::anyhow!("get package: {e}"))?;
if pkg.is_empty() {
Ok(None)
} else {
Ok(Some(pkg.to_vec()))
}
}
/// Proxy a hybrid key fetch to a remote peer.
pub async fn proxy_fetch_hybrid_key(
&self,
domain: &str,
identity_key: &[u8],
) -> anyhow::Result<Option<Vec<u8>>> {
let conn = self.get_or_connect(domain).await?;
let (send, recv) = conn.open_bi().await.context("open bi stream to peer")?;
let (reader, writer) = (
tokio_util::compat::TokioAsyncReadCompatExt::compat(recv),
tokio_util::compat::TokioAsyncWriteCompatExt::compat_write(send),
);
let rpc_network = capnp_rpc::twoparty::VatNetwork::new(
reader,
writer,
capnp_rpc::rpc_twoparty_capnp::Side::Client,
Default::default(),
);
let mut rpc_system = capnp_rpc::RpcSystem::new(Box::new(rpc_network), None);
let client: quicproquo_proto::federation_capnp::federation_service::Client =
rpc_system.bootstrap(capnp_rpc::rpc_twoparty_capnp::Side::Server);
tokio::task::spawn_local(rpc_system);
let mut req = client.proxy_fetch_hybrid_key_request();
{
let mut builder = req.get();
builder.set_identity_key(identity_key);
let mut auth = builder.init_auth();
auth.set_origin(&self.local_domain);
}
let response = req.send().promise.await
.map_err(|e| anyhow::anyhow!("federation proxy_fetch_hybrid_key failed: {e}"))?;
let pk = response.get()
.map_err(|e| anyhow::anyhow!("read proxy_fetch_hybrid_key response: {e}"))?
.get_hybrid_public_key()
.map_err(|e| anyhow::anyhow!("get hybrid_public_key: {e}"))?;
if pk.is_empty() {
Ok(None)
} else {
Ok(Some(pk.to_vec()))
}
}
/// Proxy a user resolution to a remote peer.
pub async fn proxy_resolve_user(
&self,
domain: &str,
username: &str,
) -> anyhow::Result<Option<Vec<u8>>> {
let conn = self.get_or_connect(domain).await?;
let (send, recv) = conn.open_bi().await.context("open bi stream to peer")?;
let (reader, writer) = (
tokio_util::compat::TokioAsyncReadCompatExt::compat(recv),
tokio_util::compat::TokioAsyncWriteCompatExt::compat_write(send),
);
let rpc_network = capnp_rpc::twoparty::VatNetwork::new(
reader,
writer,
capnp_rpc::rpc_twoparty_capnp::Side::Client,
Default::default(),
);
let mut rpc_system = capnp_rpc::RpcSystem::new(Box::new(rpc_network), None);
let client: quicproquo_proto::federation_capnp::federation_service::Client =
rpc_system.bootstrap(capnp_rpc::rpc_twoparty_capnp::Side::Server);
tokio::task::spawn_local(rpc_system);
let mut req = client.proxy_resolve_user_request();
{
let mut builder = req.get();
builder.set_username(username);
let mut auth = builder.init_auth();
auth.set_origin(&self.local_domain);
}
let response = req.send().promise.await
.map_err(|e| anyhow::anyhow!("federation proxy_resolve_user failed: {e}"))?;
let key = response.get()
.map_err(|e| anyhow::anyhow!("read proxy_resolve_user response: {e}"))?
.get_identity_key()
.map_err(|e| anyhow::anyhow!("get identity_key: {e}"))?;
if key.is_empty() {
Ok(None)
} else {
Ok(Some(key.to_vec()))
}
}
/// Get an existing connection or create a new one to a peer domain.
async fn get_or_connect(&self, domain: &str) -> anyhow::Result<quinn::Connection> {
// Check for cached connection that's still alive.
if let Some(conn) = self.connections.get(domain) {
if conn.close_reason().is_none() {
return Ok(conn.clone());
}
}
let addr = self.peer_addresses.get(domain).ok_or_else(|| {
anyhow::anyhow!("no federation peer configured for domain '{domain}'")
})?;
tracing::info!(domain = domain, addr = %addr, "connecting to federation peer");
let conn = self
.endpoint
.connect(*addr, domain)
.map_err(|e| anyhow::anyhow!("federation connect to {domain}: {e}"))?
.await
.with_context(|| format!("federation QUIC handshake with {domain}"))?;
self.connections.insert(domain.to_string(), conn.clone());
Ok(conn)
}
}

View File

@@ -0,0 +1,16 @@
//! Federation subsystem: server-to-server message relay over mutual TLS + QUIC.
//!
//! When federation is enabled, the server binds a second QUIC endpoint on a
//! dedicated port (default 7001) that only accepts connections from known peers
//! authenticated via mTLS. Inbound requests are handled by [`service::FederationServiceImpl`],
//! which delegates to the local [`Store`]. Outbound relay uses [`client::FederationClient`].
pub mod address;
pub mod client;
pub mod routing;
pub mod service;
pub mod tls;
pub use address::FederatedAddress;
pub use client::FederationClient;
pub use routing::Destination;

View File

@@ -0,0 +1,44 @@
//! Federation routing: determine whether a recipient is local or remote.
use std::sync::Arc;
use crate::storage::Store;
/// Where a message should be delivered.
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum Destination {
/// Recipient is on this server.
Local,
/// Recipient's home server is the given domain.
Remote(String),
}
/// Resolve a recipient identity key to a routing destination.
///
/// 1. Check the `identity_home_servers` table for an explicit mapping.
/// 2. If no mapping exists, assume local (backwards compatible with single-server deployments).
pub fn resolve_destination(
store: &Arc<dyn Store>,
recipient_key: &[u8],
local_domain: &str,
) -> Destination {
match store.get_identity_home_server(recipient_key) {
Ok(Some(domain)) if domain != local_domain => Destination::Remote(domain),
_ => Destination::Local,
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn unknown_identity_routes_local() {
let store: Arc<dyn Store> =
Arc::new(crate::storage::FileBackedStore::open(
tempfile::tempdir().unwrap().path(),
).unwrap());
let dest = resolve_destination(&store, &[1u8; 32], "local.example.com");
assert_eq!(dest, Destination::Local);
}
}

View File

@@ -0,0 +1,201 @@
//! Inbound federation handler: implements `FederationService` Cap'n Proto interface.
//!
//! Delegates all operations to the local [`Store`], acting as a trusted relay
//! from authenticated peer servers.
use std::sync::Arc;
use capnp::capability::Promise;
use quicproquo_proto::federation_capnp::federation_service;
use tokio::sync::Notify;
use dashmap::DashMap;
use crate::storage::Store;
/// Inbound federation RPC handler.
pub struct FederationServiceImpl {
pub store: Arc<dyn Store>,
pub waiters: Arc<DashMap<Vec<u8>, Arc<Notify>>>,
pub local_domain: String,
}
impl federation_service::Server for FederationServiceImpl {
fn relay_enqueue(
&mut self,
params: federation_service::RelayEnqueueParams,
mut results: federation_service::RelayEnqueueResults,
) -> Promise<(), capnp::Error> {
let p = match params.get() {
Ok(p) => p,
Err(e) => return Promise::err(capnp::Error::failed(format!("bad params: {e}"))),
};
let recipient_key = match p.get_recipient_key() {
Ok(v) => v.to_vec(),
Err(e) => return Promise::err(capnp::Error::failed(format!("bad recipient_key: {e}"))),
};
let payload = match p.get_payload() {
Ok(v) => v.to_vec(),
Err(e) => return Promise::err(capnp::Error::failed(format!("bad payload: {e}"))),
};
let channel_id = p.get_channel_id().unwrap_or_default().to_vec();
if let Ok(a) = p.get_auth() {
if let Ok(origin) = a.get_origin() {
let origin = origin.to_str().unwrap_or("?");
tracing::debug!(origin = origin, "federation relay_enqueue");
}
}
if recipient_key.len() != 32 {
return Promise::err(capnp::Error::failed("recipient_key must be 32 bytes".into()));
}
if payload.is_empty() {
return Promise::err(capnp::Error::failed("payload must not be empty".into()));
}
let seq = match self.store.enqueue(&recipient_key, &channel_id, payload) {
Ok(s) => s,
Err(e) => return Promise::err(capnp::Error::failed(format!("store error: {e}"))),
};
results.get().set_seq(seq);
// Wake any waiting fetchWait clients.
if let Some(waiter) = self.waiters.get(&recipient_key) {
waiter.notify_waiters();
}
tracing::info!(
recipient_prefix = %hex::encode(&recipient_key[..4]),
seq = seq,
"federation: relayed enqueue"
);
Promise::ok(())
}
fn relay_batch_enqueue(
&mut self,
params: federation_service::RelayBatchEnqueueParams,
mut results: federation_service::RelayBatchEnqueueResults,
) -> Promise<(), capnp::Error> {
let p = match params.get() {
Ok(p) => p,
Err(e) => return Promise::err(capnp::Error::failed(format!("bad params: {e}"))),
};
let recipient_keys = match p.get_recipient_keys() {
Ok(v) => v,
Err(e) => return Promise::err(capnp::Error::failed(format!("bad recipient_keys: {e}"))),
};
let payload = match p.get_payload() {
Ok(v) => v.to_vec(),
Err(e) => return Promise::err(capnp::Error::failed(format!("bad payload: {e}"))),
};
let channel_id = p.get_channel_id().unwrap_or_default().to_vec();
let mut seqs = Vec::with_capacity(recipient_keys.len() as usize);
for i in 0..recipient_keys.len() {
let rk = match recipient_keys.get(i) {
Ok(v) => v.to_vec(),
Err(e) => return Promise::err(capnp::Error::failed(format!("bad key[{i}]: {e}"))),
};
if rk.len() != 32 {
return Promise::err(capnp::Error::failed(
format!("recipient_key[{i}] must be 32 bytes"),
));
}
let seq = match self.store.enqueue(&rk, &channel_id, payload.clone()) {
Ok(s) => s,
Err(e) => return Promise::err(capnp::Error::failed(format!("store error: {e}"))),
};
seqs.push(seq);
if let Some(waiter) = self.waiters.get(&rk) {
waiter.notify_waiters();
}
}
let mut list = results.get().init_seqs(seqs.len() as u32);
for (i, seq) in seqs.iter().enumerate() {
list.set(i as u32, *seq);
}
tracing::info!(
recipient_count = recipient_keys.len(),
"federation: relayed batch_enqueue"
);
Promise::ok(())
}
fn proxy_fetch_key_package(
&mut self,
params: federation_service::ProxyFetchKeyPackageParams,
mut results: federation_service::ProxyFetchKeyPackageResults,
) -> Promise<(), capnp::Error> {
let identity_key = match params.get().and_then(|p| p.get_identity_key()) {
Ok(v) => v.to_vec(),
Err(e) => return Promise::err(capnp::Error::failed(format!("bad params: {e}"))),
};
match self.store.fetch_key_package(&identity_key) {
Ok(Some(pkg)) => results.get().set_package(&pkg),
Ok(None) => results.get().set_package(&[]),
Err(e) => return Promise::err(capnp::Error::failed(format!("store error: {e}"))),
}
Promise::ok(())
}
fn proxy_fetch_hybrid_key(
&mut self,
params: federation_service::ProxyFetchHybridKeyParams,
mut results: federation_service::ProxyFetchHybridKeyResults,
) -> Promise<(), capnp::Error> {
let identity_key = match params.get().and_then(|p| p.get_identity_key()) {
Ok(v) => v.to_vec(),
Err(e) => return Promise::err(capnp::Error::failed(format!("bad params: {e}"))),
};
match self.store.fetch_hybrid_key(&identity_key) {
Ok(Some(pk)) => results.get().set_hybrid_public_key(&pk),
Ok(None) => results.get().set_hybrid_public_key(&[]),
Err(e) => return Promise::err(capnp::Error::failed(format!("store error: {e}"))),
}
Promise::ok(())
}
fn proxy_resolve_user(
&mut self,
params: federation_service::ProxyResolveUserParams,
mut results: federation_service::ProxyResolveUserResults,
) -> Promise<(), capnp::Error> {
let username = match params.get().and_then(|p| p.get_username()) {
Ok(u) => match u.to_str() {
Ok(s) => s.to_string(),
Err(e) => return Promise::err(capnp::Error::failed(format!("bad utf-8: {e}"))),
},
Err(e) => return Promise::err(capnp::Error::failed(format!("bad params: {e}"))),
};
match self.store.get_user_identity_key(&username) {
Ok(Some(key)) => results.get().set_identity_key(&key),
Ok(None) => results.get().set_identity_key(&[]),
Err(e) => return Promise::err(capnp::Error::failed(format!("store error: {e}"))),
}
Promise::ok(())
}
fn federation_health(
&mut self,
_params: federation_service::FederationHealthParams,
mut results: federation_service::FederationHealthResults,
) -> Promise<(), capnp::Error> {
results.get().set_status("ok");
results.get().set_server_domain(&self.local_domain);
Promise::ok(())
}
}

View File

@@ -0,0 +1,85 @@
//! Build mTLS server/client configs for the federation endpoint.
//!
//! Federation uses a separate CA from the public-facing QUIC endpoint.
//! Both server and client present certificates; the server verifies the client
//! cert is signed by the federation CA.
use std::path::Path;
use std::sync::Arc;
use anyhow::Context;
use quinn::ServerConfig;
use quinn_proto::crypto::rustls::QuicServerConfig;
use rustls::pki_types::{CertificateDer, PrivateKeyDer};
use rustls::version::TLS13;
/// Build a QUIC server config for the federation listener with mutual TLS.
///
/// `cert`/`key`: this server's federation certificate and private key.
/// `ca`: the federation CA certificate used to verify peer certificates.
pub fn build_federation_server_config(
cert_path: &Path,
key_path: &Path,
ca_path: &Path,
) -> anyhow::Result<ServerConfig> {
let cert_bytes = std::fs::read(cert_path)
.with_context(|| format!("read federation cert: {:?}", cert_path))?;
let key_bytes = std::fs::read(key_path)
.with_context(|| format!("read federation key: {:?}", key_path))?;
let ca_bytes = std::fs::read(ca_path)
.with_context(|| format!("read federation CA: {:?}", ca_path))?;
let cert_chain = vec![CertificateDer::from(cert_bytes)];
let key = PrivateKeyDer::try_from(key_bytes)
.map_err(|_| anyhow::anyhow!("invalid federation private key"))?;
// Build a root cert store with the federation CA for client verification.
let mut root_store = rustls::RootCertStore::empty();
root_store
.add(CertificateDer::from(ca_bytes))
.context("add federation CA to root store")?;
let client_verifier = rustls::server::WebPkiClientVerifier::builder(Arc::new(root_store))
.build()
.context("build client cert verifier")?;
let mut tls = rustls::ServerConfig::builder_with_protocol_versions(&[&TLS13])
.with_client_cert_verifier(client_verifier)
.with_single_cert(cert_chain, key)?;
tls.alpn_protocols = vec![b"qnpc-fed".to_vec()];
let crypto = QuicServerConfig::try_from(tls)
.map_err(|e| anyhow::anyhow!("invalid federation server TLS config: {e}"))?;
Ok(ServerConfig::with_crypto(Arc::new(crypto)))
}
/// Build a QUIC client config for connecting to a federation peer with mutual TLS.
pub fn build_federation_client_config(
cert_path: &Path,
key_path: &Path,
ca_path: &Path,
) -> anyhow::Result<rustls::ClientConfig> {
let cert_bytes = std::fs::read(cert_path)
.with_context(|| format!("read federation cert: {:?}", cert_path))?;
let key_bytes = std::fs::read(key_path)
.with_context(|| format!("read federation key: {:?}", key_path))?;
let ca_bytes = std::fs::read(ca_path)
.with_context(|| format!("read federation CA: {:?}", ca_path))?;
let cert_chain = vec![CertificateDer::from(cert_bytes)];
let key = PrivateKeyDer::try_from(key_bytes)
.map_err(|_| anyhow::anyhow!("invalid federation client private key"))?;
let mut root_store = rustls::RootCertStore::empty();
root_store
.add(CertificateDer::from(ca_bytes))
.context("add federation CA to root store")?;
let tls = rustls::ClientConfig::builder_with_protocol_versions(&[&TLS13])
.with_root_certificates(root_store)
.with_client_auth_cert(cert_chain, key)
.context("set client auth cert")?;
Ok(tls)
}

View File

@@ -1,4 +1,4 @@
//! quicnprotochat-server — unified Authentication + Delivery service.
//! qpq-server — unified Authentication + Delivery service.
//!
//! The server hosts Authentication + Delivery services over QUIC + Cap'n Proto.
@@ -8,7 +8,7 @@ use anyhow::Context;
use clap::Parser;
use dashmap::DashMap;
use opaque_ke::ServerSetup;
use quicnprotochat_core::opaque_auth::OpaqueSuite;
use quicproquo_core::opaque_auth::OpaqueSuite;
use quinn::Endpoint;
use rand::rngs::OsRng;
use tokio::sync::Notify;
@@ -17,6 +17,7 @@ use tokio::task::LocalSet;
mod auth;
mod config;
mod error_codes;
mod federation;
mod metrics;
mod node_service;
mod sql_store;
@@ -37,62 +38,74 @@ use tls::build_server_config;
#[derive(Debug, Parser)]
#[command(
name = "quicnprotochat-server",
about = "quicnprotochat Delivery Service + Authentication Service",
name = "qpq-server",
about = "quicproquo Delivery Service + Authentication Service",
version
)]
struct Args {
/// Optional path to a TOML config file (fields map to CLI flags).
#[arg(long, env = "QUICNPROTOCHAT_CONFIG")]
#[arg(long, env = "QPQ_CONFIG")]
config: Option<PathBuf>,
/// QUIC listen address (host:port).
#[arg(long, default_value = DEFAULT_LISTEN, env = "QUICNPROTOCHAT_LISTEN")]
#[arg(long, default_value = DEFAULT_LISTEN, env = "QPQ_LISTEN")]
listen: String,
/// Directory for persisted server data (KeyPackages + delivery queues).
#[arg(long, default_value = DEFAULT_DATA_DIR, env = "QUICNPROTOCHAT_DATA_DIR")]
#[arg(long, default_value = DEFAULT_DATA_DIR, env = "QPQ_DATA_DIR")]
data_dir: String,
/// TLS certificate path (generated automatically if missing).
#[arg(long, default_value = DEFAULT_TLS_CERT, env = "QUICNPROTOCHAT_TLS_CERT")]
#[arg(long, default_value = DEFAULT_TLS_CERT, env = "QPQ_TLS_CERT")]
tls_cert: PathBuf,
/// TLS private key path (generated automatically if missing).
#[arg(long, default_value = DEFAULT_TLS_KEY, env = "QUICNPROTOCHAT_TLS_KEY")]
#[arg(long, default_value = DEFAULT_TLS_KEY, env = "QPQ_TLS_KEY")]
tls_key: PathBuf,
/// Required bearer token for auth.version=1 requests. Use --allow-insecure-auth to run without it (dev only).
#[arg(long, env = "QUICNPROTOCHAT_AUTH_TOKEN")]
#[arg(long, env = "QPQ_AUTH_TOKEN")]
auth_token: Option<String>,
/// Allow running without QUICNPROTOCHAT_AUTH_TOKEN (development only).
#[arg(long, env = "QUICNPROTOCHAT_ALLOW_INSECURE_AUTH", default_value_t = false)]
/// Allow running without QPQ_AUTH_TOKEN (development only).
#[arg(long, env = "QPQ_ALLOW_INSECURE_AUTH", default_value_t = false)]
allow_insecure_auth: bool,
/// Enable Sealed Sender: enqueue does not require identity-bound session, only a valid token.
#[arg(long, env = "QUICNPROTOCHAT_SEALED_SENDER", default_value_t = false)]
#[arg(long, env = "QPQ_SEALED_SENDER", default_value_t = false)]
sealed_sender: bool,
/// Storage backend: "file" (bincode) or "sql" (SQLCipher-encrypted).
#[arg(long, default_value = DEFAULT_STORE_BACKEND, env = "QUICNPROTOCHAT_STORE_BACKEND")]
#[arg(long, default_value = DEFAULT_STORE_BACKEND, env = "QPQ_STORE_BACKEND")]
store_backend: String,
/// Path to the SQLCipher database file (only used when --store-backend=sql).
#[arg(long, default_value = DEFAULT_DB_PATH, env = "QUICNPROTOCHAT_DB_PATH")]
#[arg(long, default_value = DEFAULT_DB_PATH, env = "QPQ_DB_PATH")]
db_path: PathBuf,
/// SQLCipher encryption key. Empty string disables encryption.
#[arg(long, default_value = "", env = "QUICNPROTOCHAT_DB_KEY")]
#[arg(long, default_value = "", env = "QPQ_DB_KEY")]
db_key: String,
/// Metrics HTTP listen address (e.g. 0.0.0.0:9090). If set and metrics enabled, /metrics is served.
#[arg(long, env = "QUICNPROTOCHAT_METRICS_LISTEN")]
#[arg(long, env = "QPQ_METRICS_LISTEN")]
metrics_listen: Option<String>,
/// Enable metrics server when metrics_listen is set.
#[arg(long, env = "QUICNPROTOCHAT_METRICS_ENABLED")]
#[arg(long, env = "QPQ_METRICS_ENABLED")]
metrics_enabled: Option<bool>,
/// Enable federation (server-to-server message relay).
#[arg(long, env = "QPQ_FEDERATION_ENABLED", default_value_t = false)]
federation_enabled: bool,
/// This server's domain for federation addressing (e.g. "chat.example.com").
#[arg(long, env = "QPQ_FEDERATION_DOMAIN")]
federation_domain: Option<String>,
/// Federation QUIC listen address (default: 0.0.0.0:7001).
#[arg(long, env = "QPQ_FEDERATION_LISTEN")]
federation_listen: Option<String>,
}
// ── Entry point ───────────────────────────────────────────────────────────────
@@ -112,7 +125,7 @@ async fn main() -> anyhow::Result<()> {
let file_cfg = load_config(args.config.as_deref())?;
let effective = merge_config(&args, &file_cfg);
let production = std::env::var("QUICNPROTOCHAT_PRODUCTION")
let production = std::env::var("QPQ_PRODUCTION")
.map(|v| matches!(v.to_lowercase().as_str(), "1" | "true" | "yes"))
.unwrap_or(false);
if production {
@@ -143,7 +156,7 @@ async fn main() -> anyhow::Result<()> {
&& !effective.allow_insecure_auth
{
anyhow::bail!(
"missing QUICNPROTOCHAT_AUTH_TOKEN; set one or pass --allow-insecure-auth for development"
"missing QPQ_AUTH_TOKEN; set one or pass --allow-insecure-auth for development"
);
}
@@ -154,7 +167,7 @@ async fn main() -> anyhow::Result<()> {
.map(|s| s.is_empty())
.unwrap_or(true)
{
tracing::warn!("running without QUICNPROTOCHAT_AUTH_TOKEN (allow-insecure-auth enabled); development only");
tracing::warn!("running without QPQ_AUTH_TOKEN (allow-insecure-auth enabled); development only");
}
let listen: SocketAddr = effective
@@ -246,10 +259,174 @@ async fn main() -> anyhow::Result<()> {
"accepting QUIC connections"
);
// ── Federation setup ─────────────────────────────────────────────────────
let federation_client: Option<Arc<federation::FederationClient>> =
if let Some(fed_cfg) = &effective.federation {
tracing::info!(
domain = %fed_cfg.domain,
listen = %fed_cfg.listen,
peers = fed_cfg.peers.len(),
"federation enabled"
);
// Build a client endpoint for outbound federation connections.
// For now we create a simple endpoint; full mTLS is used when certs are provided.
let client_config = if fed_cfg.federation_cert.exists()
&& fed_cfg.federation_key.exists()
&& fed_cfg.federation_ca.exists()
{
let tls_cfg = federation::tls::build_federation_client_config(
&fed_cfg.federation_cert,
&fed_cfg.federation_key,
&fed_cfg.federation_ca,
)
.context("build federation client TLS config")?;
let crypto = quinn::crypto::rustls::QuicClientConfig::try_from(tls_cfg)
.map_err(|e| anyhow::anyhow!("invalid federation client QUIC config: {e}"))?;
let mut qc = quinn::ClientConfig::new(Arc::new(crypto));
let mut transport = quinn::TransportConfig::default();
transport.max_idle_timeout(Some(
std::time::Duration::from_secs(120)
.try_into()
.expect("120s is valid"),
));
qc.transport_config(Arc::new(transport));
Some(qc)
} else {
tracing::warn!("federation cert/key/CA not found; outbound federation connections will fail");
None
};
let fed_bind: SocketAddr = "0.0.0.0:0".parse().unwrap();
let mut fed_endpoint = Endpoint::client(fed_bind)
.context("create federation client endpoint")?;
if let Some(cc) = client_config {
fed_endpoint.set_default_client_config(cc);
}
let client = federation::FederationClient::new(fed_cfg, fed_endpoint)
.context("create federation client")?;
// Register configured peers in storage.
for peer in &fed_cfg.peers {
if let Err(e) = store.upsert_federation_peer(&peer.domain, true) {
tracing::warn!(domain = %peer.domain, error = %e, "failed to register federation peer");
}
}
Some(Arc::new(client))
} else {
None
};
let local_domain: Option<String> = effective.federation.as_ref().map(|f| f.domain.clone());
// ── Federation listener ──────────────────────────────────────────────────
let federation_endpoint: Option<Endpoint> =
if let Some(fed_cfg) = &effective.federation {
if fed_cfg.federation_cert.exists()
&& fed_cfg.federation_key.exists()
&& fed_cfg.federation_ca.exists()
{
let fed_server_config = federation::tls::build_federation_server_config(
&fed_cfg.federation_cert,
&fed_cfg.federation_key,
&fed_cfg.federation_ca,
)
.context("build federation server TLS config")?;
let fed_listen: SocketAddr = fed_cfg
.listen
.parse()
.context("federation listen must be host:port")?;
let ep = Endpoint::server(fed_server_config, fed_listen)
.context("bind federation QUIC endpoint")?;
tracing::info!(addr = %fed_cfg.listen, "federation endpoint listening");
Some(ep)
} else {
tracing::warn!("federation certs not found; federation listener not started");
None
}
} else {
None
};
// capnp-rpc is !Send (Rc internals), so all RPC tasks must stay on a LocalSet.
let local = LocalSet::new();
local
.run_until(async move {
// Spawn federation acceptor if enabled.
if let Some(fed_ep) = federation_endpoint {
let fed_store = Arc::clone(&store);
let fed_waiters = Arc::clone(&waiters);
let fed_domain = local_domain.clone().unwrap_or_default();
tokio::task::spawn_local(async move {
loop {
let incoming = match fed_ep.accept().await {
Some(i) => i,
None => break,
};
let connecting = match incoming.accept() {
Ok(c) => c,
Err(e) => {
tracing::warn!(error = %e, "federation: accept error");
continue;
}
};
let store = Arc::clone(&fed_store);
let waiters = Arc::clone(&fed_waiters);
let domain = fed_domain.clone();
tokio::task::spawn_local(async move {
match connecting.await {
Ok(conn) => {
tracing::info!(
peer = %conn.remote_address(),
"federation: peer connected"
);
let (send, recv) = match conn.accept_bi().await {
Ok(s) => s,
Err(e) => {
tracing::warn!(error = %e, "federation: accept bi error");
return;
}
};
let reader = tokio_util::compat::TokioAsyncReadCompatExt::compat(recv);
let writer = tokio_util::compat::TokioAsyncWriteCompatExt::compat_write(send);
let network = capnp_rpc::twoparty::VatNetwork::new(
reader,
writer,
capnp_rpc::rpc_twoparty_capnp::Side::Server,
Default::default(),
);
let service_impl = federation::service::FederationServiceImpl {
store,
waiters,
local_domain: domain,
};
let client: quicproquo_proto::federation_capnp::federation_service::Client =
capnp_rpc::new_client(service_impl);
if let Err(e) = capnp_rpc::RpcSystem::new(
Box::new(network),
Some(client.client),
).await {
tracing::warn!(error = %e, "federation: RPC error");
}
}
Err(e) => {
tracing::warn!(error = %e, "federation: connection error");
}
}
});
}
});
}
loop {
tokio::select! {
biased;
@@ -284,6 +461,8 @@ async fn main() -> anyhow::Result<()> {
let sessions = Arc::clone(&sessions);
let rate_limits = Arc::clone(&rate_limits);
let sealed_sender = effective.sealed_sender;
let fed_client = federation_client.clone();
let local_dom = local_domain.clone();
tokio::task::spawn_local(async move {
if let Err(e) = handle_node_connection(
@@ -296,6 +475,8 @@ async fn main() -> anyhow::Result<()> {
sessions,
rate_limits,
sealed_sender,
fed_client,
local_dom,
)
.await
{

View File

@@ -3,8 +3,8 @@ use opaque_ke::{
CredentialFinalization, CredentialRequest, RegistrationRequest, RegistrationUpload,
ServerLogin, ServerRegistration,
};
use quicnprotochat_core::opaque_auth::OpaqueSuite;
use quicnprotochat_proto::node_capnp::node_service;
use quicproquo_core::opaque_auth::OpaqueSuite;
use quicproquo_proto::node_capnp::node_service;
use crate::auth::{coded_error, current_timestamp, PendingLogin, SESSION_TTL_SECS};
use crate::error_codes::*;

View File

@@ -1,7 +1,7 @@
//! createChannel RPC: create or look up a 1:1 DM channel.
use capnp::capability::Promise;
use quicnprotochat_proto::node_capnp::node_service;
use quicproquo_proto::node_capnp::node_service;
use crate::auth::{coded_error, require_identity, validate_auth_context};
use crate::error_codes::*;

View File

@@ -3,7 +3,7 @@ use std::time::Duration;
use capnp::capability::Promise;
use dashmap::DashMap;
use quicnprotochat_proto::node_capnp::node_service;
use quicproquo_proto::node_capnp::node_service;
use tokio::sync::Notify;
use tokio::time::timeout;

View File

@@ -1,5 +1,5 @@
use capnp::capability::Promise;
use quicnprotochat_proto::node_capnp::node_service;
use quicproquo_proto::node_capnp::node_service;
use crate::auth::{coded_error, fmt_hex, require_identity_or_request, validate_auth_context};
use crate::error_codes::*;
@@ -63,7 +63,7 @@ impl NodeServiceImpl {
return Promise::err(e);
}
if let Err(e) = quicnprotochat_core::validate_keypackage_ciphersuite(&package) {
if let Err(e) = quicproquo_core::validate_keypackage_ciphersuite(&package) {
return Promise::err(coded_error(
E021_CIPHERSUITE_NOT_ALLOWED,
format!("KeyPackage ciphersuite not allowed: {e}"),

View File

@@ -4,8 +4,8 @@ use std::time::Duration;
use capnp_rpc::RpcSystem;
use dashmap::DashMap;
use opaque_ke::ServerSetup;
use quicnprotochat_core::opaque_auth::OpaqueSuite;
use quicnprotochat_proto::node_capnp::node_service;
use quicproquo_core::opaque_auth::OpaqueSuite;
use quicproquo_proto::node_capnp::node_service;
use tokio::sync::Notify;
use tokio_util::compat::{TokioAsyncReadCompatExt, TokioAsyncWriteCompatExt};
@@ -207,6 +207,10 @@ pub struct NodeServiceImpl {
pub rate_limits: Arc<DashMap<Vec<u8>, RateEntry>>,
/// When true, enqueue does not require identity-bound session (Sealed Sender).
pub sealed_sender: bool,
/// Outbound federation client for relaying to remote servers (None if federation disabled).
pub federation_client: Option<Arc<crate::federation::FederationClient>>,
/// This server's federation domain (empty if federation disabled).
pub local_domain: Option<String>,
}
impl NodeServiceImpl {
@@ -219,6 +223,8 @@ impl NodeServiceImpl {
sessions: Arc<DashMap<Vec<u8>, SessionInfo>>,
rate_limits: Arc<DashMap<Vec<u8>, RateEntry>>,
sealed_sender: bool,
federation_client: Option<Arc<crate::federation::FederationClient>>,
local_domain: Option<String>,
) -> Self {
Self {
store,
@@ -229,6 +235,8 @@ impl NodeServiceImpl {
sessions,
rate_limits,
sealed_sender,
federation_client,
local_domain,
}
}
}
@@ -243,6 +251,8 @@ pub async fn handle_node_connection(
sessions: Arc<DashMap<Vec<u8>, SessionInfo>>,
rate_limits: Arc<DashMap<Vec<u8>, RateEntry>>,
sealed_sender: bool,
federation_client: Option<Arc<crate::federation::FederationClient>>,
local_domain: Option<String>,
) -> Result<(), anyhow::Error> {
let connection = connecting.await?;
@@ -272,6 +282,8 @@ pub async fn handle_node_connection(
sessions,
rate_limits,
sealed_sender,
federation_client,
local_domain,
));
RpcSystem::new(Box::new(network), Some(service.client))

View File

@@ -1,5 +1,5 @@
use capnp::capability::Promise;
use quicnprotochat_proto::node_capnp::node_service;
use quicproquo_proto::node_capnp::node_service;
use crate::auth::{
coded_error, fmt_hex, require_identity_or_request, validate_auth, validate_auth_context,

View File

@@ -1,7 +1,7 @@
//! resolveUser / resolveIdentity RPCs: bidirectional username ↔ identity key lookup.
use capnp::capability::Promise;
use quicnprotochat_proto::node_capnp::node_service;
use quicproquo_proto::node_capnp::node_service;
use crate::auth::{coded_error, validate_auth_context};
use crate::error_codes::*;
@@ -41,7 +41,44 @@ impl NodeServiceImpl {
return Promise::err(coded_error(E020_BAD_PARAMS, "username must not be empty"));
}
match self.store.get_user_identity_key(username_str) {
// Federation: parse user@domain format.
let addr = crate::federation::address::FederatedAddress::parse(username_str);
let is_remote = match (&addr.domain, &self.local_domain) {
(Some(d), Some(ld)) => d != ld,
(Some(_), None) => true,
_ => false,
};
if is_remote {
// Proxy to remote server via federation.
if let (Some(ref fed_client), Some(ref domain)) = (&self.federation_client, &addr.domain) {
if fed_client.has_peer(domain) {
let fed = fed_client.clone();
let user = addr.username.clone();
let dom = domain.clone();
return Promise::from_future(async move {
match fed.proxy_resolve_user(&dom, &user).await {
Ok(Some(key)) => {
results.get().set_identity_key(&key);
}
Ok(None) => {
// Not found on remote — return empty.
}
Err(e) => {
tracing::warn!(error = %e, "federation proxy_resolve_user failed");
// Fall through — return empty (not found).
}
}
Ok(())
});
}
}
// No federation client or unknown peer — return empty (not found).
return Promise::ok(());
}
// Local resolution.
match self.store.get_user_identity_key(&addr.username) {
Ok(Some(key)) => {
results.get().set_identity_key(&key);
}

View File

@@ -9,13 +9,14 @@ use rusqlite::{params, Connection};
use crate::storage::{StorageError, Store};
/// Schema version after introducing the migration runner (existing DBs had 1).
const SCHEMA_VERSION: i32 = 4;
const SCHEMA_VERSION: i32 = 5;
/// Migrations: (migration_number, SQL). Files named NNN_name.sql, applied in order when N > user_version.
const MIGRATIONS: &[(i32, &str)] = &[
(1, include_str!("../migrations/001_initial.sql")),
(3, include_str!("../migrations/002_add_seq.sql")),
(4, include_str!("../migrations/003_channels.sql")),
(5, include_str!("../migrations/004_federation.sql")),
];
/// Runs pending migrations on an open connection: applies any migration whose number is greater
@@ -494,6 +495,71 @@ impl Store for SqlStore {
.optional()
.map_err(|e| StorageError::Db(e.to_string()))
}
fn store_identity_home_server(
&self,
identity_key: &[u8],
home_server: &str,
) -> Result<(), StorageError> {
let conn = self.lock_conn()?;
let now = std::time::SystemTime::now()
.duration_since(std::time::UNIX_EPOCH)
.unwrap_or_default()
.as_secs() as i64;
conn.execute(
"INSERT OR REPLACE INTO identity_home_servers (identity_key, home_server, updated_at) VALUES (?1, ?2, ?3)",
params![identity_key, home_server, now],
)
.map_err(|e| StorageError::Db(e.to_string()))?;
Ok(())
}
fn get_identity_home_server(
&self,
identity_key: &[u8],
) -> Result<Option<String>, StorageError> {
let conn = self.lock_conn()?;
let mut stmt = conn
.prepare("SELECT home_server FROM identity_home_servers WHERE identity_key = ?1")
.map_err(|e| StorageError::Db(e.to_string()))?;
stmt.query_row(params![identity_key], |row| row.get(0))
.optional()
.map_err(|e| StorageError::Db(e.to_string()))
}
fn upsert_federation_peer(
&self,
domain: &str,
is_active: bool,
) -> Result<(), StorageError> {
let conn = self.lock_conn()?;
let now = std::time::SystemTime::now()
.duration_since(std::time::UNIX_EPOCH)
.unwrap_or_default()
.as_secs() as i64;
conn.execute(
"INSERT INTO federation_peers (domain, last_seen, is_active) VALUES (?1, ?2, ?3)
ON CONFLICT(domain) DO UPDATE SET last_seen = ?2, is_active = ?3",
params![domain, now, is_active as i32],
)
.map_err(|e| StorageError::Db(e.to_string()))?;
Ok(())
}
fn list_federation_peers(&self) -> Result<Vec<(String, bool)>, StorageError> {
let conn = self.lock_conn()?;
let mut stmt = conn
.prepare("SELECT domain, is_active FROM federation_peers WHERE is_active = 1")
.map_err(|e| StorageError::Db(e.to_string()))?;
let rows = stmt
.query_map([], |row| {
Ok((row.get::<_, String>(0)?, row.get::<_, i32>(1)? != 0))
})
.map_err(|e| StorageError::Db(e.to_string()))?
.collect::<Result<Vec<_>, _>>()
.map_err(|e| StorageError::Db(e.to_string()))?;
Ok(rows)
}
}
/// Convenience extension for `rusqlite::OptionalExtension`.

View File

@@ -133,6 +133,31 @@ pub trait Store: Send + Sync {
/// Get the two members of a channel by channel_id (16 bytes). Returns (member_a, member_b) in sorted order.
fn get_channel_members(&self, channel_id: &[u8]) -> Result<Option<(Vec<u8>, Vec<u8>)>, StorageError>;
// ── Federation ──────────────────────────────────────────────────────────
/// Store the home server domain for an identity key.
fn store_identity_home_server(
&self,
identity_key: &[u8],
home_server: &str,
) -> Result<(), StorageError>;
/// Get the home server domain for an identity key.
fn get_identity_home_server(
&self,
identity_key: &[u8],
) -> Result<Option<String>, StorageError>;
/// Insert or update a federation peer.
fn upsert_federation_peer(
&self,
domain: &str,
is_active: bool,
) -> Result<(), StorageError>;
/// List all active federation peers.
fn list_federation_peers(&self) -> Result<Vec<(String, bool)>, StorageError>;
}
// ── ChannelKey ───────────────────────────────────────────────────────────────
@@ -644,6 +669,34 @@ impl Store for FileBackedStore {
let map = lock(&self.channels)?;
Ok(map.get(channel_id).cloned())
}
fn store_identity_home_server(
&self,
_identity_key: &[u8],
_home_server: &str,
) -> Result<(), StorageError> {
// File-backed store: federation mappings are ephemeral (in-memory only).
Ok(())
}
fn get_identity_home_server(
&self,
_identity_key: &[u8],
) -> Result<Option<String>, StorageError> {
Ok(None)
}
fn upsert_federation_peer(
&self,
_domain: &str,
_is_active: bool,
) -> Result<(), StorageError> {
Ok(())
}
fn list_federation_peers(&self) -> Result<Vec<(String, bool)>, StorageError> {
Ok(vec![])
}
}
#[cfg(test)]