diff --git a/.github/workflows/relay-binaries.yml b/.github/workflows/relay-binaries.yml new file mode 100644 index 0000000..d4056b0 --- /dev/null +++ b/.github/workflows/relay-binaries.yml @@ -0,0 +1,40 @@ +name: Relay binaries +# Builds the dependency-free Go broadcast relay for every common platform and +# attaches it to the release, so scripts/relay-quickstart.sh can fetch it. +on: + push: + tags: ['*'] + workflow_dispatch: +permissions: + contents: write +jobs: + build: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + - uses: actions/setup-go@v5 + with: + go-version: '1.24' + - name: Vet and build + working-directory: relay/go + run: | + go vet ./... + mkdir -p ../../out + for t in linux/amd64 linux/arm64 linux/arm linux/386 linux/mips linux/mipsle linux/riscv64 \ + windows/amd64 windows/arm64 darwin/amd64 darwin/arm64 freebsd/amd64 android/arm64; do + os=${t%/*}; arch=${t#*/}; ext=; [ "$os" = windows ] && ext=.exe + GOOS=$os GOARCH=$arch GOMIPS=softfloat GOARM=7 CGO_ENABLED=0 \ + go build -trimpath -ldflags='-s -w' -o ../../out/htmlroom-relay-$os-$arch$ext . + done + cd ../../out && sha256sum htmlroom-relay-* > SHA256SUMS + - uses: actions/upload-artifact@v4 + with: + name: htmlroom-relay + path: out/* + - name: Attach to release + if: startsWith(github.ref, 'refs/tags/') + env: + GH_TOKEN: ${{ github.token }} + run: | + gh release view "$GITHUB_REF_NAME" >/dev/null 2>&1 || gh release create "$GITHUB_REF_NAME" --title "$GITHUB_REF_NAME" --notes "HTML Room $GITHUB_REF_NAME" + gh release upload "$GITHUB_REF_NAME" out/* --clobber diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml index 5a6ac74..55dd57a 100644 --- a/.github/workflows/test.yml +++ b/.github/workflows/test.yml @@ -30,6 +30,19 @@ jobs: - run: npx playwright install --with-deps chromium - run: npm run test:browser - run: npm run test:pages + go: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + - uses: actions/setup-go@v5 + with: + go-version: '1.24' + - name: Vet, format check and cross-compile the broadcast relay + working-directory: relay/go + run: | + test -z "$(gofmt -l .)" + go vet ./... + for t in linux/amd64 linux/mipsle windows/amd64 darwin/arm64; do GOOS=${t%/*} GOARCH=${t#*/} CGO_ENABLED=0 go build -o /dev/null .; done worker: runs-on: ubuntu-latest env: @@ -52,7 +65,7 @@ jobs: - run: | docker run -d --name htmlroom -p 127.0.0.1:18080:8080 htmlroom:test for i in $(seq 1 30); do curl -fs http://127.0.0.1:18080/healthz && break; sleep 1; done - curl -fs http://127.0.0.1:18080/hr.html | grep -q '创建 HTML 房间' + curl -fs http://127.0.0.1:18080/hr.html | grep -q '创建加密聊天室' installer: runs-on: ubuntu-latest timeout-minutes: 15 diff --git a/README.md b/README.md index 1be0019..5307bb1 100644 --- a/README.md +++ b/README.md @@ -22,8 +22,8 @@ ## 三步开始聊天 **第 1 步:创建房间** -打开 [房间生成器](https://freecode100year.github.io/htmlroom/),其他设置都不用改。 -如果想多一层保险,可以点「随机生成高熵口令」设个密码(相当于门上再加一把锁)。 +打开 [房间生成器](https://freecode100year.github.io/htmlroom/),直接点「创建房间」就行,其他设置都不用改。 +如果想多一层保险,可以点「随机生成口令」设个密码(相当于门上再加一把锁)。 **第 2 步:发给朋友** 点「创建房间」后,你可以: @@ -47,7 +47,11 @@ | 每条消息最多 **500 字** | 不联网使用(聊天时必须有网) | | 断网后自动重连 | 找回已关闭页面里的消息 | -创建房间时可按优先级填写 **1–3 个中继**(以逗号或空格分隔)。首选不可用时会尝试备用,并每 60 秒探测首选是否恢复。页面显示当前中继和在线人数;连到不同中继的成员暂时无法互通,切换后请重新核对安全码。所有成员须使用同一份房间配置。 +创建房间时可按优先级填写 **1–3 个经典中继**(以逗号或空格分隔),或改用下文的[去中心化中继](#去中心化房间不依赖任何一台服务器)。首选不可用时会尝试备用,并每 60 秒探测首选是否恢复。页面显示当前中继和在线人数;连到不同中继的成员暂时无法互通,切换后请重新核对安全码。所有成员须使用同一份房间配置。 + +**在房间文件里直接填自己的中继:** 新创建的房间文件默认允许自定义中继。打开文件后,进房间前可以把「中继服务器」改成任意 `wss://` 地址(也可以填去中心化中继),只对这次打开有效、不改动文件。大家要连同一个中继才能互相看到消息;去中心化中继只要有一个相同即可。创建时在「中继服务器(一般不用改)」里取消勾选「允许在房间文件里自定义中继」,文件就只能连列出的中继,更严格。 + +**想改用自己搭的中继?** 打开房间文件,展开「换一个中继服务器」,填入自己的 `wss://` 地址即可生成同一房间的新文件(密钥、口令不变,无需口令);也可在生成器页面的「已经有房间文件?换一个中继服务器」中转换旧文件。把新文件发给所有成员后,大家就在你的中继上聊天,并重新核对安全码。详见 [更换中继](docs/DEVELOPER.md#更换中继迁移到自建中继)。 多中继配置使用新版房间格式,旧文件/链接需重新生成。网页房间链接只支持站点预先允许的中继,其他组合请使用下载的房间文件。配置静态网页的多个中继见 [开发说明](docs/DEVELOPER.md#多中继房间)。 @@ -72,6 +76,28 @@ - 本项目还没有经过第三方专业机构的安全审计。 - 想要更放心,就一定要**核对安全码**。 +## 去中心化房间:不依赖任何一台服务器 + +创建房间时,中继除了填经典的 `wss://…`,还可以填以下三种去中心化中继,最多 5 个,可以混用: + +| 写法 | 是什么 | 谁来提供 | +| --- | --- | --- | +| `bc+wss://主机/bc/` | 只负责转发的中继 | 本项目任何部署(Cloudflare、Docker、一键安装),或下面的单文件中继;任意「同一路径收到的消息转发给其他连接」的 WebSocket 服务器都兼容 | +| `nostr+wss://…` | Nostr 中继 | 公共 Nostr 网络中的中继,或自建 | +| `mqtt+wss://…/mqtt` | MQTT 代理(WebSocket) | mosquitto、EMQX 等,路由器、树莓派都能跑 | + +消息**同时发往全部中继**并自动去重:只要有一个还通,大家就能聊天,也不会出现连到不同中继的人互相看不到的情况。成员编号、在线名单都由各成员自己协商,中继只负责转发看不懂的密文;没有房间密钥的人发来的数据会被直接丢弃。旧房间文件可以用「换一个中继服务器」直接改成去中心化房间,密钥和口令不变。 + +> 代价:没有中继替你挡住乱发数据的人(他们只能浪费流量,解不开也伪造不了消息);公共 Nostr/MQTT 中继能看到你的 IP 和收发时间,规范要求不保存临时消息,但无法保证。成员变化后请重新核对安全码。部分公共 Nostr 中继要求登录或付费,会在页面上提示被拒绝,换一个即可。 + +### 一条命令开一个中继:不要域名、不要公网 IP、不要 root + +```sh +curl -fsSL https://raw.githubusercontent.com/Freecode100Year/htmlroom/main/scripts/relay-quickstart.sh | sh -s -- --tunnel +``` + +脚本下载对应平台的单文件中继(校验 SHA-256),并通过 Cloudflare 免费临时隧道得到公网地址,最后打印一行 `bc+wss://xxxx.trycloudflare.com/bc/`,把它填进房间即可。支持 Linux、macOS、FreeBSD、WSL、Termux 和 OpenWrt。Windows 可从 [Releases](https://github.com/Freecode100Year/htmlroom/releases) 下载 `htmlroom-relay-windows-amd64.exe`,安装 [cloudflared](https://developers.cloudflare.com/cloudflare-one/connections/connect-networks/downloads/) 后运行 `htmlroom-relay-windows-amd64.exe -tunnel`。临时隧道地址每次启动都会变化;长期使用请配域名和 HTTPS,详见 [单文件中继](docs/DEVELOPER.md#单文件中继go)。 + ## 部署自己的聊天室 ### Cloudflare:前端和中继一起部署(推荐) diff --git a/THIRD_PARTY_NOTICES.md b/THIRD_PARTY_NOTICES.md index 55334d3..1d5041b 100644 --- a/THIRD_PARTY_NOTICES.md +++ b/THIRD_PARTY_NOTICES.md @@ -9,6 +9,8 @@ Copyright (c) 2026 Freecode100Year. MIT License; see LICENSE and docs/PROVENANCE lib/vendor/mlkem.js bundles ML-KEM-768 from @noble/post-quantum 0.7.1 and supporting @noble/hashes and @noble/curves code. The original source comments and license notices are retained. +html-room/mesh.js bundles BIP-340 Schnorr signing over secp256k1 from @noble/curves 1.9.7 and SHA-256 from @noble/hashes 1.8.0 (npm, exact versions in package-lock.json), used only to sign Nostr events with a per-session throwaway key. + Copyright (c) 2024 Paul Miller (https://paulmillr.com). Copyright (c) 2022 Paul Miller (paulmillr.com). diff --git a/browser-tests/custom-relay.mjs b/browser-tests/custom-relay.mjs new file mode 100644 index 0000000..66d13ba --- /dev/null +++ b/browser-tests/custom-relay.mjs @@ -0,0 +1,78 @@ +// A room file made for a relay that is gone: members type their own relay into +// the file and chat. Strict files and web links show no relay box. +import { chromium } from 'playwright'; +import { mkdtemp, writeFile, rm } from 'node:fs/promises'; +import { tmpdir } from 'node:os'; +import { pathToFileURL } from 'node:url'; +import assert from 'node:assert/strict'; +import { makeConfig } from '../html-room/crypto.js'; +import { fragmentFor } from '../html-room/transport.js'; +import { render } from '../scripts/build-html-room.mjs'; +import { nodeRelay, nostrRelay } from '../test/fixtures/mesh-relays.mjs'; + +const dir = await mkdtemp(tmpdir() + '/hr-custom-'); +const own = await nodeRelay(), nostr = await nostrRelay(); +let browser; +try { + const dead = 'wss://relay-that-is-gone.invalid'; + const config = await makeConfig(dead, 'pw', { custom: true }); + const path = dir + '/room.html'; await writeFile(path, (await render(config, config.relays)).html); + browser = await chromium.launch({ headless: true }); + const errors = [], sockets = []; + const open = async (relays) => { + const page = await browser.newPage(); + page.on('pageerror', e => errors.push(e.message)); page.on('websocket', s => sockets.push(s.url())); + await page.goto(pathToFileURL(path).href); + assert.equal(await page.locator('#custom-relay').isVisible(), true); + assert.equal(await page.locator('#relay-input').inputValue(), dead); + await page.locator('#password').fill('pw'); + if (relays) await page.locator('#relay-input').fill(relays); + await page.locator('#enter').click(); + return page; + }; + // A bad address is reported and does not count as a wrong password. + const probe = await open('http://nope.example'); + await probe.locator('#status').filter({ hasText: '中继地址不对' }).waitFor(); + assert.equal(await probe.locator('#enter').isEnabled(), true); + await probe.close(); + + // Classic: both type this project's relay. + const a = await open(own.url), b = await open(own.url); + for (const p of [a, b]) { + await p.locator('#relay-status').filter({ hasText: own.url }).waitFor({ timeout: 15000 }); + await p.locator('#status').filter({ hasText: '已加密连接' }).waitFor({ timeout: 20000 }); + } + await a.locator('#text').fill('文件里自己填的中继'); await a.locator('#send').click(); + await b.locator('#messages li').filter({ hasText: '文件里自己填的中继' }).waitFor({ timeout: 15000 }); + assert.ok(!sockets.some(u => u.includes('relay-that-is-gone')), 'the file\'s own relay was never tried'); + + // Decentralized: different relay lists that share one relay still meet. + const c = await open(`bc+${own.url}/bc/ nostr+${nostr.url}`), d = await open(`nostr+${nostr.url}`); + for (const p of [c, d]) await p.locator('#status').filter({ hasText: '已加密连接' }).waitFor({ timeout: 30000 }); + await d.locator('#text').fill('只共用一个 Nostr 中继'); await d.locator('#send').click(); + await c.locator('#messages li').filter({ hasText: '只共用一个 Nostr 中继' }).waitFor({ timeout: 20000 }); + + // Strict files and web links have no relay box. + const strict = await makeConfig(own.url, undefined), strictPath = dir + '/strict.html'; + await writeFile(strictPath, (await render(strict, strict.relays)).html); + const s = await browser.newPage(); await s.goto(pathToFileURL(strictPath).href); + await s.locator('#status').filter({ hasText: '进入房间' }).waitFor(); + assert.equal(await s.locator('#custom-relay').isVisible(), false); + const webConfig = await makeConfig(own.url, undefined, { custom: true }); + const w = await browser.newPage(); await w.goto(`http://127.0.0.1:${own.port}/hr.html${fragmentFor(webConfig)}`); + await w.locator('#status').filter({ hasText: '进入房间' }).waitFor(); + assert.equal(await w.locator('#custom-relay').isVisible(), false); + + // The creation page makes custom-relay files by default, strict when unticked. + const maker = await browser.newPage(); await maker.goto(`http://127.0.0.1:${own.port}/`); + const made = async () => { await maker.locator('#mk-make').click(); await maker.locator('#mk-out').waitFor({ state: 'visible' }); + const dl = maker.waitForEvent('download'); await maker.locator('#mk-download').click(); const f = dir + '/made.html'; await (await dl).saveAs(f); + return (await import('node:fs/promises')).readFile(f, 'utf8'); }; + assert.match(await made(), /connect-src wss: /); + await maker.locator('#mk-advanced summary').click(); await maker.locator('#mk-custom').uncheck(); + assert.doesNotMatch(await made(), /connect-src wss: /); + assert.deepEqual(errors, []); + console.log('PASS: custom relays typed into the file (classic and decentralized), bad address handling, strict files and web links without the box, maker default'); +} finally { + await browser?.close(); await Promise.all([own.stop(), nostr.stop()]); await rm(dir, { recursive: true, force: true }); +} diff --git a/browser-tests/html-room.mjs b/browser-tests/html-room.mjs index 973bd6a..ccc7613 100644 --- a/browser-tests/html-room.mjs +++ b/browser-tests/html-room.mjs @@ -19,12 +19,12 @@ try{ const pages=await Promise.all(contexts.map(c=>c.newPage())); const errors=[],requests=[],sockets=[]; for(const p of pages){p.on('pageerror',e=>errors.push(e.message));p.on('console',m=>{if(m.type()==='error')errors.push(m.text());});p.on('request',req=>requests.push(req.url()));p.on('websocket',w=>sockets.push(w.url()));await p.goto(pathToFileURL(path).href);await p.locator('#enter').click();} - for(const p of pages)await p.locator('#status').filter({hasText:'已建立签名混合加密连接'}).waitFor({timeout:15000}); + for(const p of pages)await p.locator('#status').filter({hasText:'已加密连接'}).waitFor({timeout:15000}); const send=async(p,text)=>{await p.locator('#text').fill(text);await p.locator('#send').click();}; await send(pages[0],'第一端 +加密聊天房间

加密聊天房间

正在启动……如果一直停在这里,请看下面的说明。

页面没有启动?微信、QQ 或手机「文件」App 的预览打不开聊天室,请用 Safari、Chrome 等浏览器打开这个文件。

    diff --git a/dist/hr.css b/dist/hr.css index 8f0d04a..47a77f1 100644 --- a/dist/hr.css +++ b/dist/hr.css @@ -1 +1 @@ -:root{color-scheme:dark;font-family:system-ui,sans-serif;background:#121820;color:#edf2f7}body{max-width:760px;margin:auto;padding:20px}h1{font-size:1.5rem}p{line-height:1.6}aside{border:1px solid #dcac58;padding:12px;border-radius:10px;color:#ffe0a3}input,textarea,button{font:inherit;padding:12px;border:1px solid #65778c;border-radius:8px;background:#202d3b;color:inherit;box-sizing:border-box}input,textarea{width:100%;margin:8px 0}button{cursor:pointer}button:disabled{opacity:.5}ol{padding:0;list-style:none}li{padding:12px;margin:8px 0;background:#202d3b;border-radius:8px;white-space:pre-wrap;overflow-wrap:anywhere}small{display:block;color:#b9c8d8;margin-top:6px}[hidden]{display:none}#status{color:#a7d9ff}textarea{min-height:90px} +:root{color-scheme:dark;font-family:system-ui,sans-serif;background:#121820;color:#edf2f7}body{max-width:760px;margin:auto;padding:20px}h1{font-size:1.5rem}p{line-height:1.6}aside{border:1px solid #dcac58;padding:12px;border-radius:10px;color:#ffe0a3}input,textarea,button{font:inherit;padding:12px;border:1px solid #65778c;border-radius:8px;background:#202d3b;color:inherit;box-sizing:border-box}input,textarea{width:100%;margin:8px 0}button{cursor:pointer}button:disabled{opacity:.5}ol{padding:0;list-style:none}li{padding:12px;margin:8px 0;background:#202d3b;border-radius:8px;white-space:pre-wrap;overflow-wrap:anywhere}small{display:block;color:#b9c8d8;margin-top:6px}[hidden]{display:none}#status{color:#a7d9ff}textarea{min-height:90px}.hint{color:#b9c8d8;font-size:.92rem}code{background:#202d3b;padding:1px 4px;border-radius:4px;overflow-wrap:anywhere}footer{margin-top:32px;border-top:1px solid #34465a}a{color:#a7d9ff}details{margin:14px 0}summary{cursor:pointer}#mk-make,#enter,#send{background:#2f6fb0;border-color:#4a8bd0;font-weight:600;padding:12px 22px}button{margin:4px 0}.check{display:flex;gap:8px;align-items:center;margin:10px 0}.check input{width:auto;margin:0} diff --git a/dist/hr.html b/dist/hr.html index 49abb3a..4aafb03 100644 --- a/dist/hr.html +++ b/dist/hr.html @@ -1 +1 @@ -HTML Room · 创建或进入房间

    创建 HTML 房间

    同一份文件进入同一聊天房间,生成过程在本浏览器内完成。

    +HTML Room · 加密聊天室

    创建加密聊天室

    点一下就能建好一个房间,把文件或链接发给朋友,打开就能聊。全程在你的浏览器里完成,服务器看不到聊天内容。

    中继服务器(一般不用改)

    中继负责帮大家传消息,看不到内容。想换成自己的或多个服务器:经典 wss://… 最多 3 个,坏了会自动换下一个;去中心化 bc+wss://…、nostr+wss://…、mqtt+wss://…/mqtt 最多 5 个,同时使用。两类不能混用。

    打开时,拿到文件的人进房前可以改填任意 wss:// 中继。关掉更严格:文件只能连上面这些中继。

    已经有房间文件?换一个中继服务器

    选择房间文件,填上新的中继地址,会生成同一个房间的新文件,口令不变。文件只在你的浏览器里读取,不会上传。

    HTML Room 开源项目:github.com/Freecode100Year/htmlroom

    diff --git a/dist/hr.js b/dist/hr.js index 14e9408..a046d4b 100644 --- a/dist/hr.js +++ b/dist/hr.js @@ -1 +1 @@ -(()=>{var Lt=Object.defineProperty;var _t=(t,e,r)=>e in t?Lt(t,e,{enumerable:!0,configurable:!0,writable:!0,value:r}):t[e]=r;var _=(t,e,r)=>_t(t,typeof e!="symbol"?e+"":e,r);var M=[];var Br=new TextEncoder;var Mt=[256,512,1024,2048,4096];function se(t){let e=Mt.find(s=>s>t.length)??t.length+1,r=new Uint8Array(e);return r.set(t),r[t.length]=128,r}function oe(t){let e=t.length-1;for(;e>=0&&t[e]===0;)e--;return e>=0&&t[e]===128?t.subarray(0,e):t}var q=new TextEncoder,D=t=>Array.from(new Uint8Array(t),e=>e.toString(16).padStart(2,"0")).join("");function P(t,e){if(typeof t!="string"||t.length!==e*2||!/^[0-9a-f]+$/.test(t))throw Error("\u914D\u7F6E\u5B57\u8282\u683C\u5F0F\u9519\u8BEF");return Uint8Array.from(t.match(/../g),r=>parseInt(r,16))}var N=t=>crypto.getRandomValues(new Uint8Array(t));async function He(t){let e=typeof t=="string"?P(t,32):t;if(!(e instanceof Uint8Array)||e.length!==32)throw Error("\u623F\u95F4\u5BC6\u94A5\u957F\u5EA6\u9519\u8BEF");let r=await crypto.subtle.importKey("raw",e,"HKDF",!1,["deriveBits"]),s=async a=>new Uint8Array(await crypto.subtle.deriveBits({name:"HKDF",hash:"SHA-256",salt:new Uint8Array(32),info:q.encode(`private-talk/html-room/v1/${a}`)},r,256)),o=await s("access"),n=await s("key"),i=await s("bind");return{access:o,keyBytes:n,bind:i,roomId:D(await crypto.subtle.digest("SHA-256",o)),key:await crypto.subtle.importKey("raw",n,"AES-GCM",!1,["encrypt","decrypt"])}}function Ae(t){let e=new URL(t);if(!/^(?:[a-z0-9.-]+|\[[0-9a-f:]+\])$/.test(e.hostname))throw Error("\u4E2D\u7EE7\u4E3B\u673A\u540D\u542B\u4E0D\u5B89\u5168\u5B57\u7B26");if(e.username||e.password||e.search||e.hash||e.pathname!=="/"||!["ws:","wss:"].includes(e.protocol))throw Error("\u4E2D\u7EE7\u5FC5\u987B\u662F\u7CBE\u786E\u7684 WS/WSS \u6E90");if(e.protocol==="ws:"&&!["localhost","127.0.0.1","[::1]"].includes(e.hostname))throw Error("\u8FDC\u7A0B\u4E2D\u7EE7\u5FC5\u987B\u4F7F\u7528 WSS");return e.origin}var xe={name:"PBKDF2",hash:"SHA-256",iterations:31e4};function ie(t){let e=Array.isArray(t)?t:typeof t=="string"?t.trim().split(/[\s,]+/):[],r=[...new Set(e.map(Ae))];if(!r.length||r.length>3)throw Error("\u8BF7\u63D0\u4F9B 1 \u5230 3 \u4E2A\u4E0D\u540C\u4E2D\u7EE7");return r}var je=t=>q.encode(`private-talk/html-room/v1/wrap|${t}`);async function Ne(t,e){if(typeof t!="string"||!t.length||q.encode(t).length>1024)throw Error("\u53E3\u4EE4\u957F\u5EA6\u65E0\u6548");let r=await crypto.subtle.importKey("raw",q.encode(t),"PBKDF2",!1,["deriveKey"]);return crypto.subtle.deriveKey({...xe,salt:e},r,{name:"AES-GCM",length:256},!1,["encrypt","decrypt"])}function G(t){if(!t||t.version!==2||!/^[0-9a-f]{64}$/.test(t.roomId))throw Error("\u623F\u95F4\u7248\u672C\u6216\u5730\u5740\u9519\u8BEF");if(!Array.isArray(t.relays)||JSON.stringify(ie(t.relays))!==JSON.stringify(t.relays))throw Error("\u4E2D\u7EE7\u5217\u8868\u683C\u5F0F\u9519\u8BEF");let e=["version","relays","roomId","encrypted"];if(t.encrypted===!0){let r=[...e,"salt","iv","ciphertext","kdf"];if(Object.keys(t).some(s=>!r.includes(s))||!t.kdf||Object.keys(t.kdf).length!==3||Object.entries(xe).some(([s,o])=>t.kdf[s]!==o))throw Error("\u4E0D\u652F\u6301\u7684 KDF \u914D\u7F6E");P(t.salt,16),P(t.iv,12),P(t.ciphertext,48)}else{if(t.encrypted!==!1||Object.keys(t).some(r=>![...e,"master"].includes(r)))throw Error("\u914D\u7F6E\u5B57\u6BB5\u9519\u8BEF");P(t.master,32)}return t}async function Be(t,e){let r=ie(t),s=N(32),o=await He(s),n={version:2,relays:r,roomId:o.roomId,encrypted:!!e};if(!e)return{...n,master:D(s)};let i=N(16),a=N(12),l=await Ne(e,i),c=await crypto.subtle.encrypt({name:"AES-GCM",iv:a,additionalData:je(n.roomId)},l,s);return{...n,salt:D(i),iv:D(a),ciphertext:D(c),kdf:{...xe}}}async function Re(t,e){G(t);let r=t.encrypted?new Uint8Array(await crypto.subtle.decrypt({name:"AES-GCM",iv:P(t.iv,12),additionalData:je(t.roomId)},await Ne(e,P(t.salt,16)),P(t.ciphertext,48))):P(t.master,32),s=await He(r);if(r.fill(0),s.roomId!==t.roomId)throw Error("\u623F\u95F4\u5730\u5740\u4E0E\u5BC6\u94A5\u4E0D\u5339\u914D");return s}async function Pe(t,e){let r=N(12),s=await crypto.subtle.encrypt({name:"AES-GCM",iv:r,additionalData:q.encode(`private-talk/html-room/v1/keys|${t.roomId}`)},t.key,se(q.encode(e)));return{iv:btoa(String.fromCharCode(...r)),ct:btoa(String.fromCharCode(...new Uint8Array(s)))}}async function Fe(t,e){let r=s=>Uint8Array.from(atob(s),o=>o.charCodeAt(0));return new TextDecoder().decode(oe(new Uint8Array(await crypto.subtle.decrypt({name:"AES-GCM",iv:r(e.iv),additionalData:q.encode(`private-talk/html-room/v1/keys|${t.roomId}`)},t.key,r(e.ct)))))}var ae=BigInt(4294967295),ze=BigInt(32);function Ht(t,e=!1){return e?{h:Number(t&ae),l:Number(t>>ze&ae)}:{h:Number(t>>ze&ae)|0,l:Number(t&ae)|0}}function jt(t,e=!1){let r=t.length,s=new Uint32Array(r),o=new Uint32Array(r);for(let n=0;nt?`"${t}" `:"";function F(t,e=""){if(typeof t!="number")throw new TypeError(pe(e)+"expected number, got "+typeof t);if(!Number.isSafeInteger(t)||t<0)throw new RangeError(pe(e)+"expected integer >= 0, got "+t);return t}function Nt(t,e=""){if(typeof t!="boolean")throw new TypeError(pe(e)+"expected boolean, got type="+typeof t);return t}function R(t,e,r=""){if(Xe(t)&&(e===void 0||t.length===e))return t;e!==void 0&&F(e,"length");let s=Xe(t),o=e!==void 0?` of length ${e}`:"",n=s?`length=${t.length}`:`type=${typeof t}`,i=pe(r)+"expected Uint8Array"+o+", got "+n;throw s?new RangeError(i):new TypeError(i)}var Bt=(t,e)=>{if(t===null||typeof t!="object"||Array.isArray(t))throw new TypeError((e==="object"?"":`"${e}" `)+"expected object, got type="+typeof t)},qe=(t,e)=>{Bt(t,e);let r=Object.getPrototypeOf(t);if(r!==Object.prototype&&r!==null)throw new TypeError(`"${e}" expected plain object`);if(Object.prototype.hasOwnProperty.call(t,"__proto__"))throw new TypeError(`"${e}.__proto__" is not allowed`)};function Ge(t,e=!0){if(t.destroyed)throw new Error("hash was destroyed");if(e&&t.finished)throw new Error("digest() was already called")}function Rt(t,e){R(t,void 0,"output");let r=e.outputLen;if(!(t.length>=r))throw new RangeError('"output" expected length >= '+r)}function Ke(t){return new Uint32Array(t.buffer,t.byteOffset,Math.floor(t.byteLength/4))}function et(...t){for(let e=0;e>>8&65280|t>>>24&255}function zt(t){for(let e=0;et:zt;function tt(t,e,r="opts"){return qe(t,"defaults"),e!==void 0&&qe(e,r),Object.assign(Object.create(null),t,e)}function rt(t,e={}){if(typeof t!="function")throw new TypeError('"hashCons" expected function, got type='+typeof t);e=tt({},e,"info");let r=(o,n)=>t(n).update(o).digest(),s=t(void 0);return r.outputLen=s.outputLen,r.blockLen=s.blockLen,r.canXOF=s.canXOF,r.create=o=>t(o),Object.assign(r,e),Object.freeze(r)}function Xt(t=32){F(t,"bytesLength");let e=typeof globalThis=="object"?globalThis.crypto:null;if(typeof e?.getRandomValues!="function")throw new Error("crypto.getRandomValues must be defined");if(t>65536)throw new RangeError(`"bytesLength" expected <= 65536, got ${t}`);return e.getRandomValues(new Uint8Array(t))}var ue=t=>({oid:Uint8Array.from([6,9,96,134,72,1,101,3,4,2,t])}),qt=BigInt(0),Z=BigInt(1),Gt=BigInt(2),Jt=BigInt(7),Vt=BigInt(256),Yt=BigInt(113),nt=[],st=[],ot=[];for(let t=0,e=Z,r=1,s=0;t<24;t++){[r,s]=[s,(2*r+3*s)%5],nt.push(2*(5*s+r)),st.push((t+1)*(t+2)/2%64);let o=qt;for(let n=0;n<7;n++)e=(e<>Jt)*Yt)%Vt,e&Gt&&(o^=Z<<(Z<t<>>32-r,er=(t,e,r)=>e<>>32-r,tr=(t,e,r)=>e<>>64-r,rr=(t,e,r)=>t<>>64-r,Je=(t,e,r)=>r>32?tr(t,e,r):Zt(t,e,r),Ve=(t,e,r)=>r>32?rr(t,e,r):er(t,e,r),Y=new Uint32Array(10);function nr(t,e=24){if(!(t instanceof Uint32Array))throw new TypeError('"s" expected Uint32Array(50), got type='+typeof t);if(t.length!==50)throw new RangeError('"s" expected Uint32Array(50), got length='+t.length);if(F(e,"rounds"),e<1||e>24)throw new Error('"rounds" expected integer 1..24');for(let r=24-e;r<24;r++){for(let n=0;n<10;n++)Y[n]=t[n]^t[n+10]^t[n+20]^t[n+30]^t[n+40];for(let n=0;n<10;n+=2){let i=(n+8)%10,a=(n+2)%10,l=Y[a],c=Y[a+1],d=Je(l,c,1)^Y[i],u=Ve(l,c,1)^Y[i+1];for(let v=0;v<50;v+=10)t[n+v]^=d,t[n+v+1]^=u}let s=t[2],o=t[3];for(let n=0;n<24;n++){let i=st[n],a=Je(s,o,i),l=Ve(s,o,i),c=nt[n];s=t[c],o=t[c+1],t[c]=a,t[c+1]=l}for(let n=0;n<50;n+=10){let i=t[n],a=t[n+1],l=t[n+2],c=t[n+3];t[n]^=~t[n+2]&t[n+4],t[n+1]^=~t[n+3]&t[n+5],t[n+2]^=~t[n+4]&t[n+6],t[n+3]^=~t[n+5]&t[n+7],t[n+4]^=~t[n+6]&t[n+8],t[n+5]^=~t[n+7]&t[n+9],t[n+6]^=~t[n+8]&i,t[n+7]^=~t[n+9]&a,t[n+8]^=~i&l,t[n+9]^=~a&c}t[0]^=Wt[r],t[1]^=Qt[r]}et(Y)}var at=class ct{constructor(e,r,s,o=!1,n=24){_(this,"state");_(this,"pos",0);_(this,"posOut",0);_(this,"finished",!1);_(this,"state32");_(this,"destroyed",!1);_(this,"blockLen");_(this,"suffix");_(this,"outputLen");_(this,"canXOF");_(this,"enableXOF",!1);_(this,"rounds");if(F(e,"blockLen"),F(r,"suffix"),F(n,"rounds"),Nt(o,"enableXOF"),this.blockLen=e,this.suffix=r,this.outputLen=s,this.enableXOF=o,this.canXOF=o,this.rounds=n,F(s,"outputLen"),!(0=r?Ke(e):void 0;for(let c=0;c=r){for(let u=0,v=c/4;u=s&&this.keccak();let i=Math.min(s-this.posOut,n-o);e.set(r.subarray(this.posOut,this.posOut+i),o),this.posOut+=i,o+=i}return e}xofInto(e){if(!this.enableXOF)throw new Error("XOF is not enabled");return this.writeInto(e)}xof(e){return F(e),this.xofInto(new Uint8Array(e))}digestInto(e){if(Rt(e,this),this.finished)throw new Error("digest() was already called");this.writeInto(e.length===this.outputLen?e:e.subarray(0,this.outputLen)),this.destroy()}digest(){let e=new Uint8Array(this.outputLen);return this.digestInto(e),e}destroy(){this.destroyed=!0,et(this.state)}_cloneInto(e){let{blockLen:r,suffix:s,outputLen:o,rounds:n,enableXOF:i}=this;return e||(e=new ct(r,s,o,i,n)),e.blockLen=r,e.state32.set(this.state32),e.pos=this.pos,e.posOut=this.posOut,e.finished=this.finished,e.rounds=n,e.suffix=s,e.outputLen=o,e.enableXOF=i,e.canXOF=this.canXOF,e.destroyed=this.destroyed,e}},lt=(t,e,r,s={})=>rt(()=>new at(e,t,r),s),sr=lt(6,136,32,ue(8)),or=lt(6,72,64,ue(10)),pt=(t,e,r,s={})=>rt((o={})=>(o=tt({},o),new at(e,t,o.dkLen===void 0?r:o.dkLen,!0)),s),ir=pt(31,168,16,ue(11)),ht=pt(31,136,32,ue(12));function Se(t,e="object"){if(t===null||typeof t!="object"||Array.isArray(t))throw new TypeError(e==="object"?"expected valid options object":`"${e}" expected object, got type=${typeof t}`);return t}function ar(t,e={},r={},s="object"){Se(t,s),Se(e,"fields"),Se(r,"optFields");function o(i,a,l){let c=s==="object"?`param "${String(i)}"`:`"${s}.${String(i)}"`,d=t[i];if(!Object.prototype.hasOwnProperty.call(t,i)&&(l?d!==void 0:a!=="function"))throw new TypeError(`${c} is invalid: expected own property`);if(l&&d===void 0)return;let u=typeof d;if(u!==a||d===null)throw new TypeError(`${c} is invalid: expected ${a}, got ${u}`)}let n=(i,a)=>Object.entries(i).forEach(([l,c])=>o(l,c,a));n(e,!1),n(r,!0)}function re(t,e="n"){if(typeof t!="number")throw new TypeError(`wrong u32 integer "${e}": expected number, got type=${typeof t}`);if(!Number.isSafeInteger(t)||t<0||t>4294967295)throw new RangeError(`wrong u32 integer "${e}": expected 0..4294967295, got ${t}`);return t}function ut(t){return re(t,"x"),(t&t-1)===0&&t!==0}function dt(t,e){if(re(t),typeof e!="number")throw new TypeError('"bits" expected number, got type='+typeof e);if(!Number.isSafeInteger(e)||e<0||e>32)throw new Error(`expected integer 0 <= bits <= 32, got ${e}`);let r=0;for(let s=0;s>>=1)r=r<<1|t&1;return r>>>0}function ft(t){return re(t),31-Math.clz32(t)}function Ye(t){if(!t||typeof t!="object"||typeof t.length!="number")throw new TypeError('"values" expected array-like, got type='+typeof t);let e=t.length;if(!ut(e))throw new Error("expected positive power-of-two length, got "+e);let r=ft(e);for(let s=0;s{ar(e,{N:"number",roots:"object",dit:"boolean"},{invertButterflies:"boolean",skipStages:"number",brp:"boolean"},"coreOpts");let{N:r,roots:s,dit:o,invertButterflies:n=!1,skipStages:i=0,brp:a=!0}=e;re(r,"coreOpts.N");let l=ft(r);if(!ut(r))throw new Error("FFT: Polynomial size should be power of two");re(i,"coreOpts.skipStages");let c=l===0?0:l-1;if(i>c)throw new Error(`FFT: wrong skipStages: expected 0 <= skipStages <= ${c}`);if(s.length!==r)throw new Error(`FFT: wrong roots length: expected ${r}, got ${s.length}`);let d=o!==n;return u=>{if(u.length!==r)throw new Error("FFT: wrong Polynomial length");o&&a&&Ye(u);for(let v=0,m=1;v>1,S=r>>y;for(let E=0;E{}){if(!Array.isArray(t))throw new TypeError(`"${e}" expected array, got type=${typeof t}`);for(let s=0;stypeof o=="number"?o:o.bytesLen,s=e.reduce((o,n)=>o+r(n),0);return{bytesLen:s,encode:o=>{let n=new Uint8Array(s);for(let i=0,a=0;i{R(o,s,t);let n=[];for(let i of e){let a=r(i),l=o.subarray(0,a);n.push(typeof i=="number"?l:i.decode(l)),o=o.subarray(a)}return n}}}function Te(t,e){let r=t,s=e*r.bytesLen;return{bytesLen:s,encode:o=>{let n=cr(o,"u");if(n.length!==e)throw new RangeError(`vecCoder.encode: wrong length=${n.length}. Expected: ${e}`);let i=new Uint8Array(s);for(let a=0,l=0;a{R(o,s);let n=[];for(let i=0;i32)throw new RangeError('"bits" expected <= 32, got '+t);return t===32?4294967295:~(-1<>>0}var lr=t=>{let{newPoly:e,N:r,Q:s,F:o,ROOT_OF_UNITY:n,brvBits:i,isKyber:a}=t,l=(p,h=s)=>{let w=p%h|0;return(w>=0?w|0:h+w|0)|0},c=(p,h=s)=>{let w=l(p,h)|0;return(w>h>>1?w-h|0:w)|0};function d(){let p=e(r);for(let h=0;h{throw new Error("not implemented")},m=a?{add:(p,h)=>{let w=p+h|0;return w>=s?w-s|0:w},sub:(p,h)=>{let w=p-h|0;return w<0?w+s|0:w},mul:(p,h)=>l((p|0)*(h|0))|0,inv:v}:{add:(p,h)=>l((p|0)+(h|0))|0,sub:(p,h)=>l((p|0)-(h|0))|0,mul:(p,h)=>l((p|0)*(h|0))|0,inv:v},y={N:r,roots:u,invertButterflies:!0,skipStages:a?1:0,brp:!1},f=We(m,{dit:!1,...y}),T=We(m,{dit:!0,...y}),S={encode:p=>f(p),decode:p=>{T(p);for(let h=0;hS.encode(p),decode:p=>S.decode(p)},bitsCoder:(p,h)=>{for(let g=0,b=0;g32&&Ze(b),b%=8;let w=Ze(p),A=p*(r/8);return{bytesLen:A,encode:g=>{let b=g,x=new Uint8Array(A);for(let $=0,k=0,U=0,j=0;$=8;U-=8,k>>=8)x[j++]=k&255;return x},decode:g=>{let b=e(r);for(let x=0,$=0,k=0,U=0;x=p;k-=p,$>>=p)b[U++]=h.decode($&w);return b}}}}},pr=t=>(e,r)=>{r||(r=t.blockLen);let s=new Uint8Array(e.length+2);s.set(e);let o=e.length,n=new Uint8Array(r),i=t.create({}),a=0,l=0;return{stats:()=>({calls:a,xofs:l}),get:(c,d)=>(s[o+0]=c,s[o+1]=d,i.destroy(),i=t.create({}).update(s),a++,()=>(l++,i.xofInto(n))),clean:()=>{i.destroy(),O(n,s)}}},hr=pr(ir);var I=256,C=3329,ur=3303,dr=17,L=lr({N:I,Q:C,F:ur,ROOT_OF_UNITY:dr,newPoly:t=>new Uint16Array(t),brvBits:7,isKyber:!0}),fr=Object.freeze({512:Object.freeze({N:I,Q:C,K:2,ETA1:3,ETA2:2,du:10,dv:4,RBGstrength:128}),768:Object.freeze({N:I,Q:C,K:3,ETA1:2,ETA2:2,du:10,dv:4,RBGstrength:192}),1024:Object.freeze({N:I,Q:C,K:4,ETA1:2,ETA2:2,du:11,dv:5,RBGstrength:256})}),yr=t=>{if(t>=12)return{encode:r=>r,decode:r=>r>=C?r-C:r};let e=2**(t-1);return{encode:r=>((r<r*C+e>>>t}},wr=t=>L.bitsCoder(t,t===12?{encode:e=>e,decode:e=>e>=C?e-C:e}:{encode:e=>e,decode:e=>e}),ee=t=>t===12?wr(12):L.bitsCoder(t,yr(t));function J(t,e){let r=t,s=e;for(let o=0;o=C?n-C:n}}function gr(t,e){let r=t,s=e;for(let o=0;o>1)];o&1&&(n=-n);let{c0:i,c1:a}=mr(r[2*o+0],r[2*o+1],s[2*o+0],s[2*o+1],n);r[2*o+0]=i,r[2*o+1]=a}return r}function $e(t){let e=t,r=new Uint16Array(I);for(let s=0;s>0|o[n+1]<<8)&4095,a=(o[n+1]>>4|o[n+2]<<4)&4095;i{let r=new Uint16Array(I),s=Ke(t);he(s);let o=0;for(let n=0,i=0,a=0,l=0;n>=1,o+=1,o===e?(l=a,a=0):o===2*e&&(r[i++]=L.mod(l-a),a=0,o=0)}if(he(s),o)throw new Error(`sampleCBD: leftover bits: ${o}`);return r};function te(t,e,r,s){return br(t(s*I/4,e,r),s)}var kr=t=>{let e=t,{K:r,PRF:s,XOF:o,HASH512:n,ETA1:i,ETA2:a,du:l,dv:c}=e,d=ee(1),u=ee(c),v=ee(l),m=le("publicKey",Te(ee(12),r),32),y=Te(ee(12),r),f=le("ciphertext",Te(v,r),u),T=le("seed",32,32),S=(E,p,h,w)=>{let A=[];for(let k=0;k{B(E,32,"seed");let p=new Uint8Array(33);p.set(E),p[32]=r;let h=n(p),[w,A]=T.decode(h),g=[],b=[];for(let k=0;k{let[w,A]=m.decode(E),g=o(A),b=S(w,(x,$)=>$e(g.get(x,$)),p,h);return g.clean(),b},prepare:E=>{let[p,h]=m.decode(E),w=o(h),A=[];for(let g=0;gS(p.map(x=>x.slice()),(x,$)=>A[x*r+$].slice(),g,b),clean:()=>O(p,A)}},decrypt:(E,p)=>{let[h,w]=f.decode(E),A=y.decode(p),g=new Uint16Array(I);for(let x=0;x{let f=m.subarray(0,384*e.K),T=i.encode(i.decode(Qe(f))),S=W(T,f);if(O(T),!S)throw new Error(`ML-KEM.${y}: wrong publicKey modulus`)},v=Object.freeze({...a,seed:64,msg:c,msgRand:c,secretKey:l.bytesLen});return Object.freeze({info:Object.freeze({type:"ml-kem"}),lengths:v,keygen:m=>{let y=m===void 0,f=y?Ee(d):m,T,S;try{B(f,d,"seed");let E=r.keygen(f.subarray(0,32)),p=E.publicKey;T=E.secretKey,S=s(p);let h=l.encode([T,p,S,f.subarray(32)]);return{publicKey:p,secretKey:h}}finally{T!==void 0&&O(T),S!==void 0&&O(S),y&&O(f)}},getPublicKey:m=>{let[y,f,T,S]=l.decode(m);return Uint8Array.from(f)},encapsulate:(m,y)=>{let f=y===void 0,T=f?Ee(c):y,S;try{return B(m,a.publicKey,"publicKey"),B(T,c,"message"),u(m,"encapsulate"),S=o.create().update(T).update(s(m)).digest(),{cipherText:r.encrypt(m,T,S.subarray(32,64)),sharedSecret:S.subarray(0,32)}}finally{S!==void 0&&O(S.subarray(32)),f&&O(T)}},decapsulate:(m,y)=>{B(y,l.bytesLen,"secretKey"),B(m,a.cipherText,"cipherText");let f=l.bytesLen-96,T=f+32,S=s(y.subarray(f/2,T));if(!W(S,y.subarray(T,T+32)))throw new Error("invalid secretKey: hash check failed");let[E,p,h,w]=l.decode(y),A=r.decrypt(m,E),g=o.create().update(A).update(h).digest(),b=g.subarray(0,32),x=r.encrypt(p,A,g.subarray(32,64)),$=W(m,x),k=n.create({dkLen:32}).update(w).update(m).digest();return O(A,x,g.subarray(32),$?k:b),$?b:k},prepare:m=>{B(m,a.publicKey,"publicKey"),u(m,"prepare");let y=Qe(m),f=s(y),T=r.prepare(y);return Object.freeze({publicKey:y,encapsulate:S=>{let E=S===void 0,p=E?Ee(c):S,h;try{return B(p,c,"message"),h=o.create().update(p).update(f).digest(),{cipherText:T.encrypt(p,h.subarray(32,64)),sharedSecret:h.subarray(0,32)}}finally{h!==void 0&&O(h.subarray(32)),E&&O(p)}},decapsulate:(S,E)=>{B(E,l.bytesLen,"secretKey"),B(S,a.cipherText,"cipherText");let[p,h,w,A]=l.decode(E);if(!W(h,y)||!W(w,f))throw new Error("ML-KEM.decapsulate: secretKey does not match prepared publicKey");let g=r.decrypt(S,p),b=o.create().update(g).update(f).digest(),x=b.subarray(0,32),$=T.encrypt(g,b.subarray(32,64)),k=W(S,$),U=n.create({dkLen:32}).update(A).update(S).digest();return O(g,$,b.subarray(32),k?U:x),k?x:U},clean:T.clean})}})}function Ar(t,e,r){return ht.create({dkLen:t}).update(e).update(new Uint8Array([r])).digest()}var xr={HASH256:sr,HASH512:or,KDF:ht,XOF:hr,PRF:Ar},Sr=t=>vr({...xr,...t}),de=Sr(fr[768]);var H=new TextEncoder,Oe=new TextDecoder,xt={name:"ECDH",namedCurve:"P-256"},yt={name:"ECDSA",namedCurve:"P-256"},fe={name:"ECDSA",hash:"SHA-256"},Er=2e3,Tr=100,$r=600*1e3,wt=60*1e3,Kr=20*1e3,Or=4,Cr=4e3,gt=1580,Ir=1452,Ur=/^(\d{1,9})\.(\d{1,9})$/,ye=/^[A-Za-z0-9+/]+={0,2}$/,Lr=/,"s":"([A-Za-z0-9+/]{86}==)"\}$/,X=t=>btoa(String.fromCharCode(...new Uint8Array(t))),z=t=>Uint8Array.from(atob(t),e=>e.charCodeAt(0)),mt=t=>Array.from(new Uint8Array(t),e=>e.toString(16).padStart(2,"0")).join("");async function bt(t,e){let r=await crypto.subtle.importKey("raw",t,{name:"HMAC",hash:"SHA-256"},!1,["sign"]);return new Uint8Array(await crypto.subtle.sign("HMAC",r,new Uint8Array([e])))}async function Ce(t){return{mk:await bt(t,1),next:await bt(t,2)}}async function kt(t,e){return crypto.subtle.importKey("raw",t,"AES-GCM",!1,e)}async function we(t,e,r){let s=crypto.getRandomValues(new Uint8Array(12)),o=await crypto.subtle.encrypt({name:"AES-GCM",iv:s,additionalData:H.encode(r)},t,e);return{iv:X(s),ct:X(o)}}async function Ie(t,{iv:e,ct:r},s){return new Uint8Array(await crypto.subtle.decrypt({name:"AES-GCM",iv:z(e),additionalData:H.encode(s)},t,z(r)))}var _r=(t,e)=>{let r=new Uint8Array(t.length+e.length);return r.set(t),r.set(e,t.length),r};async function vt(){let t=await crypto.subtle.generateKey(xt,!1,["deriveBits"]);return{pair:t,epk:X(await crypto.subtle.exportKey("raw",t.publicKey))}}var At=t=>`pt31a|${t.from}|${t.id}|${t.t}|${t.epk}|${t.hello?1:0}|${JSON.stringify(t.w)}`,ge=class t{static async create(e,r,s){let o=new t;return o.key=e,o.bind=r,o.me=s,o.signing=await crypto.subtle.generateKey(yt,!1,["sign","verify"]),o.spk=X(await crypto.subtle.exportKey("raw",o.signing.publicKey)),o.kem=de.keygen(),o.kpk=X(o.kem.publicKey),o.ecdh=await vt(),o.ecdhSince=Date.now(),o.oldEcdh=[],o.peers=new Map,o.changed=[],await o.rekey(0),o}get epk(){return this.ecdh.epk}async rekey(e=this.mine.epoch+1,r=Date.now()){this.mine&&r-this.ecdhSince>=Kr&&(this.oldEcdh=[{ecdh:this.ecdh,until:r+wt},...this.oldEcdh].slice(0,Or),this.ecdh=await vt(),this.ecdhSince=r),this.oldEcdh=this.oldEcdh.filter(s=>s.until>r),this.mine={epoch:e,ck:crypto.getRandomValues(new Uint8Array(32)),c:0,since:r,handed:new Map};for(let s of this.peers.values())s.sent=-1,s.gotAt=null}due(e=Date.now()){return this.mine.c>=Tr||e-this.mine.since>=$r}forget(e){this.peers.delete(e)}async pairKey(e,r,s=!1){let o=s?`${e.epk}|${r.kcIn??r.kc}`:e.epk;if(r.wraps.has(o))return r.wraps.get(o);let n=await crypto.subtle.importKey("raw",z(r.epk),xt,!1,[]),i=new Uint8Array(await crypto.subtle.deriveBits({name:"ECDH",public:n},e.pair.privateKey,256)),a=await crypto.subtle.importKey("raw",s?_r(i,r.ss):i,"HKDF",!1,["deriveKey"]),l=H.encode(`private-talk/${s?"v3.2":"v3"}/pair|${[e.epk,r.epk].sort().join("|")}`),c=await crypto.subtle.deriveKey({name:"HKDF",hash:"SHA-256",salt:this.bind,info:l},a,{name:"AES-GCM",length:256},!1,["encrypt","decrypt"]);return r.wraps.set(o,c),c}pqFor(e,r){if(r.v!==32||this.mer!==this.me&&this.peers.has(r)&&this.peers.get(r).sent!==this.mine.epoch)}async announce(e,r,s=Date.now()){return(await this.announceAll(e,r,s,1/0))[0]}async announceAll(e,r,s=Date.now(),o=Cr){let n=this.mine;for(let[y,f]of n.handed)s-f.at>wt&&n.handed.delete(y);let i=[...this.peers].some(([y,f])=>y{let y=a&&u?{q:1,k:this.kpk,...c}:{q:1,...c},f={kind:"keys",v:32,from:this.me,id:mt(crypto.getRandomValues(new Uint8Array(8))),t:s,epk:this.epk,spk:this.spk,hello:!!r&&u,w:y};f.sig=X(await crypto.subtle.sign(fe,this.signing.privateKey,H.encode(At(f)))),l.push(JSON.stringify(f)),c={},d=0,u=!1},m=()=>430+(a&&u?gt+8:0);for(let y of e){let f=this.peers.get(y);if(!f)continue;let{pq:T,can:S}=this.pqFor(y,f);if(!S)continue;let E=f.gotAt!=null&&n.handed.has(f.gotAt),p=E?f.gotAt:n.c,h=E?n.handed.get(p).ck:n.ck;E||(f.gotAt=p,n.handed.has(p)||n.handed.set(p,{ck:h,at:s}));let w=H.encode(JSON.stringify({e:n.epoch,ck:X(h),c:p})),A=T?{...await we(await this.pairKey(this.ecdh,f,!0),w,`pt32w|${this.me}|${y}|${f.epk}`),q:1,...f.kc&&{kc:f.kc}}:await we(await this.pairKey(this.ecdh,f),w,`pt3w|${this.me}|${y}|${f.epk}`),g=JSON.stringify(A).length+8;Object.keys(c).length&&m()+d+g>o&&await v(),c[y]=A,d+=g,f.sent=this.mine.epoch}return(u||Object.keys(c).length)&&await v(),l}async receive(e,r){if(!e||e.kind!=="keys"||e.from!==r||r===this.me||typeof e.epk!="string"||!ye.test(e.epk)||e.epk.length>120||!e.w||typeof e.w!="object")return null;let s=typeof e.spk=="string",o=null;if(s){if(!ye.test(e.spk)||e.spk.length>120||typeof e.sig!="string")throw new Error("bad announcement");let a=this.peers.get(r);if(o=a?.spk===e.spk?a.verify:await crypto.subtle.importKey("raw",z(e.spk),yt,!1,["verify"]),!await crypto.subtle.verify(fe,o,z(e.sig),H.encode(At(e))))throw new Error("bad signature")}let n=this.peers.get(r);n&&(n.spk??null)!==(s?e.spk:null)&&(n.spk&&this.changed.push(r),n=null),n?n.epk!==e.epk&&(s?(n.epk=e.epk,n.wraps=new Map):(n={epk:e.epk,spk:null,verify:null,wraps:new Map,recv:null,prev:null,sent:-1,gotAt:null,v:30},this.peers.set(r,n))):(n={epk:e.epk,spk:s?e.spk:null,verify:o,wraps:new Map,recv:null,prev:null,sent:-1,gotAt:null,v:s?31:30,kpk:null,ss:null,kc:null,kcIn:null,pqIn:!1},this.peers.set(r,n)),s&&e.w.q===1&&n.v!==32&&(n.v=32,n.sent=-1),s&&typeof e.w.k=="string"&&e.w.k.length===gt&&ye.test(e.w.k)&&e.w.k!==n.kpk&&(Object.assign(n,{kpk:e.w.k,ss:null,kc:null,kcIn:null,v:32}),n.wraps=new Map);let i=e.w[this.me];if(i&&typeof i.iv=="string"&&typeof i.ct=="string"){let a=i.q===1;a&&s&&(n.v=32),a&&ru.ecdh)],c=null;if(!a||n.ss)for(let u of l)try{c=JSON.parse(Oe.decode(await Ie(await this.pairKey(u,n,a),i,`${a?"pt32w":"pt3w"}|${r}|${this.me}|${u.epk}`)));break}catch{}c&&a&&this.me=0&&d.length===32&&(!n.recv||c.e>n.recv.epoch)?(n.prev=n.recv,n.recv={epoch:c.e,ck:d,c:c.c},n.pqIn=a):c&&a&&n.recv&&c.e===n.recv.epoch&&(n.pqIn=!0)}return e.hello&&(n.sent=-1),n}takeChanged(){let e=this.changed;return this.changed=[],e}ready(e,r=!1){return e.every(s=>s===this.me||this.peers.get(s)?.sent===this.mine.epoch&&(!r||this.peers.get(s).v>=31))}quantumSafe(e){return e.every(r=>r===this.me||this.peers.get(r)?.v===32&&this.peers.get(r).ss&&this.peers.get(r).pqIn)}older(e){return e.filter(r=>r!==this.me&&this.peers.get(r)?.v===30)}async pairSecret(e,r){let s=this.peers.get(e);if(!s?.ss)throw new Error("no pair secret");let o=await crypto.subtle.importKey("raw",s.ss,"HKDF",!1,["deriveKey"]),n=H.encode(`private-talk/v3.2/for|${r}|${Math.min(e,this.me)}|${Math.max(e,this.me)}`);return crypto.subtle.deriveKey({name:"HKDF",hash:"SHA-256",salt:this.bind,info:n},o,{name:"AES-GCM",length:256},!1,["encrypt","decrypt"])}async sealFor(e,r,s){return we(await this.pairSecret(e,s),H.encode(r),`pt32f|${this.me}|${e}|${s}`)}async openFrom(e,r,s){return Oe.decode(await Ie(await this.pairSecret(e,s),r,`pt32f|${e}|${this.me}|${s}`))}async seal(e){let r=this.mine,s=`${r.epoch}.${r.c}`,{mk:o,next:n}=await Ce(r.ck);r.ck=n,r.c++;let i=X(await crypto.subtle.sign(fe,this.signing.privateKey,H.encode(`pt31m|${this.me}|${s}|${e}`))),a=`${e.slice(0,-1)},"s":"${i}"}`;return{...await we(await kt(o,["encrypt"]),se(H.encode(a)),`pt3|${this.me}|${s}`),h:s}}async open(e,r){let s=Ur.exec(e.h??""),o=this.peers.get(r);if(!s||!o?.recv)throw new Error("no key");let n=Number(s[1]),i=Number(s[2]),a=n===o.recv.epoch?"recv":n===o.prev?.epoch?"prev":null,l=o[a];if(!a||iEr)throw new Error("out of step");let c=l.ck;for(let f=l.c;f=31){if(!y)throw new Error("unsigned");if(v=`${v.slice(0,y.index)}}`,!await crypto.subtle.verify(fe,o.verify,z(y[1]),H.encode(`pt31m|${r}|${e.h}|${v}`)))throw new Error("bad signature");m=!0}return o[a]={epoch:n,ck:u,c:i+1},{text:v,missed:i-l.c,signed:m}}async safetyCode(e){let r=i=>i===this.me?this.spk:this.peers.get(i)?.spk??this.peers.get(i)?.epk??"?",s=e.map(i=>`${i}:${r(i)}`).sort().join("|"),o=new Uint8Array(await crypto.subtle.digest("SHA-256",H.encode(`private-talk/v3.1/safety|${mt(this.bind)}|${s}`))),n=0n;for(let i of o.subarray(0,12))n=n<<8n|BigInt(i);return(n%10n**20n).toString().padStart(20,"0").replace(/(\d{5})(?=\d)/g,"$1 ")}};function be(t){let e=t.trim();return!e||Array.from(e).length>500||new TextEncoder().encode(e).byteLength>2048?null:e}var me=600*1e3,St=/^[0-9a-f]{16}$/;function Et(t,e,r=Date.now(),s,o,n){let i=Array.from(crypto.getRandomValues(new Uint8Array(8)),l=>l.toString(16).padStart(2,"0")).join(""),a=Number.isInteger(n)&&n>=0&&n<=5;return JSON.stringify({from:t,id:i,t:r,text:e,...s&&{g:s},...o&&{f:o},...a&&{q:n}})}function Tt(t,e,r,s=Date.now()){let o;try{o=JSON.parse(t)}catch{return null}if(!o||typeof o!="object"||typeof o.text!="string"||typeof o.id!="string"||!St.test(o.id)||!Number.isInteger(o.from)||o.from<1||o.from>25||o.from!==e.from||!Number.isFinite(o.t)||!Number.isFinite(e.at)||Math.abs(s-o.t)>me||Math.abs(e.at-o.t)>me)return null;let n=Number.isInteger(o.q)&&o.q>=0&&o.q<=5;if(n){if(o.text!==""&&be(o.text)!==o.text)return null}else if(be(o.text)!==o.text)return null;for(let[c,d]of r)Math.abs(s-d)>2*me&&r.delete(c);if(r.has(o.id))return null;r.set(o.id,o.t);let i=o.g&&typeof o.g=="object"&&!Array.isArray(o.g)?o.g:void 0,a=o.f&&typeof o.f=="object"&&!Array.isArray(o.f)?o.f:void 0,l=n?o.q:void 0;return{text:o.text,id:o.id,g:i,f:a,q:l}}function Ue(t,e,r,s=Date.now(),o="keys"){let n;try{n=JSON.parse(t)}catch{return null}return!n||typeof n!="object"||n.kind!==o||typeof n.id!="string"||!St.test(n.id)||!Number.isInteger(n.from)||n.from!==e.from||!Number.isFinite(n.t)||Math.abs(s-n.t)>me||r.has(n.id)?null:(r.set(n.id,n.t),n)}var Dr=()=>new Map,ke=class{constructor(e,r,{storage:s=Dr(),emit:o=()=>{}}={}){Object.assign(this,{d:e,config:r,store:s,emit:o,deviceId:D(N(16)),present:[],pins:new Map,seen:new Map,own:new Map,queued:[],receipts:[],received:new Map,group:null,ws:null,serial:Promise.resolve()}),this.store.set("deviceId",this.deviceId)}run(e){return this.serial=this.serial.then(()=>{if(!this.stopped)return e()}).catch(()=>this.emit("notice","\u6536\u5230\u65E0\u6CD5\u9A8C\u8BC1\u7684\u6570\u636E\uFF0C\u5DF2\u5FFD\u7565\u3002")),this.serial}send(e){!this.stopped&&this.ws?.readyState===1&&this.ws.send(JSON.stringify(e))}async announce(e=!1){if(!this.group)return;let r=this.group.owed(this.present);if(!(!e&&!r.length))for(let s of await this.group.announceAll(r,e))this.send({type:"key",...await Pe(this.d,s)})}ready(){return!this.stopped&&this.group&&this.present.length>1&&this.group.ready(this.present,!0)&&this.group.quantumSafe(this.present)}async incoming(e){if(this.stopped)return;if(e.type==="pong"){this.lastPong=Date.now();return}if(e.type==="wait"){this.nextSend=Date.now()+Math.min(3e4,Math.max(1e3,e.ms||6e3));return}if(e.type==="hello"){this.resume=e.resume,(!this.group||this.group.me!==e.you)&&(this.group=await ge.create(this.d.key,this.d.bind,e.you),this.own.clear(),this.received.clear(),this.receipts=[],this.present=[]),await this.announce(!0);return}if(!this.group)return;if(e.type==="members"){if(!Array.isArray(e.members)||e.members.some(i=>!Number.isInteger(i)||i<1||i>25))return;let n=this.present.filter(i=>!e.members.includes(i));for(let i of n){this.group.forget(i);for(let a of this.received.keys())a.startsWith(`${i}|`)&&this.received.delete(a)}this.present=e.members,n.length&&await this.group.rekey(),await this.announce(!0),this.emit("presence",this.present.length),await this.update();return}if(e.from===this.group.me){let n=this.own.get(e.h);e.type==="msg"&&n&&n.frame.iv===e.iv&&n.frame.ct===e.ct&&(n.echo=!0,this.emit("delivery",{id:n.id,text:"\u5DF2\u63D0\u4EA4\u4E2D\u7EE7"}));return}if(!this.present.includes(e.from))return;if(e.type==="key"){let n=Ue(await Fe(this.d,e),e,this.seen);if(!n||n.v!==32||!n.spk)return;let i=this.pins.get(e.from),a=await this.group.receive(n,e.from);if(!a)return;if(i&&i!==a.spk){this.halt(),this.emit("fatal","\u6210\u5458\u7B7E\u540D\u8EAB\u4EFD\u53D1\u751F\u53D8\u5316\uFF0C\u8BF7\u901A\u8FC7\u72EC\u7ACB\u6E20\u9053\u6838\u5B9E\u5E76\u91CD\u65B0\u6253\u5F00\u6587\u4EF6\u3002");return}this.pins.set(e.from,a.spk),a?.lost&&(a.sent=-1),await this.announce(),await this.update();return}if(!["msg","seen"].includes(e.type)||!e.h)return;let r=`${e.from}|${e.h}`,s=this.received.get(r);if(s){e.type==="msg"&&s.iv===e.iv&&s.ct===e.ct&&this.receipts.length<100&&this.receipts.push([e.from,e.h]);return}let o=await this.group.open(e,e.from);if(!(!o.signed||this.group.peers.get(e.from)?.v!==32))if(e.type==="seen"){let n=Ue(o.text,e,this.seen,Date.now(),"seen");if(n&&Array.isArray(n.l)&&n.l.length<=25)for(let i of n.l){if(!Array.isArray(i)||i[0]!==this.group.me)continue;let a=this.own.get(i[1]);a&&a.targets.includes(e.from)&&(a.confirmed.add(e.from),this.emit("delivery",{id:a.id,text:`\u5BA2\u6237\u7AEF\u786E\u8BA4 ${a.confirmed.size}/${a.targets.length}`}))}}else{let n=Tt(o.text,e,this.seen);if(!n||n.g||n.f)return;for(this.received.set(r,{iv:e.iv,ct:e.ct});this.received.size>200;)this.received.delete(this.received.keys().next().value);this.emit("message",{id:n.id,from:e.from,text:n.text}),this.receipts.length<100&&this.receipts.push([e.from,e.h])}}async update(){this.ready()&&this.emit("ready",await this.group.safetyCode(this.present)),await this.flush()}queue(e){if(this.stopped||(e=be(e),!e||this.queued.length>=20))return!1;let r=D(N(8));return this.queued.push({id:r,text:e}),this.emit("message",{id:r,from:"\u6211",text:e}),this.emit("delivery",{id:r,text:"\u6392\u961F\u4E2D"}),this.run(()=>this.flush()),!0}async flush(){if(!(!this.ready()||this.ws?.readyState!==1)&&(this.group.due()&&(await this.group.rekey(),await this.announce()),!!this.ready())){if(this.receipts.length){let e=JSON.stringify({kind:"seen",id:D(N(8)),from:this.group.me,t:Date.now(),l:this.receipts.splice(0,25)});this.send({type:"seen",...await this.group.seal(e)})}if(this.queued.length&&Date.now()>=(this.nextSend??0)){let e=this.queued.shift(),r={type:"msg",...await this.group.seal(Et(this.group.me,e.text))};for(this.own.set(r.h,{...e,frame:r,targets:this.present.filter(s=>s!==this.group.me),confirmed:new Set,at:Date.now()});this.own.size>100;)this.own.delete(this.own.keys().next().value);this.send(r),this.nextSend=Date.now()+6100}for(let[e,r]of this.seen)Date.now()-r>12e5&&this.seen.delete(e);for(;this.seen.size>4096;)this.seen.delete(this.seen.keys().next().value)}}async retryPending(){for(let e of this.own.values())!e.echo&&Date.now()-e.at>8e3&&(e.retries??0)<3&&(this.send(e.frame),e.at=Date.now(),e.retries=(e.retries??0)+1),!e.echo&&(e.retries??0)>=3&&this.emit("delivery",{id:e.id,text:"\u4E2D\u7EE7\u63D0\u4EA4\u672A\u786E\u8BA4\uFF0C\u8BF7\u6838\u5B9E\u540E\u91CD\u53D1"})}resetRelay(){for(let e of this.own.values())(e.confirmed.sizenew WebSocket(d,u)}){Object.assign(this,{relays:e,roomId:r,protocols:s,onactive:o,onmessage:n,onnotice:i,onfatal:a,ondisconnect:l,createSocket:c}),this.backoff=1e3,this.lastIndex=-1}start(){this.connect(0),this.probeTimer=setInterval(()=>{!this.stopped&&this.active?.ready&&this.active.index>0&&!this.probe&&this.connect(0,!0)},6e4),this.heartbeat=setInterval(()=>{let e=this.active;if(e?.ready){if(Date.now()-e.lastPong>65e3){this.fail(e);return}if(Date.now()-e.lastPing>=25e3){e.lastPing=Date.now();try{e.ws.send(JSON.stringify({type:"ping"}))}catch{this.fail(e)}}}},1e3)}connect(e,r=!1){if(this.stopped)return;let s={index:e,probe:r,frames:[],ready:!1,retired:!1};r?this.probe=s:this.pending=s;try{s.ws=this.createSocket(`${this.relays[e]}/hr/${this.roomId}`,this.protocols(e))}catch{this.fail(s);return}s.timeout=setTimeout(()=>this.fail(s),1e4),s.ws.onerror=()=>{!s.retired&&!s.probe&&this.onnotice("WSS \u63E1\u624B\u5931\u8D25\u53EF\u80FD\u662F\u7F51\u7EDC\u3001Origin\u3001\u9274\u6743\u6216\u8BC1\u4E66\u4FE1\u4EFB\u95EE\u9898\u3002\u6B63\u5728\u6309\u4F18\u5148\u7EA7\u91CD\u8BD5\uFF1B\u8BF7\u4F7F\u7528\u6709\u6548\u8BC1\u4E66\uFF0C\u52FF\u5173\u95ED\u8BC1\u4E66\u6821\u9A8C\u3002")},s.ws.onclose=o=>this.fail(s,o.code),s.ws.onmessage=o=>{if(s.retired||this.stopped||typeof o.data!="string"||o.data.length>7e3)return;let n;try{n=JSON.parse(o.data)}catch{return}if(!(!n||typeof n!="object")){if(s.ready){if(this.active!==s)return;n.type==="pong"&&(s.lastPong=Date.now()),this.onmessage(n);return}if(s.frames.length>=50){this.fail(s);return}s.frames.push(n),n.type==="hello"&&!s.activating&&Number.isInteger(n.you)&&n.you>=1&&n.you<=25&&typeof n.resume=="string"&&/^[0-9a-f]{32}$/.test(n.resume)&&(s.activating=!0,this.activate(s).catch(()=>this.fail(s)))}}}async activate(e){let r=this.active,s=this.lastIndex!==-1&&this.lastIndex!==e.index;if(this.active=e,this.probe===e&&(this.probe=null),this.pending===e&&(this.pending=null),r&&r!==e&&this.retire(r),await this.onactive(e.ws,e.index,s),!(e.retired||this.stopped||this.active!==e)){clearTimeout(e.timeout),this.lastIndex=e.index,this.backoff=1e3,e.ready=!0,e.lastPong=e.lastPing=Date.now();for(let o of e.frames)this.onmessage(o);e.frames=[]}}retire(e){if(!(!e||e.retired)){e.retired=!0,clearTimeout(e.timeout);try{e.ws?.close(4e3)}catch{}}}fail(e,r){if(!(e.retired||this.stopped)){if(this.retire(e),this.probe===e){this.probe=null;return}if(this.pending===e&&(this.pending=null),this.active===e&&(this.active=null,this.ondisconnect()),this.relays.length===1&&[4e3,4001,4002,4005,4006,4007,4008,1008,1009].includes(r)){this.stop(),this.onfatal();return}this.retire(this.probe),this.probe=null,clearTimeout(this.retry),this.retry=setTimeout(()=>this.connect((e.index+1)%this.relays.length),this.backoff),this.backoff=Math.min(this.backoff*2,3e4),r!==1006&&this.onnotice("\u8FDE\u63A5\u4E2D\u65AD\uFF0C\u6B63\u5728\u6309\u5217\u8868\u987A\u5E8F\u5207\u6362\u4E2D\u7EE7\uFF1B\u4E0D\u540C\u4E2D\u7EE7\u4E0A\u7684\u6210\u5458\u6682\u65F6\u65E0\u6CD5\u4E92\u901A\u3002")}}stop(){this.stopped=!0,clearTimeout(this.retry),clearInterval(this.probeTimer),clearInterval(this.heartbeat),this.retire(this.active),this.retire(this.pending),this.retire(this.probe)}};function $t(t){let e=c=>document.getElementById(c),r=c=>{e("status").textContent=c};e("preview-help").hidden=!0;let s=0,o=!1,n=0,i,a=new Map;function l(c,d){if((c==="notice"||c==="fatal")&&r(d),c==="relay-change"&&(e("safety").textContent="\u4E2D\u7EE7\u5DF2\u5207\u6362\uFF0C\u6B63\u5728\u91CD\u65B0\u9A8C\u8BC1\u6210\u5458\uFF1B\u8BF7\u91CD\u65B0\u6838\u5BF9\u5B89\u5168\u7801\u3002"),c==="presence"&&(e("presence").textContent=`\u5728\u7EBF ${d} \u4EBA\uFF08\u4E2D\u7EE7\u62A5\u544A\uFF09`),c==="ready"&&(r("\u5DF2\u5EFA\u7ACB\u7B7E\u540D\u6DF7\u5408\u52A0\u5BC6\u8FDE\u63A5"),e("safety").textContent=`\u5B89\u5168\u7801\uFF1A${d}\uFF0C\u8BF7\u901A\u8FC7\u72EC\u7ACB\u6E20\u9053\u6838\u5BF9\u3002`),c==="message"){let u=document.createElement("li");u.textContent=`${d.from}\uFF1A${d.text}`;let v=document.createElement("small");u.append(v),e("messages").append(u),a.set(d.id,v),a.size>200&&(a.delete(a.keys().next().value),e("messages").firstChild.remove())}c==="delivery"&&a.has(d.id)&&(a.get(d.id).textContent=d.text)}if(!globalThis.crypto?.subtle||!globalThis.WebSocket||!globalThis.TextEncoder)r("\u6B64\u9884\u89C8\u73AF\u5883\u4E0D\u652F\u6301\u6240\u9700\u811A\u672C\u6216\u52A0\u5BC6 API\uFF0C\u8BF7\u4F7F\u7528\u652F\u6301\u7684\u6D4F\u89C8\u5668\u6253\u5F00\uFF1BiOS\u3001\u5FAE\u4FE1\u548C QQ \u672C\u5730\u9884\u89C8\u9700\u5B9E\u6D4B\u3002"),e("enter").disabled=!0;else if(t.version)try{G(t),r(t.encrypted?"\u8BF7\u8F93\u5165\u53E3\u4EE4\u89E3\u9501\uFF1B\u89E3\u9501\u524D\u4E0D\u4F1A\u8FDE\u63A5\u4E2D\u7EE7\u3002":"\u65E0\u53E3\u4EE4\u6587\u4EF6\uFF1A\u70B9\u51FB\u8FDB\u5165\u623F\u95F4\u3002"),e("password").disabled=!1,e("enter").disabled=!1}catch{r("\u914D\u7F6E\u65E0\u6548\uFF0C\u8BF7\u91CD\u65B0\u751F\u6210\u6587\u4EF6\u3002"),e("enter").disabled=!0}else r("\u65E0\u79D8\u5BC6\u6A21\u677F\uFF1A\u8BF7\u4F7F\u7528\u751F\u6210\u5668\u521B\u5EFA\u623F\u95F4\u6587\u4EF6\u3002"),e("enter").disabled=!0;e("unlock").addEventListener("submit",async c=>{if(c.preventDefault(),!(o||Date.now()["pt2",`a${D(i.d.access)}`,...i.resume&&u.lastIndex===v?[`r${i.resume}`]:[]],onactive:(v,m,y)=>i.run(()=>{y&&i.resetRelay(),i.ws=v,e("relay-status").textContent=`\u5F53\u524D\u4E2D\u7EE7\uFF1A${t.relays[m]}\uFF08\u4F18\u5148\u7EA7 ${m+1}/${t.relays.length}\uFF09`,r(y?"\u5DF2\u5207\u6362\u4E2D\u7EE7\uFF0C\u6B63\u5728\u91CD\u65B0\u9A8C\u8BC1\u6210\u5458\uFF1B\u4E0D\u540C\u4E2D\u7EE7\u4E0A\u7684\u6210\u5458\u6682\u65F6\u65E0\u6CD5\u4E92\u901A\u3002":"\u5DF2\u8FDE\u63A5\u4E2D\u7EE7\uFF0C\u6B63\u5728\u9A8C\u8BC1\u6210\u5458\u5BC6\u94A5\u2026")}),onmessage:v=>{let m=i.ws;i.run(()=>{if(m===i.ws)return i.incoming(v)})},ondisconnect:()=>{i.ws=null,e("relay-status").textContent="\u5F53\u524D\u4E2D\u7EE7\uFF1A\u5DF2\u65AD\u5F00\uFF0C\u6B63\u5728\u6309\u4F18\u5148\u7EA7\u91CD\u8BD5",l("presence",0)},onnotice:r,onfatal:()=>{i.halt(),r("\u8FDE\u63A5\u88AB\u62D2\u7EDD\u6216\u4F1A\u8BDD\u7ED3\u675F\uFF0C\u8BF7\u68C0\u67E5\u623F\u95F4\u6587\u4EF6\u3001\u4E2D\u7EE7\u914D\u7F6E\u6216\u4EBA\u6570\u9650\u5236\u540E\u91CD\u65B0\u6253\u5F00\u3002")}});i.connection=u,u.start(),i.tick=setInterval(()=>i.run(async()=>{i.ws?.readyState===1&&(Date.now()-(i.lastAnnounce??0)>1e4&&(await i.announce(!0),i.lastAnnounce=Date.now()),await i.flush(),await i.retryPending())}),1600)}catch{s++,n=Date.now()+Math.min(3e4,1e3*2**Math.min(s,5)),e("password").value="",r("\u89E3\u9501\u5931\u8D25\uFF1A\u53E3\u4EE4\u6216\u6587\u4EF6\u4E0D\u6B63\u786E\u3002\u7A0D\u540E\u518D\u8BD5\u3002\u672C\u673A\u8282\u6D41\u4E0D\u80FD\u963B\u6B62\u79BB\u7EBF\u731C\u6D4B\u3002")}finally{o=!1,setTimeout(()=>{e("enter").disabled=!1},Math.max(0,n-Date.now()))}}}),e("chat").addEventListener("submit",c=>{c.preventDefault(),i?.queue(e("text").value)?e("text").value="":r("\u6D88\u606F\u4E3A\u7A7A\u3001\u8FC7\u957F\u6216\u961F\u5217\u5DF2\u6EE1\u3002")}),addEventListener("pagehide",()=>i?.halt())}var Mr=t=>JSON.stringify(t).replace(/[<>/\u2028\u2029]/g,e=>`\\u${e.charCodeAt(0).toString(16).padStart(4,"0")}`),Hr=t=>btoa(String.fromCharCode(...new TextEncoder().encode(t))).replace(/\+/g,"-").replace(/\//g,"_").replace(/=+$/,"");function _e(t){return G(t),"#hr1="+Hr(JSON.stringify(t))}function Kt(t){let e=t.version?`${t.relays[0].replace(/^ws/,"http")}/hr.html${_e(t)}`:"";return`

    \u82E5\u6B64\u63D0\u793A\u4E00\u76F4\u663E\u793A\uFF0C\u5F53\u524D\u6587\u4EF6\u9884\u89C8\u672A\u80FD\u8FD0\u884C\u804A\u5929\u811A\u672C\u3002iOS\u300C\u6587\u4EF6\u300D\u3001\u5FAE\u4FE1\u6216 QQ \u7684\u9884\u89C8\u53EF\u80FD\u65E0\u6CD5\u4F7F\u7528\u6B64\u623F\u95F4\uFF1B\u8F93\u5165\u53E3\u4EE4\u4E0D\u4F1A\u751F\u6548\u3002\u8BF7\u5728 Safari \u7B49\u6D4F\u89C8\u5668\u4E2D\u6253\u5F00\u623F\u95F4\u7F51\u9875\u94FE\u63A5\u3002

    ${e?`

    \u5728\u6D4F\u89C8\u5668\u4E2D\u6253\u5F00\u540C\u4E00\u623F\u95F4\uFF08\u82E5\u65E0\u6CD5\u8DF3\u8F6C\uFF0C\u8BF7\u590D\u5236\u94FE\u63A5\u5230 Safari \u5730\u5740\u680F\uFF09\u3002

    \u94FE\u63A5\u5305\u542B\u52A0\u5165\u623F\u95F4\u7684\u80FD\u529B\uFF0C\u8BF7\u8C28\u614E\u5206\u53D1\u3002\u7F51\u9875\u4EE3\u7801\u53EF\u8BFB\u53D6\u94FE\u63A5\u4E2D\u7684\u623F\u95F4\u914D\u7F6E\uFF0C\u5176\u5B89\u5168\u6027\u4E0D\u7B49\u540C\u4E8E\u9A8C\u8BC1\u8FC7\u7684\u79BB\u7EBF\u6587\u4EF6\uFF1B\u4E2D\u7EE7\u987B\u63D0\u4F9B /hr.html \u7F51\u9875\u5165\u53E3\u3002

    `:""}
    `}function Ot(t){if(typeof t!="string"||!/^#hr1=[A-Za-z0-9_-]+$/.test(t)||t.length>8192)throw Error("\u623F\u95F4\u94FE\u63A5\u683C\u5F0F\u6216\u957F\u5EA6\u65E0\u6548");let e=t.slice(5).replace(/-/g,"+").replace(/_/g,"/"),r=Uint8Array.from(atob(e+"=".repeat((4-e.length%4)%4)),s=>s.charCodeAt(0));if(r.length>4096)throw Error("\u623F\u95F4\u914D\u7F6E\u8FC7\u957F");return G(JSON.parse(new TextDecoder("utf-8",{fatal:!0}).decode(r)))}var Le=async t=>btoa(String.fromCharCode(...new Uint8Array(await crypto.subtle.digest("SHA-256",new TextEncoder().encode(t)))));async function Ct(t,e,r,s,o,n){if(G(e),!e.relays.every(d=>r.map(Ae).includes(d)))throw Error("\u4E2D\u7EE7\u6E90\u4E0D\u5728\u660E\u786E\u767D\u540D\u5355\u5185");if(t.length>2e5)throw Error("\u6A21\u677F\u8FC7\u957F");if(await Le(t)!==n)throw Error("\u5B8C\u6574\u6A21\u677F\u54C8\u5E0C\u4E0D\u7B26");let i=[...t.matchAll(/]*)>([\s\S]*?)<\/script>/g)],a=[...t.matchAll(/

    加密聊天房间

    正在启动……如果一直停在这里,请看下面的说明。

    ${previewHelp(config)}
    ${RETARGET}

      HTML Room 开源项目:${repoLink}

      \n`; + return { html, csp }; +} + +// Same room (version, roomId, master or wrapped master untouched), new relays. +// Relays are public routing data: not part of the roomId derivation or the +// password-wrap AAD, so no password is needed and nothing secret changes. +export function withRelays(config, input, custom = config.custom === true) { + validate(config); + const { custom: _, ...rest } = config; config = custom ? { ...rest, custom: true } : rest; + // Moving between classic (v2) and decentralized (v3) relays only changes the + // version tag: the key material and roomId stay the same. + const relays = relayList(input); + return validate({ ...config, version: versionFor(relays), relays }); +} + +const digest = async s => btoa(String.fromCharCode(...new Uint8Array(await crypto.subtle.digest('SHA-256', new TextEncoder().encode(s))))); +export async function retargetHtml(config, input, script, style, custom) { + if (typeof script !== 'string' || typeof style !== 'string' || /<\/script/i.test(script) || /<\/style/i.test(style)) throw Error('脚本或样式不可用'); + const next = withRelays(config, input, custom); + return { config: next, ...roomHtml(next, script, style, await digest(script), await digest(style)) }; +} + +export function configFromRoomHtml(html) { + if (typeof html !== 'string' || html.length > 400000) throw Error('不是有效的房间文件'); + const m = html.match(/',``); + return template.replace(marker,``).replace(previewHelp({}),previewHelp(config)).replace('',``); } diff --git a/html-room/web.js b/html-room/web.js index e7f5f0a..68164b9 100644 --- a/html-room/web.js +++ b/html-room/web.js @@ -1,49 +1,74 @@ // Adapted from Claude's hr.js maker/Fragment entry; cryptography and chat // use the canonical protocol modules shared with standalone files. import { boot } from './client.js'; -import { makeConfig, relayList, hex, random } from './crypto.js'; +import { makeConfig, relayList, relayKind, relayUrl, hex, random } from './crypto.js'; import { configFromHash, fragmentFor, fillTemplate } from './transport.js'; +import { withRelays, configFromRoomHtml } from './template.js'; const fragment=location.hash; if(fragment)history.replaceState(null,'',location.pathname+location.search); const el=id=>document.getElementById(id); const allowedRelays=PT_DEFAULT_RELAYS.length ? PT_DEFAULT_RELAYS : [location.origin.replace(/^http/,'ws')]; -let downloadUrl; +let downloadUrl,convertUrl; +// A room opens from this site's link only if this page may reach all its +// relays: preset classic relays, or bc paths on those same servers. +const linkable=relays=>relays.every(r=>allowedRelays.includes(r)||(relayKind(r)==='bc'&&allowedRelays.includes(new URL(relayUrl(r)).origin))); +async function filledTemplate(config){ + const response=await fetch('hr-template.html',{credentials:'omit',cache:'no-store',referrerPolicy:'no-referrer'}); + if(!response.ok)throw Error('模板不可用'); + return fillTemplate(await response.text(),config,config.relays,PT_SCRIPT_HASH,PT_STYLE_HASH,PT_TEMPLATE_HASH); +} function fail(text){el('mk-error').textContent=text;} function openFragment(fragment){ if(location.hash)history.replaceState(null,'',location.pathname+location.search); - if(!el('room-panel').hidden){el('status').textContent='请在新窗口打开其他房间链接,当前会话不会自动切换。';return;} + if(!el('room-panel').hidden){el('status').textContent='你已经在一个房间里了。要进别的房间,请在新窗口打开链接。';return;} el('maker').hidden=true;el('room-panel').hidden=false; try{ const config=configFromHash(fragment); - if(!config.relays.every(relay=>allowedRelays.includes(relay)))throw Error('网页入口仅允许预设中继,请用房间文件连接其他中继。'); - boot(config); - }catch{el('status').textContent='房间链接无效,或中继与本站不匹配。请重新索取链接或使用验证过的房间文件。';el('enter').disabled=true;} + if(!linkable(config.relays))throw Error('网页入口仅允许预设中继,请用房间文件连接其他中继。'); + boot(config,{allowCustom:false}); + }catch{el('status').textContent='房间链接打不开:链接不完整,或者它用的中继本站不支持。请让对方重发链接,或直接用房间文件。';el('enter').disabled=true;} } addEventListener('hashchange',()=>{if(location.hash)openFragment(location.hash);}); if(fragment)openFragment(fragment); else{ el('mk-relay').value=allowedRelays.join(', '); el('mk-gen').addEventListener('click',()=>{el('mk-pass').value=hex(random(32));}); + el('mk-mesh').addEventListener('click',()=>{el('mk-relay').value=allowedRelays.map(r=>`bc+${r}/bc/`).join(' ');fail('已换成本站的去中心化中继。还可以在后面加上 nostr+wss://… 或 mqtt+wss://… 当备份。');}); el('maker').addEventListener('submit',async event=>{ event.preventDefault();el('mk-make').disabled=true;fail('');el('mk-out').hidden=true; try{ const relays=relayList(el('mk-relay').value),password=el('mk-pass').value; if(password && new TextEncoder().encode(password).length>1024)throw Error('口令过长'); // Submitted canonical sources form the explicit file generation allowlist. - const config=await makeConfig(relays,password||undefined); - const response=await fetch('hr-template.html',{credentials:'omit',cache:'no-store',referrerPolicy:'no-referrer'}); - if(!response.ok)throw Error('模板不可用'); - const html=await fillTemplate(await response.text(),config,relays,PT_SCRIPT_HASH,PT_STYLE_HASH,PT_TEMPLATE_HASH); + const config=await makeConfig(relays,password||undefined,{custom:el('mk-custom').checked}); + const html=await filledTemplate(config); if(downloadUrl)URL.revokeObjectURL(downloadUrl); downloadUrl=URL.createObjectURL(new Blob([html],{type:'text/html'})); el('mk-download').href=downloadUrl;el('mk-out').hidden=false; el('mk-pass').value=''; - if(relays.every(relay=>allowedRelays.includes(relay))){el('mk-link').value=location.origin+location.pathname+fragmentFor(config);el('mk-link-row').hidden=false;} + if(linkable(relays)){el('mk-link').value=location.origin+location.pathname+fragmentFor(config);el('mk-link-row').hidden=false;} else{el('mk-link').value='';el('mk-link-row').hidden=true;} - fail(password?'文件已包装。请通过独立渠道传递口令;仍须防范离线猜测。':'此文件包含完整房间密钥,请勿通过未加密的社交软件直接发送。'); - }catch{fail('生成失败。请检查中继地址、环境和模板完整性。');} + fail(password?'✅ 房间建好了。口令请换一种方式告诉朋友(比如打电话),别和文件一起发。':'✅ 房间建好了。文件和链接就是钥匙,请只发给要聊天的人。'); + }catch(e){fail(/[\u4e00-\u9fff]/.test(e?.message??'')?`创建失败:${e.message}`:'创建失败,请检查中继地址后再试。');el('mk-advanced').open=true;} finally{el('mk-make').disabled=false;} }); - el('mk-copy').addEventListener('click',()=>navigator.clipboard?.writeText(el('mk-link').value).then(()=>fail('已复制。链接包含加入能力,请谨慎分发。'),()=>fail('无法自动复制,请手动复制链接。'))); + // Existing room file -> same room on other relays. Read locally, never uploaded; + // rebuilt from this site's verified template, so old files also get current code. + el('cv-make').addEventListener('click',async()=>{ + const say=t=>{el('cv-error').textContent=t;};el('cv-download').hidden=true;el('cv-make').disabled=true;say(''); + try{ + const file=el('cv-file').files?.[0];if(!file)throw Error('请先选择房间文件'); + if(file.size>400000)throw Error('这个文件太大了,不是房间文件'); + let old;try{old=configFromRoomHtml(await file.text());}catch{throw Error('读不出房间信息。如果是很早的旧版文件,请重新创建房间');} + const config=withRelays(old,el('cv-relay').value,el('cv-custom').checked); + const html=await filledTemplate(config); + if(convertUrl)URL.revokeObjectURL(convertUrl); + convertUrl=URL.createObjectURL(new Blob([html],{type:'text/html'})); + el('cv-download').href=convertUrl;el('cv-download').hidden=false; + say(`新文件已生成(中继:${config.relays.join('、')})。${config.encrypted?'口令不变。':''}请把它发给所有人,大家都用新文件打开,再核对一次安全码。`); + }catch(e){say(/[\u4e00-\u9fff]/.test(e?.message??'')?e.message:'生成失败:请检查中继地址。');} + finally{el('cv-make').disabled=false;} + }); + el('mk-copy').addEventListener('click',()=>navigator.clipboard?.writeText(el('mk-link').value).then(()=>fail('已复制。链接就是钥匙,请只发给要聊天的人。'),()=>fail('复制失败,请手动选中链接复制。'))); } -addEventListener('pagehide',()=>{if(downloadUrl)URL.revokeObjectURL(downloadUrl);}); +addEventListener('pagehide',()=>{if(downloadUrl)URL.revokeObjectURL(downloadUrl);if(convertUrl)URL.revokeObjectURL(convertUrl);}); diff --git a/package-lock.json b/package-lock.json index 76030a8..dd4bf5d 100644 --- a/package-lock.json +++ b/package-lock.json @@ -9,9 +9,12 @@ "version": "1.0.0", "license": "MIT", "dependencies": { + "@noble/curves": "1.9.7", + "@noble/hashes": "1.8.0", "ws": "^8.18.0" }, "devDependencies": { + "aedes": "1.2.0", "esbuild": "0.28.2", "playwright": "1.64.1", "wrangler": "^4.149.0" @@ -715,9 +718,6 @@ "arm" ], "dev": true, - "libc": [ - "glibc" - ], "license": "LGPL-3.0-or-later", "optional": true, "os": [ @@ -735,9 +735,6 @@ "arm64" ], "dev": true, - "libc": [ - "glibc" - ], "license": "LGPL-3.0-or-later", "optional": true, "os": [ @@ -755,9 +752,6 @@ "ppc64" ], "dev": true, - "libc": [ - "glibc" - ], "license": "LGPL-3.0-or-later", "optional": true, "os": [ @@ -775,9 +769,6 @@ "riscv64" ], "dev": true, - "libc": [ - "glibc" - ], "license": "LGPL-3.0-or-later", "optional": true, "os": [ @@ -795,9 +786,6 @@ "s390x" ], "dev": true, - "libc": [ - "glibc" - ], "license": "LGPL-3.0-or-later", "optional": true, "os": [ @@ -815,9 +803,6 @@ "x64" ], "dev": true, - "libc": [ - "glibc" - ], "license": "LGPL-3.0-or-later", "optional": true, "os": [ @@ -835,9 +820,6 @@ "arm64" ], "dev": true, - "libc": [ - "musl" - ], "license": "LGPL-3.0-or-later", "optional": true, "os": [ @@ -855,9 +837,6 @@ "x64" ], "dev": true, - "libc": [ - "musl" - ], "license": "LGPL-3.0-or-later", "optional": true, "os": [ @@ -875,9 +854,6 @@ "arm" ], "dev": true, - "libc": [ - "glibc" - ], "license": "Apache-2.0", "optional": true, "os": [ @@ -901,9 +877,6 @@ "arm64" ], "dev": true, - "libc": [ - "glibc" - ], "license": "Apache-2.0", "optional": true, "os": [ @@ -927,9 +900,6 @@ "ppc64" ], "dev": true, - "libc": [ - "glibc" - ], "license": "Apache-2.0", "optional": true, "os": [ @@ -953,9 +923,6 @@ "riscv64" ], "dev": true, - "libc": [ - "glibc" - ], "license": "Apache-2.0", "optional": true, "os": [ @@ -979,9 +946,6 @@ "s390x" ], "dev": true, - "libc": [ - "glibc" - ], "license": "Apache-2.0", "optional": true, "os": [ @@ -1005,9 +969,6 @@ "x64" ], "dev": true, - "libc": [ - "glibc" - ], "license": "Apache-2.0", "optional": true, "os": [ @@ -1031,9 +992,6 @@ "arm64" ], "dev": true, - "libc": [ - "musl" - ], "license": "Apache-2.0", "optional": true, "os": [ @@ -1057,9 +1015,6 @@ "x64" ], "dev": true, - "libc": [ - "musl" - ], "license": "Apache-2.0", "optional": true, "os": [ @@ -1200,6 +1155,33 @@ "@jridgewell/sourcemap-codec": "^1.4.10" } }, + "node_modules/@noble/curves": { + "version": "1.9.7", + "resolved": "https://registry.npmjs.org/@noble/curves/-/curves-1.9.7.tgz", + "integrity": "sha512-gbKGcRUYIjA3/zCCNaWDciTMFI0dCkvou3TL8Zmy5Nc7sJ47a0jtOeZoTaMxkuqRo9cRhjOdZJXegxYE5FN/xw==", + "license": "MIT", + "dependencies": { + "@noble/hashes": "1.8.0" + }, + "engines": { + "node": "^14.21.3 || >=16" + }, + "funding": { + "url": "https://paulmillr.com/funding/" + } + }, + "node_modules/@noble/hashes": { + "version": "1.8.0", + "resolved": "https://registry.npmjs.org/@noble/hashes/-/hashes-1.8.0.tgz", + "integrity": "sha512-jCs9ldd7NwzpgXDIf6P3+NrHh9/sD6CQdxHyjQI+h/6rDNo88ypBxxz45UDuZHz9r3tNz7N/VInSVoVdtXEI4A==", + "license": "MIT", + "engines": { + "node": "^14.21.3 || >=16" + }, + "funding": { + "url": "https://paulmillr.com/funding/" + } + }, "node_modules/@poppinss/colors": { "version": "4.1.6", "resolved": "https://registry.npmjs.org/@poppinss/colors/-/colors-4.1.6.tgz", @@ -1249,6 +1231,136 @@ "dev": true, "license": "CC0-1.0" }, + "node_modules/@types/node": { + "version": "26.6.6", + "resolved": "https://registry.npmjs.org/@types/node/-/node-26.6.6.tgz", + "integrity": "sha512-MzQj+8Kx3XI40OmUAE6Qoh58ZMNstNNMwn2nfd3qWsfe7nDNSqJNzes4njhz6AHwrSHOffSLwXU6yb474Q4aog==", + "dev": true, + "license": "MIT", + "dependencies": { + "undici-types": "~8.9.0" + } + }, + "node_modules/@types/readable-stream": { + "version": "4.0.25", + "resolved": "https://registry.npmjs.org/@types/readable-stream/-/readable-stream-4.0.25.tgz", + "integrity": "sha512-7GsxL8Wd5X3jh73/qgW7WCnbBvGSdD8U3E4Jiuus3/8I+PN20DtlkJtwFhr/sImXfDDQ2Z03i/9wNiIF3TVSsw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/node": "*" + } + }, + "node_modules/abort-controller": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/abort-controller/-/abort-controller-3.0.0.tgz", + "integrity": "sha512-h8lQ8tacZYnR3vNQTgibj+tODHI5/+l06Au2Pcriv/Gmet0eaj4TwWH41sO9wnHDiQsEj19q0drzdWdeAHtweg==", + "dev": true, + "license": "MIT", + "dependencies": { + "event-target-shim": "^5.0.0" + }, + "engines": { + "node": ">=6.5" + } + }, + "node_modules/aedes": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/aedes/-/aedes-1.2.0.tgz", + "integrity": "sha512-fCgK7TzmP2zGYS2iI/RE1tjHAIIqq5MjeOx3bicd89aTWTac+wiaywwIWhQQK6CXS+ml7ssGz8NntKYL5jPaZA==", + "dev": true, + "license": "MIT", + "dependencies": { + "aedes-packet": "^4.0.1", + "aedes-persistence": "^11.0.0", + "mqemitter": "^7.1.0", + "mqtt-packet": "^9.0.2" + }, + "engines": { + "node": ">=20" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/aedes" + }, + "peerDependencies": { + "aedes-persistence-level": "^9.1.2", + "aedes-persistence-mongodb": "^9.3.1", + "aedes-persistence-redis": "^11.2.1" + }, + "peerDependenciesMeta": { + "aedes-persistence-level": { + "optional": true + }, + "aedes-persistence-mongodb": { + "optional": true + }, + "aedes-persistence-redis": { + "optional": true + } + } + }, + "node_modules/aedes-packet": { + "version": "4.0.1", + "resolved": "https://registry.npmjs.org/aedes-packet/-/aedes-packet-4.0.1.tgz", + "integrity": "sha512-PLjKJElXEYB66RzHhJCw0SrRLfSmGRbbg2mtO5t/6FAbFp8vC0cG5ErhvPvfa9iKBrKZwoluWLgn8doBU8sRAw==", + "dev": true, + "license": "MIT", + "dependencies": { + "mqtt-packet": "^9.0.0" + }, + "engines": { + "node": ">=20" + } + }, + "node_modules/aedes-persistence": { + "version": "11.0.0", + "resolved": "https://registry.npmjs.org/aedes-persistence/-/aedes-persistence-11.0.0.tgz", + "integrity": "sha512-6ufVYbC9qrYC9te/jdVX1nXKSRJpUOi3XGLYWfmHo3ijrieZOX6k2/35LFZaX1E2VL2lV9xtq2NLNalPciuxeg==", + "dev": true, + "license": "MIT", + "dependencies": { + "aedes-packet": "^4.0.1", + "qlobber": "^8.0.1" + }, + "engines": { + "node": ">=20" + } + }, + "node_modules/base64-js": { + "version": "1.5.1", + "resolved": "https://registry.npmjs.org/base64-js/-/base64-js-1.5.1.tgz", + "integrity": "sha512-AKpaYlHn8t4SVbOHCy+b5+KKgvR4vrsD8vbvrbiQJps7fKDTkjkDry6ji0rUJjC0kzbNePLwzxq8iypo41qeWA==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/feross" + }, + { + "type": "patreon", + "url": "https://www.patreon.com/feross" + }, + { + "type": "consulting", + "url": "https://feross.org/support" + } + ], + "license": "MIT" + }, + "node_modules/bl": { + "version": "6.1.6", + "resolved": "https://registry.npmjs.org/bl/-/bl-6.1.6.tgz", + "integrity": "sha512-jLsPgN/YSvPUg9UX0Kd73CXpm2Psg9FxMeCSXnk3WBO3CMT10JMwijubhGfHCnFu6TPn1ei3b975dxv7K2pWVg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/readable-stream": "^4.0.0", + "buffer": "^6.0.3", + "inherits": "^2.0.4", + "readable-stream": "^4.2.0" + } + }, "node_modules/blake3-wasm": { "version": "2.1.5", "resolved": "https://registry.npmjs.org/blake3-wasm/-/blake3-wasm-2.1.5.tgz", @@ -1256,6 +1368,31 @@ "dev": true, "license": "MIT" }, + "node_modules/buffer": { + "version": "6.0.3", + "resolved": "https://registry.npmjs.org/buffer/-/buffer-6.0.3.tgz", + "integrity": "sha512-FTiCpNxtwiZZHEZbcbTIcZjERVICn9yq/pDFkTl95/AxzD1naBctN7YO68riM/gLSDY7sdrMby8hofADYuuqOA==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/feross" + }, + { + "type": "patreon", + "url": "https://www.patreon.com/feross" + }, + { + "type": "consulting", + "url": "https://feross.org/support" + } + ], + "license": "MIT", + "dependencies": { + "base64-js": "^1.3.1", + "ieee754": "^1.2.1" + } + }, "node_modules/cookie": { "version": "1.1.1", "resolved": "https://registry.npmjs.org/cookie/-/cookie-1.1.1.tgz", @@ -1270,6 +1407,24 @@ "url": "https://opencollective.com/express" } }, + "node_modules/debug": { + "version": "4.4.3", + "resolved": "https://registry.npmjs.org/debug/-/debug-4.4.3.tgz", + "integrity": "sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA==", + "dev": true, + "license": "MIT", + "dependencies": { + "ms": "^2.1.3" + }, + "engines": { + "node": ">=6.0" + }, + "peerDependenciesMeta": { + "supports-color": { + "optional": true + } + } + }, "node_modules/detect-libc": { "version": "2.1.2", "resolved": "https://registry.npmjs.org/detect-libc/-/detect-libc-2.1.2.tgz", @@ -1332,6 +1487,37 @@ "@esbuild/win32-x64": "0.28.2" } }, + "node_modules/event-target-shim": { + "version": "5.0.1", + "resolved": "https://registry.npmjs.org/event-target-shim/-/event-target-shim-5.0.1.tgz", + "integrity": "sha512-i/2XbnSz/uxRCU6+NdVJgKWDTM427+MqYbkQzD321DuCQJUqOuJKIA0IM2+W2xtYHdKOmZ4dR6fExsd4SXL+WQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6" + } + }, + "node_modules/events": { + "version": "3.3.0", + "resolved": "https://registry.npmjs.org/events/-/events-3.3.0.tgz", + "integrity": "sha512-mQw+2fkQbALzQ7V0MY0IqdnXNOeTtP4r0lN9z7AAawCXgqea7bDii20AYrIBrFd/Hx0M2Ocz6S111CaFkUcb0Q==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.8.x" + } + }, + "node_modules/fastparallel": { + "version": "2.4.1", + "resolved": "https://registry.npmjs.org/fastparallel/-/fastparallel-2.4.1.tgz", + "integrity": "sha512-qUmhxPgNHmvRjZKBFUNI0oZuuH9OlSIOXmJ98lhKPxMZZ7zS/Fi0wRHOihDSz0R1YiIOjxzOY4bq65YTcdBi2Q==", + "dev": true, + "license": "ISC", + "dependencies": { + "reusify": "^1.0.4", + "xtend": "^4.0.2" + } + }, "node_modules/fsevents": { "version": "2.3.3", "resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.3.tgz", @@ -1347,6 +1533,34 @@ "node": "^8.16.0 || ^10.6.0 || >=11.0.0" } }, + "node_modules/ieee754": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/ieee754/-/ieee754-1.2.1.tgz", + "integrity": "sha512-dcyqhDvX1C46lXZcVqCpK+FtMRQVdIMN6/Df5js2zouUsqG7I6sFxitIC+7KYK29KdXOLHdu9zL4sFnoVQnqaA==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/feross" + }, + { + "type": "patreon", + "url": "https://www.patreon.com/feross" + }, + { + "type": "consulting", + "url": "https://feross.org/support" + } + ], + "license": "BSD-3-Clause" + }, + "node_modules/inherits": { + "version": "2.0.4", + "resolved": "https://registry.npmjs.org/inherits/-/inherits-2.0.4.tgz", + "integrity": "sha512-k/vGaX4/Yla3WzyMCvTQOXYeIHvqOKtnqBduzTHpzpQZzAskKMhZ2K+EnBiSM9zGSoIFeMpXKxa4dYeZIQqewQ==", + "dev": true, + "license": "ISC" + }, "node_modules/kleur": { "version": "4.1.5", "resolved": "https://registry.npmjs.org/kleur/-/kleur-4.1.5.tgz", @@ -1397,6 +1611,39 @@ } } }, + "node_modules/mqemitter": { + "version": "7.1.0", + "resolved": "https://registry.npmjs.org/mqemitter/-/mqemitter-7.1.0.tgz", + "integrity": "sha512-GnBDNz3lxmllW201ne0mrmdy5tPOTnc79jjVcsfUa2LG2pUGeyGWVeiae6ZysfC/64XrYOqCKRAQYrB7pGyBVQ==", + "dev": true, + "license": "ISC", + "dependencies": { + "fastparallel": "^2.4.1", + "qlobber": "^8.0.1" + }, + "engines": { + "node": ">=20" + } + }, + "node_modules/mqtt-packet": { + "version": "9.0.2", + "resolved": "https://registry.npmjs.org/mqtt-packet/-/mqtt-packet-9.0.2.tgz", + "integrity": "sha512-MvIY0B8/qjq7bKxdN1eD+nrljoeaai+qjLJgfRn3TiMuz0pamsIWY2bFODPZMSNmabsLANXsLl4EMoWvlaTZWA==", + "dev": true, + "license": "MIT", + "dependencies": { + "bl": "^6.0.8", + "debug": "^4.3.4", + "process-nextick-args": "^2.0.1" + } + }, + "node_modules/ms": { + "version": "2.1.3", + "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz", + "integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==", + "dev": true, + "license": "MIT" + }, "node_modules/path-to-regexp": { "version": "6.3.0", "resolved": "https://registry.npmjs.org/path-to-regexp/-/path-to-regexp-6.3.0.tgz", @@ -1440,6 +1687,82 @@ "node": ">=20" } }, + "node_modules/process": { + "version": "0.11.10", + "resolved": "https://registry.npmjs.org/process/-/process-0.11.10.tgz", + "integrity": "sha512-cdGef/drWFoydD1JsMzuFf8100nZl+GT+yacc2bEced5f9Rjk4z+WtFUTBu9PhOi9j/jfmBPu0mMEY4wIdAF8A==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 0.6.0" + } + }, + "node_modules/process-nextick-args": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/process-nextick-args/-/process-nextick-args-2.0.1.tgz", + "integrity": "sha512-3ouUOpQhtgrbOa17J7+uxOTpITYWaGP7/AhoR3+A+/1e9skrzelGi/dXzEYyvbxubEF6Wn2ypscTKiKJFFn1ag==", + "dev": true, + "license": "MIT" + }, + "node_modules/qlobber": { + "version": "8.0.1", + "resolved": "https://registry.npmjs.org/qlobber/-/qlobber-8.0.1.tgz", + "integrity": "sha512-O+Wd1chXj5YE1DwmD+ae0bXiSLehmnS3czlC1R9FL/Nt/3q8uMS1bIHmg2lJfCoiimCxClWM8AAuJrF0EvNiog==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 16" + } + }, + "node_modules/readable-stream": { + "version": "4.8.0", + "resolved": "https://registry.npmjs.org/readable-stream/-/readable-stream-4.8.0.tgz", + "integrity": "sha512-2jhTTU3Eilp/2DMT71gbcoqC35eQHasMJH0RHkYxwlxIv3+FR6Y5xAFZIwEGLxKkjZYEFlsiMNIdwUqjK2lLHQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "abort-controller": "^3.0.0", + "buffer": "^6.0.3", + "events": "^3.3.0", + "process": "^0.11.10", + "string_decoder": "^1.3.0" + }, + "engines": { + "node": "^12.22.0 || ^14.17.0 || >=16.0.0" + } + }, + "node_modules/reusify": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/reusify/-/reusify-1.1.0.tgz", + "integrity": "sha512-g6QUff04oZpHs0eG5p83rFLhHeV00ug/Yf9nZM6fLeUrPguBTkTQOdpAWWspMh55TZfVQDPaN3NQJfbVRAxdIw==", + "dev": true, + "license": "MIT", + "engines": { + "iojs": ">=1.0.0", + "node": ">=0.10.0" + } + }, + "node_modules/safe-buffer": { + "version": "5.2.1", + "resolved": "https://registry.npmjs.org/safe-buffer/-/safe-buffer-5.2.1.tgz", + "integrity": "sha512-rp3So07KcdmmKbGvgaNxQSJr7bGVSVk5S9Eq1F+ppbRo70+YeaDxkw5Dd8NPN+GD6bjnYm2VuPuCXmpuYvmCXQ==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/feross" + }, + { + "type": "patreon", + "url": "https://www.patreon.com/feross" + }, + { + "type": "consulting", + "url": "https://feross.org/support" + } + ], + "license": "MIT" + }, "node_modules/semver": { "version": "7.8.5", "resolved": "https://registry.npmjs.org/semver/-/semver-7.8.5.tgz", @@ -1503,6 +1826,16 @@ } } }, + "node_modules/string_decoder": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/string_decoder/-/string_decoder-1.3.0.tgz", + "integrity": "sha512-hkRX8U1WjJFd8LsDJ2yQ/wWWxaopEsABU1XfkM8A+j0+85JAGppt16cr1Whg6KIbb4okU6Mql6BOj+uup/wKeA==", + "dev": true, + "license": "MIT", + "dependencies": { + "safe-buffer": "~5.2.0" + } + }, "node_modules/supports-color": { "version": "10.2.2", "resolved": "https://registry.npmjs.org/supports-color/-/supports-color-10.2.2.tgz", @@ -1534,6 +1867,13 @@ "node": ">=20.18.1" } }, + "node_modules/undici-types": { + "version": "8.9.0", + "resolved": "https://registry.npmjs.org/undici-types/-/undici-types-8.9.0.tgz", + "integrity": "sha512-KTDyRTYX8sWmKXAikPHHSyc63CRPETMctyjKFupcC6OBLXT3xsN0e9aF7m+mIXutFWpUXuedtowG7iLOzp0kQg==", + "dev": true, + "license": "MIT" + }, "node_modules/unenv": { "version": "2.0.0-rc.24", "resolved": "https://registry.npmjs.org/unenv/-/unenv-2.0.0-rc.24.tgz", @@ -1622,6 +1962,16 @@ } } }, + "node_modules/xtend": { + "version": "4.0.2", + "resolved": "https://registry.npmjs.org/xtend/-/xtend-4.0.2.tgz", + "integrity": "sha512-LKYU1iAXJXUgAXn9URjiu+MWhyUXHsvfp7mcuYm9dSUKK0/CjtrUwFAxD82/mCWbtLsGjFIad0wIsod4zrTAEQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.4" + } + }, "node_modules/youch": { "version": "4.1.0-beta.10", "resolved": "https://registry.npmjs.org/youch/-/youch-4.1.0-beta.10.tgz", diff --git a/package.json b/package.json index eb47bc6..edb2130 100644 --- a/package.json +++ b/package.json @@ -11,19 +11,25 @@ "build:pages": "node scripts/build-html-room.mjs pages", "build:check": "node scripts/build-html-room.mjs template --check", "test": "node --test", - "test:browser": "node browser-tests/htmlroom.mjs && node browser-tests/html-room-preview.mjs && node browser-tests/multi-relay.mjs", + "test:browser": "node browser-tests/htmlroom.mjs && node browser-tests/html-room-preview.mjs && node browser-tests/multi-relay.mjs && node browser-tests/retarget.mjs && node browser-tests/mesh.mjs && node browser-tests/custom-relay.mjs", + "test:retarget": "node browser-tests/retarget.mjs", "test:failover": "node browser-tests/multi-relay.mjs", "test:pages": "npm run build:pages && node browser-tests/pages.mjs", "test:preview": "node browser-tests/html-room-preview.mjs", "test:worker": "HR_WORKER_TEST=1 node --test test/hr-worker.test.mjs", "html-room:generate": "node scripts/build-html-room.mjs", "html-room:check": "node scripts/check-html-room.mjs", - "deploy": "npm run build:check && wrangler deploy" + "deploy": "npm run build:check && wrangler deploy", + "test:mesh": "node browser-tests/mesh.mjs", + "relay:go": "cd relay/go && go run . " }, "dependencies": { + "@noble/curves": "1.9.7", + "@noble/hashes": "1.8.0", "ws": "^8.18.0" }, "devDependencies": { + "aedes": "1.2.0", "esbuild": "0.28.2", "playwright": "1.64.1", "wrangler": "^4.149.0" diff --git a/relay/go/Dockerfile b/relay/go/Dockerfile new file mode 100644 index 0000000..c6b9e23 --- /dev/null +++ b/relay/go/Dockerfile @@ -0,0 +1,10 @@ +# docker build -t htmlroom-relay relay/go && docker run -p 8080:8080 htmlroom-relay +FROM golang:1.24-alpine AS build +WORKDIR /src +COPY go.mod main.go ./ +RUN CGO_ENABLED=0 go build -trimpath -ldflags='-s -w' -o /htmlroom-relay . +FROM scratch +COPY --from=build /htmlroom-relay /htmlroom-relay +USER 65534:65534 +EXPOSE 8080 +ENTRYPOINT ["/htmlroom-relay"] diff --git a/relay/go/go.mod b/relay/go/go.mod new file mode 100644 index 0000000..28a1826 --- /dev/null +++ b/relay/go/go.mod @@ -0,0 +1,3 @@ +module github.com/Freecode100Year/htmlroom/relay/go + +go 1.22 diff --git a/relay/go/main.go b/relay/go/main.go new file mode 100644 index 0000000..b7e7c31 --- /dev/null +++ b/relay/go/main.go @@ -0,0 +1,424 @@ +// htmlroom-relay: a broadcast relay for decentralized (version 3) HTML Room +// rooms, in one dependency-free binary for Windows, macOS, Linux, BSD and +// routers (OpenWrt mips/arm). +// +// It forwards each text frame on /bc/ to the other connections on the +// same topic. Frames are sealed by the members with a key the relay never sees; +// the relay keeps nothing on disk and logs no topics or addresses. +// +// htmlroom-relay listen on :8080 (put HTTPS in front) +// htmlroom-relay -tls-cert c -tls-key k -listen :443 +// htmlroom-relay -tunnel no domain, no open port: a free +// Cloudflare quick tunnel gives a wss:// URL +package main + +import ( + "bufio" + "context" + "crypto/sha1" + "encoding/base64" + "encoding/binary" + "errors" + "flag" + "fmt" + "io" + "log" + "net" + "net/http" + "os" + "os/exec" + "os/signal" + "regexp" + "strings" + "sync" + "syscall" + "time" + "unicode/utf8" +) + +const version = "1.0.0" + +var ( + listen = flag.String("listen", ":8080", "address to listen on") + tlsCert = flag.String("tls-cert", "", "TLS certificate file (serve wss:// directly)") + tlsKey = flag.String("tls-key", "", "TLS key file") + allowOrigin = flag.String("allow-origin", "", "comma-separated Origins browsers may connect from (default: any; data is end-to-end encrypted)") + maxConns = flag.Int("max-conns", 2000, "maximum simultaneous connections") + maxPeers = flag.Int("max-peers", 50, "maximum connections per topic") + maxFrame = flag.Int("max-frame", 16000, "maximum frame size in bytes") + ratePer10s = flag.Int("rate", 60, "frames per connection per 10 seconds") + tunnel = flag.Bool("tunnel", false, "start a Cloudflare quick tunnel (needs cloudflared) and print the public relay address") + cloudflared = flag.String("cloudflared", "cloudflared", "path to the cloudflared binary") + showVersion = flag.Bool("version", false, "print version and exit") +) + +// Topic: the path after /bc/ (or the whole path, for clients configured with +// a bare host). The HTML Room client uses 32 hex characters. +var topicPath = regexp.MustCompile(`^/(?:bc/)?([A-Za-z0-9_-]{8,128})$`) + +// out is never closed (senders hold no lock against close); done signals the end. +type conn struct { + nc net.Conn + out chan []byte + done chan struct{} + once sync.Once + window int64 + count int +} + +func (c *conn) close() { c.once.Do(func() { close(c.done); c.nc.Close() }) } + +type hub struct { + mu sync.Mutex + topics map[string]map[*conn]bool + total int +} + +func (h *hub) join(topic string, c *conn) bool { + h.mu.Lock() + defer h.mu.Unlock() + peers := h.topics[topic] + if h.total >= *maxConns || len(peers) >= *maxPeers { + return false + } + if peers == nil { + peers = map[*conn]bool{} + h.topics[topic] = peers + } + peers[c] = true + h.total++ + return true +} + +func (h *hub) leave(topic string, c *conn) { + h.mu.Lock() + defer h.mu.Unlock() + if peers := h.topics[topic]; peers[c] { + delete(peers, c) + h.total-- + if len(peers) == 0 { + delete(h.topics, topic) + } + } +} + +func (h *hub) forward(topic string, from *conn, frame []byte) { + h.mu.Lock() + defer h.mu.Unlock() + for c := range h.topics[topic] { + if c == from { + continue + } + select { + case c.out <- frame: + case <-c.done: + default: // a reader this slow is dropped rather than holding up the room + go c.close() + } + } +} + +func originAllowed(origin string) bool { + if *allowOrigin == "" || origin == "" || origin == "null" { + return true + } + for _, o := range strings.Split(*allowOrigin, ",") { + if strings.TrimSpace(o) == origin { + return true + } + } + return false +} + +func headerHas(r *http.Request, name, token string) bool { + for _, v := range r.Header.Values(name) { + for _, p := range strings.Split(v, ",") { + if strings.EqualFold(strings.TrimSpace(p), token) { + return true + } + } + } + return false +} + +const wsGUID = "258EAFA5-E914-47DA-95CA-C5AB0DC85B11" + +func (h *hub) serveHTTP(w http.ResponseWriter, r *http.Request) { + w.Header().Set("Cache-Control", "no-store") + w.Header().Set("X-Content-Type-Options", "nosniff") + if r.URL.Path == "/healthz" { + w.Header().Set("Content-Type", "application/json") + fmt.Fprintf(w, `{"ok":true,"version":%q,"kind":"bc"}`, version) + return + } + m := topicPath.FindStringSubmatch(r.URL.Path) + if m == nil { + if r.URL.Path == "/" { + w.Header().Set("Content-Type", "text/plain; charset=utf-8") + fmt.Fprintln(w, "HTML Room broadcast relay. Use it in a room as: bc+wss:///bc/") + return + } + http.NotFound(w, r) + return + } + key := r.Header.Get("Sec-WebSocket-Key") + if r.Method != http.MethodGet || !headerHas(r, "Connection", "upgrade") || !headerHas(r, "Upgrade", "websocket") || key == "" || r.Header.Get("Sec-WebSocket-Version") != "13" { + http.Error(w, "websocket only", http.StatusUpgradeRequired) + return + } + if !originAllowed(r.Header.Get("Origin")) { + http.Error(w, "forbidden", http.StatusForbidden) + return + } + hj, ok := w.(http.Hijacker) + if !ok { + http.Error(w, "cannot upgrade", http.StatusInternalServerError) + return + } + nc, rw, err := hj.Hijack() + if err != nil { + return + } + sum := sha1.Sum([]byte(key + wsGUID)) + fmt.Fprintf(rw, "HTTP/1.1 101 Switching Protocols\r\nUpgrade: websocket\r\nConnection: Upgrade\r\nSec-WebSocket-Accept: %s\r\n\r\n", base64.StdEncoding.EncodeToString(sum[:])) + if rw.Flush() != nil { + nc.Close() + return + } + c := &conn{nc: nc, out: make(chan []byte, 256), done: make(chan struct{})} + topic := m[1] + if !h.join(topic, c) { + writeFrame(nc, 0x8, closePayload(1013, "full")) + nc.Close() + return + } + go c.writeLoop() + h.readLoop(topic, c, rw.Reader) + h.leave(topic, c) + c.close() +} + +func closePayload(code uint16, reason string) []byte { + b := make([]byte, 2, 2+len(reason)) + binary.BigEndian.PutUint16(b, code) + return append(b, reason...) +} + +// Server frames are unmasked, single-fragment. +func writeFrame(w io.Writer, op byte, payload []byte) error { + head := []byte{0x80 | op} + switch n := len(payload); { + case n < 126: + head = append(head, byte(n)) + case n < 65536: + head = append(head, 126, byte(n>>8), byte(n)) + default: + head = append(head, 127, 0, 0, 0, 0, byte(n>>24), byte(n>>16), byte(n>>8), byte(n)) + } + _, err := w.Write(append(head, payload...)) + return err +} + +// Queued items are [opcode, payload...]. +func (c *conn) writeLoop() { + ping := time.NewTicker(30 * time.Second) + defer ping.Stop() + for { + select { + case <-c.done: + return + case msg := <-c.out: + c.nc.SetWriteDeadline(time.Now().Add(10 * time.Second)) + if writeFrame(c.nc, msg[0], msg[1:]) != nil { + go c.close() + return + } + case <-ping.C: + c.nc.SetWriteDeadline(time.Now().Add(10 * time.Second)) + if writeFrame(c.nc, 0x9, nil) != nil { + go c.close() + return + } + } + } +} + +func (c *conn) send(op byte, payload []byte) { + select { + case c.out <- append([]byte{op}, payload...): + case <-c.done: + default: + } +} + +var errProtocol = errors.New("protocol") + +func (h *hub) readLoop(topic string, c *conn, r *bufio.Reader) { + var msg []byte + var msgOp byte + for { + c.nc.SetReadDeadline(time.Now().Add(75 * time.Second)) + fin, op, payload, err := readFrame(r, *maxFrame) + if err != nil { + if errors.Is(err, errProtocol) { + c.send(0x8, closePayload(1009, "bad frame")) + time.Sleep(50 * time.Millisecond) + } + return + } + switch op { + case 0x8: + c.send(0x8, closePayload(1000, "")) + time.Sleep(50 * time.Millisecond) + return + case 0x9: + c.send(0xA, payload) + continue + case 0xA: + continue + case 0x1: + msg, msgOp = append(msg[:0], payload...), op + case 0x0: + if msgOp == 0 { + return + } + msg = append(msg, payload...) + default: // binary or reserved: this relay carries text only + c.send(0x8, closePayload(1003, "text only")) + time.Sleep(50 * time.Millisecond) + return + } + if len(msg) > *maxFrame { + c.send(0x8, closePayload(1009, "too big")) + time.Sleep(50 * time.Millisecond) + return + } + if !fin { + continue + } + msgOp = 0 + if !utf8.Valid(msg) { + return + } + window := time.Now().Unix() / 10 + if c.window != window { + c.window, c.count = window, 0 + } + if c.count++; c.count > *ratePer10s { + c.send(0x8, closePayload(1008, "too fast")) + time.Sleep(50 * time.Millisecond) + return + } + h.forward(topic, c, append([]byte{0x1}, msg...)) + } +} + +func readFrame(r *bufio.Reader, limit int) (fin bool, op byte, payload []byte, err error) { + var head [2]byte + if _, err = io.ReadFull(r, head[:]); err != nil { + return + } + fin, op = head[0]&0x80 != 0, head[0]&0x0f + if head[0]&0x70 != 0 || head[1]&0x80 == 0 { // no extensions; clients must mask + return false, 0, nil, errProtocol + } + n := uint64(head[1] & 0x7f) + switch n { + case 126: + var b [2]byte + if _, err = io.ReadFull(r, b[:]); err != nil { + return + } + n = uint64(binary.BigEndian.Uint16(b[:])) + case 127: + var b [8]byte + if _, err = io.ReadFull(r, b[:]); err != nil { + return + } + n = binary.BigEndian.Uint64(b[:]) + } + if n > uint64(limit) || (op >= 0x8 && (n > 125 || !fin)) { + return false, 0, nil, errProtocol + } + var mask [4]byte + if _, err = io.ReadFull(r, mask[:]); err != nil { + return + } + payload = make([]byte, n) + if _, err = io.ReadFull(r, payload); err != nil { + return + } + for i := range payload { + payload[i] ^= mask[i%4] + } + return +} + +var tunnelURL = regexp.MustCompile(`https://[a-z0-9-]+\.trycloudflare\.com`) + +// startTunnel runs cloudflared and reports the relay address it was given. +func startTunnel(ctx context.Context, port string) { + cmd := exec.CommandContext(ctx, *cloudflared, "tunnel", "--no-autoupdate", "--url", "http://127.0.0.1:"+port) + stderr, err := cmd.StderrPipe() + if err == nil { + err = cmd.Start() + } + if err != nil { + log.Printf("无法启动 cloudflared(%v)。请先安装:https://developers.cloudflare.com/cloudflare-one/connections/connect-networks/downloads/", err) + return + } + go func() { + s := bufio.NewScanner(stderr) + found := false + for s.Scan() { + if u := tunnelURL.FindString(s.Text()); u != "" && !found { + found = true + host := strings.TrimPrefix(u, "https://") + fmt.Printf("\n公网中继已就绪(临时地址,重启后会变化):\n\n bc+wss://%s/bc/\n\n在房间生成器或房间文件的「更换中继」中填写上面这一行。\n\n", host) + } + } + cmd.Wait() + if ctx.Err() == nil { + log.Printf("cloudflared 已退出,公网地址失效") + } + }() +} + +func main() { + flag.Parse() + if *showVersion { + fmt.Println(version) + return + } + h := &hub{topics: map[string]map[*conn]bool{}} + srv := &http.Server{Addr: *listen, Handler: http.HandlerFunc(h.serveHTTP), ReadHeaderTimeout: 10 * time.Second, ErrorLog: log.New(io.Discard, "", 0)} + ln, err := net.Listen("tcp", *listen) + if err != nil { + log.Fatalf("无法监听 %s:%v", *listen, err) + } + _, port, _ := net.SplitHostPort(ln.Addr().String()) + ctx, stop := signal.NotifyContext(context.Background(), os.Interrupt, syscall.SIGTERM) + defer stop() + scheme := "ws" + if *tlsCert != "" { + scheme = "wss" + } + log.Printf("HTML Room broadcast relay %s listening on %s (%s://:%s/bc/)", version, ln.Addr(), scheme, port) + if *tunnel { + startTunnel(ctx, port) + } + go func() { + <-ctx.Done() + shutdown, cancel := context.WithTimeout(context.Background(), 3*time.Second) + defer cancel() + srv.Shutdown(shutdown) + }() + if *tlsCert != "" { + err = srv.ServeTLS(ln, *tlsCert, *tlsKey) + } else { + err = srv.Serve(ln) + } + if err != nil && !errors.Is(err, http.ErrServerClosed) { + log.Fatal(err) + } +} diff --git a/scripts/build-html-room.mjs b/scripts/build-html-room.mjs index 24d7b09..c2f0d54 100644 --- a/scripts/build-html-room.mjs +++ b/scripts/build-html-room.mjs @@ -2,20 +2,22 @@ import { build } from 'esbuild'; import { createHash } from 'node:crypto'; import { readFile, writeFile, mkdir } from 'node:fs/promises'; import { fileURLToPath } from 'node:url'; -import { makeConfig, relayOrigin, relayList, validate } from '../html-room/crypto.js'; -import { escapeConfig, previewHelp } from '../html-room/transport.js'; +import { makeConfig, relayEntry, relayList, validate } from '../html-room/crypto.js'; +import { escapeConfig } from '../html-room/transport.js'; +import { roomHtml, withRelays, configFromRoomHtml, repoLink } from '../html-room/template.js'; export { escapeConfig }; const root=fileURLToPath(new URL('../',import.meta.url)); const hash=s=>createHash('sha256').update(s).digest('base64'); export async function render(config, allowed=[]) { - if(config.version){validate(config);if(!config.relays.every(relay=>allowed.map(relayOrigin).includes(relay)))throw Error('中继源不在明确白名单内');} + if(config.version){validate(config);if(!config.relays.every(relay=>allowed.map(relayEntry).includes(relay)))throw Error('中继源不在明确白名单内');} const result=await build({entryPoints:[root+'html-room/file.js'],bundle:true,write:false,format:'iife',target:['es2020'],minify:true,legalComments:'none'}); - const script=result.outputFiles[0].text.trimEnd(); - if(/<\/script/i.test(script))throw Error('可执行脚本含不安全结束标签'); + // The script embeds the room HTML (template.js) to re-emit itself; tag openers + // occur only inside string literals, where \x3C is the same character. + const script=result.outputFiles[0].text.trimEnd().replace(/<(\\?\/?)(script|style)/gi,'\\x3C$1$2'); + if(/<\/?(script|style)/i.test(script))throw Error('可执行脚本含不安全标签'); const style=(await readFile(root+'html-room/style.css','utf8')).trimEnd(); const scriptHash=hash(script),styleHash=hash(style); - const csp=`default-src 'none'; script-src 'sha256-${scriptHash}'; style-src 'sha256-${styleHash}'; connect-src ${config.version?config.relays.join(' '):"'none'"}; base-uri 'none'; form-action 'none'`; - const html=`私密 HTML 聊天房间

      私密聊天房间

      聊天脚本尚未启动,请查看下方浏览器打开说明。

      ${previewHelp(config)}

        \n`; + const {html,csp}=roomHtml(config,script,style,scriptHash,styleHash); return {html,scriptHash,styleHash,csp,bytes:Buffer.byteLength(html)}; } export async function buildWeb(check=false, {defaultRelay=null, outputDir=root+'dist/'}={}) { @@ -24,9 +26,9 @@ export async function buildWeb(check=false, {defaultRelay=null, outputDir=root+' if(!check)await mkdir(outputDir,{recursive:true}); const t=await render({},[]),style=(await readFile(root+'html-room/style.css','utf8')).trimEnd(); const built=await build({entryPoints:[root+'html-room/web.js'],bundle:true,write:false,format:'iife',target:['es2020'],minify:true,legalComments:'none',define:{PT_DEFAULT_RELAYS:JSON.stringify(defaultRelays),PT_SCRIPT_HASH:JSON.stringify(t.scriptHash),PT_STYLE_HASH:JSON.stringify(t.styleHash),PT_TEMPLATE_HASH:JSON.stringify(hash(t.html))}}); - const maker=`

        创建 HTML 房间

        同一份文件进入同一聊天房间,生成过程在本浏览器内完成。

        `; + const maker=`

        创建加密聊天室

        点一下就能建好一个房间,把文件或链接发给朋友,打开就能聊。全程在你的浏览器里完成,服务器看不到聊天内容。

        中继服务器(一般不用改)

        中继负责帮大家传消息,看不到内容。想换成自己的或多个服务器:经典 wss://… 最多 3 个,坏了会自动换下一个;去中心化 bc+wss://…、nostr+wss://…、mqtt+wss://…/mqtt 最多 5 个,同时使用。两类不能混用。

        打开时,拿到文件的人进房前可以改填任意 wss:// 中继。关掉更严格:文件只能连上面这些中继。

        已经有房间文件?换一个中继服务器

        选择房间文件,填上新的中继地址,会生成同一个房间的新文件,口令不变。文件只在你的浏览器里读取,不会上传。

        HTML Room 开源项目:${repoLink}

        `; const body=t.html.match(/([\s\S]*?)${maker}\n`; + const page=`HTML Room · 加密聊天室${maker}\n`; const files={'hr-template.html':t.html,'hr.js':built.outputFiles[0].text,'hr.css':style+'\n','hr.html':page}; for(const [name,text] of Object.entries(files)){ const path=outputDir+name; @@ -47,10 +49,24 @@ if(process.argv[1]===fileURLToPath(import.meta.url)){ } if(args[0]==='template' && (args.length>2 || args.slice(1).some(x=>x!=='--check')))throw Error('template 仅支持 --check'); if(args[0]==='template'){console.log(JSON.stringify(await buildWeb(args.includes('--check'))));process.exit(0);} - let output,relay,allowed; + if(args[0]==='retarget'){ + const flags=args.slice(1); + let custom;for(const f of ['--strict','--custom'])if(flags.includes(f)){custom=f==='--custom';flags.splice(flags.indexOf(f),1);} + if(flags.length%2 || flags.some((value,i)=>i%2===0&&!['--in','--out','--relay'].includes(value)) || flags.filter(x=>x==='--in').length!==1 || flags.filter(x=>x==='--out').length!==1)throw Error('用法:retarget --in 旧房间.html --out 新房间.html --relay wss://新中继 [--relay wss://备用中继]'); + const getAll=name=>flags.filter((value,i)=>i%2===1&&flags[i-1]===name); + const relays=getAll('--relay');if(!relays.length)throw Error('retarget 需要至少一个 --relay'); + // Old files are rebuilt with the current template: the room secret is kept, the code is upgraded. + const config=withRelays(configFromRoomHtml(await readFile(getAll('--in')[0],'utf8')),relays,custom); + const r=await render(config,config.relays); + await writeFile(getAll('--out')[0],r.html,{mode:0o600,flag:'wx'}); + console.log(JSON.stringify({bytes:r.bytes,relays:config.relays,roomId:config.roomId,encrypted:config.encrypted}));process.exit(0); + } + let output,relay,allowed,strict=true; if(args[0]==='room'){ + strict=false; const flags=args.slice(1); - if(flags.length%2 || flags.some((value,i)=>i%2===0&&!['--out','--relay','--allow-relay'].includes(value)) || flags.filter(x=>x==='--out').length!==1)throw Error('room 仅支持一个 --out 和可重复的 --relay、--allow-relay;口令请用 HR_PASSWORD_STDIN'); + strict=flags.includes('--strict');if(strict)flags.splice(flags.indexOf('--strict'),1); + if(flags.length%2 || flags.some((value,i)=>i%2===0&&!['--out','--relay','--allow-relay'].includes(value)) || flags.filter(x=>x==='--out').length!==1)throw Error('room 仅支持一个 --out、可重复的 --relay、--allow-relay 和 --strict(不允许在文件里自定义中继);口令请用 HR_PASSWORD_STDIN'); const getAll=name=>flags.filter((value,i)=>i%2===1&&flags[i-1]===name); [output]=getAll('--out');relay=getAll('--relay');allowed=getAll('--allow-relay'); if(!output||!relay.length||!allowed.length)throw Error('room 需要 --out、--relay 与明确 --allow-relay'); @@ -58,7 +74,8 @@ if(process.argv[1]===fileURLToPath(import.meta.url)){ if(!output)throw Error('用法:node scripts/build-html-room.mjs out/index.html wss://relay.example wss://relay.example (从标准输入可选读取口令,HR_PASSWORD_STDIN=1)'); let pass; if(process.env.HR_PASSWORD_STDIN==='1'){let s='';for await(const chunk of process.stdin){s+=chunk;if(Buffer.byteLength(s)>4096)throw Error('输入过长');}pass=s.replace(/\r?\n$/,'');if(!pass)throw Error('口令不能为空');} - const config=relay?await makeConfig(relay,pass):{}; + // room: custom relays allowed unless --strict; the legacy positional form stays strict. + const config=relay?await makeConfig(relay,pass,{custom:!strict}):{}; const r=await render(config,allowed); await mkdir(new URL('../out/',import.meta.url),{recursive:true}); await writeFile(output,r.html,{mode:0o600,flag:'wx'}); diff --git a/scripts/relay-quickstart.sh b/scripts/relay-quickstart.sh new file mode 100755 index 0000000..6d88950 --- /dev/null +++ b/scripts/relay-quickstart.sh @@ -0,0 +1,88 @@ +#!/bin/sh +# Start an HTML Room broadcast relay (for decentralized rooms) without root, +# Docker, a domain or an open port: +# +# curl -fsSL https://raw.githubusercontent.com/Freecode100Year/htmlroom/main/scripts/relay-quickstart.sh | sh -s -- --tunnel +# +# --tunnel get a public wss:// address through a free Cloudflare quick +# tunnel (downloads cloudflared next to the relay if needed) +# --port N local port (default 8080) +# --dir DIR where to keep the binaries (default ~/.htmlroom-relay) +# --from-source build with a local Go toolchain instead of downloading +# --check print what would be downloaded and exit +# Runs on Linux, macOS, FreeBSD, WSL, Termux and OpenWrt (busybox sh). +set -eu +REPO=${HTMLROOM_REPO:-Freecode100Year/htmlroom} +BASE=${HTMLROOM_RELEASE_BASE:-https://github.com/$REPO/releases/latest/download} +PORT=8080 DIR=${HOME:-.}/.htmlroom-relay TUNNEL=0 SOURCE=0 CHECK=0 +while [ $# -gt 0 ]; do + case $1 in + --tunnel) TUNNEL=1 ;; + --port) PORT=${2:?--port 需要端口号}; shift ;; + --dir) DIR=${2:?--dir 需要目录}; shift ;; + --from-source) SOURCE=1 ;; + --check) CHECK=1 ;; + -h|--help) sed -n '2,15p' "$0" 2>/dev/null || true; exit 0 ;; + *) echo "未知参数:$1" >&2; exit 2 ;; + esac + shift +done +case $PORT in ''|*[!0-9]*) echo "端口无效:$PORT" >&2; exit 2 ;; esac + +platform() { + os=$(uname -s | tr 'A-Z' 'a-z'); m=$(uname -m) + case $os in linux|darwin|freebsd) ;; msys*|mingw*|cygwin*) os=windows ;; *) echo "不支持的系统:$os" >&2; exit 1 ;; esac + [ "$os" = linux ] && [ -n "${PREFIX:-}" ] && case $PREFIX in *com.termux*) os=android ;; esac + case $m in + x86_64|amd64) arch=amd64 ;; aarch64|arm64) arch=arm64 ;; armv7*|armv6*|arm) arch=arm ;; + i386|i686) arch=386 ;; mips) arch=mips ;; mipsel|mipsle) arch=mipsle ;; riscv64) arch=riscv64 ;; + *) echo "不支持的处理器:$m" >&2; exit 1 ;; + esac + echo "$os-$arch" +} +fetch() { # url file + if command -v curl >/dev/null 2>&1; then curl -fsSL -o "$2" "$1" + elif command -v wget >/dev/null 2>&1; then wget -qO "$2" "$1" + else echo "需要 curl 或 wget" >&2; exit 1; fi +} + +P=$(platform); EXT=; case $P in windows-*) EXT=.exe ;; esac +RELAY=$DIR/htmlroom-relay$EXT +CF_ARCH=${P#*-}; case $CF_ARCH in arm) CF_ARCH=arm ;; esac +CF_URL=https://github.com/cloudflare/cloudflared/releases/latest/download/cloudflared-${P%-*}-$CF_ARCH +[ "${P%-*}" = darwin ] && CF_URL=https://github.com/cloudflare/cloudflared/releases/latest/download/cloudflared-darwin-$CF_ARCH.tgz +if [ $CHECK = 1 ]; then + echo "平台:$P"; echo "中继:$BASE/htmlroom-relay-$P$EXT -> $RELAY"; [ $TUNNEL = 1 ] && echo "隧道:$CF_URL"; exit 0 +fi +mkdir -p "$DIR" +if [ $SOURCE = 1 ]; then + command -v go >/dev/null 2>&1 || { echo "--from-source 需要 Go 1.22+" >&2; exit 1; } + src=$(cd "$(dirname "$0")/../relay/go" 2>/dev/null && pwd) || { echo "请在仓库中运行 --from-source" >&2; exit 1; } + (cd "$src" && CGO_ENABLED=0 go build -trimpath -ldflags='-s -w' -o "$RELAY" .) +elif [ ! -x "$RELAY" ]; then + echo "下载中继:htmlroom-relay-$P$EXT" + fetch "$BASE/htmlroom-relay-$P$EXT" "$RELAY.part" + fetch "$BASE/SHA256SUMS" "$DIR/SHA256SUMS" || { rm -f "$RELAY.part"; echo "无法下载 SHA256SUMS,未安装" >&2; exit 1; } + want=$(grep " htmlroom-relay-$P$EXT\$" "$DIR/SHA256SUMS" | cut -d' ' -f1) + got=$( (sha256sum "$RELAY.part" 2>/dev/null || shasum -a 256 "$RELAY.part") | cut -d' ' -f1) + [ -n "$want" ] && [ "$want" = "$got" ] || { rm -f "$RELAY.part"; echo "校验和不符,已删除下载文件" >&2; exit 1; } + chmod +x "$RELAY.part"; mv "$RELAY.part" "$RELAY" +fi +ARGS="-listen 127.0.0.1:$PORT" +if [ $TUNNEL = 1 ]; then + CF=$(command -v cloudflared 2>/dev/null || echo "$DIR/cloudflared") + if [ ! -x "$CF" ]; then + echo "下载 cloudflared(Cloudflare 官方隧道客户端)" + case $CF_URL in + *.tgz) fetch "$CF_URL" "$DIR/cf.tgz"; tar -xzf "$DIR/cf.tgz" -C "$DIR"; rm -f "$DIR/cf.tgz" ;; + *) fetch "$CF_URL" "$DIR/cloudflared" ;; + esac + chmod +x "$DIR/cloudflared"; CF=$DIR/cloudflared + fi + ARGS="$ARGS -tunnel -cloudflared $CF" +else + ARGS="-listen :$PORT" + echo "未使用 --tunnel:请在前面配置 HTTPS 反向代理,房间里填写 bc+wss://你的域名/bc/" +fi +# shellcheck disable=SC2086 +exec "$RELAY" $ARGS diff --git a/server/server.mjs b/server/server.mjs index 3c97111..3252f17 100644 --- a/server/server.mjs +++ b/server/server.mjs @@ -24,8 +24,8 @@ import { join, extname, normalize } from 'node:path'; import { fileURLToPath } from 'node:url'; import { WebSocketServer } from 'ws'; import { - MAX_MEMBERS, ROOM_ID, PONG, CLOSE_BUSY, CLOSE_CAPACITY, CLOSE_LEFT, CLOSE_DENIED, PLAIN_HEADERS, BYE_HEADERS, JOINS_PER_MINUTE, - PROTOCOL, WIRE_VERSION, COVER, inside, emptyBook, admit, depart, awayNumbers, token, hostOf, parseProtocols, accessMatches + MAX_MEMBERS, ROOM_ID, PONG, CLOSE_BUSY, CLOSE_CAPACITY, CLOSE_FULL, CLOSE_LEFT, CLOSE_DENIED, PLAIN_HEADERS, BYE_HEADERS, JOINS_PER_MINUTE, + PROTOCOL, WIRE_VERSION, COVER, inside, BC_TOPIC, BC_MAX_FRAME, BC_MAX_PEERS, broadcastFrameBad, emptyBook, admit, depart, awayNumbers, token, hostOf, parseProtocols, accessMatches } from '../src/rules.js'; import { loadConfigFile, checkConfig, ConfigError } from './config.mjs'; @@ -196,8 +196,29 @@ const wss = new WebSocketServer({ handleProtocols: (protocols) => (protocols.has(PROTOCOL) ? PROTOCOL : false) }); +// Decentralized rooms: forward text frames to the rest of the topic, nothing more. +const bcs = new WebSocketServer({ noServer: true, maxPayload: BC_MAX_FRAME }); +const topics = new Map(); +function enterBroadcast(ws, topic, ip) { + if (wss.clients.size + bcs.clients.size > MAX_CONNECTIONS) return ws.close(CLOSE_CAPACITY, 'capacity'); + if (!allowJoin(ip)) return ws.close(CLOSE_BUSY, 'busy'); + const peers = topics.get(topic) ?? new Set(); + if (peers.size >= BC_MAX_PEERS) return ws.close(CLOSE_FULL, 'full'); + topics.set(topic, peers); peers.add(ws); + ws.alive = true; ws.state = { window: 0, count: 0 }; + ws.on('pong', () => { ws.alive = true; }); + ws.on('message', (data, isBinary) => { + ws.alive = true; + const raw = isBinary ? null : data.toString(); + if (broadcastFrameBad(raw, ws.state)) return ws.close(1008, 'bad frame'); + for (const peer of peers) if (peer !== ws && peer.readyState === 1) peer.send(raw); + }); + ws.on('close', () => { peers.delete(ws); if (!peers.size) topics.delete(topic); }); + ws.on('error', () => ws.terminate()); +} + function enter(ws, roomId, ip, resume) { - if (wss.clients.size > MAX_CONNECTIONS) return ws.close(CLOSE_CAPACITY, 'capacity'); + if (wss.clients.size + bcs.clients.size > MAX_CONNECTIONS) return ws.close(CLOSE_CAPACITY, 'capacity'); if (!allowJoin(ip)) return ws.close(CLOSE_BUSY, 'busy'); const room = rooms.get(roomId) ?? { peers: new Set(), book: emptyBook() }; const inUse = new Map([...room.peers].map((p) => [p.state.n, p.state.resume])); @@ -256,6 +277,11 @@ server.on('upgrade', (req, socket, head) => { const headers = Object.entries(COVER.headers).map(([k, v]) => `${k}: ${v}\r\n`).join(''); return socket.end(`HTTP/1.1 404 Not Found\r\n${headers}Content-Length: ${COVER.body.length}\r\nConnection: close\r\n\r\n${COVER.body}`); } + const b = typeof path === 'string' && path.match(/^\/bc\/([0-9a-f]+)$/); + if (b && BC_TOPIC.test(b[1])) { + if (!htmlRoomOrigin(req.headers.origin, originOf(req), config.allowedOrigins)) return reject('403 Forbidden'); + return bcs.handleUpgrade(req, socket, head, (ws) => enterBroadcast(ws, b[1], clientIP(req))); + } if (!m || !ROOM_ID.test(m[2])) return reject('404 Not Found'); // Stops other web pages from connecting through a visitor's browser. // Programs outside a browser can send any Origin they like. @@ -273,7 +299,7 @@ server.on('upgrade', (req, socket, head) => { // Drop connections that stopped answering (phones that lost the network). setInterval(() => { - for (const ws of wss.clients) { + for (const ws of [...wss.clients, ...bcs.clients]) { if (!ws.alive) { ws.terminate(); continue; } ws.alive = false; ws.ping(); @@ -293,9 +319,9 @@ for (const sig of ['SIGTERM', 'SIGINT']) process.on(sig, () => { if (stopping) process.exit(0); stopping = true; server.close(); - for (const ws of wss.clients) ws.close(1012, 'restart'); + for (const ws of [...wss.clients, ...bcs.clients]) ws.close(1012, 'restart'); const deadline = Date.now() + 3000; const check = setInterval(() => { - if (!wss.clients.size || Date.now() > deadline) { clearInterval(check); process.exit(0); } + if (!(wss.clients.size + bcs.clients.size) || Date.now() > deadline) { clearInterval(check); process.exit(0); } }, 100); }); diff --git a/src/rules.js b/src/rules.js index b308b1b..0f98738 100644 --- a/src/rules.js +++ b/src/rules.js @@ -242,3 +242,18 @@ export function inside(pathname, entry) { // What an address outside the entry gets: the reply of a bare Go web server, // with none of this site's headers. export const COVER = { status: 404, headers: { 'Content-Type': 'text/plain; charset=utf-8', 'X-Content-Type-Options': 'nosniff' }, body: '404 page not found\n' }; + +// Plain broadcast for decentralized (version 3) rooms: /bc/. Frames are +// sealed with a key the relay never sees and members number themselves, so +// the relay only forwards bounded text frames to the other connections on the +// same topic. Any server doing just that is compatible (see relay/go). +export const BC_TOPIC = /^[0-9a-f]{32}$/; +export const BC_MAX_FRAME = 16000; +export const BC_MAX_PEERS = 50; +export const BC_RATE_PER_10S = 60; +// state { window, count } is updated in place; true means close the connection. +export function broadcastFrameBad(raw, state, now = Date.now()) { + const window = Math.floor(now / 10000); + if (state.window !== window) { state.window = window; state.count = 0; } + return typeof raw !== 'string' || raw.length > BC_MAX_FRAME || ++state.count > BC_RATE_PER_10S; +} diff --git a/src/worker.js b/src/worker.js index 4f46113..dcca895 100644 --- a/src/worker.js +++ b/src/worker.js @@ -2,7 +2,8 @@ import { htmlRoomOrigin, checkHtmlRoomFrame } from './html-room.js'; import { DurableObject } from 'cloudflare:workers'; import { MAX_MEMBERS, ROOM_ID, PING, PONG, CLOSE_BUSY, CLOSE_LEFT, PLAIN_HEADERS, BYE_HEADERS, PROTOCOL, WIRE_VERSION, - emptyBook, admit, depart, awayNumbers, token, hostOf, parseProtocols, accessMatches + emptyBook, admit, depart, awayNumbers, token, hostOf, parseProtocols, accessMatches, + BC_TOPIC, BC_MAX_PEERS, CLOSE_FULL, broadcastFrameBad } from './rules.js'; const plain = (status, body = null, headers = PLAIN_HEADERS) => new Response(body, { status, headers }); // The page asks for the pt2 subprotocol; a 101 answer must name it or the @@ -27,6 +28,14 @@ export default { async fetch(request, env) { const url = new URL(request.url); if (url.pathname === '/bye') return bye(request, url); + const b = url.pathname.match(/^\/bc\/([0-9a-f]+)$/); + if (b) { + if (!BC_TOPIC.test(b[1])) return plain(400, 'bad topic'); + if (request.headers.get('Upgrade') !== 'websocket') return plain(426, 'websocket only'); + if (!htmlRoomOrigin(request.headers.get('Origin'), url.origin, (env.HR_ALLOWED_ORIGINS || '').split(',').map(s => s.trim()).filter(Boolean))) return plain(403, 'forbidden'); + if (env.JOIN_LIMIT && !(await env.JOIN_LIMIT.limit({ key: request.headers.get('CF-Connecting-IP') || 'unknown' })).success) return plain(429, 'busy'); + return env.ROOMS.get(env.ROOMS.idFromName(`bc:${b[1]}`)).fetch(request); + } const m = url.pathname.match(/^\/(hr)\/([0-9a-f]+)$/); if (!m) { if(url.pathname==='/'){ @@ -82,6 +91,7 @@ export class Room extends DurableObject { } async fetch(request) { + if (new URL(request.url).pathname.startsWith('/bc/')) return this.joinBroadcast(); const { resume } = parseProtocols(request.headers.get('Sec-WebSocket-Protocol')); const peers = this.peers(); const inUse = new Map(peers.map((ws) => { const a = ws.deserializeAttachment(); return [a.n, a.resume]; })); @@ -111,8 +121,24 @@ export class Room extends DurableObject { return hostOf(peers.map((ws) => { const a = ws.deserializeAttachment(); return [a.n, a.seq]; })); } + // Decentralized rooms (/bc/): forward text frames, nothing more. + joinBroadcast() { + const [client, server] = Object.values(new WebSocketPair()); + if (this.ctx.getWebSockets().length >= BC_MAX_PEERS) { server.accept(); server.close(CLOSE_FULL, 'full'); return new Response(null, { status: 101, webSocket: client, headers: PLAIN_HEADERS }); } + this.ctx.acceptWebSocket(server); + server.serializeAttachment({ bc: true, window: 0, count: 0 }); + return new Response(null, { status: 101, webSocket: client, headers: PLAIN_HEADERS }); + } + async webSocketMessage(ws, raw) { const a = ws.deserializeAttachment(); + if (a?.bc) { + const bad = broadcastFrameBad(raw, a); + ws.serializeAttachment(a); + if (bad) return ws.close(1008, 'bad frame'); + for (const peer of this.ctx.getWebSockets()) if (peer !== ws) { try { peer.send(raw); } catch {} } + return; + } if (!a || a.done) return; const r = checkHtmlRoomFrame(raw, a); ws.serializeAttachment(a); @@ -130,6 +156,7 @@ export class Room extends DurableObject { gone(ws, code) { const a = ws.deserializeAttachment(); + if (a?.bc) { try { ws.close(); } catch {} return; } if (a && !a.done) { a.done = true; ws.serializeAttachment(a); diff --git a/test/fixtures/hr-relay-suite.mjs b/test/fixtures/hr-relay-suite.mjs index 4d29788..3994834 100644 --- a/test/fixtures/hr-relay-suite.mjs +++ b/test/fixtures/hr-relay-suite.mjs @@ -121,7 +121,7 @@ test('/hr/ drops table, ref and file frames without closing, and passes key, see assert.ok(b.frames.some((f) => f.type === 'seen')); assert.ok(!b.frames.some((f) => f.type === 'file')); assert.ok(!b.frames.some((f) => f.type === 'ref' || f.type === 'cards')); - // The sender gets its own message back: the page's "已提交中继". + // The sender gets its own message back: the page's "已发出". assert.ok(await a.frame('msg', (f) => f.from === 1)); assert.equal(a.closed, null); a.ws.close(4000); b.ws.close(4000); @@ -145,4 +145,25 @@ test('/hr/ reconnect with the resume token replaces the old connection', async ( again.ws.close(4000); }); +// Decentralized rooms: /bc/ forwards text to the rest of the topic. +test('/bc/ forwards text frames to the other members of the topic only', async () => { + const topic = hex(random(16)), open = (t = topic, origin = 'null') => new Promise((done, fail) => { + const ws = new WebSocket(`${BASE}/bc/${t}`, { origin }); + ws.on('open', () => done(ws)); ws.on('unexpected-response', (_, res) => { res.resume(); fail(Object.assign(new Error('http'), { status: res.statusCode })); }); ws.on('error', () => {}); + }); + const [a, b, c] = [await open(), await open(), await open()], d = await open(hex(random(16))); + const got = [], wrong = []; + b.on('message', (m) => got.push(String(m))); c.on('message', (m) => got.push(String(m))); + a.on('message', (m) => wrong.push(String(m))); d.on('message', (m) => wrong.push(String(m))); + a.send('sealed-frame'); + for (let i = 0; i < 40 && got.length < 2; i++) await new Promise((r) => setTimeout(r, 50)); + assert.deepEqual(got, ['sealed-frame', 'sealed-frame']); + await new Promise((r) => setTimeout(r, 150)); + assert.deepEqual(wrong, []); + const closed = new Promise((r) => b.on('close', r)); b.send('x'.repeat(16001)); + await closed; + await assert.rejects(open(topic, 'https://evil.example'), (e) => e.status === 403); + for (const ws of [a, c, d]) ws.close(); +}); + } diff --git a/test/fixtures/mesh-relays.mjs b/test/fixtures/mesh-relays.mjs new file mode 100644 index 0000000..bcffb6b --- /dev/null +++ b/test/fixtures/mesh-relays.mjs @@ -0,0 +1,76 @@ +// Local stand-ins for the relays a decentralized room can use. +import { spawn, execFileSync } from 'node:child_process'; +import { createServer } from 'node:http'; +import { existsSync, mkdtempSync } from 'node:fs'; +import { tmpdir } from 'node:os'; +import { WebSocketServer, createWebSocketStream } from 'ws'; +import { schnorr } from '@noble/curves/secp256k1'; +import { sha256 } from '@noble/hashes/sha256'; +import { Aedes } from 'aedes'; + +const root = new URL('../../', import.meta.url).pathname; +const hex = u8 => Buffer.from(u8).toString('hex'); +async function healthy(url) { + for (let i = 0; i < 80; i++) { try { if ((await fetch(url)).ok) return; } catch {} await new Promise(r => setTimeout(r, 100)); } + throw Error('fixture did not start: ' + url); +} +const listen = server => new Promise(r => server.listen(0, '127.0.0.1', () => r(server.address().port))); + +// This project's Node server (classic /hr/ and broadcast /bc/). +export async function nodeRelay(env = {}) { + const port = 20000 + Math.floor(Math.random() * 20000); + const child = spawn(process.execPath, [root + 'server/server.mjs'], { env: { ...process.env, PORT: String(port), HOST: '127.0.0.1', ...env }, stdio: 'ignore' }); + await healthy(`http://127.0.0.1:${port}/healthz`); + return { port, url: `ws://127.0.0.1:${port}`, stop: () => new Promise(r => { if (child.exitCode !== null || child.signalCode) return r(); child.once('exit', r); child.kill('SIGTERM'); }) }; +} + +let goBinary; +export function goAvailable() { try { execFileSync('go', ['version'], { stdio: 'ignore' }); return true; } catch { return false; } } +export async function goRelay(args = []) { + if (!goBinary) { + goBinary = mkdtempSync(tmpdir() + '/hr-go-') + '/htmlroom-relay' + (process.platform === 'win32' ? '.exe' : ''); + execFileSync('go', ['build', '-o', goBinary, '.'], { cwd: root + 'relay/go', stdio: 'ignore', env: { ...process.env, CGO_ENABLED: '0' } }); + } + const port = 20000 + Math.floor(Math.random() * 20000); + const child = spawn(goBinary, ['-listen', `127.0.0.1:${port}`, ...args], { stdio: ['ignore', 'pipe', 'pipe'] }); + let output = ''; child.stdout.on('data', d => { output += d; }); child.stderr.on('data', d => { output += d; }); + await healthy(`http://127.0.0.1:${port}/healthz`); + return { port, url: `ws://127.0.0.1:${port}`, binary: goBinary, output: () => output, stop: () => new Promise(r => { if (child.exitCode !== null || child.signalCode) return r(); child.once('exit', r); child.kill('SIGTERM'); }) }; +} + +// A minimal NIP-01 relay that, like public relays, refuses events whose id or +// signature is wrong and forwards ephemeral events without storing them. +export async function nostrRelay() { + const server = createServer(), wss = new WebSocketServer({ server }); + const subs = new Map(); const stats = { accepted: 0, rejected: 0 }; + wss.on('connection', ws => { + subs.set(ws, new Map()); + ws.on('message', raw => { + let m; try { m = JSON.parse(raw); } catch { return; } + if (m[0] === 'REQ') subs.get(ws).set(m[1], m[2]); + if (m[0] === 'CLOSE') subs.get(ws).delete(m[1]); + if (m[0] === 'EVENT') { + const ev = m[1]; + const id = hex(sha256(new TextEncoder().encode(JSON.stringify([0, ev.pubkey, ev.created_at, ev.kind, ev.tags, ev.content])))); + let ok = id === ev.id; try { ok = ok && schnorr.verify(ev.sig, ev.id, ev.pubkey); } catch { ok = false; } + ws.send(JSON.stringify(['OK', ev.id, ok, ok ? '' : 'invalid: bad signature'])); + if (!ok) { stats.rejected++; return; } + stats.accepted++; + for (const [peer, filters] of subs) for (const [sub, f] of filters) + if (f.kinds?.includes(ev.kind) && (!f['#t'] || ev.tags.some(t => t[0] === 't' && f['#t'].includes(t[1])))) peer.send(JSON.stringify(['EVENT', sub, ev])); + } + }); + ws.on('close', () => subs.delete(ws)); + }); + const port = await listen(server); + return { port, url: `ws://127.0.0.1:${port}`, stats, stop: () => new Promise(r => { for (const c of wss.clients) c.terminate(); if (!server.listening) return r(); server.close(() => r()); }) }; +} + +// A real MQTT broker (aedes) behind WebSocket, path /mqtt. +export async function mqttBroker() { + const broker = await Aedes.createBroker(); + const server = createServer(), wss = new WebSocketServer({ server, path: '/mqtt', handleProtocols: p => p.has('mqtt') ? 'mqtt' : false }); + wss.on('connection', ws => broker.handle(createWebSocketStream(ws))); + const port = await listen(server); + return { port, url: `ws://127.0.0.1:${port}/mqtt`, stop: () => new Promise(r => { for (const c of wss.clients) c.terminate(); if (!server.listening) return r(); server.close(() => broker.close(() => r())); }) }; +} diff --git a/test/html-room.test.mjs b/test/html-room.test.mjs index 40bc119..7b35e6e 100644 --- a/test/html-room.test.mjs +++ b/test/html-room.test.mjs @@ -58,7 +58,7 @@ test('HTML Room v3 signed chat and receipt, replay and forged receipt rejection' a.queue('secret chat');await a.serial;const msg=frames.find(([,m])=>m.type==='msg')[1];await drain(); assert.equal(events[1].filter(([k])=>k==='message').length,1); await b.incoming(msg);assert.equal(events[1].filter(([k])=>k==='message').length,1); - await b.flush();await drain();assert.ok(events[0].some(([k,v])=>k==='delivery'&&v.text==='客户端确认 1/1')); + await b.flush();await drain();assert.ok(events[0].some(([k,v])=>k==='delivery'&&v.text==='已送达 1/1')); const h=[...a.own.keys()][0];const own=a.own.get(h);own.confirmed.clear(); const fake={type:'seen',from:2,h:'0.999',iv:msg.iv,ct:msg.ct};await assert.rejects(a.incoming(fake));assert.equal(own.confirmed.size,0); }); diff --git a/test/mesh-relays.test.mjs b/test/mesh-relays.test.mjs new file mode 100644 index 0000000..b07ef7f --- /dev/null +++ b/test/mesh-relays.test.mjs @@ -0,0 +1,99 @@ +// Decentralized rooms over real relays: this project's Node /bc/, the Go +// broadcast relay, a signature-checking Nostr relay and a real MQTT broker. +import test from 'node:test'; +import assert from 'node:assert/strict'; +import WebSocket from 'ws'; +import { derive, random } from '../html-room/crypto.js'; +import { MeshConnection } from '../html-room/mesh.js'; +import { nodeRelay, goRelay, goAvailable, nostrRelay, mqttBroker } from './fixtures/mesh-relays.mjs'; + +const wait = ms => new Promise(r => setTimeout(r, ms)); +// Browsers opening a file send Origin: null. +const createSocket = (url, protocols) => new WebSocket(url, protocols, { origin: 'null' }); +const open = (url, opts = {}) => new Promise((resolve, reject) => { const ws = new WebSocket(url, opts); ws.once('open', () => resolve(ws)); ws.once('error', reject); ws.once('unexpected-response', (_, res) => reject(Object.assign(Error('http ' + res.statusCode), { status: res.statusCode }))); }); +const next = ws => new Promise(r => ws.once('message', (d, bin) => r(bin ? d : d.toString()))); +const closed = ws => new Promise(r => ws.once('close', code => r(code))); +const TOPIC = 'ab'.repeat(16); + +async function broadcastContract(base, label) { + const [a, b, c] = await Promise.all([1, 2, 3].map(() => open(`${base}/bc/${TOPIC}`, { origin: 'null' }))); + const other = await open(`${base}/bc/${'cd'.repeat(16)}`, { origin: 'null' }); + let leaked = false; other.on('message', () => { leaked = true; }); a.on('message', () => assert.fail(label + ': echoed to sender')); + const got = Promise.all([next(b), next(c)]); + a.send('hello-frame'); + assert.deepEqual(await got, ['hello-frame', 'hello-frame'], label); + await wait(100); assert.equal(leaked, false, label + ': topics are separate'); + a.removeAllListeners('message'); + const big = closed(a); a.send('x'.repeat(16001)); assert.ok([1008, 1009].includes(await big), label + ': oversize closes'); + const bin = closed(b); b.send(Buffer.from([1, 2, 3])); assert.ok([1003, 1008].includes(await bin), label + ': binary closes'); + c.close(); other.close(); +} + +test('Node relay /bc/ forwards text to the rest of the topic only, with limits and origin policy', async (t) => { + const relay = await nodeRelay(); t.after(relay.stop); + await broadcastContract(relay.url, 'node'); + await assert.rejects(open(`${relay.url}/bc/${TOPIC}`, { origin: 'https://evil.example' }), e => e.status === 403); + await assert.rejects(open(`${relay.url}/bc/short`, { origin: 'null' }), e => e.status === 404); +}); + +test('Go relay forwards the same way, with optional origin allowlist', { skip: !goAvailable() && 'go not installed' }, async (t) => { + const relay = await goRelay(); t.after(relay.stop); + await broadcastContract(relay.url, 'go'); + const health = await (await fetch(`http://127.0.0.1:${relay.port}/healthz`)).json(); + assert.equal(health.kind, 'bc'); + const strict = await goRelay(['-allow-origin', 'https://ok.example']); t.after(strict.stop); + await assert.rejects(open(`${strict.url}/bc/${TOPIC}`, { origin: 'https://evil.example' }), e => e.status === 403); + (await open(`${strict.url}/bc/${TOPIC}`, { origin: 'https://ok.example' })).close(); + (await open(`${strict.url}/bc/${TOPIC}`, { origin: 'null' })).close(); +}); + +test('Go relay -tunnel prints the relay line from cloudflared output', { skip: !goAvailable() && 'go not installed' }, async (t) => { + const { writeFileSync, chmodSync, mkdtempSync } = await import('node:fs'); + const dir = mkdtempSync('/tmp/hr-cf-'), fake = dir + '/cloudflared'; + writeFileSync(fake, '#!/bin/sh\necho "INF | https://quiet-river-1234.trycloudflare.com |" >&2\nsleep 30\n'); chmodSync(fake, 0o755); + const relay = await goRelay(['-tunnel', '-cloudflared', fake]); t.after(relay.stop); + for (let i = 0; i < 50 && !relay.output().includes('bc+wss://'); i++) await wait(100); + assert.match(relay.output(), /bc\+wss:\/\/quiet-river-1234\.trycloudflare\.com\/bc\//); +}); + +function member(d, relays) { + const got = { members: [], frames: [] }; + const m = new MeshConnection(relays, { derived: d, createSocket, claimDelay: 400, + onhello: () => {}, onmembers: l => got.members.push(l), onframe: (f, from) => got.frames.push([from, f.ct]) }); + m.start(); return { m, got }; +} +const frame = ct => JSON.stringify({ type: 'key', iv: 'a'.repeat(16), ct }); +async function until(fn, ms = 8000) { for (let t = 0; t < ms; t += 50) { if (fn()) return; await wait(50); } assert.fail('timed out'); } + +test('members meet across bc, Nostr and MQTT at once and survive losing relays', { timeout: 60000 }, async (t) => { + const [node, nostr, mqtt] = await Promise.all([nodeRelay(), nostrRelay(), mqttBroker()]); + const go = goAvailable() ? await goRelay() : null; + t.after(async () => { await Promise.all([node.stop(), nostr.stop(), mqtt.stop(), go?.stop()]); }); + const relays = [`bc+${node.url}/bc/`, `nostr+${nostr.url}`, `mqtt+${mqtt.url}`, ...(go ? [`bc+${go.url}/bc/`] : [])]; + const d = await derive(random(32)); + const a = member(d, relays), b = member(d, relays); + t.after(() => { a.m.stop(); b.m.stop(); }); + await until(() => a.m.openCount() === relays.length && b.m.openCount() === relays.length); + await until(() => JSON.stringify(a.got.members.at(-1)) === '[1,2]' && JSON.stringify(b.got.members.at(-1)) === '[1,2]'); + a.m.socket.send(frame('all-relays-' + 'x'.repeat(14))); + await until(() => b.got.frames.some(([, ct]) => ct.startsWith('all-relays'))); + await wait(300); + assert.equal(b.got.frames.filter(([, ct]) => ct.startsWith('all-relays')).length, 1, 'one copy per frame however many relays carried it'); + assert.ok(nostr.stats.accepted > 0 && nostr.stats.rejected === 0, 'every Nostr event had a valid id and signature'); + + // Lose the broadcast relays, then MQTT: members keep hearing each other over Nostr alone. + await node.stop(); await go?.stop(); await mqtt.stop(); + await until(() => a.m.openCount() === 1 && b.m.openCount() === 1); + b.m.socket.send(frame('nostr-only-' + 'y'.repeat(14))); + await until(() => a.got.frames.some(([, ct]) => ct.startsWith('nostr-only'))); +}); + +test('MQTT alone carries a room', { timeout: 30000 }, async (t) => { + const mqtt = await mqttBroker(); t.after(mqtt.stop); + const d = await derive(random(32)); + const a = member(d, [`mqtt+${mqtt.url}`]), b = member(d, [`mqtt+${mqtt.url}`]); + t.after(() => { a.m.stop(); b.m.stop(); }); + await until(() => JSON.stringify(b.got.members.at(-1)) === '[1,2]'); + b.m.socket.send(frame('mqtt-alone-' + 'z'.repeat(14))); + await until(() => a.got.frames.some(([, ct]) => ct.startsWith('mqtt-alone'))); +}); diff --git a/test/mesh.test.mjs b/test/mesh.test.mjs new file mode 100644 index 0000000..496c096 --- /dev/null +++ b/test/mesh.test.mjs @@ -0,0 +1,158 @@ +import test from 'node:test'; +import assert from 'node:assert/strict'; +import { schnorr } from '@noble/curves/secp256k1'; +import { makeConfig, relayEntry, relayList, unlock, validate, derive, random, cspSource } from '../html-room/crypto.js'; +import { withRelays } from '../html-room/template.js'; +import { render } from '../scripts/build-html-room.mjs'; +import { sealFrame, openFrame, nostrEvent, NOSTR_KIND, mqttConnect, mqttPublish, mqttSubscribe, mqttParse, MeshConnection } from '../html-room/mesh.js'; + +test('decentralized relay entries are canonical and never mix with classic relays', () => { + assert.equal(relayEntry('bc+wss://Relay.Example.com'), 'bc+wss://relay.example.com/bc/'); + assert.equal(relayEntry('bc+wss://relay.example.com:443/x/'), 'bc+wss://relay.example.com/x/'); + assert.equal(relayEntry('NOSTR+wss://relay.example.com/'), 'nostr+wss://relay.example.com'); + assert.equal(relayEntry('mqtt+wss://broker.example.com:8084/mqtt'), 'mqtt+wss://broker.example.com:8084/mqtt'); + assert.equal(relayEntry('wss://relay.example.com'), 'wss://relay.example.com'); + for (const bad of ['bc+wss://h/x', 'nostr+ws://remote.example', 'mqtt+wss://h/a?b=1', 'mqtt+wss://u:p@h/mqtt', 'bc+wss://h/a%20b/', 'nostr+wss://h/a;b', 'gopher+wss://h', 'mqtt+wss://h//x']) + assert.throws(() => relayEntry(bad), bad); + assert.throws(() => relayList('wss://a.example nostr+wss://b.example')); + assert.equal(relayList('nostr+wss://a.example mqtt+wss://b.example/mqtt bc+wss://c.example nostr+wss://d.example nostr+wss://e.example').length, 5); + assert.throws(() => relayList('nostr+wss://a.example nostr+wss://b.example nostr+wss://c.example nostr+wss://d.example nostr+wss://e.example nostr+wss://f.example')); + assert.equal(cspSource('bc+wss://c.example/bc/'), 'wss://c.example/bc/'); +}); + +test('version follows relay kind; moving to decentralized relays keeps key, password wrap and roomId', async () => { + const classic = await makeConfig('wss://relay.example', 'pw'); + assert.equal(classic.version, 2); + const mesh = withRelays(classic, 'nostr+wss://n.example mqtt+wss://m.example/mqtt'); + assert.deepEqual(Object.keys(mesh), Object.keys(classic)); + assert.equal(mesh.version, 3); assert.equal(mesh.roomId, classic.roomId); assert.equal(mesh.ciphertext, classic.ciphertext); + assert.equal((await unlock(mesh, 'pw')).roomId, classic.roomId); + assert.equal(withRelays(mesh, 'wss://relay.example').version, 2); + assert.throws(() => validate({ ...mesh, version: 2 })); + const html = (await render(mesh, mesh.relays)).html; + assert.match(html, /connect-src wss:\/\/n\.example wss:\/\/m\.example\/mqtt;/); + assert.doesNotMatch(html.split('