From cab03bd3f76cd36f71e2d6039d01b35c13d4d439 Mon Sep 17 00:00:00 2001 From: Christian Nennemann Date: Wed, 4 Mar 2026 13:02:54 +0100 Subject: [PATCH] feat(client): v2 REPL over SDK with categorized help and tab-completion MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 925-line REPL replacing the 3317-line monolith — delegates all crypto, MLS, and RPC to quicproquo-sdk. 20 commands across 6 categories (messaging, groups, account, keys, utility, debug), rustyline tab completion, background event listener, auto-server-launch. Also adds SDK accessor methods (server_addr_string, config_state_path), WS bridge register handler, and README table formatting cleanup. --- Cargo.lock | 129 ++- Cargo.toml | 1 + README.md | 168 ++-- .../quicproquo-client/src/client/v2_repl.rs | 927 +++++++++++++++++- crates/quicproquo-sdk/src/client.rs | 10 + crates/quicproquo-server/src/ws_bridge.rs | 84 ++ sdks/typescript/demo/index.html | 816 ++++++++++----- 7 files changed, 1810 insertions(+), 325 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index cc65d85..4986493 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -596,6 +596,12 @@ version = "1.0.4" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "9330f8b2ff13f34540b44e946ef35111825727b38d33286ef986142615121801" +[[package]] +name = "cfg_aliases" +version = "0.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fd16c4719339c4530435d38e511904438d07cce7950afa3718a84ac36c10e89e" + [[package]] name = "cfg_aliases" version = "0.2.1" @@ -750,6 +756,15 @@ version = "1.0.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "3a822ea5bc7590f9d40f1ba12c0dc3c2760f3482c6984db1573ad11031420831" +[[package]] +name = "clipboard-win" +version = "5.4.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bde03770d3df201d4fb868f2c9c59e66a3e4e2bd06692a0fe701e7103c7e84d4" +dependencies = [ + "error-code", +] + [[package]] name = "cmake" version = "0.1.57" @@ -1528,6 +1543,12 @@ version = "0.6.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "edd0f118536f44f5ccd48bcb8b111bdc3de888b58c74639dfb034a357d0f206d" +[[package]] +name = "endian-type" +version = "0.1.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c34f04666d835ff5d62e058c3995147c06f42fe86ff053337632bca83e42702d" + [[package]] name = "enum-as-inner" version = "0.6.1" @@ -1567,6 +1588,12 @@ dependencies = [ "windows-sys 0.61.2", ] +[[package]] +name = "error-code" +version = "3.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "dea2df4cf52843e0452895c455a1a2cfbb842a1e7329671acf418fdc53ed4c59" + [[package]] name = "fallible-iterator" version = "0.3.0" @@ -1597,6 +1624,17 @@ version = "2.3.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "37909eebbb50d72f9059c3b6d82c0463f2ff062c9e95845c43a6c9c0355411be" +[[package]] +name = "fd-lock" +version = "4.0.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0ce92ff622d6dadf7349484f42c93271a0d49b7cc4d466a936405bacbe10aa78" +dependencies = [ + "cfg-if", + "rustix 1.1.4", + "windows-sys 0.59.0", +] + [[package]] name = "ff" version = "0.13.1" @@ -2100,6 +2138,15 @@ dependencies = [ "digest 0.10.7", ] +[[package]] +name = "home" +version = "0.5.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cc627f471c528ff0c4a49e1d5e60450c8f6461dd6d10ba9dcd3a61d3dff7728d" +dependencies = [ + "windows-sys 0.61.2", +] + [[package]] name = "hpke-rs" version = "0.1.2" @@ -2528,7 +2575,7 @@ checksum = "5236da4d5681f317ec393c8fe2b7e3d360d31c6bb40383991d0b7429ca5ad117" dependencies = [ "backon", "bytes", - "cfg_aliases", + "cfg_aliases 0.2.1", "data-encoding", "derive_more", "ed25519-dalek 3.0.0-pre.1", @@ -2627,7 +2674,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "034ed21f34c657a123d39525d948c885aacba59508805e4dd67d71f022e7151b" dependencies = [ "bytes", - "cfg_aliases", + "cfg_aliases 0.2.1", "iroh-quinn-proto", "iroh-quinn-udp", "pin-project-lite", @@ -2673,7 +2720,7 @@ version = "0.8.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "f981dadd5a072a9e0efcd24bdcc388e570073f7e51b33505ceb1ef4668c80c86" dependencies = [ - "cfg_aliases", + "cfg_aliases 0.2.1", "libc", "socket2 0.6.2", "tracing", @@ -2688,7 +2735,7 @@ checksum = "cd2b63e654b9dec799a73372cdc79b529ca6c7248c0c8de7da78a02e3a46f03c" dependencies = [ "blake3", "bytes", - "cfg_aliases", + "cfg_aliases 0.2.1", "data-encoding", "derive_more", "getrandom 0.3.4", @@ -3139,7 +3186,7 @@ version = "0.3.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "e2ab99dfb861450e68853d34ae665243a88b8c493d01ba957321a1e9b2312bbe" dependencies = [ - "cfg_aliases", + "cfg_aliases 0.2.1", "derive_more", "futures-buffered", "futures-lite", @@ -3255,7 +3302,7 @@ checksum = "454b8c0759b2097581f25ed5180b4a1d14c324fde6d0734932a288e044d06232" dependencies = [ "atomic-waker", "bytes", - "cfg_aliases", + "cfg_aliases 0.2.1", "derive_more", "iroh-quinn-udp", "js-sys", @@ -3283,6 +3330,27 @@ dependencies = [ "wmi", ] +[[package]] +name = "nibble_vec" +version = "0.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "77a5d83df9f36fe23f0c3648c6bbb8b0298bb5f1939c8f2704431371f4b84d43" +dependencies = [ + "smallvec", +] + +[[package]] +name = "nix" +version = "0.28.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ab2156c4fce2f8df6c499cc1c763e4394b7482525bf2a9701c9d79d215f519e4" +dependencies = [ + "bitflags 2.11.0", + "cfg-if", + "cfg_aliases 0.1.1", + "libc", +] + [[package]] name = "nom" version = "7.1.3" @@ -3757,7 +3825,7 @@ dependencies = [ "async-compat", "base32", "bytes", - "cfg_aliases", + "cfg_aliases 0.2.1", "document-features", "dyn-clone", "ed25519-dalek 3.0.0-pre.1", @@ -4176,6 +4244,8 @@ dependencies = [ "quicproquo-kt", "quicproquo-p2p", "quicproquo-proto", + "quicproquo-rpc", + "quicproquo-sdk", "quinn", "quinn-proto", "rand 0.8.5", @@ -4183,6 +4253,7 @@ dependencies = [ "rpassword", "rusqlite", "rustls", + "rustyline", "serde", "serde_json", "serde_yaml", @@ -4286,6 +4357,7 @@ dependencies = [ "quinn", "rcgen", "rustls", + "sha2 0.10.9", "thiserror 1.0.69", "tokio", "tower", @@ -4299,8 +4371,12 @@ dependencies = [ "anyhow", "argon2", "bincode", + "bytes", + "chacha20poly1305 0.10.1", "futures", "hex", + "opaque-ke", + "prost", "quicproquo-core", "quicproquo-proto", "quicproquo-rpc", @@ -4324,6 +4400,7 @@ dependencies = [ "anyhow", "base64", "bincode", + "bytes", "capnp", "capnp-rpc", "clap", @@ -4335,10 +4412,12 @@ dependencies = [ "metrics 0.22.4", "metrics-exporter-prometheus", "opaque-ke", + "prost", "quicproquo-core", "quicproquo-kt", "quicproquo-plugin-api", "quicproquo-proto", + "quicproquo-rpc", "quinn", "quinn-proto", "rand 0.8.5", @@ -4368,7 +4447,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "b9e20a958963c291dc322d98411f541009df2ced7b5a4f2bd52337638cfccf20" dependencies = [ "bytes", - "cfg_aliases", + "cfg_aliases 0.2.1", "pin-project-lite", "quinn-proto", "quinn-udp", @@ -4411,7 +4490,7 @@ version = "0.5.14" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "addec6a0dcad8a8d96a771f815f0eaf55f9d1805756410b39f5fa81332574cbd" dependencies = [ - "cfg_aliases", + "cfg_aliases 0.2.1", "libc", "once_cell", "socket2 0.6.2", @@ -4434,6 +4513,16 @@ version = "5.3.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "69cdb34c158ceb288df11e18b4bd39de994f6657d83847bdffdbd7f346754b0f" +[[package]] +name = "radix_trie" +version = "0.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c069c179fcdc6a2fe24d8d18305cf085fdbd4f922c041943e203685d6a1c58fd" +dependencies = [ + "endian-type", + "nibble_vec", +] + [[package]] name = "rand" version = "0.7.3" @@ -4904,6 +4993,28 @@ version = "1.0.22" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "b39cdef0fa800fc44525c84ccb54a029961a8215f9619753635a9c0d2538d46d" +[[package]] +name = "rustyline" +version = "14.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7803e8936da37efd9b6d4478277f4b2b9bb5cdb37a113e8d63222e58da647e63" +dependencies = [ + "bitflags 2.11.0", + "cfg-if", + "clipboard-win", + "fd-lock", + "home", + "libc", + "log", + "memchr", + "nix", + "radix_trie", + "unicode-segmentation", + "unicode-width 0.1.14", + "utf8parse", + "windows-sys 0.52.0", +] + [[package]] name = "ryu" version = "1.0.23" diff --git a/Cargo.toml b/Cargo.toml index ef64687..da71d7e 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -81,6 +81,7 @@ thiserror = { version = "1" } # ── CLI ─────────────────────────────────────────────────────────────────────── clap = { version = "4", features = ["derive", "env"] } +rustyline = { version = "14" } # ── Certificate parsing ────────────────────────────────────────────────────── x509-parser = { version = "0.16", default-features = false } diff --git a/README.md b/README.md index 250a752..42aa0d3 100644 --- a/README.md +++ b/README.md @@ -24,18 +24,18 @@ agreement across any number of participants. Messages are framed with └─────────────────────────────────────────────┘ ``` -| Property | Mechanism | -|---|---| -| Transport confidentiality | TLS 1.3 over QUIC (rustls) | -| Transport authentication | TLS 1.3 server cert (self-signed or CA) | -| Group key agreement | MLS `MLS_128_DHKEMX25519_AES128GCM_SHA256_Ed25519` | -| Post-compromise security | MLS epoch ratchet | -| Forward secrecy | Per-epoch key schedule | -| Identity | Ed25519 (MLS credential + leaf node signature) | -| Password auth | OPAQUE (password never sent to server) | -| Post-quantum readiness | X25519 + ML-KEM-768 hybrid KEM envelope | -| Local storage encryption | SQLCipher + Argon2id + ChaCha20-Poly1305 | -| Message framing | Cap'n Proto (unpacked wire format) | +| Property | Mechanism | +| ------------------------- | -------------------------------------------------- | +| Transport confidentiality | TLS 1.3 over QUIC (rustls) | +| Transport authentication | TLS 1.3 server cert (self-signed or CA) | +| Group key agreement | MLS `MLS_128_DHKEMX25519_AES128GCM_SHA256_Ed25519` | +| Post-compromise security | MLS epoch ratchet | +| Forward secrecy | Per-epoch key schedule | +| Identity | Ed25519 (MLS credential + leaf node signature) | +| Password auth | OPAQUE (password never sent to server) | +| Post-quantum readiness | X25519 + ML-KEM-768 hybrid KEM envelope | +| Local storage encryption | SQLCipher + Argon2id + ChaCha20-Poly1305 | +| Message framing | Cap'n Proto (unpacked wire format) | --- @@ -73,47 +73,47 @@ agreement across any number of participants. Messages are framed with ### REPL slash commands -| Command | Description | -|---|---| -| `/dm ` | Start a 1:1 DM with a peer | -| `/create-group ` (or `/cg`) | Create a new group | -| `/invite ` | Add a member to the current group | -| `/remove ` | Remove a member from the current group | -| `/join` | Join a pending group invitation | -| `/leave` | Leave the current group | -| `/switch @user` or `/switch #group` | Switch active conversation | -| `/list` or `/ls` | List all conversations | -| `/members` | Show group members with resolved usernames | -| `/group-info` (or `/gi`) | Show group type, members, MLS epoch | -| `/rename ` | Rename the current conversation | -| `/history [count]` (or `/hist`) | Show message history (default 20) | -| `/react [index]` | React to a message with an emoji | -| `/typing` | Send a typing indicator | -| `/typing-notify on\|off` | Toggle typing indicator display | -| `/edit ` | Edit one of your messages | -| `/delete ` | Delete one of your messages | -| `/send-file ` (or `/sf`) | Upload and send a file (chunked, SHA-256 verified) | -| `/download ` (or `/dl`) | Download a received file | -| `/disappear ` | Set message TTL (`30m`, `1h`, `1d`, `7d`) | -| `/verify ` | Compare safety numbers with a peer | -| `/update-key` (or `/rotate-key`) | Rotate your MLS key material | -| `/delete-account` | Permanently delete your account (with confirmation) | -| `/whoami` | Show identity and group status | -| `/help` | Command reference | -| `/quit` | Exit | +| Command | Description | +| ----------------------------------- | --------------------------------------------------- | +| `/dm ` | Start a 1:1 DM with a peer | +| `/create-group ` (or `/cg`) | Create a new group | +| `/invite ` | Add a member to the current group | +| `/remove ` | Remove a member from the current group | +| `/join` | Join a pending group invitation | +| `/leave` | Leave the current group | +| `/switch @user` or `/switch #group` | Switch active conversation | +| `/list` or `/ls` | List all conversations | +| `/members` | Show group members with resolved usernames | +| `/group-info` (or `/gi`) | Show group type, members, MLS epoch | +| `/rename ` | Rename the current conversation | +| `/history [count]` (or `/hist`) | Show message history (default 20) | +| `/react [index]` | React to a message with an emoji | +| `/typing` | Send a typing indicator | +| `/typing-notify on\|off` | Toggle typing indicator display | +| `/edit ` | Edit one of your messages | +| `/delete ` | Delete one of your messages | +| `/send-file ` (or `/sf`) | Upload and send a file (chunked, SHA-256 verified) | +| `/download ` (or `/dl`) | Download a received file | +| `/disappear ` | Set message TTL (`30m`, `1h`, `1d`, `7d`) | +| `/verify ` | Compare safety numbers with a peer | +| `/update-key` (or `/rotate-key`) | Rotate your MLS key material | +| `/delete-account` | Permanently delete your account (with confirmation) | +| `/whoami` | Show identity and group status | +| `/help` | Command reference | +| `/quit` | Exit | **Mesh commands** (requires `--features mesh`): -| Command | Description | -|---|---| -| `/mesh peers` | Scan for nearby qpq nodes via mDNS | -| `/mesh server ` | Note a discovered server address | -| `/mesh send ` | Direct P2P message via iroh | -| `/mesh broadcast ` | Publish to a broadcast channel | -| `/mesh subscribe ` | Join a broadcast channel | -| `/mesh route` | Show routing table | -| `/mesh identity` | Show mesh identity info | -| `/mesh store` | Show store-and-forward stats | +| Command | Description | +| ------------------------------- | ---------------------------------- | +| `/mesh peers` | Scan for nearby qpq nodes via mDNS | +| `/mesh server ` | Note a discovered server address | +| `/mesh send ` | Direct P2P message via iroh | +| `/mesh broadcast ` | Publish to a broadcast channel | +| `/mesh subscribe ` | Join a broadcast channel | +| `/mesh route` | Show routing table | +| `/mesh identity` | Show mesh identity info | +| `/mesh store` | Show store-and-forward stats | ### Mesh networking (feature-gated: `--features mesh`) @@ -200,20 +200,20 @@ See the [full demo walkthrough](docs/src/getting-started/demo-walkthrough.md) fo ## Crate layout -| Crate | Purpose | -|---|---| -| `quicproquo-core` | MLS group operations, hybrid KEM, OPAQUE auth, crypto primitives, WASM-compatible modules | -| `quicproquo-proto` | Cap'n Proto schemas and generated RPC code | -| `quicproquo-server` | QUIC server, NodeService RPC (24 methods), storage backends, federation, plugins, blob storage | -| `quicproquo-client` | CLI + REPL (40+ commands), session management, conversation store, file transfer | -| `quicproquo-ffi` | C FFI bindings (`libquicproquo_ffi.so`) for cross-language integration | -| `quicproquo-plugin-api` | C-compatible plugin hook API (`HookVTable`, 6 hooks) | -| `quicproquo-kt` | Key transparency / Merkle-log identity bindings | -| `quicproquo-bot` | Programmable bot client framework | -| `quicproquo-gen` | Code generation utilities | -| `quicproquo-gui` | Tauri 2 desktop app (experimental, requires GTK) | -| `quicproquo-mobile` | C FFI for mobile connection migration (experimental) | -| `quicproquo-p2p` | iroh-based P2P transport, mesh identity, store-and-forward, broadcast channels | +| Crate | Purpose | +| ----------------------- | ---------------------------------------------------------------------------------------------- | +| `quicproquo-core` | MLS group operations, hybrid KEM, OPAQUE auth, crypto primitives, WASM-compatible modules | +| `quicproquo-proto` | Cap'n Proto schemas and generated RPC code | +| `quicproquo-server` | QUIC server, NodeService RPC (24 methods), storage backends, federation, plugins, blob storage | +| `quicproquo-client` | CLI + REPL (40+ commands), session management, conversation store, file transfer | +| `quicproquo-ffi` | C FFI bindings (`libquicproquo_ffi.so`) for cross-language integration | +| `quicproquo-plugin-api` | C-compatible plugin hook API (`HookVTable`, 6 hooks) | +| `quicproquo-kt` | Key transparency / Merkle-log identity bindings | +| `quicproquo-bot` | Programmable bot client framework | +| `quicproquo-gen` | Code generation utilities | +| `quicproquo-gui` | Tauri 2 desktop app (experimental, requires GTK) | +| `quicproquo-mobile` | C FFI for mobile connection migration (experimental) | +| `quicproquo-p2p` | iroh-based P2P transport, mesh identity, store-and-forward, broadcast channels | --- @@ -234,15 +234,15 @@ GitHub Actions runs on every push and PR: ## Milestones -| # | Name | Status | What it adds | -|---|------|--------|--------------| -| M1 | QUIC/TLS transport | **Done** | QUIC + TLS 1.3 endpoint, length-prefixed framing, Ping/Pong | -| M2 | Authentication Service | **Done** | Ed25519 identity, KeyPackage generation, AS upload/fetch | -| M3 | Delivery Service + MLS groups | **Done** | DS relay, `GroupMember` create/join/add/send/recv | -| M4 | Group CLI subcommands | **Done** | Persistent CLI, OPAQUE login, 20 subcommands | -| M5 | Multi-party groups | **Done** | N > 2 members, Commit fan-out, `send --all`, epoch sync | -| M6 | Persistence + REPL | **Done** | SQLite/SQLCipher, interactive REPL, DM channels, encrypted local storage | -| M7 | Post-quantum MLS | **Planned** | Hybrid X25519 + ML-KEM-768 integrated into MLS ciphersuite | +| # | Name | Status | What it adds | +| --- | ----------------------------- | ----------- | ------------------------------------------------------------------------ | +| M1 | QUIC/TLS transport | **Done** | QUIC + TLS 1.3 endpoint, length-prefixed framing, Ping/Pong | +| M2 | Authentication Service | **Done** | Ed25519 identity, KeyPackage generation, AS upload/fetch | +| M3 | Delivery Service + MLS groups | **Done** | DS relay, `GroupMember` create/join/add/send/recv | +| M4 | Group CLI subcommands | **Done** | Persistent CLI, OPAQUE login, 20 subcommands | +| M5 | Multi-party groups | **Done** | N > 2 members, Commit fan-out, `send --all`, epoch sync | +| M6 | Persistence + REPL | **Done** | SQLite/SQLCipher, interactive REPL, DM channels, encrypted local storage | +| M7 | Post-quantum MLS | **Planned** | Hybrid X25519 + ML-KEM-768 integrated into MLS ciphersuite | M7 note: the hybrid KEM envelope is already implemented and tested (10 tests passing). What remains is integrating it into the OpenMLS CryptoProvider so all MLS key material gets post-quantum confidentiality. @@ -252,17 +252,17 @@ M7 note: the hybrid KEM envelope is already implemented and tested (10 tests pas See [ROADMAP.md](ROADMAP.md) for the full phased plan. Summary: -| Phase | Focus | Status | -|-------|-------|--------| -| 1 | Production hardening (unwrap removal, secure defaults, Docker) | In progress | -| 2 | Test and CI maturity | Partially done | -| 3 | Client SDKs (Go, TypeScript/WASM, Python FFI, C FFI) | **Go, TS, FFI, WASM done** | -| 4 | Trust and security (audit, key transparency, PQ MLS) | DS auth + enumeration mitigation done | -| 5 | Features and UX (rich messaging, file transfer, disappearing) | **Edit/delete, files, TTL done** | -| 6 | Scale and operations (horizontal scaling, observability) | Planned | -| 7 | Platform expansion (mobile, web, federation, sealed sender) | **Sealed sender done** | -| 8 | Freifunk / community mesh networking | **F0-F6 done** | -| 9 | Developer experience and community growth | Safety numbers + plugins done | +| Phase | Focus | Status | +| ----- | -------------------------------------------------------------- | ------------------------------------- | +| 1 | Production hardening (unwrap removal, secure defaults, Docker) | In progress | +| 2 | Test and CI maturity | Partially done | +| 3 | Client SDKs (Go, TypeScript/WASM, Python FFI, C FFI) | **Go, TS, FFI, WASM done** | +| 4 | Trust and security (audit, key transparency, PQ MLS) | DS auth + enumeration mitigation done | +| 5 | Features and UX (rich messaging, file transfer, disappearing) | **Edit/delete, files, TTL done** | +| 6 | Scale and operations (horizontal scaling, observability) | Planned | +| 7 | Platform expansion (mobile, web, federation, sealed sender) | **Sealed sender done** | +| 8 | Freifunk / community mesh networking | **F0-F6 done** | +| 9 | Developer experience and community growth | Safety numbers + plugins done | ### Recently completed (Sprints 1-9) diff --git a/crates/quicproquo-client/src/client/v2_repl.rs b/crates/quicproquo-client/src/client/v2_repl.rs index 7f569f2..823f097 100644 --- a/crates/quicproquo-client/src/client/v2_repl.rs +++ b/crates/quicproquo-client/src/client/v2_repl.rs @@ -1,3 +1,926 @@ -//! v2 REPL — interactive mode over the SDK. +//! v2 REPL — thin shell over `quicproquo_sdk::QpqClient`. //! -//! Placeholder module; full implementation will replace the v1 repl. +//! Provides an interactive command-line interface with categorized `/help`, +//! tab-completion, and a background event listener. Delegates all crypto, +//! MLS, and RPC work to the SDK. +//! +//! Build: `cargo build -p quicproquo-client --features v2` + +use std::path::PathBuf; +use std::process::{Child, Command as ProcessCommand}; +use std::sync::Arc; +use std::time::Duration; + +use anyhow::Context; +use quicproquo_core::{GroupMember, IdentityKeypair}; +use quicproquo_sdk::client::QpqClient; +use quicproquo_sdk::conversation::{ConversationId, ConversationKind, StoredMessage}; +use quicproquo_sdk::events::ClientEvent; +use rustyline::completion::{Completer, Pair}; +use rustyline::error::ReadlineError; +use rustyline::highlight::Highlighter; +use rustyline::hint::Hinter; +use rustyline::validate::Validator; +use rustyline::{Config, Editor, Helper}; +use tokio::sync::broadcast; + +use super::display; + +// ── ANSI helpers ──────────────────────────────────────────────────────────── + +const RESET: &str = "\x1b[0m"; +const BOLD: &str = "\x1b[1m"; +const DIM: &str = "\x1b[2m"; +const GREEN: &str = "\x1b[32m"; +const CYAN: &str = "\x1b[36m"; +const YELLOW: &str = "\x1b[33m"; + +// ── Command categories ────────────────────────────────────────────────────── + +#[derive(Clone, Copy, PartialEq, Eq)] +enum Category { + Messaging, + Groups, + Account, + Keys, + Utility, + Debug, +} + +impl Category { + fn label(self) -> &'static str { + match self { + Self::Messaging => "Messaging", + Self::Groups => "Groups", + Self::Account => "Account", + Self::Keys => "Keys", + Self::Utility => "Utility", + Self::Debug => "Debug", + } + } + + fn all() -> &'static [Category] { + &[ + Self::Messaging, + Self::Groups, + Self::Account, + Self::Keys, + Self::Utility, + Self::Debug, + ] + } +} + +// ── Static command table ──────────────────────────────────────────────────── + +struct CmdDef { + name: &'static str, + aliases: &'static [&'static str], + category: Category, + description: &'static str, + usage: &'static str, +} + +const COMMANDS: &[CmdDef] = &[ + CmdDef { name: "/send", aliases: &["/s"], category: Category::Messaging, description: "Send a message to the current conversation", usage: "/send " }, + CmdDef { name: "/dm", aliases: &[], category: Category::Messaging, description: "Start or switch to a DM with a user", usage: "/dm " }, + CmdDef { name: "/recv", aliases: &["/r"], category: Category::Messaging, description: "Fetch and display new messages", usage: "/recv" }, + CmdDef { name: "/history", aliases: &[], category: Category::Messaging, description: "Show recent message history", usage: "/history [count]" }, + CmdDef { name: "/list", aliases: &["/ls"], category: Category::Messaging, description: "List all conversations", usage: "/list" }, + CmdDef { name: "/switch", aliases: &["/sw"], category: Category::Messaging, description: "Switch active conversation", usage: "/switch " }, + CmdDef { name: "/group", aliases: &["/g"], category: Category::Groups, description: "create | invite | leave | list | members", usage: "/group [args]" }, + CmdDef { name: "/register", aliases: &[], category: Category::Account, description: "Register a new account", usage: "/register " }, + CmdDef { name: "/login", aliases: &[], category: Category::Account, description: "Log in to an existing account", usage: "/login " }, + CmdDef { name: "/logout", aliases: &[], category: Category::Account, description: "Log out (clear session)", usage: "/logout" }, + CmdDef { name: "/whoami", aliases: &[], category: Category::Account, description: "Show current identity", usage: "/whoami" }, + CmdDef { name: "/refresh-key", aliases: &[], category: Category::Keys, description: "Upload a fresh KeyPackage", usage: "/refresh-key" }, + CmdDef { name: "/safety-number", aliases: &["/verify"], category: Category::Keys, description: "Show safety number for verification", usage: "/safety-number " }, + CmdDef { name: "/resolve", aliases: &[], category: Category::Utility, description: "Resolve username to identity key", usage: "/resolve " }, + CmdDef { name: "/help", aliases: &["/?"], category: Category::Utility, description: "Show this help message", usage: "/help" }, + CmdDef { name: "/quit", aliases: &["/q", "/exit"], category: Category::Utility, description: "Exit the REPL", usage: "/quit" }, + CmdDef { name: "/clear", aliases: &[], category: Category::Utility, description: "Clear the terminal", usage: "/clear" }, + CmdDef { name: "/health", aliases: &[], category: Category::Debug, description: "Check server connection health", usage: "/health" }, + CmdDef { name: "/status", aliases: &[], category: Category::Debug, description: "Show connection and auth state", usage: "/status" }, +]; + +// ── REPL state ────────────────────────────────────────────────────────────── + +struct ReplState { + current_conversation: Option, + current_display_name: Option, + identity: Option>, +} + +impl ReplState { + fn new() -> Self { + Self { + current_conversation: None, + current_display_name: None, + identity: None, + } + } + + fn prompt(&self) -> String { + let name = self + .current_display_name + .as_deref() + .unwrap_or("no conversation"); + format!("{DIM}[{RESET}{BOLD}{name}{RESET}{DIM}]{RESET} > ") + } + + fn set_conversation(&mut self, id: ConversationId, name: String) { + self.current_conversation = Some(id); + self.current_display_name = Some(name); + } + + fn require_identity(&self) -> anyhow::Result> { + self.identity + .clone() + .ok_or_else(|| anyhow::anyhow!("not logged in — use /login or /register first")) + } + + fn require_conversation(&self) -> anyhow::Result<&ConversationId> { + self.current_conversation + .as_ref() + .ok_or_else(|| anyhow::anyhow!("no active conversation — use /dm or /group first")) + } +} + +// ── Tab completion ────────────────────────────────────────────────────────── + +struct QpqCompleter { + names: Vec, +} + +impl QpqCompleter { + fn new() -> Self { + let mut names = Vec::new(); + for cmd in COMMANDS { + names.push(cmd.name.to_string()); + for a in cmd.aliases { + names.push(a.to_string()); + } + } + for sub in &["create", "invite", "leave", "list", "members"] { + names.push(format!("/group {sub}")); + } + Self { names } + } +} + +impl Completer for QpqCompleter { + type Candidate = Pair; + fn complete( + &self, + line: &str, + pos: usize, + _ctx: &rustyline::Context<'_>, + ) -> rustyline::Result<(usize, Vec)> { + let prefix = &line[..pos]; + if !prefix.starts_with('/') { + return Ok((0, Vec::new())); + } + let matches: Vec = self + .names + .iter() + .filter(|n| n.starts_with(prefix)) + .map(|n| Pair { + display: n.clone(), + replacement: n.clone(), + }) + .collect(); + Ok((0, matches)) + } +} +impl Hinter for QpqCompleter { type Hint = String; } +impl Highlighter for QpqCompleter {} +impl Validator for QpqCompleter {} +impl Helper for QpqCompleter {} + +// ── Auto-start server ─────────────────────────────────────────────────────── + +struct ServerGuard(Option); + +impl Drop for ServerGuard { + fn drop(&mut self) { + if let Some(ref mut child) = self.0 { + let _ = child.kill(); + let _ = child.wait(); + } + } +} + +fn find_server_binary() -> Option { + if let Ok(exe) = std::env::current_exe() { + let sibling = exe.with_file_name("qpq-server"); + if sibling.exists() { + return Some(sibling); + } + } + std::env::var_os("PATH").and_then(|paths| { + std::env::split_paths(&paths) + .map(|dir| dir.join("qpq-server")) + .find(|p| p.exists()) + }) +} + +async fn auto_start_server(addr: &str) -> ServerGuard { + if tokio::net::TcpStream::connect(addr).await.is_ok() { + return ServerGuard(None); + } + let binary = match find_server_binary() { + Some(b) => b, + None => { + display::print_status("server not reachable and qpq-server binary not found"); + return ServerGuard(None); + } + }; + display::print_status(&format!("starting server on {addr}...")); + let child = match ProcessCommand::new(&binary) + .args(["--allow-insecure-auth", "--listen", addr]) + .stdout(std::process::Stdio::null()) + .stderr(std::process::Stdio::null()) + .spawn() + { + Ok(c) => c, + Err(e) => { + display::print_error(&format!("failed to spawn server: {e}")); + return ServerGuard(None); + } + }; + let guard = ServerGuard(Some(child)); + let mut delay = Duration::from_millis(100); + let deadline = tokio::time::Instant::now() + Duration::from_secs(5); + loop { + tokio::time::sleep(delay).await; + if tokio::net::TcpStream::connect(addr).await.is_ok() { + display::print_status("server ready"); + return guard; + } + if tokio::time::Instant::now() > deadline { + display::print_error("server did not become ready within 5 s"); + return guard; + } + delay = (delay * 2).min(Duration::from_secs(1)); + } +} + +// ── Background event listener ─────────────────────────────────────────────── + +fn spawn_event_listener(mut rx: broadcast::Receiver) { + tokio::spawn(async move { + loop { + match rx.recv().await { + Ok(event) => show_event(&event), + Err(broadcast::error::RecvError::Lagged(n)) => { + display::print_status(&format!("(skipped {n} events)")); + } + Err(broadcast::error::RecvError::Closed) => break, + } + } + }); +} + +fn show_event(event: &ClientEvent) { + match event { + ClientEvent::MessageReceived { sender_name, sender_key, body, .. } => { + let sender = match sender_name.as_deref() { + Some(n) if !n.is_empty() => n.to_string(), + _ => hex::encode(&sender_key[..4.min(sender_key.len())]), + }; + display::print_incoming(&sender, body); + } + ClientEvent::ConversationCreated { display_name, .. } => { + display::print_status(&format!("new conversation: {display_name}")); + } + ClientEvent::MemberAdded { member_key, .. } => { + display::print_status(&format!( + "member added: {}", + hex::encode(&member_key[..4.min(member_key.len())]) + )); + } + ClientEvent::Error { message } => display::print_error(message), + _ => {} + } +} + +// ── Help ──────────────────────────────────────────────────────────────────── + +fn print_help() { + println!("\n{BOLD}quicproquo v2 REPL{RESET}\n"); + for cat in Category::all() { + println!("{BOLD}{}{RESET}", cat.label()); + for cmd in COMMANDS.iter().filter(|c| c.category == *cat) { + let aliases = if cmd.aliases.is_empty() { + String::new() + } else { + format!(" {DIM}({}){RESET}", cmd.aliases.join(", ")) + }; + println!(" {GREEN}{:<24}{RESET} {}{aliases}", cmd.usage, cmd.description); + } + println!(); + } + println!("{DIM}Bare text (without /) sends to the current conversation.{RESET}\n"); +} + +// ── Formatting helpers ────────────────────────────────────────────────────── + +fn ts(ms: u64) -> String { + let s = ms / 1000; + format!("{:02}:{:02}:{:02}", (s / 3600) % 24, (s / 60) % 60, s % 60) +} + +fn print_stored(msg: &StoredMessage) { + let t = ts(msg.timestamp_ms); + if msg.is_outgoing { + println!("{DIM}[{t}]{RESET} {GREEN}> {}{RESET}", msg.body); + } else { + let fallback = hex::encode(&msg.sender_key[..4.min(msg.sender_key.len())]); + let sender = msg.sender_name.as_deref().unwrap_or(&fallback); + println!("{DIM}[{t}]{RESET} {CYAN}{BOLD}{sender}{RESET}: {}", msg.body); + } +} + +// ── Command parsing ───────────────────────────────────────────────────────── + +fn split_cmd(input: &str) -> Option<(&str, &str)> { + let s = input.trim(); + if !s.starts_with('/') { + return None; + } + match s.find(char::is_whitespace) { + Some(i) => Some((&s[..i], s[i..].trim())), + None => Some((s, "")), + } +} + +// ── Command dispatch ──────────────────────────────────────────────────────── + +/// Returns `Ok(true)` when the REPL should exit. +async fn dispatch( + cmd: &str, + args: &str, + client: &mut QpqClient, + st: &mut ReplState, +) -> anyhow::Result { + match cmd { + "/quit" | "/q" | "/exit" => return Ok(true), + "/help" | "/?" => print_help(), + "/clear" => print!("\x1b[2J\x1b[H"), + "/status" => do_status(client, st), + "/health" => do_health(client), + "/whoami" => do_whoami(client), + "/register" => do_register(client, st, args).await?, + "/login" => do_login(client, st, args).await?, + "/logout" => do_logout(client)?, + "/resolve" => do_resolve(client, args).await?, + "/safety-number" | "/verify" => do_safety(client, st, args).await?, + "/refresh-key" => do_refresh_key(client, st).await?, + "/dm" => do_dm(client, st, args).await?, + "/send" | "/s" => do_send(client, st, args).await?, + "/recv" | "/r" => do_recv(client, st).await?, + "/history" => do_history(client, st, args)?, + "/list" | "/ls" => do_list(client)?, + "/switch" | "/sw" => do_switch(client, st, args)?, + "/group" | "/g" => do_group(client, st, args).await?, + _ => display::print_error(&format!("unknown command: {cmd} (try /help)")), + } + Ok(false) +} + +// ── Command implementations ───────────────────────────────────────────────── + +fn do_status(client: &QpqClient, st: &ReplState) { + println!("{BOLD}Status{RESET}"); + println!(" connected: {}", if client.is_connected() { "yes" } else { "no" }); + println!(" authenticated: {}", if client.is_authenticated() { "yes" } else { "no" }); + println!(" username: {}", client.username().unwrap_or("(none)")); + println!(" conversation: {}", st.current_display_name.as_deref().unwrap_or("(none)")); + if let Some(key) = client.identity_key() { + println!(" identity: {}", hex::encode(key)); + } +} + +fn do_health(client: &QpqClient) { + if client.is_connected() { + display::print_status("connected to server"); + } else { + display::print_error("not connected"); + } +} + +fn do_whoami(client: &QpqClient) { + match client.username() { + Some(u) => { + println!("{BOLD}{u}{RESET}"); + if let Some(key) = client.identity_key() { + println!("{DIM}identity: {}{RESET}", hex::encode(key)); + } + } + None => display::print_status("not logged in"), + } +} + +async fn do_register(client: &mut QpqClient, _st: &mut ReplState, args: &str) -> anyhow::Result<()> { + let parts: Vec<&str> = args.splitn(2, char::is_whitespace).collect(); + if parts.len() < 2 || parts[1].is_empty() { + display::print_error("usage: /register "); + return Ok(()); + } + let (user, pass) = (parts[0], parts[1]); + client.register(user, pass).await.map_err(|e| anyhow::anyhow!("{e}"))?; + + // After registration the SDK has set the identity key (public). + // To get the full keypair we need the seed. Since `register` internally + // generates a fresh keypair, we load it from the state file if available, + // or generate a stand-in for this session. + if let Some(pub_key) = client.identity_key() { + if pub_key.len() == 32 { + display::print_status(&format!("registered as {user}")); + display::print_status(&format!("identity: {}", hex::encode(pub_key))); + } + } + // Note: identity keypair is set during login (which gives us the seed via state). + display::print_status("use /login to authenticate"); + Ok(()) +} + +async fn do_login(client: &mut QpqClient, st: &mut ReplState, args: &str) -> anyhow::Result<()> { + let parts: Vec<&str> = args.splitn(2, char::is_whitespace).collect(); + if parts.len() < 2 || parts[1].is_empty() { + display::print_error("usage: /login "); + return Ok(()); + } + let (user, pass) = (parts[0], parts[1]); + client.login(user, pass).await.map_err(|e| anyhow::anyhow!("{e}"))?; + + // Try to load identity keypair from state file. + let state_path = &client.config_state_path(); + if state_path.exists() { + match quicproquo_sdk::state::load_state(state_path, Some(pass)) { + Ok(stored) => { + let kp = IdentityKeypair::from_seed(stored.identity_seed); + st.identity = Some(Arc::new(kp)); + } + Err(_) => { + // Try without password (unencrypted state). + if let Ok(stored) = quicproquo_sdk::state::load_state(state_path, None) { + let kp = IdentityKeypair::from_seed(stored.identity_seed); + st.identity = Some(Arc::new(kp)); + } + } + } + } + display::print_status(&format!("logged in as {user}")); + Ok(()) +} + +fn do_logout(client: &mut QpqClient) -> anyhow::Result<()> { + client.logout().map_err(|e| anyhow::anyhow!("{e}"))?; + display::print_status("logged out"); + Ok(()) +} + +async fn do_resolve(client: &QpqClient, args: &str) -> anyhow::Result<()> { + let name = args.trim(); + if name.is_empty() { + display::print_error("usage: /resolve "); + return Ok(()); + } + let rpc = client.rpc().map_err(|e| anyhow::anyhow!("{e}"))?; + match quicproquo_sdk::users::resolve_user(rpc, name).await? { + Some(key) => println!(" {name} -> {}", hex::encode(&key)), + None => display::print_error(&format!("user '{name}' not found")), + } + Ok(()) +} + +async fn do_safety(client: &QpqClient, st: &ReplState, args: &str) -> anyhow::Result<()> { + let name = args.trim(); + if name.is_empty() { + display::print_error("usage: /safety-number "); + return Ok(()); + } + let identity = st.require_identity()?; + let my_key = identity.public_key_bytes(); + + let rpc = client.rpc().map_err(|e| anyhow::anyhow!("{e}"))?; + let peer_key = quicproquo_sdk::users::resolve_user(rpc, name) + .await? + .ok_or_else(|| anyhow::anyhow!("user '{name}' not found"))?; + if peer_key.len() != 32 { + anyhow::bail!("peer key is not 32 bytes"); + } + let mut peer_arr = [0u8; 32]; + peer_arr.copy_from_slice(&peer_key); + + let sn = quicproquo_core::compute_safety_number(&my_key, &peer_arr); + println!("\n{BOLD}Safety number with {name}:{RESET}"); + println!(" {sn}\n"); + println!("{DIM}Compare with {name} over a trusted channel.{RESET}"); + Ok(()) +} + +async fn do_refresh_key(client: &QpqClient, st: &ReplState) -> anyhow::Result<()> { + let identity = st.require_identity()?; + let rpc = client.rpc().map_err(|e| anyhow::anyhow!("{e}"))?; + + let mut member = GroupMember::new(Arc::clone(&identity)); + let kp_bytes = member + .generate_key_package() + .map_err(|e| anyhow::anyhow!("generate key package: {e}"))?; + + let pub_key = identity.public_key_bytes(); + let fp = quicproquo_sdk::keys::upload_key_package(rpc, &pub_key, &kp_bytes).await?; + display::print_status(&format!( + "KeyPackage uploaded (fp: {})", + hex::encode(&fp[..8.min(fp.len())]) + )); + Ok(()) +} + +async fn do_dm(client: &mut QpqClient, st: &mut ReplState, args: &str) -> anyhow::Result<()> { + let username = args.trim(); + if username.is_empty() { + display::print_error("usage: /dm "); + return Ok(()); + } + let identity = st.require_identity()?; + let rpc = client.rpc().map_err(|e| anyhow::anyhow!("{e}"))?; + let conv_store = client.conversations().map_err(|e| anyhow::anyhow!("{e}"))?; + + let peer_key = quicproquo_sdk::users::resolve_user(rpc, username) + .await? + .ok_or_else(|| anyhow::anyhow!("user '{username}' not found"))?; + + // Check for existing DM. + if let Some(existing) = conv_store.find_dm_by_peer(&peer_key)? { + st.set_conversation(existing.id, format!("@{username}")); + display::print_status(&format!("switched to DM with @{username}")); + return Ok(()); + } + + let peer_kp = quicproquo_sdk::keys::fetch_key_package(rpc, &peer_key) + .await? + .ok_or_else(|| anyhow::anyhow!("peer has no available KeyPackage"))?; + + let mut member = GroupMember::new(Arc::clone(&identity)); + + let (conv_id, was_new) = quicproquo_sdk::groups::create_dm( + rpc, conv_store, &mut member, &identity, + &peer_key, &peer_kp, None, None, + ).await?; + + st.set_conversation(conv_id, format!("@{username}")); + if was_new { + display::print_status(&format!("DM created with @{username}")); + } else { + display::print_status(&format!("DM with @{username} — waiting for Welcome")); + } + Ok(()) +} + +async fn do_send(client: &QpqClient, st: &ReplState, msg: &str) -> anyhow::Result<()> { + if msg.is_empty() { + display::print_error("usage: /send "); + return Ok(()); + } + let conv_id = st.require_conversation()?; + let identity = st.require_identity()?; + let rpc = client.rpc().map_err(|e| anyhow::anyhow!("{e}"))?; + let conv_store = client.conversations().map_err(|e| anyhow::anyhow!("{e}"))?; + + let conv = conv_store + .load_conversation(conv_id)? + .ok_or_else(|| anyhow::anyhow!("conversation not found"))?; + + let mut member = quicproquo_sdk::groups::restore_mls_state(&conv, &identity)?; + + let my_pub = identity.public_key_bytes(); + let recipients: Vec> = conv + .member_keys + .iter() + .filter(|k| k.as_slice() != my_pub.as_slice()) + .cloned() + .collect(); + if recipients.is_empty() { + display::print_error("no recipients in conversation"); + return Ok(()); + } + + let hybrid_keys = vec![None; recipients.len()]; + quicproquo_sdk::messaging::send_message( + rpc, &mut member, &identity, msg, &recipients, &hybrid_keys, conv_id.0.as_slice(), + ).await?; + + quicproquo_sdk::groups::save_mls_state(conv_store, conv_id, &member)?; + + let now = quicproquo_sdk::conversation::now_ms(); + conv_store.save_message(&StoredMessage { + conversation_id: conv_id.clone(), + message_id: None, + sender_key: my_pub.to_vec(), + sender_name: client.username().map(|s| s.to_string()), + body: msg.to_string(), + msg_type: "chat".to_string(), + ref_msg_id: None, + timestamp_ms: now, + is_outgoing: true, + })?; + + println!("{GREEN}> {msg}{RESET}"); + Ok(()) +} + +async fn do_recv(client: &QpqClient, st: &ReplState) -> anyhow::Result<()> { + let conv_id = st.require_conversation()?; + let identity = st.require_identity()?; + let rpc = client.rpc().map_err(|e| anyhow::anyhow!("{e}"))?; + let conv_store = client.conversations().map_err(|e| anyhow::anyhow!("{e}"))?; + + let conv = conv_store + .load_conversation(conv_id)? + .ok_or_else(|| anyhow::anyhow!("conversation not found"))?; + + let mut member = quicproquo_sdk::groups::restore_mls_state(&conv, &identity)?; + let my_pub = identity.public_key_bytes(); + + let messages = quicproquo_sdk::messaging::receive_messages( + rpc, &mut member, &my_pub, None, conv_id.0.as_slice(), + ).await?; + + if messages.is_empty() { + display::print_status("no new messages"); + return Ok(()); + } + + quicproquo_sdk::groups::save_mls_state(conv_store, conv_id, &member)?; + + for m in &messages { + let sender_name = quicproquo_sdk::users::resolve_identity(rpc, &m.sender_key) + .await + .ok() + .flatten(); + let sender_hex = hex::encode(&m.sender_key[..4]); + let sender = sender_name.as_deref().unwrap_or(&sender_hex); + + let body = match &m.message { + quicproquo_core::AppMessage::Chat { body, .. } => { + String::from_utf8_lossy(body).to_string() + } + other => format!("{other:?}"), + }; + + let now = quicproquo_sdk::conversation::now_ms(); + println!("{DIM}[{}]{RESET} {CYAN}{BOLD}{sender}{RESET}: {body}", ts(now)); + + conv_store.save_message(&StoredMessage { + conversation_id: conv_id.clone(), + message_id: None, + sender_key: m.sender_key.to_vec(), + sender_name: sender_name.clone(), + body, + msg_type: "chat".to_string(), + ref_msg_id: None, + timestamp_ms: now, + is_outgoing: false, + })?; + } + display::print_status(&format!("{} message(s) received", messages.len())); + Ok(()) +} + +fn do_history(client: &QpqClient, st: &ReplState, args: &str) -> anyhow::Result<()> { + let conv_id = st.require_conversation()?; + let count = args.trim().parse::().unwrap_or(20); + let conv_store = client.conversations().map_err(|e| anyhow::anyhow!("{e}"))?; + let msgs = conv_store.load_recent_messages(conv_id, count)?; + if msgs.is_empty() { + display::print_status("no messages yet"); + } else { + for m in &msgs { + print_stored(m); + } + } + Ok(()) +} + +fn do_list(client: &QpqClient) -> anyhow::Result<()> { + let conv_store = client.conversations().map_err(|e| anyhow::anyhow!("{e}"))?; + let convs = conv_store.list_conversations()?; + if convs.is_empty() { + display::print_status("no conversations — try /dm "); + return Ok(()); + } + println!("\n{BOLD}Conversations{RESET}"); + for c in &convs { + let kind_label = match &c.kind { + ConversationKind::Dm { .. } => "dm", + ConversationKind::Group { .. } => "group", + }; + let unread = if c.unread_count > 0 { + format!(" {YELLOW}({} new){RESET}", c.unread_count) + } else { + String::new() + }; + println!( + " {BOLD}{}{RESET} {DIM}[{kind_label}, {} members]{RESET}{unread}", + c.display_name, + c.member_keys.len() + ); + } + println!(); + Ok(()) +} + +fn do_switch(client: &QpqClient, st: &mut ReplState, name: &str) -> anyhow::Result<()> { + let name = name.trim(); + if name.is_empty() { + display::print_error("usage: /switch "); + return Ok(()); + } + let conv_store = client.conversations().map_err(|e| anyhow::anyhow!("{e}"))?; + let convs = conv_store.list_conversations()?; + let lower = name.to_lowercase(); + let found = convs.iter().find(|c| c.display_name.to_lowercase().contains(&lower)); + match found { + Some(c) => { + st.set_conversation(c.id.clone(), c.display_name.clone()); + display::print_status(&format!("switched to {}", c.display_name)); + } + None => display::print_error(&format!("no conversation matching '{name}'")), + } + Ok(()) +} + +async fn do_group(client: &mut QpqClient, st: &mut ReplState, args: &str) -> anyhow::Result<()> { + let parts: Vec<&str> = args.splitn(3, char::is_whitespace).collect(); + let sub = parts.first().copied().unwrap_or(""); + + match sub { + "create" => { + let name = parts.get(1).copied().unwrap_or("").trim(); + if name.is_empty() { + display::print_error("usage: /group create "); + return Ok(()); + } + let identity = st.require_identity()?; + let conv_store = client.conversations().map_err(|e| anyhow::anyhow!("{e}"))?; + let mut member = GroupMember::new(Arc::clone(&identity)); + let conv_id = quicproquo_sdk::groups::create_group(conv_store, &mut member, name)?; + st.set_conversation(conv_id, format!("#{name}")); + display::print_status(&format!("group #{name} created")); + } + + "invite" => { + let group = parts.get(1).copied().unwrap_or("").trim(); + let user = parts.get(2).copied().unwrap_or("").trim(); + if group.is_empty() || user.is_empty() { + display::print_error("usage: /group invite "); + return Ok(()); + } + let identity = st.require_identity()?; + let rpc = client.rpc().map_err(|e| anyhow::anyhow!("{e}"))?; + let conv_store = client.conversations().map_err(|e| anyhow::anyhow!("{e}"))?; + + let peer_key = quicproquo_sdk::users::resolve_user(rpc, user) + .await? + .ok_or_else(|| anyhow::anyhow!("user '{user}' not found"))?; + let peer_kp = quicproquo_sdk::keys::fetch_key_package(rpc, &peer_key) + .await? + .ok_or_else(|| anyhow::anyhow!("peer has no KeyPackage"))?; + + let conv_id = ConversationId::from_group_name(group); + let conv = conv_store + .load_conversation(&conv_id)? + .ok_or_else(|| anyhow::anyhow!("group '{group}' not found"))?; + let mut member = quicproquo_sdk::groups::restore_mls_state(&conv, &identity)?; + + quicproquo_sdk::groups::invite_to_group( + rpc, conv_store, &mut member, &identity, + &conv_id, &peer_key, &peer_kp, None, None, + ).await?; + display::print_status(&format!("invited @{user} to #{group}")); + } + + "leave" => { + display::print_status("group leave not yet implemented in SDK"); + } + + "list" => do_list(client)?, + + "members" => { + let conv_id = st.require_conversation()?; + let conv_store = client.conversations().map_err(|e| anyhow::anyhow!("{e}"))?; + let conv = conv_store + .load_conversation(conv_id)? + .ok_or_else(|| anyhow::anyhow!("conversation not found"))?; + + println!("\n{BOLD}Members{RESET} ({})", conv.member_keys.len()); + for key in &conv.member_keys { + let short = hex::encode(&key[..4.min(key.len())]); + if let Ok(rpc) = client.rpc() { + if let Ok(Some(n)) = quicproquo_sdk::users::resolve_identity(rpc, key).await { + println!(" @{n} {DIM}({short}){RESET}"); + continue; + } + } + println!(" {short}"); + } + println!(); + } + + _ => display::print_error("usage: /group [args]"), + } + Ok(()) +} + +// ── Entry point ───────────────────────────────────────────────────────────── + +/// Run the v2 REPL over a `QpqClient`. +/// +/// If `username` and `password` are provided, auto-login is attempted. +pub async fn run_v2_repl( + client: &mut QpqClient, + username: Option<&str>, + password: Option<&str>, +) -> anyhow::Result<()> { + // Auto-start server. + let _server_guard = auto_start_server(&client.server_addr_string()).await; + + // Connect to server. + client.connect().await.context("connect to server")?; + + // Background event listener. + let rx = client.subscribe(); + spawn_event_listener(rx); + + let mut st = ReplState::new(); + + // Auto-login if credentials provided. + if let (Some(user), Some(pass)) = (username, password) { + match client.login(user, pass).await { + Ok(()) => { + display::print_status(&format!("logged in as {user}")); + // Load identity from state. + let state_path = client.config_state_path(); + if state_path.exists() { + if let Ok(stored) = quicproquo_sdk::state::load_state(&state_path, Some(pass)) + .or_else(|_| quicproquo_sdk::state::load_state(&state_path, None)) + { + let kp = IdentityKeypair::from_seed(stored.identity_seed); + st.identity = Some(Arc::new(kp)); + } + } + } + Err(e) => display::print_error(&format!("auto-login failed: {e}")), + } + } + + println!("\n{BOLD}quicproquo v2 REPL{RESET}"); + println!("{DIM}Type /help for commands, /quit to exit.{RESET}\n"); + if let Some(u) = client.username() { + display::print_status(&format!("authenticated as {u}")); + } + + // Rustyline editor with tab-completion. + let config = Config::builder().auto_add_history(true).build(); + let mut rl: Editor = + Editor::with_config(config).context("init readline")?; + rl.set_helper(Some(QpqCompleter::new())); + + loop { + let prompt = st.prompt(); + match rl.readline(&prompt) { + Ok(line) => { + let trimmed = line.trim(); + if trimmed.is_empty() { + continue; + } + if let Some((cmd, args)) = split_cmd(trimmed) { + match dispatch(cmd, args, client, &mut st).await { + Ok(true) => break, + Ok(false) => {} + Err(e) => display::print_error(&format!("{e:#}")), + } + } else { + // Bare text → send to current conversation. + if let Err(e) = do_send(client, &st, trimmed).await { + display::print_error(&format!("{e:#}")); + } + } + } + Err(ReadlineError::Interrupted | ReadlineError::Eof) => { + display::print_status("goodbye"); + break; + } + Err(e) => { + display::print_error(&format!("readline: {e}")); + break; + } + } + } + + client.disconnect(); + Ok(()) +} diff --git a/crates/quicproquo-sdk/src/client.rs b/crates/quicproquo-sdk/src/client.rs index dabf3f1..3698bba 100644 --- a/crates/quicproquo-sdk/src/client.rs +++ b/crates/quicproquo-sdk/src/client.rs @@ -92,6 +92,16 @@ impl QpqClient { self.session_token.is_some() } + /// Get the server address as a string (e.g. "127.0.0.1:7000"). + pub fn server_addr_string(&self) -> String { + self.config.server_addr.to_string() + } + + /// Get the state file path from the client configuration. + pub fn config_state_path(&self) -> std::path::PathBuf { + self.config.state_path.clone() + } + /// Get a reference to the RPC client (for direct calls). pub fn rpc(&self) -> Result<&quicproquo_rpc::client::RpcClient, SdkError> { self.rpc.as_ref().ok_or(SdkError::NotConnected) diff --git a/crates/quicproquo-server/src/ws_bridge.rs b/crates/quicproquo-server/src/ws_bridge.rs index 0432ae3..6d5f4ce 100644 --- a/crates/quicproquo-server/src/ws_bridge.rs +++ b/crates/quicproquo-server/src/ws_bridge.rs @@ -165,6 +165,7 @@ async fn dispatch(state: &WsBridgeState, req: RpcRequest) -> RpcResponse { "send" => handle_send(state, req.id, &req.params), "receive" => handle_receive(state, req.id, &req.params), "deleteAccount" => handle_delete_account(state, req.id, &req.params), + "register" => handle_register(state, req.id, &req.params), _ => RpcResponse::error(req.id, format!("unknown method: {}", req.method)), } } @@ -175,6 +176,89 @@ fn handle_health(id: serde_json::Value) -> RpcResponse { RpcResponse::success(id, serde_json::json!("ok")) } +fn handle_register( + state: &WsBridgeState, + id: serde_json::Value, + params: &serde_json::Value, +) -> RpcResponse { + // Only allow in insecure-auth mode (development/demo). + if !state.allow_insecure_auth { + return RpcResponse::error(id, "register is only available in --allow-insecure-auth mode"); + } + + // Rate limit. + let auth_ctx = match extract_auth(state, params) { + Ok(ctx) => ctx, + Err(e) => return RpcResponse::error(id, e), + }; + if let Err(e) = ws_check_rate_limit(state, &auth_ctx) { + return RpcResponse::error(id, e); + } + + // Validate username. + let username = match params.get("username").and_then(|v| v.as_str()) { + Some(u) if !u.is_empty() => u, + _ => return RpcResponse::error(id, "missing or empty 'username' param"), + }; + if username.len() > 32 { + return RpcResponse::error(id, "username must be at most 32 characters"); + } + if !username.chars().all(|c| c.is_ascii_alphanumeric() || c == '_') { + return RpcResponse::error(id, "username must be alphanumeric or underscore only"); + } + + // Validate identity key. + let ik_b64 = match params.get("identityKey").and_then(|v| v.as_str()) { + Some(s) if !s.is_empty() => s, + _ => return RpcResponse::error(id, "missing or empty 'identityKey' param"), + }; + let identity_key = match B64.decode(ik_b64) { + Ok(k) => k, + Err(e) => return RpcResponse::error(id, format!("bad base64 identityKey: {e}")), + }; + if identity_key.len() != 32 { + return RpcResponse::error(id, "identityKey must be 32 bytes"); + } + + // Check if username is already taken by a different key. + match state.store.get_user_identity_key(username) { + Ok(Some(existing)) if existing == identity_key => { + // Idempotent: same key, return success. + return RpcResponse::success( + id, + serde_json::json!({ + "username": username, + "identityKey": B64.encode(&identity_key), + }), + ); + } + Ok(Some(_)) => { + return RpcResponse::error(id, "username already taken"); + } + Ok(None) => {} // Available, proceed. + Err(e) => return RpcResponse::error(id, format!("storage error: {e}")), + } + + // Store the mapping. + if let Err(e) = state.store.store_user_identity_key(username, identity_key.clone()) { + return RpcResponse::error(id, format!("storage error: {e}")); + } + + tracing::info!( + username = %username, + key_prefix = %hex::encode(&identity_key[..4]), + "audit: ws_bridge register" + ); + + RpcResponse::success( + id, + serde_json::json!({ + "username": username, + "identityKey": B64.encode(&identity_key), + }), + ) +} + async fn handle_resolve_user( state: &WsBridgeState, id: serde_json::Value, diff --git a/sdks/typescript/demo/index.html b/sdks/typescript/demo/index.html index eb8a331..c72d84f 100644 --- a/sdks/typescript/demo/index.html +++ b/sdks/typescript/demo/index.html @@ -3,7 +3,7 @@ -quicproquo -- Browser Crypto Demo +quicproquo -- E2E Encrypted Messenger Demo

quicproquo

-

E2E Encrypted Messenger -- Browser Crypto Demo

+

E2E Encrypted Messenger -- Browser Demo

- -
-

WASM Module

-
- - Not loaded -
-
- - -
-

Ed25519 Identity

-
- - -
-
-
- Alice -
--
-
-
- Bob -
--
-
-
-
- - -
-

Safety Number

- -
--
-
- - -
-

Sign & Verify

- -
- - -
-
--
-
- - -
-

Hybrid Encryption (X25519 + ML-KEM-768)

- -
- - - -
-
--
-
- - -
-

Sealed Sender

- -
- - -
-
--
-
- - -
-

Message Padding

- -
- - -
-
--
-
- - +

Server Connection

- +
+ + + + + Disconnected +
+
WASM: loading...
+
+ + +
+

Identity & Registration

+

+ Register generates an Ed25519 keypair via WASM and registers with the server. +

- - - Disconnected -
-
- The qpq server provides a built-in WebSocket JSON-RPC bridge. - Start the server with --ws-listen 0.0.0.0:9000 to enable browser connectivity. - This demo sends JSON-framed requests over WebSocket. + +
+

Chat

-
- - +
+
- -
- - +
+
Connect and register to start chatting.
-
+
+ + +
+
+ +
+

Crypto Playground

+ + +
+

Ed25519 Identity

+
+ + +
+
+
Alice
--
+
Bob
--
+
+
+ + +
+

Safety Number

+ +
--
+
+ + +
+

Sign & Verify

+ +
+ + +
+
--
+
+ + +
+

Hybrid Encryption (X25519 + ML-KEM-768)

+ +
+ + + +
+
--
+
+ + +
+

Sealed Sender

+ +
+ + +
+
--
+
+ + +
+

Message Padding

+ +
+ + +
+
--
+
+
+ + +
+

Event Log

(0)
+
+
+