// BrowserAdapter — standalone in-browser waste client. // Speaks the yaw/2.1 signaling + WebRTC protocol; emits IpcMessage events // identical to DaemonAdapter so the store and UI need no changes. // // No daemon required. Identity is Ed25519, stored as hex seed in localStorage. // Crypto via libsodium-wrappers. import sodium from 'libsodium-wrappers' import type { IpcMessage, PeerInfo } from '../types' type Listener = (msg: IpcMessage) => void type Status = 'disconnected' | 'connecting' | 'connected' const BIND_PREFIX = 'yaw/2 bind' const EKEY_PREFIX = 'yaw/2.1 ekey' const FS_TIMEOUT = 2000 const STUN = 'stun:stun.l.google.com:19302' const enc = (s: string) => new TextEncoder().encode(s) function concat(...arrs: Uint8Array[]): Uint8Array { const n = arrs.reduce((a, b) => a + b.length, 0) const out = new Uint8Array(n); let o = 0 for (const a of arrs) { out.set(a, o); o += a.length } return out } function toHex(bytes: Uint8Array): string { return Array.from(bytes).map(b => b.toString(16).padStart(2, '0')).join('') } async function netHash(name: string): Promise { const input = enc('yaw2-net:' + name) // Use native SHA-256 for protocol-compatible network IDs. if (globalThis.crypto?.subtle) { const digest = await globalThis.crypto.subtle.digest('SHA-256', input) return toHex(new Uint8Array(digest)) } // Fallback for environments where subtle crypto is unavailable. const sha256 = (sodium as unknown as { crypto_hash_sha256?: (m: Uint8Array) => Uint8Array }).crypto_hash_sha256 if (sha256) return sodium.to_hex(sha256(input)) throw new Error('SHA-256 not available in this browser runtime') } // ── Identity ───────────────────────────────────────────────────────────────── class Identity { pub: Uint8Array priv: Uint8Array id: string curvePriv: Uint8Array nick: string constructor(kp: { publicKey: Uint8Array; privateKey: Uint8Array }) { this.pub = kp.publicKey this.priv = kp.privateKey this.id = sodium.to_hex(this.pub) this.curvePriv = sodium.crypto_sign_ed25519_sk_to_curve25519(this.priv) this.nick = localStorage.getItem('waste_nick') || '' } static load(): Identity { const seedHex = localStorage.getItem('waste_seed') let kp if (seedHex) { kp = sodium.crypto_sign_seed_keypair(sodium.from_hex(seedHex)) } else { kp = sodium.crypto_sign_keypair() localStorage.setItem('waste_seed', sodium.to_hex(kp.privateKey.slice(0, 32))) } return new Identity(kp) } setNick(nick: string) { this.nick = nick.trim().slice(0, 40) localStorage.setItem('waste_nick', this.nick) } sign(data: Uint8Array): Uint8Array { return sodium.crypto_sign_detached(data, this.priv) } static verify(idHex: string, data: Uint8Array, sig: Uint8Array): boolean { try { return sodium.crypto_sign_verify_detached(sig, data, sodium.from_hex(idHex)) } catch { return false } } seal(recipIdHex: string, plaintext: Uint8Array): string { const pub = sodium.crypto_sign_ed25519_pk_to_curve25519(sodium.from_hex(recipIdHex)) const nonce = sodium.randombytes_buf(24) const ct = sodium.crypto_box_easy(plaintext, nonce, pub, this.curvePriv) return sodium.to_base64(concat(nonce, ct), sodium.base64_variants.ORIGINAL) } open(senderIdHex: string, boxB64: string): Uint8Array | null { const pub = sodium.crypto_sign_ed25519_pk_to_curve25519(sodium.from_hex(senderIdHex)) const box = sodium.from_base64(boxB64, sodium.base64_variants.ORIGINAL) try { return sodium.crypto_box_open_easy(box.slice(24), box.slice(0, 24), pub, this.curvePriv) } catch { return null } } exportBackup(passphrase: string): object { const BK_OPS = 2, BK_MEM = 67108864 const b64 = (b: Uint8Array) => sodium.to_base64(b, sodium.base64_variants.ORIGINAL) const seed = this.priv.slice(0, 32) const salt = sodium.randombytes_buf(sodium.crypto_pwhash_SALTBYTES) const key = sodium.crypto_pwhash( sodium.crypto_secretbox_KEYBYTES, passphrase, salt, BK_OPS, BK_MEM, sodium.crypto_pwhash_ALG_ARGON2ID13 ) const nonce = sodium.randombytes_buf(sodium.crypto_secretbox_NONCEBYTES) const ct = sodium.crypto_secretbox_easy(seed, nonce, key) return { yaw: 'yaw-key-backup-1', id: this.id, alg: 'argon2id-secretbox', ops: BK_OPS, mem: BK_MEM, salt: b64(salt), nonce: b64(nonce), ct: b64(ct) } } static importBackup(b: Record, passphrase: string): Identity { if (!b || b['yaw'] !== 'yaw-key-backup-1') throw new Error('not a yaw key backup') const ub = (s: unknown) => sodium.from_base64(s as string, sodium.base64_variants.ORIGINAL) const key = sodium.crypto_pwhash( sodium.crypto_secretbox_KEYBYTES, passphrase, ub(b['salt']), b['ops'] as number | 0, b['mem'] as number | 0, sodium.crypto_pwhash_ALG_ARGON2ID13 ) const seed = sodium.crypto_secretbox_open_easy(ub(b['ct']), ub(b['nonce']), key) localStorage.setItem('waste_seed', sodium.to_hex(seed)) return new Identity(sodium.crypto_sign_seed_keypair(seed)) } get short(): string { return this.id.slice(0, 16).replace(/(.{4})/g, '$1 ').trim() } toPeerInfo(networkId = ''): PeerInfo { return { id: this.id, alias: this.nick || this.short, public_key: this.id, created_at: new Date().toISOString(), network_id: networkId } as PeerInfo & { network_id?: string } } } // ── Signaling ───────────────────────────────────────────────────────────────── class Signaling { private ws: WebSocket | null = null private _closed = false private _backoff = 1000 private _cbs: { onFrom?: (from: string, box: string) => void onJoin?: (id: string) => void onLeave?: (id: string) => void onReconnect?: (peers: string[]) => void } = {} private url: string private identity: Identity private net: string constructor(url: string, identity: Identity, net: string) { this.url = url this.identity = identity this.net = net } connect( onFrom: (from: string, box: string) => void, onJoin: (id: string) => void, onLeave: (id: string) => void, onReconnect: (peers: string[]) => void, ): Promise { this._cbs = { onFrom, onJoin, onLeave, onReconnect } return this._open(true) } private _open(initial: boolean): Promise { return new Promise((resolve, reject) => { const ws = new WebSocket(this.url) this.ws = ws let joined = false ws.onerror = () => { if (initial && !joined) reject(new Error('signaling connection failed')) } ws.onclose = () => { if (!this._closed) this._scheduleReconnect() } ws.onmessage = (ev) => { let m: Record try { m = JSON.parse(ev.data) } catch { return } if (m['type'] === 'challenge') { const nonce = sodium.from_hex(m['nonce'] as string) const sig = sodium.to_hex(this.identity.sign(concat(nonce, enc(this.net)))) ws.send(JSON.stringify({ type: 'join', id: this.identity.id, net: this.net, sig })) } else if (m['type'] === 'joined') { joined = true; this._backoff = 1000 const peers = (m['peers'] as string[]) || [] if (initial) resolve(peers) else this._cbs.onReconnect?.(peers) } else if (m['type'] === 'from') { this._cbs.onFrom?.(m['from'] as string, m['box'] as string) } else if (m['type'] === 'peer-join') { this._cbs.onJoin?.(m['id'] as string) } else if (m['type'] === 'peer-leave') { this._cbs.onLeave?.(m['id'] as string) } } }) } private _scheduleReconnect() { if (this._closed) return const delay = this._backoff this._backoff = Math.min(this._backoff * 2, 30000) setTimeout(() => { if (!this._closed) this._open(false).catch(() => { }) }, delay) } sendTo(toId: string, box: string) { try { this.ws?.send(JSON.stringify({ type: 'to', to: toId, box })) } catch { } } close() { this._closed = true; this.ws?.close() } } // ── Peer connection (yaw/2.1) ───────────────────────────────────────────────── type PeerCallback = (event: string, data: Record) => void class PeerConn { pc: RTCPeerConnection dc: RTCDataChannel | null = null verified = false peerAuthed = false created = Date.now() private _esk: Uint8Array | null private _epk: Uint8Array | null peer_epk: Uint8Array | null = null private _ekeySent = false private _offerPending = false private _offered = false private identity: Identity private sig: Signaling public peerId: string private on: PeerCallback private nick: string constructor(identity: Identity, sig: Signaling, peerId: string, on: PeerCallback, nick: string) { this.identity = identity this.sig = sig this.peerId = peerId this.on = on this.nick = nick this.pc = new RTCPeerConnection({ iceServers: [{ urls: STUN }] }) const kp = sodium.crypto_box_keypair() this._esk = kp.privateKey this._epk = kp.publicKey this.pc.ondatachannel = (ev) => this._wire(ev.channel) this.pc.onconnectionstatechange = () => this.on('status', { peer: this.peerId, state: this.pc.connectionState }) } private _seal(obj: object, preferEph: boolean): [string, boolean] { const data = enc(JSON.stringify(obj)) if (preferEph && this.peer_epk && this._esk) { const nonce = sodium.randombytes_buf(24) const ct = sodium.crypto_box_easy(data, nonce, this.peer_epk, this._esk) return [sodium.to_base64(concat(nonce, ct), sodium.base64_variants.ORIGINAL), true] } return [this.identity.seal(this.peerId, data), false] } private _open(box: string): [Uint8Array | null, boolean] { if (this.peer_epk && this._esk) { try { const raw = sodium.from_base64(box, sodium.base64_variants.ORIGINAL) return [sodium.crypto_box_open_easy(raw.slice(24), raw.slice(0, 24), this.peer_epk, this._esk), true] } catch { } } return [this.identity.open(this.peerId, box), false] } private _sendEkey() { if (this._ekeySent || !this._epk) return this._ekeySent = true const signed = concat(enc(EKEY_PREFIX), sodium.from_hex(this.identity.id), sodium.from_hex(this.peerId), this._epk) const msg = { kind: 'ekey', v: 'yaw/2.1', epk: sodium.to_hex(this._epk), sig: sodium.to_hex(this.identity.sign(signed)) } const [box] = this._seal(msg, false) this.sig.sendTo(this.peerId, box) } private async _onEkey(obj: Record) { if (this.peer_epk) return try { const epkRaw = sodium.from_hex(obj['epk'] as string) const sig = sodium.from_hex(obj['sig'] as string) const signed = concat(enc(EKEY_PREFIX), sodium.from_hex(this.peerId), sodium.from_hex(this.identity.id), epkRaw) if (epkRaw.length !== 32 || !Identity.verify(this.peerId, signed, sig)) return this.peer_epk = epkRaw } catch { return } this._sendEkey() if (this._offerPending) await this._doOffer() } async startOffer() { this._sendEkey() this._offerPending = true setTimeout(() => { if (this._offerPending) this._doOffer() }, FS_TIMEOUT) if (this.peer_epk) await this._doOffer() } private async _doOffer() { if (this._offered) return this._offered = true; this._offerPending = false this.dc = this.pc.createDataChannel('yaw') this._wire(this.dc) await this.pc.setLocalDescription(await this.pc.createOffer()) await gatherComplete(this.pc) const [box] = this._seal({ kind: 'offer', sdp: this.pc.localDescription!.sdp }, true) this.sig.sendTo(this.peerId, box) } async onBox(box: string) { const [plain, usedEph] = this._open(box) if (!plain) return this.peerAuthed = true let obj: Record try { obj = JSON.parse(new TextDecoder().decode(plain)) } catch { return } if (obj['kind'] === 'ekey') { await this._onEkey(obj) } else if (obj['kind'] === 'offer') { await this.pc.setRemoteDescription({ type: 'offer', sdp: obj['sdp'] as string }) await this.pc.setLocalDescription(await this.pc.createAnswer()) await gatherComplete(this.pc) const [box2] = this._seal({ kind: 'answer', sdp: this.pc.localDescription!.sdp }, usedEph) this.sig.sendTo(this.peerId, box2) } else if (obj['kind'] === 'answer') { await this.pc.setRemoteDescription({ type: 'answer', sdp: obj['sdp'] as string }) } } private _wire(channel: RTCDataChannel) { if (channel.label !== 'yaw') return this.dc = channel channel.onopen = () => this._sendHello() channel.onmessage = (ev) => this._onControl(ev.data) if (channel.readyState === 'open') this._sendHello() } private _sendHello() { const bind = enc(BIND_PREFIX) const caps: string[] = [] this._dc({ type: 'hello', id: this.identity.id, nick: this.nick, caps, sig: sodium.to_hex(this.identity.sign(bind)) }) } private _onControl(data: string) { let m: Record try { m = JSON.parse(data) } catch { return } if (m['type'] === 'hello') { let reason = '' if (m['id'] !== this.peerId) reason = 'id mismatch' else if (!this.peerAuthed) reason = 'unauthenticated signaling' this.verified = !reason this.on('connected', { peer: this.peerId, verified: this.verified, nick: m['nick'] as string || '', caps: m['caps'] || [] }) } else if (m['type'] === 'chat') { this.on('chat', { peer: this.peerId, room: m['room'] as string || 'general', text: m['text'] as string, ts: m['ts'] as number || Date.now() }) } else if (m['type'] === 'pm') { this.on('pm', { peer: this.peerId, text: m['text'] as string, ts: m['ts'] as number || Date.now() }) } } sendChat(room: string, text: string) { this._dc({ type: 'chat', room, text, ts: Date.now() }) } sendPm(text: string) { this._dc({ type: 'pm', text, ts: Date.now() }) } private _dc(obj: object) { if (this.dc?.readyState === 'open') this.dc.send(JSON.stringify(obj)) } close() { try { this.pc.close() } catch { } } } function gatherComplete(pc: RTCPeerConnection): Promise { if (pc.iceGatheringState === 'complete') return Promise.resolve() return new Promise((res) => { const check = () => { if (pc.iceGatheringState === 'complete') { pc.removeEventListener('icegatheringstatechange', check) res() } } pc.addEventListener('icegatheringstatechange', check) setTimeout(res, 6000) }) } // ── BrowserAdapter ──────────────────────────────────────────────────────────── export class BrowserAdapter { private listeners: Listener[] = [] private identity: Identity | null = null private sig: Signaling | null = null private peers: Map = new Map() private present: Set = new Set() private networkId = '' private networkName = '' status: Status = 'disconnected' onStatusChange?: (s: Status) => void private setStatus(s: Status) { this.status = s this.onStatusChange?.(s) } private emit(msg: IpcMessage) { this.listeners.forEach(l => l(msg)) } on(listener: Listener) { this.listeners.push(listener) return () => { this.listeners = this.listeners.filter(l => l !== listener) } } // Call after connect() to set/update the nick shown to peers setNick(nick: string) { this.identity?.setNick(nick) if (this.identity) { this.emit({ type: 'state_snapshot', master_alias: this.identity.nick || this.identity.short, master_id: this.identity.id, rooms: ['general'], networks: this.networkId ? [{ network_id: this.networkId, network_name: this.networkName, local_peer: this.identity.toPeerInfo(this.networkId), }] : [], }) } } async connect() { await sodium.ready this.identity = Identity.load() this.setStatus('connecting') // Emit identity immediately — UI can show alias before network join this.emit({ type: 'state_snapshot', master_alias: this.identity.nick || this.identity.short, master_id: this.identity.id, rooms: ['general'], networks: [], }) // Mark connected — onboarding shows join form this.setStatus('connected') } disconnect() { this.sig?.close() this.sig = null this.peers.forEach(p => p.close()) this.peers.clear() this.present.clear() this.networkId = '' this.networkName = '' this.setStatus('disconnected') } async joinNetwork(anchorUrl: string, nameOrHash: string, isHash = false) { if (!this.identity) return const hash = isHash ? nameOrHash : await netHash(nameOrHash) this.networkId = hash.slice(0, 16) this.networkName = isHash ? hash.slice(0, 16) : nameOrHash this.sig?.close() this.peers.forEach(p => p.close()) this.peers.clear() this.present.clear() const sig = new Signaling(anchorUrl, this.identity, hash) this.sig = sig try { const present = await sig.connect( (from, box) => this._onFrom(from, box), (pid) => { this.present.add(pid); this._tryConnect(pid) }, (pid) => { this.present.delete(pid) this.peers.get(pid)?.close() this.peers.delete(pid) this.emit({ type: 'peer_disconnected', peer_id: pid as unknown as import('../types').PeerID }) }, (peers) => { this.present = new Set(peers) peers.forEach(pid => this._tryConnect(pid)) }, ) for (const pid of present) { this.present.add(pid) await this._tryConnect(pid) } const localPeer = this.identity.toPeerInfo(this.networkId) this.emit({ type: 'network_joined', network_id: this.networkId, network_name: this.networkName, local_peer: localPeer, networks: [{ network_id: this.networkId, network_name: this.networkName, local_peer: localPeer }], }) setInterval(() => { this.present.forEach(pid => this._tryConnect(pid)) }, 4000) } catch (err) { this.emit({ type: 'error', error_message: `signaling: ${err}` }) } } private async _tryConnect(pid: string) { if (!this.identity || pid === this.identity.id) return if (this.identity.id >= pid) return // lower ID answers; higher ID offers const existing = this.peers.get(pid) if (existing) { const open = existing.dc?.readyState === 'open' const state = existing.pc.connectionState const alive = state !== 'failed' && state !== 'closed' const fresh = Date.now() - existing.created < 12000 if (existing.verified || open || (alive && fresh)) return } const peer = new PeerConn(this.identity, this.sig!, pid, (ev, data) => this._onPeerEvent(ev, data), this.identity.nick) this.peers.set(pid, peer) await peer.startOffer() } private async _onFrom(from: string, box: string) { if (!this.identity || from === this.identity.id) return let peer = this.peers.get(from) if (!peer || peer.pc.connectionState === 'failed' || peer.pc.connectionState === 'closed') { peer = new PeerConn(this.identity, this.sig!, from, (ev, data) => this._onPeerEvent(ev, data), this.identity.nick) this.peers.set(from, peer) } await peer.onBox(box) } private _onPeerEvent(event: string, data: Record) { if (event === 'connected') { const nick = (data['nick'] as string) || (data['peer'] as string).slice(0, 16) this.emit({ type: 'peer_connected', peer: { id: data['peer'] as string, alias: nick || (data['peer'] as string).slice(0, 8), public_key: data['peer'] as string, created_at: new Date().toISOString() }, }) } else if (event === 'chat') { const ts = (data['ts'] as number) || Date.now() const mid = `${data['peer']}-${ts}` this.emit({ type: 'message_received', network_id: this.networkId, message: { mid, from: data['peer'] as unknown as import('../types').PeerID, room: (data['room'] as string) || 'general', text: data['text'] as string, ts, }, }) } else if (event === 'pm') { const ts = (data['ts'] as number) || Date.now() const from = data['peer'] as string this.emit({ type: 'message_received', network_id: this.networkId, message: { mid: `${from}-${ts}`, from: from as unknown as import('../types').PeerID, room: `dm:${from.slice(0, 8)}`, text: data['text'] as string, ts, }, }) } } send(msg: IpcMessage) { if (!this.identity) return if (msg.type === 'join_network') { const cfg = (window as unknown as { WASTE_CONFIG?: { signalURL?: string } })['WASTE_CONFIG'] const anchorUrl = cfg?.signalURL || localStorage.getItem('waste_anchor_url') || '' const isHash = !msg.network_name && !!msg.network_hash const nameOrHash = msg.network_name || msg.network_hash || '' if (nameOrHash) this.joinNetwork(anchorUrl, nameOrHash, isHash) return } if (msg.type === 'send_message') { const room = msg.room || 'general' const text = msg.body || '' if (!text) return const ts = Date.now() const mid = `local-${ts}` if (msg.to) { // DM const pid = msg.to as string this.peers.get(pid)?.sendPm(text) this.emit({ type: 'message_received', network_id: this.networkId, message: { mid, from: this.identity.id as unknown as import('../types').PeerID, to: msg.to, room: `dm:${pid.slice(0, 8)}`, text, ts }, }) } else { // Broadcast this.peers.forEach(p => p.sendChat(room, text)) this.emit({ type: 'message_received', network_id: this.networkId, message: { mid, from: this.identity.id as unknown as import('../types').PeerID, room, text, ts }, }) } return } if (msg.type === 'export_identity') { if (!msg.passphrase) return try { const backup = this.identity.exportBackup(msg.passphrase) this.emit({ type: 'identity_exported', backup: JSON.stringify(backup, null, 2) }) } catch (err) { this.emit({ type: 'error', error_message: `export_identity: ${err}` }) } return } if (msg.type === 'import_identity') { if (!msg.passphrase || !msg.backup) return try { const parsed = JSON.parse(msg.backup) const newId = Identity.importBackup(parsed, msg.passphrase) this.identity = newId this.emit({ type: 'identity_imported' }) this.emit({ type: 'state_snapshot', master_alias: newId.nick || newId.short, master_id: newId.id, rooms: ['general'], networks: [], }) } catch (err) { this.emit({ type: 'error', error_message: `import_identity: ${err}` }) } return } if (msg.type === 'generate_invite') { const anchor = localStorage.getItem('waste_anchor_url') || 'wss://waste.dev.xplwd.com/ws' const name = this.networkName if (!name || name === this.networkId) { this.emit({ type: 'error', error_message: 'generate_invite: joined by hash — no network name available' }) return } const h = netHash(name) const payload = btoa(JSON.stringify({ anchor, network: name, net: h })) .replace(/\+/g, '-').replace(/\//g, '_').replace(/=/g, '') this.emit({ type: 'invite_generated', invite: `waste:${payload}`, network_id: this.networkId }) return } } }