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. INTERFACE AND SITE STYLE The interface follows web/styles.css and the previous www/message.html and www/form.css from the parent of e373f15, the commit that removed the original Message Me form. It reuses the red ASCII logo, 80-column desktop width, black/white light and dark themes, blue links, green prompts/actions, ASCII form frames, and bracketed controls. Licensing links retain the main site's amber and muted green accents. The LibreJS badge stays yellow with its keyboard-accessible warning. Small screens keep readable form controls and fit the ASCII rules to the available width instead of shrinking all text. No remote fonts, images, or additional scripts are required. 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 listen on loopback port 9081 by default (override with CHAT_TEST_PORT), leaving the normal local preview on 9080 available. 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.