// 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 const element = id => document.getElementById(id); const show = (id, visible = true) => { element(id).hidden = !visible; }; const status = message => { element('status').textContent = message; }; let config; let wasm; let credentials; let session; let selected; let before = 0; let pendingFile; let setupToken; let pendingSend; let generation = 0; let busy = false; const workers = new Map(); const blobs = new Set(); async function api(request) { 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 = await response.json(); if (current !== generation) throw new Error('Session locked'); if (!response.ok || result.error) throw new Error(result.error || 'Server request failed'); return result; } async function cryptoOperation(request) { const current = generation; 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 = () => { 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 = () => { stop(); reject(new Error('Cryptographic worker failed')); }; worker.onmessage = event => { stop(); if (current !== generation) { reject(new Error('Session locked')); return; } if (event.data.error) reject(new Error(event.data.error)); else resolve(event.data.result); }; worker.postMessage({ bytes, request }); }); } function protectRequest(op, fields) { if (!credentials) throw new Error('Unlock your identity file first'); return { op, ...credentials, origin: location.origin, ...fields }; } function clearState() { 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; 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']) show(id, false); show('entry', Boolean(config?.owner)); status('Locked. Select your file and enter its password to return.'); } function action(id, callback) { element(id).addEventListener(id.endsWith('-form') ? 'submit' : 'click', async event => { event.preventDefault(); if (busy) return; busy = true; status('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) status(error.message); } finally { if (generation === current) { busy = false; for (const button of document.querySelectorAll('button')) button.disabled = false; } } }); } function download(file) { 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() { const password = element('new-password').value; if (password !== element('confirm-password').value) throw new Error('Passwords do not match'); if (!config.owner && location.hash !== '#setup') throw new Error('Owner setup is required first'); setupToken = !config.owner ? element('setup-token').value : undefined; status('Generating your purpose-specific identity and protecting its file...'); const result = await cryptoOperation({ op: 'create', origin: location.origin, name: element('name').value, password, owner: config.owner || '' }); pendingFile = result.file; download(result.file); element('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.'; status('Reopen your downloaded identity file to verify recovery.'); } async function unlockIdentity() { const file = element('file').files[0]; if (!file || file.size > 262144) throw new Error('Choose an identity file no larger than 256 KiB'); const fileText = await file.text(); if (pendingFile && fileText !== pendingFile) throw new Error('Reopen the exact identity file you just downloaded'); const candidate = { file: fileText, password: element('password').value }; element('unlock-form').reset(); status('Unlocking locally and verifying your identity...'); const identity = await cryptoOperation({ op: 'open', ...candidate, origin: location.origin }); if (config.owner && identity.owner !== config.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('unlock', false); show('chat'); element('who').textContent = `${identity.name} [${session.owner === true ? 'OWNER' : 'VISITOR'}]`; element('fingerprint').textContent = `Identity: ${identity.fingerprint}`; if (authenticated.owner) { session.owner = true; show('inbox'); await inbox(); } else { session.owner = false; const profile = await cryptoOperation(protectRequest('encrypt', { recipient: config.owner, payload: JSON.stringify({ v: 1, kind: 'profile', origin: location.origin, conversation: session.id, sender: session.id, name: identity.name }) })); await api({ op: 'profile', conversation: session.id, ciphertext: profile.ciphertext }); selected = { id: session.id, public: identity.public }; element('conversation-fingerprint').textContent = `Conversation: ${session.id}`; show('thread'); element('thread-name').textContent = 'You <-> Doan'; await history(0); } status('Unlocked in memory only. No history loaded automatically. Lock or leave to forget this session.'); } function checkedPayload(payload, kind, conversation, sender) { const value = 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'); return value; } async function inbox() { 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)} / ${conversation.total} messages`; button.addEventListener('click', async () => { if (busy) return; if (pendingSend) { status('Retry the pending message, or lock before switching conversations.'); return; } busy = true; const current = generation; status('Opening conversation...'); try { selected = conversation; element('conversation-fingerprint').textContent = `Conversation: ${conversation.id}`; element('thread-name').textContent = `Doan <-> ${conversation.id.slice(0, 12)}`; pendingSend = undefined; element('text').value = ''; show('retry', false); element('messages').replaceChildren(); show('thread'); let name = conversation.id.slice(0, 12); if (conversation.profile) { const result = await cryptoOperation(protectRequest('decrypt', { sender: conversation.public, ciphertext: conversation.profile })); 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 history(0); status('Conversation opened. Choose how many messages to load.'); } catch (error) { status(error.message); } finally { if (generation === current) busy = false; } }); element('conversations').append(button); } } function counters(conversation) { const sent = session.owner ? conversation.owner_sent : conversation.visitor_sent; const received = session.owner ? conversation.visitor_sent : conversation.owner_sent; element('counts').textContent = `Total: ${conversation.total} | Sent: ${sent} | Received: ${received}`; } async function history(count, cursor = 0) { if (!selected) throw new Error('Choose a conversation first'); if (!Number.isSafeInteger(count) || count < 0 || count > 50) throw new Error('Choose 0 to 50 messages'); const result = await api({ op: 'history', conversation: selected.id, count, before: cursor }); counters(result.conversation); if (!cursor) element('messages').replaceChildren(); if (result.messages.length) { status('Decrypting and verifying selected messages locally...'); const requests = result.messages.map(message => ({ sender: message.sender === selected.id ? selected.public : config.owner, ciphertext: message.ciphertext })); const decrypted = await cryptoOperation(protectRequest('decrypt_batch', { messages: requests })); for (let index = 0; index < result.messages.length; index += 1) { const message = result.messages[index]; const item = document.createElement('li'); const label = document.createElement('small'); label.textContent = `#${message.seq} / ${message.sender === session.id ? 'YOU' : 'THEM'}`; const text = document.createElement('p'); try { const entry = decrypted.messages[index]; if (entry.signer !== message.sender) throw new Error('Sender fingerprint mismatch'); if (entry.error) throw new Error('Invalid ciphertext or signature'); const payload = checkedPayload(entry.payload, 'message', selected.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); } before = Math.min(...result.messages.map(message => message.seq)); } show('older', count > 0 && result.messages.length === count); status(count === 0 ? 'No message history loaded.' : 'Selected history decrypted and verified locally.'); } async function submitPending() { if (!pendingSend) throw new Error('No pending message'); const result = await api(pendingSend); counters(result.conversation); pendingSend = undefined; element('text').value = ''; show('retry', false); status('Encrypted message stored. Load history to read messages.'); } async function send() { if (!selected) throw new Error('Choose a conversation first'); if (pendingSend) throw new Error('Retry or lock before composing another message'); const text = element('text').value; if (!text.trim() || 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 = session.owner ? selected.public : config.owner; status('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: selected.id, sender: session.id, id, text }) })); pendingSend = { op: 'send', conversation: selected.id, id, ciphertext: result.ciphertext }; show('retry'); await submitPending(); } action('create-choice', async () => { show('entry', false); show('create'); status('Create a purpose-specific protected identity file.'); }); action('open-choice', async () => { show('entry', false); show('unlock'); status('Select your saved identity file.'); }); action('create-form', createIdentity); action('unlock-form', unlockIdentity); action('history-form', () => history(Number(element('history-count').value))); action('send-form', send); action('retry', submitPending); action('refresh-inbox', inbox); action('older', () => history(Number(element('history-count').value), before)); element('lock').addEventListener('click', () => { if (session) api({ op: 'lock' }).catch(() => {}); clearState(); }); window.addEventListener('pagehide', clearState); window.addEventListener('pageshow', event => { if (event.persisted) clearState(); }); async function start() { wasm = fetch('/crypto.wasm', { cache: 'no-store', credentials: 'omit' }).then(response => { if (!response.ok) throw new Error('Build the WASI cryptographic module first'); return response.arrayBuffer(); }); await wasm; config = await api({ op: 'config' }); if (config.origin !== location.origin) throw new Error('Server origin mismatch'); if (!config.owner) { if (location.hash === '#setup') { show('create'); show('setup-label'); element('create-title').textContent = 'Create the dedicated owner identity'; status('Owner setup. Keep this identity separate from your canonical OpenPGP key.'); } else { status('Owner setup is not complete. Please return later.'); } } else { show('entry'); status('Choose CREATE NEW CHAT or OPEN CHAT FILE.'); } } start().catch(error => status(error.message)); // @license-end