From ded55bcd7d2e72d50243544176019cb2562fc960 Mon Sep 17 00:00:00 2001 From: AAA <35992542+TNTcraftHIM@users.noreply.github.com> Date: Thu, 20 Aug 2026 20:00:52 +0800 Subject: [PATCH] feat(access): add private room password entry (#77) Adds bounded per-room Viewer password entry while preserving grant/public paths and Native v2 wire compatibility. --- docs/adr/0002-persistent-protected-rooms.md | 113 ++++++--- docs/project-memory.md | 4 +- docs/research/webrtc-p2p-screen-sharing.md | 6 +- docs/status.md | 4 +- docs/方案设计.md | 31 +-- docs/需求理解.md | 19 +- src/client/pages/HostPage.tsx | 103 ++++++++ src/client/pages/ViewerPage.tsx | 71 +++++- src/client/styles.css | 33 +++ src/server/room-database.ts | 87 ++++++- src/server/room-store.ts | 237 +++++++++++++++++- src/server/signaling.ts | 154 ++++++++++-- src/shared/protocol.ts | 23 ++ tests/protocol.test.ts | 49 ++++ tests/room-store.test.ts | 255 ++++++++++++++++++++ tests/server-signal.test.ts | 117 ++++++++- 16 files changed, 1210 insertions(+), 96 deletions(-) diff --git a/docs/adr/0002-persistent-protected-rooms.md b/docs/adr/0002-persistent-protected-rooms.md index 715d37f..b0ac405 100644 --- a/docs/adr/0002-persistent-protected-rooms.md +++ b/docs/adr/0002-persistent-protected-rooms.md @@ -1,6 +1,6 @@ # ADR-0002: Persistent Rooms And Scoped Viewer Access -- Status: Accepted; runtime candidate implemented, deployment pending +- Status: Accepted; grant boundary deployed, room-password extension source-only - Date: 2026-08-19 ## Context @@ -11,18 +11,19 @@ so the deployment owner must control Host admission. Watching should remain a normal-browser, link-first action and should not reveal the deployment's Host password to every invited friend. -The superseded runtime and current production deployment use one optional -`ACCESS_PASSWORD`, one stateless cookie, -and the same WebSocket upgrade gate for both roles. A Viewer then authenticates -with only a numeric room code. This is simple but conflates deployment admission -with room privacy: sharing the site password grants broader access than a Viewer -needs, while disabling it makes a random room code the only weak capability. -Persistent sequential IDs make that problem more visible. +The superseded runtime used one optional `ACCESS_PASSWORD`, one stateless cookie, +and the same WebSocket upgrade gate for both roles. The deployed replacement now +separates Host admission from default private fragment grants and explicit +public-watch. The accepted source-only extension adds optional room-password +entry without reopening the old site-wide Viewer gate. Persistent sequential IDs +make a room-scoped boundary especially important. -Accounts, a user database, per-person ACLs, human room passwords, and a server -session table would exceed the product need. A single room-scoped bearer -capability provides the intended delegation with less user and operational -state. Media topology remains independent and is governed by ADR-0001/0005. +Accounts, a user database, per-person ACLs, password recovery, and a server +session table would exceed the product need. A room-scoped bearer capability +remains the lowest-friction invitation, while an optional low-policy room +password lets a trusted friend enter from a numeric room code without weakening +Host admission. Media topology remains independent and is governed by +ADR-0001/0005. ## Decision @@ -71,6 +72,32 @@ Every room has one of two Viewer policies: central-egress limits. Sequential public room IDs deliberately provide no privacy and must be labelled accordingly. +A private room may also have one optional Viewer password. An existing valid +fragment grant still enters directly. A code-only Viewer sees a neutral password +form and, on success, authenticates only that current Viewer session in that +room. The Host can set, replace, or remove it. The input accepts 1 through 64 +visible ASCII characters without composition rules; this is a convenience +boundary for trusted friends, not an account credential or recovery system. + +Plaintext room passwords exist only transiently in the current page state, +WebSocket authentication message, and KDF call memory. They never enter a URL, +cookie, browser persistent storage, log, error, or SQLite. The server uses Node's +asynchronous scrypt with fixed `N=16384,r=8,p=1`, a random 16-byte salt, a +32-byte verifier, two active KDF slots, and at most 16 waiters. A full queue fails +boundedly; a waiter rechecks its Viewer socket or Host session before starting +KDF and skips stale work. A target-machine benchmark +may tune those parameters after the feature is deployed; it does not block the +functional default. Unknown rooms, absent/wrong passwords, and a full room after +a correct password share generic rejection. Completion rechecks current room +material and socket/Host session so stale asynchronous work cannot authorize or +commit. + +The wire remains `screener-v2`. Only a Web Host that advertises +`viewerPasswordSettings: true` may set/remove the password and receive +`viewer-password-updated`; Native v2 does not advertise the capability and sees +no new message type. Viewer `authenticate` may carry the current password +attempt. The generic `authenticated` message does not gain a password field. + Private invitations use `/r/{roomId}#v={grant}`. RFC 3986 separates a fragment before dereference, and the WebSocket API rejects fragment-bearing URLs, so the client never appends it to HTTP or `/signal`. On first load the client strictly @@ -99,7 +126,9 @@ every current Viewer socket. Closing signaling alone is insufficient because a healthy P2P edge can keep playing without it. Rotation and public-to-private each return one new grant. Revoke stores a fresh random locked-private digest for which no grant was generated and returns no grant until a later rotation. -The old grant or code-only session then cannot reconnect. A persistence failure +The old grant or code-only session then cannot reconnect; a separately configured +room password remains an available future entry after current Viewers are +disconnected. A persistence failure leaves the old digest, existing sockets, and topology unchanged. Switching from private to public only broadens future Viewer admission and does not move healthy media. @@ -111,7 +140,8 @@ healthy media. `ROOM_TTL_SECONDS`. With it, SQLite allocates persistent decimal IDs using `INTEGER PRIMARY KEY AUTOINCREMENT`. -Schema v2 keeps one `rooms` table and one additional field: +Schema v3 keeps one `rooms` table and adds one nullable password-material field +to the deployed v2 shape: ```text id INTEGER PRIMARY KEY AUTOINCREMENT @@ -119,17 +149,23 @@ host_token_digest BLOB NOT NULL CHECK (length(host_token_digest) = 32) viewer_grant_digest BLOB NULL CHECK (viewer_grant_digest IS NULL OR length(viewer_grant_digest) = 32) +viewer_password_material BLOB NULL + CHECK (viewer_password_material IS NULL OR length(viewer_password_material) = 48) ``` -`NULL` means public-watch; 32 bytes mean private-link. SQLite stores no raw -password, Host token, Viewer grant, cookie, display name, participant, +For the grant, `NULL` means public-watch and 32 bytes mean private-link. Password +material is either `NULL` or one 48-byte BLOB containing a 16-byte salt followed +by a 32-byte scrypt verifier, so salt/verifier cannot be half-written. SQLite +stores no raw Host-admission or room password, Host token, Viewer grant, cookie, +display name, participant, `clientId`, `peerId`, IP address, SDP, ICE candidate, media, or TURN credential. There is no user, invitation, or session table. -Schema v1 migrates inside one `BEGIN IMMEDIATE` transaction. It adds the nullable -checked `BLOB` column, assigns every existing row a fresh random 32-byte -locked-private digest for which no grant is generated, and advances -`user_version`. Existing Host +Schema v1 or v2 migrates to v3 inside one `BEGIN IMMEDIATE` transaction. V1 first +adds the nullable checked grant `BLOB` and assigns every existing row a fresh +random 32-byte locked-private digest for which no grant is generated. Both v1 +and v2 then add the checked nullable password-material column, default it to +`NULL`, and advance `user_version`. Existing Host tokens still reclaim their rooms, after which the Host rotates once to obtain a new invitation. Old code-only links fail closed. Deployment takes a v1 backup before migration; a rollback to the old binary stops the service and restores that backup rather @@ -146,8 +182,8 @@ SQLite or participates in authorization, routing, or quality decisions. room-creation and Host capability. - One digest scopes leaked Viewer authority to one room and one role; rotation provides a clear recovery operation. -- Public watching remains explicit, and persistent state grows by one nullable - digest column rather than a new subsystem. +- Public watching remains explicit. Optional password entry adds one nullable + fixed-size material column, not an account or authentication subsystem. - A capability link is a bearer credential. Anyone who obtains it can join before expiry and remain until disconnect or room-wide rotation/revoke; targeted per-person revocation is unavailable. @@ -155,7 +191,7 @@ SQLite or participates in authorization, routing, or quality decisions. Closing the only Host tab loses its raw copy; the Host must rotate to produce another link, although room ownership remains recoverable from its Host token. - Rotation/revoke deliberately disconnects all Viewers and rebuilds their routes. -- SQLite v2 is not readable by the old runtime, so rollback requires the +- SQLite v3 is not readable by the old runtime, so rollback requires the pre-migration backup. - Public-watch consumes bounded Viewer/media capacity and offers no privacy. @@ -163,8 +199,9 @@ SQLite or participates in authorization, routing, or quality decisions. - One password for Host and Viewer: over-grants every invited Viewer and couples deployment abuse control to room privacy. -- Human per-room passwords: adds input, password handling, and recovery without - improving the link-sharing workflow over a high-entropy capability. +- A room password as the only private mechanism: loses the direct fragment-grant + workflow and encourages password reuse. The password is an optional parallel + Viewer entry, while the grant remains the default invitation. - Query/path grant: request targets are commonly logged; RFC 6750 likewise warns against bearer tokens in URI queries. A fragment plus first WS message keeps it out of ordinary HTTP and proxy request logs. @@ -182,23 +219,25 @@ SQLite or participates in authorization, routing, or quality decisions. - The auth matrix proves production Host admission, Viewer-without-Host-cookie, 8-byte minimum/independent secret, ingress rate limit, exact-room/role grant - scope, non-enumerating failures, and public-watch caps. + scope, correct/wrong/replaced/removed room passwords, non-enumerating failures, + public code-only entry, and v2 Native wire isolation. - Leak tests cover HTTP/WS targets, Referrer, browser storage, SQLite, application and proxy logs, and errors; fragment consumption immediately clears the URL. - Rotation/revoke/public-to-private proves commit-before-disconnect, complete grace/route cleanup, old receive/send media teardown, one-time grant semantics, old reconnect rejection, locked revoke, and no change after persistence failure. -- A production-copy v1 `STRICT` database migrates transactionally to checked, - locked-private v2; malformed non-null values fail closed, rollback restores its - backup, and v1 tabs terminate without compatibility code. +- Production-copy v1/v2 `STRICT` databases migrate transactionally to checked v3 + with locked-private v1 grants and nullable single-BLOB password material; + malformed non-null values fail closed and rollback restores the backup. ## Implementation Status -The 2026-08-20 repository candidate implements the single-version v2 runtime, -same-row SQLite migration, private/public room policy, fragment consumption, -Host admission, and commit-first generation teardown. Focused automated tests -cover protocol bounds, exact-room grants, persistent-write failure, active -peer/SFU edge retirement, old-generation socket rejection, and v1 migration -rollback. The tracked nginx limiter and backup/restore procedure are present. -Production ingress loading, browser storage/request/log leak inspection, a -production-copy migration rehearsal, and deployment remain acceptance work. +The deployed 2026-08-20 boundary implements the single-version v2 runtime, +private/public room policy, fragment consumption, Host admission, and +commit-first grant teardown on SQLite v2. The current source-only candidate adds +the optional room password, capability-isolated Web Host controls, asynchronous +session rechecks, and the v1/v2-to-v3 single-BLOB migration. Focused automated +tests cover password set/change/remove, grant continuity, public code-only entry, +Native wire isolation, protocol bounds, and both migration origins. Password +deployment, browser UX/leak inspection, production-copy migration rehearsal, +and post-feature target-machine KDF benchmarking remain acceptance work. diff --git a/docs/project-memory.md b/docs/project-memory.md index 31e7d7d..b22214d 100644 --- a/docs/project-memory.md +++ b/docs/project-memory.md @@ -12,7 +12,7 @@ Last updated: 2026-08-20 - Two-tree packet/layer striping may reduce endpoint upload toward one stream bitrate, but needs a bounded multi-parent, loss, sync, churn, and latency experiment; it is not in the current full-stream chains. - Direct/peer keeps per-PC stock GCC. SFU paths start at a `HIGH` ceiling and share at most one `LOW`; next test LiveKit exactly-two built-in BWE on zero-descendant leaves. If it passes, no app media selector is built. Explicit quality, manual activation, then custom/native are later fallbacks; always-on `LOW` needs resource gates. - A later explicit quality fallback evacuates children under a generation guard first. Autonomous BWE enters `suspect`; confirmation evacuates children, and no confirmed `FALLBACK` parent remains. Root-with-children impact is a default-on gate; capacity returns after longer recovery plus cooldown. Self-report alone never triggers it. -- Separate abuse control from watching: production Host admission protects room creation/Host role, while a default private capability grants Viewer access to one room; public-watch is explicit. Keep raw grants out of localStorage/cookies/query/logs/SQLite; retain no account, ACL, user, or session table. +- Separate abuse control from watching: Host admission protects creation/Host; private Viewer entry uses a room grant or optional room password, while public-watch accepts the code. Persist neither raw credential; keep accounts, ACLs, users, and session tables out. - Keep decisions, snapshots, research, code, `AGENTS.md`, and `.codex/` in Git; rewrite memory/status in place. Research current primary sources before material work and reject speculative machinery. - Migrate client, server, and deployment atomically. After a canary, delete superseded config/wire/parsers/tests; do not retain compatibility layers, dual writes, or a second architecture without a current consumer. Git history owns the old implementation. - Autonomously deploy each coherent low-risk milestone after narrow tests, independent review, one full gate, CI and rollback preflight. Keep protocol/database migrations atomic rather than folding them into routine UI/media updates. @@ -33,7 +33,7 @@ Last updated: 2026-08-20 - Production reports poor film audio and self-echo when system capture includes voice software. Diagnose audio A/B/C and sync; Web cannot isolate arbitrary processes and `maxBitrate` is not quality-up. A Windows 11 native candidate defaults to game-process-tree audio and never widens silently; Windows 10 remains unresolved/unsupported. See `docs/research/browser-screen-audio-quality.md`. - Viewer-local names and the opt-in Web Host roster are deployed without media changes. Host self-name, endpoint details, and RTP loss remain pending. - Do not add scene detection, dynamic-FPS control, or forced AV1 without negotiation, encode, game, CPU/GPU, and sender evidence. -- ADR-0002 access is deployed; names/presence are session-only with no account, member, session, or roster table. +- ADR-0002 grant/public access is deployed; room-password is source-only. Names/presence remain session-only without account or roster tables. - Deferred architecture audit: `docs/maintenance.md`. ## Current Implementation diff --git a/docs/research/webrtc-p2p-screen-sharing.md b/docs/research/webrtc-p2p-screen-sharing.md index 62c44ea..1e6d572 100644 --- a/docs/research/webrtc-p2p-screen-sharing.md +++ b/docs/research/webrtc-p2p-screen-sharing.md @@ -228,11 +228,11 @@ Electron 可以固定 Chromium 版本,枚举屏幕/窗口,改善选源、热 ### 最小访问模型 -旧 production 的 `ACCESS_PASSWORD`、同一 Host/Viewer cookie gate 和 code-only Viewer 是已部署事实,不再是接受的目标;仓库 access candidate 已把“谁可以建立/发布房间”和“谁可以看这一间房”分开。production 必配的 `HOST_ADMISSION_PASSWORD` 无状态 cookie 只允许建房及尝试 Host role,房间 Host token 仍独立验证;默认 private-link 使用一个 room-scoped Viewer bearer grant,public-watch 才接受 code-only Viewer。它们不需要账号、JWT、服务端 session Map、逐人 ACL 或人类房间密码。WebSocket upgrade 不知道未来 role,因此只能保留 Origin/容量门并记录 Host cookie 状态;首条 Host 鉴权再同时要求该状态和 Host token,Viewer 只走 room policy。RFC 6455 明确允许服务端用 handshake `Origin` 作接纳判断,但这不是 Viewer 授权本身。 +旧 production 的 `ACCESS_PASSWORD`、同一 Host/Viewer cookie gate 和 code-only Viewer 是已部署事实,不再是接受的目标;仓库 access candidate 已把“谁可以建立/发布房间”和“谁可以看这一间房”分开。production 必配的 `HOST_ADMISSION_PASSWORD` 无状态 cookie 只允许建房及尝试 Host role,房间 Host token 仍独立验证;默认 private-link 使用一个 room-scoped Viewer bearer grant,public-watch 才接受 code-only Viewer。原研究结论不要求人类房间密码;后续已接受的最小扩展增加可选的逐房间 Viewer 密码作为 code-only 私密入口,但仍不引入账号、JWT、服务端 session Map 或逐人 ACL。WebSocket upgrade 不知道未来 role,因此只能保留 Origin/容量门并记录 Host cookie 状态;首条 Host 鉴权再同时要求该状态和 Host token,Viewer 只走 room policy。RFC 6455 明确允许服务端用 handshake `Origin` 作接纳判断,但这不是 Viewer 授权本身。 RFC 3986 的规范事实是 fragment 在 URI dereference 前由 user agent 分离;WHATWG WebSockets 进一步规定含 fragment 的 constructor URL 必须抛 `SyntaxError`。因此把 256-bit room grant 放在 `/r/{code}#v=...`,再由页面在首个 WSS application message 发送,可以使它不进入 HTTP 或 WebSocket request-target。RFC 6750 对 OAuth bearer query 的警告并不直接规定本产品,但它提供了适用的安全类比:URI query 高概率被日志记录,不应承载此 grant。W3C Referrer Policy 的算法会从 referrer URL 移除 fragment,production 的 `no-referrer` header 再禁止整个 header;这是传输边界,不是“不会泄漏”的保证。 -W3C TAG 的 capability URL 指南指出 URL 仍会出现在地址栏、历史、扩展、同步服务、截图和转发路径中,建议高熵、到期与可撤销。目标因此使用 Node.js `randomBytes(32)` 的加密强随机量、完整 grant 的 SHA-256 摘要、有限期限,以及 Host rotate 或 locked revoke。浏览器首次严格解析后只写 room-scoped `sessionStorage`,立即 `history.replaceState` 到 canonical URL;刷新和页面内 WSS reconnect 可复用。HTML 标准明确新 auxiliary browsing context 可以复制同源 opener 的 session storage;这是已持有 bearer 的本地浏览器上下文转交边界,不是服务器扩大 room/role 权限,因此不增加导航状态机。独立且无 fragment/room key 的访问仍 fail closed。raw grant 不进入 `localStorage`、cookie、query、Referrer、SQLite、应用/代理日志或错误。OWASP 日志指南也明确把 access token、session identifier 和密码列为通常不应直接记录的数据。Fragment 降低服务端泄漏面,但 possession 仍等于该房间 Viewer 权限。 +W3C TAG 的 capability URL 指南指出 URL 仍会出现在地址栏、历史、扩展、同步服务、截图和转发路径中,建议高熵、到期与可撤销。目标因此使用 Node.js `randomBytes(32)` 的加密强随机量、完整 grant 的 SHA-256 摘要、有限期限,以及 Host rotate 或 locked revoke。浏览器首次严格解析后只写 room-scoped `sessionStorage`,立即 `history.replaceState` 到 canonical URL;刷新和页面内 WSS reconnect 可复用。HTML 标准明确新 auxiliary browsing context 可以复制同源 opener 的 session storage;这是已持有 bearer 的本地浏览器上下文转交边界,不是服务器扩大 room/role 权限,因此不增加导航状态机。独立且无 fragment/room key 的访问只进入中性密码路径,没有正确房间密码仍 fail closed。raw grant 不进入 `localStorage`、cookie、query、Referrer、SQLite、应用/代理日志或错误。OWASP 日志指南也明确把 access token、session identifier 和密码列为通常不应直接记录的数据。Fragment 降低服务端泄漏面,但 possession 仍等于该房间 Viewer 权限。 持久化只在现有 `rooms` row 增加 nullable `viewer_grant_digest`;`NULL` 表示 public-watch,非空值以 `CHECK` 约束为 32-byte BLOB。SQLite `STRICT` table 只接受规定的类型名,因此不能声明 `BLOB(32)`;括号长度也不是 SQLite 的长度约束。官方迁移指南支持在 transaction 中完成 schema/data 变更,目标用一个 `BEGIN IMMEDIATE` 给每个 schema v1 旧行写入未生成对应 grant 的 fresh random locked-private digest,再更新 `user_version`。这是项目设计推论,不是 SQLite 自动提供的权限语义。上线前备份 v1;旧 binary 回滚恢复备份,不在 runtime 保留双 schema。 @@ -278,7 +278,7 @@ WebRTC 标准没有承诺固定毫秒延迟。工程目标必须带网络条件 - WebRTC 使用 DTLS-SRTP。TURN 只能看到加密后的媒体包,但仍能看到地址、房间时序和流量元数据。 - P2P 会让房间内双方得知网络地址。熟人首版可以接受,陌生人房间不能默认接受。 - TURN 必须使用短期凭据、速率限制、每用户/房间配额和出口告警,不能提供匿名公共 relay。 -- Host admission password 只控制建房/Host role,不能作为私密观看凭据;private-link 依赖 room-scoped grant,public-watch 的 room code 则明确不提供隐私。两者都不改变媒体 fanout/egress 上限。 +- Host admission password 只控制建房/Host role,不能作为私密观看凭据;private-link 接受 room-scoped grant 或可选逐房间 Viewer 密码,public-watch 的 room code 则明确不提供隐私。这些入口都不改变媒体 fanout/egress 上限。 - raw Viewer grant 与 Host token 等同访问凭据:只保存摘要,禁止日志/遥测/错误/Referrer/SQLite 明文。显示名、room-scoped peer 后缀和 IP 诊断均不参与授权。 - 如果未来使用 SFU 且要求服务器看不到内容,再评估 SFrame/WebRTC Encoded Transform 和群组密钥管理。 diff --git a/docs/status.md b/docs/status.md index 6e0d88b..7dd44ef 100644 --- a/docs/status.md +++ b/docs/status.md @@ -16,8 +16,8 @@ and is not part of production. ## Current Snapshot - Capture precedes room creation; source/quality changes preserve peers and pause keeps audio/connections. Quality defaults to `maintain-resolution` with balanced/fluid options. -- Production requires `HOST_ADMISSION_PASSWORD` only for creation/Host role and uses `screener-v2`, default private fragment grants, explicit public-watch, and generation-bound rotate/revoke. Viewers never need the Host secret; there is no account/session table or old parser. -- SQLite v2 keeps Host and nullable Viewer-grant digests in one checked `STRICT` row. The stopped migration retained four rooms and locked each old room private without minting a raw grant. +- Production access remains `screener-v2`: Host secret gates creation/Host; private grants and public codes authorize Viewers. The source-only, undeployed candidate adds private room-code + password entry without changing grant/public entry. +- Production SQLite v2 retains four rooms and checked grant digests. Candidate v3 atomically adds a nullable checked 48-byte salt+scrypt-verifier BLOB; never plaintext. - Source-only Native v2 uses memory-only rooms, 300s reclaim and `ROOM_TTL`; production does not run it. - PR #44's exact-room STUN-only/SFU-UDP router is enabled only for production room `1`: host/SFU roots <=2, browser relay <=1, bounded failure. HTTPS/WSS stays TLS/TCP. - Fallback prewarm is token-free and limited to room `1`; mobile/iPad viewers are leaves and healthy edges stay sticky. diff --git a/docs/方案设计.md b/docs/方案设计.md index e6013c1..d864b14 100644 --- a/docs/方案设计.md +++ b/docs/方案设计.md @@ -27,7 +27,7 @@ | Web 客户端 | React 19、TypeScript、Vite 8 | 同一套响应式界面覆盖分享者与桌面/移动观看者;官方模板成熟 | | 媒体 | 浏览器原生 WebRTC 与 Screen Capture API | 不引入 PeerJS/simple-peer,保留对 ICE、sender 参数和 stats 的直接控制 | | 应用服务 | Node.js 24 LTS、TypeScript、原生 HTTP、`ws` | 服务只负责小流量控制面;前后端可共享协议类型和校验 | -| 可选房间持久化 | Node.js 内置 `node:sqlite` | 单文件、无额外依赖;只保存房间 ID、Host token 摘要与 nullable Viewer grant 摘要 | +| 可选房间持久化 | Node.js 内置 `node:sqlite` | 单文件、无额外依赖;只保存房间 ID、Host token 摘要、nullable Viewer grant 摘要与 nullable Viewer-password KDF material | | 运行时校验 | Zod | WebSocket 和 HTTP 输入不可信,需要与 TypeScript 类型一致的运行时边界 | | 静态文件 | `sirv` | 生产环境由同一 Node 进程提供前端构建结果,避免引入完整 Web 框架 | | 地址发现/鉴权回退 | coturn 4.17.2 或更新补丁版本 | 当前 tracked template 与 production 应用只下发 STUN,但共享机保留旧 authenticated-relay daemon 配置/端口规则且不下发 credential;未来只为 controller 选中的异常 edge 签发短期 TURN,先验收 TURN REST、UDP relay range 与 quotas,不启用媒体 TCP | @@ -74,15 +74,17 @@ WebSocket upgrade 无法预知后续 role,因此不能再用 Host cookie 拒 - `private-link`:服务端用 `randomBytes(32)` 生成 256-bit 随机量,并把到期信息和随机量组成严格、room-scoped 的 opaque Viewer grant;只保存完整 grant 的 SHA-256 摘要并用定长比较验证。临时房间 grant 不晚于 room TTL;持久房间 grant 固定七天。这个无配置首版边界允许熟人跨短期游戏会话复用链接,但不让可枚举持久房间产生永久邀请;只有真实使用证据才调整。到期在首次鉴权/重连时拒绝,不定时踢出已鉴权 Viewer;即时清退使用 rotate/revoke。 - `public-watch`:纯 `/r/{roomId}` 或 `/join` 房间码允许 Viewer 进入,不签发 Viewer grant。这是有意公开的观看 policy,不是私密链接;它仍受房间 Viewer、全局/未鉴权连接和中央 egress/admission 上限保护,不提供房间目录。 -- 人类可输入的逐房间密码、逐人邀请、账号和通用授权服务不做。Capability link 已满足可信朋友的低摩擦观看;需要隔离单个观看者时再以真实需求评估多 grant,而不是先建 ACL。 +- `private-link` 另可设置一个逐房间 Viewer 密码。有效 fragment grant 保持直达;只有房间号时显示中性密码表单,正确密码仅鉴权当前 Viewer 会话。Host 可设置、改写或移除,密码接受 1 至 64 个可见 ASCII 字符且不要求组合复杂度。逐人邀请、账号、找回、ACL 和通用授权服务仍不做。 -私密邀请使用 `/r/{roomId}#v={grant}`。RFC 3986 要求 fragment 在 dereference 前由 user agent 分离,因此 HTTP request-target 与反向代理 access log 看不到它;WHATWG WebSocket 还明确拒绝含 fragment 的 URL。客户端只从页面 URL 严格解析一次,把 grant 写入 `screener:viewer-grant:{roomId}` 的 `sessionStorage`,随即以 `history.replaceState` 把地址栏替换为 canonical `/r/{roomId}`;刷新和页面内 WSS 重连继续使用该页面会话的值,独立且无 fragment/room key 的页面 fail closed。Host 建房/rotate 收到的 raw grant 同样只在内存和 room-scoped `sessionStorage` 中保留,用它临时构造复制链接;写入 `localStorage` 的 room ownership 对象只能包含 canonical URL、room ID、Host token 和期限。grant 不进入 cookie、query、WebSocket URL、Referrer、日志或错误。生产继续发送 `Referrer-Policy: no-referrer`;fragment 与该 header 降低传输泄漏,但不能防止原邀请消息、浏览器扩展、截图或用户转发,UI 必须把链接称为访问凭据。 +房间密码明文只短暂存在于当前 React 状态、WebSocket 鉴权消息和异步派生调用内存;不进入 URL/query/fragment、cookie、`localStorage`、`sessionStorage`、日志、错误或 SQLite。服务端使用 Node 标准异步 `scrypt` 固定 `N=16384,r=8,p=1`、16-byte 随机 salt 和 32-byte verifier;进程级 gate 最多执行 2 个、等待 16 个派生,队满即有界失败。等待任务取槽后先复核当前 Host/Viewer session,stale work 不启动 KDF;实际派生后再复核 room material 与连接代次。功能部署后的目标机 benchmark 再校准参数,不阻塞最小闭环。不存在房间、未配置密码、错误密码与正确密码后的满房都使用通用拒绝,不形成存在性 oracle。 -`ROOM_DATABASE_PATH` 仍是可选 SQLite 文件路径,但必须同时配置 `HOST_ADMISSION_PASSWORD`。无数据库时房间使用随机数字 ID 和 `ROOM_TTL_SECONDS`;有数据库时 SQLite `INTEGER PRIMARY KEY AUTOINCREMENT` 从 `1` 开始分配持久 room ID。所有房间继续生成 256-bit Host token,服务端只保存 SHA-256 摘要;Host 浏览器只把 room ownership 明文留在同源 `localStorage`。SQLite schema v2 在同一个 `rooms` row 上只有 `id`、32-byte `host_token_digest` 和 nullable 32-byte `viewer_grant_digest`:`NULL` 表示 public-watch,32 bytes 表示 private-link;没有用户、成员或 session 表。 +私密邀请使用 `/r/{roomId}#v={grant}`。RFC 3986 要求 fragment 在 dereference 前由 user agent 分离,因此 HTTP request-target 与反向代理 access log 看不到它;WHATWG WebSocket 还明确拒绝含 fragment 的 URL。客户端只从页面 URL 严格解析一次,把 grant 写入 `screener:viewer-grant:{roomId}` 的 `sessionStorage`,随即以 `history.replaceState` 把地址栏替换为 canonical `/r/{roomId}`;刷新和页面内 WSS 重连继续使用该页面会话的值,独立且无 fragment/room key 的页面只进入中性密码路径,没有正确房间密码仍 fail closed。Host 建房/rotate 收到的 raw grant 同样只在内存和 room-scoped `sessionStorage` 中保留,用它临时构造复制链接;写入 `localStorage` 的 room ownership 对象只能包含 canonical URL、room ID、Host token 和期限。grant 不进入 cookie、query、WebSocket URL、Referrer、日志或错误。生产继续发送 `Referrer-Policy: no-referrer`;fragment 与该 header 降低传输泄漏,但不能防止原邀请消息、浏览器扩展、截图或用户转发,UI 必须把链接称为访问凭据。 -schema v1 -> v2 使用单个 `BEGIN IMMEDIATE` 事务新增 `viewer_grant_digest BLOB NULL CHECK (viewer_grant_digest IS NULL OR length(viewer_grant_digest) = 32)`,为每个旧行写入由 Node `randomBytes(32)` 产生、未生成对应 grant 的 locked digest,再更新 `user_version`。这既满足当前 `STRICT` table 的合法类型名,也让 SQLite 真实约束长度;读取畸形非空值时仍 fail closed。旧 Host token 能认领房间,但必须 rotate 才能取得新私密邀请;旧 code-only Viewer 不能 fail-open。部署前备份 v1 数据库;由于新 release 不读写双 schema,回滚旧 binary 必须停服并恢复该备份。临时内存房间随部署重启结束。 +`ROOM_DATABASE_PATH` 仍是可选 SQLite 文件路径,但必须同时配置 `HOST_ADMISSION_PASSWORD`。无数据库时房间使用随机数字 ID 和 `ROOM_TTL_SECONDS`;有数据库时 SQLite `INTEGER PRIMARY KEY AUTOINCREMENT` 从 `1` 开始分配持久 room ID。所有房间继续生成 256-bit Host token,服务端只保存 SHA-256 摘要;Host 浏览器只把 room ownership 明文留在同源 `localStorage`。SQLite schema v3 在同一个 `rooms` row 上保存 `id`、32-byte `host_token_digest`、nullable 32-byte `viewer_grant_digest` 和 nullable 48-byte `viewer_password_material`;后者是 16-byte salt 与 32-byte scrypt verifier 的单一 BLOB,`CHECK` 只允许 `NULL` 或准确长度 48,避免半写状态。没有明文房间密码、用户、成员或 session 表。 -rotate、revoke 或从 public-watch 切回 private-link 都是强撤销:服务端先原子提交新 digest/authorization generation;成功后清除所有 Viewer grace、连接代次与路由状态,向 Viewer 发送通用访问已撤销终态使其立即关闭接收 `RTCPeerConnection`/媒体并停止重连,同时向每个 Host/relay/SFU upstream owner 发送权威旧边移除使其关闭对应发送边,最后关闭 Viewer socket。不能只断 WSS,因为健康 P2P 可在信令断开后继续播放。rotate 与 public-to-private 都生成并仅返回一次新 grant;revoke 写入未生成对应 grant 的 fresh random locked digest,房间保持私密锁定直到下一次 rotate。数据库写失败时,内存摘要、现有 Viewer、媒体和拓扑保持不变。切到 public-watch 只放宽以后 Viewer 的 code-only 鉴权,不需要迁移健康媒体。服务端永不返回现有 grant 明文;丢失 Host 标签页中的 grant 时只能 rotate。 +schema v1/v2 -> v3 使用单个 `BEGIN IMMEDIATE` 事务。v1 先新增 checked `viewer_grant_digest` 并为旧行写入没有对应 raw grant 的随机 locked digest;v1 与 v2 都新增 checked `viewer_password_material`,默认 `NULL`,最后更新 `user_version`。读取畸形非空值时 fail closed。旧 Host token 能认领房间;旧 code-only Viewer 不能 fail-open。部署前备份数据库;由于新 release 不读写双 schema,回滚旧 binary 必须停服并恢复备份。临时内存房间随部署重启结束。 + +rotate、revoke 或从 public-watch 切回 private-link 都是当前 Viewer 的强撤销:服务端先原子提交新 digest/authorization generation;成功后清除所有 Viewer grace、连接代次与路由状态,向 Viewer 发送通用访问已撤销终态使其立即关闭接收 `RTCPeerConnection`/媒体并停止重连,同时向每个 Host/relay/SFU upstream owner 发送权威旧边移除使其关闭对应发送边,最后关闭 Viewer socket。不能只断 WSS,因为健康 P2P 可在信令断开后继续播放。rotate 与 public-to-private 都生成并仅返回一次新 grant;revoke 写入未生成对应 grant 的 fresh random locked digest,不再允许旧 grant,但已配置的房间密码仍是独立的以后进入方式。数据库写失败时,内存摘要、现有 Viewer、媒体和拓扑保持不变。切到 public-watch 只放宽以后 Viewer 的 code-only 鉴权,不需要迁移健康媒体。服务端永不返回现有 grant 明文;丢失 Host 标签页中的 grant 时只能 rotate。 房间规则: @@ -102,17 +104,18 @@ Web Host 在 `authenticate` 中显式发送 `viewerPresence: true` capability; ## 信令协议 -WebSocket 使用无 query/fragment 的同源 `/signal`。upgrade 只记录 Host-admission cookie 是否有效;连接后五秒内第一条消息必须是 `screener-v2` 的房间级 `authenticate`。Host 消息携带 room、role、内部 Host token 与 clientId,并由 socket state 复核 Host admission;Viewer 消息携带 room、role、clientId 和 private-link 时的 Viewer grant。服务端设置 64 KiB `maxPayload`、关闭 `perMessageDeflate`,并以 ping/pong 清理失效连接。单进程固定限制 2048 条总连接和 256 条未鉴权连接,容量在 upgrade 前预留,超限直接返回 HTTP 503 并销毁 socket。 +WebSocket 使用无 query/fragment 的同源 `/signal`。upgrade 只记录 Host-admission cookie 是否有效;连接后五秒内第一条消息必须是 `screener-v2` 的房间级 `authenticate`。Host 消息携带 room、role、内部 Host token 与 clientId,并由 socket state 复核 Host admission;Web Host 可选发 `viewerPasswordSettings: true` capability,Native v2 不发送且不接收任何密码设置新消息。Viewer 消息携带 room、role、clientId,以及 private room 的 Viewer grant 或当前尝试的房间密码。服务端设置 64 KiB `maxPayload`、关闭 `perMessageDeflate`,并以 ping/pong 清理失效连接。单进程固定限制 2048 条总连接和 256 条未鉴权连接,容量在 upgrade 前预留,超限直接返回 HTTP 503 并销毁 socket。 主要消息: | 方向 | 消息 | 作用 | | --- | --- | --- | -| client -> server | `authenticate` | Host 以 upgrade 时的 admission 状态加 room-scoped Host token 建立会话,并可显式订阅 Web-only viewer presence;Viewer 以 public room code 或 private Viewer grant 建立仅观看会话并可附带规范化本地显示名 | +| client -> server | `authenticate` | Host 以 upgrade 时的 admission 状态加 room-scoped Host token 建立会话,并可显式订阅 Web-only viewer presence/password settings;Viewer 以 public room code、private Viewer grant 或当前房间密码尝试建立仅观看会话并可附带规范化本地显示名 | | server -> client | `authenticated` | 返回角色、peerId、可为空的房间期限、容量、host 状态、该 viewer 的历史 connectionId、host 的权威 viewer 列表和 ICE 配置 | | viewer -> server | `set-display-name` | 只更新该 Viewer 当前 authenticated socket state 的规范化名称,不写房间或数据库状态 | | server -> subscribed Web host | `viewer-presence` | 返回全部当前在线 Viewer 的权威 snapshot 与只读 active media topology;不触发媒体连接 | | host -> server -> host | `set-viewer-access` / `viewer-access-updated` | Host 切换 public/private、rotate 或 revoke;raw grant 只在新建、rotate 或 public-to-private 成功响应中出现一次 | +| opted-in Web host -> server -> host | `set-viewer-password` / `viewer-password-updated` | 设置、替换或移除逐房间 Viewer 密码;服务端只返回 enabled snapshot,不回显密码;未声明 capability 的 v2 Host 不接收该新 type | | server -> host | `peer-joined` / `peer-left` | 建立或关闭该观看者的独立连接 | | host -> viewer | `offer` / `candidate` | 带 `peerId` 与 `connectionId` 的定向信令 | | viewer -> host | `answer` / `candidate` | 只能回给本房间分享者 | @@ -313,10 +316,10 @@ hop 按[低服务器成本媒体路由](./research/low-server-media-routes.md) 首屏就是工具,不做营销页。视觉采用中性深灰工作台,绿色表示 direct、琥珀色表示 relay/警告、红色表示失败,避免单一蓝紫色主题;浏览器 favicon 复用页头 `MonitorUp` 的深绿底、绿色显示器和上行箭头语言。 -- 访问入口:未鉴权根路径和 `/join` 只显示中性的“访问验证/访问口令”,未知路径只显示“无法访问”,HTML metadata 不说明站点用途。有效 `/r/{code}#v={grant}` 或 public-watch `/r/{code}` 先完成 Viewer 鉴权,成功后才渲染房间 UI;无效、过期或满员只显示通用拒绝。Viewer 页面不显示或泄露部署访问口令。这是降低偶然访客信息量的展示边界,不把浏览器可下载的 bundle 当成秘密。 +- 访问入口:未鉴权根路径和 `/join` 只显示中性的“访问验证/访问口令”,未知路径只显示“无法访问”,HTML metadata 不说明站点用途。有效 `/r/{code}#v={grant}` 或 public-watch `/r/{code}` 先完成 Viewer 鉴权并直接进入;private room 仅有房间号时在同一中性边界显示“访问密码”输入,成功后才渲染房间 UI。无效、过期、错误密码或满员只显示通用拒绝。Viewer 页面不显示或泄露部署访问口令或站点用途。这是降低偶然访客信息量的展示边界,不把浏览器可下载的 bundle 当成秘密。 - 分享者根页面:顶部显示“{分享者显示名} 的房间”和房间状态,并在标题旁显示可单独复制的紧凑房间号;主区域保持稳定的 16:9 舞台。未发布、已停止或可恢复错误状态时,舞台内以同等尺寸和权重显示“开始分享”和“加入房间”,中间只用“或”与分隔线表达发送/接收;前者直接开始现有分享流程,后者直接进入 `/join`,不增加首页、向导或拓扑选择。直播时舞台显示本地预览,底部保留停止、暂停、质量档位和复制完整邀请链接。 -- `/join`:只输入正整数房间码并进入 `/r/{code}`,仅 public-watch 会通过;private-link 缺失 grant 时统一提示使用完整邀请链接。 -- `/r/{code}` 观看者:主区域最大化远端视频;private-link 首次解析 fragment grant 后立即清理地址栏,public-watch 只需 code;标题旁显示可复制的紧凑房间号,顶部保留连接状态,并提供本地音量、静音、全屏和重试控制。 +- `/join`:只输入正整数房间码并进入 `/r/{code}`;public-watch 直接通过,private room 缺失 grant 时显示中性房间密码表单。 +- `/r/{code}` 观看者:未鉴权时不渲染产品用途;private room 首次解析 fragment grant 后立即清理地址栏并直达,仅有 code 时可用房间密码;public-watch 只需 code。鉴权后主区域最大化远端视频,标题旁显示可复制的紧凑房间号,顶部保留连接状态,并提供本地音量、静音、全屏和重试控制。 - 移动端:视频保持稳定宽高比,控制与状态移到视频下方,任何文字和按钮不得溢出或遮挡。 两端后续默认显示紧凑在线 roster;Host 与 Viewer 均可修改本机显示名,展示为名称加 room-scoped `peerId` 短后缀。一个不持久化的“显示连接详情”复选框按页展开信令、ICE/TURN、浏览器实际暴露的本地/远端候选地址、codec、RTT、RTP 丢包率、编解码耗时及逐边统计;真实连接失败、音频缺失、持续画质受限和需要用户动作的告警始终可见。用户名/roster 是独立 PR,不进入 access credential 或 SQLite。 @@ -330,7 +333,7 @@ hop 按[低服务器成本媒体路由](./research/low-server-media-routes.md) ## 安全与故障边界 - 生产仅允许 HTTPS/WSS,并校验 WebSocket `Origin` allowlist。 -- Host admission 密码以定长摘要配合常量时间比较;Host-admission cookie 由服务端 HMAC 签名且不含账号或可查询 session ID。Host token 与 Viewer grant 只保存 SHA-256 摘要并以定长比较验证;Host admission 与建房 HTTP 响应设置 `Cache-Control: no-store`。 +- Host admission 密码以定长摘要配合常量时间比较;Host-admission cookie 由服务端 HMAC 签名且不含账号或可查询 session ID。Host token 与 Viewer grant 只保存 SHA-256 摘要并以定长比较验证;房间密码只保存带随机 salt 的 scrypt verifier material,并对派生结果做定长比较。Host admission 与建房 HTTP 响应设置 `Cache-Control: no-store`。 - Fragment 只避免其进入 HTTP/WebSocket request-target,不能把 capability 变成非敏感数据。客户端首次读取后立即清理地址栏,只在 room-scoped `sessionStorage` 留存;服务端和代理日志不得记录 WebSocket payload、Authorization、cookie、raw grant 或响应 body,异常只能记录安全路径、状态码和分类。 - HTTP body、WebSocket payload、总连接数、未鉴权连接数、输出缓冲、房间总数、观看人数和 candidate 缓存都有硬上限;无法解析的 upgrade request-target 在未鉴权边界返回 HTTP 400,不允许异常逃逸到进程顶层。 - 应用日志不得主动记录 IP;反向代理、STUN 和 LiveKit 为提供网络服务必然可见源地址,部署时需设置最短必要留存与访问控制。 @@ -343,11 +346,11 @@ hop 按[低服务器成本媒体路由](./research/low-server-media-routes.md) - HostAdmission 的 production 必配、8 至 128 visible-ASCII bytes/独立 secret、local-dev 显式关闭、12 小时签名 cookie、过期、篡改、cookie 属性、旧 `ACCESS_PASSWORD` fail-fast 和无服务端 session 状态; - RoomStore 的随机临时房间、TTL、Host token/Viewer grant 摘要验证、private/public policy、grant 到期/跨房拒绝、rotate/revoke 强撤销、停止分享等待、Viewer 上限和超额拒绝; -- SQLite 房间库从 `1` 自增、重启恢复、v1 -> v2 单事务 locked-private 迁移、nullable 32-byte CHECK、畸形值 fail-closed、rotate/revoke 写失败原子性和数据最小化;数据库路径无 Host admission 时启动失败; +- SQLite 房间库从 `1` 自增、重启恢复、v1/v2 -> v3 单事务迁移、nullable 32-byte grant 与 48-byte password material CHECK、畸形值 fail-closed、rotate/revoke/password 写失败原子性和数据最小化;数据库路径无 Host admission 时启动失败; - STUN/TURN UDP URL 严格校验、生产 STUN 必需、selected-edge tuple 完整性/TTL/secret 独立性、issuer HMAC/expiry/opaque tag、当前 route/session/revision/old-new connection generation 与 one-use rebuild guard,以及 ordinary peer、未命中房间和 Native 的 STUN-only isolation;LiveKit `sfu-config` 保持独立边界; - 单一 `screener-v2` 信令 literal、v1 旧页面 mismatch 的一次性刷新终态,以及协议非法 JSON、未知消息、错误方向、跨房间目标和 payload 限制; - 质量协议的 host 授权、严格字段与数值范围、last-wins、当前/未来 relay、已配置 SFU 和普通 P2P wire 兼容;sender 的四字段 requested/applied 读回、拒绝与持续受限提示; -- HTTP/WS 集成:nginx per-source admission `limit_req` 与统一错误不泄露 secret,应用不建立 IP limiter 状态;Viewer 无 Host cookie 可在 Origin/容量门下 upgrade;Host 首条鉴权同时要求 upgrade-captured admission 与 Host token,Viewer 只按 room policy;Host cookie 不绕过 private grant,grant 不能建房/Host/跨房。覆盖 private fragment/sessionStorage/replaceState、独立无 grant 访问、不含 query/Referrer/log、public code-only、public-to-private 一次性 grant,以及 rotate/revoke 后 grace/route 清理、旧 Viewer/上游媒体边关闭和 reconnect 拒绝; +- HTTP/WS 集成:nginx per-source admission `limit_req` 与统一错误不泄露 secret,应用不建立 IP limiter 状态;Viewer 无 Host cookie 可在 Origin/容量门下 upgrade;Host 首条鉴权同时要求 upgrade-captured admission 与 Host token,Viewer 只按 room policy;Host cookie 不绕过 private grant/password,grant/password 不能建房、取得 Host 或跨房。覆盖 private fragment/sessionStorage/replaceState、房间号+正确/错误/已替换/已移除密码、不含明文于 URL/浏览器持久存储/SQLite/log、public code-only、v2 Native 无 capability 不收新 wire,以及 rotate/revoke 后 grace/route 清理、旧 Viewer/上游媒体边关闭和 reconnect 拒绝; - 客户端策略与 stats 解析:会话替换不重连,ordinary ICE、LiveKit 与 selected-edge TURN protocol 分离,远端 TURN transport 保持未知; - TypeScript 类型检查、Vite production build 和仓库 hygiene。 diff --git a/docs/需求理解.md b/docs/需求理解.md index 51b54ea..62bf1f9 100644 --- a/docs/需求理解.md +++ b/docs/需求理解.md @@ -13,7 +13,7 @@ ### P0 - 必须实现 - [ ] 首屏双入口:分享入口通过 Host admission 后,主工作区必须同等突出“开始分享”和“加入房间”两个主要动作,并用清楚的视觉分隔表达发送与接收;观看者不需要知道 Host admission 密码。点击后直接进入现有分享或房间加入流程,不增加营销首页、模式向导或拓扑选择。浏览器标签页使用与现有 `MonitorUp` 品牌语言一致的 favicon。 -- [ ] 简单房间:分享者获得纯数字房间码。房间默认是私密链接模式,邀请使用 `/r/{code}#v={grant}`;拥有该链接只可作为 Viewer 进入这一间房,不能建房、发布、冒充 Host 或跨房。Host 可显式选择 public-watch,使 `/r/{code}` 和 `/join` 的纯房间码可直接观看;当前已部署 slice 尚未实现房间密码,已接受的后续边界见 P1。Host 与 Viewer 标题附近都紧凑、显眼地显示房间号并提供只复制房间号的动作,Host 保留复制完整邀请链接;持久房间码从 `1` 开始自增,其他模式继续使用随机临时房间码。 +- [ ] 简单房间:分享者获得纯数字房间码。房间默认是私密模式,现有 `/r/{code}#v={grant}` 邀请继续直接进入;Host 也可给该房间设置低门槛观看密码,使仅有 `/r/{code}` 或 `/join` 房间码的 Viewer 在中性页面输入密码后进入。Host 可显式选择 public-watch,使纯房间码直接观看。当前源码候选已实现密码入口,但线上版本尚未部署。所有入口只授予该房间 Viewer,不能建房、发布、冒充 Host 或跨房。Host 与 Viewer 标题附近都紧凑、显眼地显示房间号并提供只复制房间号的动作,Host 保留复制完整邀请链接;持久房间码从 `1` 开始自增,其他模式继续使用随机临时房间码。 - [ ] 免安装观看:桌面和手机观看者可直接通过浏览器链接进入、授权并播放,不依赖与分享端相同的桌面客户端。 - [ ] 屏幕采集:分享者可主动选择显示器或应用窗口,开始、停止、临时暂停画面和切换分享来源。Host 本地预览必须直接使用 capture stream,不得为预览建立 viewer/媒体边或消耗 TURN、SFU、应用服务器 media egress;预览仍可能产生本地合成、缩放和 GPU 显示成本,不能把隐藏预览称为停止采集或编码。 - [ ] 游戏声音:Windows Chrome/Edge 目标环境可请求系统音频;没有返回音轨时必须明确提示,而不是静默无声。 @@ -29,13 +29,13 @@ - [ ] 断线恢复:临时网络变化时按不同层级做一次性恢复,而不是把同一动作机械重复三次。`failed` 边先做一次 ICE restart,再重建一次同 parent 的 `RTCPeerConnection`,随后尝试一个不同的合格 peer parent;只有 peer 路径耗尽才进入 SFU。`connecting`/`checking` 先显示不改变拓扑的慢连接状态,硬超时必须由真实移动网络证据确定,不能为了更快使用服务器而过早放弃最终可成功的 P2P。任一步恢复即停止后续动作;所有异步结果受 session、route revision、assignment/connection generation 约束。active SFU 只允许一次 fresh-token 重试,再失败即有界回到 peer baseline 或明确失败,并向 Host 显示不含 URL、地址、token 或原始错误的失败阶段。 - [ ] 可观测性:按目标 RTP transport 与不透明连接/ICE 代次关联 selected path、`iceGatheringState`/ICE/connection state、事件推导的建立时长、generation-baselined pair-change delta,以及 RTT、RTP 丢包率、抖动、码率、帧率、编码耗时和受限原因。候选与错误的 wire、日志和导出只使用[低服务器成本媒体路由](./research/low-server-media-routes.md)的脱敏字段和 code bucket,禁止 raw SDP、candidate、地址、URL、`errorText` 或网络标识。只有创建该 `RTCPeerConnection` 的本机可在折叠详情中显示浏览器对 selected local/remote candidate 实际暴露的地址与端口;该值不上传、不持久化、不用于地理/NAT 推断,缺失或被隐私保护时显示未知。 - [ ] 渐进式诊断:分享者和观看者默认只看到房间、画面、核心状态和常用控制;候选路径、RTT、丢包、编解码器、编码耗时等技术详情由一个明确的“显示连接详情”开关展开。隐藏诊断不能隐藏失败原因、音频缺失、持续画质受限或需要用户操作的告警。 -- [ ] Host admission:生产部署必须配置独立的 `HOST_ADMISSION_PASSWORD` 控制建房和 Host 信令角色;只有本地开发/测试可显式关闭。它只是防止随手滥用的部署访问口令,不是账号密码;非空值接受 8 至 128 个可见 ASCII bytes,不能复用 TURN、LiveKit、TLS 或其他部署 secret。验证成功签发 12 小时、无服务端会话状态的 HttpOnly、`SameSite=Strict` Host-admission cookie。未鉴权根路径、`/join` 和未知路径只显示中性的访问验证或通用拒绝,不说明站点用途;有效 private-link grant 或 public-watch 直链仍直接进入目标房间。这只是界面最小暴露,不把可下载的前端代码误当保密边界。建房 HTTP 只认该 cookie且不提供 Bearer 旁路;Host admission endpoint 由部署层/nginx 做 per-source `limit_req`,失败只返回统一结果,不在 Node 新增 IP 身份库/LRU/session 表或持久化来源地址。WebSocket upgrade 继续校验 Origin 与全局/未鉴权容量并记录 cookie 状态,首条 Host `authenticate` 必须同时通过该状态与房间 `hostToken`。Viewer 不经过此 gate,Host cookie 也不能绕过私密房间的 Viewer grant。不引入账号、JWT、服务端 session Map 或通用 logout。 -- [ ] 房间级观看授权:新房间默认生成含 256-bit 随机量、限时且可撤销的 room-scoped Viewer grant;服务端只保存完整 grant 的 SHA-256 摘要。到期阻止以后鉴权/重连,但不为已鉴权 Viewer 设置踢出定时器;即时清退使用 rotate/revoke。浏览器从 URL fragment 严格读取 grant 后,只写该房间的 `sessionStorage` 并立即用 `history.replaceState` 清除 fragment;刷新和页面内重连可继续,独立且没有 grant 的访问必须 fail closed。grant 不得进入 `localStorage`、cookie、query、WebSocket URL、Referrer、日志或错误。Host 可 rotate 或 revoke;revoke 写入一个未生成对应 grant 的 locked-private digest,直到下一次 rotate 才产生新邀请。新摘要成功提交后必须清除 Viewer grace/路由、终止旧授权代次的信令和媒体并断开全部旧 Viewer,数据库写失败则不得改变连接。public-watch 是显式、可逆的 room policy,只放宽观看,不放宽建房或发布。 -- [ ] 最小持久化:`ROOM_DATABASE_PATH` 可选且必须与 `HOST_ADMISSION_PASSWORD` 同时配置。两者都存在时,SQLite 只持久保存从 `1` 开始自增的房间 ID、Host token 摘要和同一 `rooms` 行上的 nullable Viewer grant 摘要;`NULL` 表示 public-watch,非空值必须由数据库 `CHECK` 约束为 32-byte BLOB,不新增用户、成员或 session 表。持久房间停止后仍显示等待状态;私密邀请按期限或 Host rotate 更新。旧 schema 行在原子迁移时必须 fail-closed 为没有可用明文 grant 的锁定私密状态,Host rotate 后才能生成新邀请。 +- [ ] Host admission:生产部署必须配置独立的 `HOST_ADMISSION_PASSWORD` 控制建房和 Host 信令角色;只有本地开发/测试可显式关闭。它只是防止随手滥用的部署访问口令,不是账号密码;非空值接受 8 至 128 个可见 ASCII bytes,不能复用 TURN、LiveKit、TLS 或其他部署 secret。验证成功签发 12 小时、无服务端会话状态的 HttpOnly、`SameSite=Strict` Host-admission cookie。未鉴权根路径、`/join` 和未知路径只显示中性的访问验证或通用拒绝,不说明站点用途;有效 private-link grant 或 public-watch 直链仍直接进入目标房间,正确房间密码从中性表单进入。这只是界面最小暴露,不把可下载的前端代码误当保密边界。建房 HTTP 只认该 cookie且不提供 Bearer 旁路;Host admission endpoint 由部署层/nginx 做 per-source `limit_req`,失败只返回统一结果,不在 Node 新增 IP 身份库/LRU/session 表或持久化来源地址。WebSocket upgrade 继续校验 Origin 与全局/未鉴权容量并记录 cookie 状态,首条 Host `authenticate` 必须同时通过该状态与房间 `hostToken`。Viewer 不经过此 gate,Host cookie 也不能绕过私密房间的 Viewer grant/password。不引入账号、JWT、服务端 session Map 或通用 logout。 +- [ ] 房间级观看授权:新房间默认生成含 256-bit 随机量、限时且可撤销的 room-scoped Viewer grant;服务端只保存完整 grant 的 SHA-256 摘要。到期阻止以后鉴权/重连,但不为已鉴权 Viewer 设置踢出定时器;即时清退使用 rotate/revoke。浏览器从 URL fragment 严格读取 grant 后,只写该房间的 `sessionStorage` 并立即用 `history.replaceState` 清除 fragment;刷新和页面内重连可继续。私密房间缺失 grant 时只有正确的该房间观看密码可进入;两者都没有必须 fail closed。grant 不得进入 `localStorage`、cookie、query、WebSocket URL、Referrer、日志或错误。Host 可 rotate 或 revoke;revoke 只锁定 grant 路径,已配置的房间密码仍是独立的后续进入方式。新 grant 摘要成功提交后必须清除 Viewer grace/路由、终止旧授权代次的信令和媒体并断开全部旧 Viewer,数据库写失败则不得改变连接。public-watch 是显式、可逆的 room policy,只放宽观看,不放宽建房或发布。 +- [ ] 最小持久化:`ROOM_DATABASE_PATH` 可选且必须与 `HOST_ADMISSION_PASSWORD` 同时配置。两者都存在时,SQLite 只持久保存从 `1` 开始自增的房间 ID、Host token 摘要、nullable Viewer grant 摘要,以及 nullable 48-byte Viewer-password material(16-byte salt + 32-byte scrypt verifier);数据库 `CHECK` 约束非空 BLOB 长度,不新增用户、成员或 session 表。明文房间密码只短暂存在于当前页面和鉴权/派生调用内存,不进入 URL、cookie、浏览器持久存储、日志或 SQLite。持久房间停止后仍显示等待状态;私密邀请按期限或 Host rotate 更新。旧 schema 行在原子迁移时必须 fail-closed 为没有可用明文 grant 的锁定私密状态,Host rotate 后才能生成新邀请。 ### P1 - 重要功能 -- [ ] 私密房间双入口:在不引入账号、密码找回或通用服务端 session 的前提下,private room 同时接受“房间号 + 该房间观看密码”和现有 `/r/{code}#v={grant}` 私密邀请;有效 grant 直接进入目标房间,没有 grant 的 `/join` 或 `/r/{code}` 只显示中性的房间密码输入。Host 可设置或轮换房间观看密码,并可选择复制仅含房间号的入口或默认附带 room-scoped access token 的完整邀请。房间密码、grant 与 verifier 的权限都只能是该房间 Viewer,不得进入 URL query、`localStorage`、日志或 SQLite 明文,也不能取得 Host admission、建房、发布或跨房权限。失败响应不区分房间不存在与密码错误,并复用有界 admission 限制;确切 verifier/KDF 参数必须在目标 1 GiB 部署机基准后选择。该项排在当前自动路由、selected-edge TURN 兜底和 Windows Native 主线之后,不得提前改写生产能力声明。 +- [ ] 私密房间双入口:在不引入账号、密码找回或通用服务端 session 的前提下,private room 同时接受“房间号 + 该房间观看密码”和现有 `/r/{code}#v={grant}` 私密邀请;有效 grant 直接进入目标房间,没有 grant 的 `/join` 或 `/r/{code}` 只显示中性的房间密码输入。Host 可设置、轮换或移除房间观看密码。密码接受 1 至 64 个可见 ASCII 字符,不要求字符组合复杂度;服务端先以固定保守参数的 Node 异步 scrypt 落地,全局最多执行 2 个、等待 16 个派生,取槽后跳过 stale session,目标 1 GiB 部署机的参数 benchmark 与优化在功能部署后进行。房间密码、grant 与 verifier 的权限都只能是该房间 Viewer;明文密码不得进入 URL/query/cookie、`localStorage`、`sessionStorage`、日志或 SQLite,也不能取得 Host admission、建房、发布或跨房权限。不存在、错误密码、队满和正确密码后的满房都返回通用结果。当前源码候选已实现该最小闭环,线上仍未部署。 - [ ] 实验路由按房间灰度:部署开启 `PEER_ASSISTED_MEDIA` 时必须同时提供非空、严格的房间 ID allowlist,只有指定房间进入 peer-assisted 与已配置的 SFU 路径;缺失、空值、非法或重复配置启动即失败,绝不 fail-open 为全房。未命中房间继续使用当前 ordinary P2P 拓扑及其质量拒绝、停止、重连和删除语义,并保持 STUN-only。未来 selected-edge TURN 只能复用同一 controller 的当前 assignment/session/revision/connection generation,不得借 allowlist 向普通 peer edge 预发 TURN candidates。旧 production release 与新 candidate 必须用隔离实例验证和回滚,不能在产品代码建立长期双 transport 分支。exact-room 只服务本阶段验证;验收后以独立 coherent change 退役灰度边界,不能靠清空变量暗中放量。该能力不增加 UI、用户选择、百分比发布或第二套路由器。 @@ -77,7 +77,7 @@ - 首版不包含远程键鼠控制、公开/大规模直播、录制、社区系统或完整语音聊天;大规模分享直接使用现有直播服务。 - public-watch 中房间码本身就是有意公开的观看入口,不提供隐私保证、目录或防转发承诺;随机/自增房间码均不得被描述为私密邀请。现有每房 Viewer 上限、全局/未鉴权连接上限、房间上限和中央 egress/admission cap 仍适用,public-watch 不能创建无界服务器 fanout。 -- SQLite 只用于持久房间身份与最小授权摘要:房间 ID、Host token 摘要、nullable Viewer grant 摘要。不得保存 Host admission 密码、token/grant 明文、cookie、显示名、成员、`clientId`、`peerId`、SDP、ICE candidate、IP 地址、媒体或 TURN 凭据;首版仍是单进程、单实例控制面。 +- SQLite 只用于持久房间身份与最小授权材料:房间 ID、Host token 摘要、nullable Viewer grant 摘要和 nullable 48-byte Viewer-password salt+verifier。不得保存 Host admission 密码、房间观看密码、token/grant 明文、cookie、显示名、成员、`clientId`、`peerId`、SDP、ICE candidate、IP 地址、媒体或 TURN 凭据;首版仍是单进程、单实例控制面。 - 当前八名观看者和 1 至 16 配置范围是接入边界;目标默认 20 也只是通过指定矩阵后的接入边界,不承诺任意机器、档位、网络或跨洲路径都稳定承载。 - 浏览器规范要求每次由用户主动选择分享源,权限不能永久保存;纯网页不能保证指定游戏窗口或始终获得系统音频。 - 1080p60 是能力目标,实际分辨率、帧率和硬件编码由浏览器、GPU、驱动、画面复杂度及网络共同决定。 @@ -95,10 +95,11 @@ - Given production Host admission secret 短于 8 bytes、长于 128 bytes 或含非可见 ASCII,When 新 release 启动,Then 必须 fail-fast。Given 同一来源连续提交错误 secret,When 超过 deployment `limit_req` 的 `5r/m`、`burst=5 nodelay` 边界,Then nginx 返回不含凭据的通用限频结果;允许请求的错误值仍使用常量时间比较和统一鉴权失败,limiter 只使用 nginx 内存且不新增 IP 持久化/日志字段。 - Given `ROOM_DATABASE_PATH` 为空,When 创建房间,Then 无论是否启用 Host admission 都使用随机临时房间码并遵守 `ROOM_TTL_SECONDS`;停止分享本身不立即销毁房间。 - Given 新建私密房间,When 服务端返回邀请并由 Host/Viewer 客户端读取,Then grant 含 256-bit 随机量、只授权该房间 Viewer、只在该页面会话的 room-scoped `sessionStorage` 留存,并在首次读取后从地址栏移除;HTTP request-target、WebSocket URL、Referrer、cookie、`localStorage`、SQLite 明文、日志和错误均不包含 grant。Given 独立页面既无 fragment 也无该 room key,When 尝试进入私密房间,Then 必须要求完整邀请而不是退回 code-only。 -- Given Viewer 只有 Host admission cookie、另一个房间的 grant、缺失/过期 grant 或自行把 role 改为 Host,When 首条信令鉴权到达,Then 私密房间返回同一通用无效/过期邀请结果且不泄露房间存在性;不能建房、发布、跨房或绕过 Host token。 -- Given Host rotate/revoke 私密邀请或把 public-watch 改为私密,When 新摘要持久化成功,Then 服务端先提交新授权代次,再清除 Viewer grace/路由,命令 Viewer 关闭接收 PC/媒体并让每个 Host/relay/SFU upstream owner 关闭对应旧代次媒体边,最后关闭旧 Viewer socket;rotate 和 public-to-private 各返回一次新 grant,revoke 不返回 grant 且保持 locked-private,旧页面停止自动重连。Given 持久化失败,Then 旧摘要、连接和媒体保持不变。 +- Given Viewer 只有 Host admission cookie、另一个房间的 grant、缺失/过期 grant 且没有正确房间密码,或自行把 role 改为 Host,When 首条信令鉴权到达,Then 私密房间返回同一通用无效结果且不泄露房间存在性;不能建房、发布、跨房或绕过 Host token。 +- Given Host rotate/revoke 私密邀请或把 public-watch 改为私密,When 新摘要持久化成功,Then 服务端先提交新授权代次,再清除 Viewer grace/路由,命令 Viewer 关闭接收 PC/媒体并让每个 Host/relay/SFU upstream owner 关闭对应旧代次媒体边,最后关闭旧 Viewer socket;rotate 和 public-to-private 各返回一次新 grant,revoke 不返回 grant 且锁定 grant 路径,已配置的房间密码仍是独立的以后进入方式。Given 持久化失败,Then 旧摘要、连接和媒体保持不变。 - Given 房间处于 public-watch,When 用户只输入房间码,Then 可在现有 Viewer/连接/egress 上限内观看,但仍不能创建房间、取得 Host 角色或枚举房间目录;Host 切回私密时签发新 grant 并撤销现有 code-only Viewer。 -- Given schema v1 SQLite 在备份后首次由新 release 打开,When 单事务升级到 v2,Then 只新增 `viewer_grant_digest BLOB NULL CHECK (viewer_grant_digest IS NULL OR length(viewer_grant_digest) = 32)`,旧行被置为未生成对应 grant 的锁定私密摘要;旧 Host token 仍可回收房间并 rotate,旧 Viewer 链接不能 fail-open。畸形非空值必须拒绝写入或读取时 fail closed;回滚旧 binary 必须恢复迁移前备份,不运行双 schema 代码。 +- Given private room 配置了观看密码,When Viewer 仅以房间号访问,Then 未授权页面只显示中性密码表单;正确密码只鉴权当前 Viewer 会话,错误密码与不存在房间返回同一通用结果。Given Host 改密或移除,Then 旧密码的以后鉴权失败,现有 fragment grant 仍可直接进入;public-watch 纯房间码仍直接进入。 +- Given schema v1 或 v2 SQLite 在备份后首次由新 release 打开,When 单事务升级到 v3,Then v1 先补 checked `viewer_grant_digest` 并 fail-closed 锁定旧行,v1/v2 都新增 `viewer_password_material BLOB NULL CHECK (viewer_password_material IS NULL OR length(viewer_password_material) = 48)`;旧 Host token 仍可回收房间,旧 Viewer 链接不能 fail-open。畸形非空值必须拒绝写入或读取时 fail closed;回滚旧 binary 必须恢复迁移前备份,不运行双 schema 代码。 - Given 部署切换了不兼容的当前协议,When 仍打开的旧页面连接新服务端,Then 单一 release/protocol mismatch 边界终止该 session、停止自动重连并明确提示刷新;不得通过旧 parser、双写或双协议继续假连接。 ### P2P、SFU 与可选 TURN 兜底 diff --git a/src/client/pages/HostPage.tsx b/src/client/pages/HostPage.tsx index 35a1ed7..b32d34d 100644 --- a/src/client/pages/HostPage.tsx +++ b/src/client/pages/HostPage.tsx @@ -3,18 +3,22 @@ import { Copy, Globe2, Hash, + KeyRound, LockKeyhole, MonitorUp, Pause, Play, RefreshCw, Square, + Trash2, Users, } from "lucide-react"; import { useEffect, useMemo, useRef, useState } from "react"; import { DEFAULT_QUALITY_SETTINGS, + MAX_VIEWER_PASSWORD_LENGTH, VIEWER_QUALITY_EVIDENCE_EXPIRY_MS, + viewerPasswordSchema, type CreateRoomResponse, type IceConfig, type ServerMessage, @@ -185,6 +189,9 @@ export function HostPage({ onAuthorizationRequired }: HostPageProps = {}) { const [newRoomViewerPolicy, setNewRoomViewerPolicy] = useState("private-link"); const [viewerAccessUpdating, setViewerAccessUpdating] = useState(false); + const [viewerPasswordEnabled, setViewerPasswordEnabled] = useState(false); + const [viewerPasswordDraft, setViewerPasswordDraft] = useState(""); + const [viewerPasswordUpdating, setViewerPasswordUpdating] = useState(false); const [maxViewers, setMaxViewers] = useState(null); const [peerSnapshots, setPeerSnapshots] = useState>( () => new Map(), @@ -205,6 +212,7 @@ export function HostPage({ onAuthorizationRequired }: HostPageProps = {}) { const videoRef = useRef(null); const streamRef = useRef(null); const signalRef = useRef(null); + const viewerPasswordActionRef = useRef<"set" | "remove" | null>(null); const iceConfigRef = useRef(null); const peersRef = useRef(new Map()); const retiredConnectionsRef = useRef(new Map()); @@ -888,6 +896,19 @@ export function HostPage({ onAuthorizationRequired }: HostPageProps = {}) { ); return; } + if (message.type === "viewer-password-updated") { + const action = viewerPasswordActionRef.current; + viewerPasswordActionRef.current = null; + setViewerPasswordEnabled(message.enabled); + setViewerPasswordUpdating(false); + setViewerPasswordDraft(""); + if (action) { + setNotice( + action === "remove" ? "访问密码已移除" : "访问密码已更新", + ); + } + return; + } if (message.type === "viewer-presence") { setViewerPresence(message.viewers); return; @@ -975,6 +996,8 @@ export function HostPage({ onAuthorizationRequired }: HostPageProps = {}) { } if (message.type === "error") { setViewerAccessUpdating(false); + setViewerPasswordUpdating(false); + viewerPasswordActionRef.current = null; if (["INVALID_TOKEN", "ROOM_EXPIRED"].includes(message.code)) { forgetRoom(); endSharing("房间已失效,再次点击将创建新房", false); @@ -1068,6 +1091,7 @@ export function HostPage({ onAuthorizationRequired }: HostPageProps = {}) { clientId: getStableClientId("host", activeRoom.roomId), shareGeneration, viewerPresence: true, + viewerPasswordSettings: true, }, { onStatus: (status) => { @@ -1322,6 +1346,30 @@ export function HostPage({ onAuthorizationRequired }: HostPageProps = {}) { setNotice(null); } + function changeViewerPassword(password: string | null): void { + if ( + password !== null && + !viewerPasswordSchema.safeParse(password).success + ) { + setNotice( + `访问密码只需 1-${MAX_VIEWER_PASSWORD_LENGTH} 个可见字符`, + ); + return; + } + if ( + viewerPasswordUpdating || + phase !== "live" || + !signalRef.current?.send({ type: "set-viewer-password", password }) + ) { + setNotice("开始分享并连接后才能修改访问密码"); + return; + } + viewerPasswordActionRef.current = password === null ? "remove" : "set"; + setViewerPasswordUpdating(true); + setViewerPasswordDraft(""); + setNotice(null); + } + const expirationText = room ? room.expiresAt ? `${new Intl.DateTimeFormat("zh-CN", { @@ -1717,6 +1765,61 @@ export function HostPage({ onAuthorizationRequired }: HostPageProps = {}) { )} + {room.viewerPolicy === "private-link" && ( +
{ + event.preventDefault(); + changeViewerPassword(viewerPasswordDraft); + }} + > + + + + + {viewerPasswordEnabled && ( + + )} +
+ )} )} diff --git a/src/client/pages/ViewerPage.tsx b/src/client/pages/ViewerPage.tsx index bc4da12..f3611f6 100644 --- a/src/client/pages/ViewerPage.tsx +++ b/src/client/pages/ViewerPage.tsx @@ -1,4 +1,5 @@ import { + KeyRound, LoaderCircle, Maximize2, Play, @@ -11,7 +12,9 @@ import { import { useEffect, useMemo, useRef, useState } from "react"; import { DEFAULT_QUALITY_SETTINGS, + MAX_VIEWER_PASSWORD_LENGTH, VIEWER_QUALITY_EVIDENCE_EXPIRY_MS, + viewerPasswordSchema, type IceConfig, type MediaAssignment, type ServerMessage, @@ -94,6 +97,14 @@ export function ViewerPage({ roomId, viewerGrant }: ViewerPageProps) { const [displayName, setDisplayName] = useState(() => readDisplayName()); const [displayNameDraft, setDisplayNameDraft] = useState(displayName); const [displayNameError, setDisplayNameError] = useState(null); + const [viewerPasswordDraft, setViewerPasswordDraft] = useState(""); + const [viewerPasswordError, setViewerPasswordError] = useState( + null, + ); + const [viewerPasswordAttempt, setViewerPasswordAttempt] = useState<{ + password: string; + sequence: number; + } | null>(null); const qualityLimitation = useMemo( () => @@ -156,6 +167,9 @@ export function ViewerPage({ roomId, viewerGrant }: ViewerPageProps) { role: "viewer", clientId: getStableClientId("viewer", roomId), ...(viewerGrant ? { viewerGrant } : {}), + ...(!viewerGrant && viewerPasswordAttempt + ? { viewerPassword: viewerPasswordAttempt.password } + : {}), displayName: displayNameRef.current, }, { @@ -540,6 +554,8 @@ export function ViewerPage({ roomId, viewerGrant }: ViewerPageProps) { selectedEdgeTurn = null; viewerAuthenticated = true; setAccessState("ready"); + setViewerPasswordDraft(""); + setViewerPasswordError(null); clearRelayChildEvidence(); currentPeerId = message.peerId; viewerAuthorizationGeneration = @@ -801,6 +817,9 @@ export function ViewerPage({ roomId, viewerGrant }: ViewerPageProps) { } if (message.code === "INVALID_TOKEN") { clearViewerGrant(roomId); + if (!viewerGrant && viewerPasswordAttempt) { + setViewerPasswordError("访问验证失败,请重试"); + } setStatusText("邀请无效或已失效"); return; } @@ -833,7 +852,7 @@ export function ViewerPage({ roomId, viewerGrant }: ViewerPageProps) { viewerRelay?.dispose(); viewerRelay = null; }; - }, [roomId, viewerGrant]); + }, [roomId, viewerGrant, viewerPasswordAttempt]); useEffect(() => { const video = videoRef.current; @@ -930,6 +949,22 @@ export function ViewerPage({ roomId, viewerGrant }: ViewerPageProps) { signalRef.current?.setViewerDisplayName(saved); } + function submitViewerPassword(event: React.FormEvent): void { + event.preventDefault(); + if (!viewerPasswordSchema.safeParse(viewerPasswordDraft).success) { + setViewerPasswordError( + `请输入 1-${MAX_VIEWER_PASSWORD_LENGTH} 个可见字符`, + ); + return; + } + setViewerPasswordError(null); + setAccessState("checking"); + setViewerPasswordAttempt((current) => ({ + password: viewerPasswordDraft, + sequence: (current?.sequence ?? 0) + 1, + })); + } + if (accessState !== "ready") { return (
@@ -939,10 +974,42 @@ export function ViewerPage({ roomId, viewerGrant }: ViewerPageProps) {
- ) : ( + ) : viewerGrant ? (

无法访问

+ ) : ( +
+
+

访问验证

+

请输入访问密码

+
+ + {viewerPasswordError && ( +

+ {viewerPasswordError} +

+ )} + +
)} diff --git a/src/client/styles.css b/src/client/styles.css index 0e88ba7..fd46ab8 100644 --- a/src/client/styles.css +++ b/src/client/styles.css @@ -704,6 +704,7 @@ h3 { .invite-bar { display: flex; + flex-wrap: wrap; min-width: 0; gap: 12px; align-items: center; @@ -725,6 +726,30 @@ h3 { gap: 6px; } +.viewer-password-control { + display: grid; + grid-template-columns: auto minmax(160px, 240px) 40px 40px; + width: 100%; + gap: 6px; + align-items: center; + padding-top: 10px; + border-top: 1px solid var(--border); +} + +.viewer-password-control > label { + color: var(--muted); + font-size: 12px; +} + +.viewer-password-control .input-with-icon { + min-width: 0; +} + +.viewer-password-control input { + min-width: 0; + width: 100%; +} + .invite-copy .field-label { margin-bottom: 3px; } @@ -1154,6 +1179,14 @@ h3 { align-items: flex-end; } + .viewer-password-control { + grid-template-columns: minmax(0, 1fr) 40px 40px; + } + + .viewer-password-control > label { + grid-column: 1 / -1; + } + .viewer-stats .stats-grid { grid-template-columns: repeat(2, minmax(0, 1fr)); } diff --git a/src/server/room-database.ts b/src/server/room-database.ts index fe1129c..5b37a85 100644 --- a/src/server/room-database.ts +++ b/src/server/room-database.ts @@ -1,13 +1,14 @@ import { randomBytes } from "node:crypto"; import { DatabaseSync } from "node:sqlite"; -const SCHEMA_VERSION = 2; +const SCHEMA_VERSION = 3; const MAX_ROOM_ID = 999_999_999_999; export interface StoredRoom { roomId: string; hostTokenDigest: Buffer; viewerGrantDigest: Buffer | null; + viewerPasswordMaterial: Buffer | null; } export class RoomDatabase { @@ -28,7 +29,9 @@ export class RoomDatabase { loadRooms(): StoredRoom[] { const rows = this.database .prepare( - "SELECT id, host_token_digest, viewer_grant_digest FROM rooms ORDER BY id", + `SELECT id, host_token_digest, viewer_grant_digest, + viewer_password_material + FROM rooms ORDER BY id`, ) .all(); @@ -44,6 +47,11 @@ export class RoomDatabase { roomId: roomId.toString(), hostTokenDigest: Buffer.from(row.host_token_digest), viewerGrantDigest: readViewerGrantDigest(row.viewer_grant_digest), + viewerPasswordMaterial: readNullableBytes( + row.viewer_password_material, + 48, + "viewer password material", + ), }; }); } @@ -79,6 +87,7 @@ export class RoomDatabase { hostTokenDigest: Buffer.from(hostTokenDigest), viewerGrantDigest: viewerGrantDigest === null ? null : Buffer.from(viewerGrantDigest), + viewerPasswordMaterial: null, }; } catch (error) { if (this.database.isTransaction) { @@ -104,6 +113,26 @@ export class RoomDatabase { } } + updateViewerPassword( + roomId: string, + material: Buffer | null, + ): void { + const numericRoomId = parseRoomId(roomId); + if (material) { + assertBytes(material, 48, "Viewer password material"); + } + const changes = this.database + .prepare( + `UPDATE rooms + SET viewer_password_material = ? + WHERE id = ?`, + ) + .run(material, numericRoomId).changes; + if (changes !== 1) { + throw new Error("Persistent room is missing from the room database"); + } + } + deleteRoom(roomId: string): boolean { const numericRoomId = parseRoomId(roomId); return ( @@ -124,6 +153,10 @@ export class RoomDatabase { if (version === SCHEMA_VERSION) { return; } + if (version === 2) { + this.migrateV2(); + return; + } if (version === 1) { this.migrateV1(); return; @@ -144,6 +177,11 @@ export class RoomDatabase { CHECK ( viewer_grant_digest IS NULL OR length(viewer_grant_digest) = 32 + ), + viewer_password_material BLOB NULL + CHECK ( + viewer_password_material IS NULL OR + length(viewer_password_material) = 48 ) ) STRICT; PRAGMA user_version = ${SCHEMA_VERSION}; @@ -166,6 +204,11 @@ export class RoomDatabase { viewer_grant_digest IS NULL OR length(viewer_grant_digest) = 32 ); + ALTER TABLE rooms ADD COLUMN viewer_password_material BLOB NULL + CHECK ( + viewer_password_material IS NULL OR + length(viewer_password_material) = 48 + ); `); const roomIds = this.database.prepare("SELECT id FROM rooms ORDER BY id").all(); const lockRoom = this.database.prepare( @@ -187,6 +230,26 @@ export class RoomDatabase { throw error; } } + + private migrateV2(): void { + this.database.exec("BEGIN IMMEDIATE"); + try { + this.database.exec(` + ALTER TABLE rooms ADD COLUMN viewer_password_material BLOB NULL + CHECK ( + viewer_password_material IS NULL OR + length(viewer_password_material) = 48 + ); + PRAGMA user_version = ${SCHEMA_VERSION}; + COMMIT; + `); + } catch (error) { + if (this.database.isTransaction) { + this.database.exec("ROLLBACK"); + } + throw error; + } + } } function assertDigest(value: Buffer, name: string): void { @@ -195,6 +258,26 @@ function assertDigest(value: Buffer, name: string): void { } } +function assertBytes(value: Buffer, length: number, name: string): void { + if (value.byteLength !== length) { + throw new Error(`${name} must contain ${length} bytes`); + } +} + +function readNullableBytes( + value: unknown, + length: number, + name: string, +): Buffer | null { + if (value === null) { + return null; + } + if (!(value instanceof Uint8Array) || value.byteLength !== length) { + throw new Error(`Room database contains an invalid ${name}`); + } + return Buffer.from(value); +} + function readViewerGrantDigest(value: unknown): Buffer | null { if (value === null) { return null; diff --git a/src/server/room-store.ts b/src/server/room-store.ts index 4afe8de..62c177b 100644 --- a/src/server/room-store.ts +++ b/src/server/room-store.ts @@ -1,10 +1,16 @@ -import { createHash, randomBytes, timingSafeEqual } from "node:crypto"; +import { + createHash, + randomBytes, + scrypt, + timingSafeEqual, +} from "node:crypto"; import { MAX_HOST_CLAIM_TTL_SECONDS, MAX_VIEWERS_PER_ROOM_LIMIT, ROOM_CODE_LENGTH, viewerGrantSchema, + viewerPasswordSchema, type Role, type ViewerAccessPolicy, } from "../shared/protocol.js"; @@ -18,6 +24,21 @@ export type RoomStoreErrorCode = | "ROOM_LIMIT"; const PERSISTENT_VIEWER_GRANT_TTL_MS = 7 * 24 * 60 * 60 * 1_000; +const VIEWER_PASSWORD_SALT_BYTES = 16; +const VIEWER_PASSWORD_VERIFIER_BYTES = 32; +const VIEWER_PASSWORD_MATERIAL_BYTES = + VIEWER_PASSWORD_SALT_BYTES + VIEWER_PASSWORD_VERIFIER_BYTES; +const VIEWER_PASSWORD_KDF_CONCURRENCY = 2; +const VIEWER_PASSWORD_KDF_PENDING_LIMIT = 16; +const VIEWER_PASSWORD_SCRYPT_OPTIONS = { + N: 16_384, + r: 8, + p: 1, + maxmem: 32 * 1024 * 1024, +} as const; +const DUMMY_VIEWER_PASSWORD_MATERIAL = Buffer.alloc( + VIEWER_PASSWORD_MATERIAL_BYTES, +); export class RoomStoreError extends Error { constructor(public readonly code: RoomStoreErrorCode) { @@ -36,6 +57,7 @@ interface Room { roomId: string; hostTokenDigest: Buffer; viewerGrantDigest: Buffer | null; + viewerPasswordMaterial: Buffer | null; viewerAuthorizationGeneration: string; expiresAtMs: number | null; provisionalHostExpiresAtMs: number | null; @@ -68,6 +90,13 @@ export type ConnectParticipantInput = sessionId: string; }; +export interface ConnectViewerWithPasswordInput { + roomId: string; + password: string; + clientId: string; + sessionId: string; +} + export interface ConnectedParticipant { roomId: string; role: Role; @@ -77,6 +106,7 @@ export interface ConnectedParticipant { replacedSessionId?: string; viewerPeerIds: readonly string[]; viewerPolicy: ViewerAccessPolicy; + viewerPasswordEnabled: boolean; viewerAuthorizationGeneration: string; } @@ -157,6 +187,10 @@ export class RoomStore { storedRoom.viewerGrantDigest === null ? null : Buffer.from(storedRoom.viewerGrantDigest), + viewerPasswordMaterial: + storedRoom.viewerPasswordMaterial === null + ? null + : Buffer.from(storedRoom.viewerPasswordMaterial), viewerAuthorizationGeneration: this.newAuthorizationGeneration(), expiresAtMs: null, provisionalHostExpiresAtMs: null, @@ -238,6 +272,7 @@ export class RoomStore { roomId, hostTokenDigest, viewerGrantDigest, + viewerPasswordMaterial: null, viewerAuthorizationGeneration, expiresAtMs, provisionalHostExpiresAtMs, @@ -277,6 +312,129 @@ export class RoomStore { return this.connectViewer(room, input); } + async connectViewerWithPassword( + input: ConnectViewerWithPasswordInput, + mayConnect: () => boolean = () => true, + ): Promise { + if (!viewerPasswordSchema.safeParse(input.password).success) { + throw new RoomStoreError("INVALID_TOKEN"); + } + + let room: Room | undefined; + try { + room = this.getAvailableRoom(input.roomId); + } catch (error) { + if (!(error instanceof RoomStoreError)) { + throw error; + } + } + if (room?.viewerGrantDigest === null) { + if (!mayConnect()) { + throw new RoomStoreError("INVALID_TOKEN"); + } + return this.connectViewer(room, { ...input, role: "viewer" }); + } + + const expectedMaterial = Buffer.from( + room?.viewerPasswordMaterial ?? DUMMY_VIEWER_PASSWORD_MATERIAL, + ); + const expectedSalt = expectedMaterial.subarray( + 0, + VIEWER_PASSWORD_SALT_BYTES, + ); + const expectedVerifier = expectedMaterial.subarray( + VIEWER_PASSWORD_SALT_BYTES, + ); + const derived = await deriveViewerPassword( + input.password, + expectedSalt, + mayConnect, + ); + if (derived === null) { + throw new RoomStoreError("INVALID_TOKEN"); + } + const matches = timingSafeEqual(derived, expectedVerifier); + + let currentRoom: Room; + try { + currentRoom = this.getAvailableRoom(input.roomId); + } catch (error) { + if (error instanceof RoomStoreError) { + throw new RoomStoreError("INVALID_TOKEN"); + } + throw error; + } + if (currentRoom.viewerGrantDigest === null) { + if (!mayConnect()) { + throw new RoomStoreError("INVALID_TOKEN"); + } + return this.connectViewer(currentRoom, { ...input, role: "viewer" }); + } + if ( + !room || + currentRoom !== room || + !matches || + !sameBytes(currentRoom.viewerPasswordMaterial, expectedMaterial) || + !mayConnect() + ) { + throw new RoomStoreError("INVALID_TOKEN"); + } + try { + return this.connectViewer(currentRoom, { ...input, role: "viewer" }); + } catch (error) { + if (error instanceof RoomStoreError && error.code === "ROOM_FULL") { + throw new RoomStoreError("INVALID_TOKEN"); + } + throw error; + } + } + + async setViewerPassword( + roomId: string, + password: string | null, + hostSessionId: string, + ): Promise { + const room = this.getAvailableRoom(roomId); + if (room.host?.sessionId !== hostSessionId) { + throw new RoomStoreError("INVALID_TOKEN"); + } + + let nextPasswordMaterial: Buffer | null = null; + if (password !== null) { + if (!viewerPasswordSchema.safeParse(password).success) { + throw new RoomStoreError("INVALID_TOKEN"); + } + const salt = this.random(VIEWER_PASSWORD_SALT_BYTES); + if (salt.byteLength !== VIEWER_PASSWORD_SALT_BYTES) { + throw new Error("Viewer password salt source must return 16 bytes"); + } + const verifier = await deriveViewerPassword(password, salt, () => { + const currentRoom = this.rooms.get(roomId); + return ( + currentRoom === room && + currentRoom.host?.sessionId === hostSessionId + ); + }); + if (verifier === null) { + throw new RoomStoreError("INVALID_TOKEN"); + } + nextPasswordMaterial = Buffer.concat([salt, verifier]); + } + + const currentRoom = this.getAvailableRoom(roomId); + if ( + currentRoom !== room || + currentRoom.host?.sessionId !== hostSessionId + ) { + throw new RoomStoreError("INVALID_TOKEN"); + } + if (currentRoom.expiresAtMs === null) { + this.options.database?.updateViewerPassword(roomId, nextPasswordMaterial); + } + currentRoom.viewerPasswordMaterial = nextPasswordMaterial; + return nextPasswordMaterial !== null; + } + setViewerAccess( roomId: string, action: "public-watch" | "rotate" | "revoke", @@ -483,6 +641,7 @@ export class RoomStore { // Disconnected viewers remain room members until their grace period ends. viewerPeerIds: [...room.viewers.values()].map((viewer) => viewer.peerId), viewerPolicy: viewerPolicy(room), + viewerPasswordEnabled: room.viewerPasswordMaterial !== null, viewerAuthorizationGeneration: room.viewerAuthorizationGeneration, }; } @@ -516,6 +675,7 @@ export class RoomStore { replacedSessionId === input.sessionId ? undefined : replacedSessionId, viewerPeerIds: [], viewerPolicy: viewerPolicy(room), + viewerPasswordEnabled: room.viewerPasswordMaterial !== null, viewerAuthorizationGeneration: room.viewerAuthorizationGeneration, }; } @@ -658,6 +818,81 @@ function verifyDigest(value: string, expectedDigest: Buffer): boolean { return timingSafeEqual(digest(value), expectedDigest); } +function sameBytes(value: Buffer | null, expected: Buffer): boolean { + return value !== null && timingSafeEqual(value, expected); +} + +function deriveViewerPassword( + password: string, + salt: Buffer, + mayStart: () => boolean = () => true, +): Promise { + return viewerPasswordKdfGate.run( + () => + new Promise((resolve, reject) => { + scrypt( + password, + salt, + VIEWER_PASSWORD_VERIFIER_BYTES, + VIEWER_PASSWORD_SCRYPT_OPTIONS, + (error, derivedKey) => (error ? reject(error) : resolve(derivedKey)), + ); + }), + mayStart, + ); +} + +class AsyncGate { + private active = 0; + private readonly waiters: Array<() => void> = []; + + constructor( + private readonly limit: number, + private readonly pendingLimit: number, + ) {} + + async run( + task: () => Promise, + mayStart: () => boolean, + ): Promise { + if (!(await this.acquire())) { + return null; + } + try { + return mayStart() ? await task() : null; + } finally { + this.release(); + } + } + + private acquire(): Promise { + if (this.active >= this.limit) { + if (this.waiters.length >= this.pendingLimit) { + return Promise.resolve(false); + } + return new Promise((resolve) => + this.waiters.push(() => resolve(true)), + ); + } + this.active += 1; + return Promise.resolve(true); + } + + private release(): void { + const next = this.waiters.shift(); + if (next) { + next(); + return; + } + this.active -= 1; + } +} + +const viewerPasswordKdfGate = new AsyncGate( + VIEWER_PASSWORD_KDF_CONCURRENCY, + VIEWER_PASSWORD_KDF_PENDING_LIMIT, +); + function viewerPolicy(room: Room): ViewerAccessPolicy { return room.viewerGrantDigest === null ? "public-watch" : "private-link"; } diff --git a/src/server/signaling.ts b/src/server/signaling.ts index 94601f7..975cdf0 100644 --- a/src/server/signaling.ts +++ b/src/server/signaling.ts @@ -46,6 +46,7 @@ interface AuthenticatedSession { shareGeneration: string | null; displayName: string | null; viewerPresence: boolean; + viewerPasswordSettings: boolean; } interface SocketState { @@ -54,6 +55,8 @@ interface SocketState { authenticationTimer: NodeJS.Timeout; hostAdmissionAuthenticated: boolean; revoked?: boolean; + authenticating?: boolean; + viewerPasswordUpdatePending?: boolean; authenticated?: AuthenticatedSession; } @@ -315,7 +318,16 @@ export class SignalingServer { socket.close(4001, "Authentication required"); return; } - this.authenticate(socket, state, message); + if (state.authenticating) { + this.rejectInvalidMessage(socket); + return; + } + state.authenticating = true; + void this.authenticate(socket, state, message).finally(() => { + if (this.socketStates.get(socket) === state) { + state.authenticating = false; + } + }); return; } if (message.type === "authenticate") { @@ -338,11 +350,11 @@ export class SignalingServer { this.handleAuthenticatedMessage(socket, state.authenticated, message); } - private authenticate( + private async authenticate( socket: WebSocket, state: SocketState, message: Extract, - ): void { + ): Promise { if (message.role === "host" && !state.hostAdmissionAuthenticated) { this.sendError(socket, "AUTH_REQUIRED", "Host admission is required"); socket.close(4003, "Authentication failed"); @@ -351,26 +363,49 @@ export class SignalingServer { let participant; try { - participant = this.options.roomStore.connectParticipant( - message.role === "host" - ? { - roomId: message.roomId, - role: "host", - token: message.token, - clientId: message.clientId, - sessionId: state.sessionId, - } - : { - roomId: message.roomId, - role: "viewer", - ...(message.viewerGrant - ? { viewerGrant: message.viewerGrant } - : {}), - clientId: message.clientId, - sessionId: state.sessionId, - }, - ); + if ( + message.role === "viewer" && + !message.viewerGrant && + message.viewerPassword !== undefined + ) { + participant = await this.options.roomStore.connectViewerWithPassword( + { + roomId: message.roomId, + password: message.viewerPassword, + clientId: message.clientId, + sessionId: state.sessionId, + }, + () => + this.socketStates.get(socket) === state && + !state.revoked && + !state.authenticated && + socket.readyState === WebSocket.OPEN, + ); + } else { + participant = this.options.roomStore.connectParticipant( + message.role === "host" + ? { + roomId: message.roomId, + role: "host", + token: message.token, + clientId: message.clientId, + sessionId: state.sessionId, + } + : { + roomId: message.roomId, + role: "viewer", + ...(message.viewerGrant + ? { viewerGrant: message.viewerGrant } + : {}), + clientId: message.clientId, + sessionId: state.sessionId, + }, + ); + } } catch (error) { + if (this.socketStates.get(socket) !== state || state.revoked) { + return; + } if (!(error instanceof RoomStoreError)) { console.error("Signaling authentication failed unexpectedly"); } @@ -414,6 +449,7 @@ export class SignalingServer { ? (message.displayName ?? DEFAULT_VIEWER_DISPLAY_NAME) : null, viewerPresence: false, + viewerPasswordSettings: false, }; if (participant.role === "viewer") { this.viewerQualityEvidenceGates.delete( @@ -483,6 +519,14 @@ export class SignalingServer { } state.authenticated.viewerPresence = message.role === "host" && message.viewerPresence === true; + state.authenticated.viewerPasswordSettings = + message.role === "host" && message.viewerPasswordSettings === true; + if (state.authenticated.viewerPasswordSettings) { + this.send(socket, { + type: "viewer-password-updated", + enabled: participant.viewerPasswordEnabled, + }); + } if (participant.replacedSessionId) { const replaced = this.socketsBySessionId.get(participant.replacedSessionId); @@ -668,6 +712,36 @@ export class SignalingServer { } this.updateViewerAccess(socket, authenticated, message.action); return; + case "set-viewer-password": { + if ( + authenticated.role !== "host" || + !authenticated.viewerPasswordSettings + ) { + this.sendError( + socket, + "FORBIDDEN", + "Viewer password settings are unavailable", + ); + return; + } + const socketState = this.socketStates.get(socket); + if (!socketState || socketState.viewerPasswordUpdatePending) { + this.sendError(socket, "FORBIDDEN", "Viewer password update is busy"); + return; + } + socketState.viewerPasswordUpdatePending = true; + void this.updateViewerPassword( + socket, + authenticated, + socketState, + message.password, + ).finally(() => { + if (this.socketStates.get(socket) === socketState) { + socketState.viewerPasswordUpdatePending = false; + } + }); + return; + } case "stop-sharing": if (authenticated.role !== "host") { this.sendError(socket, "FORBIDDEN", "Only the host may stop sharing"); @@ -787,6 +861,42 @@ export class SignalingServer { }); } + private async updateViewerPassword( + hostSocket: WebSocket, + authenticated: AuthenticatedSession, + state: SocketState, + password: string | null, + ): Promise { + try { + const enabled = await this.options.roomStore.setViewerPassword( + authenticated.roomId, + password, + state.sessionId, + ); + if ( + this.socketStates.get(hostSocket) !== state || + state.authenticated !== authenticated || + state.revoked + ) { + return; + } + this.send(hostSocket, { type: "viewer-password-updated", enabled }); + } catch { + if ( + this.socketStates.get(hostSocket) === state && + state.authenticated === authenticated && + !state.revoked + ) { + console.error("Viewer password update failed"); + this.sendError( + hostSocket, + "SERVER_ERROR", + "Viewer password could not be updated", + ); + } + } + } + private handleViewerQualityEvidence( socket: WebSocket, source: AuthenticatedSession, diff --git a/src/shared/protocol.ts b/src/shared/protocol.ts index ab0c57a..61c513c 100644 --- a/src/shared/protocol.ts +++ b/src/shared/protocol.ts @@ -16,6 +16,8 @@ export const VIEWER_QUALITY_EVIDENCE_INTERVAL_MS = 2_000; export const VIEWER_QUALITY_EVIDENCE_EXPIRY_MS = 5_000; export const MAX_DISPLAY_NAME_CODE_POINTS = 24; export const DEFAULT_VIEWER_DISPLAY_NAME = "访客"; +export const MIN_VIEWER_PASSWORD_LENGTH = 1; +export const MAX_VIEWER_PASSWORD_LENGTH = 64; const FORBIDDEN_DISPLAY_NAME_CHARACTERS = /[\p{Cc}\p{Zl}\p{Zp}\u061c\u200b\u200e\u200f\u202a-\u202e\u2060\u2066-\u2069\ufeff]/u; @@ -82,6 +84,13 @@ export const viewerGrantSchema = z .max(96) .regex(/^g1\.[1-9]\d{0,11}\.[1-9]\d{0,12}\.[A-Za-z0-9_-]{43}$/); +export const viewerPasswordSchema = z + .string() + .min(MIN_VIEWER_PASSWORD_LENGTH) + .max(MAX_VIEWER_PASSWORD_LENGTH) + .regex(/^[\x21-\x7e]+$/); +export type ViewerPassword = z.infer; + export const viewerAccessPolicySchema = z.enum([ "private-link", "public-watch", @@ -466,6 +475,7 @@ const authenticateMessageSchema = z.discriminatedUnion("role", [ clientId: opaqueIdSchema, shareGeneration: opaqueIdSchema.optional(), viewerPresence: z.literal(true).optional(), + viewerPasswordSettings: z.literal(true).optional(), }) .strict(), z @@ -476,6 +486,7 @@ const authenticateMessageSchema = z.discriminatedUnion("role", [ role: z.literal("viewer"), clientId: opaqueIdSchema, viewerGrant: viewerGrantSchema.optional(), + viewerPassword: viewerPasswordSchema.optional(), displayName: displayNameSchema.optional(), }) .strict(), @@ -545,6 +556,12 @@ export const clientMessageSchema = z.union([ action: z.enum(["public-watch", "rotate", "revoke"]), }) .strict(), + z + .object({ + type: z.literal("set-viewer-password"), + password: viewerPasswordSchema.nullable(), + }) + .strict(), z .object({ type: z.literal("stop-sharing"), @@ -721,6 +738,12 @@ export const serverMessageSchema = z.union([ viewerAuthorizationGeneration: opaqueIdSchema, }) .strict(), + z + .object({ + type: z.literal("viewer-password-updated"), + enabled: z.boolean(), + }) + .strict(), z.object({ type: z.literal("sharing-stopped") }).strict(), z .object({ diff --git a/tests/protocol.test.ts b/tests/protocol.test.ts index 8e93c93..2c1c5b8 100644 --- a/tests/protocol.test.ts +++ b/tests/protocol.test.ts @@ -8,6 +8,7 @@ import { MAX_PARENT_EDGE_QUALITY_EVIDENCE_BYTES, MAX_SFU_TOKEN_LENGTH, MAX_VIEWER_QUALITY_EVIDENCE_BYTES, + MAX_VIEWER_PASSWORD_LENGTH, MAX_VIEWERS_PER_ROOM_LIMIT, SIGNALING_PROTOCOL, clientMessageSchema, @@ -16,6 +17,7 @@ import { normalizeDisplayName, participantRouteAssignmentSchema, serverMessageSchema, + viewerPasswordSchema, } from "../src/shared/protocol.js"; const token = "a".repeat(43); @@ -224,6 +226,53 @@ describe("client signaling protocol", () => { ).toBe(false); }); + it("accepts simple bounded Viewer passwords and the Web Host capability", () => { + expect(viewerPasswordSchema.safeParse("x").success).toBe(true); + expect(viewerPasswordSchema.safeParse("simple-password").success).toBe(true); + for (const invalid of [ + "", + "contains space", + "line\nbreak", + "x".repeat(MAX_VIEWER_PASSWORD_LENGTH + 1), + ]) { + expect(viewerPasswordSchema.safeParse(invalid).success).toBe(false); + } + + expect( + clientMessageSchema.safeParse({ + type: "authenticate", + protocol: SIGNALING_PROTOCOL, + roomId, + role: "viewer", + clientId: "client_12345678", + viewerPassword: "easy-password", + }).success, + ).toBe(true); + expect( + clientMessageSchema.safeParse({ + type: "authenticate", + protocol: SIGNALING_PROTOCOL, + roomId, + role: "host", + token, + clientId: "client_12345678", + viewerPasswordSettings: true, + }).success, + ).toBe(true); + expect( + clientMessageSchema.safeParse({ + type: "set-viewer-password", + password: null, + }).success, + ).toBe(true); + expect( + serverMessageSchema.safeParse({ + type: "viewer-password-updated", + enabled: true, + }).success, + ).toBe(true); + }); + it("rejects unknown fields and malformed tokens", () => { const result = clientMessageSchema.safeParse({ type: "authenticate", diff --git a/tests/room-store.test.ts b/tests/room-store.test.ts index d8745eb..0554933 100644 --- a/tests/room-store.test.ts +++ b/tests/room-store.test.ts @@ -22,6 +22,13 @@ function expectRoomError(action: () => unknown, code: RoomStoreError["code"]): v } } +async function expectRoomErrorAsync( + action: () => Promise, + code: RoomStoreError["code"], +): Promise { + await expect(action()).rejects.toMatchObject({ code }); +} + function viewerGrant(room: { viewerGrant: string | null }): string { if (!room.viewerGrant) { throw new Error("Expected a private room Viewer grant"); @@ -499,6 +506,210 @@ describe("RoomStore", () => { ).toMatchObject({ viewerPolicy: "public-watch" }); }); + it("accepts, changes, and removes a private room password", async () => { + const store = new RoomStore({ + ttlMs: 10_000, + maxRooms: 10, + maxViewersPerRoom: 5, + }); + const room = store.createRoom(); + const host = store.connectParticipant({ + roomId: room.roomId, + role: "host", + token: room.hostToken, + clientId: "password-host", + sessionId: "password-host-session", + }); + expect(host.viewerPasswordEnabled).toBe(false); + + await expect( + store.setViewerPassword( + room.roomId, + "easy-password", + "password-host-session", + ), + ).resolves.toBe(true); + await expect( + store.connectViewerWithPassword({ + roomId: room.roomId, + password: "easy-password", + clientId: "password-viewer-1", + sessionId: "password-viewer-session-1", + }), + ).resolves.toMatchObject({ role: "viewer", viewerPasswordEnabled: true }); + await expectRoomErrorAsync( + () => + store.connectViewerWithPassword({ + roomId: room.roomId, + password: "wrong-password", + clientId: "wrong-password-viewer", + sessionId: "wrong-password-session", + }), + "INVALID_TOKEN", + ); + + await store.setViewerPassword( + room.roomId, + "new-password", + "password-host-session", + ); + await expectRoomErrorAsync( + () => + store.connectViewerWithPassword({ + roomId: room.roomId, + password: "easy-password", + clientId: "old-password-viewer", + sessionId: "old-password-session", + }), + "INVALID_TOKEN", + ); + await expect( + store.connectViewerWithPassword({ + roomId: room.roomId, + password: "new-password", + clientId: "password-viewer-2", + sessionId: "password-viewer-session-2", + }), + ).resolves.toMatchObject({ role: "viewer" }); + + await expect( + store.setViewerPassword(room.roomId, null, "password-host-session"), + ).resolves.toBe(false); + await expectRoomErrorAsync( + () => + store.connectViewerWithPassword({ + roomId: room.roomId, + password: "new-password", + clientId: "removed-password-viewer", + sessionId: "removed-password-session", + }), + "INVALID_TOKEN", + ); + expect( + store.connectParticipant({ + roomId: room.roomId, + role: "viewer", + viewerGrant: viewerGrant(room), + clientId: "grant-still-works", + sessionId: "grant-still-works-session", + }).role, + ).toBe("viewer"); + }); + + it("bounds pending password derivations and skips stale waiters", async () => { + const store = new RoomStore({ + ttlMs: 10_000, + maxRooms: 10, + maxViewersPerRoom: 3, + }); + let mayStartCalls = 0; + const attempts = Array.from({ length: 40 }, (_, index) => + store.connectViewerWithPassword( + { + roomId: "999999", + password: "bounded-password", + clientId: `bounded-client-${index}`, + sessionId: `bounded-session-${index}`, + }, + () => { + mayStartCalls += 1; + return index < 2; + }, + ), + ); + + const results = await Promise.allSettled(attempts); + expect(mayStartCalls).toBe(18); + expect( + results.every( + (result) => + result.status === "rejected" && + result.reason instanceof RoomStoreError && + result.reason.code === "INVALID_TOKEN", + ), + ).toBe(true); + }); + + it("does not reveal a full private room after a correct password", async () => { + const store = new RoomStore({ + ttlMs: 10_000, + maxRooms: 10, + maxViewersPerRoom: 1, + }); + const room = store.createRoom(); + store.connectParticipant({ + roomId: room.roomId, + role: "host", + token: room.hostToken, + clientId: "full-password-host", + sessionId: "full-password-host-session", + }); + await store.setViewerPassword( + room.roomId, + "full-password", + "full-password-host-session", + ); + store.connectParticipant({ + roomId: room.roomId, + role: "viewer", + viewerGrant: viewerGrant(room), + clientId: "full-room-viewer", + sessionId: "full-room-viewer-session", + }); + + await expectRoomErrorAsync( + () => + store.connectViewerWithPassword({ + roomId: room.roomId, + password: "full-password", + clientId: "full-password-viewer", + sessionId: "full-password-viewer-session", + }), + "INVALID_TOKEN", + ); + }); + + it("does not commit a password after the Host session is replaced", async () => { + const store = new RoomStore({ + ttlMs: 10_000, + maxRooms: 10, + maxViewersPerRoom: 3, + }); + const room = store.createRoom(); + store.connectParticipant({ + roomId: room.roomId, + role: "host", + token: room.hostToken, + clientId: "same-host", + sessionId: "old-host-session", + }); + + const pending = store.setViewerPassword( + room.roomId, + "stale-password", + "old-host-session", + ); + store.connectParticipant({ + roomId: room.roomId, + role: "host", + token: room.hostToken, + clientId: "same-host", + sessionId: "new-host-session", + }); + + await expect(pending).rejects.toMatchObject({ code: "INVALID_TOKEN" }); + await expectRoomErrorAsync( + () => + store.connectViewerWithPassword({ + roomId: room.roomId, + password: "stale-password", + clientId: "stale-password-viewer", + sessionId: "stale-password-viewer-session", + }), + "INVALID_TOKEN", + ); + }); + it("treats public-to-private as a strong Viewer authorization rotation", () => { const store = new RoomStore({ ttlMs: 10_000, @@ -726,6 +937,31 @@ describe("RoomStore", () => { true, ); expect(rooms[0].viewerGrantDigest).not.toEqual(rooms[1].viewerGrantDigest); + expect(rooms.every((room) => room.viewerPasswordMaterial === null)).toBe( + true, + ); + database.close(); + } finally { + rmSync(directory, { recursive: true, force: true }); + } + }); + + it("migrates v2 rooms to a nullable checked password material column", () => { + const directory = mkdtempSync(join(tmpdir(), "screener-room-v2-migration-")); + const databasePath = join(directory, "rooms.sqlite"); + try { + createV2Database(databasePath); + const database = new RoomDatabase(databasePath); + expect(database.loadRooms()).toMatchObject([ + { roomId: "1", viewerPasswordMaterial: null }, + ]); + database.updateViewerPassword("1", Buffer.alloc(48, 7)); + expect(database.loadRooms()[0].viewerPasswordMaterial).toEqual( + Buffer.alloc(48, 7), + ); + expect(() => + database.updateViewerPassword("1", Buffer.alloc(47)), + ).toThrow("Viewer password material must contain 48 bytes"); database.close(); } finally { rmSync(directory, { recursive: true, force: true }); @@ -799,3 +1035,22 @@ function createV1Database(path: string, roomCount: number): void { } database.close(); } + +function createV2Database(path: string): void { + const database = new DatabaseSync(path); + database.exec(` + CREATE TABLE rooms ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + host_token_digest BLOB NOT NULL CHECK (length(host_token_digest) = 32), + viewer_grant_digest BLOB NULL + CHECK (viewer_grant_digest IS NULL OR length(viewer_grant_digest) = 32) + ) STRICT; + PRAGMA user_version = 2; + `); + database + .prepare( + "INSERT INTO rooms (host_token_digest, viewer_grant_digest) VALUES (?, ?)", + ) + .run(Buffer.alloc(32, 1), Buffer.alloc(32, 2)); + database.close(); +} diff --git a/tests/server-signal.test.ts b/tests/server-signal.test.ts index 8bd122d..5829436 100644 --- a/tests/server-signal.test.ts +++ b/tests/server-signal.test.ts @@ -443,7 +443,12 @@ async function authenticate( clientId: string, relayCapacity: 0 | 1 | null = 1, shareGeneration?: string, - presence: { displayName?: string; viewerPresence?: true } = {}, + presence: { + displayName?: string; + viewerPresence?: true; + viewerPasswordSettings?: true; + viewerPassword?: string; + } = {}, ) { client.socket.send( JSON.stringify( @@ -457,6 +462,9 @@ async function authenticate( clientId, ...(shareGeneration ? { shareGeneration } : {}), ...(presence.viewerPresence ? { viewerPresence: true } : {}), + ...(presence.viewerPasswordSettings + ? { viewerPasswordSettings: true } + : {}), } : { type: "authenticate", @@ -464,7 +472,11 @@ async function authenticate( roomId: room.roomId, role, clientId, - ...(room.viewerGrant ? { viewerGrant: room.viewerGrant } : {}), + ...(presence.viewerPassword + ? { viewerPassword: presence.viewerPassword } + : room.viewerGrant + ? { viewerGrant: room.viewerGrant } + : {}), ...(presence.displayName ? { displayName: presence.displayName } : {}), @@ -808,6 +820,107 @@ describe("WebSocket signaling", () => { await host.inbox.next("peer-joined"); await host.inbox.expectNone(40); + + host.socket.send( + JSON.stringify({ type: "set-viewer-password", password: "unused" }), + ); + expect(await host.inbox.next("error")).toMatchObject({ + code: "FORBIDDEN", + }); + await host.inbox.expectNone(40); + }); + + it("lets an opted-in Web Host set and remove room password access", async () => { + const harness = await startHarness(); + const host = await openClient(harness.webSocketUrl); + const hostAuth = await authenticate( + host, + harness.room, + "host", + "password-settings-host", + 1, + undefined, + { viewerPasswordSettings: true }, + ); + expect("viewerPasswordEnabled" in hostAuth).toBe(false); + expect(await host.inbox.next("viewer-password-updated")).toEqual({ + type: "viewer-password-updated", + enabled: false, + }); + + host.socket.send( + JSON.stringify({ + type: "set-viewer-password", + password: "easy-password", + }), + ); + expect(await host.inbox.next("viewer-password-updated")).toEqual({ + type: "viewer-password-updated", + enabled: true, + }); + + const wrongViewer = await openClient(harness.webSocketUrl); + wrongViewer.socket.send( + JSON.stringify({ + type: "authenticate", + protocol: SIGNALING_PROTOCOL, + roomId: harness.room.roomId, + role: "viewer", + clientId: "wrong-password-viewer", + viewerPassword: "wrong-password", + }), + ); + expect(await wrongViewer.inbox.next("error")).toMatchObject({ + code: "INVALID_TOKEN", + }); + + const passwordViewer = await openClient(harness.webSocketUrl); + await expect( + authenticate( + passwordViewer, + harness.room, + "viewer", + "password-viewer", + 1, + undefined, + { viewerPassword: "easy-password" }, + ), + ).resolves.toMatchObject({ role: "viewer" }); + await host.inbox.next("peer-joined"); + + host.socket.send( + JSON.stringify({ type: "set-viewer-password", password: null }), + ); + expect(await host.inbox.next("viewer-password-updated")).toEqual({ + type: "viewer-password-updated", + enabled: false, + }); + + const grantViewer = await openClient(harness.webSocketUrl); + await expect( + authenticate( + grantViewer, + harness.room, + "viewer", + "grant-after-password-removal", + ), + ).resolves.toMatchObject({ role: "viewer" }); + await host.inbox.next("peer-joined"); + + const removedPasswordViewer = await openClient(harness.webSocketUrl); + removedPasswordViewer.socket.send( + JSON.stringify({ + type: "authenticate", + protocol: SIGNALING_PROTOCOL, + roomId: harness.room.roomId, + role: "viewer", + clientId: "removed-password-viewer", + viewerPassword: "easy-password", + }), + ); + expect(await removedPasswordViewer.inbox.next("error")).toMatchObject({ + code: "INVALID_TOKEN", + }); }); it("reports every online Viewer without expanding the Host media fanout", async () => {