diff --git a/README.md b/README.md index 2b402e1..65e580f 100644 --- a/README.md +++ b/README.md @@ -44,7 +44,7 @@ flowchart TD **1. 创建房间** 打开[创建页面](https://freecode100year.github.io/htmlroom/),直接点「创建房间」。可选: - **房间标题**:最多 60 字,显示在浏览器标签页和页面顶部;不填就是「聚众加密聊天房间」。 -- **口令**:相当于门上再加一把锁。建议点「随机生成口令」。 +- **口令**:相当于门上再加一把锁。建议点「随机生成口令」:会生成一串好念好抄的口令(如 `K7QM-3XRP-9DTA-HWVE-N4BC`),直接显示出来,点「复制口令」即可复制;房间建好后口令仍会显示在结果里。 **2. 发给朋友** 下载 `index.html` 发给朋友,或复制房间链接。设了口令的话,**口令请换一种方式告诉对方**(比如文件用微信发,口令打电话说)。 diff --git a/browser-tests/htmlroom.mjs b/browser-tests/htmlroom.mjs index 9b23cbe..b771692 100644 --- a/browser-tests/htmlroom.mjs +++ b/browser-tests/htmlroom.mjs @@ -29,7 +29,7 @@ try{ for(const c of [a,b])assert.deepEqual(await c.cookies(),[]); for(const p of [maker,file])assert.deepEqual(await p.evaluate(async()=>[localStorage.length,sessionStorage.length,(await indexedDB.databases()).length]),[0,0,0]); const bad=await a.newPage();let connected=0;bad.on('websocket',()=>connected++);await bad.goto(base+'/hr.html#hr1=invalid');await bad.getByText(/房间链接打不开/).waitFor();assert.equal(connected,0); - const wrapped=await a.newPage();await wrapped.goto(base+'/hr.html');await wrapped.locator('#mk-gen').click();assert.equal((await wrapped.locator('#mk-pass').inputValue()).length,64);await wrapped.locator('#mk-make').click();await wrapped.locator('#mk-out').waitFor({state:'visible'}); + const wrapped=await a.newPage();await wrapped.goto(base+'/hr.html');await wrapped.locator('#mk-gen').click();assert.match(await wrapped.locator('#mk-pass').inputValue(),/^[A-HJKMNP-Z2-9]{4}(-[A-HJKMNP-Z2-9]{4}){4}$/);assert.equal(await wrapped.locator('#mk-pass').getAttribute('type'),'text');await wrapped.locator('#mk-make').click();await wrapped.locator('#mk-out').waitFor({state:'visible'}); const encoded=new URL(await wrapped.locator('#mk-link').inputValue()).hash.slice(5);const protectedConfig=JSON.parse(Buffer.from(encoded,'base64url').toString());assert.equal(protectedConfig.encrypted,true);assert.equal('master'in protectedConfig,false); console.log(JSON.stringify({suite:'integrated hosted maker/Fragment + file interop',checks:9,result:'pass',browser:browser.version(),consoleErrors:0,thirdPartyRequests:0})); }finally{await browser?.close();server?.kill();await rm(dir,{recursive:true,force:true});} diff --git a/browser-tests/password-ui.mjs b/browser-tests/password-ui.mjs new file mode 100644 index 0000000..6f458bd --- /dev/null +++ b/browser-tests/password-ui.mjs @@ -0,0 +1,68 @@ +// The generated password is readable and copyable, survives creating the room, +// and opens it; the room page can show what is typed. Both on the site and in +// a local file (where the copy may fall back to select-and-copy). +import { chromium } from 'playwright'; +import { mkdtemp, writeFile, readFile, rm } from 'node:fs/promises'; +import { tmpdir } from 'node:os'; +import { pathToFileURL } from 'node:url'; +import assert from 'node:assert/strict'; +import { render } from '../scripts/build-html-room.mjs'; +import { configFromRoomHtml } from '../html-room/template.js'; +import { unlock } from '../html-room/crypto.js'; +import { nodeRelay } from '../test/fixtures/mesh-relays.mjs'; + +const FORMAT = /^[A-HJKMNP-Z2-9]{4}(-[A-HJKMNP-Z2-9]{4}){4}$/; +const dir = await mkdtemp(tmpdir() + '/hr-pw-'); +const relay = await nodeRelay(); +let browser; +try { + browser = await chromium.launch({ headless: true }); + const ctx = await browser.newContext({ permissions: ['clipboard-read', 'clipboard-write'] }); + const errors = []; const watch = p => { p.on('pageerror', e => errors.push(e.message)); return p; }; + const page = watch(await ctx.newPage()); + await page.goto(`http://127.0.0.1:${relay.port}/`); + await page.locator('#mk-gen').click(); + const pw = await page.locator('#mk-pass').inputValue(); + assert.match(pw, FORMAT, 'readable groups, no look-alike characters'); + assert.equal(await page.locator('#mk-pass').getAttribute('type'), 'text', 'shown after generating'); + assert.equal(await page.locator('#mk-show').textContent(), '隐藏'); + await page.locator('#mk-copy-pass').click(); + assert.equal(await page.evaluate(() => navigator.clipboard.readText()), pw, 'copied to the clipboard'); + await page.locator('#mk-show').click(); + assert.equal(await page.locator('#mk-pass').getAttribute('type'), 'password'); + await page.locator('#mk-show').click(); + await page.locator('#mk-make').click(); await page.locator('#mk-out').waitFor({ state: 'visible' }); + assert.equal(await page.locator('#mk-pass-row').isVisible(), true, 'the password stays readable with the result'); + assert.equal(await page.locator('#mk-pass-out').inputValue(), pw); + await page.evaluate(() => navigator.clipboard.writeText('-')); + await page.locator('#mk-pass-copy').click(); + assert.equal(await page.evaluate(() => navigator.clipboard.readText()), pw); + const dl = page.waitForEvent('download'); await page.locator('#mk-download').click(); + const roomPath = dir + '/room.html'; await (await dl).saveAs(roomPath); + const config = configFromRoomHtml(await readFile(roomPath, 'utf8')); + assert.equal(config.encrypted, true); + assert.equal((await unlock(config, pw)).roomId, config.roomId, 'the shown password opens the room'); + + // The room page: show what is typed, then enter with the copied password. + const room = watch(await ctx.newPage()); await room.goto(pathToFileURL(roomPath).href); + await room.locator('#password').fill(pw); + assert.equal(await room.locator('#password').getAttribute('type'), 'password'); + await room.locator('#pw-show').click(); + assert.equal(await room.locator('#password').getAttribute('type'), 'text'); + await room.locator('#enter').click(); + await room.locator('#relay-status').filter({ hasText: relay.url }).waitFor({ timeout: 15000 }); + + // A local creator without clipboard permission: copy falls back, and the text is left selected. + const blankPath = dir + '/creator.html'; await writeFile(blankPath, (await render({}, [])).html); + const local = watch(await (await browser.newContext()).newPage()); await local.goto(pathToFileURL(blankPath).href); + await local.locator('#mk-gen').click(); + const pw2 = await local.locator('#mk-pass').inputValue(); + assert.match(pw2, FORMAT); assert.notEqual(pw2, pw); + await local.locator('#mk-copy-pass').click(); + await local.locator('#mk-error').filter({ hasText: /口令已复制|口令已选中/ }).waitFor(); + assert.equal(await local.evaluate(() => getSelection().toString() || document.activeElement.value.slice(document.activeElement.selectionStart, document.activeElement.selectionEnd)), pw2, 'selected for copying by hand'); + assert.deepEqual(errors, []); + console.log('PASS: generated password readable and copyable, kept on the result, opens the room; show/hide on both forms; local-file copy fallback'); +} finally { + await browser?.close(); await relay.stop(); await rm(dir, { recursive: true, force: true }); +} diff --git a/dist/_headers b/dist/_headers index 91c1b09..f90e0bb 100644 --- a/dist/_headers +++ b/dist/_headers @@ -1,5 +1,5 @@ /* - 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' + Content-Security-Policy: default-src 'none'; script-src 'sha256-aNnjLm65Zqk0gxUuDbkzb8IA4rm24tWrD4YbtohyyZ8='; style-src 'sha256-kDQ4uvzydd/bYRgj2CH45MTiA02XvOWw5A9uc4FMls8='; 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 diff --git a/dist/hr-template.html b/dist/hr-template.html index 621aaca..6aad7e7 100644 --- a/dist/hr-template.html +++ b/dist/hr-template.html @@ -28,8 +28,8 @@ `)}});for(let f of["exit","SIGINT","SIGTERM"])process.once(f,()=>c.kill())}let Z=!1;for(let t of["SIGTERM","SIGINT"])process.on(t,()=>{Z&&process.exit(0),Z=!0,m.close();for(let c of S)c.close(1012,"restart");setTimeout(()=>process.exit(0),S.size?500:0)});return m}typeof require<"u"&&require.main===module&&ke((0,L.readFileSync)(__filename,"utf8"),require("node:path").basename(__filename));0&&(module.exports={serve}); /* --> -聚众加密聊天房间

聚众加密聊天房间

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

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

    +聚众加密聊天房间

    聚众加密聊天房间

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

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

      diff --git a/dist/hr.html b/dist/hr.html index 621aaca..6aad7e7 100644 --- a/dist/hr.html +++ b/dist/hr.html @@ -28,8 +28,8 @@ `)}});for(let f of["exit","SIGINT","SIGTERM"])process.once(f,()=>c.kill())}let Z=!1;for(let t of["SIGTERM","SIGINT"])process.on(t,()=>{Z&&process.exit(0),Z=!0,m.close();for(let c of S)c.close(1012,"restart");setTimeout(()=>process.exit(0),S.size?500:0)});return m}typeof require<"u"&&require.main===module&&ke((0,L.readFileSync)(__filename,"utf8"),require("node:path").basename(__filename));0&&(module.exports={serve}); /* --> -聚众加密聊天房间

      聚众加密聊天房间

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

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

        +聚众加密聊天房间

        聚众加密聊天房间

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

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

          diff --git a/docs/html-room/release-integrity.json b/docs/html-room/release-integrity.json index 31038a1..0ebbab8 100644 --- a/docs/html-room/release-integrity.json +++ b/docs/html-room/release-integrity.json @@ -1,16 +1,16 @@ { "version": 2, - "stage": "room-title-htmlroom", + "stage": "visible-password-htmlroom", "esbuild": "0.28.2", "artifacts": { "html-room-template.html": { - "bytes": 141314, - "sha256": "5a3f6fff21b29210b2744c91ab1e83cc67c668ee91e5008cc2e8e5b88edfbc70", + "bytes": 144598, + "sha256": "371e2db7d0a7b88a67d3999745f2041e7b96fac568d12e0e232765704eb1c347", "csp": "([^" } }, - "scriptHashBase64": "2JtQBGQ1g3RysBYMwJmcKy+WFVqLIDDZxJLfYInhifQ=", - "scriptHashHex": "d89b50046435837472b0160cc0999c2b2f96155a8b2030d9c492df6089e189f4", - "styleHashBase64": "+cH4mX6DQpXWnU8EWkUQE30KyFp1e6rCFFdGd+i+pzA=", + "scriptHashBase64": "aNnjLm65Zqk0gxUuDbkzb8IA4rm24tWrD4YbtohyyZ8=", + "scriptHashHex": "68d9e32e6eb966a93483152e0db9336fc200e2b9b6e2d5ab0f861bb68872c99f", + "styleHashBase64": "kDQ4uvzydd/bYRgj2CH45MTiA02XvOWw5A9uc4FMls8=", "serverCommentSha256": "971a532473d0c7d039dd0617391a736bd4057fab488e2249d612188a340195fe" } diff --git a/html-room/client.js b/html-room/client.js index 4f55d2b..320a94c 100644 --- a/html-room/client.js +++ b/html-room/client.js @@ -1,4 +1,5 @@ import { unlock, validate, hex, sessionRelays } from './crypto.js'; +import { showToggle } from './ui.js'; import { Session } from './session.js'; import { RelayConnection } from './relay-connection.js'; import { MeshConnection } from './mesh.js'; @@ -21,6 +22,7 @@ if(!globalThis.crypto?.subtle || !globalThis.WebSocket || !globalThis.TextEncode else if(config.version){ try{validate(config);status(config.encrypted?'请输入口令,然后点「进入房间」。':'点「进入房间」开始聊天。');el('password').disabled=false;el('enter').disabled=false;}catch{status('房间文件有问题,请让对方重新发一份。');el('enter').disabled=true;} }else{status('这是空白模板,请先在创建页面里创建房间。');el('enter').disabled=true;} +showToggle(el('password'),el('pw-show')); const custom=allowCustom && config.version && config.custom===true; if(custom){el('custom-relay').hidden=false;el('relay-input').value=config.relays.join(' ');} el('unlock').addEventListener('submit',async e=>{e.preventDefault();if(busy||Date.now() parseInt(h, 16)); } export const random = n => crypto.getRandomValues(new Uint8Array(n)); +// A random password people can read out and type: 20 characters from 31 that +// are hard to confuse (no I, L, O, 0, 1), in groups of four. About 99 bits. +const PW_ALPHABET = 'ABCDEFGHJKMNPQRSTUVWXYZ23456789'; +export function readablePassword(groups = 5) { + const out = []; + while (out.length < groups * 4) { + for (const b of random(32)) if (b < 248 && out.length < groups * 4) out.push(PW_ALPHABET[b % 31]); // 248 = 8 × 31: no bias + } + return out.join('').match(/.{4}/g).join('-'); +} export async function derive(master) { const raw = typeof master === 'string' ? bytes(master, 32) : master; if (!(raw instanceof Uint8Array) || raw.length !== 32) throw Error('房间密钥长度错误'); diff --git a/html-room/file.js b/html-room/file.js index 3672def..2721074 100644 --- a/html-room/file.js +++ b/html-room/file.js @@ -6,7 +6,8 @@ // 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, DEFAULT_TITLE } from './crypto.js'; +import { makeConfig, relayList, validate, validateBlank, readablePassword, DEFAULT_TITLE } from './crypto.js'; +import { copyText, showToggle } from './ui.js'; import { configFromHash, fragmentFor } from './transport.js'; import { retargetHtml, htmlFor, withRelays, configFromRoomHtml } from './template.js'; @@ -51,7 +52,15 @@ function enableMaker(defaults, { open, siteTitle }) { if (open) { el('new-room').open = true; el('new-room').querySelector('summary').hidden = true; } el('mk-relay').value = defaults.join(' '); if (!defaults.length) el('mk-advanced').open = true; - el('mk-gen').addEventListener('click', () => { el('mk-pass').value = hex(random(32)); }); + const pass = showToggle(el('mk-pass'), el('mk-show')); + el('mk-pass').addEventListener('input', () => { el('mk-copy-pass').hidden = !el('mk-pass').value; }); + // A generated password is shown, so it can be read out and copied. + el('mk-gen').addEventListener('click', () => { + el('mk-pass').value = readablePassword(); pass.show(true); el('mk-copy-pass').hidden = false; + fail('已生成口令,显示在上面。可以点「复制口令」。'); + }); + el('mk-copy-pass').addEventListener('click', () => copyText(el('mk-pass')).then(ok => fail(ok ? '口令已复制。请换一种方式告诉朋友,别和文件一起发。' : '复制失败:口令已选中,请手动复制。'))); + el('mk-pass-copy').addEventListener('click', () => copyText(el('mk-pass-out')).then(ok => fail(ok ? '口令已复制。请换一种方式告诉朋友,别和文件一起发。' : '复制失败:口令已选中,请手动复制。'))); // Classic relays of this project also forward decentralized rooms on /bc/. el('mk-mesh').addEventListener('click', () => { const list = el('mk-relay').value.trim().split(/[\s,]+/).filter(Boolean).map(r => /^wss?:\/\//.test(r) ? `bc+${r.replace(/\/+$/, '')}/bc/` : r); @@ -65,7 +74,10 @@ function enableMaker(defaults, { open, siteTitle }) { if (password && new TextEncoder().encode(password).length > 1024) throw Error('口令太长了'); 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 = ''; + el('mk-out').hidden = false; + // The password stays readable with the result: losing it loses the room. + el('mk-pass-out').value = password; el('mk-pass-row').hidden = !password; + el('mk-pass').value = ''; pass.show(false); el('mk-copy-pass').hidden = true; // A link only works on a hosted creator, which can open any room. if (hosted) { el('mk-link').value = location.origin + location.pathname + fragmentFor(next); el('mk-link-row').hidden = false; } else { el('mk-link').value = ''; el('mk-link-row').hidden = true; } @@ -73,7 +85,7 @@ function enableMaker(defaults, { open, siteTitle }) { } catch (e) { fail(cjk(e) ? `创建失败:${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('复制失败,请手动选中链接复制。'))); + el('mk-copy').addEventListener('click', () => copyText(el('mk-link')).then(ok => fail(ok ? '已复制。链接就是钥匙,请只发给要聊天的人。' : '复制失败:链接已选中,请手动复制。'))); 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 { diff --git a/html-room/style.css b/html-room/style.css index a633ab1..1639aa6 100644 --- a/html-room/style.css +++ b/html-room/style.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}.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} \ No newline at end of file +: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}.pw,#mk-pass,#mk-pass-out,#password{font-family:ui-monospace,SFMono-Regular,Menlo,Consolas,monospace;letter-spacing:.02em}.row{display:flex;gap:8px;align-items:center}.row input{flex:1;min-width:0}.row button{flex:none}.check{display:flex;gap:8px;align-items:center;margin:10px 0}.check input{width:auto;margin:0} \ No newline at end of file diff --git a/html-room/template.js b/html-room/template.js index 80b23d1..82f8984 100644 --- a/html-room/template.js +++ b/html-room/template.js @@ -17,7 +17,7 @@ const RETARGET = `