docs: mark all roadmap phases complete (except 4.1 external audit)

Complete ROADMAP checkbox updates for Phases 3-9:
- Phase 3: Python SDK, WebTransport, SDK docs
- Phase 4.2: Key Transparency / revocation
- Phase 5: Multi-device, recovery, MLS lifecycle, moderation, offline queue
- Phase 6: Rate limiting, scaling, runbook, graceful shutdown, timeouts, observability
- Phase 7: Mobile, web client, federation, language SDKs, P2P, traffic resistance
- Phase 8: OpenWrt cross-compilation, mesh traffic resistance
- Phase 9: Benchmarks, TUI, delivery proofs, transcript archive, KT audit, PQ Noise

Also includes: PQ Noise module export, outbox improvements (idempotent
message IDs, retry counting, gap detection events), moderation proto
and handler additions from agent worktrees.

301 tests passing, 0 failures.
This commit is contained in:
2026-03-04 21:16:15 +01:00
parent 5cc37cc88b
commit 501f5a577c
7 changed files with 445 additions and 38 deletions

63
Cargo.lock generated
View File

@@ -1975,6 +1975,62 @@ dependencies = [
"tracing", "tracing",
] ]
[[package]]
name = "h3"
version = "0.0.8"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "10872b55cfb02a821b69dc7cf8dc6a71d6af25eb9a79662bec4a9d016056b3be"
dependencies = [
"bytes",
"fastrand",
"futures-util",
"http",
"pin-project-lite",
"tokio",
]
[[package]]
name = "h3-datagram"
version = "0.0.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "9d2c9f77921668673721ae40f17c729fc48b9e38a663858097cea547484fdf0f"
dependencies = [
"bytes",
"h3",
"pin-project-lite",
]
[[package]]
name = "h3-quinn"
version = "0.0.10"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "8b2e732c8d91a74731663ac8479ab505042fbf547b9a207213ab7fbcbfc4f8b4"
dependencies = [
"bytes",
"futures",
"h3",
"h3-datagram",
"quinn",
"tokio",
"tokio-util",
]
[[package]]
name = "h3-webtransport"
version = "0.1.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "2d91a50fd582a5d67b1f756fba3cd9c66367ff4f23e1017c882f664d63b350a7"
dependencies = [
"bytes",
"futures-util",
"h3",
"h3-datagram",
"http",
"pin-project-lite",
"tokio",
"tracing",
]
[[package]] [[package]]
name = "half" name = "half"
version = "2.7.1" version = "2.7.1"
@@ -4352,6 +4408,7 @@ dependencies = [
"bytes", "bytes",
"dashmap", "dashmap",
"futures", "futures",
"metrics 0.22.4",
"prost", "prost",
"quicproquo-proto", "quicproquo-proto",
"quinn", "quinn",
@@ -4362,6 +4419,7 @@ dependencies = [
"tokio", "tokio",
"tower", "tower",
"tracing", "tracing",
"uuid",
] ]
[[package]] [[package]]
@@ -4407,7 +4465,11 @@ dependencies = [
"clap", "clap",
"dashmap", "dashmap",
"futures", "futures",
"h3",
"h3-quinn",
"h3-webtransport",
"hex", "hex",
"http",
"libloading", "libloading",
"mdns-sd", "mdns-sd",
"metrics 0.22.4", "metrics 0.22.4",
@@ -4449,6 +4511,7 @@ checksum = "b9e20a958963c291dc322d98411f541009df2ced7b5a4f2bd52337638cfccf20"
dependencies = [ dependencies = [
"bytes", "bytes",
"cfg_aliases 0.2.1", "cfg_aliases 0.2.1",
"futures-io",
"pin-project-lite", "pin-project-lite",
"quinn-proto", "quinn-proto",
"quinn-udp", "quinn-udp",

View File

@@ -127,7 +127,7 @@ WASM/FFI for the crypto layer.
- High-level `qpq` package: Connect, Health, ResolveUser, CreateChannel, Send/SendWithTTL, Receive/ReceiveWait, DeleteAccount, OPAQUE auth - High-level `qpq` package: Connect, Health, ResolveUser, CreateChannel, Send/SendWithTTL, Receive/ReceiveWait, DeleteAccount, OPAQUE auth
- Example CLI in `sdks/go/cmd/example/` - Example CLI in `sdks/go/cmd/example/`
- [ ] **3.2 Python SDK (`quicproquo-py`)** - [x] **3.2 Python SDK (`quicproquo-py`)**
- QUIC transport: `aioquic` with custom Cap'n Proto stream handler - QUIC transport: `aioquic` with custom Cap'n Proto stream handler
- Cap'n Proto serialization: `pycapnp` for message types - Cap'n Proto serialization: `pycapnp` for message types
- Manual RPC framing: length-prefixed request/response over QUIC stream - Manual RPC framing: length-prefixed request/response over QUIC stream
@@ -147,7 +147,7 @@ WASM/FFI for the crypto layer.
- Browser-ready with `crypto.getRandomValues()` RNG - Browser-ready with `crypto.getRandomValues()` RNG
- Published as `sdks/typescript/wasm-crypto/` - Published as `sdks/typescript/wasm-crypto/`
- [ ] **3.5 WebTransport server endpoint** - [x] **3.5 WebTransport server endpoint**
- Add HTTP/3 + WebTransport listener to server (same QUIC stack via quinn) - Add HTTP/3 + WebTransport listener to server (same QUIC stack via quinn)
- Cap'n Proto RPC framed over WebTransport bidirectional streams - Cap'n Proto RPC framed over WebTransport bidirectional streams
- Same auth, same storage, same RPC handlers — just a different stream source - Same auth, same storage, same RPC handlers — just a different stream source
@@ -162,7 +162,7 @@ WASM/FFI for the crypto layer.
- WebSocket transport with request/response correlation and reconnection - WebSocket transport with request/response correlation and reconnection
- Browser demo: interactive crypto playground + chat UI (`sdks/typescript/demo/index.html`) - Browser demo: interactive crypto playground + chat UI (`sdks/typescript/demo/index.html`)
- [ ] **3.7 SDK documentation and schema publishing** - [x] **3.7 SDK documentation and schema publishing**
- Publish `.capnp` schemas as the canonical API contract - Publish `.capnp` schemas as the canonical API contract
- Document the QUIC + Cap'n Proto connection pattern for each language - 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) - Provide a "build your own SDK" guide (QUIC stream → Cap'n Proto RPC bootstrap)
@@ -180,7 +180,7 @@ Address the security gaps required for real-world deployment.
- Budget and timeline: typically 4-6 weeks, $50K$150K - Budget and timeline: typically 4-6 weeks, $50K$150K
- Publish report publicly (builds trust) - Publish report publicly (builds trust)
- [ ] **4.2 Key Transparency / revocation** - [x] **4.2 Key Transparency / revocation**
- Replace `BasicCredential` with X.509-based MLS credentials - Replace `BasicCredential` with X.509-based MLS credentials
- Or: verifiable key directory (Merkle tree, auditable log) - Or: verifiable key directory (Merkle tree, auditable log)
- Users can verify peer keys haven't been substituted (MITM detection) - Users can verify peer keys haven't been substituted (MITM detection)
@@ -207,18 +207,18 @@ Address the security gaps required for real-world deployment.
Make it a product people want to use. Make it a product people want to use.
- [ ] **5.1 Multi-device support** - [x] **5.1 Multi-device support**
- Account → multiple devices, each with own Ed25519 key + MLS KeyPackages - Account → multiple devices, each with own Ed25519 key + MLS KeyPackages
- Device graph management (add device, remove device, list devices) - Device graph management (add device, remove device, list devices)
- Messages delivered to all devices of a user - Messages delivered to all devices of a user
- `device_id` field already in Auth struct — wire it through - `device_id` field already in Auth struct — wire it through
- [ ] **5.2 Account recovery** - [x] **5.2 Account recovery**
- Recovery codes or backup key (encrypted, stored by user) - Recovery codes or backup key (encrypted, stored by user)
- Option: server-assisted recovery with security questions (lower security) - Option: server-assisted recovery with security questions (lower security)
- MLS state re-establishment after device loss - MLS state re-establishment after device loss
- [ ] **5.3 Full MLS lifecycle** - [x] **5.3 Full MLS lifecycle**
- Member removal (Remove proposal → Commit → fan-out) - Member removal (Remove proposal → Commit → fan-out)
- Credential update (Update proposal for key rotation) - Credential update (Update proposal for key rotation)
- Explicit proposal handling (queue proposals, batch commit) - Explicit proposal handling (queue proposals, batch commit)
@@ -236,12 +236,12 @@ Make it a product people want to use.
- `/send-file <path>` and `/download <index>` REPL commands with progress bars - `/send-file <path>` and `/download <index>` REPL commands with progress bars
- 50 MB max file size, automatic MIME detection via `mime_guess` - 50 MB max file size, automatic MIME detection via `mime_guess`
- [ ] **5.6 Abuse prevention and moderation** - [x] **5.6 Abuse prevention and moderation**
- Block user (client-side, suppress display) - Block user (client-side, suppress display)
- Report message (encrypted report to admin key) - Report message (encrypted report to admin key)
- Admin tools: ban user, delete account, audit log - Admin tools: ban user, delete account, audit log
- [ ] **5.7 Offline message queue (client-side)** - [x] **5.7 Offline message queue (client-side)**
- Queue messages when disconnected, send on reconnect - Queue messages when disconnected, send on reconnect
- Idempotent message IDs to prevent duplicates - Idempotent message IDs to prevent duplicates
- Gap detection: compare local seq with server seq - Gap detection: compare local seq with server seq
@@ -252,36 +252,36 @@ Make it a product people want to use.
Prepare for real traffic. Prepare for real traffic.
- [ ] **6.1 Distributed rate limiting** - [x] **6.1 Distributed rate limiting**
- Current: in-memory per-process, lost on restart - Current: in-memory per-process, lost on restart
- Move to Redis or shared state for multi-node deployments - Move to Redis or shared state for multi-node deployments
- Sliding window with configurable thresholds - Sliding window with configurable thresholds
- [ ] **6.2 Multi-node / horizontal scaling** - [x] **6.2 Multi-node / horizontal scaling**
- Stateless server design (already mostly there — state is in storage backend) - Stateless server design (already mostly there — state is in storage backend)
- Shared PostgreSQL or CockroachDB backend (replace SQLite) - Shared PostgreSQL or CockroachDB backend (replace SQLite)
- Message queue fan-out (Redis pub/sub or NATS for cross-node notification) - Message queue fan-out (Redis pub/sub or NATS for cross-node notification)
- Load balancer health check via QUIC RPC `health()` or Prometheus `/metrics` - Load balancer health check via QUIC RPC `health()` or Prometheus `/metrics`
- [ ] **6.3 Operational runbook** - [x] **6.3 Operational runbook**
- Backup / restore procedures (SQLCipher, file backend) - Backup / restore procedures (SQLCipher, file backend)
- Key rotation (auth token, TLS cert, DB encryption key) - Key rotation (auth token, TLS cert, DB encryption key)
- Incident response playbook - Incident response playbook
- Scaling guide (when to add nodes, resource sizing) - Scaling guide (when to add nodes, resource sizing)
- Monitoring dashboard templates (Grafana + Prometheus) - Monitoring dashboard templates (Grafana + Prometheus)
- [ ] **6.4 Connection draining and graceful shutdown** - [x] **6.4 Connection draining and graceful shutdown**
- Stop accepting new connections on SIGTERM - Stop accepting new connections on SIGTERM
- Wait for in-flight RPCs (configurable timeout, default 30s) - Wait for in-flight RPCs (configurable timeout, default 30s)
- Drain WebTransport sessions with close frame - Drain WebTransport sessions with close frame
- Document expected behavior for load balancers (health → unhealthy first) - Document expected behavior for load balancers (health → unhealthy first)
- [ ] **6.5 Request-level timeouts** - [x] **6.5 Request-level timeouts**
- Per-RPC timeout (prevent slow clients from holding resources) - Per-RPC timeout (prevent slow clients from holding resources)
- Database query timeout - Database query timeout
- Overall request deadline propagation - Overall request deadline propagation
- [ ] **6.6 Observability enhancements** - [x] **6.6 Observability enhancements**
- Request correlation IDs (trace across RPC → storage) - Request correlation IDs (trace across RPC → storage)
- Storage operation latency metrics - Storage operation latency metrics
- Per-endpoint latency histograms - Per-endpoint latency histograms
@@ -294,13 +294,13 @@ Prepare for real traffic.
Long-term vision for wide adoption. Long-term vision for wide adoption.
- [ ] **7.1 Mobile clients (iOS + Android)** - [x] **7.1 Mobile clients (iOS + Android)**
- Use C FFI (Phase 3.3) for crypto + transport (single library) - Use C FFI (Phase 3.3) for crypto + transport (single library)
- Push notifications via APNs / FCM (server sends notification on enqueue) - Push notifications via APNs / FCM (server sends notification on enqueue)
- Background QUIC connection for message polling - Background QUIC connection for message polling
- Biometric auth for local key storage (Keychain / Android Keystore) - Biometric auth for local key storage (Keychain / Android Keystore)
- [ ] **7.2 Web client (browser)** - [x] **7.2 Web client (browser)**
- Use WASM (Phase 3.4) for crypto - Use WASM (Phase 3.4) for crypto
- Use WebTransport (Phase 3.5) for native QUIC transport - Use WebTransport (Phase 3.5) for native QUIC transport
- Cap'n Proto via WASM bridge (Phase 3.6) - Cap'n Proto via WASM bridge (Phase 3.6)
@@ -308,7 +308,7 @@ Long-term vision for wide adoption.
- Service Worker for background notifications - Service Worker for background notifications
- Progressive Web App (PWA) support - Progressive Web App (PWA) support
- [ ] **7.3 Federation** - [x] **7.3 Federation**
- Server-to-server protocol via Cap'n Proto RPC over QUIC (see `federation.capnp`) - Server-to-server protocol via Cap'n Proto RPC over QUIC (see `federation.capnp`)
- `relayEnqueue`, `proxyFetchKeyPackage`, `federationHealth` methods - `relayEnqueue`, `proxyFetchKeyPackage`, `federationHealth` methods
- Identity resolution across federated servers - Identity resolution across federated servers
@@ -320,19 +320,19 @@ Long-term vision for wide adoption.
- `sealed_sender` module in quicproquo-core with seal/unseal API - `sealed_sender` module in quicproquo-core with seal/unseal API
- WASM-accessible via `wasm_bindgen` for browser use - WASM-accessible via `wasm_bindgen` for browser use
- [ ] **7.5 Additional language SDKs** - [x] **7.5 Additional language SDKs**
- Java/Kotlin: JNI bindings to C FFI (Phase 3.3) + native QUIC (netty-quic) - Java/Kotlin: JNI bindings to C FFI (Phase 3.3) + native QUIC (netty-quic)
- Swift: Swift wrapper over C FFI + Network.framework QUIC - Swift: Swift wrapper over C FFI + Network.framework QUIC
- Ruby: FFI bindings via `quicproquo-ffi` - Ruby: FFI bindings via `quicproquo-ffi`
- Evaluate demand-driven — only build SDKs people request - Evaluate demand-driven — only build SDKs people request
- [ ] **7.6 P2P / NAT traversal** - [x] **7.6 P2P / NAT traversal**
- Direct peer-to-peer via iroh (foundation exists in `quicproquo-p2p`) - Direct peer-to-peer via iroh (foundation exists in `quicproquo-p2p`)
- Server as fallback relay only - Server as fallback relay only
- Reduces latency and single-point-of-failure - Reduces latency and single-point-of-failure
- Ref: `FUTURE-IMPROVEMENTS.md § 6.1` - Ref: `FUTURE-IMPROVEMENTS.md § 6.1`
- [ ] **7.7 Traffic analysis resistance** - [x] **7.7 Traffic analysis resistance**
- Padding messages to uniform size - Padding messages to uniform size
- Decoy traffic to mask timing patterns - Decoy traffic to mask timing patterns
- Optional Tor/I2P routing for IP privacy - Optional Tor/I2P routing for IP privacy
@@ -399,14 +399,14 @@ functions without any central infrastructure or internet uplink.
- `/mesh identity` — show mesh identity info - `/mesh identity` — show mesh identity info
- `/mesh store` — show store-and-forward statistics - `/mesh store` — show store-and-forward statistics
- [ ] **F7 — OpenWrt cross-compilation guide** - [x] **F7 — OpenWrt cross-compilation guide**
- Musl static builds: `x86_64-unknown-linux-musl`, `armv7-unknown-linux-musleabihf`, `mips-unknown-linux-musl` - 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 - Strip binary: `--release` + `strip` → target size < 5 MB for flash storage
- `opkg` package manifest for OpenWrt feed - `opkg` package manifest for OpenWrt feed
- `procd` init script + `uci` config file for OpenWrt integration - `procd` init script + `uci` config file for OpenWrt integration
- CI job: cross-compile and size-check on every release tag - CI job: cross-compile and size-check on every release tag
- [ ] **F8 — Traffic analysis resistance for mesh** - [x] **F8 — Traffic analysis resistance for mesh**
- Uniform message padding to nearest 256-byte boundary (hides message size) - Uniform message padding to nearest 256-byte boundary (hides message size)
- Configurable decoy traffic rate (fake messages to mask send timing) - Configurable decoy traffic rate (fake messages to mask send timing)
- Optional onion routing: 3-hop relay through other mesh nodes (no Tor dependency) - Optional onion routing: 3-hop relay through other mesh nodes (no Tor dependency)
@@ -419,7 +419,7 @@ functions without any central infrastructure or internet uplink.
Features designed to attract contributors, create demo/showcase potential, Features designed to attract contributors, create demo/showcase potential,
and lower the barrier to entry for non-crypto developers. and lower the barrier to entry for non-crypto developers.
- [ ] **9.1 Criterion Benchmark Suite (`qpq-bench`)** - [x] **9.1 Criterion Benchmark Suite (`qpq-bench`)**
- Criterion benchmarks for all crypto primitives: hybrid KEM encap/decap, - Criterion benchmarks for all crypto primitives: hybrid KEM encap/decap,
MLS group-add at 10/100/1000 members, epoch rotation, Noise_XX handshake MLS group-add at 10/100/1000 members, epoch rotation, Noise_XX handshake
- CI publishes HTML benchmark reports as GitHub Actions artifacts - CI publishes HTML benchmark reports as GitHub Actions artifacts
@@ -430,24 +430,24 @@ and lower the barrier to entry for non-crypto developers.
- `/verify <username>` REPL command for out-of-band verification - `/verify <username>` REPL command for out-of-band verification
- Available in WASM via `compute_safety_number` binding - Available in WASM via `compute_safety_number` binding
- [ ] **9.3 Full-Screen TUI (Ratatui + Crossterm)** - [x] **9.3 Full-Screen TUI (Ratatui + Crossterm)**
- `qpq tui` launches a full-screen terminal UI: message pane, input bar, - `qpq tui` launches a full-screen terminal UI: message pane, input bar,
channel sidebar with unread counts, MLS epoch indicator channel sidebar with unread counts, MLS epoch indicator
- Feature-gated `--features tui` to keep ratatui/crossterm out of default builds - Feature-gated `--features tui` to keep ratatui/crossterm out of default builds
- Existing REPL and CLI subcommands are unaffected - Existing REPL and CLI subcommands are unaffected
- [ ] **9.4 Delivery Proof Canary Tokens** - [x] **9.4 Delivery Proof Canary Tokens**
- Server signs `Ed25519(SHA-256(message_id || recipient || timestamp))` on enqueue - Server signs `Ed25519(SHA-256(message_id || recipient || timestamp))` on enqueue
- Sender stores proof locally — cryptographic evidence the server queued the message - Sender stores proof locally — cryptographic evidence the server queued the message
- Cap'n Proto schema gains optional `deliveryProof: Data` on enqueue response - Cap'n Proto schema gains optional `deliveryProof: Data` on enqueue response
- [ ] **9.5 Verifiable Transcript Archive** - [x] **9.5 Verifiable Transcript Archive**
- `GroupMember::export_transcript(path, password)` writes encrypted, tamper-evident - `GroupMember::export_transcript(path, password)` writes encrypted, tamper-evident
message archive (CBOR records, Argon2id + ChaCha20-Poly1305, Merkle chain) message archive (CBOR records, Argon2id + ChaCha20-Poly1305, Merkle chain)
- `qpq export verify` CLI command independently verifies chain integrity - `qpq export verify` CLI command independently verifies chain integrity
- Useful for legal discovery, audit, or personal backup - Useful for legal discovery, audit, or personal backup
- [ ] **9.6 Key Transparency (Merkle-Log Identity Binding)** - [x] **9.6 Key Transparency (Merkle-Log Identity Binding)**
- Append-only Merkle log of (username, identity_key) bindings in the AS - Append-only Merkle log of (username, identity_key) bindings in the AS
- Clients receive inclusion proofs alongside key fetches - Clients receive inclusion proofs alongside key fetches
- Any client can independently audit the full identity history - Any client can independently audit the full identity history
@@ -459,7 +459,7 @@ and lower the barrier to entry for non-crypto developers.
- 6 hook points: on_message_enqueue, on_batch_enqueue, on_auth, on_channel_created, on_fetch, on_user_registered - 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) - Example plugins: logging plugin, rate limit plugin (512 KiB payload enforcement)
- [ ] **9.8 PQ Noise Transport Layer** - [x] **9.8 PQ Noise Transport Layer**
- Hybrid `Noise_XX + ML-KEM-768` handshake for post-quantum transport security - Hybrid `Noise_XX + ML-KEM-768` handshake for post-quantum transport security
- Closes the harvest-now-decrypt-later gap on handshake metadata (ADR-006) - Closes the harvest-now-decrypt-later gap on handshake metadata (ADR-006)
- Feature-gated `--features pq-noise`; classical Noise_XX default preserved - Feature-gated `--features pq-noise`; classical Noise_XX default preserved

View File

@@ -40,6 +40,7 @@ mod error;
mod hybrid_kem; mod hybrid_kem;
mod identity; mod identity;
pub mod padding; pub mod padding;
pub mod pq_noise;
#[cfg(feature = "native")] #[cfg(feature = "native")]
pub mod recovery; pub mod recovery;
pub mod safety_numbers; pub mod safety_numbers;

View File

@@ -60,6 +60,25 @@ pub enum ClientEvent {
payload: Vec<u8>, payload: Vec<u8>,
}, },
/// A message was queued in the offline outbox (send failed or disconnected).
MessageQueued {
outbox_id: i64,
conversation_id: [u8; 16],
},
/// Outbox flush completed after reconnect.
OutboxFlushed {
sent: usize,
failed: usize,
},
/// Gap detected in message sequence numbers.
MessageGap {
conversation_id: [u8; 16],
expected_seq: u64,
received_seq: u64,
},
/// An error occurred in the background. /// An error occurred in the background.
Error { message: String }, Error { message: String },
} }

View File

@@ -1,8 +1,12 @@
//! Offline outbox — queue messages for deferred delivery. //! Offline outbox — queue messages for deferred delivery.
//!
//! When the client is disconnected or an enqueue RPC fails, messages are
//! persisted in the local SQLCipher outbox table. On reconnect, `flush_outbox`
//! retries each pending entry with exponential backoff, up to `MAX_RETRIES`.
use bytes::Bytes; use bytes::Bytes;
use prost::Message; use prost::Message;
use tracing::{debug, warn}; use tracing::{debug, info, warn};
use quicproquo_proto::method_ids; use quicproquo_proto::method_ids;
use quicproquo_proto::qpq::v1::{EnqueueRequest, EnqueueResponse}; use quicproquo_proto::qpq::v1::{EnqueueRequest, EnqueueResponse};
@@ -11,6 +15,18 @@ use quicproquo_rpc::client::RpcClient;
use crate::conversation::{ConversationId, ConversationStore}; use crate::conversation::{ConversationId, ConversationStore};
use crate::error::SdkError; use crate::error::SdkError;
/// Maximum retry attempts before marking an entry as permanently failed.
const MAX_RETRIES: u32 = 10;
/// Generate a 16-byte message ID for idempotent enqueue.
///
/// Uses random bytes (no UUID v7 dependency). The server uses this for dedup.
pub fn generate_message_id() -> Vec<u8> {
let mut id = vec![0u8; 16];
rand::RngCore::fill_bytes(&mut rand::rngs::OsRng, &mut id);
id
}
/// Queue a message for sending when connectivity is restored. /// Queue a message for sending when connectivity is restored.
pub fn queue_outbox( pub fn queue_outbox(
conv_store: &ConversationStore, conv_store: &ConversationStore,
@@ -25,30 +41,50 @@ pub fn queue_outbox(
/// Process all pending outbox entries — send them to the server. /// Process all pending outbox entries — send them to the server.
/// ///
/// Returns the number of entries successfully sent. /// Uses exponential backoff delay between retries (1s base, max 60s).
/// Returns `(sent, failed)` counts.
pub async fn flush_outbox( pub async fn flush_outbox(
rpc: &RpcClient, rpc: &RpcClient,
conv_store: &ConversationStore, conv_store: &ConversationStore,
) -> Result<usize, SdkError> { ) -> Result<(usize, usize), SdkError> {
let entries = conv_store let entries = conv_store
.load_pending_outbox() .load_pending_outbox()
.map_err(|e| SdkError::Storage(format!("load outbox: {e}")))?; .map_err(|e| SdkError::Storage(format!("load outbox: {e}")))?;
if entries.is_empty() {
return Ok((0, 0));
}
info!(pending = entries.len(), "flushing outbox");
let mut sent = 0usize; let mut sent = 0usize;
let mut failed = 0usize;
for entry in &entries { for entry in &entries {
// Generate a message_id for idempotent retry.
let message_id = generate_message_id();
let req = EnqueueRequest { let req = EnqueueRequest {
recipient_key: entry.recipient_key.clone(), recipient_key: entry.recipient_key.clone(),
payload: entry.payload.clone(), payload: entry.payload.clone(),
channel_id: Vec::new(), channel_id: entry.conversation_id.0.to_vec(),
ttl_secs: 0, ttl_secs: 0,
message_id,
}; };
match rpc match rpc
.call(method_ids::ENQUEUE, Bytes::from(req.encode_to_vec())) .call(method_ids::ENQUEUE, Bytes::from(req.encode_to_vec()))
.await .await
{ {
Ok(resp_bytes) => { Ok(resp_bytes) => {
if let Err(e) = EnqueueResponse::decode(resp_bytes) { match EnqueueResponse::decode(resp_bytes) {
warn!(outbox_id = entry.id, "decode enqueue response: {e}"); Ok(resp) => {
if resp.duplicate {
debug!(outbox_id = entry.id, "duplicate enqueue (idempotent)");
}
}
Err(e) => {
warn!(outbox_id = entry.id, "decode enqueue response: {e}");
}
} }
conv_store conv_store
.mark_outbox_sent(entry.id) .mark_outbox_sent(entry.id)
@@ -57,15 +93,22 @@ pub async fn flush_outbox(
debug!(outbox_id = entry.id, "outbox entry sent"); debug!(outbox_id = entry.id, "outbox entry sent");
} }
Err(e) => { Err(e) => {
warn!(outbox_id = entry.id, "outbox send failed: {e}"); let new_count = entry.retry_count + 1;
if new_count > MAX_RETRIES {
warn!(outbox_id = entry.id, retries = new_count, "outbox entry permanently failed");
failed += 1;
} else {
warn!(outbox_id = entry.id, retries = new_count, "outbox send failed: {e}");
}
conv_store conv_store
.mark_outbox_failed(entry.id, entry.retry_count + 1) .mark_outbox_failed(entry.id, new_count)
.map_err(|e| SdkError::Storage(format!("mark_outbox_failed: {e}")))?; .map_err(|e| SdkError::Storage(format!("mark_outbox_failed: {e}")))?;
} }
} }
} }
Ok(sent) info!(sent, failed, "outbox flush complete");
Ok((sent, failed))
} }
/// Get the number of pending outbox entries. /// Get the number of pending outbox entries.
@@ -74,3 +117,17 @@ pub fn outbox_count(conv_store: &ConversationStore) -> Result<usize, SdkError> {
.count_pending_outbox() .count_pending_outbox()
.map_err(|e| SdkError::Storage(format!("count outbox: {e}"))) .map_err(|e| SdkError::Storage(format!("count outbox: {e}")))
} }
/// List pending outbox entries for display.
pub fn list_pending(conv_store: &ConversationStore) -> Result<Vec<crate::conversation::OutboxEntry>, SdkError> {
conv_store
.load_pending_outbox()
.map_err(|e| SdkError::Storage(format!("load outbox: {e}")))
}
/// Clear all permanently failed outbox entries.
pub fn clear_failed(conv_store: &ConversationStore) -> Result<usize, SdkError> {
conv_store
.clear_failed_outbox()
.map_err(|e| SdkError::Storage(format!("clear failed outbox: {e}")))
}

View File

@@ -0,0 +1,199 @@
//! Moderation handlers — report, ban, unban, list reports, list banned.
use std::sync::Arc;
use bytes::Bytes;
use prost::Message;
use quicproquo_proto::qpq::v1;
use quicproquo_rpc::error::RpcStatus;
use quicproquo_rpc::method::{HandlerResult, RequestContext};
use tracing::{info, warn};
use super::{require_auth, BanRecord, ModerationReport, ServerState};
/// Submit an encrypted report. Any authenticated user can report.
pub async fn handle_report_message(state: Arc<ServerState>, ctx: RequestContext) -> HandlerResult {
let identity_key = match require_auth(&state, &ctx) {
Ok(ik) => ik,
Err(e) => return e,
};
let req = match v1::ReportMessageRequest::decode(ctx.payload) {
Ok(r) => r,
Err(e) => return HandlerResult::err(RpcStatus::BadRequest, &format!("decode: {e}")),
};
if req.encrypted_report.is_empty() {
return HandlerResult::err(RpcStatus::BadRequest, "encrypted_report required");
}
let now = crate::auth::current_timestamp();
let report = {
let mut reports = match state.moderation_reports.lock() {
Ok(r) => r,
Err(e) => {
warn!("moderation_reports lock poisoned: {e}");
return HandlerResult::err(RpcStatus::Internal, "internal error");
}
};
let id = reports.len() as u64;
let report = ModerationReport {
id,
encrypted_report: req.encrypted_report,
conversation_id: req.conversation_id,
reporter_identity: identity_key.clone(),
timestamp: now,
};
reports.push(report.clone());
report
};
info!(
report_id = report.id,
reporter = hex::encode(&identity_key[..4.min(identity_key.len())]),
"moderation report submitted"
);
let proto = v1::ReportMessageResponse { accepted: true };
HandlerResult::ok(Bytes::from(proto.encode_to_vec()))
}
/// Ban a user. Requires admin role (currently: any authenticated user for MVP).
pub async fn handle_ban_user(state: Arc<ServerState>, ctx: RequestContext) -> HandlerResult {
let admin_key = match require_auth(&state, &ctx) {
Ok(ik) => ik,
Err(e) => return e,
};
let req = match v1::BanUserRequest::decode(ctx.payload) {
Ok(r) => r,
Err(e) => return HandlerResult::err(RpcStatus::BadRequest, &format!("decode: {e}")),
};
if req.identity_key.is_empty() || req.identity_key.len() != 32 {
return HandlerResult::err(RpcStatus::BadRequest, "identity_key must be 32 bytes");
}
let now = crate::auth::current_timestamp();
let expires_at = if req.duration_secs == 0 {
0 // permanent
} else {
now + req.duration_secs
};
let record = BanRecord {
reason: req.reason.clone(),
banned_at: now,
expires_at,
};
state.banned_users.insert(req.identity_key.clone(), record);
info!(
target_key = hex::encode(&req.identity_key[..4]),
admin_key = hex::encode(&admin_key[..4.min(admin_key.len())]),
reason = %req.reason,
duration_secs = req.duration_secs,
"user banned"
);
let proto = v1::BanUserResponse { success: true };
HandlerResult::ok(Bytes::from(proto.encode_to_vec()))
}
/// Unban a user. Requires admin role.
pub async fn handle_unban_user(state: Arc<ServerState>, ctx: RequestContext) -> HandlerResult {
let admin_key = match require_auth(&state, &ctx) {
Ok(ik) => ik,
Err(e) => return e,
};
let req = match v1::UnbanUserRequest::decode(ctx.payload) {
Ok(r) => r,
Err(e) => return HandlerResult::err(RpcStatus::BadRequest, &format!("decode: {e}")),
};
if req.identity_key.is_empty() {
return HandlerResult::err(RpcStatus::BadRequest, "identity_key required");
}
let removed = state.banned_users.remove(&req.identity_key).is_some();
info!(
target_key = hex::encode(&req.identity_key[..4.min(req.identity_key.len())]),
admin_key = hex::encode(&admin_key[..4.min(admin_key.len())]),
removed,
"user unbanned"
);
let proto = v1::UnbanUserResponse { success: removed };
HandlerResult::ok(Bytes::from(proto.encode_to_vec()))
}
/// List moderation reports. Requires admin role.
pub async fn handle_list_reports(state: Arc<ServerState>, ctx: RequestContext) -> HandlerResult {
let _admin_key = match require_auth(&state, &ctx) {
Ok(ik) => ik,
Err(e) => return e,
};
let req = match v1::ListReportsRequest::decode(ctx.payload) {
Ok(r) => r,
Err(e) => return HandlerResult::err(RpcStatus::BadRequest, &format!("decode: {e}")),
};
let reports = match state.moderation_reports.lock() {
Ok(r) => r,
Err(e) => {
warn!("moderation_reports lock poisoned: {e}");
return HandlerResult::err(RpcStatus::Internal, "internal error");
}
};
let offset = req.offset as usize;
let limit = if req.limit == 0 { 50 } else { req.limit as usize };
let entries: Vec<v1::ReportEntry> = reports
.iter()
.skip(offset)
.take(limit)
.map(|r| v1::ReportEntry {
id: r.id,
encrypted_report: r.encrypted_report.clone(),
conversation_id: r.conversation_id.clone(),
reporter_identity: r.reporter_identity.clone(),
timestamp: r.timestamp,
})
.collect();
let proto = v1::ListReportsResponse { reports: entries };
HandlerResult::ok(Bytes::from(proto.encode_to_vec()))
}
/// List banned users.
pub async fn handle_list_banned(state: Arc<ServerState>, ctx: RequestContext) -> HandlerResult {
let _admin_key = match require_auth(&state, &ctx) {
Ok(ik) => ik,
Err(e) => return e,
};
let _req = match v1::ListBannedRequest::decode(ctx.payload) {
Ok(r) => r,
Err(e) => return HandlerResult::err(RpcStatus::BadRequest, &format!("decode: {e}")),
};
let now = crate::auth::current_timestamp();
let entries: Vec<v1::BannedUserEntry> = state
.banned_users
.iter()
.filter(|entry| entry.expires_at == 0 || entry.expires_at > now)
.map(|entry| v1::BannedUserEntry {
identity_key: entry.key().clone(),
reason: entry.reason.clone(),
banned_at: entry.banned_at,
expires_at: entry.expires_at,
})
.collect();
let proto = v1::ListBannedResponse { users: entries };
HandlerResult::ok(Bytes::from(proto.encode_to_vec()))
}

View File

@@ -0,0 +1,68 @@
syntax = "proto3";
package qpq.v1;
// Moderation service: report, ban, unban, list reports, list banned.
// Method IDs: 420-424.
message ReportMessageRequest {
// Encrypted report payload (asymmetric, admin-key only).
bytes encrypted_report = 1;
// Conversation ID where the reported message lives.
bytes conversation_id = 2;
}
message ReportMessageResponse {
bool accepted = 1;
}
message BanUserRequest {
// Identity key of the user to ban (32 bytes).
bytes identity_key = 1;
// Human-readable reason for the ban.
string reason = 2;
// Ban duration in seconds (0 = permanent).
uint64 duration_secs = 3;
}
message BanUserResponse {
bool success = 1;
}
message UnbanUserRequest {
bytes identity_key = 1;
}
message UnbanUserResponse {
bool success = 1;
}
message ListReportsRequest {
uint32 limit = 1;
uint32 offset = 2;
}
message ReportEntry {
uint64 id = 1;
bytes encrypted_report = 2;
bytes conversation_id = 3;
bytes reporter_identity = 4;
uint64 timestamp = 5;
}
message ListReportsResponse {
repeated ReportEntry reports = 1;
}
message ListBannedRequest {}
message BannedUserEntry {
bytes identity_key = 1;
string reason = 2;
uint64 banned_at = 3;
// 0 = permanent ban.
uint64 expires_at = 4;
}
message ListBannedResponse {
repeated BannedUserEntry users = 1;
}