From 82a04f3e12058ac9b84b31ccd497955b656147d7 Mon Sep 17 00:00:00 2001 From: leetcrypt Date: Sat, 30 May 2026 11:47:25 -0700 Subject: [PATCH] =?UTF-8?q?feat(coven):=20SRP/Fernet=20crypto=20parity=20+?= =?UTF-8?q?=20multi-user=20coven=20foundation=20=E2=9B=A7?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Begin the coven evolution of cmd-chat (see docs/spec-collaborative-sandbox.md): a Rust/ratatui client for the unchanged Python Sanic server, plus the multi-user + zero-knowledge groundwork. P0 — crypto parity (the spec's #1 risk), proven three ways: - Hand-rolled SRP-6a (NG_2048, SHA-256, rfc5054 padding) matching pysrp byte-for-byte, incl. the fixed b"chat" SRP identity and minimal-vs-256B width quirks. Golden-vector unit test + offline selftest. - Live handshake against the running server (H_AMK verified). - Cross-language E2E: Python client decrypts a Rust-encrypted Fernet message. P2 — multi-user coven (server): - CMD_CHAT_MAX_USERS capacity cap (default 4, infra-for-more). - Authoritative roster + user_joined broadcasts. - Free the slot/username on ws disconnect (was held until 1h stale sweep). Also: fix requirements.txt (was UTF-16, unparseable by pip). coven/ : Rust crate (crypto.rs proven; main.rs spike CLI: selftest/handshake/srpm) docs/ : full feature spec for the 6 requested features. Co-Authored-By: Claude Opus 4.8 --- .gitignore | 3 +- cmd_chat/server/factory.py | 3 + cmd_chat/server/views.py | 37 + coven/Cargo.lock | 1902 ++++++++++++++++++++++++++++ coven/Cargo.toml | 35 + coven/README.md | 57 + coven/src/crypto.rs | 237 ++++ coven/src/main.rs | 212 ++++ coven/tools/gen_vectors.py | 32 + docs/spec-collaborative-sandbox.md | 513 ++++++++ requirements.txt | Bin 524 -> 262 bytes 11 files changed, 3030 insertions(+), 1 deletion(-) create mode 100644 coven/Cargo.lock create mode 100644 coven/Cargo.toml create mode 100644 coven/README.md create mode 100644 coven/src/crypto.rs create mode 100644 coven/src/main.rs create mode 100644 coven/tools/gen_vectors.py create mode 100644 docs/spec-collaborative-sandbox.md diff --git a/.gitignore b/.gitignore index 122f55e..b1b09fa 100644 --- a/.gitignore +++ b/.gitignore @@ -14,4 +14,5 @@ build dist true.txt secured_console_chat.egg-info -.pytest_cache/ \ No newline at end of file +.pytest_cache//.venv/ +/downloads/ diff --git a/cmd_chat/server/factory.py b/cmd_chat/server/factory.py index c40541e..c6d779f 100644 --- a/cmd_chat/server/factory.py +++ b/cmd_chat/server/factory.py @@ -24,6 +24,9 @@ def create_app(password: str = "", name: str = "cmd-chat-server") -> Sanic: app.ctx.ws_secret = os.urandom(32) app.ctx.admin_token = secrets.token_hex(16) app.ctx.rate_limiter = RateLimiter(max_requests=10, window_seconds=60) + # Coven capacity. 4 by default; raise via CMD_CHAT_MAX_USERS — infra-for-more, + # the cap is data not architecture (broadcast fan-out is O(N)). + app.ctx.max_users = int(os.environ.get("CMD_CHAT_MAX_USERS", "4")) app.ctx.cleanup_task = None register_lifecycle(app) diff --git a/cmd_chat/server/views.py b/cmd_chat/server/views.py index a24ee69..a59cba5 100644 --- a/cmd_chat/server/views.py +++ b/cmd_chat/server/views.py @@ -15,6 +15,18 @@ def generate_ws_token(user_id: str, secret: bytes) -> str: return hmac.new(secret, user_id.encode(), hashlib.sha256).hexdigest() +def _roster_frame(app: Sanic) -> str: + """Authoritative presence snapshot — all coven members converge on this.""" + users = app.ctx.session_store.get_all() + return json.dumps( + { + "type": "roster", + "users": [{"user_id": u.user_id, "username": u.username} for u in users], + "capacity": app.ctx.max_users, + } + ) + + async def srp_init(request: Request, app: Sanic) -> HTTPResponse: try: client_ip = get_client_ip(request) @@ -33,6 +45,9 @@ async def srp_init(request: Request, app: Sanic) -> HTTPResponse: if app.ctx.session_store.username_exists(username): return response.json({"error": "Username taken"}, status=409) + if app.ctx.session_store.count() >= app.ctx.max_users: + return response.json({"error": "Coven full"}, status=409) + user_id, B, salt = app.ctx.srp_manager.init_auth(username, client_public) return response.json( @@ -64,6 +79,11 @@ async def srp_verify(request: Request, app: Sanic) -> HTTPResponse: client_proof = base64.b64decode(client_proof_b64) + # Authoritative capacity gate — the slot is only consumed once a session + # is actually added here (init is best-effort / racy). + if app.ctx.session_store.count() >= app.ctx.max_users: + return response.json({"error": "Coven full"}, status=409) + H_AMK, session_key = app.ctx.srp_manager.verify_auth(user_id, client_proof) fernet_key = base64.urlsafe_b64encode(session_key[:32]) @@ -115,6 +135,19 @@ async def chat_ws(request: Request, ws: Websocket, app: Sanic) -> None: try: await send_state(ws, app) + # Announce arrival to everyone already present, then a fresh roster. + await manager.broadcast( + json.dumps( + { + "type": "user_joined", + "user_id": user_id, + "username": session.username, + } + ), + exclude_user=user_id, + ) + await manager.broadcast(_roster_frame(app)) + async for data in ws: if data is None: break @@ -140,6 +173,9 @@ async def chat_ws(request: Request, ws: Websocket, app: Sanic) -> None: pass finally: await manager.disconnect(user_id) + # Free the slot + username so the coven can be rejoined (was previously + # held until the 1h stale sweep, which also blocked the name). + app.ctx.session_store.remove(user_id) await manager.broadcast( json.dumps( { @@ -148,6 +184,7 @@ async def chat_ws(request: Request, ws: Websocket, app: Sanic) -> None: } ) ) + await manager.broadcast(_roster_frame(app)) async def health(request: Request, app: Sanic) -> HTTPResponse: diff --git a/coven/Cargo.lock b/coven/Cargo.lock new file mode 100644 index 0000000..e1d59e5 --- /dev/null +++ b/coven/Cargo.lock @@ -0,0 +1,1902 @@ +# This file is automatically @generated by Cargo. +# It is not intended for manual editing. +version = 4 + +[[package]] +name = "anstream" +version = "1.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "824a212faf96e9acacdbd09febd34438f8f711fb84e09a8916013cd7815ca28d" +dependencies = [ + "anstyle", + "anstyle-parse", + "anstyle-query", + "anstyle-wincon", + "colorchoice", + "is_terminal_polyfill", + "utf8parse", +] + +[[package]] +name = "anstyle" +version = "1.0.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "940b3a0ca603d1eade50a4846a2afffd5ef57a9feac2c0e2ec2e14f9ead76000" + +[[package]] +name = "anstyle-parse" +version = "1.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "52ce7f38b242319f7cabaa6813055467063ecdc9d355bbb4ce0c68908cd8130e" +dependencies = [ + "utf8parse", +] + +[[package]] +name = "anstyle-query" +version = "1.1.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "40c48f72fd53cd289104fc64099abca73db4166ad86ea0b4341abe65af83dadc" +dependencies = [ + "windows-sys 0.61.2", +] + +[[package]] +name = "anstyle-wincon" +version = "3.0.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "291e6a250ff86cd4a820112fb8898808a366d8f9f58ce16d1f538353ad55747d" +dependencies = [ + "anstyle", + "once_cell_polyfill", + "windows-sys 0.61.2", +] + +[[package]] +name = "anyhow" +version = "1.0.102" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7f202df86484c868dbad7eaa557ef785d5c66295e41b460ef922eca0723b842c" + +[[package]] +name = "atomic-waker" +version = "1.1.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1505bd5d3d116872e7271a6d4e16d81d0c8570876c8de68093a09ac269d8aac0" + +[[package]] +name = "autocfg" +version = "1.5.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f2032f911046de80f0a198e0901378627c33f59ea0ac00e363d481118bd70a53" + +[[package]] +name = "aws-lc-rs" +version = "1.17.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5ec2f1fc3ec205783a5da9a7e6c1509cc69dedf09a1949e412c1e18469326d00" +dependencies = [ + "aws-lc-sys", + "zeroize", +] + +[[package]] +name = "aws-lc-sys" +version = "0.41.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1a2f9779ce85b93ab6170dd940ad0169b5766ff848247aff13bb788b832fe3f4" +dependencies = [ + "cc", + "cmake", + "dunce", + "fs_extra", +] + +[[package]] +name = "base64" +version = "0.22.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "72b3254f16251a8381aa12e40e3c4d2f0199f8c6508fbecb9d91f575e0fbb8c6" + +[[package]] +name = "bitflags" +version = "2.11.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c4512299f36f043ab09a583e57bceb5a5aab7a73db1805848e8fef3c9e8c78b3" + +[[package]] +name = "block-buffer" +version = "0.10.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3078c7629b62d3f0439517fa394996acacc5cbc91c5a20d8c658e77abd503a71" +dependencies = [ + "generic-array", +] + +[[package]] +name = "bumpalo" +version = "3.20.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "72f5acc6cb2ba439de613abc23857ec3d78374d8ed5ac84e9d11336e87da8649" + +[[package]] +name = "byteorder" +version = "1.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1fd0f2584146f6f2ef48085050886acf353beff7305ebd1ae69500e27c67f64b" + +[[package]] +name = "bytes" +version = "1.11.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1e748733b7cbc798e1434b6ac524f0c1ff2ab456fe201501e6497c8417a4fc33" + +[[package]] +name = "cc" +version = "1.2.63" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "556e016178bb5662a08681bbe0f00f8e17631781a4dfc8c45e466e4b185ec27f" +dependencies = [ + "find-msvc-tools", + "jobserver", + "libc", + "shlex", +] + +[[package]] +name = "cfg-if" +version = "1.0.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9330f8b2ff13f34540b44e946ef35111825727b38d33286ef986142615121801" + +[[package]] +name = "cfg_aliases" +version = "0.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "613afe47fcd5fac7ccf1db93babcb082c5994d996f20b8b159f2ad1658eb5724" + +[[package]] +name = "clap" +version = "4.6.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1ddb117e43bbf7dacf0a4190fef4d345b9bad68dfc649cb349e7d17d28428e51" +dependencies = [ + "clap_builder", + "clap_derive", +] + +[[package]] +name = "clap_builder" +version = "4.6.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "714a53001bf66416adb0e2ef5ac857140e7dc3a0c48fb28b2f10762fc4b5069f" +dependencies = [ + "anstream", + "anstyle", + "clap_lex", + "strsim", +] + +[[package]] +name = "clap_derive" +version = "4.6.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f2ce8604710f6733aa641a2b3731eaa1e8b3d9973d5e3565da11800813f997a9" +dependencies = [ + "heck", + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "clap_lex" +version = "1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c8d4a3bb8b1e0c1050499d1815f5ab16d04f0959b233085fb31653fbfc9d98f9" + +[[package]] +name = "cmake" +version = "0.1.58" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c0f78a02292a74a88ac736019ab962ece0bc380e3f977bf72e376c5d78ff0678" +dependencies = [ + "cc", +] + +[[package]] +name = "colorchoice" +version = "1.0.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1d07550c9036bf2ae0c684c4297d503f838287c83c53686d05370d0e139ae570" + +[[package]] +name = "coven" +version = "0.1.0" +dependencies = [ + "anyhow", + "base64", + "clap", + "fernet", + "hex", + "hkdf", + "num-bigint", + "num-traits", + "rand 0.8.6", + "reqwest", + "rustls", + "serde", + "serde_json", + "sha2", + "tungstenite", + "url", +] + +[[package]] +name = "cpufeatures" +version = "0.2.17" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "59ed5838eebb26a2bb2e58f6d5b5316989ae9d08bab10e0e6d103e656d1b0280" +dependencies = [ + "libc", +] + +[[package]] +name = "crypto-common" +version = "0.1.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "78c8292055d1c1df0cce5d180393dc8cce0abec0a7102adb6c7b1eef6016d60a" +dependencies = [ + "generic-array", + "typenum", +] + +[[package]] +name = "data-encoding" +version = "2.11.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a4ae5f15dda3c708c0ade84bfee31ccab44a3da4f88015ed22f63732abe300c8" + +[[package]] +name = "digest" +version = "0.10.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9ed9a281f7bc9b7576e61468ba615a66a5c8cfdff42420a70aa82701a3b1e292" +dependencies = [ + "block-buffer", + "crypto-common", + "subtle", +] + +[[package]] +name = "displaydoc" +version = "0.2.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1ac70aa55017e108007fbaf5aa0f54b021c98f92ff8af59d42eda9da96e3dd4f" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "dunce" +version = "1.0.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "92773504d58c093f6de2459af4af33faa518c13451eb8f2b5698ed3d36e7c813" + +[[package]] +name = "fernet" +version = "0.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c66b725fe9483b9ee72ccaec072b15eb8ad95a3ae63a8c798d5748883b72fd33" +dependencies = [ + "base64", + "byteorder", + "getrandom 0.2.17", + "openssl", + "zeroize", +] + +[[package]] +name = "find-msvc-tools" +version = "0.1.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5baebc0774151f905a1a2cc41989300b1e6fbb29aff0ceffa1064fdd3088d582" + +[[package]] +name = "foreign-types" +version = "0.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f6f339eb8adc052cd2ca78910fda869aefa38d22d5cb648e6485e4d3fc06f3b1" +dependencies = [ + "foreign-types-shared", +] + +[[package]] +name = "foreign-types-shared" +version = "0.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "00b0228411908ca8685dba7fc2cdd70ec9990a6e753e89b6ac91a84c40fbaf4b" + +[[package]] +name = "form_urlencoded" +version = "1.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cb4cb245038516f5f85277875cdaa4f7d2c9a0fa0468de06ed190163b1581fcf" +dependencies = [ + "percent-encoding", +] + +[[package]] +name = "fs_extra" +version = "1.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "42703706b716c37f96a77aea830392ad231f44c9e9a67872fa5548707e11b11c" + +[[package]] +name = "futures-channel" +version = "0.3.32" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "07bbe89c50d7a535e539b8c17bc0b49bdb77747034daa8087407d655f3f7cc1d" +dependencies = [ + "futures-core", + "futures-sink", +] + +[[package]] +name = "futures-core" +version = "0.3.32" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7e3450815272ef58cec6d564423f6e755e25379b217b0bc688e295ba24df6b1d" + +[[package]] +name = "futures-io" +version = "0.3.32" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cecba35d7ad927e23624b22ad55235f2239cfa44fd10428eecbeba6d6a717718" + +[[package]] +name = "futures-sink" +version = "0.3.32" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c39754e157331b013978ec91992bde1ac089843443c49cbc7f46150b0fad0893" + +[[package]] +name = "futures-task" +version = "0.3.32" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "037711b3d59c33004d3856fbdc83b99d4ff37a24768fa1be9ce3538a1cde4393" + +[[package]] +name = "futures-util" +version = "0.3.32" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "389ca41296e6190b48053de0321d02a77f32f8a5d2461dd38762c0593805c6d6" +dependencies = [ + "futures-core", + "futures-io", + "futures-sink", + "futures-task", + "memchr", + "pin-project-lite", + "slab", +] + +[[package]] +name = "generic-array" +version = "0.14.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "85649ca51fd72272d7821adaf274ad91c288277713d9c18820d8499a7ff69e9a" +dependencies = [ + "typenum", + "version_check", +] + +[[package]] +name = "getrandom" +version = "0.2.17" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ff2abc00be7fca6ebc474524697ae276ad847ad0a6b3faa4bcb027e9a4614ad0" +dependencies = [ + "cfg-if", + "js-sys", + "libc", + "wasi", + "wasm-bindgen", +] + +[[package]] +name = "getrandom" +version = "0.3.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "899def5c37c4fd7b2664648c28120ecec138e4d395b459e5ca34f9cce2dd77fd" +dependencies = [ + "cfg-if", + "js-sys", + "libc", + "r-efi", + "wasip2", + "wasm-bindgen", +] + +[[package]] +name = "heck" +version = "0.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2304e00983f87ffb38b55b444b5e3b60a884b5d30c0fca7d82fe33449bbe55ea" + +[[package]] +name = "hex" +version = "0.4.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7f24254aa9a54b5c858eaee2f5bccdb46aaf0e486a595ed5fd8f86ba55232a70" + +[[package]] +name = "hkdf" +version = "0.12.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7b5f8eb2ad728638ea2c7d47a21db23b7b58a72ed6a38256b8a1849f15fbbdf7" +dependencies = [ + "hmac", +] + +[[package]] +name = "hmac" +version = "0.12.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6c49c37c09c17a53d937dfbb742eb3a961d65a994e6bcdcf37e7399d0cc8ab5e" +dependencies = [ + "digest", +] + +[[package]] +name = "http" +version = "1.4.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8be7462df143984c4598a256ef469b251d7d7f9e271135073e78fc535414f3d0" +dependencies = [ + "bytes", + "itoa", +] + +[[package]] +name = "http-body" +version = "1.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1efedce1fb8e6913f23e0c92de8e62cd5b772a67e7b3946df930a62566c93184" +dependencies = [ + "bytes", + "http", +] + +[[package]] +name = "http-body-util" +version = "0.1.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b021d93e26becf5dc7e1b75b1bed1fd93124b374ceb73f43d4d4eafec896a64a" +dependencies = [ + "bytes", + "futures-core", + "http", + "http-body", + "pin-project-lite", +] + +[[package]] +name = "httparse" +version = "1.10.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6dbf3de79e51f3d586ab4cb9d5c3e2c14aa28ed23d180cf89b4df0454a69cc87" + +[[package]] +name = "hyper" +version = "1.10.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "55281c53a1894c864990125767da440a4e630446785086f52523b20033b74498" +dependencies = [ + "atomic-waker", + "bytes", + "futures-channel", + "futures-core", + "http", + "http-body", + "httparse", + "itoa", + "pin-project-lite", + "smallvec", + "tokio", + "want", +] + +[[package]] +name = "hyper-rustls" +version = "0.27.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "33ca68d021ef39cf6463ab54c1d0f5daf03377b70561305bb89a8f83aab66e0f" +dependencies = [ + "http", + "hyper", + "hyper-util", + "rustls", + "tokio", + "tokio-rustls", + "tower-service", + "webpki-roots 1.0.7", +] + +[[package]] +name = "hyper-util" +version = "0.1.20" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "96547c2556ec9d12fb1578c4eaf448b04993e7fb79cbaad930a656880a6bdfa0" +dependencies = [ + "base64", + "bytes", + "futures-channel", + "futures-util", + "http", + "http-body", + "hyper", + "ipnet", + "libc", + "percent-encoding", + "pin-project-lite", + "socket2", + "tokio", + "tower-service", + "tracing", +] + +[[package]] +name = "icu_collections" +version = "2.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2984d1cd16c883d7935b9e07e44071dca8d917fd52ecc02c04d5fa0b5a3f191c" +dependencies = [ + "displaydoc", + "potential_utf", + "utf8_iter", + "yoke", + "zerofrom", + "zerovec", +] + +[[package]] +name = "icu_locale_core" +version = "2.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "92219b62b3e2b4d88ac5119f8904c10f8f61bf7e95b640d25ba3075e6cac2c29" +dependencies = [ + "displaydoc", + "litemap", + "tinystr", + "writeable", + "zerovec", +] + +[[package]] +name = "icu_normalizer" +version = "2.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c56e5ee99d6e3d33bd91c5d85458b6005a22140021cc324cea84dd0e72cff3b4" +dependencies = [ + "icu_collections", + "icu_normalizer_data", + "icu_properties", + "icu_provider", + "smallvec", + "zerovec", +] + +[[package]] +name = "icu_normalizer_data" +version = "2.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "da3be0ae77ea334f4da67c12f149704f19f81d1adf7c51cf482943e84a2bad38" + +[[package]] +name = "icu_properties" +version = "2.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bee3b67d0ea5c2cca5003417989af8996f8604e34fb9ddf96208a033901e70de" +dependencies = [ + "icu_collections", + "icu_locale_core", + "icu_properties_data", + "icu_provider", + "zerotrie", + "zerovec", +] + +[[package]] +name = "icu_properties_data" +version = "2.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8e2bbb201e0c04f7b4b3e14382af113e17ba4f63e2c9d2ee626b720cbce54a14" + +[[package]] +name = "icu_provider" +version = "2.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "139c4cf31c8b5f33d7e199446eff9c1e02decfc2f0eec2c8d71f65befa45b421" +dependencies = [ + "displaydoc", + "icu_locale_core", + "writeable", + "yoke", + "zerofrom", + "zerotrie", + "zerovec", +] + +[[package]] +name = "idna" +version = "1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3b0875f23caa03898994f6ddc501886a45c7d3d62d04d2d90788d47be1b1e4de" +dependencies = [ + "idna_adapter", + "smallvec", + "utf8_iter", +] + +[[package]] +name = "idna_adapter" +version = "1.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cb68373c0d6620ef8105e855e7745e18b0d00d3bdb07fb532e434244cdb9a714" +dependencies = [ + "icu_normalizer", + "icu_properties", +] + +[[package]] +name = "ipnet" +version = "2.12.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d98f6fed1fde3f8c21bc40a1abb88dd75e67924f9cffc3ef95607bad8017f8e2" + +[[package]] +name = "is_terminal_polyfill" +version = "1.70.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a6cb138bb79a146c1bd460005623e142ef0181e3d0219cb493e02f7d08a35695" + +[[package]] +name = "itoa" +version = "1.0.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8f42a60cbdf9a97f5d2305f08a87dc4e09308d1276d28c869c684d7777685682" + +[[package]] +name = "jobserver" +version = "0.1.34" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9afb3de4395d6b3e67a780b6de64b51c978ecf11cb9a462c66be7d4ca9039d33" +dependencies = [ + "getrandom 0.3.4", + "libc", +] + +[[package]] +name = "js-sys" +version = "0.3.99" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "142bc4740e452c1e57ade0cbc129f139c9093e354346f0872ef985f4f5cf5f11" +dependencies = [ + "cfg-if", + "futures-util", + "once_cell", + "wasm-bindgen", +] + +[[package]] +name = "libc" +version = "0.2.186" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "68ab91017fe16c622486840e4c83c9a37afeff978bd239b5293d61ece587de66" + +[[package]] +name = "litemap" +version = "0.8.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "92daf443525c4cce67b150400bc2316076100ce0b3686209eb8cf3c31612e6f0" + +[[package]] +name = "log" +version = "0.4.30" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "616ec5685824bcc94416c6d4a7a446eea774a31efd7062c8480ba6fd06d7a6e5" + +[[package]] +name = "lru-slab" +version = "0.1.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "112b39cec0b298b6c1999fee3e31427f74f676e4cb9879ed1a121b43661a4154" + +[[package]] +name = "memchr" +version = "2.8.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6b947ae49db0d222b1dbc6b113ce7248a3fc3a6ca21b696717bfc000ba4484d8" + +[[package]] +name = "mio" +version = "1.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "02bd0af71c67b473010cbbc60715ee815645a4dc942899111f494b4b737d6fda" +dependencies = [ + "libc", + "wasi", + "windows-sys 0.61.2", +] + +[[package]] +name = "num-bigint" +version = "0.4.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a5e44f723f1133c9deac646763579fdb3ac745e418f2a7af9cd0c431da1f20b9" +dependencies = [ + "num-integer", + "num-traits", +] + +[[package]] +name = "num-integer" +version = "0.1.46" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7969661fd2958a5cb096e56c8e1ad0444ac2bbcd0061bd28660485a44879858f" +dependencies = [ + "num-traits", +] + +[[package]] +name = "num-traits" +version = "0.2.19" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "071dfc062690e90b734c0b2273ce72ad0ffa95f0c74596bc250dcfd960262841" +dependencies = [ + "autocfg", +] + +[[package]] +name = "once_cell" +version = "1.21.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9f7c3e4beb33f85d45ae3e3a1792185706c8e16d043238c593331cc7cd313b50" + +[[package]] +name = "once_cell_polyfill" +version = "1.70.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "384b8ab6d37215f3c5301a95a4accb5d64aa607f1fcb26a11b5303878451b4fe" + +[[package]] +name = "openssl" +version = "0.10.80" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a45fa2aa886c42762255da344f0a0d313e254066c46aad76f300c3d3da62d967" +dependencies = [ + "bitflags", + "cfg-if", + "foreign-types", + "libc", + "openssl-macros", + "openssl-sys", +] + +[[package]] +name = "openssl-macros" +version = "0.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a948666b637a0f465e8564c73e89d4dde00d72d4d473cc972f390fc3dcee7d9c" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "openssl-sys" +version = "0.9.116" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f28a22dc7140cda5f096e5e7724a6962ca81a7f8bfd2979f9b18c11af56318c4" +dependencies = [ + "cc", + "libc", + "pkg-config", + "vcpkg", +] + +[[package]] +name = "percent-encoding" +version = "2.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9b4f627cb1b25917193a259e49bdad08f671f8d9708acfd5fe0a8c1455d87220" + +[[package]] +name = "pin-project-lite" +version = "0.2.17" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a89322df9ebe1c1578d689c92318e070967d1042b512afbe49518723f4e6d5cd" + +[[package]] +name = "pkg-config" +version = "0.3.33" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "19f132c84eca552bf34cab8ec81f1c1dcc229b811638f9d283dceabe58c5569e" + +[[package]] +name = "potential_utf" +version = "0.1.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0103b1cef7ec0cf76490e969665504990193874ea05c85ff9bab8b911d0a0564" +dependencies = [ + "zerovec", +] + +[[package]] +name = "ppv-lite86" +version = "0.2.21" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "85eae3c4ed2f50dcfe72643da4befc30deadb458a9b590d720cde2f2b1e97da9" +dependencies = [ + "zerocopy", +] + +[[package]] +name = "proc-macro2" +version = "1.0.106" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8fd00f0bb2e90d81d1044c2b32617f68fcb9fa3bb7640c23e9c748e53fb30934" +dependencies = [ + "unicode-ident", +] + +[[package]] +name = "quinn" +version = "0.11.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b9e20a958963c291dc322d98411f541009df2ced7b5a4f2bd52337638cfccf20" +dependencies = [ + "bytes", + "cfg_aliases", + "pin-project-lite", + "quinn-proto", + "quinn-udp", + "rustc-hash", + "rustls", + "socket2", + "thiserror 2.0.18", + "tokio", + "tracing", + "web-time", +] + +[[package]] +name = "quinn-proto" +version = "0.11.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "434b42fec591c96ef50e21e886936e66d3cc3f737104fdb9b737c40ffb94c098" +dependencies = [ + "bytes", + "getrandom 0.3.4", + "lru-slab", + "rand 0.9.4", + "ring", + "rustc-hash", + "rustls", + "rustls-pki-types", + "slab", + "thiserror 2.0.18", + "tinyvec", + "tracing", + "web-time", +] + +[[package]] +name = "quinn-udp" +version = "0.5.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "addec6a0dcad8a8d96a771f815f0eaf55f9d1805756410b39f5fa81332574cbd" +dependencies = [ + "cfg_aliases", + "libc", + "once_cell", + "socket2", + "tracing", + "windows-sys 0.60.2", +] + +[[package]] +name = "quote" +version = "1.0.45" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "41f2619966050689382d2b44f664f4bc593e129785a36d6ee376ddf37259b924" +dependencies = [ + "proc-macro2", +] + +[[package]] +name = "r-efi" +version = "5.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "69cdb34c158ceb288df11e18b4bd39de994f6657d83847bdffdbd7f346754b0f" + +[[package]] +name = "rand" +version = "0.8.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5ca0ecfa931c29007047d1bc58e623ab12e5590e8c7cc53200d5202b69266d8a" +dependencies = [ + "libc", + "rand_chacha 0.3.1", + "rand_core 0.6.4", +] + +[[package]] +name = "rand" +version = "0.9.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "44c5af06bb1b7d3216d91932aed5265164bf384dc89cd6ba05cf59a35f5f76ea" +dependencies = [ + "rand_chacha 0.9.0", + "rand_core 0.9.5", +] + +[[package]] +name = "rand_chacha" +version = "0.3.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e6c10a63a0fa32252be49d21e7709d4d4baf8d231c2dbce1eaa8141b9b127d88" +dependencies = [ + "ppv-lite86", + "rand_core 0.6.4", +] + +[[package]] +name = "rand_chacha" +version = "0.9.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d3022b5f1df60f26e1ffddd6c66e8aa15de382ae63b3a0c1bfc0e4d3e3f325cb" +dependencies = [ + "ppv-lite86", + "rand_core 0.9.5", +] + +[[package]] +name = "rand_core" +version = "0.6.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ec0be4795e2f6a28069bec0b5ff3e2ac9bafc99e6a9a7dc3547996c5c816922c" +dependencies = [ + "getrandom 0.2.17", +] + +[[package]] +name = "rand_core" +version = "0.9.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "76afc826de14238e6e8c374ddcc1fa19e374fd8dd986b0d2af0d02377261d83c" +dependencies = [ + "getrandom 0.3.4", +] + +[[package]] +name = "reqwest" +version = "0.12.28" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "eddd3ca559203180a307f12d114c268abf583f59b03cb906fd0b3ff8646c1147" +dependencies = [ + "base64", + "bytes", + "futures-channel", + "futures-core", + "futures-util", + "http", + "http-body", + "http-body-util", + "hyper", + "hyper-rustls", + "hyper-util", + "js-sys", + "log", + "percent-encoding", + "pin-project-lite", + "quinn", + "rustls", + "rustls-pki-types", + "serde", + "serde_json", + "serde_urlencoded", + "sync_wrapper", + "tokio", + "tokio-rustls", + "tower", + "tower-http", + "tower-service", + "url", + "wasm-bindgen", + "wasm-bindgen-futures", + "web-sys", + "webpki-roots 1.0.7", +] + +[[package]] +name = "ring" +version = "0.17.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a4689e6c2294d81e88dc6261c768b63bc4fcdb852be6d1352498b114f61383b7" +dependencies = [ + "cc", + "cfg-if", + "getrandom 0.2.17", + "libc", + "untrusted", + "windows-sys 0.52.0", +] + +[[package]] +name = "rustc-hash" +version = "2.1.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "94300abf3f1ae2e2b8ffb7b58043de3d399c73fa6f4b73826402a5c457614dbe" + +[[package]] +name = "rustls" +version = "0.23.40" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ef86cd5876211988985292b91c96a8f2d298df24e75989a43a3c73f2d4d8168b" +dependencies = [ + "aws-lc-rs", + "log", + "once_cell", + "ring", + "rustls-pki-types", + "rustls-webpki", + "subtle", + "zeroize", +] + +[[package]] +name = "rustls-pki-types" +version = "1.14.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "30a7197ae7eb376e574fe940d068c30fe0462554a3ddbe4eca7838e049c937a9" +dependencies = [ + "web-time", + "zeroize", +] + +[[package]] +name = "rustls-webpki" +version = "0.103.13" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "61c429a8649f110dddef65e2a5ad240f747e85f7758a6bccc7e5777bd33f756e" +dependencies = [ + "aws-lc-rs", + "ring", + "rustls-pki-types", + "untrusted", +] + +[[package]] +name = "rustversion" +version = "1.0.22" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b39cdef0fa800fc44525c84ccb54a029961a8215f9619753635a9c0d2538d46d" + +[[package]] +name = "ryu" +version = "1.0.23" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9774ba4a74de5f7b1c1451ed6cd5285a32eddb5cccb8cc655a4e50009e06477f" + +[[package]] +name = "serde" +version = "1.0.228" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9a8e94ea7f378bd32cbbd37198a4a91436180c5bb472411e48b5ec2e2124ae9e" +dependencies = [ + "serde_core", + "serde_derive", +] + +[[package]] +name = "serde_core" +version = "1.0.228" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "41d385c7d4ca58e59fc732af25c3983b67ac852c1a25000afe1175de458b67ad" +dependencies = [ + "serde_derive", +] + +[[package]] +name = "serde_derive" +version = "1.0.228" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d540f220d3187173da220f885ab66608367b6574e925011a9353e4badda91d79" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "serde_json" +version = "1.0.150" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e8014e44b4736ed0538adeecded0fce2a272f22dc9578a7eb6b2d9993c74cfb9" +dependencies = [ + "itoa", + "memchr", + "serde", + "serde_core", + "zmij", +] + +[[package]] +name = "serde_urlencoded" +version = "0.7.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d3491c14715ca2294c4d6a88f15e84739788c1d030eed8c110436aafdaa2f3fd" +dependencies = [ + "form_urlencoded", + "itoa", + "ryu", + "serde", +] + +[[package]] +name = "sha1" +version = "0.10.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e3bf829a2d51ab4a5ddf1352d8470c140cadc8301b2ae1789db023f01cedd6ba" +dependencies = [ + "cfg-if", + "cpufeatures", + "digest", +] + +[[package]] +name = "sha2" +version = "0.10.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a7507d819769d01a365ab707794a4084392c824f54a7a6a7862f8c3d0892b283" +dependencies = [ + "cfg-if", + "cpufeatures", + "digest", +] + +[[package]] +name = "shlex" +version = "2.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f8fadd59c855ef2080decdef8ff161eb6661b86933c9d82e5ba29dc602a55aba" + +[[package]] +name = "slab" +version = "0.4.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0c790de23124f9ab44544d7ac05d60440adc586479ce501c1d6d7da3cd8c9cf5" + +[[package]] +name = "smallvec" +version = "1.15.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "67b1b7a3b5fe4f1376887184045fcf45c69e92af734b7aaddc05fb777b6fbd03" + +[[package]] +name = "socket2" +version = "0.6.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "52d1cfed4120b4d927bf7c0f86d2087a4a7d6027c906d9f9d525a80573b9be51" +dependencies = [ + "libc", + "windows-sys 0.61.2", +] + +[[package]] +name = "stable_deref_trait" +version = "1.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6ce2be8dc25455e1f91df71bfa12ad37d7af1092ae736f3a6cd0e37bc7810596" + +[[package]] +name = "strsim" +version = "0.11.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7da8b5736845d9f2fcb837ea5d9e2628564b3b043a70948a3f0b778838c5fb4f" + +[[package]] +name = "subtle" +version = "2.6.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "13c2bddecc57b384dee18652358fb23172facb8a2c51ccc10d74c157bdea3292" + +[[package]] +name = "syn" +version = "2.0.117" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e665b8803e7b1d2a727f4023456bbbbe74da67099c585258af0ad9c5013b9b99" +dependencies = [ + "proc-macro2", + "quote", + "unicode-ident", +] + +[[package]] +name = "sync_wrapper" +version = "1.0.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0bf256ce5efdfa370213c1dabab5935a12e49f2c58d15e9eac2870d3b4f27263" +dependencies = [ + "futures-core", +] + +[[package]] +name = "synstructure" +version = "0.13.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "728a70f3dbaf5bab7f0c4b1ac8d7ae5ea60a4b5549c8a5914361c99147a709d2" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "thiserror" +version = "1.0.69" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b6aaf5339b578ea85b50e080feb250a3e8ae8cfcdff9a461c9ec2904bc923f52" +dependencies = [ + "thiserror-impl 1.0.69", +] + +[[package]] +name = "thiserror" +version = "2.0.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4288b5bcbc7920c07a1149a35cf9590a2aa808e0bc1eafaade0b80947865fbc4" +dependencies = [ + "thiserror-impl 2.0.18", +] + +[[package]] +name = "thiserror-impl" +version = "1.0.69" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4fee6c4efc90059e10f81e6d42c60a18f76588c3d74cb83a0b242a2b6c7504c1" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "thiserror-impl" +version = "2.0.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ebc4ee7f67670e9b64d05fa4253e753e016c6c95ff35b89b7941d6b856dec1d5" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "tinystr" +version = "0.8.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c8323304221c2a851516f22236c5722a72eaa19749016521d6dff0824447d96d" +dependencies = [ + "displaydoc", + "zerovec", +] + +[[package]] +name = "tinyvec" +version = "1.11.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3e61e67053d25a4e82c844e8424039d9745781b3fc4f32b8d55ed50f5f667ef3" +dependencies = [ + "tinyvec_macros", +] + +[[package]] +name = "tinyvec_macros" +version = "0.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1f3ccbac311fea05f86f61904b462b55fb3df8837a366dfc601a0161d0532f20" + +[[package]] +name = "tokio" +version = "1.52.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8fc7f01b389ac15039e4dc9531aa973a135d7a4135281b12d7c1bc79fd57fffe" +dependencies = [ + "bytes", + "libc", + "mio", + "pin-project-lite", + "socket2", + "windows-sys 0.61.2", +] + +[[package]] +name = "tokio-rustls" +version = "0.26.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1729aa945f29d91ba541258c8df89027d5792d85a8841fb65e8bf0f4ede4ef61" +dependencies = [ + "rustls", + "tokio", +] + +[[package]] +name = "tower" +version = "0.5.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ebe5ef63511595f1344e2d5cfa636d973292adc0eec1f0ad45fae9f0851ab1d4" +dependencies = [ + "futures-core", + "futures-util", + "pin-project-lite", + "sync_wrapper", + "tokio", + "tower-layer", + "tower-service", +] + +[[package]] +name = "tower-http" +version = "0.6.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4cfcf7e2740e6fc6d4d688b4ef00650406bb94adf4731e43c096c3a19fe40840" +dependencies = [ + "bitflags", + "bytes", + "futures-util", + "http", + "http-body", + "pin-project-lite", + "tower", + "tower-layer", + "tower-service", + "url", +] + +[[package]] +name = "tower-layer" +version = "0.3.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "121c2a6cda46980bb0fcd1647ffaf6cd3fc79a013de288782836f6df9c48780e" + +[[package]] +name = "tower-service" +version = "0.3.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8df9b6e13f2d32c91b9bd719c00d1958837bc7dec474d94952798cc8e69eeec3" + +[[package]] +name = "tracing" +version = "0.1.44" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "63e71662fa4b2a2c3a26f570f037eb95bb1f85397f3cd8076caed2f026a6d100" +dependencies = [ + "pin-project-lite", + "tracing-core", +] + +[[package]] +name = "tracing-core" +version = "0.1.36" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "db97caf9d906fbde555dd62fa95ddba9eecfd14cb388e4f491a66d74cd5fb79a" +dependencies = [ + "once_cell", +] + +[[package]] +name = "try-lock" +version = "0.2.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e421abadd41a4225275504ea4d6566923418b7f05506fbc9c0fe86ba7396114b" + +[[package]] +name = "tungstenite" +version = "0.24.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "18e5b8366ee7a95b16d32197d0b2604b43a0be89dc5fac9f8e96ccafbaedda8a" +dependencies = [ + "byteorder", + "bytes", + "data-encoding", + "http", + "httparse", + "log", + "rand 0.8.6", + "rustls", + "rustls-pki-types", + "sha1", + "thiserror 1.0.69", + "utf-8", + "webpki-roots 0.26.11", +] + +[[package]] +name = "typenum" +version = "1.20.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b6f5e870be6c3b371b77fe0ee0bafb859fa4964b4404c27de1d380043c4dda20" + +[[package]] +name = "unicode-ident" +version = "1.0.24" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e6e4313cd5fcd3dad5cafa179702e2b244f760991f45397d14d4ebf38247da75" + +[[package]] +name = "untrusted" +version = "0.9.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8ecb6da28b8a351d773b68d5825ac39017e680750f980f3a1a85cd8dd28a47c1" + +[[package]] +name = "url" +version = "2.5.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ff67a8a4397373c3ef660812acab3268222035010ab8680ec4215f38ba3d0eed" +dependencies = [ + "form_urlencoded", + "idna", + "percent-encoding", + "serde", +] + +[[package]] +name = "utf-8" +version = "0.7.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "09cc8ee72d2a9becf2f2febe0205bbed8fc6615b7cb429ad062dc7b7ddd036a9" + +[[package]] +name = "utf8_iter" +version = "1.0.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b6c140620e7ffbb22c2dee59cafe6084a59b5ffc27a8859a5f0d494b5d52b6be" + +[[package]] +name = "utf8parse" +version = "0.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "06abde3611657adf66d383f00b093d7faecc7fa57071cce2578660c9f1010821" + +[[package]] +name = "vcpkg" +version = "0.2.15" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "accd4ea62f7bb7a82fe23066fb0957d48ef677f6eeb8215f372f52e48bb32426" + +[[package]] +name = "version_check" +version = "0.9.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0b928f33d975fc6ad9f86c8f283853ad26bdd5b10b7f1542aa2fa15e2289105a" + +[[package]] +name = "want" +version = "0.3.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bfa7760aed19e106de2c7c0b581b509f2f25d3dacaf737cb82ac61bc6d760b0e" +dependencies = [ + "try-lock", +] + +[[package]] +name = "wasi" +version = "0.11.1+wasi-snapshot-preview1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ccf3ec651a847eb01de73ccad15eb7d99f80485de043efb2f370cd654f4ea44b" + +[[package]] +name = "wasip2" +version = "1.0.3+wasi-0.2.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "20064672db26d7cdc89c7798c48a0fdfac8213434a1186e5ef29fd560ae223d6" +dependencies = [ + "wit-bindgen", +] + +[[package]] +name = "wasm-bindgen" +version = "0.2.122" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3ed04576f974d2b2fba0f38c51dbc5518011e38c36bf1143164be765528fd409" +dependencies = [ + "cfg-if", + "once_cell", + "rustversion", + "wasm-bindgen-macro", + "wasm-bindgen-shared", +] + +[[package]] +name = "wasm-bindgen-futures" +version = "0.4.72" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9473dbd2991ae90b6291c3c32c30c6187ac49aa32f9905d1cce280ec1e110b0f" +dependencies = [ + "js-sys", + "wasm-bindgen", +] + +[[package]] +name = "wasm-bindgen-macro" +version = "0.2.122" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "916151b09da36bd82f6615cbf3a419e2f0ba23a03c6160e8e92eb6bd4aa1dec6" +dependencies = [ + "quote", + "wasm-bindgen-macro-support", +] + +[[package]] +name = "wasm-bindgen-macro-support" +version = "0.2.122" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "299047362ccbfce148b67ab7e73349f77748e00c8296f9542adfad2ad82c5c5e" +dependencies = [ + "bumpalo", + "proc-macro2", + "quote", + "syn", + "wasm-bindgen-shared", +] + +[[package]] +name = "wasm-bindgen-shared" +version = "0.2.122" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9a929b2c61f11ba3e9bc35b50c1f25cb38e0e892c0c231ae2b8cf78d5dad4437" +dependencies = [ + "unicode-ident", +] + +[[package]] +name = "web-sys" +version = "0.3.99" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6d621441cfc37b84979402712047321980c178f299193a3589d05b99e8763436" +dependencies = [ + "js-sys", + "wasm-bindgen", +] + +[[package]] +name = "web-time" +version = "1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5a6580f308b1fad9207618087a65c04e7a10bc77e02c8e84e9b00dd4b12fa0bb" +dependencies = [ + "js-sys", + "wasm-bindgen", +] + +[[package]] +name = "webpki-roots" +version = "0.26.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "521bc38abb08001b01866da9f51eb7c5d647a19260e00054a8c7fd5f9e57f7a9" +dependencies = [ + "webpki-roots 1.0.7", +] + +[[package]] +name = "webpki-roots" +version = "1.0.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "52f5ee44c96cf55f1b349600768e3ece3a8f26010c05265ab73f945bb1a2eb9d" +dependencies = [ + "rustls-pki-types", +] + +[[package]] +name = "windows-link" +version = "0.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f0805222e57f7521d6a62e36fa9163bc891acd422f971defe97d64e70d0a4fe5" + +[[package]] +name = "windows-sys" +version = "0.52.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "282be5f36a8ce781fad8c8ae18fa3f9beff57ec1b52cb3de0789201425d9a33d" +dependencies = [ + "windows-targets 0.52.6", +] + +[[package]] +name = "windows-sys" +version = "0.60.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f2f500e4d28234f72040990ec9d39e3a6b950f9f22d3dba18416c35882612bcb" +dependencies = [ + "windows-targets 0.53.5", +] + +[[package]] +name = "windows-sys" +version = "0.61.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ae137229bcbd6cdf0f7b80a31df61766145077ddf49416a728b02cb3921ff3fc" +dependencies = [ + "windows-link", +] + +[[package]] +name = "windows-targets" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9b724f72796e036ab90c1021d4780d4d3d648aca59e491e6b98e725b84e99973" +dependencies = [ + "windows_aarch64_gnullvm 0.52.6", + "windows_aarch64_msvc 0.52.6", + "windows_i686_gnu 0.52.6", + "windows_i686_gnullvm 0.52.6", + "windows_i686_msvc 0.52.6", + "windows_x86_64_gnu 0.52.6", + "windows_x86_64_gnullvm 0.52.6", + "windows_x86_64_msvc 0.52.6", +] + +[[package]] +name = "windows-targets" +version = "0.53.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4945f9f551b88e0d65f3db0bc25c33b8acea4d9e41163edf90dcd0b19f9069f3" +dependencies = [ + "windows-link", + "windows_aarch64_gnullvm 0.53.1", + "windows_aarch64_msvc 0.53.1", + "windows_i686_gnu 0.53.1", + "windows_i686_gnullvm 0.53.1", + "windows_i686_msvc 0.53.1", + "windows_x86_64_gnu 0.53.1", + "windows_x86_64_gnullvm 0.53.1", + "windows_x86_64_msvc 0.53.1", +] + +[[package]] +name = "windows_aarch64_gnullvm" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "32a4622180e7a0ec044bb555404c800bc9fd9ec262ec147edd5989ccd0c02cd3" + +[[package]] +name = "windows_aarch64_gnullvm" +version = "0.53.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a9d8416fa8b42f5c947f8482c43e7d89e73a173cead56d044f6a56104a6d1b53" + +[[package]] +name = "windows_aarch64_msvc" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "09ec2a7bb152e2252b53fa7803150007879548bc709c039df7627cabbd05d469" + +[[package]] +name = "windows_aarch64_msvc" +version = "0.53.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b9d782e804c2f632e395708e99a94275910eb9100b2114651e04744e9b125006" + +[[package]] +name = "windows_i686_gnu" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8e9b5ad5ab802e97eb8e295ac6720e509ee4c243f69d781394014ebfe8bbfa0b" + +[[package]] +name = "windows_i686_gnu" +version = "0.53.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "960e6da069d81e09becb0ca57a65220ddff016ff2d6af6a223cf372a506593a3" + +[[package]] +name = "windows_i686_gnullvm" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0eee52d38c090b3caa76c563b86c3a4bd71ef1a819287c19d586d7334ae8ed66" + +[[package]] +name = "windows_i686_gnullvm" +version = "0.53.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fa7359d10048f68ab8b09fa71c3daccfb0e9b559aed648a8f95469c27057180c" + +[[package]] +name = "windows_i686_msvc" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "240948bc05c5e7c6dabba28bf89d89ffce3e303022809e73deaefe4f6ec56c66" + +[[package]] +name = "windows_i686_msvc" +version = "0.53.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1e7ac75179f18232fe9c285163565a57ef8d3c89254a30685b57d83a38d326c2" + +[[package]] +name = "windows_x86_64_gnu" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "147a5c80aabfbf0c7d901cb5895d1de30ef2907eb21fbbab29ca94c5b08b1a78" + +[[package]] +name = "windows_x86_64_gnu" +version = "0.53.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9c3842cdd74a865a8066ab39c8a7a473c0778a3f29370b5fd6b4b9aa7df4a499" + +[[package]] +name = "windows_x86_64_gnullvm" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "24d5b23dc417412679681396f2b49f3de8c1473deb516bd34410872eff51ed0d" + +[[package]] +name = "windows_x86_64_gnullvm" +version = "0.53.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0ffa179e2d07eee8ad8f57493436566c7cc30ac536a3379fdf008f47f6bb7ae1" + +[[package]] +name = "windows_x86_64_msvc" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "589f6da84c646204747d1270a2a5661ea66ed1cced2631d546fdfb155959f9ec" + +[[package]] +name = "windows_x86_64_msvc" +version = "0.53.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d6bbff5f0aada427a1e5a6da5f1f98158182f26556f345ac9e04d36d0ebed650" + +[[package]] +name = "wit-bindgen" +version = "0.57.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1ebf944e87a7c253233ad6766e082e3cd714b5d03812acc24c318f549614536e" + +[[package]] +name = "writeable" +version = "0.6.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1ffae5123b2d3fc086436f8834ae3ab053a283cfac8fe0a0b8eaae044768a4c4" + +[[package]] +name = "yoke" +version = "0.8.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "abe8c5fda708d9ca3df187cae8bfb9ceda00dd96231bed36e445a1a48e66f9ca" +dependencies = [ + "stable_deref_trait", + "yoke-derive", + "zerofrom", +] + +[[package]] +name = "yoke-derive" +version = "0.8.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "de844c262c8848816172cef550288e7dc6c7b7814b4ee56b3e1553f275f1858e" +dependencies = [ + "proc-macro2", + "quote", + "syn", + "synstructure", +] + +[[package]] +name = "zerocopy" +version = "0.8.50" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3b065d4f0e55f82fae73202e189638116a87c55ab6b8e6c2721e13dd9d854ad1" +dependencies = [ + "zerocopy-derive", +] + +[[package]] +name = "zerocopy-derive" +version = "0.8.50" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0b631b19d36a892ab55420c92dbc83ccd79274f25be714855d3074aa71cab639" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "zerofrom" +version = "0.1.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0ec05a11813ea801ff6d75110ad09cd0824ddba17dfe17128ea0d5f68e6c5272" +dependencies = [ + "zerofrom-derive", +] + +[[package]] +name = "zerofrom-derive" +version = "0.1.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "11532158c46691caf0f2593ea8358fed6bbf68a0315e80aae9bd41fbade684a1" +dependencies = [ + "proc-macro2", + "quote", + "syn", + "synstructure", +] + +[[package]] +name = "zeroize" +version = "1.8.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b97154e67e32c85465826e8bcc1c59429aaaf107c1e4a9e53c8d8ccd5eff88d0" +dependencies = [ + "zeroize_derive", +] + +[[package]] +name = "zeroize_derive" +version = "1.4.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "85a5b4158499876c763cb03bc4e49185d3cccbabb15b33c627f7884f43db852e" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "zerotrie" +version = "0.2.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0f9152d31db0792fa83f70fb2f83148effb5c1f5b8c7686c3459e361d9bc20bf" +dependencies = [ + "displaydoc", + "yoke", + "zerofrom", +] + +[[package]] +name = "zerovec" +version = "0.11.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "90f911cbc359ab6af17377d242225f4d75119aec87ea711a880987b18cd7b239" +dependencies = [ + "yoke", + "zerofrom", + "zerovec-derive", +] + +[[package]] +name = "zerovec-derive" +version = "0.11.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "625dc425cab0dca6dc3c3319506e6593dcb08a9f387ea3b284dbd52a92c40555" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "zmij" +version = "1.0.21" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b8848ee67ecc8aedbaf3e4122217aff892639231befc6a1b58d29fff4c2cabaa" diff --git a/coven/Cargo.toml b/coven/Cargo.toml new file mode 100644 index 0000000..e29d6ed --- /dev/null +++ b/coven/Cargo.toml @@ -0,0 +1,35 @@ +[package] +name = "coven" +version = "0.1.0" +edition = "2021" +description = "coven — encrypted collaborative covens with a summoned sandbox familiar. ⛧" +license = "MIT" + +[[bin]] +name = "coven" +path = "src/main.rs" + +[dependencies] +# crypto +num-bigint = "0.4" +num-traits = "0.2" +sha2 = "0.10" +hkdf = "0.12" +fernet = "0.2" +base64 = "0.22" +rand = "0.8" +hex = "0.4" + +# net +reqwest = { version = "0.12", default-features = false, features = ["blocking", "json", "rustls-tls"] } +tungstenite = { version = "0.24", features = ["rustls-tls-webpki-roots"] } +rustls = "0.23" +url = "2" + +# data +serde = { version = "1", features = ["derive"] } +serde_json = "1" + +# cli / errors +clap = { version = "4", features = ["derive"] } +anyhow = "1" diff --git a/coven/README.md b/coven/README.md new file mode 100644 index 0000000..3aba677 --- /dev/null +++ b/coven/README.md @@ -0,0 +1,57 @@ +
+ +# ⛧ coven ⛧ + +### encrypted collaborative covens with a summoned sandbox familiar + +`zero-knowledge server · end-to-end fernet · srp · ratatui` + +*they want you dependent. we want you free.* + +
+ +--- + +**coven** is the evolution of `cmd-chat`: a multi-user, end-to-end-encrypted +terminal session where a small circle (a *coven*) shares chat, files, and — when +summoned — a disposable sandboxed Linux **familiar** they drive together, with +real Linux permissions and a high priest who can delegate the keys. + +The server never sees plaintext. Everything — messages, files, terminal output — +is relayed as opaque ciphertext. Close the window, the coven dissolves. + +## status + +This is the Rust client (`ratatui`) for the unchanged Python (Sanic) server. The +wire protocol is JSON-over-WebSocket; SRP + HKDF→Fernet are byte-for-byte +compatible with the Python `srp` / `cryptography` stack. + +| phase | feature | state | +|---|---|---| +| **P0** | Rust↔Python SRP / Fernet crypto parity | ✅ proven (golden vectors + live + cross-lang E2E) | +| **P2** | multi-user coven (cap 4, infra for more) + authoritative roster | ✅ server-side done | +| **P1** | ratatui coven UI (chat, roster, themes) | 🚧 in progress | +| **P3** | sandbox familiar (multipass/docker) + shared PTY | ⏳ designed (see `../docs/spec-collaborative-sandbox.md`) | +| **P4** | permissions (app RBAC + VM unix users / sudo) | ⏳ designed | +| **P5** | file + directory offerings into the shared coven | ⏳ designed | + +## crypto parity — the load-bearing proof + +``` +$ coven selftest # offline: Rust SRP ≡ Python srp golden vectors +$ coven handshake --password --no-tls + ⛧ /srp/verify ok — server identity proven (H_AMK ✓) + ⛧ round-trip ✓ decrypted: "the coven is summoned ⛧" +``` + +`tools/gen_vectors.py` regenerates the golden vectors from the live Python +library (must match the server's `_ctsrp` backend with `rfc5054_enable()`). + +> **note:** the SRP identity is always the fixed room identity `b"chat"`; the +> display name is carried only in JSON, never in the SRP proof. The Python `srp` +> package's `rfc5054_enable()` toggles the *active backend's* flag — vectors must +> be generated with the same backend the server actually loads (`_ctsrp`). + +## license + +MIT · *malware bless · hack the planet* diff --git a/coven/src/crypto.rs b/coven/src/crypto.rs new file mode 100644 index 0000000..56b6aca --- /dev/null +++ b/coven/src/crypto.rs @@ -0,0 +1,237 @@ +//! SRP-6a + room-key crypto, byte-for-byte compatible with the Python +//! `srp` library (NG_2048, SHA-256, `rfc5054_enable()`) and `cryptography` +//! HKDF→Fernet as used by the Sanic server / reference client. +//! +//! Reference (pysrp `_pysrp.py`): +//! x = SHA256( salt || SHA256(I || ":" || P) ) +//! k = SHA256( PAD(N) || PAD(g) ) (rfc5054: PAD to len(N)) +//! A = g^a mod N +//! u = SHA256( PAD(A) || PAD(B) ) +//! S = (B - k*g^x)^(a + u*x) mod N +//! K = SHA256( S ) +//! M = SHA256( (H(N) xor H(PAD(g))) || SHA256(I) || salt || A || B || K ) +//! HAMK= SHA256( A || M || K ) +//! Note: A and B inside M / HAMK use *minimal* big-endian bytes (no padding); +//! only k and u pad to len(N) (= 256 bytes for NG_2048). + +use num_bigint::BigUint; +use num_traits::Zero; +use sha2::{Digest, Sha256}; + +/// RFC 5054 / pysrp NG_2048 safe prime. +const N_HEX: &str = "\ +AC6BDB41324A9A9BF166DE5E1389582FAF72B6651987EE07FC3192943DB56050A37329CBB4\ +A099ED8193E0757767A13DD52312AB4B03310DCD7F48A9DA04FD50E8083969EDB767B0CF60\ +95179A163AB3661A05FBD5FAAAE82918A9962F0B93B855F97993EC975EEAA80D740ADBF4FF\ +747359D041D5C33EA71D281E446B14773BCA97B43A23FB801676BD207A436C6481F1D2B907\ +8717461A5B9D32E688F87748544523B524B0D57D5EA77A2775D2ECFA032CFBDBF52FB37861\ +60279004E57AE6AF874E7303CE53299CCC041C7BC308D82A5698F3A8D0C38271AE35F8E9DB\ +FBB694B5C803D89F7AE435DE236D525F54759B65E372FCD68EF20FA7111F9E4AFF73"; + +/// The SRP identity used by every cmd-chat / coven room (server hardcodes this). +/// The user's chosen display name is independent of this value. +pub const SRP_IDENTITY: &[u8] = b"chat"; + +fn n() -> BigUint { + BigUint::parse_bytes(N_HEX.as_bytes(), 16).expect("valid N") +} +fn g() -> BigUint { + BigUint::from(2u32) +} + +fn sha256(parts: &[&[u8]]) -> Vec { + let mut h = Sha256::new(); + for p in parts { + h.update(p); + } + h.finalize().to_vec() +} + +/// Left-pad `b` with zero bytes to exactly `width` bytes. +fn pad(b: &[u8], width: usize) -> Vec { + if b.len() >= width { + return b.to_vec(); + } + let mut out = vec![0u8; width - b.len()]; + out.extend_from_slice(b); + out +} + +fn bytes_to_long(b: &[u8]) -> BigUint { + BigUint::from_bytes_be(b) +} + +/// pysrp `long_to_bytes`: minimal big-endian, empty for zero. +fn long_to_bytes(x: &BigUint) -> Vec { + if x.is_zero() { + return Vec::new(); + } + x.to_bytes_be() +} + +/// Multiplier k = SHA256(PAD(N) || PAD(g)), padded to len(N). +fn compute_k(n: &BigUint) -> BigUint { + let width = long_to_bytes(n).len(); + let nb = pad(&long_to_bytes(n), width); + let gb = pad(&long_to_bytes(&g()), width); + bytes_to_long(&sha256(&[&nb, &gb])) +} + +/// x = SHA256(salt || SHA256(I || ":" || P)). +fn gen_x(salt: &[u8], username: &[u8], password: &[u8]) -> BigUint { + let inner = sha256(&[username, b":", password]); + bytes_to_long(&sha256(&[salt, &inner])) +} + +/// (H(N) xor H(PAD(g))) used inside M. +fn hn_xor_g(n: &BigUint) -> Vec { + let width = long_to_bytes(n).len(); + let h_n = sha256(&[&long_to_bytes(n)]); + let h_g = sha256(&[&pad(&long_to_bytes(&g()), width)]); + h_n.iter().zip(h_g.iter()).map(|(a, b)| a ^ b).collect() +} + +/// Client-side SRP-6a state. +pub struct SrpClient { + username: Vec, + password: Vec, + n: BigUint, + k: BigUint, + a: BigUint, + pub a_pub: BigUint, // A +} + +impl SrpClient { + /// New client with a random 256-byte ephemeral `a` (high bit set, per pysrp). + pub fn new(username: &[u8], password: &[u8]) -> Self { + let mut buf = [0u8; 256]; + rand::RngCore::fill_bytes(&mut rand::thread_rng(), &mut buf); + buf[0] |= 0x80; + Self::with_a(username, password, &buf) + } + + /// Deterministic constructor for test vectors. + pub fn with_a(username: &[u8], password: &[u8], a_bytes: &[u8]) -> Self { + let n = n(); + let k = compute_k(&n); + let a = bytes_to_long(a_bytes); + let a_pub = g().modpow(&a, &n); + Self { + username: username.to_vec(), + password: password.to_vec(), + n, + k, + a, + a_pub, + } + } + + /// Wire bytes for A (minimal big-endian). + pub fn a_bytes(&self) -> Vec { + long_to_bytes(&self.a_pub) + } + + /// Process the server challenge (salt, B). Returns (M, K, H_AMK_expected). + /// `M` is sent to the server; `h_amk` is compared to the server's reply. + pub fn process_challenge( + &self, + salt: &[u8], + b_bytes: &[u8], + ) -> anyhow::Result { + let n = &self.n; + let width = long_to_bytes(n).len(); + let big_b = bytes_to_long(b_bytes); + if (&big_b % n).is_zero() { + anyhow::bail!("SRP safety check failed: B mod N == 0"); + } + + let a_min = long_to_bytes(&self.a_pub); + let b_min = long_to_bytes(&big_b); + let u = bytes_to_long(&sha256(&[&pad(&a_min, width), &pad(&b_min, width)])); + if u.is_zero() { + anyhow::bail!("SRP safety check failed: u == 0"); + } + + let x = gen_x(salt, &self.username, &self.password); + let v = g().modpow(&x, n); + + // base = (B - k*v) mod N, kept non-negative. + let kv = (&self.k * &v) % n; + let base = ((&big_b % n) + n - kv) % n; + let exp = &self.a + &u * &x; + let s = base.modpow(&exp, n); + + let k_key = sha256(&[&long_to_bytes(&s)]); + + let m = sha256(&[ + &hn_xor_g(n), + &sha256(&[&self.username]), + salt, + &a_min, + &b_min, + &k_key, + ]); + let h_amk = sha256(&[&a_min, &m, &k_key]); + + Ok(Challenge { + m, + session_key: k_key, + h_amk, + }) + } +} + +pub struct Challenge { + pub m: Vec, + pub session_key: Vec, + pub h_amk: Vec, +} + +// ── Room key: HKDF-SHA256(password, salt=room_salt, info) → Fernet ────────── + +/// Derive the shared room Fernet key exactly as the reference client: +/// `Fernet(urlsafe_b64( HKDF(SHA256, 32, room_salt, "cmd-chat-room-key")(pw) ))`. +pub fn room_fernet(password: &[u8], room_salt: &[u8]) -> anyhow::Result { + use base64::Engine; + let hk = hkdf::Hkdf::::new(Some(room_salt), password); + let mut okm = [0u8; 32]; + hk.expand(b"cmd-chat-room-key", &mut okm) + .map_err(|_| anyhow::anyhow!("hkdf expand failed"))?; + let key_b64 = base64::engine::general_purpose::URL_SAFE.encode(okm); + fernet::Fernet::new(&key_b64).ok_or_else(|| anyhow::anyhow!("invalid fernet key")) +} + +#[cfg(test)] +mod tests { + use super::*; + + // Golden vectors generated from the live Python `srp` (_ctsrp backend), + // rfc5054 enabled, NG_2048, SHA-256. See tools/gen_vectors.py. + const PW: &[u8] = b"labtest"; + const USER: &[u8] = b"chat"; + const SALT_HEX: &str = "0a1b2c3d"; + const A_HEX: &str = "8613d4e3da583215e770e4de20622d664374d237a96aabdebe1e38ae34b2d0bc45da3251d9f76337f918bbfa49a52aaf4a6d5f141aadc82f73f7559a3c0859c733d4cb258e9fdd797a3c1be8f71a0f5db0a9d15e19b5af82c408513d512c1824c3f61f3099b93bc9cf8c8bcdbd8f87ec6a347bb81bf5027a30b9ce6eb6beb110efc734164f65d4fc08ff7da2ef19732f559c07197c5a166b52c27a9806f9776b6b88c79739f6a1e024b2d3856f4fc7e69b39548f02a599e178fcb9b6a574a13964ab0331a40b839810e27d5a9bd71f9bacdf1ed26bdc4baaaa0088ecfa1d2daae7f47b6d67e5480d57e97770bbb623177f92080b0e963097fa72ef9f6ded07f0"; + const B_HEX: &str = "047426a55963c70bc385c6a51f6e9dc0bfe5e16b0d1fee4f566fb54b60fa77144f15ed1ee6ade007bd92f2b90846e1ee083ab4290239420606f48a1d861f759543d7856cbce21fd7fec98c9961a66610b412fea2efc5be78f35b18fd48176ac80c3a1cbefacac81e25e7da8079fac4012d01c47d85b783c2ea7340819bfe73d29cd0953d47c8fade77caa5459fb77d88fb918c073a77c495fa884859142a270cb0b1668de06131b150df4dbc931953a381710b7fdb98a953d6f77a4bba847c4c62c15cca8e514dc13f531427966a553c461aa4ab0caec9665612861fef03d48676e5f6551fc8ca4317f3118e0294c949bd2f5821e5900e7f695225dafa0ba2d2"; + const M_HEX: &str = "6e733ba88eb86c52e3be89207d2815a65b4dea8116f668af5de1b66ce1f047dd"; + const HAMK_HEX: &str = "649a7d46bb9210483e0489b7f9e6fb300a6cddd6381b018fa81770076169a837"; + const K_HEX: &str = "a12218af3fda651aa3c094a4db474a5eee919496c3ae8d38a4f6be1104ed4928"; + + fn a_bytes() -> Vec { + let mut v = vec![0x80u8]; + v.extend(std::iter::repeat(0x22u8).take(31)); + v + } + + #[test] + fn srp_matches_pysrp_vectors() { + let c = SrpClient::with_a(USER, PW, &a_bytes()); + assert_eq!(hex::encode(c.a_bytes()), A_HEX, "A mismatch"); + + let salt = hex::decode(SALT_HEX).unwrap(); + let b = hex::decode(B_HEX).unwrap(); + let ch = c.process_challenge(&salt, &b).unwrap(); + assert_eq!(hex::encode(&ch.session_key), K_HEX, "K mismatch"); + assert_eq!(hex::encode(&ch.m), M_HEX, "M mismatch"); + assert_eq!(hex::encode(&ch.h_amk), HAMK_HEX, "H_AMK mismatch"); + } +} diff --git a/coven/src/main.rs b/coven/src/main.rs new file mode 100644 index 0000000..e388e63 --- /dev/null +++ b/coven/src/main.rs @@ -0,0 +1,212 @@ +//! coven ⛧ — encrypted collaborative covens with a summoned sandbox familiar. +//! +//! This binary currently exposes the crypto-parity spike used to prove the +//! Rust client speaks the same SRP / Fernet dialect as the Python Sanic server. +//! The ratatui coven UI is built on top of this proven foundation. + +mod crypto; + +use anyhow::{Context, Result}; +use base64::Engine; +use clap::{Parser, Subcommand}; +use serde_json::json; + +const STD: base64::engine::general_purpose::GeneralPurpose = + base64::engine::general_purpose::STANDARD; + +#[derive(Parser)] +#[command(name = "coven", about = "⛧ encrypted collaborative covens ⛧")] +struct Cli { + #[command(subcommand)] + cmd: Cmd, +} + +#[derive(Subcommand)] +enum Cmd { + /// Run the offline SRP golden-vector self-test. + Selftest, + /// Debug: compute A and M from explicit a/salt/B hex (parity check vs python). + Srpm { + a_hex: String, + salt_hex: String, + b_hex: String, + #[arg(long, default_value = "labtest")] + password: String, + #[arg(long, default_value = "chat")] + user: String, + }, + /// Perform a live SRP handshake + send one encrypted message (interop proof). + Handshake { + ip: String, + port: u16, + user: String, + #[arg(long)] + password: String, + #[arg(long, default_value_t = false)] + no_tls: bool, + #[arg(long, default_value_t = false)] + insecure: bool, + }, +} + +fn main() -> Result<()> { + match Cli::parse().cmd { + Cmd::Selftest => selftest(), + Cmd::Srpm { + a_hex, + salt_hex, + b_hex, + password, + user, + } => { + let a = hex::decode(a_hex)?; + let salt = hex::decode(salt_hex)?; + let b = hex::decode(b_hex)?; + let c = crypto::SrpClient::with_a(user.as_bytes(), password.as_bytes(), &a); + println!("A {}", hex::encode(c.a_bytes())); + let ch = c.process_challenge(&salt, &b)?; + println!("M {}", hex::encode(&ch.m)); + println!("K {}", hex::encode(&ch.session_key)); + Ok(()) + } + Cmd::Handshake { + ip, + port, + user, + password, + no_tls, + insecure, + } => handshake(&ip, port, &user, &password, no_tls, insecure), + } +} + +fn selftest() -> Result<()> { + // Re-derive the golden vectors at runtime as a smoke check. + let c = crypto::SrpClient::with_a(b"chat", b"labtest", &{ + let mut v = vec![0x80u8]; + v.extend(std::iter::repeat(0x22u8).take(31)); + v + }); + let a = hex::encode(c.a_bytes()); + println!("⛧ A = {}…", &a[..32]); + let salt = hex::decode("0a1b2c3d")?; + let b = hex::decode("047426a55963c70bc385c6a51f6e9dc0bfe5e16b0d1fee4f566fb54b60fa77144f15ed1ee6ade007bd92f2b90846e1ee083ab4290239420606f48a1d861f759543d7856cbce21fd7fec98c9961a66610b412fea2efc5be78f35b18fd48176ac80c3a1cbefacac81e25e7da8079fac4012d01c47d85b783c2ea7340819bfe73d29cd0953d47c8fade77caa5459fb77d88fb918c073a77c495fa884859142a270cb0b1668de06131b150df4dbc931953a381710b7fdb98a953d6f77a4bba847c4c62c15cca8e514dc13f531427966a553c461aa4ab0caec9665612861fef03d48676e5f6551fc8ca4317f3118e0294c949bd2f5821e5900e7f695225dafa0ba2d2")?; + let ch = c.process_challenge(&salt, &b)?; + let want_m = "6e733ba88eb86c52e3be89207d2815a65b4dea8116f668af5de1b66ce1f047dd"; + assert_eq!(hex::encode(&ch.m), want_m, "M mismatch vs pysrp"); + println!("⛧ M matches pysrp golden vector ✓"); + println!("⛧ selftest passed — Rust SRP ≡ Python srp"); + Ok(()) +} + +fn handshake( + ip: &str, + port: u16, + user: &str, + password: &str, + no_tls: bool, + insecure: bool, +) -> Result<()> { + let scheme = if no_tls { "http" } else { "https" }; + let base = format!("{scheme}://{ip}:{port}"); + + let http = reqwest::blocking::Client::builder() + .danger_accept_invalid_certs(insecure && !no_tls) + .timeout(std::time::Duration::from_secs(30)) + .build()?; + + // SRP identity is the fixed room identity b"chat" (see server srp_auth.py); + // the display `user` name is carried only in the JSON, never in the SRP proof. + let client = crypto::SrpClient::new(crypto::SRP_IDENTITY, password.as_bytes()); + + // /srp/init + let init: serde_json::Value = http + .post(format!("{base}/srp/init")) + .json(&json!({ "username": user, "A": STD.encode(client.a_bytes()) })) + .send() + .context("srp/init request")? + .error_for_status() + .context("srp/init status")? + .json()?; + + let user_id = init["user_id"].as_str().context("no user_id")?.to_string(); + let b = STD.decode(init["B"].as_str().context("no B")?)?; + let salt = STD.decode(init["salt"].as_str().context("no salt")?)?; + let room_salt = STD.decode(init["room_salt"].as_str().context("no room_salt")?)?; + println!("⛧ /srp/init ok — user_id={}…", &user_id[..8]); + + let ch = client.process_challenge(&salt, &b)?; + + // /srp/verify + let verify: serde_json::Value = http + .post(format!("{base}/srp/verify")) + .json(&json!({ + "user_id": user_id, + "username": user, + "M": STD.encode(&ch.m), + })) + .send() + .context("srp/verify request")? + .error_for_status() + .context("srp/verify status — auth rejected?")? + .json()?; + + let server_hamk = STD.decode(verify["H_AMK"].as_str().context("no H_AMK")?)?; + anyhow::ensure!(server_hamk == ch.h_amk, "server H_AMK mismatch — MITM?"); + let ws_token = verify["ws_token"].as_str().context("no ws_token")?.to_string(); + println!("⛧ /srp/verify ok — server identity proven (H_AMK ✓)"); + + // Room key + encrypt a message the Python clients can read. + let fernet = crypto::room_fernet(password.as_bytes(), &room_salt)?; + let ct = fernet.encrypt(b"the coven is summoned \xE2\x9B\xA7"); + + // Connect WS and send the ciphertext. + let ws_scheme = if no_tls { "ws" } else { "wss" }; + let ws_url = format!( + "{ws_scheme}://{ip}:{port}/ws/chat?user_id={user_id}&ws_token={ws_token}" + ); + let (mut sock, _resp) = + tungstenite::connect(&ws_url).context("ws connect (insecure wss not yet wired)")?; + println!("⛧ websocket attached to the coven"); + + // First frame is the `init` state snapshot. + if let Ok(msg) = sock.read() { + if let Ok(txt) = msg.into_text() { + let v: serde_json::Value = serde_json::from_str(&txt).unwrap_or_default(); + println!("⛧ recv: type={}", v["type"].as_str().unwrap_or("?")); + } + } + sock.send(tungstenite::Message::Text(ct))?; + sock.flush()?; + println!("⛧ sent encrypted offering"); + + // Read frames until we see our broadcast echo, then decrypt it to prove the + // full round-trip (Rust encrypt → server relay → Rust decrypt). + for _ in 0..5 { + match sock.read() { + Ok(tungstenite::Message::Text(txt)) => { + let v: serde_json::Value = serde_json::from_str(&txt).unwrap_or_default(); + if v["type"] == "message" { + if let Some(ct) = v["data"]["text"].as_str() { + match fernet.decrypt(ct) { + Ok(pt) => { + println!( + "⛧ round-trip ✓ decrypted: {:?}", + String::from_utf8_lossy(&pt) + ); + break; + } + Err(_) => println!("⛧ [decrypt failed]"), + } + } + } + } + Ok(_) => {} + Err(e) => { + println!("⛧ ws read ended: {e}"); + break; + } + } + } + Ok(()) +} diff --git a/coven/tools/gen_vectors.py b/coven/tools/gen_vectors.py new file mode 100644 index 0000000..80c0b01 --- /dev/null +++ b/coven/tools/gen_vectors.py @@ -0,0 +1,32 @@ +import srp, srp._pysrp as p, hashlib, binascii +srp.rfc5054_enable() +# force pure-python to match (and check active backend) +import srp as S +print("# backend:", S._mod.__name__) +H=hashlib.sha256 +user=b"chat"; pw=b"labtest" +salt=bytes.fromhex("0a1b2c3d") # fixed 4-byte salt +a=bytes.fromhex(("11"*256)) # fixed 256-byte a (high bit set via 0x11.. ok? need high bit) +a=bytes([0x80])+bytes.fromhex("22"*255) # ensure high bit set, 256 bytes +b=bytes([0x80])+bytes.fromhex("33"*255) +# verifier from known salt: replicate create_salted_verification_key internals with fixed salt +N,g=p.get_ng(p.NG_2048,None,None) +x=p.gen_x(H, salt, user, pw) +v=pow(g,x,N) +v_bytes=p.long_to_bytes(v) +usr=p.User(user,pw,hash_alg=p.SHA256,ng_type=p.NG_2048,bytes_a=a) +I,A=usr.start_authentication() +svr=p.Verifier(user,salt,v_bytes,A,hash_alg=p.SHA256,ng_type=p.NG_2048,bytes_b=b) +s2,B=svr.get_challenge() +M=usr.process_challenge(salt,B) +HAMK=svr.verify_session(M) +usr.verify_session(HAMK) +def hx(x): return binascii.hexlify(x).decode() +print("N_bits", N.bit_length()) +print("salt", hx(salt)) +print("A", hx(A)) +print("B", hx(B)) +print("M", hx(M)) +print("HAMK", hx(HAMK)) +print("K", hx(usr.K)) +print("authok", usr.authenticated()) diff --git a/docs/spec-collaborative-sandbox.md b/docs/spec-collaborative-sandbox.md new file mode 100644 index 0000000..ff4b5f1 --- /dev/null +++ b/docs/spec-collaborative-sandbox.md @@ -0,0 +1,513 @@ +# cmd-chat → Collaborative Sandbox Sessions — Spec + +> **Status:** Draft v1 · **Date:** 2026-05-30 +> **Scope:** Evolves `cmd-chat` from an E2E-encrypted terminal chat into a +> multi-user collaborative session with a shared, sandboxed Linux environment. +> **Baseline reviewed:** `cmd_chat/` @ `main` (commit `dc1b5e5`). + +--- + +## 0. Decisions locked (from product owner) + +| # | Decision | Choice | +|---|----------|--------| +| A | Client language / TUI | **Rust + ratatui client**, **Python Sanic server unchanged**. Stable JSON-over-WebSocket wire protocol between them. | +| B | Sandbox backend | **Pluggable backend interface. Multipass default, Docker secondary.** | +| C | Shared-terminal model | **Single shared PTY** (collaborative, tmux-share style). Permissions = who may type. | +| D | Permission model | **Two layers:** app-level RBAC (owner/admin/member) **+** real VM unix users & `sudo` delegation. | + +--- + +## 1. Vision & goals + +Turn a cmd-chat "room" into a **shared workspace**: up to 4 people (infra for more) +join one encrypted session, chat, drop files/dirs into a shared space, and +collaboratively drive **one sandboxed Linux box** they can type commands into and +run scripts in — with real Linux permissions and a clear owner who can delegate +superuser rights. + +### Goals +- Preserve the existing security guarantees: **zero-knowledge server**, **E2E + Fernet encryption**, **SRP auth**, **RAM-only server**, **no IP leaks**. +- 4 concurrent users per session today; capacity is a single config constant, not + an architectural limit. +- A genuinely nice **ratatui** TUI: panes, themes, custom colour/layout config. +- One-command launch of a **disposable sandbox** (Multipass VM or Docker + container) that the whole room shares. +- File **and directory** upload into the shared session. +- Linux-grade permissions inside the sandbox + app-level roles governing the + session itself. + +### Non-goals (v1) +- Persistence / chat history survival across server restart (server stays RAM-only). +- Federation / multiple rooms per server process (one room per `serve`, as today). +- Running the sandbox *on the server* (see §4 — it runs on the initiator's client). +- Mobile / GUI clients. Terminal only. +- Multi-VM topologies. One shared sandbox per session. + +--- + +## 2. Baseline architecture (what exists today) + +``` +CLIENT (python+rich) SERVER (sanic, RAM-only) CLIENT + SRP handshake ───────────────► /srp/init, /srp/verify ──────────► (relay only) + HKDF(pw,salt) → room_key server NEVER + Fernet(room_key) encrypt WSS /ws/chat (broadcast) sees plaintext + ──── ciphertext ──────────────► ConnectionManager.broadcast ─────► decrypt(room_key) + file xfer = _ft JSON over the same encrypted message channel (64KB chunks, SHA-256) +``` + +- **Server modules:** `factory.py` (DI/wiring), `server.py` (TLS + run), + `routes.py`, `views.py` (SRP + ws + admin), `managers.py` + (`ConnectionManager`), `stores.py` (`MessageStore`, `UserSessionStore`), + `srp_auth.py`, `models.py`, `helpers.py` (`RateLimiter`, `get_client_ip`). +- **Client:** single `client.py` — `Client` class, asyncio `receive_loop` + + `input_loop`, rich console clear-and-reprint, `_ft` file-transfer protocol. +- **Wire types today:** `init`, `message`, `user_left`. File transfer rides inside + `message.text` as JSON beginning `{"_ft": …}` (offer/accept/reject/chunk/done). + +### What we keep vs. change +| Component | v1 plan | +|---|---| +| Sanic server, SRP, TLS, RateLimiter | **Keep**, extend with new relay message types + capacity check. | +| `ConnectionManager` broadcast | **Keep**; add `broadcast(exclude_user)` usage and roster events. | +| RAM-only / zero-knowledge | **Keep** — non-negotiable; everything new also rides as ciphertext. | +| Python rich client | **Replace** with Rust ratatui client (protocol-compatible). | +| File transfer `_ft` protocol | **Keep & extend** for directories (tar stream). | + +--- + +## 3. Target architecture (high level) + +``` +┌─────────────────────────── SESSION (one room) ───────────────────────────┐ +│ │ +│ OWNER CLIENT (Rust/ratatui) SERVER (Sanic, dumb relay) │ +│ ┌───────────────────────────┐ ┌─────────────────────────┐ │ +│ │ ratatui UI │ │ SRP / TLS / rate-limit │ │ +│ │ chat | roster | sandbox │◄──WSS───►│ ConnectionManager │◄──┐ │ +│ │ E2E Fernet(room_key) │ cipher │ broadcast(opaque bytes) │ │ │ +│ │ ┌───────────────────────┐ │ text │ Stores (RAM only) │ │ │ +│ │ │ SANDBOX BROKER (local)│ │ └─────────────────────────┘ │ │ +│ │ │ Multipass | Docker │ │ │ │ +│ │ │ PTY ⇄ encrypted frames│ │ MEMBER CLIENTS (Rust/ratatui) ─────┘ │ +│ │ │ RBAC + unix-user map │ │ decrypt → render shared PTY pane │ +│ │ └───────────────────────┘ │ send keystrokes (if permitted) │ +│ └───────────────────────────┘ │ +└───────────────────────────────────────────────────────────────────────────┘ +``` + +**Key principle — the server stays zero-knowledge.** The sandbox does **not** run +on the server (the server has no room key and must never see plaintext). Instead: + +- The **client that launches the sandbox** (normally the owner) hosts a local + **Sandbox Broker** that spawns the Multipass VM / Docker container and owns its + PTY. +- PTY output is Fernet-encrypted with the room key and relayed through the server + as opaque ciphertext — identical trust model to file transfer. +- Keystrokes from other clients travel encrypted to the broker, which is the + **single policy-enforcement point** (it decides who may type / `sudo` / upload). + +This is the only design that satisfies *both* "shared sandbox" *and* "server can't +read anything." It is documented as a constraint, not an accident. + +--- + +## 4. Wire protocol (v2) + +### 4.1 Versioning & envelope +- Add a top-level relay type **`hello`** exchanged right after `init` carrying + `protocol_version` (`2`). Server relays it untouched. Clients negotiate down / + warn on mismatch. +- All new collaborative payloads ride **inside the encrypted channel** the same way + `_ft` does: the cleartext-to-server is a `message` frame whose decrypted `text` + is JSON with a discriminator key. We generalise `_ft` to a namespaced envelope: + +```jsonc +// decrypted application payload (server only ever sees the ciphertext of this) +{ + "v": 2, + "kind": "chat" | "file" | "dir" | "sbx" | "perm" | "presence", + "id": "uuid", // correlation id + "from":"username", // asserted by sender, validated by broker for sbx/perm + "ts": "iso8601", + "body": { /* kind-specific */ } +} +``` + +> Rationale: keeps the server's relay role unchanged (still just `message` +> broadcast of opaque bytes) while giving the app a clean, extensible schema. +> Existing `_ft` messages map onto `kind:"file"`. + +### 4.2 New server-visible relay types (cleartext metadata only) +The server learns nothing it shouldn't; these carry no plaintext content. +- `roster` — authoritative presence list + capacity (server-generated; see §5). +- `capacity_full` — sent on connect rejection (HTTP 409 / ws close code). +- Everything else stays `message` (opaque ciphertext). + +### 4.3 Sandbox sub-protocol (`kind:"sbx"`, encrypted, broker-authoritative) +| `body.op` | Direction | Meaning | +|---|---|---| +| `launch` | client→broker | request to start sandbox (owner/admin only) | +| `status` | broker→all | `starting`/`ready`/`stopped`/`error`, backend, image, specs | +| `pty_data` | broker→all | base64 PTY output chunk (the shared terminal stream) | +| `pty_input` | client→broker | keystrokes; broker enforces "may type" ACL | +| `resize` | client→broker | cols/rows; broker applies if sender is the active driver | +| `run_script` | client→broker | upload+exec a script in the VM (perm-gated) | +| `stop` | owner→broker | tear down sandbox | + +### 4.4 Permission sub-protocol (`kind:"perm"`, broker-authoritative) +| `body.op` | Meaning | +|---|---| +| `grant` / `revoke` | change app role (admin/member) — owner/admin only | +| `sudo_grant` / `sudo_revoke` | add/remove a user from VM sudoers — superuser only | +| `acl` | broker broadcasts the current authoritative ACL snapshot | + +--- + +## 5. Feature 1 — Multi-user sessions (4 now, N later) + +**Current state:** `ConnectionManager` already supports arbitrarily many websocket +connections; `username_exists` blocks dup names. No capacity cap, no real roster +broadcast (only `init` snapshot + `user_left`). + +**Changes** +1. **Capacity constant** `SESSION_MAX_USERS = 4` in `factory.py` (env override + `CMD_CHAT_MAX_USERS`). Enforced in `srp_verify` *and* on ws connect: + reject with `409 Username/Room full` / ws close code `4004` when + `session_store.count() >= max`. +2. **Authoritative roster.** Server emits a `roster` event (join, leave, + role-change) so all clients converge on one presence list with roles. Replaces + the ad-hoc `user_left` patching (kept for back-comp, superseded by `roster`). +3. **`user_joined` event** added (today only `user_left` exists) for live roster. +4. **Infra-for-more:** capacity is data, not code. Document that >4 needs (a) UI + roster scroll, (b) broadcast fan-out is O(N) per message — fine to low double + digits; note ceiling in README. No protocol change required to raise the cap. + +**Acceptance** +- 5th join attempt to a 4-cap room is cleanly refused with a user-visible reason. +- Roster in every client matches server truth within one broadcast round-trip. +- Raising `CMD_CHAT_MAX_USERS=8` works with zero code changes. + +--- + +## 6. Feature 2 — Rust ratatui client (enhanced + themeable) + +**New crate:** `cmd-chat-tui/` (Rust 2021). Talks the v2 protocol to the existing +Sanic server. The Python client remains in-tree as a reference/fallback until the +Rust client reaches parity, then is deprecated. + +### 6.1 Crate layout +``` +cmd-chat-tui/ + Cargo.toml + src/ + main.rs # CLI (clap): connect/serve-shim, flags mirror python + app.rs # App state, event loop (tokio + crossterm) + net/ + srp.rs # SRP-6a client (crate: srp + sha2) — matches python params + ws.rs # tokio-tungstenite WS client + crypto.rs # HKDF-SHA256 → Fernet (crate: fernet) room key + proto.rs # v2 envelope (serde) types + ui/ + layout.rs # ratatui layout regions + chat.rs # chat pane (scrollback, msg styling) + roster.rs # users + roles + sandbox status + sandbox.rs # shared PTY pane (vt100 parse: crate `vt100`) + input.rs # input box, command palette (/send /run /grant …) + theme.rs # theme model + loader + sandbox/ + broker.rs # local PTY broker (owner side) — see §8 + backend.rs # trait SandboxBackend + multipass.rs # default backend + docker.rs # secondary backend + files.rs # upload (file + dir/tar), SHA-256, chunking + perms.rs # client-side ACL view + enforcement hints + themes/ + default.toml nord.toml gruvbox.toml mono.toml +``` + +### 6.2 Crypto parity (must match Python exactly) +- SRP-6a, group **RFC5054 / SHA-256**, identity `b"chat"` (matches + `srp_auth.py`). Verify interop against the live Sanic server early — this is the + #1 integration risk. +- Room key: `HKDF(SHA256, len=32, salt=room_salt, info=b"cmd-chat-room-key")` + over the password, then `Fernet(urlsafe_b64(key))` — byte-for-byte as + `client.py::srp_authenticate`. +- WS auth: `ws_token` echoed from `/srp/verify`; HMAC token already server-side. + +### 6.3 UI / layout +Default layout (resizable, ratatui `Layout` constraints): +``` +┌ cmd-chat ── room: ── 🔒 E2E ── users 3/4 ──────────────┐ +│ ┌─ chat ───────────────────────────┐ ┌─ roster ─────────────┐ │ +│ │ 12:01 alice: hey │ │ ● alice (owner,root)│ │ +│ │ 12:01 bob: yo │ │ ● bob (admin,sudo)│ │ +│ │ …scrollback… │ │ ○ carol (member) │ │ +│ └──────────────────────────────────┘ │ sandbox: ● ready │ │ +│ ┌─ sandbox (shared PTY · driver: alice) ─────────────────────┐│ +│ │ ubuntu@sbx:~$ ./build.sh ││ +│ │ …live vt100 output for everyone… ││ +│ └────────────────────────────────────────────────────────────┘│ +│ > type message · /send /run /sbx /grant (F2 toggle PTY focus)│ +└────────────────────────────────────────────────────────────────┘ +``` +- **Panes:** chat, roster (with roles + sandbox status), shared PTY, input. +- **Focus model:** Tab cycles panes; `F2` toggles "drive the PTY" (keystrokes go to + sandbox) vs "chat input". Visible indicator of who currently holds the PTY driver + token (see §8.3). +- **Command palette** (`/`): `/send`, `/sendd `, `/run