Copyright (C) 2026 Doan Luu Duc Tai <doan@luuduct.ai>

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 <https://www.gnu.org/licenses/>.


EXPERIMENTAL PRIVATE CHAT

    Available on main. Direct development and a prebuilt Podman/HTTPS
    deployment path are implemented. See CHAT-PRODUCTION.txt for the release,
    production setup, and recovery procedure; feature work does not itself merge
    main or activate the VPS.
    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.

    Startup shows an ASCII download bar, percentage, and received/total MiB
    while fetching the cryptographic module. If its size is unavailable, it shows
    received MiB. A 45-second gap without download progress aborts with a reload
    instruction; slow transfers continue while bytes arrive. After download,
    a separate status identifies the connection to chat. No browser cache or
    storage was added.

    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. 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 in the root of the stack repository on main. 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.
    CHAT_BIND and public navigation now use the shared root configuration loader;
    see CONFIGURATION.txt for the documented deployment keys.

    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/ and choose OWNER SETUP, labeled for the owner
    only. The direct http://127.0.0.1:9080/#setup link also works.

    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. OWNER SETUP
    stays visible; once an owner exists, it explains how to open the existing
    owner file or reset after loss instead of silently replacing the key. 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 direct development server refuses a non-loopback bind or an origin that
    does not exactly match its development bind address. Packaged production uses
    a private container listener behind Caddy HTTPS, with an explicit external
    CHAT_HOST/HTTPS_PORT origin. Forwarded request headers do not choose that
    origin. Do not expose the direct development server through a public proxy.

OWNER FILE LOSS AND BACKEND RESET

    A setup token grants registration permission. It cannot recover the private
    key inside a lost owner file. If that file and its backups are gone, existing
    messages cannot be decrypted with a newly generated identity. The reset below
    intentionally starts a fresh chat service and clears all stored messages,
    encrypted profiles, conversations, counters, and the owner registration.
    It preserves the root .env and any previously exported backup archives.
    It does not claim forensic erasure of backups, snapshots, or OS copies.

    Run these commands locally at the stack root for the current development
    build. There is no reset operation in the HTTP API. Filesystem access to
    the private chat directory is the administrative boundary. Production uses
    ./bin/stack chat --owner-fingerprint and ./bin/stack chat --reset-owner
    through an isolated maintenance container. Stop production with ./bin/stack
    down first; restart with ./bin/stack up --production. No Rust is needed on
    the VPS. See CHAT-PRODUCTION.txt before a 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

    A fingerprint mismatch refuses without deleting data. The server and reset
    process share a filesystem lock. Direct development additionally requires
    the loopback service port to be free. This prevents resetting a running service, including a
    development process launched before the lock was added. Online snapshots and
    reading the public owner fingerprint remain available while chat is running.

    Restart:

        cargo stack chat

    Choose OWNER SETUP and use the existing CHAT_SETUP_TOKEN from the same root
    .env to generate, save, and reopen a fresh owner file. Back that file and its
    password up in KeePassXC. Visitors must create fresh chat files too: their
    previous files pin the former owner key and are correctly rejected after
    replacement. A reset cannot prove continuity with the lost key.

    If an old owner file is found later, an old encrypted backup may still allow
    recovering its original conversations in an isolated restored installation.
    The reset does not remove existing exported archives, and it never substitutes
    a new key into old ciphertext or old 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 JSON identity files are indented and use an array of 80-character
    Base64 ciphertext chunks; the final chunk may be shorter. JSON indentation,
    quotes, and commas are additional to the ciphertext width. The reader joins
    the chunks before Base64 decoding. No private fields become plaintext.
    This is the application's own JSON envelope, not OpenPGP ASCII armor.
    The new file-format 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 uses its original identifier as associated data. Changing a version label
    or converting its field shape does not convert a file: the authentication tag
    binds the version. No existing file needs rewriting; old readers do not support
    new v2 exports. Cryptographic algorithms and KDF parameters are unchanged.

    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 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.

    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 pause/copy/resume process; the chat container stays unpaused.
    Separate snapshots cover both chat and chat-local. A 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.
    Scrolling belongs to the root viewport at the screen edge; the logo has no
    inner scroll container. JavaScript licenses sit below the LibreJS badge.
    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.

    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, 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
        PLAYWRIGHT_BROWSERS_PATH="$PWD/.tools/browsers" node crates/chat-server/tests/progress.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. The optional packaged
    HTTPS variant is documented in CHAT-PRODUCTION.txt and retains its disposable
    installation roots for inspection. Tests do not initialize the real owner
    identity or 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

    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, 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. See CHAT-DESIGN.txt and
    CHAT-OPENSSL-MIGRATION.txt for the design and historical backend review.
