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:
@@ -1,5 +0,0 @@
|
||||
//! Desktop entry point for quicnprotochat-gui.
|
||||
|
||||
fn main() {
|
||||
quicnprotochat_gui::run()
|
||||
}
|
||||
@@ -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"
|
||||
@@ -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)?;
|
||||
@@ -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,
|
||||
@@ -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.
|
||||
@@ -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
|
||||
))
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
@@ -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;
|
||||
@@ -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)]
|
||||
@@ -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)
|
||||
@@ -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
|
||||
152
crates/quicproquo-core/benches/hybrid_kem_bench.rs
Normal file
152
crates/quicproquo-core/benches/hybrid_kem_bench.rs
Normal 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);
|
||||
132
crates/quicproquo-core/benches/mls_operations.rs
Normal file
132
crates/quicproquo-core/benches/mls_operations.rs
Normal 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);
|
||||
170
crates/quicproquo-core/benches/serialization.rs
Normal file
170
crates/quicproquo-core/benches/serialization.rs
Normal 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);
|
||||
21
crates/quicproquo-core/proto/chat_message.proto
Normal file
21
crates/quicproquo-core/proto/chat_message.proto
Normal 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;
|
||||
}
|
||||
@@ -1,4 +1,4 @@
|
||||
//! Error types for `quicnprotochat-core`.
|
||||
//! Error types for `quicproquo-core`.
|
||||
|
||||
use thiserror::Error;
|
||||
|
||||
@@ -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() {
|
||||
@@ -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() {
|
||||
@@ -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 {
|
||||
@@ -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
|
||||
@@ -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;
|
||||
|
||||
@@ -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 ────────────────────────────────────────────────────────────────
|
||||
|
||||
@@ -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
|
||||
144
crates/quicproquo-core/src/padding.rs
Normal file
144
crates/quicproquo-core/src/padding.rs
Normal 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());
|
||||
}
|
||||
}
|
||||
154
crates/quicproquo-core/src/sealed_sender.rs
Normal file
154
crates/quicproquo-core/src/sealed_sender.rs
Normal 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());
|
||||
}
|
||||
}
|
||||
@@ -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 }
|
||||
@@ -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.:
|
||||
|
Before Width: | Height: | Size: 2.1 KiB After Width: | Height: | Size: 2.1 KiB |
@@ -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 {
|
||||
@@ -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
|
||||
5
crates/quicproquo-gui/src/main.rs
Normal file
5
crates/quicproquo-gui/src/main.rs
Normal file
@@ -0,0 +1,5 @@
|
||||
//! Desktop entry point for quicproquo-gui.
|
||||
|
||||
fn main() {
|
||||
quicproquo_gui::run()
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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>
|
||||
23
crates/quicproquo-mobile/Cargo.toml
Normal file
23
crates/quicproquo-mobile/Cargo.toml
Normal 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 }
|
||||
331
crates/quicproquo-mobile/src/lib.rs
Normal file
331
crates/quicproquo-mobile/src/lib.rs
Normal 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
|
||||
}
|
||||
}
|
||||
@@ -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]
|
||||
@@ -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.
|
||||
@@ -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.
|
||||
@@ -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. \
|
||||
@@ -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> {
|
||||
@@ -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 }
|
||||
16
crates/quicproquo-server/migrations/004_federation.sql
Normal file
16
crates/quicproquo-server/migrations/004_federation.sql
Normal 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
|
||||
);
|
||||
@@ -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,
|
||||
}
|
||||
|
||||
@@ -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(())
|
||||
78
crates/quicproquo-server/src/federation/address.rs
Normal file
78
crates/quicproquo-server/src/federation/address.rs
Normal 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()));
|
||||
}
|
||||
}
|
||||
287
crates/quicproquo-server/src/federation/client.rs
Normal file
287
crates/quicproquo-server/src/federation/client.rs
Normal 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)
|
||||
}
|
||||
}
|
||||
16
crates/quicproquo-server/src/federation/mod.rs
Normal file
16
crates/quicproquo-server/src/federation/mod.rs
Normal 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;
|
||||
44
crates/quicproquo-server/src/federation/routing.rs
Normal file
44
crates/quicproquo-server/src/federation/routing.rs
Normal 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);
|
||||
}
|
||||
}
|
||||
201
crates/quicproquo-server/src/federation/service.rs
Normal file
201
crates/quicproquo-server/src/federation/service.rs
Normal 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(())
|
||||
}
|
||||
}
|
||||
85
crates/quicproquo-server/src/federation/tls.rs
Normal file
85
crates/quicproquo-server/src/federation/tls.rs
Normal 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)
|
||||
}
|
||||
@@ -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
|
||||
{
|
||||
@@ -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::*;
|
||||
@@ -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::*;
|
||||
@@ -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;
|
||||
|
||||
@@ -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}"),
|
||||
@@ -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))
|
||||
@@ -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,
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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`.
|
||||
@@ -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)]
|
||||
Reference in New Issue
Block a user