目录
1. RTMP握手解析
- 为了在保证握手的身份验证功能的基础上尽量减少通信的次数,RTMP握手发送顺序一般是:
|client|Server |
|---C0+C1---->|
|<--S0+S1+S2-- |
|---C2----> |
- RTMP握手分为简单握手和复杂握手。
1. 简单握手
1. c0和s0
- version:版本号,固定为0x03
- 结构如下:
2. c1和s1
- times:4字节,包含一个timestamp,用于本终端发送的所有后续块的时间起点。
- 4字节时间戳一般以毫秒为单位,这个值也可以是0,也可以是任意值。
- zero:4字节,字段内容全是0。
- 通过4字节二进制串全0,服务端可以判断出是客户端使用的是否是简单模式。
- random-bytes:1528字节,可以是任意内容。
- 终端需要区分出响应来自发起的握手还是对端发起的握手。
- 内容随机,不需要加密。
- nginx-rtmp-module作为客户端时,c1中time使用的是当前unix时间戳的毫秒部分。 nginx-rtmp-module作为服务端时,如果判断客户端为简单模式,解析完c1中的时间戳后并没有使用这个时间戳。发送s1时,是将c1的1536字节原样返回的。
- 结构如下:
3. c2和s2
- time:4个字节,表示对端发送的时间戳。
- c2的time应该设置为s1中的time字段。
- s2的time应该设置为c1中的time字段。
- time2:4个字节,表示接收对端发送过来的时间戳。
- c2的time2应该设置为收到s1的时间点。
- s2的time2应该设置为收到c1的时间点。
- random-bytes: 1528字节,表示对端发送过来的随机数据。
- c2的random-bytes应该设置为收到s1的random-bytes。
- s2的random-bytes应该设置为收到c1的random-bytes。
- obs客户端(obs使用简单握手模式)和nginx-rtmp-module服务端握手,c1、c2、s1、s2的整个1536字节是完全相同的。
- 说明time和time2这些字段,nginx-rtmp-module并没有完全按照文档说的来做。
- 结构如下:
2. 复杂握手
1. hmac-sha256
- hmac-sha256算法,复杂模式会使用它做一些签名运算和验证。
- 这个算法的输入为一个key(长度可以为任意)和一个input字符串(长度可以为任意),经过hmac-sha256运算后得到一个32字节的签名串。
- key和input固定时,hmac-sha256运算结果也是固定的。
2. c0和s0
- version:版本号,固定为0x03
3. c1和s1
- 复杂握手将c1和s1划分成四个部分,根据key和digest的位置,分为两种方式。
- 第一种方式:
#schema0
time: 4bytes
version: 4bytes
key: 764bytes
digest: 764bytes
- 第二种方式:
#schema1
time: 4bytes
version: 4bytes
digest: 764bytes
key: 764bytes
- 两种schema中,使用哪一种由客户端决定,服务端首先按照schema0解析,失败则再按照schema1解析。
- 结构解析如下:
- time:4字节,同简单模式,ffmpeg使用的是[0, 0, 0, 0]
- 4字节时间戳一般以毫秒为单位,这个值也可以是0,也可以是任意值。
- version:4字节,版本号,nginx-rtmp-module使用的是[0x0C, 0x00, 0x0D, 0x0E]。ffmpeg使用的是[9, 0, 124, 2]
- key:764字节,结构如下:
random-data: (offset) bytes
key-data: 128 bytes
random-data: (764 - offset - 128 - 4) bytes
offset: 4 bytes
- digest:764字节,结构如下:
offset: 4 bytes
random-data: (offset) bytes
digest-data: 32 bytes
random-data: (764 - 4 - offset - 32) bytes
4. c2和s2
- c2和s2主要用来对S1和C1的验证,长度同样为1536字节。
- 结构如下:
random-data: 1504bytes
digest-data: 32bytes
- random-data和digest-data都应来自对应的数据。
5. digest相关
1. digest位置
- c1和s1结构分为两种格式。
- 第一种方式:
#schema0
time: 4bytes
version: 4bytes
key: 764bytes
digest: 764bytes
- 第二种方式:
#schema1
time: 4bytes
version: 4bytes
digest: 764bytes
key: 764bytes
- digest的位置可以在前半部分,也可以在后半部分。
- 当digest位置在前半部分时,digest的位置信息(offset)保存在前半部分起始位置。
- c1格式展开如下:
| 4字节time | 4字节版本号 | 4字节offset | left[...] | 32字节digest | right[...] | 后半部分764字节 |
// 取余728是因为前半部分的764字节要减去offset字段的4字节,再减去digest的32字节
// 12是因为要跳过4字节time + 4字节版本号 + 4字节offset
offset = (c1[8] + c1[9] + c1[10] + c1[11]) % 728 + 12
- offset的取值范围为[12,740)。
- 当digest在后半部分时,offset保存在后半部分的起始位置。
- c1格式展开如下:
| 4字节time | 4字节模式串 | 前半部分764字节 | 4字节offset | left[...] | 32字节digest | right[...] |
// 取余728是因为后半部分的764字节要减去offset字段的4字节,再减去digest的32字节
// +8+764+4是因为要跳过4字节time + 4字节版本号 + 前半部分764字节 + 4字节offset
offset = (c1[8+764] + c1[8+764+1] + c1[8+764+2] + c1[8+764+3]) % 728 + 8 + 764 + 4
- offset的取值范围为[776,1504)
2. digest生成
- c1和c2中1528字节复杂二进制串生成规则:
- 将1528字节复杂二进制串进行随机化处理。
- 在1528字节随机二进制串中写入32字节的digest签名。
- 具体过程见代码解析。
2. lal中RTMP握手实现
-
lalserver是纯Golang开发的流媒体(直播音视频网络传输)服务器。目前已支持RTMP, RTSP(RTP/RTCP), HLS, HTTP[S]/WebSocket[S]-FLV/TS, GB28181协议。并支持通过插件形式进行二次开发扩展。
-
lal github 地址: https://github.com/q191201771/lal
-
lal 官方文档地址:lal官方文档
-
lal中每来一个rtmp连接,就会开启一个协程进行接收,(s *ServerSession) handshake() 函数负责rtmp握手。
-
相关原理参考上述RTMP握手解析介绍。
func (s *ServerSession) handshake() error {
if err := s.hs.ReadC0C1(s.conn); err != nil {
return err
}
Log.Infof("[%s] < R Handshake C0+C1.", s.UniqueKey())
Log.Infof("[%s] > W Handshake S0+S1+S2.", s.UniqueKey())
if err := s.hs.WriteS0S1S2(s.conn); err != nil {
return err
}
if err := s.hs.ReadC2(s.conn); err != nil {
return err
}
Log.Infof("[%s] < R Handshake C2.", s.UniqueKey())
return nil
}
1. 服务端接收客户端发送的c0,c1 chunk
1. ReadC0C1(reader io.Reader) 函数解析
- ReadC0C1(reader io.Reader) 函数会解析c0c1 chunk,生成新的digest-data,如果digest-data长度不为0,则说明是复杂握手,否则是简单握手,同时会构造s0,s1,s2 chunk用于后续发送。
- 判断好简单握手还是复杂握手后,开始构造s0,s1,s2。
- s0占1字节,表示版本号,值为3。
- s1占1536字节,4字节time,另外:
- 对于简单握手:
- 接下来4字节为zero,都为0
- 最后1528字节为random-bytes,添加随机字符
- 对于复杂握手:
- 接下来4字节version,内容为:0x0D, 0x0E, 0x0A, 0x0D
- 最后1528字节为random-bytes,先往里边填充随机字符,然后按照digest key结构,获取digest的offset,根据36字节固定的key生成新的digest-data替换原先digest-data。
- 对于简单握手:
- s2占1536字节,分为简单模式和复杂模式:
- 对于简单模式:
- s2复制了c0c1 chunk中c1的内容
- 对于复杂模式:
- s2先填充1528字节的random-bytes,然后再根据parseChallenge()函数生成的新digest-data作为key,生成新的digest-data填充到s2的末尾32字节。
- 对于简单模式:
- 代码如下:
func (s *HandshakeServer) ReadC0C1(reader io.Reader) (err error) {
c0c1 := make([]byte, c0c1Len)
if _, err = io.ReadAtLeast(reader, c0c1, c0c1Len); err != nil { // 读取c0c1Len(1+1536)字节
return err
}
s.s0s1s2 = make([]byte, s0s1s2Len) // 用于存储s0s1s2
s2key := parseChallenge(c0c1, clientKey[:clientPartKeyLen], serverKey[:serverFullKeyLen]) //解析c0c1或者s0s1
s.isSampleMode = len(s2key) == 0 // s2key长度不为0,说明是复杂握手
s.s0s1s2[0] = version
s1 := s.s0s1s2[1:]
s2 := s.s0s1s2[s0s1Len:]
bele.BePutUint32(s1, uint32(time.Now().UnixNano())) // s1添加时间戳
random1528(s1[8:]) // 填充1528字节随机字符
if s.isSampleMode {
//s1
bele.BePutUint32(s1[4:], 0) // 简单握手zero4字节内容都为0
copy(s2, c0c1[1:]) // s2复制c1内容
} else {
//s1
copy(s1[4:], serverVersion) // s1添加version,服务端为:0x0D, 0x0E, 0x0A, 0x0D
offs := int(s1[8]) + int(s1[9]) + int(s1[10]) + int(s1[11]) // s1使用 digest key结构,获取offset
offs = (offs % 728) + 12 // 12 = 4字节time+4字节模式串+4字节offset
// 填充s1内容,key为36字节固定key,生成新的32字节的digest-data填入到s1
makeDigestWithoutCenterPart(s.s0s1s2[1:s0s1Len], offs, serverKey[:serverPartKeyLen], s.s0s1s2[1+offs:])
// s2
// make digest to s2 suffix position
random1528(s2)
replyOffs := s2Len - keyLen
makeDigestWithoutCenterPart(s2, replyOffs, s2key, s2[replyOffs:]) // 将digest-data填充到s2的后32字节
}
return nil
}
2. parseChallenge(b []byte, peerKey []byte, key []byte) 函数解析
- parseChallenge()函数用于解析c0c1(也可解析s0s1)
- 首先会大端模式获取c0c1 chunk的第5~9字节作为version,如果version为0,说明是简单模式,否则则是复杂模式。
- 对于复杂模式,使用findDigest()函数先按照schema0格式(即key digest结构)进行查找digest-data的起始下标offs,如果没有找到,则按照schema1格式(即digest key结构)进行查找digest-data的起始下标offs。
- 当找到c1的digest-data的起始下标offset后,使用serverKey前36字节作为key,digest-data作为input,生成新的digest-data返回。
- serverKey内容为:
// 36+32
var serverKey = []byte{
'G', 'e', 'n', 'u', 'i', 'n', 'e', ' ', 'A', 'd', 'o', 'b', 'e', ' ',
'F', 'l', 'a', 's', 'h', ' ', 'M', 'e', 'd', 'i', 'a', ' ',
'S', 'e', 'r', 'v', 'e', 'r', ' ',
'0', '0', '1',
0xF0, 0xEE, 0xC2, 0x4A, 0x80, 0x68, 0xBE, 0xE8, 0x2E, 0x00, 0xD0, 0xD1,
0x02, 0x9E, 0x7E, 0x57, 0x6E, 0xEC, 0x5D, 0x2D, 0x29, 0x80, 0x6F, 0xAB,
0x93, 0xB8, 0xE6, 0x36, 0xCF, 0xEB, 0x31, 0xAE,
}
- 代码如下:
// c0c1 clientPartKey serverFullKey
// s0s1 serverPartKey clientFullKey
func parseChallenge(b []byte, peerKey []byte, key []byte) []byte {
//if b[0] != version {
// return nil, ErrRtmp
//}
ver := bele.BeUint32(b[5:]) // 大端,从下标5(c0 1字节 + c1 time4字节)开始读取4个字节,获取ver
if ver == 0 { // 如果ver = 0,说明是简单模式,复杂模式的ver不等于0
Log.Debug("handshake simple mode.")
return nil
}
offs := findDigest(b[1:], 764+8, peerKey) // 按照key digest结构进行查找digest-data的下标,time + version + key = 4 + 4 + 764
if offs == -1 {
offs = findDigest(b[1:], 8, peerKey) // 按照digest key结构进行查找
}
if offs == -1 {
Log.Warn("get digest offs failed. roll back to try simple handshake.")
return nil
}
Log.Debug("handshake complex mode.")
// use c0c1 digest to make a new digest
digest := makeDigest(b[1+offs:1+offs+keyLen], key) // 使用本端key和c0c1的digest-data生成新的digest-data并返回
return digest
}
3. findDigest(b []byte, base int, key []byte) 函数解析
- findDigest()函数用于查找c1或s1的digest-data的起始位置offs。
- 参数base为按照key digest结构或者digest key结构中digest的offset的起始位置,见schema0和schema1结构。
- key digest结构中,base为764+8(4字节time + 4字节version + 764字节key)
- digest offset = c1[8+764] + c1[8+764+1] + c1[8+764+2] + c1[8+764+3] + base + 4
- digest key结构中,base为8(4字节time + 4字节version)
- digest offset = (c1[8] + c1[9] + c1[10] + c1[11]) + base + 4
- key digest结构中,base为764+8(4字节time + 4字节version + 764字节key)
- 获取digest offs后,根据clientKey前30字节作为key,offs左边部分拼接上offs+len(digest[32字节])右边部分作为hmac-sha256的input,生成32字节的digest-data。
- clientKey内容为:
// 30+32
var clientKey = []byte{
'G', 'e', 'n', 'u', 'i', 'n', 'e', ' ', 'A', 'd', 'o', 'b', 'e', ' ',
'F', 'l', 'a', 's', 'h', ' ', 'P', 'l', 'a', 'y', 'e', 'r', ' ',
'0', '0', '1',
0xF0, 0xEE, 0xC2, 0x4A, 0x80, 0x68, 0xBE, 0xE8, 0x2E, 0x00, 0xD0, 0xD1,
0x02, 0x9E, 0x7E, 0x57, 0x6E, 0xEC, 0x5D, 0x2D, 0x29, 0x80, 0x6F, 0xAB,
0x93, 0xB8, 0xE6, 0x36, 0xCF, 0xEB, 0x31, 0xAE,
}
- 将生成的digest-data与原digest-data比较,相同则说明是复杂模式。
- 代码如下:
func findDigest(b []byte, base int, key []byte) int {
// calc offs
offs := int(b[base]) + int(b[base+1]) + int(b[base+2]) + int(b[base+3]) // digest offset
offs = (offs % 728) + base + 4 // offset + base + len(offset),移动offs到digest-data
// calc digest
digest := make([]byte, keyLen) // digest-data为keyLen(32)字节
makeDigestWithoutCenterPart(b, offs, key, digest) // digest-data生成
// compare origin digest in buffer with calced digest
if bytes.Compare(digest, b[offs:offs+keyLen]) == 0 { // 比较原digest和计算得到的digest是否相同,相同则为复杂模式
return offs
}
return -1
}
4. makeDigestWithoutCenterPart(b []byte, offs int, key []byte, out []byte) 函数解析
- makeDigestWithoutCenterPart()函数用于拼接digest-data的左边部分和右边部分作为为hmac-sha256的input,根据给定的key生成新的digest。
func makeDigestWithoutCenterPart(b []byte, offs int, key []byte, out []byte) {
mac := hmac.New(sha256.New, key) // 30字节固定key作为hmac-sha256的key
//c1 digest左边部分拼接上c1 digest右边部分(如果右边部分存在的话)作为hmac-sha256的input(整个大小是1536-32)
// left
if offs != 0 {
mac.Write(b[:offs])
}
// right
if len(b)-offs-keyLen > 0 { // digest的random data部分
mac.Write(b[offs+keyLen:])
}
// calc
copy(out, mac.Sum(nil)) // hmac-sha256计算得出32字节的digest填入c1中digest字段中
}
2. 服务端发送s0,s1,s2 chunk给客户端
1. WriteS0S1S2(write io.Writer) 函数解析
- WriteS0S1S2()函数将s0,s1,s2 chunk发送给客户端。
func (s *HandshakeServer) WriteS0S1S2(write io.Writer) error {
_, err := write.Write(s.s0s1s2)
return err
}
3. 服务端接收客户端发送的c2 chunk
1. ReadC2(conn io.Reader) 函数解析
- ReadC2()函数会读取c2Len字节,读取不报错即算完成。
func (s *HandshakeServer) ReadC2(conn io.Reader) error {
c2 := make([]byte, c2Len)
if _, err := io.ReadAtLeast(conn, c2, c2Len); err != nil {
return err
}
return nil
}