console.clear() import * as crypt from './crypto.ts' import { addStrings, createBlob, decoder, encoder, followed_chats, id_list, info, makeMessage, pubkeyToBlobs, subtractStrings } from './blobs.ts' import { hideModal, setModalHTML, showModal } from './stuff.ts' function byteSlice(str: string, size: number) { return decoder.decode(encoder.encode(str).slice(0, size)) } function byteSize(str: string) { return encoder.encode(str).length } const input = document.querySelector('input[type="text"]')! as HTMLInputElement const btn = document.querySelector('.send-btn')! const cont = document.querySelector('container')! const edot = document.querySelector('.dot')! const estatus = document.querySelector('.status')! function status(dot: 'live' | 'connecting' | 'initializing' | 'disconnected', text: string) { edot.className = `dot dot-${dot}` estatus.innerHTML = text } const socket = new WebSocket(`${location.protocol.startsWith('https') ? 'wss' : 'ws'}://${location.hostname}:${location.protocol.startsWith('https') ? '10000' : '10001'}/tcp/ws/localhost/5001`) socket.binaryType = 'arraybuffer' socket.addEventListener('error', e => { console.error(e) status('disconnected', 'error') }) socket.addEventListener('close', e => { console.error(e) status('disconnected', 'disconnected') }) let inbox: any[] = [] socket.addEventListener('open', e => { status('initializing', 'phase0:open') }) socket.addEventListener('message', e => { const data = e.data inbox.push([Date.now(), data]) }) let ws_uuid: string let my_id: number let my_mode: 'tcp' | 'udp' let my_name: string | undefined = localStorage.getItem('name') ?? undefined async function spinSocket() { while (socket.readyState !== socket.OPEN) { await new Promise(r => setTimeout(r, 10)) } } async function setPassword(_password: string) { if (byteSize(_password) > 8) console.warn('warning: password truncated to eight bytes') const part1 = byteSlice(_password, 8) const part2 = new Array(Math.abs(part1.length)).fill(String.fromCharCode(1)).join('') const password = part2 + part1 await spinSocket() socket.send('P' + password) } async function sendMessage(_password: string, message: string) { if (byteSize(_password) > 8) console.warn('warning: password truncated to eight bytes') const part1 = byteSlice(_password, 8) const part2 = new Array(Math.abs(part1.length)).fill(String.fromCharCode(1)).join('') const password = part2 + part1 await spinSocket() socket.send( 'M' + password + message + '\n' ) } const msg_pass = '10000000' let msgHandler: ((time: number, packet: string) => Promise) | undefined let nicknames: Record = JSON.parse(localStorage.getItem('nicknames') ?? '{}') let pubkey_id_pairs: Record = {} function shortenPubkey(key: string) { return key.substring(0, 6) + key.substring(key.length - 4) } function systemMessage(text: string) { cont.appendChild(makeMessage('system', 0, '', text, '0')) } async function parseTrio(trio: string) { const first = trio.indexOf(':') const second = trio.indexOf(':', first + 1) if (first === -1 || second === -1) return undefined const pubkey = trio.substring(0, first) const signature = trio.substring(first + 1, second) const data = trio.substring(second + 1).trim() if (await crypt.verify(pubkey.substring(pubkey.length / 2), encoder.encode(data), signature)) return { pubkey, signature, data } console.log('not legit :(', pubkey, signature, data) return undefined } const onlineStatus = new Map() let chats: Record = {} let current_chat: string = localStorage.getItem('active_chat') ?? 'main' type StoredMessage = { obj: Record meta: { id: number, pubkey: string, nickname: string } direction: 'in' | 'out' | 'system' } let chatData: Record = JSON.parse(localStorage.getItem('chat_data') ?? '{}') function persistChats() { localStorage.setItem('chat_data', JSON.stringify(chatData)) } function addMessage(chat: string, stored: StoredMessage) { if (!chats[chat]) chats[chat] = [] if (!chatData[chat]) chatData[chat] = [] chatData[chat]!.push(stored) chatData[chat] = chatData[chat]! persistChats() const elm = renderMessage(stored) chats[chat]!.push(elm) return elm } function renderMessage(stored: StoredMessage) { const { obj, meta, direction } = stored const nick = ((meta.pubkey === info.keypair?.identity ? (my_name ?? my_id) : nicknames[meta.pubkey])) ?? meta.id const cur = new Date(obj.timestamp) const fmt = `${cur.getFullYear()}/${cur.getMonth()}/${cur.getDay()} ${cur.getHours()}:${cur.getMinutes()}:${cur.getSeconds()}` const prefix = direction === 'system' ? '' : `[${obj.timestamp ? fmt : 'sometime.'} ${nick} ${shortenPubkey(meta.pubkey)}]` return makeMessage(direction, meta.id, prefix, obj.text, meta.pubkey) } function switchChat(next_chat: string) { current_chat = next_chat cont.innerHTML = '' if (chats[current_chat]) { for (const elm of chats[current_chat]!) { cont.appendChild(elm) } } localStorage.setItem('active_chat', current_chat) refreshChatList() } globalThis.switchChat = switchChat const pendingPings = new Map void>() function ping(targetPubkey: string, timeoutMs = 5000): Promise { const nonce = crypt.toB64(crypto.getRandomValues(new Uint8Array(12))) return new Promise(resolve => { const timer = setTimeout(() => { pendingPings.delete(nonce) resolve(false) }, timeoutMs) pendingPings.set(nonce, () => { clearTimeout(timer) resolve(true) }) sendMessage(msg_pass, `ping:${targetPubkey}:${nonce}`) }) } let CHUNK_SIZE = 1024 const pendingReceives = new Map }>() const pendingSends = new Map void }>() async function sendFile(targetPubkey: string, file: File, onProgress?: (n: number, total: number) => void) { const buf = await file.arrayBuffer() const bytes = new Uint8Array(buf) const chunks: Uint8Array[] = [] for (let i = 0; i < bytes.length; i += CHUNK_SIZE) { chunks.push(bytes.slice(i, i + CHUNK_SIZE)) } const id = crypt.toB64(crypto.getRandomValues(new Uint8Array(12))) pendingSends.set(id, { targetPubkey, chunks, onProgress }) await sendMessage(msg_pass, `file:offer:${targetPubkey}:${await createTrio(JSON.stringify({ id, name: file.name, size: file.size, totalChunks: chunks.length, mime: file.type }))}`) } async function flushChunks(id: string) { const send = pendingSends.get(id) if (!send) return const { targetPubkey, chunks, onProgress } = send for (let i = 0; i < chunks.length; i++) { const encrypted = await crypt.encryptAsymmetric(targetPubkey.substring(0, targetPubkey.length / 2), chunks[i]!) await sendMessage(msg_pass, `file:chunk:${targetPubkey}:${i}:${await createTrio(id + ':' + encrypted)}`) onProgress?.(i + 1, chunks.length) } await sendMessage(msg_pass, `file:done:${await createTrio(id)}`) pendingSends.delete(id) } const pendingOffers = new Map() const TRANSFERS_CHAT = '__transfers__' document.querySelector('s-item[archived]')!.addEventListener('click', () => { switchChat(TRANSFERS_CHAT) }) async function chatHandler(time: number, packet: string) { console.log('catching all packets!', time, packet.substring(0, 64)) if (packet.startsWith('M')) { packet = packet.substring(1) const idstr = packet.split(':')[0]! let id = Number(idstr) if (isNaN(id)) id = 1 // fallback to some weird amount packet = packet.substring(idstr.length + 1 + 1 + 1 + 10 + 1 + 8) // id : {T } password if (packet.startsWith('pubkey:')) { // announcement // no use for this currently const pubkey = packet.substring(7).trim() pubkey_id_pairs[id] = pubkey // systemMessage(`${shortenPubkey(pubkey)} has joined the chat.`) return true } if (packet.startsWith('ping:')) { // check if user is online // anatomy -- // ping : target_pubkey : 16-char nonce packet = packet.substring(5) const target = packet.split(':')[0] if (!target) return false if (target !== info?.keypair?.identity) return true packet = packet.substring(target.length + 1) const nonce = packet.trim() if (nonce.length !== 16) return false await sendMessage(msg_pass, `pong:${await createTrio(nonce)}`) return true } if (packet.startsWith('pong:')) { // anatomy -- // pong : target_pubkey : 16-char nonce : signature packet = packet.substring(5) const pong = await parseTrio(packet) if (!pong) return true const nonce = pong.data.trim() if (nonce.length !== 16) return true pendingPings.get(nonce)?.(pong.pubkey) pendingPings.delete(nonce) return true } if (packet.startsWith('msg:')) { // actual message // anatomy -- // msg : pubkey : signature : message packet = packet.substring(4) // msg: const msg = await parseTrio(packet) if (msg) { pubkey_id_pairs[id] = msg.pubkey try { const obj = JSON.parse(msg.data) if (obj.chat.startsWith('@')) { const sidekey = subtractStrings(obj.chat.substring(1), info?.keypair?.identity) obj.chat = '@' + sidekey obj.text = decoder.decode(await crypt.decryptAsymmetric(info?.keypair?.cryptPrivate, obj.text)) } if (obj.text && obj.chat && followed_chats.includes(obj.chat)) { if (!chats[obj.chat]) chats[obj.chat] = [] const msgobj = addMessage(obj.chat, { obj, meta: { id, pubkey: msg.pubkey, nickname: nicknames[msg.pubkey] ?? String(id) }, direction: 'in' }) chats[obj.chat]!.push(msgobj) if (obj.chat === current_chat) { cont.appendChild(msgobj) } cont.scrollTop = cont.scrollHeight } } catch (e) { console.warn('invalid json', e) } } } if (packet.startsWith('nick:')) { // nickname announcement // anatomy -- // nick : pubkey : signature : message packet = packet.substring(5) // nick: const nick = await parseTrio(packet) if (nick) { nick.data = nick.data.toLowerCase() // skip fraudulent nickname override const ind = Object.values(nicknames).findIndex(e => e === nick.data) const existingKey = ind === -1 ? '' : Object.keys(nicknames)[ind]! if (ind !== -1 && shortenPubkey(existingKey) === nick.pubkey) return true nicknames[nick.pubkey] = nick.data localStorage.setItem('nicknames', JSON.stringify(nicknames)) pubkey_id_pairs[id] = nick.pubkey const nickname_elms = nick.pubkey ? id_list[nick.pubkey] : undefined if (nickname_elms) { for (const elm of nickname_elms) { elm.querySelector('name')!.innerHTML = nick.data ?? id } } } } if (packet.startsWith('file:offer:')) { // ogt file offer packet = packet.substring(11) const colon = packet.indexOf(':') const target = packet.substring(0, colon) if (target !== info?.keypair?.identity) return true packet = packet.substring(colon + 1) const msg = await parseTrio(packet) if (!msg) return true const { id, name, size, totalChunks, mime } = JSON.parse(msg.data) pendingOffers.set(id, { name, mime, size, totalChunks, senderPubkey: msg.pubkey }) if (!chats[TRANSFERS_CHAT]) chats[TRANSFERS_CHAT] = [] const cur = new Date() const fmt = `${cur.getFullYear()}/${cur.getMonth()}/${cur.getDay()} ${cur.getHours()}:${cur.getMinutes()}:${cur.getSeconds()}` const sender = nicknames[msg.pubkey] ?? shortenPubkey(msg.pubkey) const row = makeMessage('system', 0, `[${fmt}]`, `📁 ${sender} wants to send ${name} (${(size / 1024).toFixed(1)} KB) — accept or reject`, '0') row.querySelector('[data-transfer-id]')!.addEventListener('click', async () => { const offer = pendingOffers.get(id) if (!offer) { // row.querySelector('[data-transfer-id]')!.textContent = '(already handled)' return } const accept = confirm(`Accept "${offer.name}" (${(offer.size / 1024).toFixed(1)} KB) from ${nicknames[offer.senderPubkey] ?? shortenPubkey(offer.senderPubkey)}?`) pendingOffers.delete(id) row.querySelector('[data-transfer-id]')!.textContent = accept ? '(accepted)' : '(rejected)' row.setAttribute('handled', 'true') const badgeCount = chats[TRANSFERS_CHAT]!.filter(e => !e.hasAttribute('handled')).length if (badgeCount === 0) document.querySelector('s-item[archived')!.removeAttribute('badge') else document.querySelector('s-item[archived]')!.setAttribute('badge', badgeCount.toString()) if (!accept) { await sendMessage(msg_pass, `file:abort:${await createTrio(id)}`) return } pendingReceives.set(id, { name: offer.name, mime: offer.mime, total: offer.totalChunks, chunks: new Map() }) await sendMessage(msg_pass, `file:accept:${await createTrio(id)}`) }) chats[TRANSFERS_CHAT]!.push(row) document.querySelector('s-item[archived]')!.setAttribute('badge', chats[TRANSFERS_CHAT].filter(e => !e.hasAttribute('handled')).length.toString()) if (current_chat === TRANSFERS_CHAT) cont.appendChild(row) return true } if (packet.startsWith('file:accept:')) { // file accepted, send contents packet = packet.substring(12) const msg = await parseTrio(packet) if (!msg) return true const id = msg.data.trim() await flushChunks(id) return true } if (packet.startsWith('file:chunk:')) { // got file chunk packet = packet.substring(11) const parts = packet.split(':') const target = parts[0]! if (target !== info?.keypair?.identity) return true // skip target + index const firstColon = packet.indexOf(':') const secondColon = packet.indexOf(':', firstColon + 1) const msg = await parseTrio(packet.substring(secondColon + 1)) if (!msg) return true const colonIdx = msg.data.indexOf(':') const id = msg.data.trim().substring(0, colonIdx) const data = msg.data.substring(colonIdx + 1) const receive = pendingReceives.get(id) if (!receive) return true const decrypted = await crypt.decryptAsymmetric(info?.keypair?.cryptPrivate, data) const index = parseInt(parts[1]!) receive.chunks.set(index, decrypted) status('live', `receiving ${receive.name}: ${receive.chunks.size}/${receive.total}`) return true } if (packet.startsWith('file:done:')) { // finished packet = packet.substring(10) const msg = await parseTrio(packet) if (!msg) return true const id = msg.data.trim() const receive = pendingReceives.get(id) if (!receive) return true // assemble const ordered = Array.from({ length: receive.total }, (_, i) => receive.chunks.get(i)!) if (ordered.some(c => !c)) { console.warn('file transfer incomplete') return true } const total = ordered.reduce((s, c) => s + c.length, 0) const merged = new Uint8Array(total) let off = 0 for (const chunk of ordered) { merged.set(chunk, off) off += chunk.length } const blob = new Blob([merged], { type: receive.mime }) const url = URL.createObjectURL(blob) const a = document.createElement('a') a.href = url a.download = receive.name a.click() URL.revokeObjectURL(url) pendingReceives.delete(id) status('live', 'live') return true } if (packet.startsWith('file:abort:')) { packet = packet.substring(11) const msg = await parseTrio(packet) if (!msg) return true const id = msg.data.trim() pendingSends.delete(id) pendingReceives.delete(id) return true } } return true } globalThis.refreshChatList = refreshChatList function refreshChatList() { localStorage.setItem('followed_chats', JSON.stringify(followed_chats)) const c_channels = document.querySelector('s-channels')! const c_directs = document.querySelector('s-directs')! c_channels.innerHTML = '' c_directs.innerHTML = '' for (const chat of followed_chats) { const dm = chat.startsWith('@') const target = dm ? c_directs : c_channels const elm = document.createElement('s-item') elm.setAttribute(dm ? 'dm' : 'channel', '') if (dm) { const pubkey = chat.substring(1) const label = nicknames[pubkey] ?? `${shortenPubkey(pubkey)}?` const dotAttr = onlineStatus.has(pubkey) ? (onlineStatus.get(pubkey) ? 'online' : 'offline') : 'away' elm.innerHTML = `${label} ` } else { elm.innerHTML = chat } if (chat === current_chat) elm.setAttribute('active', '') elm.addEventListener('click', () => { switchChat(chat) }) target.appendChild(elm) } const transfersBtn = document.querySelector('s-item[archived]')! transfersBtn.toggleAttribute('active', current_chat === TRANSFERS_CHAT) transfersBtn.toggleAttribute('pending', pendingOffers.size > 0) } async function pollContactsLoop() { while (true) { const dms = followed_chats.filter(c => c.startsWith('@')) await Promise.all(dms.map(async (chat) => { const pubkey = chat.substring(1) if (pubkey === info?.keypair?.identity) return let alive = await ping(pubkey) // atleast 2 retries for (let i = 0; i < 2 && !alive; i++) alive = await ping(pubkey) onlineStatus.set(pubkey, alive) })) refreshChatList() await new Promise(r => setTimeout(r, 30_000)) } } async function runSetup() { status('live', 'setup1:keypair') const keypairText = localStorage.getItem('keypair') if (keypairText) { console.log('user aready has a keypair.') info.keypair = JSON.parse(keypairText) } else { console.log('generating keypair...') let newpair: any let confirmed = false showModal() async function checkPair() { const pair1 = await crypt.generateEncryptionKeyPair() const pair2 = await crypt.generateSigningKeyPair() newpair = { identity: pair1.publicKey + pair2.publicKey, cryptPrivate: pair1.privateKey, signPrivate: pair2.privateKey } setModalHTML('') const blob = createBlob(pubkeyToBlobs(newpair.identity)) blob.style.transform = 'scale(150%)' blob.style.marginLeft = '10px' blob.addEventListener('click', checkPair) const skeleton = document.createElement('div') const header = document.createElement('div') header.classList.add('modal-header') header.innerHTML = 'generate a blob!' const body = document.createElement('div') body.classList.add('modal-body') const p3 = document.createElement('p') p3.innerHTML = `this is a blob, the visual representation of your public key.
click on the blob until you're satisfied with the colors.

` body.appendChild(p3) body.appendChild(blob) const btn = document.createElement('button') btn.classList.add('btn') btn.classList.add('btn-primary') btn.innerHTML = 'done.' btn.addEventListener('click', () => { localStorage.setItem('keypair', JSON.stringify(newpair)) info.keypair = newpair confirmed = true hideModal() }) body.appendChild(document.createElement('br')) body.appendChild(document.createElement('br')) body.appendChild(btn) skeleton.appendChild(header) skeleton.appendChild(body) document.getElementById('modal-el')!.appendChild(skeleton) } await checkPair() while (!confirmed) await new Promise(r => setTimeout(r, 100)) } if (my_name) nicknames[info?.keypair?.identity] = my_name document.querySelector('s-avatar')!.appendChild(createBlob(pubkeyToBlobs(info?.keypair?.identity))) document.querySelector('s-name')!.innerHTML = my_name ?? `id${my_id}` msgHandler = chatHandler status('live', 'setup2:subscribe') await setPassword(msg_pass) status('live', 'setup3:announce') await sendMessage(msg_pass, 'pubkey:' + info?.keypair?.identity) if (my_name) { status('live', 'setup4:nickname') await sendMessage(msg_pass, `nick:${await createTrio(my_name)}`) } status('live', 'setup5:chats') refreshChatList() status('live', 'setup6:history') for (const [chat, messages] of Object.entries(chatData)) { chats[chat] = messages.map(renderMessage) } switchChat(current_chat) pollContactsLoop() status('live', 'live') } async function inboxProcessor() { if (inbox.length === 0) return const [time, packet] = inbox.shift() let text if (typeof packet === 'string') { if (packet.startsWith('ok_')) { // broadlayer uuid ws_uuid = packet.substring(3) console.log('phase 0: got uuid') status('initializing', 'shake0:uuid') return } text = packet } else { text = decoder.decode(packet) if (text.startsWith('I')) { const mode = text.startsWith('IT') ? 'tcp' : 'udp' console.log('phase 1: connected on', mode, 'mode') const idstr = text.substring(2).trim() const id = parseInt(idstr) if (isNaN(id)) { console.warn('failed on nan id catch "' + text + '"') return } my_id = id my_mode = mode CHUNK_SIZE = my_mode === 'tcp' ? 8192 : 1024 console.log('phase 1: got user id') status('initializing', 'shake1:uid') await runSetup() return } if (text.startsWith('T')) { console.log('phase 2: udp server sent timeout, broadlayer handles it') status('initializing', 'shake2:udptime') return } if (text.startsWith('m_ok') || text.startsWith('p_ok')) { console.log('got an m_ok/p_ok, ignoring') return } } if (msgHandler && await msgHandler(time, text)) return console.log('unknown/invalid packet', packet) } async function inboxProcessorLoop() { const max = Math.max(inbox.length, 1) for (let i = 0; i < max; i++) { await inboxProcessor() } await new Promise(r => setTimeout(r, 100)) requestAnimationFrame(inboxProcessorLoop) } requestAnimationFrame(inboxProcessorLoop) // setPassword('hello') async function createTrio(text: string) { return `${info?.keypair?.identity}:${await crypt.sign(info?.keypair?.signPrivate, encoder.encode(text))}:${text}` } async function _send() { const val = input.value.trim() if (!val) return if (val.startsWith('/')) { const cmd = val.substring(1) if (cmd.startsWith('nick ')) { const nick = cmd.substring(5).trim() localStorage.setItem('name', nick) my_name = nick nicknames[info?.keypair?.identity] = nick const nickname_elms = id_list[info?.keypair?.identity] if (nickname_elms) { for (const elm of nickname_elms) { elm.querySelector('name')!.innerHTML = nick ?? my_id } } document.querySelector('s-name')!.innerHTML = nick ?? `id${my_id}` // announce await sendMessage(msg_pass, `nick:${await createTrio(nick)}`) systemMessage('successfully set your nickname.') return } else if (cmd.startsWith('join ')) { const chat = cmd.substring(5).trim() if (!followed_chats.includes(chat)) { followed_chats.push(chat) refreshChatList() } return } systemMessage('unknown command.') return } let chat = current_chat let text if (chat.startsWith('@')) { const sidekey = chat.substring(1) chat = '@' + addStrings(sidekey, info?.keypair?.identity) text = await crypt.encryptAsymmetric(sidekey.substring(0, sidekey.length / 2), encoder.encode(val)) } else text = val const time = Date.now() await sendMessage(msg_pass, `msg:${await createTrio(JSON.stringify({ text, chat, timestamp: time }))}`) const msgobj = addMessage(current_chat, { obj: { text: val, chat: current_chat, timestamp: time }, meta: { id: my_id, pubkey: info?.keypair?.identity, nickname: my_name ?? String(my_id) }, direction: 'out' }) cont.appendChild(msgobj) if (!chats[current_chat]) chats[current_chat] = [] chats[current_chat]!.push(msgobj) } async function send() { await _send() cont.scrollTop = cont.scrollHeight input.value = '' input.focus() } btn.addEventListener('click', send) input.addEventListener('keydown', e => { if (e.key === 'Enter') send() }) const fileBtn = document.querySelector('.file-btn')! fileBtn.addEventListener('click', () => { if (!current_chat.startsWith('@')) { systemMessage('file transfer is only available in direct messages.') return } const input = document.createElement('input') input.type = 'file' input.addEventListener('change', async () => { const file = input.files?.[0] if (!file) return const targetPubkey = current_chat.substring(1) await sendFile(targetPubkey, file, (n, total) => { status('live', `sending ${file.name}: ${n}/${total}`) }) status('live', 'live') }) input.click() })