Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 2 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -49,6 +49,8 @@

创建房间时可按优先级填写 **1–3 个中继**(以逗号或空格分隔)。首选不可用时会尝试备用,并每 60 秒探测首选是否恢复。页面显示当前中继和在线人数;连到不同中继的成员暂时无法互通,切换后请重新核对安全码。所有成员须使用同一份房间配置。

**想改用自己搭的中继?** 打开房间文件,展开「更换中继」,填入自己的 `wss://` 地址即可生成同一房间的新文件(密钥、口令不变,无需口令);也可在生成器页面的「已有房间文件?改用其他中继」中转换旧文件。把新文件发给所有成员后,大家就在你的中继上聊天,并重新核对安全码。详见 [更换中继](docs/DEVELOPER.md#更换中继迁移到自建中继)。

多中继配置使用新版房间格式,旧文件/链接需重新生成。网页房间链接只支持站点预先允许的中继,其他组合请使用下载的房间文件。配置静态网页的多个中继见 [开发说明](docs/DEVELOPER.md#多中继房间)。

## 常见问题
Expand Down
92 changes: 92 additions & 0 deletions browser-tests/retarget.mjs
Original file line number Diff line number Diff line change
@@ -0,0 +1,92 @@
// A room made for relay A is re-targeted to relay B (in the file itself, by
// the hosted converter and by the CLI); members then chat through B only.
import { chromium } from 'playwright';
import { spawn, execFileSync } from 'node:child_process';
import { mkdtemp, writeFile, rm, readFile } 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 { render } from '../scripts/build-html-room.mjs';
import { checkHtml } from '../scripts/check-html-room.mjs';

const base = 19530 + Math.floor(Math.random() * 100), ports = [base, base + 200];
const [relayA, relayB] = ports.map(p => `ws://127.0.0.1:${p}`);
const dir = await mkdtemp(tmpdir() + '/hr-retarget-');
const servers = [];
async function start(port) {
const child = spawn(process.execPath, ['server/server.mjs'], { env: { ...process.env, PORT: String(port), HOST: '127.0.0.1' }, stdio: 'ignore' });
servers.push(child);
for (let i = 0; i < 40; i++) {
try { if ((await fetch(`http://127.0.0.1:${port}/healthz`)).ok) return; } catch {}
await new Promise(r => setTimeout(r, 100));
}
throw Error('relay fixture did not start');
}
const configOf = html => JSON.parse(html.match(/type="application\/json">(.*?)<\/script>/s)[1]);
let browser;
try {
await start(ports[0]); await start(ports[1]);
const password = 'correct horse battery staple';
const config = await makeConfig([relayA], password), original = await render(config, [relayA]);
const oldPath = dir + '/old.html'; await writeFile(oldPath, original.html);
browser = await chromium.launch({ headless: true });
const errors = [];
const page = await browser.newPage(); page.on('pageerror', e => errors.push(e.message));

// 1. In-file conversion, no password needed.
await page.goto(pathToFileURL(oldPath).href);
await page.locator('#retarget summary').click();
assert.equal(await page.locator('#rt-relay').inputValue(), relayA);
await page.locator('#rt-relay').fill('http://evil.example/path'); await page.locator('#rt-make').click();
await page.locator('#rt-status').filter({ hasText: '生成失败' }).waitFor();
await page.locator('#rt-relay').fill(relayB); await page.locator('#rt-make').click();
const download = page.waitForEvent('download'); await page.locator('#rt-download').click();
const newHtml = await readFile(await (await download).path(), 'utf8');
const next = configOf(newHtml);
assert.deepEqual(next, { ...config, relays: [relayB] }, 'only relays change; wrapped key, salt, roomId kept');
assert.equal(newHtml, (await render(next, [relayB])).html, 'byte-identical to a fresh build');
await checkHtml(newHtml, original.scriptHash);
assert.match(newHtml, new RegExp(`connect-src ${relayB};`));
const newPath = dir + '/new.html'; await writeFile(newPath, newHtml);

// 2. Two members with the new file chat through relay B only.
const members = await Promise.all([browser.newPage(), browser.newPage()]);
const sockets = [];
for (const m of members) {
m.on('pageerror', e => errors.push(e.message)); m.on('websocket', s => sockets.push(s.url()));
await m.goto(pathToFileURL(newPath).href);
await m.locator('#password').fill(password); await m.locator('#enter').click();
}
for (const m of members) {
await m.locator('#relay-status').filter({ hasText: relayB }).waitFor({ timeout: 15000 });
await m.locator('#presence').filter({ hasText: '在线 2 人' }).waitFor({ timeout: 15000 });
await m.locator('#status').filter({ hasText: '已建立签名混合加密连接' }).waitFor({ timeout: 15000 });
}
await members[0].locator('#text').fill('自建中继你好'); await members[0].locator('#send').click();
await members[1].locator('#messages li').filter({ hasText: '自建中继你好' }).waitFor({ timeout: 10000 });
assert.ok(sockets.length && sockets.every(u => u === `${relayB}/hr/${config.roomId}`), 'never touched relay A');

// 3. Hosted converter on relay B's own page turns the old file into the same result.
const maker = await browser.newPage(); maker.on('pageerror', e => errors.push(e.message));
await maker.goto(`http://127.0.0.1:${ports[1]}/`);
await maker.locator('#cv summary').click();
await maker.locator('#cv-file').setInputFiles(oldPath);
await maker.locator('#cv-relay').fill(`${relayB} ${relayA}`); await maker.locator('#cv-make').click();
const d2 = maker.waitForEvent('download'); await maker.locator('#cv-download').click();
const hosted = await readFile(await (await d2).path(), 'utf8');
assert.deepEqual(configOf(hosted).relays, [relayB, relayA]);
await checkHtml(hosted, original.scriptHash);

// 4. CLI.
const cliOut = dir + '/cli.html';
execFileSync(process.execPath, ['scripts/build-html-room.mjs', 'retarget', '--in', oldPath, '--out', cliOut, '--relay', relayB]);
assert.equal(await readFile(cliOut, 'utf8'), newHtml);

assert.deepEqual(errors, []);
console.log('PASS: in-file, hosted and CLI relay re-targeting; chat on the new relay with the unchanged password');
} finally {
await browser?.close();
for (const s of servers) s.kill('SIGTERM');
await rm(dir, { recursive: true, force: true });
}
3 changes: 2 additions & 1 deletion dist/hr-template.html

Large diffs are not rendered by default.

2 changes: 1 addition & 1 deletion dist/hr.html
Original file line number Diff line number Diff line change
@@ -1 +1 @@
<!doctype html><html lang="zh-CN"><head><meta charset="utf-8"><meta name="viewport" content="width=device-width,initial-scale=1"><meta name="referrer" content="no-referrer"><meta http-equiv="Content-Security-Policy" content="default-src 'none'; script-src 'self'; style-src 'self'; connect-src 'self'; base-uri 'none'; form-action 'none'"><title>HTML Room · 创建或进入房间</title><link rel="stylesheet" href="hr.css"><script defer src="hr.js"></script></head><body><form id="maker"><h1>创建 HTML 房间</h1><p>同一份文件进入同一聊天房间,生成过程在本浏览器内完成。</p><label for="mk-relay">中继源(按优先级填写 1–3 个,以空格或逗号分隔)</label><input id="mk-relay" required maxlength="650"><label for="mk-pass">可选打开口令(建议随机生成,通过独立渠道传递)</label><input id="mk-pass" type="password" maxlength="1024" autocomplete="off"><button id="mk-gen" type="button">随机生成高熵口令</button><button id="mk-make">创建房间</button><p id="mk-error" role="status"></p><section id="mk-out" hidden><a id="mk-download" download="index.html">下载房间文件</a><section id="mk-link-row" hidden><p>网页入口代码可读取 Fragment;其安全性不等同于经过验证的离线文件。手机预览兼容性仍未验证。</p><input id="mk-link" readonly aria-label="房间链接"><button id="mk-copy" type="button">复制网页链接</button></section></section></form><section id="room-panel" hidden><h1>私密聊天房间</h1><aside id="risk">此文件包含完整房间密钥,请勿通过未加密的社交软件直接发送。口令模式也可被离线猜测;建议高熵密码,通过独立渠道传递口令。</aside><p id="status" role="status">聊天脚本尚未启动,请查看下方浏览器打开说明。</p><section id="preview-help"><p>若此提示一直显示,当前文件预览未能运行聊天脚本。iOS「文件」、微信或 QQ 的预览可能无法使用此房间;输入口令不会生效。请在 Safari 等浏览器中打开房间网页链接。</p></section><form id="unlock"><label for="password">房间口令(无口令文件可直接进入)</label><input id="password" disabled type="password" maxlength="1024" autocomplete="off"><button id="enter" disabled>解锁并进入</button></form><p id="relay-status" role="status"></p><p id="presence"></p><p id="safety"></p><ol id="messages" aria-live="polite"></ol><form id="chat" hidden><label for="text">消息(最多 500 字)</label><textarea id="text" maxlength="500"></textarea><button id="send">发送</button></form></section></body></html>
<!doctype html><html lang="zh-CN"><head><meta charset="utf-8"><meta name="viewport" content="width=device-width,initial-scale=1"><meta name="referrer" content="no-referrer"><meta http-equiv="Content-Security-Policy" content="default-src 'none'; script-src 'self'; style-src 'self'; connect-src 'self'; base-uri 'none'; form-action 'none'"><title>HTML Room · 创建或进入房间</title><link rel="stylesheet" href="hr.css"><script defer src="hr.js"></script></head><body><form id="maker"><h1>创建 HTML 房间</h1><p>同一份文件进入同一聊天房间,生成过程在本浏览器内完成。</p><label for="mk-relay">中继源(按优先级填写 1–3 个,以空格或逗号分隔)</label><input id="mk-relay" required maxlength="650"><label for="mk-pass">可选打开口令(建议随机生成,通过独立渠道传递)</label><input id="mk-pass" type="password" maxlength="1024" autocomplete="off"><button id="mk-gen" type="button">随机生成高熵口令</button><button id="mk-make">创建房间</button><p id="mk-error" role="status"></p><section id="mk-out" hidden><a id="mk-download" download="index.html">下载房间文件</a><section id="mk-link-row" hidden><p>网页入口代码可读取 Fragment;其安全性不等同于经过验证的离线文件。手机预览兼容性仍未验证。</p><input id="mk-link" readonly aria-label="房间链接"><button id="mk-copy" type="button">复制网页链接</button></section></section><details id="cv"><summary>已有房间文件?改用其他中继(如自建 wss://)</summary><p>选择现有房间文件并填写新中继,生成同一房间(密钥、口令不变)的新文件。文件只在本浏览器内读取,不会上传。</p><label for="cv-file">房间文件</label><input id="cv-file" type="file" accept=".html,text/html"><label for="cv-relay">新中继源(按优先级 1–3 个)</label><input id="cv-relay" maxlength="650" autocomplete="off" spellcheck="false" placeholder="wss://relay.example.com"><button id="cv-make" type="button">生成新房间文件</button><p id="cv-error" role="status"></p><a id="cv-download" download="index.html" hidden>下载新房间文件</a></details></form><section id="room-panel" hidden><h1>私密聊天房间</h1><aside id="risk">此文件包含完整房间密钥,请勿通过未加密的社交软件直接发送。口令模式也可被离线猜测;建议高熵密码,通过独立渠道传递口令。</aside><p id="status" role="status">聊天脚本尚未启动,请查看下方浏览器打开说明。</p><section id="preview-help"><p>若此提示一直显示,当前文件预览未能运行聊天脚本。iOS「文件」、微信或 QQ 的预览可能无法使用此房间;输入口令不会生效。请在 Safari 等浏览器中打开房间网页链接。</p></section><form id="unlock"><label for="password">房间口令(无口令文件可直接进入)</label><input id="password" disabled type="password" maxlength="1024" autocomplete="off"><button id="enter" disabled>解锁并进入</button></form><details id="retarget" hidden><summary>更换中继(例如改用自建 wss:// 中继)</summary><p>生成一份只改中继地址的新房间文件:房间密钥、口令和房间号都不变,生成时无需口令。所有成员须改用同一份新文件,连到不同中继的成员互相看不到消息;切换后请重新核对安全码。新中继的运营者能看到房间号和入房凭证,但无法解密消息。</p><label for="rt-relay">新中继源(按优先级 1–3 个,空格或逗号分隔;保留旧中继即作备用)</label><input id="rt-relay" maxlength="650" autocomplete="off" spellcheck="false"><button id="rt-make" type="button">生成新房间文件</button><p id="rt-status" role="status"></p><a id="rt-download" download="index.html" hidden>下载新房间文件</a></details><p id="relay-status" role="status"></p><p id="presence"></p><p id="safety"></p><ol id="messages" aria-live="polite"></ol><form id="chat" hidden><label for="text">消息(最多 500 字)</label><textarea id="text" maxlength="500"></textarea><button id="send">发送</button></form></section></body></html>
2 changes: 1 addition & 1 deletion dist/hr.js

Large diffs are not rendered by default.

18 changes: 18 additions & 0 deletions docs/DEVELOPER.md
Original file line number Diff line number Diff line change
Expand Up @@ -220,6 +220,24 @@ HR_PAGES_RELAYS='wss://primary.example.com,wss://backup.example.com' npm run bui

Vercel 配置同名构建变量并重新部署。旧 `HR_PAGES_RELAY` 仍接受单项,`HR_PAGES_RELAYS` 优先。网页只接纳 `relays` 全部属于构建白名单的链接,每个中继都须放行该前端的精确 HTTPS Origin。文件静态恢复链接指向首选中继的 `/hr.html`,其网页也须支持该完整列表,否则应在支持脚本的浏览器中直接打开文件。不要把房间主密钥放到查询参数中。

## 更换中继(迁移到自建中继)

`relays` 只是公开路由配置:roomId 由主密钥派生,口令包装的 AAD 只绑定 roomId,二者都不含中继地址。因此同一房间可以搬到任何兼容中继(本项目的 Node/Docker 或 Cloudflare 中继),新中继无需任何房间预配置——文件以 `Origin: null` 连接,中继默认放行。

三种方式,结果逐字节相同,都能通过 `verify-room-file.mjs`:

- **房间文件内**:打开 `index.html`,展开「更换中继」,填写新中继(如 `wss://relay.example.com`,可在后面保留旧中继作备用),点击「生成新房间文件」并下载。无需口令。
- **网页生成器**:在「已有房间文件?改用其他中继」中选择旧文件并填写新中继。文件只在浏览器内读取;会用本站模板重建,所以没有该按钮的旧文件也能转换(旧 `version: 1` 文件除外)。
- **命令行**:

```sh
node scripts/build-html-room.mjs retarget \
--in old/index.html --out new/index.html \
--relay wss://relay.example.com [--relay wss://backup.example.com]
```

新文件的 CSP `connect-src` 仍只列出精确的新中继源,不使用通配符。注意:所有成员必须改用同一份新文件,连到不同中继的成员互相看不到消息;新中继运营者可看到 roomId 和入房凭证(可借此占用旧中继名额),但拿不到主密钥、无法解密;切换后重新核对安全码。浏览器要求 WSS 证书有效,用裸 IP 时需要为该 IP 签发的证书,使用域名配合一键安装脚本最简单。

## 命令行生成与核验房间文件

```sh
Expand Down
4 changes: 3 additions & 1 deletion docs/html-room/protocol.md
Original file line number Diff line number Diff line change
Expand Up @@ -14,7 +14,9 @@ Canonical 配置为 `{version:2,relays:[...],roomId,encrypted,...}`。relays 有

本版本选择跨中继复用同一 roomId、access、公告密钥和 bind(上文 HKDF v1 域字符串不变),以保持冻结派生向量和现有中继鉴权。roomId 仍为 SHA-256(access),master 不发送给中继;仅 access/resume 通过 WebSocket 子协议用于鉴权,不放在查询参数或日志里。这个选择让多个中继可以关联同一房间;未采用按中继域分离,不主张跨中继不可关联。

口令包装格式、310000 轮 PBKDF2 和 wrap AAD 不变。relays 是公开路由配置,不作为身份保证;不能通过修改此列表获得主密钥,仍需通过可信渠道核验整个房间文件。
口令包装格式、310000 轮 PBKDF2 和 wrap AAD 不变。

更换中继:relays 不参与 roomId 派生和 wrap AAD,因此只替换 relays 即得到同一房间的新配置(master 或 salt/iv/ciphertext 原样保留,无需口令)。房间文件读取自身 `currentScript` 与 `<style>` 的精确字节,用与构建脚本共用的 `html-room/template.js` 重新生成整份 HTML,CSP connect-src 仍为新列表的精确源;结果与 `render(newConfig)` 逐字节一致。网页生成器与 CLI `retarget` 读取旧文件配置后用当前模板重建。relays 是公开路由配置,不作为身份保证;不能通过修改此列表获得主密钥,仍需通过可信渠道核验整个房间文件。

## 连接状态与切换

Expand Down
12 changes: 6 additions & 6 deletions docs/html-room/release-integrity.json
Original file line number Diff line number Diff line change
@@ -1,15 +1,15 @@
{
"version": 2,
"stage": "multi-relay-htmlroom",
"stage": "relay-retarget-htmlroom",
"esbuild": "0.28.2",
"artifacts": {
"html-room-template.html": {
"bytes": 48310,
"sha256": "d9b9a7555ad78ab1c33228af2e9e572a57a8b340d9a2fc1348e1cd53a878a558",
"csp": "default-src 'none'; script-src 'sha256-tTqNSL291JCaHVl82ldEjeyFgHXa1c+NAHOq6aPMrXM='; style-src 'sha256-tjnnYoX05TfNl+NsaNK4PRoAh8mTsDFSpuWgGDuoa88='; connect-src 'none'; base-uri 'none'; form-action 'none'"
"bytes": 55876,
"sha256": "03e3a00e545d8612a2e04e0421cff7ccbca43790d40891fd9be533bd836a90e2",
"csp": "default-src 'none'; script-src 'sha256-+lC7bl6rUf3/5EFtJs5OlWpPRK1SZf8jxIWqtkdKPvY='; style-src 'sha256-tjnnYoX05TfNl+NsaNK4PRoAh8mTsDFSpuWgGDuoa88='; connect-src 'none'; base-uri 'none'; form-action 'none'"
}
},
"scriptHashBase64": "tTqNSL291JCaHVl82ldEjeyFgHXa1c+NAHOq6aPMrXM=",
"scriptHashHex": "b53a8d48bdbdd4909a1d597cda57448dec858075dad5cf8d0073aae9a3ccad73",
"scriptHashBase64": "+lC7bl6rUf3/5EFtJs5OlWpPRK1SZf8jxIWqtkdKPvY=",
"scriptHashHex": "fa50bb6e5eab51fdffe4416d26ce4e956a4f44ad5265ff23c485aab6474a3ef6",
"styleHashBase64": "tjnnYoX05TfNl+NsaNK4PRoAh8mTsDFSpuWgGDuoa88="
}
27 changes: 26 additions & 1 deletion html-room/file.js
Original file line number Diff line number Diff line change
@@ -1,3 +1,28 @@
import { boot } from './client.js';
try { boot(JSON.parse(document.getElementById('pt-room-config').textContent)); }
import { retargetHtml } from './template.js';
// Read before anything else: the exact executable and style bytes this file
// runs with (CSP already pinned them by hash), reused to re-emit the file.
const ownScript=document.currentScript?.textContent, ownStyle=document.querySelector('style')?.textContent;
let config;
try { config=JSON.parse(document.getElementById('pt-room-config').textContent); boot(config); }
catch { document.getElementById('status').textContent='配置无效,请重新获取房间文件。';document.getElementById('enter').disabled=true; }

function enableRetarget(config){
const el=id=>document.getElementById(id), say=t=>{el('rt-status').textContent=t;};
let url;
el('retarget').hidden=false;el('rt-relay').value=config.relays.join(' ');
el('rt-make').addEventListener('click',async()=>{
el('rt-download').hidden=true;el('rt-make').disabled=true;say('');
try{
const r=await retargetHtml(config,el('rt-relay').value,ownScript,ownStyle);
if(JSON.stringify(r.config.relays)===JSON.stringify(config.relays)){say('中继与当前文件相同,无需生成。');return;}
if(url)URL.revokeObjectURL(url);
url=URL.createObjectURL(new Blob([r.html],{type:'text/html'}));
el('rt-download').href=url;el('rt-download').hidden=false;
say(`已生成:新文件按顺序连接 ${r.config.relays.join('、')}。房间密钥和口令不变;请把新文件发给所有成员,用新文件重新打开并核对安全码。`);
}catch(e){say(/[一-鿿]/.test(e?.message??'')?`生成失败:${e.message}。`:'生成失败:请填写 1–3 个 wss:// 中继源(只含域名或 IP,可带端口,不含路径)。');}
finally{el('rt-make').disabled=false;}
});
addEventListener('pagehide',()=>{if(url)URL.revokeObjectURL(url);});
}
try{ if(config?.version && ownScript && ownStyle) enableRetarget(config); }catch{}
Loading
Loading