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.
This commit is contained in:
AAA authored and GitHub committed 2026-08-20 20:00:52 +08:00
1 parent 2930e4c788
commit ded55bcd7d
16 files changed
+1210 -96

No files matched your search

+76 -37
View File
@@ -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.
+2 -2
View File
@@ -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
+3 -3
View File
@@ -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 和群组密钥管理。
+2 -2
View File
@@ -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.
+17 -14
View File
@@ -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。
+10 -9
View File
@@ -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 兜底
+103
View File
@@ -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<ViewerAccessPolicy>("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<number | null>(null);
const [peerSnapshots, setPeerSnapshots] = useState<Map<string, PeerSnapshot>>(
() => new Map(),
@@ -205,6 +212,7 @@ export function HostPage({ onAuthorizationRequired }: HostPageProps = {}) {
const videoRef = useRef<HTMLVideoElement | null>(null);
const streamRef = useRef<MediaStream | null>(null);
const signalRef = useRef<SignalingClient | null>(null);
const viewerPasswordActionRef = useRef<"set" | "remove" | null>(null);
const iceConfigRef = useRef<IceConfig | null>(null);
const peersRef = useRef(new Map<string, HostPeer>());
const retiredConnectionsRef = useRef(new Map<string, string>());
@@ -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 = {}) {
</>
)}
</div>
{room.viewerPolicy === "private-link" && (
<form
className="viewer-password-control"
onSubmit={(event) => {
event.preventDefault();
changeViewerPassword(viewerPasswordDraft);
}}
>
<label htmlFor="viewer-password">访问密码</label>
<span className="input-with-icon">
<KeyRound size={16} aria-hidden="true" />
<input
id="viewer-password"
type="password"
value={viewerPasswordDraft}
maxLength={MAX_VIEWER_PASSWORD_LENGTH}
autoComplete="new-password"
placeholder={
viewerPasswordEnabled ? "输入新密码" : "设置密码"
}
disabled={viewerPasswordUpdating || phase !== "live"}
onChange={(event) =>
setViewerPasswordDraft(event.target.value)
}
/>
</span>
<button
className="icon-button"
type="submit"
title={viewerPasswordEnabled ? "更改访问密码" : "设置访问密码"}
aria-label={
viewerPasswordEnabled ? "更改访问密码" : "设置访问密码"
}
disabled={
viewerPasswordUpdating ||
phase !== "live" ||
viewerPasswordDraft.length === 0
}
>
<Check size={18} />
</button>
{viewerPasswordEnabled && (
<button
className="icon-button"
type="button"
title="移除访问密码"
aria-label="移除访问密码"
disabled={viewerPasswordUpdating || phase !== "live"}
onClick={() => changeViewerPassword(null)}
>
<Trash2 size={18} />
</button>
)}
</form>
)}
</div>
)}
</section>
+69 -2
View File
@@ -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<string | null>(null);
const [viewerPasswordDraft, setViewerPasswordDraft] = useState("");
const [viewerPasswordError, setViewerPasswordError] = useState<string | null>(
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<HTMLFormElement>): 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 (
<div className="app-shell">
@@ -939,10 +974,42 @@ export function ViewerPage({ roomId, viewerGrant }: ViewerPageProps) {
<LoaderCircle size={20} className="spin" aria-hidden="true" />
正在验证
</div>
) : (
) : viewerGrant ? (
<section className="access-panel">
<h1>无法访问</h1>
</section>
) : (
<form className="access-panel" onSubmit={submitViewerPassword}>
<div>
<h1>访问验证</h1>
<p className="section-meta">请输入访问密码</p>
</div>
<label className="token-field">
<span>访问密码</span>
<span className="input-with-icon">
<KeyRound size={16} aria-hidden="true" />
<input
type="password"
value={viewerPasswordDraft}
maxLength={MAX_VIEWER_PASSWORD_LENGTH}
autoComplete="current-password"
autoFocus
onChange={(event) => {
setViewerPasswordDraft(event.target.value);
setViewerPasswordError(null);
}}
/>
</span>
</label>
{viewerPasswordError && (
<p className="access-error" role="alert">
{viewerPasswordError}
</p>
)}
<button className="button button-primary" type="submit">
进入
</button>
</form>
)}
</main>
</div>
+33
View File
@@ -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));
}
+85 -2
View File
@@ -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;
+236 -1
View File
@@ -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<ConnectedParticipant> {
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<boolean> {
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<Buffer | null> {
return viewerPasswordKdfGate.run(
() =>
new Promise<Buffer>((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<T>(
task: () => Promise<T>,
mayStart: () => boolean,
): Promise<T | null> {
if (!(await this.acquire())) {
return null;
}
try {
return mayStart() ? await task() : null;
} finally {
this.release();
}
}
private acquire(): Promise<boolean> {
if (this.active >= this.limit) {
if (this.waiters.length >= this.pendingLimit) {
return Promise.resolve(false);
}
return new Promise<boolean>((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";
}
+132 -22
View File
@@ -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<ClientMessage, { type: "authenticate" }>,
): void {
): Promise<void> {
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<void> {
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,
+23
View File
@@ -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<typeof viewerPasswordSchema>;
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({
+49
View File
@@ -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",
+255
View File
@@ -22,6 +22,13 @@ function expectRoomError(action: () => unknown, code: RoomStoreError["code"]): v
}
}
async function expectRoomErrorAsync(
action: () => Promise<unknown>,
code: RoomStoreError["code"],
): Promise<void> {
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();
}
+115 -2
View File
@@ -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 () => {