1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
|
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 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.
|