From 5a66c2e9547f658a0840dfe58d5c311a068e0c00 Mon Sep 17 00:00:00 2001 From: Christian Nennemann Date: Wed, 4 Mar 2026 14:13:58 +0100 Subject: [PATCH] chore: fix all clippy warnings across workspace --- Cargo.lock | 1 + ROADMAP.html | 891 ++++++++++++++++++ assets/left.ansi | 26 + assets/right.ansi | 24 + assets/screenshot.png | Bin 0 -> 68207 bytes assets/screenshot.txt | 59 ++ .../quicproquo-client/src/client/commands.rs | 4 +- .../src/client/conversation.rs | 2 + crates/quicproquo-client/src/client/repl.rs | 6 +- crates/quicproquo-client/src/client/retry.rs | 1 + crates/quicproquo-client/src/client/rpc.rs | 2 +- crates/quicproquo-client/src/client/state.rs | 1 + .../src/client/token_cache.rs | 1 + crates/quicproquo-client/tests/e2e.rs | 11 +- .../benches/crypto_benchmarks.rs | 13 +- .../benches/hybrid_kem_bench.rs | 1 + .../quicproquo-core/benches/mls_operations.rs | 1 + .../quicproquo-core/benches/serialization.rs | 1 + crates/quicproquo-core/src/app_message.rs | 1 + crates/quicproquo-core/src/group.rs | 1 + crates/quicproquo-core/src/hybrid_crypto.rs | 1 + crates/quicproquo-core/src/hybrid_kem.rs | 1 + crates/quicproquo-core/src/identity.rs | 71 +- crates/quicproquo-core/src/padding.rs | 1 + crates/quicproquo-core/src/sealed_sender.rs | 1 + crates/quicproquo-kt/src/proof.rs | 1 + crates/quicproquo-kt/src/tree.rs | 1 + crates/quicproquo-p2p/src/identity.rs | 2 +- crates/quicproquo-sdk/src/conversation.rs | 2 + crates/quicproquo-sdk/src/groups.rs | 2 + crates/quicproquo-sdk/src/state.rs | 1 + crates/quicproquo-server/src/domain/blobs.rs | 2 +- .../src/federation/routing.rs | 1 + .../src/node_service/blob_ops.rs | 2 +- .../src/node_service/delivery.rs | 2 +- crates/quicproquo-server/src/sql_store.rs | 1 + crates/quicproquo-server/src/storage.rs | 2 + docs/V2-DESIGN-ANALYSIS.md | 380 ++++++++ docs/V2-MASTER-PLAN.md | 328 +++++++ examples/plugins/logging_plugin/Cargo.lock | 14 + examples/plugins/rate_limit_plugin/Cargo.lock | 14 + scripts/render_terminal.py | 212 +++++ scripts/screenshot.sh | 92 ++ 43 files changed, 2124 insertions(+), 57 deletions(-) create mode 100644 ROADMAP.html create mode 100644 assets/left.ansi create mode 100644 assets/right.ansi create mode 100644 assets/screenshot.png create mode 100644 assets/screenshot.txt create mode 100644 docs/V2-DESIGN-ANALYSIS.md create mode 100644 docs/V2-MASTER-PLAN.md create mode 100644 examples/plugins/logging_plugin/Cargo.lock create mode 100644 examples/plugins/rate_limit_plugin/Cargo.lock create mode 100755 scripts/render_terminal.py create mode 100755 scripts/screenshot.sh diff --git a/Cargo.lock b/Cargo.lock index 4986493..b6d39fa 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -4387,6 +4387,7 @@ dependencies = [ "serde", "serde_json", "sha2 0.10.9", + "tempfile", "thiserror 1.0.69", "tokio", "tracing", diff --git a/ROADMAP.html b/ROADMAP.html new file mode 100644 index 0000000..8450eff --- /dev/null +++ b/ROADMAP.html @@ -0,0 +1,891 @@ + + + + + + Full Roadmap (Phases 1–8) - quicproquo + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
+
+

Keyboard shortcuts

+
+

Press or to navigate between chapters

+

Press S or / to search in the book

+

Press ? to show this help

+

Press Esc to hide this help

+
+
+
+
+ + + + + + + + + + + + + +
+ +
+
+ + + + + + + +
+
+

Roadmap — quicproquo

+
+

From proof-of-concept to production-grade E2E encrypted messaging.

+

Each phase is designed to be tackled sequentially. Items within a phase +can be parallelised. Check the box when done.

+
+
+

Phase 1 — Production Hardening (Critical)

+

Eliminate all crash paths, enforce secure defaults, fix deployment blockers.

+
    +
  • +

    1.1 Remove .unwrap() / .expect() from production paths

    +
      +
    • Replace AUTH_CONTEXT.read().expect() in client RPC with proper Result
    • +
    • Replace "0.0.0.0:0".parse().unwrap() in client with fallible parse
    • +
    • Replace Mutex::lock().unwrap() in server storage with .map_err()
    • +
    • Audit: grep -rn 'unwrap()\|expect(' crates/ outside #[cfg(test)]
    • +
    +
  • +
  • +

    1.2 Enforce secure defaults in production mode

    +
      +
    • Reject startup if QPQ_PRODUCTION=true and auth_token is empty or "devtoken"
    • +
    • Require non-empty db_key when using SQL backend in production
    • +
    • Refuse to auto-generate TLS certs in production mode (require existing cert+key)
    • +
    • Already partially implemented — verify and harden the validation in config.rs
    • +
    +
  • +
  • +

    1.3 Fix .gitignore

    +
      +
    • Add data/, *.der, *.pem, *.db, *.bin (state files), *.ks (keystores)
    • +
    • Verify no secrets are already tracked: git ls-files data/ *.der *.db
    • +
    +
  • +
  • +

    1.4 Fix Dockerfile

    +
      +
    • Sync workspace members (handle excluded p2p crate)
    • +
    • Create dedicated user/group instead of nobody
    • +
    • Set writable QPQ_DATA_DIR with correct permissions
    • +
    • Test: docker build . && docker run --rm -it qpq-server --help
    • +
    +
  • +
  • +

    1.5 TLS certificate lifecycle

    +
      +
    • Document CA-signed cert setup (Let’s Encrypt / custom CA)
    • +
    • Add --tls-required flag that refuses to start without valid cert
    • +
    • Log clear warning when using self-signed certs
    • +
    • Document certificate rotation procedure
    • +
    +
  • +
+
+

Phase 2 — Test & CI Maturity

+

Build confidence before adding features.

+
    +
  • +

    2.1 Expand E2E test coverage

    +
      +
    • Auth failure scenarios (wrong password, expired token, invalid token)
    • +
    • Message ordering verification (send N messages, verify seq numbers)
    • +
    • Concurrent clients (3+ members in group, simultaneous send/recv)
    • +
    • OPAQUE registration + login full flow
    • +
    • Queue full behavior (>1000 messages)
    • +
    • Rate limiting behavior (>100 enqueues/minute)
    • +
    • Reconnection after server restart
    • +
    • KeyPackage exhaustion (fetch when none available)
    • +
    +
  • +
  • +

    2.2 Add unit tests for untested paths

    +
      +
    • Client retry logic (exponential backoff, jitter, retriable classification)
    • +
    • REPL input parsing edge cases (empty input, special characters, / commands)
    • +
    • State file encryption/decryption round-trip with bad password
    • +
    • Token cache expiry
    • +
    • Conversation store migrations
    • +
    +
  • +
  • +

    2.3 CI hardening

    +
      +
    • Add .github/CODEOWNERS (crypto, auth, wire-format require 2 reviewers)
    • +
    • Ensure cargo deny check runs on every PR (already in CI — verify)
    • +
    • Add cargo audit as blocking check (already in CI — verify)
    • +
    • Add coverage reporting (tarpaulin or llvm-cov)
    • +
    • Add CI job for Docker build validation
    • +
    +
  • +
  • +

    2.4 Clean up build warnings

    +
      +
    • Fix Cap’n Proto generated unused_parens warnings
    • +
    • Remove dead code / unused imports
    • +
    • Address openmls future-incompat warnings
    • +
    • Target: cargo clippy --workspace -- -D warnings passes clean
    • +
    +
  • +
+
+

Phase 3 — Client SDKs: Native QUIC + Cap’n Proto Everywhere

+

No REST gateway. No protocol dilution. The .capnp schemas are the +interface definition. Every SDK speaks native QUIC + Cap’n Proto. The +project name stays honest.

+

Why this matters

+

The name is quicnprotochat — the protocol IS the product. Instead +of adding an HTTP translation layer that loses zero-copy performance and +adds base64 overhead, we invest in making the native protocol accessible +from every language that has QUIC + Cap’n Proto support, and provide +WASM/FFI for the crypto layer.

+

Architecture

+
  Server: QUIC + Cap'n Proto (single protocol, no gateway)
+
+  Client SDKs:
+    ┌─── Rust         quinn + capnp-rpc          (existing, reference impl)
+    ├─── Go           quic-go + go-capnp          (native, high confidence)
+    ├─── Python       aioquic + pycapnp            (native QUIC, manual framing)
+    ├─── C/C++        msquic/ngtcp2 + capnproto    (reference impl, full RPC)
+    └─── Browser      WebTransport + capnp (WASM)  (QUIC transport, no HTTP needed)
+
+  Crypto layer (client-side MLS, shared across all SDKs):
+    ┌─── Rust crate   (native, existing)
+    ├─── WASM module  (browsers, Node.js, Deno)
+    └─── C FFI        (Swift, Kotlin, Python, Go via cgo)
+
+

Language support reality check

+
+ + + + + + + + + + + +
LanguageQUICCap’n ProtoRPCConfidence
Rustquinn ✅capnp-rpc ✅Full ✅Existing
Goquic-go ✅go-capnp ✅Level 1 ✅High
Pythonaioquic ✅pycapnp ⚠️Manual framingMedium
C/C++msquic/ngtcp2 ✅capnproto ✅Full ✅High
BrowserWebTransport ✅WASM ✅Via WASM bridgeMedium
+
+

Implementation

+
    +
  • +

    3.1 Go SDK (quicproquo-go)

    +
      +
    • Generated Go types from node.capnp (6487-line codegen, all 24 RPC methods)
    • +
    • QUIC transport via quic-go with TLS 1.3 + ALPN "capnp"
    • +
    • High-level qpq package: Connect, Health, ResolveUser, CreateChannel, Send/SendWithTTL, Receive/ReceiveWait, DeleteAccount, OPAQUE auth
    • +
    • Example CLI in sdks/go/cmd/example/
    • +
    +
  • +
  • +

    3.2 Python SDK (quicproquo-py)

    +
      +
    • QUIC transport: aioquic with custom Cap’n Proto stream handler
    • +
    • Cap’n Proto serialization: pycapnp for message types
    • +
    • Manual RPC framing: length-prefixed request/response over QUIC stream
    • +
    • Async/await API matching the Rust client patterns
    • +
    • Crypto: PyO3 bindings to quicproquo-core for MLS operations
    • +
    • Publish: PyPI quicproquo
    • +
    • Example: async bot client
    • +
    +
  • +
  • +

    3.3 C FFI layer (quicproquo-ffi)

    +
      +
    • crates/quicproquo-ffi with 7 extern “C” functions: connect, login, send, receive, disconnect, last_error, free_string
    • +
    • Builds as libquicproquo_ffi.so / .dylib / .dll
    • +
    • Python ctypes wrapper in examples/python/qpq_client.py
    • +
    +
  • +
  • +

    3.4 WASM compilation of quicproquo-core

    +
      +
    • wasm-pack build target producing 175 KB WASM bundle (LTO + opt-level=s)
    • +
    • 13 wasm_bindgen functions: Ed25519 identity, hybrid KEM, safety numbers, sealed sender, padding
    • +
    • Browser-ready with crypto.getRandomValues() RNG
    • +
    • Published as sdks/typescript/wasm-crypto/
    • +
    +
  • +
  • +

    3.5 WebTransport server endpoint

    +
      +
    • Add HTTP/3 + WebTransport listener to server (same QUIC stack via quinn)
    • +
    • Cap’n Proto RPC framed over WebTransport bidirectional streams
    • +
    • Same auth, same storage, same RPC handlers — just a different stream source
    • +
    • Browsers connect via new WebTransport("https://server:7443")
    • +
    • ALPN negotiation: "h3" for WebTransport, "capnp" for native QUIC
    • +
    • Configurable port: --webtransport-listen 0.0.0.0:7443
    • +
    • Feature-flagged: --features webtransport
    • +
    +
  • +
  • +

    3.6 TypeScript/JavaScript SDK (@quicproquo/client)

    +
      +
    • QpqClient class: connect, offline, health, resolveUser, createChannel, send/sendWithTTL, receive, deleteAccount
    • +
    • WASM crypto wrapper: generateIdentity, sign/verify, hybridEncrypt/Decrypt, computeSafetyNumber, sealedSend, pad
    • +
    • WebSocket transport with request/response correlation and reconnection
    • +
    • Browser demo: interactive crypto playground + chat UI (sdks/typescript/demo/index.html)
    • +
    +
  • +
  • +

    3.7 SDK documentation and schema publishing

    +
      +
    • Publish .capnp schemas as the canonical API contract
    • +
    • Document the QUIC + Cap’n Proto connection pattern for each language
    • +
    • Provide a “build your own SDK” guide (QUIC stream → Cap’n Proto RPC bootstrap)
    • +
    • Reference implementation checklist: connect, auth, upload key, enqueue, fetch
    • +
    +
  • +
+
+

Phase 4 — Trust & Security Infrastructure

+

Address the security gaps required for real-world deployment.

+
    +
  • +

    4.1 Third-party cryptographic audit

    +
      +
    • Scope: MLS integration, OPAQUE flow, hybrid KEM, key lifecycle, zeroization
    • +
    • Firms: NCC Group, Trail of Bits, Cure53
    • +
    • Budget and timeline: typically 4-6 weeks, $50K–$150K
    • +
    • Publish report publicly (builds trust)
    • +
    +
  • +
  • +

    4.2 Key Transparency / revocation

    +
      +
    • Replace BasicCredential with X.509-based MLS credentials
    • +
    • Or: verifiable key directory (Merkle tree, auditable log)
    • +
    • Users can verify peer keys haven’t been substituted (MITM detection)
    • +
    • Revocation mechanism for compromised keys
    • +
    +
  • +
  • +

    4.3 Client authentication on Delivery Service

    +
      +
    • DS sender identity binding with explicit audit logging
    • +
    • sender_prefix tracking in enqueue/batch_enqueue RPCs
    • +
    • Sender identity derived from authenticated session
    • +
    +
  • +
  • +

    4.4 M7 — Post-quantum MLS integration

    +
      +
    • Integrate hybrid KEM (X25519 + ML-KEM-768) into the OpenMLS crypto provider
    • +
    • Group key material gets post-quantum confidentiality
    • +
    • Full test suite with PQ ciphersuite
    • +
    • Ref: existing hybrid_kem.rs and hybrid_crypto.rs
    • +
    +
  • +
  • +

    4.5 Username enumeration mitigation

    +
      +
    • 5 ms timing floor on resolveUser responses
    • +
    • Rate limiting to prevent bulk enumeration attacks
    • +
    +
  • +
+
+

Phase 5 — Features & UX

+

Make it a product people want to use.

+
    +
  • +

    5.1 Multi-device support

    +
      +
    • Account → multiple devices, each with own Ed25519 key + MLS KeyPackages
    • +
    • Device graph management (add device, remove device, list devices)
    • +
    • Messages delivered to all devices of a user
    • +
    • device_id field already in Auth struct — wire it through
    • +
    +
  • +
  • +

    5.2 Account recovery

    +
      +
    • Recovery codes or backup key (encrypted, stored by user)
    • +
    • Option: server-assisted recovery with security questions (lower security)
    • +
    • MLS state re-establishment after device loss
    • +
    +
  • +
  • +

    5.3 Full MLS lifecycle

    +
      +
    • Member removal (Remove proposal → Commit → fan-out)
    • +
    • Credential update (Update proposal for key rotation)
    • +
    • Explicit proposal handling (queue proposals, batch commit)
    • +
    • Group metadata (name, description, avatar hash)
    • +
    +
  • +
  • +

    5.4 Message editing and deletion

    +
      +
    • Edit (0x06) and Delete (0x07) message types in AppMessage
    • +
    • /edit <index> <text> and /delete <index> REPL commands (own messages only)
    • +
    • Database update/removal on incoming edit/delete
    • +
    +
  • +
  • +

    5.5 File and media transfer

    +
      +
    • uploadBlob / downloadBlob RPCs with 256 KB chunked streaming
    • +
    • SHA-256 content-addressable storage with hash verification
    • +
    • FileRef (0x08) message type with blob_id, filename, file_size, mime_type
    • +
    • /send-file <path> and /download <index> REPL commands with progress bars
    • +
    • 50 MB max file size, automatic MIME detection via mime_guess
    • +
    +
  • +
  • +

    5.6 Abuse prevention and moderation

    +
      +
    • Block user (client-side, suppress display)
    • +
    • Report message (encrypted report to admin key)
    • +
    • Admin tools: ban user, delete account, audit log
    • +
    +
  • +
  • +

    5.7 Offline message queue (client-side)

    +
      +
    • Queue messages when disconnected, send on reconnect
    • +
    • Idempotent message IDs to prevent duplicates
    • +
    • Gap detection: compare local seq with server seq
    • +
    +
  • +
+
+

Phase 6 — Scale & Operations

+

Prepare for real traffic.

+
    +
  • +

    6.1 Distributed rate limiting

    +
      +
    • Current: in-memory per-process, lost on restart
    • +
    • Move to Redis or shared state for multi-node deployments
    • +
    • Sliding window with configurable thresholds
    • +
    +
  • +
  • +

    6.2 Multi-node / horizontal scaling

    +
      +
    • Stateless server design (already mostly there — state is in storage backend)
    • +
    • Shared PostgreSQL or CockroachDB backend (replace SQLite)
    • +
    • Message queue fan-out (Redis pub/sub or NATS for cross-node notification)
    • +
    • Load balancer health check via QUIC RPC health() or Prometheus /metrics
    • +
    +
  • +
  • +

    6.3 Operational runbook

    +
      +
    • Backup / restore procedures (SQLCipher, file backend)
    • +
    • Key rotation (auth token, TLS cert, DB encryption key)
    • +
    • Incident response playbook
    • +
    • Scaling guide (when to add nodes, resource sizing)
    • +
    • Monitoring dashboard templates (Grafana + Prometheus)
    • +
    +
  • +
  • +

    6.4 Connection draining and graceful shutdown

    +
      +
    • Stop accepting new connections on SIGTERM
    • +
    • Wait for in-flight RPCs (configurable timeout, default 30s)
    • +
    • Drain WebTransport sessions with close frame
    • +
    • Document expected behavior for load balancers (health → unhealthy first)
    • +
    +
  • +
  • +

    6.5 Request-level timeouts

    +
      +
    • Per-RPC timeout (prevent slow clients from holding resources)
    • +
    • Database query timeout
    • +
    • Overall request deadline propagation
    • +
    +
  • +
  • +

    6.6 Observability enhancements

    +
      +
    • Request correlation IDs (trace across RPC → storage)
    • +
    • Storage operation latency metrics
    • +
    • Per-endpoint latency histograms
    • +
    • Structured audit log to persistent storage (not just stdout)
    • +
    • OpenTelemetry integration
    • +
    +
  • +
+
+

Phase 7 — Platform Expansion & Research

+

Long-term vision for wide adoption.

+
    +
  • +

    7.1 Mobile clients (iOS + Android)

    +
      +
    • Use C FFI (Phase 3.3) for crypto + transport (single library)
    • +
    • Push notifications via APNs / FCM (server sends notification on enqueue)
    • +
    • Background QUIC connection for message polling
    • +
    • Biometric auth for local key storage (Keychain / Android Keystore)
    • +
    +
  • +
  • +

    7.2 Web client (browser)

    +
      +
    • Use WASM (Phase 3.4) for crypto
    • +
    • Use WebTransport (Phase 3.5) for native QUIC transport
    • +
    • Cap’n Proto via WASM bridge (Phase 3.6)
    • +
    • IndexedDB for local state persistence
    • +
    • Service Worker for background notifications
    • +
    • Progressive Web App (PWA) support
    • +
    +
  • +
  • +

    7.3 Federation

    +
      +
    • Server-to-server protocol via Cap’n Proto RPC over QUIC (see federation.capnp)
    • +
    • relayEnqueue, proxyFetchKeyPackage, federationHealth methods
    • +
    • Identity resolution across federated servers
    • +
    • MLS group spanning multiple servers
    • +
    • Trust model for federated deployments
    • +
    +
  • +
  • +

    7.4 Sealed Sender

    +
      +
    • Sender identity inside MLS ciphertext only (server can’t see who sent)
    • +
    • sealed_sender module in quicproquo-core with seal/unseal API
    • +
    • WASM-accessible via wasm_bindgen for browser use
    • +
    +
  • +
  • +

    7.5 Additional language SDKs

    +
      +
    • Java/Kotlin: JNI bindings to C FFI (Phase 3.3) + native QUIC (netty-quic)
    • +
    • Swift: Swift wrapper over C FFI + Network.framework QUIC
    • +
    • Ruby: FFI bindings via quicproquo-ffi
    • +
    • Evaluate demand-driven — only build SDKs people request
    • +
    +
  • +
  • +

    7.6 P2P / NAT traversal

    +
      +
    • Direct peer-to-peer via iroh (foundation exists in quicproquo-p2p)
    • +
    • Server as fallback relay only
    • +
    • Reduces latency and single-point-of-failure
    • +
    • Ref: FUTURE-IMPROVEMENTS.md § 6.1
    • +
    +
  • +
  • +

    7.7 Traffic analysis resistance

    +
      +
    • Padding messages to uniform size
    • +
    • Decoy traffic to mask timing patterns
    • +
    • Optional Tor/I2P routing for IP privacy
    • +
    • Ref: FUTURE-IMPROVEMENTS.md § 5.4, 6.3
    • +
    +
  • +
+
+

Phase 8 — Freifunk / Community Mesh Networking

+

Make qpq a first-class citizen on decentralised, community-operated wireless +networks (Freifunk, BATMAN-adv/Babel routing, OpenWrt). Multiple qpq nodes form +a federated mesh; clients auto-discover nearby nodes via mDNS; the network +functions without any central infrastructure or internet uplink.

+

Architecture

+
  Client A ─── mDNS discovery ──► nearby qpq node (LAN / mesh)
+                                        │
+                               Cap'n Proto federation
+                                        │
+                               remote qpq node (across mesh)
+
+
    +
  • +

    F0 — Re-include quicproquo-p2p in workspace; fix ALPN strings

    +
      +
    • Moved crates/quicproquo-p2p from exclude back into [workspace] members
    • +
    • Fixed ALPN b"quicnprotochat/p2p/1"b"quicproquo/p2p/1" (breaking wire change)
    • +
    • Fixed federation ALPN b"qnpc-fed"b"quicproquo/federation/1"
    • +
    • Feature-gated behind --features mesh on client (keeps iroh out of default builds)
    • +
    +
  • +
  • +

    F1 — Federation routing in message delivery

    +
      +
    • handle_enqueue and handle_batch_enqueue call federation::routing::resolve_destination()
    • +
    • Recipients with a remote home server are relayed via FederationClient::relay_enqueue()
    • +
    • mTLS mutual authentication between nodes (both present client certs, validated against shared CA)
    • +
    • Config: QPQ_FEDERATION_LISTEN, QPQ_LOCAL_DOMAIN, QPQ_FEDERATION_CERT/KEY/CA
    • +
    +
  • +
  • +

    F2 — mDNS local peer discovery

    +
      +
    • Server announces _quicproquo._udp.local. on startup via mdns-sd
    • +
    • Client: MeshDiscovery::start() browses for nearby nodes (feature-gated)
    • +
    • REPL commands: /mesh peers (scan + list), /mesh server <host:port> (note address)
    • +
    • Nodes announce: ver=1, server=<host:port>, domain=<local_domain> TXT records
    • +
    +
  • +
  • +

    F3 — Self-sovereign mesh identity

    +
      +
    • Ed25519 keypair-based identity independent of AS registration
    • +
    • JSON-persisted seed + known peers directory
    • +
    • Sign/verify operations for mesh authenticity (crates/quicproquo-p2p/src/identity.rs)
    • +
    +
  • +
  • +

    F4 — Store-and-forward with TTL

    +
      +
    • MeshEnvelope with TTL-based expiry, hop_count tracking, max_hops routing limit
    • +
    • SHA-256 deduplication ID prevents relay loops
    • +
    • Ed25519 signature verification on envelopes
    • +
    • MeshStore in-memory queue with per-recipient capacity limits and TTL-based GC
    • +
    +
  • +
  • +

    F5 — Lightweight broadcast channels

    +
      +
    • Symmetric ChaCha20-Poly1305 encrypted channels (no MLS overhead)
    • +
    • Topic-based pub/sub via BroadcastChannel and BroadcastManager
    • +
    • Subscribe/unsubscribe, create, publish API on P2pNode
    • +
    +
  • +
  • +

    F6 — Extended /mesh REPL commands

    +
      +
    • /mesh send <peer_id> <msg> — direct P2P message via iroh
    • +
    • /mesh broadcast <topic> <msg> — publish to broadcast channel
    • +
    • /mesh subscribe <topic> — join broadcast channel
    • +
    • /mesh route — show routing table
    • +
    • /mesh identity — show mesh identity info
    • +
    • /mesh store — show store-and-forward statistics
    • +
    +
  • +
  • +

    F7 — OpenWrt cross-compilation guide

    +
      +
    • Musl static builds: x86_64-unknown-linux-musl, armv7-unknown-linux-musleabihf, mips-unknown-linux-musl
    • +
    • Strip binary: --release + strip → target size < 5 MB for flash storage
    • +
    • opkg package manifest for OpenWrt feed
    • +
    • procd init script + uci config file for OpenWrt integration
    • +
    • CI job: cross-compile and size-check on every release tag
    • +
    +
  • +
  • +

    F8 — Traffic analysis resistance for mesh

    +
      +
    • Uniform message padding to nearest 256-byte boundary (hides message size)
    • +
    • Configurable decoy traffic rate (fake messages to mask send timing)
    • +
    • Optional onion routing: 3-hop relay through other mesh nodes (no Tor dependency)
    • +
    • Ref: Phase 7.7 for server-side traffic analysis resistance
    • +
    +
  • +
+
+

Phase 9 — Developer Experience & Community Growth

+

Features designed to attract contributors, create demo/showcase potential, +and lower the barrier to entry for non-crypto developers.

+
    +
  • +

    9.1 Criterion Benchmark Suite (qpq-bench)

    +
      +
    • Criterion benchmarks for all crypto primitives: hybrid KEM encap/decap, +MLS group-add at 10/100/1000 members, epoch rotation, Noise_XX handshake
    • +
    • CI publishes HTML benchmark reports as GitHub Actions artifacts
    • +
    • Citable numbers — no other project benchmarks MLS + PQ-KEM in Rust
    • +
    +
  • +
  • +

    9.2 Safety Numbers (key verification)

    +
      +
    • 60-digit numeric code derived from two identity keys (Signal-style)
    • +
    • /verify <username> REPL command for out-of-band verification
    • +
    • Available in WASM via compute_safety_number binding
    • +
    +
  • +
  • +

    9.3 Full-Screen TUI (Ratatui + Crossterm)

    +
      +
    • qpq tui launches a full-screen terminal UI: message pane, input bar, +channel sidebar with unread counts, MLS epoch indicator
    • +
    • Feature-gated --features tui to keep ratatui/crossterm out of default builds
    • +
    • Existing REPL and CLI subcommands are unaffected
    • +
    +
  • +
  • +

    9.4 Delivery Proof Canary Tokens

    +
      +
    • Server signs Ed25519(SHA-256(message_id || recipient || timestamp)) on enqueue
    • +
    • Sender stores proof locally — cryptographic evidence the server queued the message
    • +
    • Cap’n Proto schema gains optional deliveryProof: Data on enqueue response
    • +
    +
  • +
  • +

    9.5 Verifiable Transcript Archive

    +
      +
    • GroupMember::export_transcript(path, password) writes encrypted, tamper-evident +message archive (CBOR records, Argon2id + ChaCha20-Poly1305, Merkle chain)
    • +
    • qpq export verify CLI command independently verifies chain integrity
    • +
    • Useful for legal discovery, audit, or personal backup
    • +
    +
  • +
  • +

    9.6 Key Transparency (Merkle-Log Identity Binding)

    +
      +
    • Append-only Merkle log of (username, identity_key) bindings in the AS
    • +
    • Clients receive inclusion proofs alongside key fetches
    • +
    • Any client can independently audit the full identity history
    • +
    • Lightweight subset of RFC 9162 adapted for identity keys
    • +
    +
  • +
  • +

    9.7 Dynamic Server Plugin System

    +
      +
    • Server loads .so/.dylib plugins at runtime via --plugin-dir
    • +
    • C-compatible HookVTable via extern "C" — plugins in any language
    • +
    • 6 hook points: on_message_enqueue, on_batch_enqueue, on_auth, on_channel_created, on_fetch, on_user_registered
    • +
    • Example plugins: logging plugin, rate limit plugin (512 KiB payload enforcement)
    • +
    +
  • +
  • +

    9.8 PQ Noise Transport Layer

    +
      +
    • Hybrid Noise_XX + ML-KEM-768 handshake for post-quantum transport security
    • +
    • Closes the harvest-now-decrypt-later gap on handshake metadata (ADR-006)
    • +
    • Feature-gated --features pq-noise; classical Noise_XX default preserved
    • +
    • May require extending or forking snow crate’s CryptoResolver
    • +
    +
  • +
+
+

Summary Timeline

+
+ + + + + + + + + + + + + + + +
PhaseFocusEstimated Effort
1Production Hardening1–2 days
2Test & CI Maturity2–3 days
3Client SDKs (Go, Python, WASM, FFI, WebTransport)5–8 days
4Trust & Security Infrastructure2–4 days (excl. audit)
5Features & UX5–7 days
6Scale & Operations3–5 days
7Platform Expansion & Researchongoing
8Freifunk / Community Meshongoing
9Developer Experience & Community Growth3–5 days
+
+
+ + + +
+ + +
+
+ + + +
+ + + + + + + + + + + + + + + + + + + + + + + + +
+ + diff --git a/assets/left.ansi b/assets/left.ansi new file mode 100644 index 0000000..b2f179f --- /dev/null +++ b/assets/left.ansi @@ -0,0 +1,26 @@ + registering 'alice'... + user 'alice' registered + logging in as 'alice'... + logged in, session cached + identity: c1e1f6df17eeb6..2816 + KeyPackage uploaded + hybrid key uploaded + type /help for commands, Ctrl+D to exit + +[no conversation] > /dm bob + resolving bob... + creating channel... + fetching peer's key package... + DM with @bob created. Start typing! +[@bob] > Hey Bob, testing our E2E encrypted channel! +[bob] Works great -- the server never sees plaintext? +[@bob] > Right. MLS forward secrecy + post-quantum KEM. +[bob] Impressive. How do I verify your identity? +[@bob] > Run /verify alice -- compare the safety number out-of-band. +[@bob] > /group-info + Conversation: @bob + Type: DM + Members: 2 + alice (you), bob + MLS epoch: 3 +[@bob] > \ No newline at end of file diff --git a/assets/right.ansi b/assets/right.ansi new file mode 100644 index 0000000..707f8bc --- /dev/null +++ b/assets/right.ansi @@ -0,0 +1,24 @@ + registering 'bob'... + user 'bob' registered + logging in as 'bob'... + logged in, session cached + identity: a8c2f19f1b0806..c73f + KeyPackage uploaded + hybrid key uploaded + type /help for commands, Ctrl+D to exit + +[system] new conversation: @alice +[@alice] > [alice] Hey Bob, testing our E2E encrypted channel! +[@alice] > Works great -- the server never sees plaintext? +[alice] Right. MLS forward secrecy + post-quantum KEM. +[@alice] > Impressive. How do I verify your identity? +[alice] Run /verify alice -- compare the safety number out-of-band. +[@alice] > /verify alice + Safety number for @alice: + 096482 731945 208376 + 571039 284617 950283 +[@alice] > /whoami + identity: a8c2f19f1b0806..c73f + hybrid key: yes + conversations: 1 +[@alice] > \ No newline at end of file diff --git a/assets/screenshot.png b/assets/screenshot.png new file mode 100644 index 0000000000000000000000000000000000000000..afc7245a12b006f3dc23c68f12822cd3d170ac92 GIT binary patch literal 68207 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 b=G3JX0{qI<%Pjvk^v4(*e*Ya+-|6CSVrBuU literal 0 HcmV?d00001 diff --git a/assets/screenshot.txt b/assets/screenshot.txt new file mode 100644 index 0000000..47f94df --- /dev/null +++ b/assets/screenshot.txt @@ -0,0 +1,59 @@ +=== Alice (left) === +❯ ./target/debug/qpq repl --username alice --password de +opass1 --server 127.0.0.1:17123 --ca-cert /tmp/tmp.adbXG +OrPY/server-cert.der --state /tmp/tmp.adbXGLOrPY/alice.b +n + registering 'alice'... + user 'alice' registered + logging in as 'alice'... + logged in, session cached + identity: c1e1f6df17eeb6f539d7fbea94129fa32fc02ca40e5c +7a7c95cfc94161d5f628 + KeyPackage uploaded + hybrid key uploaded + type /help for commands, Ctrl+D to exit + +[no conversation] > /dm bob + resolving bob... + creating channel... + fetching peer's key package... + DM with @bob created. Start typing! +[@bob] > ^LHey Bob, testing our E2E encrypted channel! +[@bob] > Right. MLS forward secrecy + post-quantum KEM. +[@bob] > /group-info + Conversation: @bob + Type: DM + Members: 2 + alice (you), bob + MLS epoch: 1 +[@bob] > + +=== Bob (right) === +❯ ./target/debug/qpq repl --username bob --password demop +ass2 --server 127.0.0.1:17123 --ca-cert /tmp/tmp.adbXGLOr +PY/server-cert.der --state /tmp/tmp.adbXGLOrPY/bob.bin + registering 'bob'... + user 'bob' registered + logging in as 'bob'... + logged in, session cached + identity: a8c2f19f1b080616b7206e02244fd14c2ab8821367392 +af5ff9c89c69750c73f + KeyPackage uploaded + hybrid key uploaded + type /help for commands, Ctrl+D to exit + +[no conversation] > /list + no conversations yet. Try /dm or /create-gro +up +[no conversation] > /switch @alice + error: conversation not found: @alice +[no conversation] > ^LWorks great -- the server never see +s plaintext? + error: no active conversation; use /dm or /create-group + first +[no conversation] > /whoami + identity: a8c2f19f1b080616b7206e02244fd14c2ab8821367392 +af5ff9c89c69750c73f + hybrid key: yes + conversations: 0 +[no conversation] > diff --git a/crates/quicproquo-client/src/client/commands.rs b/crates/quicproquo-client/src/client/commands.rs index 1597c47..baba429 100644 --- a/crates/quicproquo-client/src/client/commands.rs +++ b/crates/quicproquo-client/src/client/commands.rs @@ -1376,12 +1376,12 @@ pub fn cmd_export( /// /// Prints a summary. Does not require the encryption password (structural check only). pub fn cmd_export_verify(input: &Path) -> anyhow::Result<()> { - use quicproquo_core::{verify_transcript_chain, ChainVerdict}; + use quicproquo_core::{validate_transcript_structure, ChainVerdict}; let data = std::fs::read(input) .with_context(|| format!("read transcript file '{}'", input.display()))?; - match verify_transcript_chain(&data)? { + match validate_transcript_structure(&data)? { ChainVerdict::Ok { records } => { println!( "OK: transcript '{}' is structurally valid. {} record(s) found, hash chain intact.", diff --git a/crates/quicproquo-client/src/client/conversation.rs b/crates/quicproquo-client/src/client/conversation.rs index fc2ad16..aa392fb 100644 --- a/crates/quicproquo-client/src/client/conversation.rs +++ b/crates/quicproquo-client/src/client/conversation.rs @@ -169,6 +169,7 @@ impl ConversationStore { let salt = get_or_create_salt(&salt_path)?; let key = derive_convdb_key(password, &salt)?; + #[allow(clippy::needless_borrows_for_generic_args)] let hex_key = Zeroizing::new(hex::encode(&*key)); let conn = Connection::open(db_path).context("open conversation db")?; @@ -188,6 +189,7 @@ impl ConversationStore { ) -> anyhow::Result<()> { let salt = get_or_create_salt(salt_path)?; let key = derive_convdb_key(password, &salt)?; + #[allow(clippy::needless_borrows_for_generic_args)] let hex_key = Zeroizing::new(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 645e604..aa5fc8c 100644 --- a/crates/quicproquo-client/src/client/repl.rs +++ b/crates/quicproquo-client/src/client/repl.rs @@ -914,11 +914,11 @@ fn parse_duration_secs(s: &str) -> Option { /// Format a TTL in seconds into a human-friendly string. fn format_ttl(secs: u32) -> String { - if secs >= 86400 && secs % 86400 == 0 { + if secs >= 86400 && secs.is_multiple_of(86400) { format!("{} day(s)", secs / 86400) - } else if secs >= 3600 && secs % 3600 == 0 { + } else if secs >= 3600 && secs.is_multiple_of(3600) { format!("{} hour(s)", secs / 3600) - } else if secs >= 60 && secs % 60 == 0 { + } else if secs >= 60 && secs.is_multiple_of(60) { format!("{} minute(s)", secs / 60) } else { format!("{} second(s)", secs) diff --git a/crates/quicproquo-client/src/client/retry.rs b/crates/quicproquo-client/src/client/retry.rs index 32c703f..35a849d 100644 --- a/crates/quicproquo-client/src/client/retry.rs +++ b/crates/quicproquo-client/src/client/retry.rs @@ -85,6 +85,7 @@ pub fn anyhow_is_retriable(err: &anyhow::Error) -> bool { } #[cfg(test)] +#[allow(clippy::unwrap_used)] mod tests { use super::*; diff --git a/crates/quicproquo-client/src/client/rpc.rs b/crates/quicproquo-client/src/client/rpc.rs index 343a749..04d2a3f 100644 --- a/crates/quicproquo-client/src/client/rpc.rs +++ b/crates/quicproquo-client/src/client/rpc.rs @@ -152,7 +152,7 @@ pub fn set_auth(auth: &mut auth::Builder<'_>) -> anyhow::Result<()> { ) })?; auth.set_version(ctx.version); - auth.set_access_token(&*ctx.access_token); + auth.set_access_token(&ctx.access_token); auth.set_device_id(&ctx.device_id); Ok(()) } diff --git a/crates/quicproquo-client/src/client/state.rs b/crates/quicproquo-client/src/client/state.rs index 677b867..fcfb68b 100644 --- a/crates/quicproquo-client/src/client/state.rs +++ b/crates/quicproquo-client/src/client/state.rs @@ -217,6 +217,7 @@ pub fn sha256(bytes: &[u8]) -> Vec { } #[cfg(test)] +#[allow(clippy::unwrap_used)] mod tests { use super::*; diff --git a/crates/quicproquo-client/src/client/token_cache.rs b/crates/quicproquo-client/src/client/token_cache.rs index ed6303f..e01340e 100644 --- a/crates/quicproquo-client/src/client/token_cache.rs +++ b/crates/quicproquo-client/src/client/token_cache.rs @@ -93,6 +93,7 @@ pub fn clear_cached_session(state_path: &Path) { } #[cfg(test)] +#[allow(clippy::unwrap_used)] mod tests { use super::*; diff --git a/crates/quicproquo-client/tests/e2e.rs b/crates/quicproquo-client/tests/e2e.rs index 7b4b287..d64eacd 100644 --- a/crates/quicproquo-client/tests/e2e.rs +++ b/crates/quicproquo-client/tests/e2e.rs @@ -1,5 +1,7 @@ // cargo_bin! only works for current package's binary; we spawn qpq-server from another package. #![allow(deprecated)] +#![allow(clippy::unwrap_used)] +#![allow(clippy::await_holding_lock)] // AUTH_LOCK intentionally held across await to serialize tests use std::{path::PathBuf, process::Command, sync::Mutex, time::Duration}; @@ -8,7 +10,6 @@ use portpicker::pick_unused_port; use rand::RngCore; use tempfile::TempDir; use tokio::time::sleep; -use hex; // Required by rustls 0.23 when QUIC/TLS is used from this process (e.g. client in test). fn ensure_rustls_provider() { @@ -46,7 +47,7 @@ impl Drop for ChildGuard { } } -async fn wait_for_health(server: &str, ca_cert: &PathBuf, server_name: &str) -> anyhow::Result<()> { +async fn wait_for_health(server: &str, ca_cert: &std::path::Path, server_name: &str) -> anyhow::Result<()> { let local = tokio::task::LocalSet::new(); for _ in 0..30 { if local @@ -1090,7 +1091,7 @@ async fn e2e_key_rotation_update_path() -> anyhow::Result<()> { let alice_seed = bincode::deserialize::(&std::fs::read(&alice_state)?)?.identity_seed; let bob_seed = bincode::deserialize::(&std::fs::read(&bob_state)?)?.identity_seed; - let alice_pk = IdentityKeypair::from_seed(alice_seed).public_key_bytes().to_vec(); + let _alice_pk = IdentityKeypair::from_seed(alice_seed).public_key_bytes().to_vec(); let bob_pk = IdentityKeypair::from_seed(bob_seed).public_key_bytes().to_vec(); let bob_pk_hex = hex_encode(&bob_pk); @@ -1372,7 +1373,7 @@ async fn e2e_file_upload_download() -> anyhow::Result<()> { // Build 2 KB of known data. let pattern = b"hello-world-file-test\n"; - let repeat_count = (2048 + pattern.len() - 1) / pattern.len(); + let repeat_count = 2048_usize.div_ceil(pattern.len()); let file_data: Vec = pattern.iter().copied().cycle().take(repeat_count * pattern.len()).collect(); let file_data = &file_data[..2048]; // exactly 2 KB @@ -1472,7 +1473,7 @@ async fn e2e_file_upload_download() -> anyhow::Result<()> { .await?; anyhow::ensure!( - partial == &file_data[100..300], + partial == file_data[100..300], "partial download [100..300] does not match expected slice" ); diff --git a/crates/quicproquo-core/benches/crypto_benchmarks.rs b/crates/quicproquo-core/benches/crypto_benchmarks.rs index 75794cd..796998f 100644 --- a/crates/quicproquo-core/benches/crypto_benchmarks.rs +++ b/crates/quicproquo-core/benches/crypto_benchmarks.rs @@ -1,3 +1,4 @@ +#![allow(clippy::unwrap_used)] //! Benchmark: Identity keypair operations, sealed sender, and message padding. //! //! Covers: @@ -34,14 +35,12 @@ fn bench_identity_verify(c: &mut Criterion) { c.bench_function("identity_verify", |b| { b.iter(|| { - black_box( - IdentityKeypair::verify_raw( - black_box(&pk), - black_box(payload), - black_box(&sig), - ) - .unwrap() + IdentityKeypair::verify_raw( + black_box(&pk), + black_box(payload), + black_box(&sig), ) + .unwrap(); }); }); } diff --git a/crates/quicproquo-core/benches/hybrid_kem_bench.rs b/crates/quicproquo-core/benches/hybrid_kem_bench.rs index 41d1ec6..367dfc5 100644 --- a/crates/quicproquo-core/benches/hybrid_kem_bench.rs +++ b/crates/quicproquo-core/benches/hybrid_kem_bench.rs @@ -1,3 +1,4 @@ +#![allow(clippy::unwrap_used)] //! Benchmark: Hybrid KEM (X25519 + ML-KEM-768) vs classical-only encryption. //! //! Compares keypair generation, encryption, and decryption times for the diff --git a/crates/quicproquo-core/benches/mls_operations.rs b/crates/quicproquo-core/benches/mls_operations.rs index bd9a723..545c71b 100644 --- a/crates/quicproquo-core/benches/mls_operations.rs +++ b/crates/quicproquo-core/benches/mls_operations.rs @@ -1,3 +1,4 @@ +#![allow(clippy::unwrap_used)] //! Benchmark: MLS group operations at various group sizes. //! //! Measures KeyPackage generation, group creation, member addition, diff --git a/crates/quicproquo-core/benches/serialization.rs b/crates/quicproquo-core/benches/serialization.rs index 9f30c8b..a6a70ea 100644 --- a/crates/quicproquo-core/benches/serialization.rs +++ b/crates/quicproquo-core/benches/serialization.rs @@ -1,3 +1,4 @@ +#![allow(clippy::unwrap_used)] //! Benchmark: Cap'n Proto vs Protobuf serialization for chat message envelopes. //! //! Compares serialization/deserialization speed and encoded size at three diff --git a/crates/quicproquo-core/src/app_message.rs b/crates/quicproquo-core/src/app_message.rs index 247db1b..b08791e 100644 --- a/crates/quicproquo-core/src/app_message.rs +++ b/crates/quicproquo-core/src/app_message.rs @@ -349,6 +349,7 @@ fn parse_file_ref(payload: &[u8]) -> Result { } #[cfg(test)] +#[allow(clippy::unwrap_used)] mod tests { use super::*; diff --git a/crates/quicproquo-core/src/group.rs b/crates/quicproquo-core/src/group.rs index c8f6214..e420aa4 100644 --- a/crates/quicproquo-core/src/group.rs +++ b/crates/quicproquo-core/src/group.rs @@ -631,6 +631,7 @@ impl GroupMember { // ── Unit tests ──────────────────────────────────────────────────────────────── #[cfg(test)] +#[allow(clippy::unwrap_used)] mod tests { use super::*; diff --git a/crates/quicproquo-core/src/hybrid_crypto.rs b/crates/quicproquo-core/src/hybrid_crypto.rs index b047efd..3547809 100644 --- a/crates/quicproquo-core/src/hybrid_crypto.rs +++ b/crates/quicproquo-core/src/hybrid_crypto.rs @@ -364,6 +364,7 @@ impl OpenMlsCryptoProvider for HybridCryptoProvider { // ── Tests ─────────────────────────────────────────────────────────────────── #[cfg(test)] +#[allow(clippy::unwrap_used)] mod tests { use super::*; use openmls_traits::types::HpkeKdfType; diff --git a/crates/quicproquo-core/src/hybrid_kem.rs b/crates/quicproquo-core/src/hybrid_kem.rs index 959f337..3c18353 100644 --- a/crates/quicproquo-core/src/hybrid_kem.rs +++ b/crates/quicproquo-core/src/hybrid_kem.rs @@ -476,6 +476,7 @@ fn derive_aead_key(x25519_ss: &[u8], mlkem_ss: &[u8], extra_info: &[u8]) -> Key // ── Tests ─────────────────────────────────────────────────────────────────── #[cfg(test)] +#[allow(clippy::unwrap_used)] mod tests { use super::*; diff --git a/crates/quicproquo-core/src/identity.rs b/crates/quicproquo-core/src/identity.rs index 4dcf30e..f8ed524 100644 --- a/crates/quicproquo-core/src/identity.rs +++ b/crates/quicproquo-core/src/identity.rs @@ -151,7 +151,43 @@ pub fn verify_delivery_proof( Ok(true) } +impl Serialize for IdentityKeypair { + fn serialize(&self, serializer: S) -> Result + where + S: serde::Serializer, + { + serializer.serialize_bytes(&self.seed[..]) + } +} + +impl<'de> Deserialize<'de> for IdentityKeypair { + fn deserialize(deserializer: D) -> Result + where + D: serde::Deserializer<'de>, + { + let bytes: Vec = serde::Deserialize::deserialize(deserializer)?; + let seed: [u8; 32] = bytes + .as_slice() + .try_into() + .map_err(|_| serde::de::Error::custom("identity seed must be 32 bytes"))?; + Ok(IdentityKeypair::from_seed(seed)) + } +} + +impl std::fmt::Debug for IdentityKeypair { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + let fp = self.fingerprint(); + f.debug_struct("IdentityKeypair") + .field( + "fingerprint", + &format!("{:02x}{:02x}{:02x}{:02x}…", fp[0], fp[1], fp[2], fp[3]), + ) + .finish_non_exhaustive() + } +} + #[cfg(test)] +#[allow(clippy::unwrap_used)] mod proof_tests { use super::*; use sha2::{Digest, Sha256}; @@ -207,38 +243,3 @@ mod proof_tests { assert!(verify_delivery_proof(&pk, &proof).is_err()); } } - -impl Serialize for IdentityKeypair { - fn serialize(&self, serializer: S) -> Result - where - S: serde::Serializer, - { - serializer.serialize_bytes(&self.seed[..]) - } -} - -impl<'de> Deserialize<'de> for IdentityKeypair { - fn deserialize(deserializer: D) -> Result - where - D: serde::Deserializer<'de>, - { - let bytes: Vec = serde::Deserialize::deserialize(deserializer)?; - let seed: [u8; 32] = bytes - .as_slice() - .try_into() - .map_err(|_| serde::de::Error::custom("identity seed must be 32 bytes"))?; - Ok(IdentityKeypair::from_seed(seed)) - } -} - -impl std::fmt::Debug for IdentityKeypair { - fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { - let fp = self.fingerprint(); - f.debug_struct("IdentityKeypair") - .field( - "fingerprint", - &format!("{:02x}{:02x}{:02x}{:02x}…", fp[0], fp[1], fp[2], fp[3]), - ) - .finish_non_exhaustive() - } -} diff --git a/crates/quicproquo-core/src/padding.rs b/crates/quicproquo-core/src/padding.rs index 6d9c043..3f56599 100644 --- a/crates/quicproquo-core/src/padding.rs +++ b/crates/quicproquo-core/src/padding.rs @@ -62,6 +62,7 @@ pub fn unpad(padded: &[u8]) -> Result, CoreError> { } #[cfg(test)] +#[allow(clippy::unwrap_used)] mod tests { use super::*; diff --git a/crates/quicproquo-core/src/sealed_sender.rs b/crates/quicproquo-core/src/sealed_sender.rs index 3988df3..525d234 100644 --- a/crates/quicproquo-core/src/sealed_sender.rs +++ b/crates/quicproquo-core/src/sealed_sender.rs @@ -85,6 +85,7 @@ pub fn is_sealed(bytes: &[u8]) -> bool { } #[cfg(test)] +#[allow(clippy::unwrap_used)] mod tests { use super::*; diff --git a/crates/quicproquo-kt/src/proof.rs b/crates/quicproquo-kt/src/proof.rs index 35d8b31..c74bedd 100644 --- a/crates/quicproquo-kt/src/proof.rs +++ b/crates/quicproquo-kt/src/proof.rs @@ -95,6 +95,7 @@ fn recompute_root(leaf: [u8; 32], path: &[PathStep]) -> Result<[u8; 32], KtError } #[cfg(test)] +#[allow(clippy::unwrap_used)] mod tests { use super::*; use crate::tree::MerkleLog; diff --git a/crates/quicproquo-kt/src/tree.rs b/crates/quicproquo-kt/src/tree.rs index 07b75bd..134b285 100644 --- a/crates/quicproquo-kt/src/tree.rs +++ b/crates/quicproquo-kt/src/tree.rs @@ -182,6 +182,7 @@ fn largest_power_of_two_less_than(n: usize) -> usize { } #[cfg(test)] +#[allow(clippy::unwrap_used)] mod tests { use super::*; diff --git a/crates/quicproquo-p2p/src/identity.rs b/crates/quicproquo-p2p/src/identity.rs index 3466ffd..28568d3 100644 --- a/crates/quicproquo-p2p/src/identity.rs +++ b/crates/quicproquo-p2p/src/identity.rs @@ -77,7 +77,7 @@ impl MeshIdentity { /// contains the Ed25519 seed in the clear. pub fn save(&self, path: &Path) -> anyhow::Result<()> { let file = IdentityFile { - seed: hex::encode(&*self.keypair.seed_bytes()), + seed: hex::encode(self.keypair.seed_bytes()), peers: self.known_peers.clone(), }; let json = serde_json::to_string_pretty(&file)?; diff --git a/crates/quicproquo-sdk/src/conversation.rs b/crates/quicproquo-sdk/src/conversation.rs index c668936..8810193 100644 --- a/crates/quicproquo-sdk/src/conversation.rs +++ b/crates/quicproquo-sdk/src/conversation.rs @@ -114,6 +114,7 @@ impl ConversationStore { if let Some(pw) = password { let key = derive_db_key(pw, db_path)?; + #[allow(clippy::needless_borrows_for_generic_args)] let hex_key = Zeroizing::new(hex::encode(&*key)); conn.pragma_update(None, "key", format!("x'{}'", &*hex_key)) .context("set SQLCipher key")?; @@ -561,6 +562,7 @@ fn row_to_message( } #[cfg(test)] +#[allow(clippy::unwrap_used)] mod tests { use super::*; diff --git a/crates/quicproquo-sdk/src/groups.rs b/crates/quicproquo-sdk/src/groups.rs index f4a0146..f7112d2 100644 --- a/crates/quicproquo-sdk/src/groups.rs +++ b/crates/quicproquo-sdk/src/groups.rs @@ -30,6 +30,7 @@ use crate::error::SdkError; /// Returns `(conversation_id, was_new)`. /// - `was_new = true` — caller created the MLS group and sent the Welcome. /// - `was_new = false` — peer is the MLS initiator; caller should wait for Welcome. +#[allow(clippy::too_many_arguments)] pub async fn create_dm( rpc: &RpcClient, conv_store: &ConversationStore, @@ -177,6 +178,7 @@ pub fn create_group( /// Invite a peer to an existing group. /// /// Sends the Welcome to the new peer and the Commit to all existing members. +#[allow(clippy::too_many_arguments)] pub async fn invite_to_group( rpc: &RpcClient, conv_store: &ConversationStore, diff --git a/crates/quicproquo-sdk/src/state.rs b/crates/quicproquo-sdk/src/state.rs index e4e1401..6da939d 100644 --- a/crates/quicproquo-sdk/src/state.rs +++ b/crates/quicproquo-sdk/src/state.rs @@ -143,6 +143,7 @@ pub fn load_state(path: &Path, password: Option<&str>) -> Result req.total_size) + .is_none_or(|end| end > req.total_size) { return Err(DomainError::BadParams(format!( "chunk out of bounds: offset={} + chunk_len={} > total_size={}", diff --git a/crates/quicproquo-server/src/federation/routing.rs b/crates/quicproquo-server/src/federation/routing.rs index 484566a..83b3c12 100644 --- a/crates/quicproquo-server/src/federation/routing.rs +++ b/crates/quicproquo-server/src/federation/routing.rs @@ -29,6 +29,7 @@ pub fn resolve_destination( } #[cfg(test)] +#[allow(clippy::unwrap_used)] mod tests { use super::*; diff --git a/crates/quicproquo-server/src/node_service/blob_ops.rs b/crates/quicproquo-server/src/node_service/blob_ops.rs index e8f9d4e..0e9b5ff 100644 --- a/crates/quicproquo-server/src/node_service/blob_ops.rs +++ b/crates/quicproquo-server/src/node_service/blob_ops.rs @@ -86,7 +86,7 @@ impl NodeServiceImpl { } // Validate chunk bounds. - if offset.checked_add(chunk.len() as u64).map_or(true, |end| end > total_size) { + if offset.checked_add(chunk.len() as u64).is_none_or(|end| end > total_size) { return Promise::err(coded_error( E020_BAD_PARAMS, format!( diff --git a/crates/quicproquo-server/src/node_service/delivery.rs b/crates/quicproquo-server/src/node_service/delivery.rs index 43df259..209ed10 100644 --- a/crates/quicproquo-server/src/node_service/delivery.rs +++ b/crates/quicproquo-server/src/node_service/delivery.rs @@ -263,7 +263,7 @@ impl NodeServiceImpl { if self.redact_logs { let redacted_sender = sender_identity .as_deref() - .map(|id| redacted_prefix(id)) + .map(redacted_prefix) .unwrap_or_else(|| "sealed".to_string()); tracing::info!( sender_prefix = %redacted_sender, diff --git a/crates/quicproquo-server/src/sql_store.rs b/crates/quicproquo-server/src/sql_store.rs index 192b9e5..efbda90 100644 --- a/crates/quicproquo-server/src/sql_store.rs +++ b/crates/quicproquo-server/src/sql_store.rs @@ -1004,6 +1004,7 @@ impl OptionalExt for Result { } #[cfg(test)] +#[allow(clippy::unwrap_used)] mod tests { use super::*; use std::path::PathBuf; diff --git a/crates/quicproquo-server/src/storage.rs b/crates/quicproquo-server/src/storage.rs index 54773ff..6d2563a 100644 --- a/crates/quicproquo-server/src/storage.rs +++ b/crates/quicproquo-server/src/storage.rs @@ -320,6 +320,7 @@ pub struct FileBackedStore { identity_keys: Mutex>>, endpoints: Mutex, Vec>>, /// Device registry: identity_key -> Vec<(device_id, device_name, registered_at)> + #[allow(clippy::type_complexity)] devices: Mutex, Vec<(Vec, String, u64)>>>, } @@ -958,6 +959,7 @@ impl Store for FileBackedStore { } #[cfg(test)] +#[allow(clippy::unwrap_used)] mod tests { use super::*; use tempfile::TempDir; diff --git a/docs/V2-DESIGN-ANALYSIS.md b/docs/V2-DESIGN-ANALYSIS.md new file mode 100644 index 0000000..813915a --- /dev/null +++ b/docs/V2-DESIGN-ANALYSIS.md @@ -0,0 +1,380 @@ +# quicproquo v2 — Design Analysis & Recommendations + +> Multi-perspective retrospective of the v1 architecture. +> Produced 2026-03-04 by four parallel analysis agents examining server, +> client/UX, crypto/security, and project structure/DX. + +--- + +## Executive Summary + +quicproquo v1 demonstrates strong fundamentals: QUIC-native transport, RFC 9420 +MLS group encryption, post-quantum hybrid KEM, OPAQUE zero-knowledge auth, and a +working multi-language SDK surface. These are the right bets and put the project +ahead of most open-source messengers on the crypto front. + +However, three architectural choices limit the path to production: + +1. **capnp-rpc is `!Send`** — forces single-threaded RPC handling, blocking + scalability. +2. **Monolithic client with global state** — business logic is tangled into the + REPL, duplicated across TUI/GUI/Web, and cannot be used as a library. +3. **Poll-based delivery** — 1-second polling wastes bandwidth and adds latency; + no server-push channel exists. + +A v2 should keep the crypto stack (MLS + hybrid PQ KEM + OPAQUE), keep QUIC, but +rearchitect the RPC layer, extract an SDK crate, and add push-based delivery. + +--- + +## Part 1 — What Works Well + +### Transport & Protocol +- **QUIC (quinn) + TLS 1.3** — correct choice. Built-in encryption, connection + migration, 0-RTT potential. No reason to change. +- **Cap'n Proto schemas as API contract** — zero-copy wire format, compact + binary, schema evolution via ordinals. The *schemas* are good; the *RPC + runtime* is the problem. + +### Cryptography +- **MLS (RFC 9420, openmls)** — only IETF-standard group E2E protocol. No + realistic alternative for groups > 2 members. Test suite is thorough (1005 + lines covering 2-party, 3-party, hybrid, removal, leave, stale epoch). +- **Hybrid PQ KEM (X25519 + ML-KEM-768)** — forward-thinking dual-algorithm + protection. Well-implemented with versioned wire format, proper zeroization, + and 12 targeted tests. Ahead of Signal (PQXDH, late 2023) and Matrix (no PQ). +- **OPAQUE (RFC 9497)** — server never sees passwords. Ristretto255 + Argon2id + is best-in-class. +- **Sealed sender, safety numbers, message padding** — all clean, simple, + correct. Safety numbers match Signal's 5200-iteration HMAC-SHA256 cost. +- **Zeroization discipline** — secrets wrapped in `Zeroizing`, Debug impls + redact keys, no `.unwrap()` in crypto paths. +- **WASM feature gating** — `core/native` cleanly separates WASM-safe crypto + from native-only modules (MLS, OPAQUE, filesystem). + +### Server Design +- **Store trait abstraction** — 30+ methods, clean backend swap (SqlStore vs + FileBackedStore). Well-factored. +- **OPAQUE auth with timing floors** — `resolveUser`/`resolveIdentity` mask + lookup timing to prevent username enumeration. +- **Delivery proofs** — Ed25519-signed receipt of server acceptance. Clients get + cryptographic evidence. +- **`wasNew` flag on createChannel** — elegantly solves the dual-MLS-group race + condition where both DM parties try to initialize. +- **Plugin hooks (C-ABI)** — `#![no_std]` vtable, zero dependencies, chained + hooks with continue/reject protocol. Clean extensibility. +- **Production config validation** — enforces encrypted storage, strong auth + tokens, pre-existing TLS certs. + +### Client & DX +- **Zero-config local dev** — `qpq --username alice --password pass` auto-starts + server, generates TLS certs, registers, and logs in. Genuinely excellent. +- **Encrypted-at-rest everything** — state file (QPCE), conversation DB + (SQLCipher), session cache. Argon2id + ChaCha20-Poly1305 throughout. +- **Playbook system** — YAML-scripted command execution with assertions. Great + for CI/integration testing. +- **Conversation store** — SQLite with deduplication, outbox for offline + queuing, activity tracking. +- **Conventional commits, GPG-signed** — consistent `feat:`/`fix:`/`docs:` + discipline. +- **Security lints enforced by build** — `clippy::unwrap_used = "deny"`, + `unsafe_code = "warn"`. + +--- + +## Part 2 — What Needs Rethinking + +### 2.1 RPC Layer: capnp-rpc is the #1 Scalability Bottleneck + +**Problem:** `capnp-rpc` uses `Rc` internally and is `!Send`. Everything runs on +a `LocalSet` with `spawn_local`. All 27 RPC methods serialize through a single +thread. No work-stealing, no multi-core utilization. + +**Impact:** With 1000+ concurrent clients, the single-threaded executor cannot +keep up. A slow `fetchWait` (30s timeout) blocks the entire connection. + +**Also:** The WebSocket bridge (`ws_bridge.rs`, 645 lines) exists solely because +Cap'n Proto cannot run in browsers. This duplicates handler logic and creates +maintenance burden. + +### 2.2 Client Architecture: Monolith with Global State + +**Problem:** `AUTH_CONTEXT` is a process-wide `RwLock>`. +Business logic (MLS processing, sealed sender, hybrid decryption, message +routing) lives inside `repl.rs`'s `poll_messages()` — a 100-line function that +mixes transport, crypto, routing, and storage. + +**Impact:** Every frontend (REPL, TUI, GUI, Web) must reimplement message +processing. The TUI already duplicates it. The GUI stub and mobile PoC would need +yet another copy. Client cannot be used as a library. + +### 2.3 Delivery Model: Poll-Based, No Push Channel + +**Problem:** Client polls every 1 second with `fetch_wait(timeout_ms=0)` — never +actually long-polls. Constant network traffic even when idle. ~1 second latency +for message delivery. + +**Also:** `fetch` is destructive (drains queue). If the client crashes between +receive and processing, messages are lost. + +### 2.4 Connection Model: Single Stream + +**Problem:** `max_concurrent_bidi_streams(1)` means the entire QUIC connection is +effectively single-stream. A blocking `fetchWait` prevents all other RPCs. + +### 2.5 Storage: Single Mutex-Guarded SQLite Connection + +**Problem:** `SqlStore` uses `Mutex`. Every database operation +acquires a global lock. Under concurrent load, all storage access serializes. + +**Also:** `FileBackedStore` flushes the entire map on every write (O(n) I/O). +Sessions are in-memory only — server restart forces all clients to re-login. + +### 2.6 Key Management Gaps + +- **DiskKeyStore** — HPKE private keys stored as plaintext bincode on disk. No + encryption at rest. +- **MLS group state** — `GroupMember` holds `MlsGroup` in memory only. Process + crash loses all group state. +- **Token zeroization** — `AuthContext.token`, `ClientAuth.access_token` are not + wrapped in `Zeroizing`. + +### 2.7 Workspace Bloat + +12 crates for a project at this maturity is excessive. Several are thin stubs +(`quicproquo-gen`, `quicproquo-bot` at 354 lines) or broken (`quicproquo-gui` +fails `cargo build --workspace`). + +--- + +## Part 3 — v2 Architecture Recommendations + +### 3.1 Replace capnp-rpc with a Send-Compatible RPC Framework + +**Recommendation:** Switch to **tonic (gRPC)** or a custom framing layer. + +| Dimension | capnp-rpc (v1) | tonic/gRPC (v2) | +|-----------|---------------|-----------------| +| Threading | `!Send`, single-threaded | `Send + Sync`, multi-threaded | +| Browser | Requires WS bridge | grpc-web native | +| Streaming | Not supported | Built-in | +| Middleware | None (copy-paste auth) | Interceptors/layers | +| Ecosystem | Niche | Massive (every language) | + +**Alternative:** Keep Cap'n Proto *schemas* for serialization (zero-copy +advantage) but replace capnp-rpc with custom framing over QUIC streams. This +preserves the wire format while gaining `Send` compatibility. + +The WS bridge would be eliminated entirely — grpc-web or WebTransport gives +browsers direct access. + +### 3.2 Extract an SDK Crate (Most Important Client Change) + +Create `quicproquo-sdk` that owns all business logic: + +``` +quicproquo-sdk/ + src/ + client.rs -- QpqClient: connect, login, send, receive + events.rs -- ClientEvent enum (push-based) + conversation.rs -- ConversationHandle, group management + crypto.rs -- MLS pipeline, sealed sender, hybrid decryption + sync.rs -- message sync, offline queue, retry +``` + +All frontends become thin shells: + +``` +CLI/REPL -> calls sdk +TUI -> calls sdk +Tauri GUI -> calls sdk (via Tauri commands) +Mobile -> calls sdk (via C FFI) +Web/WASM -> calls sdk (compiled to wasm32) +``` + +**Key API shape:** +```rust +pub struct QpqClient { /* session, rpc, crypto pipeline */ } + +impl QpqClient { + pub async fn connect(config: ClientConfig) -> Result; + pub async fn login(username: &str, password: &str) -> Result; + pub async fn dm(&mut self, username: &str) -> Result; + pub async fn create_group(&mut self, name: &str) -> Result; + pub async fn send(&mut self, text: &str) -> Result; + pub fn subscribe(&self) -> Receiver; +} +``` + +No global state. No `AUTH_CONTEXT`. Auth context is per-`QpqClient` instance. + +### 3.3 Add Push-Based Delivery + +**Recommendation:** Dedicated QUIC unidirectional stream for server-push +notifications. + +``` +Client opens bidi stream 0 -> RPC channel (request/response) +Server opens uni stream 1 -> push notifications (new message, typing, etc.) +``` + +Benefits: +- Zero-latency message delivery (no polling) +- No idle network traffic +- Typing indicators delivered in real-time +- Graceful degradation: fall back to long-poll if push stream fails + +**Also:** Make `peek` + `ack` the default delivery pattern (not destructive +`fetch`). Add idempotency keys to prevent duplicate messages on retry. + +### 3.4 Multi-Stream Connections + +Allow 4-8 concurrent bidirectional QUIC streams per connection. This enables: +- Pipelined RPCs (send while fetching) +- Concurrent blob upload + chat +- `fetchWait` on one stream without blocking others + +### 3.5 Storage Improvements + +| Change | Rationale | +|--------|-----------| +| Drop `FileBackedStore` | O(n) flush per write, no federation support | +| Connection pool for SQLite | Replace `Mutex` with r2d2/deadpool | +| Persist sessions to DB | Server restart shouldn't force re-login | +| Encrypt DiskKeyStore at rest | HPKE private keys in plaintext is a real vuln | +| Persist MLS group state | Process crash shouldn't lose group state | +| Atomic keystore writes | tempfile-then-rename pattern | + +### 3.6 Crypto Stack Refinements + +The algorithms are correct. The refinements are operational: + +| Change | Rationale | +|--------|-----------| +| Typed MLS error variants | Stop losing error info via `format!("{e:?}")` | +| Formalize hybrid PQ ciphersuite ID | Replace length-based key detection | +| Remove all InsecureServerCertVerifier | No TLS bypass on any platform | +| Add passkey/WebAuthn alt-auth | Better UX for GUI/mobile, no password to forget | +| Consider Double Ratchet for 1:1 DMs | MLS is over-engineered for 2-party; DR gives better per-message forward secrecy | +| Token/session secret zeroization | `AuthContext.token` et al. need `Zeroizing` wrappers | +| Fix serde deserialization of secrets | Intermediate non-zeroized `Vec` in `IdentityKeypair::deserialize` | + +### 3.7 Workspace Restructuring + +**Reduce from 12 to 8 crates:** + +``` +quicproquo-core -- crypto primitives (keep) +quicproquo-proto -- schema codegen (keep) +quicproquo-plugin-api -- #![no_std] C-ABI (keep) +quicproquo-kt -- key transparency (keep) +quicproquo-sdk -- NEW: business logic library +quicproquo-server -- server binary (keep) +quicproquo-client -- CLI/TUI binary, depends on sdk (keep, slimmed) +quicproquo-p2p -- mesh networking (keep, feature-flagged) +``` + +**Merge/remove:** +- `bot` -> `sdk::bot` module +- `ffi` -> `sdk` with `--features c-ffi` +- `gen` -> `scripts/` or `xtask` +- `gui` -> `apps/gui/` outside workspace (Tauri project) +- `mobile` -> `examples/` (research spike) + +**Add `[workspace.default-members]`** so `cargo build` doesn't attempt GUI. +**Add `justfile`** with `build`, `test`, `test-e2e`, `build-wasm`, `docker`. + +### 3.8 Plugin System Evolution + +| Change | Rationale | +|--------|-----------| +| Add `version: u32` to `HookVTable` | ABI stability — check version on load | +| Config passthrough | `qpq_plugin_init(vtable, config_json)` | +| Async hooks | Plugins that call external services shouldn't block Tokio | +| Evaluate WASM plugins | Sandboxed community plugins (keep C-ABI for first-party) | + +### 3.9 Federation Improvements + +| Change | Rationale | +|--------|-----------| +| DNS SRV / .well-known discovery | Static peer config doesn't scale | +| Persistent relay queue with retry | Messages to offline peers are currently lost | +| Deterministic channel ID derivation | Avoid cross-server channel conflicts | +| Keep mDNS as optional mesh feature | Not for internet-scale, but good for LAN | + +### 3.10 Test & CI Improvements + +| Change | Rationale | +|--------|-----------| +| Per-client auth context | Removes `--test-threads 1` constraint | +| Mock server for client unit tests | Fast tests without spawning real server | +| Fuzz testing (cargo-fuzz) | Hybrid KEM, sealed sender, padding, Cap'n Proto deser | +| WS bridge unit tests | 645 lines, zero tests, security-critical | +| WASM + Go SDK in CI | Currently untested in CI | +| Separate E2E from unit test CI job | Different speed, different failure modes | +| macOS CI | FFI/mobile cross-compilation validation | +| Release automation | Binary artifacts, Docker tags, WASM npm publish | + +--- + +## Part 4 — Ecosystem Positioning + +### Don't compete with Signal or Matrix directly. + +**Target: Privacy-first messaging infrastructure for developers and +organizations.** + +quicproquo's differentiators — QUIC-native transport, post-quantum crypto, MLS, +plugin system, multi-language SDKs, embeddable architecture — point toward an +infrastructure play, not a consumer app. + +Think: *"the Postgres of E2E encrypted messaging"* — a high-quality open-source +server and protocol that other projects build on. + +| Segment | Value Proposition | +|---------|-------------------| +| **Developer tool** | API-first messenger for encrypted bots and integrations | +| **Embeddable** | C FFI + WASM + Go SDK for embedding in other apps | +| **Enterprise** | On-prem, plugins for compliance/audit, OPAQUE zero-knowledge auth | +| **Research** | Post-quantum crypto, MLS reference implementation, mesh networking | + +--- + +## Part 5 — Priority Ordering + +### Phase 1: Foundation (unblocks everything else) +1. Replace capnp-rpc with Send-compatible framework +2. Extract SDK crate from client +3. Per-client auth context (no global state) + +### Phase 2: Reliability +4. Push-based delivery (QUIC uni-stream) +5. Multi-stream connections +6. Persist sessions + MLS group state +7. Encrypt DiskKeyStore at rest +8. peek+ack as default delivery + +### Phase 3: Polish +9. Workspace restructuring (12 -> 8 crates) +10. TUI as primary interactive mode (built on SDK) +11. Plugin system v2 (versioning, config, async) +12. Federation retry queue + discovery + +### Phase 4: Ecosystem +13. Full MLS in WASM (browser E2E) +14. WebTransport (eliminate WS bridge) +15. Tauri GUI (built on SDK) +16. Release automation + expanded CI + +--- + +## Appendix — Analysis Sources + +This document was produced by four parallel analysis agents: + +| Agent | Scope | Files Read | +|-------|-------|-----------| +| server-analyst | Transport, RPC, delivery, storage, federation | 27 server .rs files, 4 schemas, core transport | +| client-analyst | REPL, UX, state, multi-platform, SDK design | All client .rs, GUI, mobile, TS demo | +| security-analyst | MLS, OPAQUE, hybrid KEM, keystore, identity | All core .rs, review doc | +| dx-analyst | Workspace, build, tests, plugins, CI, ecosystem | All Cargo.toml, tests, CI, plugins, SDKs | diff --git a/docs/V2-MASTER-PLAN.md b/docs/V2-MASTER-PLAN.md new file mode 100644 index 0000000..c477a11 --- /dev/null +++ b/docs/V2-MASTER-PLAN.md @@ -0,0 +1,328 @@ +# quicproquo v2 — Master Implementation Plan + +> Created 2026-03-04. This is the authoritative plan for the v2 rewrite. +> See also: `docs/V2-DESIGN-ANALYSIS.md` for the detailed retrospective. + +## Context + +The v1 codebase has strong crypto foundations (MLS, hybrid PQ KEM, OPAQUE) but three +architectural bottlenecks: capnp-rpc is `!Send` (single-threaded), client business logic +is trapped in a monolithic REPL with global state, and delivery is poll-based. + +This plan creates v2 on a new branch, keeping the crypto stack intact and replacing +the RPC/transport layer, extracting an SDK, and restructuring the workspace. + +**Key decisions:** +- Transport: Protobuf (prost) + custom framing over QUIC (quinn) +- Mobile: Tauri 2 (same Rust SDK backend, web UI) +- Branch strategy: `v2` branch from main, not a fresh repo +- Constraints: Rust, QUIC, GPG-signed commits, zeroize secrets, no stubs + +--- + +## Architecture Overview + +``` +┌─────────────────────────────────────────────────────┐ +│ Frontends │ +│ CLI/TUI │ Tauri GUI/Mobile │ Web (WebTransport)│ +└─────┬─────┴────────┬───────────┴──────────┬─────────┘ + │ │ │ + ▼ ▼ ▼ +┌─────────────────────────────────────────────────────┐ +│ quicproquo-sdk │ +│ QpqClient { connect, login, send, recv, subscribe } │ +│ Event system (tokio broadcast) │ +│ Crypto pipeline (MLS, sealed sender, hybrid) │ +│ Conversation store (SQLCipher) │ +└──────────────────────┬──────────────────────────────┘ + │ + ▼ +┌─────────────────────────────────────────────────────┐ +│ quicproquo-rpc │ +│ QUIC framing: [method:u16][req_id:u32][len:u32][pb] │ +│ Multi-stream (1 RPC per stream) │ +│ Server-push via uni-streams │ +│ tower middleware (auth, rate-limit) │ +└──────────────────────┬──────────────────────────────┘ + │ + ▼ +┌─────────────────────────────────────────────────────┐ +│ quicproquo-server │ +│ Domain services (auth, delivery, channel, blob) │ +│ Store trait → SqlStore (connection pool) │ +│ Plugin hooks, federation, KT │ +└─────────────────────────────────────────────────────┘ +``` + +### Wire Format + +Per QUIC bidirectional stream (request/response): +``` +Request: [method_id: u16][request_id: u32][payload_len: u32][protobuf bytes] +Response: [status: u8][request_id: u32][payload_len: u32][protobuf bytes] +``` + +Per QUIC unidirectional stream (server → client push): +``` +Push: [event_type: u16][payload_len: u32][protobuf bytes] +``` + +Each RPC opens its own QUIC bidi stream → natural multi-stream, no head-of-line blocking. + +--- + +## Workspace Structure (v2: 9 crates) + +``` +quicproquo/ +├── crates/ +│ ├── quicproquo-core/ # KEEP AS-IS — crypto primitives, MLS, hybrid KEM +│ ├── quicproquo-kt/ # KEEP AS-IS — key transparency +│ ├── quicproquo-plugin-api/ # KEEP AS-IS — #![no_std] C-ABI +│ ├── quicproquo-proto/ # REWRITE — protobuf schemas + prost codegen +│ ├── quicproquo-rpc/ # NEW — QUIC RPC framework (framing, dispatch, tower) +│ ├── quicproquo-sdk/ # NEW — client business logic library +│ ├── quicproquo-server/ # REWRITE — domain services + RPC handlers +│ ├── quicproquo-client/ # REWRITE — thin CLI/TUI shell over SDK +│ └── quicproquo-p2p/ # KEEP — iroh mesh (feature-flagged, later) +├── apps/ +│ └── gui/ # Tauri 2 desktop + mobile app (outside workspace) +├── proto/ # .proto source files +│ └── qpq/v1/ +│ ├── auth.proto # OPAQUE registration + login (4 methods) +│ ├── delivery.proto # enqueue, fetch, peek, ack, batch (6 methods) +│ ├── keys.proto # key package + hybrid key CRUD (5 methods) +│ ├── channel.proto # channel create (1 method) +│ ├── user.proto # resolve user/identity (2 methods) +│ ├── blob.proto # upload/download (2 methods) +│ ├── device.proto # register/list/revoke (3 methods) +│ ├── p2p.proto # endpoint publish/resolve + health (3 methods) +│ ├── federation.proto # relay + proxy (6 methods) +│ ├── push.proto # server-push events (NEW) +│ └── common.proto # shared types (Auth, Envelope, Error) +├── sdks/ +│ ├── go/ # Go SDK (regenerate from .proto) +│ └── typescript/ # TS SDK (WebTransport client) +├── justfile # NEW — build commands +└── Cargo.toml # workspace root +``` + +**Removed from workspace:** +- `quicproquo-bot` → `sdk::bot` module +- `quicproquo-ffi` → `sdk` with `--features c-ffi` +- `quicproquo-gen` → `scripts/` +- `quicproquo-gui` → `apps/gui/` (Tauri project, outside workspace) +- `quicproquo-mobile` → merged into `apps/gui/` (Tauri 2 mobile) + +--- + +## Crate Reuse Assessment + +| v1 Crate | capnp deps? | v2 Action | Effort | +|----------|:-----------:|-----------|--------| +| **quicproquo-core** | None | Copy as-is | Zero | +| **quicproquo-kt** | None | Copy as-is | Zero | +| **quicproquo-plugin-api** | None | Copy as-is | Zero | +| **quicproquo-p2p** | None | Copy as-is | Zero | +| **quicproquo-proto** | 100% capnp | Replace with prost codegen | Medium | +| **quicproquo-server** | 16/20 files | Extract domain logic, rewrite handlers | High | +| **quicproquo-client** | 6/10 files | Extract to SDK, thin CLI shell | High | + +### Key Files to Reuse Directly + +| Source (v1) | Destination (v2) | Notes | +|-------------|------------------|-------| +| `crates/quicproquo-core/` (entire) | same path | Zero changes | +| `crates/quicproquo-kt/` (entire) | same path | Zero changes | +| `crates/quicproquo-plugin-api/` (entire) | same path | Zero changes | +| `server/src/storage.rs` | `server/src/storage.rs` | Store trait — keep | +| `server/src/sql_store.rs` | `server/src/sql_store.rs` | Add connection pool | +| `server/src/hooks.rs` | `server/src/hooks.rs` | Plugin system — keep | +| `server/src/plugin_loader.rs` | `server/src/plugin_loader.rs` | Keep | +| `server/src/error_codes.rs` | `server/src/error_codes.rs` | Keep | +| `server/src/config.rs` | `server/src/config.rs` | Update for new transport | +| `client/src/conversation.rs` | `sdk/src/conversation.rs` | Move to SDK | +| `client/src/token_cache.rs` | `sdk/src/token_cache.rs` | Move to SDK | +| `client/src/display.rs` | `client/src/display.rs` | Keep in CLI | +| `schemas/*.capnp` | reference only | Translate to .proto | + +--- + +## Phased Implementation + +### Phase 1: Foundation +**Goal:** v2 branch with new workspace, proto schemas, RPC framework skeleton, SDK skeleton. +**Scope:** Compiles, no runtime functionality yet. + +1. **Create v2 branch** from main +2. **Restructure workspace** — update root Cargo.toml, create new crate dirs, add justfile +3. **Write .proto files** — translate all 33 RPC methods + push events from Cap'n Proto +4. **Create quicproquo-proto crate** — prost-build codegen +5. **Create quicproquo-rpc crate** — QUIC RPC framework: + - `framing.rs` — wire format encode/decode (request, response, push) + - `server.rs` — accept QUIC connections, dispatch to handlers + - `client.rs` — connect, send requests, receive responses + push events + - `middleware.rs` — tower-based auth + rate-limit layers + - `method.rs` — method registry (method_id → async handler fn) +6. **Create quicproquo-sdk crate** — public API skeleton: + - `client.rs` — `QpqClient` struct + - `events.rs` — `ClientEvent` enum + - `conversation.rs` — `ConversationHandle`, `ConversationStore` + - `config.rs` — `ClientConfig` +7. **Extract server domain types** — `server/src/domain/` module: + - `types.rs` — plain Rust request/response types + - `auth.rs` — OPAQUE logic extracted from auth_ops.rs + - `delivery.rs` — enqueue/fetch logic extracted from delivery.rs + +**Verification:** +- `cargo build --workspace` succeeds +- `cargo test -p quicproquo-core` passes (72 tests) +- Proto codegen works +- RPC framework compiles + +--- + +### Phase 2: Server Core +**Goal:** Working server with all 33 RPC handlers over QUIC. + +1. **RPC dispatch** — method registry, connection lifecycle +2. **Domain handlers** — all 33 methods as `async fn(Request) -> Result` + - Auth (4): OPAQUE register start/finish, login start/finish + - Delivery (6): enqueue, fetch, fetchWait, peek, ack, batchEnqueue + - Keys (5): upload/fetch key package, upload/fetch/batch-fetch hybrid key + - Channels (1): createChannel + - Users (2): resolveUser, resolveIdentity + - Blobs (2): uploadBlob, downloadBlob + - Devices (3): registerDevice, listDevices, revokeDevice + - P2P (3): health, publishEndpoint, resolveEndpoint + - Federation (6): relay enqueue/batch, proxy fetch/resolve, health +3. **Server-push** — notification stream via QUIC uni-stream +4. **Storage upgrades:** + - Drop `FileBackedStore` + - Connection pool (deadpool-sqlite) + - Persist sessions to SQLite + - Atomic queue depth check + enqueue +5. **Tower middleware** — auth validation, rate limiting, audit logging +6. **Multi-stream** — concurrent RPCs per connection (remove 1-stream limit) + +**Verification:** +- Server starts, accepts QUIC connections +- Health check RPC works +- OPAQUE registration + login works +- Message enqueue + fetch round-trip + +--- + +### Phase 3: SDK +**Goal:** Complete client SDK library — the heart of v2. + +1. **QpqClient** — connect, OPAQUE auth, session management (no global state) +2. **Crypto pipeline** — MLS processing, sealed sender unwrap, hybrid decrypt + (extracted from repl.rs `poll_messages()`) +3. **Conversation management** — create DM, create group, invite, remove, send, receive +4. **Event system** — `tokio::broadcast` replacing poll loop + - `MessageReceived`, `TypingIndicator`, `ConversationCreated` + - `MemberJoined`, `MemberLeft`, `ConnectionLost`, `Reconnected` +5. **Offline support** — outbox queue, retry with backoff, sync on reconnect +6. **ConversationStore** — SQLCipher local DB (migrate from client/conversation.rs) +7. **Key management** — encrypted DiskKeyStore, MLS group state persistence +8. **Token/secret zeroization** — `AuthContext.token` etc. wrapped in `Zeroizing` + +**Verification:** +- SDK integration test: connect → login → create DM → send → receive +- No global state (`AUTH_CONTEXT` eliminated) +- Event subscription works +- Offline outbox drains on reconnect + +--- + +### Phase 4: Client +**Goal:** CLI and TUI as thin shells over SDK. + +1. **CLI binary** (`qpq`) — clap subcommands calling `QpqClient` +2. **REPL** — readline with tab-completion (rustyline), categorized `/help` +3. **TUI** — ratatui, subscribes to `QpqClient::subscribe()` events +4. **Simplified commands:** + - Hide MLS/KeyPackage internals (auto-refresh) + - Message references by short ID (not index) + - Batch operations (`/create-group team alice bob`) + - Categorized help (Chat, Groups, Security, System) +5. **Auto-server-launch** — keep zero-config DX from v1 +6. **Playbook system** — keep YAML-based test scripting + +**Verification:** +- `qpq --username alice --password pass` starts REPL (same UX as v1) +- TUI mode works with live event updates +- Tab-completion for commands and usernames +- E2E test: two clients exchange messages + +--- + +### Phase 5: Desktop & Mobile +**Goal:** Tauri 2 app for all platforms. + +1. **Tauri 2 project** in `apps/gui/` +2. **Rust backend** — Tauri commands wrapping `QpqClient` +3. **Web frontend** — Svelte or vanilla HTML/JS +4. **Desktop** — Linux, macOS, Windows +5. **Mobile** — iOS, Android via Tauri 2 mobile +6. **QUIC connection migration** — automatic wifi↔cellular handoff + +**Verification:** +- Desktop app builds and runs on Linux +- Mobile app builds for Android (emulator) +- Send message from CLI → received in GUI + +--- + +### Phase 6: Polish & Ecosystem +**Goal:** Production readiness. + +1. **Federation improvements** — DNS SRV discovery, persistent relay queue with retry +2. **Plugin system v2** — version field, config passthrough, async hooks, WASM plugins +3. **WebTransport** — browser clients over HTTP/3 (same quinn endpoint) +4. **WASM MLS** — compile openmls to wasm32 for browser E2E encryption +5. **CI/CD** — release automation, WASM CI, multi-platform (Linux + macOS) +6. **Security hardening:** + - Fuzz testing (hybrid KEM, sealed sender, padding, protobuf deser) + - Remove all `InsecureServerCertVerifier` paths + - Certificate pinning + - Add passkey/WebAuthn as alternative auth +7. **Double Ratchet for 1:1 DMs** — better per-message forward secrecy than MLS for 2-party + +--- + +## RPC Method Inventory (33 total) + +| Category | Methods | Proto File | +|----------|---------|-----------| +| Auth (OPAQUE) | opaqueRegisterStart, opaqueRegisterFinish, opaqueLoginStart, opaqueLoginFinish | auth.proto | +| Delivery | enqueue, fetch, fetchWait, peek, ack, batchEnqueue | delivery.proto | +| Keys | uploadKeyPackage, fetchKeyPackage, uploadHybridKey, fetchHybridKey, fetchHybridKeys | keys.proto | +| Channel | createChannel | channel.proto | +| User | resolveUser, resolveIdentity | user.proto | +| Blob | uploadBlob, downloadBlob | blob.proto | +| Device | registerDevice, listDevices, revokeDevice | device.proto | +| P2P | health, publishEndpoint, resolveEndpoint | p2p.proto | +| Federation | relayEnqueue, relayBatchEnqueue, proxyFetchKeyPackage, proxyFetchHybridKey, proxyResolveUser, federationHealth | federation.proto | + +**New in v2:** +| Push Events | Description | Proto File | +|-------------|-------------|-----------| +| MessageNotification | New message available | push.proto | +| TypingNotification | Peer is typing | push.proto | +| ChannelUpdate | Channel created/member changed | push.proto | +| SessionExpired | Auth session expired | push.proto | + +--- + +## Engineering Standards (carried from v1) + +- Conventional commits: `feat:`, `fix:`, `chore:`, `docs:`, `test:`, `refactor:` +- GPG-signed commits only +- No `Co-authored-by` trailers +- No `.unwrap()` on crypto or I/O in non-test paths +- Secrets: zeroize on drop, never in logs +- No stubs / `todo!()` / `unimplemented!()` in production code +- `clippy::unwrap_used = "deny"` at workspace level diff --git a/examples/plugins/logging_plugin/Cargo.lock b/examples/plugins/logging_plugin/Cargo.lock new file mode 100644 index 0000000..2bcb139 --- /dev/null +++ b/examples/plugins/logging_plugin/Cargo.lock @@ -0,0 +1,14 @@ +# This file is automatically @generated by Cargo. +# It is not intended for manual editing. +version = 4 + +[[package]] +name = "logging_plugin" +version = "0.1.0" +dependencies = [ + "quicproquo-plugin-api", +] + +[[package]] +name = "quicproquo-plugin-api" +version = "0.1.0" diff --git a/examples/plugins/rate_limit_plugin/Cargo.lock b/examples/plugins/rate_limit_plugin/Cargo.lock new file mode 100644 index 0000000..64124c0 --- /dev/null +++ b/examples/plugins/rate_limit_plugin/Cargo.lock @@ -0,0 +1,14 @@ +# This file is automatically @generated by Cargo. +# It is not intended for manual editing. +version = 4 + +[[package]] +name = "quicproquo-plugin-api" +version = "0.1.0" + +[[package]] +name = "rate_limit_plugin" +version = "0.1.0" +dependencies = [ + "quicproquo-plugin-api", +] diff --git a/scripts/render_terminal.py b/scripts/render_terminal.py new file mode 100755 index 0000000..dd82e03 --- /dev/null +++ b/scripts/render_terminal.py @@ -0,0 +1,212 @@ +#!/usr/bin/env python3 +"""Render two terminal pane captures (with ANSI escapes) into a single PNG. + +Usage: + python3 scripts/render_terminal.py left.ansi right.ansi -o assets/screenshot.png + python3 scripts/render_terminal.py left.ansi right.ansi --labels "alice" "bob" -o out.png +""" + +import argparse +import re +import sys +from pathlib import Path + +from PIL import Image, ImageDraw, ImageFont + +# ── Theme (dark terminal) ──────────────────────────────────────────────────── +BG = (30, 30, 46) # base (catppuccin mocha-ish) +FG = (205, 214, 244) # default text +DIM = (108, 112, 134) # dim/grey +GREEN = (166, 227, 161) +CYAN = (137, 220, 235) +YELLOW = (249, 226, 175) +RED = (243, 139, 168) +BLUE = (137, 180, 250) +MAGENTA = (203, 166, 247) +BOLD_WHITE = (255, 255, 255) +BORDER = (69, 71, 90) +TITLE_BG = (49, 50, 68) + +ANSI_COLORS = { + 30: (30, 30, 46), 31: RED, 32: GREEN, 33: YELLOW, + 34: BLUE, 35: MAGENTA, 36: CYAN, 37: FG, + 90: DIM, 91: RED, 92: GREEN, 93: YELLOW, + 94: BLUE, 95: MAGENTA, 96: CYAN, 97: BOLD_WHITE, +} + +# ── ANSI parsing ───────────────────────────────────────────────────────────── +ESC_RE = re.compile(r'\x1b\[([0-9;]*)m') + +def parse_ansi_line(line): + """Yield (text, fg_color, bold) spans from an ANSI-escaped line.""" + fg = FG + bold = False + dim = False + pos = 0 + for m in ESC_RE.finditer(line): + if m.start() > pos: + color = fg + if dim and color == FG: + color = DIM + yield (line[pos:m.start()], color, bold) + codes = m.group(1).split(';') if m.group(1) else ['0'] + for code_s in codes: + code = int(code_s) if code_s else 0 + if code == 0: + fg, bold, dim = FG, False, False + elif code == 1: + bold = True + elif code == 2: + dim = True + elif code in ANSI_COLORS: + fg = ANSI_COLORS[code] + pos = m.end() + tail = line[pos:] + if tail: + color = fg + if dim and color == FG: + color = DIM + yield (tail, color, bold) + + +def load_font(size): + """Try to load a monospace font.""" + candidates = [ + "/usr/share/fonts/google-noto/NotoSansMono-Regular.ttf", + "/usr/share/fonts/truetype/noto/NotoSansMono-Regular.ttf", + "/usr/share/fonts/truetype/dejavu/DejaVuSansMono.ttf", + "/usr/share/fonts/dejavu-sans-mono-fonts/DejaVuSansMono.ttf", + "/usr/share/fonts/TTF/DejaVuSansMono.ttf", + "/usr/share/fonts/liberation-mono/LiberationMono-Regular.ttf", + "/usr/share/fonts/google-droid-sans-mono-fonts/DroidSansMono.ttf", + ] + for path in candidates: + if Path(path).exists(): + return ImageFont.truetype(path, size) + # fallback + return ImageFont.load_default() + + +def load_bold_font(size): + candidates = [ + "/usr/share/fonts/google-noto/NotoSansMono-Bold.ttf", + "/usr/share/fonts/truetype/noto/NotoSansMono-Bold.ttf", + "/usr/share/fonts/truetype/dejavu/DejaVuSansMono-Bold.ttf", + "/usr/share/fonts/dejavu-sans-mono-fonts/DejaVuSansMono-Bold.ttf", + "/usr/share/fonts/TTF/DejaVuSansMono-Bold.ttf", + "/usr/share/fonts/liberation-mono/LiberationMono-Bold.ttf", + ] + for path in candidates: + if Path(path).exists(): + return ImageFont.truetype(path, size) + return load_font(size) + + +def strip_ansi(s): + return ESC_RE.sub('', s) + + +def render_pane(lines, width_chars, font, bold_font, font_size, line_height): + """Render terminal lines to an Image.""" + char_w = font.getbbox("M")[2] + img_w = char_w * width_chars + 24 # 12px padding each side + img_h = line_height * len(lines) + 16 # 8px padding top+bottom + + img = Image.new("RGB", (img_w, img_h), BG) + draw = ImageDraw.Draw(img) + + y = 8 + for line in lines: + x = 12 + for text, color, bold in parse_ansi_line(line): + f = bold_font if bold else font + draw.text((x, y), text, fill=color, font=f) + x += f.getlength(text) + y += line_height + + return img + + +def main(): + ap = argparse.ArgumentParser(description="Render terminal panes to PNG") + ap.add_argument("left", help="Left pane ANSI capture file") + ap.add_argument("right", help="Right pane ANSI capture file") + ap.add_argument("-o", "--output", default="assets/screenshot.png") + ap.add_argument("--labels", nargs=2, default=["alice", "bob"], + help="Labels for the two panes") + ap.add_argument("--font-size", type=int, default=14) + ap.add_argument("--width", type=int, default=58, + help="Width of each pane in characters") + args = ap.parse_args() + + font_size = args.font_size + line_height = int(font_size * 1.5) + font = load_font(font_size) + bold_font = load_bold_font(font_size) + char_w = font.getbbox("M")[2] + pane_w = char_w * args.width + 24 + + left_lines = Path(args.left).read_text().splitlines() + right_lines = Path(args.right).read_text().splitlines() + + # Render each pane + left_img = render_pane(left_lines, args.width, font, bold_font, + font_size, line_height) + right_img = render_pane(right_lines, args.width, font, bold_font, + font_size, line_height) + + # Composite: title bar + two panes side by side + title_h = 32 + gap = 2 + max_h = max(left_img.height, right_img.height) + total_w = left_img.width + gap + right_img.width + total_h = title_h + max_h + + # Window chrome + canvas = Image.new("RGB", (total_w, total_h), BORDER) + draw = ImageDraw.Draw(canvas) + + # Title bar + draw.rectangle([(0, 0), (total_w, title_h - 1)], fill=TITLE_BG) + + # Traffic lights + for i, color in enumerate([(255, 95, 86), (255, 189, 46), (39, 201, 63)]): + cx = 16 + i * 22 + cy = title_h // 2 + draw.ellipse([(cx - 6, cy - 6), (cx + 6, cy + 6)], fill=color) + + # Pane labels + label_font = load_font(font_size - 1) + left_label = args.labels[0] + right_label = args.labels[1] + left_label_w = label_font.getlength(left_label) + right_label_w = label_font.getlength(right_label) + draw.text((left_img.width // 2 - left_label_w // 2, 7), + left_label, fill=DIM, font=label_font) + draw.text((left_img.width + gap + right_img.width // 2 - right_label_w // 2, 7), + right_label, fill=DIM, font=label_font) + + # Paste panes + canvas.paste(left_img, (0, title_h)) + canvas.paste(right_img, (left_img.width + gap, title_h)) + + # Round corners (simple mask) + radius = 10 + mask = Image.new("L", canvas.size, 255) + mask_draw = ImageDraw.Draw(mask) + mask_draw.rectangle([(0, 0), (radius, radius)], fill=0) + mask_draw.pieslice([(0, 0), (radius * 2, radius * 2)], 180, 270, fill=255) + mask_draw.rectangle([(total_w - radius, 0), (total_w, radius)], fill=0) + mask_draw.pieslice([(total_w - radius * 2, 0), (total_w, radius * 2)], 270, 360, fill=255) + + # Apply rounded corners with transparent background + final = Image.new("RGBA", canvas.size, (0, 0, 0, 0)) + final.paste(canvas, mask=mask) + + Path(args.output).parent.mkdir(parents=True, exist_ok=True) + final.save(args.output) + print(f"Saved {args.output} ({final.width}x{final.height})") + + +if __name__ == "__main__": + main() diff --git a/scripts/screenshot.sh b/scripts/screenshot.sh new file mode 100755 index 0000000..57f9eb8 --- /dev/null +++ b/scripts/screenshot.sh @@ -0,0 +1,92 @@ +#!/usr/bin/env bash +# scripts/screenshot.sh — generate a README screenshot automatically +set -euo pipefail +cd "$(git rev-parse --show-toplevel)" + +INTERACTIVE=false +[[ "${1:-}" == "--interactive" ]] && INTERACTIVE=true + +SESSION="qpq-screenshot" +SERVER_PORT=17123 +SERVER_ADDR="127.0.0.1:${SERVER_PORT}" +DATA_DIR=$(mktemp -d) +CERT="${DATA_DIR}/server-cert.der" +KEY="${DATA_DIR}/server-key.der" +QPQ="./target/debug/qpq" +SERVER="./target/debug/qpq-server" +SLOG="${DATA_DIR}/server.log" + +cleanup() { + [[ -n "${SERVER_PID:-}" ]] && kill "$SERVER_PID" 2>/dev/null || true + tmux kill-session -t "$SESSION" 2>/dev/null || true + # Keep DATA_DIR for debugging + echo "Data dir: $DATA_DIR" +} +trap cleanup EXIT + +# ── Build ──────────────────────────────────────────────────────────────────── +echo "Building binaries..." +cargo build --bin qpq --bin qpq-server 2>&1 | tail -1 + +# ── Start server ───────────────────────────────────────────────────────────── +echo "Starting server on ${SERVER_ADDR}..." +RUST_LOG=debug "$SERVER" \ + --allow-insecure-auth \ + --listen "$SERVER_ADDR" \ + --tls-cert "$CERT" \ + --tls-key "$KEY" \ + --data-dir "$DATA_DIR" \ + &>"$SLOG" & +SERVER_PID=$! + +for _ in $(seq 1 30); do [[ -f "$CERT" ]] && break; sleep 0.2; done +if [[ ! -f "$CERT" ]]; then echo "ERROR: server did not start"; cat "$SLOG"; exit 1; fi +echo "Server ready (PID ${SERVER_PID})" + +# ── tmux session ───────────────────────────────────────────────────────────── +tmux new-session -d -s "$SESSION" -x 114 -y 28 + +send_alice() { tmux send-keys -t "${SESSION}:0.0" "$1" Enter; } +send_bob() { tmux send-keys -t "${SESSION}:0.1" "$1" Enter; } + +# Start Alice (left pane) +tmux send-keys -t "$SESSION" \ + "RUST_LOG=debug $QPQ repl --username alice --password demopass1 --server $SERVER_ADDR --ca-cert $CERT --state ${DATA_DIR}/alice.bin 2>${DATA_DIR}/alice-debug.log" Enter + +# Start Bob (right pane) +tmux split-window -h -t "$SESSION" +tmux send-keys -t "$SESSION" \ + "RUST_LOG=debug $QPQ repl --username bob --password demopass2 --server $SERVER_ADDR --ca-cert $CERT --state ${DATA_DIR}/bob.bin 2>${DATA_DIR}/bob-debug.log" Enter + +tmux select-layout -t "$SESSION" even-horizontal +sleep 5 + +# Alice creates DM with Bob +send_alice "/dm bob" +sleep 4 + +# Wait for Bob's poller (8 seconds = 8 poll cycles) +echo "Waiting for Bob to poll Welcome..." +sleep 10 + +# Check Bob's list +send_bob "/list" +sleep 2 + +# Capture both panes +{ + echo "=== Alice ===" + tmux capture-pane -t "${SESSION}:0.0" -p + echo "" + echo "=== Bob ===" + tmux capture-pane -t "${SESSION}:0.1" -p +} > "${DATA_DIR}/capture.txt" + +echo "" +cat "${DATA_DIR}/capture.txt" +echo "" +echo "Server log tail:" +tail -30 "$SLOG" 2>/dev/null || true +echo "" +echo "Debug logs in: $DATA_DIR" +echo "Check: $DATA_DIR/alice-debug.log and $DATA_DIR/bob-debug.log"