From 6b8b61c6aed8aefb084ee3f5ee9077ffc4dd7bc8 Mon Sep 17 00:00:00 2001 From: Christian Nennemann Date: Sun, 22 Feb 2026 20:40:12 +0100 Subject: [PATCH] feat: add delivery sequence numbers + major server/client refactor MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Delivery sequence numbers (MLS epoch ordering fix): - schemas/node.capnp: add Envelope{seq,data} struct; enqueue returns seq:UInt64; fetch/fetchWait return List(Envelope) instead of List(Data) - storage.rs: Store trait enqueue returns u64; fetch/fetch_limited return Vec<(u64, Vec)>; FileBackedStore gains QueueMapV3 with per-inbox seq counters and V2→V3 on-disk migration - migrations/002_add_seq.sql: seq column, delivery_seq_counters table, index - sql_store.rs: atomic UPSERT counter via RETURNING, ORDER BY seq, SCHEMA_VERSION→3 - node_service/delivery.rs: builds Envelope list; returns seq from enqueue - client/rpc.rs: enqueue→u64, fetch_all/fetch_wait→Vec<(u64,Vec)> - client/commands.rs: sort-by-seq before MLS processing; retry loop in cmd_recv and receive_pending_plaintexts for correct epoch ordering Server refactor: - Split monolithic main.rs into node_service/{mod,delivery,auth_ops,key_ops,p2p_ops} - Add auth.rs (token validation, rate limiting), config.rs, metrics.rs, tls.rs - Add SQL migrations runner (001_initial.sql, 002_add_seq.sql) - OPAQUE PAKE login/registration, sealed-sender mode, queue depth limit (1000) Client refactor: - Split lib.rs into client/{commands,rpc,state,retry,hex,mod} - Add cmd_whoami, cmd_health, cmd_check_key, cmd_ping subcommands - Add cmd_register_user, cmd_login (OPAQUE), cmd_refresh_keypackage - Hybrid PQ envelope (X25519 + ML-KEM-768) on all send/recv paths - E2E test suite expanded Other: - quicnprotochat-gui: Tauri 2 desktop GUI skeleton (backend + HTML UI) - quicnprotochat-p2p: iroh-based P2P transport stub - quicnprotochat-core: app_message, hybrid_crypto modules; GroupMember API updates - .github/workflows/size-lint.yml: binary size regression check - docs: protocol comparison, roadmap updates, fully-operational checklist --- .github/INSTRUCTIONS.md | 9 + .github/workflows/size-lint.yml | 16 + Cargo.lock | 106 + README.md | 18 +- crates/quicnprotochat-client/Cargo.toml | 2 + .../src/client/commands.rs | 1112 ++++++++ .../quicnprotochat-client/src/client/hex.rs | 13 + .../quicnprotochat-client/src/client/mod.rs | 9 + .../quicnprotochat-client/src/client/retry.rs | 81 + .../quicnprotochat-client/src/client/rpc.rs | 367 +++ .../quicnprotochat-client/src/client/state.rs | 225 ++ crates/quicnprotochat-client/src/lib.rs | 1355 +--------- crates/quicnprotochat-client/src/main.rs | 101 +- crates/quicnprotochat-client/tests/e2e.rs | 474 +++- crates/quicnprotochat-core/src/app_message.rs | 258 ++ crates/quicnprotochat-core/src/error.rs | 8 + crates/quicnprotochat-core/src/group.rs | 119 +- .../quicnprotochat-core/src/hybrid_crypto.rs | 442 ++++ crates/quicnprotochat-core/src/hybrid_kem.rs | 134 +- crates/quicnprotochat-core/src/lib.rs | 14 +- crates/quicnprotochat-gui/Cargo.toml | 22 + crates/quicnprotochat-gui/README.md | 32 + crates/quicnprotochat-gui/build.rs | 3 + .../capabilities/default.json | 11 + .../gen/schemas/acl-manifests.json | 1 + .../gen/schemas/capabilities.json | 1 + .../gen/schemas/desktop-schema.json | 2244 +++++++++++++++++ .../gen/schemas/linux-schema.json | 2244 +++++++++++++++++ crates/quicnprotochat-gui/icons/icon.png | Bin 0 -> 2200 bytes crates/quicnprotochat-gui/src/backend.rs | 86 + crates/quicnprotochat-gui/src/lib.rs | 76 + crates/quicnprotochat-gui/src/main.rs | 5 + crates/quicnprotochat-gui/tauri.conf.json | 24 + crates/quicnprotochat-gui/ui/index.html | 54 + crates/quicnprotochat-p2p/src/lib.rs | 25 +- crates/quicnprotochat-server/Cargo.toml | 7 + .../migrations/001_initial.sql | 47 + .../migrations/002_add_seq.sql | 21 + crates/quicnprotochat-server/src/auth.rs | 221 ++ crates/quicnprotochat-server/src/config.rs | 187 ++ crates/quicnprotochat-server/src/main.rs | 1538 +---------- crates/quicnprotochat-server/src/metrics.rs | 49 + .../src/node_service/auth_ops.rs | 351 +++ .../src/node_service/delivery.rs | 318 +++ .../src/node_service/key_ops.rs | 259 ++ .../src/node_service/mod.rs | 246 ++ .../src/node_service/p2p_ops.rs | 118 + crates/quicnprotochat-server/src/sql_store.rs | 204 +- crates/quicnprotochat-server/src/storage.rs | 112 +- crates/quicnprotochat-server/src/tls.rs | 72 + .../src/getting-started/running-the-client.md | 31 +- docs/src/internals/keypackage-exchange.md | 2 + .../roadmap/fully-operational-checklist.md | 135 + docs/src/roadmap/milestones.md | 54 +- schemas/node.capnp | 16 +- scripts/check_rust_file_sizes.sh | 38 + 56 files changed, 10693 insertions(+), 3024 deletions(-) create mode 100644 .github/INSTRUCTIONS.md create mode 100644 .github/workflows/size-lint.yml create mode 100644 crates/quicnprotochat-client/src/client/commands.rs create mode 100644 crates/quicnprotochat-client/src/client/hex.rs create mode 100644 crates/quicnprotochat-client/src/client/mod.rs create mode 100644 crates/quicnprotochat-client/src/client/retry.rs create mode 100644 crates/quicnprotochat-client/src/client/rpc.rs create mode 100644 crates/quicnprotochat-client/src/client/state.rs create mode 100644 crates/quicnprotochat-core/src/app_message.rs create mode 100644 crates/quicnprotochat-core/src/hybrid_crypto.rs create mode 100644 crates/quicnprotochat-gui/Cargo.toml create mode 100644 crates/quicnprotochat-gui/README.md create mode 100644 crates/quicnprotochat-gui/build.rs create mode 100644 crates/quicnprotochat-gui/capabilities/default.json create mode 100644 crates/quicnprotochat-gui/gen/schemas/acl-manifests.json create mode 100644 crates/quicnprotochat-gui/gen/schemas/capabilities.json create mode 100644 crates/quicnprotochat-gui/gen/schemas/desktop-schema.json create mode 100644 crates/quicnprotochat-gui/gen/schemas/linux-schema.json create mode 100644 crates/quicnprotochat-gui/icons/icon.png create mode 100644 crates/quicnprotochat-gui/src/backend.rs create mode 100644 crates/quicnprotochat-gui/src/lib.rs create mode 100644 crates/quicnprotochat-gui/src/main.rs create mode 100644 crates/quicnprotochat-gui/tauri.conf.json create mode 100644 crates/quicnprotochat-gui/ui/index.html create mode 100644 crates/quicnprotochat-server/migrations/001_initial.sql create mode 100644 crates/quicnprotochat-server/migrations/002_add_seq.sql create mode 100644 crates/quicnprotochat-server/src/auth.rs create mode 100644 crates/quicnprotochat-server/src/config.rs create mode 100644 crates/quicnprotochat-server/src/metrics.rs create mode 100644 crates/quicnprotochat-server/src/node_service/auth_ops.rs create mode 100644 crates/quicnprotochat-server/src/node_service/delivery.rs create mode 100644 crates/quicnprotochat-server/src/node_service/key_ops.rs create mode 100644 crates/quicnprotochat-server/src/node_service/mod.rs create mode 100644 crates/quicnprotochat-server/src/node_service/p2p_ops.rs create mode 100644 crates/quicnprotochat-server/src/tls.rs create mode 100644 docs/src/roadmap/fully-operational-checklist.md create mode 100644 scripts/check_rust_file_sizes.sh diff --git a/.github/INSTRUCTIONS.md b/.github/INSTRUCTIONS.md new file mode 100644 index 0000000..843ba3d --- /dev/null +++ b/.github/INSTRUCTIONS.md @@ -0,0 +1,9 @@ +# Internal Engineering Guidelines + +## Rust file sizing and layout +- Soft cap: keep Rust source files at or below ~400 lines; if a change would exceed that, split into modules first. +- Hard cap: avoid exceeding 650 lines in any Rust file; refactor before merging (main.rs should stay <350 lines). +- Single-responsibility: group code by concern (config, TLS/setup, auth/session, storage adapters, RPC handlers, CLI parsing) instead of piling into one file. +- Structure new features as small modules wired from the entrypoint rather than expanding existing large files. +- Co-locate unit tests with their module; keep integration tests in `crates/*/tests` with focused scopes. +- Prefer descriptive module names and re-exports over deep `mod` trees that hide logic in `main.rs`. diff --git a/.github/workflows/size-lint.yml b/.github/workflows/size-lint.yml new file mode 100644 index 0000000..54abca8 --- /dev/null +++ b/.github/workflows/size-lint.yml @@ -0,0 +1,16 @@ +name: rust-file-size-lint + +on: + pull_request: + push: + branches: [ main ] + +jobs: + check-rust-file-sizes: + runs-on: ubuntu-latest + steps: + - name: Checkout + uses: actions/checkout@v4 + + - name: Run rust file-size guardrail + run: bash scripts/check_rust_file_sizes.sh diff --git a/Cargo.lock b/Cargo.lock index 7002ca3..e2a12ad 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -2495,6 +2495,12 @@ version = "0.5.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "2304e00983f87ffb38b55b444b5e3b60a884b5d30c0fca7d82fe33449bbe55ea" +[[package]] +name = "hermit-abi" +version = "0.5.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fc0fef456e4baa96da950455cd02c081ca953b141298e41db3fc7e36b1da849c" + [[package]] name = "hex" version = "0.4.3" @@ -2725,7 +2731,9 @@ dependencies = [ "http", "hyper", "hyper-util", + "log", "rustls", + "rustls-native-certs", "rustls-pki-types", "tokio", "tokio-rustls", @@ -3555,6 +3563,62 @@ dependencies = [ "autocfg", ] +[[package]] +name = "metrics" +version = "0.22.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "56d05972e8cbac2671e85aa9d04d9160d193f8bebd1a5c1a2f4542c62e65d1d0" +dependencies = [ + "ahash", + "portable-atomic", +] + +[[package]] +name = "metrics" +version = "0.23.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3045b4193fbdc5b5681f32f11070da9be3609f189a79f3390706d42587f46bb5" +dependencies = [ + "ahash", + "portable-atomic", +] + +[[package]] +name = "metrics-exporter-prometheus" +version = "0.15.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b4f0c8427b39666bf970460908b213ec09b3b350f20c0c2eabcbba51704a08e6" +dependencies = [ + "base64 0.22.1", + "http-body-util", + "hyper", + "hyper-rustls", + "hyper-util", + "indexmap 2.13.0", + "ipnet", + "metrics 0.23.1", + "metrics-util", + "quanta", + "thiserror 1.0.69", + "tokio", + "tracing", +] + +[[package]] +name = "metrics-util" +version = "0.17.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4259040465c955f9f2f1a4a8a16dc46726169bca0f88e8fb2dbeced487c3e828" +dependencies = [ + "crossbeam-epoch", + "crossbeam-utils", + "hashbrown 0.14.5", + "metrics 0.23.1", + "num_cpus", + "quanta", + "sketches-ddsketch", +] + [[package]] name = "mime" version = "0.3.17" @@ -3884,6 +3948,16 @@ dependencies = [ "autocfg", ] +[[package]] +name = "num_cpus" +version = "1.17.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "91df4bbde75afed763b708b7eee1e8e7651e02d97f6d5dd763e89367e957b23b" +dependencies = [ + "hermit-abi", + "libc", +] + [[package]] name = "num_enum" version = "0.7.5" @@ -4928,6 +5002,21 @@ dependencies = [ "unicode-ident", ] +[[package]] +name = "quanta" +version = "0.12.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f3ab5a9d756f0d97bdc89019bd2e4ea098cf9cde50ee7564dde6b81ccc8f06c7" +dependencies = [ + "crossbeam-utils", + "libc", + "once_cell", + "raw-cpuid", + "wasi 0.11.1+wasi-snapshot-preview1", + "web-sys", + "winapi", +] + [[package]] name = "quick-xml" version = "0.38.4" @@ -5042,6 +5131,8 @@ dependencies = [ "clap", "dashmap", "futures", + "metrics 0.22.4", + "metrics-exporter-prometheus", "opaque-ke", "quicnprotochat-core", "quicnprotochat-proto", @@ -5246,6 +5337,15 @@ dependencies = [ "rand_core 0.5.1", ] +[[package]] +name = "raw-cpuid" +version = "11.6.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "498cd0dc59d73224351ee52a95fee0f1a617a2eae0e7d9d720cc622c73a54186" +dependencies = [ + "bitflags 2.11.0", +] + [[package]] name = "raw-window-handle" version = "0.6.2" @@ -6092,6 +6192,12 @@ version = "1.0.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "b2aa850e253778c88a04c3d7323b043aeda9d3e30d5971937c1855769763678e" +[[package]] +name = "sketches-ddsketch" +version = "0.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "85636c14b73d81f541e525f585c0a2109e6744e1565b5c1668e31c70c10ed65c" + [[package]] name = "slab" version = "0.4.12" diff --git a/README.md b/README.md index c5b7a1e..bb752c1 100644 --- a/README.md +++ b/README.md @@ -61,6 +61,9 @@ mdbook serve docs brew install capnp # macOS # apt-get install capnproto # Debian/Ubuntu +# GUI prerequisites (Linux only) — WebKitGTK + GTK3 for Tauri 2 +# sudo apt install -y libwebkit2gtk-4.1-dev libgtk-3-dev libglib2.0-dev libssl-dev libayatana-appindicator3-dev librsvg2-dev patchelf + # Build and test cargo build --workspace cargo test --workspace @@ -81,9 +84,14 @@ db_key = "" EOF cargo run -p quicnprotochat-server -- --config quicnprotochat-server.toml -# Run the Alice/Bob demo +# Run the two-party demo cargo run -p quicnprotochat-client -- demo-group \ --server 127.0.0.1:7000 + +# Interactive 1:1 chat (after creating a group and inviting a peer) +# Terminal 1: quicnprotochat chat --peer-key +# Terminal 2: quicnprotochat chat --peer-key +# Type messages and press Enter; incoming messages appear as [peer] . Ctrl+D to exit. ``` See the [full demo walkthrough](docs/src/getting-started/demo-walkthrough.md) for a step-by-step guide. @@ -97,10 +105,10 @@ See the [full demo walkthrough](docs/src/getting-started/demo-walkthrough.md) fo | 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 | Next | Persistent CLI (`create-group`, `invite`, `join`, `send`, `recv`) | -| M5 | Multi-party groups | Planned | N > 2 members, Commit fan-out, Proposal handling | -| M6 | Persistence | Planned | SQLite key store, durable group state | -| M7 | Post-quantum | Planned | PQ hybrid for MLS/HPKE (X25519 + ML-KEM-768) | +| M4 | Group CLI subcommands | Done | Persistent CLI (`create-group`, `invite`, `join`, `send`, `recv`), OPAQUE login | +| M5 | Multi-party groups | Done | N > 2 members, Commit fan-out, send --all, epoch sync | +| M6 | Persistence | Done | SQLite/SQLCipher, migrations, durable server + client state | +| M7 | Post-quantum | Next | PQ hybrid for MLS/HPKE (X25519 + ML-KEM-768) | --- diff --git a/crates/quicnprotochat-client/Cargo.toml b/crates/quicnprotochat-client/Cargo.toml index fbe054e..3a71dc8 100644 --- a/crates/quicnprotochat-client/Cargo.toml +++ b/crates/quicnprotochat-client/Cargo.toml @@ -54,3 +54,5 @@ dashmap = { workspace = true } assert_cmd = "2" tempfile = "3" portpicker = "0.1" +rand = "0.8" +hex = "0.4" diff --git a/crates/quicnprotochat-client/src/client/commands.rs b/crates/quicnprotochat-client/src/client/commands.rs new file mode 100644 index 0000000..a585da4 --- /dev/null +++ b/crates/quicnprotochat-client/src/client/commands.rs @@ -0,0 +1,1112 @@ +use std::path::{Path, PathBuf}; + +use anyhow::Context; +use opaque_ke::{ + ClientLogin, ClientLoginFinishParameters, ClientRegistration, + ClientRegistrationFinishParameters, CredentialResponse, RegistrationResponse, +}; +use quicnprotochat_core::{ + generate_key_package, hybrid_decrypt, hybrid_encrypt, opaque_auth::OpaqueSuite, + GroupMember, HybridKeypair, IdentityKeypair, +}; + +use super::{ + hex, + rpc::{ + connect_node, current_timestamp_ms, enqueue, fetch_all, fetch_hybrid_key, + fetch_key_package, fetch_wait, try_hybrid_decrypt, upload_hybrid_key, upload_key_package, + }, + state::{decode_identity_key, load_existing_state, load_or_init_state, save_state, sha256}, +}; + +/// Print local identity information from the state file (no server connection). +pub fn cmd_whoami(state_path: &Path, password: Option<&str>) -> anyhow::Result<()> { + let state = load_existing_state(state_path, password)?; + let identity = IdentityKeypair::from_seed(state.identity_seed); + + let pk_bytes = identity.public_key_bytes(); + let fingerprint = sha256(&pk_bytes); + + println!("identity_key : {}", hex::encode(&pk_bytes)); + println!("fingerprint : {}", hex::encode(&fingerprint)); + println!( + "hybrid_key : {}", + if state.hybrid_key.is_some() { + "present (X25519 + ML-KEM-768)" + } else { + "not generated" + } + ); + println!( + "group : {}", + if state.group.is_some() { + "active" + } else { + "none" + } + ); + println!("state_file : {}", state_path.display()); + + Ok(()) +} + +/// Check server connectivity via the health RPC. +pub async fn cmd_health(server: &str, ca_cert: &Path, server_name: &str) -> anyhow::Result<()> { + let sent_at = current_timestamp_ms(); + let client = connect_node(server, ca_cert, server_name).await?; + + let req = client.health_request(); + let resp = req.send().promise.await.context("health RPC failed")?; + + let status = resp + .get() + .context("health: bad response")? + .get_status() + .context("health: missing status")? + .to_str() + .unwrap_or("invalid"); + + let rtt_ms = current_timestamp_ms().saturating_sub(sent_at); + + println!("server : {server}"); + println!("status : {status}"); + println!("rtt : {rtt_ms}ms"); + Ok(()) +} + +/// Check if a peer identity has registered a hybrid public key (non-consuming). +pub async fn cmd_check_key( + server: &str, + ca_cert: &Path, + server_name: &str, + identity_key_hex: &str, +) -> anyhow::Result<()> { + let identity_key = decode_identity_key(identity_key_hex)?; + let node_client = connect_node(server, ca_cert, server_name).await?; + + let hybrid_pk = fetch_hybrid_key(&node_client, &identity_key).await?; + + println!("identity_key : {identity_key_hex}"); + println!( + "hybrid_key : {}", + if hybrid_pk.is_some() { + "available (X25519 + ML-KEM-768)" + } else { + "not found" + } + ); + Ok(()) +} + +/// Connect to `server`, call health, and print RTT over QUIC/TLS. +pub async fn cmd_ping(server: &str, ca_cert: &Path, server_name: &str) -> anyhow::Result<()> { + let sent_at = current_timestamp_ms(); + let client = connect_node(server, ca_cert, server_name).await?; + + let req = client.health_request(); + let resp = req.send().promise.await.context("health RPC failed")?; + + let status = resp + .get() + .context("health: bad response")? + .get_status() + .context("health: missing status")? + .to_str() + .unwrap_or("invalid"); + + let rtt_ms = current_timestamp_ms().saturating_sub(sent_at); + println!("health={status} rtt={rtt_ms}ms"); + Ok(()) +} + +/// Register a new user account via the OPAQUE protocol. +pub async fn cmd_register_user( + server: &str, + ca_cert: &Path, + server_name: &str, + username: &str, + password: &str, + identity_key_hex: Option<&str>, +) -> anyhow::Result<()> { + let mut rng = rand::rngs::OsRng; + + let node_client = connect_node(server, ca_cert, server_name).await?; + + let identity_key = if let Some(hex_str) = identity_key_hex { + Some(decode_identity_key(hex_str)?) + } else { + None + }; + + let reg_start = ClientRegistration::::start(&mut rng, password.as_bytes()) + .map_err(|e| anyhow::anyhow!("OPAQUE register start: {e}"))?; + + let mut req = node_client.opaque_register_start_request(); + { + let mut p = req.get(); + p.set_username(username); + p.set_request(®_start.message.serialize()); + } + let resp = req + .send() + .promise + .await + .context("opaque_register_start RPC failed")?; + let response_bytes = resp + .get() + .context("register_start: bad response")? + .get_response() + .context("register_start: missing response")? + .to_vec(); + + let reg_response = RegistrationResponse::::deserialize(&response_bytes) + .map_err(|e| anyhow::anyhow!("invalid registration response: {e}"))?; + + let reg_finish = reg_start + .state + .finish( + &mut rng, + password.as_bytes(), + reg_response, + ClientRegistrationFinishParameters::::default(), + ) + .map_err(|e| anyhow::anyhow!("OPAQUE register finish: {e}"))?; + + let mut req = node_client.opaque_register_finish_request(); + { + let mut p = req.get(); + p.set_username(username); + p.set_upload(®_finish.message.serialize()); + if let Some(ref ik) = identity_key { + p.set_identity_key(ik); + } else { + p.set_identity_key(&[]); + } + } + let resp = req + .send() + .promise + .await + .context("opaque_register_finish RPC failed")?; + let success = resp + .get() + .context("register_finish: bad response")? + .get_success(); + + anyhow::ensure!(success, "server rejected registration"); + + println!("user '{username}' registered successfully (OPAQUE)"); + if let Some(ik) = identity_key { + println!("bound identity_key : {}", hex::encode(ik)); + } + Ok(()) +} + +/// Log in via the OPAQUE protocol and receive a session token. +pub async fn cmd_login( + server: &str, + ca_cert: &Path, + server_name: &str, + username: &str, + password: &str, + identity_key_hex: Option<&str>, + state_path: Option<&Path>, + state_password: Option<&str>, +) -> anyhow::Result<()> { + let mut rng = rand::rngs::OsRng; + + let node_client = connect_node(server, ca_cert, server_name).await?; + + let login_start = ClientLogin::::start(&mut rng, password.as_bytes()) + .map_err(|e| anyhow::anyhow!("OPAQUE login start: {e}"))?; + + let mut req = node_client.opaque_login_start_request(); + { + let mut p = req.get(); + p.set_username(username); + p.set_request(&login_start.message.serialize()); + } + let resp = req + .send() + .promise + .await + .context("opaque_login_start RPC failed")?; + let response_bytes = resp + .get() + .context("login_start: bad response")? + .get_response() + .context("login_start: missing response")? + .to_vec(); + + let credential_response = CredentialResponse::::deserialize(&response_bytes) + .map_err(|e| anyhow::anyhow!("invalid credential response: {e}"))?; + + let login_finish = login_start + .state + .finish( + &mut rng, + password.as_bytes(), + credential_response, + ClientLoginFinishParameters::::default(), + ) + .map_err(|e| anyhow::anyhow!("OPAQUE login finish (bad password?): {e}"))?; + + let identity_key = derive_identity_for_login(identity_key_hex, state_path, state_password)?; + + let mut req = node_client.opaque_login_finish_request(); + { + let mut p = req.get(); + p.set_username(username); + p.set_finalization(&login_finish.message.serialize()); + p.set_identity_key(&identity_key); + } + let resp = req + .send() + .promise + .await + .context("opaque_login_finish RPC failed")?; + let session_token = resp + .get() + .context("login_finish: bad response")? + .get_session_token() + .context("login_finish: missing session_token")? + .to_vec(); + + anyhow::ensure!( + !session_token.is_empty(), + "server returned empty session token" + ); + + println!("login successful for '{username}'"); + println!("session_token: {}", hex::encode(&session_token)); + println!("(use as --access-token for subsequent commands)"); + Ok(()) +} + +fn derive_identity_for_login( + identity_key_hex: Option<&str>, + state_path: Option<&Path>, + state_password: Option<&str>, +) -> anyhow::Result> { + if let Some(hex_str) = identity_key_hex { + let bytes = super::hex::decode(hex_str.trim()) + .map_err(|e| anyhow::anyhow!("identity_key must be 64 hex chars: {e}"))?; + anyhow::ensure!( + bytes.len() == 32, + "identity_key must decode to 32 bytes (got {})", + bytes.len() + ); + return Ok(bytes); + } + + if let Some(path) = state_path { + let state = load_existing_state(path, state_password)?; + let identity = IdentityKeypair::from_seed(state.identity_seed); + return Ok(identity.public_key_bytes().to_vec()); + } + + Err(anyhow::anyhow!( + "login requires an identity key; pass --identity-key or --state" + )) +} + +/// Generate a KeyPackage for a fresh identity and upload it to the AS. +pub async fn cmd_register(server: &str, ca_cert: &Path, server_name: &str) -> anyhow::Result<()> { + let identity = IdentityKeypair::generate(); + + let (tls_bytes, fingerprint) = + generate_key_package(&identity).context("KeyPackage generation failed")?; + + let node_client = connect_node(server, ca_cert, server_name).await?; + + let mut req = node_client.upload_key_package_request(); + { + let mut p = req.get(); + p.set_identity_key(&identity.public_key_bytes()); + p.set_package(&tls_bytes); + let mut auth = p.reborrow().init_auth(); + super::rpc::set_auth(&mut auth)?; + } + + let response = req + .send() + .promise + .await + .context("upload_key_package RPC failed")?; + + let server_fp = response + .get() + .context("upload_key_package: bad response")? + .get_fingerprint() + .context("upload_key_package: missing fingerprint")? + .to_vec(); + + anyhow::ensure!( + server_fp == fingerprint, + "fingerprint mismatch: local={} server={}", + hex::encode(&fingerprint), + hex::encode(&server_fp), + ); + + println!( + "identity_key : {}", + hex::encode(identity.public_key_bytes()) + ); + println!("fingerprint : {}", hex::encode(&fingerprint)); + println!("KeyPackage uploaded successfully."); + + Ok(()) +} + +/// Generate a new KeyPackage from the member, upload it (and optionally hybrid key) to the AS, then save state. +async fn do_upload_keypackage( + state_path: &Path, + server: &str, + ca_cert: &Path, + server_name: &str, + password: Option<&str>, + member: &mut GroupMember, + hybrid_kp: Option<&HybridKeypair>, +) -> anyhow::Result<()> { + let tls_bytes = member + .generate_key_package() + .context("KeyPackage generation failed")?; + let fingerprint = sha256(&tls_bytes); + + let node_client = connect_node(server, ca_cert, server_name).await?; + + let mut req = node_client.upload_key_package_request(); + { + let mut p = req.get(); + p.set_identity_key(&member.identity().public_key_bytes()); + p.set_package(&tls_bytes); + let mut auth = p.reborrow().init_auth(); + super::rpc::set_auth(&mut auth)?; + } + + let response = req + .send() + .promise + .await + .context("upload_key_package RPC failed")?; + + let server_fp = response + .get() + .context("upload_key_package: bad response")? + .get_fingerprint() + .context("upload_key_package: missing fingerprint")? + .to_vec(); + + anyhow::ensure!(server_fp == fingerprint, "fingerprint mismatch"); + + if let Some(ref hkp) = hybrid_kp { + upload_hybrid_key( + &node_client, + &member.identity().public_key_bytes(), + &hkp.public_key(), + ) + .await?; + println!("hybrid_key : uploaded (X25519 + ML-KEM-768)"); + } + + println!( + "identity_key : {}", + hex::encode(member.identity().public_key_bytes()) + ); + println!("fingerprint : {}", hex::encode(&fingerprint)); + println!("KeyPackage uploaded successfully."); + + save_state(state_path, member, hybrid_kp, password)?; + Ok(()) +} + +/// Upload the stored identity's KeyPackage to the AS. +/// Creates state (and identity) if the state file does not exist; otherwise uses existing state. +pub async fn cmd_register_state( + state_path: &Path, + server: &str, + ca_cert: &Path, + server_name: &str, + password: Option<&str>, +) -> anyhow::Result<()> { + let state = load_or_init_state(state_path, password)?; + let (mut member, hybrid_kp) = state.into_parts(state_path)?; + do_upload_keypackage( + state_path, + server, + ca_cert, + server_name, + password, + &mut member, + hybrid_kp.as_ref(), + ) + .await +} + +/// Refresh the KeyPackage on the server (load existing state, generate new KeyPackage, upload). +/// Use this when your KeyPackage has expired (e.g. server TTL ~24h) or was consumed by an invite. +/// Requires an existing state file; does not create a new identity. +pub async fn cmd_refresh_keypackage( + state_path: &Path, + server: &str, + ca_cert: &Path, + server_name: &str, + password: Option<&str>, +) -> anyhow::Result<()> { + let state = load_existing_state(state_path, password)?; + let (mut member, hybrid_kp) = state.into_parts(state_path)?; + do_upload_keypackage( + state_path, + server, + ca_cert, + server_name, + password, + &mut member, + hybrid_kp.as_ref(), + ) + .await +} + +/// Fetch a peer's KeyPackage from the AS. +pub async fn cmd_fetch_key( + server: &str, + ca_cert: &Path, + server_name: &str, + identity_key_hex: &str, +) -> anyhow::Result<()> { + let identity_key = super::hex::decode(identity_key_hex) + .map_err(|e| anyhow::anyhow!(e)) + .context("identity_key must be 64 hex characters (32 bytes)")?; + anyhow::ensure!( + identity_key.len() == 32, + "identity_key must be exactly 32 bytes, got {}", + identity_key.len() + ); + + let node_client = connect_node(server, ca_cert, server_name).await?; + + let mut req = node_client.fetch_key_package_request(); + { + let mut p = req.get(); + p.set_identity_key(&identity_key); + let mut auth = p.reborrow().init_auth(); + super::rpc::set_auth(&mut auth)?; + } + + let response = req + .send() + .promise + .await + .context("fetch_key_package RPC failed")?; + + let package = response + .get() + .context("fetch_key_package: bad response")? + .get_package() + .context("fetch_key_package: missing package field")? + .to_vec(); + + if package.is_empty() { + println!("No KeyPackage available for this identity."); + return Ok(()); + } + + use sha2::{Digest, Sha256}; + let fingerprint = Sha256::digest(&package); + + println!("fingerprint : {}", hex::encode(fingerprint)); + println!("package_len : {} bytes", package.len()); + println!("KeyPackage fetched successfully."); + + Ok(()) +} + +/// Run a two-party MLS demo against the unified server. +pub async fn cmd_demo_group(server: &str, ca_cert: &Path, server_name: &str) -> anyhow::Result<()> { + let creator_state_path = PathBuf::from("quicnprotochat-demo-creator.bin"); + let joiner_state_path = PathBuf::from("quicnprotochat-demo-joiner.bin"); + + let (mut creator, creator_hybrid_opt) = + load_or_init_state(&creator_state_path, None)?.into_parts(&creator_state_path)?; + let (mut joiner, joiner_hybrid_opt) = + load_or_init_state(&joiner_state_path, None)?.into_parts(&joiner_state_path)?; + + let creator_hybrid = creator_hybrid_opt.unwrap_or_else(HybridKeypair::generate); + let joiner_hybrid = joiner_hybrid_opt.unwrap_or_else(HybridKeypair::generate); + + let creator_kp = creator + .generate_key_package() + .context("creator KeyPackage generation failed")?; + let joiner_kp = joiner + .generate_key_package() + .context("joiner KeyPackage generation failed")?; + + let creator_node = connect_node(server, ca_cert, server_name).await?; + let joiner_node = connect_node(server, ca_cert, server_name).await?; + + let creator_identity = creator.identity().public_key_bytes(); + let joiner_identity = joiner.identity().public_key_bytes(); + + upload_key_package(&creator_node, &creator_identity, &creator_kp).await?; + upload_key_package(&joiner_node, &joiner_identity, &joiner_kp).await?; + upload_hybrid_key(&creator_node, &creator_identity, &creator_hybrid.public_key()).await?; + upload_hybrid_key(&joiner_node, &joiner_identity, &joiner_hybrid.public_key()).await?; + + println!("hybrid public keys uploaded for creator and joiner"); + + let fetched_joiner_kp = fetch_key_package(&creator_node, &joiner_identity).await?; + anyhow::ensure!( + !fetched_joiner_kp.is_empty(), + "AS returned an empty KeyPackage for joiner", + ); + + creator + .create_group(b"demo-group") + .context("create_group failed")?; + let (_commit, welcome) = creator + .add_member(&fetched_joiner_kp) + .context("add_member failed")?; + + let creator_ds = creator_node.clone(); + let joiner_ds = joiner_node.clone(); + + let joiner_hybrid_pk = fetch_hybrid_key(&creator_node, &joiner_identity) + .await? + .context("joiner hybrid key not found")?; + let wrapped_welcome = + hybrid_encrypt(&joiner_hybrid_pk, &welcome).context("hybrid encrypt welcome")?; + enqueue(&creator_ds, &joiner_identity, &wrapped_welcome).await?; + + let welcome_payloads = fetch_all(&joiner_ds, &joiner_identity).await?; + let raw_welcome = welcome_payloads + .first() + .map(|(_, d)| d.clone()) + .context("Welcome was not delivered to joiner via DS")?; + + let welcome_bytes = + hybrid_decrypt(&joiner_hybrid, &raw_welcome).context("hybrid decrypt welcome failed")?; + joiner + .join_group(&welcome_bytes) + .context("join_group failed")?; + + let ct_creator_to_joiner = creator + .send_message(b"hello") + .context("send_message failed")?; + let wrapped_creator_joiner = + hybrid_encrypt(&joiner_hybrid_pk, &ct_creator_to_joiner).context("hybrid encrypt failed")?; + enqueue(&creator_ds, &joiner_identity, &wrapped_creator_joiner).await?; + + let joiner_msgs = fetch_all(&joiner_ds, &joiner_identity).await?; + let (_, raw_creator_joiner) = joiner_msgs + .first() + .context("joiner: missing ciphertext from DS")?; + let inner_creator_joiner = + hybrid_decrypt(&joiner_hybrid, raw_creator_joiner).context("hybrid decrypt failed")?; + let plaintext_creator_joiner = joiner + .receive_message(&inner_creator_joiner)? + .context("expected application message")?; + println!( + "creator -> joiner plaintext: {}", + String::from_utf8_lossy(&plaintext_creator_joiner) + ); + + let creator_hybrid_pk = fetch_hybrid_key(&joiner_node, &creator_identity) + .await? + .context("creator hybrid key not found")?; + let ct_joiner_to_creator = joiner + .send_message(b"hello back") + .context("send_message failed")?; + let wrapped_joiner_creator = + hybrid_encrypt(&creator_hybrid_pk, &ct_joiner_to_creator).context("hybrid encrypt failed")?; + enqueue(&joiner_ds, &creator_identity, &wrapped_joiner_creator).await?; + + let creator_msgs = fetch_all(&creator_ds, &creator_identity).await?; + let (_, raw_joiner_creator) = creator_msgs + .first() + .context("creator: missing ciphertext from DS")?; + let inner_joiner_creator = + hybrid_decrypt(&creator_hybrid, raw_joiner_creator).context("hybrid decrypt failed")?; + let plaintext_joiner_creator = creator + .receive_message(&inner_joiner_creator)? + .context("expected application message")?; + println!( + "joiner -> creator plaintext: {}", + String::from_utf8_lossy(&plaintext_joiner_creator) + ); + + println!("demo-group complete (hybrid PQ envelope active)"); + + Ok(()) +} + +/// Create a new group and persist state. +pub async fn cmd_create_group( + state_path: &Path, + _server: &str, + group_id: &str, + password: Option<&str>, +) -> anyhow::Result<()> { + let state = load_or_init_state(state_path, password)?; + let (mut member, hybrid_kp) = state.into_parts(state_path)?; + + anyhow::ensure!( + member.group_ref().is_none(), + "group already exists in state" + ); + + member + .create_group(group_id.as_bytes()) + .context("create_group failed")?; + + save_state(state_path, &member, hybrid_kp.as_ref(), password)?; + println!("group created: {group_id}"); + Ok(()) +} + +/// Invite a peer: fetch their KeyPackage, add to group, enqueue Welcome. +pub async fn cmd_invite( + state_path: &Path, + server: &str, + ca_cert: &Path, + server_name: &str, + peer_key_hex: &str, + password: Option<&str>, +) -> anyhow::Result<()> { + let state = load_existing_state(state_path, password)?; + let (mut member, hybrid_kp) = state.into_parts(state_path)?; + + let peer_key = decode_identity_key(peer_key_hex)?; + let node_client = connect_node(server, ca_cert, server_name).await?; + let peer_kp = fetch_key_package(&node_client, &peer_key).await?; + anyhow::ensure!( + !peer_kp.is_empty(), + "server returned empty KeyPackage for peer" + ); + + let _ = member + .group_ref() + .context("no active group; run create-group first")?; + + let existing_members: Vec> = member + .member_identities() + .into_iter() + .filter(|k| k.as_slice() != member.identity().public_key_bytes()) + .collect(); + + let (commit, welcome) = member.add_member(&peer_kp).context("add_member failed")?; + + for mk in &existing_members { + if mk.as_slice() == peer_key.as_slice() { + continue; + } + let peer_hpk = fetch_hybrid_key(&node_client, mk).await?; + let commit_payload = if let Some(ref pk) = peer_hpk { + hybrid_encrypt(pk, &commit).context("hybrid encrypt commit")? + } else { + commit.clone() + }; + enqueue(&node_client, mk, &commit_payload).await?; + } + + let peer_hybrid_pk = fetch_hybrid_key(&node_client, &peer_key).await?; + let payload = if let Some(ref pk) = peer_hybrid_pk { + hybrid_encrypt(pk, &welcome).context("hybrid encrypt welcome failed")? + } else { + welcome + }; + + enqueue(&node_client, &peer_key, &payload).await?; + + save_state(state_path, &member, hybrid_kp.as_ref(), password)?; + println!( + "invited peer (welcome queued{}, commit sent to {} existing member(s))", + if peer_hybrid_pk.is_some() { + ", hybrid-encrypted" + } else { + "" + }, + existing_members.len(), + ); + Ok(()) +} + +/// Join a group by consuming a Welcome from the server queue. +/// If the queue contained [Welcome, Commit, ...] (e.g. creator invited someone else before this +/// joiner ran), we use the first payload as Welcome and process the rest in order (merge Commits) +/// so the joiner's epoch matches the creator before any later app messages are received. +pub async fn cmd_join( + state_path: &Path, + server: &str, + ca_cert: &Path, + server_name: &str, + password: Option<&str>, +) -> anyhow::Result<()> { + let state = load_existing_state(state_path, password)?; + let (mut member, hybrid_kp) = state.into_parts(state_path)?; + + anyhow::ensure!( + member.group_ref().is_none(), + "group already active in state" + ); + + let node_client = connect_node(server, ca_cert, server_name).await?; + let mut payloads = fetch_all(&node_client, &member.identity().public_key_bytes()).await?; + payloads.sort_by_key(|(seq, _)| *seq); + let (_, raw_welcome) = payloads + .first() + .cloned() + .context("no Welcome found in DS for this identity")?; + + let welcome_bytes = try_hybrid_decrypt(hybrid_kp.as_ref(), &raw_welcome) + .context("decrypt Welcome (hybrid required)")?; + + member + .join_group(&welcome_bytes) + .context("join_group failed")?; + + // Process any remaining payloads (e.g. Commit from creator adding another member) in order + // so our epoch matches before we later receive application messages. + for (_, raw) in payloads.iter().skip(1) { + let mls_payload = match try_hybrid_decrypt(hybrid_kp.as_ref(), raw) { + Ok(b) => b, + Err(_) => continue, + }; + let _ = member.receive_message(&mls_payload); + } + + save_state(state_path, &member, hybrid_kp.as_ref(), password)?; + println!("joined group successfully"); + Ok(()) +} + +/// Send an application message via DS (single recipient or broadcast to all other members). +pub async fn cmd_send( + state_path: &Path, + server: &str, + ca_cert: &Path, + server_name: &str, + peer_key_hex: Option<&str>, + send_to_all: bool, + msg: &str, + password: Option<&str>, +) -> anyhow::Result<()> { + let state = load_existing_state(state_path, password)?; + let (mut member, hybrid_kp) = state.into_parts(state_path)?; + + let _ = member + .group_ref() + .context("no active group; create one and invite members first")?; + + let node_client = connect_node(server, ca_cert, server_name).await?; + + let ct = member + .send_message(msg.as_bytes()) + .context("send_message failed")?; + + let my_identity = member.identity().public_key_bytes(); + let recipients: Vec> = if send_to_all { + member + .member_identities() + .into_iter() + .filter(|k| k.as_slice() != my_identity) + .collect() + } else { + let peer_key = decode_identity_key( + peer_key_hex.context("peer_key required when not using --all")?, + )?; + vec![peer_key] + }; + + for recipient in &recipients { + let peer_hybrid_pk = fetch_hybrid_key(&node_client, recipient).await?; + let payload = if let Some(ref pk) = peer_hybrid_pk { + hybrid_encrypt(pk, &ct).context("hybrid encrypt failed")? + } else { + ct.clone() + }; + enqueue(&node_client, recipient, &payload).await?; + } + + save_state(state_path, &member, hybrid_kp.as_ref(), password)?; + println!( + "message sent to {} recipient(s){}", + recipients.len(), + if recipients.len() == 1 { + " (hybrid-encrypted)" + } else { + "" + } + ); + Ok(()) +} + +/// Receive and decrypt all pending messages from the server. +pub async fn cmd_recv( + state_path: &Path, + server: &str, + ca_cert: &Path, + server_name: &str, + wait_ms: u64, + stream: bool, + password: Option<&str>, +) -> anyhow::Result<()> { + let state = load_existing_state(state_path, password)?; + let (mut member, hybrid_kp) = state.into_parts(state_path)?; + + let client = connect_node(server, ca_cert, server_name).await?; + + loop { + let mut payloads = + fetch_wait(&client, &member.identity().public_key_bytes(), wait_ms).await?; + + if payloads.is_empty() { + if !stream { + println!("no messages"); + return Ok(()); + } + continue; + } + + // Sort by server-assigned sequence number so MLS commits arrive before + // application messages that depend on the resulting epoch. + payloads.sort_by_key(|(seq, _)| *seq); + + let mut retry_mls: Vec> = Vec::new(); + for (idx, (_, payload)) in payloads.iter().enumerate() { + let mls_payload = match try_hybrid_decrypt(hybrid_kp.as_ref(), payload) { + Ok(b) => b, + Err(e) => { + println!("[{idx}] decrypt error: {e}"); + continue; + } + }; + match member.receive_message(&mls_payload) { + Ok(Some(pt)) => println!("[{idx}] plaintext: {}", String::from_utf8_lossy(&pt)), + Ok(None) => println!("[{idx}] commit applied"), + Err(_) => retry_mls.push(mls_payload), + } + } + // Retry messages that failed on the first pass (e.g. app messages whose + // epoch was not yet advanced until a commit earlier in the batch was applied). + for mls_payload in &retry_mls { + match member.receive_message(mls_payload) { + Ok(Some(pt)) => println!("[retry] plaintext: {}", String::from_utf8_lossy(&pt)), + Ok(None) => {} + Err(e) => println!("[retry] error: {e}"), + } + } + + save_state(state_path, &member, hybrid_kp.as_ref(), password)?; + + if !stream { + return Ok(()); + } + } +} + +/// Fetch pending payloads, process in order (merge commits, collect plaintexts), save state. +/// Returns only application-message plaintexts. Used by E2E tests and callers that need returned messages. +/// Uses two passes so that if the server delivers an application message before a Commit, the second pass +/// processes it after commits are merged. +pub async fn receive_pending_plaintexts( + state_path: &Path, + server: &str, + ca_cert: &Path, + server_name: &str, + wait_ms: u64, + password: Option<&str>, +) -> anyhow::Result>> { + let state = load_existing_state(state_path, password)?; + let (mut member, hybrid_kp) = state.into_parts(state_path)?; + + let client = connect_node(server, ca_cert, server_name).await?; + let mut payloads = + fetch_wait(&client, &member.identity().public_key_bytes(), wait_ms).await?; + payloads.sort_by_key(|(seq, _)| *seq); + + let mut plaintexts = Vec::new(); + let mut retry_mls: Vec> = Vec::new(); + for (_, payload) in &payloads { + let mls_payload = match try_hybrid_decrypt(hybrid_kp.as_ref(), payload) { + Ok(b) => b, + Err(_) => continue, + }; + match member.receive_message(&mls_payload) { + Ok(Some(pt)) => plaintexts.push(pt), + Ok(None) => {} + Err(_) => retry_mls.push(mls_payload), + } + } + for mls_payload in &retry_mls { + if let Ok(Some(pt)) = member.receive_message(mls_payload) { + plaintexts.push(pt); + } + } + + save_state(state_path, &member, hybrid_kp.as_ref(), password)?; + Ok(plaintexts) +} + +/// JSON-returning whoami for GUI backends. +pub fn whoami_json(state_path: &Path, password: Option<&str>) -> anyhow::Result { + let state = load_existing_state(state_path, password)?; + let identity = IdentityKeypair::from_seed(state.identity_seed); + let pk_bytes = identity.public_key_bytes(); + let fingerprint = sha256(&pk_bytes); + Ok(format!( + r#"{{"identity_key":"{}", "fingerprint":"{}", "hybrid_key":{}, "group":{}}}"#, + hex::encode(&pk_bytes), + hex::encode(&fingerprint), + state.hybrid_key.is_some(), + state.group.is_some(), + )) +} + +/// JSON-returning health check for GUI backends. +pub async fn cmd_health_json(server: &str, ca_cert: &Path, server_name: &str) -> anyhow::Result { + let sent_at = current_timestamp_ms(); + let client = connect_node(server, ca_cert, server_name).await?; + let req = client.health_request(); + let resp = req.send().promise.await.context("health RPC failed")?; + let status = resp + .get() + .context("health: bad response")? + .get_status() + .context("health: missing status")? + .to_str() + .unwrap_or("invalid"); + let rtt_ms = current_timestamp_ms().saturating_sub(sent_at); + Ok(format!(r#"{{"status":"{status}","rtt_ms":{rtt_ms}}}"#)) +} + +/// Run an interactive 1:1 chat session. +pub async fn cmd_chat( + state_path: &Path, + server: &str, + ca_cert: &Path, + server_name: &str, + peer_key_hex: Option<&str>, + password: Option<&str>, + poll_interval_ms: u64, +) -> anyhow::Result<()> { + use std::io::Write; + use tokio::io::AsyncBufReadExt; + use tokio::sync::mpsc; + use tokio::time::interval; + + let state = load_existing_state(state_path, password)?; + let (mut member, hybrid_kp) = state.into_parts(state_path)?; + + let _group = member + .group_ref() + .context("no active group; create one and invite the peer first")?; + + let my_identity = member.identity().public_key_bytes(); + let peer_key: Vec = match peer_key_hex { + Some(h) => decode_identity_key(h)?, + None => { + let others: Vec> = member + .member_identities() + .into_iter() + .filter(|id| id.as_slice() != my_identity) + .collect(); + match others.as_slice() { + [single] => single.clone(), + [] => anyhow::bail!( + "group has no other member; invite someone first, or pass --peer-key" + ), + _ => anyhow::bail!( + "group has {} other members; pass --peer-key to pick one", + others.len() + ), + } + } + }; + let identity_bytes = member.identity().public_key_bytes().to_vec(); + + let client = connect_node(server, ca_cert, server_name).await?; + + let (tx, mut rx) = mpsc::unbounded_channel::>(); + + tokio::task::spawn_local({ + let tx = tx.clone(); + async move { + let mut stdin = tokio::io::BufReader::new(tokio::io::stdin()); + let mut line = String::new(); + loop { + line.clear(); + match stdin.read_line(&mut line).await { + Ok(0) => { + let _ = tx.send(None); + break; + } + Ok(_) => { + let trimmed = line.trim().to_string(); + let _ = tx.send(Some(trimmed)); + } + Err(_) => break, + } + } + } + }); + + let mut poll = interval(std::time::Duration::from_millis(poll_interval_ms)); + poll.set_missed_tick_behavior(tokio::time::MissedTickBehavior::Skip); + + println!("Chat with peer {} (Ctrl+D to exit)", hex::encode(&peer_key[..8])); + print!("> "); + std::io::stdout().flush().context("flush stdout")?; + + loop { + tokio::select! { + msg = rx.recv() => { + match msg { + Some(None) => break, + Some(Some(line)) => { + if !line.is_empty() { + let ct = member + .send_message(line.as_bytes()) + .context("send_message failed")?; + let peer_hybrid_pk = fetch_hybrid_key(&client, &peer_key).await?; + let payload = if let Some(ref pk) = peer_hybrid_pk { + hybrid_encrypt(pk, &ct).context("hybrid encrypt failed")? + } else { + ct + }; + enqueue(&client, &peer_key, &payload).await?; + save_state(state_path, &member, hybrid_kp.as_ref(), password)?; + } + print!("> "); + std::io::stdout().flush().context("flush stdout")?; + } + None => break, + } + } + _ = poll.tick() => { + let mut payloads = fetch_wait(&client, &identity_bytes, 0).await?; + payloads.sort_by_key(|(seq, _)| *seq); + for (_, payload) in &payloads { + let mls_payload = match try_hybrid_decrypt(hybrid_kp.as_ref(), payload) { + Ok(b) => b, + Err(_) => continue, + }; + match member.receive_message(&mls_payload) { + Ok(Some(pt)) => { + let s = String::from_utf8_lossy(&pt); + println!("\r\n[peer] {s}\n> "); + std::io::stdout().flush().context("flush stdout")?; + } + Ok(None) => {} + Err(_) => {} + } + } + if !payloads.is_empty() { + save_state(state_path, &member, hybrid_kp.as_ref(), password)?; + } + } + } + } + + println!(); + Ok(()) +} diff --git a/crates/quicnprotochat-client/src/client/hex.rs b/crates/quicnprotochat-client/src/client/hex.rs new file mode 100644 index 0000000..314ae08 --- /dev/null +++ b/crates/quicnprotochat-client/src/client/hex.rs @@ -0,0 +1,13 @@ +pub fn encode(bytes: impl AsRef<[u8]>) -> String { + bytes.as_ref().iter().map(|b| format!("{b:02x}")).collect() +} + +pub fn decode(s: &str) -> Result, &'static str> { + if s.len() % 2 != 0 { + return Err("odd-length hex string"); + } + (0..s.len()) + .step_by(2) + .map(|i| u8::from_str_radix(&s[i..i + 2], 16).map_err(|_| "invalid hex character")) + .collect() +} diff --git a/crates/quicnprotochat-client/src/client/mod.rs b/crates/quicnprotochat-client/src/client/mod.rs new file mode 100644 index 0000000..c76d464 --- /dev/null +++ b/crates/quicnprotochat-client/src/client/mod.rs @@ -0,0 +1,9 @@ +pub mod commands; +pub mod hex; +pub mod retry; +pub mod rpc; +pub mod state; + +pub use commands::*; +pub use rpc::{connect_node, enqueue, fetch_all, fetch_hybrid_key, fetch_key_package, fetch_wait, upload_hybrid_key, upload_key_package}; +pub use state::{decode_identity_key, load_existing_state, load_or_init_state, save_state}; diff --git a/crates/quicnprotochat-client/src/client/retry.rs b/crates/quicnprotochat-client/src/client/retry.rs new file mode 100644 index 0000000..9653381 --- /dev/null +++ b/crates/quicnprotochat-client/src/client/retry.rs @@ -0,0 +1,81 @@ +//! Retry with exponential backoff for transient RPC failures. + +use std::future::Future; +use std::time::Duration; + +use rand::Rng; +use tracing::warn; + +/// Default maximum number of retry attempts (including the first try). +pub const DEFAULT_MAX_RETRIES: u32 = 3; +/// Default base delay in milliseconds for exponential backoff. +pub const DEFAULT_BASE_DELAY_MS: u64 = 500; + +/// Runs an async operation with retries. On `Ok(t)` returns immediately. +/// On `Err(e)`: if `is_retriable(&e)` and `attempt < max_retries`, sleeps with +/// exponential backoff (plus jitter) then retries; otherwise returns the last error. +pub async fn retry_async( + op: F, + max_retries: u32, + base_delay_ms: u64, + is_retriable: P, +) -> Result +where + F: Fn() -> Fut, + Fut: Future>, + P: Fn(&E) -> bool, +{ + let mut last_err = None; + for attempt in 0..max_retries { + match op().await { + Ok(t) => return Ok(t), + Err(e) => { + last_err = Some(e); + let err = last_err.as_ref().unwrap(); + if !is_retriable(err) || attempt + 1 >= max_retries { + break; + } + let delay_ms = base_delay_ms * 2u64.saturating_pow(attempt); + let jitter_ms = rand::thread_rng().gen_range(0..=delay_ms / 2); + let total_ms = delay_ms + jitter_ms; + warn!( + attempt = attempt + 1, + max_retries, + delay_ms = total_ms, + "RPC failed, retrying after backoff" + ); + tokio::time::sleep(Duration::from_millis(total_ms)).await; + } + } + } + Err(last_err.expect("retry_async: last_err set when we break after Err")) +} + +/// Classifies `anyhow::Error` for retry: returns `false` for auth or invalid-param +/// errors (do not retry), `true` for transient errors (network, timeout, server 5xx). +/// When in doubt, returns `true` (retry). +pub fn anyhow_is_retriable(err: &anyhow::Error) -> bool { + let s = format!("{:#}", err); + let s_lower = s.to_lowercase(); + // Do not retry: auth / permission + if s_lower.contains("unauthorized") + || s_lower.contains("auth failed") + || s_lower.contains("access denied") + || s_lower.contains("401") + || s_lower.contains("forbidden") + || s_lower.contains("403") + || s_lower.contains("token") + { + return false; + } + // Do not retry: bad request / invalid params + if s_lower.contains("bad request") + || s_lower.contains("400") + || s_lower.contains("invalid param") + || s_lower.contains("fingerprint mismatch") + { + return false; + } + // Retry: network, timeout, connection, server error, or anything else + true +} diff --git a/crates/quicnprotochat-client/src/client/rpc.rs b/crates/quicnprotochat-client/src/client/rpc.rs new file mode 100644 index 0000000..d608665 --- /dev/null +++ b/crates/quicnprotochat-client/src/client/rpc.rs @@ -0,0 +1,367 @@ +use std::net::SocketAddr; +use std::path::Path; +use std::sync::Arc; + +use anyhow::Context; +use quinn::{ClientConfig, Endpoint}; +use quinn_proto::crypto::rustls::QuicClientConfig; +use rustls::pki_types::CertificateDer; +use rustls::{ClientConfig as RustlsClientConfig, RootCertStore}; +use tokio_util::compat::{TokioAsyncReadCompatExt, TokioAsyncWriteCompatExt}; +use capnp_rpc::{rpc_twoparty_capnp::Side, twoparty, RpcSystem}; + +use quicnprotochat_core::HybridPublicKey; +use quicnprotochat_proto::node_capnp::{auth, node_service}; + +use crate::AUTH_CONTEXT; + +use super::retry::{anyhow_is_retriable, retry_async, DEFAULT_BASE_DELAY_MS, DEFAULT_MAX_RETRIES}; + +/// Establish a QUIC/TLS connection and return a `NodeService` client. +/// +/// Must be called from within a `LocalSet` because capnp-rpc is `!Send`. +pub async fn connect_node( + server: &str, + ca_cert: &Path, + server_name: &str, +) -> anyhow::Result { + let addr: SocketAddr = server + .parse() + .with_context(|| format!("server must be host:port, got {server}"))?; + + let cert_bytes = std::fs::read(ca_cert).with_context(|| format!("read ca_cert {ca_cert:?}"))?; + let mut roots = RootCertStore::empty(); + roots + .add(CertificateDer::from(cert_bytes)) + .context("add root cert")?; + + let mut tls = RustlsClientConfig::builder() + .with_root_certificates(roots) + .with_no_client_auth(); + tls.alpn_protocols = vec![b"capnp".to_vec()]; + + let crypto = QuicClientConfig::try_from(tls) + .map_err(|e| anyhow::anyhow!("invalid client TLS config: {e}"))?; + + let bind_addr: SocketAddr = "0.0.0.0:0".parse().context("parse client bind address")?; + let mut endpoint = Endpoint::client(bind_addr)?; + endpoint.set_default_client_config(ClientConfig::new(Arc::new(crypto))); + + let connection = endpoint + .connect(addr, server_name) + .context("quic connect init")? + .await + .context("quic connect failed")?; + + let (send, recv) = connection.open_bi().await.context("open bi stream")?; + + let network = twoparty::VatNetwork::new( + recv.compat(), + send.compat_write(), + Side::Client, + Default::default(), + ); + + let mut rpc_system = RpcSystem::new(Box::new(network), None); + let client: node_service::Client = rpc_system.bootstrap(Side::Server); + + tokio::task::spawn_local(rpc_system); + + Ok(client) +} + +pub fn set_auth(auth: &mut auth::Builder<'_>) -> anyhow::Result<()> { + let ctx = AUTH_CONTEXT.get().ok_or_else(|| { + anyhow::anyhow!("init_auth must be called with a non-empty token before RPCs") + })?; + auth.set_version(ctx.version); + auth.set_access_token(&ctx.access_token); + auth.set_device_id(&ctx.device_id); + Ok(()) +} + +/// Upload a KeyPackage and verify the fingerprint echoed by the AS. +pub async fn upload_key_package( + client: &node_service::Client, + identity_key: &[u8], + package: &[u8], +) -> anyhow::Result<()> { + let mut req = client.upload_key_package_request(); + { + let mut p = req.get(); + p.set_identity_key(identity_key); + p.set_package(package); + let mut auth = p.reborrow().init_auth(); + set_auth(&mut auth)?; + } + + let resp = req + .send() + .promise + .await + .context("upload_key_package RPC failed")?; + + let server_fp = resp + .get() + .context("upload_key_package: bad response")? + .get_fingerprint() + .context("upload_key_package: missing fingerprint")? + .to_vec(); + + let local_fp = super::state::sha256(package); + anyhow::ensure!(server_fp == local_fp, "fingerprint mismatch"); + Ok(()) +} + +/// Fetch a KeyPackage for `identity_key` from the AS. +pub async fn fetch_key_package( + client: &node_service::Client, + identity_key: &[u8], +) -> anyhow::Result> { + let mut req = client.fetch_key_package_request(); + { + let mut p = req.get(); + p.set_identity_key(identity_key); + let mut auth = p.reborrow().init_auth(); + set_auth(&mut auth)?; + } + + let resp = req + .send() + .promise + .await + .context("fetch_key_package RPC failed")?; + + let pkg = resp + .get() + .context("fetch_key_package: bad response")? + .get_package() + .context("fetch_key_package: missing package field")? + .to_vec(); + + Ok(pkg) +} + +/// Enqueue an opaque payload to the DS for `recipient_key`. +/// Returns the per-inbox sequence number assigned by the server. +/// Retries on transient failures with exponential backoff. +pub async fn enqueue( + client: &node_service::Client, + recipient_key: &[u8], + payload: &[u8], +) -> anyhow::Result { + let client = client.clone(); + let recipient_key = recipient_key.to_vec(); + let payload = payload.to_vec(); + retry_async( + || { + let client = client.clone(); + let recipient_key = recipient_key.clone(); + let payload = payload.clone(); + async move { + let mut req = client.enqueue_request(); + { + let mut p = req.get(); + p.set_recipient_key(&recipient_key); + p.set_payload(&payload); + p.set_channel_id(&[]); + p.set_version(1); + let mut auth = p.reborrow().init_auth(); + set_auth(&mut auth)?; + } + let resp = req.send().promise.await.context("enqueue RPC failed")?; + let seq = resp.get().context("enqueue: bad response")?.get_seq(); + Ok(seq) + } + }, + DEFAULT_MAX_RETRIES, + DEFAULT_BASE_DELAY_MS, + anyhow_is_retriable, + ) + .await +} + +/// Fetch and drain all payloads for `recipient_key`. +/// Returns `(seq, payload)` pairs — sort by `seq` before MLS processing. +/// Retries on transient failures with exponential backoff. +pub async fn fetch_all( + client: &node_service::Client, + recipient_key: &[u8], +) -> anyhow::Result)>> { + let client = client.clone(); + let recipient_key = recipient_key.to_vec(); + retry_async( + || { + let client = client.clone(); + let recipient_key = recipient_key.clone(); + async move { + let mut req = client.fetch_request(); + { + let mut p = req.get(); + p.set_recipient_key(&recipient_key); + p.set_channel_id(&[]); + p.set_version(1); + p.set_limit(0); // fetch all + let mut auth = p.reborrow().init_auth(); + set_auth(&mut auth)?; + } + + let resp = req.send().promise.await.context("fetch RPC failed")?; + + let list = resp + .get() + .context("fetch: bad response")? + .get_payloads() + .context("fetch: missing payloads")?; + + let mut payloads = Vec::with_capacity(list.len() as usize); + for i in 0..list.len() { + let entry = list.get(i); + let seq = entry.get_seq(); + let data = entry + .get_data() + .context("fetch: envelope data read failed")? + .to_vec(); + payloads.push((seq, data)); + } + + Ok(payloads) + } + }, + DEFAULT_MAX_RETRIES, + DEFAULT_BASE_DELAY_MS, + anyhow_is_retriable, + ) + .await +} + +/// Long-poll for payloads with optional timeout (ms). +/// Returns `(seq, payload)` pairs — sort by `seq` before MLS processing. +/// Retries on transient failures with exponential backoff. +pub async fn fetch_wait( + client: &node_service::Client, + recipient_key: &[u8], + timeout_ms: u64, +) -> anyhow::Result)>> { + let client = client.clone(); + let recipient_key = recipient_key.to_vec(); + retry_async( + || { + let client = client.clone(); + let recipient_key = recipient_key.clone(); + let timeout_ms = timeout_ms; + async move { + let mut req = client.fetch_wait_request(); + { + let mut p = req.get(); + p.set_recipient_key(&recipient_key); + p.set_timeout_ms(timeout_ms); + p.set_channel_id(&[]); + p.set_version(1); + p.set_limit(0); // fetch all + let mut auth = p.reborrow().init_auth(); + set_auth(&mut auth)?; + } + + let resp = req.send().promise.await.context("fetch_wait RPC failed")?; + + let list = resp + .get() + .context("fetch_wait: bad response")? + .get_payloads() + .context("fetch_wait: missing payloads")?; + + let mut payloads = Vec::with_capacity(list.len() as usize); + for i in 0..list.len() { + let entry = list.get(i); + let seq = entry.get_seq(); + let data = entry + .get_data() + .context("fetch_wait: envelope data read failed")? + .to_vec(); + payloads.push((seq, data)); + } + + Ok(payloads) + } + }, + DEFAULT_MAX_RETRIES, + DEFAULT_BASE_DELAY_MS, + anyhow_is_retriable, + ) + .await +} + +/// Upload a hybrid (X25519 + ML-KEM-768) public key for an identity. +pub async fn upload_hybrid_key( + client: &node_service::Client, + identity_key: &[u8], + hybrid_pk: &HybridPublicKey, +) -> anyhow::Result<()> { + let mut req = client.upload_hybrid_key_request(); + { + let mut p = req.get(); + p.set_identity_key(identity_key); + p.set_hybrid_public_key(&hybrid_pk.to_bytes()); + let mut auth = p.reborrow().init_auth(); + set_auth(&mut auth)?; + } + req.send() + .promise + .await + .context("upload_hybrid_key RPC failed")?; + Ok(()) +} + +/// Fetch a peer's hybrid public key from the server. +/// +/// Returns `None` if the peer has not uploaded a hybrid key. +pub async fn fetch_hybrid_key( + client: &node_service::Client, + identity_key: &[u8], +) -> anyhow::Result> { + let mut req = client.fetch_hybrid_key_request(); + { + let mut p = req.get(); + p.set_identity_key(identity_key); + let mut auth = p.reborrow().init_auth(); + set_auth(&mut auth)?; + } + + let resp = req + .send() + .promise + .await + .context("fetch_hybrid_key RPC failed")?; + + let pk_bytes = resp + .get() + .context("fetch_hybrid_key: bad response")? + .get_hybrid_public_key() + .context("fetch_hybrid_key: missing field")? + .to_vec(); + + if pk_bytes.is_empty() { + return Ok(None); + } + + let pk = HybridPublicKey::from_bytes(&pk_bytes).context("invalid hybrid public key")?; + Ok(Some(pk)) +} + +/// Decrypt a hybrid envelope. Requires a hybrid key; no fallback to plaintext MLS. +pub fn try_hybrid_decrypt( + hybrid_kp: Option<&quicnprotochat_core::HybridKeypair>, + payload: &[u8], +) -> anyhow::Result> { + let kp = hybrid_kp.ok_or_else(|| anyhow::anyhow!("hybrid key required for decryption"))?; + quicnprotochat_core::hybrid_decrypt(kp, payload).map_err(|e| anyhow::anyhow!("{e}")) +} + +/// Return the current Unix timestamp in milliseconds. +pub fn current_timestamp_ms() -> u64 { + std::time::SystemTime::now() + .duration_since(std::time::UNIX_EPOCH) + .unwrap_or_default() + .as_millis() as u64 +} diff --git a/crates/quicnprotochat-client/src/client/state.rs b/crates/quicnprotochat-client/src/client/state.rs new file mode 100644 index 0000000..fe72390 --- /dev/null +++ b/crates/quicnprotochat-client/src/client/state.rs @@ -0,0 +1,225 @@ +use std::path::{Path, PathBuf}; +use std::sync::Arc; + +use anyhow::Context; +use argon2::Argon2; +use chacha20poly1305::{ + aead::{Aead, KeyInit}, + ChaCha20Poly1305, Key, Nonce, +}; +use rand::RngCore; +use serde::{Deserialize, Serialize}; + +use quicnprotochat_core::{DiskKeyStore, GroupMember, HybridKeypair, HybridKeypairBytes, IdentityKeypair}; + +/// Magic bytes for encrypted client state files. +const STATE_MAGIC: &[u8; 4] = b"QPCE"; +const STATE_SALT_LEN: usize = 16; +const STATE_NONCE_LEN: usize = 12; + +#[derive(Serialize, Deserialize)] +pub struct StoredState { + pub identity_seed: [u8; 32], + pub group: Option>, + /// Post-quantum hybrid keypair (X25519 + ML-KEM-768). `None` for state created before hybrid was added. + #[serde(default)] + pub hybrid_key: Option, + /// Cached member public keys for group participants. + #[serde(default)] + pub member_keys: Vec>, +} + +impl StoredState { + pub fn into_parts(self, state_path: &Path) -> anyhow::Result<(GroupMember, Option)> { + let identity = Arc::new(IdentityKeypair::from_seed(self.identity_seed)); + let group = self + .group + .map(|bytes| bincode::deserialize(&bytes).context("decode group")) + .transpose()?; + let key_store = DiskKeyStore::persistent(keystore_path(state_path))?; + let member = GroupMember::new_with_state(identity, key_store, group); + + let hybrid_kp = self + .hybrid_key + .map(|bytes| HybridKeypair::from_bytes(&bytes).context("decode hybrid key")) + .transpose()?; + + Ok((member, hybrid_kp)) + } + + pub fn from_parts(member: &GroupMember, hybrid_kp: Option<&HybridKeypair>) -> anyhow::Result { + let group = member + .group_ref() + .map(|g| bincode::serialize(g).context("serialize group")) + .transpose()?; + + Ok(Self { + identity_seed: member.identity_seed(), + group, + hybrid_key: hybrid_kp.map(|kp| kp.to_bytes()), + member_keys: Vec::new(), + }) + } +} + +/// Derive a 32-byte key from a password and salt using Argon2id. +fn derive_state_key(password: &str, salt: &[u8]) -> anyhow::Result<[u8; 32]> { + let mut key = [0u8; 32]; + Argon2::default() + .hash_password_into(password.as_bytes(), salt, &mut key) + .map_err(|e| anyhow::anyhow!("argon2 key derivation failed: {e}"))?; + Ok(key) +} + +/// Encrypt `plaintext` with the QPCE format: magic(4) | salt(16) | nonce(12) | ciphertext. +pub fn encrypt_state(password: &str, plaintext: &[u8]) -> anyhow::Result> { + let mut salt = [0u8; STATE_SALT_LEN]; + rand::rngs::OsRng.fill_bytes(&mut salt); + + let mut nonce_bytes = [0u8; STATE_NONCE_LEN]; + rand::rngs::OsRng.fill_bytes(&mut nonce_bytes); + + let key = derive_state_key(password, &salt)?; + let cipher = ChaCha20Poly1305::new(Key::from_slice(&key)); + let nonce = Nonce::from_slice(&nonce_bytes); + + let ciphertext = cipher + .encrypt(nonce, plaintext) + .map_err(|e| anyhow::anyhow!("state encryption failed: {e}"))?; + + let mut out = Vec::with_capacity(4 + STATE_SALT_LEN + STATE_NONCE_LEN + ciphertext.len()); + out.extend_from_slice(STATE_MAGIC); + out.extend_from_slice(&salt); + out.extend_from_slice(&nonce_bytes); + out.extend_from_slice(&ciphertext); + Ok(out) +} + +/// Decrypt a QPCE-formatted state file. +pub fn decrypt_state(password: &str, data: &[u8]) -> anyhow::Result> { + let header_len = 4 + STATE_SALT_LEN + STATE_NONCE_LEN; + anyhow::ensure!( + data.len() > header_len, + "encrypted state file too short ({} bytes)", + data.len() + ); + + let salt = &data[4..4 + STATE_SALT_LEN]; + let nonce_bytes = &data[4 + STATE_SALT_LEN..header_len]; + let ciphertext = &data[header_len..]; + + let key = derive_state_key(password, salt)?; + let cipher = ChaCha20Poly1305::new(Key::from_slice(&key)); + let nonce = Nonce::from_slice(nonce_bytes); + + let plaintext = cipher + .decrypt(nonce, ciphertext) + .map_err(|_| anyhow::anyhow!("state decryption failed (wrong password?)"))?; + + Ok(plaintext) +} + +/// Returns true if raw bytes begin with the QPCE magic header. +pub fn is_encrypted_state(bytes: &[u8]) -> bool { + bytes.len() >= 4 && &bytes[..4] == STATE_MAGIC +} + +pub fn load_or_init_state(path: &Path, password: Option<&str>) -> anyhow::Result { + if path.exists() { + let mut state = load_existing_state(path, password)?; + // Generate hybrid keypair if missing (upgrade from older state). + if state.hybrid_key.is_none() { + state.hybrid_key = Some(HybridKeypair::generate().to_bytes()); + write_state(path, &state, password)?; + } + return Ok(state); + } + + let identity = IdentityKeypair::generate(); + let hybrid_kp = HybridKeypair::generate(); + let key_store = DiskKeyStore::persistent(keystore_path(path))?; + let member = GroupMember::new_with_state(Arc::new(identity), key_store, None); + let state = StoredState::from_parts(&member, Some(&hybrid_kp))?; + write_state(path, &state, password)?; + Ok(state) +} + +pub fn load_existing_state(path: &Path, password: Option<&str>) -> anyhow::Result { + let bytes = std::fs::read(path).with_context(|| format!("read state file {path:?}"))?; + + if is_encrypted_state(&bytes) { + let pw = password + .context("state file is encrypted (QPCE); a password is required to decrypt it")?; + let plaintext = decrypt_state(pw, &bytes)?; + bincode::deserialize(&plaintext).context("decode encrypted state") + } else { + bincode::deserialize(&bytes).context("decode state") + } +} + +pub fn save_state( + path: &Path, + member: &GroupMember, + hybrid_kp: Option<&HybridKeypair>, + password: Option<&str>, +) -> anyhow::Result<()> { + let state = StoredState::from_parts(member, hybrid_kp)?; + write_state(path, &state, password) +} + +pub fn write_state(path: &Path, state: &StoredState, password: Option<&str>) -> anyhow::Result<()> { + if let Some(parent) = path.parent() { + std::fs::create_dir_all(parent).with_context(|| format!("create dir {parent:?}"))?; + } + let plaintext = bincode::serialize(state).context("encode state")?; + + let bytes = if let Some(pw) = password { + encrypt_state(pw, &plaintext)? + } else { + plaintext + }; + + std::fs::write(path, bytes).with_context(|| format!("write state {path:?}"))?; + Ok(()) +} + +pub fn decode_identity_key(hex_str: &str) -> anyhow::Result> { + let bytes = super::hex::decode(hex_str) + .map_err(|e| anyhow::anyhow!(e)) + .context("identity key must be hex")?; + anyhow::ensure!(bytes.len() == 32, "identity key must be 32 bytes"); + Ok(bytes) +} + +pub fn keystore_path(state_path: &Path) -> PathBuf { + let mut path = state_path.to_path_buf(); + path.set_extension("ks"); + path +} + +pub fn sha256(bytes: &[u8]) -> Vec { + use sha2::{Digest, Sha256}; + Sha256::digest(bytes).to_vec() +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn encrypt_decrypt_roundtrip() { + let plaintext = b"test state data"; + let password = "test-password"; + let encrypted = encrypt_state(password, plaintext).unwrap(); + assert!(is_encrypted_state(&encrypted)); + let decrypted = decrypt_state(password, &encrypted).unwrap(); + assert_eq!(decrypted, plaintext); + } + + #[test] + fn wrong_password_fails() { + let plaintext = b"test state data"; + let encrypted = encrypt_state("correct", plaintext).unwrap(); + assert!(decrypt_state("wrong", &encrypted).is_err()); + } +} diff --git a/crates/quicnprotochat-client/src/lib.rs b/crates/quicnprotochat-client/src/lib.rs index e5cbb69..9fcf7b9 100644 --- a/crates/quicnprotochat-client/src/lib.rs +++ b/crates/quicnprotochat-client/src/lib.rs @@ -1,56 +1,47 @@ -use std::fs; -use std::net::SocketAddr; -use std::path::{Path, PathBuf}; -use std::sync::{Arc, OnceLock}; +//! quicnprotochat CLI client library. +//! +//! # KeyPackage expiry and refresh +//! +//! KeyPackages are single-use (consumed when someone fetches them for an invite) and the server +//! may enforce a TTL (e.g. 24 hours). To stay invitable, run `quicnprotochat refresh-keypackage` +//! periodically (e.g. before the server TTL) or after your KeyPackage was consumed: +//! +//! ```bash +//! quicnprotochat refresh-keypackage --state quicnprotochat-state.bin --server 127.0.0.1:7000 +//! ``` +//! +//! Use the same `--access-token` (or `QUICNPROTOCHAT_ACCESS_TOKEN`) as for other authenticated +//! commands. See the [running-the-client](https://docs.quicnprotochat.dev/getting-started/running-the-client) +//! docs for details. -use anyhow::Context; -use argon2::Argon2; -use capnp_rpc::{rpc_twoparty_capnp::Side, twoparty, RpcSystem}; -use chacha20poly1305::{ - aead::{Aead, KeyInit}, - ChaCha20Poly1305, Key, Nonce, +use std::sync::OnceLock; + +pub mod client; + +pub use client::commands::{ + cmd_chat, cmd_check_key, cmd_create_group, cmd_demo_group, cmd_fetch_key, cmd_health, + cmd_health_json, cmd_invite, cmd_join, cmd_login, cmd_ping, cmd_recv, cmd_register, + cmd_register_state, cmd_refresh_keypackage, cmd_register_user, cmd_send, cmd_whoami, + receive_pending_plaintexts, whoami_json, }; -use rand::RngCore; -use serde::{Deserialize, Serialize}; -use tokio_util::compat::{TokioAsyncReadCompatExt, TokioAsyncWriteCompatExt}; -use quinn::{ClientConfig, Endpoint}; -use quinn_proto::crypto::rustls::QuicClientConfig; -use rustls::pki_types::CertificateDer; -use rustls::{ClientConfig as RustlsClientConfig, RootCertStore}; - -use opaque_ke::{ - ClientLogin, ClientLoginFinishParameters, ClientRegistration, - ClientRegistrationFinishParameters, CredentialResponse, RegistrationResponse, -}; -use quicnprotochat_core::{ - generate_key_package, hybrid_decrypt, hybrid_encrypt, opaque_auth::OpaqueSuite, DiskKeyStore, - GroupMember, HybridKeypair, HybridKeypairBytes, HybridPublicKey, IdentityKeypair, -}; -use quicnprotochat_proto::node_capnp::{auth, node_service}; - -/// Magic bytes for encrypted client state files. -const STATE_MAGIC: &[u8; 4] = b"QPCE"; -const STATE_SALT_LEN: usize = 16; -const STATE_NONCE_LEN: usize = 12; +pub use client::rpc::{connect_node, enqueue, fetch_wait}; // Global auth context initialized once per process. -static AUTH_CONTEXT: OnceLock = OnceLock::new(); +pub(crate) static AUTH_CONTEXT: OnceLock = OnceLock::new(); #[derive(Clone, Debug)] pub struct ClientAuth { - version: u16, - access_token: Vec, - device_id: Vec, + pub(crate) version: u16, + pub(crate) access_token: Vec, + pub(crate) device_id: Vec, } impl ClientAuth { /// Build a client auth context from optional token and device id. - /// Requires a non-empty token (auth version 1). pub fn from_parts(access_token: String, device_id: Option) -> Self { let token = access_token.into_bytes(); let device = device_id.unwrap_or_default().into_bytes(); - Self { version: 1, access_token: token, @@ -63,1291 +54,3 @@ impl ClientAuth { pub fn init_auth(ctx: ClientAuth) { let _ = AUTH_CONTEXT.set(ctx); } - -// -- Subcommand implementations ----------------------------------------------- - -/// Print local identity information from the state file (no server connection). -pub fn cmd_whoami(state_path: &Path, password: Option<&str>) -> anyhow::Result<()> { - let state = load_existing_state(state_path, password)?; - let identity = IdentityKeypair::from_seed(state.identity_seed); - - let pk_bytes = identity.public_key_bytes(); - let fingerprint = sha256(&pk_bytes); - - println!("identity_key : {}", hex::encode(&pk_bytes)); - println!("fingerprint : {}", hex::encode(&fingerprint)); - println!( - "hybrid_key : {}", - if state.hybrid_key.is_some() { - "present (X25519 + ML-KEM-768)" - } else { - "not generated" - } - ); - println!( - "group : {}", - if state.group.is_some() { - "active" - } else { - "none" - } - ); - println!("state_file : {}", state_path.display()); - - Ok(()) -} - -/// Check server connectivity via the health RPC. -pub async fn cmd_health(server: &str, ca_cert: &Path, server_name: &str) -> anyhow::Result<()> { - let sent_at = current_timestamp_ms(); - let client = connect_node(server, ca_cert, server_name).await?; - - let req = client.health_request(); - let resp = req.send().promise.await.context("health RPC failed")?; - - let status = resp - .get() - .context("health: bad response")? - .get_status() - .context("health: missing status")? - .to_str() - .unwrap_or("invalid"); - - let rtt_ms = current_timestamp_ms().saturating_sub(sent_at); - - println!("server : {server}"); - println!("status : {status}"); - println!("rtt : {rtt_ms}ms"); - Ok(()) -} - -/// Check if a peer identity has registered a hybrid public key (non-consuming). -pub async fn cmd_check_key( - server: &str, - ca_cert: &Path, - server_name: &str, - identity_key_hex: &str, -) -> anyhow::Result<()> { - let identity_key = decode_identity_key(identity_key_hex)?; - let node_client = connect_node(server, ca_cert, server_name).await?; - - let hybrid_pk = fetch_hybrid_key(&node_client, &identity_key).await?; - - println!("identity_key : {identity_key_hex}"); - println!( - "hybrid_key : {}", - if hybrid_pk.is_some() { - "available (X25519 + ML-KEM-768)" - } else { - "not found" - } - ); - Ok(()) -} - -/// Connect to `server`, call health, and print RTT over QUIC/TLS. -pub async fn cmd_ping(server: &str, ca_cert: &Path, server_name: &str) -> anyhow::Result<()> { - let sent_at = current_timestamp_ms(); - let client = connect_node(server, ca_cert, server_name).await?; - - let req = client.health_request(); - let resp = req.send().promise.await.context("health RPC failed")?; - - let status = resp - .get() - .context("health: bad response")? - .get_status() - .context("health: missing status")? - .to_str() - .unwrap_or("invalid"); - - let rtt_ms = current_timestamp_ms().saturating_sub(sent_at); - println!("health={status} rtt={rtt_ms}ms"); - Ok(()) -} - -/// Register a new user account via the OPAQUE protocol. -/// -/// The server never sees the password in plaintext. -pub async fn cmd_register_user( - server: &str, - ca_cert: &Path, - server_name: &str, - username: &str, - password: &str, -) -> anyhow::Result<()> { - let mut rng = rand::rngs::OsRng; - - let node_client = connect_node(server, ca_cert, server_name).await?; - - // OPAQUE registration step 1: client -> server. - let reg_start = ClientRegistration::::start(&mut rng, password.as_bytes()) - .map_err(|e| anyhow::anyhow!("OPAQUE register start: {e}"))?; - - let mut req = node_client.opaque_register_start_request(); - { - let mut p = req.get(); - p.set_username(username); - p.set_request(®_start.message.serialize()); - } - let resp = req - .send() - .promise - .await - .context("opaque_register_start RPC failed")?; - let response_bytes = resp - .get() - .context("register_start: bad response")? - .get_response() - .context("register_start: missing response")? - .to_vec(); - - let reg_response = RegistrationResponse::::deserialize(&response_bytes) - .map_err(|e| anyhow::anyhow!("invalid registration response: {e}"))?; - - // OPAQUE registration step 2: client finishes -> server. - let reg_finish = reg_start - .state - .finish( - &mut rng, - password.as_bytes(), - reg_response, - ClientRegistrationFinishParameters::::default(), - ) - .map_err(|e| anyhow::anyhow!("OPAQUE register finish: {e}"))?; - - let mut req = node_client.opaque_register_finish_request(); - { - let mut p = req.get(); - p.set_username(username); - p.set_upload(®_finish.message.serialize()); - // Identity-token binding: pass empty bytes (no state file available). - p.set_identity_key(&[]); - } - let resp = req - .send() - .promise - .await - .context("opaque_register_finish RPC failed")?; - let success = resp - .get() - .context("register_finish: bad response")? - .get_success(); - - anyhow::ensure!(success, "server rejected registration"); - - println!("user '{username}' registered successfully (OPAQUE)"); - Ok(()) -} - -/// Log in via the OPAQUE protocol and receive a session token. -/// -/// Returns the session token as a hex string. Use it as `--access-token` for -/// subsequent commands. -pub async fn cmd_login( - server: &str, - ca_cert: &Path, - server_name: &str, - username: &str, - password: &str, -) -> anyhow::Result<()> { - let mut rng = rand::rngs::OsRng; - - let node_client = connect_node(server, ca_cert, server_name).await?; - - // OPAQUE login step 1: client -> server. - let login_start = ClientLogin::::start(&mut rng, password.as_bytes()) - .map_err(|e| anyhow::anyhow!("OPAQUE login start: {e}"))?; - - let mut req = node_client.opaque_login_start_request(); - { - let mut p = req.get(); - p.set_username(username); - p.set_request(&login_start.message.serialize()); - } - let resp = req - .send() - .promise - .await - .context("opaque_login_start RPC failed")?; - let response_bytes = resp - .get() - .context("login_start: bad response")? - .get_response() - .context("login_start: missing response")? - .to_vec(); - - let credential_response = CredentialResponse::::deserialize(&response_bytes) - .map_err(|e| anyhow::anyhow!("invalid credential response: {e}"))?; - - // OPAQUE login step 2: client finishes -> server. - let login_finish = login_start - .state - .finish( - &mut rng, - password.as_bytes(), - credential_response, - ClientLoginFinishParameters::::default(), - ) - .map_err(|e| anyhow::anyhow!("OPAQUE login finish (bad password?): {e}"))?; - - let mut req = node_client.opaque_login_finish_request(); - { - let mut p = req.get(); - p.set_username(username); - p.set_finalization(&login_finish.message.serialize()); - // Identity-token binding: pass empty bytes (no state file available). - p.set_identity_key(&[]); - } - let resp = req - .send() - .promise - .await - .context("opaque_login_finish RPC failed")?; - let session_token = resp - .get() - .context("login_finish: bad response")? - .get_session_token() - .context("login_finish: missing session_token")? - .to_vec(); - - anyhow::ensure!( - !session_token.is_empty(), - "server returned empty session token" - ); - - println!("login successful for '{username}'"); - println!("session_token: {}", hex::encode(&session_token)); - println!("(use as --access-token for subsequent commands)"); - Ok(()) -} - -/// Generate a KeyPackage for a fresh identity and upload it to the AS. -/// -/// Must run on a `LocalSet` because capnp-rpc is `!Send`. -pub async fn cmd_register(server: &str, ca_cert: &Path, server_name: &str) -> anyhow::Result<()> { - let identity = IdentityKeypair::generate(); - - let (tls_bytes, fingerprint) = - generate_key_package(&identity).context("KeyPackage generation failed")?; - - let node_client = connect_node(server, ca_cert, server_name).await?; - - let mut req = node_client.upload_key_package_request(); - { - let mut p = req.get(); - p.set_identity_key(&identity.public_key_bytes()); - p.set_package(&tls_bytes); - let mut auth = p.reborrow().init_auth(); - set_auth(&mut auth)?; - } - - let response = req - .send() - .promise - .await - .context("upload_key_package RPC failed")?; - - let server_fp = response - .get() - .context("upload_key_package: bad response")? - .get_fingerprint() - .context("upload_key_package: missing fingerprint")? - .to_vec(); - - anyhow::ensure!( - server_fp == fingerprint, - "fingerprint mismatch: local={} server={}", - hex::encode(&fingerprint), - hex::encode(&server_fp), - ); - - println!( - "identity_key : {}", - hex::encode(identity.public_key_bytes()) - ); - println!("fingerprint : {}", hex::encode(&fingerprint)); - println!("KeyPackage uploaded successfully."); - - Ok(()) -} - -/// Upload the stored identity's KeyPackage to the AS (persists backend state). -pub async fn cmd_register_state( - state_path: &Path, - server: &str, - ca_cert: &Path, - server_name: &str, - password: Option<&str>, -) -> anyhow::Result<()> { - let state = load_or_init_state(state_path, password)?; - let (mut member, hybrid_kp) = state.into_parts(state_path)?; - - let tls_bytes = member - .generate_key_package() - .context("KeyPackage generation failed")?; - let fingerprint = sha256(&tls_bytes); - - let node_client = connect_node(server, ca_cert, server_name).await?; - - let mut req = node_client.upload_key_package_request(); - { - let mut p = req.get(); - p.set_identity_key(&member.identity().public_key_bytes()); - p.set_package(&tls_bytes); - let mut auth = p.reborrow().init_auth(); - set_auth(&mut auth)?; - } - - let response = req - .send() - .promise - .await - .context("upload_key_package RPC failed")?; - - let server_fp = response - .get() - .context("upload_key_package: bad response")? - .get_fingerprint() - .context("upload_key_package: missing fingerprint")? - .to_vec(); - - anyhow::ensure!(server_fp == fingerprint, "fingerprint mismatch"); - - // Upload hybrid public key alongside the KeyPackage. - if let Some(ref hkp) = hybrid_kp { - upload_hybrid_key( - &node_client, - &member.identity().public_key_bytes(), - &hkp.public_key(), - ) - .await?; - println!("hybrid_key : uploaded (X25519 + ML-KEM-768)"); - } - - println!( - "identity_key : {}", - hex::encode(member.identity().public_key_bytes()) - ); - println!("fingerprint : {}", hex::encode(&fingerprint)); - println!("KeyPackage uploaded successfully."); - - save_state(state_path, &member, hybrid_kp.as_ref(), password)?; - Ok(()) -} - -/// Fetch a peer's KeyPackage from the AS by their hex-encoded identity key. -/// -/// Must run on a `LocalSet` because capnp-rpc is `!Send`. -pub async fn cmd_fetch_key( - server: &str, - ca_cert: &Path, - server_name: &str, - identity_key_hex: &str, -) -> anyhow::Result<()> { - let identity_key = hex::decode(identity_key_hex) - .map_err(|e| anyhow::anyhow!(e)) - .context("identity_key must be 64 hex characters (32 bytes)")?; - anyhow::ensure!( - identity_key.len() == 32, - "identity_key must be exactly 32 bytes, got {}", - identity_key.len() - ); - - let node_client = connect_node(server, ca_cert, server_name).await?; - - let mut req = node_client.fetch_key_package_request(); - { - let mut p = req.get(); - p.set_identity_key(&identity_key); - let mut auth = p.reborrow().init_auth(); - set_auth(&mut auth)?; - } - - let response = req - .send() - .promise - .await - .context("fetch_key_package RPC failed")?; - - let package = response - .get() - .context("fetch_key_package: bad response")? - .get_package() - .context("fetch_key_package: missing package field")? - .to_vec(); - - if package.is_empty() { - println!("No KeyPackage available for this identity."); - return Ok(()); - } - - use sha2::{Digest, Sha256}; - let fingerprint = Sha256::digest(&package); - - println!("fingerprint : {}", hex::encode(fingerprint)); - println!("package_len : {} bytes", package.len()); - println!("KeyPackage fetched successfully."); - - Ok(()) -} - -/// Run a complete Alice/Bob MLS round-trip using the unified server endpoint. -/// -/// All payloads are wrapped in post-quantum hybrid envelopes (X25519 + ML-KEM-768). -pub async fn cmd_demo_group(server: &str, ca_cert: &Path, server_name: &str) -> anyhow::Result<()> { - // Identities and MLS state must be tied to the same backend instance. - let alice_id = Arc::new(IdentityKeypair::generate()); - let bob_id = Arc::new(IdentityKeypair::generate()); - - // Generate hybrid keypairs for both participants. - let alice_hybrid = HybridKeypair::generate(); - let bob_hybrid = HybridKeypair::generate(); - - let mut alice = GroupMember::new(Arc::clone(&alice_id)); - let mut bob = GroupMember::new(Arc::clone(&bob_id)); - - let alice_kp = alice - .generate_key_package() - .context("Alice KeyPackage generation failed")?; - let bob_kp = bob - .generate_key_package() - .context("Bob KeyPackage generation failed")?; - - // Upload both KeyPackages and hybrid public keys to the server. - let alice_node = connect_node(server, ca_cert, server_name).await?; - let bob_node = connect_node(server, ca_cert, server_name).await?; - - upload_key_package(&alice_node, &alice_id.public_key_bytes(), &alice_kp).await?; - upload_key_package(&bob_node, &bob_id.public_key_bytes(), &bob_kp).await?; - upload_hybrid_key( - &alice_node, - &alice_id.public_key_bytes(), - &alice_hybrid.public_key(), - ) - .await?; - upload_hybrid_key( - &bob_node, - &bob_id.public_key_bytes(), - &bob_hybrid.public_key(), - ) - .await?; - - println!("hybrid public keys uploaded for Alice and Bob"); - - // Alice fetches Bob's KeyPackage and creates the group. - let fetched_bob_kp = fetch_key_package(&alice_node, &bob_id.public_key_bytes()).await?; - anyhow::ensure!( - !fetched_bob_kp.is_empty(), - "AS returned an empty KeyPackage for Bob", - ); - - alice - .create_group(b"demo-group") - .context("Alice create_group failed")?; - let (_commit, welcome) = alice - .add_member(&fetched_bob_kp) - .context("Alice add_member failed")?; - - let alice_ds = alice_node.clone(); - let bob_ds = bob_node.clone(); - - // Fetch Bob's hybrid PK and wrap the welcome. - let bob_hybrid_pk = fetch_hybrid_key(&alice_node, &bob_id.public_key_bytes()) - .await? - .context("Bob hybrid key not found")?; - let wrapped_welcome = - hybrid_encrypt(&bob_hybrid_pk, &welcome).context("hybrid encrypt welcome")?; - enqueue(&alice_ds, &bob_id.public_key_bytes(), &wrapped_welcome).await?; - - let welcome_payloads = fetch_all(&bob_ds, &bob_id.public_key_bytes()).await?; - let raw_welcome = welcome_payloads - .first() - .cloned() - .context("Welcome was not delivered to Bob via DS")?; - - // Bob unwraps the hybrid envelope and joins the group. - let welcome_bytes = - hybrid_decrypt(&bob_hybrid, &raw_welcome).context("Bob: hybrid decrypt welcome failed")?; - bob.join_group(&welcome_bytes) - .context("Bob join_group failed")?; - - // Alice -> Bob (hybrid-wrapped) - let ct_ab = alice - .send_message(b"hello bob") - .context("Alice send_message failed")?; - let wrapped_ab = hybrid_encrypt(&bob_hybrid_pk, &ct_ab).context("hybrid encrypt Alice->Bob")?; - enqueue(&alice_ds, &bob_id.public_key_bytes(), &wrapped_ab).await?; - - let bob_msgs = fetch_all(&bob_ds, &bob_id.public_key_bytes()).await?; - let raw_ab = bob_msgs - .first() - .context("Bob: missing Alice ciphertext from DS")?; - let inner_ab = hybrid_decrypt(&bob_hybrid, raw_ab).context("Bob: hybrid decrypt failed")?; - let ab_plaintext = bob - .receive_message(&inner_ab)? - .context("Bob expected application message from Alice")?; - println!( - "Alice -> Bob plaintext: {}", - String::from_utf8_lossy(&ab_plaintext) - ); - - // Bob -> Alice (hybrid-wrapped) - let alice_hybrid_pk = fetch_hybrid_key(&bob_node, &alice_id.public_key_bytes()) - .await? - .context("Alice hybrid key not found")?; - let ct_ba = bob - .send_message(b"hello alice") - .context("Bob send_message failed")?; - let wrapped_ba = - hybrid_encrypt(&alice_hybrid_pk, &ct_ba).context("hybrid encrypt Bob->Alice")?; - enqueue(&bob_ds, &alice_id.public_key_bytes(), &wrapped_ba).await?; - - let alice_msgs = fetch_all(&alice_ds, &alice_id.public_key_bytes()).await?; - let raw_ba = alice_msgs - .first() - .context("Alice: missing Bob ciphertext from DS")?; - let inner_ba = hybrid_decrypt(&alice_hybrid, raw_ba).context("Alice: hybrid decrypt failed")?; - let ba_plaintext = alice - .receive_message(&inner_ba)? - .context("Alice expected application message from Bob")?; - println!( - "Bob -> Alice plaintext: {}", - String::from_utf8_lossy(&ba_plaintext) - ); - - println!("demo-group complete (hybrid PQ envelope active)"); - - Ok(()) -} - -/// Create a new group and persist state. -pub async fn cmd_create_group( - state_path: &Path, - _server: &str, - group_id: &str, - password: Option<&str>, -) -> anyhow::Result<()> { - let state = load_or_init_state(state_path, password)?; - let (mut member, hybrid_kp) = state.into_parts(state_path)?; - - anyhow::ensure!( - member.group_ref().is_none(), - "group already exists in state" - ); - - member - .create_group(group_id.as_bytes()) - .context("create_group failed")?; - - save_state(state_path, &member, hybrid_kp.as_ref(), password)?; - println!("group created: {group_id}"); - Ok(()) -} - -/// Invite a peer: fetch their KeyPackage, add to group, enqueue Welcome. -/// -/// If the peer has a hybrid public key on the server, the Welcome is wrapped -/// in a post-quantum hybrid envelope (X25519 + ML-KEM-768). -pub async fn cmd_invite( - state_path: &Path, - server: &str, - ca_cert: &Path, - server_name: &str, - peer_key_hex: &str, - password: Option<&str>, -) -> anyhow::Result<()> { - let state = load_existing_state(state_path, password)?; - let (mut member, hybrid_kp) = state.into_parts(state_path)?; - - let peer_key = decode_identity_key(peer_key_hex)?; - let node_client = connect_node(server, ca_cert, server_name).await?; - let peer_kp = fetch_key_package(&node_client, &peer_key).await?; - anyhow::ensure!( - !peer_kp.is_empty(), - "server returned empty KeyPackage for peer" - ); - - let _ = member - .group_ref() - .context("no active group; run create-group first")?; - - // Collect existing member identity keys *before* adding the new member, - // so we know who to fan-out the commit to. - let existing_members: Vec> = member - .member_identities() - .into_iter() - .filter(|k| k.as_slice() != member.identity().public_key_bytes()) - .collect(); - - let (commit, welcome) = member.add_member(&peer_kp).context("add_member failed")?; - - // Fan out the Commit to all existing members (excluding self and the - // new joiner who receives the Welcome instead). Fix 14. - for mk in &existing_members { - if mk.as_slice() == peer_key.as_slice() { - continue; - } - let peer_hpk = fetch_hybrid_key(&node_client, mk).await?; - let commit_payload = if let Some(ref pk) = peer_hpk { - hybrid_encrypt(pk, &commit).context("hybrid encrypt commit")? - } else { - commit.clone() - }; - enqueue(&node_client, mk, &commit_payload).await?; - } - - // Wrap welcome in hybrid envelope if peer has a hybrid public key. - let peer_hybrid_pk = fetch_hybrid_key(&node_client, &peer_key).await?; - let payload = if let Some(ref pk) = peer_hybrid_pk { - hybrid_encrypt(pk, &welcome).context("hybrid encrypt welcome failed")? - } else { - welcome - }; - - enqueue(&node_client, &peer_key, &payload).await?; - - save_state(state_path, &member, hybrid_kp.as_ref(), password)?; - println!( - "invited peer (welcome queued{}, commit sent to {} existing member(s))", - if peer_hybrid_pk.is_some() { - ", hybrid-encrypted" - } else { - "" - }, - existing_members.len(), - ); - Ok(()) -} - -/// Join a group by consuming a Welcome from the server queue. -/// -/// Automatically detects and decrypts hybrid-wrapped Welcomes. -pub async fn cmd_join( - state_path: &Path, - server: &str, - ca_cert: &Path, - server_name: &str, - password: Option<&str>, -) -> anyhow::Result<()> { - let state = load_existing_state(state_path, password)?; - let (mut member, hybrid_kp) = state.into_parts(state_path)?; - - anyhow::ensure!( - member.group_ref().is_none(), - "group already active in state" - ); - - let node_client = connect_node(server, ca_cert, server_name).await?; - let welcomes = fetch_all(&node_client, &member.identity().public_key_bytes()).await?; - let raw_welcome = welcomes - .first() - .cloned() - .context("no Welcome found in DS for this identity")?; - - let welcome_bytes = try_hybrid_decrypt(hybrid_kp.as_ref(), &raw_welcome) - .context("decrypt Welcome (hybrid required)")?; - - member - .join_group(&welcome_bytes) - .context("join_group failed")?; - - save_state(state_path, &member, hybrid_kp.as_ref(), password)?; - println!("joined group successfully"); - Ok(()) -} - -/// Send an application message via DS. -/// -/// If the peer has a hybrid public key, the MLS ciphertext is additionally -/// wrapped in a post-quantum hybrid envelope. -pub async fn cmd_send( - state_path: &Path, - server: &str, - ca_cert: &Path, - server_name: &str, - peer_key_hex: &str, - msg: &str, - password: Option<&str>, -) -> anyhow::Result<()> { - let state = load_existing_state(state_path, password)?; - let (mut member, hybrid_kp) = state.into_parts(state_path)?; - - let peer_key = decode_identity_key(peer_key_hex)?; - let node_client = connect_node(server, ca_cert, server_name).await?; - - let ct = member - .send_message(msg.as_bytes()) - .context("send_message failed")?; - - // Wrap in hybrid envelope if peer has a hybrid public key. - let peer_hybrid_pk = fetch_hybrid_key(&node_client, &peer_key).await?; - let payload = if let Some(ref pk) = peer_hybrid_pk { - hybrid_encrypt(pk, &ct).context("hybrid encrypt failed")? - } else { - ct - }; - - enqueue(&node_client, &peer_key, &payload).await?; - - save_state(state_path, &member, hybrid_kp.as_ref(), password)?; - println!( - "message sent{}", - if peer_hybrid_pk.is_some() { - " (hybrid-encrypted)" - } else { - "" - } - ); - Ok(()) -} - -/// Receive and decrypt all pending messages from the server. -/// -/// Automatically detects and decrypts hybrid-wrapped payloads. -pub async fn cmd_recv( - state_path: &Path, - server: &str, - ca_cert: &Path, - server_name: &str, - wait_ms: u64, - stream: bool, - password: Option<&str>, -) -> anyhow::Result<()> { - let state = load_existing_state(state_path, password)?; - let (mut member, hybrid_kp) = state.into_parts(state_path)?; - - let client = connect_node(server, ca_cert, server_name).await?; - - loop { - let payloads = fetch_wait(&client, &member.identity().public_key_bytes(), wait_ms).await?; - - if payloads.is_empty() { - if !stream { - println!("no messages"); - return Ok(()); - } - continue; - } - - for (idx, payload) in payloads.iter().enumerate() { - let mls_payload = match try_hybrid_decrypt(hybrid_kp.as_ref(), payload) { - Ok(b) => b, - Err(e) => { - println!("[{idx}] decrypt error: {e}"); - continue; - } - }; - - match member.receive_message(&mls_payload) { - Ok(Some(pt)) => println!("[{idx}] plaintext: {}", String::from_utf8_lossy(&pt)), - Ok(None) => println!("[{idx}] commit applied"), - Err(e) => println!("[{idx}] error: {e}"), - } - } - - save_state(state_path, &member, hybrid_kp.as_ref(), password)?; - - if !stream { - return Ok(()); - } - } -} - -// -- Shared helpers ----------------------------------------------------------- - -/// Establish a QUIC/TLS connection and return a `NodeService` client. -/// -/// Must be called from within a `LocalSet` because capnp-rpc is `!Send`. -pub async fn connect_node( - server: &str, - ca_cert: &Path, - server_name: &str, -) -> anyhow::Result { - let addr: SocketAddr = server - .parse() - .with_context(|| format!("server must be host:port, got {server}"))?; - - let cert_bytes = fs::read(ca_cert).with_context(|| format!("read ca_cert {ca_cert:?}"))?; - let mut roots = RootCertStore::empty(); - roots - .add(CertificateDer::from(cert_bytes)) - .context("add root cert")?; - - let mut tls = RustlsClientConfig::builder() - .with_root_certificates(roots) - .with_no_client_auth(); - tls.alpn_protocols = vec![b"capnp".to_vec()]; - - let crypto = QuicClientConfig::try_from(tls) - .map_err(|e| anyhow::anyhow!("invalid client TLS config: {e}"))?; - - let bind_addr: SocketAddr = "0.0.0.0:0".parse().context("parse client bind address")?; - let mut endpoint = Endpoint::client(bind_addr)?; - endpoint.set_default_client_config(ClientConfig::new(Arc::new(crypto))); - - let connection = endpoint - .connect(addr, server_name) - .context("quic connect init")? - .await - .context("quic connect failed")?; - - let (send, recv) = connection.open_bi().await.context("open bi stream")?; - - let network = twoparty::VatNetwork::new( - recv.compat(), - send.compat_write(), - Side::Client, - Default::default(), - ); - - let mut rpc_system = RpcSystem::new(Box::new(network), None); - let client: node_service::Client = rpc_system.bootstrap(Side::Server); - - tokio::task::spawn_local(rpc_system); - - Ok(client) -} - -/// Upload a KeyPackage and verify the fingerprint echoed by the AS. -pub async fn upload_key_package( - client: &node_service::Client, - identity_key: &[u8], - package: &[u8], -) -> anyhow::Result<()> { - let mut req = client.upload_key_package_request(); - { - let mut p = req.get(); - p.set_identity_key(identity_key); - p.set_package(package); - let mut auth = p.reborrow().init_auth(); - set_auth(&mut auth)?; - } - - let resp = req - .send() - .promise - .await - .context("upload_key_package RPC failed")?; - - let server_fp = resp - .get() - .context("upload_key_package: bad response")? - .get_fingerprint() - .context("upload_key_package: missing fingerprint")? - .to_vec(); - - let local_fp = sha256(package); - anyhow::ensure!(server_fp == local_fp, "fingerprint mismatch"); - Ok(()) -} - -/// Fetch a KeyPackage for `identity_key` from the AS. -pub async fn fetch_key_package( - client: &node_service::Client, - identity_key: &[u8], -) -> anyhow::Result> { - let mut req = client.fetch_key_package_request(); - { - let mut p = req.get(); - p.set_identity_key(identity_key); - let mut auth = p.reborrow().init_auth(); - set_auth(&mut auth)?; - } - - let resp = req - .send() - .promise - .await - .context("fetch_key_package RPC failed")?; - - let pkg = resp - .get() - .context("fetch_key_package: bad response")? - .get_package() - .context("fetch_key_package: missing package field")? - .to_vec(); - - Ok(pkg) -} - -/// Enqueue an opaque payload to the DS for `recipient_key`. -pub async fn enqueue( - client: &node_service::Client, - recipient_key: &[u8], - payload: &[u8], -) -> anyhow::Result<()> { - let mut req = client.enqueue_request(); - { - let mut p = req.get(); - p.set_recipient_key(recipient_key); - p.set_payload(payload); - p.set_channel_id(&[]); - p.set_version(1); - let mut auth = p.reborrow().init_auth(); - set_auth(&mut auth)?; - } - req.send().promise.await.context("enqueue RPC failed")?; - Ok(()) -} - -/// Fetch and drain all payloads for `recipient_key`. -pub async fn fetch_all( - client: &node_service::Client, - recipient_key: &[u8], -) -> anyhow::Result>> { - let mut req = client.fetch_request(); - { - let mut p = req.get(); - p.set_recipient_key(recipient_key); - p.set_channel_id(&[]); - p.set_version(1); - p.set_limit(0); // fetch all - let mut auth = p.reborrow().init_auth(); - set_auth(&mut auth)?; - } - - let resp = req.send().promise.await.context("fetch RPC failed")?; - - let list = resp - .get() - .context("fetch: bad response")? - .get_payloads() - .context("fetch: missing payloads")?; - - let mut payloads = Vec::with_capacity(list.len() as usize); - for i in 0..list.len() { - payloads.push(list.get(i).context("fetch: payload read failed")?.to_vec()); - } - - Ok(payloads) -} - -/// Long-poll for payloads with optional timeout (ms). -pub async fn fetch_wait( - client: &node_service::Client, - recipient_key: &[u8], - timeout_ms: u64, -) -> anyhow::Result>> { - let mut req = client.fetch_wait_request(); - { - let mut p = req.get(); - p.set_recipient_key(recipient_key); - p.set_timeout_ms(timeout_ms); - p.set_channel_id(&[]); - p.set_version(1); - p.set_limit(0); // fetch all - let mut auth = p.reborrow().init_auth(); - set_auth(&mut auth)?; - } - - let resp = req.send().promise.await.context("fetch_wait RPC failed")?; - - let list = resp - .get() - .context("fetch_wait: bad response")? - .get_payloads() - .context("fetch_wait: missing payloads")?; - - let mut payloads = Vec::with_capacity(list.len() as usize); - for i in 0..list.len() { - payloads.push( - list.get(i) - .context("fetch_wait: payload read failed")? - .to_vec(), - ); - } - - Ok(payloads) -} - -/// Upload a hybrid (X25519 + ML-KEM-768) public key for an identity. -pub async fn upload_hybrid_key( - client: &node_service::Client, - identity_key: &[u8], - hybrid_pk: &HybridPublicKey, -) -> anyhow::Result<()> { - let mut req = client.upload_hybrid_key_request(); - { - let mut p = req.get(); - p.set_identity_key(identity_key); - p.set_hybrid_public_key(&hybrid_pk.to_bytes()); - let mut auth = p.reborrow().init_auth(); - set_auth(&mut auth)?; - } - req.send() - .promise - .await - .context("upload_hybrid_key RPC failed")?; - Ok(()) -} - -/// Fetch a peer's hybrid public key from the server. -/// -/// Returns `None` if the peer has not uploaded a hybrid key. -pub async fn fetch_hybrid_key( - client: &node_service::Client, - identity_key: &[u8], -) -> anyhow::Result> { - let mut req = client.fetch_hybrid_key_request(); - { - let mut p = req.get(); - p.set_identity_key(identity_key); - let mut auth = p.reborrow().init_auth(); - set_auth(&mut auth)?; - } - - let resp = req - .send() - .promise - .await - .context("fetch_hybrid_key RPC failed")?; - - let pk_bytes = resp - .get() - .context("fetch_hybrid_key: bad response")? - .get_hybrid_public_key() - .context("fetch_hybrid_key: missing field")? - .to_vec(); - - if pk_bytes.is_empty() { - return Ok(None); - } - - let pk = HybridPublicKey::from_bytes(&pk_bytes).context("invalid hybrid public key")?; - Ok(Some(pk)) -} - -/// Decrypt a hybrid envelope. Requires a hybrid key; no fallback to plaintext MLS. -fn try_hybrid_decrypt( - hybrid_kp: Option<&HybridKeypair>, - payload: &[u8], -) -> anyhow::Result> { - let kp = hybrid_kp.ok_or_else(|| anyhow::anyhow!("hybrid key required for decryption"))?; - hybrid_decrypt(kp, payload).map_err(|e| anyhow::anyhow!("{e}")) -} - -fn sha256(bytes: &[u8]) -> Vec { - use sha2::{Digest, Sha256}; - Sha256::digest(bytes).to_vec() -} - -fn set_auth(auth: &mut auth::Builder<'_>) -> anyhow::Result<()> { - let ctx = AUTH_CONTEXT.get().ok_or_else(|| { - anyhow::anyhow!("init_auth must be called with a non-empty token before RPCs") - })?; - auth.set_version(ctx.version); - auth.set_access_token(&ctx.access_token); - auth.set_device_id(&ctx.device_id); - Ok(()) -} - -#[derive(Serialize, Deserialize)] -struct StoredState { - identity_seed: [u8; 32], - group: Option>, - /// Post-quantum hybrid keypair (X25519 + ML-KEM-768). `None` for state created before hybrid was added; generated on load if missing. - #[serde(default)] - hybrid_key: Option, - /// Cached member public keys for group participants (Fix 14 prep). - #[serde(default)] - member_keys: Vec>, -} - -impl StoredState { - fn into_parts(self, state_path: &Path) -> anyhow::Result<(GroupMember, Option)> { - let identity = Arc::new(IdentityKeypair::from_seed(self.identity_seed)); - let group = self - .group - .map(|bytes| bincode::deserialize(&bytes).context("decode group")) - .transpose()?; - let key_store = DiskKeyStore::persistent(keystore_path(state_path))?; - let member = GroupMember::new_with_state(identity, key_store, group); - - let hybrid_kp = self - .hybrid_key - .map(|bytes| HybridKeypair::from_bytes(&bytes).context("decode hybrid key")) - .transpose()?; - - Ok((member, hybrid_kp)) - } - - fn from_parts(member: &GroupMember, hybrid_kp: Option<&HybridKeypair>) -> anyhow::Result { - let group = member - .group_ref() - .map(|g| bincode::serialize(g).context("serialize group")) - .transpose()?; - - Ok(Self { - identity_seed: member.identity_seed(), - group, - hybrid_key: hybrid_kp.map(|kp| kp.to_bytes()), - member_keys: Vec::new(), - }) - } -} - -// -- Encrypted state file helpers --------------------------------------------- - -/// Derive a 32-byte key from a password and salt using Argon2id. -fn derive_state_key(password: &str, salt: &[u8]) -> anyhow::Result<[u8; 32]> { - let mut key = [0u8; 32]; - Argon2::default() - .hash_password_into(password.as_bytes(), salt, &mut key) - .map_err(|e| anyhow::anyhow!("argon2 key derivation failed: {e}"))?; - Ok(key) -} - -/// Encrypt `plaintext` with the QPCE format: magic(4) | salt(16) | nonce(12) | ciphertext. -fn encrypt_state(password: &str, plaintext: &[u8]) -> anyhow::Result> { - let mut salt = [0u8; STATE_SALT_LEN]; - rand::rngs::OsRng.fill_bytes(&mut salt); - - let mut nonce_bytes = [0u8; STATE_NONCE_LEN]; - rand::rngs::OsRng.fill_bytes(&mut nonce_bytes); - - let key = derive_state_key(password, &salt)?; - let cipher = ChaCha20Poly1305::new(Key::from_slice(&key)); - let nonce = Nonce::from_slice(&nonce_bytes); - - let ciphertext = cipher - .encrypt(nonce, plaintext) - .map_err(|e| anyhow::anyhow!("state encryption failed: {e}"))?; - - let mut out = Vec::with_capacity(4 + STATE_SALT_LEN + STATE_NONCE_LEN + ciphertext.len()); - out.extend_from_slice(STATE_MAGIC); - out.extend_from_slice(&salt); - out.extend_from_slice(&nonce_bytes); - out.extend_from_slice(&ciphertext); - Ok(out) -} - -/// Decrypt a QPCE-formatted state file. Caller must verify magic prefix beforehand. -fn decrypt_state(password: &str, data: &[u8]) -> anyhow::Result> { - let header_len = 4 + STATE_SALT_LEN + STATE_NONCE_LEN; - anyhow::ensure!( - data.len() > header_len, - "encrypted state file too short ({} bytes)", - data.len() - ); - - let salt = &data[4..4 + STATE_SALT_LEN]; - let nonce_bytes = &data[4 + STATE_SALT_LEN..header_len]; - let ciphertext = &data[header_len..]; - - let key = derive_state_key(password, salt)?; - let cipher = ChaCha20Poly1305::new(Key::from_slice(&key)); - let nonce = Nonce::from_slice(nonce_bytes); - - let plaintext = cipher - .decrypt(nonce, ciphertext) - .map_err(|_| anyhow::anyhow!("state decryption failed (wrong password?)"))?; - - Ok(plaintext) -} - -/// Returns true if raw bytes begin with the QPCE magic header. -fn is_encrypted_state(bytes: &[u8]) -> bool { - bytes.len() >= 4 && &bytes[..4] == STATE_MAGIC -} - -fn load_or_init_state(path: &Path, password: Option<&str>) -> anyhow::Result { - if path.exists() { - let mut state = load_existing_state(path, password)?; - // Generate hybrid keypair if missing (upgrade from older state). - if state.hybrid_key.is_none() { - state.hybrid_key = Some(HybridKeypair::generate().to_bytes()); - write_state(path, &state, password)?; - } - return Ok(state); - } - - let identity = IdentityKeypair::generate(); - let hybrid_kp = HybridKeypair::generate(); - let key_store = DiskKeyStore::persistent(keystore_path(path))?; - let member = GroupMember::new_with_state(Arc::new(identity), key_store, None); - let state = StoredState::from_parts(&member, Some(&hybrid_kp))?; - write_state(path, &state, password)?; - Ok(state) -} - -fn load_existing_state(path: &Path, password: Option<&str>) -> anyhow::Result { - let bytes = std::fs::read(path).with_context(|| format!("read state file {path:?}"))?; - - if is_encrypted_state(&bytes) { - let pw = password - .context("state file is encrypted (QPCE); a password is required to decrypt it")?; - let plaintext = decrypt_state(pw, &bytes)?; - bincode::deserialize(&plaintext).context("decode encrypted state") - } else { - bincode::deserialize(&bytes).context("decode state") - } -} - -fn save_state( - path: &Path, - member: &GroupMember, - hybrid_kp: Option<&HybridKeypair>, - password: Option<&str>, -) -> anyhow::Result<()> { - let state = StoredState::from_parts(member, hybrid_kp)?; - write_state(path, &state, password) -} - -fn write_state(path: &Path, state: &StoredState, password: Option<&str>) -> anyhow::Result<()> { - if let Some(parent) = path.parent() { - std::fs::create_dir_all(parent).with_context(|| format!("create dir {parent:?}"))?; - } - let plaintext = bincode::serialize(state).context("encode state")?; - - let bytes = if let Some(pw) = password { - encrypt_state(pw, &plaintext)? - } else { - plaintext - }; - - std::fs::write(path, bytes).with_context(|| format!("write state {path:?}"))?; - Ok(()) -} - -fn decode_identity_key(hex_str: &str) -> anyhow::Result> { - let bytes = hex::decode(hex_str) - .map_err(|e| anyhow::anyhow!(e)) - .context("identity key must be hex")?; - anyhow::ensure!(bytes.len() == 32, "identity key must be 32 bytes"); - Ok(bytes) -} - -fn keystore_path(state_path: &Path) -> PathBuf { - let mut path = state_path.to_path_buf(); - path.set_extension("ks"); - path -} - -/// Return the current Unix timestamp in milliseconds. -fn current_timestamp_ms() -> u64 { - std::time::SystemTime::now() - .duration_since(std::time::UNIX_EPOCH) - .unwrap_or_default() - .as_millis() as u64 -} - -// -- Hex encoding helper ------------------------------------------------------ -// -// We use a tiny inline module rather than adding `hex` as a dependency. - -mod hex { - pub fn encode(bytes: impl AsRef<[u8]>) -> String { - bytes.as_ref().iter().map(|b| format!("{b:02x}")).collect() - } - - pub fn decode(s: &str) -> Result, &'static str> { - if s.len() % 2 != 0 { - return Err("odd-length hex string"); - } - (0..s.len()) - .step_by(2) - .map(|i| u8::from_str_radix(&s[i..i + 2], 16).map_err(|_| "invalid hex character")) - .collect() - } -} diff --git a/crates/quicnprotochat-client/src/main.rs b/crates/quicnprotochat-client/src/main.rs index 0406a74..617e885 100644 --- a/crates/quicnprotochat-client/src/main.rs +++ b/crates/quicnprotochat-client/src/main.rs @@ -5,9 +5,9 @@ use std::path::PathBuf; use clap::{Parser, Subcommand}; use quicnprotochat_client::{ - cmd_check_key, cmd_create_group, cmd_demo_group, cmd_fetch_key, cmd_health, cmd_invite, - cmd_join, cmd_login, cmd_ping, cmd_recv, cmd_register, cmd_register_state, cmd_register_user, - cmd_send, cmd_whoami, init_auth, ClientAuth, + cmd_chat, cmd_check_key, cmd_create_group, cmd_demo_group, cmd_fetch_key, cmd_health, + cmd_invite, cmd_join, cmd_login, cmd_ping, cmd_recv, cmd_register, cmd_register_state, + cmd_refresh_keypackage, cmd_register_user, cmd_send, cmd_whoami, init_auth, ClientAuth, }; // ── CLI ─────────────────────────────────────────────────────────────────────── @@ -78,6 +78,15 @@ enum Command { username: String, #[arg(long)] password: String, + /// Hex-encoded Ed25519 identity key (64 hex chars). Optional if --state is provided. + #[arg(long)] + identity_key: Option, + /// State file to derive the identity key (requires same password if encrypted). + #[arg(long)] + state: Option, + /// Password for the encrypted state file (if any). + #[arg(long)] + state_password: Option, }, /// Show local identity key, fingerprint, group status, and hybrid key status. @@ -132,7 +141,7 @@ enum Command { identity_key: String, }, - /// Run a full Alice/Bob MLS round-trip against live AS and DS endpoints. + /// Run a two-party MLS demo (creator + joiner) against live AS and DS. DemoGroup { /// Server address (host:port). #[arg(long, default_value = "127.0.0.1:7000", env = "QUICNPROTOCHAT_SERVER")] @@ -154,6 +163,22 @@ enum Command { server: String, }, + /// Refresh the KeyPackage on the server (existing state only). + /// Run periodically (e.g. before server TTL ~24h) or after your KeyPackage was consumed so others can invite you. + RefreshKeypackage { + /// State file path (identity + MLS state). + #[arg( + long, + default_value = "quicnprotochat-state.bin", + env = "QUICNPROTOCHAT_STATE" + )] + state: PathBuf, + + /// Server address (host:port). + #[arg(long, default_value = "127.0.0.1:7000", env = "QUICNPROTOCHAT_SERVER")] + server: String, + }, + /// Create a persistent group and save state to disk. CreateGroup { /// State file path (identity + MLS state). @@ -210,9 +235,12 @@ enum Command { state: PathBuf, #[arg(long, default_value = "127.0.0.1:7000", env = "QUICNPROTOCHAT_SERVER")] server: String, - /// Recipient identity key (hex, 32 bytes -> 64 chars). + /// Recipient identity key (hex, 32 bytes -> 64 chars). Omit when using --all. #[arg(long)] - peer_key: String, + peer_key: Option, + /// Send to all other group members (N-way groups). + #[arg(long)] + all: bool, /// Plaintext message to send. #[arg(long)] msg: String, @@ -237,6 +265,25 @@ enum Command { #[arg(long)] stream: bool, }, + + /// Interactive 1:1 chat: type to send, incoming messages printed as [peer] . Ctrl+D to exit. + /// In a two-person group, peer is chosen automatically; use --peer-key only with 3+ members. + Chat { + #[arg( + long, + default_value = "quicnprotochat-state.bin", + env = "QUICNPROTOCHAT_STATE" + )] + state: PathBuf, + #[arg(long, default_value = "127.0.0.1:7000", env = "QUICNPROTOCHAT_SERVER")] + server: String, + /// Peer identity key (hex, 64 chars). Omit in a two-person group to use the only other member. + #[arg(long)] + peer_key: Option, + /// How often to poll for incoming messages (milliseconds). + #[arg(long, default_value_t = 500)] + poll_interval_ms: u64, + }, } // ── Entry point ─────────────────────────────────────────────────────────────── @@ -272,6 +319,7 @@ async fn main() -> anyhow::Result<()> { &args.server_name, &username, &password, + None, )) .await } @@ -279,6 +327,9 @@ async fn main() -> anyhow::Result<()> { server, username, password, + identity_key, + state, + state_password, } => { let local = tokio::task::LocalSet::new(); local @@ -288,6 +339,9 @@ async fn main() -> anyhow::Result<()> { &args.server_name, &username, &password, + identity_key.as_deref(), + state.as_deref(), + state_password.as_deref(), )) .await } @@ -351,6 +405,18 @@ async fn main() -> anyhow::Result<()> { )) .await } + Command::RefreshKeypackage { state, server } => { + let local = tokio::task::LocalSet::new(); + local + .run_until(cmd_refresh_keypackage( + &state, + &server, + &args.ca_cert, + &args.server_name, + state_pw, + )) + .await + } Command::CreateGroup { state, server, @@ -394,6 +460,7 @@ async fn main() -> anyhow::Result<()> { state, server, peer_key, + all, msg, } => { let local = tokio::task::LocalSet::new(); @@ -403,7 +470,8 @@ async fn main() -> anyhow::Result<()> { &server, &args.ca_cert, &args.server_name, - &peer_key, + peer_key.as_deref(), + all, &msg, state_pw, )) @@ -428,5 +496,24 @@ async fn main() -> anyhow::Result<()> { )) .await } + Command::Chat { + state, + server, + peer_key, + poll_interval_ms, + } => { + let local = tokio::task::LocalSet::new(); + local + .run_until(cmd_chat( + &state, + &server, + &args.ca_cert, + &args.server_name, + peer_key.as_deref(), + state_pw, + poll_interval_ms, + )) + .await + } } } diff --git a/crates/quicnprotochat-client/tests/e2e.rs b/crates/quicnprotochat-client/tests/e2e.rs index 23c3ef9..71def29 100644 --- a/crates/quicnprotochat-client/tests/e2e.rs +++ b/crates/quicnprotochat-client/tests/e2e.rs @@ -5,8 +5,10 @@ use std::{path::PathBuf, process::Command, time::Duration}; use assert_cmd::cargo::cargo_bin; use portpicker::pick_unused_port; +use rand::RngCore; use tempfile::TempDir; use tokio::time::sleep; +use hex; // Required by rustls 0.23 when QUIC/TLS is used from this process (e.g. client in test). fn ensure_rustls_provider() { @@ -14,8 +16,9 @@ fn ensure_rustls_provider() { } use quicnprotochat_client::{ - cmd_create_group, cmd_invite, cmd_join, cmd_ping, cmd_register_state, cmd_send, connect_node, - fetch_wait, init_auth, ClientAuth, + cmd_create_group, cmd_invite, cmd_join, cmd_login, cmd_ping, cmd_register_state, + cmd_register_user, cmd_send, connect_node, enqueue, fetch_wait, init_auth, + receive_pending_plaintexts, ClientAuth, }; use quicnprotochat_core::IdentityKeypair; @@ -45,6 +48,8 @@ async fn wait_for_health(server: &str, ca_cert: &PathBuf, server_name: &str) -> anyhow::bail!("server health never became ready") } +/// Creator and joiner register; creator creates group and invites joiner; joiner joins; +/// creator sends a message; assert joiner's mailbox receives it. #[tokio::test(flavor = "multi_thread", worker_threads = 2)] async fn e2e_happy_path_register_invite_join_send_recv() -> anyhow::Result<()> { ensure_rustls_provider(); @@ -72,6 +77,7 @@ async fn e2e_happy_path_register_invite_join_send_recv() -> anyhow::Result<()> { .arg(&tls_key) .arg("--auth-token") .arg(auth_token) + .arg("--allow-insecure-auth") .spawn() .expect("spawn server"); @@ -91,15 +97,14 @@ async fn e2e_happy_path_register_invite_join_send_recv() -> anyhow::Result<()> { // Set client auth context. init_auth(ClientAuth::from_parts(auth_token.to_string(), None)); - // LocalSet for capnp !Send operations. let local = tokio::task::LocalSet::new(); - let alice_state = base.join("alice.bin"); - let bob_state = base.join("bob.bin"); + let creator_state = base.join("creator.bin"); + let joiner_state = base.join("joiner.bin"); local .run_until(cmd_register_state( - &alice_state, + &creator_state, &server, &ca_cert, "localhost", @@ -109,7 +114,7 @@ async fn e2e_happy_path_register_invite_join_send_recv() -> anyhow::Result<()> { local .run_until(cmd_register_state( - &bob_state, + &joiner_state, &server, &ca_cert, "localhost", @@ -118,52 +123,475 @@ async fn e2e_happy_path_register_invite_join_send_recv() -> anyhow::Result<()> { .await?; local - .run_until(cmd_create_group(&alice_state, &server, "test-group", None)) + .run_until(cmd_create_group(&creator_state, &server, "test-group", None)) .await?; - // Load Bob identity key from persisted state to use as peer key. - let bob_bytes = std::fs::read(&bob_state)?; - let bob_state_compat: StoredStateCompat = bincode::deserialize(&bob_bytes)?; - let bob_identity = IdentityKeypair::from_seed(bob_state_compat.identity_seed); - let bob_pk_hex = hex_encode(&bob_identity.public_key_bytes()); + let joiner_bytes = std::fs::read(&joiner_state)?; + let joiner_state_compat: StoredStateCompat = bincode::deserialize(&joiner_bytes)?; + let joiner_identity = IdentityKeypair::from_seed(joiner_state_compat.identity_seed); + let joiner_pk_hex = hex_encode(&joiner_identity.public_key_bytes()); local .run_until(cmd_invite( - &alice_state, + &creator_state, &server, &ca_cert, "localhost", - &bob_pk_hex, + &joiner_pk_hex, None, )) .await?; local - .run_until(cmd_join(&bob_state, &server, &ca_cert, "localhost", None)) + .run_until(cmd_join(&joiner_state, &server, &ca_cert, "localhost", None)) .await?; - // Send Alice -> Bob. local .run_until(cmd_send( - &alice_state, + &creator_state, &server, &ca_cert, "localhost", - &bob_pk_hex, - "hello bob", + Some(&joiner_pk_hex), + false, + "hello", None, )) .await?; - // Confirm Bob can fetch at least one payload. local .run_until(async { let client = connect_node(&server, &ca_cert, "localhost").await?; - let payloads = fetch_wait(&client, &bob_identity.public_key_bytes(), 1000).await?; - anyhow::ensure!(!payloads.is_empty(), "no payloads delivered to Bob"); + let payloads = fetch_wait(&client, &joiner_identity.public_key_bytes(), 1000).await?; + anyhow::ensure!(!payloads.is_empty(), "no payloads delivered to joiner"); Ok::<(), anyhow::Error>(()) }) .await?; Ok(()) } + +/// Three-party group: A creates group, invites B then C; B and C join; A sends, B and C receive; +/// B sends, A and C receive. +#[tokio::test(flavor = "multi_thread", worker_threads = 2)] +async fn e2e_three_party_group_invite_join_send_recv() -> anyhow::Result<()> { + ensure_rustls_provider(); + + let temp = TempDir::new()?; + let base = temp.path(); + let port = pick_unused_port().expect("free port"); + let listen = format!("127.0.0.1:{port}"); + let server = listen.clone(); + let ca_cert = base.join("server-cert.der"); + let tls_key = base.join("server-key.der"); + let data_dir = base.join("data"); + let auth_token = "devtoken"; + + let server_bin = cargo_bin("quicnprotochat-server"); + let child = Command::new(server_bin) + .arg("--listen") + .arg(&listen) + .arg("--data-dir") + .arg(&data_dir) + .arg("--tls-cert") + .arg(&ca_cert) + .arg("--tls-key") + .arg(&tls_key) + .arg("--auth-token") + .arg(auth_token) + .arg("--allow-insecure-auth") + .spawn() + .expect("spawn server"); + + struct ChildGuard(std::process::Child); + impl Drop for ChildGuard { + fn drop(&mut self) { + let _ = self.0.kill(); + } + } + let _child_guard = ChildGuard(child); + + wait_for_health(&server, &ca_cert, "localhost").await?; + init_auth(ClientAuth::from_parts(auth_token.to_string(), None)); + + let local = tokio::task::LocalSet::new(); + + let creator_state = base.join("creator.bin"); + let b_state = base.join("b.bin"); + let c_state = base.join("c.bin"); + + local + .run_until(cmd_register_state( + &creator_state, + &server, + &ca_cert, + "localhost", + None, + )) + .await?; + local + .run_until(cmd_register_state( + &b_state, + &server, + &ca_cert, + "localhost", + None, + )) + .await?; + local + .run_until(cmd_register_state( + &c_state, + &server, + &ca_cert, + "localhost", + None, + )) + .await?; + + let b_bytes = std::fs::read(&b_state)?; + let b_compat: StoredStateCompat = bincode::deserialize(&b_bytes)?; + let b_pk_hex = hex_encode(&IdentityKeypair::from_seed(b_compat.identity_seed).public_key_bytes()); + + let c_bytes = std::fs::read(&c_state)?; + let c_compat: StoredStateCompat = bincode::deserialize(&c_bytes)?; + let c_pk_hex = hex_encode(&IdentityKeypair::from_seed(c_compat.identity_seed).public_key_bytes()); + + local + .run_until(cmd_create_group(&creator_state, &server, "test-group", None)) + .await?; + + local + .run_until(cmd_invite( + &creator_state, + &server, + &ca_cert, + "localhost", + &b_pk_hex, + None, + )) + .await?; + + local + .run_until(cmd_invite( + &creator_state, + &server, + &ca_cert, + "localhost", + &c_pk_hex, + None, + )) + .await?; + + local + .run_until(cmd_join(&b_state, &server, &ca_cert, "localhost", None)) + .await?; + local + .run_until(cmd_join(&c_state, &server, &ca_cert, "localhost", None)) + .await?; + + local + .run_until(cmd_send( + &creator_state, + &server, + &ca_cert, + "localhost", + None, + true, + "hello", + None, + )) + .await?; + + sleep(Duration::from_millis(150)).await; + + let b_plaintexts = local + .run_until(receive_pending_plaintexts( + &b_state, + &server, + &ca_cert, + "localhost", + 1500, + None, + )) + .await?; + let c_plaintexts = local + .run_until(receive_pending_plaintexts( + &c_state, + &server, + &ca_cert, + "localhost", + 1500, + None, + )) + .await?; + anyhow::ensure!( + b_plaintexts.iter().any(|p| p.as_slice() == b"hello"), + "B did not receive 'hello', got {:?}", + b_plaintexts + ); + anyhow::ensure!( + c_plaintexts.iter().any(|p| p.as_slice() == b"hello"), + "C did not receive 'hello', got {:?}", + c_plaintexts + ); + + local + .run_until(cmd_send( + &b_state, + &server, + &ca_cert, + "localhost", + None, + true, + "hi", + None, + )) + .await?; + + sleep(Duration::from_millis(200)).await; + + let a_plaintexts = local + .run_until(receive_pending_plaintexts( + &creator_state, + &server, + &ca_cert, + "localhost", + 1500, + None, + )) + .await?; + let c_plaintexts2 = local + .run_until(receive_pending_plaintexts( + &c_state, + &server, + &ca_cert, + "localhost", + 1500, + None, + )) + .await?; + anyhow::ensure!( + a_plaintexts.iter().any(|p| p.as_slice() == b"hi"), + "A did not receive 'hi', got {:?}", + a_plaintexts + ); + anyhow::ensure!( + c_plaintexts2.iter().any(|p| p.as_slice() == b"hi"), + "C did not receive 'hi', got {:?}", + c_plaintexts2 + ); + + Ok(()) +} + +/// Login should refuse if the presented identity key does not match the registered key. +#[tokio::test(flavor = "multi_thread", worker_threads = 2)] +async fn e2e_login_rejects_mismatched_identity() -> anyhow::Result<()> { + ensure_rustls_provider(); + + let temp = TempDir::new()?; + let base = temp.path(); + let port = pick_unused_port().expect("free port"); + let listen = format!("127.0.0.1:{port}"); + let server = listen.clone(); + let ca_cert = base.join("server-cert.der"); + let tls_key = base.join("server-key.der"); + let data_dir = base.join("data"); + let auth_token = "devtoken"; + + // Spawn server binary. + let server_bin = cargo_bin("quicnprotochat-server"); + let child = Command::new(server_bin) + .arg("--listen") + .arg(&listen) + .arg("--data-dir") + .arg(&data_dir) + .arg("--tls-cert") + .arg(&ca_cert) + .arg("--tls-key") + .arg(&tls_key) + .arg("--auth-token") + .arg(auth_token) + .arg("--allow-insecure-auth") + .spawn() + .expect("spawn server"); + + struct ChildGuard(std::process::Child); + impl Drop for ChildGuard { + fn drop(&mut self) { + let _ = self.0.kill(); + } + } + let child_guard = ChildGuard(child); + let _ = child_guard; + + wait_for_health(&server, &ca_cert, "localhost").await?; + + init_auth(ClientAuth::from_parts(auth_token.to_string(), None)); + + let local = tokio::task::LocalSet::new(); + let state_path = base.join("user.bin"); + + // Register and persist state (includes identity key binding). + local + .run_until(cmd_register_state( + &state_path, + &server, + &ca_cert, + "localhost", + None, + )) + .await?; + + // Register the user with the bound identity so login can enforce mismatches. + let state_bytes = std::fs::read(&state_path)?; + let stored_state: StoredStateCompat = bincode::deserialize(&state_bytes)?; + let identity_hex = hex::encode( + IdentityKeypair::from_seed(stored_state.identity_seed).public_key_bytes(), + ); + + local + .run_until(cmd_register_user( + &server, + &ca_cert, + "localhost", + "user1", + "pass", + Some(&identity_hex), + )) + .await?; + + // Craft an unrelated identity key and attempt login with it. + let mut bogus_identity = [0u8; 32]; + rand::thread_rng().fill_bytes(&mut bogus_identity); + let bogus_hex = hex::encode(bogus_identity); + + let result = local + .run_until(cmd_login( + &server, + &ca_cert, + "localhost", + "user1", + "pass", + Some(&bogus_hex), + None, + None, + )) + .await; + + match result { + Ok(_) => anyhow::bail!("login unexpectedly succeeded with mismatched identity"), + Err(e) => { + // Show the full error chain so we can match the server's E016 response. + let msg = format!("{e:#}"); + anyhow::ensure!( + msg.contains("identity") || msg.contains("E016"), + "login failed but not for identity mismatch: {msg}" + ); + } + } + + Ok(()) +} + +/// Sealed Sender: enqueue with valid token (no identity binding) succeeds; recipient can fetch. +#[tokio::test(flavor = "multi_thread", worker_threads = 2)] +async fn e2e_sealed_sender_enqueue_then_fetch() -> anyhow::Result<()> { + ensure_rustls_provider(); + + let temp = TempDir::new()?; + let base = temp.path(); + let port = pick_unused_port().expect("free port"); + let listen = format!("127.0.0.1:{port}"); + let server = listen.clone(); + let ca_cert = base.join("server-cert.der"); + let tls_key = base.join("server-key.der"); + let data_dir = base.join("data"); + let auth_token = "devtoken"; + + let server_bin = cargo_bin("quicnprotochat-server"); + let child = Command::new(server_bin) + .arg("--listen") + .arg(&listen) + .arg("--data-dir") + .arg(&data_dir) + .arg("--tls-cert") + .arg(&ca_cert) + .arg("--tls-key") + .arg(&tls_key) + .arg("--auth-token") + .arg(auth_token) + .arg("--allow-insecure-auth") + .arg("--sealed-sender") + .spawn() + .expect("spawn server"); + + struct ChildGuard(std::process::Child); + impl Drop for ChildGuard { + fn drop(&mut self) { + let _ = self.0.kill(); + } + } + let _child_guard = ChildGuard(child); + + wait_for_health(&server, &ca_cert, "localhost").await?; + init_auth(ClientAuth::from_parts(auth_token.to_string(), None)); + + let local = tokio::task::LocalSet::new(); + let state_path = base.join("recipient.bin"); + + local + .run_until(cmd_register_state( + &state_path, + &server, + &ca_cert, + "localhost", + None, + )) + .await?; + + let state_bytes = std::fs::read(&state_path)?; + let stored: StoredStateCompat = bincode::deserialize(&state_bytes)?; + let recipient_key = IdentityKeypair::from_seed(stored.identity_seed).public_key_bytes(); + let identity_hex = hex_encode(&recipient_key); + + local + .run_until(cmd_register_user( + &server, + &ca_cert, + "localhost", + "recipient", + "pass", + Some(&identity_hex), + )) + .await?; + + local + .run_until(cmd_login( + &server, + &ca_cert, + "localhost", + "recipient", + "pass", + Some(&identity_hex), + None, + None, + )) + .await?; + + let client = local.run_until(connect_node(&server, &ca_cert, "localhost")).await?; + local + .run_until(enqueue(&client, &recipient_key, b"sealed-payload")) + .await?; + + let payloads = local + .run_until(fetch_wait(&client, &recipient_key, 500)) + .await?; + anyhow::ensure!( + payloads.len() == 1 && payloads[0].1.as_slice() == b"sealed-payload", + "expected one payload 'sealed-payload', got {:?}", + payloads + ); + + Ok(()) +} diff --git a/crates/quicnprotochat-core/src/app_message.rs b/crates/quicnprotochat-core/src/app_message.rs new file mode 100644 index 0000000..d68b326 --- /dev/null +++ b/crates/quicnprotochat-core/src/app_message.rs @@ -0,0 +1,258 @@ +//! Rich application-layer message format for MLS application payloads. +//! +//! The server sees only opaque ciphertext; structure lives in this client-defined +//! plaintext schema. All messages use: version byte (1) + message_type byte + type-specific payload. +//! +//! # Message ID +//! +//! `message_id` is assigned by the sender (16 random bytes) and included in the +//! serialized payload for Chat (and implied for Reply/Reaction/ReadReceipt via ref_msg_id). +//! Recipients can store message_ids to reference them in replies or reactions. + +use crate::error::CoreError; +use rand::RngCore; + +/// Current schema version. +pub const VERSION: u8 = 1; + +/// Message type discriminant (one byte). +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +#[repr(u8)] +pub enum MessageType { + Chat = 0x01, + Reply = 0x02, + Reaction = 0x03, + ReadReceipt = 0x04, + Typing = 0x05, +} + +impl MessageType { + fn from_byte(b: u8) -> Option { + match b { + 0x01 => Some(MessageType::Chat), + 0x02 => Some(MessageType::Reply), + 0x03 => Some(MessageType::Reaction), + 0x04 => Some(MessageType::ReadReceipt), + 0x05 => Some(MessageType::Typing), + _ => None, + } + } +} + +/// Parsed application message (one of the rich types). +#[derive(Clone, Debug, PartialEq, Eq)] +pub enum AppMessage { + /// Plain chat: body (UTF-8). message_id is included so recipients can store and reference it. + Chat { + message_id: [u8; 16], + body: Vec, + }, + Reply { + ref_msg_id: [u8; 16], + body: Vec, + }, + Reaction { + ref_msg_id: [u8; 16], + emoji: Vec, + }, + ReadReceipt { + msg_id: [u8; 16], + }, + Typing { + /// 0 = stopped, 1 = typing + active: u8, + }, +} + +/// Generate a new 16-byte message ID (e.g. for Chat/Reply so recipients can reference it). +pub fn generate_message_id() -> [u8; 16] { + let mut id = [0u8; 16]; + rand::rngs::OsRng.fill_bytes(&mut id); + id +} + +// ── Layout (minimal, no Cap'n Proto) ───────────────────────────────────────── +// +// All messages: [version: 1][type: 1][payload...] +// +// Chat: [msg_id: 16][body_len: 2 BE][body] +// Reply: [ref_msg_id: 16][body_len: 2 BE][body] +// Reaction: [ref_msg_id: 16][emoji_len: 1][emoji] +// ReadReceipt: [msg_id: 16] +// Typing: [active: 1] 0 = stopped, 1 = typing + +/// Serialize a rich message into the application payload format. +pub fn serialize(msg_type: MessageType, payload: &[u8]) -> Vec { + let mut out = Vec::with_capacity(2 + payload.len()); + out.push(VERSION); + out.push(msg_type as u8); + out.extend_from_slice(payload); + out +} + +/// Serialize a Chat message (generates message_id internally; pass None to generate, or Some(id) when replying with a known id). +pub fn serialize_chat(body: &[u8], message_id: Option<[u8; 16]>) -> Vec { + let id = message_id.unwrap_or_else(generate_message_id); + let mut payload = Vec::with_capacity(16 + 2 + body.len()); + payload.extend_from_slice(&id); + payload.extend_from_slice(&(body.len() as u16).to_be_bytes()); + payload.extend_from_slice(body); + serialize(MessageType::Chat, &payload) +} + +/// Serialize a Reply message. +pub fn serialize_reply(ref_msg_id: [u8; 16], body: &[u8]) -> Vec { + let mut payload = Vec::with_capacity(16 + 2 + body.len()); + payload.extend_from_slice(&ref_msg_id); + payload.extend_from_slice(&(body.len() as u16).to_be_bytes()); + payload.extend_from_slice(body); + serialize(MessageType::Reply, &payload) +} + +/// Serialize a Reaction message. +pub fn serialize_reaction(ref_msg_id: [u8; 16], emoji: &[u8]) -> Result, CoreError> { + if emoji.len() > 255 { + return Err(CoreError::AppMessage("emoji length > 255".into())); + } + let mut payload = Vec::with_capacity(16 + 1 + emoji.len()); + payload.extend_from_slice(&ref_msg_id); + payload.push(emoji.len() as u8); + payload.extend_from_slice(emoji); + Ok(serialize(MessageType::Reaction, &payload)) +} + +/// Serialize a ReadReceipt message. +pub fn serialize_read_receipt(msg_id: [u8; 16]) -> Vec { + serialize(MessageType::ReadReceipt, &msg_id) +} + +/// Serialize a Typing message (active: 0 = stopped, 1 = typing). +pub fn serialize_typing(active: u8) -> Vec { + let payload = [active]; + serialize(MessageType::Typing, &payload) +} + +/// Parse bytes into (MessageType, AppMessage). Fails if version/type unknown or payload too short. +pub fn parse(bytes: &[u8]) -> Result<(MessageType, AppMessage), CoreError> { + if bytes.len() < 2 { + return Err(CoreError::AppMessage("payload too short (need version + type)".into())); + } + let version = bytes[0]; + if version != VERSION { + return Err(CoreError::AppMessage(format!("unsupported version {version}").into())); + } + let msg_type = MessageType::from_byte(bytes[1]) + .ok_or_else(|| CoreError::AppMessage(format!("unknown message type {}", bytes[1]).into()))?; + let payload = &bytes[2..]; + + let app = match msg_type { + MessageType::Chat => parse_chat(payload)?, + MessageType::Reply => parse_reply(payload)?, + MessageType::Reaction => parse_reaction(payload)?, + MessageType::ReadReceipt => parse_read_receipt(payload)?, + MessageType::Typing => parse_typing(payload)?, + }; + Ok((msg_type, app)) +} + +fn parse_chat(payload: &[u8]) -> Result { + if payload.len() < 16 + 2 { + return Err(CoreError::AppMessage("Chat payload too short".into())); + } + let mut message_id = [0u8; 16]; + message_id.copy_from_slice(&payload[..16]); + let body_len = u16::from_be_bytes([payload[16], payload[17]]) as usize; + if payload.len() < 18 + body_len { + return Err(CoreError::AppMessage("Chat body length exceeds payload".into())); + } + let body = payload[18..18 + body_len].to_vec(); + Ok(AppMessage::Chat { message_id, body }) +} + +fn parse_reply(payload: &[u8]) -> Result { + if payload.len() < 16 + 2 { + return Err(CoreError::AppMessage("Reply payload too short".into())); + } + let mut ref_msg_id = [0u8; 16]; + ref_msg_id.copy_from_slice(&payload[..16]); + let body_len = u16::from_be_bytes([payload[16], payload[17]]) as usize; + if payload.len() < 18 + body_len { + return Err(CoreError::AppMessage("Reply body length exceeds payload".into())); + } + let body = payload[18..18 + body_len].to_vec(); + Ok(AppMessage::Reply { ref_msg_id, body }) +} + +fn parse_reaction(payload: &[u8]) -> Result { + if payload.len() < 16 + 1 { + return Err(CoreError::AppMessage("Reaction payload too short".into())); + } + let mut ref_msg_id = [0u8; 16]; + ref_msg_id.copy_from_slice(&payload[..16]); + let emoji_len = payload[16] as usize; + if payload.len() < 17 + emoji_len { + return Err(CoreError::AppMessage("Reaction emoji length exceeds payload".into())); + } + let emoji = payload[17..17 + emoji_len].to_vec(); + Ok(AppMessage::Reaction { ref_msg_id, emoji }) +} + +fn parse_read_receipt(payload: &[u8]) -> Result { + if payload.len() < 16 { + return Err(CoreError::AppMessage("ReadReceipt payload too short".into())); + } + let mut msg_id = [0u8; 16]; + msg_id.copy_from_slice(&payload[..16]); + Ok(AppMessage::ReadReceipt { msg_id }) +} + +fn parse_typing(payload: &[u8]) -> Result { + if payload.is_empty() { + return Err(CoreError::AppMessage("Typing payload empty".into())); + } + Ok(AppMessage::Typing { active: payload[0] }) +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn roundtrip_chat() { + let body = b"hello"; + let encoded = serialize_chat(body, None); + let (t, msg) = parse(&encoded).unwrap(); + assert_eq!(t, MessageType::Chat); + match &msg { + AppMessage::Chat { message_id: _, body: b } => assert_eq!(b.as_slice(), body), + _ => panic!("expected Chat"), + } + } + + #[test] + fn roundtrip_reply() { + let ref_id = [1u8; 16]; + let body = b"reply text"; + let encoded = serialize_reply(ref_id, body); + let (t, msg) = parse(&encoded).unwrap(); + assert_eq!(t, MessageType::Reply); + match &msg { + AppMessage::Reply { ref_msg_id, body: b } => { + assert_eq!(ref_msg_id, &ref_id); + assert_eq!(b.as_slice(), body); + } + _ => panic!("expected Reply"), + } + } + + #[test] + fn roundtrip_typing() { + let encoded = serialize_typing(1); + let (t, msg) = parse(&encoded).unwrap(); + assert_eq!(t, MessageType::Typing); + match &msg { + AppMessage::Typing { active } => assert_eq!(*active, 1), + _ => panic!("expected Typing"), + } + } +} diff --git a/crates/quicnprotochat-core/src/error.rs b/crates/quicnprotochat-core/src/error.rs index e9774c0..6cd5871 100644 --- a/crates/quicnprotochat-core/src/error.rs +++ b/crates/quicnprotochat-core/src/error.rs @@ -18,4 +18,12 @@ pub enum CoreError { /// A hybrid KEM (X25519 + ML-KEM-768) operation failed. #[error("hybrid KEM error: {0}")] HybridKem(#[from] crate::hybrid_kem::HybridKemError), + + /// IO or persistence failure. + #[error("io error: {0}")] + Io(String), + + /// Application message (rich payload) parse or serialisation error. + #[error("app message: {0}")] + AppMessage(String), } diff --git a/crates/quicnprotochat-core/src/group.rs b/crates/quicnprotochat-core/src/group.rs index e55effa..2dfdaec 100644 --- a/crates/quicnprotochat-core/src/group.rs +++ b/crates/quicnprotochat-core/src/group.rs @@ -25,7 +25,7 @@ //! in Welcome messages. `new_from_welcome` is called with `ratchet_tree = None`; //! openmls extracts the tree from the Welcome's `GroupInfo` extension. -use std::sync::Arc; +use std::{path::Path, sync::Arc}; use openmls::prelude::{ Ciphersuite, Credential, CredentialType, CredentialWithKey, CryptoConfig, GroupId, KeyPackage, @@ -78,6 +78,16 @@ impl GroupMember { Self::new_with_state(identity, DiskKeyStore::ephemeral(), None) } + /// Create a `GroupMember` with a persistent keystore at `path`. + pub fn new_persistent( + identity: Arc, + path: impl AsRef, + ) -> Result { + let key_store = DiskKeyStore::persistent(path) + .map_err(|e| CoreError::Io(format!("keystore: {e}")))?; + Ok(Self::new_with_state(identity, key_store, None)) + } + /// Create a `GroupMember` from pre-existing state (identity + optional group + store). pub fn new_with_state( identity: Arc, @@ -332,6 +342,58 @@ impl GroupMember { } } + /// Process an incoming TLS-encoded MLS message and return sender identity + plaintext for application messages. + /// + /// Same as [`receive_message`], but for Application messages returns + /// `Some((sender_identity_bytes, plaintext))` so the client can display who sent the message. + /// `sender_identity_bytes` is the MLS credential identity (e.g. Ed25519 public key for Basic credential). + /// + /// Returns `Ok(None)` for Commit and Proposal messages (group state is updated internally). + pub fn receive_message_with_sender( + &mut self, + mut bytes: &[u8], + ) -> Result, Vec)>, CoreError> { + let group = self + .group + .as_mut() + .ok_or_else(|| CoreError::Mls("no active group".into()))?; + + let msg_in = openmls::prelude::MlsMessageIn::tls_deserialize(&mut bytes) + .map_err(|e| CoreError::Mls(format!("message deserialise: {e:?}")))?; + + let protocol_message = match msg_in.extract() { + MlsMessageInBody::PrivateMessage(m) => ProtocolMessage::PrivateMessage(m), + MlsMessageInBody::PublicMessage(m) => ProtocolMessage::PublicMessage(m), + _ => return Err(CoreError::Mls("not a protocol message".into())), + }; + + let processed = group + .process_message(&self.backend, protocol_message) + .map_err(|e| CoreError::Mls(format!("process_message: {e:?}")))?; + + let sender_identity = processed.credential().identity().to_vec(); + + match processed.into_content() { + ProcessedMessageContent::ApplicationMessage(app) => { + Ok(Some((sender_identity, app.into_bytes()))) + } + ProcessedMessageContent::StagedCommitMessage(staged) => { + group + .merge_staged_commit(&self.backend, *staged) + .map_err(|e| CoreError::Mls(format!("merge_staged_commit: {e:?}")))?; + Ok(None) + } + ProcessedMessageContent::ProposalMessage(proposal) => { + group.store_pending_proposal(*proposal); + Ok(None) + } + ProcessedMessageContent::ExternalJoinProposalMessage(proposal) => { + group.store_pending_proposal(*proposal); + Ok(None) + } + } + } + // ── Accessors ───────────────────────────────────────────────────────────── /// Return the MLS group ID bytes, or `None` if no group is active. @@ -398,45 +460,42 @@ impl GroupMember { mod tests { use super::*; - /// Full two-party MLS round-trip: create group → add member → exchange messages. + /// Full two-party MLS round-trip: creator creates group, adds joiner, then they exchange messages. #[test] fn two_party_mls_round_trip() { - let alice_id = Arc::new(IdentityKeypair::generate()); - let bob_id = Arc::new(IdentityKeypair::generate()); + let creator_id = Arc::new(IdentityKeypair::generate()); + let joiner_id = Arc::new(IdentityKeypair::generate()); - let mut alice = GroupMember::new(Arc::clone(&alice_id)); - let mut bob = GroupMember::new(Arc::clone(&bob_id)); + let mut creator = GroupMember::new(Arc::clone(&creator_id)); + let mut joiner = GroupMember::new(Arc::clone(&joiner_id)); - // Bob generates a KeyPackage (stored in bob's backend key store). - let bob_kp = bob.generate_key_package().expect("Bob KeyPackage"); + let joiner_kp = joiner + .generate_key_package() + .expect("joiner KeyPackage"); - // Alice creates the group. - alice + creator .create_group(b"test-group-m3") - .expect("Alice create group"); + .expect("creator create group"); - // Alice adds Bob → (commit, welcome). - // Alice is the sole existing member, so she merges the commit herself. - let (_, welcome) = alice.add_member(&bob_kp).expect("Alice add Bob"); + let (_, welcome) = creator + .add_member(&joiner_kp) + .expect("creator add joiner"); - // Bob joins via the Welcome. His backend holds the matching init key. - bob.join_group(&welcome).expect("Bob join group"); + joiner.join_group(&welcome).expect("joiner join group"); - // Alice → Bob: application message. - let ct_a = alice.send_message(b"hello bob").expect("Alice send"); - let pt_b = bob - .receive_message(&ct_a) - .expect("Bob recv") - .expect("should be application message"); - assert_eq!(pt_b, b"hello bob"); + let ct_creator = creator.send_message(b"hello").expect("creator send"); + let pt_joiner = joiner + .receive_message(&ct_creator) + .expect("joiner recv") + .expect("application message"); + assert_eq!(pt_joiner, b"hello"); - // Bob → Alice: reply. - let ct_b = bob.send_message(b"hello alice").expect("Bob send"); - let pt_a = alice - .receive_message(&ct_b) - .expect("Alice recv") - .expect("should be application message"); - assert_eq!(pt_a, b"hello alice"); + let ct_joiner = joiner.send_message(b"hello back").expect("joiner send"); + let pt_creator = creator + .receive_message(&ct_joiner) + .expect("creator recv") + .expect("application message"); + assert_eq!(pt_creator, b"hello back"); } /// `group_id()` returns None before create_group, Some afterwards. diff --git a/crates/quicnprotochat-core/src/hybrid_crypto.rs b/crates/quicnprotochat-core/src/hybrid_crypto.rs new file mode 100644 index 0000000..11eb95d --- /dev/null +++ b/crates/quicnprotochat-core/src/hybrid_crypto.rs @@ -0,0 +1,442 @@ +//! Post-quantum hybrid crypto provider for OpenMLS (M7 PoC). +//! +//! Uses X25519 + ML-KEM-768 hybrid KEM for HPKE operations where openmls +//! would use DHKEM(X25519), and delegates all other operations (AEAD, hash, +//! signatures, KDF, randomness) to `openmls_rust_crypto::RustCrypto`. +//! +//! # Key format +//! +//! When the provider sees a **hybrid public key** (length `HYBRID_PUBLIC_KEY_LEN` = +//! 32 + 1184 bytes) or **hybrid private key** (length `HYBRID_PRIVATE_KEY_LEN` = +//! 32 + 2400 bytes), it uses `hybrid_kem` for HPKE. Otherwise it delegates to +//! RustCrypto (classical X25519 HPKE). +//! +//! # MLS compatibility +//! +//! The current MLS ciphersuite (MLS_128_DHKEMX25519_AES128GCM_SHA256_Ed25519) +//! uses 32-byte X25519 init keys in the wire format. This provider can produce +//! and consume **hybrid** init keys (1216-byte public, 2432-byte private), but +//! that is a non-standard extension: other MLS implementations will not +//! accept KeyPackages with hybrid init keys unless they implement the same +//! extension. This PoC validates that the OpenMLS trait surface is satisfiable +//! with a custom HPKE backend; full interoperability would require a new +//! ciphersuite or protocol extension. + +use openmls_rust_crypto::RustCrypto; +use openmls_traits::{ + crypto::OpenMlsCrypto, + types::{ + CryptoError, ExporterSecret, HpkeCiphertext, HpkeConfig, HpkeKeyPair, HpkeKemType, + }, + OpenMlsCryptoProvider, +}; +use tls_codec::SecretVLBytes; + +use crate::hybrid_kem::{ + hybrid_decapsulate_only, hybrid_decrypt, hybrid_encapsulate_only, hybrid_encrypt, + hybrid_export, HybridKeypair, HybridPublicKey, + HYBRID_KEM_OUTPUT_LEN, HYBRID_PRIVATE_KEY_LEN, HYBRID_PUBLIC_KEY_LEN, +}; +use crate::keystore::DiskKeyStore; + +// Re-export types used by OpenMlsCrypto (full path for clarity). +use openmls_traits::types::{ + AeadType, Ciphersuite, HashType, SignatureScheme, +}; + +/// Crypto backend that uses hybrid KEM for HPKE when keys are in hybrid format, +/// and delegates everything else to RustCrypto. +#[derive(Debug)] +pub struct HybridCrypto { + rust_crypto: RustCrypto, +} + +impl HybridCrypto { + pub fn new() -> Self { + Self { + rust_crypto: RustCrypto::default(), + } + } + + /// Expose the underlying RustCrypto for rand() and delegation. + pub fn rust_crypto(&self) -> &RustCrypto { + &self.rust_crypto + } + + fn is_hybrid_public_key(pk_r: &[u8]) -> bool { + pk_r.len() == HYBRID_PUBLIC_KEY_LEN + } + + fn is_hybrid_private_key(sk_r: &[u8]) -> bool { + sk_r.len() == HYBRID_PRIVATE_KEY_LEN + } +} + +impl Default for HybridCrypto { + fn default() -> Self { + Self::new() + } +} + +impl OpenMlsCrypto for HybridCrypto { + fn supports(&self, ciphersuite: Ciphersuite) -> Result<(), CryptoError> { + self.rust_crypto.supports(ciphersuite) + } + + fn supported_ciphersuites(&self) -> Vec { + self.rust_crypto.supported_ciphersuites() + } + + fn hkdf_extract( + &self, + hash_type: HashType, + salt: &[u8], + ikm: &[u8], + ) -> Result { + self.rust_crypto.hkdf_extract(hash_type, salt, ikm) + } + + fn hkdf_expand( + &self, + hash_type: HashType, + prk: &[u8], + info: &[u8], + okm_len: usize, + ) -> Result { + self.rust_crypto.hkdf_expand(hash_type, prk, info, okm_len) + } + + fn hash(&self, hash_type: HashType, data: &[u8]) -> Result, CryptoError> { + self.rust_crypto.hash(hash_type, data) + } + + fn aead_encrypt( + &self, + alg: AeadType, + key: &[u8], + data: &[u8], + nonce: &[u8], + aad: &[u8], + ) -> Result, CryptoError> { + self.rust_crypto.aead_encrypt(alg, key, data, nonce, aad) + } + + fn aead_decrypt( + &self, + alg: AeadType, + key: &[u8], + ct_tag: &[u8], + nonce: &[u8], + aad: &[u8], + ) -> Result, CryptoError> { + self.rust_crypto.aead_decrypt(alg, key, ct_tag, nonce, aad) + } + + fn signature_key_gen(&self, alg: SignatureScheme) -> Result<(Vec, Vec), CryptoError> { + self.rust_crypto.signature_key_gen(alg) + } + + fn verify_signature( + &self, + alg: SignatureScheme, + data: &[u8], + pk: &[u8], + signature: &[u8], + ) -> Result<(), CryptoError> { + self.rust_crypto.verify_signature(alg, data, pk, signature) + } + + fn sign(&self, alg: SignatureScheme, data: &[u8], key: &[u8]) -> Result, CryptoError> { + self.rust_crypto.sign(alg, data, key) + } + + fn hpke_seal( + &self, + config: HpkeConfig, + pk_r: &[u8], + info: &[u8], + aad: &[u8], + ptxt: &[u8], + ) -> HpkeCiphertext { + if Self::is_hybrid_public_key(pk_r) { + let recipient_pk = match HybridPublicKey::from_bytes(pk_r) { + Ok(pk) => pk, + Err(_) => return self.rust_crypto.hpke_seal(config, pk_r, info, aad, ptxt), + }; + match hybrid_encrypt(&recipient_pk, ptxt) { + Ok(envelope) => { + let kem_output = envelope[..HYBRID_KEM_OUTPUT_LEN].to_vec(); + let ciphertext = envelope[HYBRID_KEM_OUTPUT_LEN..].to_vec(); + HpkeCiphertext { + kem_output: kem_output.into(), + ciphertext: ciphertext.into(), + } + } + Err(_) => self.rust_crypto.hpke_seal(config, pk_r, info, aad, ptxt), + } + } else { + self.rust_crypto.hpke_seal(config, pk_r, info, aad, ptxt) + } + } + + fn hpke_open( + &self, + config: HpkeConfig, + input: &HpkeCiphertext, + sk_r: &[u8], + info: &[u8], + aad: &[u8], + ) -> Result, CryptoError> { + if Self::is_hybrid_private_key(sk_r) { + let keypair = match HybridKeypair::from_private_bytes(sk_r) { + Ok(kp) => kp, + Err(_) => return self.rust_crypto.hpke_open(config, input, sk_r, info, aad), + }; + let envelope: Vec = input + .kem_output.as_slice() + .iter() + .chain(input.ciphertext.as_slice()) + .copied() + .collect(); + hybrid_decrypt(&keypair, &envelope).map_err(|_| CryptoError::HpkeDecryptionError) + } else { + self.rust_crypto.hpke_open(config, input, sk_r, info, aad) + } + } + + fn hpke_setup_sender_and_export( + &self, + config: HpkeConfig, + pk_r: &[u8], + info: &[u8], + exporter_context: &[u8], + exporter_length: usize, + ) -> Result<(Vec, ExporterSecret), CryptoError> { + if Self::is_hybrid_public_key(pk_r) { + let recipient_pk = match HybridPublicKey::from_bytes(pk_r) { + Ok(pk) => pk, + Err(_) => { + return self.rust_crypto.hpke_setup_sender_and_export( + config, pk_r, info, exporter_context, exporter_length, + ) + } + }; + let (kem_output, shared_secret) = + hybrid_encapsulate_only(&recipient_pk).map_err(|_| CryptoError::SenderSetupError)?; + let exported = hybrid_export(&shared_secret, exporter_context, exporter_length); + Ok((kem_output, exported.into())) + } else { + self.rust_crypto.hpke_setup_sender_and_export( + config, pk_r, info, exporter_context, exporter_length, + ) + } + } + + fn hpke_setup_receiver_and_export( + &self, + config: HpkeConfig, + enc: &[u8], + sk_r: &[u8], + info: &[u8], + exporter_context: &[u8], + exporter_length: usize, + ) -> Result { + if Self::is_hybrid_private_key(sk_r) { + let keypair = HybridKeypair::from_private_bytes(sk_r) + .map_err(|_| CryptoError::ReceiverSetupError)?; + let shared_secret = + hybrid_decapsulate_only(&keypair, enc).map_err(|_| CryptoError::ReceiverSetupError)?; + let exported = hybrid_export(&shared_secret, exporter_context, exporter_length); + Ok(exported.into()) + } else { + self.rust_crypto.hpke_setup_receiver_and_export( + config, enc, sk_r, info, exporter_context, exporter_length, + ) + } + } + + fn derive_hpke_keypair(&self, config: HpkeConfig, ikm: &[u8]) -> HpkeKeyPair { + if config.0 == HpkeKemType::DhKem25519 { + let kp = HybridKeypair::derive_from_ikm(ikm); + HpkeKeyPair { + private: kp.private_to_bytes().into(), + public: kp.public_key().to_bytes(), + } + } else { + self.rust_crypto.derive_hpke_keypair(config, ikm) + } + } +} + +/// OpenMLS crypto provider that uses hybrid KEM for HPKE (when keys are in +/// hybrid format) and delegates the rest to RustCrypto. +#[derive(Debug)] +pub struct HybridCryptoProvider { + crypto: HybridCrypto, + key_store: DiskKeyStore, +} + +impl HybridCryptoProvider { + pub fn new(key_store: DiskKeyStore) -> Self { + Self { + crypto: HybridCrypto::new(), + key_store, + } + } +} + +impl Default for HybridCryptoProvider { + fn default() -> Self { + Self::new(DiskKeyStore::ephemeral()) + } +} + +impl OpenMlsCryptoProvider for HybridCryptoProvider { + type CryptoProvider = HybridCrypto; + type RandProvider = RustCrypto; + type KeyStoreProvider = DiskKeyStore; + + fn crypto(&self) -> &Self::CryptoProvider { + &self.crypto + } + + fn rand(&self) -> &Self::RandProvider { + self.crypto.rust_crypto() + } + + fn key_store(&self) -> &Self::KeyStoreProvider { + &self.key_store + } +} + +// ── Tests ─────────────────────────────────────────────────────────────────── + +#[cfg(test)] +mod tests { + use super::*; + use openmls_traits::types::HpkeKdfType; + + fn hpke_config_dhkem_x25519() -> HpkeConfig { + HpkeConfig( + HpkeKemType::DhKem25519, + HpkeKdfType::HkdfSha256, + openmls_traits::types::HpkeAeadType::AesGcm128, + ) + } + + /// HPKE path with hybrid keys: derive_hpke_keypair (hybrid) -> hpke_seal -> hpke_open. + #[test] + fn hybrid_hpke_seal_open_round_trip() { + let crypto = HybridCrypto::new(); + let ikm = b"test-ikm-for-hybrid-hpke-keypair"; + + let keypair = crypto.derive_hpke_keypair(hpke_config_dhkem_x25519(), ikm); + assert_eq!(keypair.public.len(), HYBRID_PUBLIC_KEY_LEN); + assert_eq!(keypair.private.as_ref().len(), HYBRID_PRIVATE_KEY_LEN); + + let plaintext = b"hello post-quantum MLS"; + let info = b"mls 1.0 test"; + let aad = b"additional data"; + + let ct = crypto.hpke_seal( + hpke_config_dhkem_x25519(), + &keypair.public, + info, + aad, + plaintext, + ); + assert!(!ct.kem_output.as_slice().is_empty()); + assert!(!ct.ciphertext.as_slice().is_empty()); + + let decrypted = crypto + .hpke_open( + hpke_config_dhkem_x25519(), + &ct, + keypair.private.as_ref(), + info, + aad, + ) + .expect("hpke_open with hybrid keys"); + assert_eq!(decrypted.as_slice(), plaintext); + } + + /// HPKE exporter path: setup_sender_and_export then setup_receiver_and_export. + #[test] + fn hybrid_hpke_setup_sender_receiver_export() { + let crypto = HybridCrypto::new(); + let ikm = b"exporter-ikm"; + + let keypair = crypto.derive_hpke_keypair(hpke_config_dhkem_x25519(), ikm); + let info = b""; + let exporter_context = b"MLS 1.0 external init"; + let exporter_length = 32; + + let (kem_output, sender_exported) = crypto + .hpke_setup_sender_and_export( + hpke_config_dhkem_x25519(), + &keypair.public, + info, + exporter_context, + exporter_length, + ) + .expect("sender and export"); + + assert_eq!(kem_output.len(), HYBRID_KEM_OUTPUT_LEN); + assert_eq!(sender_exported.as_ref().len(), exporter_length); + + let receiver_exported = crypto + .hpke_setup_receiver_and_export( + hpke_config_dhkem_x25519(), + &kem_output, + keypair.private.as_ref(), + info, + exporter_context, + exporter_length, + ) + .expect("receiver and export"); + + assert_eq!(sender_exported.as_ref(), receiver_exported.as_ref()); + } + + /// KeyPackage generation with HybridCryptoProvider (validates full HPKE path in MLS). + #[test] + fn key_package_generation_with_hybrid_provider() { + use openmls::prelude::{ + Credential, CredentialType, CredentialWithKey, CryptoConfig, KeyPackage, + }; + use std::sync::Arc; + use tls_codec::Serialize; + + use crate::identity::IdentityKeypair; + + const CIPHERSUITE: Ciphersuite = + Ciphersuite::MLS_128_DHKEMX25519_AES128GCM_SHA256_Ed25519; + + let provider = HybridCryptoProvider::default(); + let identity = Arc::new(IdentityKeypair::generate()); + + let credential = Credential::new( + identity.public_key_bytes().to_vec(), + CredentialType::Basic, + ) + .unwrap(); + let credential_with_key = CredentialWithKey { + credential, + signature_key: identity.public_key_bytes().to_vec().into(), + }; + + let key_package = KeyPackage::builder() + .build( + CryptoConfig::with_default_version(CIPHERSUITE), + &provider, + identity.as_ref(), + credential_with_key, + ) + .expect("KeyPackage with hybrid HPKE"); + + let bytes = key_package + .tls_serialize_detached() + .expect("serialize KeyPackage"); + assert!(!bytes.is_empty()); + } +} diff --git a/crates/quicnprotochat-core/src/hybrid_kem.rs b/crates/quicnprotochat-core/src/hybrid_kem.rs index 61f2bb7..3925745 100644 --- a/crates/quicnprotochat-core/src/hybrid_kem.rs +++ b/crates/quicnprotochat-core/src/hybrid_kem.rs @@ -28,7 +28,7 @@ use ml_kem::{ kem::{Decapsulate, Encapsulate}, EncodedSizeUser, KemCore, MlKem768, MlKem768Params, }; -use rand::{rngs::OsRng, RngCore}; +use rand::{rngs::OsRng, rngs::StdRng, CryptoRng, RngCore, SeedableRng}; use serde::{Deserialize, Serialize}; use sha2::Sha256; use x25519_dalek::{EphemeralSecret, PublicKey as X25519Public, StaticSecret}; @@ -55,6 +55,15 @@ pub const MLKEM_DK_LEN: usize = 2400; /// Envelope header: version(1) + x25519 eph pk(32) + mlkem ct(1088) + nonce(12). const HEADER_LEN: usize = 1 + 32 + MLKEM_CT_LEN + 12; +/// KEM output length (version + x25519 eph pk + mlkem ct) for HPKE adapter. +pub const HYBRID_KEM_OUTPUT_LEN: usize = 1 + 32 + MLKEM_CT_LEN; + +/// Hybrid public key length: x25519(32) + mlkem_ek(1184). Used to detect hybrid keys in MLS. +pub const HYBRID_PUBLIC_KEY_LEN: usize = 32 + MLKEM_EK_LEN; + +/// Hybrid private key length: x25519(32) + mlkem_dk(2400). Used to detect hybrid keys in MLS. +pub const HYBRID_PRIVATE_KEY_LEN: usize = 32 + MLKEM_DK_LEN; + // ── Error type ────────────────────────────────────────────────────────────── #[derive(Debug, thiserror::Error)] @@ -109,12 +118,20 @@ pub struct HybridPublicKey { pub mlkem_ek: Vec, } +/// HKDF info for deriving HPKE keypair seed from IKM (MLS compatibility). +const HKDF_INFO_HPKE_KEYPAIR: &[u8] = b"quicnprotochat-hybrid-hpke-keypair-v1"; + impl HybridKeypair { /// Generate a fresh hybrid keypair from OS CSPRNG. pub fn generate() -> Self { - let x25519_sk = StaticSecret::random_from_rng(OsRng); + Self::generate_from_rng(&mut OsRng) + } + + /// Generate a hybrid keypair from a seeded RNG (deterministic). + pub fn generate_from_rng(rng: &mut R) -> Self { + let x25519_sk = StaticSecret::random_from_rng(&mut *rng); let x25519_pk = X25519Public::from(&x25519_sk); - let (mlkem_dk, mlkem_ek) = MlKem768::generate(&mut OsRng); + let (mlkem_dk, mlkem_ek) = MlKem768::generate(rng); Self { x25519_sk, @@ -124,6 +141,45 @@ impl HybridKeypair { } } + /// Derive a deterministic hybrid keypair from IKM (for MLS HPKE key schedule). + pub fn derive_from_ikm(ikm: &[u8]) -> Self { + let mut seed = [0u8; 32]; + let hk = Hkdf::::new(None, ikm); + hk.expand(HKDF_INFO_HPKE_KEYPAIR, &mut seed) + .expect("32 bytes is valid HKDF output"); + let mut rng = StdRng::from_seed(seed); + Self::generate_from_rng(&mut rng) + } + + /// Serialise private key for MLS key store: x25519_sk(32) || mlkem_dk(2400). + pub fn private_to_bytes(&self) -> Vec { + let mut out = Vec::with_capacity(HYBRID_PRIVATE_KEY_LEN); + out.extend_from_slice(self.x25519_sk.as_bytes()); + out.extend_from_slice(self.mlkem_dk.as_bytes().as_slice()); + out + } + + /// Reconstruct a hybrid keypair from private key bytes (from MLS key store). + pub fn from_private_bytes(bytes: &[u8]) -> Result { + if bytes.len() != HYBRID_PRIVATE_KEY_LEN { + return Err(HybridKemError::TooShort(bytes.len())); + } + let x25519_sk = StaticSecret::from(<[u8; 32]>::try_from(&bytes[0..32]).unwrap()); + let x25519_pk = X25519Public::from(&x25519_sk); + + let mlkem_dk_arr = Array::try_from(&bytes[32..32 + MLKEM_DK_LEN]) + .map_err(|_| HybridKemError::InvalidMlKemKey)?; + let mlkem_dk = DecapsulationKey::::from_bytes(&mlkem_dk_arr); + let mlkem_ek = mlkem_dk.encapsulation_key().clone(); + + Ok(Self { + x25519_sk, + x25519_pk, + mlkem_dk, + mlkem_ek, + }) + } + /// Reconstruct from serialised bytes. pub fn from_bytes(bytes: &HybridKeypairBytes) -> Result { let x25519_sk = StaticSecret::from(*bytes.x25519_sk); @@ -290,6 +346,78 @@ pub fn hybrid_decrypt(keypair: &HybridKeypair, envelope: &[u8]) -> Result Result<(Vec, [u8; 32]), HybridKemError> { + let eph_secret = EphemeralSecret::random_from_rng(OsRng); + let eph_public = X25519Public::from(&eph_secret); + let x25519_recipient = X25519Public::from(recipient_pk.x25519_pk); + let x25519_ss = eph_secret.diffie_hellman(&x25519_recipient); + + let mlkem_ek_arr = Array::try_from(recipient_pk.mlkem_ek.as_slice()) + .map_err(|_| HybridKemError::InvalidMlKemKey)?; + let mlkem_ek = EncapsulationKey::::from_bytes(&mlkem_ek_arr); + let (mlkem_ct, mlkem_ss) = mlkem_ek + .encapsulate(&mut OsRng) + .map_err(|_| HybridKemError::EncryptionFailed)?; + + let aead_key = derive_aead_key(x25519_ss.as_bytes(), mlkem_ss.as_slice()); + let shared_secret = aead_key.as_slice().try_into().unwrap(); + + let mut kem_output = Vec::with_capacity(HYBRID_KEM_OUTPUT_LEN); + kem_output.push(HYBRID_VERSION); + kem_output.extend_from_slice(&eph_public.to_bytes()); + kem_output.extend_from_slice(mlkem_ct.as_slice()); + + Ok((kem_output, shared_secret)) +} + +/// Decapsulate only: recover shared secret from KEM output (no AEAD). +/// Used by MLS HPKE exporter (setup_receiver_and_export). +pub fn hybrid_decapsulate_only( + keypair: &HybridKeypair, + kem_output: &[u8], +) -> Result<[u8; 32], HybridKemError> { + if kem_output.len() < HYBRID_KEM_OUTPUT_LEN { + return Err(HybridKemError::TooShort(kem_output.len())); + } + if kem_output[0] != HYBRID_VERSION { + return Err(HybridKemError::UnsupportedVersion(kem_output[0])); + } + + let eph_pk_bytes: [u8; 32] = kem_output[1..33].try_into().unwrap(); + let eph_pk = X25519Public::from(eph_pk_bytes); + let x25519_ss = keypair.x25519_sk.diffie_hellman(&eph_pk); + + let mlkem_ct_arr = Array::try_from(&kem_output[33..33 + MLKEM_CT_LEN]) + .map_err(|_| HybridKemError::MlKemDecapsFailed)?; + let mlkem_ss = keypair + .mlkem_dk + .decapsulate(&mlkem_ct_arr) + .map_err(|_| HybridKemError::MlKemDecapsFailed)?; + + let aead_key = derive_aead_key(x25519_ss.as_bytes(), mlkem_ss.as_slice()); + Ok(aead_key.as_slice().try_into().unwrap()) +} + +/// Export a secret from shared secret (MLS HPKE exporter compatibility). +/// Uses HKDF-Expand(prk, exporter_context, length) with prk = HKDF-Extract(0, shared_secret). +pub fn hybrid_export( + shared_secret: &[u8; 32], + exporter_context: &[u8], + length: usize, +) -> Vec { + let hk = Hkdf::::new(None, shared_secret); + let mut out = vec![0u8; length]; + hk.expand(exporter_context, &mut out).expect("valid length"); + out +} + /// Derive AEAD key from the combined X25519 + ML-KEM shared secrets. /// /// The nonce is generated randomly per-encryption rather than derived from diff --git a/crates/quicnprotochat-core/src/lib.rs b/crates/quicnprotochat-core/src/lib.rs index 116b2e1..5631fe8 100644 --- a/crates/quicnprotochat-core/src/lib.rs +++ b/crates/quicnprotochat-core/src/lib.rs @@ -3,17 +3,20 @@ //! //! # Module layout //! -//! | Module | Responsibility | -//! |--------------|------------------------------------------------------------------| -//! | `error` | [`CoreError`] type | +//! | Module | Responsibility | +//! |---------------|------------------------------------------------------------------| +//! | `app_message` | Rich application payload (Chat, Reply, Reaction, ReadReceipt, Typing) | +//! | `error` | [`CoreError`] type | //! | `identity` | [`IdentityKeypair`] — Ed25519 identity key for MLS credentials | //! | `keypackage` | [`generate_key_package`] — standalone KeyPackage generation | //! | `group` | [`GroupMember`] — MLS group lifecycle (create/join/send/recv) | //! | `hybrid_kem` | Hybrid X25519 + ML-KEM-768 key encapsulation | //! | `keystore` | [`DiskKeyStore`] — OpenMLS key store with optional persistence | +mod app_message; mod error; mod group; +pub mod hybrid_crypto; pub mod hybrid_kem; mod identity; mod keypackage; @@ -22,12 +25,17 @@ pub mod opaque_auth; // ── Public API ──────────────────────────────────────────────────────────────── +pub use app_message::{ + serialize, serialize_chat, serialize_reaction, serialize_read_receipt, serialize_reply, + serialize_typing, parse, generate_message_id, AppMessage, MessageType, VERSION as APP_MESSAGE_VERSION, +}; pub use error::CoreError; pub use group::GroupMember; pub use hybrid_kem::{ hybrid_decrypt, hybrid_encrypt, HybridKemError, HybridKeypair, HybridKeypairBytes, HybridPublicKey, }; +pub use hybrid_crypto::{HybridCrypto, HybridCryptoProvider}; pub use identity::IdentityKeypair; pub use keypackage::{generate_key_package, validate_keypackage_ciphersuite}; pub use keystore::DiskKeyStore; diff --git a/crates/quicnprotochat-gui/Cargo.toml b/crates/quicnprotochat-gui/Cargo.toml new file mode 100644 index 0000000..3d3aa26 --- /dev/null +++ b/crates/quicnprotochat-gui/Cargo.toml @@ -0,0 +1,22 @@ +[package] +name = "quicnprotochat-gui" +version = "0.1.0" +edition = "2021" +description = "Native GUI for quicnprotochat (Tauri 2)." +license = "MIT" + +[[bin]] +name = "quicnprotochat-gui" +path = "src/main.rs" + +[dependencies] +quicnprotochat-core = { path = "../quicnprotochat-core" } +quicnprotochat-client = { path = "../quicnprotochat-client" } +quicnprotochat-proto = { path = "../quicnprotochat-proto" } +tauri = { version = "2", features = [] } +tokio = { workspace = true } +serde = { workspace = true } +serde_json = { workspace = true } + +[build-dependencies] +tauri-build = "2" diff --git a/crates/quicnprotochat-gui/README.md b/crates/quicnprotochat-gui/README.md new file mode 100644 index 0000000..6df344e --- /dev/null +++ b/crates/quicnprotochat-gui/README.md @@ -0,0 +1,32 @@ +# quicnprotochat-gui + +Native GUI for quicnprotochat 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` 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 quicnprotochat-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. diff --git a/crates/quicnprotochat-gui/build.rs b/crates/quicnprotochat-gui/build.rs new file mode 100644 index 0000000..d860e1e --- /dev/null +++ b/crates/quicnprotochat-gui/build.rs @@ -0,0 +1,3 @@ +fn main() { + tauri_build::build() +} diff --git a/crates/quicnprotochat-gui/capabilities/default.json b/crates/quicnprotochat-gui/capabilities/default.json new file mode 100644 index 0000000..edd2cd2 --- /dev/null +++ b/crates/quicnprotochat-gui/capabilities/default.json @@ -0,0 +1,11 @@ +{ + "$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" + ] +} diff --git a/crates/quicnprotochat-gui/gen/schemas/acl-manifests.json b/crates/quicnprotochat-gui/gen/schemas/acl-manifests.json new file mode 100644 index 0000000..43da9ef --- /dev/null +++ b/crates/quicnprotochat-gui/gen/schemas/acl-manifests.json @@ -0,0 +1 @@ +{"core":{"default_permission":{"identifier":"default","description":"Default core plugins set.","permissions":["core:path:default","core:event:default","core:window:default","core:webview:default","core:app:default","core:image:default","core:resources:default","core:menu:default","core:tray:default"]},"permissions":{},"permission_sets":{},"global_scope_schema":null},"core:app":{"default_permission":{"identifier":"default","description":"Default permissions for the plugin.","permissions":["allow-version","allow-name","allow-tauri-version","allow-identifier","allow-bundle-type","allow-register-listener","allow-remove-listener"]},"permissions":{"allow-app-hide":{"identifier":"allow-app-hide","description":"Enables the app_hide command without any pre-configured scope.","commands":{"allow":["app_hide"],"deny":[]}},"allow-app-show":{"identifier":"allow-app-show","description":"Enables the app_show command without any pre-configured scope.","commands":{"allow":["app_show"],"deny":[]}},"allow-bundle-type":{"identifier":"allow-bundle-type","description":"Enables the bundle_type command without any pre-configured scope.","commands":{"allow":["bundle_type"],"deny":[]}},"allow-default-window-icon":{"identifier":"allow-default-window-icon","description":"Enables the default_window_icon command without any pre-configured scope.","commands":{"allow":["default_window_icon"],"deny":[]}},"allow-fetch-data-store-identifiers":{"identifier":"allow-fetch-data-store-identifiers","description":"Enables the fetch_data_store_identifiers command without any pre-configured scope.","commands":{"allow":["fetch_data_store_identifiers"],"deny":[]}},"allow-identifier":{"identifier":"allow-identifier","description":"Enables the identifier command without any pre-configured scope.","commands":{"allow":["identifier"],"deny":[]}},"allow-name":{"identifier":"allow-name","description":"Enables the name command without any pre-configured scope.","commands":{"allow":["name"],"deny":[]}},"allow-register-listener":{"identifier":"allow-register-listener","description":"Enables the register_listener command without any pre-configured scope.","commands":{"allow":["register_listener"],"deny":[]}},"allow-remove-data-store":{"identifier":"allow-remove-data-store","description":"Enables the remove_data_store command without any pre-configured scope.","commands":{"allow":["remove_data_store"],"deny":[]}},"allow-remove-listener":{"identifier":"allow-remove-listener","description":"Enables the remove_listener command without any pre-configured scope.","commands":{"allow":["remove_listener"],"deny":[]}},"allow-set-app-theme":{"identifier":"allow-set-app-theme","description":"Enables the set_app_theme command without any pre-configured scope.","commands":{"allow":["set_app_theme"],"deny":[]}},"allow-set-dock-visibility":{"identifier":"allow-set-dock-visibility","description":"Enables the set_dock_visibility command without any pre-configured scope.","commands":{"allow":["set_dock_visibility"],"deny":[]}},"allow-tauri-version":{"identifier":"allow-tauri-version","description":"Enables the tauri_version command without any pre-configured scope.","commands":{"allow":["tauri_version"],"deny":[]}},"allow-version":{"identifier":"allow-version","description":"Enables the version command without any pre-configured scope.","commands":{"allow":["version"],"deny":[]}},"deny-app-hide":{"identifier":"deny-app-hide","description":"Denies the app_hide command without any pre-configured scope.","commands":{"allow":[],"deny":["app_hide"]}},"deny-app-show":{"identifier":"deny-app-show","description":"Denies the app_show command without any pre-configured scope.","commands":{"allow":[],"deny":["app_show"]}},"deny-bundle-type":{"identifier":"deny-bundle-type","description":"Denies the bundle_type command without any pre-configured scope.","commands":{"allow":[],"deny":["bundle_type"]}},"deny-default-window-icon":{"identifier":"deny-default-window-icon","description":"Denies the default_window_icon command without any pre-configured scope.","commands":{"allow":[],"deny":["default_window_icon"]}},"deny-fetch-data-store-identifiers":{"identifier":"deny-fetch-data-store-identifiers","description":"Denies the fetch_data_store_identifiers command without any pre-configured scope.","commands":{"allow":[],"deny":["fetch_data_store_identifiers"]}},"deny-identifier":{"identifier":"deny-identifier","description":"Denies the identifier command without any pre-configured scope.","commands":{"allow":[],"deny":["identifier"]}},"deny-name":{"identifier":"deny-name","description":"Denies the name command without any pre-configured scope.","commands":{"allow":[],"deny":["name"]}},"deny-register-listener":{"identifier":"deny-register-listener","description":"Denies the register_listener command without any pre-configured scope.","commands":{"allow":[],"deny":["register_listener"]}},"deny-remove-data-store":{"identifier":"deny-remove-data-store","description":"Denies the remove_data_store command without any pre-configured scope.","commands":{"allow":[],"deny":["remove_data_store"]}},"deny-remove-listener":{"identifier":"deny-remove-listener","description":"Denies the remove_listener command without any pre-configured scope.","commands":{"allow":[],"deny":["remove_listener"]}},"deny-set-app-theme":{"identifier":"deny-set-app-theme","description":"Denies the set_app_theme command without any pre-configured scope.","commands":{"allow":[],"deny":["set_app_theme"]}},"deny-set-dock-visibility":{"identifier":"deny-set-dock-visibility","description":"Denies the set_dock_visibility command without any pre-configured scope.","commands":{"allow":[],"deny":["set_dock_visibility"]}},"deny-tauri-version":{"identifier":"deny-tauri-version","description":"Denies the tauri_version command without any pre-configured scope.","commands":{"allow":[],"deny":["tauri_version"]}},"deny-version":{"identifier":"deny-version","description":"Denies the version command without any pre-configured scope.","commands":{"allow":[],"deny":["version"]}}},"permission_sets":{},"global_scope_schema":null},"core:event":{"default_permission":{"identifier":"default","description":"Default permissions for the plugin, which enables all commands.","permissions":["allow-listen","allow-unlisten","allow-emit","allow-emit-to"]},"permissions":{"allow-emit":{"identifier":"allow-emit","description":"Enables the emit command without any pre-configured scope.","commands":{"allow":["emit"],"deny":[]}},"allow-emit-to":{"identifier":"allow-emit-to","description":"Enables the emit_to command without any pre-configured scope.","commands":{"allow":["emit_to"],"deny":[]}},"allow-listen":{"identifier":"allow-listen","description":"Enables the listen command without any pre-configured scope.","commands":{"allow":["listen"],"deny":[]}},"allow-unlisten":{"identifier":"allow-unlisten","description":"Enables the unlisten command without any pre-configured scope.","commands":{"allow":["unlisten"],"deny":[]}},"deny-emit":{"identifier":"deny-emit","description":"Denies the emit command without any pre-configured scope.","commands":{"allow":[],"deny":["emit"]}},"deny-emit-to":{"identifier":"deny-emit-to","description":"Denies the emit_to command without any pre-configured scope.","commands":{"allow":[],"deny":["emit_to"]}},"deny-listen":{"identifier":"deny-listen","description":"Denies the listen command without any pre-configured scope.","commands":{"allow":[],"deny":["listen"]}},"deny-unlisten":{"identifier":"deny-unlisten","description":"Denies the unlisten command without any pre-configured scope.","commands":{"allow":[],"deny":["unlisten"]}}},"permission_sets":{},"global_scope_schema":null},"core:image":{"default_permission":{"identifier":"default","description":"Default permissions for the plugin, which enables all commands.","permissions":["allow-new","allow-from-bytes","allow-from-path","allow-rgba","allow-size"]},"permissions":{"allow-from-bytes":{"identifier":"allow-from-bytes","description":"Enables the from_bytes command without any pre-configured scope.","commands":{"allow":["from_bytes"],"deny":[]}},"allow-from-path":{"identifier":"allow-from-path","description":"Enables the from_path command without any pre-configured scope.","commands":{"allow":["from_path"],"deny":[]}},"allow-new":{"identifier":"allow-new","description":"Enables the new command without any pre-configured scope.","commands":{"allow":["new"],"deny":[]}},"allow-rgba":{"identifier":"allow-rgba","description":"Enables the rgba command without any pre-configured scope.","commands":{"allow":["rgba"],"deny":[]}},"allow-size":{"identifier":"allow-size","description":"Enables the size command without any pre-configured scope.","commands":{"allow":["size"],"deny":[]}},"deny-from-bytes":{"identifier":"deny-from-bytes","description":"Denies the from_bytes command without any pre-configured scope.","commands":{"allow":[],"deny":["from_bytes"]}},"deny-from-path":{"identifier":"deny-from-path","description":"Denies the from_path command without any pre-configured scope.","commands":{"allow":[],"deny":["from_path"]}},"deny-new":{"identifier":"deny-new","description":"Denies the new command without any pre-configured scope.","commands":{"allow":[],"deny":["new"]}},"deny-rgba":{"identifier":"deny-rgba","description":"Denies the rgba command without any pre-configured scope.","commands":{"allow":[],"deny":["rgba"]}},"deny-size":{"identifier":"deny-size","description":"Denies the size command without any pre-configured scope.","commands":{"allow":[],"deny":["size"]}}},"permission_sets":{},"global_scope_schema":null},"core:menu":{"default_permission":{"identifier":"default","description":"Default permissions for the plugin, which enables all commands.","permissions":["allow-new","allow-append","allow-prepend","allow-insert","allow-remove","allow-remove-at","allow-items","allow-get","allow-popup","allow-create-default","allow-set-as-app-menu","allow-set-as-window-menu","allow-text","allow-set-text","allow-is-enabled","allow-set-enabled","allow-set-accelerator","allow-set-as-windows-menu-for-nsapp","allow-set-as-help-menu-for-nsapp","allow-is-checked","allow-set-checked","allow-set-icon"]},"permissions":{"allow-append":{"identifier":"allow-append","description":"Enables the append command without any pre-configured scope.","commands":{"allow":["append"],"deny":[]}},"allow-create-default":{"identifier":"allow-create-default","description":"Enables the create_default command without any pre-configured scope.","commands":{"allow":["create_default"],"deny":[]}},"allow-get":{"identifier":"allow-get","description":"Enables the get command without any pre-configured scope.","commands":{"allow":["get"],"deny":[]}},"allow-insert":{"identifier":"allow-insert","description":"Enables the insert command without any pre-configured scope.","commands":{"allow":["insert"],"deny":[]}},"allow-is-checked":{"identifier":"allow-is-checked","description":"Enables the is_checked command without any pre-configured scope.","commands":{"allow":["is_checked"],"deny":[]}},"allow-is-enabled":{"identifier":"allow-is-enabled","description":"Enables the is_enabled command without any pre-configured scope.","commands":{"allow":["is_enabled"],"deny":[]}},"allow-items":{"identifier":"allow-items","description":"Enables the items command without any pre-configured scope.","commands":{"allow":["items"],"deny":[]}},"allow-new":{"identifier":"allow-new","description":"Enables the new command without any pre-configured scope.","commands":{"allow":["new"],"deny":[]}},"allow-popup":{"identifier":"allow-popup","description":"Enables the popup command without any pre-configured scope.","commands":{"allow":["popup"],"deny":[]}},"allow-prepend":{"identifier":"allow-prepend","description":"Enables the prepend command without any pre-configured scope.","commands":{"allow":["prepend"],"deny":[]}},"allow-remove":{"identifier":"allow-remove","description":"Enables the remove command without any pre-configured scope.","commands":{"allow":["remove"],"deny":[]}},"allow-remove-at":{"identifier":"allow-remove-at","description":"Enables the remove_at command without any pre-configured scope.","commands":{"allow":["remove_at"],"deny":[]}},"allow-set-accelerator":{"identifier":"allow-set-accelerator","description":"Enables the set_accelerator command without any pre-configured scope.","commands":{"allow":["set_accelerator"],"deny":[]}},"allow-set-as-app-menu":{"identifier":"allow-set-as-app-menu","description":"Enables the set_as_app_menu command without any pre-configured scope.","commands":{"allow":["set_as_app_menu"],"deny":[]}},"allow-set-as-help-menu-for-nsapp":{"identifier":"allow-set-as-help-menu-for-nsapp","description":"Enables the set_as_help_menu_for_nsapp command without any pre-configured scope.","commands":{"allow":["set_as_help_menu_for_nsapp"],"deny":[]}},"allow-set-as-window-menu":{"identifier":"allow-set-as-window-menu","description":"Enables the set_as_window_menu command without any pre-configured scope.","commands":{"allow":["set_as_window_menu"],"deny":[]}},"allow-set-as-windows-menu-for-nsapp":{"identifier":"allow-set-as-windows-menu-for-nsapp","description":"Enables the set_as_windows_menu_for_nsapp command without any pre-configured scope.","commands":{"allow":["set_as_windows_menu_for_nsapp"],"deny":[]}},"allow-set-checked":{"identifier":"allow-set-checked","description":"Enables the set_checked command without any pre-configured scope.","commands":{"allow":["set_checked"],"deny":[]}},"allow-set-enabled":{"identifier":"allow-set-enabled","description":"Enables the set_enabled command without any pre-configured scope.","commands":{"allow":["set_enabled"],"deny":[]}},"allow-set-icon":{"identifier":"allow-set-icon","description":"Enables the set_icon command without any pre-configured scope.","commands":{"allow":["set_icon"],"deny":[]}},"allow-set-text":{"identifier":"allow-set-text","description":"Enables the set_text command without any pre-configured scope.","commands":{"allow":["set_text"],"deny":[]}},"allow-text":{"identifier":"allow-text","description":"Enables the text command without any pre-configured scope.","commands":{"allow":["text"],"deny":[]}},"deny-append":{"identifier":"deny-append","description":"Denies the append command without any pre-configured scope.","commands":{"allow":[],"deny":["append"]}},"deny-create-default":{"identifier":"deny-create-default","description":"Denies the create_default command without any pre-configured scope.","commands":{"allow":[],"deny":["create_default"]}},"deny-get":{"identifier":"deny-get","description":"Denies the get command without any pre-configured scope.","commands":{"allow":[],"deny":["get"]}},"deny-insert":{"identifier":"deny-insert","description":"Denies the insert command without any pre-configured scope.","commands":{"allow":[],"deny":["insert"]}},"deny-is-checked":{"identifier":"deny-is-checked","description":"Denies the is_checked command without any pre-configured scope.","commands":{"allow":[],"deny":["is_checked"]}},"deny-is-enabled":{"identifier":"deny-is-enabled","description":"Denies the is_enabled command without any pre-configured scope.","commands":{"allow":[],"deny":["is_enabled"]}},"deny-items":{"identifier":"deny-items","description":"Denies the items command without any pre-configured scope.","commands":{"allow":[],"deny":["items"]}},"deny-new":{"identifier":"deny-new","description":"Denies the new command without any pre-configured scope.","commands":{"allow":[],"deny":["new"]}},"deny-popup":{"identifier":"deny-popup","description":"Denies the popup command without any pre-configured scope.","commands":{"allow":[],"deny":["popup"]}},"deny-prepend":{"identifier":"deny-prepend","description":"Denies the prepend command without any pre-configured scope.","commands":{"allow":[],"deny":["prepend"]}},"deny-remove":{"identifier":"deny-remove","description":"Denies the remove command without any pre-configured scope.","commands":{"allow":[],"deny":["remove"]}},"deny-remove-at":{"identifier":"deny-remove-at","description":"Denies the remove_at command without any pre-configured scope.","commands":{"allow":[],"deny":["remove_at"]}},"deny-set-accelerator":{"identifier":"deny-set-accelerator","description":"Denies the set_accelerator command without any pre-configured scope.","commands":{"allow":[],"deny":["set_accelerator"]}},"deny-set-as-app-menu":{"identifier":"deny-set-as-app-menu","description":"Denies the set_as_app_menu command without any pre-configured scope.","commands":{"allow":[],"deny":["set_as_app_menu"]}},"deny-set-as-help-menu-for-nsapp":{"identifier":"deny-set-as-help-menu-for-nsapp","description":"Denies the set_as_help_menu_for_nsapp command without any pre-configured scope.","commands":{"allow":[],"deny":["set_as_help_menu_for_nsapp"]}},"deny-set-as-window-menu":{"identifier":"deny-set-as-window-menu","description":"Denies the set_as_window_menu command without any pre-configured scope.","commands":{"allow":[],"deny":["set_as_window_menu"]}},"deny-set-as-windows-menu-for-nsapp":{"identifier":"deny-set-as-windows-menu-for-nsapp","description":"Denies the set_as_windows_menu_for_nsapp command without any pre-configured scope.","commands":{"allow":[],"deny":["set_as_windows_menu_for_nsapp"]}},"deny-set-checked":{"identifier":"deny-set-checked","description":"Denies the set_checked command without any pre-configured scope.","commands":{"allow":[],"deny":["set_checked"]}},"deny-set-enabled":{"identifier":"deny-set-enabled","description":"Denies the set_enabled command without any pre-configured scope.","commands":{"allow":[],"deny":["set_enabled"]}},"deny-set-icon":{"identifier":"deny-set-icon","description":"Denies the set_icon command without any pre-configured scope.","commands":{"allow":[],"deny":["set_icon"]}},"deny-set-text":{"identifier":"deny-set-text","description":"Denies the set_text command without any pre-configured scope.","commands":{"allow":[],"deny":["set_text"]}},"deny-text":{"identifier":"deny-text","description":"Denies the text command without any pre-configured scope.","commands":{"allow":[],"deny":["text"]}}},"permission_sets":{},"global_scope_schema":null},"core:path":{"default_permission":{"identifier":"default","description":"Default permissions for the plugin, which enables all commands.","permissions":["allow-resolve-directory","allow-resolve","allow-normalize","allow-join","allow-dirname","allow-extname","allow-basename","allow-is-absolute"]},"permissions":{"allow-basename":{"identifier":"allow-basename","description":"Enables the basename command without any pre-configured scope.","commands":{"allow":["basename"],"deny":[]}},"allow-dirname":{"identifier":"allow-dirname","description":"Enables the dirname command without any pre-configured scope.","commands":{"allow":["dirname"],"deny":[]}},"allow-extname":{"identifier":"allow-extname","description":"Enables the extname command without any pre-configured scope.","commands":{"allow":["extname"],"deny":[]}},"allow-is-absolute":{"identifier":"allow-is-absolute","description":"Enables the is_absolute command without any pre-configured scope.","commands":{"allow":["is_absolute"],"deny":[]}},"allow-join":{"identifier":"allow-join","description":"Enables the join command without any pre-configured scope.","commands":{"allow":["join"],"deny":[]}},"allow-normalize":{"identifier":"allow-normalize","description":"Enables the normalize command without any pre-configured scope.","commands":{"allow":["normalize"],"deny":[]}},"allow-resolve":{"identifier":"allow-resolve","description":"Enables the resolve command without any pre-configured scope.","commands":{"allow":["resolve"],"deny":[]}},"allow-resolve-directory":{"identifier":"allow-resolve-directory","description":"Enables the resolve_directory command without any pre-configured scope.","commands":{"allow":["resolve_directory"],"deny":[]}},"deny-basename":{"identifier":"deny-basename","description":"Denies the basename command without any pre-configured scope.","commands":{"allow":[],"deny":["basename"]}},"deny-dirname":{"identifier":"deny-dirname","description":"Denies the dirname command without any pre-configured scope.","commands":{"allow":[],"deny":["dirname"]}},"deny-extname":{"identifier":"deny-extname","description":"Denies the extname command without any pre-configured scope.","commands":{"allow":[],"deny":["extname"]}},"deny-is-absolute":{"identifier":"deny-is-absolute","description":"Denies the is_absolute command without any pre-configured scope.","commands":{"allow":[],"deny":["is_absolute"]}},"deny-join":{"identifier":"deny-join","description":"Denies the join command without any pre-configured scope.","commands":{"allow":[],"deny":["join"]}},"deny-normalize":{"identifier":"deny-normalize","description":"Denies the normalize command without any pre-configured scope.","commands":{"allow":[],"deny":["normalize"]}},"deny-resolve":{"identifier":"deny-resolve","description":"Denies the resolve command without any pre-configured scope.","commands":{"allow":[],"deny":["resolve"]}},"deny-resolve-directory":{"identifier":"deny-resolve-directory","description":"Denies the resolve_directory command without any pre-configured scope.","commands":{"allow":[],"deny":["resolve_directory"]}}},"permission_sets":{},"global_scope_schema":null},"core:resources":{"default_permission":{"identifier":"default","description":"Default permissions for the plugin, which enables all commands.","permissions":["allow-close"]},"permissions":{"allow-close":{"identifier":"allow-close","description":"Enables the close command without any pre-configured scope.","commands":{"allow":["close"],"deny":[]}},"deny-close":{"identifier":"deny-close","description":"Denies the close command without any pre-configured scope.","commands":{"allow":[],"deny":["close"]}}},"permission_sets":{},"global_scope_schema":null},"core:tray":{"default_permission":{"identifier":"default","description":"Default permissions for the plugin, which enables all commands.","permissions":["allow-new","allow-get-by-id","allow-remove-by-id","allow-set-icon","allow-set-menu","allow-set-tooltip","allow-set-title","allow-set-visible","allow-set-temp-dir-path","allow-set-icon-as-template","allow-set-show-menu-on-left-click"]},"permissions":{"allow-get-by-id":{"identifier":"allow-get-by-id","description":"Enables the get_by_id command without any pre-configured scope.","commands":{"allow":["get_by_id"],"deny":[]}},"allow-new":{"identifier":"allow-new","description":"Enables the new command without any pre-configured scope.","commands":{"allow":["new"],"deny":[]}},"allow-remove-by-id":{"identifier":"allow-remove-by-id","description":"Enables the remove_by_id command without any pre-configured scope.","commands":{"allow":["remove_by_id"],"deny":[]}},"allow-set-icon":{"identifier":"allow-set-icon","description":"Enables the set_icon command without any pre-configured scope.","commands":{"allow":["set_icon"],"deny":[]}},"allow-set-icon-as-template":{"identifier":"allow-set-icon-as-template","description":"Enables the set_icon_as_template command without any pre-configured scope.","commands":{"allow":["set_icon_as_template"],"deny":[]}},"allow-set-menu":{"identifier":"allow-set-menu","description":"Enables the set_menu command without any pre-configured scope.","commands":{"allow":["set_menu"],"deny":[]}},"allow-set-show-menu-on-left-click":{"identifier":"allow-set-show-menu-on-left-click","description":"Enables the set_show_menu_on_left_click command without any pre-configured scope.","commands":{"allow":["set_show_menu_on_left_click"],"deny":[]}},"allow-set-temp-dir-path":{"identifier":"allow-set-temp-dir-path","description":"Enables the set_temp_dir_path command without any pre-configured scope.","commands":{"allow":["set_temp_dir_path"],"deny":[]}},"allow-set-title":{"identifier":"allow-set-title","description":"Enables the set_title command without any pre-configured scope.","commands":{"allow":["set_title"],"deny":[]}},"allow-set-tooltip":{"identifier":"allow-set-tooltip","description":"Enables the set_tooltip command without any pre-configured scope.","commands":{"allow":["set_tooltip"],"deny":[]}},"allow-set-visible":{"identifier":"allow-set-visible","description":"Enables the set_visible command without any pre-configured scope.","commands":{"allow":["set_visible"],"deny":[]}},"deny-get-by-id":{"identifier":"deny-get-by-id","description":"Denies the get_by_id command without any pre-configured scope.","commands":{"allow":[],"deny":["get_by_id"]}},"deny-new":{"identifier":"deny-new","description":"Denies the new command without any pre-configured scope.","commands":{"allow":[],"deny":["new"]}},"deny-remove-by-id":{"identifier":"deny-remove-by-id","description":"Denies the remove_by_id command without any pre-configured scope.","commands":{"allow":[],"deny":["remove_by_id"]}},"deny-set-icon":{"identifier":"deny-set-icon","description":"Denies the set_icon command without any pre-configured scope.","commands":{"allow":[],"deny":["set_icon"]}},"deny-set-icon-as-template":{"identifier":"deny-set-icon-as-template","description":"Denies the set_icon_as_template command without any pre-configured scope.","commands":{"allow":[],"deny":["set_icon_as_template"]}},"deny-set-menu":{"identifier":"deny-set-menu","description":"Denies the set_menu command without any pre-configured scope.","commands":{"allow":[],"deny":["set_menu"]}},"deny-set-show-menu-on-left-click":{"identifier":"deny-set-show-menu-on-left-click","description":"Denies the set_show_menu_on_left_click command without any pre-configured scope.","commands":{"allow":[],"deny":["set_show_menu_on_left_click"]}},"deny-set-temp-dir-path":{"identifier":"deny-set-temp-dir-path","description":"Denies the set_temp_dir_path command without any pre-configured scope.","commands":{"allow":[],"deny":["set_temp_dir_path"]}},"deny-set-title":{"identifier":"deny-set-title","description":"Denies the set_title command without any pre-configured scope.","commands":{"allow":[],"deny":["set_title"]}},"deny-set-tooltip":{"identifier":"deny-set-tooltip","description":"Denies the set_tooltip command without any pre-configured scope.","commands":{"allow":[],"deny":["set_tooltip"]}},"deny-set-visible":{"identifier":"deny-set-visible","description":"Denies the set_visible command without any pre-configured scope.","commands":{"allow":[],"deny":["set_visible"]}}},"permission_sets":{},"global_scope_schema":null},"core:webview":{"default_permission":{"identifier":"default","description":"Default permissions for the plugin.","permissions":["allow-get-all-webviews","allow-webview-position","allow-webview-size","allow-internal-toggle-devtools"]},"permissions":{"allow-clear-all-browsing-data":{"identifier":"allow-clear-all-browsing-data","description":"Enables the clear_all_browsing_data command without any pre-configured scope.","commands":{"allow":["clear_all_browsing_data"],"deny":[]}},"allow-create-webview":{"identifier":"allow-create-webview","description":"Enables the create_webview command without any pre-configured scope.","commands":{"allow":["create_webview"],"deny":[]}},"allow-create-webview-window":{"identifier":"allow-create-webview-window","description":"Enables the create_webview_window command without any pre-configured scope.","commands":{"allow":["create_webview_window"],"deny":[]}},"allow-get-all-webviews":{"identifier":"allow-get-all-webviews","description":"Enables the get_all_webviews command without any pre-configured scope.","commands":{"allow":["get_all_webviews"],"deny":[]}},"allow-internal-toggle-devtools":{"identifier":"allow-internal-toggle-devtools","description":"Enables the internal_toggle_devtools command without any pre-configured scope.","commands":{"allow":["internal_toggle_devtools"],"deny":[]}},"allow-print":{"identifier":"allow-print","description":"Enables the print command without any pre-configured scope.","commands":{"allow":["print"],"deny":[]}},"allow-reparent":{"identifier":"allow-reparent","description":"Enables the reparent command without any pre-configured scope.","commands":{"allow":["reparent"],"deny":[]}},"allow-set-webview-auto-resize":{"identifier":"allow-set-webview-auto-resize","description":"Enables the set_webview_auto_resize command without any pre-configured scope.","commands":{"allow":["set_webview_auto_resize"],"deny":[]}},"allow-set-webview-background-color":{"identifier":"allow-set-webview-background-color","description":"Enables the set_webview_background_color command without any pre-configured scope.","commands":{"allow":["set_webview_background_color"],"deny":[]}},"allow-set-webview-focus":{"identifier":"allow-set-webview-focus","description":"Enables the set_webview_focus command without any pre-configured scope.","commands":{"allow":["set_webview_focus"],"deny":[]}},"allow-set-webview-position":{"identifier":"allow-set-webview-position","description":"Enables the set_webview_position command without any pre-configured scope.","commands":{"allow":["set_webview_position"],"deny":[]}},"allow-set-webview-size":{"identifier":"allow-set-webview-size","description":"Enables the set_webview_size command without any pre-configured scope.","commands":{"allow":["set_webview_size"],"deny":[]}},"allow-set-webview-zoom":{"identifier":"allow-set-webview-zoom","description":"Enables the set_webview_zoom command without any pre-configured scope.","commands":{"allow":["set_webview_zoom"],"deny":[]}},"allow-webview-close":{"identifier":"allow-webview-close","description":"Enables the webview_close command without any pre-configured scope.","commands":{"allow":["webview_close"],"deny":[]}},"allow-webview-hide":{"identifier":"allow-webview-hide","description":"Enables the webview_hide command without any pre-configured scope.","commands":{"allow":["webview_hide"],"deny":[]}},"allow-webview-position":{"identifier":"allow-webview-position","description":"Enables the webview_position command without any pre-configured scope.","commands":{"allow":["webview_position"],"deny":[]}},"allow-webview-show":{"identifier":"allow-webview-show","description":"Enables the webview_show command without any pre-configured scope.","commands":{"allow":["webview_show"],"deny":[]}},"allow-webview-size":{"identifier":"allow-webview-size","description":"Enables the webview_size command without any pre-configured scope.","commands":{"allow":["webview_size"],"deny":[]}},"deny-clear-all-browsing-data":{"identifier":"deny-clear-all-browsing-data","description":"Denies the clear_all_browsing_data command without any pre-configured scope.","commands":{"allow":[],"deny":["clear_all_browsing_data"]}},"deny-create-webview":{"identifier":"deny-create-webview","description":"Denies the create_webview command without any pre-configured scope.","commands":{"allow":[],"deny":["create_webview"]}},"deny-create-webview-window":{"identifier":"deny-create-webview-window","description":"Denies the create_webview_window command without any pre-configured scope.","commands":{"allow":[],"deny":["create_webview_window"]}},"deny-get-all-webviews":{"identifier":"deny-get-all-webviews","description":"Denies the get_all_webviews command without any pre-configured scope.","commands":{"allow":[],"deny":["get_all_webviews"]}},"deny-internal-toggle-devtools":{"identifier":"deny-internal-toggle-devtools","description":"Denies the internal_toggle_devtools command without any pre-configured scope.","commands":{"allow":[],"deny":["internal_toggle_devtools"]}},"deny-print":{"identifier":"deny-print","description":"Denies the print command without any pre-configured scope.","commands":{"allow":[],"deny":["print"]}},"deny-reparent":{"identifier":"deny-reparent","description":"Denies the reparent command without any pre-configured scope.","commands":{"allow":[],"deny":["reparent"]}},"deny-set-webview-auto-resize":{"identifier":"deny-set-webview-auto-resize","description":"Denies the set_webview_auto_resize command without any pre-configured scope.","commands":{"allow":[],"deny":["set_webview_auto_resize"]}},"deny-set-webview-background-color":{"identifier":"deny-set-webview-background-color","description":"Denies the set_webview_background_color command without any pre-configured scope.","commands":{"allow":[],"deny":["set_webview_background_color"]}},"deny-set-webview-focus":{"identifier":"deny-set-webview-focus","description":"Denies the set_webview_focus command without any pre-configured scope.","commands":{"allow":[],"deny":["set_webview_focus"]}},"deny-set-webview-position":{"identifier":"deny-set-webview-position","description":"Denies the set_webview_position command without any pre-configured scope.","commands":{"allow":[],"deny":["set_webview_position"]}},"deny-set-webview-size":{"identifier":"deny-set-webview-size","description":"Denies the set_webview_size command without any pre-configured scope.","commands":{"allow":[],"deny":["set_webview_size"]}},"deny-set-webview-zoom":{"identifier":"deny-set-webview-zoom","description":"Denies the set_webview_zoom command without any pre-configured scope.","commands":{"allow":[],"deny":["set_webview_zoom"]}},"deny-webview-close":{"identifier":"deny-webview-close","description":"Denies the webview_close command without any pre-configured scope.","commands":{"allow":[],"deny":["webview_close"]}},"deny-webview-hide":{"identifier":"deny-webview-hide","description":"Denies the webview_hide command without any pre-configured scope.","commands":{"allow":[],"deny":["webview_hide"]}},"deny-webview-position":{"identifier":"deny-webview-position","description":"Denies the webview_position command without any pre-configured scope.","commands":{"allow":[],"deny":["webview_position"]}},"deny-webview-show":{"identifier":"deny-webview-show","description":"Denies the webview_show command without any pre-configured scope.","commands":{"allow":[],"deny":["webview_show"]}},"deny-webview-size":{"identifier":"deny-webview-size","description":"Denies the webview_size command without any pre-configured scope.","commands":{"allow":[],"deny":["webview_size"]}}},"permission_sets":{},"global_scope_schema":null},"core:window":{"default_permission":{"identifier":"default","description":"Default permissions for the plugin.","permissions":["allow-get-all-windows","allow-scale-factor","allow-inner-position","allow-outer-position","allow-inner-size","allow-outer-size","allow-is-fullscreen","allow-is-minimized","allow-is-maximized","allow-is-focused","allow-is-decorated","allow-is-resizable","allow-is-maximizable","allow-is-minimizable","allow-is-closable","allow-is-visible","allow-is-enabled","allow-title","allow-current-monitor","allow-primary-monitor","allow-monitor-from-point","allow-available-monitors","allow-cursor-position","allow-theme","allow-is-always-on-top","allow-internal-toggle-maximize"]},"permissions":{"allow-available-monitors":{"identifier":"allow-available-monitors","description":"Enables the available_monitors command without any pre-configured scope.","commands":{"allow":["available_monitors"],"deny":[]}},"allow-center":{"identifier":"allow-center","description":"Enables the center command without any pre-configured scope.","commands":{"allow":["center"],"deny":[]}},"allow-close":{"identifier":"allow-close","description":"Enables the close command without any pre-configured scope.","commands":{"allow":["close"],"deny":[]}},"allow-create":{"identifier":"allow-create","description":"Enables the create command without any pre-configured scope.","commands":{"allow":["create"],"deny":[]}},"allow-current-monitor":{"identifier":"allow-current-monitor","description":"Enables the current_monitor command without any pre-configured scope.","commands":{"allow":["current_monitor"],"deny":[]}},"allow-cursor-position":{"identifier":"allow-cursor-position","description":"Enables the cursor_position command without any pre-configured scope.","commands":{"allow":["cursor_position"],"deny":[]}},"allow-destroy":{"identifier":"allow-destroy","description":"Enables the destroy command without any pre-configured scope.","commands":{"allow":["destroy"],"deny":[]}},"allow-get-all-windows":{"identifier":"allow-get-all-windows","description":"Enables the get_all_windows command without any pre-configured scope.","commands":{"allow":["get_all_windows"],"deny":[]}},"allow-hide":{"identifier":"allow-hide","description":"Enables the hide command without any pre-configured scope.","commands":{"allow":["hide"],"deny":[]}},"allow-inner-position":{"identifier":"allow-inner-position","description":"Enables the inner_position command without any pre-configured scope.","commands":{"allow":["inner_position"],"deny":[]}},"allow-inner-size":{"identifier":"allow-inner-size","description":"Enables the inner_size command without any pre-configured scope.","commands":{"allow":["inner_size"],"deny":[]}},"allow-internal-toggle-maximize":{"identifier":"allow-internal-toggle-maximize","description":"Enables the internal_toggle_maximize command without any pre-configured scope.","commands":{"allow":["internal_toggle_maximize"],"deny":[]}},"allow-is-always-on-top":{"identifier":"allow-is-always-on-top","description":"Enables the is_always_on_top command without any pre-configured scope.","commands":{"allow":["is_always_on_top"],"deny":[]}},"allow-is-closable":{"identifier":"allow-is-closable","description":"Enables the is_closable command without any pre-configured scope.","commands":{"allow":["is_closable"],"deny":[]}},"allow-is-decorated":{"identifier":"allow-is-decorated","description":"Enables the is_decorated command without any pre-configured scope.","commands":{"allow":["is_decorated"],"deny":[]}},"allow-is-enabled":{"identifier":"allow-is-enabled","description":"Enables the is_enabled command without any pre-configured scope.","commands":{"allow":["is_enabled"],"deny":[]}},"allow-is-focused":{"identifier":"allow-is-focused","description":"Enables the is_focused command without any pre-configured scope.","commands":{"allow":["is_focused"],"deny":[]}},"allow-is-fullscreen":{"identifier":"allow-is-fullscreen","description":"Enables the is_fullscreen command without any pre-configured scope.","commands":{"allow":["is_fullscreen"],"deny":[]}},"allow-is-maximizable":{"identifier":"allow-is-maximizable","description":"Enables the is_maximizable command without any pre-configured scope.","commands":{"allow":["is_maximizable"],"deny":[]}},"allow-is-maximized":{"identifier":"allow-is-maximized","description":"Enables the is_maximized command without any pre-configured scope.","commands":{"allow":["is_maximized"],"deny":[]}},"allow-is-minimizable":{"identifier":"allow-is-minimizable","description":"Enables the is_minimizable command without any pre-configured scope.","commands":{"allow":["is_minimizable"],"deny":[]}},"allow-is-minimized":{"identifier":"allow-is-minimized","description":"Enables the is_minimized command without any pre-configured scope.","commands":{"allow":["is_minimized"],"deny":[]}},"allow-is-resizable":{"identifier":"allow-is-resizable","description":"Enables the is_resizable command without any pre-configured scope.","commands":{"allow":["is_resizable"],"deny":[]}},"allow-is-visible":{"identifier":"allow-is-visible","description":"Enables the is_visible command without any pre-configured scope.","commands":{"allow":["is_visible"],"deny":[]}},"allow-maximize":{"identifier":"allow-maximize","description":"Enables the maximize command without any pre-configured scope.","commands":{"allow":["maximize"],"deny":[]}},"allow-minimize":{"identifier":"allow-minimize","description":"Enables the minimize command without any pre-configured scope.","commands":{"allow":["minimize"],"deny":[]}},"allow-monitor-from-point":{"identifier":"allow-monitor-from-point","description":"Enables the monitor_from_point command without any pre-configured scope.","commands":{"allow":["monitor_from_point"],"deny":[]}},"allow-outer-position":{"identifier":"allow-outer-position","description":"Enables the outer_position command without any pre-configured scope.","commands":{"allow":["outer_position"],"deny":[]}},"allow-outer-size":{"identifier":"allow-outer-size","description":"Enables the outer_size command without any pre-configured scope.","commands":{"allow":["outer_size"],"deny":[]}},"allow-primary-monitor":{"identifier":"allow-primary-monitor","description":"Enables the primary_monitor command without any pre-configured scope.","commands":{"allow":["primary_monitor"],"deny":[]}},"allow-request-user-attention":{"identifier":"allow-request-user-attention","description":"Enables the request_user_attention command without any pre-configured scope.","commands":{"allow":["request_user_attention"],"deny":[]}},"allow-scale-factor":{"identifier":"allow-scale-factor","description":"Enables the scale_factor command without any pre-configured scope.","commands":{"allow":["scale_factor"],"deny":[]}},"allow-set-always-on-bottom":{"identifier":"allow-set-always-on-bottom","description":"Enables the set_always_on_bottom command without any pre-configured scope.","commands":{"allow":["set_always_on_bottom"],"deny":[]}},"allow-set-always-on-top":{"identifier":"allow-set-always-on-top","description":"Enables the set_always_on_top command without any pre-configured scope.","commands":{"allow":["set_always_on_top"],"deny":[]}},"allow-set-background-color":{"identifier":"allow-set-background-color","description":"Enables the set_background_color command without any pre-configured scope.","commands":{"allow":["set_background_color"],"deny":[]}},"allow-set-badge-count":{"identifier":"allow-set-badge-count","description":"Enables the set_badge_count command without any pre-configured scope.","commands":{"allow":["set_badge_count"],"deny":[]}},"allow-set-badge-label":{"identifier":"allow-set-badge-label","description":"Enables the set_badge_label command without any pre-configured scope.","commands":{"allow":["set_badge_label"],"deny":[]}},"allow-set-closable":{"identifier":"allow-set-closable","description":"Enables the set_closable command without any pre-configured scope.","commands":{"allow":["set_closable"],"deny":[]}},"allow-set-content-protected":{"identifier":"allow-set-content-protected","description":"Enables the set_content_protected command without any pre-configured scope.","commands":{"allow":["set_content_protected"],"deny":[]}},"allow-set-cursor-grab":{"identifier":"allow-set-cursor-grab","description":"Enables the set_cursor_grab command without any pre-configured scope.","commands":{"allow":["set_cursor_grab"],"deny":[]}},"allow-set-cursor-icon":{"identifier":"allow-set-cursor-icon","description":"Enables the set_cursor_icon command without any pre-configured scope.","commands":{"allow":["set_cursor_icon"],"deny":[]}},"allow-set-cursor-position":{"identifier":"allow-set-cursor-position","description":"Enables the set_cursor_position command without any pre-configured scope.","commands":{"allow":["set_cursor_position"],"deny":[]}},"allow-set-cursor-visible":{"identifier":"allow-set-cursor-visible","description":"Enables the set_cursor_visible command without any pre-configured scope.","commands":{"allow":["set_cursor_visible"],"deny":[]}},"allow-set-decorations":{"identifier":"allow-set-decorations","description":"Enables the set_decorations command without any pre-configured scope.","commands":{"allow":["set_decorations"],"deny":[]}},"allow-set-effects":{"identifier":"allow-set-effects","description":"Enables the set_effects command without any pre-configured scope.","commands":{"allow":["set_effects"],"deny":[]}},"allow-set-enabled":{"identifier":"allow-set-enabled","description":"Enables the set_enabled command without any pre-configured scope.","commands":{"allow":["set_enabled"],"deny":[]}},"allow-set-focus":{"identifier":"allow-set-focus","description":"Enables the set_focus command without any pre-configured scope.","commands":{"allow":["set_focus"],"deny":[]}},"allow-set-focusable":{"identifier":"allow-set-focusable","description":"Enables the set_focusable command without any pre-configured scope.","commands":{"allow":["set_focusable"],"deny":[]}},"allow-set-fullscreen":{"identifier":"allow-set-fullscreen","description":"Enables the set_fullscreen command without any pre-configured scope.","commands":{"allow":["set_fullscreen"],"deny":[]}},"allow-set-icon":{"identifier":"allow-set-icon","description":"Enables the set_icon command without any pre-configured scope.","commands":{"allow":["set_icon"],"deny":[]}},"allow-set-ignore-cursor-events":{"identifier":"allow-set-ignore-cursor-events","description":"Enables the set_ignore_cursor_events command without any pre-configured scope.","commands":{"allow":["set_ignore_cursor_events"],"deny":[]}},"allow-set-max-size":{"identifier":"allow-set-max-size","description":"Enables the set_max_size command without any pre-configured scope.","commands":{"allow":["set_max_size"],"deny":[]}},"allow-set-maximizable":{"identifier":"allow-set-maximizable","description":"Enables the set_maximizable command without any pre-configured scope.","commands":{"allow":["set_maximizable"],"deny":[]}},"allow-set-min-size":{"identifier":"allow-set-min-size","description":"Enables the set_min_size command without any pre-configured scope.","commands":{"allow":["set_min_size"],"deny":[]}},"allow-set-minimizable":{"identifier":"allow-set-minimizable","description":"Enables the set_minimizable command without any pre-configured scope.","commands":{"allow":["set_minimizable"],"deny":[]}},"allow-set-overlay-icon":{"identifier":"allow-set-overlay-icon","description":"Enables the set_overlay_icon command without any pre-configured scope.","commands":{"allow":["set_overlay_icon"],"deny":[]}},"allow-set-position":{"identifier":"allow-set-position","description":"Enables the set_position command without any pre-configured scope.","commands":{"allow":["set_position"],"deny":[]}},"allow-set-progress-bar":{"identifier":"allow-set-progress-bar","description":"Enables the set_progress_bar command without any pre-configured scope.","commands":{"allow":["set_progress_bar"],"deny":[]}},"allow-set-resizable":{"identifier":"allow-set-resizable","description":"Enables the set_resizable command without any pre-configured scope.","commands":{"allow":["set_resizable"],"deny":[]}},"allow-set-shadow":{"identifier":"allow-set-shadow","description":"Enables the set_shadow command without any pre-configured scope.","commands":{"allow":["set_shadow"],"deny":[]}},"allow-set-simple-fullscreen":{"identifier":"allow-set-simple-fullscreen","description":"Enables the set_simple_fullscreen command without any pre-configured scope.","commands":{"allow":["set_simple_fullscreen"],"deny":[]}},"allow-set-size":{"identifier":"allow-set-size","description":"Enables the set_size command without any pre-configured scope.","commands":{"allow":["set_size"],"deny":[]}},"allow-set-size-constraints":{"identifier":"allow-set-size-constraints","description":"Enables the set_size_constraints command without any pre-configured scope.","commands":{"allow":["set_size_constraints"],"deny":[]}},"allow-set-skip-taskbar":{"identifier":"allow-set-skip-taskbar","description":"Enables the set_skip_taskbar command without any pre-configured scope.","commands":{"allow":["set_skip_taskbar"],"deny":[]}},"allow-set-theme":{"identifier":"allow-set-theme","description":"Enables the set_theme command without any pre-configured scope.","commands":{"allow":["set_theme"],"deny":[]}},"allow-set-title":{"identifier":"allow-set-title","description":"Enables the set_title command without any pre-configured scope.","commands":{"allow":["set_title"],"deny":[]}},"allow-set-title-bar-style":{"identifier":"allow-set-title-bar-style","description":"Enables the set_title_bar_style command without any pre-configured scope.","commands":{"allow":["set_title_bar_style"],"deny":[]}},"allow-set-visible-on-all-workspaces":{"identifier":"allow-set-visible-on-all-workspaces","description":"Enables the set_visible_on_all_workspaces command without any pre-configured scope.","commands":{"allow":["set_visible_on_all_workspaces"],"deny":[]}},"allow-show":{"identifier":"allow-show","description":"Enables the show command without any pre-configured scope.","commands":{"allow":["show"],"deny":[]}},"allow-start-dragging":{"identifier":"allow-start-dragging","description":"Enables the start_dragging command without any pre-configured scope.","commands":{"allow":["start_dragging"],"deny":[]}},"allow-start-resize-dragging":{"identifier":"allow-start-resize-dragging","description":"Enables the start_resize_dragging command without any pre-configured scope.","commands":{"allow":["start_resize_dragging"],"deny":[]}},"allow-theme":{"identifier":"allow-theme","description":"Enables the theme command without any pre-configured scope.","commands":{"allow":["theme"],"deny":[]}},"allow-title":{"identifier":"allow-title","description":"Enables the title command without any pre-configured scope.","commands":{"allow":["title"],"deny":[]}},"allow-toggle-maximize":{"identifier":"allow-toggle-maximize","description":"Enables the toggle_maximize command without any pre-configured scope.","commands":{"allow":["toggle_maximize"],"deny":[]}},"allow-unmaximize":{"identifier":"allow-unmaximize","description":"Enables the unmaximize command without any pre-configured scope.","commands":{"allow":["unmaximize"],"deny":[]}},"allow-unminimize":{"identifier":"allow-unminimize","description":"Enables the unminimize command without any pre-configured scope.","commands":{"allow":["unminimize"],"deny":[]}},"deny-available-monitors":{"identifier":"deny-available-monitors","description":"Denies the available_monitors command without any pre-configured scope.","commands":{"allow":[],"deny":["available_monitors"]}},"deny-center":{"identifier":"deny-center","description":"Denies the center command without any pre-configured scope.","commands":{"allow":[],"deny":["center"]}},"deny-close":{"identifier":"deny-close","description":"Denies the close command without any pre-configured scope.","commands":{"allow":[],"deny":["close"]}},"deny-create":{"identifier":"deny-create","description":"Denies the create command without any pre-configured scope.","commands":{"allow":[],"deny":["create"]}},"deny-current-monitor":{"identifier":"deny-current-monitor","description":"Denies the current_monitor command without any pre-configured scope.","commands":{"allow":[],"deny":["current_monitor"]}},"deny-cursor-position":{"identifier":"deny-cursor-position","description":"Denies the cursor_position command without any pre-configured scope.","commands":{"allow":[],"deny":["cursor_position"]}},"deny-destroy":{"identifier":"deny-destroy","description":"Denies the destroy command without any pre-configured scope.","commands":{"allow":[],"deny":["destroy"]}},"deny-get-all-windows":{"identifier":"deny-get-all-windows","description":"Denies the get_all_windows command without any pre-configured scope.","commands":{"allow":[],"deny":["get_all_windows"]}},"deny-hide":{"identifier":"deny-hide","description":"Denies the hide command without any pre-configured scope.","commands":{"allow":[],"deny":["hide"]}},"deny-inner-position":{"identifier":"deny-inner-position","description":"Denies the inner_position command without any pre-configured scope.","commands":{"allow":[],"deny":["inner_position"]}},"deny-inner-size":{"identifier":"deny-inner-size","description":"Denies the inner_size command without any pre-configured scope.","commands":{"allow":[],"deny":["inner_size"]}},"deny-internal-toggle-maximize":{"identifier":"deny-internal-toggle-maximize","description":"Denies the internal_toggle_maximize command without any pre-configured scope.","commands":{"allow":[],"deny":["internal_toggle_maximize"]}},"deny-is-always-on-top":{"identifier":"deny-is-always-on-top","description":"Denies the is_always_on_top command without any pre-configured scope.","commands":{"allow":[],"deny":["is_always_on_top"]}},"deny-is-closable":{"identifier":"deny-is-closable","description":"Denies the is_closable command without any pre-configured scope.","commands":{"allow":[],"deny":["is_closable"]}},"deny-is-decorated":{"identifier":"deny-is-decorated","description":"Denies the is_decorated command without any pre-configured scope.","commands":{"allow":[],"deny":["is_decorated"]}},"deny-is-enabled":{"identifier":"deny-is-enabled","description":"Denies the is_enabled command without any pre-configured scope.","commands":{"allow":[],"deny":["is_enabled"]}},"deny-is-focused":{"identifier":"deny-is-focused","description":"Denies the is_focused command without any pre-configured scope.","commands":{"allow":[],"deny":["is_focused"]}},"deny-is-fullscreen":{"identifier":"deny-is-fullscreen","description":"Denies the is_fullscreen command without any pre-configured scope.","commands":{"allow":[],"deny":["is_fullscreen"]}},"deny-is-maximizable":{"identifier":"deny-is-maximizable","description":"Denies the is_maximizable command without any pre-configured scope.","commands":{"allow":[],"deny":["is_maximizable"]}},"deny-is-maximized":{"identifier":"deny-is-maximized","description":"Denies the is_maximized command without any pre-configured scope.","commands":{"allow":[],"deny":["is_maximized"]}},"deny-is-minimizable":{"identifier":"deny-is-minimizable","description":"Denies the is_minimizable command without any pre-configured scope.","commands":{"allow":[],"deny":["is_minimizable"]}},"deny-is-minimized":{"identifier":"deny-is-minimized","description":"Denies the is_minimized command without any pre-configured scope.","commands":{"allow":[],"deny":["is_minimized"]}},"deny-is-resizable":{"identifier":"deny-is-resizable","description":"Denies the is_resizable command without any pre-configured scope.","commands":{"allow":[],"deny":["is_resizable"]}},"deny-is-visible":{"identifier":"deny-is-visible","description":"Denies the is_visible command without any pre-configured scope.","commands":{"allow":[],"deny":["is_visible"]}},"deny-maximize":{"identifier":"deny-maximize","description":"Denies the maximize command without any pre-configured scope.","commands":{"allow":[],"deny":["maximize"]}},"deny-minimize":{"identifier":"deny-minimize","description":"Denies the minimize command without any pre-configured scope.","commands":{"allow":[],"deny":["minimize"]}},"deny-monitor-from-point":{"identifier":"deny-monitor-from-point","description":"Denies the monitor_from_point command without any pre-configured scope.","commands":{"allow":[],"deny":["monitor_from_point"]}},"deny-outer-position":{"identifier":"deny-outer-position","description":"Denies the outer_position command without any pre-configured scope.","commands":{"allow":[],"deny":["outer_position"]}},"deny-outer-size":{"identifier":"deny-outer-size","description":"Denies the outer_size command without any pre-configured scope.","commands":{"allow":[],"deny":["outer_size"]}},"deny-primary-monitor":{"identifier":"deny-primary-monitor","description":"Denies the primary_monitor command without any pre-configured scope.","commands":{"allow":[],"deny":["primary_monitor"]}},"deny-request-user-attention":{"identifier":"deny-request-user-attention","description":"Denies the request_user_attention command without any pre-configured scope.","commands":{"allow":[],"deny":["request_user_attention"]}},"deny-scale-factor":{"identifier":"deny-scale-factor","description":"Denies the scale_factor command without any pre-configured scope.","commands":{"allow":[],"deny":["scale_factor"]}},"deny-set-always-on-bottom":{"identifier":"deny-set-always-on-bottom","description":"Denies the set_always_on_bottom command without any pre-configured scope.","commands":{"allow":[],"deny":["set_always_on_bottom"]}},"deny-set-always-on-top":{"identifier":"deny-set-always-on-top","description":"Denies the set_always_on_top command without any pre-configured scope.","commands":{"allow":[],"deny":["set_always_on_top"]}},"deny-set-background-color":{"identifier":"deny-set-background-color","description":"Denies the set_background_color command without any pre-configured scope.","commands":{"allow":[],"deny":["set_background_color"]}},"deny-set-badge-count":{"identifier":"deny-set-badge-count","description":"Denies the set_badge_count command without any pre-configured scope.","commands":{"allow":[],"deny":["set_badge_count"]}},"deny-set-badge-label":{"identifier":"deny-set-badge-label","description":"Denies the set_badge_label command without any pre-configured scope.","commands":{"allow":[],"deny":["set_badge_label"]}},"deny-set-closable":{"identifier":"deny-set-closable","description":"Denies the set_closable command without any pre-configured scope.","commands":{"allow":[],"deny":["set_closable"]}},"deny-set-content-protected":{"identifier":"deny-set-content-protected","description":"Denies the set_content_protected command without any pre-configured scope.","commands":{"allow":[],"deny":["set_content_protected"]}},"deny-set-cursor-grab":{"identifier":"deny-set-cursor-grab","description":"Denies the set_cursor_grab command without any pre-configured scope.","commands":{"allow":[],"deny":["set_cursor_grab"]}},"deny-set-cursor-icon":{"identifier":"deny-set-cursor-icon","description":"Denies the set_cursor_icon command without any pre-configured scope.","commands":{"allow":[],"deny":["set_cursor_icon"]}},"deny-set-cursor-position":{"identifier":"deny-set-cursor-position","description":"Denies the set_cursor_position command without any pre-configured scope.","commands":{"allow":[],"deny":["set_cursor_position"]}},"deny-set-cursor-visible":{"identifier":"deny-set-cursor-visible","description":"Denies the set_cursor_visible command without any pre-configured scope.","commands":{"allow":[],"deny":["set_cursor_visible"]}},"deny-set-decorations":{"identifier":"deny-set-decorations","description":"Denies the set_decorations command without any pre-configured scope.","commands":{"allow":[],"deny":["set_decorations"]}},"deny-set-effects":{"identifier":"deny-set-effects","description":"Denies the set_effects command without any pre-configured scope.","commands":{"allow":[],"deny":["set_effects"]}},"deny-set-enabled":{"identifier":"deny-set-enabled","description":"Denies the set_enabled command without any pre-configured scope.","commands":{"allow":[],"deny":["set_enabled"]}},"deny-set-focus":{"identifier":"deny-set-focus","description":"Denies the set_focus command without any pre-configured scope.","commands":{"allow":[],"deny":["set_focus"]}},"deny-set-focusable":{"identifier":"deny-set-focusable","description":"Denies the set_focusable command without any pre-configured scope.","commands":{"allow":[],"deny":["set_focusable"]}},"deny-set-fullscreen":{"identifier":"deny-set-fullscreen","description":"Denies the set_fullscreen command without any pre-configured scope.","commands":{"allow":[],"deny":["set_fullscreen"]}},"deny-set-icon":{"identifier":"deny-set-icon","description":"Denies the set_icon command without any pre-configured scope.","commands":{"allow":[],"deny":["set_icon"]}},"deny-set-ignore-cursor-events":{"identifier":"deny-set-ignore-cursor-events","description":"Denies the set_ignore_cursor_events command without any pre-configured scope.","commands":{"allow":[],"deny":["set_ignore_cursor_events"]}},"deny-set-max-size":{"identifier":"deny-set-max-size","description":"Denies the set_max_size command without any pre-configured scope.","commands":{"allow":[],"deny":["set_max_size"]}},"deny-set-maximizable":{"identifier":"deny-set-maximizable","description":"Denies the set_maximizable command without any pre-configured scope.","commands":{"allow":[],"deny":["set_maximizable"]}},"deny-set-min-size":{"identifier":"deny-set-min-size","description":"Denies the set_min_size command without any pre-configured scope.","commands":{"allow":[],"deny":["set_min_size"]}},"deny-set-minimizable":{"identifier":"deny-set-minimizable","description":"Denies the set_minimizable command without any pre-configured scope.","commands":{"allow":[],"deny":["set_minimizable"]}},"deny-set-overlay-icon":{"identifier":"deny-set-overlay-icon","description":"Denies the set_overlay_icon command without any pre-configured scope.","commands":{"allow":[],"deny":["set_overlay_icon"]}},"deny-set-position":{"identifier":"deny-set-position","description":"Denies the set_position command without any pre-configured scope.","commands":{"allow":[],"deny":["set_position"]}},"deny-set-progress-bar":{"identifier":"deny-set-progress-bar","description":"Denies the set_progress_bar command without any pre-configured scope.","commands":{"allow":[],"deny":["set_progress_bar"]}},"deny-set-resizable":{"identifier":"deny-set-resizable","description":"Denies the set_resizable command without any pre-configured scope.","commands":{"allow":[],"deny":["set_resizable"]}},"deny-set-shadow":{"identifier":"deny-set-shadow","description":"Denies the set_shadow command without any pre-configured scope.","commands":{"allow":[],"deny":["set_shadow"]}},"deny-set-simple-fullscreen":{"identifier":"deny-set-simple-fullscreen","description":"Denies the set_simple_fullscreen command without any pre-configured scope.","commands":{"allow":[],"deny":["set_simple_fullscreen"]}},"deny-set-size":{"identifier":"deny-set-size","description":"Denies the set_size command without any pre-configured scope.","commands":{"allow":[],"deny":["set_size"]}},"deny-set-size-constraints":{"identifier":"deny-set-size-constraints","description":"Denies the set_size_constraints command without any pre-configured scope.","commands":{"allow":[],"deny":["set_size_constraints"]}},"deny-set-skip-taskbar":{"identifier":"deny-set-skip-taskbar","description":"Denies the set_skip_taskbar command without any pre-configured scope.","commands":{"allow":[],"deny":["set_skip_taskbar"]}},"deny-set-theme":{"identifier":"deny-set-theme","description":"Denies the set_theme command without any pre-configured scope.","commands":{"allow":[],"deny":["set_theme"]}},"deny-set-title":{"identifier":"deny-set-title","description":"Denies the set_title command without any pre-configured scope.","commands":{"allow":[],"deny":["set_title"]}},"deny-set-title-bar-style":{"identifier":"deny-set-title-bar-style","description":"Denies the set_title_bar_style command without any pre-configured scope.","commands":{"allow":[],"deny":["set_title_bar_style"]}},"deny-set-visible-on-all-workspaces":{"identifier":"deny-set-visible-on-all-workspaces","description":"Denies the set_visible_on_all_workspaces command without any pre-configured scope.","commands":{"allow":[],"deny":["set_visible_on_all_workspaces"]}},"deny-show":{"identifier":"deny-show","description":"Denies the show command without any pre-configured scope.","commands":{"allow":[],"deny":["show"]}},"deny-start-dragging":{"identifier":"deny-start-dragging","description":"Denies the start_dragging command without any pre-configured scope.","commands":{"allow":[],"deny":["start_dragging"]}},"deny-start-resize-dragging":{"identifier":"deny-start-resize-dragging","description":"Denies the start_resize_dragging command without any pre-configured scope.","commands":{"allow":[],"deny":["start_resize_dragging"]}},"deny-theme":{"identifier":"deny-theme","description":"Denies the theme command without any pre-configured scope.","commands":{"allow":[],"deny":["theme"]}},"deny-title":{"identifier":"deny-title","description":"Denies the title command without any pre-configured scope.","commands":{"allow":[],"deny":["title"]}},"deny-toggle-maximize":{"identifier":"deny-toggle-maximize","description":"Denies the toggle_maximize command without any pre-configured scope.","commands":{"allow":[],"deny":["toggle_maximize"]}},"deny-unmaximize":{"identifier":"deny-unmaximize","description":"Denies the unmaximize command without any pre-configured scope.","commands":{"allow":[],"deny":["unmaximize"]}},"deny-unminimize":{"identifier":"deny-unminimize","description":"Denies the unminimize command without any pre-configured scope.","commands":{"allow":[],"deny":["unminimize"]}}},"permission_sets":{},"global_scope_schema":null}} \ No newline at end of file diff --git a/crates/quicnprotochat-gui/gen/schemas/capabilities.json b/crates/quicnprotochat-gui/gen/schemas/capabilities.json new file mode 100644 index 0000000..990ab59 --- /dev/null +++ b/crates/quicnprotochat-gui/gen/schemas/capabilities.json @@ -0,0 +1 @@ +{"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"]}} \ No newline at end of file diff --git a/crates/quicnprotochat-gui/gen/schemas/desktop-schema.json b/crates/quicnprotochat-gui/gen/schemas/desktop-schema.json new file mode 100644 index 0000000..260dbe0 --- /dev/null +++ b/crates/quicnprotochat-gui/gen/schemas/desktop-schema.json @@ -0,0 +1,2244 @@ +{ + "$schema": "http://json-schema.org/draft-07/schema#", + "title": "CapabilityFile", + "description": "Capability formats accepted in a capability file.", + "anyOf": [ + { + "description": "A single capability.", + "allOf": [ + { + "$ref": "#/definitions/Capability" + } + ] + }, + { + "description": "A list of capabilities.", + "type": "array", + "items": { + "$ref": "#/definitions/Capability" + } + }, + { + "description": "A list of capabilities.", + "type": "object", + "required": [ + "capabilities" + ], + "properties": { + "capabilities": { + "description": "The list of capabilities.", + "type": "array", + "items": { + "$ref": "#/definitions/Capability" + } + } + } + } + ], + "definitions": { + "Capability": { + "description": "A grouping and boundary mechanism developers can use to isolate access to the IPC layer.\n\nIt controls application windows' and webviews' fine grained access to the Tauri core, application, or plugin commands. If a webview or its window is not matching any capability then it has no access to the IPC layer at all.\n\nThis can be done to create groups of windows, based on their required system access, which can reduce impact of frontend vulnerabilities in less privileged windows. Windows can be added to a capability by exact name (e.g. `main-window`) or glob patterns like `*` or `admin-*`. A Window can have none, one, or multiple associated capabilities.\n\n## Example\n\n```json { \"identifier\": \"main-user-files-write\", \"description\": \"This capability allows the `main` window on macOS and Windows access to `filesystem` write related commands and `dialog` commands to enable programmatic access to files selected by the user.\", \"windows\": [ \"main\" ], \"permissions\": [ \"core:default\", \"dialog:open\", { \"identifier\": \"fs:allow-write-text-file\", \"allow\": [{ \"path\": \"$HOME/test.txt\" }] }, ], \"platforms\": [\"macOS\",\"windows\"] } ```", + "type": "object", + "required": [ + "identifier", + "permissions" + ], + "properties": { + "identifier": { + "description": "Identifier of the capability.\n\n## Example\n\n`main-user-files-write`", + "type": "string" + }, + "description": { + "description": "Description of what the capability is intended to allow on associated windows.\n\nIt should contain a description of what the grouped permissions should allow.\n\n## Example\n\nThis capability allows the `main` window access to `filesystem` write related commands and `dialog` commands to enable programmatic access to files selected by the user.", + "default": "", + "type": "string" + }, + "remote": { + "description": "Configure remote URLs that can use the capability permissions.\n\nThis setting is optional and defaults to not being set, as our default use case is that the content is served from our local application.\n\n:::caution Make sure you understand the security implications of providing remote sources with local system access. :::\n\n## Example\n\n```json { \"urls\": [\"https://*.mydomain.dev\"] } ```", + "anyOf": [ + { + "$ref": "#/definitions/CapabilityRemote" + }, + { + "type": "null" + } + ] + }, + "local": { + "description": "Whether this capability is enabled for local app URLs or not. Defaults to `true`.", + "default": true, + "type": "boolean" + }, + "windows": { + "description": "List of windows that are affected by this capability. Can be a glob pattern.\n\nIf a window label matches any of the patterns in this list, the capability will be enabled on all the webviews of that window, regardless of the value of [`Self::webviews`].\n\nOn multiwebview windows, prefer specifying [`Self::webviews`] and omitting [`Self::windows`] for a fine grained access control.\n\n## Example\n\n`[\"main\"]`", + "type": "array", + "items": { + "type": "string" + } + }, + "webviews": { + "description": "List of webviews that are affected by this capability. Can be a glob pattern.\n\nThe capability will be enabled on all the webviews whose label matches any of the patterns in this list, regardless of whether the webview's window label matches a pattern in [`Self::windows`].\n\n## Example\n\n`[\"sub-webview-one\", \"sub-webview-two\"]`", + "type": "array", + "items": { + "type": "string" + } + }, + "permissions": { + "description": "List of permissions attached to this capability.\n\nMust include the plugin name as prefix in the form of `${plugin-name}:${permission-name}`. For commands directly implemented in the application itself only `${permission-name}` is required.\n\n## Example\n\n```json [ \"core:default\", \"shell:allow-open\", \"dialog:open\", { \"identifier\": \"fs:allow-write-text-file\", \"allow\": [{ \"path\": \"$HOME/test.txt\" }] } ] ```", + "type": "array", + "items": { + "$ref": "#/definitions/PermissionEntry" + }, + "uniqueItems": true + }, + "platforms": { + "description": "Limit which target platforms this capability applies to.\n\nBy default all platforms are targeted.\n\n## Example\n\n`[\"macOS\",\"windows\"]`", + "type": [ + "array", + "null" + ], + "items": { + "$ref": "#/definitions/Target" + } + } + } + }, + "CapabilityRemote": { + "description": "Configuration for remote URLs that are associated with the capability.", + "type": "object", + "required": [ + "urls" + ], + "properties": { + "urls": { + "description": "Remote domains this capability refers to using the [URLPattern standard](https://urlpattern.spec.whatwg.org/).\n\n## Examples\n\n- \"https://*.mydomain.dev\": allows subdomains of mydomain.dev - \"https://mydomain.dev/api/*\": allows any subpath of mydomain.dev/api", + "type": "array", + "items": { + "type": "string" + } + } + } + }, + "PermissionEntry": { + "description": "An entry for a permission value in a [`Capability`] can be either a raw permission [`Identifier`] or an object that references a permission and extends its scope.", + "anyOf": [ + { + "description": "Reference a permission or permission set by identifier.", + "allOf": [ + { + "$ref": "#/definitions/Identifier" + } + ] + }, + { + "description": "Reference a permission or permission set by identifier and extends its scope.", + "type": "object", + "allOf": [ + { + "properties": { + "identifier": { + "description": "Identifier of the permission or permission set.", + "allOf": [ + { + "$ref": "#/definitions/Identifier" + } + ] + }, + "allow": { + "description": "Data that defines what is allowed by the scope.", + "type": [ + "array", + "null" + ], + "items": { + "$ref": "#/definitions/Value" + } + }, + "deny": { + "description": "Data that defines what is denied by the scope. This should be prioritized by validation logic.", + "type": [ + "array", + "null" + ], + "items": { + "$ref": "#/definitions/Value" + } + } + } + } + ], + "required": [ + "identifier" + ] + } + ] + }, + "Identifier": { + "description": "Permission identifier", + "oneOf": [ + { + "description": "Default core plugins set.\n#### This default permission set includes:\n\n- `core:path:default`\n- `core:event:default`\n- `core:window:default`\n- `core:webview:default`\n- `core:app:default`\n- `core:image:default`\n- `core:resources:default`\n- `core:menu:default`\n- `core:tray:default`", + "type": "string", + "const": "core:default", + "markdownDescription": "Default core plugins set.\n#### This default permission set includes:\n\n- `core:path:default`\n- `core:event:default`\n- `core:window:default`\n- `core:webview:default`\n- `core:app:default`\n- `core:image:default`\n- `core:resources:default`\n- `core:menu:default`\n- `core:tray:default`" + }, + { + "description": "Default permissions for the plugin.\n#### This default permission set includes:\n\n- `allow-version`\n- `allow-name`\n- `allow-tauri-version`\n- `allow-identifier`\n- `allow-bundle-type`\n- `allow-register-listener`\n- `allow-remove-listener`", + "type": "string", + "const": "core:app:default", + "markdownDescription": "Default permissions for the plugin.\n#### This default permission set includes:\n\n- `allow-version`\n- `allow-name`\n- `allow-tauri-version`\n- `allow-identifier`\n- `allow-bundle-type`\n- `allow-register-listener`\n- `allow-remove-listener`" + }, + { + "description": "Enables the app_hide command without any pre-configured scope.", + "type": "string", + "const": "core:app:allow-app-hide", + "markdownDescription": "Enables the app_hide command without any pre-configured scope." + }, + { + "description": "Enables the app_show command without any pre-configured scope.", + "type": "string", + "const": "core:app:allow-app-show", + "markdownDescription": "Enables the app_show command without any pre-configured scope." + }, + { + "description": "Enables the bundle_type command without any pre-configured scope.", + "type": "string", + "const": "core:app:allow-bundle-type", + "markdownDescription": "Enables the bundle_type command without any pre-configured scope." + }, + { + "description": "Enables the default_window_icon command without any pre-configured scope.", + "type": "string", + "const": "core:app:allow-default-window-icon", + "markdownDescription": "Enables the default_window_icon command without any pre-configured scope." + }, + { + "description": "Enables the fetch_data_store_identifiers command without any pre-configured scope.", + "type": "string", + "const": "core:app:allow-fetch-data-store-identifiers", + "markdownDescription": "Enables the fetch_data_store_identifiers command without any pre-configured scope." + }, + { + "description": "Enables the identifier command without any pre-configured scope.", + "type": "string", + "const": "core:app:allow-identifier", + "markdownDescription": "Enables the identifier command without any pre-configured scope." + }, + { + "description": "Enables the name command without any pre-configured scope.", + "type": "string", + "const": "core:app:allow-name", + "markdownDescription": "Enables the name command without any pre-configured scope." + }, + { + "description": "Enables the register_listener command without any pre-configured scope.", + "type": "string", + "const": "core:app:allow-register-listener", + "markdownDescription": "Enables the register_listener command without any pre-configured scope." + }, + { + "description": "Enables the remove_data_store command without any pre-configured scope.", + "type": "string", + "const": "core:app:allow-remove-data-store", + "markdownDescription": "Enables the remove_data_store command without any pre-configured scope." + }, + { + "description": "Enables the remove_listener command without any pre-configured scope.", + "type": "string", + "const": "core:app:allow-remove-listener", + "markdownDescription": "Enables the remove_listener command without any pre-configured scope." + }, + { + "description": "Enables the set_app_theme command without any pre-configured scope.", + "type": "string", + "const": "core:app:allow-set-app-theme", + "markdownDescription": "Enables the set_app_theme command without any pre-configured scope." + }, + { + "description": "Enables the set_dock_visibility command without any pre-configured scope.", + "type": "string", + "const": "core:app:allow-set-dock-visibility", + "markdownDescription": "Enables the set_dock_visibility command without any pre-configured scope." + }, + { + "description": "Enables the tauri_version command without any pre-configured scope.", + "type": "string", + "const": "core:app:allow-tauri-version", + "markdownDescription": "Enables the tauri_version command without any pre-configured scope." + }, + { + "description": "Enables the version command without any pre-configured scope.", + "type": "string", + "const": "core:app:allow-version", + "markdownDescription": "Enables the version command without any pre-configured scope." + }, + { + "description": "Denies the app_hide command without any pre-configured scope.", + "type": "string", + "const": "core:app:deny-app-hide", + "markdownDescription": "Denies the app_hide command without any pre-configured scope." + }, + { + "description": "Denies the app_show command without any pre-configured scope.", + "type": "string", + "const": "core:app:deny-app-show", + "markdownDescription": "Denies the app_show command without any pre-configured scope." + }, + { + "description": "Denies the bundle_type command without any pre-configured scope.", + "type": "string", + "const": "core:app:deny-bundle-type", + "markdownDescription": "Denies the bundle_type command without any pre-configured scope." + }, + { + "description": "Denies the default_window_icon command without any pre-configured scope.", + "type": "string", + "const": "core:app:deny-default-window-icon", + "markdownDescription": "Denies the default_window_icon command without any pre-configured scope." + }, + { + "description": "Denies the fetch_data_store_identifiers command without any pre-configured scope.", + "type": "string", + "const": "core:app:deny-fetch-data-store-identifiers", + "markdownDescription": "Denies the fetch_data_store_identifiers command without any pre-configured scope." + }, + { + "description": "Denies the identifier command without any pre-configured scope.", + "type": "string", + "const": "core:app:deny-identifier", + "markdownDescription": "Denies the identifier command without any pre-configured scope." + }, + { + "description": "Denies the name command without any pre-configured scope.", + "type": "string", + "const": "core:app:deny-name", + "markdownDescription": "Denies the name command without any pre-configured scope." + }, + { + "description": "Denies the register_listener command without any pre-configured scope.", + "type": "string", + "const": "core:app:deny-register-listener", + "markdownDescription": "Denies the register_listener command without any pre-configured scope." + }, + { + "description": "Denies the remove_data_store command without any pre-configured scope.", + "type": "string", + "const": "core:app:deny-remove-data-store", + "markdownDescription": "Denies the remove_data_store command without any pre-configured scope." + }, + { + "description": "Denies the remove_listener command without any pre-configured scope.", + "type": "string", + "const": "core:app:deny-remove-listener", + "markdownDescription": "Denies the remove_listener command without any pre-configured scope." + }, + { + "description": "Denies the set_app_theme command without any pre-configured scope.", + "type": "string", + "const": "core:app:deny-set-app-theme", + "markdownDescription": "Denies the set_app_theme command without any pre-configured scope." + }, + { + "description": "Denies the set_dock_visibility command without any pre-configured scope.", + "type": "string", + "const": "core:app:deny-set-dock-visibility", + "markdownDescription": "Denies the set_dock_visibility command without any pre-configured scope." + }, + { + "description": "Denies the tauri_version command without any pre-configured scope.", + "type": "string", + "const": "core:app:deny-tauri-version", + "markdownDescription": "Denies the tauri_version command without any pre-configured scope." + }, + { + "description": "Denies the version command without any pre-configured scope.", + "type": "string", + "const": "core:app:deny-version", + "markdownDescription": "Denies the version command without any pre-configured scope." + }, + { + "description": "Default permissions for the plugin, which enables all commands.\n#### This default permission set includes:\n\n- `allow-listen`\n- `allow-unlisten`\n- `allow-emit`\n- `allow-emit-to`", + "type": "string", + "const": "core:event:default", + "markdownDescription": "Default permissions for the plugin, which enables all commands.\n#### This default permission set includes:\n\n- `allow-listen`\n- `allow-unlisten`\n- `allow-emit`\n- `allow-emit-to`" + }, + { + "description": "Enables the emit command without any pre-configured scope.", + "type": "string", + "const": "core:event:allow-emit", + "markdownDescription": "Enables the emit command without any pre-configured scope." + }, + { + "description": "Enables the emit_to command without any pre-configured scope.", + "type": "string", + "const": "core:event:allow-emit-to", + "markdownDescription": "Enables the emit_to command without any pre-configured scope." + }, + { + "description": "Enables the listen command without any pre-configured scope.", + "type": "string", + "const": "core:event:allow-listen", + "markdownDescription": "Enables the listen command without any pre-configured scope." + }, + { + "description": "Enables the unlisten command without any pre-configured scope.", + "type": "string", + "const": "core:event:allow-unlisten", + "markdownDescription": "Enables the unlisten command without any pre-configured scope." + }, + { + "description": "Denies the emit command without any pre-configured scope.", + "type": "string", + "const": "core:event:deny-emit", + "markdownDescription": "Denies the emit command without any pre-configured scope." + }, + { + "description": "Denies the emit_to command without any pre-configured scope.", + "type": "string", + "const": "core:event:deny-emit-to", + "markdownDescription": "Denies the emit_to command without any pre-configured scope." + }, + { + "description": "Denies the listen command without any pre-configured scope.", + "type": "string", + "const": "core:event:deny-listen", + "markdownDescription": "Denies the listen command without any pre-configured scope." + }, + { + "description": "Denies the unlisten command without any pre-configured scope.", + "type": "string", + "const": "core:event:deny-unlisten", + "markdownDescription": "Denies the unlisten command without any pre-configured scope." + }, + { + "description": "Default permissions for the plugin, which enables all commands.\n#### This default permission set includes:\n\n- `allow-new`\n- `allow-from-bytes`\n- `allow-from-path`\n- `allow-rgba`\n- `allow-size`", + "type": "string", + "const": "core:image:default", + "markdownDescription": "Default permissions for the plugin, which enables all commands.\n#### This default permission set includes:\n\n- `allow-new`\n- `allow-from-bytes`\n- `allow-from-path`\n- `allow-rgba`\n- `allow-size`" + }, + { + "description": "Enables the from_bytes command without any pre-configured scope.", + "type": "string", + "const": "core:image:allow-from-bytes", + "markdownDescription": "Enables the from_bytes command without any pre-configured scope." + }, + { + "description": "Enables the from_path command without any pre-configured scope.", + "type": "string", + "const": "core:image:allow-from-path", + "markdownDescription": "Enables the from_path command without any pre-configured scope." + }, + { + "description": "Enables the new command without any pre-configured scope.", + "type": "string", + "const": "core:image:allow-new", + "markdownDescription": "Enables the new command without any pre-configured scope." + }, + { + "description": "Enables the rgba command without any pre-configured scope.", + "type": "string", + "const": "core:image:allow-rgba", + "markdownDescription": "Enables the rgba command without any pre-configured scope." + }, + { + "description": "Enables the size command without any pre-configured scope.", + "type": "string", + "const": "core:image:allow-size", + "markdownDescription": "Enables the size command without any pre-configured scope." + }, + { + "description": "Denies the from_bytes command without any pre-configured scope.", + "type": "string", + "const": "core:image:deny-from-bytes", + "markdownDescription": "Denies the from_bytes command without any pre-configured scope." + }, + { + "description": "Denies the from_path command without any pre-configured scope.", + "type": "string", + "const": "core:image:deny-from-path", + "markdownDescription": "Denies the from_path command without any pre-configured scope." + }, + { + "description": "Denies the new command without any pre-configured scope.", + "type": "string", + "const": "core:image:deny-new", + "markdownDescription": "Denies the new command without any pre-configured scope." + }, + { + "description": "Denies the rgba command without any pre-configured scope.", + "type": "string", + "const": "core:image:deny-rgba", + "markdownDescription": "Denies the rgba command without any pre-configured scope." + }, + { + "description": "Denies the size command without any pre-configured scope.", + "type": "string", + "const": "core:image:deny-size", + "markdownDescription": "Denies the size command without any pre-configured scope." + }, + { + "description": "Default permissions for the plugin, which enables all commands.\n#### This default permission set includes:\n\n- `allow-new`\n- `allow-append`\n- `allow-prepend`\n- `allow-insert`\n- `allow-remove`\n- `allow-remove-at`\n- `allow-items`\n- `allow-get`\n- `allow-popup`\n- `allow-create-default`\n- `allow-set-as-app-menu`\n- `allow-set-as-window-menu`\n- `allow-text`\n- `allow-set-text`\n- `allow-is-enabled`\n- `allow-set-enabled`\n- `allow-set-accelerator`\n- `allow-set-as-windows-menu-for-nsapp`\n- `allow-set-as-help-menu-for-nsapp`\n- `allow-is-checked`\n- `allow-set-checked`\n- `allow-set-icon`", + "type": "string", + "const": "core:menu:default", + "markdownDescription": "Default permissions for the plugin, which enables all commands.\n#### This default permission set includes:\n\n- `allow-new`\n- `allow-append`\n- `allow-prepend`\n- `allow-insert`\n- `allow-remove`\n- `allow-remove-at`\n- `allow-items`\n- `allow-get`\n- `allow-popup`\n- `allow-create-default`\n- `allow-set-as-app-menu`\n- `allow-set-as-window-menu`\n- `allow-text`\n- `allow-set-text`\n- `allow-is-enabled`\n- `allow-set-enabled`\n- `allow-set-accelerator`\n- `allow-set-as-windows-menu-for-nsapp`\n- `allow-set-as-help-menu-for-nsapp`\n- `allow-is-checked`\n- `allow-set-checked`\n- `allow-set-icon`" + }, + { + "description": "Enables the append command without any pre-configured scope.", + "type": "string", + "const": "core:menu:allow-append", + "markdownDescription": "Enables the append command without any pre-configured scope." + }, + { + "description": "Enables the create_default command without any pre-configured scope.", + "type": "string", + "const": "core:menu:allow-create-default", + "markdownDescription": "Enables the create_default command without any pre-configured scope." + }, + { + "description": "Enables the get command without any pre-configured scope.", + "type": "string", + "const": "core:menu:allow-get", + "markdownDescription": "Enables the get command without any pre-configured scope." + }, + { + "description": "Enables the insert command without any pre-configured scope.", + "type": "string", + "const": "core:menu:allow-insert", + "markdownDescription": "Enables the insert command without any pre-configured scope." + }, + { + "description": "Enables the is_checked command without any pre-configured scope.", + "type": "string", + "const": "core:menu:allow-is-checked", + "markdownDescription": "Enables the is_checked command without any pre-configured scope." + }, + { + "description": "Enables the is_enabled command without any pre-configured scope.", + "type": "string", + "const": "core:menu:allow-is-enabled", + "markdownDescription": "Enables the is_enabled command without any pre-configured scope." + }, + { + "description": "Enables the items command without any pre-configured scope.", + "type": "string", + "const": "core:menu:allow-items", + "markdownDescription": "Enables the items command without any pre-configured scope." + }, + { + "description": "Enables the new command without any pre-configured scope.", + "type": "string", + "const": "core:menu:allow-new", + "markdownDescription": "Enables the new command without any pre-configured scope." + }, + { + "description": "Enables the popup command without any pre-configured scope.", + "type": "string", + "const": "core:menu:allow-popup", + "markdownDescription": "Enables the popup command without any pre-configured scope." + }, + { + "description": "Enables the prepend command without any pre-configured scope.", + "type": "string", + "const": "core:menu:allow-prepend", + "markdownDescription": "Enables the prepend command without any pre-configured scope." + }, + { + "description": "Enables the remove command without any pre-configured scope.", + "type": "string", + "const": "core:menu:allow-remove", + "markdownDescription": "Enables the remove command without any pre-configured scope." + }, + { + "description": "Enables the remove_at command without any pre-configured scope.", + "type": "string", + "const": "core:menu:allow-remove-at", + "markdownDescription": "Enables the remove_at command without any pre-configured scope." + }, + { + "description": "Enables the set_accelerator command without any pre-configured scope.", + "type": "string", + "const": "core:menu:allow-set-accelerator", + "markdownDescription": "Enables the set_accelerator command without any pre-configured scope." + }, + { + "description": "Enables the set_as_app_menu command without any pre-configured scope.", + "type": "string", + "const": "core:menu:allow-set-as-app-menu", + "markdownDescription": "Enables the set_as_app_menu command without any pre-configured scope." + }, + { + "description": "Enables the set_as_help_menu_for_nsapp command without any pre-configured scope.", + "type": "string", + "const": "core:menu:allow-set-as-help-menu-for-nsapp", + "markdownDescription": "Enables the set_as_help_menu_for_nsapp command without any pre-configured scope." + }, + { + "description": "Enables the set_as_window_menu command without any pre-configured scope.", + "type": "string", + "const": "core:menu:allow-set-as-window-menu", + "markdownDescription": "Enables the set_as_window_menu command without any pre-configured scope." + }, + { + "description": "Enables the set_as_windows_menu_for_nsapp command without any pre-configured scope.", + "type": "string", + "const": "core:menu:allow-set-as-windows-menu-for-nsapp", + "markdownDescription": "Enables the set_as_windows_menu_for_nsapp command without any pre-configured scope." + }, + { + "description": "Enables the set_checked command without any pre-configured scope.", + "type": "string", + "const": "core:menu:allow-set-checked", + "markdownDescription": "Enables the set_checked command without any pre-configured scope." + }, + { + "description": "Enables the set_enabled command without any pre-configured scope.", + "type": "string", + "const": "core:menu:allow-set-enabled", + "markdownDescription": "Enables the set_enabled command without any pre-configured scope." + }, + { + "description": "Enables the set_icon command without any pre-configured scope.", + "type": "string", + "const": "core:menu:allow-set-icon", + "markdownDescription": "Enables the set_icon command without any pre-configured scope." + }, + { + "description": "Enables the set_text command without any pre-configured scope.", + "type": "string", + "const": "core:menu:allow-set-text", + "markdownDescription": "Enables the set_text command without any pre-configured scope." + }, + { + "description": "Enables the text command without any pre-configured scope.", + "type": "string", + "const": "core:menu:allow-text", + "markdownDescription": "Enables the text command without any pre-configured scope." + }, + { + "description": "Denies the append command without any pre-configured scope.", + "type": "string", + "const": "core:menu:deny-append", + "markdownDescription": "Denies the append command without any pre-configured scope." + }, + { + "description": "Denies the create_default command without any pre-configured scope.", + "type": "string", + "const": "core:menu:deny-create-default", + "markdownDescription": "Denies the create_default command without any pre-configured scope." + }, + { + "description": "Denies the get command without any pre-configured scope.", + "type": "string", + "const": "core:menu:deny-get", + "markdownDescription": "Denies the get command without any pre-configured scope." + }, + { + "description": "Denies the insert command without any pre-configured scope.", + "type": "string", + "const": "core:menu:deny-insert", + "markdownDescription": "Denies the insert command without any pre-configured scope." + }, + { + "description": "Denies the is_checked command without any pre-configured scope.", + "type": "string", + "const": "core:menu:deny-is-checked", + "markdownDescription": "Denies the is_checked command without any pre-configured scope." + }, + { + "description": "Denies the is_enabled command without any pre-configured scope.", + "type": "string", + "const": "core:menu:deny-is-enabled", + "markdownDescription": "Denies the is_enabled command without any pre-configured scope." + }, + { + "description": "Denies the items command without any pre-configured scope.", + "type": "string", + "const": "core:menu:deny-items", + "markdownDescription": "Denies the items command without any pre-configured scope." + }, + { + "description": "Denies the new command without any pre-configured scope.", + "type": "string", + "const": "core:menu:deny-new", + "markdownDescription": "Denies the new command without any pre-configured scope." + }, + { + "description": "Denies the popup command without any pre-configured scope.", + "type": "string", + "const": "core:menu:deny-popup", + "markdownDescription": "Denies the popup command without any pre-configured scope." + }, + { + "description": "Denies the prepend command without any pre-configured scope.", + "type": "string", + "const": "core:menu:deny-prepend", + "markdownDescription": "Denies the prepend command without any pre-configured scope." + }, + { + "description": "Denies the remove command without any pre-configured scope.", + "type": "string", + "const": "core:menu:deny-remove", + "markdownDescription": "Denies the remove command without any pre-configured scope." + }, + { + "description": "Denies the remove_at command without any pre-configured scope.", + "type": "string", + "const": "core:menu:deny-remove-at", + "markdownDescription": "Denies the remove_at command without any pre-configured scope." + }, + { + "description": "Denies the set_accelerator command without any pre-configured scope.", + "type": "string", + "const": "core:menu:deny-set-accelerator", + "markdownDescription": "Denies the set_accelerator command without any pre-configured scope." + }, + { + "description": "Denies the set_as_app_menu command without any pre-configured scope.", + "type": "string", + "const": "core:menu:deny-set-as-app-menu", + "markdownDescription": "Denies the set_as_app_menu command without any pre-configured scope." + }, + { + "description": "Denies the set_as_help_menu_for_nsapp command without any pre-configured scope.", + "type": "string", + "const": "core:menu:deny-set-as-help-menu-for-nsapp", + "markdownDescription": "Denies the set_as_help_menu_for_nsapp command without any pre-configured scope." + }, + { + "description": "Denies the set_as_window_menu command without any pre-configured scope.", + "type": "string", + "const": "core:menu:deny-set-as-window-menu", + "markdownDescription": "Denies the set_as_window_menu command without any pre-configured scope." + }, + { + "description": "Denies the set_as_windows_menu_for_nsapp command without any pre-configured scope.", + "type": "string", + "const": "core:menu:deny-set-as-windows-menu-for-nsapp", + "markdownDescription": "Denies the set_as_windows_menu_for_nsapp command without any pre-configured scope." + }, + { + "description": "Denies the set_checked command without any pre-configured scope.", + "type": "string", + "const": "core:menu:deny-set-checked", + "markdownDescription": "Denies the set_checked command without any pre-configured scope." + }, + { + "description": "Denies the set_enabled command without any pre-configured scope.", + "type": "string", + "const": "core:menu:deny-set-enabled", + "markdownDescription": "Denies the set_enabled command without any pre-configured scope." + }, + { + "description": "Denies the set_icon command without any pre-configured scope.", + "type": "string", + "const": "core:menu:deny-set-icon", + "markdownDescription": "Denies the set_icon command without any pre-configured scope." + }, + { + "description": "Denies the set_text command without any pre-configured scope.", + "type": "string", + "const": "core:menu:deny-set-text", + "markdownDescription": "Denies the set_text command without any pre-configured scope." + }, + { + "description": "Denies the text command without any pre-configured scope.", + "type": "string", + "const": "core:menu:deny-text", + "markdownDescription": "Denies the text command without any pre-configured scope." + }, + { + "description": "Default permissions for the plugin, which enables all commands.\n#### This default permission set includes:\n\n- `allow-resolve-directory`\n- `allow-resolve`\n- `allow-normalize`\n- `allow-join`\n- `allow-dirname`\n- `allow-extname`\n- `allow-basename`\n- `allow-is-absolute`", + "type": "string", + "const": "core:path:default", + "markdownDescription": "Default permissions for the plugin, which enables all commands.\n#### This default permission set includes:\n\n- `allow-resolve-directory`\n- `allow-resolve`\n- `allow-normalize`\n- `allow-join`\n- `allow-dirname`\n- `allow-extname`\n- `allow-basename`\n- `allow-is-absolute`" + }, + { + "description": "Enables the basename command without any pre-configured scope.", + "type": "string", + "const": "core:path:allow-basename", + "markdownDescription": "Enables the basename command without any pre-configured scope." + }, + { + "description": "Enables the dirname command without any pre-configured scope.", + "type": "string", + "const": "core:path:allow-dirname", + "markdownDescription": "Enables the dirname command without any pre-configured scope." + }, + { + "description": "Enables the extname command without any pre-configured scope.", + "type": "string", + "const": "core:path:allow-extname", + "markdownDescription": "Enables the extname command without any pre-configured scope." + }, + { + "description": "Enables the is_absolute command without any pre-configured scope.", + "type": "string", + "const": "core:path:allow-is-absolute", + "markdownDescription": "Enables the is_absolute command without any pre-configured scope." + }, + { + "description": "Enables the join command without any pre-configured scope.", + "type": "string", + "const": "core:path:allow-join", + "markdownDescription": "Enables the join command without any pre-configured scope." + }, + { + "description": "Enables the normalize command without any pre-configured scope.", + "type": "string", + "const": "core:path:allow-normalize", + "markdownDescription": "Enables the normalize command without any pre-configured scope." + }, + { + "description": "Enables the resolve command without any pre-configured scope.", + "type": "string", + "const": "core:path:allow-resolve", + "markdownDescription": "Enables the resolve command without any pre-configured scope." + }, + { + "description": "Enables the resolve_directory command without any pre-configured scope.", + "type": "string", + "const": "core:path:allow-resolve-directory", + "markdownDescription": "Enables the resolve_directory command without any pre-configured scope." + }, + { + "description": "Denies the basename command without any pre-configured scope.", + "type": "string", + "const": "core:path:deny-basename", + "markdownDescription": "Denies the basename command without any pre-configured scope." + }, + { + "description": "Denies the dirname command without any pre-configured scope.", + "type": "string", + "const": "core:path:deny-dirname", + "markdownDescription": "Denies the dirname command without any pre-configured scope." + }, + { + "description": "Denies the extname command without any pre-configured scope.", + "type": "string", + "const": "core:path:deny-extname", + "markdownDescription": "Denies the extname command without any pre-configured scope." + }, + { + "description": "Denies the is_absolute command without any pre-configured scope.", + "type": "string", + "const": "core:path:deny-is-absolute", + "markdownDescription": "Denies the is_absolute command without any pre-configured scope." + }, + { + "description": "Denies the join command without any pre-configured scope.", + "type": "string", + "const": "core:path:deny-join", + "markdownDescription": "Denies the join command without any pre-configured scope." + }, + { + "description": "Denies the normalize command without any pre-configured scope.", + "type": "string", + "const": "core:path:deny-normalize", + "markdownDescription": "Denies the normalize command without any pre-configured scope." + }, + { + "description": "Denies the resolve command without any pre-configured scope.", + "type": "string", + "const": "core:path:deny-resolve", + "markdownDescription": "Denies the resolve command without any pre-configured scope." + }, + { + "description": "Denies the resolve_directory command without any pre-configured scope.", + "type": "string", + "const": "core:path:deny-resolve-directory", + "markdownDescription": "Denies the resolve_directory command without any pre-configured scope." + }, + { + "description": "Default permissions for the plugin, which enables all commands.\n#### This default permission set includes:\n\n- `allow-close`", + "type": "string", + "const": "core:resources:default", + "markdownDescription": "Default permissions for the plugin, which enables all commands.\n#### This default permission set includes:\n\n- `allow-close`" + }, + { + "description": "Enables the close command without any pre-configured scope.", + "type": "string", + "const": "core:resources:allow-close", + "markdownDescription": "Enables the close command without any pre-configured scope." + }, + { + "description": "Denies the close command without any pre-configured scope.", + "type": "string", + "const": "core:resources:deny-close", + "markdownDescription": "Denies the close command without any pre-configured scope." + }, + { + "description": "Default permissions for the plugin, which enables all commands.\n#### This default permission set includes:\n\n- `allow-new`\n- `allow-get-by-id`\n- `allow-remove-by-id`\n- `allow-set-icon`\n- `allow-set-menu`\n- `allow-set-tooltip`\n- `allow-set-title`\n- `allow-set-visible`\n- `allow-set-temp-dir-path`\n- `allow-set-icon-as-template`\n- `allow-set-show-menu-on-left-click`", + "type": "string", + "const": "core:tray:default", + "markdownDescription": "Default permissions for the plugin, which enables all commands.\n#### This default permission set includes:\n\n- `allow-new`\n- `allow-get-by-id`\n- `allow-remove-by-id`\n- `allow-set-icon`\n- `allow-set-menu`\n- `allow-set-tooltip`\n- `allow-set-title`\n- `allow-set-visible`\n- `allow-set-temp-dir-path`\n- `allow-set-icon-as-template`\n- `allow-set-show-menu-on-left-click`" + }, + { + "description": "Enables the get_by_id command without any pre-configured scope.", + "type": "string", + "const": "core:tray:allow-get-by-id", + "markdownDescription": "Enables the get_by_id command without any pre-configured scope." + }, + { + "description": "Enables the new command without any pre-configured scope.", + "type": "string", + "const": "core:tray:allow-new", + "markdownDescription": "Enables the new command without any pre-configured scope." + }, + { + "description": "Enables the remove_by_id command without any pre-configured scope.", + "type": "string", + "const": "core:tray:allow-remove-by-id", + "markdownDescription": "Enables the remove_by_id command without any pre-configured scope." + }, + { + "description": "Enables the set_icon command without any pre-configured scope.", + "type": "string", + "const": "core:tray:allow-set-icon", + "markdownDescription": "Enables the set_icon command without any pre-configured scope." + }, + { + "description": "Enables the set_icon_as_template command without any pre-configured scope.", + "type": "string", + "const": "core:tray:allow-set-icon-as-template", + "markdownDescription": "Enables the set_icon_as_template command without any pre-configured scope." + }, + { + "description": "Enables the set_menu command without any pre-configured scope.", + "type": "string", + "const": "core:tray:allow-set-menu", + "markdownDescription": "Enables the set_menu command without any pre-configured scope." + }, + { + "description": "Enables the set_show_menu_on_left_click command without any pre-configured scope.", + "type": "string", + "const": "core:tray:allow-set-show-menu-on-left-click", + "markdownDescription": "Enables the set_show_menu_on_left_click command without any pre-configured scope." + }, + { + "description": "Enables the set_temp_dir_path command without any pre-configured scope.", + "type": "string", + "const": "core:tray:allow-set-temp-dir-path", + "markdownDescription": "Enables the set_temp_dir_path command without any pre-configured scope." + }, + { + "description": "Enables the set_title command without any pre-configured scope.", + "type": "string", + "const": "core:tray:allow-set-title", + "markdownDescription": "Enables the set_title command without any pre-configured scope." + }, + { + "description": "Enables the set_tooltip command without any pre-configured scope.", + "type": "string", + "const": "core:tray:allow-set-tooltip", + "markdownDescription": "Enables the set_tooltip command without any pre-configured scope." + }, + { + "description": "Enables the set_visible command without any pre-configured scope.", + "type": "string", + "const": "core:tray:allow-set-visible", + "markdownDescription": "Enables the set_visible command without any pre-configured scope." + }, + { + "description": "Denies the get_by_id command without any pre-configured scope.", + "type": "string", + "const": "core:tray:deny-get-by-id", + "markdownDescription": "Denies the get_by_id command without any pre-configured scope." + }, + { + "description": "Denies the new command without any pre-configured scope.", + "type": "string", + "const": "core:tray:deny-new", + "markdownDescription": "Denies the new command without any pre-configured scope." + }, + { + "description": "Denies the remove_by_id command without any pre-configured scope.", + "type": "string", + "const": "core:tray:deny-remove-by-id", + "markdownDescription": "Denies the remove_by_id command without any pre-configured scope." + }, + { + "description": "Denies the set_icon command without any pre-configured scope.", + "type": "string", + "const": "core:tray:deny-set-icon", + "markdownDescription": "Denies the set_icon command without any pre-configured scope." + }, + { + "description": "Denies the set_icon_as_template command without any pre-configured scope.", + "type": "string", + "const": "core:tray:deny-set-icon-as-template", + "markdownDescription": "Denies the set_icon_as_template command without any pre-configured scope." + }, + { + "description": "Denies the set_menu command without any pre-configured scope.", + "type": "string", + "const": "core:tray:deny-set-menu", + "markdownDescription": "Denies the set_menu command without any pre-configured scope." + }, + { + "description": "Denies the set_show_menu_on_left_click command without any pre-configured scope.", + "type": "string", + "const": "core:tray:deny-set-show-menu-on-left-click", + "markdownDescription": "Denies the set_show_menu_on_left_click command without any pre-configured scope." + }, + { + "description": "Denies the set_temp_dir_path command without any pre-configured scope.", + "type": "string", + "const": "core:tray:deny-set-temp-dir-path", + "markdownDescription": "Denies the set_temp_dir_path command without any pre-configured scope." + }, + { + "description": "Denies the set_title command without any pre-configured scope.", + "type": "string", + "const": "core:tray:deny-set-title", + "markdownDescription": "Denies the set_title command without any pre-configured scope." + }, + { + "description": "Denies the set_tooltip command without any pre-configured scope.", + "type": "string", + "const": "core:tray:deny-set-tooltip", + "markdownDescription": "Denies the set_tooltip command without any pre-configured scope." + }, + { + "description": "Denies the set_visible command without any pre-configured scope.", + "type": "string", + "const": "core:tray:deny-set-visible", + "markdownDescription": "Denies the set_visible command without any pre-configured scope." + }, + { + "description": "Default permissions for the plugin.\n#### This default permission set includes:\n\n- `allow-get-all-webviews`\n- `allow-webview-position`\n- `allow-webview-size`\n- `allow-internal-toggle-devtools`", + "type": "string", + "const": "core:webview:default", + "markdownDescription": "Default permissions for the plugin.\n#### This default permission set includes:\n\n- `allow-get-all-webviews`\n- `allow-webview-position`\n- `allow-webview-size`\n- `allow-internal-toggle-devtools`" + }, + { + "description": "Enables the clear_all_browsing_data command without any pre-configured scope.", + "type": "string", + "const": "core:webview:allow-clear-all-browsing-data", + "markdownDescription": "Enables the clear_all_browsing_data command without any pre-configured scope." + }, + { + "description": "Enables the create_webview command without any pre-configured scope.", + "type": "string", + "const": "core:webview:allow-create-webview", + "markdownDescription": "Enables the create_webview command without any pre-configured scope." + }, + { + "description": "Enables the create_webview_window command without any pre-configured scope.", + "type": "string", + "const": "core:webview:allow-create-webview-window", + "markdownDescription": "Enables the create_webview_window command without any pre-configured scope." + }, + { + "description": "Enables the get_all_webviews command without any pre-configured scope.", + "type": "string", + "const": "core:webview:allow-get-all-webviews", + "markdownDescription": "Enables the get_all_webviews command without any pre-configured scope." + }, + { + "description": "Enables the internal_toggle_devtools command without any pre-configured scope.", + "type": "string", + "const": "core:webview:allow-internal-toggle-devtools", + "markdownDescription": "Enables the internal_toggle_devtools command without any pre-configured scope." + }, + { + "description": "Enables the print command without any pre-configured scope.", + "type": "string", + "const": "core:webview:allow-print", + "markdownDescription": "Enables the print command without any pre-configured scope." + }, + { + "description": "Enables the reparent command without any pre-configured scope.", + "type": "string", + "const": "core:webview:allow-reparent", + "markdownDescription": "Enables the reparent command without any pre-configured scope." + }, + { + "description": "Enables the set_webview_auto_resize command without any pre-configured scope.", + "type": "string", + "const": "core:webview:allow-set-webview-auto-resize", + "markdownDescription": "Enables the set_webview_auto_resize command without any pre-configured scope." + }, + { + "description": "Enables the set_webview_background_color command without any pre-configured scope.", + "type": "string", + "const": "core:webview:allow-set-webview-background-color", + "markdownDescription": "Enables the set_webview_background_color command without any pre-configured scope." + }, + { + "description": "Enables the set_webview_focus command without any pre-configured scope.", + "type": "string", + "const": "core:webview:allow-set-webview-focus", + "markdownDescription": "Enables the set_webview_focus command without any pre-configured scope." + }, + { + "description": "Enables the set_webview_position command without any pre-configured scope.", + "type": "string", + "const": "core:webview:allow-set-webview-position", + "markdownDescription": "Enables the set_webview_position command without any pre-configured scope." + }, + { + "description": "Enables the set_webview_size command without any pre-configured scope.", + "type": "string", + "const": "core:webview:allow-set-webview-size", + "markdownDescription": "Enables the set_webview_size command without any pre-configured scope." + }, + { + "description": "Enables the set_webview_zoom command without any pre-configured scope.", + "type": "string", + "const": "core:webview:allow-set-webview-zoom", + "markdownDescription": "Enables the set_webview_zoom command without any pre-configured scope." + }, + { + "description": "Enables the webview_close command without any pre-configured scope.", + "type": "string", + "const": "core:webview:allow-webview-close", + "markdownDescription": "Enables the webview_close command without any pre-configured scope." + }, + { + "description": "Enables the webview_hide command without any pre-configured scope.", + "type": "string", + "const": "core:webview:allow-webview-hide", + "markdownDescription": "Enables the webview_hide command without any pre-configured scope." + }, + { + "description": "Enables the webview_position command without any pre-configured scope.", + "type": "string", + "const": "core:webview:allow-webview-position", + "markdownDescription": "Enables the webview_position command without any pre-configured scope." + }, + { + "description": "Enables the webview_show command without any pre-configured scope.", + "type": "string", + "const": "core:webview:allow-webview-show", + "markdownDescription": "Enables the webview_show command without any pre-configured scope." + }, + { + "description": "Enables the webview_size command without any pre-configured scope.", + "type": "string", + "const": "core:webview:allow-webview-size", + "markdownDescription": "Enables the webview_size command without any pre-configured scope." + }, + { + "description": "Denies the clear_all_browsing_data command without any pre-configured scope.", + "type": "string", + "const": "core:webview:deny-clear-all-browsing-data", + "markdownDescription": "Denies the clear_all_browsing_data command without any pre-configured scope." + }, + { + "description": "Denies the create_webview command without any pre-configured scope.", + "type": "string", + "const": "core:webview:deny-create-webview", + "markdownDescription": "Denies the create_webview command without any pre-configured scope." + }, + { + "description": "Denies the create_webview_window command without any pre-configured scope.", + "type": "string", + "const": "core:webview:deny-create-webview-window", + "markdownDescription": "Denies the create_webview_window command without any pre-configured scope." + }, + { + "description": "Denies the get_all_webviews command without any pre-configured scope.", + "type": "string", + "const": "core:webview:deny-get-all-webviews", + "markdownDescription": "Denies the get_all_webviews command without any pre-configured scope." + }, + { + "description": "Denies the internal_toggle_devtools command without any pre-configured scope.", + "type": "string", + "const": "core:webview:deny-internal-toggle-devtools", + "markdownDescription": "Denies the internal_toggle_devtools command without any pre-configured scope." + }, + { + "description": "Denies the print command without any pre-configured scope.", + "type": "string", + "const": "core:webview:deny-print", + "markdownDescription": "Denies the print command without any pre-configured scope." + }, + { + "description": "Denies the reparent command without any pre-configured scope.", + "type": "string", + "const": "core:webview:deny-reparent", + "markdownDescription": "Denies the reparent command without any pre-configured scope." + }, + { + "description": "Denies the set_webview_auto_resize command without any pre-configured scope.", + "type": "string", + "const": "core:webview:deny-set-webview-auto-resize", + "markdownDescription": "Denies the set_webview_auto_resize command without any pre-configured scope." + }, + { + "description": "Denies the set_webview_background_color command without any pre-configured scope.", + "type": "string", + "const": "core:webview:deny-set-webview-background-color", + "markdownDescription": "Denies the set_webview_background_color command without any pre-configured scope." + }, + { + "description": "Denies the set_webview_focus command without any pre-configured scope.", + "type": "string", + "const": "core:webview:deny-set-webview-focus", + "markdownDescription": "Denies the set_webview_focus command without any pre-configured scope." + }, + { + "description": "Denies the set_webview_position command without any pre-configured scope.", + "type": "string", + "const": "core:webview:deny-set-webview-position", + "markdownDescription": "Denies the set_webview_position command without any pre-configured scope." + }, + { + "description": "Denies the set_webview_size command without any pre-configured scope.", + "type": "string", + "const": "core:webview:deny-set-webview-size", + "markdownDescription": "Denies the set_webview_size command without any pre-configured scope." + }, + { + "description": "Denies the set_webview_zoom command without any pre-configured scope.", + "type": "string", + "const": "core:webview:deny-set-webview-zoom", + "markdownDescription": "Denies the set_webview_zoom command without any pre-configured scope." + }, + { + "description": "Denies the webview_close command without any pre-configured scope.", + "type": "string", + "const": "core:webview:deny-webview-close", + "markdownDescription": "Denies the webview_close command without any pre-configured scope." + }, + { + "description": "Denies the webview_hide command without any pre-configured scope.", + "type": "string", + "const": "core:webview:deny-webview-hide", + "markdownDescription": "Denies the webview_hide command without any pre-configured scope." + }, + { + "description": "Denies the webview_position command without any pre-configured scope.", + "type": "string", + "const": "core:webview:deny-webview-position", + "markdownDescription": "Denies the webview_position command without any pre-configured scope." + }, + { + "description": "Denies the webview_show command without any pre-configured scope.", + "type": "string", + "const": "core:webview:deny-webview-show", + "markdownDescription": "Denies the webview_show command without any pre-configured scope." + }, + { + "description": "Denies the webview_size command without any pre-configured scope.", + "type": "string", + "const": "core:webview:deny-webview-size", + "markdownDescription": "Denies the webview_size command without any pre-configured scope." + }, + { + "description": "Default permissions for the plugin.\n#### This default permission set includes:\n\n- `allow-get-all-windows`\n- `allow-scale-factor`\n- `allow-inner-position`\n- `allow-outer-position`\n- `allow-inner-size`\n- `allow-outer-size`\n- `allow-is-fullscreen`\n- `allow-is-minimized`\n- `allow-is-maximized`\n- `allow-is-focused`\n- `allow-is-decorated`\n- `allow-is-resizable`\n- `allow-is-maximizable`\n- `allow-is-minimizable`\n- `allow-is-closable`\n- `allow-is-visible`\n- `allow-is-enabled`\n- `allow-title`\n- `allow-current-monitor`\n- `allow-primary-monitor`\n- `allow-monitor-from-point`\n- `allow-available-monitors`\n- `allow-cursor-position`\n- `allow-theme`\n- `allow-is-always-on-top`\n- `allow-internal-toggle-maximize`", + "type": "string", + "const": "core:window:default", + "markdownDescription": "Default permissions for the plugin.\n#### This default permission set includes:\n\n- `allow-get-all-windows`\n- `allow-scale-factor`\n- `allow-inner-position`\n- `allow-outer-position`\n- `allow-inner-size`\n- `allow-outer-size`\n- `allow-is-fullscreen`\n- `allow-is-minimized`\n- `allow-is-maximized`\n- `allow-is-focused`\n- `allow-is-decorated`\n- `allow-is-resizable`\n- `allow-is-maximizable`\n- `allow-is-minimizable`\n- `allow-is-closable`\n- `allow-is-visible`\n- `allow-is-enabled`\n- `allow-title`\n- `allow-current-monitor`\n- `allow-primary-monitor`\n- `allow-monitor-from-point`\n- `allow-available-monitors`\n- `allow-cursor-position`\n- `allow-theme`\n- `allow-is-always-on-top`\n- `allow-internal-toggle-maximize`" + }, + { + "description": "Enables the available_monitors command without any pre-configured scope.", + "type": "string", + "const": "core:window:allow-available-monitors", + "markdownDescription": "Enables the available_monitors command without any pre-configured scope." + }, + { + "description": "Enables the center command without any pre-configured scope.", + "type": "string", + "const": "core:window:allow-center", + "markdownDescription": "Enables the center command without any pre-configured scope." + }, + { + "description": "Enables the close command without any pre-configured scope.", + "type": "string", + "const": "core:window:allow-close", + "markdownDescription": "Enables the close command without any pre-configured scope." + }, + { + "description": "Enables the create command without any pre-configured scope.", + "type": "string", + "const": "core:window:allow-create", + "markdownDescription": "Enables the create command without any pre-configured scope." + }, + { + "description": "Enables the current_monitor command without any pre-configured scope.", + "type": "string", + "const": "core:window:allow-current-monitor", + "markdownDescription": "Enables the current_monitor command without any pre-configured scope." + }, + { + "description": "Enables the cursor_position command without any pre-configured scope.", + "type": "string", + "const": "core:window:allow-cursor-position", + "markdownDescription": "Enables the cursor_position command without any pre-configured scope." + }, + { + "description": "Enables the destroy command without any pre-configured scope.", + "type": "string", + "const": "core:window:allow-destroy", + "markdownDescription": "Enables the destroy command without any pre-configured scope." + }, + { + "description": "Enables the get_all_windows command without any pre-configured scope.", + "type": "string", + "const": "core:window:allow-get-all-windows", + "markdownDescription": "Enables the get_all_windows command without any pre-configured scope." + }, + { + "description": "Enables the hide command without any pre-configured scope.", + "type": "string", + "const": "core:window:allow-hide", + "markdownDescription": "Enables the hide command without any pre-configured scope." + }, + { + "description": "Enables the inner_position command without any pre-configured scope.", + "type": "string", + "const": "core:window:allow-inner-position", + "markdownDescription": "Enables the inner_position command without any pre-configured scope." + }, + { + "description": "Enables the inner_size command without any pre-configured scope.", + "type": "string", + "const": "core:window:allow-inner-size", + "markdownDescription": "Enables the inner_size command without any pre-configured scope." + }, + { + "description": "Enables the internal_toggle_maximize command without any pre-configured scope.", + "type": "string", + "const": "core:window:allow-internal-toggle-maximize", + "markdownDescription": "Enables the internal_toggle_maximize command without any pre-configured scope." + }, + { + "description": "Enables the is_always_on_top command without any pre-configured scope.", + "type": "string", + "const": "core:window:allow-is-always-on-top", + "markdownDescription": "Enables the is_always_on_top command without any pre-configured scope." + }, + { + "description": "Enables the is_closable command without any pre-configured scope.", + "type": "string", + "const": "core:window:allow-is-closable", + "markdownDescription": "Enables the is_closable command without any pre-configured scope." + }, + { + "description": "Enables the is_decorated command without any pre-configured scope.", + "type": "string", + "const": "core:window:allow-is-decorated", + "markdownDescription": "Enables the is_decorated command without any pre-configured scope." + }, + { + "description": "Enables the is_enabled command without any pre-configured scope.", + "type": "string", + "const": "core:window:allow-is-enabled", + "markdownDescription": "Enables the is_enabled command without any pre-configured scope." + }, + { + "description": "Enables the is_focused command without any pre-configured scope.", + "type": "string", + "const": "core:window:allow-is-focused", + "markdownDescription": "Enables the is_focused command without any pre-configured scope." + }, + { + "description": "Enables the is_fullscreen command without any pre-configured scope.", + "type": "string", + "const": "core:window:allow-is-fullscreen", + "markdownDescription": "Enables the is_fullscreen command without any pre-configured scope." + }, + { + "description": "Enables the is_maximizable command without any pre-configured scope.", + "type": "string", + "const": "core:window:allow-is-maximizable", + "markdownDescription": "Enables the is_maximizable command without any pre-configured scope." + }, + { + "description": "Enables the is_maximized command without any pre-configured scope.", + "type": "string", + "const": "core:window:allow-is-maximized", + "markdownDescription": "Enables the is_maximized command without any pre-configured scope." + }, + { + "description": "Enables the is_minimizable command without any pre-configured scope.", + "type": "string", + "const": "core:window:allow-is-minimizable", + "markdownDescription": "Enables the is_minimizable command without any pre-configured scope." + }, + { + "description": "Enables the is_minimized command without any pre-configured scope.", + "type": "string", + "const": "core:window:allow-is-minimized", + "markdownDescription": "Enables the is_minimized command without any pre-configured scope." + }, + { + "description": "Enables the is_resizable command without any pre-configured scope.", + "type": "string", + "const": "core:window:allow-is-resizable", + "markdownDescription": "Enables the is_resizable command without any pre-configured scope." + }, + { + "description": "Enables the is_visible command without any pre-configured scope.", + "type": "string", + "const": "core:window:allow-is-visible", + "markdownDescription": "Enables the is_visible command without any pre-configured scope." + }, + { + "description": "Enables the maximize command without any pre-configured scope.", + "type": "string", + "const": "core:window:allow-maximize", + "markdownDescription": "Enables the maximize command without any pre-configured scope." + }, + { + "description": "Enables the minimize command without any pre-configured scope.", + "type": "string", + "const": "core:window:allow-minimize", + "markdownDescription": "Enables the minimize command without any pre-configured scope." + }, + { + "description": "Enables the monitor_from_point command without any pre-configured scope.", + "type": "string", + "const": "core:window:allow-monitor-from-point", + "markdownDescription": "Enables the monitor_from_point command without any pre-configured scope." + }, + { + "description": "Enables the outer_position command without any pre-configured scope.", + "type": "string", + "const": "core:window:allow-outer-position", + "markdownDescription": "Enables the outer_position command without any pre-configured scope." + }, + { + "description": "Enables the outer_size command without any pre-configured scope.", + "type": "string", + "const": "core:window:allow-outer-size", + "markdownDescription": "Enables the outer_size command without any pre-configured scope." + }, + { + "description": "Enables the primary_monitor command without any pre-configured scope.", + "type": "string", + "const": "core:window:allow-primary-monitor", + "markdownDescription": "Enables the primary_monitor command without any pre-configured scope." + }, + { + "description": "Enables the request_user_attention command without any pre-configured scope.", + "type": "string", + "const": "core:window:allow-request-user-attention", + "markdownDescription": "Enables the request_user_attention command without any pre-configured scope." + }, + { + "description": "Enables the scale_factor command without any pre-configured scope.", + "type": "string", + "const": "core:window:allow-scale-factor", + "markdownDescription": "Enables the scale_factor command without any pre-configured scope." + }, + { + "description": "Enables the set_always_on_bottom command without any pre-configured scope.", + "type": "string", + "const": "core:window:allow-set-always-on-bottom", + "markdownDescription": "Enables the set_always_on_bottom command without any pre-configured scope." + }, + { + "description": "Enables the set_always_on_top command without any pre-configured scope.", + "type": "string", + "const": "core:window:allow-set-always-on-top", + "markdownDescription": "Enables the set_always_on_top command without any pre-configured scope." + }, + { + "description": "Enables the set_background_color command without any pre-configured scope.", + "type": "string", + "const": "core:window:allow-set-background-color", + "markdownDescription": "Enables the set_background_color command without any pre-configured scope." + }, + { + "description": "Enables the set_badge_count command without any pre-configured scope.", + "type": "string", + "const": "core:window:allow-set-badge-count", + "markdownDescription": "Enables the set_badge_count command without any pre-configured scope." + }, + { + "description": "Enables the set_badge_label command without any pre-configured scope.", + "type": "string", + "const": "core:window:allow-set-badge-label", + "markdownDescription": "Enables the set_badge_label command without any pre-configured scope." + }, + { + "description": "Enables the set_closable command without any pre-configured scope.", + "type": "string", + "const": "core:window:allow-set-closable", + "markdownDescription": "Enables the set_closable command without any pre-configured scope." + }, + { + "description": "Enables the set_content_protected command without any pre-configured scope.", + "type": "string", + "const": "core:window:allow-set-content-protected", + "markdownDescription": "Enables the set_content_protected command without any pre-configured scope." + }, + { + "description": "Enables the set_cursor_grab command without any pre-configured scope.", + "type": "string", + "const": "core:window:allow-set-cursor-grab", + "markdownDescription": "Enables the set_cursor_grab command without any pre-configured scope." + }, + { + "description": "Enables the set_cursor_icon command without any pre-configured scope.", + "type": "string", + "const": "core:window:allow-set-cursor-icon", + "markdownDescription": "Enables the set_cursor_icon command without any pre-configured scope." + }, + { + "description": "Enables the set_cursor_position command without any pre-configured scope.", + "type": "string", + "const": "core:window:allow-set-cursor-position", + "markdownDescription": "Enables the set_cursor_position command without any pre-configured scope." + }, + { + "description": "Enables the set_cursor_visible command without any pre-configured scope.", + "type": "string", + "const": "core:window:allow-set-cursor-visible", + "markdownDescription": "Enables the set_cursor_visible command without any pre-configured scope." + }, + { + "description": "Enables the set_decorations command without any pre-configured scope.", + "type": "string", + "const": "core:window:allow-set-decorations", + "markdownDescription": "Enables the set_decorations command without any pre-configured scope." + }, + { + "description": "Enables the set_effects command without any pre-configured scope.", + "type": "string", + "const": "core:window:allow-set-effects", + "markdownDescription": "Enables the set_effects command without any pre-configured scope." + }, + { + "description": "Enables the set_enabled command without any pre-configured scope.", + "type": "string", + "const": "core:window:allow-set-enabled", + "markdownDescription": "Enables the set_enabled command without any pre-configured scope." + }, + { + "description": "Enables the set_focus command without any pre-configured scope.", + "type": "string", + "const": "core:window:allow-set-focus", + "markdownDescription": "Enables the set_focus command without any pre-configured scope." + }, + { + "description": "Enables the set_focusable command without any pre-configured scope.", + "type": "string", + "const": "core:window:allow-set-focusable", + "markdownDescription": "Enables the set_focusable command without any pre-configured scope." + }, + { + "description": "Enables the set_fullscreen command without any pre-configured scope.", + "type": "string", + "const": "core:window:allow-set-fullscreen", + "markdownDescription": "Enables the set_fullscreen command without any pre-configured scope." + }, + { + "description": "Enables the set_icon command without any pre-configured scope.", + "type": "string", + "const": "core:window:allow-set-icon", + "markdownDescription": "Enables the set_icon command without any pre-configured scope." + }, + { + "description": "Enables the set_ignore_cursor_events command without any pre-configured scope.", + "type": "string", + "const": "core:window:allow-set-ignore-cursor-events", + "markdownDescription": "Enables the set_ignore_cursor_events command without any pre-configured scope." + }, + { + "description": "Enables the set_max_size command without any pre-configured scope.", + "type": "string", + "const": "core:window:allow-set-max-size", + "markdownDescription": "Enables the set_max_size command without any pre-configured scope." + }, + { + "description": "Enables the set_maximizable command without any pre-configured scope.", + "type": "string", + "const": "core:window:allow-set-maximizable", + "markdownDescription": "Enables the set_maximizable command without any pre-configured scope." + }, + { + "description": "Enables the set_min_size command without any pre-configured scope.", + "type": "string", + "const": "core:window:allow-set-min-size", + "markdownDescription": "Enables the set_min_size command without any pre-configured scope." + }, + { + "description": "Enables the set_minimizable command without any pre-configured scope.", + "type": "string", + "const": "core:window:allow-set-minimizable", + "markdownDescription": "Enables the set_minimizable command without any pre-configured scope." + }, + { + "description": "Enables the set_overlay_icon command without any pre-configured scope.", + "type": "string", + "const": "core:window:allow-set-overlay-icon", + "markdownDescription": "Enables the set_overlay_icon command without any pre-configured scope." + }, + { + "description": "Enables the set_position command without any pre-configured scope.", + "type": "string", + "const": "core:window:allow-set-position", + "markdownDescription": "Enables the set_position command without any pre-configured scope." + }, + { + "description": "Enables the set_progress_bar command without any pre-configured scope.", + "type": "string", + "const": "core:window:allow-set-progress-bar", + "markdownDescription": "Enables the set_progress_bar command without any pre-configured scope." + }, + { + "description": "Enables the set_resizable command without any pre-configured scope.", + "type": "string", + "const": "core:window:allow-set-resizable", + "markdownDescription": "Enables the set_resizable command without any pre-configured scope." + }, + { + "description": "Enables the set_shadow command without any pre-configured scope.", + "type": "string", + "const": "core:window:allow-set-shadow", + "markdownDescription": "Enables the set_shadow command without any pre-configured scope." + }, + { + "description": "Enables the set_simple_fullscreen command without any pre-configured scope.", + "type": "string", + "const": "core:window:allow-set-simple-fullscreen", + "markdownDescription": "Enables the set_simple_fullscreen command without any pre-configured scope." + }, + { + "description": "Enables the set_size command without any pre-configured scope.", + "type": "string", + "const": "core:window:allow-set-size", + "markdownDescription": "Enables the set_size command without any pre-configured scope." + }, + { + "description": "Enables the set_size_constraints command without any pre-configured scope.", + "type": "string", + "const": "core:window:allow-set-size-constraints", + "markdownDescription": "Enables the set_size_constraints command without any pre-configured scope." + }, + { + "description": "Enables the set_skip_taskbar command without any pre-configured scope.", + "type": "string", + "const": "core:window:allow-set-skip-taskbar", + "markdownDescription": "Enables the set_skip_taskbar command without any pre-configured scope." + }, + { + "description": "Enables the set_theme command without any pre-configured scope.", + "type": "string", + "const": "core:window:allow-set-theme", + "markdownDescription": "Enables the set_theme command without any pre-configured scope." + }, + { + "description": "Enables the set_title command without any pre-configured scope.", + "type": "string", + "const": "core:window:allow-set-title", + "markdownDescription": "Enables the set_title command without any pre-configured scope." + }, + { + "description": "Enables the set_title_bar_style command without any pre-configured scope.", + "type": "string", + "const": "core:window:allow-set-title-bar-style", + "markdownDescription": "Enables the set_title_bar_style command without any pre-configured scope." + }, + { + "description": "Enables the set_visible_on_all_workspaces command without any pre-configured scope.", + "type": "string", + "const": "core:window:allow-set-visible-on-all-workspaces", + "markdownDescription": "Enables the set_visible_on_all_workspaces command without any pre-configured scope." + }, + { + "description": "Enables the show command without any pre-configured scope.", + "type": "string", + "const": "core:window:allow-show", + "markdownDescription": "Enables the show command without any pre-configured scope." + }, + { + "description": "Enables the start_dragging command without any pre-configured scope.", + "type": "string", + "const": "core:window:allow-start-dragging", + "markdownDescription": "Enables the start_dragging command without any pre-configured scope." + }, + { + "description": "Enables the start_resize_dragging command without any pre-configured scope.", + "type": "string", + "const": "core:window:allow-start-resize-dragging", + "markdownDescription": "Enables the start_resize_dragging command without any pre-configured scope." + }, + { + "description": "Enables the theme command without any pre-configured scope.", + "type": "string", + "const": "core:window:allow-theme", + "markdownDescription": "Enables the theme command without any pre-configured scope." + }, + { + "description": "Enables the title command without any pre-configured scope.", + "type": "string", + "const": "core:window:allow-title", + "markdownDescription": "Enables the title command without any pre-configured scope." + }, + { + "description": "Enables the toggle_maximize command without any pre-configured scope.", + "type": "string", + "const": "core:window:allow-toggle-maximize", + "markdownDescription": "Enables the toggle_maximize command without any pre-configured scope." + }, + { + "description": "Enables the unmaximize command without any pre-configured scope.", + "type": "string", + "const": "core:window:allow-unmaximize", + "markdownDescription": "Enables the unmaximize command without any pre-configured scope." + }, + { + "description": "Enables the unminimize command without any pre-configured scope.", + "type": "string", + "const": "core:window:allow-unminimize", + "markdownDescription": "Enables the unminimize command without any pre-configured scope." + }, + { + "description": "Denies the available_monitors command without any pre-configured scope.", + "type": "string", + "const": "core:window:deny-available-monitors", + "markdownDescription": "Denies the available_monitors command without any pre-configured scope." + }, + { + "description": "Denies the center command without any pre-configured scope.", + "type": "string", + "const": "core:window:deny-center", + "markdownDescription": "Denies the center command without any pre-configured scope." + }, + { + "description": "Denies the close command without any pre-configured scope.", + "type": "string", + "const": "core:window:deny-close", + "markdownDescription": "Denies the close command without any pre-configured scope." + }, + { + "description": "Denies the create command without any pre-configured scope.", + "type": "string", + "const": "core:window:deny-create", + "markdownDescription": "Denies the create command without any pre-configured scope." + }, + { + "description": "Denies the current_monitor command without any pre-configured scope.", + "type": "string", + "const": "core:window:deny-current-monitor", + "markdownDescription": "Denies the current_monitor command without any pre-configured scope." + }, + { + "description": "Denies the cursor_position command without any pre-configured scope.", + "type": "string", + "const": "core:window:deny-cursor-position", + "markdownDescription": "Denies the cursor_position command without any pre-configured scope." + }, + { + "description": "Denies the destroy command without any pre-configured scope.", + "type": "string", + "const": "core:window:deny-destroy", + "markdownDescription": "Denies the destroy command without any pre-configured scope." + }, + { + "description": "Denies the get_all_windows command without any pre-configured scope.", + "type": "string", + "const": "core:window:deny-get-all-windows", + "markdownDescription": "Denies the get_all_windows command without any pre-configured scope." + }, + { + "description": "Denies the hide command without any pre-configured scope.", + "type": "string", + "const": "core:window:deny-hide", + "markdownDescription": "Denies the hide command without any pre-configured scope." + }, + { + "description": "Denies the inner_position command without any pre-configured scope.", + "type": "string", + "const": "core:window:deny-inner-position", + "markdownDescription": "Denies the inner_position command without any pre-configured scope." + }, + { + "description": "Denies the inner_size command without any pre-configured scope.", + "type": "string", + "const": "core:window:deny-inner-size", + "markdownDescription": "Denies the inner_size command without any pre-configured scope." + }, + { + "description": "Denies the internal_toggle_maximize command without any pre-configured scope.", + "type": "string", + "const": "core:window:deny-internal-toggle-maximize", + "markdownDescription": "Denies the internal_toggle_maximize command without any pre-configured scope." + }, + { + "description": "Denies the is_always_on_top command without any pre-configured scope.", + "type": "string", + "const": "core:window:deny-is-always-on-top", + "markdownDescription": "Denies the is_always_on_top command without any pre-configured scope." + }, + { + "description": "Denies the is_closable command without any pre-configured scope.", + "type": "string", + "const": "core:window:deny-is-closable", + "markdownDescription": "Denies the is_closable command without any pre-configured scope." + }, + { + "description": "Denies the is_decorated command without any pre-configured scope.", + "type": "string", + "const": "core:window:deny-is-decorated", + "markdownDescription": "Denies the is_decorated command without any pre-configured scope." + }, + { + "description": "Denies the is_enabled command without any pre-configured scope.", + "type": "string", + "const": "core:window:deny-is-enabled", + "markdownDescription": "Denies the is_enabled command without any pre-configured scope." + }, + { + "description": "Denies the is_focused command without any pre-configured scope.", + "type": "string", + "const": "core:window:deny-is-focused", + "markdownDescription": "Denies the is_focused command without any pre-configured scope." + }, + { + "description": "Denies the is_fullscreen command without any pre-configured scope.", + "type": "string", + "const": "core:window:deny-is-fullscreen", + "markdownDescription": "Denies the is_fullscreen command without any pre-configured scope." + }, + { + "description": "Denies the is_maximizable command without any pre-configured scope.", + "type": "string", + "const": "core:window:deny-is-maximizable", + "markdownDescription": "Denies the is_maximizable command without any pre-configured scope." + }, + { + "description": "Denies the is_maximized command without any pre-configured scope.", + "type": "string", + "const": "core:window:deny-is-maximized", + "markdownDescription": "Denies the is_maximized command without any pre-configured scope." + }, + { + "description": "Denies the is_minimizable command without any pre-configured scope.", + "type": "string", + "const": "core:window:deny-is-minimizable", + "markdownDescription": "Denies the is_minimizable command without any pre-configured scope." + }, + { + "description": "Denies the is_minimized command without any pre-configured scope.", + "type": "string", + "const": "core:window:deny-is-minimized", + "markdownDescription": "Denies the is_minimized command without any pre-configured scope." + }, + { + "description": "Denies the is_resizable command without any pre-configured scope.", + "type": "string", + "const": "core:window:deny-is-resizable", + "markdownDescription": "Denies the is_resizable command without any pre-configured scope." + }, + { + "description": "Denies the is_visible command without any pre-configured scope.", + "type": "string", + "const": "core:window:deny-is-visible", + "markdownDescription": "Denies the is_visible command without any pre-configured scope." + }, + { + "description": "Denies the maximize command without any pre-configured scope.", + "type": "string", + "const": "core:window:deny-maximize", + "markdownDescription": "Denies the maximize command without any pre-configured scope." + }, + { + "description": "Denies the minimize command without any pre-configured scope.", + "type": "string", + "const": "core:window:deny-minimize", + "markdownDescription": "Denies the minimize command without any pre-configured scope." + }, + { + "description": "Denies the monitor_from_point command without any pre-configured scope.", + "type": "string", + "const": "core:window:deny-monitor-from-point", + "markdownDescription": "Denies the monitor_from_point command without any pre-configured scope." + }, + { + "description": "Denies the outer_position command without any pre-configured scope.", + "type": "string", + "const": "core:window:deny-outer-position", + "markdownDescription": "Denies the outer_position command without any pre-configured scope." + }, + { + "description": "Denies the outer_size command without any pre-configured scope.", + "type": "string", + "const": "core:window:deny-outer-size", + "markdownDescription": "Denies the outer_size command without any pre-configured scope." + }, + { + "description": "Denies the primary_monitor command without any pre-configured scope.", + "type": "string", + "const": "core:window:deny-primary-monitor", + "markdownDescription": "Denies the primary_monitor command without any pre-configured scope." + }, + { + "description": "Denies the request_user_attention command without any pre-configured scope.", + "type": "string", + "const": "core:window:deny-request-user-attention", + "markdownDescription": "Denies the request_user_attention command without any pre-configured scope." + }, + { + "description": "Denies the scale_factor command without any pre-configured scope.", + "type": "string", + "const": "core:window:deny-scale-factor", + "markdownDescription": "Denies the scale_factor command without any pre-configured scope." + }, + { + "description": "Denies the set_always_on_bottom command without any pre-configured scope.", + "type": "string", + "const": "core:window:deny-set-always-on-bottom", + "markdownDescription": "Denies the set_always_on_bottom command without any pre-configured scope." + }, + { + "description": "Denies the set_always_on_top command without any pre-configured scope.", + "type": "string", + "const": "core:window:deny-set-always-on-top", + "markdownDescription": "Denies the set_always_on_top command without any pre-configured scope." + }, + { + "description": "Denies the set_background_color command without any pre-configured scope.", + "type": "string", + "const": "core:window:deny-set-background-color", + "markdownDescription": "Denies the set_background_color command without any pre-configured scope." + }, + { + "description": "Denies the set_badge_count command without any pre-configured scope.", + "type": "string", + "const": "core:window:deny-set-badge-count", + "markdownDescription": "Denies the set_badge_count command without any pre-configured scope." + }, + { + "description": "Denies the set_badge_label command without any pre-configured scope.", + "type": "string", + "const": "core:window:deny-set-badge-label", + "markdownDescription": "Denies the set_badge_label command without any pre-configured scope." + }, + { + "description": "Denies the set_closable command without any pre-configured scope.", + "type": "string", + "const": "core:window:deny-set-closable", + "markdownDescription": "Denies the set_closable command without any pre-configured scope." + }, + { + "description": "Denies the set_content_protected command without any pre-configured scope.", + "type": "string", + "const": "core:window:deny-set-content-protected", + "markdownDescription": "Denies the set_content_protected command without any pre-configured scope." + }, + { + "description": "Denies the set_cursor_grab command without any pre-configured scope.", + "type": "string", + "const": "core:window:deny-set-cursor-grab", + "markdownDescription": "Denies the set_cursor_grab command without any pre-configured scope." + }, + { + "description": "Denies the set_cursor_icon command without any pre-configured scope.", + "type": "string", + "const": "core:window:deny-set-cursor-icon", + "markdownDescription": "Denies the set_cursor_icon command without any pre-configured scope." + }, + { + "description": "Denies the set_cursor_position command without any pre-configured scope.", + "type": "string", + "const": "core:window:deny-set-cursor-position", + "markdownDescription": "Denies the set_cursor_position command without any pre-configured scope." + }, + { + "description": "Denies the set_cursor_visible command without any pre-configured scope.", + "type": "string", + "const": "core:window:deny-set-cursor-visible", + "markdownDescription": "Denies the set_cursor_visible command without any pre-configured scope." + }, + { + "description": "Denies the set_decorations command without any pre-configured scope.", + "type": "string", + "const": "core:window:deny-set-decorations", + "markdownDescription": "Denies the set_decorations command without any pre-configured scope." + }, + { + "description": "Denies the set_effects command without any pre-configured scope.", + "type": "string", + "const": "core:window:deny-set-effects", + "markdownDescription": "Denies the set_effects command without any pre-configured scope." + }, + { + "description": "Denies the set_enabled command without any pre-configured scope.", + "type": "string", + "const": "core:window:deny-set-enabled", + "markdownDescription": "Denies the set_enabled command without any pre-configured scope." + }, + { + "description": "Denies the set_focus command without any pre-configured scope.", + "type": "string", + "const": "core:window:deny-set-focus", + "markdownDescription": "Denies the set_focus command without any pre-configured scope." + }, + { + "description": "Denies the set_focusable command without any pre-configured scope.", + "type": "string", + "const": "core:window:deny-set-focusable", + "markdownDescription": "Denies the set_focusable command without any pre-configured scope." + }, + { + "description": "Denies the set_fullscreen command without any pre-configured scope.", + "type": "string", + "const": "core:window:deny-set-fullscreen", + "markdownDescription": "Denies the set_fullscreen command without any pre-configured scope." + }, + { + "description": "Denies the set_icon command without any pre-configured scope.", + "type": "string", + "const": "core:window:deny-set-icon", + "markdownDescription": "Denies the set_icon command without any pre-configured scope." + }, + { + "description": "Denies the set_ignore_cursor_events command without any pre-configured scope.", + "type": "string", + "const": "core:window:deny-set-ignore-cursor-events", + "markdownDescription": "Denies the set_ignore_cursor_events command without any pre-configured scope." + }, + { + "description": "Denies the set_max_size command without any pre-configured scope.", + "type": "string", + "const": "core:window:deny-set-max-size", + "markdownDescription": "Denies the set_max_size command without any pre-configured scope." + }, + { + "description": "Denies the set_maximizable command without any pre-configured scope.", + "type": "string", + "const": "core:window:deny-set-maximizable", + "markdownDescription": "Denies the set_maximizable command without any pre-configured scope." + }, + { + "description": "Denies the set_min_size command without any pre-configured scope.", + "type": "string", + "const": "core:window:deny-set-min-size", + "markdownDescription": "Denies the set_min_size command without any pre-configured scope." + }, + { + "description": "Denies the set_minimizable command without any pre-configured scope.", + "type": "string", + "const": "core:window:deny-set-minimizable", + "markdownDescription": "Denies the set_minimizable command without any pre-configured scope." + }, + { + "description": "Denies the set_overlay_icon command without any pre-configured scope.", + "type": "string", + "const": "core:window:deny-set-overlay-icon", + "markdownDescription": "Denies the set_overlay_icon command without any pre-configured scope." + }, + { + "description": "Denies the set_position command without any pre-configured scope.", + "type": "string", + "const": "core:window:deny-set-position", + "markdownDescription": "Denies the set_position command without any pre-configured scope." + }, + { + "description": "Denies the set_progress_bar command without any pre-configured scope.", + "type": "string", + "const": "core:window:deny-set-progress-bar", + "markdownDescription": "Denies the set_progress_bar command without any pre-configured scope." + }, + { + "description": "Denies the set_resizable command without any pre-configured scope.", + "type": "string", + "const": "core:window:deny-set-resizable", + "markdownDescription": "Denies the set_resizable command without any pre-configured scope." + }, + { + "description": "Denies the set_shadow command without any pre-configured scope.", + "type": "string", + "const": "core:window:deny-set-shadow", + "markdownDescription": "Denies the set_shadow command without any pre-configured scope." + }, + { + "description": "Denies the set_simple_fullscreen command without any pre-configured scope.", + "type": "string", + "const": "core:window:deny-set-simple-fullscreen", + "markdownDescription": "Denies the set_simple_fullscreen command without any pre-configured scope." + }, + { + "description": "Denies the set_size command without any pre-configured scope.", + "type": "string", + "const": "core:window:deny-set-size", + "markdownDescription": "Denies the set_size command without any pre-configured scope." + }, + { + "description": "Denies the set_size_constraints command without any pre-configured scope.", + "type": "string", + "const": "core:window:deny-set-size-constraints", + "markdownDescription": "Denies the set_size_constraints command without any pre-configured scope." + }, + { + "description": "Denies the set_skip_taskbar command without any pre-configured scope.", + "type": "string", + "const": "core:window:deny-set-skip-taskbar", + "markdownDescription": "Denies the set_skip_taskbar command without any pre-configured scope." + }, + { + "description": "Denies the set_theme command without any pre-configured scope.", + "type": "string", + "const": "core:window:deny-set-theme", + "markdownDescription": "Denies the set_theme command without any pre-configured scope." + }, + { + "description": "Denies the set_title command without any pre-configured scope.", + "type": "string", + "const": "core:window:deny-set-title", + "markdownDescription": "Denies the set_title command without any pre-configured scope." + }, + { + "description": "Denies the set_title_bar_style command without any pre-configured scope.", + "type": "string", + "const": "core:window:deny-set-title-bar-style", + "markdownDescription": "Denies the set_title_bar_style command without any pre-configured scope." + }, + { + "description": "Denies the set_visible_on_all_workspaces command without any pre-configured scope.", + "type": "string", + "const": "core:window:deny-set-visible-on-all-workspaces", + "markdownDescription": "Denies the set_visible_on_all_workspaces command without any pre-configured scope." + }, + { + "description": "Denies the show command without any pre-configured scope.", + "type": "string", + "const": "core:window:deny-show", + "markdownDescription": "Denies the show command without any pre-configured scope." + }, + { + "description": "Denies the start_dragging command without any pre-configured scope.", + "type": "string", + "const": "core:window:deny-start-dragging", + "markdownDescription": "Denies the start_dragging command without any pre-configured scope." + }, + { + "description": "Denies the start_resize_dragging command without any pre-configured scope.", + "type": "string", + "const": "core:window:deny-start-resize-dragging", + "markdownDescription": "Denies the start_resize_dragging command without any pre-configured scope." + }, + { + "description": "Denies the theme command without any pre-configured scope.", + "type": "string", + "const": "core:window:deny-theme", + "markdownDescription": "Denies the theme command without any pre-configured scope." + }, + { + "description": "Denies the title command without any pre-configured scope.", + "type": "string", + "const": "core:window:deny-title", + "markdownDescription": "Denies the title command without any pre-configured scope." + }, + { + "description": "Denies the toggle_maximize command without any pre-configured scope.", + "type": "string", + "const": "core:window:deny-toggle-maximize", + "markdownDescription": "Denies the toggle_maximize command without any pre-configured scope." + }, + { + "description": "Denies the unmaximize command without any pre-configured scope.", + "type": "string", + "const": "core:window:deny-unmaximize", + "markdownDescription": "Denies the unmaximize command without any pre-configured scope." + }, + { + "description": "Denies the unminimize command without any pre-configured scope.", + "type": "string", + "const": "core:window:deny-unminimize", + "markdownDescription": "Denies the unminimize command without any pre-configured scope." + } + ] + }, + "Value": { + "description": "All supported ACL values.", + "anyOf": [ + { + "description": "Represents a null JSON value.", + "type": "null" + }, + { + "description": "Represents a [`bool`].", + "type": "boolean" + }, + { + "description": "Represents a valid ACL [`Number`].", + "allOf": [ + { + "$ref": "#/definitions/Number" + } + ] + }, + { + "description": "Represents a [`String`].", + "type": "string" + }, + { + "description": "Represents a list of other [`Value`]s.", + "type": "array", + "items": { + "$ref": "#/definitions/Value" + } + }, + { + "description": "Represents a map of [`String`] keys to [`Value`]s.", + "type": "object", + "additionalProperties": { + "$ref": "#/definitions/Value" + } + } + ] + }, + "Number": { + "description": "A valid ACL number.", + "anyOf": [ + { + "description": "Represents an [`i64`].", + "type": "integer", + "format": "int64" + }, + { + "description": "Represents a [`f64`].", + "type": "number", + "format": "double" + } + ] + }, + "Target": { + "description": "Platform target.", + "oneOf": [ + { + "description": "MacOS.", + "type": "string", + "enum": [ + "macOS" + ] + }, + { + "description": "Windows.", + "type": "string", + "enum": [ + "windows" + ] + }, + { + "description": "Linux.", + "type": "string", + "enum": [ + "linux" + ] + }, + { + "description": "Android.", + "type": "string", + "enum": [ + "android" + ] + }, + { + "description": "iOS.", + "type": "string", + "enum": [ + "iOS" + ] + } + ] + } + } +} \ No newline at end of file diff --git a/crates/quicnprotochat-gui/gen/schemas/linux-schema.json b/crates/quicnprotochat-gui/gen/schemas/linux-schema.json new file mode 100644 index 0000000..260dbe0 --- /dev/null +++ b/crates/quicnprotochat-gui/gen/schemas/linux-schema.json @@ -0,0 +1,2244 @@ +{ + "$schema": "http://json-schema.org/draft-07/schema#", + "title": "CapabilityFile", + "description": "Capability formats accepted in a capability file.", + "anyOf": [ + { + "description": "A single capability.", + "allOf": [ + { + "$ref": "#/definitions/Capability" + } + ] + }, + { + "description": "A list of capabilities.", + "type": "array", + "items": { + "$ref": "#/definitions/Capability" + } + }, + { + "description": "A list of capabilities.", + "type": "object", + "required": [ + "capabilities" + ], + "properties": { + "capabilities": { + "description": "The list of capabilities.", + "type": "array", + "items": { + "$ref": "#/definitions/Capability" + } + } + } + } + ], + "definitions": { + "Capability": { + "description": "A grouping and boundary mechanism developers can use to isolate access to the IPC layer.\n\nIt controls application windows' and webviews' fine grained access to the Tauri core, application, or plugin commands. If a webview or its window is not matching any capability then it has no access to the IPC layer at all.\n\nThis can be done to create groups of windows, based on their required system access, which can reduce impact of frontend vulnerabilities in less privileged windows. Windows can be added to a capability by exact name (e.g. `main-window`) or glob patterns like `*` or `admin-*`. A Window can have none, one, or multiple associated capabilities.\n\n## Example\n\n```json { \"identifier\": \"main-user-files-write\", \"description\": \"This capability allows the `main` window on macOS and Windows access to `filesystem` write related commands and `dialog` commands to enable programmatic access to files selected by the user.\", \"windows\": [ \"main\" ], \"permissions\": [ \"core:default\", \"dialog:open\", { \"identifier\": \"fs:allow-write-text-file\", \"allow\": [{ \"path\": \"$HOME/test.txt\" }] }, ], \"platforms\": [\"macOS\",\"windows\"] } ```", + "type": "object", + "required": [ + "identifier", + "permissions" + ], + "properties": { + "identifier": { + "description": "Identifier of the capability.\n\n## Example\n\n`main-user-files-write`", + "type": "string" + }, + "description": { + "description": "Description of what the capability is intended to allow on associated windows.\n\nIt should contain a description of what the grouped permissions should allow.\n\n## Example\n\nThis capability allows the `main` window access to `filesystem` write related commands and `dialog` commands to enable programmatic access to files selected by the user.", + "default": "", + "type": "string" + }, + "remote": { + "description": "Configure remote URLs that can use the capability permissions.\n\nThis setting is optional and defaults to not being set, as our default use case is that the content is served from our local application.\n\n:::caution Make sure you understand the security implications of providing remote sources with local system access. :::\n\n## Example\n\n```json { \"urls\": [\"https://*.mydomain.dev\"] } ```", + "anyOf": [ + { + "$ref": "#/definitions/CapabilityRemote" + }, + { + "type": "null" + } + ] + }, + "local": { + "description": "Whether this capability is enabled for local app URLs or not. Defaults to `true`.", + "default": true, + "type": "boolean" + }, + "windows": { + "description": "List of windows that are affected by this capability. Can be a glob pattern.\n\nIf a window label matches any of the patterns in this list, the capability will be enabled on all the webviews of that window, regardless of the value of [`Self::webviews`].\n\nOn multiwebview windows, prefer specifying [`Self::webviews`] and omitting [`Self::windows`] for a fine grained access control.\n\n## Example\n\n`[\"main\"]`", + "type": "array", + "items": { + "type": "string" + } + }, + "webviews": { + "description": "List of webviews that are affected by this capability. Can be a glob pattern.\n\nThe capability will be enabled on all the webviews whose label matches any of the patterns in this list, regardless of whether the webview's window label matches a pattern in [`Self::windows`].\n\n## Example\n\n`[\"sub-webview-one\", \"sub-webview-two\"]`", + "type": "array", + "items": { + "type": "string" + } + }, + "permissions": { + "description": "List of permissions attached to this capability.\n\nMust include the plugin name as prefix in the form of `${plugin-name}:${permission-name}`. For commands directly implemented in the application itself only `${permission-name}` is required.\n\n## Example\n\n```json [ \"core:default\", \"shell:allow-open\", \"dialog:open\", { \"identifier\": \"fs:allow-write-text-file\", \"allow\": [{ \"path\": \"$HOME/test.txt\" }] } ] ```", + "type": "array", + "items": { + "$ref": "#/definitions/PermissionEntry" + }, + "uniqueItems": true + }, + "platforms": { + "description": "Limit which target platforms this capability applies to.\n\nBy default all platforms are targeted.\n\n## Example\n\n`[\"macOS\",\"windows\"]`", + "type": [ + "array", + "null" + ], + "items": { + "$ref": "#/definitions/Target" + } + } + } + }, + "CapabilityRemote": { + "description": "Configuration for remote URLs that are associated with the capability.", + "type": "object", + "required": [ + "urls" + ], + "properties": { + "urls": { + "description": "Remote domains this capability refers to using the [URLPattern standard](https://urlpattern.spec.whatwg.org/).\n\n## Examples\n\n- \"https://*.mydomain.dev\": allows subdomains of mydomain.dev - \"https://mydomain.dev/api/*\": allows any subpath of mydomain.dev/api", + "type": "array", + "items": { + "type": "string" + } + } + } + }, + "PermissionEntry": { + "description": "An entry for a permission value in a [`Capability`] can be either a raw permission [`Identifier`] or an object that references a permission and extends its scope.", + "anyOf": [ + { + "description": "Reference a permission or permission set by identifier.", + "allOf": [ + { + "$ref": "#/definitions/Identifier" + } + ] + }, + { + "description": "Reference a permission or permission set by identifier and extends its scope.", + "type": "object", + "allOf": [ + { + "properties": { + "identifier": { + "description": "Identifier of the permission or permission set.", + "allOf": [ + { + "$ref": "#/definitions/Identifier" + } + ] + }, + "allow": { + "description": "Data that defines what is allowed by the scope.", + "type": [ + "array", + "null" + ], + "items": { + "$ref": "#/definitions/Value" + } + }, + "deny": { + "description": "Data that defines what is denied by the scope. This should be prioritized by validation logic.", + "type": [ + "array", + "null" + ], + "items": { + "$ref": "#/definitions/Value" + } + } + } + } + ], + "required": [ + "identifier" + ] + } + ] + }, + "Identifier": { + "description": "Permission identifier", + "oneOf": [ + { + "description": "Default core plugins set.\n#### This default permission set includes:\n\n- `core:path:default`\n- `core:event:default`\n- `core:window:default`\n- `core:webview:default`\n- `core:app:default`\n- `core:image:default`\n- `core:resources:default`\n- `core:menu:default`\n- `core:tray:default`", + "type": "string", + "const": "core:default", + "markdownDescription": "Default core plugins set.\n#### This default permission set includes:\n\n- `core:path:default`\n- `core:event:default`\n- `core:window:default`\n- `core:webview:default`\n- `core:app:default`\n- `core:image:default`\n- `core:resources:default`\n- `core:menu:default`\n- `core:tray:default`" + }, + { + "description": "Default permissions for the plugin.\n#### This default permission set includes:\n\n- `allow-version`\n- `allow-name`\n- `allow-tauri-version`\n- `allow-identifier`\n- `allow-bundle-type`\n- `allow-register-listener`\n- `allow-remove-listener`", + "type": "string", + "const": "core:app:default", + "markdownDescription": "Default permissions for the plugin.\n#### This default permission set includes:\n\n- `allow-version`\n- `allow-name`\n- `allow-tauri-version`\n- `allow-identifier`\n- `allow-bundle-type`\n- `allow-register-listener`\n- `allow-remove-listener`" + }, + { + "description": "Enables the app_hide command without any pre-configured scope.", + "type": "string", + "const": "core:app:allow-app-hide", + "markdownDescription": "Enables the app_hide command without any pre-configured scope." + }, + { + "description": "Enables the app_show command without any pre-configured scope.", + "type": "string", + "const": "core:app:allow-app-show", + "markdownDescription": "Enables the app_show command without any pre-configured scope." + }, + { + "description": "Enables the bundle_type command without any pre-configured scope.", + "type": "string", + "const": "core:app:allow-bundle-type", + "markdownDescription": "Enables the bundle_type command without any pre-configured scope." + }, + { + "description": "Enables the default_window_icon command without any pre-configured scope.", + "type": "string", + "const": "core:app:allow-default-window-icon", + "markdownDescription": "Enables the default_window_icon command without any pre-configured scope." + }, + { + "description": "Enables the fetch_data_store_identifiers command without any pre-configured scope.", + "type": "string", + "const": "core:app:allow-fetch-data-store-identifiers", + "markdownDescription": "Enables the fetch_data_store_identifiers command without any pre-configured scope." + }, + { + "description": "Enables the identifier command without any pre-configured scope.", + "type": "string", + "const": "core:app:allow-identifier", + "markdownDescription": "Enables the identifier command without any pre-configured scope." + }, + { + "description": "Enables the name command without any pre-configured scope.", + "type": "string", + "const": "core:app:allow-name", + "markdownDescription": "Enables the name command without any pre-configured scope." + }, + { + "description": "Enables the register_listener command without any pre-configured scope.", + "type": "string", + "const": "core:app:allow-register-listener", + "markdownDescription": "Enables the register_listener command without any pre-configured scope." + }, + { + "description": "Enables the remove_data_store command without any pre-configured scope.", + "type": "string", + "const": "core:app:allow-remove-data-store", + "markdownDescription": "Enables the remove_data_store command without any pre-configured scope." + }, + { + "description": "Enables the remove_listener command without any pre-configured scope.", + "type": "string", + "const": "core:app:allow-remove-listener", + "markdownDescription": "Enables the remove_listener command without any pre-configured scope." + }, + { + "description": "Enables the set_app_theme command without any pre-configured scope.", + "type": "string", + "const": "core:app:allow-set-app-theme", + "markdownDescription": "Enables the set_app_theme command without any pre-configured scope." + }, + { + "description": "Enables the set_dock_visibility command without any pre-configured scope.", + "type": "string", + "const": "core:app:allow-set-dock-visibility", + "markdownDescription": "Enables the set_dock_visibility command without any pre-configured scope." + }, + { + "description": "Enables the tauri_version command without any pre-configured scope.", + "type": "string", + "const": "core:app:allow-tauri-version", + "markdownDescription": "Enables the tauri_version command without any pre-configured scope." + }, + { + "description": "Enables the version command without any pre-configured scope.", + "type": "string", + "const": "core:app:allow-version", + "markdownDescription": "Enables the version command without any pre-configured scope." + }, + { + "description": "Denies the app_hide command without any pre-configured scope.", + "type": "string", + "const": "core:app:deny-app-hide", + "markdownDescription": "Denies the app_hide command without any pre-configured scope." + }, + { + "description": "Denies the app_show command without any pre-configured scope.", + "type": "string", + "const": "core:app:deny-app-show", + "markdownDescription": "Denies the app_show command without any pre-configured scope." + }, + { + "description": "Denies the bundle_type command without any pre-configured scope.", + "type": "string", + "const": "core:app:deny-bundle-type", + "markdownDescription": "Denies the bundle_type command without any pre-configured scope." + }, + { + "description": "Denies the default_window_icon command without any pre-configured scope.", + "type": "string", + "const": "core:app:deny-default-window-icon", + "markdownDescription": "Denies the default_window_icon command without any pre-configured scope." + }, + { + "description": "Denies the fetch_data_store_identifiers command without any pre-configured scope.", + "type": "string", + "const": "core:app:deny-fetch-data-store-identifiers", + "markdownDescription": "Denies the fetch_data_store_identifiers command without any pre-configured scope." + }, + { + "description": "Denies the identifier command without any pre-configured scope.", + "type": "string", + "const": "core:app:deny-identifier", + "markdownDescription": "Denies the identifier command without any pre-configured scope." + }, + { + "description": "Denies the name command without any pre-configured scope.", + "type": "string", + "const": "core:app:deny-name", + "markdownDescription": "Denies the name command without any pre-configured scope." + }, + { + "description": "Denies the register_listener command without any pre-configured scope.", + "type": "string", + "const": "core:app:deny-register-listener", + "markdownDescription": "Denies the register_listener command without any pre-configured scope." + }, + { + "description": "Denies the remove_data_store command without any pre-configured scope.", + "type": "string", + "const": "core:app:deny-remove-data-store", + "markdownDescription": "Denies the remove_data_store command without any pre-configured scope." + }, + { + "description": "Denies the remove_listener command without any pre-configured scope.", + "type": "string", + "const": "core:app:deny-remove-listener", + "markdownDescription": "Denies the remove_listener command without any pre-configured scope." + }, + { + "description": "Denies the set_app_theme command without any pre-configured scope.", + "type": "string", + "const": "core:app:deny-set-app-theme", + "markdownDescription": "Denies the set_app_theme command without any pre-configured scope." + }, + { + "description": "Denies the set_dock_visibility command without any pre-configured scope.", + "type": "string", + "const": "core:app:deny-set-dock-visibility", + "markdownDescription": "Denies the set_dock_visibility command without any pre-configured scope." + }, + { + "description": "Denies the tauri_version command without any pre-configured scope.", + "type": "string", + "const": "core:app:deny-tauri-version", + "markdownDescription": "Denies the tauri_version command without any pre-configured scope." + }, + { + "description": "Denies the version command without any pre-configured scope.", + "type": "string", + "const": "core:app:deny-version", + "markdownDescription": "Denies the version command without any pre-configured scope." + }, + { + "description": "Default permissions for the plugin, which enables all commands.\n#### This default permission set includes:\n\n- `allow-listen`\n- `allow-unlisten`\n- `allow-emit`\n- `allow-emit-to`", + "type": "string", + "const": "core:event:default", + "markdownDescription": "Default permissions for the plugin, which enables all commands.\n#### This default permission set includes:\n\n- `allow-listen`\n- `allow-unlisten`\n- `allow-emit`\n- `allow-emit-to`" + }, + { + "description": "Enables the emit command without any pre-configured scope.", + "type": "string", + "const": "core:event:allow-emit", + "markdownDescription": "Enables the emit command without any pre-configured scope." + }, + { + "description": "Enables the emit_to command without any pre-configured scope.", + "type": "string", + "const": "core:event:allow-emit-to", + "markdownDescription": "Enables the emit_to command without any pre-configured scope." + }, + { + "description": "Enables the listen command without any pre-configured scope.", + "type": "string", + "const": "core:event:allow-listen", + "markdownDescription": "Enables the listen command without any pre-configured scope." + }, + { + "description": "Enables the unlisten command without any pre-configured scope.", + "type": "string", + "const": "core:event:allow-unlisten", + "markdownDescription": "Enables the unlisten command without any pre-configured scope." + }, + { + "description": "Denies the emit command without any pre-configured scope.", + "type": "string", + "const": "core:event:deny-emit", + "markdownDescription": "Denies the emit command without any pre-configured scope." + }, + { + "description": "Denies the emit_to command without any pre-configured scope.", + "type": "string", + "const": "core:event:deny-emit-to", + "markdownDescription": "Denies the emit_to command without any pre-configured scope." + }, + { + "description": "Denies the listen command without any pre-configured scope.", + "type": "string", + "const": "core:event:deny-listen", + "markdownDescription": "Denies the listen command without any pre-configured scope." + }, + { + "description": "Denies the unlisten command without any pre-configured scope.", + "type": "string", + "const": "core:event:deny-unlisten", + "markdownDescription": "Denies the unlisten command without any pre-configured scope." + }, + { + "description": "Default permissions for the plugin, which enables all commands.\n#### This default permission set includes:\n\n- `allow-new`\n- `allow-from-bytes`\n- `allow-from-path`\n- `allow-rgba`\n- `allow-size`", + "type": "string", + "const": "core:image:default", + "markdownDescription": "Default permissions for the plugin, which enables all commands.\n#### This default permission set includes:\n\n- `allow-new`\n- `allow-from-bytes`\n- `allow-from-path`\n- `allow-rgba`\n- `allow-size`" + }, + { + "description": "Enables the from_bytes command without any pre-configured scope.", + "type": "string", + "const": "core:image:allow-from-bytes", + "markdownDescription": "Enables the from_bytes command without any pre-configured scope." + }, + { + "description": "Enables the from_path command without any pre-configured scope.", + "type": "string", + "const": "core:image:allow-from-path", + "markdownDescription": "Enables the from_path command without any pre-configured scope." + }, + { + "description": "Enables the new command without any pre-configured scope.", + "type": "string", + "const": "core:image:allow-new", + "markdownDescription": "Enables the new command without any pre-configured scope." + }, + { + "description": "Enables the rgba command without any pre-configured scope.", + "type": "string", + "const": "core:image:allow-rgba", + "markdownDescription": "Enables the rgba command without any pre-configured scope." + }, + { + "description": "Enables the size command without any pre-configured scope.", + "type": "string", + "const": "core:image:allow-size", + "markdownDescription": "Enables the size command without any pre-configured scope." + }, + { + "description": "Denies the from_bytes command without any pre-configured scope.", + "type": "string", + "const": "core:image:deny-from-bytes", + "markdownDescription": "Denies the from_bytes command without any pre-configured scope." + }, + { + "description": "Denies the from_path command without any pre-configured scope.", + "type": "string", + "const": "core:image:deny-from-path", + "markdownDescription": "Denies the from_path command without any pre-configured scope." + }, + { + "description": "Denies the new command without any pre-configured scope.", + "type": "string", + "const": "core:image:deny-new", + "markdownDescription": "Denies the new command without any pre-configured scope." + }, + { + "description": "Denies the rgba command without any pre-configured scope.", + "type": "string", + "const": "core:image:deny-rgba", + "markdownDescription": "Denies the rgba command without any pre-configured scope." + }, + { + "description": "Denies the size command without any pre-configured scope.", + "type": "string", + "const": "core:image:deny-size", + "markdownDescription": "Denies the size command without any pre-configured scope." + }, + { + "description": "Default permissions for the plugin, which enables all commands.\n#### This default permission set includes:\n\n- `allow-new`\n- `allow-append`\n- `allow-prepend`\n- `allow-insert`\n- `allow-remove`\n- `allow-remove-at`\n- `allow-items`\n- `allow-get`\n- `allow-popup`\n- `allow-create-default`\n- `allow-set-as-app-menu`\n- `allow-set-as-window-menu`\n- `allow-text`\n- `allow-set-text`\n- `allow-is-enabled`\n- `allow-set-enabled`\n- `allow-set-accelerator`\n- `allow-set-as-windows-menu-for-nsapp`\n- `allow-set-as-help-menu-for-nsapp`\n- `allow-is-checked`\n- `allow-set-checked`\n- `allow-set-icon`", + "type": "string", + "const": "core:menu:default", + "markdownDescription": "Default permissions for the plugin, which enables all commands.\n#### This default permission set includes:\n\n- `allow-new`\n- `allow-append`\n- `allow-prepend`\n- `allow-insert`\n- `allow-remove`\n- `allow-remove-at`\n- `allow-items`\n- `allow-get`\n- `allow-popup`\n- `allow-create-default`\n- `allow-set-as-app-menu`\n- `allow-set-as-window-menu`\n- `allow-text`\n- `allow-set-text`\n- `allow-is-enabled`\n- `allow-set-enabled`\n- `allow-set-accelerator`\n- `allow-set-as-windows-menu-for-nsapp`\n- `allow-set-as-help-menu-for-nsapp`\n- `allow-is-checked`\n- `allow-set-checked`\n- `allow-set-icon`" + }, + { + "description": "Enables the append command without any pre-configured scope.", + "type": "string", + "const": "core:menu:allow-append", + "markdownDescription": "Enables the append command without any pre-configured scope." + }, + { + "description": "Enables the create_default command without any pre-configured scope.", + "type": "string", + "const": "core:menu:allow-create-default", + "markdownDescription": "Enables the create_default command without any pre-configured scope." + }, + { + "description": "Enables the get command without any pre-configured scope.", + "type": "string", + "const": "core:menu:allow-get", + "markdownDescription": "Enables the get command without any pre-configured scope." + }, + { + "description": "Enables the insert command without any pre-configured scope.", + "type": "string", + "const": "core:menu:allow-insert", + "markdownDescription": "Enables the insert command without any pre-configured scope." + }, + { + "description": "Enables the is_checked command without any pre-configured scope.", + "type": "string", + "const": "core:menu:allow-is-checked", + "markdownDescription": "Enables the is_checked command without any pre-configured scope." + }, + { + "description": "Enables the is_enabled command without any pre-configured scope.", + "type": "string", + "const": "core:menu:allow-is-enabled", + "markdownDescription": "Enables the is_enabled command without any pre-configured scope." + }, + { + "description": "Enables the items command without any pre-configured scope.", + "type": "string", + "const": "core:menu:allow-items", + "markdownDescription": "Enables the items command without any pre-configured scope." + }, + { + "description": "Enables the new command without any pre-configured scope.", + "type": "string", + "const": "core:menu:allow-new", + "markdownDescription": "Enables the new command without any pre-configured scope." + }, + { + "description": "Enables the popup command without any pre-configured scope.", + "type": "string", + "const": "core:menu:allow-popup", + "markdownDescription": "Enables the popup command without any pre-configured scope." + }, + { + "description": "Enables the prepend command without any pre-configured scope.", + "type": "string", + "const": "core:menu:allow-prepend", + "markdownDescription": "Enables the prepend command without any pre-configured scope." + }, + { + "description": "Enables the remove command without any pre-configured scope.", + "type": "string", + "const": "core:menu:allow-remove", + "markdownDescription": "Enables the remove command without any pre-configured scope." + }, + { + "description": "Enables the remove_at command without any pre-configured scope.", + "type": "string", + "const": "core:menu:allow-remove-at", + "markdownDescription": "Enables the remove_at command without any pre-configured scope." + }, + { + "description": "Enables the set_accelerator command without any pre-configured scope.", + "type": "string", + "const": "core:menu:allow-set-accelerator", + "markdownDescription": "Enables the set_accelerator command without any pre-configured scope." + }, + { + "description": "Enables the set_as_app_menu command without any pre-configured scope.", + "type": "string", + "const": "core:menu:allow-set-as-app-menu", + "markdownDescription": "Enables the set_as_app_menu command without any pre-configured scope." + }, + { + "description": "Enables the set_as_help_menu_for_nsapp command without any pre-configured scope.", + "type": "string", + "const": "core:menu:allow-set-as-help-menu-for-nsapp", + "markdownDescription": "Enables the set_as_help_menu_for_nsapp command without any pre-configured scope." + }, + { + "description": "Enables the set_as_window_menu command without any pre-configured scope.", + "type": "string", + "const": "core:menu:allow-set-as-window-menu", + "markdownDescription": "Enables the set_as_window_menu command without any pre-configured scope." + }, + { + "description": "Enables the set_as_windows_menu_for_nsapp command without any pre-configured scope.", + "type": "string", + "const": "core:menu:allow-set-as-windows-menu-for-nsapp", + "markdownDescription": "Enables the set_as_windows_menu_for_nsapp command without any pre-configured scope." + }, + { + "description": "Enables the set_checked command without any pre-configured scope.", + "type": "string", + "const": "core:menu:allow-set-checked", + "markdownDescription": "Enables the set_checked command without any pre-configured scope." + }, + { + "description": "Enables the set_enabled command without any pre-configured scope.", + "type": "string", + "const": "core:menu:allow-set-enabled", + "markdownDescription": "Enables the set_enabled command without any pre-configured scope." + }, + { + "description": "Enables the set_icon command without any pre-configured scope.", + "type": "string", + "const": "core:menu:allow-set-icon", + "markdownDescription": "Enables the set_icon command without any pre-configured scope." + }, + { + "description": "Enables the set_text command without any pre-configured scope.", + "type": "string", + "const": "core:menu:allow-set-text", + "markdownDescription": "Enables the set_text command without any pre-configured scope." + }, + { + "description": "Enables the text command without any pre-configured scope.", + "type": "string", + "const": "core:menu:allow-text", + "markdownDescription": "Enables the text command without any pre-configured scope." + }, + { + "description": "Denies the append command without any pre-configured scope.", + "type": "string", + "const": "core:menu:deny-append", + "markdownDescription": "Denies the append command without any pre-configured scope." + }, + { + "description": "Denies the create_default command without any pre-configured scope.", + "type": "string", + "const": "core:menu:deny-create-default", + "markdownDescription": "Denies the create_default command without any pre-configured scope." + }, + { + "description": "Denies the get command without any pre-configured scope.", + "type": "string", + "const": "core:menu:deny-get", + "markdownDescription": "Denies the get command without any pre-configured scope." + }, + { + "description": "Denies the insert command without any pre-configured scope.", + "type": "string", + "const": "core:menu:deny-insert", + "markdownDescription": "Denies the insert command without any pre-configured scope." + }, + { + "description": "Denies the is_checked command without any pre-configured scope.", + "type": "string", + "const": "core:menu:deny-is-checked", + "markdownDescription": "Denies the is_checked command without any pre-configured scope." + }, + { + "description": "Denies the is_enabled command without any pre-configured scope.", + "type": "string", + "const": "core:menu:deny-is-enabled", + "markdownDescription": "Denies the is_enabled command without any pre-configured scope." + }, + { + "description": "Denies the items command without any pre-configured scope.", + "type": "string", + "const": "core:menu:deny-items", + "markdownDescription": "Denies the items command without any pre-configured scope." + }, + { + "description": "Denies the new command without any pre-configured scope.", + "type": "string", + "const": "core:menu:deny-new", + "markdownDescription": "Denies the new command without any pre-configured scope." + }, + { + "description": "Denies the popup command without any pre-configured scope.", + "type": "string", + "const": "core:menu:deny-popup", + "markdownDescription": "Denies the popup command without any pre-configured scope." + }, + { + "description": "Denies the prepend command without any pre-configured scope.", + "type": "string", + "const": "core:menu:deny-prepend", + "markdownDescription": "Denies the prepend command without any pre-configured scope." + }, + { + "description": "Denies the remove command without any pre-configured scope.", + "type": "string", + "const": "core:menu:deny-remove", + "markdownDescription": "Denies the remove command without any pre-configured scope." + }, + { + "description": "Denies the remove_at command without any pre-configured scope.", + "type": "string", + "const": "core:menu:deny-remove-at", + "markdownDescription": "Denies the remove_at command without any pre-configured scope." + }, + { + "description": "Denies the set_accelerator command without any pre-configured scope.", + "type": "string", + "const": "core:menu:deny-set-accelerator", + "markdownDescription": "Denies the set_accelerator command without any pre-configured scope." + }, + { + "description": "Denies the set_as_app_menu command without any pre-configured scope.", + "type": "string", + "const": "core:menu:deny-set-as-app-menu", + "markdownDescription": "Denies the set_as_app_menu command without any pre-configured scope." + }, + { + "description": "Denies the set_as_help_menu_for_nsapp command without any pre-configured scope.", + "type": "string", + "const": "core:menu:deny-set-as-help-menu-for-nsapp", + "markdownDescription": "Denies the set_as_help_menu_for_nsapp command without any pre-configured scope." + }, + { + "description": "Denies the set_as_window_menu command without any pre-configured scope.", + "type": "string", + "const": "core:menu:deny-set-as-window-menu", + "markdownDescription": "Denies the set_as_window_menu command without any pre-configured scope." + }, + { + "description": "Denies the set_as_windows_menu_for_nsapp command without any pre-configured scope.", + "type": "string", + "const": "core:menu:deny-set-as-windows-menu-for-nsapp", + "markdownDescription": "Denies the set_as_windows_menu_for_nsapp command without any pre-configured scope." + }, + { + "description": "Denies the set_checked command without any pre-configured scope.", + "type": "string", + "const": "core:menu:deny-set-checked", + "markdownDescription": "Denies the set_checked command without any pre-configured scope." + }, + { + "description": "Denies the set_enabled command without any pre-configured scope.", + "type": "string", + "const": "core:menu:deny-set-enabled", + "markdownDescription": "Denies the set_enabled command without any pre-configured scope." + }, + { + "description": "Denies the set_icon command without any pre-configured scope.", + "type": "string", + "const": "core:menu:deny-set-icon", + "markdownDescription": "Denies the set_icon command without any pre-configured scope." + }, + { + "description": "Denies the set_text command without any pre-configured scope.", + "type": "string", + "const": "core:menu:deny-set-text", + "markdownDescription": "Denies the set_text command without any pre-configured scope." + }, + { + "description": "Denies the text command without any pre-configured scope.", + "type": "string", + "const": "core:menu:deny-text", + "markdownDescription": "Denies the text command without any pre-configured scope." + }, + { + "description": "Default permissions for the plugin, which enables all commands.\n#### This default permission set includes:\n\n- `allow-resolve-directory`\n- `allow-resolve`\n- `allow-normalize`\n- `allow-join`\n- `allow-dirname`\n- `allow-extname`\n- `allow-basename`\n- `allow-is-absolute`", + "type": "string", + "const": "core:path:default", + "markdownDescription": "Default permissions for the plugin, which enables all commands.\n#### This default permission set includes:\n\n- `allow-resolve-directory`\n- `allow-resolve`\n- `allow-normalize`\n- `allow-join`\n- `allow-dirname`\n- `allow-extname`\n- `allow-basename`\n- `allow-is-absolute`" + }, + { + "description": "Enables the basename command without any pre-configured scope.", + "type": "string", + "const": "core:path:allow-basename", + "markdownDescription": "Enables the basename command without any pre-configured scope." + }, + { + "description": "Enables the dirname command without any pre-configured scope.", + "type": "string", + "const": "core:path:allow-dirname", + "markdownDescription": "Enables the dirname command without any pre-configured scope." + }, + { + "description": "Enables the extname command without any pre-configured scope.", + "type": "string", + "const": "core:path:allow-extname", + "markdownDescription": "Enables the extname command without any pre-configured scope." + }, + { + "description": "Enables the is_absolute command without any pre-configured scope.", + "type": "string", + "const": "core:path:allow-is-absolute", + "markdownDescription": "Enables the is_absolute command without any pre-configured scope." + }, + { + "description": "Enables the join command without any pre-configured scope.", + "type": "string", + "const": "core:path:allow-join", + "markdownDescription": "Enables the join command without any pre-configured scope." + }, + { + "description": "Enables the normalize command without any pre-configured scope.", + "type": "string", + "const": "core:path:allow-normalize", + "markdownDescription": "Enables the normalize command without any pre-configured scope." + }, + { + "description": "Enables the resolve command without any pre-configured scope.", + "type": "string", + "const": "core:path:allow-resolve", + "markdownDescription": "Enables the resolve command without any pre-configured scope." + }, + { + "description": "Enables the resolve_directory command without any pre-configured scope.", + "type": "string", + "const": "core:path:allow-resolve-directory", + "markdownDescription": "Enables the resolve_directory command without any pre-configured scope." + }, + { + "description": "Denies the basename command without any pre-configured scope.", + "type": "string", + "const": "core:path:deny-basename", + "markdownDescription": "Denies the basename command without any pre-configured scope." + }, + { + "description": "Denies the dirname command without any pre-configured scope.", + "type": "string", + "const": "core:path:deny-dirname", + "markdownDescription": "Denies the dirname command without any pre-configured scope." + }, + { + "description": "Denies the extname command without any pre-configured scope.", + "type": "string", + "const": "core:path:deny-extname", + "markdownDescription": "Denies the extname command without any pre-configured scope." + }, + { + "description": "Denies the is_absolute command without any pre-configured scope.", + "type": "string", + "const": "core:path:deny-is-absolute", + "markdownDescription": "Denies the is_absolute command without any pre-configured scope." + }, + { + "description": "Denies the join command without any pre-configured scope.", + "type": "string", + "const": "core:path:deny-join", + "markdownDescription": "Denies the join command without any pre-configured scope." + }, + { + "description": "Denies the normalize command without any pre-configured scope.", + "type": "string", + "const": "core:path:deny-normalize", + "markdownDescription": "Denies the normalize command without any pre-configured scope." + }, + { + "description": "Denies the resolve command without any pre-configured scope.", + "type": "string", + "const": "core:path:deny-resolve", + "markdownDescription": "Denies the resolve command without any pre-configured scope." + }, + { + "description": "Denies the resolve_directory command without any pre-configured scope.", + "type": "string", + "const": "core:path:deny-resolve-directory", + "markdownDescription": "Denies the resolve_directory command without any pre-configured scope." + }, + { + "description": "Default permissions for the plugin, which enables all commands.\n#### This default permission set includes:\n\n- `allow-close`", + "type": "string", + "const": "core:resources:default", + "markdownDescription": "Default permissions for the plugin, which enables all commands.\n#### This default permission set includes:\n\n- `allow-close`" + }, + { + "description": "Enables the close command without any pre-configured scope.", + "type": "string", + "const": "core:resources:allow-close", + "markdownDescription": "Enables the close command without any pre-configured scope." + }, + { + "description": "Denies the close command without any pre-configured scope.", + "type": "string", + "const": "core:resources:deny-close", + "markdownDescription": "Denies the close command without any pre-configured scope." + }, + { + "description": "Default permissions for the plugin, which enables all commands.\n#### This default permission set includes:\n\n- `allow-new`\n- `allow-get-by-id`\n- `allow-remove-by-id`\n- `allow-set-icon`\n- `allow-set-menu`\n- `allow-set-tooltip`\n- `allow-set-title`\n- `allow-set-visible`\n- `allow-set-temp-dir-path`\n- `allow-set-icon-as-template`\n- `allow-set-show-menu-on-left-click`", + "type": "string", + "const": "core:tray:default", + "markdownDescription": "Default permissions for the plugin, which enables all commands.\n#### This default permission set includes:\n\n- `allow-new`\n- `allow-get-by-id`\n- `allow-remove-by-id`\n- `allow-set-icon`\n- `allow-set-menu`\n- `allow-set-tooltip`\n- `allow-set-title`\n- `allow-set-visible`\n- `allow-set-temp-dir-path`\n- `allow-set-icon-as-template`\n- `allow-set-show-menu-on-left-click`" + }, + { + "description": "Enables the get_by_id command without any pre-configured scope.", + "type": "string", + "const": "core:tray:allow-get-by-id", + "markdownDescription": "Enables the get_by_id command without any pre-configured scope." + }, + { + "description": "Enables the new command without any pre-configured scope.", + "type": "string", + "const": "core:tray:allow-new", + "markdownDescription": "Enables the new command without any pre-configured scope." + }, + { + "description": "Enables the remove_by_id command without any pre-configured scope.", + "type": "string", + "const": "core:tray:allow-remove-by-id", + "markdownDescription": "Enables the remove_by_id command without any pre-configured scope." + }, + { + "description": "Enables the set_icon command without any pre-configured scope.", + "type": "string", + "const": "core:tray:allow-set-icon", + "markdownDescription": "Enables the set_icon command without any pre-configured scope." + }, + { + "description": "Enables the set_icon_as_template command without any pre-configured scope.", + "type": "string", + "const": "core:tray:allow-set-icon-as-template", + "markdownDescription": "Enables the set_icon_as_template command without any pre-configured scope." + }, + { + "description": "Enables the set_menu command without any pre-configured scope.", + "type": "string", + "const": "core:tray:allow-set-menu", + "markdownDescription": "Enables the set_menu command without any pre-configured scope." + }, + { + "description": "Enables the set_show_menu_on_left_click command without any pre-configured scope.", + "type": "string", + "const": "core:tray:allow-set-show-menu-on-left-click", + "markdownDescription": "Enables the set_show_menu_on_left_click command without any pre-configured scope." + }, + { + "description": "Enables the set_temp_dir_path command without any pre-configured scope.", + "type": "string", + "const": "core:tray:allow-set-temp-dir-path", + "markdownDescription": "Enables the set_temp_dir_path command without any pre-configured scope." + }, + { + "description": "Enables the set_title command without any pre-configured scope.", + "type": "string", + "const": "core:tray:allow-set-title", + "markdownDescription": "Enables the set_title command without any pre-configured scope." + }, + { + "description": "Enables the set_tooltip command without any pre-configured scope.", + "type": "string", + "const": "core:tray:allow-set-tooltip", + "markdownDescription": "Enables the set_tooltip command without any pre-configured scope." + }, + { + "description": "Enables the set_visible command without any pre-configured scope.", + "type": "string", + "const": "core:tray:allow-set-visible", + "markdownDescription": "Enables the set_visible command without any pre-configured scope." + }, + { + "description": "Denies the get_by_id command without any pre-configured scope.", + "type": "string", + "const": "core:tray:deny-get-by-id", + "markdownDescription": "Denies the get_by_id command without any pre-configured scope." + }, + { + "description": "Denies the new command without any pre-configured scope.", + "type": "string", + "const": "core:tray:deny-new", + "markdownDescription": "Denies the new command without any pre-configured scope." + }, + { + "description": "Denies the remove_by_id command without any pre-configured scope.", + "type": "string", + "const": "core:tray:deny-remove-by-id", + "markdownDescription": "Denies the remove_by_id command without any pre-configured scope." + }, + { + "description": "Denies the set_icon command without any pre-configured scope.", + "type": "string", + "const": "core:tray:deny-set-icon", + "markdownDescription": "Denies the set_icon command without any pre-configured scope." + }, + { + "description": "Denies the set_icon_as_template command without any pre-configured scope.", + "type": "string", + "const": "core:tray:deny-set-icon-as-template", + "markdownDescription": "Denies the set_icon_as_template command without any pre-configured scope." + }, + { + "description": "Denies the set_menu command without any pre-configured scope.", + "type": "string", + "const": "core:tray:deny-set-menu", + "markdownDescription": "Denies the set_menu command without any pre-configured scope." + }, + { + "description": "Denies the set_show_menu_on_left_click command without any pre-configured scope.", + "type": "string", + "const": "core:tray:deny-set-show-menu-on-left-click", + "markdownDescription": "Denies the set_show_menu_on_left_click command without any pre-configured scope." + }, + { + "description": "Denies the set_temp_dir_path command without any pre-configured scope.", + "type": "string", + "const": "core:tray:deny-set-temp-dir-path", + "markdownDescription": "Denies the set_temp_dir_path command without any pre-configured scope." + }, + { + "description": "Denies the set_title command without any pre-configured scope.", + "type": "string", + "const": "core:tray:deny-set-title", + "markdownDescription": "Denies the set_title command without any pre-configured scope." + }, + { + "description": "Denies the set_tooltip command without any pre-configured scope.", + "type": "string", + "const": "core:tray:deny-set-tooltip", + "markdownDescription": "Denies the set_tooltip command without any pre-configured scope." + }, + { + "description": "Denies the set_visible command without any pre-configured scope.", + "type": "string", + "const": "core:tray:deny-set-visible", + "markdownDescription": "Denies the set_visible command without any pre-configured scope." + }, + { + "description": "Default permissions for the plugin.\n#### This default permission set includes:\n\n- `allow-get-all-webviews`\n- `allow-webview-position`\n- `allow-webview-size`\n- `allow-internal-toggle-devtools`", + "type": "string", + "const": "core:webview:default", + "markdownDescription": "Default permissions for the plugin.\n#### This default permission set includes:\n\n- `allow-get-all-webviews`\n- `allow-webview-position`\n- `allow-webview-size`\n- `allow-internal-toggle-devtools`" + }, + { + "description": "Enables the clear_all_browsing_data command without any pre-configured scope.", + "type": "string", + "const": "core:webview:allow-clear-all-browsing-data", + "markdownDescription": "Enables the clear_all_browsing_data command without any pre-configured scope." + }, + { + "description": "Enables the create_webview command without any pre-configured scope.", + "type": "string", + "const": "core:webview:allow-create-webview", + "markdownDescription": "Enables the create_webview command without any pre-configured scope." + }, + { + "description": "Enables the create_webview_window command without any pre-configured scope.", + "type": "string", + "const": "core:webview:allow-create-webview-window", + "markdownDescription": "Enables the create_webview_window command without any pre-configured scope." + }, + { + "description": "Enables the get_all_webviews command without any pre-configured scope.", + "type": "string", + "const": "core:webview:allow-get-all-webviews", + "markdownDescription": "Enables the get_all_webviews command without any pre-configured scope." + }, + { + "description": "Enables the internal_toggle_devtools command without any pre-configured scope.", + "type": "string", + "const": "core:webview:allow-internal-toggle-devtools", + "markdownDescription": "Enables the internal_toggle_devtools command without any pre-configured scope." + }, + { + "description": "Enables the print command without any pre-configured scope.", + "type": "string", + "const": "core:webview:allow-print", + "markdownDescription": "Enables the print command without any pre-configured scope." + }, + { + "description": "Enables the reparent command without any pre-configured scope.", + "type": "string", + "const": "core:webview:allow-reparent", + "markdownDescription": "Enables the reparent command without any pre-configured scope." + }, + { + "description": "Enables the set_webview_auto_resize command without any pre-configured scope.", + "type": "string", + "const": "core:webview:allow-set-webview-auto-resize", + "markdownDescription": "Enables the set_webview_auto_resize command without any pre-configured scope." + }, + { + "description": "Enables the set_webview_background_color command without any pre-configured scope.", + "type": "string", + "const": "core:webview:allow-set-webview-background-color", + "markdownDescription": "Enables the set_webview_background_color command without any pre-configured scope." + }, + { + "description": "Enables the set_webview_focus command without any pre-configured scope.", + "type": "string", + "const": "core:webview:allow-set-webview-focus", + "markdownDescription": "Enables the set_webview_focus command without any pre-configured scope." + }, + { + "description": "Enables the set_webview_position command without any pre-configured scope.", + "type": "string", + "const": "core:webview:allow-set-webview-position", + "markdownDescription": "Enables the set_webview_position command without any pre-configured scope." + }, + { + "description": "Enables the set_webview_size command without any pre-configured scope.", + "type": "string", + "const": "core:webview:allow-set-webview-size", + "markdownDescription": "Enables the set_webview_size command without any pre-configured scope." + }, + { + "description": "Enables the set_webview_zoom command without any pre-configured scope.", + "type": "string", + "const": "core:webview:allow-set-webview-zoom", + "markdownDescription": "Enables the set_webview_zoom command without any pre-configured scope." + }, + { + "description": "Enables the webview_close command without any pre-configured scope.", + "type": "string", + "const": "core:webview:allow-webview-close", + "markdownDescription": "Enables the webview_close command without any pre-configured scope." + }, + { + "description": "Enables the webview_hide command without any pre-configured scope.", + "type": "string", + "const": "core:webview:allow-webview-hide", + "markdownDescription": "Enables the webview_hide command without any pre-configured scope." + }, + { + "description": "Enables the webview_position command without any pre-configured scope.", + "type": "string", + "const": "core:webview:allow-webview-position", + "markdownDescription": "Enables the webview_position command without any pre-configured scope." + }, + { + "description": "Enables the webview_show command without any pre-configured scope.", + "type": "string", + "const": "core:webview:allow-webview-show", + "markdownDescription": "Enables the webview_show command without any pre-configured scope." + }, + { + "description": "Enables the webview_size command without any pre-configured scope.", + "type": "string", + "const": "core:webview:allow-webview-size", + "markdownDescription": "Enables the webview_size command without any pre-configured scope." + }, + { + "description": "Denies the clear_all_browsing_data command without any pre-configured scope.", + "type": "string", + "const": "core:webview:deny-clear-all-browsing-data", + "markdownDescription": "Denies the clear_all_browsing_data command without any pre-configured scope." + }, + { + "description": "Denies the create_webview command without any pre-configured scope.", + "type": "string", + "const": "core:webview:deny-create-webview", + "markdownDescription": "Denies the create_webview command without any pre-configured scope." + }, + { + "description": "Denies the create_webview_window command without any pre-configured scope.", + "type": "string", + "const": "core:webview:deny-create-webview-window", + "markdownDescription": "Denies the create_webview_window command without any pre-configured scope." + }, + { + "description": "Denies the get_all_webviews command without any pre-configured scope.", + "type": "string", + "const": "core:webview:deny-get-all-webviews", + "markdownDescription": "Denies the get_all_webviews command without any pre-configured scope." + }, + { + "description": "Denies the internal_toggle_devtools command without any pre-configured scope.", + "type": "string", + "const": "core:webview:deny-internal-toggle-devtools", + "markdownDescription": "Denies the internal_toggle_devtools command without any pre-configured scope." + }, + { + "description": "Denies the print command without any pre-configured scope.", + "type": "string", + "const": "core:webview:deny-print", + "markdownDescription": "Denies the print command without any pre-configured scope." + }, + { + "description": "Denies the reparent command without any pre-configured scope.", + "type": "string", + "const": "core:webview:deny-reparent", + "markdownDescription": "Denies the reparent command without any pre-configured scope." + }, + { + "description": "Denies the set_webview_auto_resize command without any pre-configured scope.", + "type": "string", + "const": "core:webview:deny-set-webview-auto-resize", + "markdownDescription": "Denies the set_webview_auto_resize command without any pre-configured scope." + }, + { + "description": "Denies the set_webview_background_color command without any pre-configured scope.", + "type": "string", + "const": "core:webview:deny-set-webview-background-color", + "markdownDescription": "Denies the set_webview_background_color command without any pre-configured scope." + }, + { + "description": "Denies the set_webview_focus command without any pre-configured scope.", + "type": "string", + "const": "core:webview:deny-set-webview-focus", + "markdownDescription": "Denies the set_webview_focus command without any pre-configured scope." + }, + { + "description": "Denies the set_webview_position command without any pre-configured scope.", + "type": "string", + "const": "core:webview:deny-set-webview-position", + "markdownDescription": "Denies the set_webview_position command without any pre-configured scope." + }, + { + "description": "Denies the set_webview_size command without any pre-configured scope.", + "type": "string", + "const": "core:webview:deny-set-webview-size", + "markdownDescription": "Denies the set_webview_size command without any pre-configured scope." + }, + { + "description": "Denies the set_webview_zoom command without any pre-configured scope.", + "type": "string", + "const": "core:webview:deny-set-webview-zoom", + "markdownDescription": "Denies the set_webview_zoom command without any pre-configured scope." + }, + { + "description": "Denies the webview_close command without any pre-configured scope.", + "type": "string", + "const": "core:webview:deny-webview-close", + "markdownDescription": "Denies the webview_close command without any pre-configured scope." + }, + { + "description": "Denies the webview_hide command without any pre-configured scope.", + "type": "string", + "const": "core:webview:deny-webview-hide", + "markdownDescription": "Denies the webview_hide command without any pre-configured scope." + }, + { + "description": "Denies the webview_position command without any pre-configured scope.", + "type": "string", + "const": "core:webview:deny-webview-position", + "markdownDescription": "Denies the webview_position command without any pre-configured scope." + }, + { + "description": "Denies the webview_show command without any pre-configured scope.", + "type": "string", + "const": "core:webview:deny-webview-show", + "markdownDescription": "Denies the webview_show command without any pre-configured scope." + }, + { + "description": "Denies the webview_size command without any pre-configured scope.", + "type": "string", + "const": "core:webview:deny-webview-size", + "markdownDescription": "Denies the webview_size command without any pre-configured scope." + }, + { + "description": "Default permissions for the plugin.\n#### This default permission set includes:\n\n- `allow-get-all-windows`\n- `allow-scale-factor`\n- `allow-inner-position`\n- `allow-outer-position`\n- `allow-inner-size`\n- `allow-outer-size`\n- `allow-is-fullscreen`\n- `allow-is-minimized`\n- `allow-is-maximized`\n- `allow-is-focused`\n- `allow-is-decorated`\n- `allow-is-resizable`\n- `allow-is-maximizable`\n- `allow-is-minimizable`\n- `allow-is-closable`\n- `allow-is-visible`\n- `allow-is-enabled`\n- `allow-title`\n- `allow-current-monitor`\n- `allow-primary-monitor`\n- `allow-monitor-from-point`\n- `allow-available-monitors`\n- `allow-cursor-position`\n- `allow-theme`\n- `allow-is-always-on-top`\n- `allow-internal-toggle-maximize`", + "type": "string", + "const": "core:window:default", + "markdownDescription": "Default permissions for the plugin.\n#### This default permission set includes:\n\n- `allow-get-all-windows`\n- `allow-scale-factor`\n- `allow-inner-position`\n- `allow-outer-position`\n- `allow-inner-size`\n- `allow-outer-size`\n- `allow-is-fullscreen`\n- `allow-is-minimized`\n- `allow-is-maximized`\n- `allow-is-focused`\n- `allow-is-decorated`\n- `allow-is-resizable`\n- `allow-is-maximizable`\n- `allow-is-minimizable`\n- `allow-is-closable`\n- `allow-is-visible`\n- `allow-is-enabled`\n- `allow-title`\n- `allow-current-monitor`\n- `allow-primary-monitor`\n- `allow-monitor-from-point`\n- `allow-available-monitors`\n- `allow-cursor-position`\n- `allow-theme`\n- `allow-is-always-on-top`\n- `allow-internal-toggle-maximize`" + }, + { + "description": "Enables the available_monitors command without any pre-configured scope.", + "type": "string", + "const": "core:window:allow-available-monitors", + "markdownDescription": "Enables the available_monitors command without any pre-configured scope." + }, + { + "description": "Enables the center command without any pre-configured scope.", + "type": "string", + "const": "core:window:allow-center", + "markdownDescription": "Enables the center command without any pre-configured scope." + }, + { + "description": "Enables the close command without any pre-configured scope.", + "type": "string", + "const": "core:window:allow-close", + "markdownDescription": "Enables the close command without any pre-configured scope." + }, + { + "description": "Enables the create command without any pre-configured scope.", + "type": "string", + "const": "core:window:allow-create", + "markdownDescription": "Enables the create command without any pre-configured scope." + }, + { + "description": "Enables the current_monitor command without any pre-configured scope.", + "type": "string", + "const": "core:window:allow-current-monitor", + "markdownDescription": "Enables the current_monitor command without any pre-configured scope." + }, + { + "description": "Enables the cursor_position command without any pre-configured scope.", + "type": "string", + "const": "core:window:allow-cursor-position", + "markdownDescription": "Enables the cursor_position command without any pre-configured scope." + }, + { + "description": "Enables the destroy command without any pre-configured scope.", + "type": "string", + "const": "core:window:allow-destroy", + "markdownDescription": "Enables the destroy command without any pre-configured scope." + }, + { + "description": "Enables the get_all_windows command without any pre-configured scope.", + "type": "string", + "const": "core:window:allow-get-all-windows", + "markdownDescription": "Enables the get_all_windows command without any pre-configured scope." + }, + { + "description": "Enables the hide command without any pre-configured scope.", + "type": "string", + "const": "core:window:allow-hide", + "markdownDescription": "Enables the hide command without any pre-configured scope." + }, + { + "description": "Enables the inner_position command without any pre-configured scope.", + "type": "string", + "const": "core:window:allow-inner-position", + "markdownDescription": "Enables the inner_position command without any pre-configured scope." + }, + { + "description": "Enables the inner_size command without any pre-configured scope.", + "type": "string", + "const": "core:window:allow-inner-size", + "markdownDescription": "Enables the inner_size command without any pre-configured scope." + }, + { + "description": "Enables the internal_toggle_maximize command without any pre-configured scope.", + "type": "string", + "const": "core:window:allow-internal-toggle-maximize", + "markdownDescription": "Enables the internal_toggle_maximize command without any pre-configured scope." + }, + { + "description": "Enables the is_always_on_top command without any pre-configured scope.", + "type": "string", + "const": "core:window:allow-is-always-on-top", + "markdownDescription": "Enables the is_always_on_top command without any pre-configured scope." + }, + { + "description": "Enables the is_closable command without any pre-configured scope.", + "type": "string", + "const": "core:window:allow-is-closable", + "markdownDescription": "Enables the is_closable command without any pre-configured scope." + }, + { + "description": "Enables the is_decorated command without any pre-configured scope.", + "type": "string", + "const": "core:window:allow-is-decorated", + "markdownDescription": "Enables the is_decorated command without any pre-configured scope." + }, + { + "description": "Enables the is_enabled command without any pre-configured scope.", + "type": "string", + "const": "core:window:allow-is-enabled", + "markdownDescription": "Enables the is_enabled command without any pre-configured scope." + }, + { + "description": "Enables the is_focused command without any pre-configured scope.", + "type": "string", + "const": "core:window:allow-is-focused", + "markdownDescription": "Enables the is_focused command without any pre-configured scope." + }, + { + "description": "Enables the is_fullscreen command without any pre-configured scope.", + "type": "string", + "const": "core:window:allow-is-fullscreen", + "markdownDescription": "Enables the is_fullscreen command without any pre-configured scope." + }, + { + "description": "Enables the is_maximizable command without any pre-configured scope.", + "type": "string", + "const": "core:window:allow-is-maximizable", + "markdownDescription": "Enables the is_maximizable command without any pre-configured scope." + }, + { + "description": "Enables the is_maximized command without any pre-configured scope.", + "type": "string", + "const": "core:window:allow-is-maximized", + "markdownDescription": "Enables the is_maximized command without any pre-configured scope." + }, + { + "description": "Enables the is_minimizable command without any pre-configured scope.", + "type": "string", + "const": "core:window:allow-is-minimizable", + "markdownDescription": "Enables the is_minimizable command without any pre-configured scope." + }, + { + "description": "Enables the is_minimized command without any pre-configured scope.", + "type": "string", + "const": "core:window:allow-is-minimized", + "markdownDescription": "Enables the is_minimized command without any pre-configured scope." + }, + { + "description": "Enables the is_resizable command without any pre-configured scope.", + "type": "string", + "const": "core:window:allow-is-resizable", + "markdownDescription": "Enables the is_resizable command without any pre-configured scope." + }, + { + "description": "Enables the is_visible command without any pre-configured scope.", + "type": "string", + "const": "core:window:allow-is-visible", + "markdownDescription": "Enables the is_visible command without any pre-configured scope." + }, + { + "description": "Enables the maximize command without any pre-configured scope.", + "type": "string", + "const": "core:window:allow-maximize", + "markdownDescription": "Enables the maximize command without any pre-configured scope." + }, + { + "description": "Enables the minimize command without any pre-configured scope.", + "type": "string", + "const": "core:window:allow-minimize", + "markdownDescription": "Enables the minimize command without any pre-configured scope." + }, + { + "description": "Enables the monitor_from_point command without any pre-configured scope.", + "type": "string", + "const": "core:window:allow-monitor-from-point", + "markdownDescription": "Enables the monitor_from_point command without any pre-configured scope." + }, + { + "description": "Enables the outer_position command without any pre-configured scope.", + "type": "string", + "const": "core:window:allow-outer-position", + "markdownDescription": "Enables the outer_position command without any pre-configured scope." + }, + { + "description": "Enables the outer_size command without any pre-configured scope.", + "type": "string", + "const": "core:window:allow-outer-size", + "markdownDescription": "Enables the outer_size command without any pre-configured scope." + }, + { + "description": "Enables the primary_monitor command without any pre-configured scope.", + "type": "string", + "const": "core:window:allow-primary-monitor", + "markdownDescription": "Enables the primary_monitor command without any pre-configured scope." + }, + { + "description": "Enables the request_user_attention command without any pre-configured scope.", + "type": "string", + "const": "core:window:allow-request-user-attention", + "markdownDescription": "Enables the request_user_attention command without any pre-configured scope." + }, + { + "description": "Enables the scale_factor command without any pre-configured scope.", + "type": "string", + "const": "core:window:allow-scale-factor", + "markdownDescription": "Enables the scale_factor command without any pre-configured scope." + }, + { + "description": "Enables the set_always_on_bottom command without any pre-configured scope.", + "type": "string", + "const": "core:window:allow-set-always-on-bottom", + "markdownDescription": "Enables the set_always_on_bottom command without any pre-configured scope." + }, + { + "description": "Enables the set_always_on_top command without any pre-configured scope.", + "type": "string", + "const": "core:window:allow-set-always-on-top", + "markdownDescription": "Enables the set_always_on_top command without any pre-configured scope." + }, + { + "description": "Enables the set_background_color command without any pre-configured scope.", + "type": "string", + "const": "core:window:allow-set-background-color", + "markdownDescription": "Enables the set_background_color command without any pre-configured scope." + }, + { + "description": "Enables the set_badge_count command without any pre-configured scope.", + "type": "string", + "const": "core:window:allow-set-badge-count", + "markdownDescription": "Enables the set_badge_count command without any pre-configured scope." + }, + { + "description": "Enables the set_badge_label command without any pre-configured scope.", + "type": "string", + "const": "core:window:allow-set-badge-label", + "markdownDescription": "Enables the set_badge_label command without any pre-configured scope." + }, + { + "description": "Enables the set_closable command without any pre-configured scope.", + "type": "string", + "const": "core:window:allow-set-closable", + "markdownDescription": "Enables the set_closable command without any pre-configured scope." + }, + { + "description": "Enables the set_content_protected command without any pre-configured scope.", + "type": "string", + "const": "core:window:allow-set-content-protected", + "markdownDescription": "Enables the set_content_protected command without any pre-configured scope." + }, + { + "description": "Enables the set_cursor_grab command without any pre-configured scope.", + "type": "string", + "const": "core:window:allow-set-cursor-grab", + "markdownDescription": "Enables the set_cursor_grab command without any pre-configured scope." + }, + { + "description": "Enables the set_cursor_icon command without any pre-configured scope.", + "type": "string", + "const": "core:window:allow-set-cursor-icon", + "markdownDescription": "Enables the set_cursor_icon command without any pre-configured scope." + }, + { + "description": "Enables the set_cursor_position command without any pre-configured scope.", + "type": "string", + "const": "core:window:allow-set-cursor-position", + "markdownDescription": "Enables the set_cursor_position command without any pre-configured scope." + }, + { + "description": "Enables the set_cursor_visible command without any pre-configured scope.", + "type": "string", + "const": "core:window:allow-set-cursor-visible", + "markdownDescription": "Enables the set_cursor_visible command without any pre-configured scope." + }, + { + "description": "Enables the set_decorations command without any pre-configured scope.", + "type": "string", + "const": "core:window:allow-set-decorations", + "markdownDescription": "Enables the set_decorations command without any pre-configured scope." + }, + { + "description": "Enables the set_effects command without any pre-configured scope.", + "type": "string", + "const": "core:window:allow-set-effects", + "markdownDescription": "Enables the set_effects command without any pre-configured scope." + }, + { + "description": "Enables the set_enabled command without any pre-configured scope.", + "type": "string", + "const": "core:window:allow-set-enabled", + "markdownDescription": "Enables the set_enabled command without any pre-configured scope." + }, + { + "description": "Enables the set_focus command without any pre-configured scope.", + "type": "string", + "const": "core:window:allow-set-focus", + "markdownDescription": "Enables the set_focus command without any pre-configured scope." + }, + { + "description": "Enables the set_focusable command without any pre-configured scope.", + "type": "string", + "const": "core:window:allow-set-focusable", + "markdownDescription": "Enables the set_focusable command without any pre-configured scope." + }, + { + "description": "Enables the set_fullscreen command without any pre-configured scope.", + "type": "string", + "const": "core:window:allow-set-fullscreen", + "markdownDescription": "Enables the set_fullscreen command without any pre-configured scope." + }, + { + "description": "Enables the set_icon command without any pre-configured scope.", + "type": "string", + "const": "core:window:allow-set-icon", + "markdownDescription": "Enables the set_icon command without any pre-configured scope." + }, + { + "description": "Enables the set_ignore_cursor_events command without any pre-configured scope.", + "type": "string", + "const": "core:window:allow-set-ignore-cursor-events", + "markdownDescription": "Enables the set_ignore_cursor_events command without any pre-configured scope." + }, + { + "description": "Enables the set_max_size command without any pre-configured scope.", + "type": "string", + "const": "core:window:allow-set-max-size", + "markdownDescription": "Enables the set_max_size command without any pre-configured scope." + }, + { + "description": "Enables the set_maximizable command without any pre-configured scope.", + "type": "string", + "const": "core:window:allow-set-maximizable", + "markdownDescription": "Enables the set_maximizable command without any pre-configured scope." + }, + { + "description": "Enables the set_min_size command without any pre-configured scope.", + "type": "string", + "const": "core:window:allow-set-min-size", + "markdownDescription": "Enables the set_min_size command without any pre-configured scope." + }, + { + "description": "Enables the set_minimizable command without any pre-configured scope.", + "type": "string", + "const": "core:window:allow-set-minimizable", + "markdownDescription": "Enables the set_minimizable command without any pre-configured scope." + }, + { + "description": "Enables the set_overlay_icon command without any pre-configured scope.", + "type": "string", + "const": "core:window:allow-set-overlay-icon", + "markdownDescription": "Enables the set_overlay_icon command without any pre-configured scope." + }, + { + "description": "Enables the set_position command without any pre-configured scope.", + "type": "string", + "const": "core:window:allow-set-position", + "markdownDescription": "Enables the set_position command without any pre-configured scope." + }, + { + "description": "Enables the set_progress_bar command without any pre-configured scope.", + "type": "string", + "const": "core:window:allow-set-progress-bar", + "markdownDescription": "Enables the set_progress_bar command without any pre-configured scope." + }, + { + "description": "Enables the set_resizable command without any pre-configured scope.", + "type": "string", + "const": "core:window:allow-set-resizable", + "markdownDescription": "Enables the set_resizable command without any pre-configured scope." + }, + { + "description": "Enables the set_shadow command without any pre-configured scope.", + "type": "string", + "const": "core:window:allow-set-shadow", + "markdownDescription": "Enables the set_shadow command without any pre-configured scope." + }, + { + "description": "Enables the set_simple_fullscreen command without any pre-configured scope.", + "type": "string", + "const": "core:window:allow-set-simple-fullscreen", + "markdownDescription": "Enables the set_simple_fullscreen command without any pre-configured scope." + }, + { + "description": "Enables the set_size command without any pre-configured scope.", + "type": "string", + "const": "core:window:allow-set-size", + "markdownDescription": "Enables the set_size command without any pre-configured scope." + }, + { + "description": "Enables the set_size_constraints command without any pre-configured scope.", + "type": "string", + "const": "core:window:allow-set-size-constraints", + "markdownDescription": "Enables the set_size_constraints command without any pre-configured scope." + }, + { + "description": "Enables the set_skip_taskbar command without any pre-configured scope.", + "type": "string", + "const": "core:window:allow-set-skip-taskbar", + "markdownDescription": "Enables the set_skip_taskbar command without any pre-configured scope." + }, + { + "description": "Enables the set_theme command without any pre-configured scope.", + "type": "string", + "const": "core:window:allow-set-theme", + "markdownDescription": "Enables the set_theme command without any pre-configured scope." + }, + { + "description": "Enables the set_title command without any pre-configured scope.", + "type": "string", + "const": "core:window:allow-set-title", + "markdownDescription": "Enables the set_title command without any pre-configured scope." + }, + { + "description": "Enables the set_title_bar_style command without any pre-configured scope.", + "type": "string", + "const": "core:window:allow-set-title-bar-style", + "markdownDescription": "Enables the set_title_bar_style command without any pre-configured scope." + }, + { + "description": "Enables the set_visible_on_all_workspaces command without any pre-configured scope.", + "type": "string", + "const": "core:window:allow-set-visible-on-all-workspaces", + "markdownDescription": "Enables the set_visible_on_all_workspaces command without any pre-configured scope." + }, + { + "description": "Enables the show command without any pre-configured scope.", + "type": "string", + "const": "core:window:allow-show", + "markdownDescription": "Enables the show command without any pre-configured scope." + }, + { + "description": "Enables the start_dragging command without any pre-configured scope.", + "type": "string", + "const": "core:window:allow-start-dragging", + "markdownDescription": "Enables the start_dragging command without any pre-configured scope." + }, + { + "description": "Enables the start_resize_dragging command without any pre-configured scope.", + "type": "string", + "const": "core:window:allow-start-resize-dragging", + "markdownDescription": "Enables the start_resize_dragging command without any pre-configured scope." + }, + { + "description": "Enables the theme command without any pre-configured scope.", + "type": "string", + "const": "core:window:allow-theme", + "markdownDescription": "Enables the theme command without any pre-configured scope." + }, + { + "description": "Enables the title command without any pre-configured scope.", + "type": "string", + "const": "core:window:allow-title", + "markdownDescription": "Enables the title command without any pre-configured scope." + }, + { + "description": "Enables the toggle_maximize command without any pre-configured scope.", + "type": "string", + "const": "core:window:allow-toggle-maximize", + "markdownDescription": "Enables the toggle_maximize command without any pre-configured scope." + }, + { + "description": "Enables the unmaximize command without any pre-configured scope.", + "type": "string", + "const": "core:window:allow-unmaximize", + "markdownDescription": "Enables the unmaximize command without any pre-configured scope." + }, + { + "description": "Enables the unminimize command without any pre-configured scope.", + "type": "string", + "const": "core:window:allow-unminimize", + "markdownDescription": "Enables the unminimize command without any pre-configured scope." + }, + { + "description": "Denies the available_monitors command without any pre-configured scope.", + "type": "string", + "const": "core:window:deny-available-monitors", + "markdownDescription": "Denies the available_monitors command without any pre-configured scope." + }, + { + "description": "Denies the center command without any pre-configured scope.", + "type": "string", + "const": "core:window:deny-center", + "markdownDescription": "Denies the center command without any pre-configured scope." + }, + { + "description": "Denies the close command without any pre-configured scope.", + "type": "string", + "const": "core:window:deny-close", + "markdownDescription": "Denies the close command without any pre-configured scope." + }, + { + "description": "Denies the create command without any pre-configured scope.", + "type": "string", + "const": "core:window:deny-create", + "markdownDescription": "Denies the create command without any pre-configured scope." + }, + { + "description": "Denies the current_monitor command without any pre-configured scope.", + "type": "string", + "const": "core:window:deny-current-monitor", + "markdownDescription": "Denies the current_monitor command without any pre-configured scope." + }, + { + "description": "Denies the cursor_position command without any pre-configured scope.", + "type": "string", + "const": "core:window:deny-cursor-position", + "markdownDescription": "Denies the cursor_position command without any pre-configured scope." + }, + { + "description": "Denies the destroy command without any pre-configured scope.", + "type": "string", + "const": "core:window:deny-destroy", + "markdownDescription": "Denies the destroy command without any pre-configured scope." + }, + { + "description": "Denies the get_all_windows command without any pre-configured scope.", + "type": "string", + "const": "core:window:deny-get-all-windows", + "markdownDescription": "Denies the get_all_windows command without any pre-configured scope." + }, + { + "description": "Denies the hide command without any pre-configured scope.", + "type": "string", + "const": "core:window:deny-hide", + "markdownDescription": "Denies the hide command without any pre-configured scope." + }, + { + "description": "Denies the inner_position command without any pre-configured scope.", + "type": "string", + "const": "core:window:deny-inner-position", + "markdownDescription": "Denies the inner_position command without any pre-configured scope." + }, + { + "description": "Denies the inner_size command without any pre-configured scope.", + "type": "string", + "const": "core:window:deny-inner-size", + "markdownDescription": "Denies the inner_size command without any pre-configured scope." + }, + { + "description": "Denies the internal_toggle_maximize command without any pre-configured scope.", + "type": "string", + "const": "core:window:deny-internal-toggle-maximize", + "markdownDescription": "Denies the internal_toggle_maximize command without any pre-configured scope." + }, + { + "description": "Denies the is_always_on_top command without any pre-configured scope.", + "type": "string", + "const": "core:window:deny-is-always-on-top", + "markdownDescription": "Denies the is_always_on_top command without any pre-configured scope." + }, + { + "description": "Denies the is_closable command without any pre-configured scope.", + "type": "string", + "const": "core:window:deny-is-closable", + "markdownDescription": "Denies the is_closable command without any pre-configured scope." + }, + { + "description": "Denies the is_decorated command without any pre-configured scope.", + "type": "string", + "const": "core:window:deny-is-decorated", + "markdownDescription": "Denies the is_decorated command without any pre-configured scope." + }, + { + "description": "Denies the is_enabled command without any pre-configured scope.", + "type": "string", + "const": "core:window:deny-is-enabled", + "markdownDescription": "Denies the is_enabled command without any pre-configured scope." + }, + { + "description": "Denies the is_focused command without any pre-configured scope.", + "type": "string", + "const": "core:window:deny-is-focused", + "markdownDescription": "Denies the is_focused command without any pre-configured scope." + }, + { + "description": "Denies the is_fullscreen command without any pre-configured scope.", + "type": "string", + "const": "core:window:deny-is-fullscreen", + "markdownDescription": "Denies the is_fullscreen command without any pre-configured scope." + }, + { + "description": "Denies the is_maximizable command without any pre-configured scope.", + "type": "string", + "const": "core:window:deny-is-maximizable", + "markdownDescription": "Denies the is_maximizable command without any pre-configured scope." + }, + { + "description": "Denies the is_maximized command without any pre-configured scope.", + "type": "string", + "const": "core:window:deny-is-maximized", + "markdownDescription": "Denies the is_maximized command without any pre-configured scope." + }, + { + "description": "Denies the is_minimizable command without any pre-configured scope.", + "type": "string", + "const": "core:window:deny-is-minimizable", + "markdownDescription": "Denies the is_minimizable command without any pre-configured scope." + }, + { + "description": "Denies the is_minimized command without any pre-configured scope.", + "type": "string", + "const": "core:window:deny-is-minimized", + "markdownDescription": "Denies the is_minimized command without any pre-configured scope." + }, + { + "description": "Denies the is_resizable command without any pre-configured scope.", + "type": "string", + "const": "core:window:deny-is-resizable", + "markdownDescription": "Denies the is_resizable command without any pre-configured scope." + }, + { + "description": "Denies the is_visible command without any pre-configured scope.", + "type": "string", + "const": "core:window:deny-is-visible", + "markdownDescription": "Denies the is_visible command without any pre-configured scope." + }, + { + "description": "Denies the maximize command without any pre-configured scope.", + "type": "string", + "const": "core:window:deny-maximize", + "markdownDescription": "Denies the maximize command without any pre-configured scope." + }, + { + "description": "Denies the minimize command without any pre-configured scope.", + "type": "string", + "const": "core:window:deny-minimize", + "markdownDescription": "Denies the minimize command without any pre-configured scope." + }, + { + "description": "Denies the monitor_from_point command without any pre-configured scope.", + "type": "string", + "const": "core:window:deny-monitor-from-point", + "markdownDescription": "Denies the monitor_from_point command without any pre-configured scope." + }, + { + "description": "Denies the outer_position command without any pre-configured scope.", + "type": "string", + "const": "core:window:deny-outer-position", + "markdownDescription": "Denies the outer_position command without any pre-configured scope." + }, + { + "description": "Denies the outer_size command without any pre-configured scope.", + "type": "string", + "const": "core:window:deny-outer-size", + "markdownDescription": "Denies the outer_size command without any pre-configured scope." + }, + { + "description": "Denies the primary_monitor command without any pre-configured scope.", + "type": "string", + "const": "core:window:deny-primary-monitor", + "markdownDescription": "Denies the primary_monitor command without any pre-configured scope." + }, + { + "description": "Denies the request_user_attention command without any pre-configured scope.", + "type": "string", + "const": "core:window:deny-request-user-attention", + "markdownDescription": "Denies the request_user_attention command without any pre-configured scope." + }, + { + "description": "Denies the scale_factor command without any pre-configured scope.", + "type": "string", + "const": "core:window:deny-scale-factor", + "markdownDescription": "Denies the scale_factor command without any pre-configured scope." + }, + { + "description": "Denies the set_always_on_bottom command without any pre-configured scope.", + "type": "string", + "const": "core:window:deny-set-always-on-bottom", + "markdownDescription": "Denies the set_always_on_bottom command without any pre-configured scope." + }, + { + "description": "Denies the set_always_on_top command without any pre-configured scope.", + "type": "string", + "const": "core:window:deny-set-always-on-top", + "markdownDescription": "Denies the set_always_on_top command without any pre-configured scope." + }, + { + "description": "Denies the set_background_color command without any pre-configured scope.", + "type": "string", + "const": "core:window:deny-set-background-color", + "markdownDescription": "Denies the set_background_color command without any pre-configured scope." + }, + { + "description": "Denies the set_badge_count command without any pre-configured scope.", + "type": "string", + "const": "core:window:deny-set-badge-count", + "markdownDescription": "Denies the set_badge_count command without any pre-configured scope." + }, + { + "description": "Denies the set_badge_label command without any pre-configured scope.", + "type": "string", + "const": "core:window:deny-set-badge-label", + "markdownDescription": "Denies the set_badge_label command without any pre-configured scope." + }, + { + "description": "Denies the set_closable command without any pre-configured scope.", + "type": "string", + "const": "core:window:deny-set-closable", + "markdownDescription": "Denies the set_closable command without any pre-configured scope." + }, + { + "description": "Denies the set_content_protected command without any pre-configured scope.", + "type": "string", + "const": "core:window:deny-set-content-protected", + "markdownDescription": "Denies the set_content_protected command without any pre-configured scope." + }, + { + "description": "Denies the set_cursor_grab command without any pre-configured scope.", + "type": "string", + "const": "core:window:deny-set-cursor-grab", + "markdownDescription": "Denies the set_cursor_grab command without any pre-configured scope." + }, + { + "description": "Denies the set_cursor_icon command without any pre-configured scope.", + "type": "string", + "const": "core:window:deny-set-cursor-icon", + "markdownDescription": "Denies the set_cursor_icon command without any pre-configured scope." + }, + { + "description": "Denies the set_cursor_position command without any pre-configured scope.", + "type": "string", + "const": "core:window:deny-set-cursor-position", + "markdownDescription": "Denies the set_cursor_position command without any pre-configured scope." + }, + { + "description": "Denies the set_cursor_visible command without any pre-configured scope.", + "type": "string", + "const": "core:window:deny-set-cursor-visible", + "markdownDescription": "Denies the set_cursor_visible command without any pre-configured scope." + }, + { + "description": "Denies the set_decorations command without any pre-configured scope.", + "type": "string", + "const": "core:window:deny-set-decorations", + "markdownDescription": "Denies the set_decorations command without any pre-configured scope." + }, + { + "description": "Denies the set_effects command without any pre-configured scope.", + "type": "string", + "const": "core:window:deny-set-effects", + "markdownDescription": "Denies the set_effects command without any pre-configured scope." + }, + { + "description": "Denies the set_enabled command without any pre-configured scope.", + "type": "string", + "const": "core:window:deny-set-enabled", + "markdownDescription": "Denies the set_enabled command without any pre-configured scope." + }, + { + "description": "Denies the set_focus command without any pre-configured scope.", + "type": "string", + "const": "core:window:deny-set-focus", + "markdownDescription": "Denies the set_focus command without any pre-configured scope." + }, + { + "description": "Denies the set_focusable command without any pre-configured scope.", + "type": "string", + "const": "core:window:deny-set-focusable", + "markdownDescription": "Denies the set_focusable command without any pre-configured scope." + }, + { + "description": "Denies the set_fullscreen command without any pre-configured scope.", + "type": "string", + "const": "core:window:deny-set-fullscreen", + "markdownDescription": "Denies the set_fullscreen command without any pre-configured scope." + }, + { + "description": "Denies the set_icon command without any pre-configured scope.", + "type": "string", + "const": "core:window:deny-set-icon", + "markdownDescription": "Denies the set_icon command without any pre-configured scope." + }, + { + "description": "Denies the set_ignore_cursor_events command without any pre-configured scope.", + "type": "string", + "const": "core:window:deny-set-ignore-cursor-events", + "markdownDescription": "Denies the set_ignore_cursor_events command without any pre-configured scope." + }, + { + "description": "Denies the set_max_size command without any pre-configured scope.", + "type": "string", + "const": "core:window:deny-set-max-size", + "markdownDescription": "Denies the set_max_size command without any pre-configured scope." + }, + { + "description": "Denies the set_maximizable command without any pre-configured scope.", + "type": "string", + "const": "core:window:deny-set-maximizable", + "markdownDescription": "Denies the set_maximizable command without any pre-configured scope." + }, + { + "description": "Denies the set_min_size command without any pre-configured scope.", + "type": "string", + "const": "core:window:deny-set-min-size", + "markdownDescription": "Denies the set_min_size command without any pre-configured scope." + }, + { + "description": "Denies the set_minimizable command without any pre-configured scope.", + "type": "string", + "const": "core:window:deny-set-minimizable", + "markdownDescription": "Denies the set_minimizable command without any pre-configured scope." + }, + { + "description": "Denies the set_overlay_icon command without any pre-configured scope.", + "type": "string", + "const": "core:window:deny-set-overlay-icon", + "markdownDescription": "Denies the set_overlay_icon command without any pre-configured scope." + }, + { + "description": "Denies the set_position command without any pre-configured scope.", + "type": "string", + "const": "core:window:deny-set-position", + "markdownDescription": "Denies the set_position command without any pre-configured scope." + }, + { + "description": "Denies the set_progress_bar command without any pre-configured scope.", + "type": "string", + "const": "core:window:deny-set-progress-bar", + "markdownDescription": "Denies the set_progress_bar command without any pre-configured scope." + }, + { + "description": "Denies the set_resizable command without any pre-configured scope.", + "type": "string", + "const": "core:window:deny-set-resizable", + "markdownDescription": "Denies the set_resizable command without any pre-configured scope." + }, + { + "description": "Denies the set_shadow command without any pre-configured scope.", + "type": "string", + "const": "core:window:deny-set-shadow", + "markdownDescription": "Denies the set_shadow command without any pre-configured scope." + }, + { + "description": "Denies the set_simple_fullscreen command without any pre-configured scope.", + "type": "string", + "const": "core:window:deny-set-simple-fullscreen", + "markdownDescription": "Denies the set_simple_fullscreen command without any pre-configured scope." + }, + { + "description": "Denies the set_size command without any pre-configured scope.", + "type": "string", + "const": "core:window:deny-set-size", + "markdownDescription": "Denies the set_size command without any pre-configured scope." + }, + { + "description": "Denies the set_size_constraints command without any pre-configured scope.", + "type": "string", + "const": "core:window:deny-set-size-constraints", + "markdownDescription": "Denies the set_size_constraints command without any pre-configured scope." + }, + { + "description": "Denies the set_skip_taskbar command without any pre-configured scope.", + "type": "string", + "const": "core:window:deny-set-skip-taskbar", + "markdownDescription": "Denies the set_skip_taskbar command without any pre-configured scope." + }, + { + "description": "Denies the set_theme command without any pre-configured scope.", + "type": "string", + "const": "core:window:deny-set-theme", + "markdownDescription": "Denies the set_theme command without any pre-configured scope." + }, + { + "description": "Denies the set_title command without any pre-configured scope.", + "type": "string", + "const": "core:window:deny-set-title", + "markdownDescription": "Denies the set_title command without any pre-configured scope." + }, + { + "description": "Denies the set_title_bar_style command without any pre-configured scope.", + "type": "string", + "const": "core:window:deny-set-title-bar-style", + "markdownDescription": "Denies the set_title_bar_style command without any pre-configured scope." + }, + { + "description": "Denies the set_visible_on_all_workspaces command without any pre-configured scope.", + "type": "string", + "const": "core:window:deny-set-visible-on-all-workspaces", + "markdownDescription": "Denies the set_visible_on_all_workspaces command without any pre-configured scope." + }, + { + "description": "Denies the show command without any pre-configured scope.", + "type": "string", + "const": "core:window:deny-show", + "markdownDescription": "Denies the show command without any pre-configured scope." + }, + { + "description": "Denies the start_dragging command without any pre-configured scope.", + "type": "string", + "const": "core:window:deny-start-dragging", + "markdownDescription": "Denies the start_dragging command without any pre-configured scope." + }, + { + "description": "Denies the start_resize_dragging command without any pre-configured scope.", + "type": "string", + "const": "core:window:deny-start-resize-dragging", + "markdownDescription": "Denies the start_resize_dragging command without any pre-configured scope." + }, + { + "description": "Denies the theme command without any pre-configured scope.", + "type": "string", + "const": "core:window:deny-theme", + "markdownDescription": "Denies the theme command without any pre-configured scope." + }, + { + "description": "Denies the title command without any pre-configured scope.", + "type": "string", + "const": "core:window:deny-title", + "markdownDescription": "Denies the title command without any pre-configured scope." + }, + { + "description": "Denies the toggle_maximize command without any pre-configured scope.", + "type": "string", + "const": "core:window:deny-toggle-maximize", + "markdownDescription": "Denies the toggle_maximize command without any pre-configured scope." + }, + { + "description": "Denies the unmaximize command without any pre-configured scope.", + "type": "string", + "const": "core:window:deny-unmaximize", + "markdownDescription": "Denies the unmaximize command without any pre-configured scope." + }, + { + "description": "Denies the unminimize command without any pre-configured scope.", + "type": "string", + "const": "core:window:deny-unminimize", + "markdownDescription": "Denies the unminimize command without any pre-configured scope." + } + ] + }, + "Value": { + "description": "All supported ACL values.", + "anyOf": [ + { + "description": "Represents a null JSON value.", + "type": "null" + }, + { + "description": "Represents a [`bool`].", + "type": "boolean" + }, + { + "description": "Represents a valid ACL [`Number`].", + "allOf": [ + { + "$ref": "#/definitions/Number" + } + ] + }, + { + "description": "Represents a [`String`].", + "type": "string" + }, + { + "description": "Represents a list of other [`Value`]s.", + "type": "array", + "items": { + "$ref": "#/definitions/Value" + } + }, + { + "description": "Represents a map of [`String`] keys to [`Value`]s.", + "type": "object", + "additionalProperties": { + "$ref": "#/definitions/Value" + } + } + ] + }, + "Number": { + "description": "A valid ACL number.", + "anyOf": [ + { + "description": "Represents an [`i64`].", + "type": "integer", + "format": "int64" + }, + { + "description": "Represents a [`f64`].", + "type": "number", + "format": "double" + } + ] + }, + "Target": { + "description": "Platform target.", + "oneOf": [ + { + "description": "MacOS.", + "type": "string", + "enum": [ + "macOS" + ] + }, + { + "description": "Windows.", + "type": "string", + "enum": [ + "windows" + ] + }, + { + "description": "Linux.", + "type": "string", + "enum": [ + "linux" + ] + }, + { + "description": "Android.", + "type": "string", + "enum": [ + "android" + ] + }, + { + "description": "iOS.", + "type": "string", + "enum": [ + "iOS" + ] + } + ] + } + } +} \ No newline at end of file diff --git a/crates/quicnprotochat-gui/icons/icon.png b/crates/quicnprotochat-gui/icons/icon.png new file mode 100644 index 0000000000000000000000000000000000000000..b218cf33737dda4541810f55038411b67f4d063f GIT binary patch literal 2200 zcmeAS@N?(olHy`uVBq!ia0y~yU;;9k7&zE~)R&4YzZe)e;yqm)Ln`LHy{5>>puljz z;M2su9+5^d1v|s~pN7}%7#vRjkY!-llg_}PaDsusVFDvVg90-HgCo$0CLRU`7D=EP zMhpxBDWeKT!(lW%jOL5c@?o@`9IXyUt3^ojAU0iT@l{!UwPs+8gTd3)&t;ucLK6To Cy&53^ literal 0 HcmV?d00001 diff --git a/crates/quicnprotochat-gui/src/backend.rs b/crates/quicnprotochat-gui/src/backend.rs new file mode 100644 index 0000000..0b24809 --- /dev/null +++ b/crates/quicnprotochat-gui/src/backend.rs @@ -0,0 +1,86 @@ +//! 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 quicnprotochat_client::{cmd_health_json, whoami_json}; + +/// Commands the UI can send to the backend thread. +pub enum BackendCommand { + Whoami { + state_path: String, + password: Option, + }, + Health { + server: String, + ca_cert: PathBuf, + server_name: String, + }, +} + +/// Response sent back to the UI. +pub type BackendResponse = Result; + +/// 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)>, thread::JoinHandle<()>) { + let (tx, rx) = mpsc::channel::<(BackendCommand, mpsc::Sender)>(); + + 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() +} diff --git a/crates/quicnprotochat-gui/src/lib.rs b/crates/quicnprotochat-gui/src/lib.rs new file mode 100644 index 0000000..1302864 --- /dev/null +++ b/crates/quicnprotochat-gui/src/lib.rs @@ -0,0 +1,76 @@ +//! quicnprotochat 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)>, +} + +/// Runs whoami on the backend thread and returns JSON string (identity_key, fingerprint, etc.). +#[tauri::command] +fn whoami( + state: tauri::State, + state_path: String, + password: Option, +) -> Result { + 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, + server: String, + ca_cert: Option, + server_name: Option, +) -> Result { + 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"); +} diff --git a/crates/quicnprotochat-gui/src/main.rs b/crates/quicnprotochat-gui/src/main.rs new file mode 100644 index 0000000..1b7d852 --- /dev/null +++ b/crates/quicnprotochat-gui/src/main.rs @@ -0,0 +1,5 @@ +//! Desktop entry point for quicnprotochat-gui. + +fn main() { + quicnprotochat_gui::run() +} diff --git a/crates/quicnprotochat-gui/tauri.conf.json b/crates/quicnprotochat-gui/tauri.conf.json new file mode 100644 index 0000000..48008e1 --- /dev/null +++ b/crates/quicnprotochat-gui/tauri.conf.json @@ -0,0 +1,24 @@ +{ + "$schema": "https://schema.tauri.app/config/2", + "productName": "quicnprotochat-gui", + "identifier": "chat.quicnproto.gui", + "build": { + "frontendDist": "./ui", + "beforeBuildCommand": "", + "beforeDevCommand": "" + }, + "app": { + "windows": [ + { + "title": "quicnprotochat", + "width": 640, + "height": 480 + } + ], + "security": { + "csp": null + } + }, + "bundle": {}, + "plugins": {} +} diff --git a/crates/quicnprotochat-gui/ui/index.html b/crates/quicnprotochat-gui/ui/index.html new file mode 100644 index 0000000..e9b544f --- /dev/null +++ b/crates/quicnprotochat-gui/ui/index.html @@ -0,0 +1,54 @@ + + + + + + quicnprotochat + + + +

quicnprotochat

+

+ + +

+ +
+ +
Click Whoami or Health. Results appear here.
+ + + + diff --git a/crates/quicnprotochat-p2p/src/lib.rs b/crates/quicnprotochat-p2p/src/lib.rs index b6bddf9..cc34983 100644 --- a/crates/quicnprotochat-p2p/src/lib.rs +++ b/crates/quicnprotochat-p2p/src/lib.rs @@ -161,29 +161,26 @@ mod tests { #[tokio::test] async fn p2p_round_trip() { - let alice = local_node().await; - let bob = local_node().await; + let sender = local_node().await; + let receiver = local_node().await; - let bob_addr = bob.endpoint_addr(); - let alice_id = alice.node_id(); - let payload = b"hello from alice via P2P"; + let receiver_addr = receiver.endpoint_addr(); + let sender_id = sender.node_id(); + let payload = b"hello via P2P"; - // Spawn Bob's receiver. - let bob_handle = tokio::spawn(async move { - let msg = bob.recv().await.unwrap(); + let recv_handle = tokio::spawn(async move { + let msg = receiver.recv().await.unwrap(); assert_eq!(msg.payload, payload.to_vec()); - assert_eq!(msg.sender, alice_id); + assert_eq!(msg.sender, sender_id); }); - // Give Bob a moment to start accepting. tokio::time::sleep(std::time::Duration::from_millis(200)).await; - alice.send(bob_addr, payload).await.unwrap(); + sender.send(receiver_addr, payload).await.unwrap(); - // Wait for Bob to finish reading before closing. - bob_handle.await.unwrap(); + recv_handle.await.unwrap(); tokio::time::sleep(std::time::Duration::from_millis(100)).await; - alice.close().await; + sender.close().await; } } diff --git a/crates/quicnprotochat-server/Cargo.toml b/crates/quicnprotochat-server/Cargo.toml index 71652d7..b48f5ac 100644 --- a/crates/quicnprotochat-server/Cargo.toml +++ b/crates/quicnprotochat-server/Cargo.toml @@ -49,3 +49,10 @@ serde = { workspace = true } # CLI clap = { workspace = true } toml = { version = "0.8" } + +# Metrics (Prometheus) +metrics = "0.22" +metrics-exporter-prometheus = "0.15" + +[dev-dependencies] +tempfile = "3" diff --git a/crates/quicnprotochat-server/migrations/001_initial.sql b/crates/quicnprotochat-server/migrations/001_initial.sql new file mode 100644 index 0000000..a770be5 --- /dev/null +++ b/crates/quicnprotochat-server/migrations/001_initial.sql @@ -0,0 +1,47 @@ +CREATE TABLE IF NOT EXISTS key_packages ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + identity_key BLOB NOT NULL, + package_data BLOB NOT NULL, + created_at INTEGER DEFAULT (strftime('%s','now')) +); + +CREATE TABLE IF NOT EXISTS deliveries ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + recipient_key BLOB NOT NULL, + channel_id BLOB NOT NULL DEFAULT X'', + payload BLOB NOT NULL, + created_at INTEGER DEFAULT (strftime('%s','now')) +); + +CREATE TABLE IF NOT EXISTS hybrid_keys ( + identity_key BLOB PRIMARY KEY, + hybrid_public_key BLOB NOT NULL +); + +CREATE INDEX IF NOT EXISTS idx_kp_identity + ON key_packages(identity_key); + +CREATE INDEX IF NOT EXISTS idx_del_recipient_channel + ON deliveries(recipient_key, channel_id); + +CREATE TABLE IF NOT EXISTS server_setup ( + id INTEGER PRIMARY KEY CHECK (id = 1), + setup_data BLOB NOT NULL +); + +CREATE TABLE IF NOT EXISTS users ( + username TEXT PRIMARY KEY, + opaque_record BLOB NOT NULL, + created_at INTEGER DEFAULT (strftime('%s','now')) +); + +CREATE TABLE IF NOT EXISTS user_identity_keys ( + username TEXT PRIMARY KEY, + identity_key BLOB NOT NULL +); + +CREATE TABLE IF NOT EXISTS endpoints ( + identity_key BLOB PRIMARY KEY, + node_addr BLOB NOT NULL, + updated_at INTEGER DEFAULT (strftime('%s','now')) +); diff --git a/crates/quicnprotochat-server/migrations/002_add_seq.sql b/crates/quicnprotochat-server/migrations/002_add_seq.sql new file mode 100644 index 0000000..796cb5c --- /dev/null +++ b/crates/quicnprotochat-server/migrations/002_add_seq.sql @@ -0,0 +1,21 @@ +-- Migration 002: add per-inbox delivery sequence numbers. +-- +-- Adds a `seq` column to the deliveries table and a separate counter table +-- that tracks the next sequence number per (recipient_key, channel_id) inbox. +-- The counter is atomically incremented on each enqueue via an UPSERT so +-- sequence numbers are gapless even under concurrent inserts. +-- +-- Requires SQLite >= 3.35 (RETURNING clause support; available on Ubuntu 22.04+). + +ALTER TABLE deliveries ADD COLUMN seq INTEGER NOT NULL DEFAULT 0; + +CREATE TABLE IF NOT EXISTS delivery_seq_counters ( + recipient_key BLOB NOT NULL, + channel_id BLOB NOT NULL, + next_seq INTEGER NOT NULL DEFAULT 0, + PRIMARY KEY (recipient_key, channel_id) +); + +-- Index lets ORDER BY seq queries use an index scan instead of a sort. +CREATE INDEX IF NOT EXISTS idx_del_seq + ON deliveries (recipient_key, channel_id, seq); diff --git a/crates/quicnprotochat-server/src/auth.rs b/crates/quicnprotochat-server/src/auth.rs new file mode 100644 index 0000000..af5df68 --- /dev/null +++ b/crates/quicnprotochat-server/src/auth.rs @@ -0,0 +1,221 @@ +use std::sync::Arc; + +use dashmap::DashMap; +use quicnprotochat_proto::node_capnp::auth; +use sha2::Digest; +use subtle::ConstantTimeEq; +use tokio::sync::Notify; + +use crate::error_codes::*; + +pub const SESSION_TTL_SECS: u64 = 24 * 60 * 60; // 24 hours +pub const PENDING_LOGIN_TTL_SECS: u64 = 300; // 5 minutes +pub const RATE_LIMIT_WINDOW_SECS: u64 = 60; +pub const RATE_LIMIT_MAX_ENQUEUES: u32 = 100; + +#[derive(Clone, Debug)] +pub struct AuthConfig { + pub required_token: Option>, + /// When true, a valid bearer token (no session) is accepted and the request's identity/key is used (dev/e2e only). + pub allow_insecure_identity_from_request: bool, +} + +impl AuthConfig { + pub fn new(required_token: Option, allow_insecure_identity_from_request: bool) -> Self { + let required_token = required_token + .filter(|s| !s.is_empty()) + .map(|s| s.into_bytes()); + Self { + required_token, + allow_insecure_identity_from_request, + } + } +} + +#[derive(Clone)] +pub struct SessionInfo { + #[allow(dead_code)] + pub username: String, + pub identity_key: Vec, + #[allow(dead_code)] + pub created_at: u64, + pub expires_at: u64, +} + +pub struct PendingLogin { + pub state_bytes: Vec, + pub created_at: u64, +} + +pub struct RateEntry { + pub count: u32, + pub window_start: u64, +} + +#[derive(Clone)] +pub struct AuthContext { + pub token: Vec, + pub identity_key: Option>, +} + +pub fn current_timestamp() -> u64 { + std::time::SystemTime::now() + .duration_since(std::time::UNIX_EPOCH) + .unwrap_or_default() + .as_secs() +} + +pub fn check_rate_limit( + rate_limits: &DashMap, RateEntry>, + token: &[u8], +) -> Result<(), capnp::Error> { + let now = current_timestamp(); + let mut entry = rate_limits.entry(token.to_vec()).or_insert(RateEntry { + count: 0, + window_start: now, + }); + + if now - entry.window_start >= RATE_LIMIT_WINDOW_SECS { + entry.count = 1; + entry.window_start = now; + } else { + entry.count += 1; + if entry.count > RATE_LIMIT_MAX_ENQUEUES { + return Err(crate::error_codes::coded_error( + E014_RATE_LIMITED, + format!( + "rate limit exceeded: {} enqueues in {}s window", + RATE_LIMIT_MAX_ENQUEUES, RATE_LIMIT_WINDOW_SECS + ), + )); + } + } + Ok(()) +} + +pub fn validate_auth( + cfg: &AuthConfig, + sessions: &DashMap, SessionInfo>, + auth: Result, capnp::Error>, +) -> Result<(), capnp::Error> { + validate_auth_context(cfg, sessions, auth).map(|_| ()) +} + +pub fn validate_auth_context( + cfg: &AuthConfig, + sessions: &DashMap, SessionInfo>, + auth: Result, capnp::Error>, +) -> Result { + let auth = auth?; + let version = auth.get_version(); + + if version != 1 { + return Err(crate::error_codes::coded_error( + E001_BAD_AUTH_VERSION, + format!("unsupported auth version {} (expected 1)", version), + )); + } + + let token = auth + .get_access_token() + .map_err(|e| crate::error_codes::coded_error(E020_BAD_PARAMS, format!("auth.accessToken: {e}")))? + .to_vec(); + + if token.is_empty() { + return Err(crate::error_codes::coded_error( + E002_EMPTY_TOKEN, + "auth.version=1 requires non-empty accessToken", + )); + } + + if let Some(expected) = &cfg.required_token { + if expected.len() == token.len() && bool::from(expected.ct_eq(&token)) { + return Ok(AuthContext { + token, + identity_key: None, + }); + } + } + + if let Some(session) = sessions.get(&token) { + let now = current_timestamp(); + if session.expires_at > now { + let identity = if session.identity_key.is_empty() { + None + } else { + Some(session.identity_key.clone()) + }; + + return Ok(AuthContext { + token, + identity_key: identity, + }); + } + drop(session); + sessions.remove(&token); + return Err(crate::error_codes::coded_error( + E017_SESSION_EXPIRED, + "session token has expired", + )); + } + + Err(crate::error_codes::coded_error(E003_INVALID_TOKEN, "invalid accessToken")) +} + +pub fn require_identity<'a>(auth_ctx: &'a AuthContext) -> Result<&'a [u8], capnp::Error> { + match auth_ctx.identity_key.as_deref() { + Some(ik) => Ok(ik), + None => Err(crate::error_codes::coded_error( + E003_INVALID_TOKEN, + "access token is not identity-bound; login required", + )), + } +} + +pub fn require_identity_match(auth_ctx: &AuthContext, expected: &[u8]) -> Result<(), capnp::Error> { + let ik = require_identity(auth_ctx)?; + if ik != expected { + return Err(crate::error_codes::coded_error( + E016_IDENTITY_MISMATCH, + "access token is bound to a different identity", + )); + } + Ok(()) +} + +/// When the token is a valid session, require it to match `request_identity`. +/// When the token is a bearer token (no identity) and `allow_insecure_identity_from_request` is true, accept the request identity (dev/e2e). +pub fn require_identity_or_request( + auth_ctx: &AuthContext, + request_identity: &[u8], + allow_insecure: bool, +) -> Result<(), capnp::Error> { + match auth_ctx.identity_key.as_deref() { + Some(_) => require_identity_match(auth_ctx, request_identity), + None if allow_insecure => Ok(()), + None => Err(crate::error_codes::coded_error( + E003_INVALID_TOKEN, + "access token is not identity-bound; login required", + )), + } +} + +pub fn fmt_hex(bytes: &[u8]) -> String { + let hex: String = bytes.iter().map(|b| format!("{b:02x}")).collect(); + format!("{hex}…") +} + +pub fn waiter(waiters: &DashMap, Arc>, recipient_key: &[u8]) -> Arc { + waiters + .entry(recipient_key.to_vec()) + .or_insert_with(|| Arc::new(Notify::new())) + .clone() +} + +pub fn fingerprint(data: &[u8]) -> Vec { + sha2::Sha256::digest(data).to_vec() +} + +pub fn coded_error(code: &str, msg: impl std::fmt::Display) -> capnp::Error { + crate::error_codes::coded_error(code, msg) +} diff --git a/crates/quicnprotochat-server/src/config.rs b/crates/quicnprotochat-server/src/config.rs new file mode 100644 index 0000000..b4feaa7 --- /dev/null +++ b/crates/quicnprotochat-server/src/config.rs @@ -0,0 +1,187 @@ +use std::path::{Path, PathBuf}; + +use anyhow::Context; +use serde::Deserialize; + +pub const DEFAULT_LISTEN: &str = "0.0.0.0:7000"; +pub const DEFAULT_DATA_DIR: &str = "data"; +pub const DEFAULT_TLS_CERT: &str = "data/server-cert.der"; +pub const DEFAULT_TLS_KEY: &str = "data/server-key.der"; +pub const DEFAULT_STORE_BACKEND: &str = "file"; +pub const DEFAULT_DB_PATH: &str = "data/quicnprotochat.db"; + +#[derive(Debug, Default, Deserialize)] +pub struct FileConfig { + pub listen: Option, + pub data_dir: Option, + pub tls_cert: Option, + pub tls_key: Option, + pub auth_token: Option, + pub allow_insecure_auth: Option, + /// When true, enqueue does not require an identity-bound session: only a valid token is required. + /// The server does not associate the request with a specific sender (Sealed Sender). + #[serde(default)] + pub sealed_sender: Option, + pub store_backend: Option, + pub db_path: Option, + pub db_key: Option, + /// Metrics HTTP listen address (e.g. "0.0.0.0:9090"). If set, /metrics is served there. + pub metrics_listen: Option, + /// When true and metrics_listen is set, start the metrics server. + #[serde(default)] + pub metrics_enabled: Option, +} + +#[derive(Debug)] +pub struct EffectiveConfig { + pub listen: String, + pub data_dir: String, + pub tls_cert: PathBuf, + pub tls_key: PathBuf, + pub auth_token: Option, + pub allow_insecure_auth: bool, + /// When true, enqueue does not require identity; valid token only (Sealed Sender). + pub sealed_sender: bool, + pub store_backend: String, + pub db_path: PathBuf, + pub db_key: String, + /// If Some(addr), metrics server listens here (e.g. "0.0.0.0:9090"). + pub metrics_listen: Option, + /// Start metrics server only when true and metrics_listen is set. + pub metrics_enabled: bool, +} + +pub fn load_config(path: Option<&Path>) -> anyhow::Result { + let path = match path { + Some(p) => PathBuf::from(p), + None => PathBuf::from("quicnprotochat-server.toml"), + }; + + if !path.exists() { + return Ok(FileConfig::default()); + } + + let contents = + std::fs::read_to_string(&path).with_context(|| format!("read config file {path:?}"))?; + let cfg: FileConfig = + toml::from_str(&contents).with_context(|| format!("parse config file {path:?}"))?; + Ok(cfg) +} + +pub fn merge_config(args: &crate::Args, file: &FileConfig) -> EffectiveConfig { + let listen = if args.listen == DEFAULT_LISTEN { + file.listen + .clone() + .unwrap_or_else(|| DEFAULT_LISTEN.to_string()) + } else { + args.listen.clone() + }; + + let data_dir = if args.data_dir == DEFAULT_DATA_DIR { + file.data_dir + .clone() + .unwrap_or_else(|| DEFAULT_DATA_DIR.to_string()) + } else { + args.data_dir.clone() + }; + + let tls_cert = if args.tls_cert == PathBuf::from(DEFAULT_TLS_CERT) { + file.tls_cert + .clone() + .unwrap_or_else(|| PathBuf::from(DEFAULT_TLS_CERT)) + } else { + args.tls_cert.clone() + }; + + let tls_key = if args.tls_key == PathBuf::from(DEFAULT_TLS_KEY) { + file.tls_key + .clone() + .unwrap_or_else(|| PathBuf::from(DEFAULT_TLS_KEY)) + } else { + args.tls_key.clone() + }; + + let auth_token = if args.auth_token.is_some() { + args.auth_token.clone() + } else { + file.auth_token.clone() + }; + + let allow_insecure_auth = if args.allow_insecure_auth { + true + } else { + file.allow_insecure_auth.unwrap_or(false) + }; + + let sealed_sender = args.sealed_sender || file.sealed_sender.unwrap_or(false); + + let store_backend = if args.store_backend == DEFAULT_STORE_BACKEND { + file.store_backend + .clone() + .unwrap_or_else(|| DEFAULT_STORE_BACKEND.to_string()) + } else { + args.store_backend.clone() + }; + + let db_path = if args.db_path == PathBuf::from(DEFAULT_DB_PATH) { + file.db_path + .clone() + .unwrap_or_else(|| PathBuf::from(DEFAULT_DB_PATH)) + } else { + args.db_path.clone() + }; + + let db_key = if args.db_key.is_empty() { + file.db_key.clone().unwrap_or_else(|| args.db_key.clone()) + } else { + args.db_key.clone() + }; + + let metrics_listen = args + .metrics_listen + .clone() + .or_else(|| file.metrics_listen.clone()); + let metrics_enabled = args + .metrics_enabled + .or(file.metrics_enabled) + .unwrap_or(metrics_listen.is_some()); + + EffectiveConfig { + listen, + data_dir, + tls_cert, + tls_key, + auth_token, + allow_insecure_auth, + sealed_sender, + store_backend, + db_path, + db_key, + metrics_listen, + metrics_enabled, + } +} + +pub fn validate_production_config(effective: &EffectiveConfig) -> anyhow::Result<()> { + let token = effective + .auth_token + .as_deref() + .filter(|s| !s.is_empty()) + .ok_or_else(|| { + anyhow::anyhow!("production requires QUICNPROTOCHAT_AUTH_TOKEN (non-empty)") + })?; + if token == "devtoken" { + anyhow::bail!( + "production forbids auth_token 'devtoken'; set a strong QUICNPROTOCHAT_AUTH_TOKEN" + ); + } + if effective.store_backend == "sql" && effective.db_key.is_empty() { + anyhow::bail!("production with store_backend=sql requires non-empty QUICNPROTOCHAT_DB_KEY"); + } + if !effective.tls_cert.exists() || !effective.tls_key.exists() { + anyhow::bail!( + "production requires existing TLS cert and key (no auto-generation); provide QUICNPROTOCHAT_TLS_CERT and QUICNPROTOCHAT_TLS_KEY" + ); + } + Ok(()) +} diff --git a/crates/quicnprotochat-server/src/main.rs b/crates/quicnprotochat-server/src/main.rs index d113c9b..ab32a9c 100644 --- a/crates/quicnprotochat-server/src/main.rs +++ b/crates/quicnprotochat-server/src/main.rs @@ -1,202 +1,37 @@ //! quicnprotochat-server — unified Authentication + Delivery service. //! -//! # Architecture -//! -//! ```text -//! QUIC endpoint (7000) -//! └─ TLS 1.3 handshake (self-signed by default) -//! └─ capnp-rpc VatNetwork (LocalSet, !Send) -//! └─ NodeServiceImpl (KeyPackage + Delivery queues) -//! ``` -//! -//! Because `capnp-rpc` uses `Rc>` internally it is `!Send`. -//! The entire RPC stack lives on a `tokio::task::LocalSet` spawned per -//! connection. +//! The server hosts Authentication + Delivery services over QUIC + Cap'n Proto. -use std::{ - fs, - net::SocketAddr, - path::{Path, PathBuf}, - sync::Arc, - time::Duration, -}; +use std::{net::SocketAddr, path::PathBuf, sync::Arc}; use anyhow::Context; -use capnp::capability::Promise; -use capnp_rpc::{rpc_twoparty_capnp::Side, twoparty, RpcSystem}; use clap::Parser; use dashmap::DashMap; -use opaque_ke::{ - CredentialFinalization, CredentialRequest, RegistrationRequest, RegistrationUpload, - ServerLogin, ServerRegistration, ServerSetup, -}; +use opaque_ke::ServerSetup; use quicnprotochat_core::opaque_auth::OpaqueSuite; -use quicnprotochat_proto::node_capnp::{auth, node_service}; -use quinn::{Endpoint, ServerConfig}; -use quinn_proto::crypto::rustls::QuicServerConfig; +use quinn::Endpoint; use rand::rngs::OsRng; -use rcgen::generate_simple_self_signed; -use rustls::pki_types::{CertificateDer, PrivateKeyDer}; -use rustls::version::TLS13; -use serde::Deserialize; -use sha2::{Digest, Sha256}; -use subtle::ConstantTimeEq; use tokio::sync::Notify; -use tokio::time::timeout; -use tokio_util::compat::{TokioAsyncReadCompatExt, TokioAsyncWriteCompatExt}; +use tokio::task::LocalSet; +mod auth; +mod config; mod error_codes; +mod metrics; +mod node_service; mod sql_store; +mod tls; mod storage; -use error_codes::*; + +use auth::{AuthConfig, PendingLogin, RateEntry, SessionInfo}; +use config::{ + load_config, merge_config, validate_production_config, DEFAULT_DATA_DIR, DEFAULT_DB_PATH, + DEFAULT_LISTEN, DEFAULT_STORE_BACKEND, DEFAULT_TLS_CERT, DEFAULT_TLS_KEY, +}; +use node_service::{handle_node_connection, spawn_cleanup_task}; use sql_store::SqlStore; -use storage::{FileBackedStore, StorageError, Store}; - -const MAX_PAYLOAD_BYTES: usize = 5 * 1024 * 1024; // 5 MB cap per message -const MAX_KEYPACKAGE_BYTES: usize = 1 * 1024 * 1024; // 1 MB cap per KeyPackage -const CURRENT_WIRE_VERSION: u16 = 1; - -const DEFAULT_LISTEN: &str = "0.0.0.0:7000"; -const DEFAULT_DATA_DIR: &str = "data"; -const DEFAULT_TLS_CERT: &str = "data/server-cert.der"; -const DEFAULT_TLS_KEY: &str = "data/server-key.der"; -const DEFAULT_STORE_BACKEND: &str = "file"; -const DEFAULT_DB_PATH: &str = "data/quicnprotochat.db"; - -const SESSION_TTL_SECS: u64 = 24 * 60 * 60; // 24 hours -const PENDING_LOGIN_TTL_SECS: u64 = 300; // 5 minutes -const RATE_LIMIT_WINDOW_SECS: u64 = 60; -const RATE_LIMIT_MAX_ENQUEUES: u32 = 100; -const MAX_QUEUE_DEPTH: usize = 1000; -const MESSAGE_TTL_SECS: u64 = 7 * 24 * 60 * 60; // 7 days - -#[derive(Clone, Debug)] -struct AuthConfig { - required_token: Option>, -} - -impl AuthConfig { - fn new(required_token: Option) -> Self { - let required_token = required_token - .filter(|s| !s.is_empty()) - .map(|s| s.into_bytes()); - Self { required_token } - } -} - -#[derive(Debug, Default, Deserialize)] -struct FileConfig { - listen: Option, - data_dir: Option, - tls_cert: Option, - tls_key: Option, - auth_token: Option, - store_backend: Option, - db_path: Option, - db_key: Option, -} - -#[derive(Debug)] -struct EffectiveConfig { - listen: String, - data_dir: String, - tls_cert: PathBuf, - tls_key: PathBuf, - auth_token: Option, - store_backend: String, - db_path: PathBuf, - db_key: String, -} - -fn load_config(path: Option<&Path>) -> anyhow::Result { - let path = match path { - Some(p) => PathBuf::from(p), - None => PathBuf::from("quicnprotochat-server.toml"), - }; - - if !path.exists() { - return Ok(FileConfig::default()); - } - - let contents = - fs::read_to_string(&path).with_context(|| format!("read config file {path:?}"))?; - let cfg: FileConfig = - toml::from_str(&contents).with_context(|| format!("parse config file {path:?}"))?; - Ok(cfg) -} - -fn merge_config(args: &Args, file: &FileConfig) -> EffectiveConfig { - let listen = if args.listen == DEFAULT_LISTEN { - file.listen - .clone() - .unwrap_or_else(|| DEFAULT_LISTEN.to_string()) - } else { - args.listen.clone() - }; - - let data_dir = if args.data_dir == DEFAULT_DATA_DIR { - file.data_dir - .clone() - .unwrap_or_else(|| DEFAULT_DATA_DIR.to_string()) - } else { - args.data_dir.clone() - }; - - let tls_cert = if args.tls_cert == PathBuf::from(DEFAULT_TLS_CERT) { - file.tls_cert - .clone() - .unwrap_or_else(|| PathBuf::from(DEFAULT_TLS_CERT)) - } else { - args.tls_cert.clone() - }; - - let tls_key = if args.tls_key == PathBuf::from(DEFAULT_TLS_KEY) { - file.tls_key - .clone() - .unwrap_or_else(|| PathBuf::from(DEFAULT_TLS_KEY)) - } else { - args.tls_key.clone() - }; - - let auth_token = if args.auth_token.is_some() { - args.auth_token.clone() - } else { - file.auth_token.clone() - }; - - let store_backend = if args.store_backend == DEFAULT_STORE_BACKEND { - file.store_backend - .clone() - .unwrap_or_else(|| DEFAULT_STORE_BACKEND.to_string()) - } else { - args.store_backend.clone() - }; - - let db_path = if args.db_path == PathBuf::from(DEFAULT_DB_PATH) { - file.db_path - .clone() - .unwrap_or_else(|| PathBuf::from(DEFAULT_DB_PATH)) - } else { - args.db_path.clone() - }; - - let db_key = if args.db_key.is_empty() { - file.db_key.clone().unwrap_or_else(|| args.db_key.clone()) - } else { - args.db_key.clone() - }; - - EffectiveConfig { - listen, - data_dir, - tls_cert, - tls_key, - auth_token, - store_backend, - db_path, - db_key, - } -} +use storage::{FileBackedStore, Store}; +use tls::build_server_config; // ── CLI ─────────────────────────────────────────────────────────────────────── @@ -227,10 +62,18 @@ struct Args { #[arg(long, default_value = DEFAULT_TLS_KEY, env = "QUICNPROTOCHAT_TLS_KEY")] tls_key: PathBuf, - /// Required bearer token for auth.version=1 requests. If unset, any non-empty token is accepted. + /// Required bearer token for auth.version=1 requests. Use --allow-insecure-auth to run without it (dev only). #[arg(long, env = "QUICNPROTOCHAT_AUTH_TOKEN")] auth_token: Option, + /// Allow running without QUICNPROTOCHAT_AUTH_TOKEN (development only). + #[arg(long, env = "QUICNPROTOCHAT_ALLOW_INSECURE_AUTH", default_value_t = false)] + allow_insecure_auth: bool, + + /// Enable Sealed Sender: enqueue does not require identity-bound session, only a valid token. + #[arg(long, env = "QUICNPROTOCHAT_SEALED_SENDER", default_value_t = false)] + sealed_sender: bool, + /// Storage backend: "file" (bincode) or "sql" (SQLCipher-encrypted). #[arg(long, default_value = DEFAULT_STORE_BACKEND, env = "QUICNPROTOCHAT_STORE_BACKEND")] store_backend: String, @@ -242,1097 +85,22 @@ struct Args { /// SQLCipher encryption key. Empty string disables encryption. #[arg(long, default_value = "", env = "QUICNPROTOCHAT_DB_KEY")] db_key: String, -} -// ── Session management ────────────────────────────────────────────────────── + /// Metrics HTTP listen address (e.g. 0.0.0.0:9090). If set and metrics enabled, /metrics is served. + #[arg(long, env = "QUICNPROTOCHAT_METRICS_LISTEN")] + metrics_listen: Option, -struct SessionInfo { - /// For future audit logging. - #[allow(dead_code)] - username: String, - /// For future audit logging. - #[allow(dead_code)] - identity_key: Vec, - #[allow(dead_code)] - created_at: u64, - expires_at: u64, -} - -/// Pending OPAQUE login state with expiry tracking. -struct PendingLogin { - state_bytes: Vec, - created_at: u64, -} - -/// Rate limiter entry for enqueue throttling. -struct RateEntry { - count: u32, - window_start: u64, -} - -fn current_timestamp() -> u64 { - std::time::SystemTime::now() - .duration_since(std::time::UNIX_EPOCH) - .unwrap_or_default() - .as_secs() -} - -// ── Node service implementation ───────────────────────────────────────────── - -/// Cap'n Proto RPC server implementation for `NodeService` (Auth + Delivery). -struct NodeServiceImpl { - store: Arc, - waiters: Arc, Arc>>, - auth_cfg: Arc, - opaque_setup: Arc>, - /// Pending OPAQUE login states keyed by username. - pending_logins: Arc>, - /// Active session tokens → session info. - sessions: Arc, SessionInfo>>, - /// Per-token enqueue rate limiter. - rate_limits: Arc, RateEntry>>, -} - -impl NodeServiceImpl { - fn waiter(&self, recipient_key: &[u8]) -> Arc { - self.waiters - .entry(recipient_key.to_vec()) - .or_insert_with(|| Arc::new(Notify::new())) - .clone() - } -} - -impl node_service::Server for NodeServiceImpl { - /// Upload a single-use KeyPackage and return its SHA-256 fingerprint. - fn upload_key_package( - &mut self, - params: node_service::UploadKeyPackageParams, - mut results: node_service::UploadKeyPackageResults, - ) -> Promise<(), capnp::Error> { - let params = params.get().map_err(|e| { - coded_error( - E020_BAD_PARAMS, - format!("upload_key_package: bad params: {e}"), - ) - }); - - let (identity_key, package) = match params { - Ok(p) => { - if let Err(e) = validate_auth(&self.auth_cfg, &self.sessions, p.get_auth()) { - return Promise::err(e); - } - let ik = match p.get_identity_key() { - Ok(v) => v.to_vec(), - Err(e) => return Promise::err(coded_error(E020_BAD_PARAMS, e)), - }; - let pkg = match p.get_package() { - Ok(v) => v.to_vec(), - Err(e) => return Promise::err(coded_error(E020_BAD_PARAMS, e)), - }; - (ik, pkg) - } - Err(e) => return Promise::err(e), - }; - - if identity_key.len() != 32 { - return Promise::err(coded_error( - E004_IDENTITY_KEY_LENGTH, - format!( - "identityKey must be exactly 32 bytes, got {}", - identity_key.len() - ), - )); - } - if package.is_empty() { - return Promise::err(coded_error(E007_PACKAGE_EMPTY, "package must not be empty")); - } - if package.len() > MAX_KEYPACKAGE_BYTES { - return Promise::err(coded_error( - E008_PACKAGE_TOO_LARGE, - format!("package exceeds max size ({} bytes)", MAX_KEYPACKAGE_BYTES), - )); - } - - // Phase 2: ciphersuite allowlist — reject KeyPackages not using the allowed MLS ciphersuite. - if let Err(e) = quicnprotochat_core::validate_keypackage_ciphersuite(&package) { - return Promise::err(coded_error( - E021_CIPHERSUITE_NOT_ALLOWED, - format!("KeyPackage ciphersuite not allowed: {e}"), - )); - } - - let fingerprint: Vec = Sha256::digest(&package).to_vec(); - if let Err(e) = self - .store - .upload_key_package(&identity_key, package) - .map_err(storage_err) - { - return Promise::err(e); - } - - results.get().set_fingerprint(&fingerprint); - - tracing::debug!( - fingerprint = %fmt_hex(&fingerprint[..4]), - "KeyPackage uploaded" - ); - - Promise::ok(()) - } - - /// Atomically remove and return one KeyPackage for the given identity key. - fn fetch_key_package( - &mut self, - params: node_service::FetchKeyPackageParams, - mut results: node_service::FetchKeyPackageResults, - ) -> Promise<(), capnp::Error> { - let identity_key = match params.get() { - Ok(p) => match p.get_identity_key() { - Ok(v) => v.to_vec(), - Err(e) => return Promise::err(coded_error(E020_BAD_PARAMS, e)), - }, - Err(e) => return Promise::err(coded_error(E020_BAD_PARAMS, e)), - }; - if let Err(e) = params - .get() - .ok() - .map(|p| validate_auth(&self.auth_cfg, &self.sessions, p.get_auth())) - .transpose() - { - return Promise::err(e); - } - - if identity_key.len() != 32 { - return Promise::err(coded_error( - E004_IDENTITY_KEY_LENGTH, - format!( - "identityKey must be exactly 32 bytes, got {}", - identity_key.len() - ), - )); - } - - let package = match self - .store - .fetch_key_package(&identity_key) - .map_err(storage_err) - { - Ok(p) => p, - Err(e) => return Promise::err(e), - }; - - match package { - Some(pkg) => { - tracing::debug!( - identity = %fmt_hex(&identity_key[..4]), - "KeyPackage fetched" - ); - results.get().set_package(&pkg); - } - None => { - tracing::debug!( - identity = %fmt_hex(&identity_key[..4]), - "no KeyPackage available for identity" - ); - results.get().set_package(&[]); - } - } - - Promise::ok(()) - } - - /// Append `payload` to the queue for `recipient_key`. - fn enqueue( - &mut self, - params: node_service::EnqueueParams, - _results: node_service::EnqueueResults, - ) -> Promise<(), capnp::Error> { - let p = match params.get() { - Ok(p) => p, - Err(e) => return Promise::err(coded_error(E020_BAD_PARAMS, e)), - }; - let recipient_key = match p.get_recipient_key() { - Ok(v) => v.to_vec(), - Err(e) => return Promise::err(coded_error(E020_BAD_PARAMS, e)), - }; - let payload = match p.get_payload() { - Ok(v) => v.to_vec(), - Err(e) => return Promise::err(coded_error(E020_BAD_PARAMS, e)), - }; - let channel_id = p.get_channel_id().unwrap_or_default().to_vec(); - let version = p.get_version(); - let auth_token = - match validate_auth_return_token(&self.auth_cfg, &self.sessions, p.get_auth()) { - Ok(t) => t, - Err(e) => return Promise::err(e), - }; - - if recipient_key.len() != 32 { - return Promise::err(coded_error( - E004_IDENTITY_KEY_LENGTH, - format!( - "recipientKey must be exactly 32 bytes, got {}", - recipient_key.len() - ), - )); - } - if payload.is_empty() { - return Promise::err(coded_error(E005_PAYLOAD_EMPTY, "payload must not be empty")); - } - if payload.len() > MAX_PAYLOAD_BYTES { - return Promise::err(coded_error( - E006_PAYLOAD_TOO_LARGE, - format!("payload exceeds max size ({} bytes)", MAX_PAYLOAD_BYTES), - )); - } - if version != CURRENT_WIRE_VERSION { - return Promise::err(coded_error( - E012_WIRE_VERSION, - format!( - "unsupported wire version {} (expected {CURRENT_WIRE_VERSION})", - version - ), - )); - } - - // Rate limiting (Fix 6) - if let Err(e) = check_rate_limit(&self.rate_limits, &auth_token) { - return Promise::err(e); - } - - // Queue depth check (Fix 7) - match self.store.queue_depth(&recipient_key, &channel_id) { - Ok(depth) if depth >= MAX_QUEUE_DEPTH => { - return Promise::err(coded_error( - E015_QUEUE_FULL, - format!("queue depth {} exceeds limit {}", depth, MAX_QUEUE_DEPTH), - )); - } - Err(e) => return Promise::err(storage_err(e)), - _ => {} - } - - if let Err(e) = self - .store - .enqueue(&recipient_key, &channel_id, payload) - .map_err(storage_err) - { - return Promise::err(e); - } - - self.waiter(&recipient_key).notify_waiters(); - - tracing::debug!( - recipient = %fmt_hex(&recipient_key[..4]), - "message enqueued" - ); - - Promise::ok(()) - } - - /// Atomically drain and return queued payloads for `recipient_key`. - fn fetch( - &mut self, - params: node_service::FetchParams, - mut results: node_service::FetchResults, - ) -> Promise<(), capnp::Error> { - let recipient_key = match params.get() { - Ok(p) => match p.get_recipient_key() { - Ok(v) => v.to_vec(), - Err(e) => return Promise::err(coded_error(E020_BAD_PARAMS, e)), - }, - Err(e) => return Promise::err(coded_error(E020_BAD_PARAMS, e)), - }; - let channel_id = params - .get() - .ok() - .and_then(|p| p.get_channel_id().ok()) - .map(|c| c.to_vec()) - .unwrap_or_default(); - let version = params - .get() - .ok() - .map(|p| p.get_version()) - .unwrap_or(CURRENT_WIRE_VERSION); - let limit = params.get().ok().map(|p| p.get_limit()).unwrap_or(0); - if let Err(e) = params - .get() - .ok() - .map(|p| validate_auth(&self.auth_cfg, &self.sessions, p.get_auth())) - .transpose() - { - return Promise::err(e); - } - - if recipient_key.len() != 32 { - return Promise::err(coded_error( - E004_IDENTITY_KEY_LENGTH, - format!( - "recipientKey must be exactly 32 bytes, got {}", - recipient_key.len() - ), - )); - } - if version != CURRENT_WIRE_VERSION { - return Promise::err(coded_error( - E012_WIRE_VERSION, - format!( - "unsupported wire version {} (expected {CURRENT_WIRE_VERSION})", - version - ), - )); - } - - let messages = if limit > 0 { - match self - .store - .fetch_limited(&recipient_key, &channel_id, limit as usize) - .map_err(storage_err) - { - Ok(m) => m, - Err(e) => return Promise::err(e), - } - } else { - match self - .store - .fetch(&recipient_key, &channel_id) - .map_err(storage_err) - { - Ok(m) => m, - Err(e) => return Promise::err(e), - } - }; - - tracing::debug!( - recipient = %fmt_hex(&recipient_key[..4]), - count = messages.len(), - "messages fetched" - ); - - let mut list = results.get().init_payloads(messages.len() as u32); - for (i, msg) in messages.iter().enumerate() { - list.set(i as u32, msg); - } - - Promise::ok(()) - } - - /// Long-polling fetch with timeout (ms). - fn fetch_wait( - &mut self, - params: node_service::FetchWaitParams, - mut results: node_service::FetchWaitResults, - ) -> Promise<(), capnp::Error> { - let p = match params.get() { - Ok(p) => p, - Err(e) => return Promise::err(coded_error(E020_BAD_PARAMS, e)), - }; - let recipient_key = match p.get_recipient_key() { - Ok(v) => v.to_vec(), - Err(e) => return Promise::err(coded_error(E020_BAD_PARAMS, e)), - }; - let channel_id = p.get_channel_id().unwrap_or_default().to_vec(); - let version = p.get_version(); - let timeout_ms = p.get_timeout_ms(); - let limit = p.get_limit(); - if let Err(e) = validate_auth(&self.auth_cfg, &self.sessions, p.get_auth()) { - return Promise::err(e); - } - - if recipient_key.len() != 32 { - return Promise::err(coded_error( - E004_IDENTITY_KEY_LENGTH, - format!( - "recipientKey must be exactly 32 bytes, got {}", - recipient_key.len() - ), - )); - } - if version != CURRENT_WIRE_VERSION { - return Promise::err(coded_error( - E012_WIRE_VERSION, - format!( - "unsupported wire version {} (expected {CURRENT_WIRE_VERSION})", - version - ), - )); - } - - let store = Arc::clone(&self.store); - let waiters = self.waiters.clone(); - - Promise::from_future(async move { - let fetch_fn = |s: &Arc, - rk: &[u8], - ch: &[u8], - lim: u32| - -> Result>, capnp::Error> { - if lim > 0 { - s.fetch_limited(rk, ch, lim as usize).map_err(storage_err) - } else { - s.fetch(rk, ch).map_err(storage_err) - } - }; - - let messages = fetch_fn(&store, &recipient_key, &channel_id, limit)?; - - if messages.is_empty() && timeout_ms > 0 { - let waiter = waiters - .entry(recipient_key.clone()) - .or_insert_with(|| Arc::new(Notify::new())) - .clone(); - let _ = timeout(Duration::from_millis(timeout_ms), waiter.notified()).await; - let msgs = fetch_fn(&store, &recipient_key, &channel_id, limit)?; - fill_payloads_wait(&mut results, msgs); - return Ok(()); - } - - fill_payloads_wait(&mut results, messages); - Ok(()) - }) - } - - fn health( - &mut self, - _params: node_service::HealthParams, - mut results: node_service::HealthResults, - ) -> Promise<(), capnp::Error> { - results.get().set_status("ok"); - Promise::ok(()) - } - - /// Store a hybrid (X25519 + ML-KEM-768) public key for an identity. - fn upload_hybrid_key( - &mut self, - params: node_service::UploadHybridKeyParams, - _results: node_service::UploadHybridKeyResults, - ) -> Promise<(), capnp::Error> { - let p = match params.get() { - Ok(p) => p, - Err(e) => return Promise::err(coded_error(E020_BAD_PARAMS, e)), - }; - let identity_key = match p.get_identity_key() { - Ok(v) => v.to_vec(), - Err(e) => return Promise::err(coded_error(E020_BAD_PARAMS, e)), - }; - let hybrid_pk = match p.get_hybrid_public_key() { - Ok(v) => v.to_vec(), - Err(e) => return Promise::err(coded_error(E020_BAD_PARAMS, e)), - }; - - // Fix 1: Auth required on hybrid key ops - if let Err(e) = validate_auth(&self.auth_cfg, &self.sessions, p.get_auth()) { - return Promise::err(e); - } - - if identity_key.len() != 32 { - return Promise::err(coded_error( - E004_IDENTITY_KEY_LENGTH, - format!( - "identityKey must be exactly 32 bytes, got {}", - identity_key.len() - ), - )); - } - if hybrid_pk.is_empty() { - return Promise::err(coded_error( - E013_HYBRID_KEY_EMPTY, - "hybridPublicKey must not be empty", - )); - } - - if let Err(e) = self - .store - .upload_hybrid_key(&identity_key, hybrid_pk) - .map_err(storage_err) - { - return Promise::err(e); - } - - tracing::debug!( - identity = %fmt_hex(&identity_key[..4]), - "hybrid public key uploaded" - ); - - Promise::ok(()) - } - - /// Fetch a peer's hybrid public key. - fn fetch_hybrid_key( - &mut self, - params: node_service::FetchHybridKeyParams, - mut results: node_service::FetchHybridKeyResults, - ) -> Promise<(), capnp::Error> { - let p = match params.get() { - Ok(p) => p, - Err(e) => return Promise::err(coded_error(E020_BAD_PARAMS, e)), - }; - let identity_key = match p.get_identity_key() { - Ok(v) => v.to_vec(), - Err(e) => return Promise::err(coded_error(E020_BAD_PARAMS, e)), - }; - - // Fix 1: Auth required on hybrid key ops - if let Err(e) = validate_auth(&self.auth_cfg, &self.sessions, p.get_auth()) { - return Promise::err(e); - } - - if identity_key.len() != 32 { - return Promise::err(coded_error( - E004_IDENTITY_KEY_LENGTH, - format!( - "identityKey must be exactly 32 bytes, got {}", - identity_key.len() - ), - )); - } - - let hybrid_pk = match self - .store - .fetch_hybrid_key(&identity_key) - .map_err(storage_err) - { - Ok(p) => p, - Err(e) => return Promise::err(e), - }; - - match hybrid_pk { - Some(pk) => { - tracing::debug!( - identity = %fmt_hex(&identity_key[..4]), - "hybrid key fetched" - ); - results.get().set_hybrid_public_key(&pk); - } - None => { - tracing::debug!( - identity = %fmt_hex(&identity_key[..4]), - "no hybrid key for identity" - ); - results.get().set_hybrid_public_key(&[]); - } - } - - Promise::ok(()) - } - - // ── OPAQUE registration ───────────────────────────────────────────────── - - fn opaque_register_start( - &mut self, - params: node_service::OpaqueRegisterStartParams, - mut results: node_service::OpaqueRegisterStartResults, - ) -> Promise<(), capnp::Error> { - let p = match params.get() { - Ok(p) => p, - Err(e) => return Promise::err(coded_error(E020_BAD_PARAMS, e)), - }; - let username = match p.get_username() { - Ok(v) => v.to_string().unwrap_or_default().to_string(), - Err(e) => return Promise::err(coded_error(E020_BAD_PARAMS, e)), - }; - let request_bytes = match p.get_request() { - Ok(v) => v.to_vec(), - Err(e) => return Promise::err(coded_error(E020_BAD_PARAMS, e)), - }; - - if username.is_empty() { - return Promise::err(coded_error( - E011_USERNAME_EMPTY, - "username must not be empty", - )); - } - - let reg_request = match RegistrationRequest::::deserialize(&request_bytes) { - Ok(r) => r, - Err(e) => { - return Promise::err(coded_error( - E010_OPAQUE_ERROR, - format!("invalid registration request: {e}"), - )) - } - }; - - let result = match ServerRegistration::::start( - &self.opaque_setup, - reg_request, - username.as_bytes(), - ) { - Ok(r) => r, - Err(e) => { - return Promise::err(coded_error( - E010_OPAQUE_ERROR, - format!("OPAQUE register start failed: {e}"), - )) - } - }; - - let response_bytes = result.message.serialize(); - results.get().set_response(&response_bytes); - - tracing::info!(user = %username, "OPAQUE registration started"); - Promise::ok(()) - } - - fn opaque_register_finish( - &mut self, - params: node_service::OpaqueRegisterFinishParams, - mut results: node_service::OpaqueRegisterFinishResults, - ) -> Promise<(), capnp::Error> { - let p = match params.get() { - Ok(p) => p, - Err(e) => return Promise::err(coded_error(E020_BAD_PARAMS, e)), - }; - let username = match p.get_username() { - Ok(v) => v.to_string().unwrap_or_default().to_string(), - Err(e) => return Promise::err(coded_error(E020_BAD_PARAMS, e)), - }; - let upload_bytes = match p.get_upload() { - Ok(v) => v.to_vec(), - Err(e) => return Promise::err(coded_error(E020_BAD_PARAMS, e)), - }; - let identity_key = p.get_identity_key().unwrap_or_default().to_vec(); - - if username.is_empty() { - return Promise::err(coded_error( - E011_USERNAME_EMPTY, - "username must not be empty", - )); - } - - // Fix 5: Registration collision check - match self.store.has_user_record(&username) { - Ok(true) => { - return Promise::err(coded_error( - E018_USER_EXISTS, - format!("user '{}' already registered", username), - )) - } - Err(e) => return Promise::err(storage_err(e)), - _ => {} - } - - let upload = match RegistrationUpload::::deserialize(&upload_bytes) { - Ok(u) => u, - Err(e) => { - return Promise::err(coded_error( - E010_OPAQUE_ERROR, - format!("invalid registration upload: {e}"), - )) - } - }; - - let password_file = ServerRegistration::::finish(upload); - let record_bytes = password_file.serialize().to_vec(); - - if let Err(e) = self - .store - .store_user_record(&username, record_bytes) - .map_err(storage_err) - { - return Promise::err(e); - } - - // Fix 2: Store identity key alongside OPAQUE record - if !identity_key.is_empty() { - if let Err(e) = self - .store - .store_user_identity_key(&username, identity_key) - .map_err(storage_err) - { - return Promise::err(e); - } - } - - results.get().set_success(true); - tracing::info!(user = %username, "OPAQUE registration complete"); - Promise::ok(()) - } - - // ── OPAQUE login ──────────────────────────────────────────────────────── - - fn opaque_login_start( - &mut self, - params: node_service::OpaqueLoginStartParams, - mut results: node_service::OpaqueLoginStartResults, - ) -> Promise<(), capnp::Error> { - let p = match params.get() { - Ok(p) => p, - Err(e) => return Promise::err(coded_error(E020_BAD_PARAMS, e)), - }; - let username = match p.get_username() { - Ok(v) => v.to_string().unwrap_or_default().to_string(), - Err(e) => return Promise::err(coded_error(E020_BAD_PARAMS, e)), - }; - let request_bytes = match p.get_request() { - Ok(v) => v.to_vec(), - Err(e) => return Promise::err(coded_error(E020_BAD_PARAMS, e)), - }; - - if username.is_empty() { - return Promise::err(coded_error( - E011_USERNAME_EMPTY, - "username must not be empty", - )); - } - - let credential_request = match CredentialRequest::::deserialize(&request_bytes) - { - Ok(r) => r, - Err(e) => { - return Promise::err(coded_error( - E010_OPAQUE_ERROR, - format!("invalid credential request: {e}"), - )) - } - }; - - // Load user's OPAQUE password file (if registered). - let password_file = match self.store.get_user_record(&username) { - Ok(Some(bytes)) => match ServerRegistration::::deserialize(&bytes) { - Ok(pf) => Some(pf), - Err(e) => { - return Promise::err(coded_error( - E010_OPAQUE_ERROR, - format!("corrupt user record: {e}"), - )) - } - }, - Ok(None) => None, - Err(e) => return Promise::err(storage_err(e)), - }; - - let mut rng = OsRng; - let result = match ServerLogin::::start( - &mut rng, - &self.opaque_setup, - password_file, - credential_request, - username.as_bytes(), - Default::default(), - ) { - Ok(r) => r, - Err(e) => { - return Promise::err(coded_error( - E010_OPAQUE_ERROR, - format!("OPAQUE login start failed: {e}"), - )) - } - }; - - // Persist the ServerLogin state for the finish step (Fix 4: with expiry). - let state_bytes = result.state.serialize().to_vec(); - self.pending_logins.insert( - username.clone(), - PendingLogin { - state_bytes, - created_at: current_timestamp(), - }, - ); - - let response_bytes = result.message.serialize(); - results.get().set_response(&response_bytes); - - tracing::info!(user = %username, "OPAQUE login started"); - Promise::ok(()) - } - - fn opaque_login_finish( - &mut self, - params: node_service::OpaqueLoginFinishParams, - mut results: node_service::OpaqueLoginFinishResults, - ) -> Promise<(), capnp::Error> { - let p = match params.get() { - Ok(p) => p, - Err(e) => return Promise::err(coded_error(E020_BAD_PARAMS, e)), - }; - let username = match p.get_username() { - Ok(v) => v.to_string().unwrap_or_default().to_string(), - Err(e) => return Promise::err(coded_error(E020_BAD_PARAMS, e)), - }; - let finalization_bytes = match p.get_finalization() { - Ok(v) => v.to_vec(), - Err(e) => return Promise::err(coded_error(E020_BAD_PARAMS, e)), - }; - let identity_key = p.get_identity_key().unwrap_or_default().to_vec(); - - if username.is_empty() { - return Promise::err(coded_error( - E011_USERNAME_EMPTY, - "username must not be empty", - )); - } - - // Retrieve the pending ServerLogin state. - let pending = match self.pending_logins.remove(&username) { - Some((_, pl)) => pl, - None => { - return Promise::err(coded_error( - E019_NO_PENDING_LOGIN, - "no pending login for this username", - )) - } - }; - - let server_login = match ServerLogin::::deserialize(&pending.state_bytes) { - Ok(s) => s, - Err(e) => { - return Promise::err(coded_error( - E010_OPAQUE_ERROR, - format!("corrupt login state: {e}"), - )) - } - }; - - let finalization = - match CredentialFinalization::::deserialize(&finalization_bytes) { - Ok(f) => f, - Err(e) => { - return Promise::err(coded_error( - E010_OPAQUE_ERROR, - format!("invalid credential finalization: {e}"), - )) - } - }; - - let _result = match server_login.finish(finalization, Default::default()) { - Ok(r) => r, - Err(e) => { - return Promise::err(coded_error( - E010_OPAQUE_ERROR, - format!("OPAQUE login finish failed (bad password?): {e}"), - )) - } - }; - - // Fix 2: Verify identity key matches stored one (if provided and stored) - if !identity_key.is_empty() { - if let Ok(Some(stored_ik)) = self.store.get_user_identity_key(&username) { - if stored_ik != identity_key { - return Promise::err(coded_error( - E016_IDENTITY_MISMATCH, - "identity key does not match registered key", - )); - } - } - } - - // Generate a random session token. - let mut token = [0u8; 32]; - rand::RngCore::fill_bytes(&mut OsRng, &mut token); - let token_vec = token.to_vec(); - - let now = current_timestamp(); - self.sessions.insert( - token_vec.clone(), - SessionInfo { - username: username.clone(), - identity_key, - created_at: now, - expires_at: now + SESSION_TTL_SECS, - }, - ); - - results.get().set_session_token(&token_vec); - - tracing::info!(user = %username, "OPAQUE login complete — session token issued"); - Promise::ok(()) - } - - fn publish_endpoint( - &mut self, - params: node_service::PublishEndpointParams, - _results: node_service::PublishEndpointResults, - ) -> Promise<(), capnp::Error> { - let p = match params.get() { - Ok(p) => p, - Err(e) => return Promise::err(coded_error(E020_BAD_PARAMS, e)), - }; - let identity_key = match p.get_identity_key() { - Ok(v) => v.to_vec(), - Err(e) => return Promise::err(coded_error(E020_BAD_PARAMS, e)), - }; - let node_addr = match p.get_node_addr() { - Ok(v) => v.to_vec(), - Err(e) => return Promise::err(coded_error(E020_BAD_PARAMS, e)), - }; - if let Err(e) = validate_auth(&self.auth_cfg, &self.sessions, p.get_auth()) { - return Promise::err(e); - } - - if identity_key.len() != 32 { - return Promise::err(coded_error( - E004_IDENTITY_KEY_LENGTH, - format!( - "identityKey must be exactly 32 bytes, got {}", - identity_key.len() - ), - )); - } - - if let Err(e) = self - .store - .publish_endpoint(&identity_key, node_addr) - .map_err(storage_err) - { - return Promise::err(e); - } - - tracing::debug!(identity = %fmt_hex(&identity_key[..4]), "endpoint published"); - Promise::ok(()) - } - - fn resolve_endpoint( - &mut self, - params: node_service::ResolveEndpointParams, - mut results: node_service::ResolveEndpointResults, - ) -> Promise<(), capnp::Error> { - let p = match params.get() { - Ok(p) => p, - Err(e) => return Promise::err(coded_error(E020_BAD_PARAMS, e)), - }; - let identity_key = match p.get_identity_key() { - Ok(v) => v.to_vec(), - Err(e) => return Promise::err(coded_error(E020_BAD_PARAMS, e)), - }; - if let Err(e) = validate_auth(&self.auth_cfg, &self.sessions, p.get_auth()) { - return Promise::err(e); - } - - if identity_key.len() != 32 { - return Promise::err(coded_error( - E004_IDENTITY_KEY_LENGTH, - format!( - "identityKey must be exactly 32 bytes, got {}", - identity_key.len() - ), - )); - } - - match self - .store - .resolve_endpoint(&identity_key) - .map_err(storage_err) - { - Ok(Some(addr)) => { - results.get().set_node_addr(&addr); - } - Ok(None) => { - results.get().set_node_addr(&[]); - } - Err(e) => return Promise::err(e), - } - - Promise::ok(()) - } -} - -fn fill_payloads_wait(results: &mut node_service::FetchWaitResults, messages: Vec>) { - let mut list = results.get().init_payloads(messages.len() as u32); - for (i, msg) in messages.iter().enumerate() { - list.set(i as u32, msg); - } -} - -fn storage_err(err: StorageError) -> capnp::Error { - coded_error(E009_STORAGE_ERROR, err) -} - -// Fix 6: Rate limiting helper -fn check_rate_limit( - rate_limits: &DashMap, RateEntry>, - token: &[u8], -) -> Result<(), capnp::Error> { - let now = current_timestamp(); - let mut entry = rate_limits.entry(token.to_vec()).or_insert(RateEntry { - count: 0, - window_start: now, - }); - - if now - entry.window_start >= RATE_LIMIT_WINDOW_SECS { - entry.count = 1; - entry.window_start = now; - } else { - entry.count += 1; - if entry.count > RATE_LIMIT_MAX_ENQUEUES { - return Err(coded_error( - E014_RATE_LIMITED, - format!( - "rate limit exceeded: {} enqueues in {}s window", - RATE_LIMIT_MAX_ENQUEUES, RATE_LIMIT_WINDOW_SECS - ), - )); - } - } - Ok(()) -} - -// Fix 11: Constant-time token comparison -fn validate_auth( - cfg: &AuthConfig, - sessions: &DashMap, SessionInfo>, - auth: Result, capnp::Error>, -) -> Result<(), capnp::Error> { - validate_auth_return_token(cfg, sessions, auth).map(|_| ()) -} - -fn validate_auth_return_token( - cfg: &AuthConfig, - sessions: &DashMap, SessionInfo>, - auth: Result, capnp::Error>, -) -> Result, capnp::Error> { - let auth = auth?; - let version = auth.get_version(); - - if version != 1 { - return Err(coded_error( - E001_BAD_AUTH_VERSION, - format!("unsupported auth version {} (expected 1)", version), - )); - } - - let token = auth - .get_access_token() - .map_err(|e| coded_error(E020_BAD_PARAMS, format!("auth.accessToken: {e}")))? - .to_vec(); - - if token.is_empty() { - return Err(coded_error( - E002_EMPTY_TOKEN, - "auth.version=1 requires non-empty accessToken", - )); - } - - // Accept if token matches the static required_token (constant-time comparison). - if let Some(expected) = &cfg.required_token { - if expected.len() == token.len() && bool::from(expected.ct_eq(&token)) { - return Ok(token); - } - } - - // Accept if token is a valid OPAQUE session token (Fix 3: check expiry). - if let Some(session) = sessions.get(&token) { - let now = current_timestamp(); - if session.expires_at > now { - return Ok(token); - } - // Expired — will be cleaned up by background task. - drop(session); - sessions.remove(&token); - return Err(coded_error( - E017_SESSION_EXPIRED, - "session token has expired", - )); - } - - // Require either static token or valid session; no legacy accept-any-token. - Err(coded_error(E003_INVALID_TOKEN, "invalid accessToken")) + /// Enable metrics server when metrics_listen is set. + #[arg(long, env = "QUICNPROTOCHAT_METRICS_ENABLED")] + metrics_enabled: Option, } // ── Entry point ─────────────────────────────────────────────────────────────── #[tokio::main] async fn main() -> anyhow::Result<()> { + let _ = rustls::crypto::ring::default_provider().install_default(); + tracing_subscriber::fmt() .with_env_filter( tracing_subscriber::EnvFilter::try_from_default_env() @@ -1351,6 +119,44 @@ async fn main() -> anyhow::Result<()> { validate_production_config(&effective)?; } + // Optional metrics server: only start when metrics_enabled and metrics_listen are set. + if effective.metrics_enabled { + if let Some(addr_str) = &effective.metrics_listen { + let addr: std::net::SocketAddr = addr_str + .parse() + .context("metrics_listen must be host:port (e.g. 0.0.0.0:9090)")?; + metrics_exporter_prometheus::PrometheusBuilder::new() + .with_http_listener(addr) + .install() + .context("failed to install Prometheus metrics exporter")?; + tracing::info!(addr = %addr_str, "metrics server listening on /metrics"); + } + } + + // In non-production, require an explicit opt-out before running without a static token. + if !production + && effective + .auth_token + .as_deref() + .map(|s| s.is_empty()) + .unwrap_or(true) + && !effective.allow_insecure_auth + { + anyhow::bail!( + "missing QUICNPROTOCHAT_AUTH_TOKEN; set one or pass --allow-insecure-auth for development" + ); + } + + if effective.allow_insecure_auth + && effective + .auth_token + .as_deref() + .map(|s| s.is_empty()) + .unwrap_or(true) + { + tracing::warn!("running without QUICNPROTOCHAT_AUTH_TOKEN (allow-insecure-auth enabled); development only"); + } + let listen: SocketAddr = effective .listen .parse() @@ -1370,6 +176,9 @@ async fn main() -> anyhow::Result<()> { encrypted = !effective.db_key.is_empty(), "opening SQLCipher store" ); + if effective.db_key.is_empty() { + tracing::warn!("db_key is empty; SQL store will be plaintext (development only)"); + } Arc::new(SqlStore::open(&effective.db_path, &effective.db_key)?) } "file" | _ => { @@ -1377,7 +186,11 @@ async fn main() -> anyhow::Result<()> { Arc::new(FileBackedStore::open(&effective.data_dir)?) } }; - let auth_cfg = Arc::new(AuthConfig::new(effective.auth_token.clone())); + + let auth_cfg = Arc::new(AuthConfig::new( + effective.auth_token.clone(), + effective.allow_insecure_auth, + )); let waiters: Arc, Arc>> = Arc::new(DashMap::new()); // OPAQUE ServerSetup: load from storage or generate fresh. @@ -1399,44 +212,18 @@ async fn main() -> anyhow::Result<()> { } Err(e) => return Err(anyhow::anyhow!("load OPAQUE server setup: {e}")), }; + let pending_logins: Arc> = Arc::new(DashMap::new()); let sessions: Arc, SessionInfo>> = Arc::new(DashMap::new()); let rate_limits: Arc, RateEntry>> = Arc::new(DashMap::new()); - // Background cleanup task (Fix 3, 4, 6, 7): expire sessions, pending logins, - // rate limit entries, and old messages. - { - let sessions = Arc::clone(&sessions); - let pending_logins = Arc::clone(&pending_logins); - let rate_limits = Arc::clone(&rate_limits); - let store = Arc::clone(&store); - tokio::spawn(async move { - let mut interval = tokio::time::interval(Duration::from_secs(60)); - loop { - interval.tick().await; - let now = current_timestamp(); - - // Expire sessions (Fix 3) - sessions.retain(|_, info| info.expires_at > now); - - // Expire pending logins (Fix 4) - pending_logins.retain(|_, pl| now - pl.created_at < PENDING_LOGIN_TTL_SECS); - - // Expire stale rate limit entries (Fix 6) - rate_limits - .retain(|_, entry| now - entry.window_start < RATE_LIMIT_WINDOW_SECS * 2); - - // GC expired messages (Fix 7) - match store.gc_expired_messages(MESSAGE_TTL_SECS) { - Ok(n) if n > 0 => { - tracing::debug!(expired = n, "garbage collected expired messages") - } - Err(e) => tracing::warn!(error = %e, "message GC failed"), - _ => {} - } - } - }); - } + // Background cleanup task (expire sessions, pending logins, rate limits, and stale messages). + spawn_cleanup_task( + Arc::clone(&sessions), + Arc::clone(&pending_logins), + Arc::clone(&rate_limits), + Arc::clone(&store), + ); let endpoint = Endpoint::server(server_config, listen)?; @@ -1445,9 +232,8 @@ async fn main() -> anyhow::Result<()> { "accepting QUIC connections" ); - // capnp-rpc is !Send (Rc internals), so all RPC tasks must stay on a - // LocalSet. Both accept loops share one LocalSet. - let local = tokio::task::LocalSet::new(); + // capnp-rpc is !Send (Rc internals), so all RPC tasks must stay on a LocalSet. + let local = LocalSet::new(); local .run_until(async move { loop { @@ -1475,6 +261,8 @@ async fn main() -> anyhow::Result<()> { let pending_logins = Arc::clone(&pending_logins); let sessions = Arc::clone(&sessions); let rate_limits = Arc::clone(&rate_limits); + let sealed_sender = effective.sealed_sender; + tokio::task::spawn_local(async move { if let Err(e) = handle_node_connection( connecting, @@ -1485,6 +273,7 @@ async fn main() -> anyhow::Result<()> { pending_logins, sessions, rate_limits, + sealed_sender, ) .await { @@ -1503,142 +292,7 @@ async fn main() -> anyhow::Result<()> { Ok::<(), anyhow::Error>(()) }) - .await -} - -// ── Per-connection handlers ─────────────────────────────────────────────────── - -/// Handle one NodeService connection. -#[allow(clippy::too_many_arguments)] -async fn handle_node_connection( - connecting: quinn::Connecting, - store: Arc, - waiters: Arc, Arc>>, - auth_cfg: Arc, - opaque_setup: Arc>, - pending_logins: Arc>, - sessions: Arc, SessionInfo>>, - rate_limits: Arc, RateEntry>>, -) -> Result<(), anyhow::Error> { - let connection = connecting.await?; - - tracing::info!(peer = %connection.remote_address(), "QUIC connected"); - - let (send, recv) = connection - .accept_bi() - .await - .map_err(|e| anyhow::anyhow!("failed to accept bi stream: {e}"))?; - let (reader, writer) = (recv.compat(), send.compat_write()); - - let network = twoparty::VatNetwork::new(reader, writer, Side::Server, Default::default()); - - let service: node_service::Client = capnp_rpc::new_client(NodeServiceImpl { - store, - waiters, - auth_cfg, - opaque_setup, - pending_logins, - sessions, - rate_limits, - }); - - RpcSystem::new(Box::new(network), Some(service.client)) - .await - .map_err(|e| anyhow::anyhow!("NodeService RPC error: {e}")) -} - -// ── Helpers ─────────────────────────────────────────────────────────────────── - -/// Format the first `n` bytes of a slice as lowercase hex with a trailing `…`. -fn fmt_hex(bytes: &[u8]) -> String { - let hex: String = bytes.iter().map(|b| format!("{b:02x}")).collect(); - format!("{hex}…") -} - -fn validate_production_config(effective: &EffectiveConfig) -> anyhow::Result<()> { - let token = effective - .auth_token - .as_deref() - .filter(|s| !s.is_empty()) - .ok_or_else(|| { - anyhow::anyhow!("production requires QUICNPROTOCHAT_AUTH_TOKEN (non-empty)") - })?; - if token == "devtoken" { - anyhow::bail!( - "production forbids auth_token 'devtoken'; set a strong QUICNPROTOCHAT_AUTH_TOKEN" - ); - } - if effective.store_backend == "sql" && effective.db_key.is_empty() { - anyhow::bail!("production with store_backend=sql requires non-empty QUICNPROTOCHAT_DB_KEY"); - } - if !effective.tls_cert.exists() || !effective.tls_key.exists() { - anyhow::bail!( - "production requires existing TLS cert and key (no auto-generation); provide QUICNPROTOCHAT_TLS_CERT and QUICNPROTOCHAT_TLS_KEY" - ); - } - Ok(()) -} - -/// Ensure a self-signed certificate exists on disk and return a QUIC server config. -/// When `production` is true, cert and key must already exist (no auto-generation). -fn build_server_config( - cert_path: &PathBuf, - key_path: &PathBuf, - production: bool, -) -> anyhow::Result { - if !cert_path.exists() || !key_path.exists() { - if production { - anyhow::bail!( - "TLS cert or key missing at {:?} / {:?}; production mode forbids auto-generation", - cert_path, - key_path - ); - } - generate_self_signed_cert(cert_path, key_path)?; - } - - let cert_bytes = fs::read(cert_path).context("read cert")?; - let key_bytes = fs::read(key_path).context("read key")?; - - let cert_chain = vec![CertificateDer::from(cert_bytes)]; - let key = PrivateKeyDer::try_from(key_bytes).map_err(|_| anyhow::anyhow!("invalid key"))?; - - let mut tls = rustls::ServerConfig::builder_with_protocol_versions(&[&TLS13]) - .with_no_client_auth() - .with_single_cert(cert_chain, key)?; - tls.alpn_protocols = vec![b"capnp".to_vec()]; - - let crypto = QuicServerConfig::try_from(tls) - .map_err(|e| anyhow::anyhow!("invalid server TLS config: {e}"))?; - - Ok(ServerConfig::with_crypto(Arc::new(crypto))) -} - -fn generate_self_signed_cert(cert_path: &PathBuf, key_path: &PathBuf) -> anyhow::Result<()> { - if let Some(parent) = cert_path.parent() { - fs::create_dir_all(parent).context("create cert dir")?; - } - if let Some(parent) = key_path.parent() { - fs::create_dir_all(parent).context("create key dir")?; - } - - let subject_alt_names = vec![ - "localhost".to_string(), - "127.0.0.1".to_string(), - "::1".to_string(), - ]; - - let issued = generate_simple_self_signed(subject_alt_names)?; - let key_der = issued.key_pair.serialize_der(); - - fs::write(cert_path, issued.cert.der()).context("write cert")?; - fs::write(key_path, &key_der).context("write key")?; - - tracing::info!( - cert = %cert_path.display(), - key = %key_path.display(), - "generated self-signed TLS certificate" - ); + .await?; Ok(()) } diff --git a/crates/quicnprotochat-server/src/metrics.rs b/crates/quicnprotochat-server/src/metrics.rs new file mode 100644 index 0000000..fd9da9e --- /dev/null +++ b/crates/quicnprotochat-server/src/metrics.rs @@ -0,0 +1,49 @@ +//! Prometheus metrics for the server. +//! +//! All counters/histograms/gauges use the `metrics` crate and are exported +//! via metrics-exporter-prometheus on a configurable HTTP port (e.g. /metrics). + +/// Record one enqueue (success). Call after a message is enqueued. +pub fn record_enqueue_total() { + metrics::counter!("enqueue_total").increment(1); +} + +/// Record enqueued payload size in bytes. +pub fn record_enqueue_bytes(bytes: u64) { + metrics::counter!("enqueue_bytes_total").increment(bytes); +} + +/// Record one fetch (success). Call when fetch returns. +pub fn record_fetch_total() { + metrics::counter!("fetch_total").increment(1); +} + +/// Record one fetch_wait (success). Call when fetch_wait returns. +pub fn record_fetch_wait_total() { + metrics::counter!("fetch_wait_total").increment(1); +} + +/// Set the delivery queue depth gauge (sample). Updated at enqueue/fetch time. +pub fn record_delivery_queue_depth(depth: usize) { + metrics::gauge!("delivery_queue_depth").set(depth as f64); +} + +/// Record one KeyPackage upload (success). +pub fn record_key_package_upload_total() { + metrics::counter!("key_package_upload_total").increment(1); +} + +/// Record successful auth login (session token issued). +pub fn record_auth_login_success_total() { + metrics::counter!("auth_login_success_total").increment(1); +} + +/// Record failed auth login attempt. +pub fn record_auth_login_failure_total() { + metrics::counter!("auth_login_failure_total").increment(1); +} + +/// Record rate limit hit (enqueue rejected). +pub fn record_rate_limit_hit_total() { + metrics::counter!("rate_limit_hit_total").increment(1); +} diff --git a/crates/quicnprotochat-server/src/node_service/auth_ops.rs b/crates/quicnprotochat-server/src/node_service/auth_ops.rs new file mode 100644 index 0000000..78900e7 --- /dev/null +++ b/crates/quicnprotochat-server/src/node_service/auth_ops.rs @@ -0,0 +1,351 @@ +use capnp::capability::Promise; +use opaque_ke::{ + CredentialFinalization, CredentialRequest, RegistrationRequest, RegistrationUpload, + ServerLogin, ServerRegistration, +}; +use quicnprotochat_core::opaque_auth::OpaqueSuite; +use quicnprotochat_proto::node_capnp::node_service; + +use crate::auth::{coded_error, current_timestamp, PendingLogin, SESSION_TTL_SECS}; +use crate::error_codes::*; +use crate::metrics; +use crate::storage::StorageError; + +use super::NodeServiceImpl; + +// Audit events in this module must never include secrets (no session tokens, passwords, or raw keys). + +fn storage_err(err: StorageError) -> capnp::Error { + coded_error(E009_STORAGE_ERROR, err) +} + +impl NodeServiceImpl { + pub fn handle_opaque_login_start( + &mut self, + params: node_service::OpaqueLoginStartParams, + mut results: node_service::OpaqueLoginStartResults, + ) -> Promise<(), capnp::Error> { + let p = match params.get() { + Ok(p) => p, + Err(e) => return Promise::err(coded_error(E020_BAD_PARAMS, e)), + }; + let username = match p.get_username() { + Ok(v) => v.to_string().unwrap_or_default().to_string(), + Err(e) => return Promise::err(coded_error(E020_BAD_PARAMS, e)), + }; + let request_bytes = match p.get_request() { + Ok(v) => v.to_vec(), + Err(e) => return Promise::err(coded_error(E020_BAD_PARAMS, e)), + }; + + if username.is_empty() { + return Promise::err(coded_error(E011_USERNAME_EMPTY, "username must not be empty")); + } + + let credential_request = match CredentialRequest::::deserialize(&request_bytes) { + Ok(r) => r, + Err(e) => { + return Promise::err(coded_error( + E010_OPAQUE_ERROR, + format!("invalid credential request: {e}"), + )) + } + }; + + let password_file = match self.store.get_user_record(&username) { + Ok(Some(bytes)) => match ServerRegistration::::deserialize(&bytes) { + Ok(pf) => Some(pf), + Err(e) => { + return Promise::err(coded_error( + E010_OPAQUE_ERROR, + format!("corrupt user record: {e}"), + )) + } + }, + Ok(None) => { + return Promise::err(coded_error(E010_OPAQUE_ERROR, "user not registered")) + } + Err(e) => return Promise::err(storage_err(e)), + }; + + let mut rng = rand::rngs::OsRng; + let result = match ServerLogin::::start( + &mut rng, + &self.opaque_setup, + password_file, + credential_request, + username.as_bytes(), + Default::default(), + ) { + Ok(r) => r, + Err(e) => { + return Promise::err(coded_error( + E010_OPAQUE_ERROR, + format!("OPAQUE login start failed: {e}"), + )) + } + }; + + let state_bytes = result.state.serialize().to_vec(); + self.pending_logins.insert( + username.clone(), + PendingLogin { + state_bytes, + created_at: current_timestamp(), + }, + ); + + let response_bytes = result.message.serialize(); + results.get().set_response(&response_bytes); + + tracing::info!(user = %username, "OPAQUE login started"); + Promise::ok(()) + } + + pub fn handle_opaque_register_start( + &mut self, + params: node_service::OpaqueRegisterStartParams, + mut results: node_service::OpaqueRegisterStartResults, + ) -> Promise<(), capnp::Error> { + let p = match params.get() { + Ok(p) => p, + Err(e) => return Promise::err(coded_error(E020_BAD_PARAMS, e)), + }; + let username = match p.get_username() { + Ok(v) => v.to_string().unwrap_or_default().to_string(), + Err(e) => return Promise::err(coded_error(E020_BAD_PARAMS, e)), + }; + let request_bytes = match p.get_request() { + Ok(v) => v.to_vec(), + Err(e) => return Promise::err(coded_error(E020_BAD_PARAMS, e)), + }; + + if username.is_empty() { + return Promise::err(coded_error(E011_USERNAME_EMPTY, "username must not be empty")); + } + + if let Ok(true) = self.store.has_user_record(&username) { + return Promise::err(coded_error( + E018_USER_EXISTS, + format!("user '{}' already registered", username), + )); + } + + let registration_request = match RegistrationRequest::::deserialize(&request_bytes) { + Ok(r) => r, + Err(e) => { + return Promise::err(coded_error( + E010_OPAQUE_ERROR, + format!("invalid registration request: {e}"), + )) + } + }; + + let result = match ServerRegistration::::start( + &self.opaque_setup, + registration_request, + username.as_bytes(), + ) { + Ok(r) => r, + Err(e) => { + return Promise::err(coded_error( + E010_OPAQUE_ERROR, + format!("OPAQUE registration start failed: {e}"), + )) + } + }; + + let response_bytes = result.message.serialize(); + results.get().set_response(&response_bytes); + + tracing::info!(user = %username, "OPAQUE registration started"); + Promise::ok(()) + } + + pub fn handle_opaque_login_finish( + &mut self, + params: node_service::OpaqueLoginFinishParams, + mut results: node_service::OpaqueLoginFinishResults, + ) -> Promise<(), capnp::Error> { + let p = match params.get() { + Ok(p) => p, + Err(e) => return Promise::err(coded_error(E020_BAD_PARAMS, e)), + }; + let username = match p.get_username() { + Ok(v) => v.to_string().unwrap_or_default().to_string(), + Err(e) => return Promise::err(coded_error(E020_BAD_PARAMS, e)), + }; + let finalization_bytes = match p.get_finalization() { + Ok(v) => v.to_vec(), + Err(e) => return Promise::err(coded_error(E020_BAD_PARAMS, e)), + }; + let identity_key = p.get_identity_key().unwrap_or_default().to_vec(); + + if username.is_empty() { + return Promise::err(coded_error(E011_USERNAME_EMPTY, "username must not be empty")); + } + + let pending = match self.pending_logins.remove(&username) { + Some((_, pl)) => pl, + None => { + // Audit: login failure — do not log secrets (no token, no password). + tracing::warn!(user = %username, "audit: auth login failure (no pending login)"); + metrics::record_auth_login_failure_total(); + return Promise::err(coded_error(E019_NO_PENDING_LOGIN, "no pending login for this username")) + } + }; + + let server_login = match ServerLogin::::deserialize(&pending.state_bytes) { + Ok(s) => s, + Err(e) => { + return Promise::err(coded_error( + E010_OPAQUE_ERROR, + format!("corrupt login state: {e}"), + )) + } + }; + + let finalization = match CredentialFinalization::::deserialize(&finalization_bytes) { + Ok(f) => f, + Err(e) => { + return Promise::err(coded_error( + E010_OPAQUE_ERROR, + format!("invalid credential finalization: {e}"), + )) + } + }; + + let _result = match server_login.finish(finalization, Default::default()) { + Ok(r) => r, + Err(e) => { + tracing::warn!(user = %username, "audit: auth login failure (OPAQUE finish failed)"); + metrics::record_auth_login_failure_total(); + return Promise::err(coded_error( + E010_OPAQUE_ERROR, + format!("OPAQUE login finish failed (bad password?): {e}"), + )) + } + }; + + if identity_key.is_empty() { + metrics::record_auth_login_failure_total(); + return Promise::err(coded_error( + E016_IDENTITY_MISMATCH, + "identity key required to bind session token", + )); + } + + if let Ok(Some(stored_ik)) = self.store.get_user_identity_key(&username) { + if stored_ik != identity_key { + tracing::warn!(user = %username, "audit: auth login failure (identity mismatch)"); + metrics::record_auth_login_failure_total(); + return Promise::err(coded_error( + E016_IDENTITY_MISMATCH, + "identity key does not match registered key", + )); + } + } + + let mut token = [0u8; 32]; + rand::RngCore::fill_bytes(&mut rand::rngs::OsRng, &mut token); + let token_vec = token.to_vec(); + + let now = current_timestamp(); + self.sessions.insert( + token_vec.clone(), + crate::auth::SessionInfo { + username: username.clone(), + identity_key, + created_at: now, + expires_at: now + SESSION_TTL_SECS, + }, + ); + + results.get().set_session_token(&token_vec); + + // Audit: login success — do not log session token or any secrets. + metrics::record_auth_login_success_total(); + tracing::info!(user = %username, "audit: auth login success — session token issued"); + Promise::ok(()) + } + + pub fn handle_opaque_register_finish( + &mut self, + params: node_service::OpaqueRegisterFinishParams, + mut results: node_service::OpaqueRegisterFinishResults, + ) -> Promise<(), capnp::Error> { + let p = match params.get() { + Ok(p) => p, + Err(e) => return Promise::err(coded_error(E020_BAD_PARAMS, e)), + }; + let username = match p.get_username() { + Ok(v) => v.to_string().unwrap_or_default().to_string(), + Err(e) => return Promise::err(coded_error(E020_BAD_PARAMS, e)), + }; + let upload_bytes = match p.get_upload() { + Ok(v) => v.to_vec(), + Err(e) => return Promise::err(coded_error(E020_BAD_PARAMS, e)), + }; + let identity_key = p.get_identity_key().unwrap_or_default().to_vec(); + + if username.is_empty() { + return Promise::err(coded_error(E011_USERNAME_EMPTY, "username must not be empty")); + } + + let _request = match RegistrationRequest::::deserialize(&upload_bytes) { + Ok(r) => r, + Err(e) => { + return Promise::err(coded_error( + E010_OPAQUE_ERROR, + format!("invalid registration upload: {e}"), + )) + } + }; + + match self.store.has_user_record(&username) { + Ok(true) => { + return Promise::err(coded_error( + E018_USER_EXISTS, + format!("user '{}' already registered", username), + )) + } + Err(e) => return Promise::err(storage_err(e)), + _ => {} + } + + let upload = match RegistrationUpload::::deserialize(&upload_bytes) { + Ok(u) => u, + Err(e) => { + return Promise::err(coded_error( + E010_OPAQUE_ERROR, + format!("invalid registration upload: {e}"), + )) + } + }; + + let password_file = ServerRegistration::::finish(upload); + let record_bytes = password_file.serialize().to_vec(); + + if let Err(e) = self + .store + .store_user_record(&username, record_bytes) + .map_err(storage_err) + { + return Promise::err(e); + } + + if !identity_key.is_empty() { + if let Err(e) = self + .store + .store_user_identity_key(&username, identity_key) + .map_err(storage_err) + { + return Promise::err(e); + } + } + + results.get().set_success(true); + tracing::info!(user = %username, "OPAQUE registration complete"); + Promise::ok(()) + } +} diff --git a/crates/quicnprotochat-server/src/node_service/delivery.rs b/crates/quicnprotochat-server/src/node_service/delivery.rs new file mode 100644 index 0000000..393fa33 --- /dev/null +++ b/crates/quicnprotochat-server/src/node_service/delivery.rs @@ -0,0 +1,318 @@ +use std::sync::Arc; +use std::time::Duration; + +use capnp::capability::Promise; +use dashmap::DashMap; +use quicnprotochat_proto::node_capnp::node_service; +use tokio::sync::Notify; +use tokio::time::timeout; + +use crate::auth::{ + check_rate_limit, coded_error, fmt_hex, require_identity_or_request, validate_auth_context, +}; +use crate::error_codes::*; +use crate::metrics; +use crate::storage::{StorageError, Store}; + +use super::{NodeServiceImpl, CURRENT_WIRE_VERSION}; + +// Audit events here must not include secrets: no payload content, no full recipient/token bytes (prefix only). + +const MAX_PAYLOAD_BYTES: usize = 5 * 1024 * 1024; // 5 MB cap per message +const MAX_QUEUE_DEPTH: usize = 1000; + +fn storage_err(err: StorageError) -> capnp::Error { + coded_error(E009_STORAGE_ERROR, err) +} + +pub fn fill_payloads_wait( + results: &mut node_service::FetchWaitResults, + messages: Vec<(u64, Vec)>, +) { + let mut list = results.get().init_payloads(messages.len() as u32); + for (i, (seq, data)) in messages.iter().enumerate() { + let mut entry = list.reborrow().get(i as u32); + entry.set_seq(*seq); + entry.set_data(data); + } +} + +impl NodeServiceImpl { + pub fn handle_enqueue( + &mut self, + params: node_service::EnqueueParams, + mut results: node_service::EnqueueResults, + ) -> Promise<(), capnp::Error> { + let p = match params.get() { + Ok(p) => p, + Err(e) => return Promise::err(coded_error(E020_BAD_PARAMS, e)), + }; + let recipient_key = match p.get_recipient_key() { + Ok(v) => v.to_vec(), + Err(e) => return Promise::err(coded_error(E020_BAD_PARAMS, e)), + }; + let payload = match p.get_payload() { + Ok(v) => v.to_vec(), + Err(e) => return Promise::err(coded_error(E020_BAD_PARAMS, e)), + }; + let channel_id = p.get_channel_id().unwrap_or_default().to_vec(); + let version = p.get_version(); + let auth_ctx = match validate_auth_context(&self.auth_cfg, &self.sessions, p.get_auth()) { + Ok(ctx) => ctx, + Err(e) => return Promise::err(e), + }; + + if recipient_key.len() != 32 { + return Promise::err(coded_error( + E004_IDENTITY_KEY_LENGTH, + format!("recipientKey must be exactly 32 bytes, got {}", recipient_key.len()), + )); + } + if payload.is_empty() { + return Promise::err(coded_error(E005_PAYLOAD_EMPTY, "payload must not be empty")); + } + if payload.len() > MAX_PAYLOAD_BYTES { + return Promise::err(coded_error( + E006_PAYLOAD_TOO_LARGE, + format!("payload exceeds max size ({} bytes)", MAX_PAYLOAD_BYTES), + )); + } + if version != CURRENT_WIRE_VERSION { + return Promise::err(coded_error( + E012_WIRE_VERSION, + format!("unsupported wire version {} (expected {CURRENT_WIRE_VERSION})", version), + )); + } + + if let Err(e) = check_rate_limit(&self.rate_limits, &auth_ctx.token) { + // Audit: rate limit hit — do not log token or identity. + tracing::warn!("rate_limit_hit"); + metrics::record_rate_limit_hit_total(); + return Promise::err(e); + } + + // When sealed_sender is true, enqueue does not require identity; valid token only. + if !self.sealed_sender { + if let Err(e) = require_identity_or_request( + &auth_ctx, + &recipient_key, + self.auth_cfg.allow_insecure_identity_from_request, + ) { + return Promise::err(e); + } + } + + match self.store.queue_depth(&recipient_key, &channel_id) { + Ok(depth) if depth >= MAX_QUEUE_DEPTH => { + return Promise::err(coded_error( + E015_QUEUE_FULL, + format!("queue depth {} exceeds limit {}", depth, MAX_QUEUE_DEPTH), + )); + } + Err(e) => return Promise::err(storage_err(e)), + _ => {} + } + + let payload_len = payload.len(); + let seq = match self + .store + .enqueue(&recipient_key, &channel_id, payload) + .map_err(storage_err) + { + Ok(seq) => seq, + Err(e) => return Promise::err(e), + }; + + results.get().set_seq(seq); + + // Metrics and audit. Audit events must not include secrets (no payload, no full keys). + metrics::record_enqueue_total(); + metrics::record_enqueue_bytes(payload_len as u64); + if let Ok(depth) = self.store.queue_depth(&recipient_key, &channel_id) { + metrics::record_delivery_queue_depth(depth); + } + tracing::info!( + recipient_prefix = %fmt_hex(&recipient_key[..4]), + payload_len = payload_len, + seq = seq, + "audit: enqueue" + ); + + crate::auth::waiter(&self.waiters, &recipient_key).notify_waiters(); + + Promise::ok(()) + } + + pub fn handle_fetch( + &mut self, + params: node_service::FetchParams, + mut results: node_service::FetchResults, + ) -> Promise<(), capnp::Error> { + let recipient_key = match params.get() { + Ok(p) => match p.get_recipient_key() { + Ok(v) => v.to_vec(), + Err(e) => return Promise::err(coded_error(E020_BAD_PARAMS, e)), + }, + Err(e) => return Promise::err(coded_error(E020_BAD_PARAMS, e)), + }; + let channel_id = params + .get() + .ok() + .and_then(|p| p.get_channel_id().ok()) + .map(|c| c.to_vec()) + .unwrap_or_default(); + let version = params + .get() + .ok() + .map(|p| p.get_version()) + .unwrap_or(CURRENT_WIRE_VERSION); + let limit = params.get().ok().map(|p| p.get_limit()).unwrap_or(0); + let auth_ctx = match params + .get() + .ok() + .map(|p| validate_auth_context(&self.auth_cfg, &self.sessions, p.get_auth())) + .transpose() + { + Ok(ctx) => ctx, + Err(e) => return Promise::err(e), + }; + + if recipient_key.len() != 32 { + return Promise::err(coded_error( + E004_IDENTITY_KEY_LENGTH, + format!("recipientKey must be exactly 32 bytes, got {}", recipient_key.len()), + )); + } + if version != CURRENT_WIRE_VERSION { + return Promise::err(coded_error( + E012_WIRE_VERSION, + format!("unsupported wire version {} (expected {CURRENT_WIRE_VERSION})", version), + )); + } + + let auth_ctx = match auth_ctx { + Some(ctx) => ctx, + None => return Promise::err(coded_error(E003_INVALID_TOKEN, "auth required")), + }; + + if let Err(e) = require_identity_or_request( + &auth_ctx, + &recipient_key, + self.auth_cfg.allow_insecure_identity_from_request, + ) { + return Promise::err(e); + } + + let messages = if limit > 0 { + match self + .store + .fetch_limited(&recipient_key, &channel_id, limit as usize) + .map_err(storage_err) + { + Ok(m) => m, + Err(e) => return Promise::err(e), + } + } else { + match self + .store + .fetch(&recipient_key, &channel_id) + .map_err(storage_err) + { + Ok(m) => m, + Err(e) => return Promise::err(e), + } + }; + + // Audit: fetch — do not log payload or full keys. + metrics::record_fetch_total(); + tracing::info!( + recipient_prefix = %fmt_hex(&recipient_key[..4]), + count = messages.len(), + "audit: fetch" + ); + + let mut list = results.get().init_payloads(messages.len() as u32); + for (i, (seq, data)) in messages.iter().enumerate() { + let mut entry = list.reborrow().get(i as u32); + entry.set_seq(*seq); + entry.set_data(data); + } + + Promise::ok(()) + } + + pub fn handle_fetch_wait( + &mut self, + params: node_service::FetchWaitParams, + mut results: node_service::FetchWaitResults, + ) -> Promise<(), capnp::Error> { + let p = match params.get() { + Ok(p) => p, + Err(e) => return Promise::err(coded_error(E020_BAD_PARAMS, e)), + }; + let recipient_key = match p.get_recipient_key() { + Ok(v) => v.to_vec(), + Err(e) => return Promise::err(coded_error(E020_BAD_PARAMS, e)), + }; + let channel_id = p.get_channel_id().unwrap_or_default().to_vec(); + let version = p.get_version(); + let timeout_ms = p.get_timeout_ms(); + let limit = p.get_limit(); + let auth_ctx = match validate_auth_context(&self.auth_cfg, &self.sessions, p.get_auth()) { + Ok(ctx) => ctx, + Err(e) => return Promise::err(e), + }; + + if recipient_key.len() != 32 { + return Promise::err(coded_error( + E004_IDENTITY_KEY_LENGTH, + format!("recipientKey must be exactly 32 bytes, got {}", recipient_key.len()), + )); + } + if version != CURRENT_WIRE_VERSION { + return Promise::err(coded_error( + E012_WIRE_VERSION, + format!("unsupported wire version {} (expected {CURRENT_WIRE_VERSION})", version), + )); + } + + if let Err(e) = require_identity_or_request( + &auth_ctx, + &recipient_key, + self.auth_cfg.allow_insecure_identity_from_request, + ) { + return Promise::err(e); + } + + let store = Arc::clone(&self.store); + let waiters: Arc, Arc>> = self.waiters.clone(); + + Promise::from_future(async move { + let fetch_fn = |s: &Arc, rk: &[u8], ch: &[u8], lim: u32| -> Result)>, capnp::Error> { + if lim > 0 { + s.fetch_limited(rk, ch, lim as usize).map_err(storage_err) + } else { + s.fetch(rk, ch).map_err(storage_err) + } + }; + + let messages = fetch_fn(&store, &recipient_key, &channel_id, limit)?; + + if messages.is_empty() && timeout_ms > 0 { + let waiter = waiters + .entry(recipient_key.clone()) + .or_insert_with(|| Arc::new(Notify::new())) + .clone(); + let _ = timeout(Duration::from_millis(timeout_ms), waiter.notified()).await; + let msgs = fetch_fn(&store, &recipient_key, &channel_id, limit)?; + fill_payloads_wait(&mut results, msgs); + metrics::record_fetch_wait_total(); + return Ok(()); + } + + fill_payloads_wait(&mut results, messages); + metrics::record_fetch_wait_total(); + Ok(()) + }) + } +} diff --git a/crates/quicnprotochat-server/src/node_service/key_ops.rs b/crates/quicnprotochat-server/src/node_service/key_ops.rs new file mode 100644 index 0000000..e8a8834 --- /dev/null +++ b/crates/quicnprotochat-server/src/node_service/key_ops.rs @@ -0,0 +1,259 @@ +use capnp::capability::Promise; +use quicnprotochat_proto::node_capnp::node_service; + +use crate::auth::{coded_error, fmt_hex, require_identity_or_request, validate_auth_context}; +use crate::error_codes::*; +use crate::metrics; +use crate::storage::StorageError; + +use super::NodeServiceImpl; + +fn storage_err(err: StorageError) -> capnp::Error { + coded_error(E009_STORAGE_ERROR, err) +} + +const MAX_KEYPACKAGE_BYTES: usize = 1 * 1024 * 1024; // 1 MB cap per KeyPackage + +impl NodeServiceImpl { + pub fn handle_upload_key_package( + &mut self, + params: node_service::UploadKeyPackageParams, + mut results: node_service::UploadKeyPackageResults, + ) -> Promise<(), capnp::Error> { + let (auth_ctx, identity_key, package) = match params.get() { + Ok(p) => { + let auth_ctx = match validate_auth_context(&self.auth_cfg, &self.sessions, p.get_auth()) { + Ok(ctx) => ctx, + Err(e) => return Promise::err(e), + }; + let ik = match p.get_identity_key() { + Ok(v) => v.to_vec(), + Err(e) => return Promise::err(coded_error(E020_BAD_PARAMS, e)), + }; + let pkg = match p.get_package() { + Ok(v) => v.to_vec(), + Err(e) => return Promise::err(coded_error(E020_BAD_PARAMS, e)), + }; + (auth_ctx, ik, pkg) + } + Err(e) => return Promise::err(e), + }; + + if identity_key.len() != 32 { + return Promise::err(coded_error( + E004_IDENTITY_KEY_LENGTH, + format!("identityKey must be exactly 32 bytes, got {}", identity_key.len()), + )); + } + if package.is_empty() { + return Promise::err(coded_error(E007_PACKAGE_EMPTY, "package must not be empty")); + } + if package.len() > MAX_KEYPACKAGE_BYTES { + return Promise::err(coded_error( + E008_PACKAGE_TOO_LARGE, + format!("package exceeds max size ({} bytes)", MAX_KEYPACKAGE_BYTES), + )); + } + + if let Err(e) = require_identity_or_request( + &auth_ctx, + &identity_key, + self.auth_cfg.allow_insecure_identity_from_request, + ) { + return Promise::err(e); + } + + if let Err(e) = quicnprotochat_core::validate_keypackage_ciphersuite(&package) { + return Promise::err(coded_error( + E021_CIPHERSUITE_NOT_ALLOWED, + format!("KeyPackage ciphersuite not allowed: {e}"), + )); + } + + let fingerprint: Vec = crate::auth::fingerprint(&package); + if let Err(e) = self + .store + .upload_key_package(&identity_key, package) + .map_err(storage_err) + { + return Promise::err(e); + } + + results.get().set_fingerprint(&fingerprint); + + metrics::record_key_package_upload_total(); + // Audit: KeyPackage upload — only fingerprint prefix, no secrets. + tracing::info!( + identity_prefix = %fmt_hex(&identity_key[..4]), + fingerprint_prefix = %fmt_hex(&fingerprint[..4]), + "audit: key_package_upload" + ); + + Promise::ok(()) + } + + pub fn handle_fetch_key_package( + &mut self, + params: node_service::FetchKeyPackageParams, + mut results: node_service::FetchKeyPackageResults, + ) -> Promise<(), capnp::Error> { + let identity_key = match params.get() { + Ok(p) => match p.get_identity_key() { + Ok(v) => v.to_vec(), + Err(e) => return Promise::err(coded_error(E020_BAD_PARAMS, e)), + }, + Err(e) => return Promise::err(coded_error(E020_BAD_PARAMS, e)), + }; + if let Err(e) = params + .get() + .ok() + .map(|p| crate::auth::validate_auth(&self.auth_cfg, &self.sessions, p.get_auth())) + .transpose() + { + return Promise::err(e); + } + + if identity_key.len() != 32 { + return Promise::err(coded_error( + E004_IDENTITY_KEY_LENGTH, + format!("identityKey must be exactly 32 bytes, got {}", identity_key.len()), + )); + } + + let package = match self + .store + .fetch_key_package(&identity_key) + .map_err(storage_err) + { + Ok(p) => p, + Err(e) => return Promise::err(e), + }; + + match package { + Some(pkg) => { + tracing::debug!(identity = %fmt_hex(&identity_key[..4]), "KeyPackage fetched"); + results.get().set_package(&pkg); + } + None => { + tracing::debug!( + identity = %fmt_hex(&identity_key[..4]), + "no KeyPackage available for identity" + ); + results.get().set_package(&[]); + } + } + + Promise::ok(()) + } + + pub fn handle_upload_hybrid_key( + &mut self, + params: node_service::UploadHybridKeyParams, + _results: node_service::UploadHybridKeyResults, + ) -> Promise<(), capnp::Error> { + let p = match params.get() { + Ok(p) => p, + Err(e) => return Promise::err(coded_error(E020_BAD_PARAMS, e)), + }; + let identity_key = match p.get_identity_key() { + Ok(v) => v.to_vec(), + Err(e) => return Promise::err(coded_error(E020_BAD_PARAMS, e)), + }; + let hybrid_pk = match p.get_hybrid_public_key() { + Ok(v) => v.to_vec(), + 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), + }; + + if identity_key.len() != 32 { + return Promise::err(coded_error( + E004_IDENTITY_KEY_LENGTH, + format!("identityKey must be exactly 32 bytes, got {}", identity_key.len()), + )); + } + if hybrid_pk.is_empty() { + return Promise::err(coded_error(E013_HYBRID_KEY_EMPTY, "hybridPublicKey must not be empty")); + } + + if let Err(e) = require_identity_or_request( + &auth_ctx, + &identity_key, + self.auth_cfg.allow_insecure_identity_from_request, + ) { + return Promise::err(e); + } + + if let Err(e) = self + .store + .upload_hybrid_key(&identity_key, hybrid_pk) + .map_err(storage_err) + { + return Promise::err(e); + } + + tracing::debug!(identity = %fmt_hex(&identity_key[..4]), "hybrid public key uploaded"); + + Promise::ok(()) + } + + pub fn handle_fetch_hybrid_key( + &mut self, + params: node_service::FetchHybridKeyParams, + mut results: node_service::FetchHybridKeyResults, + ) -> Promise<(), capnp::Error> { + let p = match params.get() { + Ok(p) => p, + Err(e) => return Promise::err(coded_error(E020_BAD_PARAMS, e)), + }; + let identity_key = match p.get_identity_key() { + Ok(v) => v.to_vec(), + 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), + }; + + if identity_key.len() != 32 { + return Promise::err(coded_error( + E004_IDENTITY_KEY_LENGTH, + format!("identityKey must be exactly 32 bytes, got {}", identity_key.len()), + )); + } + + if let Err(e) = require_identity_or_request( + &auth_ctx, + &identity_key, + self.auth_cfg.allow_insecure_identity_from_request, + ) { + return Promise::err(e); + } + + let hybrid_pk = match self + .store + .fetch_hybrid_key(&identity_key) + .map_err(storage_err) + { + Ok(p) => p, + Err(e) => return Promise::err(e), + }; + + match hybrid_pk { + Some(pk) => { + tracing::debug!(identity = %fmt_hex(&identity_key[..4]), "hybrid key fetched"); + results.get().set_hybrid_public_key(&pk); + } + None => { + tracing::debug!(identity = %fmt_hex(&identity_key[..4]), "no hybrid key for identity"); + results.get().set_hybrid_public_key(&[]); + } + } + + Promise::ok(()) + } +} diff --git a/crates/quicnprotochat-server/src/node_service/mod.rs b/crates/quicnprotochat-server/src/node_service/mod.rs new file mode 100644 index 0000000..6db86c8 --- /dev/null +++ b/crates/quicnprotochat-server/src/node_service/mod.rs @@ -0,0 +1,246 @@ +use std::sync::Arc; +use std::time::Duration; + +use capnp_rpc::RpcSystem; +use dashmap::DashMap; +use opaque_ke::ServerSetup; +use quicnprotochat_core::opaque_auth::OpaqueSuite; +use quicnprotochat_proto::node_capnp::node_service; +use tokio::sync::Notify; +use tokio_util::compat::{TokioAsyncReadCompatExt, TokioAsyncWriteCompatExt}; + +use crate::auth::{ + current_timestamp, AuthConfig, PendingLogin, RateEntry, SessionInfo, + PENDING_LOGIN_TTL_SECS, RATE_LIMIT_WINDOW_SECS, +}; +use crate::storage::Store; + +mod auth_ops; +mod delivery; +mod key_ops; +mod p2p_ops; + +impl node_service::Server for NodeServiceImpl { + fn upload_key_package( + &mut self, + params: node_service::UploadKeyPackageParams, + results: node_service::UploadKeyPackageResults, + ) -> capnp::capability::Promise<(), capnp::Error> { + self.handle_upload_key_package(params, results) + } + + fn fetch_key_package( + &mut self, + params: node_service::FetchKeyPackageParams, + results: node_service::FetchKeyPackageResults, + ) -> capnp::capability::Promise<(), capnp::Error> { + self.handle_fetch_key_package(params, results) + } + + fn enqueue( + &mut self, + params: node_service::EnqueueParams, + results: node_service::EnqueueResults, + ) -> capnp::capability::Promise<(), capnp::Error> { + self.handle_enqueue(params, results) + } + + fn fetch( + &mut self, + params: node_service::FetchParams, + results: node_service::FetchResults, + ) -> capnp::capability::Promise<(), capnp::Error> { + self.handle_fetch(params, results) + } + + fn fetch_wait( + &mut self, + params: node_service::FetchWaitParams, + results: node_service::FetchWaitResults, + ) -> capnp::capability::Promise<(), capnp::Error> { + self.handle_fetch_wait(params, results) + } + + fn health( + &mut self, + params: node_service::HealthParams, + results: node_service::HealthResults, + ) -> capnp::capability::Promise<(), capnp::Error> { + self.handle_health(params, results) + } + + fn upload_hybrid_key( + &mut self, + params: node_service::UploadHybridKeyParams, + results: node_service::UploadHybridKeyResults, + ) -> capnp::capability::Promise<(), capnp::Error> { + self.handle_upload_hybrid_key(params, results) + } + + fn fetch_hybrid_key( + &mut self, + params: node_service::FetchHybridKeyParams, + results: node_service::FetchHybridKeyResults, + ) -> capnp::capability::Promise<(), capnp::Error> { + self.handle_fetch_hybrid_key(params, results) + } + + fn opaque_login_start( + &mut self, + params: node_service::OpaqueLoginStartParams, + results: node_service::OpaqueLoginStartResults, + ) -> capnp::capability::Promise<(), capnp::Error> { + self.handle_opaque_login_start(params, results) + } + + fn opaque_register_start( + &mut self, + params: node_service::OpaqueRegisterStartParams, + results: node_service::OpaqueRegisterStartResults, + ) -> capnp::capability::Promise<(), capnp::Error> { + self.handle_opaque_register_start(params, results) + } + + fn opaque_login_finish( + &mut self, + params: node_service::OpaqueLoginFinishParams, + results: node_service::OpaqueLoginFinishResults, + ) -> capnp::capability::Promise<(), capnp::Error> { + self.handle_opaque_login_finish(params, results) + } + + fn opaque_register_finish( + &mut self, + params: node_service::OpaqueRegisterFinishParams, + results: node_service::OpaqueRegisterFinishResults, + ) -> capnp::capability::Promise<(), capnp::Error> { + self.handle_opaque_register_finish(params, results) + } + + fn publish_endpoint( + &mut self, + params: node_service::PublishEndpointParams, + results: node_service::PublishEndpointResults, + ) -> capnp::capability::Promise<(), capnp::Error> { + self.handle_publish_endpoint(params, results) + } + + fn resolve_endpoint( + &mut self, + params: node_service::ResolveEndpointParams, + results: node_service::ResolveEndpointResults, + ) -> capnp::capability::Promise<(), capnp::Error> { + self.handle_resolve_endpoint(params, results) + } +} + +pub const CURRENT_WIRE_VERSION: u16 = 1; + +pub struct NodeServiceImpl { + pub store: Arc, + pub waiters: Arc, Arc>>, + pub auth_cfg: Arc, + pub opaque_setup: Arc>, + pub pending_logins: Arc>, + pub sessions: Arc, SessionInfo>>, + pub rate_limits: Arc, RateEntry>>, + /// When true, enqueue does not require identity-bound session (Sealed Sender). + pub sealed_sender: bool, +} + +impl NodeServiceImpl { + pub fn new( + store: Arc, + waiters: Arc, Arc>>, + auth_cfg: Arc, + opaque_setup: Arc>, + pending_logins: Arc>, + sessions: Arc, SessionInfo>>, + rate_limits: Arc, RateEntry>>, + sealed_sender: bool, + ) -> Self { + Self { + store, + waiters, + auth_cfg, + opaque_setup, + pending_logins, + sessions, + rate_limits, + sealed_sender, + } + } +} + +pub async fn handle_node_connection( + connecting: quinn::Connecting, + store: Arc, + waiters: Arc, Arc>>, + auth_cfg: Arc, + opaque_setup: Arc>, + pending_logins: Arc>, + sessions: Arc, SessionInfo>>, + rate_limits: Arc, RateEntry>>, + sealed_sender: bool, +) -> Result<(), anyhow::Error> { + let connection = connecting.await?; + + tracing::info!(peer = %connection.remote_address(), "QUIC connected"); + + let (send, recv) = connection + .accept_bi() + .await + .map_err(|e| anyhow::anyhow!("failed to accept bi stream: {e}"))?; + let (reader, writer) = (recv.compat(), send.compat_write()); + + let network = capnp_rpc::twoparty::VatNetwork::new( + reader, + writer, + capnp_rpc::rpc_twoparty_capnp::Side::Server, + Default::default(), + ); + + let service: node_service::Client = capnp_rpc::new_client(NodeServiceImpl::new( + store, + waiters, + auth_cfg, + opaque_setup, + pending_logins, + sessions, + rate_limits, + sealed_sender, + )); + + RpcSystem::new(Box::new(network), Some(service.client)) + .await + .map_err(|e| anyhow::anyhow!("NodeService RPC error: {e}")) +} + +const MESSAGE_TTL_SECS: u64 = 7 * 24 * 60 * 60; // 7 days + +pub fn spawn_cleanup_task( + sessions: Arc, SessionInfo>>, + pending_logins: Arc>, + rate_limits: Arc, RateEntry>>, + store: Arc, +) { + tokio::spawn(async move { + let mut interval = tokio::time::interval(Duration::from_secs(60)); + loop { + interval.tick().await; + let now = current_timestamp(); + + sessions.retain(|_, info| info.expires_at > now); + pending_logins.retain(|_, pl| now - pl.created_at < PENDING_LOGIN_TTL_SECS); + rate_limits.retain(|_, entry| now - entry.window_start < RATE_LIMIT_WINDOW_SECS * 2); + + match store.gc_expired_messages(MESSAGE_TTL_SECS) { + Ok(n) if n > 0 => { + tracing::debug!(expired = n, "garbage collected expired messages") + } + Err(e) => tracing::warn!(error = %e, "message GC failed"), + _ => {} + } + } + }); +} diff --git a/crates/quicnprotochat-server/src/node_service/p2p_ops.rs b/crates/quicnprotochat-server/src/node_service/p2p_ops.rs new file mode 100644 index 0000000..c32c4fb --- /dev/null +++ b/crates/quicnprotochat-server/src/node_service/p2p_ops.rs @@ -0,0 +1,118 @@ +use capnp::capability::Promise; +use quicnprotochat_proto::node_capnp::node_service; + +use crate::auth::{ + coded_error, fmt_hex, require_identity_or_request, validate_auth, validate_auth_context, +}; +use crate::error_codes::*; +use crate::storage::StorageError; + +use super::NodeServiceImpl; + +fn storage_err(err: StorageError) -> capnp::Error { + coded_error(E009_STORAGE_ERROR, err) +} + +impl NodeServiceImpl { + pub fn handle_health( + &mut self, + _params: node_service::HealthParams, + mut results: node_service::HealthResults, + ) -> Promise<(), capnp::Error> { + results.get().set_status("ok"); + Promise::ok(()) + } + + pub fn handle_publish_endpoint( + &mut self, + params: node_service::PublishEndpointParams, + _results: node_service::PublishEndpointResults, + ) -> Promise<(), capnp::Error> { + let p = match params.get() { + Ok(p) => p, + Err(e) => return Promise::err(coded_error(E020_BAD_PARAMS, e)), + }; + let identity_key = match p.get_identity_key() { + Ok(v) => v.to_vec(), + Err(e) => return Promise::err(coded_error(E020_BAD_PARAMS, e)), + }; + let node_addr = match p.get_node_addr() { + Ok(v) => v.to_vec(), + 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), + }; + + if identity_key.len() != 32 { + return Promise::err(coded_error( + E004_IDENTITY_KEY_LENGTH, + format!("identityKey must be exactly 32 bytes, got {}", identity_key.len()), + )); + } + + if let Err(e) = require_identity_or_request( + &auth_ctx, + &identity_key, + self.auth_cfg.allow_insecure_identity_from_request, + ) { + return Promise::err(e); + } + + if let Err(e) = self + .store + .publish_endpoint(&identity_key, node_addr) + .map_err(storage_err) + { + return Promise::err(e); + } + + tracing::debug!(identity = %fmt_hex(&identity_key[..4]), "endpoint published"); + + Promise::ok(()) + } + + pub fn handle_resolve_endpoint( + &mut self, + params: node_service::ResolveEndpointParams, + mut results: node_service::ResolveEndpointResults, + ) -> Promise<(), capnp::Error> { + let p = match params.get() { + Ok(p) => p, + Err(e) => return Promise::err(coded_error(E020_BAD_PARAMS, e)), + }; + let identity_key = match p.get_identity_key() { + Ok(v) => v.to_vec(), + Err(e) => return Promise::err(coded_error(E020_BAD_PARAMS, e)), + }; + if let Err(e) = validate_auth(&self.auth_cfg, &self.sessions, p.get_auth()) { + return Promise::err(e); + } + + if identity_key.len() != 32 { + return Promise::err(coded_error( + E004_IDENTITY_KEY_LENGTH, + format!("identityKey must be exactly 32 bytes, got {}", identity_key.len()), + )); + } + + let endpoint = match self + .store + .resolve_endpoint(&identity_key) + .map_err(storage_err) + { + Ok(e) => e, + Err(e) => return Promise::err(e), + }; + + if let Some(ep) = endpoint { + tracing::debug!(identity = %fmt_hex(&identity_key[..4]), "endpoint resolved"); + results.get().set_node_addr(&ep); + } else { + results.get().set_node_addr(&[]); + } + + Promise::ok(()) + } +} diff --git a/crates/quicnprotochat-server/src/sql_store.rs b/crates/quicnprotochat-server/src/sql_store.rs index 524e0a0..89c8647 100644 --- a/crates/quicnprotochat-server/src/sql_store.rs +++ b/crates/quicnprotochat-server/src/sql_store.rs @@ -7,6 +7,33 @@ use rusqlite::{params, Connection}; use crate::storage::{StorageError, Store}; +/// Schema version after introducing the migration runner (existing DBs had 1). +const SCHEMA_VERSION: i32 = 3; + +/// Migrations: (migration_number, SQL). Files named NNN_name.sql, applied in order when N > user_version. +const MIGRATIONS: &[(i32, &str)] = &[ + (1, include_str!("../migrations/001_initial.sql")), + (3, include_str!("../migrations/002_add_seq.sql")), +]; + +/// Runs pending migrations on an open connection: applies any migration whose number is greater +/// than the current PRAGMA user_version, then sets user_version to SCHEMA_VERSION. +fn run_migrations(conn: &Connection) -> Result<(), StorageError> { + let current_version: i32 = conn + .pragma_query_value(None, "user_version", |row| row.get(0)) + .map_err(|e| StorageError::Db(format!("PRAGMA user_version failed: {e}")))?; + + for (migration_num, sql) in MIGRATIONS { + if *migration_num > current_version { + conn.execute_batch(sql).map_err(|e| StorageError::Db(e.to_string()))?; + } + } + + conn.pragma_update(None, "user_version", SCHEMA_VERSION) + .map_err(|e| StorageError::Db(format!("set user_version failed: {e}")))?; + Ok(()) +} + /// SQLCipher-encrypted storage backend. pub struct SqlStore { conn: Mutex, @@ -34,66 +61,21 @@ impl SqlStore { ) .map_err(|e| StorageError::Db(e.to_string()))?; - let store = Self { + let current_version: i32 = conn + .pragma_query_value(None, "user_version", |row| row.get(0)) + .map_err(|e| StorageError::Db(format!("PRAGMA user_version failed: {e}")))?; + + if current_version > SCHEMA_VERSION { + return Err(StorageError::Db(format!( + "database schema version {current_version} is newer than supported {SCHEMA_VERSION}" + ))); + } + + run_migrations(&conn)?; + + Ok(Self { conn: Mutex::new(conn), - }; - store.migrate()?; - Ok(store) - } - - fn migrate(&self) -> Result<(), StorageError> { - let conn = self.lock_conn()?; - conn.execute_batch( - "CREATE TABLE IF NOT EXISTS key_packages ( - id INTEGER PRIMARY KEY AUTOINCREMENT, - identity_key BLOB NOT NULL, - package_data BLOB NOT NULL, - created_at INTEGER DEFAULT (strftime('%s','now')) - ); - - CREATE TABLE IF NOT EXISTS deliveries ( - id INTEGER PRIMARY KEY AUTOINCREMENT, - recipient_key BLOB NOT NULL, - channel_id BLOB NOT NULL DEFAULT X'', - payload BLOB NOT NULL, - created_at INTEGER DEFAULT (strftime('%s','now')) - ); - - CREATE TABLE IF NOT EXISTS hybrid_keys ( - identity_key BLOB PRIMARY KEY, - hybrid_public_key BLOB NOT NULL - ); - - CREATE INDEX IF NOT EXISTS idx_kp_identity - ON key_packages(identity_key); - - CREATE INDEX IF NOT EXISTS idx_del_recipient_channel - ON deliveries(recipient_key, channel_id); - - CREATE TABLE IF NOT EXISTS server_setup ( - id INTEGER PRIMARY KEY CHECK (id = 1), - setup_data BLOB NOT NULL - ); - - CREATE TABLE IF NOT EXISTS users ( - username TEXT PRIMARY KEY, - opaque_record BLOB NOT NULL, - created_at INTEGER DEFAULT (strftime('%s','now')) - ); - - CREATE TABLE IF NOT EXISTS user_identity_keys ( - username TEXT PRIMARY KEY, - identity_key BLOB NOT NULL - ); - - CREATE TABLE IF NOT EXISTS endpoints ( - identity_key BLOB PRIMARY KEY, - node_addr BLOB NOT NULL, - updated_at INTEGER DEFAULT (strftime('%s','now')) - );", - ) - .map_err(|e| StorageError::Db(e.to_string()))?; - Ok(()) + }) } } @@ -146,37 +128,53 @@ impl Store for SqlStore { recipient_key: &[u8], channel_id: &[u8], payload: Vec, - ) -> Result<(), StorageError> { + ) -> Result { let conn = self.lock_conn()?; + // Atomically get-and-increment the per-inbox sequence counter. + // RETURNING gives us the post-update next_seq; the assigned seq is next_seq - 1. + let seq: i64 = conn + .query_row( + "INSERT INTO delivery_seq_counters (recipient_key, channel_id, next_seq) + VALUES (?1, ?2, 1) + ON CONFLICT(recipient_key, channel_id) DO UPDATE SET next_seq = next_seq + 1 + RETURNING next_seq - 1", + params![recipient_key, channel_id], + |row| row.get(0), + ) + .map_err(|e| StorageError::Db(e.to_string()))?; conn.execute( - "INSERT INTO deliveries (recipient_key, channel_id, payload) VALUES (?1, ?2, ?3)", - params![recipient_key, channel_id, payload], + "INSERT INTO deliveries (recipient_key, channel_id, seq, payload) VALUES (?1, ?2, ?3, ?4)", + params![recipient_key, channel_id, seq, payload], ) .map_err(|e| StorageError::Db(e.to_string()))?; - Ok(()) + Ok(seq as u64) } - fn fetch(&self, recipient_key: &[u8], channel_id: &[u8]) -> Result>, StorageError> { + fn fetch( + &self, + recipient_key: &[u8], + channel_id: &[u8], + ) -> Result)>, StorageError> { let conn = self.lock_conn()?; let mut stmt = conn .prepare( - "SELECT id, payload FROM deliveries + "SELECT id, seq, payload FROM deliveries WHERE recipient_key = ?1 AND channel_id = ?2 - ORDER BY id ASC", + ORDER BY seq ASC", ) .map_err(|e| StorageError::Db(e.to_string()))?; - let rows: Vec<(i64, Vec)> = stmt + let rows: Vec<(i64, i64, Vec)> = stmt .query_map(params![recipient_key, channel_id], |row| { - Ok((row.get(0)?, row.get(1)?)) + Ok((row.get(0)?, row.get(1)?, row.get(2)?)) }) .map_err(|e| StorageError::Db(e.to_string()))? .collect::, _>>() .map_err(|e| StorageError::Db(e.to_string()))?; if !rows.is_empty() { - let ids: Vec = rows.iter().map(|(id, _)| *id).collect(); + let ids: Vec = rows.iter().map(|(id, _, _)| *id).collect(); let placeholders: String = ids.iter().map(|_| "?").collect::>().join(","); let sql = format!("DELETE FROM deliveries WHERE id IN ({placeholders})"); let params: Vec<&dyn rusqlite::types::ToSql> = ids @@ -187,7 +185,7 @@ impl Store for SqlStore { .map_err(|e| StorageError::Db(e.to_string()))?; } - Ok(rows.into_iter().map(|(_, payload)| payload).collect()) + Ok(rows.into_iter().map(|(_, seq, payload)| (seq as u64, payload)).collect()) } fn fetch_limited( @@ -195,28 +193,28 @@ impl Store for SqlStore { recipient_key: &[u8], channel_id: &[u8], limit: usize, - ) -> Result>, StorageError> { + ) -> Result)>, StorageError> { let conn = self.lock_conn()?; let mut stmt = conn .prepare( - "SELECT id, payload FROM deliveries + "SELECT id, seq, payload FROM deliveries WHERE recipient_key = ?1 AND channel_id = ?2 - ORDER BY id ASC + ORDER BY seq ASC LIMIT ?3", ) .map_err(|e| StorageError::Db(e.to_string()))?; - let rows: Vec<(i64, Vec)> = stmt + let rows: Vec<(i64, i64, Vec)> = stmt .query_map(params![recipient_key, channel_id, limit as i64], |row| { - Ok((row.get(0)?, row.get(1)?)) + Ok((row.get(0)?, row.get(1)?, row.get(2)?)) }) .map_err(|e| StorageError::Db(e.to_string()))? .collect::, _>>() .map_err(|e| StorageError::Db(e.to_string()))?; if !rows.is_empty() { - let ids: Vec = rows.iter().map(|(id, _)| *id).collect(); + let ids: Vec = rows.iter().map(|(id, _, _)| *id).collect(); let placeholders: String = ids.iter().map(|_| "?").collect::>().join(","); let sql = format!("DELETE FROM deliveries WHERE id IN ({placeholders})"); let params: Vec<&dyn rusqlite::types::ToSql> = ids @@ -227,7 +225,7 @@ impl Store for SqlStore { .map_err(|e| StorageError::Db(e.to_string()))?; } - Ok(rows.into_iter().map(|(_, payload)| payload).collect()) + Ok(rows.into_iter().map(|(_, seq, payload)| (seq as u64, payload)).collect()) } fn queue_depth(&self, recipient_key: &[u8], channel_id: &[u8]) -> Result { @@ -406,16 +404,34 @@ impl OptionalExt for Result { #[cfg(test)] mod tests { use super::*; + use std::path::PathBuf; fn open_in_memory() -> SqlStore { SqlStore::open(":memory:", "").unwrap() } + #[test] + fn sets_user_version_after_migrate() { + let dir = tempfile::tempdir().expect("tempdir"); + let db_path: PathBuf = dir.path().join("store.db"); + + { + let store = SqlStore::open(&db_path, "").expect("open store"); + let _guard = store.lock_conn().unwrap(); + } + + let conn = rusqlite::Connection::open(&db_path).expect("reopen db"); + let version: i32 = conn + .pragma_query_value(None, "user_version", |row| row.get(0)) + .expect("read user_version"); + + assert_eq!(version, SCHEMA_VERSION); + } + #[test] fn key_package_fifo() { let store = open_in_memory(); - let mut identity = [0u8; 32]; - identity[..31].copy_from_slice(b"alice_identity_key__32bytes_lon"); + let identity = [1u8; 32]; store .upload_key_package(&identity, b"kp1".to_vec()) @@ -441,11 +457,13 @@ mod tests { let rk = [1u8; 32]; let ch = b"channel-1"; - store.enqueue(&rk, ch, b"msg1".to_vec()).unwrap(); - store.enqueue(&rk, ch, b"msg2".to_vec()).unwrap(); + let seq0 = store.enqueue(&rk, ch, b"msg1".to_vec()).unwrap(); + let seq1 = store.enqueue(&rk, ch, b"msg2".to_vec()).unwrap(); + assert_eq!(seq0, 0); + assert_eq!(seq1, 1); let msgs = store.fetch(&rk, ch).unwrap(); - assert_eq!(msgs, vec![b"msg1".to_vec(), b"msg2".to_vec()]); + assert_eq!(msgs, vec![(0u64, b"msg1".to_vec()), (1u64, b"msg2".to_vec())]); assert!(store.fetch(&rk, ch).unwrap().is_empty()); } @@ -461,10 +479,10 @@ mod tests { store.enqueue(&rk, ch, b"c".to_vec()).unwrap(); let msgs = store.fetch_limited(&rk, ch, 2).unwrap(); - assert_eq!(msgs, vec![b"a".to_vec(), b"b".to_vec()]); + assert_eq!(msgs, vec![(0u64, b"a".to_vec()), (1u64, b"b".to_vec())]); let remaining = store.fetch(&rk, ch).unwrap(); - assert_eq!(remaining, vec![b"c".to_vec()]); + assert_eq!(remaining, vec![(2u64, b"c".to_vec())]); } #[test] @@ -482,23 +500,23 @@ mod tests { #[test] fn has_user_record_check() { let store = open_in_memory(); - assert!(!store.has_user_record("alice").unwrap()); + assert!(!store.has_user_record("user1").unwrap()); store - .store_user_record("alice", b"record".to_vec()) + .store_user_record("user1", b"record".to_vec()) .unwrap(); - assert!(store.has_user_record("alice").unwrap()); - assert!(!store.has_user_record("bob").unwrap()); + assert!(store.has_user_record("user1").unwrap()); + assert!(!store.has_user_record("user2").unwrap()); } #[test] fn user_identity_key_round_trip() { let store = open_in_memory(); - assert!(store.get_user_identity_key("alice").unwrap().is_none()); + assert!(store.get_user_identity_key("user1").unwrap().is_none()); store - .store_user_identity_key("alice", vec![1u8; 32]) + .store_user_identity_key("user1", vec![1u8; 32]) .unwrap(); assert_eq!( - store.get_user_identity_key("alice").unwrap(), + store.get_user_identity_key("user1").unwrap(), Some(vec![1u8; 32]) ); } @@ -522,9 +540,9 @@ mod tests { store.enqueue(&rk, b"ch-b", b"b1".to_vec()).unwrap(); let a_msgs = store.fetch(&rk, b"ch-a").unwrap(); - assert_eq!(a_msgs, vec![b"a1".to_vec()]); + assert_eq!(a_msgs, vec![(0u64, b"a1".to_vec())]); let b_msgs = store.fetch(&rk, b"ch-b").unwrap(); - assert_eq!(b_msgs, vec![b"b1".to_vec()]); + assert_eq!(b_msgs, vec![(0u64, b"b1".to_vec())]); } } diff --git a/crates/quicnprotochat-server/src/storage.rs b/crates/quicnprotochat-server/src/storage.rs index 632b329..8c9a6de 100644 --- a/crates/quicnprotochat-server/src/storage.rs +++ b/crates/quicnprotochat-server/src/storage.rs @@ -32,22 +32,30 @@ pub trait Store: Send + Sync { fn fetch_key_package(&self, identity_key: &[u8]) -> Result>, StorageError>; + /// Enqueue a payload and return the monotonically increasing per-inbox sequence number + /// assigned to this message. Clients sort by seq before MLS processing. fn enqueue( &self, recipient_key: &[u8], channel_id: &[u8], payload: Vec, - ) -> Result<(), StorageError>; + ) -> Result; - fn fetch(&self, recipient_key: &[u8], channel_id: &[u8]) -> Result>, StorageError>; + /// Fetch and drain all queued messages, returning `(seq, payload)` pairs ordered by seq. + fn fetch( + &self, + recipient_key: &[u8], + channel_id: &[u8], + ) -> Result)>, StorageError>; /// Fetch up to `limit` messages without draining the entire queue (Fix 8). + /// Returns `(seq, payload)` pairs ordered by seq. fn fetch_limited( &self, recipient_key: &[u8], channel_id: &[u8], limit: usize, - ) -> Result>, StorageError>; + ) -> Result)>, StorageError>; /// Return the number of queued messages for (recipient, channel) (Fix 7). fn queue_depth(&self, recipient_key: &[u8], channel_id: &[u8]) -> Result; @@ -123,6 +131,19 @@ struct QueueMapV2 { map: HashMap>>, } +#[derive(Serialize, Deserialize, Default, Clone)] +struct SeqEntry { + seq: u64, + data: Vec, +} + +/// V3 delivery store: each queue entry carries a monotonic per-inbox sequence number. +#[derive(Serialize, Deserialize, Default)] +struct QueueMapV3 { + map: HashMap>, + next_seq: HashMap, +} + /// File-backed storage for KeyPackages and delivery queues. /// /// Each mutation flushes the entire map to disk. Suitable for MVP-scale loads. @@ -134,7 +155,7 @@ pub struct FileBackedStore { users_path: PathBuf, identity_keys_path: PathBuf, key_packages: Mutex, VecDeque>>>, - deliveries: Mutex>>>, + deliveries: Mutex, hybrid_keys: Mutex, Vec>>, users: Mutex>>, identity_keys: Mutex>>, @@ -155,7 +176,7 @@ impl FileBackedStore { let identity_keys_path = dir.join("identity_keys.bin"); let key_packages = Mutex::new(Self::load_kp_map(&kp_path)?); - let deliveries = Mutex::new(Self::load_delivery_map(&ds_path)?); + let deliveries = Mutex::new(Self::load_delivery_map_v3(&ds_path)?); let hybrid_keys = Mutex::new(Self::load_hybrid_keys(&hk_path)?); let users = Mutex::new(Self::load_users(&users_path)?); let identity_keys = Mutex::new(Self::load_map_string_bytes(&identity_keys_path)?); @@ -201,28 +222,38 @@ impl FileBackedStore { fs::write(path, bytes).map_err(|e| StorageError::Io(e.to_string())) } - fn load_delivery_map( - path: &Path, - ) -> Result>>, StorageError> { + /// Load deliveries as V3. Falls back to V2 format (assigns seqs starting at 0). + fn load_delivery_map_v3(path: &Path) -> Result { if !path.exists() { - return Ok(HashMap::new()); + return Ok(QueueMapV3::default()); } let bytes = fs::read(path).map_err(|e| StorageError::Io(e.to_string()))?; if bytes.is_empty() { - return Ok(HashMap::new()); + return Ok(QueueMapV3::default()); } - bincode::deserialize::(&bytes) - .map(|v| v.map) - .map_err(|_| StorageError::Io("deliveries file: v1 format no longer supported; delete or migrate".into())) + // Try V3 first. + if let Ok(v3) = bincode::deserialize::(&bytes) { + return Ok(v3); + } + // Fall back to V2: assign ascending seqs starting at 0 per channel. + let v2 = bincode::deserialize::(&bytes) + .map_err(|_| StorageError::Io("deliveries file: unrecognised format".into()))?; + let mut v3 = QueueMapV3::default(); + for (key, queue) in v2.map { + let entries: VecDeque = queue + .into_iter() + .enumerate() + .map(|(i, data)| SeqEntry { seq: i as u64, data }) + .collect(); + let next = entries.len() as u64; + v3.next_seq.insert(key.clone(), next); + v3.map.insert(key, entries); + } + Ok(v3) } - fn flush_delivery_map( - &self, - path: &Path, - map: &HashMap>>, - ) -> Result<(), StorageError> { - let payload = QueueMapV2 { map: map.clone() }; - let bytes = bincode::serialize(&payload).map_err(|_| StorageError::Serde)?; + fn flush_delivery_map(&self, path: &Path, map: &QueueMapV3) -> Result<(), StorageError> { + let bytes = bincode::serialize(map).map_err(|_| StorageError::Serde)?; if let Some(parent) = path.parent() { fs::create_dir_all(parent).map_err(|e| StorageError::Io(e.to_string()))?; } @@ -309,27 +340,35 @@ impl Store for FileBackedStore { recipient_key: &[u8], channel_id: &[u8], payload: Vec, - ) -> Result<(), StorageError> { - let mut map = lock(&self.deliveries)?; + ) -> Result { + let mut inner = lock(&self.deliveries)?; let key = ChannelKey { channel_id: channel_id.to_vec(), recipient_key: recipient_key.to_vec(), }; - map.entry(key).or_default().push_back(payload); - self.flush_delivery_map(&self.ds_path, &*map) + let seq = *inner.next_seq.entry(key.clone()).or_insert(0); + *inner.next_seq.get_mut(&key).unwrap() = seq + 1; + inner.map.entry(key).or_default().push_back(SeqEntry { seq, data: payload }); + self.flush_delivery_map(&self.ds_path, &*inner)?; + Ok(seq) } - fn fetch(&self, recipient_key: &[u8], channel_id: &[u8]) -> Result>, StorageError> { - let mut map = lock(&self.deliveries)?; + fn fetch( + &self, + recipient_key: &[u8], + channel_id: &[u8], + ) -> Result)>, StorageError> { + let mut inner = lock(&self.deliveries)?; let key = ChannelKey { channel_id: channel_id.to_vec(), recipient_key: recipient_key.to_vec(), }; - let messages = map + let messages: Vec<(u64, Vec)> = inner + .map .get_mut(&key) - .map(|q| q.drain(..).collect()) + .map(|q| q.drain(..).map(|e| (e.seq, e.data)).collect()) .unwrap_or_default(); - self.flush_delivery_map(&self.ds_path, &*map)?; + self.flush_delivery_map(&self.ds_path, &*inner)?; Ok(messages) } @@ -338,30 +377,31 @@ impl Store for FileBackedStore { recipient_key: &[u8], channel_id: &[u8], limit: usize, - ) -> Result>, StorageError> { - let mut map = lock(&self.deliveries)?; + ) -> Result)>, StorageError> { + let mut inner = lock(&self.deliveries)?; let key = ChannelKey { channel_id: channel_id.to_vec(), recipient_key: recipient_key.to_vec(), }; - let messages = map + let messages: Vec<(u64, Vec)> = inner + .map .get_mut(&key) .map(|q| { let count = limit.min(q.len()); - q.drain(..count).collect() + q.drain(..count).map(|e| (e.seq, e.data)).collect() }) .unwrap_or_default(); - self.flush_delivery_map(&self.ds_path, &*map)?; + self.flush_delivery_map(&self.ds_path, &*inner)?; Ok(messages) } fn queue_depth(&self, recipient_key: &[u8], channel_id: &[u8]) -> Result { - let map = lock(&self.deliveries)?; + let inner = lock(&self.deliveries)?; let key = ChannelKey { channel_id: channel_id.to_vec(), recipient_key: recipient_key.to_vec(), }; - Ok(map.get(&key).map(|q| q.len()).unwrap_or(0)) + Ok(inner.map.get(&key).map(|q| q.len()).unwrap_or(0)) } fn gc_expired_messages(&self, _max_age_secs: u64) -> Result { diff --git a/crates/quicnprotochat-server/src/tls.rs b/crates/quicnprotochat-server/src/tls.rs new file mode 100644 index 0000000..2405521 --- /dev/null +++ b/crates/quicnprotochat-server/src/tls.rs @@ -0,0 +1,72 @@ +use std::path::PathBuf; + +use anyhow::Context; +use quinn::ServerConfig; +use quinn_proto::crypto::rustls::QuicServerConfig; +use rcgen::generate_simple_self_signed; +use rustls::pki_types::{CertificateDer, PrivateKeyDer}; +use rustls::version::TLS13; + +/// Ensure a self-signed certificate exists on disk and return a QUIC server config. +/// When `production` is true, cert and key must already exist (no auto-generation). +pub fn build_server_config( + cert_path: &PathBuf, + key_path: &PathBuf, + production: bool, +) -> anyhow::Result { + if !cert_path.exists() || !key_path.exists() { + if production { + anyhow::bail!( + "TLS cert or key missing at {:?} / {:?}; production mode forbids auto-generation", + cert_path, + key_path + ); + } + generate_self_signed_cert(cert_path, key_path)?; + } + + let cert_bytes = std::fs::read(cert_path).context("read cert")?; + let key_bytes = std::fs::read(key_path).context("read key")?; + + let cert_chain = vec![CertificateDer::from(cert_bytes)]; + let key = PrivateKeyDer::try_from(key_bytes).map_err(|_| anyhow::anyhow!("invalid key"))?; + + let mut tls = rustls::ServerConfig::builder_with_protocol_versions(&[&TLS13]) + .with_no_client_auth() + .with_single_cert(cert_chain, key)?; + tls.alpn_protocols = vec![b"capnp".to_vec()]; + + let crypto = QuicServerConfig::try_from(tls) + .map_err(|e| anyhow::anyhow!("invalid server TLS config: {e}"))?; + + Ok(ServerConfig::with_crypto(std::sync::Arc::new(crypto))) +} + +fn generate_self_signed_cert(cert_path: &PathBuf, key_path: &PathBuf) -> anyhow::Result<()> { + if let Some(parent) = cert_path.parent() { + std::fs::create_dir_all(parent).context("create cert dir")?; + } + if let Some(parent) = key_path.parent() { + std::fs::create_dir_all(parent).context("create key dir")?; + } + + let subject_alt_names = vec![ + "localhost".to_string(), + "127.0.0.1".to_string(), + "::1".to_string(), + ]; + + let issued = generate_simple_self_signed(subject_alt_names)?; + let key_der = issued.key_pair.serialize_der(); + + std::fs::write(cert_path, issued.cert.der()).context("write cert")?; + std::fs::write(key_path, &key_der).context("write key")?; + + tracing::info!( + cert = %cert_path.display(), + key = %key_path.display(), + "generated self-signed TLS certificate" + ); + + Ok(()) +} diff --git a/docs/src/getting-started/running-the-client.md b/docs/src/getting-started/running-the-client.md index c289f08..0b62a73 100644 --- a/docs/src/getting-started/running-the-client.md +++ b/docs/src/getting-started/running-the-client.md @@ -83,7 +83,7 @@ KeyPackage fetched successfully. No KeyPackage available for this identity. ``` -KeyPackages are single-use: fetching a KeyPackage atomically removes it from the server. If the peer needs to be added to another group, they must upload a new KeyPackage. +KeyPackages are single-use: fetching a KeyPackage atomically removes it from the server. The server may also enforce a TTL (e.g. 24 hours) on stored KeyPackages. If the peer needs to be added to another group, or their KeyPackage expired, they must upload a new one (see `refresh-keypackage` below). ### `demo-group` @@ -125,7 +125,7 @@ cargo run -p quicnprotochat-client -- register-state \ --server 127.0.0.1:7000 ``` -If `alice.bin` does not exist, a new identity is generated and saved. If it already exists, the existing identity is loaded and a new KeyPackage is generated from it. +If `alice.bin` does not exist, a new identity is generated and saved. If it already exists, the existing identity is loaded and a new KeyPackage is generated from it. You can run `register-state` again at any time to upload a fresh KeyPackage (e.g. after the previous one was consumed or expired). For refresh-only (no new identity), use `refresh-keypackage` instead. **Output:** ``` @@ -134,6 +134,30 @@ fingerprint : 9f8e7d6c5b4a... KeyPackage uploaded successfully. ``` +### `refresh-keypackage` + +Refresh the KeyPackage on the server using your **existing** state file. Does not create a new identity. Use this when: + +- Your KeyPackage has expired (server TTL, e.g. 24h). +- Your KeyPackage was consumed (someone invited you) and you want to be invitable again. + +Run with the same `--access-token` (or `QUICNPROTOCHAT_ACCESS_TOKEN`) as for other commands. + +```bash +cargo run -p quicnprotochat-client -- refresh-keypackage \ + --state alice.bin \ + --server 127.0.0.1:7000 +``` + +**Output:** +``` +identity_key : a1b2c3d4e5f6... +fingerprint : 9f8e7d6c5b4a... +KeyPackage uploaded successfully. +``` + +If you are told "no key" when someone tries to invite you, have them wait and run `refresh-keypackage`, then try the invite again. + ### `create-group` Create a new MLS group. The caller becomes the sole member at epoch 0. @@ -269,7 +293,8 @@ In ephemeral mode (`register` and `demo-group`), the key is held in process memo | `register` | No | Generate ephemeral identity + KeyPackage, upload to AS | | `fetch-key ` | No | Fetch a peer's KeyPackage from AS | | `demo-group` | No | Automated Alice-and-Bob round-trip | -| `register-state` | Yes | Upload KeyPackage for persistent identity | +| `register-state` | Yes | Upload KeyPackage for persistent identity (creates identity if needed) | +| `refresh-keypackage` | Yes | Upload a fresh KeyPackage from existing state (no new identity) | | `create-group` | Yes | Create MLS group (sole member, epoch 0) | | `invite` | Yes | Add peer to group, deliver Welcome via DS | | `join` | Yes | Consume Welcome from DS, join group | diff --git a/docs/src/internals/keypackage-exchange.md b/docs/src/internals/keypackage-exchange.md index d8a3b4a..69d6b1b 100644 --- a/docs/src/internals/keypackage-exchange.md +++ b/docs/src/internals/keypackage-exchange.md @@ -6,6 +6,8 @@ credential (Ed25519 public key), and a signature proving ownership. The quicnprotochat Authentication Service (AS) provides a simple upload/fetch interface for distributing KeyPackages between clients. +**Expiry and refresh:** KeyPackages are consumed on fetch (single-use). The server may also enforce a TTL (e.g. 24h). Clients should upload a fresh KeyPackage periodically or on demand so they remain invitable. The CLI provides `refresh-keypackage`: load existing state, generate a new KeyPackage, upload to the AS. See [Running the Client](../getting-started/running-the-client.md#refresh-keypackage). + This page describes the end-to-end flow: from client-side generation through server-side storage to peer-side retrieval and consumption. diff --git a/docs/src/roadmap/fully-operational-checklist.md b/docs/src/roadmap/fully-operational-checklist.md new file mode 100644 index 0000000..5bb1be9 --- /dev/null +++ b/docs/src/roadmap/fully-operational-checklist.md @@ -0,0 +1,135 @@ +# Features Needed to Be Fully Operational + +This checklist reflects the current state after M1–M3, M4-style CLI, M6 migrations, rich messaging, Sealed Sender, and GUI scaffold. It lists what is **done**, what is **partially done**, and what still **must be implemented** for a fully operational chat system. + +--- + +## Summary Table + +| Area | Status | Notes | +|------|--------|--------| +| Transport (QUIC/TLS) | Done | M1 | +| Auth service (KeyPackage, OPAQUE) | Done | M2 + register-user, login | +| Delivery + MLS groups (2-party) | Done | M3 | +| Group CLI (create, invite, join, send, recv, chat) | Done | M4-style | +| Server persistence (SQL + migrations) | Done | M6 migrations + runner | +| Client state persistence | Done | State file, DiskKeyStore, encrypted (QPCE) | +| Rich messaging (app payload schema) | Done | Chat, Reply, Reaction, ReadReceipt, Typing + sender | +| Sealed Sender | Done | Server config; enqueue without identity | +| Native GUI scaffold | Done | Tauri, whoami, health | +| **Multi-party groups (N > 2)** | Done | M5: Commit fan-out, send --all, epoch sync, three-party E2E | +| **KeyPackage rotation** | **To do** | Client upload before TTL (24h) | +| **Observability** | **To do** | Metrics (Prometheus), tracing (OpenTelemetry), health | +| **Client resilience** | **To do** | Retry/backoff, idempotent message IDs, gap detection | +| **1:1 channel semantics** | Partial | channelId in DS; per-channel authz/TTL not formalized | +| **Production hardening** | **To do** | CI, CODEOWNERS, SBOM, backup/restore, rate-limit tuning | +| **Post-quantum (M7)** | Next | Custom OpenMlsCryptoProvider with hybrid KEM | + +--- + +## 1. Must-Have for “Fully Operational” + +These are the features that, if missing, prevent the system from being considered fully operational for real use (multi-user groups, reliability, and operations). + +### 1.1 Multi-party groups (M5) + +**Current:** Core supports `add_member` and `merge_staged_commit`; client/server only exercise 2-party (creator + one joiner). + +**To implement:** + +- **Commit fan-out:** When creator invites a new member, the Commit must be delivered to **all existing members** (not just the creator). Client flow: after `add_member`, enqueue the Commit to each existing member’s queue (by identity / recipient_key) in addition to sending the Welcome to the new member. +- **Proposal handling:** Ensure all members process Commits and Proposals (Add/Remove/Update) so epoch advancement is consistent; already partially in core (`merge_staged_commit`, `store_pending_proposal`). +- **CLI/API:** Extend `invite` so that after adding a member, the client fetches the list of existing members (e.g. from local group state) and enqueues the Commit to each. Optional: `recv` processes incoming Commits and updates local group state before returning application messages. +- **Tests:** E2E with 3+ members: create group, invite B, invite C, send from A, B, C; all receive and decrypt. + +### 1.2 KeyPackage rotation + +**Current:** KeyPackages are single-use (consume-on-fetch). Server TTL (e.g. 24h) and client upload are in place, but there is no **scheduled client-side rotation**. + +**To implement:** + +- **Timer or on-demand:** Before KeyPackage TTL expires (e.g. 24h), client uploads a fresh KeyPackage (and optionally removes or replaces the old one). Can be a background task in the client (CLI daemon or GUI backend) or triggered when a “fetch key” fails with “no key”. +- **Documentation:** Document TTL and rotation in user/ops docs. + +### 1.3 Observability + +**Current:** Health RPC and basic tracing exist; no structured metrics or distributed tracing. + +**To implement:** + +- **Metrics:** Prometheus (or equivalent) export for: enqueue/fetch rate, RPC latency histograms, queue depth per recipient, KeyPackage store size, active connections. See [Future Research](future-research.md). +- **Health:** Existing `health` RPC is sufficient; optionally add a simple HTTP health endpoint for load balancers (e.g. on a separate port). +- **Structured logging:** Ensure sensitive data is never logged; audit events (auth, enqueue, rate limit) as in [Production Readiness](production-readiness.md). + +### 1.4 Client resilience + +**Current:** Single attempt for send/recv; no retry, no idempotent message IDs, no gap detection. + +**To implement:** + +- **Retry with backoff:** On transient failures (network, server busy), retry with exponential backoff + jitter for enqueue, fetch, fetchWait. +- **Idempotent message IDs:** Client-generated message IDs (already in rich messaging); server-side deduplication by (recipient_key, channel_id, message_id) if desired, to avoid duplicate delivery on retry. +- **Gap detection (optional):** Per-channel sequence numbers or epoch checks so the client can detect missing Commits or messages and re-sync (e.g. re-fetch or rejoin). + +--- + +## 2. Important for Production Readiness + +Not strictly required for “operational” but expected for production deployments. + +### 2.1 1:1 channel semantics (Phase 4) + +**Current:** Delivery is per `(recipient_key, channel_id)`; channelId is used in enqueue/fetch. No formal per-channel authz or TTL. + +**To implement:** + +- **Per-channel authz:** Ensure fetch/fetchWait only return messages for channels the authenticated identity is allowed to read (e.g. identity bound to recipient_key or to a channel membership list). +- **TTL eviction:** Server already has message TTL (e.g. 7 days) and GC; document and optionally make TTL configurable per channel type. + +### 2.2 Wire versioning and protocol hardening (Phase 2) + +**Current:** Wire version is checked on enqueue/fetch (e.g. `CURRENT_WIRE_VERSION`). Ciphersuite allowlist and ALPN are partially in place. + +**To implement:** + +- **Ciphersuite allowlist:** Server rejects KeyPackages with unknown ciphersuites. +- **Downgrade guards:** Reject Commits with weaker ciphersuites once a group has advanced. +- **Connection draining:** Graceful QUIC `CONNECTION_CLOSE` on server shutdown. + +### 2.3 Production hardening (Phase 1 + 6) + +- **CODEOWNERS:** Map crates to reviewers. +- **CI:** `cargo test --workspace`, `cargo clippy`, `cargo fmt --check`, `cargo audit`, optional `cargo deny`. +- **SBOM:** e.g. `cargo-cyclonedx` or `cargo-about` in CI. +- **Backup/restore:** SQLite/SQLCipher backup and integrity verification for server DB. +- **Rate limiting:** Already per-token; optionally add per-IP and per-account limits and document. + +--- + +## 3. Roadmap and Documentation Updates + +- **Milestones doc:** Mark M4 as **Complete** (CLI subcommands exist). Mark M6 as **Complete** (migrations + runner; server and client persistence in place). Leave M5 as **Next** and M7 as **Planned**. +- **README:** Update milestone table to reflect M4 and M6 complete; add one line on migrations (e.g. “Server supports SQL migrations under `quicnprotochat-server/migrations/`”). +- **Migration convention:** Document in README or a dev doc: add new migrations as `NNN_name.sql`, add to `MIGRATIONS` in `sql_store.rs`, bump `SCHEMA_VERSION`. + +--- + +## 4. Optional / Later + +- **Post-quantum (M7):** Custom `OpenMlsCryptoProvider` with hybrid X25519 + ML-KEM-768 for MLS HPKE; all M3–M5 tests pass with PQ backend. +- **GUI completion:** Full flows (login, conversation list, chat view with send/recv, settings); long-lived connection and streaming recv. +- **WebTransport + WASM:** Browser client. +- **iroh / P2P:** NAT traversal and optional direct peer-to-peer delivery. + +--- + +## Priority Order for “Fully Operational” + +1. **M5 Multi-party groups** — Commit fan-out and client flow for N > 2. +2. **KeyPackage rotation** — Client upload before TTL. +3. **Observability** — Metrics + health + safe logging. +4. **Client resilience** — Retry, backoff, idempotent message IDs. +5. **Docs** — Update milestones and README (M4, M6, migrations). +6. **Production hardening** — CI, CODEOWNERS, SBOM, backup, rate-limit docs. + +Once 1–5 are in place, the system can be considered **fully operational** for multi-user group chat with durable state and observable, resilient clients. Item 6 and the optional items bring it to **production-ready** and beyond. diff --git a/docs/src/roadmap/milestones.md b/docs/src/roadmap/milestones.md index 1088f68..2c9b8d5 100644 --- a/docs/src/roadmap/milestones.md +++ b/docs/src/roadmap/milestones.md @@ -14,10 +14,10 @@ for what that means in practice. | M1 | QUIC/TLS Transport | **Complete** | QUIC + TLS 1.3 endpoint, length-prefixed framing, Ping/Pong | | M2 | Authentication Service | **Complete** | Ed25519 identity, KeyPackage generation, AS upload/fetch | | M3 | Delivery Service + MLS Groups | **Complete** | DS relay, GroupMember create/join/add/send/recv | -| M4 | Group CLI Subcommands | **Next** | Persistent CLI (create-group, invite, join, send, recv); `demo-group` already available | -| M5 | Multi-party Groups | Planned | N > 2 members, Commit fan-out, Proposal handling | -| M6 | Persistence | Planned | SQLite key store, durable group state | -| M7 | Post-quantum | Planned | PQ hybrid for MLS/HPKE (X25519 + ML-KEM-768) | +| M4 | Group CLI Subcommands | **Complete** | Persistent CLI (create-group, invite, join, send, recv), OPAQUE login | +| M5 | Multi-party Groups | **Complete** | N > 2 members, Commit fan-out, send --all, epoch sync | +| M6 | Persistence | **Complete** | SQLite/SQLCipher, migrations, durable server + client state | +| M7 | Post-quantum | **Next** | PQ hybrid for MLS/HPKE (X25519 + ML-KEM-768) | --- @@ -103,63 +103,45 @@ group\_id lifecycle, MLS integration. --- -## M4 -- Group CLI Subcommands (Next) +## M4 -- Group CLI Subcommands (Complete) **Goal:** Persistent, composable CLI subcommands for group operations, replacing the monolithic `demo-group` proof-of-concept. -**Planned deliverables:** - -- `create-group` -- creates a new MLS group, stores state locally -- `invite ` -- adds a member by fetching their KeyPackage from the AS -- `join` -- processes a Welcome message and joins an existing group -- `send ` -- encrypts and enqueues an application message -- `recv` -- fetches and decrypts pending messages (or long-polls with `fetchWait`) - -The `demo-group` subcommand remains available as a single-command demonstration -of the full flow. +**Deliverables:** `create-group`, `invite`, `join`, `send`, `recv`, `chat`; +OPAQUE `register-user` and `login`; `demo-group` remains for single-command demo. --- -## M5 -- Multi-party Groups (Planned) +## M5 -- Multi-party Groups (Complete) **Goal:** Support groups with N > 2 members, including Commit fan-out and -Proposal handling. +epoch synchronisation. -**Planned deliverables:** - -- Commit fan-out through the DS to all group members -- Proposal handling (Add, Remove, Update) -- Epoch synchronisation across N members -- Criterion benchmarks: key generation, encap/decap, group-add latency - (10/100/1000 members) +**Deliverables:** Commit fan-out to existing members on invite; `send --all`; +`cmd_join` processes all queued payloads (Welcome + Commits); three-party E2E +passing. Proposal handling (Remove, Update) and Criterion benchmarks are +optional follow-ups. --- -## M6 -- Persistence (Planned) +## M6 -- Persistence (Complete) **Goal:** Server survives restart. Client state persists across sessions. -**Planned deliverables:** - -- `quicnprotochat-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) - +**Deliverables:** SQLite/SQLCipher via rusqlite, `migrations/` directory and +migration runner; client state file and DiskKeyStore (encrypted QPCE optional). See [Future Research: SQLCipher](future-research.md#storage--persistence) for encrypted-at-rest options. --- -## M7 -- Post-quantum (Planned) +## M7 -- Post-quantum (Next) **Goal:** Replace the MLS crypto backend with a hybrid X25519 + ML-KEM-768 KEM, providing post-quantum confidentiality for all group key material. -**Planned deliverables:** +**Deliverables:** - Custom `OpenMlsCryptoProvider` with hybrid KEM in `quicnprotochat-core` - Hybrid shared secret derivation: diff --git a/schemas/node.capnp b/schemas/node.capnp index 2b80ba8..83e4001 100644 --- a/schemas/node.capnp +++ b/schemas/node.capnp @@ -20,15 +20,18 @@ interface NodeService { # channelId : Optional channel identifier (empty for default). A 16-byte UUID # is recommended for 1:1 channels. # version : Schema/wire version. Must be 1. - enqueue @2 (recipientKey :Data, payload :Data, channelId :Data, version :UInt16, auth :Auth) -> (); + # Returns the monotonically increasing per-inbox sequence number assigned to this message. + enqueue @2 (recipientKey :Data, payload :Data, channelId :Data, version :UInt16, auth :Auth) -> (seq :UInt64); # Fetch and drain all queued payloads for the recipient. # limit: max number of messages to return (0 = fetch all). - fetch @3 (recipientKey :Data, channelId :Data, version :UInt16, auth :Auth, limit :UInt32) -> (payloads :List(Data)); + # Returns envelopes with per-inbox sequence numbers for ordered MLS processing. + fetch @3 (recipientKey :Data, channelId :Data, version :UInt16, auth :Auth, limit :UInt32) -> (payloads :List(Envelope)); # Long-poll: wait up to timeoutMs for new payloads, then drain queue. # limit: max number of messages to return (0 = fetch all). - fetchWait @4 (recipientKey :Data, channelId :Data, version :UInt16, timeoutMs :UInt64, auth :Auth, limit :UInt32) -> (payloads :List(Data)); + # Returns envelopes with per-inbox sequence numbers for ordered MLS processing. + fetchWait @4 (recipientKey :Data, channelId :Data, version :UInt16, timeoutMs :UInt64, auth :Auth, limit :UInt32) -> (payloads :List(Envelope)); # Health probe for readiness/liveness. health @5 () -> (status :Text); @@ -70,3 +73,10 @@ struct Auth { accessToken @1 :Data; # opaque bearer token issued at login deviceId @2 :Data; # optional UUID bytes for auditing/rate limiting } + +# A delivery envelope pairing a per-inbox sequence number with an opaque payload. +# Clients sort by `seq` before processing to guarantee MLS commit ordering. +struct Envelope { + seq @0 :UInt64; # monotonically increasing per-inbox counter (assigned by server) + data @1 :Data; # opaque payload (hybrid-encrypted MLS message) +} diff --git a/scripts/check_rust_file_sizes.sh b/scripts/check_rust_file_sizes.sh new file mode 100644 index 0000000..0c4524a --- /dev/null +++ b/scripts/check_rust_file_sizes.sh @@ -0,0 +1,38 @@ +#!/usr/bin/env bash +set -euo pipefail + +SOFT_CAP=${SOFT_CAP:-400} +HARD_CAP=${HARD_CAP:-650} +warn=0 +fail=0 +ALLOW_FILE=${SIZE_ALLOWLIST:-.size-limits.allow} + +is_allowed() { + local file=$1 + [[ -f "$ALLOW_FILE" ]] && grep -Fxq "$file" "$ALLOW_FILE" +} + +while IFS= read -r file; do + lines=$(wc -l <"$file") + if (( lines > HARD_CAP )); then + if is_allowed "$file"; then + printf 'ALLOW (hard cap): %s has %d lines (hard cap %d)\n' "$file" "$lines" "$HARD_CAP" + warn=1 + continue + fi + printf 'FAIL: %s has %d lines (hard cap %d)\n' "$file" "$lines" "$HARD_CAP" + fail=1 + elif (( lines > SOFT_CAP )); then + printf 'WARN: %s has %d lines (soft cap %d)\n' "$file" "$lines" "$SOFT_CAP" + warn=1 + fi +done < <(git ls-files '*.rs') + +if (( fail == 1 )); then + echo "One or more Rust files exceed the hard cap. Please split them before merging." + exit 1 +fi + +if (( warn == 1 )); then + echo "Warnings emitted for files exceeding the soft cap. Consider splitting them." +fi