EP06:推拉流安全(签名、Token 与防盗链)¶
| 上集回顾 | EP05《Simulcast 的原理和用途》讲完了多档画质怎么编、怎么切 |
|---|---|
| 下集预告 | EP07《如何增强直播弱网抗性》 |
1. 播放侧:txTime / txSecret MAC 防盗链¶
1.1 机制:对规范化资源和过期时间做 HMAC¶
观众拿到的播放链接(或者用于换取播放地址的请求)带一个 Authorization: Bearer {appId}:{txTime}:{txSecret}。这里:
txTime:一个十六进制编码的 Unix 秒级过期时间戳,明文可见,不加密。txSecret:当前v1格式为v1.{keyId}.{hex(HMAC-SHA256)}。HMAC 输入严格按字节拼接为keyId + ":" + trim(streamID, "/") + ":" + strings.ToUpper(txTime),其中streamID是规范化后的{appId}/{streamName}。签发端和验证端必须使用完全相同的路径规范化、字段顺序、分隔符、大小写和字符编码;不能在图示或 SDK 中换成另一套近似写法。
这是常见的时间戳 + HMAC 防盗链方案。严格说 HMAC 是共享密钥消息认证码(MAC),不是公私钥数字签名:任何持有 appSecret 的一方都既能生成也能验证,因此密钥不能下发到不可信客户端。
为什么要把 streamName(路径)纳入 MAC:这正是"防盗链"的核心——txSecret 认证"这一条流的路径 + 这个过期时间",同一个值换一个路径就验证不过。也就是说,即使有人拿到了 A 直播间的合法链接,也没办法把它原样搬到 B 直播间使用;但它在 A 直播间和有效期内仍可被重放。
为什么要有 txTime 这个明文过期时间:链接一旦泄露出去(比如被人转发、截图分享),也只在过期时间之前有效,不会变成一个永久可用的后门。
但过期时间不等于防重放:Authorization: Bearer ... 是持有者凭据,攻击者只要在有效期内截获,就可以原样重放。必须全程使用 TLS,避免令牌进入 URL、Referer、访问日志和分析系统,并设置尽可能短的 TTL。需要一次性语义时,还要引入服务端保存并原子消费的 jti/nonce,或把令牌绑定到会话/客户端密钥;仅有 HMAC 和 txTime 做不到这一点。
这套签名在播放链路里实际会用两次,不是一次:客户端先带着自己算好(或从登录态换来)的 Bearer {appId}:{txTime}:{txSecret} 去请求 ppcenter 的播放接口;ppcenter 验证通过后,并不是把这个 token 原样转发给边缘节点,而是用同一套 GenTxSecretV1 另外签一次——这次签的是边缘节点要验证的 WHEP 地址,形式变成 URL 查询参数 ?txTime=..&txSecret=..(见 internal/services/stream.go 的 GenerateEdgeWHEPURL/GenerateLegacyStreamBase),并且签名覆盖的是裸 {appId}/{streamName}、不含 /h264、/hevc 这类编解码器后缀,边缘节点验证前会先把路径里的编解码器段剥掉。这样同一个签名既能验证 /h264/whep 也能验证 /hevc/whep,客户端自己决定追加哪个后缀。
sequenceDiagram
participant C as 播放客户端
participant P as ppcenter
participant E as 边缘节点
C->>P: 播放请求(Bearer {appId}:{txTime}:{txSecret})
P->>P: 验证 txSecret(HMAC + 常量时间比较)
P->>P: 用 GenTxSecretV1 另签一次 WHEP 地址(签名覆盖裸路径)
P-->>C: 返回边缘 WHEP 地址(?txTime=..&txSecret=..)
C->>E: 请求 /h264/whep 或 /hevc/whep
E->>E: 先剥掉编解码器段,再验证签名
E-->>C: 放行或拒绝
1.2 验证时的两个细节,容易被忽略但很重要¶
- 常量时间比较:验证 MAC 时不能简单用提前退出的字符串比较,本项目使用
hmac.Equal。这避免比较函数本身泄漏匹配前缀的时序信息;完整服务仍需避免其他分支、日志或错误响应形成侧信道。 - 安全敏感的失败原因不做区分,但"过期"例外:当前实现(
ppcenter/internal/apis/auth.go的ApiAuthVerifyHandler、player_v1.go的V1PlayHandler)会把"过期"单独编码返回(如secret expired/viewer_token_expired),因为过期本身不是秘密,客户端需要据此判断要不要重新取一个签名;但"签名不匹配"和"appId 不存在"这两种更敏感的失败,会被收敛成同一个通用错误(invalid txSecret/invalid_viewer_token),不会告诉对方具体是哪一步错的。这是为了不给尝试破解的人提供任何"改进下一次尝试"的线索——区分的粒度按"是否泄露安全信息"来定,不是把所有失败都做成完全同一种提示。
1.3 keyId:密钥轮换的接口已经留好,但今天还没接通¶
v1.{keyId}.{签名} 这个格式里的 keyId,是密钥轮换机制的关键字段。如果连这个字段都没有,轮换 appSecret 的后果是:所有已经签发出去、还没过期的旧链接会瞬间全部失效,因为服务端已经不知道该用哪把密钥去验证它们。
设想一套完整的多版本密钥存储实现,有了 keyId 之后可以做到:
- 每一次签发签名时,都指定当前生效的密钥版本号作为
keyId(比如按月份命名成2026-07这样的字符串)。 - 服务端验证签名时,从
Bearer里解析出keyId,用它找到对应版本的密钥去验证——不需要猜,也不需要用"新密钥"去验证一条用"旧密钥"签的链接。 - 轮换密钥时,只需要让新签发的链接用新
keyId+ 新密钥,旧keyId对应的旧密钥在过渡期内继续保留、继续能验证——旧链接不会因为轮换而立刻报废,可以按自己的过期时间自然失效。 keyId本身会做格式校验:不允许包含.或:这类分隔符字符,避免和 token 里其它字段的分隔符混淆、被用来做解析层面的注入(见internal/utils/utils.go的normalizeTxSecretKeyID)。
但这套"完整实现"目前并不是 ppcenter 的现状。 读 internal/models/app_credential.go(以及底层 user_app.go)会发现 AppCredential 每个 app 只存一个 AppSecret 字段,没有第二把、第三把密钥的位置;internal/utils/utils.go 里 GenTxSecretV1WithKeyID 虽然支持任意 keyId,但全仓库所有实际签发点(auth.go 的 ApiAuthGenHandler、play_link_v1.go、origin_v1.go、record_manifest_v1.go、services/stream.go)清一色调用的是 GenTxSecretV1,也就是固定传 keyID = "default"——目前没有任何对外 API 能签发出一个非 default 的 keyId。验证侧(verifyTxSecretInternal)即使从 Bearer 里解析出不同的 keyId,也只会拿这唯一一个 AppSecret 去重算 HMAC,并不会"用 keyId 查到另一把密钥"——因为压根没有另一把密钥可查。
也就是说:今天如果真的轮换一个 app 的 AppSecret,效果和完全没有 keyId 字段时一样——所有旧链接立刻失效,不区分 keyId。keyId 字段已经预留在协议格式里,为将来接入多版本密钥存储铺好了路,但那层存储和按版本撤销的逻辑现在还不存在,不能把"格式支持"误读成"今天就能做到无中断轮换"。
2. 推流侧:WHIP Token 用的是加密,不是签名¶
这不是节点注册用的 nodeSecret
这里说的"推流 Token",是 OBS/ppobs 这类推流客户端换取一次推流机会时用的应用层凭据。它和 mmx 节点向 ppcenter 注册自己时用的 nodeSecret(WS NodeRegister/NodeRegisterAck,节点级凭据,近期 v1.0.70 还把自建节点那一侧从"展示 nodeSecret"改成了"用 licenseCode 注册")完全是两套独立机制:一个认证的是"这次推流请求",一个认证的是"这台节点本身",互不替代、互不复用,解密密钥和签发/撤销的生命周期也完全不同。
2.1 为什么不能照搬播放侧那一套¶
推流侧的 Token 需要携带更多结构化信息:设备标识、目标 appId/streamName、绑定的编解码器(h264/hevc)、签发时间和过期时间。如果还是用"明文字段 + 签名"的方式,这些字段就都是明文暴露在 Token 里,任何人拿到 Token 都能看到里面的完整信息——签名只能证明"没被篡改",不能做到"内容保密"。
推流场景选择了另一条路:用 AES-256-GCM 把整个 Token 内容加密封装,而不是明文 + 签名。这个 Token 走 WHIP-Device-Id 请求头传递(或退化到标准 Authorization: Bearer <token>),WHIP 推流强制要求它,缺失直接拒绝、没有关闭开关。
SRT 推流用的是同一套 Token,但默认不强制:SRT 把同样的 AES-256-GCM Token 放进 streamid 的第三段(publish:{appId}/{stream}:{token}),验证端(mmx)用和 WHIP 完全同一份 Go 实现、同一把 WHIP_AUTH_KEY去解,而不是另起一套机制。区别只在于是否强制校验:由 srtPublishTokenRequired 这个开关控制,默认值是 false——也就是说当前生产配置下,SRT 推流即使不带 Token 也会被放行,退回到 SRT 自身的 streamid user/pass(这一层等于不校验)。这是为了兼容不带 token 的旧版 ppobs 和第三方 SRT 推流工具保留的权宜状态,不是设计上认为 SRT 不需要鉴权;细节见 docs/design/ppcdn-mmx-publish-whitelist.zh-CN.md §3。
2.2 机制:一次加密,同时拿到保密性和防篡改¶
- 把
{uuid, appId, streamName, codec, iat, exp}这些字段序列化成 JSON,作为明文。 - 用 AES-256-GCM 加密 JSON。每次加密生成新的随机 nonce,并把 nonce 与密文一起编码进 Token;同一 AES-GCM 密钥下 nonce 必须唯一,否则会严重破坏机密性和完整性。应使用 CSPRNG、检查随机源错误、监控密钥轮换,并避免多实现间采用会重复的计数器或固定 nonce。
- 当前实现用
SHA-256(authKey)把配置字符串规整成 32 字节 AES key。这只是确定性哈希映射,不是带 salt 和成本参数的密码 KDF,不会增加低熵口令的强度。authKey必须由 CSPRNG 生成并具有足够熵;如果产品允许人类口令,应改用 Argon2id/scrypt/PBKDF2 等合适 KDF 和独立 salt。不同用途还应使用 HKDF 等方式做域分离,不要让同一原始密钥直接复用于其他协议。 - GCM 是一种"认证加密"(AEAD)算法:它在加密的同时自带一个完整性校验,任何人篡改了密文内容,解密这一步会直接失败——不需要像签名方案那样,额外附加一段独立的签名字段。这是它和播放侧 HMAC 方案的本质区别:HMAC 是"明文 + 独立签名"两段式,GCM 是"加密和防篡改揉进同一次运算"。
- 解密后还会额外检查过期时间;
codec字段被绑定后,一个只签给h264路径的 Token,拿去请求hevc路径会被直接拒绝——多轨场景下两个编解码器的推流权限互不越权。
AES-GCM 只提供机密性、完整性和来源于共享密钥的真实性,不提供重放防护。WHIP Token 同样是 bearer token:在 exp 前被窃取就可被重放。除 TLS、短 TTL 和日志脱敏外,高风险推流应使用一次性 jti/nonce 的服务端消费记录,或将凭据绑定到设备持有的密钥及具体会话。还应校验 iat、exp、允许的 clock skew、目标 app/stream/codec 和必要的 action/audience;各节点时钟必须同步。
2.3 两种机制,两种设计目的¶
| 播放侧(txTime/txSecret) | 推流侧(WHIP Token) | |
|---|---|---|
| 密码学原语 | HMAC-SHA256(共享密钥 MAC) | AES-256-GCM(认证加密) |
| 内容是否明文可见 | 是(路径、过期时间明文,MAC 防篡改) | 否(claims 加密封装) |
| 谁需要验证 | 任意持有 appSecret 的一方都能自己算出签名并验证 |
只有持有加密密钥的一方能解密出内容 |
| 密钥轮换方式 | 格式带 keyId;验证端仍须实现多版本密钥存储与撤销 |
当前格式无 keyId,轮换需部署协同或升级令牌信封格式 |
| 设计目的 | 轻量、无状态、可自行计算,适合短期播放链接 | 保密 + 防篡改一次到位,适合携带敏感结构化字段 |
| 重放属性 | 有效期内可重放,除非另加一次性状态或密钥绑定 | 有效期内可重放,GCM nonce 不等于防重放 nonce |
一句话总结核心判断:"只需要认证内容、明文公开也没关系"可用 MAC;"内容需要保密且要防篡改"可用认证加密。 两者都不天然防 bearer 重放,授权范围和令牌生命周期仍需单独设计。
3. 框图¶
3.1 播放侧 MAC 验证流程¶
flowchart TD
A["客户端请求播放<br/>Authorization: Bearer {appId}:{txTime}:{txSecret}"] --> B["服务端从 txSecret 的 v1.{keyId}.{hex} 格式里解析出 keyId"]
B --> C["取出该 appId 唯一的 appSecret<br/>今天还没有按 keyId 查多版本密钥这一步,见 §1.3"]
C --> D["按规范化规则计算 HMAC-SHA256<br/>keyId + ':' + trim(streamID,'/') + ':' + upper(txTime)"]
D --> E["常量时间比较,与客户端传来的签名逐位比对"]
E --> F{"比对结果"}
F -->|已过期| G["返回已过期提示(与其它失败提示不同)"]
F -->|未过期但不一致| H["返回统一的签名无效提示(appId 不存在也走这个)"]
F -->|未过期且一致| I["通过,返回播放地址"]
3.2 密钥轮换:keyId 要配合多版本密钥存储才能这样用(目前还不是现状)¶
这张图画的是目标设计,不是 ppcenter 今天的行为
按 §1.3 的代码核查,ppcenter 当前每个 app 只有一个 AppSecret,所有签发点都固定用 keyId = "default"。下面的流程描述的是接入多版本密钥存储之后keyId 应该怎么配合轮换工作——理解它有助于明白这个字段"为什么存在",但照着这张图去规划生产轮换操作会落空,因为按 keyId 查找旧密钥、按版本撤销这两步后端还没有实现。
stateDiagram-v2
state "旧密钥 keyId=2026-06 生效" as OldKey
state "触发轮换:新密钥 keyId=2026-07 开始签发新链接" as Rotate
state "旧链接(keyId=2026-06)仍正常验证通过" as OldValid
state "旧密钥对应的所有链接自然过期" as Expired
state "运维侧可安全下线 keyId=2026-06 旧密钥" as Decommission
[*] --> OldKey
OldKey --> Rotate: 为旧密钥签发的链接仍在观众手里,会持续到各自的 txTime 过期
Rotate --> OldValid: 新请求用 keyId=2026-07 签发
OldValid --> Expired
Expired --> Decommission
Decommission --> [*]
3.3 推流 Token:加密与解密¶
flowchart TD
A["ppcenter 签发推流 Token<br/>明文 claims:{uuid, appId, streamName, codec, iat, exp}"] --> B["生成唯一随机 nonce,执行 AES-256-GCM<br/>当前密钥映射 = SHA-256(authKey),authKey 必须是高熵随机秘密"]
B --> C["密文 Token 交给 ppobs"]
C --> D["ppobs 用 Token 发起 WHIP 推流请求"]
D --> E["ppmmx 用同一把密钥做 AES-256-GCM 解密"]
E --> F{"解密与校验结果"}
F -->|解密失败(密文被篡改,GCM 校验不过)| G["拒绝"]
F -->|解密成功但时间声明无效(iat/exp/clock skew)| G
F -->|解密成功但 codec 与请求路径不匹配| G
F -->|jti 已消费(若启用一次性令牌)| H["拒绝重放"]
F -->|全部通过| I["允许推流"]