chore: public-readiness cleanup
- Remove default Grafana password (fail loudly if unset) - Clean up stale delivery-proof TODO (already implemented at RPC layer) - Document TUI send as local-only, point to REPL for E2E delivery - Gitignore AI workflow files (CLAUDE.md, master-prompt.md, ai_team.py) - Remove 5 orphaned v1 crates (bot, ffi, gen, gui, mobile) - Commit ROADMAP.html updates
This commit is contained in:
@@ -16,5 +16,5 @@ QPQ_SEALED_SENDER=false
|
|||||||
QPQ_REDACT_LOGS=true
|
QPQ_REDACT_LOGS=true
|
||||||
QPQ_WS_LISTEN=
|
QPQ_WS_LISTEN=
|
||||||
|
|
||||||
# Grafana admin password
|
# Grafana admin password (required — must be strong, no default)
|
||||||
GRAFANA_ADMIN_PASSWORD=changeme
|
GRAFANA_ADMIN_PASSWORD=
|
||||||
|
|||||||
5
.gitignore
vendored
5
.gitignore
vendored
@@ -20,3 +20,8 @@ qpq-server.toml
|
|||||||
|
|
||||||
# Internal planning docs (not for public distribution)
|
# Internal planning docs (not for public distribution)
|
||||||
docs/internal/
|
docs/internal/
|
||||||
|
|
||||||
|
# AI development workflow files
|
||||||
|
CLAUDE.md
|
||||||
|
master-prompt.md
|
||||||
|
scripts/ai_team.py
|
||||||
|
|||||||
23
CLAUDE.md
23
CLAUDE.md
@@ -1,23 +0,0 @@
|
|||||||
# quicproquo — Claude Code Instructions
|
|
||||||
|
|
||||||
## Agent Team Workflow Rules
|
|
||||||
|
|
||||||
### NEVER delete worktrees before preserving changes
|
|
||||||
When using agent teams with `isolation: "worktree"`:
|
|
||||||
1. **Before calling `TeamDelete`**, always check each worktree for uncommitted or committed changes
|
|
||||||
2. **Create a named branch** from each worktree's HEAD and push/preserve it before cleanup
|
|
||||||
3. **Preferred pattern**: use `git branch fix/<name> <worktree-HEAD-sha>` to save the work
|
|
||||||
4. If an agent reports changes, its worktree branch MUST be merged or saved before the team is deleted
|
|
||||||
|
|
||||||
### Agent team best practices
|
|
||||||
- Always have agents **commit their changes** with descriptive messages before shutting them down
|
|
||||||
- After all agents report, **list worktrees** (`git worktree list`) and **save branches** before cleanup
|
|
||||||
- When using worktree isolation, the sequence must be: agents finish → save branches → merge → TeamDelete
|
|
||||||
- Never call TeamDelete as a shortcut to kill zombie agents — use `rm -rf ~/.claude/teams/<name>` for the team metadata only, preserving worktree dirs
|
|
||||||
|
|
||||||
### Git workflow
|
|
||||||
- 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
|
|
||||||
88
ROADMAP.html
88
ROADMAP.html
@@ -3,7 +3,7 @@
|
|||||||
<head>
|
<head>
|
||||||
<!-- Book generated using mdBook -->
|
<!-- Book generated using mdBook -->
|
||||||
<meta charset="UTF-8">
|
<meta charset="UTF-8">
|
||||||
<title>Full Roadmap (Phases 1–8) - quicproquo</title>
|
<title>Full Roadmap (Phases 1-8) - quicproquo</title>
|
||||||
|
|
||||||
|
|
||||||
<!-- Custom HTML head -->
|
<!-- Custom HTML head -->
|
||||||
@@ -35,10 +35,10 @@
|
|||||||
const path_to_root = "";
|
const path_to_root = "";
|
||||||
const default_light_theme = "navy";
|
const default_light_theme = "navy";
|
||||||
const default_dark_theme = "navy";
|
const default_dark_theme = "navy";
|
||||||
window.path_to_searchindex_js = "searchindex-92ce38c7.js";
|
window.path_to_searchindex_js = "searchindex-1e4ee6e2.js";
|
||||||
</script>
|
</script>
|
||||||
<!-- Start loading toc.js asap -->
|
<!-- Start loading toc.js asap -->
|
||||||
<script src="toc-4c7c920d.js"></script>
|
<script src="toc-69b0eb95.js"></script>
|
||||||
</head>
|
</head>
|
||||||
<body>
|
<body>
|
||||||
<div id="mdbook-help-container">
|
<div id="mdbook-help-container">
|
||||||
@@ -185,7 +185,7 @@ can be parallelised. Check the box when done.</p>
|
|||||||
<p>Eliminate all crash paths, enforce secure defaults, fix deployment blockers.</p>
|
<p>Eliminate all crash paths, enforce secure defaults, fix deployment blockers.</p>
|
||||||
<ul>
|
<ul>
|
||||||
<li>
|
<li>
|
||||||
<p><input disabled="" type="checkbox"> <strong>1.1 Remove <code>.unwrap()</code> / <code>.expect()</code> from production paths</strong></p>
|
<p><input disabled="" type="checkbox" checked=""> <strong>1.1 Remove <code>.unwrap()</code> / <code>.expect()</code> from production paths</strong></p>
|
||||||
<ul>
|
<ul>
|
||||||
<li>Replace <code>AUTH_CONTEXT.read().expect()</code> in client RPC with proper <code>Result</code></li>
|
<li>Replace <code>AUTH_CONTEXT.read().expect()</code> in client RPC with proper <code>Result</code></li>
|
||||||
<li>Replace <code>"0.0.0.0:0".parse().unwrap()</code> in client with fallible parse</li>
|
<li>Replace <code>"0.0.0.0:0".parse().unwrap()</code> in client with fallible parse</li>
|
||||||
@@ -194,7 +194,7 @@ can be parallelised. Check the box when done.</p>
|
|||||||
</ul>
|
</ul>
|
||||||
</li>
|
</li>
|
||||||
<li>
|
<li>
|
||||||
<p><input disabled="" type="checkbox"> <strong>1.2 Enforce secure defaults in production mode</strong></p>
|
<p><input disabled="" type="checkbox" checked=""> <strong>1.2 Enforce secure defaults in production mode</strong></p>
|
||||||
<ul>
|
<ul>
|
||||||
<li>Reject startup if <code>QPQ_PRODUCTION=true</code> and <code>auth_token</code> is empty or <code>"devtoken"</code></li>
|
<li>Reject startup if <code>QPQ_PRODUCTION=true</code> and <code>auth_token</code> is empty or <code>"devtoken"</code></li>
|
||||||
<li>Require non-empty <code>db_key</code> when using SQL backend in production</li>
|
<li>Require non-empty <code>db_key</code> when using SQL backend in production</li>
|
||||||
@@ -203,14 +203,14 @@ can be parallelised. Check the box when done.</p>
|
|||||||
</ul>
|
</ul>
|
||||||
</li>
|
</li>
|
||||||
<li>
|
<li>
|
||||||
<p><input disabled="" type="checkbox"> <strong>1.3 Fix <code>.gitignore</code></strong></p>
|
<p><input disabled="" type="checkbox" checked=""> <strong>1.3 Fix <code>.gitignore</code></strong></p>
|
||||||
<ul>
|
<ul>
|
||||||
<li>Add <code>data/</code>, <code>*.der</code>, <code>*.pem</code>, <code>*.db</code>, <code>*.bin</code> (state files), <code>*.ks</code> (keystores)</li>
|
<li>Add <code>data/</code>, <code>*.der</code>, <code>*.pem</code>, <code>*.db</code>, <code>*.bin</code> (state files), <code>*.ks</code> (keystores)</li>
|
||||||
<li>Verify no secrets are already tracked: <code>git ls-files data/ *.der *.db</code></li>
|
<li>Verify no secrets are already tracked: <code>git ls-files data/ *.der *.db</code></li>
|
||||||
</ul>
|
</ul>
|
||||||
</li>
|
</li>
|
||||||
<li>
|
<li>
|
||||||
<p><input disabled="" type="checkbox"> <strong>1.4 Fix Dockerfile</strong></p>
|
<p><input disabled="" type="checkbox" checked=""> <strong>1.4 Fix Dockerfile</strong></p>
|
||||||
<ul>
|
<ul>
|
||||||
<li>Sync workspace members (handle excluded <code>p2p</code> crate)</li>
|
<li>Sync workspace members (handle excluded <code>p2p</code> crate)</li>
|
||||||
<li>Create dedicated user/group instead of <code>nobody</code></li>
|
<li>Create dedicated user/group instead of <code>nobody</code></li>
|
||||||
@@ -219,7 +219,7 @@ can be parallelised. Check the box when done.</p>
|
|||||||
</ul>
|
</ul>
|
||||||
</li>
|
</li>
|
||||||
<li>
|
<li>
|
||||||
<p><input disabled="" type="checkbox"> <strong>1.5 TLS certificate lifecycle</strong></p>
|
<p><input disabled="" type="checkbox" checked=""> <strong>1.5 TLS certificate lifecycle</strong></p>
|
||||||
<ul>
|
<ul>
|
||||||
<li>Document CA-signed cert setup (Let’s Encrypt / custom CA)</li>
|
<li>Document CA-signed cert setup (Let’s Encrypt / custom CA)</li>
|
||||||
<li>Add <code>--tls-required</code> flag that refuses to start without valid cert</li>
|
<li>Add <code>--tls-required</code> flag that refuses to start without valid cert</li>
|
||||||
@@ -233,7 +233,7 @@ can be parallelised. Check the box when done.</p>
|
|||||||
<p>Build confidence before adding features.</p>
|
<p>Build confidence before adding features.</p>
|
||||||
<ul>
|
<ul>
|
||||||
<li>
|
<li>
|
||||||
<p><input disabled="" type="checkbox"> <strong>2.1 Expand E2E test coverage</strong></p>
|
<p><input disabled="" type="checkbox" checked=""> <strong>2.1 Expand E2E test coverage</strong></p>
|
||||||
<ul>
|
<ul>
|
||||||
<li>Auth failure scenarios (wrong password, expired token, invalid token)</li>
|
<li>Auth failure scenarios (wrong password, expired token, invalid token)</li>
|
||||||
<li>Message ordering verification (send N messages, verify seq numbers)</li>
|
<li>Message ordering verification (send N messages, verify seq numbers)</li>
|
||||||
@@ -246,7 +246,7 @@ can be parallelised. Check the box when done.</p>
|
|||||||
</ul>
|
</ul>
|
||||||
</li>
|
</li>
|
||||||
<li>
|
<li>
|
||||||
<p><input disabled="" type="checkbox"> <strong>2.2 Add unit tests for untested paths</strong></p>
|
<p><input disabled="" type="checkbox" checked=""> <strong>2.2 Add unit tests for untested paths</strong></p>
|
||||||
<ul>
|
<ul>
|
||||||
<li>Client retry logic (exponential backoff, jitter, retriable classification)</li>
|
<li>Client retry logic (exponential backoff, jitter, retriable classification)</li>
|
||||||
<li>REPL input parsing edge cases (empty input, special characters, <code>/</code> commands)</li>
|
<li>REPL input parsing edge cases (empty input, special characters, <code>/</code> commands)</li>
|
||||||
@@ -256,7 +256,7 @@ can be parallelised. Check the box when done.</p>
|
|||||||
</ul>
|
</ul>
|
||||||
</li>
|
</li>
|
||||||
<li>
|
<li>
|
||||||
<p><input disabled="" type="checkbox"> <strong>2.3 CI hardening</strong></p>
|
<p><input disabled="" type="checkbox" checked=""> <strong>2.3 CI hardening</strong></p>
|
||||||
<ul>
|
<ul>
|
||||||
<li>Add <code>.github/CODEOWNERS</code> (crypto, auth, wire-format require 2 reviewers)</li>
|
<li>Add <code>.github/CODEOWNERS</code> (crypto, auth, wire-format require 2 reviewers)</li>
|
||||||
<li>Ensure <code>cargo deny check</code> runs on every PR (already in CI — verify)</li>
|
<li>Ensure <code>cargo deny check</code> runs on every PR (already in CI — verify)</li>
|
||||||
@@ -266,7 +266,7 @@ can be parallelised. Check the box when done.</p>
|
|||||||
</ul>
|
</ul>
|
||||||
</li>
|
</li>
|
||||||
<li>
|
<li>
|
||||||
<p><input disabled="" type="checkbox"> <strong>2.4 Clean up build warnings</strong></p>
|
<p><input disabled="" type="checkbox" checked=""> <strong>2.4 Clean up build warnings</strong></p>
|
||||||
<ul>
|
<ul>
|
||||||
<li>Fix Cap’n Proto generated <code>unused_parens</code> warnings</li>
|
<li>Fix Cap’n Proto generated <code>unused_parens</code> warnings</li>
|
||||||
<li>Remove dead code / unused imports</li>
|
<li>Remove dead code / unused imports</li>
|
||||||
@@ -328,7 +328,7 @@ WASM/FFI for the crypto layer.</p>
|
|||||||
</ul>
|
</ul>
|
||||||
</li>
|
</li>
|
||||||
<li>
|
<li>
|
||||||
<p><input disabled="" type="checkbox"> <strong>3.2 Python SDK (<code>quicproquo-py</code>)</strong></p>
|
<p><input disabled="" type="checkbox" checked=""> <strong>3.2 Python SDK (<code>quicproquo-py</code>)</strong></p>
|
||||||
<ul>
|
<ul>
|
||||||
<li>QUIC transport: <code>aioquic</code> with custom Cap’n Proto stream handler</li>
|
<li>QUIC transport: <code>aioquic</code> with custom Cap’n Proto stream handler</li>
|
||||||
<li>Cap’n Proto serialization: <code>pycapnp</code> for message types</li>
|
<li>Cap’n Proto serialization: <code>pycapnp</code> for message types</li>
|
||||||
@@ -357,7 +357,7 @@ WASM/FFI for the crypto layer.</p>
|
|||||||
</ul>
|
</ul>
|
||||||
</li>
|
</li>
|
||||||
<li>
|
<li>
|
||||||
<p><input disabled="" type="checkbox"> <strong>3.5 WebTransport server endpoint</strong></p>
|
<p><input disabled="" type="checkbox" checked=""> <strong>3.5 WebTransport server endpoint</strong></p>
|
||||||
<ul>
|
<ul>
|
||||||
<li>Add HTTP/3 + WebTransport listener to server (same QUIC stack via quinn)</li>
|
<li>Add HTTP/3 + WebTransport listener to server (same QUIC stack via quinn)</li>
|
||||||
<li>Cap’n Proto RPC framed over WebTransport bidirectional streams</li>
|
<li>Cap’n Proto RPC framed over WebTransport bidirectional streams</li>
|
||||||
@@ -378,7 +378,7 @@ WASM/FFI for the crypto layer.</p>
|
|||||||
</ul>
|
</ul>
|
||||||
</li>
|
</li>
|
||||||
<li>
|
<li>
|
||||||
<p><input disabled="" type="checkbox"> <strong>3.7 SDK documentation and schema publishing</strong></p>
|
<p><input disabled="" type="checkbox" checked=""> <strong>3.7 SDK documentation and schema publishing</strong></p>
|
||||||
<ul>
|
<ul>
|
||||||
<li>Publish <code>.capnp</code> schemas as the canonical API contract</li>
|
<li>Publish <code>.capnp</code> schemas as the canonical API contract</li>
|
||||||
<li>Document the QUIC + Cap’n Proto connection pattern for each language</li>
|
<li>Document the QUIC + Cap’n Proto connection pattern for each language</li>
|
||||||
@@ -401,7 +401,7 @@ WASM/FFI for the crypto layer.</p>
|
|||||||
</ul>
|
</ul>
|
||||||
</li>
|
</li>
|
||||||
<li>
|
<li>
|
||||||
<p><input disabled="" type="checkbox"> <strong>4.2 Key Transparency / revocation</strong></p>
|
<p><input disabled="" type="checkbox" checked=""> <strong>4.2 Key Transparency / revocation</strong></p>
|
||||||
<ul>
|
<ul>
|
||||||
<li>Replace <code>BasicCredential</code> with X.509-based MLS credentials</li>
|
<li>Replace <code>BasicCredential</code> with X.509-based MLS credentials</li>
|
||||||
<li>Or: verifiable key directory (Merkle tree, auditable log)</li>
|
<li>Or: verifiable key directory (Merkle tree, auditable log)</li>
|
||||||
@@ -418,7 +418,7 @@ WASM/FFI for the crypto layer.</p>
|
|||||||
</ul>
|
</ul>
|
||||||
</li>
|
</li>
|
||||||
<li>
|
<li>
|
||||||
<p><input disabled="" type="checkbox"> <strong>4.4 M7 — Post-quantum MLS integration</strong></p>
|
<p><input disabled="" type="checkbox" checked=""> <strong>4.4 M7 — Post-quantum MLS integration</strong></p>
|
||||||
<ul>
|
<ul>
|
||||||
<li>Integrate hybrid KEM (X25519 + ML-KEM-768) into the OpenMLS crypto provider</li>
|
<li>Integrate hybrid KEM (X25519 + ML-KEM-768) into the OpenMLS crypto provider</li>
|
||||||
<li>Group key material gets post-quantum confidentiality</li>
|
<li>Group key material gets post-quantum confidentiality</li>
|
||||||
@@ -439,7 +439,7 @@ WASM/FFI for the crypto layer.</p>
|
|||||||
<p>Make it a product people want to use.</p>
|
<p>Make it a product people want to use.</p>
|
||||||
<ul>
|
<ul>
|
||||||
<li>
|
<li>
|
||||||
<p><input disabled="" type="checkbox"> <strong>5.1 Multi-device support</strong></p>
|
<p><input disabled="" type="checkbox" checked=""> <strong>5.1 Multi-device support</strong></p>
|
||||||
<ul>
|
<ul>
|
||||||
<li>Account → multiple devices, each with own Ed25519 key + MLS KeyPackages</li>
|
<li>Account → multiple devices, each with own Ed25519 key + MLS KeyPackages</li>
|
||||||
<li>Device graph management (add device, remove device, list devices)</li>
|
<li>Device graph management (add device, remove device, list devices)</li>
|
||||||
@@ -448,7 +448,7 @@ WASM/FFI for the crypto layer.</p>
|
|||||||
</ul>
|
</ul>
|
||||||
</li>
|
</li>
|
||||||
<li>
|
<li>
|
||||||
<p><input disabled="" type="checkbox"> <strong>5.2 Account recovery</strong></p>
|
<p><input disabled="" type="checkbox" checked=""> <strong>5.2 Account recovery</strong></p>
|
||||||
<ul>
|
<ul>
|
||||||
<li>Recovery codes or backup key (encrypted, stored by user)</li>
|
<li>Recovery codes or backup key (encrypted, stored by user)</li>
|
||||||
<li>Option: server-assisted recovery with security questions (lower security)</li>
|
<li>Option: server-assisted recovery with security questions (lower security)</li>
|
||||||
@@ -456,7 +456,7 @@ WASM/FFI for the crypto layer.</p>
|
|||||||
</ul>
|
</ul>
|
||||||
</li>
|
</li>
|
||||||
<li>
|
<li>
|
||||||
<p><input disabled="" type="checkbox"> <strong>5.3 Full MLS lifecycle</strong></p>
|
<p><input disabled="" type="checkbox" checked=""> <strong>5.3 Full MLS lifecycle</strong></p>
|
||||||
<ul>
|
<ul>
|
||||||
<li>Member removal (Remove proposal → Commit → fan-out)</li>
|
<li>Member removal (Remove proposal → Commit → fan-out)</li>
|
||||||
<li>Credential update (Update proposal for key rotation)</li>
|
<li>Credential update (Update proposal for key rotation)</li>
|
||||||
@@ -483,7 +483,7 @@ WASM/FFI for the crypto layer.</p>
|
|||||||
</ul>
|
</ul>
|
||||||
</li>
|
</li>
|
||||||
<li>
|
<li>
|
||||||
<p><input disabled="" type="checkbox"> <strong>5.6 Abuse prevention and moderation</strong></p>
|
<p><input disabled="" type="checkbox" checked=""> <strong>5.6 Abuse prevention and moderation</strong></p>
|
||||||
<ul>
|
<ul>
|
||||||
<li>Block user (client-side, suppress display)</li>
|
<li>Block user (client-side, suppress display)</li>
|
||||||
<li>Report message (encrypted report to admin key)</li>
|
<li>Report message (encrypted report to admin key)</li>
|
||||||
@@ -491,7 +491,7 @@ WASM/FFI for the crypto layer.</p>
|
|||||||
</ul>
|
</ul>
|
||||||
</li>
|
</li>
|
||||||
<li>
|
<li>
|
||||||
<p><input disabled="" type="checkbox"> <strong>5.7 Offline message queue (client-side)</strong></p>
|
<p><input disabled="" type="checkbox" checked=""> <strong>5.7 Offline message queue (client-side)</strong></p>
|
||||||
<ul>
|
<ul>
|
||||||
<li>Queue messages when disconnected, send on reconnect</li>
|
<li>Queue messages when disconnected, send on reconnect</li>
|
||||||
<li>Idempotent message IDs to prevent duplicates</li>
|
<li>Idempotent message IDs to prevent duplicates</li>
|
||||||
@@ -504,7 +504,7 @@ WASM/FFI for the crypto layer.</p>
|
|||||||
<p>Prepare for real traffic.</p>
|
<p>Prepare for real traffic.</p>
|
||||||
<ul>
|
<ul>
|
||||||
<li>
|
<li>
|
||||||
<p><input disabled="" type="checkbox"> <strong>6.1 Distributed rate limiting</strong></p>
|
<p><input disabled="" type="checkbox" checked=""> <strong>6.1 Distributed rate limiting</strong></p>
|
||||||
<ul>
|
<ul>
|
||||||
<li>Current: in-memory per-process, lost on restart</li>
|
<li>Current: in-memory per-process, lost on restart</li>
|
||||||
<li>Move to Redis or shared state for multi-node deployments</li>
|
<li>Move to Redis or shared state for multi-node deployments</li>
|
||||||
@@ -512,7 +512,7 @@ WASM/FFI for the crypto layer.</p>
|
|||||||
</ul>
|
</ul>
|
||||||
</li>
|
</li>
|
||||||
<li>
|
<li>
|
||||||
<p><input disabled="" type="checkbox"> <strong>6.2 Multi-node / horizontal scaling</strong></p>
|
<p><input disabled="" type="checkbox" checked=""> <strong>6.2 Multi-node / horizontal scaling</strong></p>
|
||||||
<ul>
|
<ul>
|
||||||
<li>Stateless server design (already mostly there — state is in storage backend)</li>
|
<li>Stateless server design (already mostly there — state is in storage backend)</li>
|
||||||
<li>Shared PostgreSQL or CockroachDB backend (replace SQLite)</li>
|
<li>Shared PostgreSQL or CockroachDB backend (replace SQLite)</li>
|
||||||
@@ -521,7 +521,7 @@ WASM/FFI for the crypto layer.</p>
|
|||||||
</ul>
|
</ul>
|
||||||
</li>
|
</li>
|
||||||
<li>
|
<li>
|
||||||
<p><input disabled="" type="checkbox"> <strong>6.3 Operational runbook</strong></p>
|
<p><input disabled="" type="checkbox" checked=""> <strong>6.3 Operational runbook</strong></p>
|
||||||
<ul>
|
<ul>
|
||||||
<li>Backup / restore procedures (SQLCipher, file backend)</li>
|
<li>Backup / restore procedures (SQLCipher, file backend)</li>
|
||||||
<li>Key rotation (auth token, TLS cert, DB encryption key)</li>
|
<li>Key rotation (auth token, TLS cert, DB encryption key)</li>
|
||||||
@@ -531,7 +531,7 @@ WASM/FFI for the crypto layer.</p>
|
|||||||
</ul>
|
</ul>
|
||||||
</li>
|
</li>
|
||||||
<li>
|
<li>
|
||||||
<p><input disabled="" type="checkbox"> <strong>6.4 Connection draining and graceful shutdown</strong></p>
|
<p><input disabled="" type="checkbox" checked=""> <strong>6.4 Connection draining and graceful shutdown</strong></p>
|
||||||
<ul>
|
<ul>
|
||||||
<li>Stop accepting new connections on SIGTERM</li>
|
<li>Stop accepting new connections on SIGTERM</li>
|
||||||
<li>Wait for in-flight RPCs (configurable timeout, default 30s)</li>
|
<li>Wait for in-flight RPCs (configurable timeout, default 30s)</li>
|
||||||
@@ -540,7 +540,7 @@ WASM/FFI for the crypto layer.</p>
|
|||||||
</ul>
|
</ul>
|
||||||
</li>
|
</li>
|
||||||
<li>
|
<li>
|
||||||
<p><input disabled="" type="checkbox"> <strong>6.5 Request-level timeouts</strong></p>
|
<p><input disabled="" type="checkbox" checked=""> <strong>6.5 Request-level timeouts</strong></p>
|
||||||
<ul>
|
<ul>
|
||||||
<li>Per-RPC timeout (prevent slow clients from holding resources)</li>
|
<li>Per-RPC timeout (prevent slow clients from holding resources)</li>
|
||||||
<li>Database query timeout</li>
|
<li>Database query timeout</li>
|
||||||
@@ -548,7 +548,7 @@ WASM/FFI for the crypto layer.</p>
|
|||||||
</ul>
|
</ul>
|
||||||
</li>
|
</li>
|
||||||
<li>
|
<li>
|
||||||
<p><input disabled="" type="checkbox"> <strong>6.6 Observability enhancements</strong></p>
|
<p><input disabled="" type="checkbox" checked=""> <strong>6.6 Observability enhancements</strong></p>
|
||||||
<ul>
|
<ul>
|
||||||
<li>Request correlation IDs (trace across RPC → storage)</li>
|
<li>Request correlation IDs (trace across RPC → storage)</li>
|
||||||
<li>Storage operation latency metrics</li>
|
<li>Storage operation latency metrics</li>
|
||||||
@@ -563,7 +563,7 @@ WASM/FFI for the crypto layer.</p>
|
|||||||
<p>Long-term vision for wide adoption.</p>
|
<p>Long-term vision for wide adoption.</p>
|
||||||
<ul>
|
<ul>
|
||||||
<li>
|
<li>
|
||||||
<p><input disabled="" type="checkbox"> <strong>7.1 Mobile clients (iOS + Android)</strong></p>
|
<p><input disabled="" type="checkbox" checked=""> <strong>7.1 Mobile clients (iOS + Android)</strong></p>
|
||||||
<ul>
|
<ul>
|
||||||
<li>Use C FFI (Phase 3.3) for crypto + transport (single library)</li>
|
<li>Use C FFI (Phase 3.3) for crypto + transport (single library)</li>
|
||||||
<li>Push notifications via APNs / FCM (server sends notification on enqueue)</li>
|
<li>Push notifications via APNs / FCM (server sends notification on enqueue)</li>
|
||||||
@@ -572,7 +572,7 @@ WASM/FFI for the crypto layer.</p>
|
|||||||
</ul>
|
</ul>
|
||||||
</li>
|
</li>
|
||||||
<li>
|
<li>
|
||||||
<p><input disabled="" type="checkbox"> <strong>7.2 Web client (browser)</strong></p>
|
<p><input disabled="" type="checkbox" checked=""> <strong>7.2 Web client (browser)</strong></p>
|
||||||
<ul>
|
<ul>
|
||||||
<li>Use WASM (Phase 3.4) for crypto</li>
|
<li>Use WASM (Phase 3.4) for crypto</li>
|
||||||
<li>Use WebTransport (Phase 3.5) for native QUIC transport</li>
|
<li>Use WebTransport (Phase 3.5) for native QUIC transport</li>
|
||||||
@@ -583,7 +583,7 @@ WASM/FFI for the crypto layer.</p>
|
|||||||
</ul>
|
</ul>
|
||||||
</li>
|
</li>
|
||||||
<li>
|
<li>
|
||||||
<p><input disabled="" type="checkbox"> <strong>7.3 Federation</strong></p>
|
<p><input disabled="" type="checkbox" checked=""> <strong>7.3 Federation</strong></p>
|
||||||
<ul>
|
<ul>
|
||||||
<li>Server-to-server protocol via Cap’n Proto RPC over QUIC (see <code>federation.capnp</code>)</li>
|
<li>Server-to-server protocol via Cap’n Proto RPC over QUIC (see <code>federation.capnp</code>)</li>
|
||||||
<li><code>relayEnqueue</code>, <code>proxyFetchKeyPackage</code>, <code>federationHealth</code> methods</li>
|
<li><code>relayEnqueue</code>, <code>proxyFetchKeyPackage</code>, <code>federationHealth</code> methods</li>
|
||||||
@@ -601,7 +601,7 @@ WASM/FFI for the crypto layer.</p>
|
|||||||
</ul>
|
</ul>
|
||||||
</li>
|
</li>
|
||||||
<li>
|
<li>
|
||||||
<p><input disabled="" type="checkbox"> <strong>7.5 Additional language SDKs</strong></p>
|
<p><input disabled="" type="checkbox" checked=""> <strong>7.5 Additional language SDKs</strong></p>
|
||||||
<ul>
|
<ul>
|
||||||
<li>Java/Kotlin: JNI bindings to C FFI (Phase 3.3) + native QUIC (netty-quic)</li>
|
<li>Java/Kotlin: JNI bindings to C FFI (Phase 3.3) + native QUIC (netty-quic)</li>
|
||||||
<li>Swift: Swift wrapper over C FFI + Network.framework QUIC</li>
|
<li>Swift: Swift wrapper over C FFI + Network.framework QUIC</li>
|
||||||
@@ -610,7 +610,7 @@ WASM/FFI for the crypto layer.</p>
|
|||||||
</ul>
|
</ul>
|
||||||
</li>
|
</li>
|
||||||
<li>
|
<li>
|
||||||
<p><input disabled="" type="checkbox"> <strong>7.6 P2P / NAT traversal</strong></p>
|
<p><input disabled="" type="checkbox" checked=""> <strong>7.6 P2P / NAT traversal</strong></p>
|
||||||
<ul>
|
<ul>
|
||||||
<li>Direct peer-to-peer via iroh (foundation exists in <code>quicproquo-p2p</code>)</li>
|
<li>Direct peer-to-peer via iroh (foundation exists in <code>quicproquo-p2p</code>)</li>
|
||||||
<li>Server as fallback relay only</li>
|
<li>Server as fallback relay only</li>
|
||||||
@@ -619,7 +619,7 @@ WASM/FFI for the crypto layer.</p>
|
|||||||
</ul>
|
</ul>
|
||||||
</li>
|
</li>
|
||||||
<li>
|
<li>
|
||||||
<p><input disabled="" type="checkbox"> <strong>7.7 Traffic analysis resistance</strong></p>
|
<p><input disabled="" type="checkbox" checked=""> <strong>7.7 Traffic analysis resistance</strong></p>
|
||||||
<ul>
|
<ul>
|
||||||
<li>Padding messages to uniform size</li>
|
<li>Padding messages to uniform size</li>
|
||||||
<li>Decoy traffic to mask timing patterns</li>
|
<li>Decoy traffic to mask timing patterns</li>
|
||||||
@@ -706,7 +706,7 @@ functions without any central infrastructure or internet uplink.</p>
|
|||||||
</ul>
|
</ul>
|
||||||
</li>
|
</li>
|
||||||
<li>
|
<li>
|
||||||
<p><input disabled="" type="checkbox"> <strong>F7 — OpenWrt cross-compilation guide</strong></p>
|
<p><input disabled="" type="checkbox" checked=""> <strong>F7 — OpenWrt cross-compilation guide</strong></p>
|
||||||
<ul>
|
<ul>
|
||||||
<li>Musl static builds: <code>x86_64-unknown-linux-musl</code>, <code>armv7-unknown-linux-musleabihf</code>, <code>mips-unknown-linux-musl</code></li>
|
<li>Musl static builds: <code>x86_64-unknown-linux-musl</code>, <code>armv7-unknown-linux-musleabihf</code>, <code>mips-unknown-linux-musl</code></li>
|
||||||
<li>Strip binary: <code>--release</code> + <code>strip</code> → target size < 5 MB for flash storage</li>
|
<li>Strip binary: <code>--release</code> + <code>strip</code> → target size < 5 MB for flash storage</li>
|
||||||
@@ -716,7 +716,7 @@ functions without any central infrastructure or internet uplink.</p>
|
|||||||
</ul>
|
</ul>
|
||||||
</li>
|
</li>
|
||||||
<li>
|
<li>
|
||||||
<p><input disabled="" type="checkbox"> <strong>F8 — Traffic analysis resistance for mesh</strong></p>
|
<p><input disabled="" type="checkbox" checked=""> <strong>F8 — Traffic analysis resistance for mesh</strong></p>
|
||||||
<ul>
|
<ul>
|
||||||
<li>Uniform message padding to nearest 256-byte boundary (hides message size)</li>
|
<li>Uniform message padding to nearest 256-byte boundary (hides message size)</li>
|
||||||
<li>Configurable decoy traffic rate (fake messages to mask send timing)</li>
|
<li>Configurable decoy traffic rate (fake messages to mask send timing)</li>
|
||||||
@@ -731,7 +731,7 @@ functions without any central infrastructure or internet uplink.</p>
|
|||||||
and lower the barrier to entry for non-crypto developers.</p>
|
and lower the barrier to entry for non-crypto developers.</p>
|
||||||
<ul>
|
<ul>
|
||||||
<li>
|
<li>
|
||||||
<p><input disabled="" type="checkbox"> <strong>9.1 Criterion Benchmark Suite (<code>qpq-bench</code>)</strong></p>
|
<p><input disabled="" type="checkbox" checked=""> <strong>9.1 Criterion Benchmark Suite (<code>qpq-bench</code>)</strong></p>
|
||||||
<ul>
|
<ul>
|
||||||
<li>Criterion benchmarks for all crypto primitives: hybrid KEM encap/decap,
|
<li>Criterion benchmarks for all crypto primitives: hybrid KEM encap/decap,
|
||||||
MLS group-add at 10/100/1000 members, epoch rotation, Noise_XX handshake</li>
|
MLS group-add at 10/100/1000 members, epoch rotation, Noise_XX handshake</li>
|
||||||
@@ -748,7 +748,7 @@ MLS group-add at 10/100/1000 members, epoch rotation, Noise_XX handshake</li>
|
|||||||
</ul>
|
</ul>
|
||||||
</li>
|
</li>
|
||||||
<li>
|
<li>
|
||||||
<p><input disabled="" type="checkbox"> <strong>9.3 Full-Screen TUI (Ratatui + Crossterm)</strong></p>
|
<p><input disabled="" type="checkbox" checked=""> <strong>9.3 Full-Screen TUI (Ratatui + Crossterm)</strong></p>
|
||||||
<ul>
|
<ul>
|
||||||
<li><code>qpq tui</code> launches a full-screen terminal UI: message pane, input bar,
|
<li><code>qpq tui</code> launches a full-screen terminal UI: message pane, input bar,
|
||||||
channel sidebar with unread counts, MLS epoch indicator</li>
|
channel sidebar with unread counts, MLS epoch indicator</li>
|
||||||
@@ -757,7 +757,7 @@ channel sidebar with unread counts, MLS epoch indicator</li>
|
|||||||
</ul>
|
</ul>
|
||||||
</li>
|
</li>
|
||||||
<li>
|
<li>
|
||||||
<p><input disabled="" type="checkbox"> <strong>9.4 Delivery Proof Canary Tokens</strong></p>
|
<p><input disabled="" type="checkbox" checked=""> <strong>9.4 Delivery Proof Canary Tokens</strong></p>
|
||||||
<ul>
|
<ul>
|
||||||
<li>Server signs <code>Ed25519(SHA-256(message_id || recipient || timestamp))</code> on enqueue</li>
|
<li>Server signs <code>Ed25519(SHA-256(message_id || recipient || timestamp))</code> on enqueue</li>
|
||||||
<li>Sender stores proof locally — cryptographic evidence the server queued the message</li>
|
<li>Sender stores proof locally — cryptographic evidence the server queued the message</li>
|
||||||
@@ -765,7 +765,7 @@ channel sidebar with unread counts, MLS epoch indicator</li>
|
|||||||
</ul>
|
</ul>
|
||||||
</li>
|
</li>
|
||||||
<li>
|
<li>
|
||||||
<p><input disabled="" type="checkbox"> <strong>9.5 Verifiable Transcript Archive</strong></p>
|
<p><input disabled="" type="checkbox" checked=""> <strong>9.5 Verifiable Transcript Archive</strong></p>
|
||||||
<ul>
|
<ul>
|
||||||
<li><code>GroupMember::export_transcript(path, password)</code> writes encrypted, tamper-evident
|
<li><code>GroupMember::export_transcript(path, password)</code> writes encrypted, tamper-evident
|
||||||
message archive (CBOR records, Argon2id + ChaCha20-Poly1305, Merkle chain)</li>
|
message archive (CBOR records, Argon2id + ChaCha20-Poly1305, Merkle chain)</li>
|
||||||
@@ -774,7 +774,7 @@ message archive (CBOR records, Argon2id + ChaCha20-Poly1305, Merkle chain)</li>
|
|||||||
</ul>
|
</ul>
|
||||||
</li>
|
</li>
|
||||||
<li>
|
<li>
|
||||||
<p><input disabled="" type="checkbox"> <strong>9.6 Key Transparency (Merkle-Log Identity Binding)</strong></p>
|
<p><input disabled="" type="checkbox" checked=""> <strong>9.6 Key Transparency (Merkle-Log Identity Binding)</strong></p>
|
||||||
<ul>
|
<ul>
|
||||||
<li>Append-only Merkle log of (username, identity_key) bindings in the AS</li>
|
<li>Append-only Merkle log of (username, identity_key) bindings in the AS</li>
|
||||||
<li>Clients receive inclusion proofs alongside key fetches</li>
|
<li>Clients receive inclusion proofs alongside key fetches</li>
|
||||||
@@ -792,7 +792,7 @@ message archive (CBOR records, Argon2id + ChaCha20-Poly1305, Merkle chain)</li>
|
|||||||
</ul>
|
</ul>
|
||||||
</li>
|
</li>
|
||||||
<li>
|
<li>
|
||||||
<p><input disabled="" type="checkbox"> <strong>9.8 PQ Noise Transport Layer</strong></p>
|
<p><input disabled="" type="checkbox" checked=""> <strong>9.8 PQ Noise Transport Layer</strong></p>
|
||||||
<ul>
|
<ul>
|
||||||
<li>Hybrid <code>Noise_XX + ML-KEM-768</code> handshake for post-quantum transport security</li>
|
<li>Hybrid <code>Noise_XX + ML-KEM-768</code> handshake for post-quantum transport security</li>
|
||||||
<li>Closes the harvest-now-decrypt-later gap on handshake metadata (ADR-006)</li>
|
<li>Closes the harvest-now-decrypt-later gap on handshake metadata (ADR-006)</li>
|
||||||
@@ -840,7 +840,7 @@ message archive (CBOR records, Argon2id + ChaCha20-Poly1305, Merkle chain)</li>
|
|||||||
<span class=fa-svg><svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 320 512"><!--! Font Awesome Free 6.2.0 by @fontawesome - https://fontawesome.com License - https://fontawesome.com/license/free (Icons: CC BY 4.0, Fonts: SIL OFL 1.1, Code: MIT License) Copyright 2022 Fonticons, Inc. --><path d="M41.4 233.4c-12.5 12.5-12.5 32.8 0 45.3l160 160c12.5 12.5 32.8 12.5 45.3 0s12.5-32.8 0-45.3L109.3 256 246.6 118.6c12.5-12.5 12.5-32.8 0-45.3s-32.8-12.5-45.3 0l-160 160z"/></svg></span>
|
<span class=fa-svg><svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 320 512"><!--! Font Awesome Free 6.2.0 by @fontawesome - https://fontawesome.com License - https://fontawesome.com/license/free (Icons: CC BY 4.0, Fonts: SIL OFL 1.1, Code: MIT License) Copyright 2022 Fonticons, Inc. --><path d="M41.4 233.4c-12.5 12.5-12.5 32.8 0 45.3l160 160c12.5 12.5 32.8 12.5 45.3 0s12.5-32.8 0-45.3L109.3 256 246.6 118.6c12.5-12.5 12.5-32.8 0-45.3s-32.8-12.5-45.3 0l-160 160z"/></svg></span>
|
||||||
</a>
|
</a>
|
||||||
|
|
||||||
<a rel="next prefetch" href="contributing/coding-standards.html" class="mobile-nav-chapters next" title="Next chapter" aria-label="Next chapter" aria-keyshortcuts="Right">
|
<a rel="next prefetch" href="operations/monitoring.html" class="mobile-nav-chapters next" title="Next chapter" aria-label="Next chapter" aria-keyshortcuts="Right">
|
||||||
<span class=fa-svg><svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 320 512"><!--! Font Awesome Free 6.2.0 by @fontawesome - https://fontawesome.com License - https://fontawesome.com/license/free (Icons: CC BY 4.0, Fonts: SIL OFL 1.1, Code: MIT License) Copyright 2022 Fonticons, Inc. --><path d="M278.6 233.4c12.5 12.5 12.5 32.8 0 45.3l-160 160c-12.5 12.5-32.8 12.5-45.3 0s-12.5-32.8 0-45.3L210.7 256 73.4 118.6c-12.5-12.5-12.5-32.8 0-45.3s32.8-12.5 45.3 0l160 160z"/></svg></span>
|
<span class=fa-svg><svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 320 512"><!--! Font Awesome Free 6.2.0 by @fontawesome - https://fontawesome.com License - https://fontawesome.com/license/free (Icons: CC BY 4.0, Fonts: SIL OFL 1.1, Code: MIT License) Copyright 2022 Fonticons, Inc. --><path d="M278.6 233.4c12.5 12.5 12.5 32.8 0 45.3l-160 160c-12.5 12.5-32.8 12.5-45.3 0s-12.5-32.8 0-45.3L210.7 256 73.4 118.6c-12.5-12.5-12.5-32.8 0-45.3s32.8-12.5 45.3 0l160 160z"/></svg></span>
|
||||||
</a>
|
</a>
|
||||||
|
|
||||||
@@ -854,7 +854,7 @@ message archive (CBOR records, Argon2id + ChaCha20-Poly1305, Merkle chain)</li>
|
|||||||
<span class=fa-svg><svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 320 512"><!--! Font Awesome Free 6.2.0 by @fontawesome - https://fontawesome.com License - https://fontawesome.com/license/free (Icons: CC BY 4.0, Fonts: SIL OFL 1.1, Code: MIT License) Copyright 2022 Fonticons, Inc. --><path d="M41.4 233.4c-12.5 12.5-12.5 32.8 0 45.3l160 160c12.5 12.5 32.8 12.5 45.3 0s12.5-32.8 0-45.3L109.3 256 246.6 118.6c12.5-12.5 12.5-32.8 0-45.3s-32.8-12.5-45.3 0l-160 160z"/></svg></span>
|
<span class=fa-svg><svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 320 512"><!--! Font Awesome Free 6.2.0 by @fontawesome - https://fontawesome.com License - https://fontawesome.com/license/free (Icons: CC BY 4.0, Fonts: SIL OFL 1.1, Code: MIT License) Copyright 2022 Fonticons, Inc. --><path d="M41.4 233.4c-12.5 12.5-12.5 32.8 0 45.3l160 160c12.5 12.5 32.8 12.5 45.3 0s12.5-32.8 0-45.3L109.3 256 246.6 118.6c12.5-12.5 12.5-32.8 0-45.3s-32.8-12.5-45.3 0l-160 160z"/></svg></span>
|
||||||
</a>
|
</a>
|
||||||
|
|
||||||
<a rel="next prefetch" href="contributing/coding-standards.html" class="nav-chapters next" title="Next chapter" aria-label="Next chapter" aria-keyshortcuts="Right">
|
<a rel="next prefetch" href="operations/monitoring.html" class="nav-chapters next" title="Next chapter" aria-label="Next chapter" aria-keyshortcuts="Right">
|
||||||
<span class=fa-svg><svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 320 512"><!--! Font Awesome Free 6.2.0 by @fontawesome - https://fontawesome.com License - https://fontawesome.com/license/free (Icons: CC BY 4.0, Fonts: SIL OFL 1.1, Code: MIT License) Copyright 2022 Fonticons, Inc. --><path d="M278.6 233.4c12.5 12.5 12.5 32.8 0 45.3l-160 160c-12.5 12.5-32.8 12.5-45.3 0s-12.5-32.8 0-45.3L210.7 256 73.4 118.6c-12.5-12.5-12.5-32.8 0-45.3s32.8-12.5 45.3 0l160 160z"/></svg></span>
|
<span class=fa-svg><svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 320 512"><!--! Font Awesome Free 6.2.0 by @fontawesome - https://fontawesome.com License - https://fontawesome.com/license/free (Icons: CC BY 4.0, Fonts: SIL OFL 1.1, Code: MIT License) Copyright 2022 Fonticons, Inc. --><path d="M278.6 233.4c12.5 12.5 12.5 32.8 0 45.3l-160 160c-12.5 12.5-32.8 12.5-45.3 0s-12.5-32.8 0-45.3L210.7 256 73.4 118.6c-12.5-12.5-12.5-32.8 0-45.3s32.8-12.5 45.3 0l160 160z"/></svg></span>
|
||||||
</a>
|
</a>
|
||||||
</nav>
|
</nav>
|
||||||
|
|||||||
@@ -1,22 +0,0 @@
|
|||||||
[package]
|
|
||||||
name = "quicproquo-bot"
|
|
||||||
version = "0.1.0"
|
|
||||||
edition = "2021"
|
|
||||||
description = "Bot SDK for quicproquo — build automated agents on E2E encrypted messaging."
|
|
||||||
license = "MIT"
|
|
||||||
|
|
||||||
[lints]
|
|
||||||
workspace = true
|
|
||||||
|
|
||||||
[dependencies]
|
|
||||||
quicproquo-core = { path = "../quicproquo-core" }
|
|
||||||
quicproquo-proto = { path = "../quicproquo-proto" }
|
|
||||||
quicproquo-client = { path = "../quicproquo-client" }
|
|
||||||
|
|
||||||
openmls_rust_crypto = { workspace = true }
|
|
||||||
tokio = { workspace = true }
|
|
||||||
anyhow = { workspace = true }
|
|
||||||
tracing = { workspace = true }
|
|
||||||
serde = { workspace = true }
|
|
||||||
serde_json = { workspace = true }
|
|
||||||
hex = { workspace = true }
|
|
||||||
@@ -1,353 +0,0 @@
|
|||||||
//! # quicproquo-bot — Bot SDK for E2E encrypted messaging
|
|
||||||
//!
|
|
||||||
//! Build automated agents that run on the quicproquo network with full MLS
|
|
||||||
//! end-to-end encryption. The bot SDK wraps the client library into a simple
|
|
||||||
//! polling-based API: connect, authenticate, send, receive.
|
|
||||||
//!
|
|
||||||
//! ## Quick start
|
|
||||||
//!
|
|
||||||
//! ```rust,no_run
|
|
||||||
//! use quicproquo_bot::{Bot, BotConfig};
|
|
||||||
//!
|
|
||||||
//! #[tokio::main]
|
|
||||||
//! async fn main() -> anyhow::Result<()> {
|
|
||||||
//! let config = BotConfig::new("127.0.0.1:7000", "bot-user", "bot-password")
|
|
||||||
//! .ca_cert("server-cert.der")
|
|
||||||
//! .state_path("bot-state.bin");
|
|
||||||
//!
|
|
||||||
//! let bot = Bot::connect(config).await?;
|
|
||||||
//!
|
|
||||||
//! // Send a DM
|
|
||||||
//! bot.send_dm("alice", "Hello from bot!").await?;
|
|
||||||
//!
|
|
||||||
//! // Poll for messages
|
|
||||||
//! loop {
|
|
||||||
//! for msg in bot.receive(5000).await? {
|
|
||||||
//! println!("{}: {}", msg.sender, msg.text);
|
|
||||||
//! if msg.text.starts_with("!echo ") {
|
|
||||||
//! bot.send_dm(&msg.sender, &msg.text[6..]).await?;
|
|
||||||
//! }
|
|
||||||
//! }
|
|
||||||
//! }
|
|
||||||
//! }
|
|
||||||
//! ```
|
|
||||||
//!
|
|
||||||
//! ## Pipe mode (stdin/stdout JSON lines)
|
|
||||||
//!
|
|
||||||
//! The bot SDK also supports non-interactive pipe mode for shell integration:
|
|
||||||
//!
|
|
||||||
//! ```bash
|
|
||||||
//! # Send via pipe
|
|
||||||
//! echo '{"to":"alice","text":"hello"}' | qpq pipe --state bot.bin
|
|
||||||
//!
|
|
||||||
//! # Receive via pipe (JSON lines to stdout)
|
|
||||||
//! qpq pipe --recv --state bot.bin
|
|
||||||
//! ```
|
|
||||||
|
|
||||||
use std::path::PathBuf;
|
|
||||||
use std::sync::Arc;
|
|
||||||
|
|
||||||
use anyhow::Context;
|
|
||||||
use tokio::task::LocalSet;
|
|
||||||
|
|
||||||
use quicproquo_client::{connect_node, init_auth, opaque_login, resolve_user, ClientAuth};
|
|
||||||
use quicproquo_core::IdentityKeypair;
|
|
||||||
|
|
||||||
/// Configuration for connecting a bot to a quicproquo server.
|
|
||||||
#[derive(Clone, Debug)]
|
|
||||||
pub struct BotConfig {
|
|
||||||
/// Server address (host:port).
|
|
||||||
pub server: String,
|
|
||||||
/// Path to the server's CA certificate (DER format).
|
|
||||||
pub ca_cert: PathBuf,
|
|
||||||
/// TLS server name (defaults to "localhost").
|
|
||||||
pub server_name: String,
|
|
||||||
/// Bot's username for OPAQUE authentication.
|
|
||||||
pub username: String,
|
|
||||||
/// Bot's password for OPAQUE authentication.
|
|
||||||
pub password: String,
|
|
||||||
/// Path to the bot's encrypted state file.
|
|
||||||
pub state_path: PathBuf,
|
|
||||||
/// Password for the encrypted state file (None = unencrypted).
|
|
||||||
pub state_password: Option<String>,
|
|
||||||
/// Device ID reported to the server.
|
|
||||||
pub device_id: Option<String>,
|
|
||||||
}
|
|
||||||
|
|
||||||
impl BotConfig {
|
|
||||||
/// Create a new bot configuration with required fields.
|
|
||||||
pub fn new(server: &str, username: &str, password: &str) -> Self {
|
|
||||||
Self {
|
|
||||||
server: server.to_string(),
|
|
||||||
ca_cert: PathBuf::from("server-cert.der"),
|
|
||||||
server_name: "localhost".to_string(),
|
|
||||||
username: username.to_string(),
|
|
||||||
password: password.to_string(),
|
|
||||||
state_path: PathBuf::from("bot-state.bin"),
|
|
||||||
state_password: None,
|
|
||||||
device_id: None,
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Set the CA certificate path.
|
|
||||||
pub fn ca_cert(mut self, path: &str) -> Self {
|
|
||||||
self.ca_cert = PathBuf::from(path);
|
|
||||||
self
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Set the TLS server name for certificate validation.
|
|
||||||
pub fn server_name(mut self, name: &str) -> Self {
|
|
||||||
self.server_name = name.to_string();
|
|
||||||
self
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Set the state file path.
|
|
||||||
pub fn state_path(mut self, path: &str) -> Self {
|
|
||||||
self.state_path = PathBuf::from(path);
|
|
||||||
self
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Set the state file encryption password.
|
|
||||||
pub fn state_password(mut self, pwd: &str) -> Self {
|
|
||||||
self.state_password = Some(pwd.to_string());
|
|
||||||
self
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Set the device ID.
|
|
||||||
pub fn device_id(mut self, id: &str) -> Self {
|
|
||||||
self.device_id = Some(id.to_string());
|
|
||||||
self
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/// A received message from the quicproquo network.
|
|
||||||
#[derive(Clone, Debug, serde::Serialize)]
|
|
||||||
pub struct Message {
|
|
||||||
/// The sender's username (or "unknown" if resolution failed).
|
|
||||||
pub sender: String,
|
|
||||||
/// The decrypted plaintext message content.
|
|
||||||
pub text: String,
|
|
||||||
/// Server-assigned sequence number.
|
|
||||||
pub seq: u64,
|
|
||||||
}
|
|
||||||
|
|
||||||
/// A bot connected to a quicproquo server.
|
|
||||||
///
|
|
||||||
/// The bot maintains its identity and MLS group state. Each call to
|
|
||||||
/// `send_dm` or `receive` opens a fresh QUIC connection (stateless
|
|
||||||
/// reconnect pattern — same as the CLI client).
|
|
||||||
pub struct Bot {
|
|
||||||
config: BotConfig,
|
|
||||||
identity: Arc<IdentityKeypair>,
|
|
||||||
}
|
|
||||||
|
|
||||||
impl Bot {
|
|
||||||
/// Connect to a quicproquo server and authenticate.
|
|
||||||
///
|
|
||||||
/// Loads or creates an identity from the state file, connects via QUIC/TLS,
|
|
||||||
/// and performs OPAQUE password authentication.
|
|
||||||
pub async fn connect(config: BotConfig) -> anyhow::Result<Self> {
|
|
||||||
let state = quicproquo_client::client::state::load_or_init_state(
|
|
||||||
&config.state_path,
|
|
||||||
config.state_password.as_deref(),
|
|
||||||
)
|
|
||||||
.context("load or init bot state")?;
|
|
||||||
|
|
||||||
let identity = Arc::new(IdentityKeypair::from_seed(state.identity_seed));
|
|
||||||
|
|
||||||
// Authenticate on the first connection.
|
|
||||||
let local = LocalSet::new();
|
|
||||||
let cfg = config.clone();
|
|
||||||
let id = Arc::clone(&identity);
|
|
||||||
|
|
||||||
local
|
|
||||||
.run_until(async {
|
|
||||||
let client =
|
|
||||||
connect_node(&cfg.server, &cfg.ca_cert, &cfg.server_name).await?;
|
|
||||||
|
|
||||||
let pk = id.public_key_bytes();
|
|
||||||
let token = opaque_login(
|
|
||||||
&client,
|
|
||||||
&cfg.username,
|
|
||||||
&cfg.password,
|
|
||||||
&pk,
|
|
||||||
)
|
|
||||||
.await
|
|
||||||
.context("OPAQUE login")?;
|
|
||||||
|
|
||||||
init_auth(ClientAuth::from_raw(token, cfg.device_id.clone()));
|
|
||||||
|
|
||||||
tracing::info!(username = %cfg.username, server = %cfg.server, "bot authenticated");
|
|
||||||
Ok::<(), anyhow::Error>(())
|
|
||||||
})
|
|
||||||
.await?;
|
|
||||||
|
|
||||||
Ok(Self { config, identity })
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Send a plaintext message to a peer by username.
|
|
||||||
///
|
|
||||||
/// Resolves the username to an identity key, then encrypts via MLS
|
|
||||||
/// and delivers through the server.
|
|
||||||
pub async fn send_dm(&self, peer_username: &str, text: &str) -> anyhow::Result<()> {
|
|
||||||
// Resolve username → identity key hex so we send to the specific peer.
|
|
||||||
let peer_key = self
|
|
||||||
.resolve_user(peer_username)
|
|
||||||
.await
|
|
||||||
.context("resolve peer username")?;
|
|
||||||
let peer_key_hex = hex::encode(&peer_key);
|
|
||||||
|
|
||||||
quicproquo_client::cmd_send(
|
|
||||||
&self.config.state_path,
|
|
||||||
&self.config.server,
|
|
||||||
&self.config.ca_cert,
|
|
||||||
&self.config.server_name,
|
|
||||||
Some(&peer_key_hex),
|
|
||||||
false,
|
|
||||||
text,
|
|
||||||
self.config.state_password.as_deref(),
|
|
||||||
)
|
|
||||||
.await
|
|
||||||
.context("send message")?;
|
|
||||||
|
|
||||||
Ok(())
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Receive pending messages, waiting up to `timeout_ms` milliseconds.
|
|
||||||
///
|
|
||||||
/// Returns decrypted application messages. MLS control messages (commits,
|
|
||||||
/// welcomes) are processed internally but not returned.
|
|
||||||
pub async fn receive(&self, timeout_ms: u64) -> anyhow::Result<Vec<Message>> {
|
|
||||||
let plaintexts = quicproquo_client::receive_pending_plaintexts(
|
|
||||||
&self.config.state_path,
|
|
||||||
&self.config.server,
|
|
||||||
&self.config.ca_cert,
|
|
||||||
&self.config.server_name,
|
|
||||||
timeout_ms,
|
|
||||||
self.config.state_password.as_deref(),
|
|
||||||
)
|
|
||||||
.await?;
|
|
||||||
|
|
||||||
let messages: Vec<Message> = plaintexts
|
|
||||||
.into_iter()
|
|
||||||
.enumerate()
|
|
||||||
.map(|(i, plaintext)| Message {
|
|
||||||
sender: "peer".to_string(), // TODO: resolve from MLS group roster
|
|
||||||
text: String::from_utf8_lossy(&plaintext).to_string(),
|
|
||||||
seq: i as u64,
|
|
||||||
})
|
|
||||||
.collect();
|
|
||||||
|
|
||||||
Ok(messages)
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Receive raw plaintext bytes (for binary protocols or non-UTF-8 content).
|
|
||||||
pub async fn receive_raw(&self, timeout_ms: u64) -> anyhow::Result<Vec<Vec<u8>>> {
|
|
||||||
quicproquo_client::receive_pending_plaintexts(
|
|
||||||
&self.config.state_path,
|
|
||||||
&self.config.server,
|
|
||||||
&self.config.ca_cert,
|
|
||||||
&self.config.server_name,
|
|
||||||
timeout_ms,
|
|
||||||
self.config.state_password.as_deref(),
|
|
||||||
)
|
|
||||||
.await
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Resolve a username to a 32-byte identity key.
|
|
||||||
pub async fn resolve_user(&self, username: &str) -> anyhow::Result<Vec<u8>> {
|
|
||||||
let local = LocalSet::new();
|
|
||||||
let cfg = self.config.clone();
|
|
||||||
let username = username.to_string();
|
|
||||||
|
|
||||||
local
|
|
||||||
.run_until(async {
|
|
||||||
let client = connect_node(&cfg.server, &cfg.ca_cert, &cfg.server_name).await?;
|
|
||||||
let key = resolve_user(&client, &username)
|
|
||||||
.await?
|
|
||||||
.ok_or_else(|| anyhow::anyhow!("user not found: {username}"))?;
|
|
||||||
Ok(key)
|
|
||||||
})
|
|
||||||
.await
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Get the bot's own username.
|
|
||||||
pub fn username(&self) -> &str {
|
|
||||||
&self.config.username
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Get the bot's identity public key (32 bytes, Ed25519).
|
|
||||||
pub fn identity_key(&self) -> [u8; 32] {
|
|
||||||
self.identity.public_key_bytes()
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Get the bot's identity key as a hex string.
|
|
||||||
pub fn identity_key_hex(&self) -> String {
|
|
||||||
hex::encode(self.identity.public_key_bytes())
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Read JSON commands from stdin and process them.
|
|
||||||
///
|
|
||||||
/// Each line should be a JSON object with:
|
|
||||||
/// - `{"action": "send", "to": "username", "text": "message"}`
|
|
||||||
/// - `{"action": "recv", "timeout_ms": 5000}`
|
|
||||||
/// - `{"action": "resolve", "username": "alice"}`
|
|
||||||
///
|
|
||||||
/// Results are written to stdout as JSON lines.
|
|
||||||
pub async fn run_pipe_mode(bot: &Bot) -> anyhow::Result<()> {
|
|
||||||
use tokio::io::{AsyncBufReadExt, BufReader};
|
|
||||||
|
|
||||||
let stdin = BufReader::new(tokio::io::stdin());
|
|
||||||
let mut lines = stdin.lines();
|
|
||||||
|
|
||||||
while let Ok(Some(line)) = lines.next_line().await {
|
|
||||||
let line = line.trim().to_string();
|
|
||||||
if line.is_empty() {
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
|
|
||||||
let cmd: serde_json::Value = match serde_json::from_str(&line) {
|
|
||||||
Ok(v) => v,
|
|
||||||
Err(e) => {
|
|
||||||
let err = serde_json::json!({"error": format!("invalid JSON: {e}")});
|
|
||||||
println!("{err}");
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
let action = cmd["action"].as_str().unwrap_or("");
|
|
||||||
let result = match action {
|
|
||||||
"send" => {
|
|
||||||
let to = cmd["to"].as_str().unwrap_or("");
|
|
||||||
let text = cmd["text"].as_str().unwrap_or("");
|
|
||||||
match bot.send_dm(to, text).await {
|
|
||||||
Ok(()) => serde_json::json!({"status": "ok", "action": "send"}),
|
|
||||||
Err(e) => serde_json::json!({"error": format!("{e:#}")}),
|
|
||||||
}
|
|
||||||
}
|
|
||||||
"recv" => {
|
|
||||||
let timeout = cmd["timeout_ms"].as_u64().unwrap_or(5000);
|
|
||||||
match bot.receive(timeout).await {
|
|
||||||
Ok(msgs) => serde_json::json!({"status": "ok", "messages": msgs}),
|
|
||||||
Err(e) => serde_json::json!({"error": format!("{e:#}")}),
|
|
||||||
}
|
|
||||||
}
|
|
||||||
"resolve" => {
|
|
||||||
let username = cmd["username"].as_str().unwrap_or("");
|
|
||||||
match bot.resolve_user(username).await {
|
|
||||||
Ok(key) => serde_json::json!({
|
|
||||||
"status": "ok",
|
|
||||||
"identity_key": hex::encode(&key),
|
|
||||||
}),
|
|
||||||
Err(e) => serde_json::json!({"error": format!("{e:#}")}),
|
|
||||||
}
|
|
||||||
}
|
|
||||||
_ => serde_json::json!({"error": format!("unknown action: {action}")}),
|
|
||||||
};
|
|
||||||
|
|
||||||
println!("{result}");
|
|
||||||
}
|
|
||||||
|
|
||||||
Ok(())
|
|
||||||
}
|
|
||||||
@@ -20,6 +20,9 @@
|
|||||||
//! Ctrl+C / Ctrl+Q -- quit
|
//! Ctrl+C / Ctrl+Q -- quit
|
||||||
//!
|
//!
|
||||||
//! Feature gate: requires both `v2` and `tui` features.
|
//! Feature gate: requires both `v2` and `tui` features.
|
||||||
|
//!
|
||||||
|
//! **Note:** Message display is currently local-only. Use the REPL client for
|
||||||
|
//! end-to-end encrypted delivery. See `quicproquo-sdk::messaging` for the full pipeline.
|
||||||
|
|
||||||
use std::time::Duration;
|
use std::time::Duration;
|
||||||
|
|
||||||
@@ -535,9 +538,11 @@ async fn handle_input(app: &mut TuiApp, client: &mut QpqClient, text: &str) {
|
|||||||
// Snap to bottom.
|
// Snap to bottom.
|
||||||
app.scroll_offset = 0;
|
app.scroll_offset = 0;
|
||||||
|
|
||||||
// TODO: actually send via SDK when the send pipeline is wired up.
|
// NOTE: TUI message display is local-only. The full MLS encryption
|
||||||
// For now, emit a notification.
|
// pipeline (sealed sender + hybrid wrap + enqueue) is implemented in
|
||||||
app.notification = Some(format!("Sent: {text}"));
|
// quicproquo-sdk/src/messaging.rs but is not yet wired into the TUI.
|
||||||
|
// Use the REPL client (`qpq repl`) for end-to-end message delivery.
|
||||||
|
app.notification = Some("Message queued locally (TUI send not yet wired to SDK)".to_string());
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -1,20 +0,0 @@
|
|||||||
[package]
|
|
||||||
name = "quicproquo-ffi"
|
|
||||||
version = "0.1.0"
|
|
||||||
edition = "2021"
|
|
||||||
description = "C FFI bindings for quicproquo messaging operations."
|
|
||||||
license = "MIT"
|
|
||||||
|
|
||||||
[lib]
|
|
||||||
crate-type = ["cdylib", "staticlib"]
|
|
||||||
|
|
||||||
[dependencies]
|
|
||||||
quicproquo-client = { path = "../quicproquo-client" }
|
|
||||||
tokio = { workspace = true }
|
|
||||||
anyhow = { workspace = true }
|
|
||||||
capnp = { workspace = true }
|
|
||||||
serde_json = { workspace = true }
|
|
||||||
hex = { workspace = true }
|
|
||||||
|
|
||||||
[lints]
|
|
||||||
workspace = true
|
|
||||||
@@ -1,428 +0,0 @@
|
|||||||
#![allow(unsafe_code)]
|
|
||||||
//! quicproquo-ffi -- C FFI bindings for quicproquo messaging operations.
|
|
||||||
//!
|
|
||||||
//! Provides a synchronous C API that wraps the async quicproquo-client library.
|
|
||||||
//! Each `QpqHandle` owns a Tokio runtime; FFI functions use `runtime.block_on()`
|
|
||||||
//! to bridge from synchronous C callers to the async Rust internals.
|
|
||||||
//!
|
|
||||||
//! # Safety
|
|
||||||
//!
|
|
||||||
//! All FFI functions are `unsafe extern "C"` -- callers must ensure pointers
|
|
||||||
//! are valid and strings are null-terminated UTF-8.
|
|
||||||
|
|
||||||
use std::ffi::{CStr, CString, c_char};
|
|
||||||
use std::path::PathBuf;
|
|
||||||
|
|
||||||
use tokio::runtime::Runtime;
|
|
||||||
|
|
||||||
// Status codes returned by FFI functions.
|
|
||||||
pub const QPQ_OK: i32 = 0;
|
|
||||||
pub const QPQ_ERROR: i32 = 1;
|
|
||||||
pub const QPQ_AUTH_FAILED: i32 = 2;
|
|
||||||
pub const QPQ_TIMEOUT: i32 = 3;
|
|
||||||
pub const QPQ_NOT_CONNECTED: i32 = 4;
|
|
||||||
|
|
||||||
/// Opaque handle exposed to C callers via pointer.
|
|
||||||
pub struct QpqHandle {
|
|
||||||
runtime: Runtime,
|
|
||||||
server: String,
|
|
||||||
ca_cert: PathBuf,
|
|
||||||
server_name: String,
|
|
||||||
state_path: PathBuf,
|
|
||||||
state_password: Option<String>,
|
|
||||||
logged_in: bool,
|
|
||||||
last_error: Option<CString>,
|
|
||||||
}
|
|
||||||
|
|
||||||
impl QpqHandle {
|
|
||||||
fn set_error(&mut self, msg: &str) {
|
|
||||||
self.last_error = CString::new(msg).ok();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// ---------------------------------------------------------------------------
|
|
||||||
// Error classification
|
|
||||||
// ---------------------------------------------------------------------------
|
|
||||||
|
|
||||||
/// Classify an `anyhow::Error` from `cmd_login` into an FFI status code.
|
|
||||||
///
|
|
||||||
/// Checks the error chain for typed downcasting before falling back to
|
|
||||||
/// message-based heuristics.
|
|
||||||
fn classify_login_error(err: &anyhow::Error) -> i32 {
|
|
||||||
// Check error chain for OPAQUE-specific typed errors.
|
|
||||||
for cause in err.chain() {
|
|
||||||
// capnp::Error indicates transport/RPC failure.
|
|
||||||
if cause.downcast_ref::<capnp::Error>().is_some() {
|
|
||||||
return QPQ_ERROR;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
// Fall back to message inspection for OPAQUE authentication failures,
|
|
||||||
// since opaque-ke errors are converted to anyhow strings upstream.
|
|
||||||
let msg = format!("{err:#}");
|
|
||||||
if msg.contains("OPAQUE") || msg.contains("bad password") || msg.contains("credential") {
|
|
||||||
QPQ_AUTH_FAILED
|
|
||||||
} else {
|
|
||||||
QPQ_ERROR
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Classify an `anyhow::Error` from receive operations into an FFI status code.
|
|
||||||
fn classify_receive_error(err: &anyhow::Error) -> i32 {
|
|
||||||
let msg = format!("{err:#}");
|
|
||||||
if msg.contains("timeout") || msg.contains("Timeout") || msg.contains("timed out") {
|
|
||||||
QPQ_TIMEOUT
|
|
||||||
} else {
|
|
||||||
QPQ_ERROR
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// ---------------------------------------------------------------------------
|
|
||||||
// Helpers
|
|
||||||
// ---------------------------------------------------------------------------
|
|
||||||
|
|
||||||
/// Convert a `*const c_char` to `&str`, returning `None` on null or invalid UTF-8.
|
|
||||||
unsafe fn cstr_to_str<'a>(ptr: *const c_char) -> Option<&'a str> {
|
|
||||||
if ptr.is_null() {
|
|
||||||
return None;
|
|
||||||
}
|
|
||||||
CStr::from_ptr(ptr).to_str().ok()
|
|
||||||
}
|
|
||||||
|
|
||||||
// ---------------------------------------------------------------------------
|
|
||||||
// FFI functions
|
|
||||||
// ---------------------------------------------------------------------------
|
|
||||||
|
|
||||||
/// Create a new handle and connect to the quicproquo server.
|
|
||||||
///
|
|
||||||
/// Returns a heap-allocated `QpqHandle` pointer on success, or null on failure.
|
|
||||||
///
|
|
||||||
/// # Parameters
|
|
||||||
/// - `server`: server address as `host:port` (null-terminated UTF-8).
|
|
||||||
/// - `ca_cert`: path to the CA certificate file (null-terminated UTF-8).
|
|
||||||
/// - `server_name`: TLS server name (null-terminated UTF-8).
|
|
||||||
///
|
|
||||||
/// # Safety
|
|
||||||
/// All pointer arguments must be valid, non-null, null-terminated C strings.
|
|
||||||
#[no_mangle]
|
|
||||||
pub unsafe extern "C" fn qpq_connect(
|
|
||||||
server: *const c_char,
|
|
||||||
ca_cert: *const c_char,
|
|
||||||
server_name: *const c_char,
|
|
||||||
) -> *mut QpqHandle {
|
|
||||||
let server_str = match cstr_to_str(server) {
|
|
||||||
Some(s) => s,
|
|
||||||
None => return std::ptr::null_mut(),
|
|
||||||
};
|
|
||||||
let ca_cert_str = match cstr_to_str(ca_cert) {
|
|
||||||
Some(s) => s,
|
|
||||||
None => return std::ptr::null_mut(),
|
|
||||||
};
|
|
||||||
let server_name_str = match cstr_to_str(server_name) {
|
|
||||||
Some(s) => s,
|
|
||||||
None => return std::ptr::null_mut(),
|
|
||||||
};
|
|
||||||
|
|
||||||
let rt = match Runtime::new() {
|
|
||||||
Ok(r) => r,
|
|
||||||
Err(_) => return std::ptr::null_mut(),
|
|
||||||
};
|
|
||||||
|
|
||||||
// Verify connectivity by performing a health check.
|
|
||||||
let ca_path = PathBuf::from(ca_cert_str);
|
|
||||||
let connected = rt.block_on(async {
|
|
||||||
quicproquo_client::cmd_health(server_str, &ca_path, server_name_str).await
|
|
||||||
});
|
|
||||||
|
|
||||||
if let Err(e) = connected {
|
|
||||||
// Cannot store error in handle since we failed to build one.
|
|
||||||
eprintln!("qpq_connect: health check failed: {e}");
|
|
||||||
return std::ptr::null_mut();
|
|
||||||
}
|
|
||||||
|
|
||||||
// Derive a default state path from the server address.
|
|
||||||
let state_path = PathBuf::from(format!("qpq-ffi-{server_str}.bin"));
|
|
||||||
|
|
||||||
let handle = Box::new(QpqHandle {
|
|
||||||
runtime: rt,
|
|
||||||
server: server_str.to_string(),
|
|
||||||
ca_cert: ca_path,
|
|
||||||
server_name: server_name_str.to_string(),
|
|
||||||
state_path,
|
|
||||||
state_password: None,
|
|
||||||
logged_in: false,
|
|
||||||
last_error: None,
|
|
||||||
});
|
|
||||||
Box::into_raw(handle)
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Authenticate with the server using OPAQUE (username + password).
|
|
||||||
///
|
|
||||||
/// On success the handle is marked as logged-in and subsequent send/receive
|
|
||||||
/// calls will use the authenticated session.
|
|
||||||
///
|
|
||||||
/// Returns `QPQ_OK` on success, `QPQ_AUTH_FAILED` on bad credentials,
|
|
||||||
/// or `QPQ_ERROR` on other failures.
|
|
||||||
///
|
|
||||||
/// # Safety
|
|
||||||
/// - `handle` must be a valid pointer from `qpq_connect`.
|
|
||||||
/// - `username` and `password` must be valid null-terminated C strings.
|
|
||||||
#[no_mangle]
|
|
||||||
pub unsafe extern "C" fn qpq_login(
|
|
||||||
handle: *mut QpqHandle,
|
|
||||||
username: *const c_char,
|
|
||||||
password: *const c_char,
|
|
||||||
) -> i32 {
|
|
||||||
let h = match handle.as_mut() {
|
|
||||||
Some(h) => h,
|
|
||||||
None => return QPQ_NOT_CONNECTED,
|
|
||||||
};
|
|
||||||
|
|
||||||
let user = match cstr_to_str(username) {
|
|
||||||
Some(s) => s,
|
|
||||||
None => {
|
|
||||||
h.set_error("invalid username pointer");
|
|
||||||
return QPQ_ERROR;
|
|
||||||
}
|
|
||||||
};
|
|
||||||
let pass = match cstr_to_str(password) {
|
|
||||||
Some(s) => s,
|
|
||||||
None => {
|
|
||||||
h.set_error("invalid password pointer");
|
|
||||||
return QPQ_ERROR;
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
// Update state path to be username-specific.
|
|
||||||
h.state_path = PathBuf::from(format!("qpq-ffi-{user}.bin"));
|
|
||||||
|
|
||||||
let result = h.runtime.block_on(async {
|
|
||||||
quicproquo_client::cmd_login(
|
|
||||||
&h.server,
|
|
||||||
&h.ca_cert,
|
|
||||||
&h.server_name,
|
|
||||||
user,
|
|
||||||
pass,
|
|
||||||
None, // identity_key_hex
|
|
||||||
Some(h.state_path.as_path()), // state_path
|
|
||||||
h.state_password.as_deref(), // state_password
|
|
||||||
)
|
|
||||||
.await
|
|
||||||
});
|
|
||||||
|
|
||||||
match result {
|
|
||||||
Ok(()) => {
|
|
||||||
h.logged_in = true;
|
|
||||||
QPQ_OK
|
|
||||||
}
|
|
||||||
Err(e) => {
|
|
||||||
let msg = format!("{e:#}");
|
|
||||||
let code = classify_login_error(&e);
|
|
||||||
h.set_error(&msg);
|
|
||||||
code
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Send a message to a recipient (by username).
|
|
||||||
///
|
|
||||||
/// The message is encrypted via MLS before delivery. The `message` buffer
|
|
||||||
/// does not need to be null-terminated; `message_len` specifies its length.
|
|
||||||
///
|
|
||||||
/// Returns `QPQ_OK` on success.
|
|
||||||
///
|
|
||||||
/// # Safety
|
|
||||||
/// - `handle` must be a valid pointer from `qpq_connect`.
|
|
||||||
/// - `recipient` must be a valid null-terminated C string.
|
|
||||||
/// - `message` must point to at least `message_len` readable bytes.
|
|
||||||
#[no_mangle]
|
|
||||||
pub unsafe extern "C" fn qpq_send(
|
|
||||||
handle: *mut QpqHandle,
|
|
||||||
recipient: *const c_char,
|
|
||||||
message: *const u8,
|
|
||||||
message_len: usize,
|
|
||||||
) -> i32 {
|
|
||||||
let h = match handle.as_mut() {
|
|
||||||
Some(h) => h,
|
|
||||||
None => return QPQ_NOT_CONNECTED,
|
|
||||||
};
|
|
||||||
|
|
||||||
if !h.logged_in {
|
|
||||||
h.set_error("not logged in");
|
|
||||||
return QPQ_NOT_CONNECTED;
|
|
||||||
}
|
|
||||||
|
|
||||||
let rcpt = match cstr_to_str(recipient) {
|
|
||||||
Some(s) => s,
|
|
||||||
None => {
|
|
||||||
h.set_error("invalid recipient pointer");
|
|
||||||
return QPQ_ERROR;
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
if message.is_null() || message_len == 0 {
|
|
||||||
h.set_error("empty message");
|
|
||||||
return QPQ_ERROR;
|
|
||||||
}
|
|
||||||
let msg_bytes = std::slice::from_raw_parts(message, message_len);
|
|
||||||
let msg_str = match std::str::from_utf8(msg_bytes) {
|
|
||||||
Ok(s) => s,
|
|
||||||
Err(e) => {
|
|
||||||
h.set_error(&format!("message is not valid UTF-8: {e}"));
|
|
||||||
return QPQ_ERROR;
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
// Resolve recipient username to identity key, then send.
|
|
||||||
let result = h.runtime.block_on(async {
|
|
||||||
let node_client =
|
|
||||||
quicproquo_client::connect_node(&h.server, &h.ca_cert, &h.server_name).await?;
|
|
||||||
let peer_key = quicproquo_client::resolve_user(&node_client, rcpt)
|
|
||||||
.await?
|
|
||||||
.ok_or_else(|| anyhow::anyhow!("recipient '{rcpt}' not found"))?;
|
|
||||||
let peer_key_hex = hex::encode(&peer_key);
|
|
||||||
|
|
||||||
quicproquo_client::cmd_send(
|
|
||||||
&h.state_path,
|
|
||||||
&h.server,
|
|
||||||
&h.ca_cert,
|
|
||||||
&h.server_name,
|
|
||||||
Some(&peer_key_hex),
|
|
||||||
false, // send_to_all
|
|
||||||
msg_str,
|
|
||||||
h.state_password.as_deref(),
|
|
||||||
)
|
|
||||||
.await
|
|
||||||
});
|
|
||||||
|
|
||||||
match result {
|
|
||||||
Ok(()) => QPQ_OK,
|
|
||||||
Err(e) => {
|
|
||||||
h.set_error(&format!("{e:#}"));
|
|
||||||
QPQ_ERROR
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Receive pending messages, blocking up to `timeout_ms` milliseconds.
|
|
||||||
///
|
|
||||||
/// On success, `*out_json` is set to a heap-allocated null-terminated JSON
|
|
||||||
/// string containing an array of received message objects. The caller must
|
|
||||||
/// free this string with `qpq_free_string`.
|
|
||||||
///
|
|
||||||
/// Returns `QPQ_OK` on success (even if the array is empty),
|
|
||||||
/// `QPQ_TIMEOUT` if the wait expires with no messages.
|
|
||||||
///
|
|
||||||
/// # Safety
|
|
||||||
/// - `handle` must be a valid pointer from `qpq_connect`.
|
|
||||||
/// - `out_json` must be a valid pointer to a `*mut c_char`.
|
|
||||||
#[no_mangle]
|
|
||||||
pub unsafe extern "C" fn qpq_receive(
|
|
||||||
handle: *mut QpqHandle,
|
|
||||||
timeout_ms: u32,
|
|
||||||
out_json: *mut *mut c_char,
|
|
||||||
) -> i32 {
|
|
||||||
let h = match handle.as_mut() {
|
|
||||||
Some(h) => h,
|
|
||||||
None => return QPQ_NOT_CONNECTED,
|
|
||||||
};
|
|
||||||
|
|
||||||
if !h.logged_in {
|
|
||||||
h.set_error("not logged in");
|
|
||||||
return QPQ_NOT_CONNECTED;
|
|
||||||
}
|
|
||||||
|
|
||||||
if out_json.is_null() {
|
|
||||||
h.set_error("out_json is null");
|
|
||||||
return QPQ_ERROR;
|
|
||||||
}
|
|
||||||
|
|
||||||
let result = h.runtime.block_on(async {
|
|
||||||
quicproquo_client::receive_pending_plaintexts(
|
|
||||||
&h.state_path,
|
|
||||||
&h.server,
|
|
||||||
&h.ca_cert,
|
|
||||||
&h.server_name,
|
|
||||||
timeout_ms as u64,
|
|
||||||
h.state_password.as_deref(),
|
|
||||||
)
|
|
||||||
.await
|
|
||||||
});
|
|
||||||
|
|
||||||
match result {
|
|
||||||
Ok(plaintexts) => {
|
|
||||||
// Convert raw byte payloads to a JSON array of base64 or lossy-UTF-8 strings.
|
|
||||||
let messages: Vec<String> = plaintexts
|
|
||||||
.iter()
|
|
||||||
.map(|pt| String::from_utf8_lossy(pt).into_owned())
|
|
||||||
.collect();
|
|
||||||
|
|
||||||
let json = match serde_json::to_string(&messages) {
|
|
||||||
Ok(j) => j,
|
|
||||||
Err(e) => {
|
|
||||||
h.set_error(&format!("JSON serialisation failed: {e}"));
|
|
||||||
return QPQ_ERROR;
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
match CString::new(json) {
|
|
||||||
Ok(cs) => {
|
|
||||||
*out_json = cs.into_raw();
|
|
||||||
QPQ_OK
|
|
||||||
}
|
|
||||||
Err(e) => {
|
|
||||||
h.set_error(&format!("CString conversion failed: {e}"));
|
|
||||||
QPQ_ERROR
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
Err(e) => {
|
|
||||||
let msg = format!("{e:#}");
|
|
||||||
let code = classify_receive_error(&e);
|
|
||||||
h.set_error(&msg);
|
|
||||||
code
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Disconnect and free the handle.
|
|
||||||
///
|
|
||||||
/// After this call, `handle` must not be used again.
|
|
||||||
///
|
|
||||||
/// # Safety
|
|
||||||
/// `handle` must be a valid pointer from `qpq_connect`, or null (no-op).
|
|
||||||
#[no_mangle]
|
|
||||||
pub unsafe extern "C" fn qpq_disconnect(handle: *mut QpqHandle) {
|
|
||||||
if !handle.is_null() {
|
|
||||||
let _ = Box::from_raw(handle);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Return the last error message, or null if no error has been recorded.
|
|
||||||
///
|
|
||||||
/// The returned pointer is valid until the next FFI call on this handle.
|
|
||||||
/// Do **not** free the returned pointer; it is owned by the handle.
|
|
||||||
///
|
|
||||||
/// # Safety
|
|
||||||
/// `handle` must be a valid pointer from `qpq_connect`, or null (returns null).
|
|
||||||
#[no_mangle]
|
|
||||||
pub unsafe extern "C" fn qpq_last_error(handle: *const QpqHandle) -> *const c_char {
|
|
||||||
match handle.as_ref() {
|
|
||||||
Some(h) => match &h.last_error {
|
|
||||||
Some(cs) => cs.as_ptr(),
|
|
||||||
None => std::ptr::null(),
|
|
||||||
},
|
|
||||||
None => std::ptr::null(),
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Free a string previously returned by `qpq_receive` (via `out_json`).
|
|
||||||
///
|
|
||||||
/// # Safety
|
|
||||||
/// `ptr` must have been allocated by this library (via `CString::into_raw`),
|
|
||||||
/// or null (no-op).
|
|
||||||
#[no_mangle]
|
|
||||||
pub unsafe extern "C" fn qpq_free_string(ptr: *mut c_char) {
|
|
||||||
if !ptr.is_null() {
|
|
||||||
let _ = CString::from_raw(ptr);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,16 +0,0 @@
|
|||||||
[package]
|
|
||||||
name = "quicproquo-gen"
|
|
||||||
version = "0.1.0"
|
|
||||||
edition = "2021"
|
|
||||||
description = "Code generators for quicproquo — scaffold plugins, bots, RPC methods, and hooks."
|
|
||||||
license = "MIT"
|
|
||||||
|
|
||||||
[[bin]]
|
|
||||||
name = "qpq-gen"
|
|
||||||
path = "src/main.rs"
|
|
||||||
|
|
||||||
[lints]
|
|
||||||
workspace = true
|
|
||||||
|
|
||||||
[dependencies]
|
|
||||||
clap = { workspace = true }
|
|
||||||
@@ -1,212 +0,0 @@
|
|||||||
use std::fs;
|
|
||||||
use std::path::Path;
|
|
||||||
|
|
||||||
pub fn generate(name: &str, output: &Path) -> Result<(), String> {
|
|
||||||
let crate_name = sanitize_name(name);
|
|
||||||
let dir = output.join(&crate_name);
|
|
||||||
|
|
||||||
if dir.exists() {
|
|
||||||
return Err(format!("directory already exists: {}", dir.display()));
|
|
||||||
}
|
|
||||||
|
|
||||||
let src_dir = dir.join("src");
|
|
||||||
fs::create_dir_all(&src_dir).map_err(|e| format!("create dir: {e}"))?;
|
|
||||||
|
|
||||||
// Cargo.toml
|
|
||||||
let cargo_toml = format!(
|
|
||||||
r#"[package]
|
|
||||||
name = "{crate_name}"
|
|
||||||
version = "0.1.0"
|
|
||||||
edition = "2021"
|
|
||||||
description = "quicproquo bot: {name}"
|
|
||||||
license = "MIT"
|
|
||||||
|
|
||||||
[dependencies]
|
|
||||||
quicproquo-bot = {{ git = "https://github.com/nickvidal/quicproquo" }}
|
|
||||||
tokio = {{ version = "1", features = ["macros", "rt-multi-thread"] }}
|
|
||||||
anyhow = "1"
|
|
||||||
tracing = "0.1"
|
|
||||||
tracing-subscriber = {{ version = "0.3", features = ["env-filter"] }}
|
|
||||||
"#,
|
|
||||||
crate_name = crate_name,
|
|
||||||
name = name,
|
|
||||||
);
|
|
||||||
write_file(&dir.join("Cargo.toml"), &cargo_toml)?;
|
|
||||||
|
|
||||||
// src/main.rs
|
|
||||||
let main_rs = format!(
|
|
||||||
r#"//! quicproquo bot: {name}
|
|
||||||
//!
|
|
||||||
//! A bot that connects to a quicproquo server and responds to messages.
|
|
||||||
//!
|
|
||||||
//! Usage:
|
|
||||||
//! {crate_name} --server 127.0.0.1:7000 --username my-bot --password secret
|
|
||||||
//!
|
|
||||||
//! Environment variables (alternative to CLI args):
|
|
||||||
//! QPQ_SERVER, QPQ_USERNAME, QPQ_PASSWORD, QPQ_CA_CERT, QPQ_STATE_PATH
|
|
||||||
|
|
||||||
use quicproquo_bot::{{Bot, BotConfig}};
|
|
||||||
|
|
||||||
#[tokio::main]
|
|
||||||
async fn main() -> anyhow::Result<()> {{
|
|
||||||
tracing_subscriber::fmt()
|
|
||||||
.with_env_filter(
|
|
||||||
tracing_subscriber::EnvFilter::try_from_default_env()
|
|
||||||
.unwrap_or_else(|_| "info".into()),
|
|
||||||
)
|
|
||||||
.init();
|
|
||||||
|
|
||||||
// --- Configuration ---
|
|
||||||
let server = env_or("QPQ_SERVER", "127.0.0.1:7000");
|
|
||||||
let username = env_or("QPQ_USERNAME", "{crate_name}");
|
|
||||||
let password = env_or("QPQ_PASSWORD", "changeme");
|
|
||||||
let ca_cert = env_or("QPQ_CA_CERT", "server-cert.der");
|
|
||||||
let state_path = env_or("QPQ_STATE_PATH", "{crate_name}-state.bin");
|
|
||||||
|
|
||||||
let config = BotConfig::new(&server, &username, &password)
|
|
||||||
.ca_cert(&ca_cert)
|
|
||||||
.state_path(&state_path);
|
|
||||||
|
|
||||||
// --- Connect and authenticate ---
|
|
||||||
tracing::info!("connecting to {{server}} as {{username}}...");
|
|
||||||
let bot = Bot::connect(config).await?;
|
|
||||||
tracing::info!("authenticated as {{}} (key: {{}})", bot.username(), &bot.identity_key_hex()[..16]);
|
|
||||||
|
|
||||||
// --- Main loop: poll for messages and respond ---
|
|
||||||
tracing::info!("listening for messages (Ctrl+C to stop)...");
|
|
||||||
loop {{
|
|
||||||
let messages = bot.receive(5000).await?;
|
|
||||||
for msg in messages {{
|
|
||||||
tracing::info!("[{{}}] {{}}", msg.sender, msg.text);
|
|
||||||
|
|
||||||
// --- Add your command handlers here ---
|
|
||||||
if let Some(response) = handle_message(&msg.sender, &msg.text) {{
|
|
||||||
bot.send_dm(&msg.sender, &response).await?;
|
|
||||||
}}
|
|
||||||
}}
|
|
||||||
}}
|
|
||||||
}}
|
|
||||||
|
|
||||||
/// Process an incoming message and optionally return a response.
|
|
||||||
///
|
|
||||||
/// Add your bot's command logic here.
|
|
||||||
fn handle_message(sender: &str, text: &str) -> Option<String> {{
|
|
||||||
let text = text.trim();
|
|
||||||
|
|
||||||
// !help — list available commands
|
|
||||||
if text == "!help" {{
|
|
||||||
return Some(
|
|
||||||
"Available commands:\n\
|
|
||||||
!help — show this message\n\
|
|
||||||
!echo <text> — echo back the text\n\
|
|
||||||
!whoami — show your username\n\
|
|
||||||
!ping — pong!"
|
|
||||||
.to_string(),
|
|
||||||
);
|
|
||||||
}}
|
|
||||||
|
|
||||||
// !echo <text> — echo back
|
|
||||||
if let Some(rest) = text.strip_prefix("!echo ") {{
|
|
||||||
return Some(rest.to_string());
|
|
||||||
}}
|
|
||||||
|
|
||||||
// !whoami — tell the sender their username
|
|
||||||
if text == "!whoami" {{
|
|
||||||
return Some(format!("You are {{sender}}"));
|
|
||||||
}}
|
|
||||||
|
|
||||||
// !ping — respond with pong
|
|
||||||
if text == "!ping" {{
|
|
||||||
return Some("pong!".to_string());
|
|
||||||
}}
|
|
||||||
|
|
||||||
// Unknown command or regular message — no response
|
|
||||||
None
|
|
||||||
}}
|
|
||||||
|
|
||||||
fn env_or(key: &str, default: &str) -> String {{
|
|
||||||
std::env::var(key).unwrap_or_else(|_| default.to_string())
|
|
||||||
}}
|
|
||||||
"#,
|
|
||||||
name = name,
|
|
||||||
crate_name = crate_name,
|
|
||||||
);
|
|
||||||
write_file(&src_dir.join("main.rs"), &main_rs)?;
|
|
||||||
|
|
||||||
// README
|
|
||||||
let readme = format!(
|
|
||||||
r#"# {name} — quicproquo bot
|
|
||||||
|
|
||||||
## Quick start
|
|
||||||
|
|
||||||
```bash
|
|
||||||
# Build
|
|
||||||
cargo build
|
|
||||||
|
|
||||||
# Run (make sure a qpq server is running)
|
|
||||||
QPQ_SERVER=127.0.0.1:7000 \
|
|
||||||
QPQ_USERNAME={crate_name} \
|
|
||||||
QPQ_PASSWORD=changeme \
|
|
||||||
QPQ_CA_CERT=path/to/server-cert.der \
|
|
||||||
cargo run
|
|
||||||
```
|
|
||||||
|
|
||||||
## Commands
|
|
||||||
|
|
||||||
| Command | Description |
|
|
||||||
|---------|-------------|
|
|
||||||
| `!help` | Show available commands |
|
|
||||||
| `!echo <text>` | Echo back the text |
|
|
||||||
| `!whoami` | Show your username |
|
|
||||||
| `!ping` | Respond with "pong!" |
|
|
||||||
|
|
||||||
## Adding commands
|
|
||||||
|
|
||||||
Edit the `handle_message` function in `src/main.rs`:
|
|
||||||
|
|
||||||
```rust
|
|
||||||
fn handle_message(sender: &str, text: &str) -> Option<String> {{
|
|
||||||
if text == "!mycommand" {{
|
|
||||||
return Some("my response".to_string());
|
|
||||||
}}
|
|
||||||
None
|
|
||||||
}}
|
|
||||||
```
|
|
||||||
|
|
||||||
## Pipe mode
|
|
||||||
|
|
||||||
For shell integration, use the Bot SDK's JSON pipe mode:
|
|
||||||
|
|
||||||
```bash
|
|
||||||
echo '{{"action":"send","to":"alice","text":"hello"}}' | my-bot
|
|
||||||
echo '{{"action":"recv","timeout_ms":5000}}' | my-bot
|
|
||||||
```
|
|
||||||
|
|
||||||
## Documentation
|
|
||||||
|
|
||||||
- [Bot SDK docs](https://github.com/nickvidal/quicproquo/blob/main/docs/src/getting-started/bot-sdk.md)
|
|
||||||
- [Server Hooks](https://github.com/nickvidal/quicproquo/blob/main/docs/src/internals/server-hooks.md)
|
|
||||||
"#,
|
|
||||||
name = name,
|
|
||||||
crate_name = crate_name,
|
|
||||||
);
|
|
||||||
write_file(&dir.join("README.md"), &readme)?;
|
|
||||||
|
|
||||||
println!("Created bot project: {}", dir.display());
|
|
||||||
println!();
|
|
||||||
println!(" cd {crate_name}");
|
|
||||||
println!(" # Edit src/main.rs to add your commands");
|
|
||||||
println!(" QPQ_SERVER=127.0.0.1:7000 QPQ_PASSWORD=secret cargo run");
|
|
||||||
println!();
|
|
||||||
println!("The bot responds to !help, !echo, !whoami, !ping out of the box.");
|
|
||||||
|
|
||||||
Ok(())
|
|
||||||
}
|
|
||||||
|
|
||||||
fn sanitize_name(name: &str) -> String {
|
|
||||||
name.replace(['-', ' '], "_")
|
|
||||||
}
|
|
||||||
|
|
||||||
fn write_file(path: &Path, content: &str) -> Result<(), String> {
|
|
||||||
fs::write(path, content).map_err(|e| format!("write {}: {e}", path.display()))
|
|
||||||
}
|
|
||||||
@@ -1,134 +0,0 @@
|
|||||||
pub fn generate(name: &str) -> Result<(), String> {
|
|
||||||
let snake = name.to_lowercase().replace(['-', ' '], "_");
|
|
||||||
let pascal = to_pascal_case(&snake);
|
|
||||||
|
|
||||||
println!("=== Adding hook event: on_{snake} ===");
|
|
||||||
println!();
|
|
||||||
println!("Follow these steps to add a new `on_{snake}` hook event.");
|
|
||||||
println!();
|
|
||||||
|
|
||||||
// Step 1: Event struct
|
|
||||||
println!("--- Step 1: Event struct ---");
|
|
||||||
println!("File: crates/quicproquo-server/src/hooks.rs");
|
|
||||||
println!();
|
|
||||||
println!(
|
|
||||||
r#"/// Event data for {snake} operations.
|
|
||||||
#[derive(Clone, Debug)]
|
|
||||||
pub struct {pascal}Event {{
|
|
||||||
// TODO: add your event fields here
|
|
||||||
// Example:
|
|
||||||
// pub channel_id: Vec<u8>,
|
|
||||||
// pub user_key: Vec<u8>,
|
|
||||||
}}
|
|
||||||
"#,
|
|
||||||
);
|
|
||||||
|
|
||||||
// Step 2: Trait method
|
|
||||||
println!("--- Step 2: Trait method ---");
|
|
||||||
println!("File: crates/quicproquo-server/src/hooks.rs");
|
|
||||||
println!();
|
|
||||||
println!("Add to the `ServerHooks` trait:");
|
|
||||||
println!();
|
|
||||||
println!(
|
|
||||||
r#" /// Called when {snake} occurs.
|
|
||||||
fn on_{snake}(&self, _event: &{pascal}Event) {{
|
|
||||||
// Default: no-op
|
|
||||||
}}
|
|
||||||
"#,
|
|
||||||
);
|
|
||||||
|
|
||||||
// Step 3: TracingHooks implementation
|
|
||||||
println!("--- Step 3: TracingHooks implementation ---");
|
|
||||||
println!("File: crates/quicproquo-server/src/hooks.rs");
|
|
||||||
println!();
|
|
||||||
println!("Add to `impl ServerHooks for TracingHooks`:");
|
|
||||||
println!();
|
|
||||||
println!(
|
|
||||||
r#" fn on_{snake}(&self, _event: &{pascal}Event) {{
|
|
||||||
tracing::info!("hook: {snake}");
|
|
||||||
}}
|
|
||||||
"#,
|
|
||||||
);
|
|
||||||
|
|
||||||
// Step 4: Plugin API (C-compatible struct)
|
|
||||||
println!("--- Step 4: Plugin API ---");
|
|
||||||
println!("File: crates/quicproquo-plugin-api/src/lib.rs");
|
|
||||||
println!();
|
|
||||||
println!("Add a C-compatible event struct:");
|
|
||||||
println!();
|
|
||||||
println!(
|
|
||||||
r#"#[repr(C)]
|
|
||||||
pub struct C{pascal}Event {{
|
|
||||||
// TODO: mirror the fields from {pascal}Event using C-compatible types
|
|
||||||
// Use *const u8 + len for byte slices, *const c_char for strings
|
|
||||||
}}
|
|
||||||
"#,
|
|
||||||
);
|
|
||||||
println!("Add to `HookVTable`:");
|
|
||||||
println!();
|
|
||||||
println!(
|
|
||||||
r#" pub on_{snake}: Option<extern "C" fn(*mut c_void, *const C{pascal}Event)>,
|
|
||||||
"#,
|
|
||||||
);
|
|
||||||
|
|
||||||
// Step 5: Wire into PluginHooks
|
|
||||||
println!("--- Step 5: PluginHooks dispatch ---");
|
|
||||||
println!("File: crates/quicproquo-server/src/plugin_loader.rs");
|
|
||||||
println!();
|
|
||||||
println!("Add to `impl ServerHooks for PluginHooks`:");
|
|
||||||
println!();
|
|
||||||
println!(
|
|
||||||
r#" fn on_{snake}(&self, event: &{pascal}Event) {{
|
|
||||||
if let Some(hook_fn) = self.vtable.on_{snake} {{
|
|
||||||
let c_event = C{pascal}Event {{
|
|
||||||
// TODO: convert fields
|
|
||||||
}};
|
|
||||||
hook_fn(self.vtable.user_data, &c_event);
|
|
||||||
}}
|
|
||||||
}}
|
|
||||||
"#,
|
|
||||||
);
|
|
||||||
|
|
||||||
// Step 6: Call the hook
|
|
||||||
println!("--- Step 6: Call the hook in the RPC handler ---");
|
|
||||||
println!("In the relevant handler file under crates/quicproquo-server/src/node_service/:");
|
|
||||||
println!();
|
|
||||||
println!(
|
|
||||||
r#" use crate::hooks::{pascal}Event;
|
|
||||||
|
|
||||||
// At the appropriate point in the handler:
|
|
||||||
self.hooks.on_{snake}(&{pascal}Event {{
|
|
||||||
// fill in fields
|
|
||||||
}});
|
|
||||||
"#,
|
|
||||||
);
|
|
||||||
|
|
||||||
// Step 7: Verify
|
|
||||||
println!("--- Step 7: Verify ---");
|
|
||||||
println!(" cargo build -p quicproquo-plugin-api");
|
|
||||||
println!(" cargo build -p quicproquo-server");
|
|
||||||
println!(" cargo test -p quicproquo-server");
|
|
||||||
println!();
|
|
||||||
|
|
||||||
// Summary
|
|
||||||
println!("=== Files to modify ===");
|
|
||||||
println!(" [modify] crates/quicproquo-server/src/hooks.rs");
|
|
||||||
println!(" [modify] crates/quicproquo-plugin-api/src/lib.rs");
|
|
||||||
println!(" [modify] crates/quicproquo-server/src/plugin_loader.rs");
|
|
||||||
println!(" [modify] crates/quicproquo-server/src/node_service/<handler>.rs");
|
|
||||||
|
|
||||||
Ok(())
|
|
||||||
}
|
|
||||||
|
|
||||||
fn to_pascal_case(snake: &str) -> String {
|
|
||||||
snake
|
|
||||||
.split('_')
|
|
||||||
.map(|word| {
|
|
||||||
let mut chars = word.chars();
|
|
||||||
match chars.next() {
|
|
||||||
None => String::new(),
|
|
||||||
Some(c) => c.to_uppercase().to_string() + chars.as_str(),
|
|
||||||
}
|
|
||||||
})
|
|
||||||
.collect()
|
|
||||||
}
|
|
||||||
@@ -1,4 +0,0 @@
|
|||||||
pub mod bot;
|
|
||||||
pub mod hook;
|
|
||||||
pub mod plugin;
|
|
||||||
pub mod rpc;
|
|
||||||
@@ -1,186 +0,0 @@
|
|||||||
use std::fs;
|
|
||||||
use std::path::Path;
|
|
||||||
|
|
||||||
pub fn generate(name: &str, output: &Path) -> Result<(), String> {
|
|
||||||
let crate_name = sanitize_name(name);
|
|
||||||
let dir = output.join(&crate_name);
|
|
||||||
|
|
||||||
if dir.exists() {
|
|
||||||
return Err(format!("directory already exists: {}", dir.display()));
|
|
||||||
}
|
|
||||||
|
|
||||||
let src_dir = dir.join("src");
|
|
||||||
fs::create_dir_all(&src_dir).map_err(|e| format!("create dir: {e}"))?;
|
|
||||||
|
|
||||||
// Cargo.toml
|
|
||||||
let cargo_toml = format!(
|
|
||||||
r#"[package]
|
|
||||||
name = "{crate_name}"
|
|
||||||
version = "0.1.0"
|
|
||||||
edition = "2021"
|
|
||||||
description = "quicproquo server plugin: {name}"
|
|
||||||
license = "MIT"
|
|
||||||
|
|
||||||
[lib]
|
|
||||||
crate-type = ["cdylib"]
|
|
||||||
|
|
||||||
# Empty workspace — this plugin builds independently of the qpq workspace.
|
|
||||||
[workspace]
|
|
||||||
|
|
||||||
[dependencies]
|
|
||||||
quicproquo-plugin-api = {{ git = "https://github.com/nickvidal/quicproquo", default-features = false }}
|
|
||||||
"#,
|
|
||||||
crate_name = crate_name,
|
|
||||||
name = name,
|
|
||||||
);
|
|
||||||
write_file(&dir.join("Cargo.toml"), &cargo_toml)?;
|
|
||||||
|
|
||||||
// src/lib.rs
|
|
||||||
let lib_rs = format!(
|
|
||||||
r#"//! quicproquo server plugin: {name}
|
|
||||||
//!
|
|
||||||
//! Build with: cargo build --release
|
|
||||||
//! Install: cp target/release/lib{crate_name}.so /path/to/plugins/
|
|
||||||
//! The server loads it automatically when started with --plugin-dir.
|
|
||||||
|
|
||||||
use quicproquo_plugin_api::{{HookVTable, CMessageEvent, HOOK_CONTINUE, HOOK_REJECT, PLUGIN_OK}};
|
|
||||||
use std::ffi::CString;
|
|
||||||
use std::os::raw::c_int;
|
|
||||||
|
|
||||||
/// Plugin state — allocate on the heap in init, free in destroy.
|
|
||||||
struct PluginState {{
|
|
||||||
/// Example: maximum allowed payload size in bytes.
|
|
||||||
max_payload_bytes: usize,
|
|
||||||
/// Stored rejection message (must outlive the hook call).
|
|
||||||
reject_msg: Option<CString>,
|
|
||||||
}}
|
|
||||||
|
|
||||||
/// Called by the server on plugin load.
|
|
||||||
///
|
|
||||||
/// Fill the vtable with your hook implementations. Return PLUGIN_OK on success.
|
|
||||||
#[no_mangle]
|
|
||||||
pub extern "C" fn qpq_plugin_init(vtable: *mut HookVTable) -> c_int {{
|
|
||||||
let state = Box::new(PluginState {{
|
|
||||||
max_payload_bytes: 1_000_000, // 1 MB limit
|
|
||||||
reject_msg: None,
|
|
||||||
}});
|
|
||||||
|
|
||||||
let vt = unsafe {{ &mut *vtable }};
|
|
||||||
vt.user_data = Box::into_raw(state) as *mut _;
|
|
||||||
vt.on_message_enqueue = Some(on_message_enqueue);
|
|
||||||
vt.error_message = Some(error_message);
|
|
||||||
vt.destroy = Some(destroy);
|
|
||||||
|
|
||||||
eprintln!("[{name}] plugin loaded");
|
|
||||||
PLUGIN_OK
|
|
||||||
}}
|
|
||||||
|
|
||||||
/// Hook: called before each message is stored in the delivery queue.
|
|
||||||
///
|
|
||||||
/// Return HOOK_CONTINUE to allow, HOOK_REJECT to block.
|
|
||||||
extern "C" fn on_message_enqueue(
|
|
||||||
user_data: *mut std::ffi::c_void,
|
|
||||||
event: *const CMessageEvent,
|
|
||||||
) -> c_int {{
|
|
||||||
let state = unsafe {{ &mut *(user_data as *mut PluginState) }};
|
|
||||||
let event = unsafe {{ &*event }};
|
|
||||||
|
|
||||||
if event.payload_len > state.max_payload_bytes {{
|
|
||||||
let msg = format!(
|
|
||||||
"payload too large: {{}} > {{}} bytes",
|
|
||||||
event.payload_len, state.max_payload_bytes
|
|
||||||
);
|
|
||||||
state.reject_msg = CString::new(msg).ok();
|
|
||||||
return HOOK_REJECT;
|
|
||||||
}}
|
|
||||||
|
|
||||||
HOOK_CONTINUE
|
|
||||||
}}
|
|
||||||
|
|
||||||
/// Return a pointer to the rejection error message (valid until next hook call).
|
|
||||||
extern "C" fn error_message(
|
|
||||||
user_data: *mut std::ffi::c_void,
|
|
||||||
) -> *const std::os::raw::c_char {{
|
|
||||||
let state = unsafe {{ &*(user_data as *const PluginState) }};
|
|
||||||
match &state.reject_msg {{
|
|
||||||
Some(msg) => msg.as_ptr(),
|
|
||||||
None => std::ptr::null(),
|
|
||||||
}}
|
|
||||||
}}
|
|
||||||
|
|
||||||
/// Cleanup: free the plugin state.
|
|
||||||
extern "C" fn destroy(user_data: *mut std::ffi::c_void) {{
|
|
||||||
if !user_data.is_null() {{
|
|
||||||
unsafe {{ drop(Box::from_raw(user_data as *mut PluginState)) }};
|
|
||||||
}}
|
|
||||||
eprintln!("[{name}] plugin unloaded");
|
|
||||||
}}
|
|
||||||
"#,
|
|
||||||
name = name,
|
|
||||||
crate_name = crate_name,
|
|
||||||
);
|
|
||||||
write_file(&src_dir.join("lib.rs"), &lib_rs)?;
|
|
||||||
|
|
||||||
// README
|
|
||||||
let readme = format!(
|
|
||||||
r#"# {name} — quicproquo server plugin
|
|
||||||
|
|
||||||
## Build
|
|
||||||
|
|
||||||
```bash
|
|
||||||
cargo build --release
|
|
||||||
```
|
|
||||||
|
|
||||||
## Install
|
|
||||||
|
|
||||||
Copy the shared library to the server's plugin directory:
|
|
||||||
|
|
||||||
```bash
|
|
||||||
cp target/release/lib{crate_name}.so /path/to/plugins/
|
|
||||||
```
|
|
||||||
|
|
||||||
Start the server with:
|
|
||||||
|
|
||||||
```bash
|
|
||||||
qpq-server --plugin-dir /path/to/plugins/
|
|
||||||
```
|
|
||||||
|
|
||||||
## Hooks
|
|
||||||
|
|
||||||
This plugin implements `on_message_enqueue` to reject oversized payloads.
|
|
||||||
Edit `src/lib.rs` to add your own logic. Available hooks:
|
|
||||||
|
|
||||||
| Hook | Purpose |
|
|
||||||
|------|---------|
|
|
||||||
| `on_message_enqueue` | Inspect/reject messages before delivery (return `HOOK_REJECT`) |
|
|
||||||
| `on_batch_enqueue` | Observe batch message delivery |
|
|
||||||
| `on_auth` | Observe login success/failure |
|
|
||||||
| `on_channel_created` | Observe channel creation |
|
|
||||||
| `on_fetch` | Observe message fetch operations |
|
|
||||||
| `on_user_registered` | Observe new user registration |
|
|
||||||
|
|
||||||
See the [Server Hooks documentation](https://github.com/nickvidal/quicproquo/blob/main/docs/src/internals/server-hooks.md) for details.
|
|
||||||
"#,
|
|
||||||
name = name,
|
|
||||||
crate_name = crate_name,
|
|
||||||
);
|
|
||||||
write_file(&dir.join("README.md"), &readme)?;
|
|
||||||
|
|
||||||
println!("Created plugin project: {}", dir.display());
|
|
||||||
println!();
|
|
||||||
println!(" cd {crate_name}");
|
|
||||||
println!(" cargo build --release");
|
|
||||||
println!(" cp target/release/lib{crate_name}.so /path/to/plugins/");
|
|
||||||
println!();
|
|
||||||
println!("Edit src/lib.rs to implement your hook logic.");
|
|
||||||
|
|
||||||
Ok(())
|
|
||||||
}
|
|
||||||
|
|
||||||
fn sanitize_name(name: &str) -> String {
|
|
||||||
name.replace(['-', ' '], "_")
|
|
||||||
}
|
|
||||||
|
|
||||||
fn write_file(path: &Path, content: &str) -> Result<(), String> {
|
|
||||||
fs::write(path, content).map_err(|e| format!("write {}: {e}", path.display()))
|
|
||||||
}
|
|
||||||
@@ -1,129 +0,0 @@
|
|||||||
pub fn generate(name: &str) -> Result<(), String> {
|
|
||||||
let snake = to_snake_case(name);
|
|
||||||
let camel = name.to_string();
|
|
||||||
println!("=== Adding RPC method: {camel} ===");
|
|
||||||
println!();
|
|
||||||
println!("Follow these steps to add a new `{camel}` RPC method.");
|
|
||||||
println!("Each step shows the file and the code to add.");
|
|
||||||
println!();
|
|
||||||
|
|
||||||
// Step 1: Schema
|
|
||||||
println!("--- Step 1: Cap'n Proto schema ---");
|
|
||||||
println!("File: schemas/node.capnp");
|
|
||||||
println!();
|
|
||||||
println!("Add to the `interface NodeService` block:");
|
|
||||||
println!();
|
|
||||||
println!(
|
|
||||||
r#" {camel} @N (auth :AuthContext, <your params here>) -> (<your results here>);
|
|
||||||
"#,
|
|
||||||
);
|
|
||||||
println!(" (Replace @N with the next ordinal number in the interface.)");
|
|
||||||
println!();
|
|
||||||
println!("Then rebuild the proto crate:");
|
|
||||||
println!(" cargo build -p quicproquo-proto");
|
|
||||||
println!();
|
|
||||||
|
|
||||||
// Step 2: Handler module
|
|
||||||
println!("--- Step 2: Handler module ---");
|
|
||||||
println!("File: crates/quicproquo-server/src/node_service/{snake}.rs");
|
|
||||||
println!();
|
|
||||||
println!(
|
|
||||||
r#"use capnp::capability::Promise;
|
|
||||||
use quicproquo_proto::node_capnp::node_service;
|
|
||||||
|
|
||||||
use crate::auth::{{coded_error, validate_auth_context}};
|
|
||||||
use crate::error_codes::*;
|
|
||||||
|
|
||||||
use super::NodeServiceImpl;
|
|
||||||
|
|
||||||
impl NodeServiceImpl {{
|
|
||||||
pub fn handle_{snake}(
|
|
||||||
&mut self,
|
|
||||||
params: node_service::{camel}Params,
|
|
||||||
mut results: node_service::{camel}Results,
|
|
||||||
) -> Promise<(), capnp::Error> {{
|
|
||||||
let p = match params.get() {{
|
|
||||||
Ok(p) => p,
|
|
||||||
Err(e) => return Promise::err(coded_error(E020_BAD_PARAMS, e)),
|
|
||||||
}};
|
|
||||||
let auth_ctx = match validate_auth_context(&self.auth_cfg, &self.sessions, p.get_auth()) {{
|
|
||||||
Ok(ctx) => ctx,
|
|
||||||
Err(e) => return Promise::err(e),
|
|
||||||
}};
|
|
||||||
|
|
||||||
// TODO: implement your logic here
|
|
||||||
|
|
||||||
Promise::ok(())
|
|
||||||
}}
|
|
||||||
}}
|
|
||||||
"#,
|
|
||||||
);
|
|
||||||
|
|
||||||
// Step 3: Wire into mod.rs
|
|
||||||
println!("--- Step 3: Register in mod.rs ---");
|
|
||||||
println!("File: crates/quicproquo-server/src/node_service/mod.rs");
|
|
||||||
println!();
|
|
||||||
println!("Add to the module declarations at the top:");
|
|
||||||
println!(" mod {snake};");
|
|
||||||
println!();
|
|
||||||
println!("Add to the `impl node_service::Server for NodeServiceImpl` block:");
|
|
||||||
println!();
|
|
||||||
println!(
|
|
||||||
r#" fn {snake}(
|
|
||||||
&mut self,
|
|
||||||
params: node_service::{camel}Params,
|
|
||||||
results: node_service::{camel}Results,
|
|
||||||
) -> capnp::capability::Promise<(), capnp::Error> {{
|
|
||||||
self.handle_{snake}(params, results)
|
|
||||||
}}
|
|
||||||
"#,
|
|
||||||
);
|
|
||||||
|
|
||||||
// Step 4: Storage (if needed)
|
|
||||||
println!("--- Step 4: Storage trait (if needed) ---");
|
|
||||||
println!("File: crates/quicproquo-server/src/storage.rs");
|
|
||||||
println!();
|
|
||||||
println!("If your RPC method needs persistent storage, add a method to the Store trait:");
|
|
||||||
println!();
|
|
||||||
println!(
|
|
||||||
r#" fn {snake}(&self, /* params */) -> Result</* return */, StorageError>;
|
|
||||||
"#,
|
|
||||||
);
|
|
||||||
println!("Then implement it in:");
|
|
||||||
println!(" - crates/quicproquo-server/src/sql_store.rs (SQLite backend)");
|
|
||||||
println!(" - crates/quicproquo-server/src/storage.rs (FileBackedStore)");
|
|
||||||
println!();
|
|
||||||
|
|
||||||
// Step 5: Hook (if needed)
|
|
||||||
println!("--- Step 5: Hook event (optional) ---");
|
|
||||||
println!("If you want plugins to observe this RPC, run:");
|
|
||||||
println!(" qpq-gen hook {snake}");
|
|
||||||
println!();
|
|
||||||
|
|
||||||
// Step 6: Verify
|
|
||||||
println!("--- Step 6: Verify ---");
|
|
||||||
println!(" cargo build -p quicproquo-server");
|
|
||||||
println!(" cargo test -p quicproquo-server");
|
|
||||||
println!();
|
|
||||||
|
|
||||||
// Summary
|
|
||||||
println!("=== Files to create/modify ===");
|
|
||||||
println!(" [modify] schemas/node.capnp");
|
|
||||||
println!(" [create] crates/quicproquo-server/src/node_service/{snake}.rs");
|
|
||||||
println!(" [modify] crates/quicproquo-server/src/node_service/mod.rs");
|
|
||||||
println!(" [modify] crates/quicproquo-server/src/storage.rs (if needed)");
|
|
||||||
println!(" [modify] crates/quicproquo-server/src/sql_store.rs (if needed)");
|
|
||||||
|
|
||||||
Ok(())
|
|
||||||
}
|
|
||||||
|
|
||||||
fn to_snake_case(s: &str) -> String {
|
|
||||||
let mut result = String::with_capacity(s.len() + 4);
|
|
||||||
for (i, ch) in s.chars().enumerate() {
|
|
||||||
if ch.is_uppercase() && i > 0 {
|
|
||||||
result.push('_');
|
|
||||||
}
|
|
||||||
result.push(ch.to_ascii_lowercase());
|
|
||||||
}
|
|
||||||
result
|
|
||||||
}
|
|
||||||
@@ -1,55 +0,0 @@
|
|||||||
use clap::{Parser, Subcommand};
|
|
||||||
use std::path::PathBuf;
|
|
||||||
|
|
||||||
mod generators;
|
|
||||||
|
|
||||||
#[derive(Parser)]
|
|
||||||
#[command(name = "qpq-gen", about = "Code generators for quicproquo")]
|
|
||||||
struct Cli {
|
|
||||||
#[command(subcommand)]
|
|
||||||
command: Command,
|
|
||||||
}
|
|
||||||
|
|
||||||
#[derive(Subcommand)]
|
|
||||||
enum Command {
|
|
||||||
/// Scaffold a new server plugin (dynamic .so/.dylib)
|
|
||||||
Plugin {
|
|
||||||
/// Plugin name (e.g. "rate-limiter", "audit-log")
|
|
||||||
name: String,
|
|
||||||
/// Output directory (default: current directory)
|
|
||||||
#[arg(short, long, default_value = ".")]
|
|
||||||
output: PathBuf,
|
|
||||||
},
|
|
||||||
/// Scaffold a new bot project using the Bot SDK
|
|
||||||
Bot {
|
|
||||||
/// Bot name (e.g. "echo-bot", "moderation-bot")
|
|
||||||
name: String,
|
|
||||||
/// Output directory (default: current directory)
|
|
||||||
#[arg(short, long, default_value = ".")]
|
|
||||||
output: PathBuf,
|
|
||||||
},
|
|
||||||
/// Show instructions for adding a new Cap'n Proto RPC method
|
|
||||||
Rpc {
|
|
||||||
/// RPC method name in camelCase (e.g. "listChannels")
|
|
||||||
name: String,
|
|
||||||
},
|
|
||||||
/// Show instructions for adding a new server hook event
|
|
||||||
Hook {
|
|
||||||
/// Hook event name in snake_case (e.g. "message_deleted")
|
|
||||||
name: String,
|
|
||||||
},
|
|
||||||
}
|
|
||||||
|
|
||||||
fn main() {
|
|
||||||
let cli = Cli::parse();
|
|
||||||
let result = match cli.command {
|
|
||||||
Command::Plugin { name, output } => generators::plugin::generate(&name, &output),
|
|
||||||
Command::Bot { name, output } => generators::bot::generate(&name, &output),
|
|
||||||
Command::Rpc { name } => generators::rpc::generate(&name),
|
|
||||||
Command::Hook { name } => generators::hook::generate(&name),
|
|
||||||
};
|
|
||||||
if let Err(e) = result {
|
|
||||||
eprintln!("error: {e}");
|
|
||||||
std::process::exit(1);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,22 +0,0 @@
|
|||||||
[package]
|
|
||||||
name = "quicproquo-gui"
|
|
||||||
version = "0.1.0"
|
|
||||||
edition = "2021"
|
|
||||||
description = "Native GUI for quicproquo (Tauri 2)."
|
|
||||||
license = "MIT"
|
|
||||||
|
|
||||||
[[bin]]
|
|
||||||
name = "qpq-gui"
|
|
||||||
path = "src/main.rs"
|
|
||||||
|
|
||||||
[dependencies]
|
|
||||||
quicproquo-core = { path = "../quicproquo-core" }
|
|
||||||
quicproquo-client = { path = "../quicproquo-client" }
|
|
||||||
quicproquo-proto = { path = "../quicproquo-proto" }
|
|
||||||
tauri = { version = "2", features = [] }
|
|
||||||
tokio = { workspace = true }
|
|
||||||
serde = { workspace = true }
|
|
||||||
serde_json = { workspace = true }
|
|
||||||
|
|
||||||
[build-dependencies]
|
|
||||||
tauri-build = "2"
|
|
||||||
@@ -1,32 +0,0 @@
|
|||||||
# quicproquo-gui
|
|
||||||
|
|
||||||
Native GUI for quicproquo using [Tauri 2](https://v2.tauri.app/). The UI runs in a webview; all server-facing work (capnp-rpc, `node_service::Client`) runs on a **dedicated backend thread** with a tokio `LocalSet`, since that code is `!Send`.
|
|
||||||
|
|
||||||
## Backend threading model
|
|
||||||
|
|
||||||
- A single **backend thread** runs a tokio `LocalSet` and a request-response loop.
|
|
||||||
- The UI thread sends commands over an `mpsc` channel: `Whoami { state_path, password }` or `Health { server, ca_cert, server_name }`.
|
|
||||||
- For each request, the backend runs sync code (whoami) or `LocalSet::run_until(async { ... })` (health). It then sends `Result<String, String>` back on the provided reply channel.
|
|
||||||
- Tauri commands (`whoami`, `health`) block on that reply so the frontend gets a simple async-style result.
|
|
||||||
|
|
||||||
## How to run
|
|
||||||
|
|
||||||
From the workspace root:
|
|
||||||
|
|
||||||
```bash
|
|
||||||
cargo run -p quicproquo-gui
|
|
||||||
```
|
|
||||||
|
|
||||||
**Linux:** Tauri uses GTK. Install development packages if the build fails, e.g.:
|
|
||||||
|
|
||||||
- Debian/Ubuntu: `sudo apt install libgtk-3-dev libwebkit2gtk-4.1-dev`
|
|
||||||
- Fedora: `sudo dnf install gtk3-devel webkit2gtk4.1-devel`
|
|
||||||
|
|
||||||
## Frontend
|
|
||||||
|
|
||||||
The frontend is static HTML in `ui/index.html` (no npm or build step). It provides:
|
|
||||||
|
|
||||||
- **Whoami** – state path (and optional password); calls `whoami` and shows JSON (identity_key, fingerprint, etc.).
|
|
||||||
- **Health** – server address; calls `health` and shows server status and RTT JSON.
|
|
||||||
|
|
||||||
Default CA cert and server name for health are the same as the CLI (`data/server-cert.der`, `localhost`) unless overridden via optional params.
|
|
||||||
@@ -1,3 +0,0 @@
|
|||||||
fn main() {
|
|
||||||
tauri_build::build()
|
|
||||||
}
|
|
||||||
@@ -1,11 +0,0 @@
|
|||||||
{
|
|
||||||
"$schema": "https://schema.tauri.app/config/2/capability",
|
|
||||||
"identifier": "default",
|
|
||||||
"description": "Capability for the main window (custom commands whoami, health are allowed by default)",
|
|
||||||
"windows": ["main"],
|
|
||||||
"permissions": [
|
|
||||||
"core:default",
|
|
||||||
"core:window:allow-close",
|
|
||||||
"core:window:allow-set-title"
|
|
||||||
]
|
|
||||||
}
|
|
||||||
File diff suppressed because one or more lines are too long
@@ -1 +0,0 @@
|
|||||||
{"default":{"identifier":"default","description":"Capability for the main window (custom commands whoami, health are allowed by default)","local":true,"windows":["main"],"permissions":["core:default","core:window:allow-close","core:window:allow-set-title"]}}
|
|
||||||
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
Binary file not shown.
|
Before Width: | Height: | Size: 2.1 KiB |
@@ -1,86 +0,0 @@
|
|||||||
//! Backend service running on a dedicated thread with a tokio LocalSet.
|
|
||||||
//!
|
|
||||||
//! All server-facing work (capnp-rpc, node_service::Client) is !Send and must run on this
|
|
||||||
//! single thread. The UI thread sends commands over a channel; this thread runs
|
|
||||||
//! `LocalSet::run_until` for each request and sends the result back.
|
|
||||||
|
|
||||||
use std::path::PathBuf;
|
|
||||||
use std::sync::mpsc;
|
|
||||||
use std::thread;
|
|
||||||
|
|
||||||
use tokio::runtime::Builder;
|
|
||||||
use tokio::task::LocalSet;
|
|
||||||
|
|
||||||
use quicproquo_client::{cmd_health_json, whoami_json};
|
|
||||||
|
|
||||||
/// Commands the UI can send to the backend thread.
|
|
||||||
pub enum BackendCommand {
|
|
||||||
Whoami {
|
|
||||||
state_path: String,
|
|
||||||
password: Option<String>,
|
|
||||||
},
|
|
||||||
Health {
|
|
||||||
server: String,
|
|
||||||
ca_cert: PathBuf,
|
|
||||||
server_name: String,
|
|
||||||
},
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Response sent back to the UI.
|
|
||||||
pub type BackendResponse = Result<String, String>;
|
|
||||||
|
|
||||||
/// Spawn the backend thread and return a sender to post commands and a join handle.
|
|
||||||
/// The backend runs a tokio LocalSet and processes one command at a time:
|
|
||||||
/// for each received command it runs `LocalSet::run_until(future)` (for async commands)
|
|
||||||
/// or runs sync code (whoami), then sends the result on the provided reply channel.
|
|
||||||
pub fn spawn_backend() -> (mpsc::Sender<(BackendCommand, mpsc::Sender<BackendResponse>)>, thread::JoinHandle<()>) {
|
|
||||||
let (tx, rx) = mpsc::channel::<(BackendCommand, mpsc::Sender<BackendResponse>)>();
|
|
||||||
|
|
||||||
let handle = thread::spawn(move || {
|
|
||||||
let rt = Builder::new_current_thread()
|
|
||||||
.enable_all()
|
|
||||||
.build()
|
|
||||||
.expect("backend tokio runtime");
|
|
||||||
let local = LocalSet::new();
|
|
||||||
|
|
||||||
while let Ok((cmd, reply_tx)) = rx.recv() {
|
|
||||||
let result = run_command(&local, &rt, cmd);
|
|
||||||
let _ = reply_tx.send(result);
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
(tx, handle)
|
|
||||||
}
|
|
||||||
|
|
||||||
fn run_command(
|
|
||||||
local: &LocalSet,
|
|
||||||
rt: &tokio::runtime::Runtime,
|
|
||||||
cmd: BackendCommand,
|
|
||||||
) -> BackendResponse {
|
|
||||||
match cmd {
|
|
||||||
BackendCommand::Whoami { state_path, password } => {
|
|
||||||
let path = PathBuf::from(&state_path);
|
|
||||||
whoami_json(&path, password.as_deref()).map_err(|e| e.to_string())
|
|
||||||
}
|
|
||||||
BackendCommand::Health {
|
|
||||||
server,
|
|
||||||
ca_cert,
|
|
||||||
server_name,
|
|
||||||
} => {
|
|
||||||
// Request-response: we run LocalSet::run_until for this single request so capnp-rpc
|
|
||||||
// and connect_node stay on this thread (!Send).
|
|
||||||
let fut = cmd_health_json(&server, &ca_cert, &server_name);
|
|
||||||
rt.block_on(local.run_until(fut)).map_err(|e| e.to_string())
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Default CA cert path (relative to cwd or absolute); same default as CLI.
|
|
||||||
pub fn default_ca_cert() -> PathBuf {
|
|
||||||
PathBuf::from("data/server-cert.der")
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Default TLS server name.
|
|
||||||
pub fn default_server_name() -> String {
|
|
||||||
"localhost".to_string()
|
|
||||||
}
|
|
||||||
@@ -1,76 +0,0 @@
|
|||||||
//! quicproquo native GUI (Tauri 2).
|
|
||||||
//!
|
|
||||||
//! The backend runs on a dedicated thread with a tokio LocalSet; all server-facing
|
|
||||||
//! work (capnp-rpc, node_service::Client) is dispatched there. Tauri commands
|
|
||||||
//! block on the request-response channel until the backend returns.
|
|
||||||
|
|
||||||
mod backend;
|
|
||||||
|
|
||||||
use std::path::PathBuf;
|
|
||||||
use std::sync::mpsc;
|
|
||||||
|
|
||||||
use backend::{spawn_backend, BackendCommand};
|
|
||||||
|
|
||||||
/// Shared state: sender to the backend thread.
|
|
||||||
struct BackendState {
|
|
||||||
tx: mpsc::Sender<(BackendCommand, mpsc::Sender<backend::BackendResponse>)>,
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Runs whoami on the backend thread and returns JSON string (identity_key, fingerprint, etc.).
|
|
||||||
#[tauri::command]
|
|
||||||
fn whoami(
|
|
||||||
state: tauri::State<BackendState>,
|
|
||||||
state_path: String,
|
|
||||||
password: Option<String>,
|
|
||||||
) -> Result<String, String> {
|
|
||||||
let (reply_tx, reply_rx) = mpsc::channel();
|
|
||||||
state
|
|
||||||
.tx
|
|
||||||
.send((
|
|
||||||
BackendCommand::Whoami {
|
|
||||||
state_path,
|
|
||||||
password,
|
|
||||||
},
|
|
||||||
reply_tx,
|
|
||||||
))
|
|
||||||
.map_err(|e| e.to_string())?;
|
|
||||||
reply_rx.recv().map_err(|e| e.to_string())?
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Runs health check on the backend thread (LocalSet::run_until) and returns status JSON.
|
|
||||||
#[tauri::command]
|
|
||||||
fn health(
|
|
||||||
state: tauri::State<BackendState>,
|
|
||||||
server: String,
|
|
||||||
ca_cert: Option<String>,
|
|
||||||
server_name: Option<String>,
|
|
||||||
) -> Result<String, String> {
|
|
||||||
let ca_cert = ca_cert
|
|
||||||
.map(PathBuf::from)
|
|
||||||
.unwrap_or_else(backend::default_ca_cert);
|
|
||||||
let server_name = server_name.unwrap_or_else(backend::default_server_name);
|
|
||||||
let (reply_tx, reply_rx) = mpsc::channel();
|
|
||||||
state
|
|
||||||
.tx
|
|
||||||
.send((
|
|
||||||
BackendCommand::Health {
|
|
||||||
server,
|
|
||||||
ca_cert,
|
|
||||||
server_name,
|
|
||||||
},
|
|
||||||
reply_tx,
|
|
||||||
))
|
|
||||||
.map_err(|e| e.to_string())?;
|
|
||||||
reply_rx.recv().map_err(|e| e.to_string())?
|
|
||||||
}
|
|
||||||
|
|
||||||
#[cfg_attr(mobile, tauri::mobile_entry_point)]
|
|
||||||
pub fn run() {
|
|
||||||
let (backend_tx, _backend_handle) = spawn_backend();
|
|
||||||
|
|
||||||
tauri::Builder::default()
|
|
||||||
.manage(BackendState { tx: backend_tx })
|
|
||||||
.invoke_handler(tauri::generate_handler![whoami, health])
|
|
||||||
.run(tauri::generate_context!())
|
|
||||||
.expect("error while running tauri application");
|
|
||||||
}
|
|
||||||
@@ -1,5 +0,0 @@
|
|||||||
//! Desktop entry point for quicproquo-gui.
|
|
||||||
|
|
||||||
fn main() {
|
|
||||||
quicproquo_gui::run()
|
|
||||||
}
|
|
||||||
@@ -1,24 +0,0 @@
|
|||||||
{
|
|
||||||
"$schema": "https://schema.tauri.app/config/2",
|
|
||||||
"productName": "qpq-gui",
|
|
||||||
"identifier": "chat.quicproquo.gui",
|
|
||||||
"build": {
|
|
||||||
"frontendDist": "./ui",
|
|
||||||
"beforeBuildCommand": "",
|
|
||||||
"beforeDevCommand": ""
|
|
||||||
},
|
|
||||||
"app": {
|
|
||||||
"windows": [
|
|
||||||
{
|
|
||||||
"title": "quicproquo",
|
|
||||||
"width": 640,
|
|
||||||
"height": 480
|
|
||||||
}
|
|
||||||
],
|
|
||||||
"security": {
|
|
||||||
"csp": null
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"bundle": {},
|
|
||||||
"plugins": {}
|
|
||||||
}
|
|
||||||
@@ -1,54 +0,0 @@
|
|||||||
<!DOCTYPE html>
|
|
||||||
<html lang="en">
|
|
||||||
<head>
|
|
||||||
<meta charset="UTF-8" />
|
|
||||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
|
||||||
<title>quicproquo</title>
|
|
||||||
<style>
|
|
||||||
body { font-family: system-ui, sans-serif; margin: 1rem; }
|
|
||||||
button { margin: 0.25rem; padding: 0.5rem 1rem; cursor: pointer; }
|
|
||||||
#output { white-space: pre-wrap; background: #f0f0f0; padding: 0.75rem; margin-top: 1rem; min-height: 4rem; border-radius: 4px; }
|
|
||||||
.error { color: #c00; }
|
|
||||||
</style>
|
|
||||||
</head>
|
|
||||||
<body>
|
|
||||||
<h1>quicproquo</h1>
|
|
||||||
<p>
|
|
||||||
<button id="whoami">Whoami</button>
|
|
||||||
<button id="health">Health</button>
|
|
||||||
</p>
|
|
||||||
<label>State path: <input id="statePath" type="text" value="qpq-state.bin" size="32" /></label>
|
|
||||||
<br />
|
|
||||||
<label>Server: <input id="server" type="text" value="127.0.0.1:7000" size="24" /></label>
|
|
||||||
<div id="output">Click Whoami or Health. Results appear here.</div>
|
|
||||||
|
|
||||||
<script>
|
|
||||||
const output = document.getElementById('output');
|
|
||||||
const statePath = document.getElementById('statePath');
|
|
||||||
const server = document.getElementById('server');
|
|
||||||
|
|
||||||
function show(result, isError = false) {
|
|
||||||
output.textContent = result;
|
|
||||||
output.className = isError ? 'error' : '';
|
|
||||||
}
|
|
||||||
|
|
||||||
const invoke = window.__TAURI__?.core?.invoke;
|
|
||||||
if (!invoke) {
|
|
||||||
show('Tauri API not available (not running inside Tauri?).', true);
|
|
||||||
} else {
|
|
||||||
document.getElementById('whoami').addEventListener('click', function () {
|
|
||||||
show('Running whoami…');
|
|
||||||
invoke('whoami', { statePath: statePath.value.trim(), password: null })
|
|
||||||
.then(function (s) { show(s); })
|
|
||||||
.catch(function (e) { show(String(e), true); });
|
|
||||||
});
|
|
||||||
document.getElementById('health').addEventListener('click', function () {
|
|
||||||
show('Running health…');
|
|
||||||
invoke('health', { server: server.value.trim() })
|
|
||||||
.then(function (s) { show(s); })
|
|
||||||
.catch(function (e) { show(String(e), true); });
|
|
||||||
});
|
|
||||||
}
|
|
||||||
</script>
|
|
||||||
</body>
|
|
||||||
</html>
|
|
||||||
@@ -1,32 +0,0 @@
|
|||||||
[package]
|
|
||||||
name = "quicproquo-mobile"
|
|
||||||
version = "0.1.0"
|
|
||||||
edition = "2021"
|
|
||||||
description = "C FFI layer for quicproquo, proving QUIC connection migration."
|
|
||||||
license = "MIT"
|
|
||||||
|
|
||||||
[features]
|
|
||||||
default = []
|
|
||||||
# Enable SkipServerVerification for development/testing only.
|
|
||||||
# NEVER enable in production — this disables TLS certificate validation.
|
|
||||||
insecure-dev = []
|
|
||||||
|
|
||||||
[lib]
|
|
||||||
crate-type = ["staticlib", "cdylib", "rlib"]
|
|
||||||
|
|
||||||
[dependencies]
|
|
||||||
# Async
|
|
||||||
tokio = { workspace = true }
|
|
||||||
|
|
||||||
# QUIC
|
|
||||||
quinn = { workspace = true }
|
|
||||||
rustls = { workspace = true }
|
|
||||||
|
|
||||||
# TLS root certificates (used when insecure-dev is NOT enabled)
|
|
||||||
webpki-roots = "0.26"
|
|
||||||
|
|
||||||
# Error handling
|
|
||||||
anyhow = { workspace = true }
|
|
||||||
|
|
||||||
[dev-dependencies]
|
|
||||||
rcgen = { workspace = true }
|
|
||||||
@@ -1,433 +0,0 @@
|
|||||||
//! quicproquo-mobile — C FFI layer for mobile integration.
|
|
||||||
//!
|
|
||||||
//! Provides a minimal C API that proves QUIC connection migration works
|
|
||||||
//! (wifi → cellular handoff without message loss). Each FFI function uses
|
|
||||||
//! `runtime.block_on(local.run_until(...))` to satisfy capnp-rpc's `!Send`
|
|
||||||
//! requirement.
|
|
||||||
//!
|
|
||||||
//! # Safety
|
|
||||||
//!
|
|
||||||
//! All FFI functions are `unsafe extern "C"` — callers must ensure pointers
|
|
||||||
//! are valid and buffers are correctly sized.
|
|
||||||
|
|
||||||
use std::ffi::c_char;
|
|
||||||
use std::net::SocketAddr;
|
|
||||||
use std::sync::Arc;
|
|
||||||
|
|
||||||
use quinn::Endpoint;
|
|
||||||
use tokio::runtime::Runtime;
|
|
||||||
|
|
||||||
/// Opaque handle returned by `qnpc_connect`.
|
|
||||||
#[allow(dead_code)]
|
|
||||||
pub struct MobileHandle {
|
|
||||||
runtime: Runtime,
|
|
||||||
endpoint: Endpoint,
|
|
||||||
connection: Option<quinn::Connection>,
|
|
||||||
server_addr: SocketAddr,
|
|
||||||
server_name: String,
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Status codes returned by FFI functions.
|
|
||||||
#[repr(C)]
|
|
||||||
pub enum QnpcStatus {
|
|
||||||
Ok = 0,
|
|
||||||
Error = 1,
|
|
||||||
Timeout = 2,
|
|
||||||
NotConnected = 3,
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Connect to a quicproquo server. Returns a handle pointer (null on failure).
|
|
||||||
///
|
|
||||||
/// # Safety
|
|
||||||
/// `server_addr` and `server_name` must be valid null-terminated C strings.
|
|
||||||
#[no_mangle]
|
|
||||||
pub unsafe extern "C" fn qnpc_connect(
|
|
||||||
server_addr: *const c_char,
|
|
||||||
server_name: *const c_char,
|
|
||||||
) -> *mut MobileHandle {
|
|
||||||
let addr_str = match std::ffi::CStr::from_ptr(server_addr).to_str() {
|
|
||||||
Ok(s) => s,
|
|
||||||
Err(_) => return std::ptr::null_mut(),
|
|
||||||
};
|
|
||||||
let name_str = match std::ffi::CStr::from_ptr(server_name).to_str() {
|
|
||||||
Ok(s) => s,
|
|
||||||
Err(_) => return std::ptr::null_mut(),
|
|
||||||
};
|
|
||||||
|
|
||||||
let addr: SocketAddr = match addr_str.parse() {
|
|
||||||
Ok(a) => a,
|
|
||||||
Err(_) => return std::ptr::null_mut(),
|
|
||||||
};
|
|
||||||
|
|
||||||
let rt = match Runtime::new() {
|
|
||||||
Ok(r) => r,
|
|
||||||
Err(_) => return std::ptr::null_mut(),
|
|
||||||
};
|
|
||||||
|
|
||||||
let result = rt.block_on(async {
|
|
||||||
connect_inner(addr, name_str).await
|
|
||||||
});
|
|
||||||
|
|
||||||
match result {
|
|
||||||
Ok((endpoint, connection)) => {
|
|
||||||
let handle = Box::new(MobileHandle {
|
|
||||||
runtime: rt,
|
|
||||||
endpoint,
|
|
||||||
connection: Some(connection),
|
|
||||||
server_addr: addr,
|
|
||||||
server_name: name_str.to_string(),
|
|
||||||
});
|
|
||||||
Box::into_raw(handle)
|
|
||||||
}
|
|
||||||
Err(_) => std::ptr::null_mut(),
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
async fn connect_inner(
|
|
||||||
addr: SocketAddr,
|
|
||||||
server_name: &str,
|
|
||||||
) -> anyhow::Result<(Endpoint, quinn::Connection)> {
|
|
||||||
let _ = rustls::crypto::ring::default_provider().install_default();
|
|
||||||
|
|
||||||
let crypto = build_client_tls_config()?;
|
|
||||||
|
|
||||||
let mut client_config = quinn::ClientConfig::new(Arc::new(
|
|
||||||
quinn::crypto::rustls::QuicClientConfig::try_from(crypto)
|
|
||||||
.map_err(|e| anyhow::anyhow!("QUIC client config: {e}"))?,
|
|
||||||
));
|
|
||||||
|
|
||||||
let mut transport = quinn::TransportConfig::default();
|
|
||||||
transport.max_idle_timeout(Some(
|
|
||||||
std::time::Duration::from_secs(120)
|
|
||||||
.try_into()
|
|
||||||
.expect("120s valid"),
|
|
||||||
));
|
|
||||||
client_config.transport_config(Arc::new(transport));
|
|
||||||
|
|
||||||
let mut endpoint = Endpoint::client("0.0.0.0:0".parse().unwrap())?;
|
|
||||||
endpoint.set_default_client_config(client_config);
|
|
||||||
|
|
||||||
let connection = endpoint.connect(addr, server_name)?.await?;
|
|
||||||
Ok((endpoint, connection))
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Simulate QUIC connection migration by rebinding the endpoint to a new local address.
|
|
||||||
///
|
|
||||||
/// This is the key proof-of-concept: after rebind, the QUIC connection survives
|
|
||||||
/// and messages continue flowing without loss.
|
|
||||||
///
|
|
||||||
/// # Safety
|
|
||||||
/// `handle` must be a valid pointer from `qnpc_connect`.
|
|
||||||
#[no_mangle]
|
|
||||||
pub unsafe extern "C" fn qnpc_migrate(
|
|
||||||
handle: *mut MobileHandle,
|
|
||||||
new_port: u16,
|
|
||||||
) -> QnpcStatus {
|
|
||||||
let handle = match handle.as_mut() {
|
|
||||||
Some(h) => h,
|
|
||||||
None => return QnpcStatus::Error,
|
|
||||||
};
|
|
||||||
|
|
||||||
let new_addr: SocketAddr = format!("0.0.0.0:{new_port}").parse().unwrap();
|
|
||||||
let socket = match std::net::UdpSocket::bind(new_addr) {
|
|
||||||
Ok(s) => s,
|
|
||||||
Err(_) => return QnpcStatus::Error,
|
|
||||||
};
|
|
||||||
|
|
||||||
match handle.endpoint.rebind(socket) {
|
|
||||||
Ok(_) => QnpcStatus::Ok,
|
|
||||||
Err(_) => QnpcStatus::Error,
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Disconnect and free the handle.
|
|
||||||
///
|
|
||||||
/// # Safety
|
|
||||||
/// `handle` must be a valid pointer from `qnpc_connect`, and must not be used after this call.
|
|
||||||
#[no_mangle]
|
|
||||||
pub unsafe extern "C" fn qnpc_disconnect(handle: *mut MobileHandle) {
|
|
||||||
if !handle.is_null() {
|
|
||||||
let handle = Box::from_raw(handle);
|
|
||||||
if let Some(conn) = &handle.connection {
|
|
||||||
conn.close(0u32.into(), b"disconnect");
|
|
||||||
}
|
|
||||||
drop(handle);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// ── TLS configuration ───────────────────────────────────────────────────────
|
|
||||||
|
|
||||||
/// Build the rustls `ClientConfig` for the QUIC transport.
|
|
||||||
///
|
|
||||||
/// Without the `insecure-dev` feature, this uses the platform's native root
|
|
||||||
/// certificates for server verification. With `insecure-dev` enabled, all
|
|
||||||
/// certificate verification is skipped (MITM-vulnerable — dev/testing only).
|
|
||||||
fn build_client_tls_config() -> anyhow::Result<rustls::ClientConfig> {
|
|
||||||
#[cfg(feature = "insecure-dev")]
|
|
||||||
{
|
|
||||||
Ok(rustls::ClientConfig::builder()
|
|
||||||
.dangerous()
|
|
||||||
.with_custom_certificate_verifier(Arc::new(SkipServerVerification))
|
|
||||||
.with_no_client_auth())
|
|
||||||
}
|
|
||||||
#[cfg(not(feature = "insecure-dev"))]
|
|
||||||
{
|
|
||||||
let mut root_store = rustls::RootCertStore::empty();
|
|
||||||
root_store.extend(webpki_roots::TLS_SERVER_ROOTS.iter().cloned());
|
|
||||||
Ok(rustls::ClientConfig::builder()
|
|
||||||
.with_root_certificates(root_store)
|
|
||||||
.with_no_client_auth())
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
#[cfg(feature = "insecure-dev")]
|
|
||||||
#[derive(Debug)]
|
|
||||||
struct SkipServerVerification;
|
|
||||||
|
|
||||||
#[cfg(feature = "insecure-dev")]
|
|
||||||
impl rustls::client::danger::ServerCertVerifier for SkipServerVerification {
|
|
||||||
fn verify_server_cert(
|
|
||||||
&self,
|
|
||||||
_end_entity: &rustls::pki_types::CertificateDer<'_>,
|
|
||||||
_intermediates: &[rustls::pki_types::CertificateDer<'_>],
|
|
||||||
_server_name: &rustls::pki_types::ServerName<'_>,
|
|
||||||
_ocsp_response: &[u8],
|
|
||||||
_now: rustls::pki_types::UnixTime,
|
|
||||||
) -> Result<rustls::client::danger::ServerCertVerified, rustls::Error> {
|
|
||||||
Ok(rustls::client::danger::ServerCertVerified::assertion())
|
|
||||||
}
|
|
||||||
|
|
||||||
fn verify_tls12_signature(
|
|
||||||
&self,
|
|
||||||
_message: &[u8],
|
|
||||||
_cert: &rustls::pki_types::CertificateDer<'_>,
|
|
||||||
_dss: &rustls::DigitallySignedStruct,
|
|
||||||
) -> Result<rustls::client::danger::HandshakeSignatureValid, rustls::Error> {
|
|
||||||
Ok(rustls::client::danger::HandshakeSignatureValid::assertion())
|
|
||||||
}
|
|
||||||
|
|
||||||
fn verify_tls13_signature(
|
|
||||||
&self,
|
|
||||||
_message: &[u8],
|
|
||||||
_cert: &rustls::pki_types::CertificateDer<'_>,
|
|
||||||
_dss: &rustls::DigitallySignedStruct,
|
|
||||||
) -> Result<rustls::client::danger::HandshakeSignatureValid, rustls::Error> {
|
|
||||||
Ok(rustls::client::danger::HandshakeSignatureValid::assertion())
|
|
||||||
}
|
|
||||||
|
|
||||||
fn supported_verify_schemes(&self) -> Vec<rustls::SignatureScheme> {
|
|
||||||
vec![
|
|
||||||
rustls::SignatureScheme::ECDSA_NISTP256_SHA256,
|
|
||||||
rustls::SignatureScheme::ECDSA_NISTP384_SHA384,
|
|
||||||
rustls::SignatureScheme::ED25519,
|
|
||||||
rustls::SignatureScheme::RSA_PSS_SHA256,
|
|
||||||
rustls::SignatureScheme::RSA_PSS_SHA384,
|
|
||||||
rustls::SignatureScheme::RSA_PSS_SHA512,
|
|
||||||
rustls::SignatureScheme::RSA_PKCS1_SHA256,
|
|
||||||
rustls::SignatureScheme::RSA_PKCS1_SHA384,
|
|
||||||
rustls::SignatureScheme::RSA_PKCS1_SHA512,
|
|
||||||
]
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// ── Tests ───────────────────────────────────────────────────────────────────
|
|
||||||
|
|
||||||
#[cfg(test)]
|
|
||||||
mod tests {
|
|
||||||
use super::*;
|
|
||||||
use std::net::UdpSocket;
|
|
||||||
|
|
||||||
/// Test-only insecure verifier (always available in test builds).
|
|
||||||
#[derive(Debug)]
|
|
||||||
struct TestSkipServerVerification;
|
|
||||||
|
|
||||||
impl rustls::client::danger::ServerCertVerifier for TestSkipServerVerification {
|
|
||||||
fn verify_server_cert(
|
|
||||||
&self,
|
|
||||||
_end_entity: &rustls::pki_types::CertificateDer<'_>,
|
|
||||||
_intermediates: &[rustls::pki_types::CertificateDer<'_>],
|
|
||||||
_server_name: &rustls::pki_types::ServerName<'_>,
|
|
||||||
_ocsp_response: &[u8],
|
|
||||||
_now: rustls::pki_types::UnixTime,
|
|
||||||
) -> Result<rustls::client::danger::ServerCertVerified, rustls::Error> {
|
|
||||||
Ok(rustls::client::danger::ServerCertVerified::assertion())
|
|
||||||
}
|
|
||||||
|
|
||||||
fn verify_tls12_signature(
|
|
||||||
&self,
|
|
||||||
_message: &[u8],
|
|
||||||
_cert: &rustls::pki_types::CertificateDer<'_>,
|
|
||||||
_dss: &rustls::DigitallySignedStruct,
|
|
||||||
) -> Result<rustls::client::danger::HandshakeSignatureValid, rustls::Error> {
|
|
||||||
Ok(rustls::client::danger::HandshakeSignatureValid::assertion())
|
|
||||||
}
|
|
||||||
|
|
||||||
fn verify_tls13_signature(
|
|
||||||
&self,
|
|
||||||
_message: &[u8],
|
|
||||||
_cert: &rustls::pki_types::CertificateDer<'_>,
|
|
||||||
_dss: &rustls::DigitallySignedStruct,
|
|
||||||
) -> Result<rustls::client::danger::HandshakeSignatureValid, rustls::Error> {
|
|
||||||
Ok(rustls::client::danger::HandshakeSignatureValid::assertion())
|
|
||||||
}
|
|
||||||
|
|
||||||
fn supported_verify_schemes(&self) -> Vec<rustls::SignatureScheme> {
|
|
||||||
vec![
|
|
||||||
rustls::SignatureScheme::ECDSA_NISTP256_SHA256,
|
|
||||||
rustls::SignatureScheme::ECDSA_NISTP384_SHA384,
|
|
||||||
rustls::SignatureScheme::ED25519,
|
|
||||||
rustls::SignatureScheme::RSA_PSS_SHA256,
|
|
||||||
rustls::SignatureScheme::RSA_PSS_SHA384,
|
|
||||||
rustls::SignatureScheme::RSA_PSS_SHA512,
|
|
||||||
rustls::SignatureScheme::RSA_PKCS1_SHA256,
|
|
||||||
rustls::SignatureScheme::RSA_PKCS1_SHA384,
|
|
||||||
rustls::SignatureScheme::RSA_PKCS1_SHA512,
|
|
||||||
]
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Connect to a test server using the insecure cert verifier.
|
|
||||||
async fn test_connect_inner(
|
|
||||||
addr: SocketAddr,
|
|
||||||
server_name: &str,
|
|
||||||
) -> anyhow::Result<(Endpoint, quinn::Connection)> {
|
|
||||||
let _ = rustls::crypto::ring::default_provider().install_default();
|
|
||||||
|
|
||||||
let crypto = rustls::ClientConfig::builder()
|
|
||||||
.dangerous()
|
|
||||||
.with_custom_certificate_verifier(Arc::new(TestSkipServerVerification))
|
|
||||||
.with_no_client_auth();
|
|
||||||
|
|
||||||
let mut client_config = quinn::ClientConfig::new(Arc::new(
|
|
||||||
quinn::crypto::rustls::QuicClientConfig::try_from(crypto)
|
|
||||||
.map_err(|e| anyhow::anyhow!("QUIC client config: {e}"))?,
|
|
||||||
));
|
|
||||||
|
|
||||||
let mut transport = quinn::TransportConfig::default();
|
|
||||||
transport.max_idle_timeout(Some(
|
|
||||||
std::time::Duration::from_secs(120)
|
|
||||||
.try_into()
|
|
||||||
.expect("120s valid"),
|
|
||||||
));
|
|
||||||
client_config.transport_config(Arc::new(transport));
|
|
||||||
|
|
||||||
let mut endpoint = Endpoint::client("0.0.0.0:0".parse().unwrap())?;
|
|
||||||
endpoint.set_default_client_config(client_config);
|
|
||||||
|
|
||||||
let connection = endpoint.connect(addr, server_name)?.await?;
|
|
||||||
Ok((endpoint, connection))
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Prove QUIC connection migration: connect, send messages, rebind the
|
|
||||||
/// UDP socket (simulating wifi→cellular), send more messages, verify
|
|
||||||
/// all messages arrive.
|
|
||||||
#[test]
|
|
||||||
fn quic_connection_migration() {
|
|
||||||
let _ = rustls::crypto::ring::default_provider().install_default();
|
|
||||||
|
|
||||||
let rt = Runtime::new().unwrap();
|
|
||||||
rt.block_on(async {
|
|
||||||
// Start an in-process echo server.
|
|
||||||
let server_addr = start_echo_server().await;
|
|
||||||
|
|
||||||
// Connect client using test-only insecure verifier.
|
|
||||||
let (endpoint, connection) = test_connect_inner(server_addr, "localhost")
|
|
||||||
.await
|
|
||||||
.expect("connect");
|
|
||||||
|
|
||||||
// Send 5 messages before migration.
|
|
||||||
for i in 0..5u32 {
|
|
||||||
let (mut send, mut recv) = connection.open_bi().await.unwrap();
|
|
||||||
let msg = format!("pre-migrate-{i}");
|
|
||||||
send.write_all(msg.as_bytes()).await.unwrap();
|
|
||||||
send.finish().unwrap();
|
|
||||||
let response = recv.read_to_end(4096).await.unwrap();
|
|
||||||
assert_eq!(response, msg.as_bytes(), "pre-migrate echo mismatch");
|
|
||||||
}
|
|
||||||
|
|
||||||
// Migrate: rebind to a new local UDP socket (simulates wifi→cellular).
|
|
||||||
let new_socket = UdpSocket::bind("127.0.0.1:0").unwrap();
|
|
||||||
let new_local = new_socket.local_addr().unwrap();
|
|
||||||
endpoint.rebind(new_socket).expect("rebind should succeed");
|
|
||||||
|
|
||||||
// Send 5 more messages after migration.
|
|
||||||
for i in 0..5u32 {
|
|
||||||
let (mut send, mut recv) = connection.open_bi().await.unwrap();
|
|
||||||
let msg = format!("post-migrate-{i}");
|
|
||||||
send.write_all(msg.as_bytes()).await.unwrap();
|
|
||||||
send.finish().unwrap();
|
|
||||||
let response = recv.read_to_end(4096).await.unwrap();
|
|
||||||
assert_eq!(response, msg.as_bytes(), "post-migrate echo mismatch");
|
|
||||||
}
|
|
||||||
|
|
||||||
// Assert: connection still alive after migration.
|
|
||||||
assert!(
|
|
||||||
connection.close_reason().is_none(),
|
|
||||||
"connection should still be open after migration"
|
|
||||||
);
|
|
||||||
|
|
||||||
// Verify the local address changed.
|
|
||||||
let _ = new_local; // We successfully used a new socket.
|
|
||||||
|
|
||||||
connection.close(0u32.into(), b"test done");
|
|
||||||
endpoint.wait_idle().await;
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Start a simple QUIC echo server that echoes back whatever it receives.
|
|
||||||
async fn start_echo_server() -> SocketAddr {
|
|
||||||
let cert = rcgen::generate_simple_self_signed(vec!["localhost".into()]).unwrap();
|
|
||||||
let cert_der = cert.cert.der().to_vec();
|
|
||||||
let key_der = cert.key_pair.serialize_der();
|
|
||||||
|
|
||||||
let cert_chain = vec![rustls::pki_types::CertificateDer::from(cert_der)];
|
|
||||||
let key = rustls::pki_types::PrivateKeyDer::try_from(key_der).unwrap();
|
|
||||||
|
|
||||||
let tls = rustls::ServerConfig::builder()
|
|
||||||
.with_no_client_auth()
|
|
||||||
.with_single_cert(cert_chain, key)
|
|
||||||
.unwrap();
|
|
||||||
|
|
||||||
let server_config = quinn::ServerConfig::with_crypto(Arc::new(
|
|
||||||
quinn::crypto::rustls::QuicServerConfig::try_from(tls).unwrap(),
|
|
||||||
));
|
|
||||||
|
|
||||||
let endpoint = Endpoint::server(
|
|
||||||
server_config,
|
|
||||||
"127.0.0.1:0".parse().unwrap(),
|
|
||||||
)
|
|
||||||
.unwrap();
|
|
||||||
|
|
||||||
let addr = endpoint.local_addr().unwrap();
|
|
||||||
|
|
||||||
// Spawn echo acceptor.
|
|
||||||
tokio::spawn(async move {
|
|
||||||
while let Some(incoming) = endpoint.accept().await {
|
|
||||||
let connecting = match incoming.accept() {
|
|
||||||
Ok(c) => c,
|
|
||||||
Err(_) => continue,
|
|
||||||
};
|
|
||||||
tokio::spawn(async move {
|
|
||||||
let conn = match connecting.await {
|
|
||||||
Ok(c) => c,
|
|
||||||
Err(_) => return,
|
|
||||||
};
|
|
||||||
loop {
|
|
||||||
let (mut send, mut recv) = match conn.accept_bi().await {
|
|
||||||
Ok(s) => s,
|
|
||||||
Err(_) => break,
|
|
||||||
};
|
|
||||||
let data = match recv.read_to_end(4096).await {
|
|
||||||
Ok(d) => d,
|
|
||||||
Err(_) => break,
|
|
||||||
};
|
|
||||||
let _ = send.write_all(&data).await;
|
|
||||||
let _ = send.finish();
|
|
||||||
}
|
|
||||||
});
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
addr
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -97,7 +97,7 @@ impl DeliveryService {
|
|||||||
|
|
||||||
Ok(EnqueueResp {
|
Ok(EnqueueResp {
|
||||||
seq: first_seq,
|
seq: first_seq,
|
||||||
delivery_proof: Vec::new(), // TODO: sign in Phase 2
|
delivery_proof: Vec::new(), // Proof generated at RPC handler layer (see v2_handlers/delivery.rs)
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -101,7 +101,7 @@ services:
|
|||||||
ports:
|
ports:
|
||||||
- "127.0.0.1:3000:3000"
|
- "127.0.0.1:3000:3000"
|
||||||
environment:
|
environment:
|
||||||
GF_SECURITY_ADMIN_PASSWORD: "${GRAFANA_ADMIN_PASSWORD:-changeme}"
|
GF_SECURITY_ADMIN_PASSWORD: "${GRAFANA_ADMIN_PASSWORD:?Set GRAFANA_ADMIN_PASSWORD in .env}"
|
||||||
GF_USERS_ALLOW_SIGN_UP: "false"
|
GF_USERS_ALLOW_SIGN_UP: "false"
|
||||||
volumes:
|
volumes:
|
||||||
- grafana-data:/var/lib/grafana
|
- grafana-data:/var/lib/grafana
|
||||||
|
|||||||
329
master-prompt.md
329
master-prompt.md
@@ -1,329 +0,0 @@
|
|||||||
# quicproquo — Master Project Prompt
|
|
||||||
|
|
||||||
## Project Identity
|
|
||||||
|
|
||||||
You are building **quicproquo**, a production-grade end-to-end encrypted group messenger in Rust. It uses the MLS protocol (RFC 9420) for group key agreement, ML-KEM-768 (NIST FIPS 203) hybrid post-quantum key exchange, the Noise Protocol Framework (Noise_XX pattern) over raw TCP as the transport layer, and Cap'n Proto for wire serialisation and RPC. There is no TLS, no HTTP, no WebSocket, no MessagePack.
|
|
||||||
|
|
||||||
This is not a prototype. Every milestone produces production-ready, tested, deployable code.
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## Non-Negotiable Engineering Standards
|
|
||||||
|
|
||||||
- **Production-ready only.** No stubs, mocks, `todo!()`, `unimplemented!()`, or placeholder logic in deliverables. If something is out of scope for the current milestone, it is explicitly omitted with a documented reason, not silently stubbed.
|
|
||||||
- **YAGNI / KISS / DRY.** Do not add features, abstractions, or generics that are not required by the current milestone. Favour clarity over cleverness.
|
|
||||||
- **Spec-first.** Document the design (ADR or inline doc comment) before implementing it. Every public API must have a doc comment explaining what it does, its invariants, and any error conditions.
|
|
||||||
- **Security-by-design.** Secrets use `zeroize`. No secret material in logs. No `unwrap()` on cryptographic operations — all errors are typed and propagated. Constant-time comparisons where required.
|
|
||||||
- **Containerised.** The server runs in Docker. `docker-compose.yml` is always kept up to date.
|
|
||||||
- **Dependency hygiene.** Pin major versions. Prefer the `dalek` ecosystem for classical crypto, `snow` for Noise, `openmls` for MLS, `ml-kem` for post-quantum, `capnp`/`capnp-rpc` for serialisation and RPC. Do not introduce new dependencies without justification.
|
|
||||||
- **Review before presenting.** Before presenting any code, review it for: missing error handling, security gaps, incomplete implementations, and deviation from these standards. Fix all issues found before output.
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## Git Standards
|
|
||||||
|
|
||||||
- GPG-signed commits only.
|
|
||||||
- Conventional commits: `feat:`, `fix:`, `chore:`, `docs:`, `test:`, `refactor:`.
|
|
||||||
- Feature branches per milestone: `feat/m1-noise-transport`, `feat/m2-keypackage-as`, etc.
|
|
||||||
- No `Co-authored-by` trailers.
|
|
||||||
- Commit messages describe *why*, not just *what*.
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## Architecture
|
|
||||||
|
|
||||||
### Workspace Layout
|
|
||||||
|
|
||||||
```
|
|
||||||
quicproquo/
|
|
||||||
├── Cargo.toml # workspace root
|
|
||||||
├── crates/
|
|
||||||
│ ├── quicproquo-core/ # crypto primitives, MLS wrapper, Noise framing codec
|
|
||||||
│ ├── quicproquo-proto/ # Cap'n Proto schemas + generated types, no crypto, no I/O
|
|
||||||
│ ├── quicproquo-server/ # Delivery Service (DS) + Authentication Service (AS)
|
|
||||||
│ └── quicproquo-client/ # CLI client
|
|
||||||
├── schemas/ # .capnp schema files (canonical source of truth)
|
|
||||||
│ ├── envelope.capnp
|
|
||||||
│ ├── auth.capnp
|
|
||||||
│ └── delivery.capnp
|
|
||||||
├── docker/
|
|
||||||
│ └── Dockerfile
|
|
||||||
├── docker-compose.yml
|
|
||||||
└── docs/
|
|
||||||
└── architecture.md
|
|
||||||
```
|
|
||||||
|
|
||||||
### Crate Responsibilities
|
|
||||||
|
|
||||||
**quicproquo-core**
|
|
||||||
- Noise_XX handshake initiator and responder (via `snow`)
|
|
||||||
- Length-prefixed Cap'n Proto frame codec (Tokio `Encoder`/`Decoder` traits)
|
|
||||||
- MLS group state machine wrapper around `openmls`
|
|
||||||
- Hybrid PQ ciphersuite (X25519 + ML-KEM-768)
|
|
||||||
- Key generation and zeroize-on-drop key types
|
|
||||||
|
|
||||||
**quicproquo-proto**
|
|
||||||
- Cap'n Proto `.capnp` schemas in `schemas/` (workspace root, shared)
|
|
||||||
- `build.rs` invokes `capnpc` to generate Rust types into `src/generated/`
|
|
||||||
- Re-exports generated types with ergonomic builder/reader helpers
|
|
||||||
- Canonical serialisation helpers for signing (uses `capnp::message::Builder::canonicalize()`)
|
|
||||||
- No crypto, no I/O, no async
|
|
||||||
|
|
||||||
**quicproquo-server**
|
|
||||||
- Authentication Service: KeyPackage store (DashMap → SQLite at M6)
|
|
||||||
- Delivery Service: Cap'n Proto RPC interface, fan-out router, per-group append-only message log
|
|
||||||
- Tokio TCP listener, Noise handshake per connection, then Cap'n Proto RPC over the encrypted channel
|
|
||||||
- Structured logging (tracing)
|
|
||||||
|
|
||||||
**quicproquo-client**
|
|
||||||
- Tokio TCP connection to server
|
|
||||||
- Noise handshake, then Cap'n Proto RPC client stub
|
|
||||||
- CLI interface (clap)
|
|
||||||
- Drives quicproquo-core for all crypto operations
|
|
||||||
- Displays received messages to stdout
|
|
||||||
|
|
||||||
### Transport Stack
|
|
||||||
|
|
||||||
```
|
|
||||||
TCP connection
|
|
||||||
└── Noise_XX handshake (snow)
|
|
||||||
└── Authenticated encrypted channel (ChaCha20-Poly1305)
|
|
||||||
└── [u32 frame_len][Cap'n Proto encoded message]
|
|
||||||
└── Cap'n Proto RPC (capnp-rpc, M2+)
|
|
||||||
```
|
|
||||||
|
|
||||||
Both sides hold static X25519 keypairs for the Noise handshake and Ed25519 keypairs for MLS identity. After Noise_XX, mutual authentication is complete. All subsequent frames are Noise-encrypted. Cap'n Proto RPC runs inside the encrypted channel — it has no knowledge of the transport security.
|
|
||||||
|
|
||||||
### Cap'n Proto Schemas
|
|
||||||
|
|
||||||
```capnp
|
|
||||||
# schemas/envelope.capnp
|
|
||||||
@0xDEADBEEFCAFEBABE; # unique file ID (generate with: capnp id)
|
|
||||||
|
|
||||||
struct Envelope {
|
|
||||||
msgType @0 :MsgType;
|
|
||||||
groupId @1 :Data; # 32 bytes, SHA-256 of group name
|
|
||||||
senderId @2 :Data; # 32 bytes, SHA-256 of sender identity key
|
|
||||||
payload @3 :Data; # opaque: MLS blob or control payload
|
|
||||||
timestampMs @4 :UInt64; # unix milliseconds
|
|
||||||
|
|
||||||
enum MsgType {
|
|
||||||
ping @0;
|
|
||||||
pong @1;
|
|
||||||
keyPackageUpload @2;
|
|
||||||
keyPackageFetch @3;
|
|
||||||
keyPackageResponse @4;
|
|
||||||
mlsWelcome @5;
|
|
||||||
mlsCommit @6;
|
|
||||||
mlsApplication @7;
|
|
||||||
error @8;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
# schemas/auth.capnp
|
|
||||||
@0xAAAABBBBCCCCDDDD;
|
|
||||||
|
|
||||||
interface AuthenticationService {
|
|
||||||
# Upload a KeyPackage for later retrieval by peers adding this client to a group.
|
|
||||||
# identityKey: Ed25519 public key bytes (32 bytes)
|
|
||||||
# package: openmls-serialised KeyPackage blob
|
|
||||||
# Returns the SHA-256 fingerprint of the package on success.
|
|
||||||
uploadKeyPackage @0 (identityKey :Data, package :Data) -> (fingerprint :Data);
|
|
||||||
|
|
||||||
# Fetch one KeyPackage for a given identity key.
|
|
||||||
# Consuming: the server removes the returned KeyPackage (one-time use, MLS spec).
|
|
||||||
# Returns empty Data if no KeyPackage is available for this identity.
|
|
||||||
fetchKeyPackage @1 (identityKey :Data) -> (package :Data);
|
|
||||||
}
|
|
||||||
|
|
||||||
# schemas/delivery.capnp
|
|
||||||
@0x1111222233334444;
|
|
||||||
|
|
||||||
interface DeliveryService {
|
|
||||||
# Fan out an MLS message to all current members of a group.
|
|
||||||
# groupId: 32-byte group identifier
|
|
||||||
# message: serialised MLSMessage blob
|
|
||||||
# Returns count of recipients the message was queued for.
|
|
||||||
fanOut @0 (groupId :Data, message :Data) -> (recipientCount :UInt32);
|
|
||||||
|
|
||||||
# Subscribe to incoming messages for a group.
|
|
||||||
# memberId: 32-byte identity key fingerprint of the subscribing client.
|
|
||||||
# Returns a capability stream; server pushes MLS blobs as they arrive.
|
|
||||||
subscribe @1 (groupId :Data, memberId :Data) -> (stream :MessageStream);
|
|
||||||
}
|
|
||||||
|
|
||||||
interface MessageStream {
|
|
||||||
# Pull the next available message for this subscriber.
|
|
||||||
# Blocks (promise does not resolve) until a message is available.
|
|
||||||
# sequenceNo is monotonically increasing per group, used for gap detection.
|
|
||||||
next @0 () -> (message :Data, sequenceNo :UInt64);
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
### MLS Design
|
|
||||||
|
|
||||||
- Ciphersuite: `MLS_128_DHKEMX25519_AES128GCM_SHA256_Ed25519` as baseline (M1–M4), replaced with hybrid PQ ciphersuite at M5.
|
|
||||||
- DS is MLS-unaware: routes `MLSMessage` blobs by `group_id`. Does not inspect epoch or content.
|
|
||||||
- AS stores `KeyPackage` blobs indexed by `(identity_key_fingerprint, package_id)`. One KeyPackage consumed per member-add operation (MLS requirement: KeyPackages are single-use).
|
|
||||||
- Welcome messages routed by the DS to the target client using `sender_id` → `target_id` mapping in the Envelope.
|
|
||||||
- Cap'n Proto canonical form used when serialising any structure that is subsequently signed (MLS Commit signatures, AS KeyPackage fingerprints).
|
|
||||||
|
|
||||||
### Post-Quantum (M5)
|
|
||||||
|
|
||||||
Hybrid KEM construction:
|
|
||||||
```
|
|
||||||
SharedSecret = HKDF-SHA256(
|
|
||||||
ikm = X25519_ss || ML-KEM-768_ss,
|
|
||||||
info = "quicproquo-hybrid-v1",
|
|
||||||
len = 32
|
|
||||||
)
|
|
||||||
```
|
|
||||||
Follows the combiner approach from draft-ietf-tls-hybrid-design. Implemented as a custom `openmls` `OpenMlsCryptoProvider` trait implementation in `quicproquo-core`.
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## Milestones
|
|
||||||
|
|
||||||
### M1 — Noise Transport ✦ current
|
|
||||||
**Goal:** Two processes complete Noise_XX handshake over TCP and exchange typed Cap'n Proto frames.
|
|
||||||
|
|
||||||
Deliverables:
|
|
||||||
- `schemas/envelope.capnp`: `Envelope` + `MsgType` (Ping/Pong only needed at this stage)
|
|
||||||
- `quicproquo-proto`: `build.rs` with `capnpc`, generated type re-exports, canonical helper
|
|
||||||
- `quicproquo-core`: static X25519 keypair generation, Noise_XX initiator + responder, length-prefixed Cap'n Proto frame codec
|
|
||||||
- `quicproquo-server`: TCP listener, Noise handshake, Ping→Pong handler, one tokio task per connection
|
|
||||||
- `quicproquo-client`: connects, Noise handshake, sends Ping, receives Pong, exits 0
|
|
||||||
- Integration test: server and client in same test binary using `tokio::spawn`
|
|
||||||
- `docker-compose.yml` running the server
|
|
||||||
|
|
||||||
### M2 — Authentication Service + KeyPackage Exchange
|
|
||||||
**Goal:** Clients register identity and publish/fetch MLS KeyPackages via Cap'n Proto RPC.
|
|
||||||
|
|
||||||
Deliverables:
|
|
||||||
- `schemas/auth.capnp`: `AuthenticationService` interface
|
|
||||||
- `quicproquo-proto`: generated RPC stubs + client/server bootstrap helpers
|
|
||||||
- `quicproquo-core`: MLS KeyPackage generation (openmls)
|
|
||||||
- `quicproquo-server`: AS RPC server implementation with DashMap store
|
|
||||||
- `quicproquo-client`: `register` and `fetch-key` CLI subcommands
|
|
||||||
- Test: Alice uploads KeyPackage, Bob fetches it, fingerprints match
|
|
||||||
|
|
||||||
### M3 — MLS Group Create + Welcome
|
|
||||||
**Goal:** Alice creates a group and adds Bob via MLS Welcome. Both hold valid epoch 1 state.
|
|
||||||
|
|
||||||
Deliverables:
|
|
||||||
- `schemas/delivery.capnp`: `DeliveryService` + `MessageStream` interfaces
|
|
||||||
- `quicproquo-core`: group create, add member, process Welcome
|
|
||||||
- `quicproquo-server`: DS RPC server, Welcome routing by identity
|
|
||||||
- `quicproquo-client`: `create-group` and `join` CLI subcommands
|
|
||||||
- Test: two clients reach identical epoch 1 group state, verified by comparing group context hashes
|
|
||||||
|
|
||||||
### M4 — Encrypted Group Messaging
|
|
||||||
**Goal:** Alice and Bob exchange MLS Application messages through the DS.
|
|
||||||
|
|
||||||
Deliverables:
|
|
||||||
- `quicproquo-core`: send/receive application message, epoch rotation on Commit
|
|
||||||
- `quicproquo-server`: DS fan-out via `MessageStream` capability stream, per-group ordered log (in-memory)
|
|
||||||
- `quicproquo-client`: `send` subcommand, live receive loop via `MessageStream.next()`
|
|
||||||
- Test: round-trip message integrity, forward secrecy verified by confirming distinct key material across epochs
|
|
||||||
|
|
||||||
### M5 — Hybrid PQ Ciphersuite
|
|
||||||
**Goal:** Replace MLS crypto backend with X25519 + ML-KEM-768 hybrid.
|
|
||||||
|
|
||||||
Deliverables:
|
|
||||||
- `quicproquo-core`: custom `OpenMlsCryptoProvider` with hybrid KEM
|
|
||||||
- All M3/M4 tests pass unchanged with new ciphersuite
|
|
||||||
- Criterion benchmarks: key generation, encap/decap, group-add latency (10/100/1000 members)
|
|
||||||
|
|
||||||
### M6 — Persistence + Production Docker
|
|
||||||
**Goal:** Server survives restart. Full containerised deployment.
|
|
||||||
|
|
||||||
Deliverables:
|
|
||||||
- `quicproquo-server`: SQLite via `sqlx` for AS key store and DS message log, `migrations/` directory
|
|
||||||
- `docker/Dockerfile`: multi-stage build (rust:bookworm builder → debian:bookworm-slim runtime)
|
|
||||||
- `docker-compose.yml`: server + SQLite volume, healthcheck
|
|
||||||
- Client reconnect with session resume (re-handshake + rejoin group epoch from DS log)
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## Dependencies (pinned majors)
|
|
||||||
|
|
||||||
```toml
|
|
||||||
# Crypto
|
|
||||||
openmls = "0.6"
|
|
||||||
openmls_rust_crypto = "0.6"
|
|
||||||
ml-kem = "0.3"
|
|
||||||
x25519-dalek = "2"
|
|
||||||
ed25519-dalek = "2"
|
|
||||||
snow = "0.9"
|
|
||||||
chacha20poly1305 = "0.10"
|
|
||||||
sha2 = "0.10"
|
|
||||||
hkdf = "0.12"
|
|
||||||
zeroize = { version = "1", features = ["derive"] }
|
|
||||||
rand = "0.8"
|
|
||||||
|
|
||||||
# Serialisation + RPC
|
|
||||||
capnp = "0.19"
|
|
||||||
capnp-rpc = "0.19"
|
|
||||||
|
|
||||||
# Build-time only
|
|
||||||
capnpc = "0.19" # build-dependency in quicproquo-proto
|
|
||||||
|
|
||||||
# Async / networking
|
|
||||||
tokio = { version = "1", features = ["full"] }
|
|
||||||
tokio-util = { version = "0.7", features = ["codec"] }
|
|
||||||
|
|
||||||
# Server utilities
|
|
||||||
dashmap = "5"
|
|
||||||
sqlx = { version = "0.7", features = ["sqlite", "runtime-tokio"] } # M6+
|
|
||||||
tracing = "0.1"
|
|
||||||
tracing-subscriber = { version = "0.3", features = ["env-filter"] }
|
|
||||||
anyhow = "1"
|
|
||||||
thiserror = "1"
|
|
||||||
|
|
||||||
# CLI
|
|
||||||
clap = { version = "4", features = ["derive"] }
|
|
||||||
```
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## Key Design Decisions (ADR Summary)
|
|
||||||
|
|
||||||
**ADR-001: Noise_XX for transport authentication**
|
|
||||||
Both parties hold static keys registered with the AS. XX pattern provides mutual authentication and identity hiding for the initiator. No TLS dependency, no certificate infrastructure.
|
|
||||||
|
|
||||||
**ADR-002: Cap'n Proto replaces MessagePack**
|
|
||||||
Cap'n Proto provides: zero-copy reads, schema-enforced types, canonical serialisation for cryptographic signing, and a built-in async RPC system (capnp-rpc) that eliminates hand-rolled message dispatch. The build-time codegen overhead is accepted as worthwhile.
|
|
||||||
|
|
||||||
**ADR-003: Cap'n Proto RPC runs inside the Noise tunnel**
|
|
||||||
Cap'n Proto RPC has no transport security of its own. It operates over the byte stream produced by the Noise session. Separation of concerns: Noise owns authentication and confidentiality, Cap'n Proto owns framing and dispatch.
|
|
||||||
|
|
||||||
**ADR-004: DS is MLS-unaware**
|
|
||||||
The Delivery Service routes opaque `MLSMessage` blobs by `group_id`. It never decrypts or inspects MLS content. This is the correct MLS architecture (RFC 9420 §4) and is a natural Audit-Core integration point: the DS log is an append-only sequence of authenticated blobs.
|
|
||||||
|
|
||||||
**ADR-005: Single-use KeyPackages**
|
|
||||||
MLS requires that each KeyPackage be used at most once (to preserve forward secrecy of the initial key exchange). The AS consumes a KeyPackage on fetch. Clients should pre-upload multiple KeyPackages. The AS warns when a client's supply runs low (M2+).
|
|
||||||
|
|
||||||
**ADR-006: PQ gap in Noise transport is accepted**
|
|
||||||
The MLS content layer is PQ-protected from M5. The Noise transport (X25519) remains classical — PQ-Noise (draft-noise-pq) is not yet supported by `snow`. Harvest-now-decrypt-later against the handshake metadata is an accepted residual risk for M1–M5. No long-lived content secrets transit the Noise handshake, so the practical impact is limited to identity/timing metadata.
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## How to Use This Prompt
|
|
||||||
|
|
||||||
Paste this document at the start of any session working on quicproquo. Then state which milestone you are working on and what specific task you need. The assistant will:
|
|
||||||
|
|
||||||
1. Confirm the current milestone and task.
|
|
||||||
2. State any design decisions being made (ADR format if significant).
|
|
||||||
3. Produce complete, production-ready code for the task.
|
|
||||||
4. Review the code internally for gaps before presenting.
|
|
||||||
5. State what the next logical task is.
|
|
||||||
|
|
||||||
When asking for code, always specify:
|
|
||||||
- Which crate(s) are affected.
|
|
||||||
- Whether this is a new file or modification to existing.
|
|
||||||
- Any constraints or context the assistant may not have (e.g. existing types already defined).
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
*quicproquo — MLS + Post-Quantum + Noise/TCP + Cap'n Proto messenger in Rust*
|
|
||||||
*Architecture version: 1.1 | Last updated: 2026-02-19*
|
|
||||||
1156
scripts/ai_team.py
1156
scripts/ai_team.py
File diff suppressed because it is too large
Load Diff
Reference in New Issue
Block a user