// Copyright (C) 2026 Doan Luu Duc Tai // // This program is free software: you can redistribute it and/or modify // it under the terms of the GNU Affero General Public License as // published by the Free Software Foundation, either version 3 of the // License, or (at your option) any later version. // // This program is distributed in the hope that it will be useful, // but WITHOUT ANY WARRANTY; without even the implied warranty of // MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the // GNU Affero General Public License for more details. // // You should have received a copy of the GNU Affero General Public License // along with this program. If not, see . // @license magnet:?xt=urn:btih:0b31508aeb0634b347b8270c7bee4d411b5d4109&dn=agpl-3.0.txt AGPL-3.0 function element(id: string): HTMLElement { const value = document.getElementById(id); if (!(value !== null)) throw new Error(`Missing interface element: ${id}`); return value; } function input(id: string): HTMLInputElement { const value = element(id); if (!(value instanceof HTMLInputElement)) throw new Error(`Expected input: ${id}`); return value; } function form(id: string): HTMLFormElement { const value = element(id); if (!(value instanceof HTMLFormElement)) throw new Error(`Expected form: ${id}`); return value; } function messageInput(): HTMLTextAreaElement { const value = element('text'); if (!(value instanceof HTMLTextAreaElement)) throw new Error('Missing message input'); return value; } const show = (id: string, visible = true): void => { element(id).hidden = !visible; }; const setStatus = (message: string): void => { element('status').textContent = message; }; const errorMessage = (error: unknown): string => error instanceof Error ? error.message : 'Unexpected operation failure'; let config: StackChat.Configuration | undefined; let wasm: Promise | undefined; let credentials: StackChat.Credentials | undefined; let session: StackChat.Session | undefined; let selected: StackChat.Conversation | undefined; let before = 0; let pendingFile: string | undefined; let setupToken: string | undefined; let pendingSend: StackChat.SendRequest | undefined; let generation = 0; let busy = false; const workers = new Map void>(); const blobs = new Set(); function configuration(): StackChat.Configuration { if (config === undefined) throw new Error('Chat configuration is not loaded'); return config; } function hasOwner(): boolean { const owner = config?.owner; return owner !== undefined && owner !== null && owner.length > 0; } function ownerKey(): string { const owner = configuration().owner; if (owner === null || owner.length === 0) throw new Error('Owner setup is required first'); return owner; } function activeSession(): StackChat.Session { if (session === undefined) throw new Error('Unlock your identity file first'); return session; } function conversation(): StackChat.Conversation { if (selected === undefined) throw new Error('Choose a conversation first'); return selected; } function isRecord(value: unknown): value is Record { return typeof value === 'object' && value !== null && !Array.isArray(value); } function record(value: unknown): Record { if (!isRecord(value)) throw new Error('Invalid response object'); return value; } function stringField(value: Record, key: string): string { const field = value[key]; if (typeof field !== 'string') throw new Error(`Invalid response field: ${key}`); return field; } function countField(value: Record, key: string): number { const field = value[key]; if (typeof field !== 'number' || !Number.isSafeInteger(field) || field < 0) throw new Error(`Invalid response count: ${key}`); return field; } function booleanField(value: Record, key: string): boolean { const field = value[key]; if (typeof field !== 'boolean') throw new Error(`Invalid response flag: ${key}`); return field; } function arrayField(value: Record, key: string): unknown[] { const field: unknown = value[key]; if (!Array.isArray(field)) throw new Error(`Invalid response list: ${key}`); return field; } function decodeConversation(value: unknown): StackChat.Conversation { const item = record(value); const total = countField(item, 'total'); const visitor = countField(item, 'visitor_sent'); const owner = countField(item, 'owner_sent'); if (total > 10000 || visitor + owner !== total) throw new Error('Invalid conversation count'); const profile = item['profile']; if (profile !== undefined && profile !== null && typeof profile !== 'string') throw new Error('Invalid conversation profile'); return { id: stringField(item, 'id'), public: stringField(item, 'public'), total, visitor_sent: visitor, owner_sent: owner, ...(profile === undefined ? {} : { profile }) }; } function decodeMessage(value: unknown): StackChat.Message { const item = record(value); return { seq: countField(item, 'seq'), id: stringField(item, 'id'), sender: stringField(item, 'sender'), ciphertext: stringField(item, 'ciphertext') }; } function decodeDecryption(value: unknown): StackChat.Decryption { const item = record(value); return { signer: stringField(item, 'signer'), payload: stringField(item, 'payload') }; } const apiReaders: { [K in keyof StackChat.Api]: (value: unknown) => StackChat.Api[K]['response'] } = { config(value) { const item = record(value); const owner = item['owner']; if (owner !== null && typeof owner !== 'string') throw new Error('Invalid owner configuration'); return { owner, origin: stringField(item, 'origin'), protocol: stringField(item, 'protocol') }; }, challenge(value) { const item = record(value); return { id: stringField(item, 'id'), challenge: stringField(item, 'challenge') }; }, authenticate(value) { const item = record(value); return { token: stringField(item, 'token'), id: stringField(item, 'id'), owner: booleanField(item, 'owner'), expires_seconds: countField(item, 'expires_seconds') }; }, profile(value) { return { stored: booleanField(record(value), 'stored') }; }, inbox(value) { return { conversations: arrayField(record(value), 'conversations').map(decodeConversation) }; }, history(value) { const item = record(value); return { conversation: decodeConversation(item['conversation']), messages: arrayField(item, 'messages').map(decodeMessage) }; }, send(value) { const item = record(value); return { seq: countField(item, 'seq'), conversation: decodeConversation(item['conversation']) }; }, lock(value) { return { locked: booleanField(record(value), 'locked') }; }, }; const cryptoReaders: { [K in keyof StackChat.Crypto]: (value: unknown) => StackChat.Crypto[K]['response'] } = { create(value) { return { file: stringField(record(value), 'file') }; }, open(value) { const item = record(value); return { public: stringField(item, 'public'), fingerprint: stringField(item, 'fingerprint'), owner: stringField(item, 'owner'), name: stringField(item, 'name') }; }, proof(value) { return { signature: stringField(record(value), 'signature') }; }, encrypt(value) { return { ciphertext: stringField(record(value), 'ciphertext') }; }, decrypt: decodeDecryption, decrypt_batch(value) { return { messages: arrayField(record(value), 'messages').map(entry => { const item = record(entry); return 'error' in item ? { error: stringField(item, 'error') } : decodeDecryption(item); }) }; }, }; async function api(request: StackChat.Api[K]['request'] & { op: K }): Promise { const current = generation; const response = await fetch('/api', { method: 'POST', credentials: 'omit', cache: 'no-store', redirect: 'error', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ ...request, token: session?.token }), signal: AbortSignal.timeout(20000), }); const result = record(await response.json()); if (current !== generation) throw new Error('Session locked'); if (!response.ok || 'error' in result) throw new Error(typeof result['error'] === 'string' ? result['error'] : 'Server request failed'); return apiReaders[request.op](result); } async function cryptoOperation(request: StackChat.Crypto[K]['request'] & { op: K }): Promise { const current = generation; if (wasm === undefined) throw new Error('Cryptographic module is not loaded'); const bytes = await wasm; if (current !== generation) throw new Error('Session locked'); return new Promise((resolve, reject) => { const worker = new Worker('/worker.js'); const stop = (): void => { worker.terminate(); workers.delete(worker); clearTimeout(timer); }; const timer = setTimeout(() => { stop(); reject(new Error('Cryptographic operation timed out')); }, 90000); workers.set(worker, () => { stop(); reject(new Error('Session locked')); }); worker.onerror = (): void => { stop(); reject(new Error('Cryptographic worker failed')); }; worker.onmessage = (event: MessageEvent): void => { stop(); if (current !== generation) { reject(new Error('Session locked')); return; } try { const message = record(event.data); if ('error' in message) throw new Error(stringField(message, 'error')); resolve(cryptoReaders[request.op](message['result'])); } catch (error) { reject(error instanceof Error ? error : new Error('Cryptographic response rejected', { cause: error })); } }; worker.postMessage({ bytes, request }); }); } function protectRequest(op: K, fields: Omit): StackChat.Protected & { op: K } & typeof fields { if (credentials === undefined) throw new Error('Unlock your identity file first'); return { op, ...credentials, origin: location.origin, ...fields }; } function clearState(): void { generation += 1; busy = false; for (const button of document.querySelectorAll('button')) button.disabled = false; for (const cancel of workers.values()) cancel(); workers.clear(); for (const url of blobs) URL.revokeObjectURL(url); blobs.clear(); credentials = undefined; session = undefined; selected = undefined; pendingFile = undefined; pendingSend = undefined; setupToken = undefined; before = 0; input('history-count').max = '0'; element('history-range').textContent = 'Latest messages (0-0)'; for (const input of document.querySelectorAll('input,textarea')) { if (input.id === 'history-count') input.value = '0'; else input.value = ''; } for (const id of ['messages', 'conversations', 'who', 'fingerprint', 'counts', 'thread-name', 'conversation-fingerprint']) element(id).replaceChildren(); for (const id of ['create', 'unlock', 'chat', 'thread', 'inbox', 'older', 'retry', 'owner-access']) show(id, false); show('entry', hasOwner()); show('owner-entry'); setStatus('Locked. Select your file and enter its password to return.'); } function background(operation: () => Promise): void { const current = generation; operation().catch((error: unknown) => { if (current === generation) setStatus(errorMessage(error)); }); } function action(id: string, callback: () => void | Promise): void { const handler = async (event: Event): Promise => { event.preventDefault(); if (busy) return; busy = true; setStatus('Working locally...'); const current = generation; for (const button of document.querySelectorAll('button:not(#lock)')) button.disabled = true; try { await callback(); } catch (error) { if (generation === current) setStatus(errorMessage(error)); } finally { if (generation === current) { busy = false; for (const button of document.querySelectorAll('button')) button.disabled = false; } } }; element(id).addEventListener(id.endsWith('-form') ? 'submit' : 'click', event => { background(() => handler(event)); }); } function download(file: string): void { const url = URL.createObjectURL(new Blob([file], { type: 'application/octet-stream' })); blobs.add(url); const link = document.createElement('a'); link.href = url; link.download = 'stack-chat-identity.chat'; link.click(); setTimeout(() => { URL.revokeObjectURL(url); blobs.delete(url); }, 30000); } async function createIdentity(): Promise { const password = input('new-password').value; if (password !== input('confirm-password').value) throw new Error('Passwords do not match'); if (!hasOwner() && element('setup-label').hasAttribute('hidden')) throw new Error('Owner setup is required first'); setupToken = !hasOwner() ? input('setup-token').value : undefined; setStatus('Generating your purpose-specific identity and protecting its file...'); const result = await cryptoOperation({ op: 'create', origin: location.origin, name: input('name').value, password, owner: configuration().owner ?? '' }); pendingFile = result.file; download(result.file); form('create-form').reset(); show('create', false); show('unlock'); element('reopen-note').textContent = 'Your file was downloaded. Reopen that exact saved file and enter its password before any message can be sent. Keep a safe backup.'; setStatus('Reopen your downloaded identity file to verify recovery.'); } async function unlockIdentity(): Promise { const file = input('file').files?.[0]; if (file === undefined || file.size > 262144) throw new Error('Choose an identity file no larger than 256 KiB'); const fileText = await file.text(); if (((pendingFile ?? '').length > 0) && fileText !== pendingFile) throw new Error('Reopen the exact identity file you just downloaded'); const candidate = { file: fileText, password: input('password').value }; form('unlock-form').reset(); setStatus('Unlocking locally and verifying your identity...'); const identity = await cryptoOperation({ op: 'open', ...candidate, origin: location.origin }); if (hasOwner() && identity.owner !== configuration().owner) throw new Error('Pinned owner key differs from this server. Stop and verify the change with Doan.'); const challenge = await api({ op: 'challenge', public: identity.public, setup_token: setupToken }); const proof = await cryptoOperation({ op: 'proof', ...candidate, origin: location.origin, challenge: challenge.challenge }); const authenticated = await api({ op: 'authenticate', id: challenge.id, signature: proof.signature }); if (authenticated.id !== identity.fingerprint || typeof authenticated.owner !== 'boolean') throw new Error('Authenticated identity mismatch'); credentials = candidate; session = { ...identity, ...authenticated }; pendingFile = undefined; setupToken = undefined; config = await api({ op: 'config' }); show('entry', false); show('owner-entry', false); show('owner-access', false); show('unlock', false); show('chat'); element('who').textContent = `${identity.name} [${activeSession().owner ? 'OWNER' : 'VISITOR'}]`; element('fingerprint').textContent = `Identity: ${identity.fingerprint}`; if (authenticated.owner) { activeSession().owner = true; show('inbox'); await inbox(); } else { activeSession().owner = false; const profile = await cryptoOperation(protectRequest('encrypt', { recipient: ownerKey(), payload: JSON.stringify({ v: 1, kind: 'profile', origin: location.origin, conversation: activeSession().id, sender: activeSession().id, name: identity.name }) })); await api({ op: 'profile', conversation: activeSession().id, ciphertext: profile.ciphertext }); selected = { id: activeSession().id, public: identity.public, total: 0, visitor_sent: 0, owner_sent: 0 }; element('conversation-fingerprint').textContent = `Conversation: ${activeSession().id}`; show('thread'); element('thread-name').textContent = 'You <-> Doan'; await loadHistory(0); } setStatus('Unlocked in memory only. No history loaded automatically. Lock or leave to forget this session.'); } function checkedPayload(payload: string, kind: 'profile', conversation: string, sender: string): { name: string }; function checkedPayload(payload: string, kind: 'message', conversation: string, sender: string): { id: string; text: string }; function checkedPayload(payload: string, kind: 'profile' | 'message', conversation: string, sender: string): { name: string } | { id: string; text: string } { const value = record(JSON.parse(payload)); if (value['v'] !== 1 || value['kind'] !== kind || value['origin'] !== location.origin || value['conversation'] !== conversation || value['sender'] !== sender) throw new Error('Signed message binding mismatch'); if (kind === 'profile') return { name: stringField(value, 'name') }; return { id: stringField(value, 'id'), text: stringField(value, 'text') }; } async function inbox(): Promise { const result = await api({ op: 'inbox' }); element('conversations').replaceChildren(); for (const conversation of result.conversations) { const button = document.createElement('button'); button.textContent = `${conversation.id.slice(0, 12)} / ${String(conversation.total)} messages`; const handler = async (): Promise => { if (busy) return; if ((pendingSend !== undefined)) { setStatus('Retry the pending message, or lock before switching conversations.'); return; } busy = true; const current = generation; setStatus('Opening conversation...'); try { selected = conversation; element('conversation-fingerprint').textContent = `Conversation: ${conversation.id}`; element('thread-name').textContent = `Doan <-> ${conversation.id.slice(0, 12)}`; pendingSend = undefined; messageInput().value = ''; show('retry', false); element('messages').replaceChildren(); show('thread'); let name = conversation.id.slice(0, 12); const certificate = conversation.profile; if (certificate !== undefined && certificate !== null && certificate.length > 0) { const result = await cryptoOperation(protectRequest('decrypt', { sender: conversation.public, ciphertext: certificate })); if (result.signer !== conversation.id) throw new Error('Profile signer fingerprint mismatch'); const profile = checkedPayload(result.payload, 'profile', conversation.id, conversation.id); if (typeof profile.name !== 'string' || profile.name.length > 80) throw new Error('Invalid encrypted profile'); name = profile.name; } element('thread-name').textContent = `Doan <-> ${name}`; await loadHistory(0); setStatus('Conversation opened. Choose how many messages to load.'); } catch (error) { setStatus(errorMessage(error)); } finally { if (generation === current) busy = false; } }; button.addEventListener('click', () => { background(handler); }); element('conversations').append(button); } } function counters(conversation: StackChat.Conversation): void { if (!Number.isSafeInteger(conversation.total) || conversation.total < 0 || conversation.total > 10000) throw new Error('Invalid conversation count'); input('history-count').max = String(conversation.total); element('history-range').textContent = `Latest messages (0-${String(conversation.total)})`; const sent = activeSession().owner ? conversation.owner_sent : conversation.visitor_sent; const received = activeSession().owner ? conversation.visitor_sent : conversation.owner_sent; element('counts').textContent = `Total: ${String(conversation.total)} | Sent: ${String(sent)} | Received: ${String(received)}`; } async function renderHistoryBatch(messages: StackChat.Message[]): Promise { const requests = messages.map(message => ({ sender: message.sender === conversation().id ? conversation().public : ownerKey(), ciphertext: message.ciphertext })); const decrypted = await cryptoOperation(protectRequest('decrypt_batch', { messages: requests })); for (let index = 0; index < messages.length; index += 1) { const message = messages[index]; if (message === undefined) throw new Error('Missing history message'); const item = document.createElement('li'); const label = document.createElement('small'); label.textContent = `#${String(message.seq)} / ${message.sender === activeSession().id ? 'YOU' : 'THEM'}`; const text = document.createElement('p'); try { const entry = decrypted.messages[index]; if (entry === undefined || 'error' in entry) throw new Error('Invalid ciphertext or signature'); if (entry.signer !== message.sender) throw new Error('Sender fingerprint mismatch'); const payload = checkedPayload(entry.payload, 'message', conversation().id, message.sender); if (payload.id !== message.id || typeof payload.text !== 'string' || new TextEncoder().encode(payload.text).length > 8192) throw new Error('Message binding mismatch'); text.textContent = payload.text; } catch { text.textContent = '[Message rejected: invalid ciphertext, signature, or conversation binding.]'; } item.append(label, text); element('messages').prepend(item); } } async function refreshCounts(): Promise { const conversationId = conversation().id; const result = await api({ op: 'history', conversation: conversationId, count: 0, before: 0 }); if (selected?.id === conversationId) counters(result.conversation); return result.conversation; } async function loadHistory(count: number, cursor = 0): Promise { if (selected === undefined) throw new Error('Choose a conversation first'); const currentConversation = await refreshCounts(); if (!Number.isSafeInteger(count) || count < 0 || count > currentConversation.total) throw new Error(`Choose 0 to ${String(currentConversation.total)} messages`); if (!(cursor !== 0)) { element('messages').replaceChildren(); before = 0; } if (count === 0) { input('history-count').value = '0'; show('older', false); setStatus('No message history loaded.'); return; } let remaining = count; let next = cursor; let exhausted = false; while (remaining > 0) { const size = Math.min(50, remaining); const result = await api({ op: 'history', conversation: conversation().id, count: size, before: next }); counters(result.conversation); if (!Array.isArray(result.messages) || result.messages.length > size) throw new Error('Invalid history response'); if (!(result.messages.length !== 0)) { exhausted = true; break; } const sequence = Math.min(...result.messages.map(message => message.seq)); if (!Number.isSafeInteger(sequence) || sequence <= 0 || (next > 0 && sequence >= next)) throw new Error('Invalid history cursor'); setStatus(`Decrypting and verifying selected messages locally (${String(count - remaining + result.messages.length)}/${String(count)})...`); await renderHistoryBatch(result.messages); remaining -= result.messages.length; next = sequence; before = next; if (result.messages.length < size) { exhausted = true; break; } } show('older', !exhausted && element('messages').children.length < Number(input('history-count').max)); setStatus('Selected history decrypted and verified locally.'); } async function submitPending(): Promise { if (pendingSend === undefined) throw new Error('No pending message'); const result = await api(pendingSend); counters(result.conversation); pendingSend = undefined; messageInput().value = ''; show('retry', false); setStatus('Encrypted message stored. Load history to read messages.'); } async function send(): Promise { if (selected === undefined) throw new Error('Choose a conversation first'); if ((pendingSend !== undefined)) throw new Error('Retry or lock before composing another message'); const text = messageInput().value; if (!((text.trim()).length > 0) || new TextEncoder().encode(text).length > 8192) throw new Error('Enter 1 to 8192 UTF-8 bytes of text'); const id = Array.from(crypto.getRandomValues(new Uint8Array(32)), byte => byte.toString(16).padStart(2, '0')).join(''); const recipient = activeSession().owner ? conversation().public : ownerKey(); setStatus('Signing and encrypting for both participants locally...'); const result = await cryptoOperation(protectRequest('encrypt', { recipient, payload: JSON.stringify({ v: 1, kind: 'message', origin: location.origin, conversation: conversation().id, sender: activeSession().id, id, text }) })); pendingSend = { op: 'send', conversation: conversation().id, id, ciphertext: result.ciphertext }; show('retry'); await submitPending(); } function ownerSetup(): void { show('entry', false); show('owner-entry', false); show('unlock', false); show('create', !hasOwner()); show('owner-access', hasOwner()); show('setup-label', !hasOwner()); element('create-title').textContent = 'Create the owner identity (owner only)'; setStatus(''); } action('create-choice', () => { show('entry', false); show('owner-entry', false); show('setup-label', false); element('create-title').textContent = 'Create a new chat'; show('create'); setStatus(''); }); action('open-choice', () => { show('entry', false); show('owner-entry', false); show('unlock'); setStatus(''); }); action('owner-choice', () => { window.history.replaceState(null, '', '#setup'); ownerSetup(); }); action('owner-open', () => { show('owner-access', false); show('unlock'); setStatus(''); }); action('create-form', createIdentity); action('unlock-form', unlockIdentity); element('history-count').addEventListener('focus', () => { const current = generation; if ((session !== undefined) && (selected !== undefined) && !busy) refreshCounts().catch((error: unknown) => { if (current === generation) setStatus(errorMessage(error)); }); }); action('history-form', () => loadHistory(Number(input('history-count').value))); action('send-form', send); action('retry', submitPending); action('refresh-inbox', inbox); action('older', () => loadHistory(Number(input('history-count').value), before)); element('lock').addEventListener('click', () => { const current = generation; if (session !== undefined) api({ op: 'lock' }).catch((error: unknown) => { if (current === generation) setStatus(errorMessage(error)); }); clearState(); }); window.addEventListener('pagehide', clearState); window.addEventListener('pageshow', event => { if (event.persisted) clearState(); }); async function downloadCrypto(): Promise { const controller = new AbortController(); let timer: ReturnType | undefined; let reader: ReadableStreamDefaultReader> | undefined; const refreshTimeout = (): void => { clearTimeout(timer); timer = setTimeout(() => { controller.abort(); }, 45000); }; try { refreshTimeout(); const response = await fetch('/crypto.wasm', { cache: 'no-store', credentials: 'omit', signal: controller.signal }); if (!response.ok) throw new Error(`Cryptographic module download failed (HTTP ${String(response.status)}). Reload to try again.`); const length = ((response.headers.get('content-encoding') ?? '').length > 0) ? 0 : Number(response.headers.get('content-length')); const total = Number.isSafeInteger(length) && length > 0 ? length : 0; const chunks: Uint8Array[] = []; let received = 0; const progress = (): void => { const loaded = (received / 1048576).toFixed(1); if ((total !== 0)) { const percent = Math.min(100, Math.floor(received * 100 / total)); const filled = Math.floor(percent / 5); setStatus(`Downloading cryptographic module... [${'#'.repeat(filled)}${'-'.repeat(20 - filled)}] ${String(percent)}% (${loaded} / ${(total / 1048576).toFixed(1)} MiB)`); } else setStatus(`Downloading cryptographic module... ${loaded} MiB received`); }; progress(); if (!(response.body !== null)) throw new Error('Cryptographic module response has no body'); reader = response.body.getReader(); for (;;) { const { done, value } = await reader.read(); if (done) break; refreshTimeout(); chunks.push(value); received += value.byteLength; progress(); } if (!(received !== 0)) throw new Error('The cryptographic module download was empty. Reload to try again.'); const bytes = new Uint8Array(received); let offset = 0; for (const chunk of chunks) { bytes.set(chunk, offset); offset += chunk.byteLength; } return bytes.buffer; } catch (error) { if (controller.signal.aborted) throw new Error('Cryptographic module download stalled for 45 seconds. Check your connection and reload to try again.', { cause: error }); throw error; } finally { clearTimeout(timer); reader?.releaseLock(); } } async function start(): Promise { wasm = downloadCrypto(); await wasm; setStatus('Cryptographic module downloaded. Connecting to chat...'); config = await api({ op: 'config' }); if (configuration().origin !== location.origin) throw new Error('Server origin mismatch'); show('owner-entry'); show('entry', hasOwner()); setStatus(''); if (location.hash === '#setup') ownerSetup(); } start().catch((error: unknown) => { setStatus(errorMessage(error)); }); // @license-end