feat: Sprint 1 — production hardening, TLS lifecycle, CI coverage, lint cleanup
- 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
This commit is contained in:
51
.github/workflows/ci.yml
vendored
51
.github/workflows/ci.yml
vendored
@@ -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 .
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -4,6 +4,8 @@
|
||||
|
||||
# QPQ — quicproquo
|
||||
|
||||
[](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
|
||||
|
||||
@@ -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" }
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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(),
|
||||
))
|
||||
|
||||
@@ -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");
|
||||
|
||||
|
||||
@@ -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).
|
||||
|
||||
@@ -25,15 +25,13 @@ where
|
||||
Fut: Future<Output = Result<T, E>>,
|
||||
P: Fn(&E) -> bool,
|
||||
{
|
||||
let mut last_err = None;
|
||||
let mut last_err: Option<E> = 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;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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<rustls::client::danger::ServerCertVerified, rustls::Error> {
|
||||
Ok(rustls::client::danger::ServerCertVerified::assertion())
|
||||
}
|
||||
|
||||
fn verify_tls12_signature(
|
||||
&self,
|
||||
_message: &[u8],
|
||||
_cert: &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: &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> {
|
||||
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<node_service::Client> {
|
||||
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<node_service::Client> {
|
||||
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();
|
||||
{
|
||||
|
||||
@@ -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")?;
|
||||
|
||||
|
||||
@@ -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<Option<ClientAuth>> = 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,
|
||||
|
||||
@@ -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<String>,
|
||||
|
||||
/// 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"))]
|
||||
|
||||
@@ -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"] }
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -9,5 +9,8 @@ license = "MIT"
|
||||
name = "qpq-gen"
|
||||
path = "src/main.rs"
|
||||
|
||||
[lints]
|
||||
workspace = true
|
||||
|
||||
[dependencies]
|
||||
clap = { workspace = true }
|
||||
|
||||
@@ -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 }
|
||||
|
||||
@@ -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"] }
|
||||
|
||||
@@ -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]
|
||||
|
||||
@@ -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 {}
|
||||
|
||||
@@ -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 }
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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))
|
||||
|
||||
@@ -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)?)
|
||||
}
|
||||
|
||||
@@ -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)),
|
||||
};
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -221,6 +221,7 @@ pub struct NodeServiceImpl {
|
||||
}
|
||||
|
||||
impl NodeServiceImpl {
|
||||
#[allow(clippy::too_many_arguments)]
|
||||
pub fn new(
|
||||
store: Arc<dyn Store>,
|
||||
waiters: Arc<DashMap<Vec<u8>, Arc<Notify>>>,
|
||||
@@ -254,6 +255,7 @@ impl NodeServiceImpl {
|
||||
}
|
||||
}
|
||||
|
||||
#[allow(clippy::too_many_arguments)]
|
||||
pub async fn handle_node_connection(
|
||||
connecting: quinn::Connecting,
|
||||
store: Arc<dyn Store>,
|
||||
|
||||
@@ -147,6 +147,7 @@ pub trait Store: Send + Sync {
|
||||
fn create_channel(&self, member_a: &[u8], member_b: &[u8]) -> Result<(Vec<u8>, 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<Option<(Vec<u8>, Vec<u8>)>, StorageError>;
|
||||
|
||||
// ── Federation ──────────────────────────────────────────────────────────
|
||||
@@ -232,6 +233,7 @@ pub struct FileBackedStore {
|
||||
channels_path: PathBuf,
|
||||
key_packages: Mutex<HashMap<Vec<u8>, VecDeque<Vec<u8>>>>,
|
||||
deliveries: Mutex<QueueMapV3>,
|
||||
#[allow(clippy::type_complexity)]
|
||||
channels: Mutex<HashMap<Vec<u8>, (Vec<u8>, Vec<u8>)>>,
|
||||
hybrid_keys: Mutex<HashMap<Vec<u8>, Vec<u8>>>,
|
||||
users: Mutex<HashMap<String, Vec<u8>>>,
|
||||
@@ -282,6 +284,7 @@ impl FileBackedStore {
|
||||
})
|
||||
}
|
||||
|
||||
#[allow(clippy::type_complexity)]
|
||||
fn load_channels(
|
||||
path: &Path,
|
||||
) -> Result<HashMap<Vec<u8>, (Vec<u8>, Vec<u8>)>, 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<Option<Vec<u8>>, 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<Option<Vec<u8>>, 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<Option<Vec<u8>>, 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<Option<Vec<u8>>, 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))
|
||||
}
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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)
|
||||
|
||||
100
docs/src/getting-started/tls.md
Normal file
100
docs/src/getting-started/tls.md
Normal file
@@ -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.
|
||||
Reference in New Issue
Block a user