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
1 change: 1 addition & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -53,6 +53,7 @@ flowchart TD
**第 1 步:创建房间**
打开 [房间生成器](https://freecode100year.github.io/htmlroom/),直接点「创建房间」就行,其他设置都不用改。
如果想多一层保险,可以点「随机生成口令」设个密码(相当于门上再加一把锁)。
还可以给房间起个名字(「房间标题」,最多 60 字),它会显示在浏览器标签页和页面顶部;不填就是「聚众加密聊天房间」。持有房间文件的人以后也能在「生成新文件」里改名,房间和口令不变。

**第 2 步:发给朋友**
点「创建房间」后,你可以:
Expand Down
23 changes: 19 additions & 4 deletions browser-tests/generations.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -23,8 +23,9 @@ try {
const offline = await browser.newContext({ acceptDownloads: true }); await offline.setOffline(true);
const errors = [], requests = [];
const watch = p => { p.on('pageerror', e => errors.push(e.message)); p.on('request', r => { if (!/^(file|blob|data):/.test(r.url())) requests.push(r.url()); }); return p; };
const create = async (page, { relays, password, expand }) => {
const create = async (page, { relays, password, expand, title }) => {
if (expand) await page.locator('#new-room > summary').click();
if (title) await page.locator('#mk-title').fill(title);
if (relays) { if (!(await page.locator('#mk-relay').isVisible())) await page.locator('#mk-advanced > summary').click(); await page.locator('#mk-relay').fill(relays); }
if (password) await page.locator('#mk-pass').fill(password);
await page.locator('#mk-make').click();
Expand All @@ -42,18 +43,32 @@ try {
assert.equal(await g0.locator('#maker').isVisible(), true);
assert.equal(await g0.locator('#unlock').isVisible(), false);
assert.equal(await g0.locator('#mk-relay').inputValue(), '');
const a = await create(g0, { relays: relay.url, password: 'first pass' });
assert.equal(a.config.custom, true);
assert.equal(await g0.title(), '聚众加密聊天房间');
const a = await create(g0, { relays: relay.url, password: 'first pass', title: '周末读书会 <b>' });
assert.equal(a.config.custom, true); assert.equal(a.config.title, '周末读书会 <b>');

// Generation 1: room A creates room B with a different password.
const g1 = watch(await offline.newPage()); await g1.goto(pathToFileURL(a.path).href);
assert.equal(await g1.title(), '周末读书会 <b>', 'the title shows as text, not markup');
assert.equal(await g1.locator('h1').first().textContent(), '周末读书会 <b>');
// Any holder can rename the room by regenerating the file (same key, same password).
await g1.locator('#retarget > summary').click();
assert.equal(await g1.locator('#rt-title').inputValue(), '周末读书会 <b>');
await g1.locator('#rt-title').fill('读书会第二季'); await g1.locator('#rt-make').click();
const rdl = g1.waitForEvent('download'); await g1.locator('#rt-download').click();
const renamedPath = dir + '/renamed.html'; await (await rdl).saveAs(renamedPath);
const renamed = await readFile(renamedPath, 'utf8'); await checkHtml(renamed, blank.scriptHash);
const rc = configFromRoomHtml(renamed);
assert.equal(rc.title, '读书会第二季'); assert.equal(rc.roomId, a.config.roomId); assert.equal(rc.ciphertext, a.config.ciphertext);
await g1.locator('#retarget > summary').click();
assert.equal(await g1.locator('#unlock').isVisible(), true);
assert.equal(await g1.locator('#maker').isVisible(), false, 'folded in a room file');
await g1.locator('#new-room > summary').click();
await g1.locator('#mk-advanced > summary').click();
assert.equal(await g1.locator('#mk-relay').inputValue(), relay.url, 'starts from this room\'s relays');
const b = await create(g1, { password: 'second pass' });
assert.notEqual(b.config.roomId, a.config.roomId, 'a new room, not a copy');
assert.equal(b.config.title, undefined, 'a new room starts with the default title unless one is given');
assert.equal((await unlock(b.config, 'second pass')).roomId, b.config.roomId);
await assert.rejects(unlock(b.config, 'first pass'));

Expand Down Expand Up @@ -83,7 +98,7 @@ try {
const again = watch(await offline.newPage()); await again.goto(pathToFileURL(selfPath).href);
assert.equal(await again.locator('#mk-relay').inputValue(), relay.url);
assert.deepEqual(errors, []);
console.log('PASS: blank creator -> room A -> room B (new key, new password) -> room C, all offline and byte-identical to release builds; chat in B; site saves itself as an offline creator');
console.log('PASS: titled room A, renamed by a holder; blank creator -> room A -> room B (new key, new password) -> room C, all offline and byte-identical to release builds; chat in B; site saves itself as an offline creator');
} finally {
await browser?.close(); await relay.stop(); await rm(dir, { recursive: true, force: true });
}
2 changes: 1 addition & 1 deletion dist/_headers
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
/*
Content-Security-Policy: default-src 'none'; script-src 'sha256-t+UQwWWn39gEDqed1s6sSnhEiZjNGlRobivE0cpid5M='; style-src 'sha256-+cH4mX6DQpXWnU8EWkUQE30KyFp1e6rCFFdGd+i+pzA='; connect-src 'self' wss: ws://localhost:* ws://127.0.0.1:*; img-src 'none'; base-uri 'none'; form-action 'none'; frame-ancestors 'none'
Content-Security-Policy: default-src 'none'; script-src 'sha256-2JtQBGQ1g3RysBYMwJmcKy+WFVqLIDDZxJLfYInhifQ='; style-src 'sha256-+cH4mX6DQpXWnU8EWkUQE30KyFp1e6rCFFdGd+i+pzA='; connect-src 'self' wss: ws://localhost:* ws://127.0.0.1:*; img-src 'none'; base-uri 'none'; form-action 'none'; frame-ancestors 'none'
Cache-Control: no-store
NEL: {"max_age":0}
Referrer-Policy: no-referrer
Expand Down
6 changes: 3 additions & 3 deletions dist/hr-template.html

Large diffs are not rendered by default.

6 changes: 3 additions & 3 deletions dist/hr.html

Large diffs are not rendered by default.

4 changes: 4 additions & 0 deletions docs/html-room/protocol.md
Original file line number Diff line number Diff line change
Expand Up @@ -69,3 +69,7 @@ V2/V3 配置可选字段 `custom`,只能为 `true`(缺省表示不允许)
另外修正了 `lib/group.js`:第一次得知某成员的 ML-KEM 公钥时不再清空已建立的配对密钥(只在公钥变化时重置)。定向投递下,号码大的一方可能先拿到配对密钥、后看到对方公钥,旧逻辑会让这一对永远等待。

中继限流相应调整:每连接 10 秒 100 帧(原 60)、密钥帧 60(原 40)、每地址每分钟入房 120(原 60)、`/bc/` 每连接 10 秒 100 帧。实测(单进程模拟全部客户端):经典中继 50 人同时进房约 7 秒全部就绪,平均每人约 265 KB;去中心化(`/bc/` 只能广播)20 人约 12 秒、每人约 450 KB,30 人约 2 MB,50 人可完成但代价按 n³ 增长,建议去中心化房间控制在 30 人左右。

## 房间标题(title)

配置可选字段 `title`:去掉首尾空白、连续空白合并为一个空格后的 1–60 个字符,不含控制字符和双向文本控制符;等于默认标题「聚众加密聊天房间」时省略。它写入 `<title>` 和页面首个 `<h1>`(按 HTML 文本转义 `& < > " '`),不参与任何密钥派生或 AAD。新建房间时可选填;「生成新文件」、转换器和 CLI(`room --title`、`retarget --title`)可保留、修改或清除(空字符串)。空白文件也可带 `title`,作为创建器页面和离线版的名称。
10 changes: 5 additions & 5 deletions docs/html-room/release-integrity.json
Original file line number Diff line number Diff line change
@@ -1,16 +1,16 @@
{
"version": 2,
"stage": "fifty-members-htmlroom",
"stage": "room-title-htmlroom",
"esbuild": "0.28.2",
"artifacts": {
"html-room-template.html": {
"bytes": 139626,
"sha256": "2fd303b6275ff62aec19746e94c718548d025f8795d409d0b5068d070fc3e713",
"bytes": 141314,
"sha256": "5a3f6fff21b29210b2744c91ab1e83cc67c668ee91e5008cc2e8e5b88edfbc70",
"csp": "([^"
}
},
"scriptHashBase64": "t+UQwWWn39gEDqed1s6sSnhEiZjNGlRobivE0cpid5M=",
"scriptHashHex": "b7e510c165a7dfd8040ea79dd6ceac4a78448998cd1a54686e2bc4d1ca627793",
"scriptHashBase64": "2JtQBGQ1g3RysBYMwJmcKy+WFVqLIDDZxJLfYInhifQ=",
"scriptHashHex": "d89b50046435837472b0160cc0999c2b2f96155a8b2030d9c492df6089e189f4",
"styleHashBase64": "+cH4mX6DQpXWnU8EWkUQE30KyFp1e6rCFFdGd+i+pzA=",
"serverCommentSha256": "971a532473d0c7d039dd0617391a736bd4057fab488e2249d612188a340195fe"
}
25 changes: 21 additions & 4 deletions html-room/crypto.js
Original file line number Diff line number Diff line change
Expand Up @@ -72,10 +72,25 @@ export const CUSTOM_SOURCES = ['wss:', 'ws://localhost:*', 'ws://127.0.0.1:*'];
export const connectSources = config => !config.version ? CUSTOM_SOURCES : [...(config.custom ? CUSTOM_SOURCES : []), ...config.relays.map(cspSource)];
// Blank files may carry default relays for the generator: {} or {relays:[...]}.
export function validateBlank(c) {
if (!c || typeof c !== 'object' || Array.isArray(c) || c.version !== undefined || Object.keys(c).some(k => k !== 'relays')) throw Error('空白文件配置错误');
if (!c || typeof c !== 'object' || Array.isArray(c) || c.version !== undefined || Object.keys(c).some(k => !['relays','title'].includes(k))) throw Error('空白文件配置错误');
if (c.title !== undefined && titleOf(c.title) !== c.title) throw Error('标题格式错误');
if (c.relays !== undefined && JSON.stringify(relayList(c.relays)) !== JSON.stringify(c.relays)) throw Error('默认中继格式错误');
return c;
}
// A room's title: shown in <title> and as the page heading. Anyone holding
// the file can choose it (when creating, or in a regenerated file); it is
// public routing-free text, escaped wherever it is written into HTML.
export const DEFAULT_TITLE = '聚众加密聊天房间';
export const MAX_TITLE = 60;
export function titleOf(input) {
if (input === undefined || input === null) return undefined;
if (typeof input !== 'string') throw Error('标题格式错误');
const t = input.replace(/\s+/g, ' ').trim();
if (!t || t === DEFAULT_TITLE) return undefined;
if (Array.from(t).length > MAX_TITLE) throw Error(`标题最多 ${MAX_TITLE} 个字`);
if (/[\u0000-\u001f\u007f-\u009f\u2028\u2029\u202a-\u202e\u2066-\u2069]/.test(t)) throw Error('标题含不支持的字符');
return t;
}
export const versionFor = relays => relays.some(r => relayKind(r) !== 'hr') ? 3 : 2;
const KDF = { name:'PBKDF2', hash:'SHA-256', iterations:310000 };
const aad = roomId => enc.encode(`private-talk/html-room/v1/wrap|${roomId}`);
Expand All @@ -91,7 +106,8 @@ export function validate(c) {
// custom: true lets whoever opens the file connect through relays they type
// in (its CSP then admits any wss: origin); absent means the listed relays only.
if (c.custom !== undefined && c.custom !== true) throw Error('配置字段错误');
const common=['version','relays','roomId','encrypted','custom'];
if (c.title !== undefined && titleOf(c.title) !== c.title) throw Error('标题格式错误');
const common=['version','relays','roomId','encrypted','custom','title'];
if (c.encrypted === true) {
const allowed=[...common,'salt','iv','ciphertext','kdf'];
if (Object.keys(c).some(k=>!allowed.includes(k)) || !c.kdf || Object.keys(c.kdf).length !== 3 || Object.entries(KDF).some(([k,v])=>c.kdf[k]!==v)) throw Error('不支持的 KDF 配置');
Expand All @@ -102,7 +118,7 @@ export function validate(c) {
}
return c;
}
export async function makeConfig(relay, pass, { custom = false } = {}) {
export async function makeConfig(relay, pass, { custom = false, title } = {}) {
const relays=relayList(relay);
const master=random(32), d=await derive(master), c={version:versionFor(relays),relays,roomId:d.roomId,encrypted:!!pass};
let out;
Expand All @@ -112,7 +128,8 @@ export async function makeConfig(relay, pass, { custom = false } = {}) {
const ciphertext=await crypto.subtle.encrypt({name:'AES-GCM',iv,additionalData:aad(c.roomId)},key,master);
out={...c,salt:hex(salt),iv:hex(iv),ciphertext:hex(ciphertext),kdf:{...KDF}};
}
return custom ? {...out,custom:true} : out;
const t = titleOf(title);
return {...out, ...(custom && {custom:true}), ...(t && {title:t})};
}
// The relays this visit uses: the file's own, or ones typed into a file that
// allows it. Only the routing changes; key material and roomId stay.
Expand Down
19 changes: 9 additions & 10 deletions html-room/file.js
Original file line number Diff line number Diff line change
Expand Up @@ -6,7 +6,7 @@
// exact script and style bytes this page runs with (html-room/template.js),
// so they are byte-identical to a release build and pass verify-room-file.
import { boot } from './client.js';
import { makeConfig, relayList, validate, validateBlank, hex, random } from './crypto.js';
import { makeConfig, relayList, validate, validateBlank, hex, random, DEFAULT_TITLE } from './crypto.js';
import { configFromHash, fragmentFor } from './transport.js';
import { retargetHtml, htmlFor, withRelays, configFromRoomHtml } from './template.js';

Expand All @@ -31,21 +31,21 @@ try { config = JSON.parse(el('pt-room-config').textContent); } catch { config =

function enableRetarget(config) {
const say = t => { el('rt-status').textContent = t; };
el('retarget').hidden = false; el('rt-relay').value = config.relays.join(' ');
el('retarget').hidden = false; el('rt-relay').value = config.relays.join(' '); el('rt-title').value = config.title ?? '';
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, own);
if (JSON.stringify(r.config.relays) === JSON.stringify(config.relays)) { say('地址和现在一样,不用换。'); return; }
const r = await retargetHtml(config, el('rt-relay').value, own, undefined, el('rt-title').value);
if (JSON.stringify(r.config.relays) === JSON.stringify(config.relays) && r.config.title === config.title) { say('中继和标题都和现在一样,不用生成。'); return; }
offer('rt-download', r.html);
say(`新文件已生成(中继:${r.config.relays.join('、')})。口令不变。请把它发给所有人,大家都用新文件打开,再核对一次安全码。`);
say(`新文件「${r.config.title ?? DEFAULT_TITLE}」已生成(中继:${r.config.relays.join('、')})。口令不变。请把它发给所有人,大家都用新文件打开,再核对一次安全码。`);
} catch (e) { say(cjk(e) ? `生成失败:${e.message}。` : '生成失败:地址格式不对。例子:wss://relay.example.com'); }
finally { el('rt-make').disabled = false; }
});
}

// The creator. defaults: relays to start from.
function enableMaker(defaults, { open }) {
function enableMaker(defaults, { open, siteTitle }) {
const fail = t => { el('mk-error').textContent = t; };
el('new-room').hidden = false;
if (open) { el('new-room').open = true; el('new-room').querySelector('summary').hidden = true; }
Expand All @@ -63,7 +63,7 @@ function enableMaker(defaults, { open }) {
try {
const relays = relayList(el('mk-relay').value), password = el('mk-pass').value;
if (password && new TextEncoder().encode(password).length > 1024) throw Error('口令太长了');
const next = await makeConfig(relays, password || undefined, { custom: el('mk-custom').checked });
const next = await makeConfig(relays, password || undefined, { custom: el('mk-custom').checked, title: el('mk-title').value });
offer('mk-download', await htmlFor(next, own));
el('mk-out').hidden = false; el('mk-pass').value = '';
// A link only works on a hosted creator, which can open any room.
Expand All @@ -87,13 +87,12 @@ function enableMaker(defaults, { open }) {
finally { el('cv-make').disabled = false; }
});
// On the site: the page itself, saved as a blank creator, works offline.
if (hosted) htmlFor(defaults.length ? { relays: defaults } : {}, own).then(html => { offer('mk-self', html); el('mk-self-row').hidden = false; }).catch(() => {});
if (hosted) htmlFor({ ...(defaults.length && { relays: defaults }), ...(siteTitle && { title: siteTitle }) }, own).then(html => { offer('mk-self', html); el('mk-self-row').hidden = false; }).catch(() => {});
}

function creatorOnly() {
for (const id of ['risk', 'status', 'preview-help', 'unlock', 'retarget', 'relay-status', 'presence', 'safety', 'messages', 'chat']) el(id).hidden = true;
document.querySelector('h1').hidden = true;
document.title = 'HTML Room · 加密聊天室';
}

function openLink(hash) {
Expand All @@ -119,7 +118,7 @@ try {
if (openLink(location.hash) && ready) enableMaker(defaults, { open: false });
} else {
creatorOnly();
if (ready) enableMaker(defaults, { open: true });
if (ready) enableMaker(defaults, { open: true, siteTitle: config.title });
addEventListener('hashchange', () => { if (location.hash.startsWith('#hr1=')) location.reload(); });
}
} else throw Error('bad config');
Expand Down
Loading
Loading