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:
1 parent
2930e4c788
commit
ded55bcd7d
16 files changed
+1210
-96
No files matched your search
@@ -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.
|
||||
@@ -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
|
||||
|
||||
@@ -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
@@ -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
@@ -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
@@ -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 兜底
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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));
|
||||
}
|
||||
|
||||
@@ -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
@@ -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
@@ -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,
|
||||
|
||||
@@ -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({
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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
@@ -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 () => {
|
||||
|
||||
Reference in new issue
Block a user