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 Available on main: direct development and prebuilt Podman/HTTPS deployment. See CHAT-PRODUCTION.txt for release, setup, and recovery. Feature work does not merge main or activate the VPS. Experimental; no independent messenger audit. WHAT EXISTS Rust HTTP server and Rust/WASI cryptography. Strict TypeScript compiles the interface and capability-limited WASI host to small, readable, free classic JavaScript. See TYPESCRIPT.txt for build/licensing. Text uses textContent. Existing services, including SearXNG, are unchanged. Crypto download shows an ASCII bar, percentage, and received/total MiB; unknown totals show received MiB. A 45-second stall aborts with reload instructions; slow transfers continue while bytes arrive. Chat connection has a separate status. No browser cache or storage was added. CREATE NEW CHAT generates a purpose-specific identity locally and downloads its protected file. Reopen that exact file with its password before first authentication/message. OPEN CHAT FILE restores access on later visits. No username/password account; messages never require updating the stable file. The owner has a separate identity/inbox. The canonical RSA OpenPGP key does not encrypt chat. History defaults to zero messages. Authenticated counters show total, sent, and received messages. Choose 0 to N recent messages, where N is the current conversation total, or page backwards for older messages. Counts refresh when the history input is focused, history is requested, or a send is acknowledged. Large selections are fetched and verified in batches of at most 50; that is a per-request resource bound, not a user-visible 50-message selection limit. 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 at the repository root on main. Native development needs Rust, libclang, OpenSSL >=3.5 development libraries, C compiler, pkg-config, make, Perl, curl, and tar. Checked OpenSSL: 3.6.5. WASI builds target x86_64 GNU/Linux and download hash-verified WASI SDK 34.0/OpenSSL sources. Toolchains stay off the VPS. 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 privately creates/appends CHAT_SETUP_TOKEN and CHAT_ORIGIN in the single root .env, preserving an existing token and SearXNG settings. Root .env.example lists both; never commit .env or create crate-specific files. CHAT_BIND and public navigation use the shared loader; see CONFIGURATION.txt. For Rust-only edits, sh infra/chat/build-browser.sh reuses verified OpenSSL/SDK inputs. Source/version/configuration/flag changes require an OpenSSL rebuild. WASI linear memory is capped at 256 MiB. Start: cargo stack chat Open http://127.0.0.1:9080/ and choose owner-only OWNER SETUP, or use http://127.0.0.1:9080/#setup. Read CHAT_SETUP_TOKEN from root .env locally; paste into setup. Choose a display name and strong unique password; save, reopen, and unlock the file. This registers the owner public identity and disables token-based setup. The token grants neither login nor key recovery. OWNER SETUP remains visible with existing-file/reset instructions; it never silently replaces a key. Never publish private file/password. Keep both in KeePassXC with a separately protected database backup. Create a visitor chat in another window at http://127.0.0.1:9080/. Identity stays in each page's memory; browser login is not shared. Control-C stops development. Restart invalidates sessions, preserving identities, conversations, counts, and ciphertext. Direct development requires loopback and an exactly matching bind/origin. Production uses a private listener behind Caddy HTTPS, with explicit CHAT_HOST/HTTPS_PORT origin; forwarded headers cannot choose it. Never expose direct development through a public proxy. OWNER FILE LOSS AND BACKEND RESET Setup tokens grant registration, not private-key recovery. A new identity cannot decrypt old messages if the owner file/backups are lost. Reset clears all messages, encrypted profiles, conversations, counters, and owner registration. Root .env and exports survive. This does not forensically erase backups, snapshots, or OS copies. Run development commands at the stack root. Reset has no HTTP API; private chat-directory access is the administrative boundary. Production runs ./bin/stack chat --owner-fingerprint and ./bin/stack chat --reset-owner in an isolated maintenance container. First stop with ./bin/stack down; restart with ./bin/stack up --production. No VPS Rust required. Read CHAT-PRODUCTION.txt before destructive reset. Read the current public fingerprint, even if you have lost the private file: cargo stack chat --owner-fingerprint Stop the chat server with Control-C in its terminal, then explicitly pass that full fingerprint to the destructive reset command: cargo stack chat --reset-owner PASTE_CURRENT_OWNER_FINGERPRINT_HERE Mismatched fingerprints refuse without deletion. Server/reset share a lock; direct development also requires a free loopback service port, protecting older running processes without the lock. Online snapshots and fingerprint reads remain available during operation. Restart: cargo stack chat Use OWNER SETUP and existing root .env CHAT_SETUP_TOKEN; generate, save, and reopen a fresh file. Back up file/password in KeePassXC. Visitors also need fresh files: old owner-key pins correctly reject replacement. Reset cannot prove continuity with the lost key. An owner file found later may recover old conversations from an encrypted backup in isolation. Reset retains exports and never substitutes new keys into old ciphertext or visitor files. 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. New identity JSON uses indentation and 80-character Base64 ciphertext chunks; the last may be shorter. JSON punctuation/indentation adds width. Readers join chunks before decoding. Private fields stay encrypted. This application envelope is not OpenPGP ASCII armor. Its identifier is authenticated as associated data: stack-chat-file/v2;argon2id-m65536-t3-p1;aes256-gcm Existing compact v1 files with one ciphertext string remain supported: stack-chat-file/v1;argon2id-m65536-t3-p1;aes256-gcm v1 retains its original associated data. The tag binds versions: relabeling or reshaping fields cannot convert files. Existing files need no rewrite; old readers reject v2. Algorithms/KDF parameters stay fixed. 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. Unlock checks exact origin and owner pin. Owner creation pins its own public certificate; visitors pin the certificate advertised over the trusted origin connection. First visits still trust served application/TLS, without 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. Transactional sends deduplicate exact ciphertext/message-ID/sender/conversation retries, returning the original sequence/counts. Reused IDs with changed data are rejected. This handles lost responses after storage. Keep the page unlocked to retry; reload loses pending ciphertext/draft, so inspect history before resending. Retries are deliberate, never automatic. Limits: 8192 UTF-8 bytes per text, 16384 bytes per signed payload, 65536 base64 characters per stored ciphertext, 50 history rows per API 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, in-place key rotation UI, blocklist, or password-change UI are implemented. Lost-owner recovery uses the explicit backend reset above. 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. Malicious servers can replace JavaScript/WASM and steal passwords/plaintext on the next visit; key pins and LibreJS cannot prevent it. Shared-host/OS compromise, extensions, browser bugs, dependency attacks, malicious friends, and denial of service remain risks. Purpose labels cannot prevent key export or reuse. This is not a SimpleX equivalent. BACKUP, EXPORT, AND RESTORE cargo stack backup uses SQLite online backups, not ordinary live database/ journal copies. chat and chat-local get separate consistent snapshots while running; chat stays unpaused. Other Podman services retain pause/copy/resume. Chat snapshots are atomic, without a cross-service transaction. Exports include root .env and must stay 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; destination must not exist. Standalone snapshots contain ciphertext/public metadata, without full-stack GPG encryption. Encrypt before off-machine storage. Browser private identities are absent; server backups cannot recover lost identity files/passwords. 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 an encrypted backup off-VPS. After recovery, test owner/visitor identities, counts, and decryption. Keep private files/passwords in KeePassXC with a protected database backup, never source control/public archives. Local deletion or file loss does not delete stored ciphertext, exports, or participants' copies. INTERFACE AND SITE STYLE web/styles.css supplies red ASCII logo, 80-column desktop width, black/white light/dark themes, blue links, green prompts/actions, ASCII frames, and bracketed controls. License links retain amber/muted green. Yellow LibreJS badge has a keyboard-accessible warning; JavaScript licenses sit below it. Small screens retain readable controls and fit ASCII rules without shrinking all text. Root viewport scrolls at the screen edge; no inner logo scrolling. No remote fonts, images, or additional scripts. LICENSES, SOURCE, AND VERIFICATION Bundled SQLite snapshots require a musl C compiler for portable CLI builds. The root CLI ran as x86_64 static PIE; VPS SQLite packages are unnecessary. Local checks compiled C with Alpine musl 1.2.6 headers/host compiler and linked with Rust's self-contained musl runtime. The rendered dependency-license page is centralized on the main website at https://doan.luuduct.ai/THIRD-PARTY-LICENSES.html. Chat links point there; its previous local license-page URL redirects there. cgit keeps ordinary source and raw-file views without a custom HTML renderer. 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, TypeScript sources/configurations/npm lock, 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" npm run test:crypto PLAYWRIGHT_BROWSERS_PATH="$PWD/.tools/browsers" npm run test:chat PLAYWRIGHT_BROWSERS_PATH="$PWD/.tools/browsers" npm run test:progress Automation needs Node/root locked Playwright, with browsers in .tools/browsers; see TYPESCRIPT.txt for installation/strict builds. Releases exclude these tools. Fixtures isolate roots with one temporary .env each, deleting private data on completion. Default loopback 9081 (CHAT_TEST_PORT overrides) leaves preview 9080 free. Packaged HTTPS tests retain disposable roots; see CHAT-PRODUCTION.txt. Tests neither initialize the real owner nor touch the VPS. 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 LibreJS rejected a combined GNU prefix/or-later suffix. The table uses recognized GNU-AGPL-3.0; prose/legal headers retain version-3-or-later. Readable classic scripts/workers avoid module-script interception requirements. This tests license recognition/operation, not delivered-code safety. 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, loading all 54 fixture messages through 50+4 batches, pagination, lost-response retry deduplication, reload, explicit lock, origin rejection, live database snapshot restoration with both original identity files, owner setup/access controls, refusing reset while running or with a mismatched fingerprint, successful backend reset and fresh registration, rejecting old visitor key pins, in-flight lock, back/forward restoration, and checks for plaintext persistence in the database/journals or private credential uploads. The packaged variant additionally verifies certificate trust/hostname through curl, restrictive export permissions, and full encrypted recovery with saved images and no build directories inside the recovered installation. Native tests cover authorization, challenge reuse, expiry, locked sessions, quotas, idempotency, and SQLite snapshot recovery. The implemented design and limits are documented here. CHAT-OPENSSL-MIGRATION.txt records the backend review.