diff --git a/.env.example b/.env.example index d2b516a..5e10814 100644 --- a/.env.example +++ b/.env.example @@ -7,7 +7,7 @@ PUBLIC_BASE_URL=http://localhost:8787 # Include both the host browser origin and PUBLIC_BASE_URL when they differ. ALLOWED_ORIGINS=http://localhost:8787 # Production-required Host admission secret. Local development/tests may leave it -# empty. A non-empty value must contain 16-128 visible ASCII bytes (0x21-0x7e). +# empty. A non-empty value must contain 8-128 visible ASCII bytes (0x21-0x7e). HOST_ADMISSION_PASSWORD= # Optional SQLite file for persistent rooms. Requires HOST_ADMISSION_PASSWORD. # The systemd example creates /var/lib/screener for this production path. @@ -36,7 +36,7 @@ MAX_SFU_ROOTS_PER_ROOM=2 STUN_URLS= # Minimal production example (use independent untracked secrets): -# HOST_ADMISSION_PASSWORD=replace-with-an-independent-32-byte-secret +# HOST_ADMISSION_PASSWORD=replace-with-an-independent-access-key # ROOM_DATABASE_PATH=/var/lib/screener/rooms.sqlite # STUN_URLS=stun:stun.example.com:3478 # Automatic SFU fallback (requires PEER_ASSISTED_MEDIA=true and does not diff --git a/README.md b/README.md index 7984d6a..acf6a9b 100644 --- a/README.md +++ b/README.md @@ -15,7 +15,7 @@ npm ci npm run dev ``` -Production requires an independent 16-128 byte visible-ASCII `HOST_ADMISSION_PASSWORD`. It authorizes room creation and Host role only; invited Viewers never need it. Local development and tests may leave it empty. Private rooms use an expiring room-scoped fragment grant, while explicit public-watch rooms accept only the numeric room code. +Production requires an independent 8-128 byte visible-ASCII `HOST_ADMISSION_PASSWORD`. It is a simple deployment access key for room creation and Host role, not an account password; invited Viewers never need it. Local development and tests may leave it empty. Private rooms use an expiring room-scoped fragment grant, while explicit public-watch rooms accept only the numeric room code. Open `http://localhost:8787`. The server listens on `0.0.0.0` by default so a phone on the same LAN can load `http://:8787`. To generate an diff --git a/docs/adr/0002-persistent-protected-rooms.md b/docs/adr/0002-persistent-protected-rooms.md index 3acb4f5..715d37f 100644 --- a/docs/adr/0002-persistent-protected-rooms.md +++ b/docs/adr/0002-persistent-protected-rooms.md @@ -30,7 +30,7 @@ Separate Host admission from Viewer authorization in one atomic release: - Replace `ACCESS_PASSWORD` with `HOST_ADMISSION_PASSWORD`. Production startup requires it; local development and tests may explicitly disable it. It accepts - 16 through 128 visible ASCII bytes, must be independent from other deployment + 8 through 128 visible ASCII bytes, must be independent from other deployment secrets, and issues a 12-hour stateless HMAC `HttpOnly`, `SameSite=Strict`, `Path=/` cookie (`Secure` and `__Host-` in HTTPS production). It authorizes only room creation and an attempted Host @@ -181,7 +181,7 @@ SQLite or participates in authorization, routing, or quality decisions. ## Acceptance Gates - The auth matrix proves production Host admission, Viewer-without-Host-cookie, - 16-byte minimum/independent secret, ingress rate limit, exact-room/role grant + 8-byte minimum/independent secret, ingress rate limit, exact-room/role grant scope, non-enumerating failures, and public-watch caps. - Leak tests cover HTTP/WS targets, Referrer, browser storage, SQLite, application and proxy logs, and errors; fragment consumption immediately clears the URL. diff --git a/docs/deployment.md b/docs/deployment.md index 6268e1e..0362018 100644 --- a/docs/deployment.md +++ b/docs/deployment.md @@ -128,7 +128,7 @@ LISTEN_HOST=127.0.0.1 PORT=8787 PUBLIC_BASE_URL=https://share.example.com ALLOWED_ORIGINS=https://share.example.com -HOST_ADMISSION_PASSWORD= +HOST_ADMISSION_PASSWORD= ROOM_DATABASE_PATH=/var/lib/screener/rooms.sqlite ROOM_TTL_SECONDS=14400 MAX_ROOMS=1000 @@ -162,7 +162,7 @@ than enabling every room. There is no browser control, percentage rollout, or all-room fail-open. `ALLOWED_ORIGINS` must list exact `http` or `https` origins, never `*`. -`HOST_ADMISSION_PASSWORD` is required in production and must contain 16 through +`HOST_ADMISSION_PASSWORD` is required in production and must contain 8 through 128 visible ASCII bytes (`0x21` through `0x7e`). It must not be reused for LiveKit, TLS, TURN, or another service. It authorizes room creation and Host role only; it is not a Viewer password. Local development and tests may omit it. diff --git a/docs/方案设计.md b/docs/方案设计.md index f0576ca..df2378c 100644 --- a/docs/方案设计.md +++ b/docs/方案设计.md @@ -66,7 +66,7 @@ tests/ RoomStore、ICE 配置、协议和 WebSocket 集成测试 ## 房间与访问控制 -`HOST_ADMISSION_PASSWORD` 是分享准入,不是 Viewer 密码。production 启动时必须配置;本地开发/测试可显式关闭。非空值必须包含 16 至 128 个可见 ASCII bytes(`0x21` 至 `0x7e`),并作为不复用 TURN、LiveKit、TLS 或其他凭据的独立 secret。`POST /api/host-admission` 只在严格允许的 Origin 下接收 `Authorization: Bearer`,成功后签发 12 小时的无状态 Host-admission cookie:值只包含版本、到期时间和 HMAC-SHA256 签名,不创建账号、JWT 或服务端 session Map。cookie 使用 `HttpOnly`、`SameSite=Strict`、`Path=/`、有限 `Max-Age`;HTTPS 生产环境使用 `Secure` 和 `__Host-` 前缀。`POST /api/rooms` 只认该 cookie,不接受同一 Bearer 旁路。原子 deployment change 在 nginx 为该 admission endpoint 配置 per-source `limit_req`,初始边界为 `5r/m`、`burst=5 nodelay`;超限返回通用 429,允许到达 Node 的错误 secret 使用常量时间比较和统一 401。limiter 只使用 nginx shared memory,不新增 IP 持久化/日志字段;Node 不建立 IP key/LRU/session table,并保留现有全局/未鉴权连接上限。删除旧 `ACCESS_PASSWORD`、`/api/session` 与旧 cookie;旧环境变量出现时 fail-fast。 +`HOST_ADMISSION_PASSWORD` 是简单的部署访问口令,不是 Viewer 密码或账号凭据。production 启动时必须配置;本地开发/测试可显式关闭。非空值必须包含 8 至 128 个可见 ASCII bytes(`0x21` 至 `0x7e`),并作为不复用 TURN、LiveKit、TLS 或其他凭据的独立 secret。`POST /api/host-admission` 只在严格允许的 Origin 下接收 `Authorization: Bearer`,成功后签发 12 小时的无状态 Host-admission cookie:值只包含版本、到期时间和 HMAC-SHA256 签名,不创建账号、JWT 或服务端 session Map。cookie 使用 `HttpOnly`、`SameSite=Strict`、`Path=/`、有限 `Max-Age`;HTTPS 生产环境使用 `Secure` 和 `__Host-` 前缀。`POST /api/rooms` 只认该 cookie,不接受同一 Bearer 旁路。原子 deployment change 在 nginx 为该 admission endpoint 配置 per-source `limit_req`,初始边界为 `5r/m`、`burst=5 nodelay`;超限返回通用 429,允许到达 Node 的错误 secret 使用常量时间比较和统一 401。limiter 只使用 nginx shared memory,不新增 IP 持久化/日志字段;Node 不建立 IP key/LRU/session table,并保留现有全局/未鉴权连接上限。删除旧 `ACCESS_PASSWORD`、`/api/session` 与旧 cookie;旧环境变量出现时 fail-fast。 WebSocket upgrade 无法预知后续 role,因此不能再用 Host cookie 拒绝全部 Viewer。它仍在握手前严格校验 `/signal` 无 query、Origin allowlist、总连接数和未鉴权连接数,并把当次 request 的 Host cookie 校验结果只保存到 socket state。五秒内的第一条 `authenticate` 才完成授权:Host 必须同时拥有 upgrade 时有效的 Host-admission 状态和当前房间 `hostToken`;Viewer 只按目标房间 policy 校验。Host cookie 不能替代私密 Viewer grant,Viewer grant 也不能用于 HTTP 建房、Host role、其他房间或其他操作。Host cookie 过期不踢掉已鉴权 socket,但其下一次 reconnect 必须重新通过 Host admission。 @@ -287,7 +287,7 @@ hop 按[低服务器成本媒体路由](./research/low-server-media-routes.md) 首屏就是工具,不做营销页。视觉采用中性深灰工作台,绿色表示 direct、琥珀色表示 relay/警告、红色表示失败,避免单一蓝紫色主题;浏览器 favicon 复用页头 `MonitorUp` 的深绿底、绿色显示器和上行箭头语言。 -- Host admission:只在用户进入分享/建房流程且 `HOST_ADMISSION_PASSWORD` cookie 无效时显示密码输入;`/join` 与 `/r/{code}` 不显示或泄露该密码。 +- 访问入口:未鉴权根路径和 `/join` 只显示中性的“访问验证/访问口令”,未知路径只显示“无法访问”,HTML metadata 不说明站点用途。有效 `/r/{code}#v={grant}` 或 public-watch `/r/{code}` 先完成 Viewer 鉴权,成功后才渲染房间 UI;无效、过期或满员只显示通用拒绝。Viewer 页面不显示或泄露部署访问口令。这是降低偶然访客信息量的展示边界,不把浏览器可下载的 bundle 当成秘密。 - 分享者根页面:顶部显示“{分享者显示名} 的房间”和房间状态,并在标题旁显示可单独复制的紧凑房间号;主区域保持稳定的 16:9 舞台。未发布、已停止或可恢复错误状态时,舞台内以同等尺寸和权重显示“开始分享”和“加入房间”,中间只用“或”与分隔线表达发送/接收;前者直接开始现有分享流程,后者直接进入 `/join`,不增加首页、向导或拓扑选择。直播时舞台显示本地预览,底部保留停止、暂停、质量档位和复制完整邀请链接。 - `/join`:只输入正整数房间码并进入 `/r/{code}`,仅 public-watch 会通过;private-link 缺失 grant 时统一提示使用完整邀请链接。 - `/r/{code}` 观看者:主区域最大化远端视频;private-link 首次解析 fragment grant 后立即清理地址栏,public-watch 只需 code;标题旁显示可复制的紧凑房间号,顶部保留连接状态,并提供本地音量、静音、全屏和重试控制。 @@ -315,7 +315,7 @@ hop 按[低服务器成本媒体路由](./research/low-server-media-routes.md) 自动测试: -- HostAdmission 的 production 必配、16 至 128 visible-ASCII bytes/独立 secret、local-dev 显式关闭、12 小时签名 cookie、过期、篡改、cookie 属性、旧 `ACCESS_PASSWORD` fail-fast 和无服务端 session 状态; +- 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 时启动失败; - STUN URL 严格校验、生产 STUN 必需、ordinary ICE 不含 username/credential、旧 `refresh-ice`/`ice-config` 被协议拒绝,以及 LiveKit `sfu-config` 独立边界; diff --git a/docs/需求理解.md b/docs/需求理解.md index 7d27642..11a163a 100644 --- a/docs/需求理解.md +++ b/docs/需求理解.md @@ -29,7 +29,7 @@ - [ ] 断线恢复:临时网络变化时按不同层级做一次性恢复,而不是把同一动作机械重复三次。`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 信令角色;只有本地开发/测试可显式关闭。非空值必须为 16 至 128 个可见 ASCII bytes,不能复用 TURN、LiveKit、TLS 或其他部署 secret。验证成功签发 12 小时、无服务端会话状态的 HttpOnly、`SameSite=Strict` Host-admission cookie。建房 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。 +- [ ] 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 后才能生成新邀请。 @@ -90,7 +90,7 @@ - Given 持久房间正在分享,When 分享者明确停止或捕获轨结束,Then 房间和链接保持有效,分享端显示“已停止分享”,观看者显示“等待开始分享”;分享者重新发布后可继续在同一房间观看。 - Given 分享期间只有 host 信令短暂断开,When 现有 P2P 媒体仍健康,Then 客户端继续播放媒体并恢复信令,而不是错误显示“已停止分享”。 - Given 应用以 production 模式启动但 `HOST_ADMISSION_PASSWORD` 缺失、`ROOM_DATABASE_PATH` 已配置但该密码为空,或原子迁移后的配置仍提供已删除的 `ACCESS_PASSWORD`,When 应用启动,Then 配置校验明确失败,而不是兼容旧 gate 或公开建房。 -- Given production Host admission secret 短于 16 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 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。 diff --git a/index.html b/index.html index 1d2a601..be8b31f 100644 --- a/index.html +++ b/index.html @@ -4,7 +4,6 @@ - Screener diff --git a/src/client/App.tsx b/src/client/App.tsx index d32cd54..a577c8f 100644 --- a/src/client/App.tsx +++ b/src/client/App.tsx @@ -37,13 +37,16 @@ export function App() { if (viewerRoute) { return ; } - if (isJoinRoute || !isHostRoute) { - return ; + if (isHostRoute) { + return ; } - return ; + if (isJoinRoute) { + return ; + } + return ; } -function HostAdmissionGate() { +function HostAdmissionGate({ surface }: { surface: "host" | "join" }) { const [access, setAccess] = useState({ kind: "checking" }); const [password, setPassword] = useState(""); const [submitting, setSubmitting] = useState(false); @@ -96,6 +99,9 @@ function HostAdmissionGate() { } if (access.kind === "ready") { + if (surface === "join") { + return ; + } return ( @@ -130,11 +136,11 @@ function HostAdmissionGate() { ) : (
void submit(event)}>
-

分享权限

-

请输入分享准入密码

+

访问验证

+

请输入访问口令