RTMP 握手:一场客户端与服务端的“暗号”对决

如果把 RTMP 连接比作两个特工接头,那握手阶段就是他们互相确认身份、对暗号的过

程。今天,我们就顺着这段代码,把这个"接头故事"讲给你听。

第一:第一次碰面 ------ C0 与 C1

故事的主角是两个角色:

  • 客户端(想推流/拉流的那个)
  • 服务端(Monibuca,也就是这段代码的主人)

客户端先开口。它发来一个 C0 + C1 ​ 的组合包,一共 1537 字节:

bash 复制代码
C0(1 字节) + C1(1536 字节)

C0:一句话表明来意

C0 只有 1 个字节,意思是:

"我用的是 RTMP 第 3 版协议!"

代码里这么写:

go 复制代码
if C0C1[0] != RTMP_HANDSHAKE_VERSION { // 0x03
    return errors.New("C0 Error")
}

如果不是 0x03,服务端直接翻脸------"不认识你,走开!"

C1:一张复杂的名片

C1 有 1536 字节,结构是这样的

| 字段 | 大小 | 含义 | | --- | --- | --- | | Time | 4 字节 | 当前时间戳 | | Zero | 4 字节 | 关键字段​ | | Random | 1528 字节 | 随机填充 |

Zero 字段是整段故事的分岔路口:

go 复制代码
var zero int
util.GetBE(C1[4:8], &zero)
if zero == 0 {
    return nc.simple_handshake(C1, checkC2)
}
return nc.complex_handshake(C1)
  • zero == 0 → 简单握手,大家都是老实人
  • zero != 0 → 复杂握手,来,对暗号!

第二:简单模式 ------ 老实人的互信

如果客户端是个"老实人",Zero 字段全为 0,那双方就走简单握手。

服务端做的事情很简单:

go 复制代码
S0S1[0] = RTMP_HANDSHAKE_VERSION
util.PutBE(S0S1[1:5], time.Now().Unix()&0xFFFFFFFF)
copy(S0S1[5:], "Monibuca")
nc.Write(S0S1)  // S0 + S1
nc.Write(C1)    // S2 = C1(原样返回)

客户端那边也差不多,它构造 C0C1 发出去,然后等 S0S1,再把 S0S1 当 C2 发回去:

go 复制代码
// 客户端构造 C1
util.PutBE(C1[0:4], time.Now().Unix()&0xFFFFFFFF)
util.PutBE(C1[4:8], 0)  // Zero = 0,声明简单握手
// 填充随机数据...

简单握手就像两个人见面握个手:"你好""你好""再见""再见"------完事。

第三:复杂模式 ------ 特工的暗号对决

如果 Zero 不为 0,说明客户端想玩点高级的。这就是 RTMP 复杂握手(Digest Handshake)。

3.1 两套暗号方案

RTMP 规范定义了两种排布方式,叫 Scheme 0 ​ 和 Scheme 1 。区别在于:在 1536 字节里,密钥(key)和摘要(digest)的位置不同。

两种方案的结构对比:

go 复制代码
Scheme 0:  [Time][Version][Digest区域][Key区域]
Scheme 1:  [Time][Version][Key区域][Digest区域]

每个区域里,偏移量都是由数据本身的前 4 个字节算出来的,相当于"藏宝图上的坐标"。

3.2 偏移量怎么算?

以 Scheme 0 的 Digest 偏移为例:

go 复制代码
func scheme0_Digest_Offset(C1S1 []byte) int {
    // 取偏移字段的 4 个字节
    scheme0_digest_offset := int(C1S1[8]) + int(C1S1[9]) + int(C1S1[10]) + int(C1S1[11])
    // 取模 + 基础偏移
    scheme0_digest_offset = (scheme0_digest_offset % 764) + 4 + 4 + 4
    return scheme0_digest_offset
}

本质就是:从几个字节里算出一个数,模一下最大值,再加个基础偏移,就得到了 digest 藏在哪。

密钥(key)的偏移也是同理,只是读的位置不同。

3.3 验证客户端身份

服务端拿到 C1 后,要做的事是:

  1. 分别用 Scheme 0 和 Scheme 1 尝试解析
  2. 对每种方案:
    • 找到 digest 的位置
    • 把 digest 前后的数据拼起来(相当于"去掉 digest 后的纯净数据")
    • 用 FP_KEY 的前 30 字节做 HMAC-SHA256 密钥,对纯净数据算哈希
    • 拿算出来的哈希和 C1 里的 digest 比对
go 复制代码
tmp_Hash, _ := HMAC_SHA256(c1_Part1_Part2, FP_KEY[:30])
ok = bytes.Equal(digest, tmp_Hash)

如果匹配上了 → 客户端身份确认

如果两种方案都失败 → "你不是自己人!"

go 复制代码
scheme, challenge, digest, ok, err = clientScheme(C1, 1)
if ok { return ... }
scheme, challenge, digest, ok, err = clientScheme(C1, 0)
if ok { return ... }
return ... errors.New("Client scheme error")

3.4 服务端回敬 S1 + S2

验证通过后,服务端要回两个东西:S1 ​ 和 S2。

构造 S1

go 复制代码
S1 := create_S1()  // 随机生成 1536 字节

然后找到 S1 里放 digest 的位置,把"去掉 digest 区域的纯净数据"用 FMS_KEY 前 36 字节做 HMAC-SHA256,算出来的结果填回 digest 位置。

go 复制代码
tmp_Hash, _ = HMAC_SHA256(S1_Part1_Part2, FMS_KEY[:36])
copy(S1[S1_Digest_Offset:], tmp_Hash)

构造 S2

S2 分两部分:

go 复制代码
S2 = 随机数据(1504字节) + digest(32字节)

digest 的生成链条是:

go 复制代码
digest = HMAC-SHA256(随机数据, HMAC-SHA256(客户端digest, FMS_KEY[:68]))
go 复制代码
tmp_Hash, _ = HMAC_SHA256(digest, FMS_KEY[:68])      // 先用客户端digest
S2_Digest, _ = HMAC_SHA256(S2_Random, tmp_Hash)      // 再对随机数据算

最后一把发出去:

go 复制代码
buffer := net.Buffers{[]byte{RTMP_HANDSHAKE_VERSION}, S1, S2_Random, S2_Digest}
buffer.WriteTo(nc)
nc.Skip(1536)  // 跳过客户端可能发来的 C2(复杂握手不需要 C2)

第四:故事总结

阶段 简单握手 复杂握手
标志 C1.Zero == 0 C1.Zero != 0
验证方式 原样返回数据 HMAC-SHA256 签名验证
密钥 无 FP_KEY / FMS_KEY
安全等级 低(谁都能连) 高(需要"暗号")
典型场景 老客户端、调试 Flash Player、正规推流

第五:核心工具函数一览

函数 作用
Handshake() 入口,根据 Zero 字段分流
simple_handshake() 简单握手实现
complex_handshake() 复杂握手实现
validateClient() 尝试验证两种 Scheme
clientScheme() 按指定 Scheme 解析并验证
schemeX_Digest_Offset() 计算 digest 偏移
schemeX_Key_Offset() 计算 key 偏移
HMAC_SHA256() HMAC-SHA256 封装
create_S1() 生成随机 S1
cerate_S2() 生成随机 S2

尾声

RTMP 握手说到底就是一句话

简单握手是"你好我好大家好",复杂握手是"对不上暗号就别想进门"。

这段代码完整实现了 RTMP 规范里的两种握手方式,是 Monibuca 流媒体服务器能和各种 RTMP 客户端(OBS、FFmpeg、Flash Player 等)顺利对接的关键所在。

相关推荐
泡泡oO2 小时前
“如果你还在用Superpowers,那我不要和你说话”
前端·后端·全栈
newerp2 小时前
sync.Mutex 源码深度拆解:正常模式(自旋)与饥饿模式
后端·程序员·go
武子康2 小时前
LingBot-Video 怎么选推理路径?8 步 DMD 不等于小显存
人工智能·后端
打工仔折腾 AI2 小时前
Docker镜像分层与卷挂载到底怎么工作:一次文件系统层面的实测分析
运维·人工智能·后端·python·docker·容器·性能优化
程序员Sunday3 小时前
MCP stdio 与 Streamable HTTP 怎么选,流式消息传到哪里
后端
tntxia3 小时前
单点登录(SSO)完整技术实现方案
后端
一个有温度的技术博主3 小时前
凌晨三点的消息堆积:当 MQ 变成“停车场“
java·后端·场景
谢亮_vipxieliang3 小时前
Go WaitGroup与Once——并发同步的基石
开发语言·后端·golang
ITOM运维行者3 小时前
PHP性能监控怎么做?从响应时间到慢函数的6个关键指标
前端·javascript·后端