EP06:推拉流安全:签名、Token 与防盗链实战¶
| 上集回顾 | EP05《Simulcast 的原理和用途》讲完了多档画质怎么编、怎么切 |
|---|---|
| 下集预告 | EP07《如何增强直播弱网抗性》 |
0. 本集目标¶
看完这一集,观众应该能做到两件事:
- 说清楚播放链接的防盗链 MAC 和推流的鉴权 Token,为什么使用不同的密码学机制,以及两者作为 bearer 凭据共同具有的重放风险。
- 理解"keyId"(密钥标识)在密钥轮换设计里本应起什么作用:格式已经支持它,但要分清这是"预留的轮换接口"还是"已经生效的轮换能力"。
1. 开场钩子(逐字稿要点)¶
前面几集讲的都是"怎么让画面又快又稳",这一集换个话题——怎么防止别人把你的直播链接偷走用在别的地方,怎么防止别人伪造一个推流请求冒充你的主播。这两个问题看起来像是同一件事"做个签名校验"就完了,但这一集会讲清楚,为什么本项目对这两个场景用的是两种完全不同的密码学手段。
2. 播放侧:txTime / txSecret MAC 防盗链¶
2.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,客户端自己决定追加哪个后缀。
2.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),不会告诉对方具体是哪一步错的。这是为了不给尝试破解的人提供任何"改进下一次尝试"的线索——区分的粒度按"是否泄露安全信息"来定,不是把所有失败都做成完全同一种提示。
2.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.4 向后兼容:旧的 MD5 签名怎么退场¶
这套系统还保留更早期的 MD5 格式(appSecret + streamID + strings.ToUpper(txTime) 直接拼接后做 MD5),仅用于迁移期兼容。它不是 HMAC,缺少明确的域分离和现代协议设计保障;MD5 已不适合新安全设计。新签发必须只使用 HMAC-SHA256 v1,生产环境应默认拒绝 legacy MD5,并为确需兼容的 app 设置独立开关、监控和明确下线日期。即使换成 HMAC,如果 claims 没有绑定 action、节点或会话,同一 bearer 仍可能被跨用途尝试重放;算法升级不能替代权限范围设计。
3. 推流侧:WHIP Token 用的是加密,不是签名¶
这不是节点注册用的 nodeSecret
这里说的"推流 Token",是 OBS/ppobs 这类推流客户端换取一次推流机会时用的应用层凭据。它和 mmx 节点向 ppcenter 注册自己时用的 nodeSecret(WS NodeRegister/NodeRegisterAck,节点级凭据,近期 v1.0.70 还把自建节点那一侧从"展示 nodeSecret"改成了"用 licenseCode 注册")完全是两套独立机制:一个认证的是"这次推流请求",一个认证的是"这台节点本身",互不替代、互不复用,解密密钥和签发/撤销的生命周期也完全不同。
3.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。
3.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;各节点时钟必须同步。
3.3 两种机制,两种设计目的¶
| 播放侧(txTime/txSecret) | 推流侧(WHIP Token) | |
|---|---|---|
| 密码学原语 | HMAC-SHA256(共享密钥 MAC) | AES-256-GCM(认证加密) |
| 内容是否明文可见 | 是(路径、过期时间明文,MAC 防篡改) | 否(claims 加密封装) |
| 谁需要验证 | 任意持有 appSecret 的一方都能自己算出签名并验证 |
只有持有加密密钥的一方能解密出内容 |
| 密钥轮换方式 | 格式带 keyId;验证端仍须实现多版本密钥存储与撤销 |
当前格式无 keyId,轮换需部署协同或升级令牌信封格式 |
| 设计目的 | 轻量、无状态、可自行计算,适合短期播放链接 | 保密 + 防篡改一次到位,适合携带敏感结构化字段 |
| 重放属性 | 有效期内可重放,除非另加一次性状态或密钥绑定 | 有效期内可重放,GCM nonce 不等于防重放 nonce |
一句话总结核心判断:"只需要认证内容、明文公开也没关系"可用 MAC;"内容需要保密且要防篡改"可用认证加密。 两者都不天然防 bearer 重放,授权范围和令牌生命周期仍需单独设计。
4. 框图¶
4.1 播放侧 MAC 验证流程¶
客户端请求播放
│ 携带 Authorization: Bearer {appId}:{txTime}:{txSecret}
▼
服务端解析出 keyId(从 txSecret 的 "v1.{keyId}.{hex}" 格式中取出)
│
▼
取出该 appId 唯一的 appSecret(今天还没有按 keyId 查多版本密钥这一步,见 §2.3)
│
▼
按规范化规则计算 HMAC-SHA256(keyId + ":" + trim(streamID,"/") + ":" + upper(txTime))
│
▼
用常量时间比较,和客户端传来的签名逐位比对
│
├─ 已过期 ──▶ 返回"已过期"(和下面的失败提示不同)
├─ 未过期但不一致 ──▶ 返回统一的"签名无效"(appId 不存在也走这个提示,不进一步区分)
└─ 未过期且一致 ──▶ 通过,返回播放地址
4.2 密钥轮换:keyId 要配合多版本密钥存储才能这样用(目前还不是现状)¶
这张图画的是目标设计,不是 ppcenter 今天的行为
按 §2.3 的代码核查,ppcenter 当前每个 app 只有一个 AppSecret,所有签发点都固定用 keyId = "default"。下面的流程描述的是接入多版本密钥存储之后keyId 应该怎么配合轮换工作——理解它有助于明白这个字段"为什么存在",但照着这张图去规划生产轮换操作会落空,因为按 keyId 查找旧密钥、按版本撤销这两步后端还没有实现。
时间线 ──────────────────────────────────────────────▶
[旧密钥 keyId=2026-06 生效]
│
│ 为旧密钥签发的链接仍在观众手里,会持续到各自的 txTime 过期
│
▼
[触发轮换:新密钥 keyId=2026-07 开始签发新链接]
│
├─ 新请求 ──▶ 用 keyId=2026-07 签发
│
└─ 旧链接(keyId=2026-06)──▶ 服务端仍保留旧密钥,正常验证通过
│
▼
[旧密钥对应的所有链接自然过期]
│
▼
[运维侧可以安全下线 keyId=2026-06 这个旧密钥]
4.3 推流 Token:加密与解密¶
ppcenter 签发推流 Token
│
│ 明文 claims:{uuid, appId, streamName, codec, iat, exp}
▼
生成唯一随机 nonce,执行 AES-256-GCM
│ 当前密钥映射 = SHA-256(authKey),authKey 必须是高熵随机秘密
│
▼
密文 Token 交给 ppobs
ppobs 用 Token 发起 WHIP 推流请求
│
▼
ppmmx 用同一把密钥做 AES-256-GCM 解密
│
├─ 解密失败(密文被篡改,GCM 校验不过)──▶ 拒绝
├─ 解密成功但时间声明无效(iat/exp/clock skew)──▶ 拒绝
├─ 解密成功但 codec 与请求路径不匹配 ──▶ 拒绝
├─ jti 已消费(若启用一次性令牌) ──▶ 拒绝重放
└─ 全部通过 ──▶ 允许推流
5. 结尾与下集预告(逐字稿要点)¶
这一集讲清楚了两套安全机制的边界:播放侧 HMAC 保护明文 claims 的完整性,推流侧 AES-GCM 同时提供保密性和完整性;
keyId这个字段已经留好了多版本密钥轮换的接口,但 ppcenter 今天还只有单把AppSecret、固定用default,真正的无中断轮换要等后端接上多版本密钥存储才能生效。随机 GCM nonce 解决的是加密安全所需的唯一性,不是 bearer 重放。两类凭据都必须使用 TLS、短 TTL、严格的 claims 绑定和日志脱敏;需要不可重放时,必须增加服务端一次性状态或持有者密钥绑定。下一集,我们回到弱网这个老话题——除了 EP04 讲过的协议层降级,还有哪些工程手段能增强直播的弱网抗性。