From 612b06aa8e9070ea274320a2bc4c70ac809e97f2 Mon Sep 17 00:00:00 2001 From: Chris Nennemann Date: Tue, 3 Mar 2026 23:19:11 +0100 Subject: [PATCH] =?UTF-8?q?feat:=20Sprint=201=20=E2=80=94=20production=20h?= =?UTF-8?q?ardening,=20TLS=20lifecycle,=20CI=20coverage,=20lint=20cleanup?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Fix 3 client panics: replace .unwrap()/.expect() with proper error handling in rpc.rs (AUTH_CONTEXT lock), repl.rs (pending_member), and retry.rs (last_err) - Add --danger-accept-invalid-certs flag with InsecureServerCertVerifier for development TLS bypass, plus mdBook TLS documentation - Add CI coverage job (cargo-tarpaulin) and Docker build validation to GitHub Actions workflow, plus README CI badge - Add [workspace.lints] config, fix 46 clippy warnings across 8 crates, zero warnings on all buildable crates - Update Dockerfile for all 11 workspace members --- .github/workflows/ci.yml | 51 +++++++++ Cargo.toml | 6 ++ README.md | 2 + crates/quicproquo-bot/Cargo.toml | 3 + crates/quicproquo-client/Cargo.toml | 3 + .../quicproquo-client/src/client/commands.rs | 12 ++- .../src/client/conversation.rs | 4 +- crates/quicproquo-client/src/client/repl.rs | 24 +++-- crates/quicproquo-client/src/client/retry.rs | 9 +- crates/quicproquo-client/src/client/rpc.rs | 94 +++++++++++++--- .../quicproquo-client/src/client/session.rs | 6 +- crates/quicproquo-client/src/lib.rs | 15 ++- crates/quicproquo-client/src/main.rs | 20 +++- crates/quicproquo-core/Cargo.toml | 3 + crates/quicproquo-core/src/app_message.rs | 4 +- crates/quicproquo-core/src/padding.rs | 2 +- crates/quicproquo-gen/Cargo.toml | 3 + crates/quicproquo-kt/Cargo.toml | 3 + crates/quicproquo-p2p/Cargo.toml | 3 + crates/quicproquo-plugin-api/Cargo.toml | 3 + crates/quicproquo-plugin-api/src/lib.rs | 2 + crates/quicproquo-proto/Cargo.toml | 7 ++ crates/quicproquo-server/Cargo.toml | 3 + crates/quicproquo-server/src/auth.rs | 2 +- crates/quicproquo-server/src/config.rs | 6 +- crates/quicproquo-server/src/main.rs | 3 +- .../src/node_service/channel_ops.rs | 2 +- .../src/node_service/key_ops.rs | 2 +- .../quicproquo-server/src/node_service/mod.rs | 2 + crates/quicproquo-server/src/storage.rs | 23 ++-- docker/Dockerfile | 32 ++++-- docs/src/SUMMARY.md | 1 + docs/src/getting-started/tls.md | 100 ++++++++++++++++++ 33 files changed, 388 insertions(+), 67 deletions(-) create mode 100644 docs/src/getting-started/tls.md diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index c9057ac..40a1db9 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -72,3 +72,54 @@ jobs: run: | cargo install cargo-audit --locked cargo audit + + coverage: + name: Coverage + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + + - name: Install Rust + uses: dtolnay/rust-action@stable + + - name: Install capnp + run: sudo apt-get update && sudo apt-get install -y capnproto + + - name: Cache cargo + uses: actions/cache@v4 + with: + path: | + ~/.cargo/registry + ~/.cargo/git + target + key: ${{ runner.os }}-coverage-${{ hashFiles('**/Cargo.lock') }} + restore-keys: | + ${{ runner.os }}-coverage- + + - name: Install cargo-tarpaulin + run: cargo install cargo-tarpaulin + + - name: Run coverage + run: | + cargo tarpaulin --workspace \ + --exclude quicproquo-gui \ + --exclude quicproquo-mobile \ + --exclude quicproquo-p2p \ + --out xml \ + --output-dir coverage/ \ + -- --test-threads 1 + + - name: Upload coverage report + uses: actions/upload-artifact@v4 + with: + name: coverage-report + path: coverage/cobertura.xml + + docker: + name: Docker Build + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + + - name: Build Docker image + run: docker build -f docker/Dockerfile . diff --git a/Cargo.toml b/Cargo.toml index 312ebc2..055711a 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -80,6 +80,12 @@ clap = { version = "4", features = ["derive", "env"] } # ── Build-time ──────────────────────────────────────────────────────────────── capnpc = { version = "0.19" } +[workspace.lints.rust] +unsafe_code = "warn" + +[workspace.lints.clippy] +unwrap_used = "warn" + [profile.release] opt-level = 3 lto = "thin" diff --git a/README.md b/README.md index ee82830..3c8703f 100644 --- a/README.md +++ b/README.md @@ -4,6 +4,8 @@ # QPQ — quicproquo +[![CI](https://github.com/nickvidal/quicproquo/actions/workflows/ci.yml/badge.svg)](https://github.com/nickvidal/quicproquo/actions/workflows/ci.yml) + > End-to-end encrypted messaging over **QUIC + TLS 1.3 + MLS** (RFC 9420), written in Rust. The server never sees plaintext. Every byte on the wire is protected by a QUIC diff --git a/crates/quicproquo-bot/Cargo.toml b/crates/quicproquo-bot/Cargo.toml index feb4919..2b4bb00 100644 --- a/crates/quicproquo-bot/Cargo.toml +++ b/crates/quicproquo-bot/Cargo.toml @@ -5,6 +5,9 @@ edition = "2021" description = "Bot SDK for quicproquo — build automated agents on E2E encrypted messaging." license = "MIT" +[lints] +workspace = true + [dependencies] quicproquo-core = { path = "../quicproquo-core" } quicproquo-proto = { path = "../quicproquo-proto" } diff --git a/crates/quicproquo-client/Cargo.toml b/crates/quicproquo-client/Cargo.toml index 3418ebf..c79498f 100644 --- a/crates/quicproquo-client/Cargo.toml +++ b/crates/quicproquo-client/Cargo.toml @@ -71,6 +71,9 @@ quicproquo-p2p = { path = "../quicproquo-p2p", optional = true } ratatui = { version = "0.29", optional = true, default-features = false, features = ["crossterm"] } crossterm = { version = "0.28", optional = true } +[lints] +workspace = true + [features] # Enable mesh-mode features: mDNS local peer discovery + P2P transport. # Build: cargo build -p quicproquo-client --features mesh diff --git a/crates/quicproquo-client/src/client/commands.rs b/crates/quicproquo-client/src/client/commands.rs index 0ff4913..1597c47 100644 --- a/crates/quicproquo-client/src/client/commands.rs +++ b/crates/quicproquo-client/src/client/commands.rs @@ -27,8 +27,8 @@ pub fn cmd_whoami(state_path: &Path, password: Option<&str>) -> anyhow::Result<( let pk_bytes = identity.public_key_bytes(); let fingerprint = sha256(&pk_bytes); - println!("identity_key : {}", hex::encode(&pk_bytes)); - println!("fingerprint : {}", hex::encode(&fingerprint)); + println!("identity_key : {}", hex::encode(pk_bytes)); + println!("fingerprint : {}", hex::encode(fingerprint)); println!( "hybrid_key : {}", if state.hybrid_key.is_some() { @@ -203,6 +203,7 @@ pub async fn cmd_register_user( } /// Log in via the OPAQUE protocol and receive a session token. +#[allow(clippy::too_many_arguments)] pub async fn cmd_login( server: &str, ca_cert: &Path, @@ -522,7 +523,7 @@ async fn do_upload_keypackage( anyhow::ensure!(server_fp == fingerprint, "fingerprint mismatch"); - if let Some(ref hkp) = hybrid_kp { + if let Some(hkp) = &hybrid_kp { upload_hybrid_key( &node_client, &member.identity().public_key_bytes(), @@ -914,6 +915,7 @@ pub async fn cmd_join( } /// Send an application message via DS (single recipient or broadcast to all other members). +#[allow(clippy::too_many_arguments)] pub async fn cmd_send( state_path: &Path, server: &str, @@ -1115,8 +1117,8 @@ pub fn whoami_json(state_path: &Path, password: Option<&str>) -> anyhow::Result< let fingerprint = sha256(&pk_bytes); Ok(format!( r#"{{"identity_key":"{}", "fingerprint":"{}", "hybrid_key":{}, "group":{}}}"#, - hex::encode(&pk_bytes), - hex::encode(&fingerprint), + hex::encode(pk_bytes), + hex::encode(fingerprint), state.hybrid_key.is_some(), state.group.is_some(), )) diff --git a/crates/quicproquo-client/src/client/conversation.rs b/crates/quicproquo-client/src/client/conversation.rs index 6aaa678..3f73cac 100644 --- a/crates/quicproquo-client/src/client/conversation.rs +++ b/crates/quicproquo-client/src/client/conversation.rs @@ -169,7 +169,7 @@ impl ConversationStore { let salt = get_or_create_salt(&salt_path)?; let key = derive_convdb_key(password, &salt)?; - let hex_key = hex::encode(&*key); + let hex_key = hex::encode(*key); let conn = Connection::open(db_path).context("open conversation db")?; conn.pragma_update(None, "key", format!("x'{hex_key}'")) @@ -188,7 +188,7 @@ impl ConversationStore { ) -> anyhow::Result<()> { let salt = get_or_create_salt(salt_path)?; let key = derive_convdb_key(password, &salt)?; - let hex_key = hex::encode(&*key); + let hex_key = hex::encode(*key); let enc_path = db_path.with_extension("convdb-enc"); diff --git a/crates/quicproquo-client/src/client/repl.rs b/crates/quicproquo-client/src/client/repl.rs index cc67084..7f959a0 100644 --- a/crates/quicproquo-client/src/client/repl.rs +++ b/crates/quicproquo-client/src/client/repl.rs @@ -284,6 +284,7 @@ async fn ensure_server( // ── REPL entry point ───────────────────────────────────────────────────────── +#[allow(clippy::too_many_arguments)] pub async fn run_repl( state_path: &Path, server: &str, @@ -497,6 +498,7 @@ async fn auto_upload_keys( } /// Determine the access token, performing OPAQUE registration/login as needed. +#[allow(clippy::too_many_arguments)] async fn resolve_access_token( state_path: &Path, server: &str, @@ -715,13 +717,11 @@ fn cmd_list(session: &SessionState) -> anyhow::Result<()> { fn cmd_switch(session: &mut SessionState, target: &str) -> anyhow::Result<()> { let target = target.trim(); - let conv = if target.starts_with('@') { - let username = &target[1..]; + let conv = if let Some(username) = target.strip_prefix('@') { session.conv_store.list_conversations()?.into_iter().find(|c| { matches!(&c.kind, ConversationKind::Dm { peer_username: Some(u), .. } if u == username) }) - } else if target.starts_with('#') { - let name = &target[1..]; + } else if let Some(name) = target.strip_prefix('#') { session.conv_store.find_group_by_name(name)? } else { // Try as display name @@ -861,7 +861,7 @@ async fn cmd_dm( display_name: format!("@{username}"), mls_group_blob: member .group_ref() - .map(|g| bincode::serialize(g)) + .map(bincode::serialize) .transpose() .context("serialize group")?, keystore_blob: None, @@ -905,7 +905,7 @@ fn cmd_create_group(session: &mut SessionState, name: &str) -> anyhow::Result<() display_name: format!("#{name}"), mls_group_blob: member .group_ref() - .map(|g| bincode::serialize(g)) + .map(bincode::serialize) .transpose() .context("serialize group")?, keystore_blob: None, @@ -1099,7 +1099,7 @@ async fn cmd_join( // Try to process with existing groups first let mut handled = false; - for (_cid, member) in &mut session.members { + for member in session.members.values_mut() { match member.receive_message(&mls_payload) { Ok(_) => { handled = true; break; } Err(_) => continue, @@ -1147,7 +1147,7 @@ async fn cmd_join( display_name: format!("#{display}"), mls_group_blob: new_member .group_ref() - .map(|g| bincode::serialize(g)) + .map(bincode::serialize) .transpose() .context("serialize joined group")?, keystore_blob: None, @@ -1570,7 +1570,13 @@ async fn try_auto_join( } // Take ownership of the pending member. - let member = session.pending_member.take().unwrap(); + let member = match session.pending_member.take() { + Some(m) => m, + None => { + tracing::error!("pending_member disappeared after successful join"); + return false; + } + }; let member_keys = member.member_identities(); // Figure out the peer (any member that isn't us). diff --git a/crates/quicproquo-client/src/client/retry.rs b/crates/quicproquo-client/src/client/retry.rs index 2f14491..2175a31 100644 --- a/crates/quicproquo-client/src/client/retry.rs +++ b/crates/quicproquo-client/src/client/retry.rs @@ -25,15 +25,13 @@ where Fut: Future>, P: Fn(&E) -> bool, { - let mut last_err = None; + let mut last_err: Option = None; for attempt in 0..max_retries { match op().await { Ok(t) => return Ok(t), Err(e) => { - last_err = Some(e); - let err = last_err.as_ref().unwrap(); - if !is_retriable(err) || attempt + 1 >= max_retries { - break; + if !is_retriable(&e) || attempt + 1 >= max_retries { + return Err(e); } let delay_ms = base_delay_ms * 2u64.saturating_pow(attempt); let jitter_ms = rand::thread_rng().gen_range(0..=delay_ms / 2); @@ -44,6 +42,7 @@ where delay_ms = total_ms, "RPC failed, retrying after backoff" ); + last_err = Some(e); tokio::time::sleep(Duration::from_millis(total_ms)).await; } } diff --git a/crates/quicproquo-client/src/client/rpc.rs b/crates/quicproquo-client/src/client/rpc.rs index eb84c0e..ac3ec4e 100644 --- a/crates/quicproquo-client/src/client/rpc.rs +++ b/crates/quicproquo-client/src/client/rpc.rs @@ -5,7 +5,7 @@ use std::sync::Arc; use anyhow::Context; use quinn::{ClientConfig, Endpoint}; use quinn_proto::crypto::rustls::QuicClientConfig; -use rustls::pki_types::CertificateDer; +use rustls::pki_types::{CertificateDer, ServerName, UnixTime}; use rustls::{ClientConfig as RustlsClientConfig, RootCertStore}; use tokio_util::compat::{TokioAsyncReadCompatExt, TokioAsyncWriteCompatExt}; use capnp_rpc::{rpc_twoparty_capnp::Side, twoparty, RpcSystem}; @@ -13,34 +13,101 @@ use capnp_rpc::{rpc_twoparty_capnp::Side, twoparty, RpcSystem}; use quicproquo_core::HybridPublicKey; use quicproquo_proto::node_capnp::{auth, node_service}; -use crate::AUTH_CONTEXT; +use crate::{AUTH_CONTEXT, INSECURE_SKIP_VERIFY}; use super::retry::{anyhow_is_retriable, retry_async, DEFAULT_BASE_DELAY_MS, DEFAULT_MAX_RETRIES}; /// Cap'n Proto traversal limit (words). 4 Mi words = 32 MiB; bounds DoS from deeply nested or large messages. const CAPNP_TRAVERSAL_LIMIT_WORDS: usize = 4 * 1024 * 1024; +/// A [`rustls::client::danger::ServerCertVerifier`] that accepts any certificate. +/// +/// **Development only.** Using this in production disables all TLS guarantees. +#[derive(Debug)] +struct InsecureServerCertVerifier; + +impl rustls::client::danger::ServerCertVerifier for InsecureServerCertVerifier { + fn verify_server_cert( + &self, + _end_entity: &CertificateDer<'_>, + _intermediates: &[CertificateDer<'_>], + _server_name: &ServerName<'_>, + _ocsp_response: &[u8], + _now: UnixTime, + ) -> Result { + Ok(rustls::client::danger::ServerCertVerified::assertion()) + } + + fn verify_tls12_signature( + &self, + _message: &[u8], + _cert: &CertificateDer<'_>, + _dss: &rustls::DigitallySignedStruct, + ) -> Result { + Ok(rustls::client::danger::HandshakeSignatureValid::assertion()) + } + + fn verify_tls13_signature( + &self, + _message: &[u8], + _cert: &CertificateDer<'_>, + _dss: &rustls::DigitallySignedStruct, + ) -> Result { + Ok(rustls::client::danger::HandshakeSignatureValid::assertion()) + } + + fn supported_verify_schemes(&self) -> Vec { + rustls::crypto::ring::default_provider() + .signature_verification_algorithms + .supported_schemes() + } +} + /// Establish a QUIC/TLS connection and return a `NodeService` client. /// /// Must be called from within a `LocalSet` because capnp-rpc is `!Send`. +/// +/// Reads [`INSECURE_SKIP_VERIFY`] to decide whether to bypass certificate +/// verification (set once at startup via [`crate::set_insecure_skip_verify`]). pub async fn connect_node( server: &str, ca_cert: &Path, server_name: &str, +) -> anyhow::Result { + let skip = INSECURE_SKIP_VERIFY.load(std::sync::atomic::Ordering::Relaxed); + connect_node_opt(server, ca_cert, server_name, skip).await +} + +/// Like [`connect_node`] but with an explicit `insecure_skip_verify` toggle. +/// +/// When `insecure_skip_verify` is `true`, certificate verification is disabled entirely. +/// This is intended for development and testing only. +pub async fn connect_node_opt( + server: &str, + ca_cert: &Path, + server_name: &str, + insecure_skip_verify: bool, ) -> anyhow::Result { let addr: SocketAddr = server .parse() .with_context(|| format!("server must be host:port, got {server}"))?; - let cert_bytes = std::fs::read(ca_cert).with_context(|| format!("read ca_cert {ca_cert:?}"))?; - let mut roots = RootCertStore::empty(); - roots - .add(CertificateDer::from(cert_bytes)) - .context("add root cert")?; - - let mut tls = RustlsClientConfig::builder() - .with_root_certificates(roots) - .with_no_client_auth(); + let mut tls = if insecure_skip_verify { + RustlsClientConfig::builder() + .dangerous() + .with_custom_certificate_verifier(Arc::new(InsecureServerCertVerifier)) + .with_no_client_auth() + } else { + let cert_bytes = + std::fs::read(ca_cert).with_context(|| format!("read ca_cert {ca_cert:?}"))?; + let mut roots = RootCertStore::empty(); + roots + .add(CertificateDer::from(cert_bytes)) + .context("add root cert")?; + RustlsClientConfig::builder() + .with_root_certificates(roots) + .with_no_client_auth() + }; tls.alpn_protocols = vec![b"capnp".to_vec()]; let crypto = QuicClientConfig::try_from(tls) @@ -76,7 +143,9 @@ pub async fn connect_node( } pub fn set_auth(auth: &mut auth::Builder<'_>) -> anyhow::Result<()> { - let guard = AUTH_CONTEXT.read().expect("AUTH_CONTEXT poisoned"); + let guard = AUTH_CONTEXT + .read() + .map_err(|e| anyhow::anyhow!("AUTH_CONTEXT lock poisoned: {e}"))?; let ctx = guard.as_ref().ok_or_else(|| { anyhow::anyhow!( "init_auth must be called before RPCs (use a bearer or session token for authenticated commands)" @@ -257,7 +326,6 @@ pub async fn fetch_wait( || { let client = client.clone(); let recipient_key = recipient_key.clone(); - let timeout_ms = timeout_ms; async move { let mut req = client.fetch_wait_request(); { diff --git a/crates/quicproquo-client/src/client/session.rs b/crates/quicproquo-client/src/client/session.rs index e40fc9e..d17e2b5 100644 --- a/crates/quicproquo-client/src/client/session.rs +++ b/crates/quicproquo-client/src/client/session.rs @@ -51,7 +51,7 @@ impl SessionState { let hybrid_kp = state .hybrid_key .as_ref() - .map(|b| HybridKeypair::from_bytes(b)) + .map(HybridKeypair::from_bytes) .transpose() .context("decode hybrid key")?; @@ -109,7 +109,7 @@ impl SessionState { // Use the first 16 bytes of the group_id as the ConversationId. let conv_id = if group_id_bytes.len() >= 16 { ConversationId::from_slice(&group_id_bytes[..16]) - .unwrap_or_else(|| ConversationId([0; 16])) + .unwrap_or(ConversationId([0; 16])) } else { ConversationId::from_group_name(&hex::encode(&group_id_bytes)) }; @@ -188,7 +188,7 @@ impl SessionState { let member = self.members.get(conv_id).context("no such conversation")?; let blob = member .group_ref() - .map(|g| bincode::serialize(g)) + .map(bincode::serialize) .transpose() .context("serialize MLS group")?; diff --git a/crates/quicproquo-client/src/lib.rs b/crates/quicproquo-client/src/lib.rs index 71c5974..ef81052 100644 --- a/crates/quicproquo-client/src/lib.rs +++ b/crates/quicproquo-client/src/lib.rs @@ -15,6 +15,7 @@ //! docs for details. use std::sync::RwLock; +use std::sync::atomic::{AtomicBool, Ordering}; pub mod client; @@ -26,11 +27,23 @@ pub use client::commands::{ }; pub use client::repl::run_repl; -pub use client::rpc::{connect_node, create_channel, enqueue, fetch_wait, resolve_user}; +pub use client::rpc::{connect_node, connect_node_opt, create_channel, enqueue, fetch_wait, resolve_user}; // Global auth context — RwLock so the REPL can set it after OPAQUE login. pub(crate) static AUTH_CONTEXT: RwLock> = RwLock::new(None); +/// When `true`, [`connect_node`] skips TLS certificate verification. +/// Set via [`set_insecure_skip_verify`]; read by the RPC layer. +pub(crate) static INSECURE_SKIP_VERIFY: AtomicBool = AtomicBool::new(false); + +/// Enable or disable insecure (no-verify) TLS mode globally. +/// +/// **Development only.** When enabled, all outgoing connections skip certificate +/// verification, making them vulnerable to MITM attacks. +pub fn set_insecure_skip_verify(enabled: bool) { + INSECURE_SKIP_VERIFY.store(enabled, Ordering::Relaxed); +} + #[derive(Clone, Debug)] pub struct ClientAuth { pub(crate) version: u16, diff --git a/crates/quicproquo-client/src/main.rs b/crates/quicproquo-client/src/main.rs index 28f1be9..6cfa1fd 100644 --- a/crates/quicproquo-client/src/main.rs +++ b/crates/quicproquo-client/src/main.rs @@ -1,6 +1,6 @@ //! quicproquo CLI client. -use std::path::PathBuf; +use std::path::{Path, PathBuf}; use anyhow::Context; use clap::{Parser, Subcommand}; @@ -9,7 +9,7 @@ use quicproquo_client::{ cmd_chat, cmd_check_key, cmd_create_group, cmd_demo_group, cmd_export, cmd_export_verify, 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, ClientAuth, + init_auth, run_repl, set_insecure_skip_verify, ClientAuth, }; #[cfg(feature = "tui")] use quicproquo_client::client::tui::run_tui; @@ -56,6 +56,15 @@ struct Args { #[arg(long, global = true, env = "QPQ_STATE_PASSWORD")] state_password: Option, + /// DANGER: Skip TLS certificate verification. Development only. + /// Disables all certificate checks, making the connection vulnerable to MITM attacks. + #[arg( + long = "danger-accept-invalid-certs", + global = true, + env = "QPQ_DANGER_ACCEPT_INVALID_CERTS" + )] + danger_accept_invalid_certs: bool, + // ── Default-repl args (used when no subcommand is given) ───────── /// State file path (identity + MLS state). Used when running the default REPL. #[arg(long, default_value = "qpq-state.bin", env = "QPQ_STATE")] @@ -393,7 +402,7 @@ enum Command { /// `state` unchanged. This lets `qpq --username alice` automatically isolate /// Alice's state without requiring a manual `--state` flag. fn derive_state_path(state: PathBuf, username: Option<&str>) -> PathBuf { - if state == PathBuf::from("qpq-state.bin") { + if state == Path::new("qpq-state.bin") { if let Some(uname) = username { return PathBuf::from(format!("qpq-{uname}.bin")); } @@ -417,6 +426,11 @@ async fn main() -> anyhow::Result<()> { let args = Args::parse(); + if args.danger_accept_invalid_certs { + eprintln!("WARNING: TLS verification disabled — insecure mode"); + set_insecure_skip_verify(true); + } + // For the REPL and TUI, defer init_auth so they can resolve their own token via OPAQUE. // For all other subcommands, initialize auth immediately. #[cfg(not(feature = "tui"))] diff --git a/crates/quicproquo-core/Cargo.toml b/crates/quicproquo-core/Cargo.toml index 361ef4e..fbea760 100644 --- a/crates/quicproquo-core/Cargo.toml +++ b/crates/quicproquo-core/Cargo.toml @@ -43,6 +43,9 @@ tokio = { workspace = true } # Error handling thiserror = { workspace = true } +[lints] +workspace = true + [dev-dependencies] tokio = { workspace = true } criterion = { version = "0.5", features = ["html_reports"] } diff --git a/crates/quicproquo-core/src/app_message.rs b/crates/quicproquo-core/src/app_message.rs index a34a965..ad676ff 100644 --- a/crates/quicproquo-core/src/app_message.rs +++ b/crates/quicproquo-core/src/app_message.rs @@ -145,10 +145,10 @@ pub fn parse(bytes: &[u8]) -> Result<(MessageType, AppMessage), CoreError> { } let version = bytes[0]; if version != VERSION { - return Err(CoreError::AppMessage(format!("unsupported version {version}").into())); + return Err(CoreError::AppMessage(format!("unsupported version {version}"))); } let msg_type = MessageType::from_byte(bytes[1]) - .ok_or_else(|| CoreError::AppMessage(format!("unknown message type {}", bytes[1]).into()))?; + .ok_or_else(|| CoreError::AppMessage(format!("unknown message type {}", bytes[1])))?; let payload = &bytes[2..]; let app = match msg_type { diff --git a/crates/quicproquo-core/src/padding.rs b/crates/quicproquo-core/src/padding.rs index a9d892c..6d9c043 100644 --- a/crates/quicproquo-core/src/padding.rs +++ b/crates/quicproquo-core/src/padding.rs @@ -29,7 +29,7 @@ fn bucket_for(content_len: usize) -> usize { } } // Larger than biggest bucket: round up to nearest 16384-byte multiple. - ((total + 16383) / 16384) * 16384 + total.div_ceil(16384) * 16384 } /// Pad a payload to the next bucket boundary with cryptographic random bytes. diff --git a/crates/quicproquo-gen/Cargo.toml b/crates/quicproquo-gen/Cargo.toml index 516f294..50ddc79 100644 --- a/crates/quicproquo-gen/Cargo.toml +++ b/crates/quicproquo-gen/Cargo.toml @@ -9,5 +9,8 @@ license = "MIT" name = "qpq-gen" path = "src/main.rs" +[lints] +workspace = true + [dependencies] clap = { workspace = true } diff --git a/crates/quicproquo-kt/Cargo.toml b/crates/quicproquo-kt/Cargo.toml index 364392b..a5b06d6 100644 --- a/crates/quicproquo-kt/Cargo.toml +++ b/crates/quicproquo-kt/Cargo.toml @@ -5,6 +5,9 @@ edition = "2021" description = "Key Transparency: append-only SHA-256 Merkle log for (username, identity_key) bindings." license = "MIT" +[lints] +workspace = true + [dependencies] sha2 = { workspace = true } thiserror = { workspace = true } diff --git a/crates/quicproquo-p2p/Cargo.toml b/crates/quicproquo-p2p/Cargo.toml index 05a216b..595ebf8 100644 --- a/crates/quicproquo-p2p/Cargo.toml +++ b/crates/quicproquo-p2p/Cargo.toml @@ -5,6 +5,9 @@ edition = "2021" description = "P2P transport layer for quicproquo using iroh." license = "MIT" +[lints] +workspace = true + [dependencies] iroh = "0.96" tokio = { version = "1", features = ["macros", "rt-multi-thread", "time", "sync"] } diff --git a/crates/quicproquo-plugin-api/Cargo.toml b/crates/quicproquo-plugin-api/Cargo.toml index 94fa9dc..86efa97 100644 --- a/crates/quicproquo-plugin-api/Cargo.toml +++ b/crates/quicproquo-plugin-api/Cargo.toml @@ -5,5 +5,8 @@ edition = "2021" description = "C-ABI vtable for quicproquo server plugins. No std dependency; usable from bare-metal plugin authors." license = "MIT" +[lints] +workspace = true + # No dependencies — intentionally minimal so plugin authors have zero forced transitive deps. [dependencies] diff --git a/crates/quicproquo-plugin-api/src/lib.rs b/crates/quicproquo-plugin-api/src/lib.rs index f1eac5f..cc99238 100644 --- a/crates/quicproquo-plugin-api/src/lib.rs +++ b/crates/quicproquo-plugin-api/src/lib.rs @@ -186,5 +186,7 @@ pub struct HookVTable { // responsible for its own thread safety. The server only calls hook functions // one at a time per plugin (wrapped in a single Arc). Plugins that mutate // user_data through callbacks must use interior mutability. +#[allow(unsafe_code)] unsafe impl Send for HookVTable {} +#[allow(unsafe_code)] unsafe impl Sync for HookVTable {} diff --git a/crates/quicproquo-proto/Cargo.toml b/crates/quicproquo-proto/Cargo.toml index fe85028..fe264fd 100644 --- a/crates/quicproquo-proto/Cargo.toml +++ b/crates/quicproquo-proto/Cargo.toml @@ -11,5 +11,12 @@ build = "build.rs" [dependencies] capnp = { workspace = true } +[lints.rust] +unsafe_code = "warn" + +[lints.clippy] +# Generated Cap'n Proto code uses patterns that trigger clippy lints. +unwrap_used = "allow" + [build-dependencies] capnpc = { workspace = true } diff --git a/crates/quicproquo-server/Cargo.toml b/crates/quicproquo-server/Cargo.toml index 95df681..aa49387 100644 --- a/crates/quicproquo-server/Cargo.toml +++ b/crates/quicproquo-server/Cargo.toml @@ -64,5 +64,8 @@ metrics-exporter-prometheus = "0.15" # mDNS service announcement for local mesh / Freifunk node discovery. mdns-sd = "0.12" +[lints] +workspace = true + [dev-dependencies] tempfile = "3" diff --git a/crates/quicproquo-server/src/auth.rs b/crates/quicproquo-server/src/auth.rs index 3ee8d0e..78df655 100644 --- a/crates/quicproquo-server/src/auth.rs +++ b/crates/quicproquo-server/src/auth.rs @@ -178,7 +178,7 @@ pub fn validate_auth_context( Err(crate::error_codes::coded_error(E003_INVALID_TOKEN, "invalid accessToken")) } -pub fn require_identity<'a>(auth_ctx: &'a AuthContext) -> Result<&'a [u8], capnp::Error> { +pub fn require_identity(auth_ctx: &AuthContext) -> Result<&[u8], capnp::Error> { match auth_ctx.identity_key.as_deref() { Some(ik) => Ok(ik), None => Err(crate::error_codes::coded_error( diff --git a/crates/quicproquo-server/src/config.rs b/crates/quicproquo-server/src/config.rs index 98a179e..2ea0c0f 100644 --- a/crates/quicproquo-server/src/config.rs +++ b/crates/quicproquo-server/src/config.rs @@ -121,7 +121,7 @@ pub fn merge_config(args: &crate::Args, file: &FileConfig) -> EffectiveConfig { args.data_dir.clone() }; - let tls_cert = if args.tls_cert == PathBuf::from(DEFAULT_TLS_CERT) { + let tls_cert = if args.tls_cert == Path::new(DEFAULT_TLS_CERT) { file.tls_cert .clone() .unwrap_or_else(|| PathBuf::from(DEFAULT_TLS_CERT)) @@ -129,7 +129,7 @@ pub fn merge_config(args: &crate::Args, file: &FileConfig) -> EffectiveConfig { args.tls_cert.clone() }; - let tls_key = if args.tls_key == PathBuf::from(DEFAULT_TLS_KEY) { + let tls_key = if args.tls_key == Path::new(DEFAULT_TLS_KEY) { file.tls_key .clone() .unwrap_or_else(|| PathBuf::from(DEFAULT_TLS_KEY)) @@ -159,7 +159,7 @@ pub fn merge_config(args: &crate::Args, file: &FileConfig) -> EffectiveConfig { args.store_backend.clone() }; - let db_path = if args.db_path == PathBuf::from(DEFAULT_DB_PATH) { + let db_path = if args.db_path == Path::new(DEFAULT_DB_PATH) { file.db_path .clone() .unwrap_or_else(|| PathBuf::from(DEFAULT_DB_PATH)) diff --git a/crates/quicproquo-server/src/main.rs b/crates/quicproquo-server/src/main.rs index 2c6df91..26097a3 100644 --- a/crates/quicproquo-server/src/main.rs +++ b/crates/quicproquo-server/src/main.rs @@ -22,6 +22,7 @@ mod federation; pub mod hooks; mod metrics; mod node_service; +#[allow(unsafe_code)] // FFI: C-ABI plugin interaction requires unsafe blocks mod plugin_loader; mod sql_store; mod tls; @@ -213,7 +214,7 @@ async fn main() -> anyhow::Result<()> { } Arc::new(SqlStore::open(&effective.db_path, &effective.db_key)?) } - "file" | _ => { + _ => { tracing::info!(dir = %effective.data_dir, "opening file-backed store"); Arc::new(FileBackedStore::open(&effective.data_dir)?) } diff --git a/crates/quicproquo-server/src/node_service/channel_ops.rs b/crates/quicproquo-server/src/node_service/channel_ops.rs index 17d02ae..7aa7181 100644 --- a/crates/quicproquo-server/src/node_service/channel_ops.rs +++ b/crates/quicproquo-server/src/node_service/channel_ops.rs @@ -53,7 +53,7 @@ impl NodeServiceImpl { )); } - let (channel_id, was_new) = match self.store.create_channel(&identity, &peer_key) { + let (channel_id, was_new) = match self.store.create_channel(identity, &peer_key) { Ok(pair) => pair, Err(e) => return Promise::err(storage_err(e)), }; diff --git a/crates/quicproquo-server/src/node_service/key_ops.rs b/crates/quicproquo-server/src/node_service/key_ops.rs index e8cf3b3..d865e1b 100644 --- a/crates/quicproquo-server/src/node_service/key_ops.rs +++ b/crates/quicproquo-server/src/node_service/key_ops.rs @@ -12,7 +12,7 @@ fn storage_err(err: StorageError) -> capnp::Error { coded_error(E009_STORAGE_ERROR, err) } -const MAX_KEYPACKAGE_BYTES: usize = 1 * 1024 * 1024; // 1 MB cap per KeyPackage +const MAX_KEYPACKAGE_BYTES: usize = 1024 * 1024; // 1 MB cap per KeyPackage impl NodeServiceImpl { pub fn handle_upload_key_package( diff --git a/crates/quicproquo-server/src/node_service/mod.rs b/crates/quicproquo-server/src/node_service/mod.rs index e2ec5b2..2ae67bf 100644 --- a/crates/quicproquo-server/src/node_service/mod.rs +++ b/crates/quicproquo-server/src/node_service/mod.rs @@ -221,6 +221,7 @@ pub struct NodeServiceImpl { } impl NodeServiceImpl { + #[allow(clippy::too_many_arguments)] pub fn new( store: Arc, waiters: Arc, Arc>>, @@ -254,6 +255,7 @@ impl NodeServiceImpl { } } +#[allow(clippy::too_many_arguments)] pub async fn handle_node_connection( connecting: quinn::Connecting, store: Arc, diff --git a/crates/quicproquo-server/src/storage.rs b/crates/quicproquo-server/src/storage.rs index 310e845..6cfa0f0 100644 --- a/crates/quicproquo-server/src/storage.rs +++ b/crates/quicproquo-server/src/storage.rs @@ -147,6 +147,7 @@ pub trait Store: Send + Sync { fn create_channel(&self, member_a: &[u8], member_b: &[u8]) -> Result<(Vec, bool), StorageError>; /// Get the two members of a channel by channel_id (16 bytes). Returns (member_a, member_b) in sorted order. + #[allow(clippy::type_complexity)] fn get_channel_members(&self, channel_id: &[u8]) -> Result, Vec)>, StorageError>; // ── Federation ────────────────────────────────────────────────────────── @@ -232,6 +233,7 @@ pub struct FileBackedStore { channels_path: PathBuf, key_packages: Mutex, VecDeque>>>, deliveries: Mutex, + #[allow(clippy::type_complexity)] channels: Mutex, (Vec, Vec)>>, hybrid_keys: Mutex, Vec>>, users: Mutex>>, @@ -282,6 +284,7 @@ impl FileBackedStore { }) } + #[allow(clippy::type_complexity)] fn load_channels( path: &Path, ) -> Result, (Vec, Vec)>, StorageError> { @@ -435,13 +438,13 @@ impl Store for FileBackedStore { map.entry(identity_key.to_vec()) .or_default() .push_back(package); - self.flush_kp_map(&self.kp_path, &*map) + self.flush_kp_map(&self.kp_path, &map) } fn fetch_key_package(&self, identity_key: &[u8]) -> Result>, StorageError> { let mut map = lock(&self.key_packages)?; let package = map.get_mut(identity_key).and_then(|q| q.pop_front()); - self.flush_kp_map(&self.kp_path, &*map)?; + self.flush_kp_map(&self.kp_path, &map)?; Ok(package) } @@ -460,7 +463,7 @@ impl Store for FileBackedStore { let seq = *entry; *entry = seq + 1; inner.map.entry(key).or_default().push_back(SeqEntry { seq, data: payload }); - self.flush_delivery_map(&self.ds_path, &*inner)?; + self.flush_delivery_map(&self.ds_path, &inner)?; Ok(seq) } @@ -479,7 +482,7 @@ impl Store for FileBackedStore { .get_mut(&key) .map(|q| q.drain(..).map(|e| (e.seq, e.data)).collect()) .unwrap_or_default(); - self.flush_delivery_map(&self.ds_path, &*inner)?; + self.flush_delivery_map(&self.ds_path, &inner)?; Ok(messages) } @@ -502,7 +505,7 @@ impl Store for FileBackedStore { q.drain(..count).map(|e| (e.seq, e.data)).collect() }) .unwrap_or_default(); - self.flush_delivery_map(&self.ds_path, &*inner)?; + self.flush_delivery_map(&self.ds_path, &inner)?; Ok(messages) } @@ -527,7 +530,7 @@ impl Store for FileBackedStore { ) -> Result<(), StorageError> { let mut map = lock(&self.hybrid_keys)?; map.insert(identity_key.to_vec(), hybrid_pk); - self.flush_hybrid_keys(&self.hk_path, &*map) + self.flush_hybrid_keys(&self.hk_path, &map) } fn fetch_hybrid_key(&self, identity_key: &[u8]) -> Result>, StorageError> { @@ -615,7 +618,7 @@ impl Store for FileBackedStore { v.insert(record); } } - self.flush_users(&self.users_path, &*map) + self.flush_users(&self.users_path, &map) } fn get_user_record(&self, username: &str) -> Result>, StorageError> { @@ -635,7 +638,7 @@ impl Store for FileBackedStore { ) -> Result<(), StorageError> { let mut map = lock(&self.identity_keys)?; map.insert(username.to_string(), identity_key); - self.flush_map_string_bytes(&self.identity_keys_path, &*map) + self.flush_map_string_bytes(&self.identity_keys_path, &map) } fn get_user_identity_key(&self, username: &str) -> Result>, StorageError> { @@ -697,7 +700,7 @@ impl Store for FileBackedStore { } else { 0 }; - self.flush_delivery_map(&self.ds_path, &*inner)?; + self.flush_delivery_map(&self.ds_path, &inner)?; Ok(removed) } @@ -730,7 +733,7 @@ impl Store for FileBackedStore { rand::thread_rng().fill_bytes(&mut channel_id); let channel_id = channel_id.to_vec(); map.insert(channel_id.clone(), (a, b)); - self.flush_channels(&self.channels_path, &*map)?; + self.flush_channels(&self.channels_path, &map)?; Ok((channel_id, true)) } diff --git a/docker/Dockerfile b/docker/Dockerfile index cc2d806..0211423 100644 --- a/docker/Dockerfile +++ b/docker/Dockerfile @@ -12,11 +12,17 @@ WORKDIR /build # Copy manifests first so dependency layers are cached independently of source. COPY Cargo.toml Cargo.lock ./ -COPY crates/quicproquo-core/Cargo.toml crates/quicproquo-core/Cargo.toml -COPY crates/quicproquo-proto/Cargo.toml crates/quicproquo-proto/Cargo.toml -COPY crates/quicproquo-server/Cargo.toml crates/quicproquo-server/Cargo.toml -COPY crates/quicproquo-client/Cargo.toml crates/quicproquo-client/Cargo.toml -COPY crates/quicproquo-p2p/Cargo.toml crates/quicproquo-p2p/Cargo.toml +COPY crates/quicproquo-core/Cargo.toml crates/quicproquo-core/Cargo.toml +COPY crates/quicproquo-proto/Cargo.toml crates/quicproquo-proto/Cargo.toml +COPY crates/quicproquo-server/Cargo.toml crates/quicproquo-server/Cargo.toml +COPY crates/quicproquo-client/Cargo.toml crates/quicproquo-client/Cargo.toml +COPY crates/quicproquo-p2p/Cargo.toml crates/quicproquo-p2p/Cargo.toml +COPY crates/quicproquo-bot/Cargo.toml crates/quicproquo-bot/Cargo.toml +COPY crates/quicproquo-gen/Cargo.toml crates/quicproquo-gen/Cargo.toml +COPY crates/quicproquo-kt/Cargo.toml crates/quicproquo-kt/Cargo.toml +COPY crates/quicproquo-plugin-api/Cargo.toml crates/quicproquo-plugin-api/Cargo.toml +COPY crates/quicproquo-gui/Cargo.toml crates/quicproquo-gui/Cargo.toml +COPY crates/quicproquo-mobile/Cargo.toml crates/quicproquo-mobile/Cargo.toml # Create dummy source files so `cargo build` can resolve the dependency graph # and cache the compiled dependencies before copying real source. @@ -26,11 +32,23 @@ RUN mkdir -p \ crates/quicproquo-server/src \ crates/quicproquo-client/src \ crates/quicproquo-p2p/src \ + crates/quicproquo-bot/src \ + crates/quicproquo-gen/src \ + crates/quicproquo-kt/src \ + crates/quicproquo-plugin-api/src \ + crates/quicproquo-gui/src \ + crates/quicproquo-mobile/src \ && echo 'fn main() {}' > crates/quicproquo-server/src/main.rs \ && echo 'fn main() {}' > crates/quicproquo-client/src/main.rs \ + && echo 'fn main() {}' > crates/quicproquo-gen/src/main.rs \ + && echo 'fn main() {}' > crates/quicproquo-bot/src/main.rs \ && touch crates/quicproquo-core/src/lib.rs \ && touch crates/quicproquo-proto/src/lib.rs \ - && touch crates/quicproquo-p2p/src/lib.rs + && touch crates/quicproquo-p2p/src/lib.rs \ + && touch crates/quicproquo-kt/src/lib.rs \ + && touch crates/quicproquo-plugin-api/src/lib.rs \ + && touch crates/quicproquo-gui/src/lib.rs \ + && touch crates/quicproquo-mobile/src/lib.rs # Schemas must exist before the proto crate's build.rs runs. COPY schemas/ schemas/ @@ -46,6 +64,8 @@ RUN touch \ crates/quicproquo-core/src/lib.rs \ crates/quicproquo-proto/src/lib.rs \ crates/quicproquo-p2p/src/lib.rs \ + crates/quicproquo-kt/src/lib.rs \ + crates/quicproquo-plugin-api/src/lib.rs \ crates/quicproquo-server/src/main.rs \ crates/quicproquo-client/src/main.rs diff --git a/docs/src/SUMMARY.md b/docs/src/SUMMARY.md index 0528084..770a562 100644 --- a/docs/src/SUMMARY.md +++ b/docs/src/SUMMARY.md @@ -17,6 +17,7 @@ - [Building from Source](getting-started/building.md) - [Running the Server](getting-started/running-the-server.md) - [Running the Client](getting-started/running-the-client.md) +- [TLS in quicproquo](getting-started/tls.md) - [Certificate Lifecycle and CA-Signed TLS](getting-started/certificate-lifecycle.md) - [Docker Deployment](getting-started/docker.md) - [Bot SDK](getting-started/bot-sdk.md) diff --git a/docs/src/getting-started/tls.md b/docs/src/getting-started/tls.md new file mode 100644 index 0000000..68c23eb --- /dev/null +++ b/docs/src/getting-started/tls.md @@ -0,0 +1,100 @@ +# TLS in quicproquo + +quicproquo uses QUIC (RFC 9000) for all client-server communication. QUIC mandates TLS 1.3, so every connection is encrypted and authenticated at the transport layer — there is no plaintext mode. + +## How it works + +The server holds a TLS certificate and private key (DER format). On startup it either loads existing files or, in development mode, generates a self-signed certificate automatically. The client authenticates the server by verifying its certificate against a trusted root provided via `--ca-cert` (or `QPQ_CA_CERT`). + +The TLS handshake negotiates the ALPN protocol `capnp`, after which the QUIC bi-directional stream carries Cap'n Proto RPC traffic. + +## Certificate pinning with `--ca-cert` + +By default the client trusts exactly the certificate (or CA) in the file given by `--ca-cert`: + +```bash +qpq --ca-cert data/server-cert.der --server-name localhost health --server 127.0.0.1:7000 +``` + +This is a form of **certificate pinning**: the client will only connect to a server whose certificate chains to the provided root. For single-server deployments, pass the server's own self-signed certificate. For CA-issued certificates, pass the CA's root certificate instead. + +| Flag / Env var | Purpose | +|---|---| +| `--ca-cert` / `QPQ_CA_CERT` | Path to trusted root certificate (DER) | +| `--server-name` / `QPQ_SERVER_NAME` | Expected TLS server name (must match certificate SAN) | + +## The `--danger-accept-invalid-certs` flag + +For local development and testing you can skip certificate verification entirely: + +```bash +qpq --danger-accept-invalid-certs health --server 127.0.0.1:7000 +``` + +Or via the environment: + +```bash +export QPQ_DANGER_ACCEPT_INVALID_CERTS=true +qpq health --server 127.0.0.1:7000 +``` + +When active, the client prints a warning to stderr: + +``` +WARNING: TLS verification disabled — insecure mode +``` + +**Never use this flag in production.** It disables all certificate checks, making the connection vulnerable to man-in-the-middle attacks. It exists solely so that developers can iterate without managing certificates during local testing. + +## Generating self-signed certificates for development + +### Using rcgen (Rust) + +The server generates a self-signed certificate automatically when the cert/key files are missing (unless `QPQ_PRODUCTION=1` is set). The generated files are written to: + +- `data/server-cert.der` — DER-encoded certificate +- `data/server-key.der` — DER-encoded PKCS#8 private key + +### Using openssl + +To generate a self-signed certificate manually: + +```bash +# Generate a private key and self-signed certificate (PEM) +openssl req -x509 -newkey ec -pkeyopt ec_paramgen_curve:prime256v1 \ + -keyout key.pem -out cert.pem -days 365 -nodes \ + -subj "/CN=localhost" \ + -addext "subjectAltName=DNS:localhost,IP:127.0.0.1" + +# Convert to DER format (required by quicproquo) +openssl x509 -in cert.pem -outform DER -out data/server-cert.der +openssl pkcs8 -topk8 -inform PEM -outform DER -in key.pem -out data/server-key.der -nocrypt +``` + +Point the server at the DER files: + +```bash +export QPQ_TLS_CERT=data/server-cert.der +export QPQ_TLS_KEY=data/server-key.der +cargo run -p quicproquo-server +``` + +And the client at the certificate: + +```bash +qpq --ca-cert data/server-cert.der --server-name localhost repl +``` + +## CA-issued certificates + +For production deployments with a public CA (e.g. Let's Encrypt): + +1. Obtain the certificate and key (e.g. via certbot). +2. Convert to DER format as shown above. +3. Configure the client to trust the CA root rather than the server certificate directly: + +```bash +qpq --ca-cert /etc/ssl/certs/isrg-root-x1.der --server-name chat.example.com repl +``` + +See [Certificate Lifecycle and CA-Signed TLS](certificate-lifecycle.md) for rotation, OCSP, and operational details.