跳转至

EP06:推拉流安全:签名、Token 与防盗链实战

上集回顾 EP05《Simulcast 的原理和用途》讲完了多档画质怎么编、怎么切
下集预告 EP07《如何增强直播弱网抗性》

0. 本集目标

看完这一集,观众应该能做到两件事:

  1. 说清楚播放链接的防盗链 MAC 和推流的鉴权 Token,为什么使用不同的密码学机制,以及两者作为 bearer 凭据共同具有的重放风险。
  2. 理解"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 讲过的协议层降级,还有哪些工程手段能增强直播的弱网抗性。