From e6b350a1d48ca0490b7a3447bb8d0a4235514b76 Mon Sep 17 00:00:00 2001 From: Doan Luu Duc Tai Date: Fri, 30 Oct 2026 21:39:57 -0500 Subject: Implement experimental file-based encrypted private chat Add fixed-suite Rust/WASI identity files, signed challenge authentication, ciphertext-only SQLite storage, a minimal LibreJS-compatible interface, and consistent backup snapshots. Keep production unchanged and document the local workflow, limits, source distribution, and browser recovery tests. --- .env.example | 3 + Cargo.lock | 368 +++++++++++ Cargo.toml | 3 +- README.txt | 13 + crates/chat-crypto/Cargo.toml | 8 +- crates/chat-crypto/README.txt | 56 +- crates/chat-crypto/browser/wasi.mjs | 50 +- crates/chat-crypto/src/application.rs | 753 ++++++++++++++++++++++ crates/chat-crypto/src/lib.rs | 6 +- crates/chat-crypto/src/main.rs | 18 +- crates/chat-crypto/tests/browser.mjs | 3 +- crates/chat-server/Cargo.toml | 46 ++ crates/chat-server/README.txt | 22 + crates/chat-server/src/http.rs | 160 +++++ crates/chat-server/src/main.rs | 129 ++++ crates/chat-server/src/service.rs | 438 +++++++++++++ crates/chat-server/src/store.rs | 169 +++++ crates/chat-server/tests/browser.mjs | 265 ++++++++ crates/chat-server/web/app.js | 325 ++++++++++ crates/chat-server/web/index.html | 82 +++ crates/chat-server/web/javascript.html | 28 + crates/chat-server/web/style.css | 49 ++ crates/chat-server/web/worker.js | 31 + deny.toml | 4 + docs/CHAT-APPLICATION.txt | 336 ++++++++++ docs/CHAT-DESIGN.txt | 7 +- infra/chat/build-browser.sh | 34 + infra/chat/build-openssl-wasi.sh | 6 +- infra/chat/build-source.sh | 29 + src/archive.rs | 48 +- src/main.rs | 16 + web/THIRD-PARTY-LICENSES.html | 1089 ++++++++++++++++++++++++++++---- 32 files changed, 4421 insertions(+), 173 deletions(-) create mode 100644 crates/chat-crypto/src/application.rs create mode 100644 crates/chat-server/Cargo.toml create mode 100644 crates/chat-server/README.txt create mode 100644 crates/chat-server/src/http.rs create mode 100644 crates/chat-server/src/main.rs create mode 100644 crates/chat-server/src/service.rs create mode 100644 crates/chat-server/src/store.rs create mode 100644 crates/chat-server/tests/browser.mjs create mode 100644 crates/chat-server/web/app.js create mode 100644 crates/chat-server/web/index.html create mode 100644 crates/chat-server/web/javascript.html create mode 100644 crates/chat-server/web/style.css create mode 100644 crates/chat-server/web/worker.js create mode 100644 docs/CHAT-APPLICATION.txt create mode 100644 infra/chat/build-browser.sh create mode 100644 infra/chat/build-source.sh diff --git a/.env.example b/.env.example index e7292bb..b8fad96 100644 --- a/.env.example +++ b/.env.example @@ -14,3 +14,6 @@ # along with this program. If not, see . SEARXNG_SECRET=GENERATE_RANDOM_HEX_KEY + +CHAT_ORIGIN=http://127.0.0.1:9080 +CHAT_SETUP_TOKEN=GENERATE_WITH_CHAT_SERVER_INIT diff --git a/Cargo.lock b/Cargo.lock index 34be726..a286226 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -127,12 +127,66 @@ dependencies = [ "term", ] +[[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 = "axum" +version = "0.8.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "31b698c5f9a010f6573133b09e0de5408834d0c82f8d7475a89fc1867a71cd90" +dependencies = [ + "axum-core", + "bytes", + "futures-util", + "http", + "http-body", + "http-body-util", + "hyper", + "hyper-util", + "itoa", + "matchit", + "memchr", + "mime", + "percent-encoding", + "pin-project-lite", + "serde_core", + "serde_json", + "serde_path_to_error", + "sync_wrapper", + "tokio", + "tower", + "tower-layer", + "tower-service", +] + +[[package]] +name = "axum-core" +version = "0.5.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "08c78f31d7b1291f7ee735c1c6780ccde7785daae9a9206026862dab7d8792d1" +dependencies = [ + "bytes", + "futures-core", + "http", + "http-body", + "http-body-util", + "mime", + "pin-project-lite", + "sync_wrapper", + "tower-layer", + "tower-service", +] + [[package]] name = "base64" version = "0.22.1" @@ -229,6 +283,12 @@ version = "3.20.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "72f5acc6cb2ba439de613abc23857ec3d78374d8ed5ac84e9d11336e87da8649" +[[package]] +name = "bytes" +version = "1.12.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fc652a48c352aef3ea3aed32080501cf3ef6ed5da78602a020c991775b0aff04" + [[package]] name = "cc" version = "1.10.0" @@ -468,6 +528,18 @@ dependencies = [ "windows-sys", ] +[[package]] +name = "fallible-iterator" +version = "0.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2acce4a10f12dc2fb14a218589d4f1f62ef011b2d0cc4b3cb1bba8e94da14649" + +[[package]] +name = "fallible-streaming-iterator" +version = "0.1.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7360491ce676a36bf9bb3c56c1aa791658183a54d2744120f27285738d90465a" + [[package]] name = "fastrand" version = "2.5.0" @@ -512,6 +584,21 @@ version = "0.1.5" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "d9c4f5dac5e15c24eb999c26181a6ca40b39fe946cbe4c263c7209467bc83af2" +[[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 = "futures-core" version = "0.3.34" @@ -613,6 +700,51 @@ version = "0.4.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "7f24254aa9a54b5c858eaee2f5bccdb46aaf0e486a595ed5fd8f86ba55232a70" +[[package]] +name = "http" +version = "1.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "918d3568bebf352712bc2ef3d46a8bcf1a75b373be6539de198e9105cbbf9ce0" +dependencies = [ + "bytes", + "itoa", +] + +[[package]] +name = "http-body" +version = "1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ca2a8f2913ee65f60facd6a5905613afaa448497a0230cc41ce022d93290bc2c" +dependencies = [ + "bytes", + "http", +] + +[[package]] +name = "http-body-util" +version = "0.1.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "23169fe34a5fbcdd3f3862e78fb9b6fccd5f02a6dc6f732547005d45631ce71c" +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 = "httpdate" +version = "1.0.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "df3b46402a9d5adb4c86a0cf463f42e19994e3ee891101b1841f30a545cb49a9" + [[package]] name = "hybrid-array" version = "0.4.15" @@ -622,6 +754,40 @@ dependencies = [ "typenum", ] +[[package]] +name = "hyper" +version = "1.12.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2c3e324da4c95177d6291d4c8730197c0d1822f8a9766814a4a44fa5ab797c9c" +dependencies = [ + "atomic-waker", + "bytes", + "futures-core", + "http", + "http-body", + "httparse", + "httpdate", + "itoa", + "pin-project-lite", + "smallvec", + "tokio", +] + +[[package]] +name = "hyper-util" +version = "0.1.21" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ddc03d96684f9226b8a787cdb71488417b53ab5ea8fdb1dac946cb9431cc8bff" +dependencies = [ + "bytes", + "http", + "http-body", + "hyper", + "pin-project-lite", + "tokio", + "tower-service", +] + [[package]] name = "iana-time-zone" version = "0.1.65" @@ -857,6 +1023,17 @@ dependencies = [ "windows-link", ] +[[package]] +name = "libsqlite3-sys" +version = "0.39.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "14a5f01b87b92705d07b433c06f96cbc764637d81a0bffb5734ce17cf0f9a2b3" +dependencies = [ + "cc", + "pkg-config", + "vcpkg", +] + [[package]] name = "link-section" version = "0.19.3" @@ -896,6 +1073,12 @@ version = "0.4.35" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "5d15f86d12da44cd8d4ec3c73984537eb7f4cadcb9776a575a0a8a05ca11005c" +[[package]] +name = "matchit" +version = "0.8.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "47e1ffaa40ddd1f3ed91f717a33c8c0ee23fff369e3aa8772b9605cc1d22f4c3" + [[package]] name = "memchr" version = "2.8.3" @@ -908,6 +1091,12 @@ version = "0.7.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "c797b9d6bb23aab2fc369c65f871be49214f5c759af65bde26ffaaa2b646b492" +[[package]] +name = "mime" +version = "0.3.17" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6877bb514081ee2a7ff5ef9de3281f14a4dd4bceac4c09388074a6b5df8a139a" + [[package]] name = "minimal-lexical" version = "0.2.1" @@ -924,6 +1113,17 @@ dependencies = [ "simd-adler32", ] +[[package]] +name = "mio" +version = "1.2.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1788edb87fdc09c7e26304471e2f5be8cdefb1b6930d6e3985fc02ff53bf86ee" +dependencies = [ + "libc", + "wasi", + "windows-sys", +] + [[package]] name = "new_debug_unreachable" version = "1.0.6" @@ -961,6 +1161,31 @@ version = "1.70.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "384b8ab6d37215f3c5301a95a4accb5d64aa607f1fcb26a11b5303878451b4fe" +[[package]] +name = "openssl" +version = "0.10.81" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "77823a27f0babb03091cb9ed9ef80af3b39dbc82f97e8fa530374b7dafd87a45" +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 2.0.119", +] + [[package]] name = "openssl-sys" version = "0.9.117" @@ -1021,6 +1246,12 @@ dependencies = [ "subtle", ] +[[package]] +name = "percent-encoding" +version = "2.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9b4f627cb1b25917193a259e49bdad08f671f8d9708acfd5fe0a8c1455d87220" + [[package]] name = "petgraph" version = "0.8.3" @@ -1146,6 +1377,19 @@ version = "0.8.11" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "d6f6ff9a378485b298a5286656da665ba74413d36db0979633275d2e708145d4" +[[package]] +name = "rusqlite" +version = "0.41.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "da9bba6f3506135e120a6586df05be18b48d18896600646a4bec8f993d91a594" +dependencies = [ + "bitflags", + "fallible-iterator", + "fallible-streaming-iterator", + "libsqlite3-sys", + "smallvec", +] + [[package]] name = "rustc-hash" version = "2.1.3" @@ -1257,6 +1501,17 @@ dependencies = [ "zmij", ] +[[package]] +name = "serde_path_to_error" +version = "0.1.20" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "10a9ff822e371bb5403e391ecd83e182e0e77ba7f6fe0160b795797109d1b457" +dependencies = [ + "itoa", + "serde", + "serde_core", +] + [[package]] name = "serde_spanned" version = "1.1.2" @@ -1310,6 +1565,16 @@ version = "2.0.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "f8fadd59c855ef2080decdef8ff161eb6661b86933c9d82e5ba29dc602a55aba" +[[package]] +name = "signal-hook-registry" +version = "1.4.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c4db69cba1110affc0e9f7bcd48bbf87b3f4fc7c61fc9155afd4c469eb3d6c1b" +dependencies = [ + "errno", + "libc", +] + [[package]] name = "simd-adler32" version = "0.3.10" @@ -1334,6 +1599,16 @@ version = "1.16.4" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "d0d5d4b1981b8bbbb7aed204cae5726d683d5d6ac7e74ec54122ea28f315e7c8" +[[package]] +name = "socket2" +version = "0.6.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c3d1e2c7f27f8d4cb10542a02c49005dbd6e93095799d6f3be745fae9f8fedd4" +dependencies = [ + "libc", + "windows-sys", +] + [[package]] name = "sponge-cursor" version = "0.1.0" @@ -1356,6 +1631,7 @@ dependencies = [ "dotenvy", "flate2", "hex", + "rusqlite", "serde", "serde_json", "sha2", @@ -1370,7 +1646,31 @@ name = "stack-chat-crypto" version = "0.1.0" dependencies = [ "anyhow", + "argon2", + "base64", + "openssl", "sequoia-openpgp", + "serde", + "serde_json", + "zeroize", +] + +[[package]] +name = "stack-chat-server" +version = "0.1.0" +dependencies = [ + "anyhow", + "axum", + "base64", + "clap", + "dotenvy", + "hex", + "openssl", + "rusqlite", + "serde_json", + "stack-chat-crypto", + "tempfile", + "tokio", ] [[package]] @@ -1419,6 +1719,12 @@ dependencies = [ "unicode-ident", ] +[[package]] +name = "sync_wrapper" +version = "1.0.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0bf256ce5efdfa370213c1dabab5935a12e49f2c58d15e9eac2870d3b4f27263" + [[package]] name = "synstructure" version = "0.14.0" @@ -1493,6 +1799,32 @@ dependencies = [ "zerovec", ] +[[package]] +name = "tokio" +version = "1.54.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b52fd3255258d26fac7264ab5a76b53c13367136dc011d3d6ad4f8ed070396a3" +dependencies = [ + "libc", + "mio", + "pin-project-lite", + "signal-hook-registry", + "socket2", + "tokio-macros", + "windows-sys", +] + +[[package]] +name = "tokio-macros" +version = "2.7.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "78773a2a397f451582ce068015985c33193cf6dea8b74d2a639fe457b2f07b0e" +dependencies = [ + "proc-macro2", + "quote", + "syn 3.0.9", +] + [[package]] name = "toml" version = "1.1.8+spec-1.1.0" @@ -1532,6 +1864,33 @@ version = "1.1.3+spec-1.1.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "06bdbd8cfc056b8d2e2e85f29b56a3bdbecb527cef81eb39e3e7b98af4652770" +[[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-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 = "typenum" version = "1.20.1" @@ -1798,6 +2157,15 @@ dependencies = [ "synstructure", ] +[[package]] +name = "zeroize" +version = "1.9.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e13084392c5e4bc371903e2935a5eaeed24905a7511356b883835e18a78f6879" +dependencies = [ + "serde", +] + [[package]] name = "zerotrie" version = "0.2.5" diff --git a/Cargo.toml b/Cargo.toml index b77059f..cea6d40 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -37,6 +37,7 @@ hex = "0.4" flate2 = { version = "1", default-features = false, features = ["rust_backend"] } serde = { version = "1.0", features = ["derive"] } serde_json = "1.0" +rusqlite = { version = "0.41", default-features = false, features = ["bundled", "backup"] } sha2 = "0.11" tar = "0.4" tempfile = "3" @@ -53,7 +54,7 @@ incremental = false strip = false [workspace] -members = [".", "crates/chat-crypto"] +members = [".", "crates/chat-crypto", "crates/chat-server"] default-members = ["."] resolver = "3" diff --git a/README.txt b/README.txt index 70a5931..5613a05 100644 --- a/README.txt +++ b/README.txt @@ -72,3 +72,16 @@ CHECKS AND LICENSES Detailed manual backup, recovery, deployment, and host setup instructions are in docs/OPERATIONS.txt. Read docs/POLICY.txt before changing conventions. + +EXPERIMENTAL CHAT BRANCH + + feature/private-chat contains a local Rust/WASI chat application. Start with + docs/CHAT-APPLICATION.txt for setup, protected files, tests, backup/restore, + source licensing, and security limitations. cargo stack chat --init appends + chat configuration to the existing single root .env; cargo stack chat runs + the local service. This does not deploy or enable production chat. + + SQLite snapshots now use the bundled SQLite C library. Portable stack + release builds consequently also require a musl C compiler on the development + machine, in addition to the Rust musl target. Runtime binaries remain static; + no new compiler is required on the VPS. diff --git a/crates/chat-crypto/Cargo.toml b/crates/chat-crypto/Cargo.toml index eaada21..738b539 100644 --- a/crates/chat-crypto/Cargo.toml +++ b/crates/chat-crypto/Cargo.toml @@ -19,7 +19,7 @@ version = "0.1.0" edition = "2024" license = "AGPL-3.0-or-later" authors = ["Doan Luu Duc Tai "] -description = "Experimental OpenPGP browser compatibility proof for private chat." +description = "Purpose-specific OpenPGP identity files and encrypted private chat." repository = "https://git.doan.luuduct.ai/stack" readme = "README.txt" keywords = ["openpgp", "wasm", "chat"] @@ -31,6 +31,12 @@ crate-type = ["rlib"] [dependencies] anyhow = "1" +argon2 = "0.5" +base64 = "0.22" +openssl = "0.10" +serde = { version = "1", features = ["derive"] } +serde_json = "1" +zeroize = { version = "1", features = ["serde"] } sequoia-openpgp = { version = "=2.4.2", default-features = false, features = ["crypto-openssl"] } [lints] diff --git a/crates/chat-crypto/README.txt b/crates/chat-crypto/README.txt index d09ff82..e04246c 100644 --- a/crates/chat-crypto/README.txt +++ b/crates/chat-crypto/README.txt @@ -14,26 +14,33 @@ You should have received a copy of the GNU Affero General Public License along with this program. If not, see . -CHAT CRYPTOGRAPHY COMPATIBILITY PROBE - - Experimental, local-only work on feature/private-chat. This is a - cryptographic compatibility test, not a messaging application. Do not use - it for private conversations or real identities. It creates temporary - identities in memory and discards them; it does not create user chat files. +PRIVATE CHAT CRYPTOGRAPHY Fixed encryption: RFC 9980 ML-KEM-1024 + X448, OpenPGP v6. - Tested signing: ML-DSA-87 + Ed448, OpenPGP v6. + Signing: ML-DSA-87 + Ed448, OpenPGP v6. Backend: Sequoia 2.4.2 with OpenSSL 3.6.5. No algorithm substitution or - vulnerability-advisory exception was used. The first checkpoint's Rust - backend is historical and no longer selected. + vulnerability-advisory exception was used. The original RustCrypto backend + is historical and no longer selected. + + This crate implements purpose-specific identity creation, Argon2id/AES-256-GCM + protected files, detached authentication proofs, signed two-recipient messages, + bounded verified history batches, and the original compatibility probe. + Native Rust and browser WASI share the implementation. The standalone command + accepts one JSON request on stdin; empty input runs the compatibility probe. + Its browser WASI host grants no filesystem or network access. Private request + data must remain in local memory and must never be submitted to the server. + + See ../../docs/CHAT-APPLICATION.txt for implemented formats, local setup, + tests, backups, and limitations. Work remains experimental and local on + feature/private-chat. Production remains unchanged. Do not mistake the + compatibility probe for an application security audit. - See ../../docs/CHAT-OPENSSL-MIGRATION.txt for the complete decision record, - pinned source hashes, build flags, capability boundaries, dependency-policy - exceptions, observed results, limitations, and reproduction commands. - ../../docs/CHAT-SECURITY-REVIEW.txt records the original dependency findings. - ../../docs/CHAT-DESIGN.txt describes the planned application. + ../../docs/CHAT-OPENSSL-MIGRATION.txt records the complete backend decision, + pinned source hashes, build flags, FFI/capability limitations, and reviewed + dependency-policy exceptions. ../../docs/CHAT-SECURITY-REVIEW.txt records + the superseded RustCrypto findings without advisory ignores. -QUICK CHECKS FROM THE STACK ROOT +CHECKS FROM THE WORKSPACE ROOT cargo test --locked -p stack-chat-crypto cargo run --locked -p stack-chat-crypto @@ -41,17 +48,8 @@ QUICK CHECKS FROM THE STACK ROOT PLAYWRIGHT_BROWSERS_PATH="$PWD/.tools/browsers" node crates/chat-crypto/tests/browser.mjs cargo stack check - Native checks require OpenSSL >=3.5 development files and libclang. - The cross-build script currently supports Linux x86_64 development hosts. - Browser tooling installation is documented in the migration record. - - The harness serves only a blank document, a readable AGPL JavaScript WASI - bridge, and the new WASI binary on an ephemeral loopback port. It verifies - the actual OpenSSL backend, cryptographic round trips, tampering and invalid - credential rejection, repeated fresh instances, and failure without CSPRNG. - It checks for external requests and persistent browser application state. - - The application itself, private chat-file envelope, and session workflow - remain unimplemented. LibreJS acceptance has not been tested; there is no - public chat page or compliance badge. The fixture password is public test - data, not a configured secret. No .env file or user private key is needed. + Native builds require OpenSSL >=3.5 development files and libclang. + The cross-build script supports an x86_64 GNU/Linux development host. + Browser tooling is excluded from the public source tree and releases. + The probe uses public fixture credentials and creates temporary identities + in memory. It does not open the root .env or use the canonical RSA key. diff --git a/crates/chat-crypto/browser/wasi.mjs b/crates/chat-crypto/browser/wasi.mjs index d27fe4b..80d1299 100644 --- a/crates/chat-crypto/browser/wasi.mjs +++ b/crates/chat-crypto/browser/wasi.mjs @@ -15,10 +15,14 @@ // @license magnet:?xt=urn:btih:0b31508aeb0634b347b8270c7bee4d411b5d4109&dn=agpl-3.0.txt AGPL-3.0 -export async function runProbe(bytes) { +async function execute(bytes, input) { let memory; let output = ''; let errors = ''; + const stdoutDecoder = new TextDecoder('utf-8', { fatal: true }); + const stderrDecoder = new TextDecoder('utf-8', { fatal: true }); + const stdin = new TextEncoder().encode(input); + let position = 0; let randomCalls = 0; let randomBytes = 0; const data = () => new DataView(memory.buffer); @@ -54,15 +58,28 @@ export async function runProbe(bytes) { for (let index = 0; index < count; index += 1) { const address = data().getUint32(vectors + index * 8, true); const length = data().getUint32(vectors + index * 8 + 4, true); - const text = new TextDecoder().decode(new Uint8Array(memory.buffer, address, length)); + const text = (fd === 1 ? stdoutDecoder : stderrDecoder).decode(new Uint8Array(memory.buffer, address, length), { stream: true }); if (fd === 1) output += text; else errors += text; total += length; - if (output.length + errors.length > 65536) throw new Error('Probe output exceeded its limit'); + if (output.length + errors.length > 1048576) throw new Error('Probe output exceeded its limit'); } put32(written, total); return 0; }, - fd_read(fd, vectors, count, read) { if (fd !== 0) return 8; put32(read, 0); return 0; }, + fd_read(fd, vectors, count, read) { + if (fd !== 0) return 8; + let total = 0; + for (let index = 0; index < count && position < stdin.length; index += 1) { + const address = data().getUint32(vectors + index * 8, true); + const capacity = data().getUint32(vectors + index * 8 + 4, true); + const length = Math.min(capacity, stdin.length - position); + new Uint8Array(memory.buffer, address, length).set(stdin.subarray(position, position + length)); + position += length; + total += length; + } + put32(read, total); + return 0; + }, fd_fdstat_get(fd, address) { if (fd > 2) return 8; new Uint8Array(memory.buffer, address, 24).fill(0); @@ -97,10 +114,29 @@ export async function runProbe(bytes) { memory = instance.exports.memory; try { instance.exports._start(); } catch (error) { if (!(error instanceof Exit) || error.code !== 0) throw new Error(`${error.message}: ${errors}`); } - if (!output.startsWith('PASS: backend=OpenSSL ') || randomCalls === 0) { - throw new Error(`OpenSSL probe did not pass: ${errors || output}`); - } + output += stdoutDecoder.decode(); + errors += stderrDecoder.decode(); + stdin.fill(0); return { output, randomCalls, randomBytes }; } +async function runProbe(bytes) { + const result = await execute(bytes, ''); + if (!result.output.startsWith('PASS: backend=OpenSSL ') || result.randomCalls === 0) { + throw new Error('OpenSSL probe did not pass'); + } + return result; +} + +async function runCommand(bytes, request) { + const input = JSON.stringify(request); + if (input.length > 8388608) throw new Error('Crypto request exceeds its limit'); + const result = await execute(bytes, input); + const response = JSON.parse(result.output); + if (response.error) throw new Error(response.error); + return response; +} + +globalThis.stackChatWasi = Object.freeze({ runCommand, runProbe }); + // @license-end diff --git a/crates/chat-crypto/src/application.rs b/crates/chat-crypto/src/application.rs new file mode 100644 index 0000000..98e8a20 --- /dev/null +++ b/crates/chat-crypto/src/application.rs @@ -0,0 +1,753 @@ +// Copyright (C) 2026 Doan Luu Duc Tai +// +// This program is free software: you can redistribute it and/or modify +// it under the terms of the GNU Affero General Public License as +// published by the Free Software Foundation, either version 3 of the +// License, or (at your option) any later version. +// +// This program is distributed in the hope that it will be useful, +// but WITHOUT ANY WARRANTY; without even the implied warranty of +// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +// GNU Affero General Public License for more details. +// +// You should have received a copy of the GNU Affero General Public License +// along with this program. If not, see . + +use anyhow::{Context, Result, ensure}; +use argon2::{Algorithm, Argon2, Params, Version}; +use base64::{Engine, prelude::BASE64_STANDARD}; +use openssl::symm::{Cipher, decrypt_aead, encrypt_aead}; +use pgp::cert::prelude::*; +use pgp::crypto::SessionKey; +use pgp::parse::{Parse, stream::DetachedVerifierBuilder}; +use pgp::serialize::{ + Serialize, + stream::{Encryptor, LiteralWriter, Message, Signer}, +}; +use pgp::types::{KeyFlags, PublicKeyAlgorithm, SymmetricAlgorithm}; +use sequoia_openpgp as pgp; +use serde::{Deserialize, Serialize as JsonSerialize}; +use serde_json::{Value, json}; +use std::io::{Read, Write}; +use zeroize::Zeroizing; + +const PURPOSE: &str = "Stack private chat identity only; do not reuse"; +const FORMAT: &str = "stack-chat-file/v1;argon2id-m65536-t3-p1;aes256-gcm"; +const LIMIT: usize = 262_144; + +#[derive(Deserialize, JsonSerialize)] +#[serde(deny_unknown_fields)] +struct Identity { + origin: String, + name: String, + owner: String, + secret: Zeroizing, +} + +#[derive(Deserialize, JsonSerialize)] +#[serde(deny_unknown_fields)] +struct Envelope { + format: String, + salt: String, + nonce: String, + ciphertext: String, + tag: String, +} + +#[derive(Deserialize)] +#[serde(tag = "op", rename_all = "snake_case", deny_unknown_fields)] +enum Request { + Create { + origin: String, + name: String, + password: Zeroizing, + owner: String, + }, + Open { + file: String, + password: Zeroizing, + origin: String, + }, + Proof { + file: String, + password: Zeroizing, + origin: String, + challenge: String, + }, + Encrypt { + file: String, + password: Zeroizing, + origin: String, + recipient: String, + payload: String, + }, + DecryptBatch { + file: String, + password: Zeroizing, + origin: String, + messages: Vec, + }, + Decrypt { + file: String, + password: Zeroizing, + origin: String, + sender: String, + ciphertext: String, + }, +} + +#[derive(Deserialize)] +#[serde(deny_unknown_fields)] +struct EncryptedMessage { + sender: String, + ciphertext: String, +} + +fn decode(value: &str) -> Result> { + ensure!(value.len() <= LIMIT, "Input exceeds its limit"); + Ok(BASE64_STANDARD.decode(value)?) +} + +fn random() -> Result<[u8; N]> { + let mut bytes = [0; N]; + pgp::crypto::random(&mut bytes)?; + Ok(bytes) +} + +fn certificate(encoded: &str) -> Result { + ensure!(encoded.len() <= 65_536, "Certificate exceeds its limit"); + let cert = Cert::from_bytes(&decode(encoded)?)?; + let policy = pgp::policy::StandardPolicy::new(); + ensure!(cert.keys().count() == 2, "Unexpected number of keys"); + ensure!( + cert.primary_key().key().version() == 6 + && cert.primary_key().key().pk_algo() == PublicKeyAlgorithm::MLDSA87_Ed448, + "Wrong signing suite" + ); + ensure!( + cert.keys().subkeys().all(|key| key.key().version() == 6 + && key.key().pk_algo() == PublicKeyAlgorithm::MLKEM1024_X448), + "Wrong encryption suite" + ); + ensure!( + cert.userids().count() == 1 + && cert + .userids() + .any(|uid| uid.userid().value() == PURPOSE.as_bytes()), + "Identity is not purpose-specific" + ); + cert.with_policy(&policy, None)?.alive()?; + ensure!( + !matches!( + cert.revocation_status(&policy, None), + pgp::types::RevocationStatus::Revoked(_) + ), + "Revoked identity" + ); + ensure!( + cert.keys() + .with_policy(&policy, None) + .alive() + .revoked(false) + .for_transport_encryption() + .count() + == 1, + "Missing valid encryption subkey" + ); + Ok(cert) +} + +fn public(cert: &Cert) -> Result { + let mut encoded = Vec::new(); + cert.clone() + .strip_secret_key_material() + .serialize(&mut encoded)?; + Ok(BASE64_STANDARD.encode(encoded)) +} + +#[doc = "Validates the fixed-suite public identity and returns its full fingerprint."] +#[doc = "# Errors"] +#[doc = "Rejects secret material, invalid certificates, and every alternative key suite."] +pub fn public_identity(encoded: &str) -> Result { + let cert = certificate(encoded)?; + ensure!(!cert.is_tsk(), "Secret material is forbidden on the server"); + Ok(cert.fingerprint().to_hex()) +} + +fn derive(password: &str, salt: &[u8]) -> Result> { + ensure!( + (12..=1024).contains(&password.len()), + "Use a password of 12 to 1024 UTF-8 bytes" + ); + let params = Params::new(65_536, 3, 1, Some(32)).map_err(|error| anyhow::anyhow!("{error}"))?; + let mut key = Zeroizing::new([0; 32]); + Argon2::new(Algorithm::Argon2id, Version::V0x13, params) + .hash_password_into(password.as_bytes(), salt, key.as_mut()) + .map_err(|error| anyhow::anyhow!("{error}"))?; + Ok(key) +} + +fn protect(identity: &Identity, password: &str) -> Result { + let plaintext = Zeroizing::new(serde_json::to_vec(identity)?); + let salt = random::<16>()?; + let nonce = random::<12>()?; + let key = derive(password, &salt)?; + let mut tag = [0; 16]; + let ciphertext = encrypt_aead( + Cipher::aes_256_gcm(), + key.as_ref(), + Some(&nonce), + FORMAT.as_bytes(), + &plaintext, + &mut tag, + )?; + Ok(serde_json::to_string(&Envelope { + format: FORMAT.into(), + salt: BASE64_STANDARD.encode(salt), + nonce: BASE64_STANDARD.encode(nonce), + ciphertext: BASE64_STANDARD.encode(ciphertext), + tag: BASE64_STANDARD.encode(tag), + })?) +} + +fn unlock(file: &str, password: &str, origin: &str) -> Result<(Identity, Cert)> { + ensure!(file.len() <= LIMIT, "Identity file exceeds its limit"); + let envelope: Envelope = serde_json::from_str(file)?; + ensure!( + envelope.format == FORMAT, + "Unsupported identity file format" + ); + let salt = decode(&envelope.salt)?; + let nonce = decode(&envelope.nonce)?; + let tag = decode(&envelope.tag)?; + ensure!( + salt.len() == 16 && nonce.len() == 12 && tag.len() == 16, + "Malformed envelope" + ); + let ciphertext = decode(&envelope.ciphertext)?; + let key = derive(password, &salt)?; + let plaintext = Zeroizing::new(decrypt_aead( + Cipher::aes_256_gcm(), + key.as_ref(), + Some(&nonce), + FORMAT.as_bytes(), + &ciphertext, + &tag, + )?); + let identity: Identity = serde_json::from_slice(&plaintext)?; + ensure!( + identity.origin == origin, + "Identity file belongs to another origin" + ); + let cert = certificate(&identity.secret)?; + ensure!(cert.is_tsk(), "Missing private identity"); + public_identity(&identity.owner)?; + Ok((identity, cert)) +} + +fn create(origin: String, name: String, password: &str, owner: String) -> Result { + ensure!( + origin.len() <= 256 + && (origin.starts_with("https://") || origin.starts_with("http://127.0.0.1:")), + "Invalid origin" + ); + ensure!( + !name.trim().is_empty() && name.len() <= 80 && !name.chars().any(char::is_control), + "Use a display name of 1 to 80 UTF-8 bytes" + ); + let (cert, _revocation) = CertBuilder::new() + .set_profile(pgp::Profile::RFC9580)? + .set_cipher_suite(CipherSuite::MLDSA87_Ed448) + .set_primary_key_flags(KeyFlags::empty().set_certification().set_signing()) + .add_userid(PURPOSE) + .add_transport_encryption_subkey() + .generate()?; + let mut secret = Zeroizing::new(Vec::new()); + cert.as_tsk().serialize(&mut *secret)?; + let owner_cert = if owner.is_empty() { + public(&cert)? + } else { + public_identity(&owner)?; + owner + }; + let identity = Identity { + origin, + name, + owner: owner_cert, + secret: Zeroizing::new(BASE64_STANDARD.encode(&secret)), + }; + Ok(json!({"file":protect(&identity, password)?})) +} + +struct MessageHelper<'a> { + recipient: &'a Cert, + sender: &'a Cert, +} + +impl pgp::parse::stream::VerificationHelper for MessageHelper<'_> { + fn get_certs(&mut self, _ids: &[pgp::KeyHandle]) -> Result> { + Ok(vec![self.sender.clone().strip_secret_key_material()]) + } + fn check(&mut self, structure: pgp::parse::stream::MessageStructure<'_>) -> Result<()> { + let mut count = 0_u8; + for layer in structure { + match layer { + pgp::parse::stream::MessageLayer::SignatureGroup { results } => { + ensure!(results.len() == 1, "Exactly one signature is required"); + for result in results { + result.map_err(|_| anyhow::anyhow!("Invalid signature"))?; + } + count = count.checked_add(1).context("Too many signatures")?; + } + pgp::parse::stream::MessageLayer::Compression { .. } => { + anyhow::bail!("Compression is forbidden") + } + pgp::parse::stream::MessageLayer::Encryption { sym_algo, .. } => ensure!( + sym_algo == SymmetricAlgorithm::AES256, + "Wrong content cipher" + ), + } + } + ensure!(count == 1, "Exactly one signature group is required"); + Ok(()) + } +} + +impl pgp::parse::stream::DecryptionHelper for MessageHelper<'_> { + fn decrypt( + &mut self, + packets: &[pgp::packet::PKESK], + passwords: &[pgp::packet::SKESK], + algorithm: Option, + decrypt: &mut dyn FnMut(Option, &SessionKey) -> bool, + ) -> Result> { + ensure!( + passwords.is_empty() && packets.len() == 2, + "Expected two public-key recipients" + ); + let policy = pgp::policy::StandardPolicy::new(); + let key = self + .recipient + .keys() + .secret() + .with_policy(&policy, None) + .alive() + .revoked(false) + .for_transport_encryption() + .next() + .context("No encryption key")? + .key() + .clone(); + let mut pair = key.into_keypair()?; + for packet in packets { + if packet + .decrypt(&mut pair, algorithm) + .is_some_and(|(cipher, key)| decrypt(cipher, &key)) + { + return Ok(Some(self.recipient.clone().strip_secret_key_material())); + } + } + anyhow::bail!("Not a recipient") + } +} + +#[doc = "Verifies a detached fixed-suite authentication proof for exactly these challenge bytes."] +#[doc = "# Errors"] +#[doc = "Rejects invalid certificates, signatures, and mismatched challenges."] +pub fn verify_proof(encoded: &str, challenge: &str, signature: &str) -> Result<()> { + public_identity(encoded)?; + ensure!(challenge.len() <= 1024, "Challenge too long"); + let cert = certificate(encoded)?; + let helper = MessageHelper { + recipient: &cert, + sender: &cert, + }; + let policy = pgp::policy::StandardPolicy::new(); + let signature_bytes = decode(signature)?; + let mut verifier = DetachedVerifierBuilder::from_bytes(&signature_bytes)? + .with_policy(&policy, None, helper)?; + verifier.verify_bytes(challenge.as_bytes())?; + Ok(()) +} + +fn proof(cert: &Cert, owner: &str, origin: &str, challenge: &str) -> Result { + let prefix = format!( + "stack-chat-auth/v1\n{origin}\n{}\n", + cert.fingerprint().to_hex() + ); + ensure!( + challenge.starts_with(&prefix) && challenge.len() <= 1024, + "Challenge binding mismatch" + ); + let parts = challenge.lines().collect::>(); + ensure!(parts.len() == 6, "Malformed challenge"); + let nonce = parts.get(3).context("Missing nonce")?; + ensure!( + nonce.len() == 64 && nonce.bytes().all(|byte| byte.is_ascii_hexdigit()), + "Malformed nonce" + ); + ensure!( + parts.get(5).context("Missing owner")? == &public_identity(owner)?, + "Owner binding mismatch" + ); + let expiry = parts.get(4).context("Missing expiry")?.parse::()?; + let now = std::time::SystemTime::now() + .duration_since(std::time::UNIX_EPOCH)? + .as_secs(); + ensure!( + expiry >= now.saturating_sub(5) && expiry <= now.saturating_add(65), + "Invalid challenge expiry" + ); + let signer = cert + .primary_key() + .key() + .parts_as_secret()? + .clone() + .into_keypair()?; + let mut signature = Vec::new(); + let mut writer = Signer::new(Message::new(&mut signature), signer)? + .detached() + .build()?; + writer.write_all(challenge.as_bytes())?; + writer.finalize()?; + Ok(json!({"signature":BASE64_STANDARD.encode(signature)})) +} + +fn encrypt(cert: &Cert, other: &Cert, payload: &str) -> Result { + ensure!(payload.len() <= 16_384, "Payload exceeds its limit"); + let policy = pgp::policy::StandardPolicy::new(); + let recipients = [cert, other].into_iter().flat_map(|identity| { + identity + .keys() + .with_policy(&policy, None) + .alive() + .revoked(false) + .for_transport_encryption() + }); + let signer = cert + .primary_key() + .key() + .parts_as_secret()? + .clone() + .into_keypair()?; + let mut ciphertext = Vec::new(); + let encrypted = Encryptor::for_recipients(Message::new(&mut ciphertext), recipients) + .symmetric_algo(SymmetricAlgorithm::AES256) + .build()?; + let authenticated = Signer::new(encrypted, signer)?.build()?; + let mut writer = LiteralWriter::new(authenticated).build()?; + writer.write_all(payload.as_bytes())?; + writer.finalize()?; + Ok(json!({"ciphertext":BASE64_STANDARD.encode(ciphertext)})) +} + +fn decrypt(cert: &Cert, sender: &Cert, encoded: &str) -> Result { + let policy = pgp::policy::StandardPolicy::new(); + let helper = MessageHelper { + recipient: cert, + sender, + }; + let ciphertext_bytes = decode(encoded)?; + let mut reader = pgp::parse::stream::DecryptorBuilder::from_bytes(&ciphertext_bytes)? + .with_policy(&policy, None, helper)? + .take(16_385); + let mut plaintext = Zeroizing::new(String::new()); + reader.read_to_string(&mut plaintext)?; + ensure!(plaintext.len() <= 16_384, "Payload exceeds its limit"); + Ok(json!({"payload":*plaintext,"signer":sender.fingerprint().to_hex()})) +} + +fn encrypt_bound( + identity: &Identity, + cert: &Cert, + recipient: &str, + payload: &str, +) -> Result { + let peer = public_identity(recipient)?; + let owner = public_identity(&identity.owner)?; + let sender = cert.fingerprint().to_hex(); + let conversation = if sender == owner { + peer.as_str() + } else { + sender.as_str() + }; + ensure!( + sender == owner || peer == owner, + "Recipient differs from pinned owner" + ); + let value: Value = serde_json::from_str(payload)?; + ensure!( + value.get("v") == Some(&json!(1)) + && value.get("origin").and_then(Value::as_str) == Some(identity.origin.as_str()), + "Invalid protocol binding" + ); + ensure!( + value.get("conversation").and_then(Value::as_str) == Some(conversation) + && value.get("sender").and_then(Value::as_str) == Some(sender.as_str()), + "Invalid identity binding" + ); + match value.get("kind").and_then(Value::as_str) { + Some("profile") => ensure!( + sender != owner + && value.get("name").and_then(Value::as_str) == Some(identity.name.as_str()), + "Invalid profile binding" + ), + Some("message") => { + let id = value + .get("id") + .and_then(Value::as_str) + .context("Missing message ID")?; + let text = value + .get("text") + .and_then(Value::as_str) + .context("Missing text")?; + ensure!( + id.len() == 64 && id.bytes().all(|byte| byte.is_ascii_hexdigit()), + "Invalid message ID" + ); + ensure!( + !text.trim().is_empty() && text.len() <= 8192, + "Invalid message length" + ); + } + _ => anyhow::bail!("Invalid payload kind"), + } + encrypt(cert, &certificate(recipient)?, payload) +} + +fn handle(request: Request) -> Result { + match request { + Request::Create { + origin, + name, + password, + owner, + } => create(origin, name, &password, owner), + Request::Open { + file, + password, + origin, + } => { + let (identity, cert) = unlock(&file, &password, &origin)?; + Ok( + json!({"public":public(&cert)?, "fingerprint":cert.fingerprint().to_hex(), "owner":identity.owner, "name":identity.name}), + ) + } + Request::Proof { + file, + password, + origin, + challenge, + } => { + let (identity, cert) = unlock(&file, &password, &origin)?; + proof(&cert, &identity.owner, &origin, &challenge) + } + Request::Encrypt { + file, + password, + origin, + recipient, + payload, + } => { + let (identity, cert) = unlock(&file, &password, &origin)?; + encrypt_bound(&identity, &cert, &recipient, &payload) + } + Request::DecryptBatch { + file, + password, + origin, + messages, + } => { + ensure!(messages.len() <= 50, "Too many messages"); + let (_identity, cert) = unlock(&file, &password, &origin)?; + let results = messages + .iter() + .map(|message| -> Value { + let result = (|| -> Result { + public_identity(&message.sender)?; + decrypt(&cert, &certificate(&message.sender)?, &message.ciphertext) + })(); + result.unwrap_or_else(|_| json!({"error":"Message rejected"})) + }) + .collect::>(); + Ok(json!({"messages":results})) + } + Request::Decrypt { + file, + password, + origin, + sender, + ciphertext, + } => { + let (_identity, cert) = unlock(&file, &password, &origin)?; + public_identity(&sender)?; + decrypt(&cert, &certificate(&sender)?, &ciphertext) + } + } +} + +#[doc = "Runs one bounded browser crypto request, without filesystem or network access."] +#[doc = "# Errors"] +#[doc = "Returns a generic failure without including credentials or plaintext."] +pub fn dispatch(request: &str) -> std::result::Result { + let result = (|| -> Result { + ensure!(request.len() <= 8_388_608, "Request exceeds its limit"); + Ok(serde_json::to_string(&handle(serde_json::from_str( + request, + )?)?)?) + })(); + result.map_err(|_| { + "Cryptographic operation failed. Check the file, password, origin, and sender.".into() + }) +} + +#[cfg(test)] +mod tests { + use super::*; + + fn reject_forged_bindings(identity: &Identity, cert: &Cert, owner: &Cert) -> Result<()> { + let fingerprint = cert.fingerprint().to_hex(); + let mut payload = json!({"v":1,"kind":"message","origin":identity.origin,"conversation":fingerprint,"sender":fingerprint,"id":"b".repeat(64),"text":"Bound message"}); + let owner_public = public(owner)?; + encrypt_bound(identity, cert, &owner_public, &payload.to_string())?; + ensure!( + encrypt_bound(identity, cert, &public(cert)?, &payload.to_string()).is_err(), + "Unpinned recipient accepted" + ); + *payload.get_mut("sender").context("Sender")? = json!(owner.fingerprint().to_hex()); + ensure!( + encrypt_bound(identity, cert, &owner_public, &payload.to_string()).is_err(), + "Forged sender accepted" + ); + *payload.get_mut("sender").context("Sender")? = json!(fingerprint); + *payload.get_mut("conversation").context("Conversation")? = + json!(owner.fingerprint().to_hex()); + ensure!( + encrypt_bound(identity, cert, &owner_public, &payload.to_string()).is_err(), + "Wrong conversation accepted" + ); + Ok(()) + } + + #[test] + fn alternative_suites_and_malformed_envelopes_are_rejected() -> Result<()> { + let (legacy, _revocation) = CertBuilder::new() + .add_userid(PURPOSE) + .add_transport_encryption_subkey() + .generate()?; + ensure!( + public_identity(&public(&legacy)?).is_err(), + "Classical fallback accepted" + ); + let invalid = serde_json::to_string(&Envelope { + format: FORMAT.into(), + salt: BASE64_STANDARD.encode([0; 15]), + nonce: BASE64_STANDARD.encode([0; 12]), + ciphertext: String::new(), + tag: BASE64_STANDARD.encode([0; 16]), + })?; + ensure!( + unlock(&invalid, "test-password-long", "http://127.0.0.1:9080").is_err(), + "Malformed salt accepted" + ); + ensure!( + unlock("{}", "test-password-long", "http://127.0.0.1:9080").is_err(), + "Malformed file accepted" + ); + Ok(()) + } + + #[test] + fn protected_file_and_authenticated_messages() -> Result<()> { + let origin = "http://127.0.0.1:9080"; + let password = "test-only-long-passphrase"; + let owner = create(origin.into(), "Owner".into(), password, String::new())?; + let owner_file = owner + .get("file") + .context("Missing field")? + .as_str() + .context("File")?; + let (owner_identity, owner_cert) = unlock(owner_file, password, origin)?; + let owner_public = public(&owner_cert)?; + let visitor = create(origin.into(), "Visitor".into(), password, owner_public)?; + let visitor_file = visitor + .get("file") + .context("Missing field")? + .as_str() + .context("File")?; + let (visitor_identity, visitor_cert) = unlock(visitor_file, password, origin)?; + ensure!( + !owner_file.contains("Owner") && !owner_file.contains(owner_identity.secret.as_str()), + "Leaking envelope" + ); + ensure!( + unlock(visitor_file, "wrong-passphrase", origin).is_err(), + "Wrong password accepted" + ); + ensure!( + unlock(visitor_file, password, "https://elsewhere.invalid").is_err(), + "Wrong origin accepted" + ); + reject_forged_bindings(&visitor_identity, &visitor_cert, &owner_cert)?; + let ciphertext = encrypt(&visitor_cert, &owner_cert, "plain text ")?; + let encoded = ciphertext + .get("ciphertext") + .context("Missing field")? + .as_str() + .context("Ciphertext")?; + ensure!( + decrypt(&owner_cert, &visitor_cert, encoded)? + .get("payload") + .context("Missing payload")? + == "plain text ", + "Round trip" + ); + ensure!( + decrypt(&visitor_cert, &visitor_cert, encoded).is_ok(), + "Own history" + ); + ensure!( + decrypt(&owner_cert, &owner_cert, encoded).is_err(), + "Wrong sender accepted" + ); + let expiry = std::time::SystemTime::now() + .duration_since(std::time::UNIX_EPOCH)? + .as_secs() + .checked_add(60) + .context("Clock")?; + let nonce = "a".repeat(64); + let challenge = format!( + "stack-chat-auth/v1\n{origin}\n{}\n{nonce}\n{expiry}\n{}", + visitor_cert.fingerprint().to_hex(), + owner_cert.fingerprint().to_hex() + ); + let signed = proof(&visitor_cert, &public(&owner_cert)?, origin, &challenge)?; + verify_proof( + &public(&visitor_cert)?, + &challenge, + signed + .get("signature") + .context("Missing field")? + .as_str() + .context("Proof")?, + )?; + ensure!( + verify_proof( + &public(&visitor_cert)?, + "altered", + signed + .get("signature") + .context("Missing field")? + .as_str() + .context("Proof")? + ) + .is_err(), + "Proof binding" + ); + ensure!( + public_identity(&owner_identity.secret).is_err(), + "Server accepted secrets" + ); + Ok(()) + } +} diff --git a/crates/chat-crypto/src/lib.rs b/crates/chat-crypto/src/lib.rs index 62319e2..7274c43 100644 --- a/crates/chat-crypto/src/lib.rs +++ b/crates/chat-crypto/src/lib.rs @@ -13,7 +13,11 @@ // You should have received a copy of the GNU Affero General Public License // along with this program. If not, see . -#![doc = "Experimental Sequoia post-quantum browser compatibility probe."] +#![doc = "Fixed-suite `OpenPGP` operations for purpose-specific private chat."] + +mod application; + +pub use application::{dispatch, public_identity, verify_proof}; use anyhow::{Context, Result, ensure}; use pgp::Profile; diff --git a/crates/chat-crypto/src/main.rs b/crates/chat-crypto/src/main.rs index b6749cf..9ecfe22 100644 --- a/crates/chat-crypto/src/main.rs +++ b/crates/chat-crypto/src/main.rs @@ -13,11 +13,23 @@ // You should have received a copy of the GNU Affero General Public License // along with this program. If not, see . -#![doc = "Command entry point for the isolated cryptographic compatibility probe."] +#![doc = "Isolated WASI command entry point for chat cryptography."] -use std::io::{self, Write}; +use std::io::{self, Read, Write}; fn main() -> io::Result<()> { - let result = stack_chat_crypto::compatibility_probe().map_err(io::Error::other)?; + let mut request = zeroize::Zeroizing::new(String::new()); + io::stdin() + .lock() + .take(8_388_609) + .read_to_string(&mut request)?; + let result = if request.is_empty() { + stack_chat_crypto::compatibility_probe().map_err(io::Error::other)? + } else { + match stack_chat_crypto::dispatch(&request) { + Ok(output) => output, + Err(error) => serde_json::to_string(&serde_json::json!({"error":error}))?, + } + }; writeln!(io::stdout().lock(), "{result}") } diff --git a/crates/chat-crypto/tests/browser.mjs b/crates/chat-crypto/tests/browser.mjs index 8d2a9bb..4fd2ee5 100644 --- a/crates/chat-crypto/tests/browser.mjs +++ b/crates/chat-crypto/tests/browser.mjs @@ -47,7 +47,8 @@ try { }); await page.goto(origin); const result = await page.evaluate(async () => { - const { runProbe } = await import('/wasi.mjs'); + await import('/wasi.mjs'); + const { runProbe } = globalThis.stackChatWasi; const bytes = await (await fetch('/probe.wasm', { cache: 'no-store' })).arrayBuffer(); const valid = await runProbe(bytes); const again = await runProbe(bytes); diff --git a/crates/chat-server/Cargo.toml b/crates/chat-server/Cargo.toml new file mode 100644 index 0000000..eecebf6 --- /dev/null +++ b/crates/chat-server/Cargo.toml @@ -0,0 +1,46 @@ +# Copyright (C) 2026 Doan Luu Duc Tai +# +# This program is free software: you can redistribute it and/or modify +# it under the terms of the GNU Affero General Public License as +# published by the Free Software Foundation, either version 3 of the +# License, or (at your option) any later version. +# +# This program is distributed in the hope that it will be useful, +# but WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +# GNU Affero General Public License for more details. +# +# You should have received a copy of the GNU Affero General Public License +# along with this program. If not, see . + +[package] +name = "stack-chat-server" +version = "0.1.0" +edition = "2024" +license = "AGPL-3.0-or-later" +authors = ["Doan Luu Duc Tai "] +description = "Encrypted private chat storage and authenticated access." +repository = "https://git.doan.luuduct.ai/stack" +readme = "README.txt" +keywords = ["chat", "openpgp", "privacy"] +categories = ["web-programming"] +publish = false + +[dependencies] +anyhow = "1" +axum = { version = "0.8", default-features = false, features = ["http1", "tokio", "json"] } +base64 = "0.22" +clap = { version = "4.6", features = ["derive"] } +dotenvy = "0.15" +hex = "0.4" +openssl = "0.10" +rusqlite = { version = "0.41", default-features = false, features = ["bundled", "backup"] } +serde_json = "1" +stack-chat-crypto = { path = "../chat-crypto", version = "0.1.0" } +tokio = { version = "1", features = ["rt-multi-thread", "net", "macros", "signal", "sync", "fs"] } + +[dev-dependencies] +tempfile = "3" + +[lints] +workspace = true diff --git a/crates/chat-server/README.txt b/crates/chat-server/README.txt new file mode 100644 index 0000000..a7aba5d --- /dev/null +++ b/crates/chat-server/README.txt @@ -0,0 +1,22 @@ +Copyright (C) 2026 Doan Luu Duc Tai + +This program is free software: you can redistribute it and/or modify +it under the terms of the GNU Affero General Public License as +published by the Free Software Foundation, either version 3 of the +License, or (at your option) any later version. + +This program is distributed in the hope that it will be useful, +but WITHOUT ANY WARRANTY; without even the implied warranty of +MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +GNU Affero General Public License for more details. + +You should have received a copy of the GNU Affero General Public License +along with this program. If not, see . + + +EXPERIMENTAL PRIVATE CHAT SERVER + + See docs/CHAT-APPLICATION.txt at the workspace root for local setup, + file formats, API/security limits, backups, recovery, source licensing, + and tests. Start with cargo stack chat --init, then cargo stack chat. + Production is intentionally not configured on this feature branch. diff --git a/crates/chat-server/src/http.rs b/crates/chat-server/src/http.rs new file mode 100644 index 0000000..bb618d9 --- /dev/null +++ b/crates/chat-server/src/http.rs @@ -0,0 +1,160 @@ +// Copyright (C) 2026 Doan Luu Duc Tai +// +// This program is free software: you can redistribute it and/or modify +// it under the terms of the GNU Affero General Public License as +// published by the Free Software Foundation, either version 3 of the +// License, or (at your option) any later version. +// +// This program is distributed in the hope that it will be useful, +// but WITHOUT ANY WARRANTY; without even the implied warranty of +// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +// GNU Affero General Public License for more details. +// +// You should have received a copy of the GNU Affero General Public License +// along with this program. If not, see . + +use crate::service::Service; +use anyhow::Result; +use axum::{ + Json, Router, + extract::{DefaultBodyLimit, State}, + http::{HeaderMap, HeaderValue, StatusCode}, + response::{IntoResponse, Response}, + routing::{get, post}, +}; +use serde_json::{Value, json}; +use std::path::{Path, PathBuf}; +use std::sync::{Arc, Mutex}; +use tokio::sync::Semaphore; + +#[derive(Clone)] +struct App { + service: Arc>, + permits: Arc, + root: PathBuf, + origin: String, +} + +fn secured(mut response: Response) -> Response { + for (name, value) in [ + ("cache-control", "no-store, max-age=0"), + ( + "content-security-policy", + "default-src 'none'; script-src 'self' 'wasm-unsafe-eval'; worker-src 'self'; connect-src 'self'; style-src 'self'; img-src 'none'; base-uri 'none'; form-action 'none'; frame-ancestors 'none'; object-src 'none'", + ), + ("referrer-policy", "no-referrer"), + ("x-content-type-options", "nosniff"), + ("cross-origin-opener-policy", "same-origin"), + ("cross-origin-resource-policy", "same-origin"), + ( + "permissions-policy", + "camera=(), microphone=(), geolocation=(), payment=(), usb=()", + ), + ] { + response + .headers_mut() + .insert(name, HeaderValue::from_static(value)); + } + response +} + +async fn api(State(app): State, headers: HeaderMap, Json(request): Json) -> Response { + if headers.get("origin").and_then(|value| value.to_str().ok()) != Some(app.origin.as_str()) { + return secured( + ( + StatusCode::FORBIDDEN, + Json(json!({"error":"Origin rejected"})), + ) + .into_response(), + ); + } + let Ok(permit) = Arc::clone(&app.permits).try_acquire_owned() else { + return secured( + ( + StatusCode::TOO_MANY_REQUESTS, + Json(json!({"error":"Server busy"})), + ) + .into_response(), + ); + }; + let result = tokio::task::spawn_blocking(move || { + let _permit = permit; + app.service + .lock() + .map_err(|_| anyhow::anyhow!("Service unavailable"))? + .call(&request) + }) + .await; + match result { + Ok(Ok(value)) => secured(Json(value).into_response()), + _ => secured((StatusCode::BAD_REQUEST,Json(json!({"error":"Request rejected. Unlock again if your session expired; check credentials, message limits, and conversation access."}))).into_response()), + } +} + +async fn asset(State(app): State, uri: axum::http::Uri) -> Response { + let (path, mime) = match uri.path() { + "/" => ( + "crates/chat-server/web/index.html", + "text/html; charset=utf-8", + ), + "/app.js" => ( + "crates/chat-server/web/app.js", + "text/javascript; charset=utf-8", + ), + "/worker.js" => ( + "crates/chat-server/web/worker.js", + "text/javascript; charset=utf-8", + ), + "/wasi.mjs" => ( + "crates/chat-crypto/browser/wasi.mjs", + "text/javascript; charset=utf-8", + ), + "/style.css" => ( + "crates/chat-server/web/style.css", + "text/css; charset=utf-8", + ), + "/crypto.wasm" => ( + "target/wasm32-wasip1/release/stack-chat-crypto.wasm", + "application/wasm", + ), + "/javascript.html" => ( + "crates/chat-server/web/javascript.html", + "text/html; charset=utf-8", + ), + "/SOURCE.tar.gz" => ("var/build/chat-source.tar.gz", "application/gzip"), + "/THIRD-PARTY-LICENSES.html" => { + ("web/THIRD-PARTY-LICENSES.html", "text/html; charset=utf-8") + } + "/LIMITATIONS.txt" => ("docs/CHAT-APPLICATION.txt", "text/plain; charset=utf-8"), + "/LICENSE" => ("LICENSE", "text/plain; charset=utf-8"), + _ => return secured(StatusCode::NOT_FOUND.into_response()), + }; + tokio::fs::read(app.root.join(path)).await.map_or_else( + |_| secured(StatusCode::NOT_FOUND.into_response()), + |bytes| secured(([("content-type", mime)], bytes).into_response()), + ) +} + +pub(crate) async fn serve( + listener: tokio::net::TcpListener, + service: Service, + root: &Path, +) -> Result<()> { + let app = App { + origin: service.origin.clone(), + service: Arc::new(Mutex::new(service)), + permits: Arc::new(Semaphore::new(4)), + root: root.to_path_buf(), + }; + let routes = Router::new() + .route("/api", post(api)) + .fallback(get(asset)) + .layer(DefaultBodyLimit::max(262_144)) + .with_state(app); + axum::serve(listener, routes) + .with_graceful_shutdown(async { + drop(tokio::signal::ctrl_c().await); + }) + .await?; + Ok(()) +} diff --git a/crates/chat-server/src/main.rs b/crates/chat-server/src/main.rs new file mode 100644 index 0000000..6c7f4e1 --- /dev/null +++ b/crates/chat-server/src/main.rs @@ -0,0 +1,129 @@ +// Copyright (C) 2026 Doan Luu Duc Tai +// +// This program is free software: you can redistribute it and/or modify +// it under the terms of the GNU Affero General Public License as +// published by the Free Software Foundation, either version 3 of the +// License, or (at your option) any later version. +// +// This program is distributed in the hope that it will be useful, +// but WITHOUT ANY WARRANTY; without even the implied warranty of +// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +// GNU Affero General Public License for more details. +// +// You should have received a copy of the GNU Affero General Public License +// along with this program. If not, see . + +#![doc = "Local experimental chat server with ciphertext-only durable storage."] + +mod http; +mod service; +mod store; + +use anyhow::{Context, Result, ensure}; +use clap::Parser; +use std::io::Write; +use std::net::SocketAddr; +use std::path::PathBuf; +use std::process::ExitCode; + +#[derive(Debug, Parser)] +#[command(about = "Run the experimental private chat locally; production is not enabled")] +struct Arguments { + #[arg(long, default_value = ".")] + root: PathBuf, + #[arg(long, default_value = "127.0.0.1:9080")] + bind: SocketAddr, + #[arg(long)] + init: bool, + #[arg(long)] + snapshot: Option, +} + +fn initialize(root: &std::path::Path) -> Result<()> { + let env = root.join(".env"); + let existing = if env.exists() { + std::fs::read_to_string(&env)? + } else { + String::new() + }; + ensure!( + !existing + .lines() + .any(|line| line.starts_with("CHAT_SETUP_TOKEN=")), + "CHAT_SETUP_TOKEN already exists" + ); + let mut file = std::fs::OpenOptions::new() + .create(true) + .append(true) + .open(&env)?; + std::fs::set_permissions(&env, std::os::unix::fs::PermissionsExt::from_mode(0o600))?; + writeln!(file, "\nCHAT_SETUP_TOKEN={}", service::token()?)?; + if !existing + .lines() + .any(|line| line.starts_with("CHAT_ORIGIN=")) + { + writeln!(file, "CHAT_ORIGIN=http://127.0.0.1:9080")?; + } + Ok(()) +} + +async fn run(arguments: Arguments) -> Result<()> { + let root = arguments.root.canonicalize()?; + if arguments.init { + return initialize(&root); + } + let data = root.join("var/data/chat"); + std::fs::create_dir_all(&data)?; + std::fs::set_permissions(&data, std::os::unix::fs::PermissionsExt::from_mode(0o700))?; + let database = data.join("chat.sqlite"); + let store = store::Store::open(&database)?; + std::fs::set_permissions( + &database, + std::os::unix::fs::PermissionsExt::from_mode(0o600), + )?; + if let Some(destination) = arguments.snapshot { + store.snapshot(&destination)?; + std::fs::set_permissions( + destination, + std::os::unix::fs::PermissionsExt::from_mode(0o600), + )?; + return Ok(()); + } + ensure!( + arguments.bind.ip().is_loopback(), + "Experimental server must bind to loopback" + ); + let settings = dotenvy::from_path_iter(root.join(".env"))? + .collect::, _>>()?; + let origin = settings + .get("CHAT_ORIGIN") + .context("Run --init to configure chat")?; + ensure!( + origin == &format!("http://{}", arguments.bind), + "This development server requires CHAT_ORIGIN to match its loopback bind address" + ); + let setup = settings + .get("CHAT_SETUP_TOKEN") + .context("Missing CHAT_SETUP_TOKEN")?; + let state = service::Service::new(store, origin.clone(), setup.clone()); + let listener = tokio::net::TcpListener::bind(arguments.bind).await?; + writeln!( + std::io::stdout().lock(), + "Experimental chat: {origin} (owner setup: /#setup)" + )?; + http::serve(listener, state, &root).await +} + +#[tokio::main] +async fn main() -> ExitCode { + match run(Arguments::parse()).await { + Ok(()) => ExitCode::SUCCESS, + Err(error) => { + drop(writeln!( + std::io::stderr().lock(), + "Chat server failed: {error:#}" + )); + ExitCode::FAILURE + } + } +} diff --git a/crates/chat-server/src/service.rs b/crates/chat-server/src/service.rs new file mode 100644 index 0000000..3f9b6ee --- /dev/null +++ b/crates/chat-server/src/service.rs @@ -0,0 +1,438 @@ +// Copyright (C) 2026 Doan Luu Duc Tai +// +// This program is free software: you can redistribute it and/or modify +// it under the terms of the GNU Affero General Public License as +// published by the Free Software Foundation, either version 3 of the +// License, or (at your option) any later version. +// +// This program is distributed in the hope that it will be useful, +// but WITHOUT ANY WARRANTY; without even the implied warranty of +// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +// GNU Affero General Public License for more details. +// +// You should have received a copy of the GNU Affero General Public License +// along with this program. If not, see . + +use crate::store::Store; +use anyhow::{Context, Result, ensure}; +use base64::{Engine, prelude::BASE64_STANDARD}; +use serde_json::{Value, json}; +use std::collections::HashMap; +use std::time::{Duration, Instant}; + +struct Challenge { + certificate: String, + text: String, + expiry: Instant, + setup: bool, +} + +struct Session { + identity: String, + expiry: Instant, +} + +pub(crate) struct Service { + pub(crate) store: Store, + pub(crate) origin: String, + setup_token: String, + challenges: HashMap, + sessions: HashMap, + window: Instant, + attempts: u16, +} + +pub(crate) fn token() -> Result { + let mut bytes = [0; 32]; + openssl::rand::rand_bytes(&mut bytes)?; + Ok(hex::encode(bytes)) +} + +pub(crate) fn field<'a>(request: &'a Value, key: &str) -> Result<&'a str> { + request + .get(key) + .and_then(Value::as_str) + .context("Missing string field") +} + +fn ciphertext(request: &Value) -> Result<&str> { + let encoded = field(request, "ciphertext")?; + ensure!( + encoded.len() <= 65_536 && BASE64_STANDARD.decode(encoded)?.len() >= 64, + "Invalid ciphertext length" + ); + Ok(encoded) +} + +fn identifier(value: &str) -> Result<()> { + ensure!( + value.len() == 64 && value.bytes().all(|byte| byte.is_ascii_hexdigit()), + "Invalid identifier" + ); + Ok(()) +} + +impl Service { + pub(crate) fn new(store: Store, origin: String, setup_token: String) -> Self { + Self { + store, + origin, + setup_token, + challenges: HashMap::new(), + sessions: HashMap::new(), + window: Instant::now(), + attempts: 0, + } + } + + fn owner_id(&self) -> Result { + stack_chat_crypto::public_identity(&self.store.owner()?.context("Owner not initialized")?) + } + + fn challenge(&mut self, request: &Value) -> Result { + if self.window.elapsed() >= Duration::from_secs(60) { + self.window = Instant::now(); + self.attempts = 0; + } + ensure!(self.attempts < 60, "Authentication rate exceeded"); + self.attempts = self + .attempts + .checked_add(1) + .context("Rate counter overflow")?; + self.challenges + .retain(|_, challenge| challenge.expiry > Instant::now()); + ensure!(self.challenges.len() < 128, "Too many pending challenges"); + let certificate = field(request, "public")?; + let fingerprint = stack_chat_crypto::public_identity(certificate)?; + let setup = self.store.owner()?.is_none(); + if setup { + let supplied = field(request, "setup_token")?; + ensure!( + self.setup_token.len() == 64 + && supplied.len() == 64 + && openssl::memcmp::eq(supplied.as_bytes(), self.setup_token.as_bytes()), + "Invalid setup credential" + ); + } + let nonce = token()?; + let expires = std::time::SystemTime::now() + .duration_since(std::time::UNIX_EPOCH)? + .as_secs() + .checked_add(60) + .context("Clock overflow")?; + let owner = if setup { + fingerprint.clone() + } else { + self.owner_id()? + }; + let text = format!( + "stack-chat-auth/v1\n{}\n{fingerprint}\n{nonce}\n{expires}\n{owner}", + self.origin + ); + self.challenges.insert( + nonce.clone(), + Challenge { + certificate: certificate.into(), + text: text.clone(), + expiry: Instant::now() + .checked_add(Duration::from_secs(60)) + .context("Clock overflow")?, + setup, + }, + ); + Ok(json!({"id":nonce,"challenge":text})) + } + + fn authenticate(&mut self, request: &Value) -> Result { + let challenge = self + .challenges + .remove(field(request, "id")?) + .context("Missing or consumed challenge")?; + ensure!(challenge.expiry > Instant::now(), "Expired challenge"); + stack_chat_crypto::verify_proof( + &challenge.certificate, + &challenge.text, + field(request, "signature")?, + )?; + let identity = stack_chat_crypto::public_identity(&challenge.certificate)?; + if challenge.setup { + self.store.install_owner(&challenge.certificate)?; + self.setup_token.clear(); + } + let owner = identity == self.owner_id()?; + if !owner { + self.store.register(&identity, &challenge.certificate)?; + } + self.sessions + .retain(|_, session| session.expiry > Instant::now()); + ensure!(self.sessions.len() < 256, "Too many sessions"); + let credential = token()?; + self.sessions.insert( + credential.clone(), + Session { + identity: identity.clone(), + expiry: Instant::now() + .checked_add(Duration::from_mins(30)) + .context("Clock overflow")?, + }, + ); + Ok(json!({"token":credential,"owner":owner,"id":identity,"expires_seconds":1800})) + } + + fn principal(&mut self, request: &Value) -> Result { + self.sessions + .retain(|_, session| session.expiry > Instant::now()); + Ok(self + .sessions + .get(field(request, "token")?) + .context("Missing or expired session")? + .identity + .clone()) + } + + fn authorized(&self, request: &Value, identity: &str) -> Result { + let conversation = field(request, "conversation")?; + identifier(conversation)?; + ensure!( + identity == conversation || identity == self.owner_id()?, + "Forbidden conversation" + ); + self.store.conversation(conversation)?; + Ok(conversation.into()) + } + + pub(crate) fn call(&mut self, request: &Value) -> Result { + match field(request, "op")? { + "config" => Ok( + json!({"owner":self.store.owner()?,"origin":self.origin,"protocol":"stack-chat/v1"}), + ), + "challenge" => self.challenge(request), + "authenticate" => self.authenticate(request), + _ => self.authenticated(request), + } + } + + fn authenticated(&mut self, request: &Value) -> Result { + let identity = self.principal(request)?; + let operation = field(request, "op")?; + if operation == "lock" { + self.sessions.remove(field(request, "token")?); + return Ok(json!({"locked":true})); + } + if operation == "inbox" { + ensure!(identity == self.owner_id()?, "Owner only"); + return self.store.inbox(); + } + let conversation = self.authorized(request, &identity)?; + match operation { + "history" => self.store.messages( + &conversation, + request + .get("count") + .and_then(Value::as_u64) + .context("Missing count")?, + request.get("before").and_then(Value::as_u64).unwrap_or(0), + ), + "profile" => { + ensure!(identity == conversation, "Visitor only"); + self.store.profile(&conversation, ciphertext(request)?)?; + Ok(json!({"stored":true})) + } + "send" => { + let id = field(request, "id")?; + identifier(id)?; + self.store + .send(&conversation, id, &identity, ciphertext(request)?) + } + _ => anyhow::bail!("Unknown operation"), + } + } +} + +#[cfg(test)] +mod tests { + use super::*; + use tempfile::TempDir; + + struct Identity { + file: String, + public: String, + fingerprint: String, + } + + fn crypto(request: &Value) -> Result { + let response = + stack_chat_crypto::dispatch(&request.to_string()).map_err(anyhow::Error::msg)?; + Ok(serde_json::from_str(&response)?) + } + + fn identity(name: &str, owner: &str) -> Result { + let created = crypto( + &json!({"op":"create","origin":"http://127.0.0.1:9080","name":name,"password":"test-only-strong-password","owner":owner}), + )?; + let file = field(&created, "file")?; + let opened = crypto( + &json!({"op":"open","origin":"http://127.0.0.1:9080","file":file,"password":"test-only-strong-password"}), + )?; + Ok(Identity { + file: file.into(), + public: field(&opened, "public")?.into(), + fingerprint: field(&opened, "fingerprint")?.into(), + }) + } + + fn authenticate(service: &mut Service, identity: &Identity, setup: &str) -> Result { + let challenge = service + .call(&json!({"op":"challenge","public":identity.public,"setup_token":setup}))?; + let proof = crypto( + &json!({"op":"proof","file":identity.file,"password":"test-only-strong-password","origin":service.origin,"challenge":field(&challenge,"challenge")?}), + )?; + let request = json!({"op":"authenticate","id":field(&challenge,"id")?,"signature":field(&proof,"signature")?}); + let result = service.call(&request)?; + ensure!(service.call(&request).is_err(), "Challenge was replayed"); + Ok(result) + } + + fn reject_expired_and_failed_proofs(service: &mut Service, identity: &Identity) -> Result<()> { + let challenge = service.call(&json!({"op":"challenge","public":identity.public}))?; + let challenge_id = field(&challenge, "id")?; + service + .challenges + .get_mut(challenge_id) + .context("Challenge")? + .expiry = Instant::now(); + let expired = json!({"op":"authenticate","id":challenge_id,"signature":""}); + ensure!( + service.call(&expired).is_err(), + "Expired challenge accepted" + ); + ensure!( + !service.challenges.contains_key(challenge_id), + "Expired challenge retained" + ); + let invalid = service.call(&json!({"op":"challenge","public":identity.public}))?; + let invalid_id = field(&invalid, "id")?; + ensure!( + service + .call(&json!({"op":"authenticate","id":invalid_id,"signature":""})) + .is_err(), + "Invalid proof accepted" + ); + ensure!( + !service.challenges.contains_key(invalid_id), + "Failed challenge reusable" + ); + service.attempts = 60; + ensure!( + service + .call(&json!({"op":"challenge","public":identity.public})) + .is_err(), + "Challenge rate bound" + ); + ensure!( + service.store.install_owner(&identity.public).is_err(), + "Owner overwritten" + ); + Ok(()) + } + + #[test] + fn authorization_replays_history_and_snapshot_recovery() -> Result<()> { + let directory = TempDir::new()?; + let path = directory.path().join("chat.sqlite"); + let setup = token()?; + let mut service = Service::new( + Store::open(&path)?, + "http://127.0.0.1:9080".into(), + setup.clone(), + ); + let owner = identity("Owner", "")?; + ensure!( + service + .call(&json!({"op":"challenge","public":owner.public,"setup_token":"wrong"})) + .is_err(), + "Setup unauthenticated" + ); + let owner_session = authenticate(&mut service, &owner, &setup)?; + ensure!( + owner_session.get("owner") == Some(&json!(true)), + "Owner role" + ); + let visitor = identity("Visitor", &owner.public)?; + let visitor_session = authenticate(&mut service, &visitor, "")?; + let visitor_token = field(&visitor_session, "token")?; + let owner_token = field(&owner_session, "token")?; + let ciphertext = BASE64_STANDARD.encode([42; 128]); + let message_id = token()?; + let send = json!({"op":"send","conversation":visitor.fingerprint,"token":visitor_token,"id":message_id,"ciphertext":ciphertext}); + service.call(&send)?; + service.call(&send)?; + let conflicting = json!({"op":"send","conversation":visitor.fingerprint,"token":visitor_token,"id":message_id,"ciphertext":BASE64_STANDARD.encode([43;128])}); + ensure!( + service.call(&conflicting).is_err(), + "Conflicting ID accepted" + ); + let empty = service.call(&json!({"op":"history","conversation":visitor.fingerprint,"token":visitor_token,"count":0}))?; + ensure!( + empty + .get("messages") + .and_then(Value::as_array) + .is_some_and(Vec::is_empty), + "N=0" + ); + ensure!( + empty + .get("conversation") + .and_then(|value| value.get("total")) + == Some(&json!(1)), + "Retry duplicated message" + ); + ensure!( + service + .call(&json!({"op":"inbox","token":visitor_token})) + .is_err(), + "Visitor read inbox" + ); + ensure!(service.call(&json!({"op":"history","conversation":owner.fingerprint,"token":visitor_token,"count":1})).is_err(), "Wrong conversation accepted"); + service.call(&json!({"op":"send","conversation":visitor.fingerprint,"token":owner_token,"id":token()?,"ciphertext":ciphertext}))?; + let latest = service.call(&json!({"op":"history","conversation":visitor.fingerprint,"token":visitor_token,"count":1}))?; + let sequence = latest + .get("messages") + .and_then(Value::as_array) + .and_then(|messages| messages.first()) + .and_then(|message| message.get("seq")) + .context("Sequence")?; + let older = service.call(&json!({"op":"history","conversation":visitor.fingerprint,"token":visitor_token,"count":1,"before":sequence}))?; + ensure!( + older + .get("messages") + .and_then(Value::as_array) + .is_some_and(|messages| messages.len() == 1), + "Pagination" + ); + ensure!(service.call(&json!({"op":"history","conversation":visitor.fingerprint,"token":visitor_token,"count":51})).is_err(), "History quota"); + reject_expired_and_failed_proofs(&mut service, &visitor)?; + let snapshot = directory.path().join("snapshot.sqlite"); + service.store.snapshot(&snapshot)?; + let restored = Store::open(&snapshot)?; + ensure!( + restored.owner()? == Some(owner.public), + "Owner missing after restore" + ); + ensure!( + restored.conversation(&visitor.fingerprint)?.get("total") == Some(&json!(2)), + "Restored count" + ); + service.call(&json!({"op":"lock","token":visitor_token}))?; + ensure!(service.call(&json!({"op":"history","conversation":visitor.fingerprint,"token":visitor_token,"count":0})).is_err(), "Locked session accepted"); + let session = service.sessions.get_mut(owner_token).context("Session")?; + session.expiry = Instant::now(); + ensure!( + service + .call(&json!({"op":"inbox","token":owner_token})) + .is_err(), + "Expired session accepted" + ); + Ok(()) + } +} diff --git a/crates/chat-server/src/store.rs b/crates/chat-server/src/store.rs new file mode 100644 index 0000000..fce3bb3 --- /dev/null +++ b/crates/chat-server/src/store.rs @@ -0,0 +1,169 @@ +// Copyright (C) 2026 Doan Luu Duc Tai +// +// This program is free software: you can redistribute it and/or modify +// it under the terms of the GNU Affero General Public License as +// published by the Free Software Foundation, either version 3 of the +// License, or (at your option) any later version. +// +// This program is distributed in the hope that it will be useful, +// but WITHOUT ANY WARRANTY; without even the implied warranty of +// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +// GNU Affero General Public License for more details. +// +// You should have received a copy of the GNU Affero General Public License +// along with this program. If not, see . + +use anyhow::{Context, Result, ensure}; +use rusqlite::{Connection, OptionalExtension, params}; +use serde_json::{Value, json}; +use std::path::Path; + +pub(crate) struct Store { + connection: Connection, +} + +impl Store { + pub(crate) fn open(path: &Path) -> Result { + let connection = Connection::open(path)?; + connection.execute_batch("PRAGMA foreign_keys=ON; PRAGMA journal_mode=DELETE; PRAGMA synchronous=FULL; PRAGMA secure_delete=ON; CREATE TABLE IF NOT EXISTS metadata (key TEXT PRIMARY KEY, value TEXT NOT NULL); CREATE TABLE IF NOT EXISTS conversations (id TEXT PRIMARY KEY, cert TEXT NOT NULL, profile TEXT); CREATE TABLE IF NOT EXISTS messages (seq INTEGER PRIMARY KEY AUTOINCREMENT, conversation TEXT NOT NULL REFERENCES conversations(id), id TEXT NOT NULL UNIQUE, sender TEXT NOT NULL, ciphertext TEXT NOT NULL);")?; + Ok(Self { connection }) + } + + pub(crate) fn owner(&self) -> Result> { + Ok(self + .connection + .query_row("SELECT value FROM metadata WHERE key='owner'", [], |row| { + row.get(0) + }) + .optional()?) + } + + pub(crate) fn install_owner(&self, cert: &str) -> Result<()> { + ensure!(self.owner()?.is_none(), "Already initialized"); + self.connection + .execute("INSERT INTO metadata VALUES ('owner', ?1)", [cert])?; + Ok(()) + } + + pub(crate) fn register(&self, id: &str, cert: &str) -> Result<()> { + if self.connection.query_row( + "SELECT EXISTS(SELECT 1 FROM conversations WHERE id=?1)", + [id], + |row| row.get::<_, bool>(0), + )? { + return Ok(()); + } + let count: i64 = + self.connection + .query_row("SELECT COUNT(*) FROM conversations", [], |row| row.get(0))?; + ensure!(count < 1000, "Conversation limit reached"); + self.connection.execute( + "INSERT INTO conversations (id,cert) VALUES (?1,?2)", + params![id, cert], + )?; + Ok(()) + } + + pub(crate) fn conversation(&self, id: &str) -> Result { + let (cert, profile): (String, Option) = self.connection.query_row( + "SELECT cert,profile FROM conversations WHERE id=?1", + [id], + |row| Ok((row.get(0)?, row.get(1)?)), + )?; + let (total, visitor): (i64, i64) = self.connection.query_row( + "SELECT COUNT(*),COALESCE(SUM(sender=?1),0) FROM messages WHERE conversation=?1", + [id], + |row| Ok((row.get(0)?, row.get(1)?)), + )?; + Ok( + json!({"id":id,"public":cert,"profile":profile,"total":total,"visitor_sent":visitor,"owner_sent":total.checked_sub(visitor).context("Counter invariant")?}), + ) + } + + pub(crate) fn inbox(&self) -> Result { + let mut statement = self + .connection + .prepare("SELECT id FROM conversations ORDER BY id")?; + let ids = statement + .query_map([], |row| row.get::<_, String>(0))? + .collect::>>()?; + let conversations = ids + .iter() + .map(|id| self.conversation(id)) + .collect::>>()?; + Ok(json!({"conversations":conversations})) + } + + pub(crate) fn profile(&self, id: &str, ciphertext: &str) -> Result<()> { + self.connection.execute( + "UPDATE conversations SET profile=?2 WHERE id=?1 AND profile IS NULL", + params![id, ciphertext], + )?; + Ok(()) + } + + pub(crate) fn messages(&self, id: &str, count: u64, before: u64) -> Result { + ensure!( + count <= 50 && before <= i64::MAX.unsigned_abs(), + "Invalid history window" + ); + let mut statement = self.connection.prepare("SELECT seq,id,sender,ciphertext FROM messages WHERE conversation=?1 AND (?3=0 OR seq(0)?,"id":row.get::<_,String>(1)?,"sender":row.get::<_,String>(2)?,"ciphertext":row.get::<_,String>(3)?})))?.collect::>>()?; + Ok(json!({"conversation":self.conversation(id)?,"messages":messages})) + } + + pub(crate) fn send( + &mut self, + conversation: &str, + id: &str, + sender: &str, + ciphertext: &str, + ) -> Result { + let transaction = self.connection.transaction()?; + let existing: Option<(i64, String, String, String)> = transaction + .query_row( + "SELECT seq,conversation,sender,ciphertext FROM messages WHERE id=?1", + [id], + |row| Ok((row.get(0)?, row.get(1)?, row.get(2)?, row.get(3)?)), + ) + .optional()?; + if let Some((seq, previous_conversation, previous_sender, previous_ciphertext)) = existing { + ensure!( + previous_conversation == conversation + && previous_sender == sender + && previous_ciphertext == ciphertext, + "Conflicting message identifier" + ); + transaction.commit()?; + return Ok(json!({"seq":seq,"conversation":self.conversation(conversation)?})); + } + let count: i64 = transaction.query_row( + "SELECT COUNT(*) FROM messages WHERE conversation=?1", + [conversation], + |row| row.get(0), + )?; + let used: i64 = transaction.query_row( + "SELECT COALESCE(SUM(LENGTH(ciphertext)),0) FROM messages", + [], + |row| row.get(0), + )?; + ensure!( + count < 10_000 && used < 1_000_000_000, + "Storage quota reached" + ); + transaction.execute( + "INSERT INTO messages (conversation,id,sender,ciphertext) VALUES (?1,?2,?3,?4)", + params![conversation, id, sender, ciphertext], + )?; + let sequence = transaction.last_insert_rowid(); + transaction.commit()?; + Ok(json!({"seq":sequence,"conversation":self.conversation(conversation)?})) + } + + pub(crate) fn snapshot(&self, destination: &Path) -> Result<()> { + ensure!(!destination.exists(), "Snapshot destination already exists"); + self.connection + .backup("main", destination, None::)?; + Ok(()) + } +} diff --git a/crates/chat-server/tests/browser.mjs b/crates/chat-server/tests/browser.mjs new file mode 100644 index 0000000..a56c7cc --- /dev/null +++ b/crates/chat-server/tests/browser.mjs @@ -0,0 +1,265 @@ +// Copyright (C) 2026 Doan Luu Duc Tai +// +// This program is free software: you can redistribute it and/or modify +// it under the terms of the GNU Affero General Public License as +// published by the Free Software Foundation, either version 3 of the +// License, or (at your option) any later version. +// +// This program is distributed in the hope that it will be useful, +// but WITHOUT ANY WARRANTY; without even the implied warranty of +// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +// GNU Affero General Public License for more details. +// +// You should have received a copy of the GNU Affero General Public License +// along with this program. If not, see . + +import assert from 'node:assert/strict'; +import { createServer } from 'node:http'; +import { mkdtemp, readFile, writeFile, symlink, rm, mkdir, rename } from 'node:fs/promises'; +import { tmpdir } from 'node:os'; +import { resolve, join } from 'node:path'; +import { randomBytes } from 'node:crypto'; +import { spawn, execFileSync } from 'node:child_process'; +import { chromium, firefox } from '../../../.tools/browser/node_modules/playwright/index.mjs'; + +const root = await mkdtemp(join(tmpdir(), 'stack-chat-e2e-')); +const token = randomBytes(32).toString('hex'); +const origin = 'http://127.0.0.1:9080'; +const password = 'browser-test-only-unique-passphrase'; +await writeFile(join(root, '.env'), `CHAT_ORIGIN=${origin}\nCHAT_SETUP_TOKEN=${token}\n`, {mode:0o600}); +for (const path of ['crates','target','docs','web','LICENSE']) await symlink(resolve(path), join(root,path)); +await mkdir(join(root,'var/build'), {recursive:true}); +let server; +let serverErrors = ''; +async function launchServer() { + server = spawn(resolve('target/debug/stack-chat-server'), ['--root',root], {stdio:['ignore','pipe','pipe']}); + server.stderr.on('data', bytes => { serverErrors += bytes.toString(); }); + await Promise.race([ + new Promise((ready, reject) => { server.stdout.once('data', ready); server.once('exit', () => reject(new Error(serverErrors || 'Server exited'))); }), + new Promise((_, reject) => setTimeout(() => reject(new Error('Server did not start')), 20000).unref()), + ]); +} +async function stopServer() { + if (server.exitCode !== null || server.signalCode !== null) return; + const stopped = new Promise(done => server.once('exit',done)); + server.kill('SIGINT'); + await stopped; +} +await launchServer(); +const libre = process.env.CHAT_LIBREJS === '1'; +let persistent; +let browser; +if (libre) { + const profile = join(root,'firefox-profile'); + await mkdir(join(profile,'extensions'),{recursive:true}); + await writeFile(join(profile,'extensions','jid1-KtlZuoiikVfFew@jetpack.xpi'),await readFile(resolve('.tools/librejs.xpi'))); + persistent = await firefox.launchPersistentContext(profile, {headless:true,acceptDownloads:true,firefoxUserPrefs:{ + 'extensions.autoDisableScopes':0,'extensions.enabledScopes':15,'extensions.startupScanScopes':15, + 'extensions.update.enabled':false,'app.update.auto':false,'datareporting.healthreport.uploadEnabled':false, + 'browser.sessionstore.resume_from_crash':false, + }}); + browser = { close: () => persistent.close() }; +} else { browser = await chromium.launch({headless:true,channel:'chromium'}); } +const deadline = setTimeout(() => browser.close(), 300000); +const contexts = []; +const external = []; +const errors = []; +const bodies = []; +async function page() { + const context = persistent || await browser.newContext({acceptDownloads:true}); + contexts.push(context); + const page = await context.newPage(); + page.setDefaultTimeout(90000); + page.on('pageerror', error => errors.push(error.message)); + + page.on('request', request => { + if (!request.url().startsWith(origin + '/')) external.push(request.url()); + if (request.url() === origin + '/api') bodies.push(JSON.parse(request.postData())); + }); + return page; +} +async function waitStatus(page, text) { await page.waitForFunction(value => document.getElementById('status').textContent.includes(value), text); } +async function create(page, name, owner = false) { + if (owner) await page.fill('#setup-token', token); else await page.click('#create-choice'); + await page.fill('#name', name); + await page.fill('#new-password', password); + await page.fill('#confirm-password', password); + const downloaded = Promise.race([page.waitForEvent('download'), page.waitForFunction(() => /failed|timed out/i.test(document.getElementById('status').textContent)).then(async () => { throw new Error(await page.locator('#status').textContent()); })]); + await page.click('#create-form button'); + const download = await downloaded; + const bytes = await readFile(await download.path()); + await waitStatus(page,'Reopen your downloaded'); + assert.equal(await page.locator('#chat').isVisible(), false); + assert.equal(bodies.some(body => body.op === 'authenticate'), owner ? false : true); + return {name:'identity.chat',mimeType:'application/octet-stream',buffer:bytes}; +} +async function unlock(page, file, phrase = password) { + await page.setInputFiles('#file', file); + await page.fill('#password', phrase); + await page.click('#unlock-form button'); +} +async function storageEmpty(page) { + assert.equal(await page.evaluate(() => localStorage.length + sessionStorage.length),0); + assert.deepEqual(await page.evaluate(() => indexedDB.databases()),[]); + assert.equal(await page.evaluate(async () => (await navigator.serviceWorker.getRegistrations()).length),0); + assert.deepEqual(await page.context().cookies(),[]); +} +async function verifyLibreJS() { + let executed = false; + const controlServer = createServer((request,response) => { + response.setHeader('Cache-Control','no-store'); + if (request.url === '/control.js') { + response.setHeader('Content-Type','text/javascript'); + response.end("window.unlicensedControl = true; fetch('/executed');"); + } else if (request.url === '/executed') { executed = true; response.end('unexpected'); } + else { response.setHeader('Content-Type','text/html'); response.end('

Unlicensed external-script rejection control.

'); } + }); + await new Promise(done => controlServer.listen(0,'127.0.0.1',done)); + const controlPage = await persistent.newPage(); + try { + await controlPage.goto(`http://127.0.0.1:${controlServer.address().port}/`); + await controlPage.waitForTimeout(1500); + assert.equal(await controlPage.evaluate(() => window.unlicensedControl),undefined,'LibreJS must reject the unlicensed control'); + assert.equal(executed,false); + } finally { await controlPage.close(); await new Promise(done => controlServer.close(done)); } +} +try { + if (libre) await verifyLibreJS(); + const owner = await page(); + const response = await owner.goto(origin + '/#setup'); + assert.match(response.headers()['cache-control'], /no-store/); + assert.match(response.headers()['content-security-policy'], /frame-ancestors 'none'/); + await waitStatus(owner,'Owner setup.'); + const ownerFile = await create(owner,'Doan',true); + await unlock(owner,ownerFile); + await waitStatus(owner,'Unlocked in memory only'); + assert.match(await owner.locator('#who').textContent(), /OWNER/); + const visitor = await page(); + await visitor.goto(origin); + await waitStatus(visitor,'Choose CREATE'); + const visitorFile = await create(visitor,'A friend'); + assert.ok(!visitorFile.buffer.toString().includes('A friend')); + await unlock(visitor,visitorFile,'wrong-but-long-password'); + await waitStatus(visitor,'Cryptographic operation failed'); + assert.equal(await visitor.locator('#chat').isVisible(),false); + await unlock(visitor,visitorFile); + await waitStatus(visitor,'Unlocked in memory only'); + assert.equal(await visitor.locator('#messages li').count(),0); + assert.match(await visitor.locator('#counts').textContent(),/Total: 0/); + const malicious = ' hello λ'; + await visitor.fill('#text',malicious); + await visitor.click('#send-form button'); + await waitStatus(visitor,'Encrypted message stored'); + await owner.click('#refresh-inbox'); + await owner.locator('#conversations button').first().click(); + await waitStatus(owner,'Conversation opened'); + assert.match(await owner.locator('#thread-name').textContent(),/A friend/); + assert.equal(await owner.locator('#messages li').count(),0); + await owner.fill('#history-count','1'); + await owner.click('#history-form button'); + await waitStatus(owner,'Selected history decrypted'); + assert.equal(await owner.locator('#messages p').textContent(),malicious); + assert.equal(await owner.evaluate(() => window.chatInjection),undefined); + await owner.fill('#text','Reply from owner'); + await owner.click('#send-form button'); + await waitStatus(owner,'Encrypted message stored'); + await visitor.fill('#history-count','2'); + await visitor.click('#history-form button'); + await waitStatus(visitor,'Selected history decrypted'); + assert.equal(await visitor.locator('#messages li').count(),2); + assert.match(await visitor.locator('#counts').textContent(),/Total: 2 \| Sent: 1 \| Received: 1/); + let interrupted = false; + await visitor.route('**/api',async route => { + const body = route.request().postDataJSON(); + if (body.op === 'send' && !interrupted) { interrupted = true; await route.fetch(); await route.abort(); } + else await route.continue(); + }); + await visitor.fill('#text','Safe retry after a lost response'); + await visitor.click('#send-form button'); + await visitor.locator('#retry').waitFor({state:'visible'}); + await visitor.waitForFunction(() => !document.getElementById('retry').disabled); + await visitor.click('#retry'); + await waitStatus(visitor,'Encrypted message stored'); + assert.match(await visitor.locator('#counts').textContent(),/Total: 3/); + await visitor.reload(); + await waitStatus(visitor,'Choose CREATE'); + assert.equal(await visitor.locator('#messages li').count(),0); + assert.equal(await visitor.locator('#file').inputValue(),''); + assert.equal(await visitor.locator('#password').inputValue(),''); + await visitor.click('#open-choice'); + await unlock(visitor,visitorFile); + await waitStatus(visitor,'Unlocked in memory only'); + assert.match(await visitor.locator('#counts').textContent(),/Total: 3/); + assert.equal(await visitor.locator('#messages li').count(),0); + await visitor.fill('#history-count','1'); + await visitor.click('#history-form button'); + await waitStatus(visitor,'Selected history decrypted'); + assert.match(await visitor.locator('#messages p').textContent(),/Safe retry/); + await visitor.click('#older'); + await waitStatus(visitor,'Selected history decrypted'); + assert.equal(await visitor.locator('#messages li').count(),2); + await visitor.fill('#history-count','0'); + await visitor.click('#history-form button'); + await waitStatus(visitor,'No message history loaded'); + assert.equal(await visitor.locator('#messages li').count(),0); + const snapshot = join(root,'snapshot.sqlite'); + execFileSync(resolve('target/debug/stack-chat-server'),['--root',root,'--snapshot',snapshot],{stdio:'pipe'}); + await stopServer(); + await rm(join(root,'var/data/chat/chat.sqlite')); + await rename(snapshot,join(root,'var/data/chat/chat.sqlite')); + await launchServer(); + await visitor.reload(); + await waitStatus(visitor,'Choose CREATE'); + await visitor.click('#open-choice'); + await unlock(visitor,visitorFile); + await waitStatus(visitor,'Unlocked in memory only'); + assert.match(await visitor.locator('#counts').textContent(),/Total: 3/); + await visitor.fill('#history-count','3'); + await visitor.click('#history-form button'); + await waitStatus(visitor,'Selected history decrypted'); + assert.equal(await visitor.locator('#messages li').count(),3); + assert.ok((await visitor.locator('#messages p').allTextContents()).includes(malicious)); + await owner.reload(); + await waitStatus(owner,'Choose CREATE'); + await owner.click('#open-choice'); + await unlock(owner,ownerFile); + await waitStatus(owner,'Unlocked in memory only'); + assert.match(await owner.locator('#who').textContent(),/OWNER/); + await visitor.fill('#history-count','3'); + await visitor.click('#history-form button'); + await visitor.click('#lock'); + await visitor.waitForTimeout(500); + assert.equal(await visitor.locator('#messages li').count(),0); + await visitor.goto(origin + '/LIMITATIONS.txt'); + await visitor.goBack(); + await visitor.waitForFunction(() => !document.getElementById('entry').hidden); + assert.equal(await visitor.locator('#messages li').count(),0); + assert.equal(await visitor.locator('#password').inputValue(),''); + assert.equal(await visitor.locator('#chat').isVisible(),false); + await storageEmpty(visitor); + await storageEmpty(owner); + assert.deepEqual(external,[]); + assert.deepEqual(errors,[]); + for (const body of bodies) { + for (const forbidden of ['file','password','secret','name','text']) assert.ok(!(forbidden in body),`Leaked ${forbidden}`); + assert.ok(!JSON.stringify(body).includes(malicious)); + } + const database = await readFile(join(root,'var/data/chat/chat.sqlite')); + for (const plaintext of [malicious,'A friend','Reply from owner','Safe retry after a lost response',password]) assert.ok(!database.includes(Buffer.from(plaintext)),`Plaintext persisted: ${plaintext}`); + const blocked = await visitor.request.post(origin + '/api',{data:{op:'config'},headers:{Origin:'https://foreign.invalid'}}); + assert.equal(blocked.status(),403); + assert.equal(serverErrors,''); + if (process.env.CHAT_SCREENSHOT) await visitor.screenshot({path:resolve('.tools/chat-preview.png'),fullPage:true}); + if (libre) { + const extensions = JSON.parse(await readFile(join(root,'firefox-profile','extensions.json'),'utf8')); + const addon = extensions.addons.find(addon => addon.id === 'jid1-KtlZuoiikVfFew@jetpack'); + assert.ok(addon?.active,'LibreJS must actually be active'); + console.log(`LibreJS ${addon.version} is active in Firefox; complete chat workflow succeeded.`); + } + console.log('PASS: real browser/WASI owner setup, protected file recovery, wrong password, visitor/owner encrypted messages, safe text rendering, N=0, counts, pagination, retry deduplication, reload, live SQLite snapshot restoration and both identity recoveries, explicit lock, origin rejection, no browser storage or external requests, no private uploads or database plaintext.'); +} finally { + clearTimeout(deadline); + await browser.close(); + await stopServer(); + await rm(root,{recursive:true,force:true}); +} diff --git a/crates/chat-server/web/app.js b/crates/chat-server/web/app.js new file mode 100644 index 0000000..c06116b --- /dev/null +++ b/crates/chat-server/web/app.js @@ -0,0 +1,325 @@ +// Copyright (C) 2026 Doan Luu Duc Tai +// +// This program is free software: you can redistribute it and/or modify +// it under the terms of the GNU Affero General Public License as +// published by the Free Software Foundation, either version 3 of the +// License, or (at your option) any later version. +// +// This program is distributed in the hope that it will be useful, +// but WITHOUT ANY WARRANTY; without even the implied warranty of +// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +// GNU Affero General Public License for more details. +// +// You should have received a copy of the GNU Affero General Public License +// along with this program. If not, see . + +// @license magnet:?xt=urn:btih:0b31508aeb0634b347b8270c7bee4d411b5d4109&dn=agpl-3.0.txt AGPL-3.0 + +const element = id => document.getElementById(id); +const show = (id, visible = true) => { element(id).hidden = !visible; }; +const status = message => { element('status').textContent = message; }; +let config; +let wasm; +let credentials; +let session; +let selected; +let before = 0; +let pendingFile; +let setupToken; +let pendingSend; +let generation = 0; +let busy = false; +const workers = new Map(); +const blobs = new Set(); + +async function api(request) { + const current = generation; + const response = await fetch('/api', { + method: 'POST', credentials: 'omit', cache: 'no-store', redirect: 'error', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ ...request, token: session?.token }), + signal: AbortSignal.timeout(20000), + }); + const result = await response.json(); + if (current !== generation) throw new Error('Session locked'); + if (!response.ok || result.error) throw new Error(result.error || 'Server request failed'); + return result; +} + +async function cryptoOperation(request) { + const current = generation; + const bytes = await wasm; + if (current !== generation) throw new Error('Session locked'); + return new Promise((resolve, reject) => { + const worker = new Worker('/worker.js'); + + const stop = () => { worker.terminate(); workers.delete(worker); clearTimeout(timer); }; + const timer = setTimeout(() => { stop(); reject(new Error('Cryptographic operation timed out')); }, 90000); + workers.set(worker, () => { stop(); reject(new Error('Session locked')); }); + worker.onerror = () => { stop(); reject(new Error('Cryptographic worker failed')); }; + worker.onmessage = event => { + stop(); + if (current !== generation) { reject(new Error('Session locked')); return; } + if (event.data.error) reject(new Error(event.data.error)); else resolve(event.data.result); + }; + worker.postMessage({ bytes, request }); + }); +} + +function protectRequest(op, fields) { + if (!credentials) throw new Error('Unlock your identity file first'); + return { op, ...credentials, origin: location.origin, ...fields }; +} + +function clearState() { + generation += 1; + busy = false; + for (const button of document.querySelectorAll('button')) button.disabled = false; + for (const cancel of workers.values()) cancel(); + workers.clear(); + for (const url of blobs) URL.revokeObjectURL(url); + blobs.clear(); + credentials = undefined; + session = undefined; + selected = undefined; + pendingFile = undefined; + pendingSend = undefined; + setupToken = undefined; + before = 0; + for (const input of document.querySelectorAll('input,textarea')) { + if (input.id === 'history-count') input.value = '0'; else input.value = ''; + } + for (const id of ['messages', 'conversations', 'who', 'fingerprint', 'counts', 'thread-name', 'conversation-fingerprint']) element(id).replaceChildren(); + for (const id of ['create', 'unlock', 'chat', 'thread', 'inbox', 'older', 'retry']) show(id, false); + show('entry', Boolean(config?.owner)); + status('Locked. Select your file and enter its password to return.'); +} + +function action(id, callback) { + element(id).addEventListener(id.endsWith('-form') ? 'submit' : 'click', async event => { + event.preventDefault(); + if (busy) return; + busy = true; + status('Working locally...'); + const current = generation; + for (const button of document.querySelectorAll('button:not(#lock)')) button.disabled = true; + try { await callback(); } + catch (error) { if (generation === current) status(error.message); } + finally { + if (generation === current) { + busy = false; + for (const button of document.querySelectorAll('button')) button.disabled = false; + } + } + }); +} + +function download(file) { + const url = URL.createObjectURL(new Blob([file], { type: 'application/octet-stream' })); + blobs.add(url); + const link = document.createElement('a'); + link.href = url; + link.download = 'stack-chat-identity.chat'; + link.click(); + setTimeout(() => { URL.revokeObjectURL(url); blobs.delete(url); }, 30000); +} + +async function createIdentity() { + const password = element('new-password').value; + if (password !== element('confirm-password').value) throw new Error('Passwords do not match'); + if (!config.owner && location.hash !== '#setup') throw new Error('Owner setup is required first'); + setupToken = !config.owner ? element('setup-token').value : undefined; + status('Generating your purpose-specific identity and protecting its file...'); + const result = await cryptoOperation({ op: 'create', origin: location.origin, name: element('name').value, password, owner: config.owner || '' }); + pendingFile = result.file; + download(result.file); + element('create-form').reset(); + show('create', false); + show('unlock'); + element('reopen-note').textContent = 'Your file was downloaded. Reopen that exact saved file and enter its password before any message can be sent. Keep a safe backup.'; + status('Reopen your downloaded identity file to verify recovery.'); +} + +async function unlockIdentity() { + const file = element('file').files[0]; + if (!file || file.size > 262144) throw new Error('Choose an identity file no larger than 256 KiB'); + const fileText = await file.text(); + if (pendingFile && fileText !== pendingFile) throw new Error('Reopen the exact identity file you just downloaded'); + const candidate = { file: fileText, password: element('password').value }; + element('unlock-form').reset(); + status('Unlocking locally and verifying your identity...'); + const identity = await cryptoOperation({ op: 'open', ...candidate, origin: location.origin }); + if (config.owner && identity.owner !== config.owner) throw new Error('Pinned owner key differs from this server. Stop and verify the change with Doan.'); + const challenge = await api({ op: 'challenge', public: identity.public, setup_token: setupToken }); + const proof = await cryptoOperation({ op: 'proof', ...candidate, origin: location.origin, challenge: challenge.challenge }); + const authenticated = await api({ op: 'authenticate', id: challenge.id, signature: proof.signature }); + if (authenticated.id !== identity.fingerprint || typeof authenticated.owner !== 'boolean') throw new Error('Authenticated identity mismatch'); + credentials = candidate; + session = { ...identity, ...authenticated }; + pendingFile = undefined; + setupToken = undefined; + config = await api({ op: 'config' }); + show('entry', false); + show('unlock', false); + show('chat'); + element('who').textContent = `${identity.name} [${session.owner === true ? 'OWNER' : 'VISITOR'}]`; + element('fingerprint').textContent = `Identity: ${identity.fingerprint}`; + if (authenticated.owner) { + session.owner = true; + show('inbox'); + await inbox(); + } else { + session.owner = false; + const profile = await cryptoOperation(protectRequest('encrypt', { recipient: config.owner, payload: JSON.stringify({ v: 1, kind: 'profile', origin: location.origin, conversation: session.id, sender: session.id, name: identity.name }) })); + await api({ op: 'profile', conversation: session.id, ciphertext: profile.ciphertext }); + selected = { id: session.id, public: identity.public }; + element('conversation-fingerprint').textContent = `Conversation: ${session.id}`; + show('thread'); + element('thread-name').textContent = 'You <-> Doan'; + await history(0); + } + status('Unlocked in memory only. No history loaded automatically. Lock or leave to forget this session.'); +} + +function checkedPayload(payload, kind, conversation, sender) { + const value = JSON.parse(payload); + if (value.v !== 1 || value.kind !== kind || value.origin !== location.origin || value.conversation !== conversation || value.sender !== sender) throw new Error('Signed message binding mismatch'); + return value; +} + +async function inbox() { + const result = await api({ op: 'inbox' }); + element('conversations').replaceChildren(); + for (const conversation of result.conversations) { + const button = document.createElement('button'); + button.textContent = `${conversation.id.slice(0, 12)} / ${conversation.total} messages`; + button.addEventListener('click', async () => { + if (busy) return; + if (pendingSend) { status('Retry the pending message, or lock before switching conversations.'); return; } + busy = true; + const current = generation; + status('Opening conversation...'); + try { + selected = conversation; + element('conversation-fingerprint').textContent = `Conversation: ${conversation.id}`; + element('thread-name').textContent = `Doan <-> ${conversation.id.slice(0, 12)}`; + pendingSend = undefined; + element('text').value = ''; + show('retry', false); + element('messages').replaceChildren(); + show('thread'); + let name = conversation.id.slice(0, 12); + if (conversation.profile) { + const result = await cryptoOperation(protectRequest('decrypt', { sender: conversation.public, ciphertext: conversation.profile })); + if (result.signer !== conversation.id) throw new Error('Profile signer fingerprint mismatch'); + const profile = checkedPayload(result.payload, 'profile', conversation.id, conversation.id); + if (typeof profile.name !== 'string' || profile.name.length > 80) throw new Error('Invalid encrypted profile'); + name = profile.name; + } + element('thread-name').textContent = `Doan <-> ${name}`; + await history(0); + status('Conversation opened. Choose how many messages to load.'); + } catch (error) { status(error.message); } + finally { if (generation === current) busy = false; } + }); + element('conversations').append(button); + } +} + +function counters(conversation) { + const sent = session.owner ? conversation.owner_sent : conversation.visitor_sent; + const received = session.owner ? conversation.visitor_sent : conversation.owner_sent; + element('counts').textContent = `Total: ${conversation.total} | Sent: ${sent} | Received: ${received}`; +} + +async function history(count, cursor = 0) { + if (!selected) throw new Error('Choose a conversation first'); + if (!Number.isSafeInteger(count) || count < 0 || count > 50) throw new Error('Choose 0 to 50 messages'); + const result = await api({ op: 'history', conversation: selected.id, count, before: cursor }); + counters(result.conversation); + if (!cursor) element('messages').replaceChildren(); + if (result.messages.length) { + status('Decrypting and verifying selected messages locally...'); + const requests = result.messages.map(message => ({ sender: message.sender === selected.id ? selected.public : config.owner, ciphertext: message.ciphertext })); + const decrypted = await cryptoOperation(protectRequest('decrypt_batch', { messages: requests })); + for (let index = 0; index < result.messages.length; index += 1) { + const message = result.messages[index]; + const item = document.createElement('li'); + const label = document.createElement('small'); + label.textContent = `#${message.seq} / ${message.sender === session.id ? 'YOU' : 'THEM'}`; + const text = document.createElement('p'); + try { + const entry = decrypted.messages[index]; + if (entry.signer !== message.sender) throw new Error('Sender fingerprint mismatch'); + if (entry.error) throw new Error('Invalid ciphertext or signature'); + const payload = checkedPayload(entry.payload, 'message', selected.id, message.sender); + if (payload.id !== message.id || typeof payload.text !== 'string' || new TextEncoder().encode(payload.text).length > 8192) throw new Error('Message binding mismatch'); + text.textContent = payload.text; + } catch { text.textContent = '[Message rejected: invalid ciphertext, signature, or conversation binding.]'; } + item.append(label, text); + element('messages').prepend(item); + } + before = Math.min(...result.messages.map(message => message.seq)); + } + show('older', count > 0 && result.messages.length === count); + status(count === 0 ? 'No message history loaded.' : 'Selected history decrypted and verified locally.'); +} + +async function submitPending() { + if (!pendingSend) throw new Error('No pending message'); + const result = await api(pendingSend); + counters(result.conversation); + pendingSend = undefined; + element('text').value = ''; + show('retry', false); + status('Encrypted message stored. Load history to read messages.'); +} + +async function send() { + if (!selected) throw new Error('Choose a conversation first'); + if (pendingSend) throw new Error('Retry or lock before composing another message'); + const text = element('text').value; + if (!text.trim() || new TextEncoder().encode(text).length > 8192) throw new Error('Enter 1 to 8192 UTF-8 bytes of text'); + const id = Array.from(crypto.getRandomValues(new Uint8Array(32)), byte => byte.toString(16).padStart(2, '0')).join(''); + const recipient = session.owner ? selected.public : config.owner; + status('Signing and encrypting for both participants locally...'); + const result = await cryptoOperation(protectRequest('encrypt', { recipient, payload: JSON.stringify({ v: 1, kind: 'message', origin: location.origin, conversation: selected.id, sender: session.id, id, text }) })); + pendingSend = { op: 'send', conversation: selected.id, id, ciphertext: result.ciphertext }; + show('retry'); + await submitPending(); +} + +action('create-choice', async () => { show('entry', false); show('create'); status('Create a purpose-specific protected identity file.'); }); +action('open-choice', async () => { show('entry', false); show('unlock'); status('Select your saved identity file.'); }); +action('create-form', createIdentity); +action('unlock-form', unlockIdentity); +action('history-form', () => history(Number(element('history-count').value))); +action('send-form', send); +action('retry', submitPending); +action('refresh-inbox', inbox); +action('older', () => history(Number(element('history-count').value), before)); +element('lock').addEventListener('click', () => { if (session) api({ op: 'lock' }).catch(() => {}); clearState(); }); +window.addEventListener('pagehide', clearState); +window.addEventListener('pageshow', event => { if (event.persisted) clearState(); }); + +async function start() { + wasm = fetch('/crypto.wasm', { cache: 'no-store', credentials: 'omit' }).then(response => { + if (!response.ok) throw new Error('Build the WASI cryptographic module first'); + return response.arrayBuffer(); + }); + await wasm; + config = await api({ op: 'config' }); + if (config.origin !== location.origin) throw new Error('Server origin mismatch'); + if (!config.owner) { + if (location.hash === '#setup') { + show('create'); + show('setup-label'); + element('create-title').textContent = 'Create the dedicated owner identity'; + status('Owner setup. Keep this identity separate from your canonical OpenPGP key.'); + } else { status('Owner setup is not complete. Please return later.'); } + } else { show('entry'); status('Choose CREATE NEW CHAT or OPEN CHAT FILE.'); } +} + +start().catch(error => status(error.message)); + +// @license-end diff --git a/crates/chat-server/web/index.html b/crates/chat-server/web/index.html new file mode 100644 index 0000000..9783789 --- /dev/null +++ b/crates/chat-server/web/index.html @@ -0,0 +1,82 @@ + + + + + + +Message Me | Doan Luu Duc Tai + + + + +
+
~doan / message me EXPERIMENTAL / LOCAL TEST
+

[ private chat ]

+

Text only. A dedicated identity file. Encrypted for you and Doan.

+

This is an experimental application, not an audited messenger. Read its limitations before use. Keep your file and password safe: there is no account recovery.

+

Loading cryptographic module...

+ + + + + + +
+ + diff --git a/crates/chat-server/web/javascript.html b/crates/chat-server/web/javascript.html new file mode 100644 index 0000000..927a678 --- /dev/null +++ b/crates/chat-server/web/javascript.html @@ -0,0 +1,28 @@ + + +JavaScript licenses
+

JavaScript licenses and source

+

All first-party scripts are available under the GNU Affero General Public License, version 3 or, at your option, any later version. The table uses the license identifiers recognized by GNU LibreJS.

+ + + + +
Script / raw fileLicenseCorresponding source
app.jsGNU-AGPL-3.0Complete corresponding source
worker.jsGNU-AGPL-3.0Complete corresponding source
wasi.mjsGNU-AGPL-3.0Complete corresponding source
+

The WebAssembly module is compiled from the Rust workspace and unmodified OpenSSL 3.6.5, under their respective free software licenses. The source archive includes first-party sources, Cargo dependencies, the OpenSSL source archive, build scripts, and notices. General-purpose Rust and WASI SDK tools are documented with verified download locations in CHAT-OPENSSL-MIGRATION.txt.

+

Third-party licenses / Source archive / Return

+
diff --git a/crates/chat-server/web/style.css b/crates/chat-server/web/style.css new file mode 100644 index 0000000..0983b2e --- /dev/null +++ b/crates/chat-server/web/style.css @@ -0,0 +1,49 @@ +/* +Copyright (C) 2026 Doan Luu Duc Tai + +This program is free software: you can redistribute it and/or modify +it under the terms of the GNU Affero General Public License as +published by the Free Software Foundation, either version 3 of the +License, or (at your option) any later version. + +This program is distributed in the hope that it will be useful, +but WITHOUT ANY WARRANTY; without even the implied warranty of +MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +GNU Affero General Public License for more details. + +You should have received a copy of the GNU Affero General Public License +along with this program. If not, see . +*/ +* { box-sizing: border-box; } +:root { color-scheme: dark; font-family: "Courier New", monospace; background: #131715; color: #d4d8cd; line-height: 1.6; } +body { margin: 0; } +main { max-width: 58rem; margin: 2rem auto; padding: 0 1.25rem; } +header, footer { border-top: 1px solid #667367; padding-top: 1rem; } +header { display: flex; flex-wrap: wrap; gap: .6rem; } +h1 { font-size: 1.8rem; color: #e3e3ce; } +h2, h3 { font-size: 1.05rem; } +a { color: #aec2a5; text-underline-offset: .2rem; } +.status, .badge { color: #e0c46d; } +.status { margin-left: auto; } +.notice { border-left: 2px solid #c5ad68; padding-left: 1rem; } +button, input, textarea { font: inherit; color: inherit; background: #1c231e; border: 1px solid #667367; border-radius: 0; padding: .55rem .7rem; } +button { cursor: pointer; margin: .4rem .6rem .4rem 0; } +button:hover, button:focus-visible { border-color: #d7c884; color: #f0e5b0; } +button:disabled { opacity: .5; cursor: wait; } +input, textarea { display: block; width: 100%; margin: .35rem 0 1rem; } +input[type=number] { width: 8rem; } +label { display: block; } +section { margin: 1.5rem 0; } +[hidden] { display: none !important; } +.chat-heading { display: flex; flex-wrap: wrap; justify-content: space-between; align-items: center; } +#fingerprint, #conversation-fingerprint { overflow-wrap: anywhere; font-size: .8rem; } +#messages { padding: 0; list-style: none; } +#messages li { border-top: 1px dashed #48564a; padding: 1rem 0; } +#messages p { white-space: pre-wrap; overflow-wrap: anywhere; margin: .4rem 0; } +#messages small { color: #a8b39e; } +#status { min-height: 1.6rem; color: #e0c46d; } +footer { margin-top: 3rem; padding-bottom: 2rem; } +footer > a { display: block; width: fit-content; } +footer p { position: relative; } +#js-warning { display: none; max-width: 48rem; border: 1px solid #c5ad68; background: #1c231e; padding: 1rem; margin: .8rem 0; } +.badge:hover + #js-warning, .badge:focus-visible + #js-warning { display: block; } diff --git a/crates/chat-server/web/worker.js b/crates/chat-server/web/worker.js new file mode 100644 index 0000000..64e0795 --- /dev/null +++ b/crates/chat-server/web/worker.js @@ -0,0 +1,31 @@ +// Copyright (C) 2026 Doan Luu Duc Tai +// +// This program is free software: you can redistribute it and/or modify +// it under the terms of the GNU Affero General Public License as +// published by the Free Software Foundation, either version 3 of the +// License, or (at your option) any later version. +// +// This program is distributed in the hope that it will be useful, +// but WITHOUT ANY WARRANTY; without even the implied warranty of +// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +// GNU Affero General Public License for more details. +// +// You should have received a copy of the GNU Affero General Public License +// along with this program. If not, see . + +// @license magnet:?xt=urn:btih:0b31508aeb0634b347b8270c7bee4d411b5d4109&dn=agpl-3.0.txt AGPL-3.0 + +importScripts('/wasi.mjs'); + +self.onmessage = async event => { + try { + const result = await self.stackChatWasi.runCommand(event.data.bytes, event.data.request); + self.postMessage({ result }); + } catch { + self.postMessage({ error: 'Cryptographic operation failed. Check your file, password, origin, and sender.' }); + } finally { + self.close(); + } +}; + +// @license-end diff --git a/deny.toml b/deny.toml index 473704d..c80da7b 100644 --- a/deny.toml +++ b/deny.toml @@ -20,6 +20,10 @@ allow = ["Apache-2.0", "BSD-3-Clause", "Unicode-3.0", "MIT", "0BSD", "Zlib"] name = "stack" allow = ["AGPL-3.0-or-later"] +[[licenses.exceptions]] +name = "stack-chat-server" +allow = ["AGPL-3.0-or-later"] + [[licenses.exceptions]] name = "stack-chat-crypto" allow = ["AGPL-3.0-or-later"] diff --git a/docs/CHAT-APPLICATION.txt b/docs/CHAT-APPLICATION.txt new file mode 100644 index 0000000..2a06649 --- /dev/null +++ b/docs/CHAT-APPLICATION.txt @@ -0,0 +1,336 @@ +Copyright (C) 2026 Doan Luu Duc Tai + +This program is free software: you can redistribute it and/or modify +it under the terms of the GNU Affero General Public License as +published by the Free Software Foundation, either version 3 of the +License, or (at your option) any later version. + +This program is distributed in the hope that it will be useful, +but WITHOUT ANY WARRANTY; without even the implied warranty of +MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +GNU Affero General Public License for more details. + +You should have received a copy of the GNU Affero General Public License +along with this program. If not, see . + + +EXPERIMENTAL PRIVATE CHAT + + Branch: feature/private-chat. This application is for local testing. + Production remains on main with its under-construction Message Me page. + No public chat service, TLS route, or new production identity was deployed. + This is working experimental software, not an independently audited messenger. + +WHAT EXISTS + + A Rust HTTP server and a Rust cryptographic command compiled to WASI. + Small, readable, free JavaScript modules implement the browser interface and + a capability-limited WASI host. Text is displayed through textContent. + SearXNG and the other existing services are unchanged. + + Visitors choose CREATE NEW CHAT or OPEN CHAT FILE. Creation generates a + purpose-specific identity locally and downloads its protected file. Reopening + the exact downloaded file and entering its password is required before the + first authentication or message. No username/password account is created. + Returning users select their file and password again. The stable file does + not need updating as messages arrive. The owner has a separate chat identity + and inbox; the canonical RSA OpenPGP key is not used for chat encryption. + + History defaults to zero messages. Authenticated counters show total, sent, + and received messages. Load 0 to 50 recent messages, then page backwards. + Reload, lock, or leaving clears the application's in-memory references and + visible messages. A separate authenticated session expires after 30 minutes. + There is no automatic background polling, notification, or remote logout. + Refresh the owner inbox and load history to fetch newly arrived messages. + +LOCAL PREREQUISITES AND COMMANDS + + Start in the root of the stack repository on feature/private-chat. Native + development needs Rust, libclang, OpenSSL 3.5 or newer development libraries, + a C compiler, pkg-config, make, Perl, curl, and tar. The checked configuration + uses OpenSSL 3.6.5. The WASI build script currently targets an x86_64 GNU/Linux + development host and downloads the hash-verified WASI SDK 34.0 and OpenSSL + source. General-purpose toolchains remain development tools, not VPS tools. + + Build and initialize once: + + sh infra/chat/build-openssl-wasi.sh + cargo stack licenses + sh infra/chat/build-source.sh + cargo stack chat --init + + Initialization appends CHAT_SETUP_TOKEN and CHAT_ORIGIN to the single root + .env, creating it privately if necessary. It refuses to overwrite an existing + setup token. The root .env.example lists both settings. Never commit .env. + Existing SearXNG settings stay in the same file. No crate has its own .env. + + For subsequent Rust-only edits, sh infra/chat/build-browser.sh reuses the + previously built, hash-verified OpenSSL/SDK inputs. Rebuild OpenSSL itself + after changing its source, version, configuration, or build flags. The WASI + module has a 256 MiB maximum linear-memory limit. + + Start: + + cargo stack chat + + Open: + + http://127.0.0.1:9080/#setup + + Read CHAT_SETUP_TOKEN from your root .env locally and paste it into the owner + setup form. Choose an owner display name and a strong unique password. Save + the downloaded file, reopen it, and unlock it. That establishes the owner + public identity. The setup token stops authorizing setup once an owner exists. + It does not become an owner login or a key recovery mechanism. Never publish + the owner private file or its password. Keep both in your private KeePassXC + database, along with a separately protected backup of that database. + + Open another browser window at http://127.0.0.1:9080/ to create a visitor chat. + Every page holds its own identity in memory; there is no shared browser login. + Stop the development server with Control-C. A server restart invalidates all + sessions but preserves identities, conversations, counts, and ciphertext. + + The server currently refuses a non-loopback bind or an origin that does not + exactly match its development bind address. HTTP is allowed only for the + explicit loopback development origin. The intended production origin remains + https://chat.doan.luuduct.ai/. Production Podman packaging, TLS configuration, + operational review, and a main-branch merge remain separate work. Do not + expose this experimental development service through a public proxy. + +FILE AND MESSAGE CRYPTOGRAPHY + + OpenPGP v6, ML-KEM-1024 + X448 encryption, ML-DSA-87 + Ed448 signatures, + and AES-256 content encryption. There is no RSA, classical-only, or alternative + post-quantum fallback. Each certificate has exactly the expected two keys and + a fixed purpose-only user ID. Names are not placed in public certificate IDs. + The server rejects uploaded secret-key material and unsupported certificates. + + The JSON identity file is an authenticated encrypted envelope. Its fixed + format identifier is authenticated as associated data: + + stack-chat-file/v1;argon2id-m65536-t3-p1;aes256-gcm + + Argon2id version 0x13 uses 65536 KiB, three passes, one lane, and a 32-byte + derived key. A fresh CSPRNG salt of 16 bytes and AES-256-GCM nonce of 12 bytes + are generated for each file; the authentication tag is 16 bytes. These KDF + parameters are fixed by the format, not accepted from an untrusted file. + The outer JSON contains only format, salt, nonce, ciphertext, and tag, encoded + in base64 where applicable. The encrypted payload contains the origin, name, + pinned owner public certificate, and the secret OpenPGP certificate. + Secret key material inside that already encrypted envelope is unprotected + only in process memory. Never extract or save the decrypted payload. + + Passwords must be 12 to 1024 UTF-8 bytes. This is a length floor, not a claim + of sufficient entropy: choose a strong unique passphrase. File parsing and + parameter checks precede the expensive KDF. Import is limited to 256 KiB. + Wrong passwords, malformed files, authentication failures, and unsupported + formats return a generic error. Mutable KDF keys, plaintext envelopes, and + private serialization buffers are zeroized where practical. + + The browser checks the file's exact origin and pinned owner certificate on + unlock. Owner identity creation pins its own public certificate. Visitor + creation pins the public certificate currently advertised over the origin's + trusted connection. First-visit trust is consequently still trust in the + served application and TLS; it is not an out-of-band identity guarantee. + + Each text message is signed, then encrypted for both participants. A signed + plaintext JSON object binds v=1, kind=message, exact origin, conversation ID, + sender fingerprint, CSPRNG message ID, and UTF-8 text. Receivers compare the verified signer fingerprint to the conversation + record and expected sender, then verify every payload binding before displaying text. Encrypted profile + objects bind kind=profile, origin, conversation, sender, and display name. + The Rust encryptor also enforces the pinned recipient, sender, origin, and + conversation before signing; the server cannot select an arbitrary recipient + by changing a public-key field. Display names remain labels, not proof of a + unique person. The interface shows full conversation fingerprints. + Message and profile formats are distinct to prevent cross-purpose replay. + Compression, unsigned messages, an unexpected content cipher, and malformed + recipient sets are rejected. Private certificates are never sent to the API. + +AUTHENTICATION AND STORAGE + + One JSON API endpoint /api accepts same-origin POST requests only; no CORS, + cookie authentication, URL credentials, or URL conversation identifiers. + A fresh random challenge binds protocol, exact origin, full fingerprint, + random nonce, an expiry, and the pinned owner fingerprint. It expires after 60 seconds and is consumed on + the first verification attempt, including a failed signature attempt. + A detached purpose-specific signature proves possession of the identity. + A random bearer session token then authorizes API requests for 30 minutes. + Session tokens and pending challenges exist only in server and page memory. + The owner can access the inbox and any conversation; a visitor can access + only the conversation indexed by their own verified fingerprint. + + Data lives under var/data/chat/chat.sqlite, in a private 0700 directory with + a 0600 database. SQLite uses foreign keys, FULL synchronous transactions, and + DELETE journaling. Durable data consists of the owner public certificate, + visitor public certificates, an encrypted name/profile, encrypted messages, + sender fingerprints, random message IDs, sequence numbers, and counts derived + from rows. Names and message text are not stored as plaintext. There are no + application access logs or deliberately retained client IP addresses. + SQLite journals contain the same public metadata and ciphertext, not private + keys or message plaintext. OS diagnostics and a future reverse proxy need + their own logging review before deployment. + + Sends are transactional. Retrying exactly the same ciphertext, message ID, + sender, and conversation returns its existing sequence without incrementing + counts. Reusing an ID with different data is rejected. This handles a response + lost after the message was stored. Keep the page unlocked to retry; reload + discards the pending ciphertext and draft, so inspect history before resending. + Send retries are deliberate; messages are not automatically resent. + + Limits: 8192 UTF-8 bytes per text, 16384 bytes per signed payload, 65536 base64 + characters per stored ciphertext, 50 history rows per request, 1000 visitor + identities, 10000 messages per conversation, and a one-billion-character + total message-ciphertext budget. At most 60 authentication challenges per + global minute window, 128 pending challenges, 256 active sessions, and four + queued cryptographic/database tasks are admitted. API bodies are at most + 256 KiB. Database access is serialized. These are basic bounds, not robust + protection against determined traffic, account creation, CPU, or disk attacks. + Public-key and profile storage, indexes, journals, and backups add overhead to + the ciphertext budget. Monitor actual disk use. No public abuse controls, + deletion UI, rotation UI, blocklist, or password-change UI are implemented. + +BROWSER STATE AND LIMITATIONS + + No application cookies, localStorage, sessionStorage, IndexedDB, service + workers, analytics, external scripts, fonts, or third-party requests. Assets + and API responses use no-store. Browser fetches omit credentials. Private + file/password bytes travel only to a local worker and Rust/WASM memory. + Each crypto worker terminates after its operation or a timeout. Lock and + pagehide terminate remaining workers, clear drafts/messages/credentials, + clear input controls, and revoke download URLs. Restored pages clear state. + + This means no intentional persistent application state, not literally zero + traces. Browser password managers, downloads, history, caches outside the + application's control, memory copies, swap, extensions, OS crash dumps, + screenshots, and a restored process can retain data. JavaScript strings, + garbage collection, compiler/library copies, and WASM memory cannot be + guaranteed erased. A 90-second worker timeout and 256 MiB WASM linear-memory maximum bound + each cryptographic instance. Browser/host copies add memory overhead. A malformed file or message can still expose defects in + the Rust, C, WASM, or browser stack. OpenSSL uses C/FFI; Rust's unsafe-code + prohibition does not make dependencies memory-safe. Native server and WASI + OpenSSL updates must both be reviewed and rebuilt when advisories warrant. + + A stolen file enables offline password guessing. A stolen file and password, + or an unlocked identity, enable impersonation and decryption of accessible + historical ciphertext. There is no username recovery, forward secrecy, or + automatic key ratchet. Losing the owner file/password loses owner access to + every conversation encrypted to it. A server rollback can omit messages; + signatures do not prove a complete transcript. Encrypted messages reveal + metadata, lengths, ordering, and interaction timing. The server sees client + addresses during connections. Bearer-token theft permits API use until + expiry or lock, though decrypting or signing still requires the private key. + + A malicious server can replace JavaScript or WASM and steal passwords and + plaintext on the next visit. A pinned key and LibreJS do not prevent that. + Shared-host compromise, browser extensions, a compromised OS, browser bugs, + dependency attacks, malicious friends, and denial of service remain risks. + A purpose label advises separation; it cannot prevent exporting and reusing + a key elsewhere. This application is not a SimpleX equivalent. + +BACKUP, EXPORT, AND RESTORE + + The existing cargo stack backup creates a GPG-encrypted archive and now uses + SQLite's online backup API for chat.sqlite. It does not copy a live SQLite + file or journal with ordinary file-copy code. It can take a consistent chat + snapshot while the local chat server runs. Existing Podman data still follows + the stack's previous pause/copy/resume process. A database snapshot is atomic + for chat data; unrelated services do not share a global transaction with it. + The backup includes the single root .env and must remain private/encrypted. + + For a chat-only consistent database export: + + cargo run --locked -p stack-chat-server -- --root . --snapshot var/exports/chat-snapshot.sqlite + + Create var/exports privately first if it does not exist. The destination must + not already exist. This standalone snapshot is ciphertext plus public metadata, + not a GPG-encrypted full-stack archive; encrypt it before off-machine storage. + It does not contain browser private identities. A server-side backup cannot + recover a lost user or owner file/password. + + Restore a full stack using the existing README.txt backup/restore instructions. + For a chat-only restore, stop the chat server, put the verified snapshot at + var/data/chat/chat.sqlite under the destination root, set directory mode 0700 + and database mode 0600, restore the single root .env privately, and rebuild + the corresponding source/WASM on the development machine. Start locally and + open the unchanged owner or visitor file with its password. Changing the + origin deliberately makes old files fail their origin binding; there is no + origin migration UI yet. Do not edit an encrypted file by hand. + + Keep at least one encrypted backup away from the VPS. Test the owner identity, + visitor identity, correct counts, and decrypted messages after a restore. + Keep private identity files and passwords in KeePassXC with a protected + database backup. Never place them in source control or the public source + archive. Deleting a message locally or losing a file does not delete stored + ciphertext, old exports, or other participants' copies. + +LICENSES, SOURCE, AND VERIFICATION + + Portable root CLI builds now also need a musl C compiler because consistent + snapshots use bundled SQLite. The root CLI was built and executed as an + x86_64 static PIE with SQLite; no runtime SQLite package is needed on the VPS. + The local check used Alpine musl 1.2.6 headers with the host C compiler for + the C object build and Rust's self-contained musl runtime for final linking. + + First-party software is GNU AGPL version 3 or later. Legal license headers + are the exception to the no-comments convention. Documentation is plaintext. + JavaScript has GNU LibreJS @license/@license-end markers and a linked + jslicense-labels1 table, following GNU's JavaScript license web-label format: + + https://www.gnu.org/licenses/javascript-labels.html + https://www.gnu.org/software/librejs/free-your-javascript.html + + The yellow GNU LibreJS-compliant badge links to the GNU project and exposes + a hover/focus explanation distinguishing software freedom from security. + A source archive is built by infra/chat/build-source.sh from an explicit + source allowlist, Cargo-vendored dependencies, and the original verified + OpenSSL archive. It excludes .env, .git, keys, target files, runtime data, + downloaded browser tools, and exports. .env.example is public. The original + dependency license texts are preserved. GNU-friendly human-readable names + such as Expat License are used in the generated third-party license page; + machine-readable SPDX identifiers remain unchanged. + + Rebuild the source archive after any source, lockfile, or notice changes. + Do not publish a binary without its exact corresponding sources. Inspect + tar -tzf var/build/chat-source.tar.gz and the generated license page. + The SQLite bundled C source is provided by libsqlite3-sys in the vendor tree. + The complete unmodified OpenSSL release is included separately. + + Run the normal strict workspace check, then browser tests: + + cargo stack check + PLAYWRIGHT_BROWSERS_PATH="$PWD/.tools/browsers" node crates/chat-crypto/tests/browser.mjs + PLAYWRIGHT_BROWSERS_PATH="$PWD/.tools/browsers" node crates/chat-server/tests/browser.mjs + + Browser automation additionally needs Node, Playwright under .tools/browser, + and its browser downloads under .tools/browsers. These are development tools + excluded from releases. The fixtures use temporary isolated installation roots + with one temporary .env each, and remove their private data on completion. + They do not initialize your real owner identity or touch production. + + LibreJS verification uses the Mozilla-distributed GNU LibreJS 7.21.1 in + Playwright Firefox 157.0, without whitelisting the application. The full chat + workflow passes while an unlicensed external control script is blocked. Set + CHAT_LIBREJS=1 when running the application browser test to reproduce this + variant; it needs that signed extension at .tools/librejs.xpi and Firefox + downloaded into .tools/browsers. The extension download used was: + + https://addons.mozilla.org/firefox/downloads/file/4169076/librejs-7.21.1.xpi + + Earlier tests exposed a LibreJS label mismatch: a combined GNU prefix and + or-later suffix was not recognized by that release. The table now uses its + recognized GNU-AGPL-3.0 identifier and the surrounding prose and legal headers + explicitly retain the version-3-or-later grant. Readable classic scripts and + workers are used instead of requiring module-script interception support. + This tests license recognition and operation, not the safety of delivered code. + + The crypto browser probe fails closed without browser CSPRNG entropy and on + unknown WASI imports. The application browser test uses real WASI/OpenSSL + cryptography, protected file downloads and reopen, incorrect passwords, + signed sends/replies, safe text display, counts, zero-history, pagination, + lost-response retry deduplication, reload, explicit lock, origin rejection, + live database snapshot restoration with both original identity files, in-flight + lock, back/forward restoration, and checks for plaintext persistence or private + credential uploads. + Native tests cover authorization, challenge reuse, expiry, locked sessions, + quotas, idempotency, and SQLite snapshot recovery. See CHAT-DESIGN.txt and + CHAT-OPENSSL-MIGRATION.txt for the design and historical backend review. diff --git a/docs/CHAT-DESIGN.txt b/docs/CHAT-DESIGN.txt index 7b8849c..d85ad44 100644 --- a/docs/CHAT-DESIGN.txt +++ b/docs/CHAT-DESIGN.txt @@ -16,9 +16,10 @@ along with this program. If not, see . PRIVATE CHAT DESIGN AND LIMITATIONS - Status: planned application; only the cryptographic compatibility probe - currently exists. This document is a specification, not a completion claim. - Work remains on feature/private-chat. Nothing is deployed to production. + Status: experimental local implementation on feature/private-chat. + CHAT-APPLICATION.txt records the implemented formats, behavior, commands, + tests, and current limitations. This document preserves the original design. + Nothing is deployed to production. USER EXPERIENCE diff --git a/infra/chat/build-browser.sh b/infra/chat/build-browser.sh new file mode 100644 index 0000000..dd0c777 --- /dev/null +++ b/infra/chat/build-browser.sh @@ -0,0 +1,34 @@ +#!/bin/sh +# Copyright (C) 2026 Doan Luu Duc Tai +# +# This program is free software: you can redistribute it and/or modify +# it under the terms of the GNU Affero General Public License as +# published by the Free Software Foundation, either version 3 of the +# License, or (at your option) any later version. +# +# This program is distributed in the hope that it will be useful, +# but WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +# GNU Affero General Public License for more details. +# +# You should have received a copy of the GNU Affero General Public License +# along with this program. If not, see . + +set -eu +ROOT="$(CDPATH= cd -- "$(dirname -- "$0")/../.." && pwd)" +SOURCE="$ROOT/.tools/wasi-source" +SDK="$SOURCE/wasi-sdk-34.0-x86_64-linux" +PREFIX="$ROOT/.tools/openssl-wasi" +cd "$ROOT" +test -x "$SDK/bin/clang" +test -f "$PREFIX/lib/libcrypto.a" +printf '%s %s\n' b761e3a0721dbae9c09a0059e5fdb2bf917d1b4a8a7b430fb3b5aafb0984b2c4 "$SOURCE/sdk.tar.gz" | sha256sum --check +printf '%s %s\n' a2157c2830efdec3788939b00c9b0638306d3f0bbb76dc4832ee503bb397df98 "$SOURCE/openssl.tar.gz" | sha256sum --check +rustup target add wasm32-wasip1 +RUST_LIB="$(rustc --print target-libdir --target wasm32-wasip1)/self-contained" +env LIBCLANG_PATH="$SDK/lib" OPENSSL_DIR="$PREFIX" OPENSSL_STATIC=1 \ + CC_wasm32_wasip1="$SDK/bin/clang" AR_wasm32_wasip1="$SDK/bin/llvm-ar" \ + BINDGEN_EXTRA_CLANG_ARGS_wasm32_wasip1="--sysroot=$SDK/share/wasi-sysroot --target=wasm32-wasip1 -fvisibility=default" \ + RUSTFLAGS="-Lnative=$RUST_LIB -Lnative=$SDK/share/wasi-sysroot/lib/wasm32-wasip1 -Clink-arg=-lwasi-emulated-getpid -Clink-arg=--max-memory=268435456" \ + cargo build --locked --release -p stack-chat-crypto --bin stack-chat-crypto --target wasm32-wasip1 +sha256sum target/wasm32-wasip1/release/stack-chat-crypto.wasm diff --git a/infra/chat/build-openssl-wasi.sh b/infra/chat/build-openssl-wasi.sh index 7cdd9b6..91c0583 100755 --- a/infra/chat/build-openssl-wasi.sh +++ b/infra/chat/build-openssl-wasi.sh @@ -42,8 +42,4 @@ cd "$SOURCE/openssl-3.6.5" env CC="$SDK/bin/clang" AR="$SDK/bin/llvm-ar" RANLIB="$SDK/bin/llvm-ranlib" CFLAGS='-O2 -D_WASI_EMULATED_GETPID -Dgetuid=getpagesize -Dgeteuid=getpagesize -Dgetgid=getpagesize -Dgetegid=getpagesize' LDFLAGS='-lwasi-emulated-getpid' ./Configure stack-wasi --prefix="$PREFIX" --libdir=lib --with-rand-seed=getrandom no-asm no-async no-autoload-config no-dso no-module no-posix-io no-secure-memory no-shared no-sock no-stdio no-thread-pool no-threads no-ui-console make -j"${CHAT_BUILD_JOBS:-8}" build_libs make install_dev -cd "$ROOT" -rustup target add wasm32-wasip1 -RUST_LIB="$(rustc --print target-libdir --target wasm32-wasip1)/self-contained" -env LIBCLANG_PATH="$SDK/lib" OPENSSL_DIR="$PREFIX" OPENSSL_STATIC=1 CC_wasm32_wasip1="$SDK/bin/clang" AR_wasm32_wasip1="$SDK/bin/llvm-ar" BINDGEN_EXTRA_CLANG_ARGS_wasm32_wasip1="--sysroot=$SDK/share/wasi-sysroot --target=wasm32-wasip1 -fvisibility=default" RUSTFLAGS="-Lnative=$RUST_LIB -Lnative=$SDK/share/wasi-sysroot/lib/wasm32-wasip1 -Clink-arg=-lwasi-emulated-getpid" cargo build --locked --release -p stack-chat-crypto --bin stack-chat-crypto --target wasm32-wasip1 -sha256sum target/wasm32-wasip1/release/stack-chat-crypto.wasm +sh "$ROOT/infra/chat/build-browser.sh" diff --git a/infra/chat/build-source.sh b/infra/chat/build-source.sh new file mode 100644 index 0000000..6f24dcd --- /dev/null +++ b/infra/chat/build-source.sh @@ -0,0 +1,29 @@ +#!/bin/sh +# Copyright (C) 2026 Doan Luu Duc Tai +# +# This program is free software: you can redistribute it and/or modify +# it under the terms of the GNU Affero General Public License as +# published by the Free Software Foundation, either version 3 of the +# License, or (at your option) any later version. +# +# This program is distributed in the hope that it will be useful, +# but WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +# GNU Affero General Public License for more details. +# +# You should have received a copy of the GNU Affero General Public License +# along with this program. If not, see . + +set -eu +ROOT="$(CDPATH= cd -- "$(dirname -- "$0")/../.." && pwd)" +cd "$ROOT" +BUILD="$ROOT/var/build" +mkdir -p "$BUILD" +cargo vendor --locked var/build/chat-vendor | sed 's|directory = "var/build/chat-vendor"|directory = "chat-vendor"|' > "$BUILD/chat-vendor-config.txt" +cp "$ROOT/.tools/wasi-source/openssl.tar.gz" "$BUILD/chat-openssl-3.6.5.tar.gz" +printf '%s %s\n' a2157c2830efdec3788939b00c9b0638306d3f0bbb76dc4832ee503bb397df98 "$BUILD/chat-openssl-3.6.5.tar.gz" | sha256sum --check +source_list="$BUILD/chat-source-files.txt" +find Cargo.toml Cargo.lock rust-toolchain.toml .cargo .gitignore .gitattributes src crates infra web docs README.txt LICENSE deny.toml about.toml about.hbs .env.example -type f -print > "$source_list" +tar -czf "$BUILD/chat-source.tar.gz.pending" --verbatim-files-from -T "$source_list" -C "$BUILD" chat-vendor chat-vendor-config.txt chat-openssl-3.6.5.tar.gz +mv "$BUILD/chat-source.tar.gz.pending" "$BUILD/chat-source.tar.gz" +sha256sum "$BUILD/chat-source.tar.gz" diff --git a/src/archive.rs b/src/archive.rs index e7e6bc5..03ec643 100644 --- a/src/archive.rs +++ b/src/archive.rs @@ -103,6 +103,25 @@ fn snapshot(project: &Project, records: &[Value], stage: &Path) -> Result<()> { resume } +fn snapshot_chat(root: &Path, stage: &Path) -> Result<()> { + let source = root.join("var/data/chat/chat.sqlite"); + if !source.is_file() { + return Ok(()); + } + let directory = stage.join("var/data/chat"); + files::private_dir(&directory)?; + let database = + rusqlite::Connection::open_with_flags(source, rusqlite::OpenFlags::SQLITE_OPEN_READ_ONLY)?; + database.busy_timeout(std::time::Duration::from_secs(5))?; + let destination = directory.join("chat.sqlite"); + database.backup("main", &destination, None::)?; + fs::set_permissions( + destination, + std::os::unix::fs::PermissionsExt::from_mode(0o600), + )?; + Ok(()) +} + fn snapshot_files(project: &Project, records: &[Value], stage: &Path) -> Result<()> { files::copy_source(&project.root, stage)?; files::copy_tree(&project.root.join(".env"), &stage.join(".env"))?; @@ -112,13 +131,14 @@ fn snapshot_files(project: &Project, records: &[Value], stage: &Path) -> Result< let data = project.root.join("var/data"); for item in fs::read_dir(&data)? { let entry = item?; - if entry.file_name() != "caddy" { + if entry.file_name() != "caddy" && entry.file_name() != "chat" { files::copy_tree( &entry.path(), &stage.join("var/data").join(entry.file_name()), )?; } } + snapshot_chat(&project.root, stage)?; let caddy = records .iter() .find(|record| { @@ -364,6 +384,32 @@ mod tests { use crate::files; use crate::project::{Mode, Project}; + #[test] + fn chat_snapshot_is_consistent_and_private() -> Result<()> { + let workspace = tempfile::tempdir()?; + let root = workspace.path().join("source"); + let stage = workspace.path().join("snapshot"); + files::private_dir(&root.join("var/data/chat"))?; + let connection = rusqlite::Connection::open(root.join("var/data/chat/chat.sqlite"))?; + connection.execute_batch( + "CREATE TABLE messages (body TEXT); INSERT INTO messages VALUES ('ciphertext-before');", + )?; + let transaction = connection.unchecked_transaction()?; + transaction.execute("INSERT INTO messages VALUES ('uncommitted')", [])?; + super::snapshot_chat(&root, &stage)?; + let snapshot = stage.join("var/data/chat/chat.sqlite"); + let restored = rusqlite::Connection::open(&snapshot)?; + let count: i64 = + restored.query_row("SELECT COUNT(*) FROM messages", [], |row| row.get(0))?; + assert_eq!(count, 1); + assert_eq!( + std::os::unix::fs::PermissionsExt::mode(&fs::metadata(snapshot)?.permissions()) & 0o777, + 0o600 + ); + transaction.rollback()?; + Ok(()) + } + #[test] fn restore_is_local_and_refuses_an_existing_destination() -> Result<()> { let workspace = tempfile::tempdir()?; diff --git a/src/main.rs b/src/main.rs index beba5b0..08c88f5 100644 --- a/src/main.rs +++ b/src/main.rs @@ -49,6 +49,11 @@ struct Cli { #[derive(Debug, Subcommand)] enum Action { + #[command(about = "Run the experimental local chat or initialize its single .env")] + Chat { + #[arg(long)] + init: bool, + }, #[command(about = "Start the local development services")] Dev { #[arg(long, default_value_t = 8080)] @@ -127,6 +132,16 @@ fn execute(arguments: Cli) -> Result<()> { return archive::restore(&absolute(archive)?, &absolute(destination)?); } let project = Project::discover(arguments.root.as_deref())?; + if let Action::Chat { init } = arguments.command { + let mut command = project.command("cargo")?; + command + .args(["run", "--locked", "-p", "stack-chat-server", "--", "--root"]) + .arg(&project.root); + if init { + command.arg("--init"); + } + return process::run(&mut command); + } let _operation = project.lock()?; match arguments.command { Action::Dev { port } => { @@ -186,6 +201,7 @@ fn execute(arguments: Cli) -> Result<()> { Action::Activate { archive } => release::activate(&absolute(&archive)?, &project), Action::Check => check(&project), Action::Licenses => licenses(&project), + Action::Chat { .. } => anyhow::bail!("Chat action was not handled"), Action::Restore { .. } => anyhow::bail!("Restore action was not handled"), } } diff --git a/web/THIRD-PARTY-LICENSES.html b/web/THIRD-PARTY-LICENSES.html index 892b28f..8187aeb 100644 --- a/web/THIRD-PARTY-LICENSES.html +++ b/web/THIRD-PARTY-LICENSES.html @@ -32,7 +32,7 @@ applications and their operating-system packages have their own notices.

Headings follow GNU's license names where listed. SPDX identifiers remain available for exact identification. The original license texts below are unchanged.

- +

Apache License, Version 2.0

@@ -242,7 +242,214 @@ The original license texts below are unchanged.

- + +
                                 Apache License
+                           Version 2.0, January 2004
+                        http://www.apache.org/licenses/
+
+   TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION
+
+   1. Definitions.
+
+      "License" shall mean the terms and conditions for use, reproduction,
+      and distribution as defined by Sections 1 through 9 of this document.
+
+      "Licensor" shall mean the copyright owner or entity authorized by
+      the copyright owner that is granting the License.
+
+      "Legal Entity" shall mean the union of the acting entity and all
+      other entities that control, are controlled by, or are under common
+      control with that entity. For the purposes of this definition,
+      "control" means (i) the power, direct or indirect, to cause the
+      direction or management of such entity, whether by contract or
+      otherwise, or (ii) ownership of fifty percent (50%) or more of the
+      outstanding shares, or (iii) beneficial ownership of such entity.
+
+      "You" (or "Your") shall mean an individual or Legal Entity
+      exercising permissions granted by this License.
+
+      "Source" form shall mean the preferred form for making modifications,
+      including but not limited to software source code, documentation
+      source, and configuration files.
+
+      "Object" form shall mean any form resulting from mechanical
+      transformation or translation of a Source form, including but
+      not limited to compiled object code, generated documentation,
+      and conversions to other media types.
+
+      "Work" shall mean the work of authorship, whether in Source or
+      Object form, made available under the License, as indicated by a
+      copyright notice that is included in or attached to the work
+      (an example is provided in the Appendix below).
+
+      "Derivative Works" shall mean any work, whether in Source or Object
+      form, that is based on (or derived from) the Work and for which the
+      editorial revisions, annotations, elaborations, or other modifications
+      represent, as a whole, an original work of authorship. For the purposes
+      of this License, Derivative Works shall not include works that remain
+      separable from, or merely link (or bind by name) to the interfaces of,
+      the Work and Derivative Works thereof.
+
+      "Contribution" shall mean any work of authorship, including
+      the original version of the Work and any modifications or additions
+      to that Work or Derivative Works thereof, that is intentionally
+      submitted to Licensor for inclusion in the Work by the copyright owner
+      or by an individual or Legal Entity authorized to submit on behalf of
+      the copyright owner. For the purposes of this definition, "submitted"
+      means any form of electronic, verbal, or written communication sent
+      to the Licensor or its representatives, including but not limited to
+      communication on electronic mailing lists, source code control systems,
+      and issue tracking systems that are managed by, or on behalf of, the
+      Licensor for the purpose of discussing and improving the Work, but
+      excluding communication that is conspicuously marked or otherwise
+      designated in writing by the copyright owner as "Not a Contribution."
+
+      "Contributor" shall mean Licensor and any individual or Legal Entity
+      on behalf of whom a Contribution has been received by Licensor and
+      subsequently incorporated within the Work.
+
+   2. Grant of Copyright License. Subject to the terms and conditions of
+      this License, each Contributor hereby grants to You a perpetual,
+      worldwide, non-exclusive, no-charge, royalty-free, irrevocable
+      copyright license to reproduce, prepare Derivative Works of,
+      publicly display, publicly perform, sublicense, and distribute the
+      Work and such Derivative Works in Source or Object form.
+
+   3. Grant of Patent License. Subject to the terms and conditions of
+      this License, each Contributor hereby grants to You a perpetual,
+      worldwide, non-exclusive, no-charge, royalty-free, irrevocable
+      (except as stated in this section) patent license to make, have made,
+      use, offer to sell, sell, import, and otherwise transfer the Work,
+      where such license applies only to those patent claims licensable
+      by such Contributor that are necessarily infringed by their
+      Contribution(s) alone or by combination of their Contribution(s)
+      with the Work to which such Contribution(s) was submitted. If You
+      institute patent litigation against any entity (including a
+      cross-claim or counterclaim in a lawsuit) alleging that the Work
+      or a Contribution incorporated within the Work constitutes direct
+      or contributory patent infringement, then any patent licenses
+      granted to You under this License for that Work shall terminate
+      as of the date such litigation is filed.
+
+   4. Redistribution. You may reproduce and distribute copies of the
+      Work or Derivative Works thereof in any medium, with or without
+      modifications, and in Source or Object form, provided that You
+      meet the following conditions:
+
+      (a) You must give any other recipients of the Work or
+          Derivative Works a copy of this License; and
+
+      (b) You must cause any modified files to carry prominent notices
+          stating that You changed the files; and
+
+      (c) You must retain, in the Source form of any Derivative Works
+          that You distribute, all copyright, patent, trademark, and
+          attribution notices from the Source form of the Work,
+          excluding those notices that do not pertain to any part of
+          the Derivative Works; and
+
+      (d) If the Work includes a "NOTICE" text file as part of its
+          distribution, then any Derivative Works that You distribute must
+          include a readable copy of the attribution notices contained
+          within such NOTICE file, excluding those notices that do not
+          pertain to any part of the Derivative Works, in at least one
+          of the following places: within a NOTICE text file distributed
+          as part of the Derivative Works; within the Source form or
+          documentation, if provided along with the Derivative Works; or,
+          within a display generated by the Derivative Works, if and
+          wherever such third-party notices normally appear. The contents
+          of the NOTICE file are for informational purposes only and
+          do not modify the License. You may add Your own attribution
+          notices within Derivative Works that You distribute, alongside
+          or as an addendum to the NOTICE text from the Work, provided
+          that such additional attribution notices cannot be construed
+          as modifying the License.
+
+      You may add Your own copyright statement to Your modifications and
+      may provide additional or different license terms and conditions
+      for use, reproduction, or distribution of Your modifications, or
+      for any such Derivative Works as a whole, provided Your use,
+      reproduction, and distribution of the Work otherwise complies with
+      the conditions stated in this License.
+
+   5. Submission of Contributions. Unless You explicitly state otherwise,
+      any Contribution intentionally submitted for inclusion in the Work
+      by You to the Licensor shall be under the terms and conditions of
+      this License, without any additional terms or conditions.
+      Notwithstanding the above, nothing herein shall supersede or modify
+      the terms of any separate license agreement you may have executed
+      with Licensor regarding such Contributions.
+
+   6. Trademarks. This License does not grant permission to use the trade
+      names, trademarks, service marks, or product names of the Licensor,
+      except as required for reasonable and customary use in describing the
+      origin of the Work and reproducing the content of the NOTICE file.
+
+   7. Disclaimer of Warranty. Unless required by applicable law or
+      agreed to in writing, Licensor provides the Work (and each
+      Contributor provides its Contributions) on an "AS IS" BASIS,
+      WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or
+      implied, including, without limitation, any warranties or conditions
+      of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A
+      PARTICULAR PURPOSE. You are solely responsible for determining the
+      appropriateness of using or redistributing the Work and assume any
+      risks associated with Your exercise of permissions under this License.
+
+   8. Limitation of Liability. In no event and under no legal theory,
+      whether in tort (including negligence), contract, or otherwise,
+      unless required by applicable law (such as deliberate and grossly
+      negligent acts) or agreed to in writing, shall any Contributor be
+      liable to You for damages, including any direct, indirect, special,
+      incidental, or consequential damages of any character arising as a
+      result of this License or out of the use or inability to use the
+      Work (including but not limited to damages for loss of goodwill,
+      work stoppage, computer failure or malfunction, or any and all
+      other commercial damages or losses), even if such Contributor
+      has been advised of the possibility of such damages.
+
+   9. Accepting Warranty or Additional Liability. While redistributing
+      the Work or Derivative Works thereof, You may choose to offer,
+      and charge a fee for, acceptance of support, warranty, indemnity,
+      or other liability obligations and/or rights consistent with this
+      License. However, in accepting such obligations, You may act only
+      on Your own behalf and on Your sole responsibility, not on behalf
+      of any other Contributor, and only if You agree to indemnify,
+      defend, and hold each Contributor harmless for any liability
+      incurred by, or claims asserted against, such Contributor by reason
+      of your accepting any such warranty or additional liability.
+
+   END OF TERMS AND CONDITIONS
+
+   APPENDIX: How to apply the Apache License to your work.
+
+      To apply the Apache License to your work, attach the following
+      boilerplate notice, with the fields enclosed by brackets "{}"
+      replaced with your own identifying information. (Don't include
+      the brackets!)  The text should be enclosed in the appropriate
+      comment syntax for the file format. We also recommend that a
+      file or class name and description of purpose be included on the
+      same "printed page" as the copyright notice for easier
+      identification within third-party archives.
+
+   Copyright {yyyy} {name of copyright owner}
+
+   Licensed under the Apache License, Version 2.0 (the "License");
+   you may not use this file except in compliance with the License.
+   You may obtain a copy of the License at
+
+       http://www.apache.org/licenses/LICENSE-2.0
+
+   Unless required by applicable law or agreed to in writing, software
+   distributed under the License is distributed on an "AS IS" BASIS,
+   WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+   See the License for the specific language governing permissions and
+   limitations under the License.
+
+
+
+
+ +
Apache License
 Version 2.0, January 2004
 http://www.apache.org/licenses/
@@ -318,8 +525,42 @@ See the License for the specific language governing permissions and
 limitations under the License.
 
-
+

Modified BSD License

+ +
BSD 3-Clause License
+
+Copyright (c) 2013, Julien Schmidt
+All rights reserved.
+
+Redistribution and use in source and binary forms, with or without
+modification, are permitted provided that the following conditions are met:
+
+1. Redistributions of source code must retain the above copyright notice, this
+   list of conditions and the following disclaimer.
+
+2. Redistributions in binary form must reproduce the above copyright notice,
+   this list of conditions and the following disclaimer in the documentation
+   and/or other materials provided with the distribution.
+
+3. Neither the name of the copyright holder nor the names of its
+   contributors may be used to endorse or promote products derived from
+   this software without specific prior written permission.
+
+THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS"
+AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE
+IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE
+DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE LIABLE
+FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL
+DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR
+SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER
+CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY,
+OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE
+OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE.
+
+
+
+
BSD 3-Clause License
 
@@ -352,7 +593,7 @@ OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE
 OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE.
 
-
+
Copyright (c) 2016-2017 Isis Agora Lovecruft, Henry de Valence. All rights reserved.
@@ -386,7 +627,7 @@ NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE OF THIS
 SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. 
 
-
+

Boost Software License

Boost Software License - Version 1.0 - August 17th, 2003
@@ -414,7 +655,7 @@ ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER
 DEALINGS IN THE SOFTWARE.
 
-
+

ISC License

Copyright © 2015, Simonas Kazlauskas
@@ -431,7 +672,7 @@ NEGLIGENCE OR OTHER TORTIOUS ACTION, ARISING OUT OF OR IN CONNECTION WITH THE US
 THIS SOFTWARE.
 
-
+

GNU Library General Public License, version 2 or later

Sequoia PGP is free software; you can redistribute it and/or modify it
@@ -934,7 +1175,7 @@ necessary.  Here is a sample; alter the names:
 That's all there is to it!
 
-
+

Expat License

# The MIT License (MIT)
@@ -959,7 +1200,7 @@ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
 OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN
 THE SOFTWARE.
-
+
(C) Copyright 2016 Jethro G. Beekman
@@ -989,10 +1230,30 @@ IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER
 DEALINGS IN THE SOFTWARE.
 
-
+
- -
Copyright (c) 2010 The Rust Project Developers
+
+
===============================================================================
+
+Copyright (c) 2016 Alex Crichton
+Copyright (c) 2017 The Tokio Authors
+
+Licensed under the Apache License, Version 2.0 (the "License");
+you may not use this file except in compliance with the License.
+You may obtain a copy of the License at
+
+	http://www.apache.org/licenses/LICENSE-2.0
+
+Unless required by applicable law or agreed to in writing, software
+distributed under the License is distributed on an "AS IS" BASIS,
+WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+See the License for the specific language governing permissions and
+limitations under the License.
+
+===============================================================================
+
+Copyright (c) 2016 Alex Crichton
+Copyright (c) 2017 The Tokio Authors
 
 Permission is hereby granted, free of charge, to any
 person obtaining a copy of this software and associated
@@ -1019,10 +1280,10 @@ IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER
 DEALINGS IN THE SOFTWARE.
 
-
+
- -
Copyright (c) 2012-2013 Mozilla Foundation
+
+
Copyright (c) 2010 The Rust Project Developers
 
 Permission is hereby granted, free of charge, to any
 person obtaining a copy of this software and associated
@@ -1049,7 +1310,37 @@ IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER
 DEALINGS IN THE SOFTWARE.
 
-
+
+ + +
Copyright (c) 2012-2013 Mozilla Foundation
+
+Permission is hereby granted, free of charge, to any
+person obtaining a copy of this software and associated
+documentation files (the "Software"), to deal in the
+Software without restriction, including without
+limitation the rights to use, copy, modify, merge,
+publish, distribute, sublicense, and/or sell copies of
+the Software, and to permit persons to whom the Software
+is furnished to do so, subject to the following
+conditions:
+
+The above copyright notice and this permission notice
+shall be included in all copies or substantial portions
+of the Software.
+
+THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF
+ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED
+TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A
+PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT
+SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY
+CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION
+OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR
+IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER
+DEALINGS IN THE SOFTWARE.
+
+
+
Copyright (c) 2013-2014 The Rust Project Developers.
@@ -1074,9 +1365,9 @@ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
 SOFTWARE.
 
-
+
- +
Copyright (c) 2013-2025 The rust-url developers
 
 Permission is hereby granted, free of charge, to any
@@ -1104,9 +1395,9 @@ IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER
 DEALINGS IN THE SOFTWARE.
 
-
+
- +
Copyright (c) 2014 Alex Crichton
 
 Permission is hereby granted, free of charge, to any
@@ -1134,7 +1425,31 @@ IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER
 DEALINGS IN THE SOFTWARE.
 
-
+
+ + +
Copyright (c) 2014 Carl Lerche and other MIO contributors
+
+Permission is hereby granted, free of charge, to any person obtaining a copy
+of this software and associated documentation files (the "Software"), to deal
+in the Software without restriction, including without limitation the rights
+to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
+copies of the Software, and to permit persons to whom the Software is
+furnished to do so, subject to the following conditions:
+
+The above copyright notice and this permission notice shall be included in
+all copies or substantial portions of the Software.
+
+THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
+IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
+FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
+AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
+LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
+OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN
+THE SOFTWARE.
+
+
+
Copyright (c) 2014 Chris Wong
@@ -1164,7 +1479,32 @@ IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER
 DEALINGS IN THE SOFTWARE.
 
-
+
+ + +
Copyright (c) 2014 Sean McArthur
+
+Permission is hereby granted, free of charge, to any person obtaining a copy
+of this software and associated documentation files (the "Software"), to deal
+in the Software without restriction, including without limitation the rights
+to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
+copies of the Software, and to permit persons to whom the Software is
+furnished to do so, subject to the following conditions:
+
+The above copyright notice and this permission notice shall be included in
+all copies or substantial portions of the Software.
+
+THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
+IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
+FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
+AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
+LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
+OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN
+THE SOFTWARE.
+
+
+
+
Copyright (c) 2014 Steve "Sc00bz" Thomas (steve at tobtu dot com)
@@ -1195,7 +1535,7 @@ IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER
 DEALINGS IN THE SOFTWARE.
 
-
+
Copyright (c) 2014 The Rust Project Developers
@@ -1225,7 +1565,31 @@ IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER
 DEALINGS IN THE SOFTWARE.
 
-
+
+ + +
Copyright (c) 2014 The rusqlite developers
+
+Permission is hereby granted, free of charge, to any person obtaining a copy
+of this software and associated documentation files (the "Software"), to deal
+in the Software without restriction, including without limitation the rights
+to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
+copies of the Software, and to permit persons to whom the Software is
+furnished to do so, subject to the following conditions:
+
+The above copyright notice and this permission notice shall be included in
+all copies or substantial portions of the Software.
+
+THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
+IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
+FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
+AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
+LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
+OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN
+THE SOFTWARE.
+
+
+
Copyright (c) 2014-2019 Geoffroy Couprie
@@ -1250,7 +1614,7 @@ OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION
 WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
 
-
+
Copyright (c) 2014-2026 Alex Crichton
@@ -1280,7 +1644,31 @@ IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER
 DEALINGS IN THE SOFTWARE.
 
-
+
+ + +
Copyright (c) 2014-2026 Sean McArthur
+
+Permission is hereby granted, free of charge, to any person obtaining a copy
+of this software and associated documentation files (the "Software"), to deal
+in the Software without restriction, including without limitation the rights
+to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
+copies of the Software, and to permit persons to whom the Software is
+furnished to do so, subject to the following conditions:
+
+The above copyright notice and this permission notice shall be included in
+all copies or substantial portions of the Software.
+
+THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
+IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
+FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
+AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
+LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
+OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN
+THE SOFTWARE.
+
+
+
Copyright (c) 2015
@@ -1310,7 +1698,7 @@ IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER
 DEALINGS IN THE SOFTWARE.
 
-
+
Copyright (c) 2015 Jonathan Reem
@@ -1340,7 +1728,7 @@ IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER
 DEALINGS IN THE SOFTWARE.
 
-
+
Copyright (c) 2015 Steven Allen
@@ -1370,7 +1758,7 @@ IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER
 DEALINGS IN THE SOFTWARE.
 
-
+
Copyright (c) 2015 The LALRPOP Project Developers
@@ -1400,7 +1788,7 @@ IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER
 DEALINGS IN THE SOFTWARE.
 
-
+
Copyright (c) 2015 The Rust Project Developers
@@ -1430,7 +1818,31 @@ IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER
 DEALINGS IN THE SOFTWARE.
 
-
+
+ + +
Copyright (c) 2015 The rust-openssl-verify Developers
+
+Permission is hereby granted, free of charge, to any person obtaining a copy
+of this software and associated documentation files (the "Software"), to deal
+in the Software without restriction, including without limitation the rights
+to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
+copies of the Software, and to permit persons to whom the Software is
+furnished to do so, subject to the following conditions:
+
+The above copyright notice and this permission notice shall be included in all
+copies or substantial portions of the Software.
+
+THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
+IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
+FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
+AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
+LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
+OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
+SOFTWARE.
+
+
+
Copyright (c) 2015-2016 The blake2-rfc Developers, Cesar Barros
@@ -1461,7 +1873,7 @@ IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER
 DEALINGS IN THE SOFTWARE.
 
-
+
Copyright (c) 2015-2017
@@ -1491,7 +1903,32 @@ IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER
 DEALINGS IN THE SOFTWARE.
 
-
+
+ + +
Copyright (c) 2015-2025 Sean McArthur
+
+Permission is hereby granted, free of charge, to any person obtaining a copy
+of this software and associated documentation files (the "Software"), to deal
+in the Software without restriction, including without limitation the rights
+to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
+copies of the Software, and to permit persons to whom the Software is
+furnished to do so, subject to the following conditions:
+
+The above copyright notice and this permission notice shall be included in
+all copies or substantial portions of the Software.
+
+THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
+IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
+FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
+AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
+LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
+OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN
+THE SOFTWARE.
+
+
+
+
Copyright (c) 2016 Alex Crichton
@@ -1522,7 +1959,7 @@ IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER
 DEALINGS IN THE SOFTWARE.
 
-
+
Copyright (c) 2016 Amanieu d'Antras
@@ -1552,7 +1989,7 @@ IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER
 DEALINGS IN THE SOFTWARE.
 
-
+
Copyright (c) 2016 Joe Wilm
@@ -1582,7 +2019,31 @@ IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER
 DEALINGS IN THE SOFTWARE.
 
-
+
+ + +
Copyright (c) 2016 Pyfisch
+
+Permission is hereby granted, free of charge, to any person obtaining a copy
+of this software and associated documentation files (the "Software"), to deal
+in the Software without restriction, including without limitation the rights
+to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
+copies of the Software, and to permit persons to whom the Software is
+furnished to do so, subject to the following conditions:
+
+The above copyright notice and this permission notice shall be included in
+all copies or substantial portions of the Software.
+
+THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
+IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
+FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
+AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
+LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
+OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN
+THE SOFTWARE.
+
+
+
Copyright (c) 2016 The Rust Project Developers
@@ -1612,7 +2073,31 @@ IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER
 DEALINGS IN THE SOFTWARE.
 
-
+
+ + +
Copyright (c) 2016 The fallible-streaming-iterator Developers
+
+Permission is hereby granted, free of charge, to any person obtaining a copy
+of this software and associated documentation files (the "Software"), to deal
+in the Software without restriction, including without limitation the rights
+to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
+copies of the Software, and to permit persons to whom the Software is
+furnished to do so, subject to the following conditions:
+
+The above copyright notice and this permission notice shall be included in all
+copies or substantial portions of the Software.
+
+THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
+IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
+FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
+AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
+LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
+OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
+SOFTWARE.
+
+
+
Copyright (c) 2016--2017
@@ -1642,7 +2127,7 @@ IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER
 DEALINGS IN THE SOFTWARE.
 
-
+
Copyright (c) 2016--2023
@@ -1672,7 +2157,7 @@ IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER
 DEALINGS IN THE SOFTWARE.
 
-
+
Copyright (c) 2016-2019 Ulrik Sverdrup "bluss" and scopeguard developers
@@ -1702,7 +2187,7 @@ IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER
 DEALINGS IN THE SOFTWARE.
 
-
+
Copyright (c) 2016-2026 The RustCrypto Project Developers
@@ -1735,7 +2220,7 @@ IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER
 DEALINGS IN THE SOFTWARE.
 
-
+
Copyright (c) 2017 Artyom Pavlov
@@ -1765,7 +2250,7 @@ IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER
 DEALINGS IN THE SOFTWARE.
 
-
+
Copyright (c) 2017 Jim McGrath
@@ -1796,7 +2281,7 @@ DEALINGS IN THE SOFTWARE.
 
 
-
+
Copyright (c) 2017 Redox OS Developers
@@ -1823,7 +2308,7 @@ OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION
 WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
 
-
+
Copyright (c) 2017 Robert Grosse
@@ -1852,7 +2337,91 @@ OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR
 IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER
 DEALINGS IN THE SOFTWARE.
-
+
+ + +
Copyright (c) 2017 The foreign-types Developers
+
+Permission is hereby granted, free of charge, to any person obtaining a copy
+of this software and associated documentation files (the "Software"), to deal
+in the Software without restriction, including without limitation the rights
+to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
+copies of the Software, and to permit persons to whom the Software is
+furnished to do so, subject to the following conditions:
+
+The above copyright notice and this permission notice shall be included in all
+copies or substantial portions of the Software.
+
+THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
+IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
+FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
+AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
+LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
+OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
+SOFTWARE.
+
+
+
+ + +
Copyright (c) 2017 http-rs authors
+
+Permission is hereby granted, free of charge, to any
+person obtaining a copy of this software and associated
+documentation files (the "Software"), to deal in the
+Software without restriction, including without
+limitation the rights to use, copy, modify, merge,
+publish, distribute, sublicense, and/or sell copies of
+the Software, and to permit persons to whom the Software
+is furnished to do so, subject to the following
+conditions:
+
+The above copyright notice and this permission notice
+shall be included in all copies or substantial portions
+of the Software.
+
+THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF
+ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED
+TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A
+PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT
+SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY
+CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION
+OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR
+IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER
+DEALINGS IN THE SOFTWARE.
+
+
+
+ + +
Copyright (c) 2017 tokio-jsonrpc developers
+
+Permission is hereby granted, free of charge, to any
+person obtaining a copy of this software and associated
+documentation files (the "Software"), to deal in the
+Software without restriction, including without
+limitation the rights to use, copy, modify, merge,
+publish, distribute, sublicense, and/or sell copies of
+the Software, and to permit persons to whom the Software
+is furnished to do so, subject to the following
+conditions:
+
+The above copyright notice and this permission notice
+shall be included in all copies or substantial portions
+of the Software.
+
+THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF
+ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED
+TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A
+PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT
+SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY
+CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION
+OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR
+IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER
+DEALINGS IN THE SOFTWARE.
+
+
+
Copyright (c) 2017-2025 RustCrypto Developers
@@ -1883,10 +2452,131 @@ IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER
 DEALINGS IN THE SOFTWARE.
 
-
+
+ + +
Copyright (c) 2018 Carl Lerche
+
+Permission is hereby granted, free of charge, to any
+person obtaining a copy of this software and associated
+documentation files (the "Software"), to deal in the
+Software without restriction, including without
+limitation the rights to use, copy, modify, merge,
+publish, distribute, sublicense, and/or sell copies of
+the Software, and to permit persons to whom the Software
+is furnished to do so, subject to the following
+conditions:
+
+The above copyright notice and this permission notice
+shall be included in all copies or substantial portions
+of the Software.
+
+THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF
+ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED
+TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A
+PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT
+SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY
+CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION
+OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR
+IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER
+DEALINGS IN THE SOFTWARE.
+
+
+
+ + +
Copyright (c) 2018 Josh Stone
+
+Permission is hereby granted, free of charge, to any
+person obtaining a copy of this software and associated
+documentation files (the "Software"), to deal in the
+Software without restriction, including without
+limitation the rights to use, copy, modify, merge,
+publish, distribute, sublicense, and/or sell copies of
+the Software, and to permit persons to whom the Software
+is furnished to do so, subject to the following
+conditions:
+
+The above copyright notice and this permission notice
+shall be included in all copies or substantial portions
+of the Software.
+
+THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF
+ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED
+TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A
+PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT
+SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY
+CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION
+OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR
+IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER
+DEALINGS IN THE SOFTWARE.
+
+
+
+ + +
Copyright (c) 2018 The Servo Project Developers
+
+Permission is hereby granted, free of charge, to any
+person obtaining a copy of this software and associated
+documentation files (the "Software"), to deal in the
+Software without restriction, including without
+limitation the rights to use, copy, modify, merge,
+publish, distribute, sublicense, and/or sell copies of
+the Software, and to permit persons to whom the Software
+is furnished to do so, subject to the following
+conditions:
+
+The above copyright notice and this permission notice
+shall be included in all copies or substantial portions
+of the Software.
+
+THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF
+ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED
+TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A
+PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT
+SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY
+CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION
+OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR
+IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER
+DEALINGS IN THE SOFTWARE.
+
+
+
+ + +
Copyright (c) 2018-2019 The RustCrypto Project Developers
+
+Permission is hereby granted, free of charge, to any
+person obtaining a copy of this software and associated
+documentation files (the "Software"), to deal in the
+Software without restriction, including without
+limitation the rights to use, copy, modify, merge,
+publish, distribute, sublicense, and/or sell copies of
+the Software, and to permit persons to whom the Software
+is furnished to do so, subject to the following
+conditions:
+
+The above copyright notice and this permission notice
+shall be included in all copies or substantial portions
+of the Software.
+
+THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF
+ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED
+TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A
+PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT
+SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY
+CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION
+OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR
+IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER
+DEALINGS IN THE SOFTWARE.
+
+
+
- -
Copyright (c) 2018 Josh Stone
+
+
Copyright (c) 2018-2024 The rust-random Project Developers
+Copyright (c) 2014 The Rust Project Developers
 
 Permission is hereby granted, free of charge, to any
 person obtaining a copy of this software and associated
@@ -1913,10 +2603,10 @@ IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER
 DEALINGS IN THE SOFTWARE.
 
-
+
- -
Copyright (c) 2018 The Servo Project Developers
+
+
Copyright (c) 2018-2025 The RustCrypto Project Developers
 
 Permission is hereby granted, free of charge, to any
 person obtaining a copy of this software and associated
@@ -1943,10 +2633,10 @@ IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER
 DEALINGS IN THE SOFTWARE.
 
-
+
- -
Copyright (c) 2018-2019 The RustCrypto Project Developers
+
+
Copyright (c) 2018-2026 The RustCrypto Project Developers
 
 Permission is hereby granted, free of charge, to any
 person obtaining a copy of this software and associated
@@ -1973,10 +2663,10 @@ IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER
 DEALINGS IN THE SOFTWARE.
 
-
+
- -
Copyright (c) 2018-2024 The rust-random Project Developers
+
+
Copyright (c) 2018-2026 The rust-random Project Developers
 Copyright (c) 2014 The Rust Project Developers
 
 Permission is hereby granted, free of charge, to any
@@ -2004,10 +2694,10 @@ IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER
 DEALINGS IN THE SOFTWARE.
 
-
+
- -
Copyright (c) 2018-2025 The RustCrypto Project Developers
+
+
Copyright (c) 2019 Carl Lerche
 
 Permission is hereby granted, free of charge, to any
 person obtaining a copy of this software and associated
@@ -2034,10 +2724,10 @@ IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER
 DEALINGS IN THE SOFTWARE.
 
-
+
- -
Copyright (c) 2018-2026 The RustCrypto Project Developers
+
+
Copyright (c) 2019 Nick Fitzgerald
 
 Permission is hereby granted, free of charge, to any
 person obtaining a copy of this software and associated
@@ -2064,11 +2754,10 @@ IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER
 DEALINGS IN THE SOFTWARE.
 
-
+
- -
Copyright (c) 2018-2026 The rust-random Project Developers
-Copyright (c) 2014 The Rust Project Developers
+
+
Copyright (c) 2019 The Rust Project Developers
 
 Permission is hereby granted, free of charge, to any
 person obtaining a copy of this software and associated
@@ -2095,10 +2784,10 @@ IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER
 DEALINGS IN THE SOFTWARE.
 
-
+
- -
Copyright (c) 2019 Carl Lerche
+
+
Copyright (c) 2019 Tower Contributors
 
 Permission is hereby granted, free of charge, to any
 person obtaining a copy of this software and associated
@@ -2125,10 +2814,10 @@ IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER
 DEALINGS IN THE SOFTWARE.
 
-
+
- -
Copyright (c) 2019 Nick Fitzgerald
+
+
Copyright (c) 2019 axum Contributors
 
 Permission is hereby granted, free of charge, to any
 person obtaining a copy of this software and associated
@@ -2155,10 +2844,10 @@ IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER
 DEALINGS IN THE SOFTWARE.
 
-
+
- -
Copyright (c) 2019 The Rust Project Developers
+
+
Copyright (c) 2019-2026 Sean McArthur & Hyper Contributors
 
 Permission is hereby granted, free of charge, to any
 person obtaining a copy of this software and associated
@@ -2185,7 +2874,7 @@ IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER
 DEALINGS IN THE SOFTWARE.
 
-
+
Copyright (c) 2020 Andrew D. Straw
@@ -2215,7 +2904,7 @@ IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER
 DEALINGS IN THE SOFTWARE.
 
-
+
Copyright (c) 2020-2023 RustCrypto Developers
@@ -2245,7 +2934,7 @@ IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER
 DEALINGS IN THE SOFTWARE.
 
-
+
Copyright (c) 2020-2025 The RustCrypto Project Developers
@@ -2275,7 +2964,7 @@ IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER
 DEALINGS IN THE SOFTWARE.
 
-
+
Copyright (c) 2020-2026 The RustCrypto Project Developers
@@ -2305,7 +2994,7 @@ IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER
 DEALINGS IN THE SOFTWARE.
 
-
+
Copyright (c) 2020-2026 The RustCrypto Project Developers
@@ -2339,7 +3028,7 @@ IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER
 DEALINGS IN THE SOFTWARE.
 
-
+
Copyright (c) 2021 RustCrypto Developers
@@ -2369,7 +3058,7 @@ IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER
 DEALINGS IN THE SOFTWARE.
 
-
+
Copyright (c) 2021-2024 The RustCrypto Project Developers
@@ -2399,7 +3088,7 @@ IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER
 DEALINGS IN THE SOFTWARE.
 
-
+
Copyright (c) 2021-2026 RustCrypto Developers
@@ -2429,7 +3118,31 @@ IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER
 DEALINGS IN THE SOFTWARE.
 
-
+
+ + +
Copyright (c) 2022 Steven Fackler
+
+Permission is hereby granted, free of charge, to any person obtaining a copy
+of this software and associated documentation files (the "Software"), to deal
+in the Software without restriction, including without limitation the rights
+to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
+copies of the Software, and to permit persons to whom the Software is
+furnished to do so, subject to the following conditions:
+
+The above copyright notice and this permission notice shall be included in all
+copies or substantial portions of the Software.
+
+THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
+IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
+FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
+AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
+LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
+OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
+SOFTWARE.
+
+
+
Copyright (c) 2022-2026 The RustCrypto Project Developers
@@ -2459,7 +3172,7 @@ IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER
 DEALINGS IN THE SOFTWARE.
 
-
+
Copyright (c) 2023 The Rust Project Developers
@@ -2489,7 +3202,31 @@ IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER
 DEALINGS IN THE SOFTWARE.
 
-
+
+ + +
Copyright (c) 2023-2025 Sean McArthur
+
+Permission is hereby granted, free of charge, to any person obtaining a copy
+of this software and associated documentation files (the "Software"), to deal
+in the Software without restriction, including without limitation the rights
+to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
+copies of the Software, and to permit persons to whom the Software is
+furnished to do so, subject to the following conditions:
+
+The above copyright notice and this permission notice shall be included in
+all copies or substantial portions of the Software.
+
+THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
+IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
+FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
+AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
+LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
+OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN
+THE SOFTWARE.
+
+
+
Copyright (c) 2026 The RustCrypto Project Developers
@@ -2519,7 +3256,7 @@ IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER
 DEALINGS IN THE SOFTWARE.
 
-
+
Copyright (c) Individual contributors
@@ -2543,7 +3280,7 @@ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
 SOFTWARE.
 
-
+
Copyright (c) The Rust Project Developers
@@ -2573,7 +3310,7 @@ IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER
 DEALINGS IN THE SOFTWARE.
 
-
+
Copyright (c) The rust-url developers
@@ -2603,7 +3340,7 @@ IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER
 DEALINGS IN THE SOFTWARE.
 
-
+
Copyright (c) The tar-rs Project Contributors
@@ -2633,7 +3370,7 @@ IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER
 DEALINGS IN THE SOFTWARE.
 
-
+
Copyright 2016 Nika Layzell
@@ -2645,7 +3382,7 @@ The above copyright notice and this permission notice shall be included in all c
 THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
 
-
+
Copyright 2018 Developers of the Rand project
@@ -2676,7 +3413,7 @@ IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER
 DEALINGS IN THE SOFTWARE.
 
-
+
Copyright Mozilla Foundation
@@ -2706,7 +3443,7 @@ IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER
 DEALINGS IN THE SOFTWARE.
 
-
+
MIT License
@@ -2732,7 +3469,7 @@ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
 SOFTWARE.
 
-
+
MIT License
@@ -2767,7 +3504,7 @@ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
 SOFTWARE.
 
-
+
MIT License
@@ -2793,7 +3530,91 @@ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
 SOFTWARE.
 
-
+
+ + +
MIT License
+
+Copyright (c) 2019 Yoshua Wuyts
+Copyright (c) Tokio Contributors
+
+Permission is hereby granted, free of charge, to any person obtaining a copy
+of this software and associated documentation files (the "Software"), to deal
+in the Software without restriction, including without limitation the rights
+to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
+copies of the Software, and to permit persons to whom the Software is
+furnished to do so, subject to the following conditions:
+
+The above copyright notice and this permission notice shall be included in all
+copies or substantial portions of the Software.
+
+THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
+IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
+FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
+AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
+LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
+OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
+SOFTWARE.
+
+
+
+ + +
MIT License
+
+Copyright (c) 2019–2025 axum Contributors
+
+Permission is hereby granted, free of charge, to any
+person obtaining a copy of this software and associated
+documentation files (the "Software"), to deal in the
+Software without restriction, including without
+limitation the rights to use, copy, modify, merge,
+publish, distribute, sublicense, and/or sell copies of
+the Software, and to permit persons to whom the Software
+is furnished to do so, subject to the following
+conditions:
+
+The above copyright notice and this permission notice
+shall be included in all copies or substantial portions
+of the Software.
+
+THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF
+ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED
+TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A
+PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT
+SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY
+CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION
+OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR
+IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER
+DEALINGS IN THE SOFTWARE.
+
+
+ + +
MIT License
+
+Copyright (c) 2022 Ibraheem Ahmed
+
+Permission is hereby granted, free of charge, to any person obtaining a copy
+of this software and associated documentation files (the "Software"), to deal
+in the Software without restriction, including without limitation the rights
+to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
+copies of the Software, and to permit persons to whom the Software is
+furnished to do so, subject to the following conditions:
+
+The above copyright notice and this permission notice shall be included in all
+copies or substantial portions of the Software.
+
+THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
+IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
+FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
+AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
+LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
+OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
+SOFTWARE.
+
+
+
MIT License
@@ -2816,7 +3637,33 @@ IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTI
 USE OR OTHER DEALINGS IN THE SOFTWARE.
 
-
+
+ + +
MIT License
+
+Copyright (c) Tokio Contributors
+
+Permission is hereby granted, free of charge, to any person obtaining a copy
+of this software and associated documentation files (the "Software"), to deal
+in the Software without restriction, including without limitation the rights
+to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
+copies of the Software, and to permit persons to whom the Software is
+furnished to do so, subject to the following conditions:
+
+The above copyright notice and this permission notice shall be included in all
+copies or substantial portions of the Software.
+
+THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
+IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
+FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
+AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
+LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
+OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
+SOFTWARE.
+
+
+
MIT License
@@ -2842,7 +3689,7 @@ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
 SOFTWARE.
 
-
+
MIT License
@@ -2872,7 +3719,7 @@ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
 SOFTWARE.
 
-
+
MIT License
@@ -2901,7 +3748,7 @@ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
 SOFTWARE.
 
-
+
MIT License
@@ -2914,7 +3761,7 @@ The above copyright notice and this permission notice shall be included in all c
 THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
 
-
+
Permission is hereby granted, free of charge, to any
@@ -2941,9 +3788,9 @@ OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR
 IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER
 DEALINGS IN THE SOFTWARE.
-
+
- +
Permission is hereby granted, free of charge, to any
 person obtaining a copy of this software and associated
 documentation files (the "Software"), to deal in the
@@ -2969,7 +3816,7 @@ IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER
 DEALINGS IN THE SOFTWARE.
 
-
+
Permission is hereby granted, free of charge, to any person obtaining
@@ -2992,7 +3839,7 @@ OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION
 WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
 
-
+
Permission is hereby granted, free of charge, to any person obtaining a copy
@@ -3014,7 +3861,7 @@ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
 SOFTWARE.
 
-
+
Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the "Software"), to deal in the Software without restriction, including without limitation the rights to use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the Software, and to permit persons to whom the Software is furnished to do so, subject to the following conditions:
@@ -3023,7 +3870,7 @@ The above copyright notice and this permission notice shall be included in all c
 
 THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
-
+
The MIT License (MIT)
@@ -3048,7 +3895,7 @@ IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN
 CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
 
-
+
The MIT License (MIT)
@@ -3074,7 +3921,7 @@ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
 SOFTWARE.
 
-
+
The MIT License (MIT)
@@ -3099,7 +3946,7 @@ IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN
 CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
 
-
+
The MIT License (MIT)
@@ -3125,7 +3972,7 @@ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN
 THE SOFTWARE.
 
-
+
The MIT License (MIT)
@@ -3151,7 +3998,7 @@ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN
 THE SOFTWARE.
 
-
+
The MIT License (MIT)
@@ -3179,7 +4026,7 @@ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
 SOFTWARE.
 
-
+
The MIT License (MIT)
@@ -3205,7 +4052,7 @@ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN
 THE SOFTWARE.
 
-
+
The MIT License (MIT)
@@ -3231,7 +4078,7 @@ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN
 THE SOFTWARE.
 
-
+
The MIT License (MIT)
@@ -3255,7 +4102,7 @@ IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN
 CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
 
-
+
The MIT License (MIT)
@@ -3280,7 +4127,7 @@ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
 OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
 SOFTWARE.
-
+

Unicode License v3

UNICODE LICENSE V3
@@ -3324,7 +4171,7 @@ dealings in these Data Files or Software without prior written
 authorization of the copyright holder.
 
-
+
UNICODE LICENSE V3
@@ -3375,7 +4222,7 @@ Portions of ICU4X may have been adapted from ICU4C and/or ICU4J.
 ICU 1.8.1 to ICU 57.1 © 1995-2016 International Business Machines Corporation and others.
 
-
+

License of ZLib

Copyright (c) 2024 Orson Peters
-- 
cgit v1.2.3