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 0000000..afc7245 Binary files /dev/null and b/assets/screenshot.png differ 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"