作者:泰一
来源:码神说公众号
本文是 STUN 协议系列第 1 篇
- 导读与 STUN 概览
- 先谈 HMAC
- 再看 Short-term Credential Mechanism
- 计算 Message Integrity
- HMAC 输入
- HMAC key
- 计算过程
- HMAC 输入
- 源码剖析
- ValidateMessageIntegrityOfType 函数
- AddMessageIntegrityOfType 函数
- ValidateMessageIntegrityOfType 函数
- 抓包分析
- 参考
导读与 STUN 概览
Session Traversal Utilities for NAT (STUN) 是一个 client/server
协议,支持两种类型的事务,分别是 request/response
事务和 indication
事务。
STUN 本身并不是一种 NAT 穿越的解决方案,它是协议,作为一个工具或者内部组件,被 NAT 穿越的解决方案(比如 ICE 和 TURN)所使用。
STUN 协议能够帮助处于内网的终端确定 NAT 为其分配的外网 IP 地址和端口(通过 XOR-MAPPED-ADDRESS
属性),还可以用于 NAT 绑定的保活(通过 binding indication
消息)。
在 ICE 中,STUN 协议用于连通性检查和 ICE 的保活(通过 binding request/response
),在 TURN 协议中,STUN 协议用于 Allocation
的建立,可以作为中继数据的载体(比如 sendindication
和 dataindication
)。也就是说,ICE 和 TURN 是两种不同的 STUN Usage。
正因为 STUN 协议是其他协议或者 NAT 解决方案的基础,所以掌握 STUN 协议是非常关键的。
本文作为 STUN 协议系列的第一篇,将介绍 STUN 协议的 short-term 消息认证机制,并致力于讲清两个点:一个是究竟取 STUN 消息的哪一部分内容参与 HMAC-SHA1 的计算,另一个是 request/response
消息究竟使用哪一方的 password 作为 HMAC key 去计算 message integrity。
Let's Go!
先谈 HMAC
HMAC,Keyed-Hashing for Message Authentication Code[1] 是一种基于加密哈希函数的消息认证机制,所能提供的消息认证包括两方面:
- 消息完整性认证,能够证明消息内容在传送过程中没有被修改。
- 信源身份认证,因为通信双方共享了认证的密钥,所以接收方能够认证消息确实是发送方所发。
HMAC 运算利用哈希算法,以一个消息 M 和一个密钥 K 作为输入,生成一个定长的消息摘要作为输出。HMAC 的一个典型应用是用在 挑战/响应(Challenge/Response)
身份认证中,认证流程这里不做介绍。
再看 Short-term Credential Mechanism
短期证书机制 short-term credential mechanism[2] 是一种对 STUN 消息进行完整性保护与认证的机制。使用短期证书机制的前提是:在 STUN 消息传输之前,客户端和服务端已经通过其他协议交换了彼此的证书。比如在 ICE 的应用中,客户端和服务端会通过单独的信令通道来交换彼此的证书,证书在媒体会话期间适用。
证书由 username 和 password 组成,因为是短期证书,所以具有时效性,可以天然的降低重放攻击的风险。证书用于对 STUN 请求与响应消息的完整性检查,而具体的实现机制就是 HMAC,计算出的 HMAC 结果存储在 STUN 的 MESSAGE-INTEGRITY
属性中。
计算 Message Integrity
STUN 的 MESSAGE-INTEGRITY
属性包含了对 STUN 消息进行 HMAC-SHA1 计算之后的 HMAC 值,由于使用 SHA-1 哈希函数,所以计算出来的 HMAC 值固定为 20 字节。在后面的介绍中,我会使用缩写 M-I
来表示 Message Integrity,并将对 STUN 消息进行 HMAC-SHA1 计算后得到的 HMAC 值称为 M-I 值。
那么在 short-term 机制下,M-I 值是怎样计算的呢?答案是:以 request 消息的发起方的视角为基准,STUN 消息的一部分作为 HMAC 算法的输入,对端的 password 作为 HMAC 算法的 key。
HMAC 输入
不过,是要用 STUN 消息的哪一部分作为输入呢?RFC8489: MESSAGE-INTEGRITY[3] 中给出了答案,但是乍一读,很多人可能会晕掉,所以接下来我会为大家更好的去解释这一段描述。
关于 M-I 值的计算,分为两个大的方向,一个是作为 STUN 消息的发送方,需要在构造 STUN 消息时同时构造 M-I 属性,而构造 M-I 属性,就必然要计算 M-I 值;另一个是作为 STUN 消息的接收方,需要在收到 STUN 消息后验证其 M-I 属性,具体的做法就是比较 M-I 属性的 M-I 值是否和接收方计算出的 M-I 值一致,因此也是要计算 M-I 值。
无论是构造 M-I 属性时计算 M-I 值还是验证 M-I 属性时计算 M-I 值,流程都是完全一样的,只需要理解好三个点:
- STUN 消息的 M-I 属性之前的(不包括 M-I),包括头部在内的所有内容作为 HMAC 的输入数据。
- STUN 消息的 M-I 属性之前的(不包括 M-I),包括头部在内的所有内容的长度作为 HMAC 的输入长度。
- 在 HMAC 计算之前,要调整 STUN 头部字段
message length
的值,message length
的大小为 M-I 属性之前的(包括 M-I)总的长度。
关于第 3 点,需要注意的是,在构造 M-I 属性时是不需要调整 message length
值的,一般是在验证 M-I 属性时调整 message length
值。这是因为,对于接收方收到的 STUN 消息,可能在 M-I 属性之后还存在 FINGERPRINT
或者 MESSAGE-INTEGRITY-SHA256
属性,因此 message length
需要去掉这两种属性的长度。
然而,对于发送方,在构造 STUN 消息的 M-I 属性时,还未构造 FINGERPRINT
或者 MESSAGE-INTEGRITY-SHA256
属性,因此 message length
不需要做调整。在下文的源码剖析部分,我们会深刻的理解以上几点,在进入源码剖析之前,还需要再介绍一下作为 HMAC key 的 password 是如何运用的。
HMAC key
在 short-term 机制下, 对于 request 发起方,HMAC 的 key 使用的是对方的 password,即 SDP 中的 ice-pwd 描述。
remark: 上文中提到 short-term 证书是由 username 和 password 组成,但是实际上 short-term 只用到了 password,并未用到 username。
remark: username 的规则是:对方的 ufrag: 自己的 ufrag。
举个例子,taiyi 发布自己的流到 SFU。taiyi 和 SFU 的名字与密码信息如下:
taiyi: ufrag = sLop passwd = GCR3LqC+baeBQ7NxdWb8Q4Oc
SFU: ufrag = N+vv passwd = da2vlP6ZJrd4VbnSEP/AdjcW
taiyi 发送 STUN BindingRequest 消息给 SFU:
- username:
N+vv:sLop
。 - short-term 使用的 HMAC key 应该是 SFU 的 password:
da2vlP6ZJrd4VbnSEP/AdjcW
。
SFU 收到来自 taiyi 的 BindingRequest 后,就可以使用自己的 password 计算消息的 M-I 值,以进行消息认证。认证成功后,SFU 回复 BindingResponse 给 taiyi:
- username:
N+vv:sLop
。 - short-term 使用的 HMAC key 应该是 SFU 的 password:
da2vlP6ZJrd4VbnSEP/AdjcW
。
可以知道,在 taiyi 与 SFU 的这一次 STUN binding request/response 事务中,response 的 username 规则以及使用的 password 与 request 完全一致。
可以记为:username 与 password 的规则都是以 request 的发起方作为基准,response 向 request 看齐。
同理,SFU 发送 STUN BindingRequest 消息给 taiyi,taiyi 回复 BindingResponse,此时以 request 发起方 SFU 为准:
- username:
sLop:N+vv
- short-term 使用的 HMAC key 应该是 taiyi 的 password:
GCR3LqC+baeBQ7NxdWb8Q4Oc
。
为了能够更深刻的理解上述流程,我画了一张图,如下:
note: 上图所写的 ufrag 和 password 并非 rfc 规定的标准的格式,仅为了更好的理解。
计算过程
下面介绍对 STUN 消息进行完整性验证时的 M-I 值的计算过程。假设 SFU 收到的 STUN binding request 消息如下:
// 20 bytes [ STUN HEADER ] // 12 bytes(2 bytes type, 2 bytes length, 8 bytes username) [ USERNAME ] // 24 bytes (2 bytes type, 2 bytes length, 20 bytes hmac-sha1) [ MESSAGE-INTEGRITY-ATTRIBUTE ] // 8 bytes(2 bytes type, 2 bytes length, 4 bytes crc32 value) [ FINGERPRINT ]
计算流程对应的伪代码如下:
// 去掉 8 字节大小的 Fingerprint 属性, // 然后将消息序列化为字节,得到 stun_binary, // 注意,不要去掉 MessageIntegrity 属性。 stun_msg = (header, attributes[Username, MessageIntegrity, Fingerprint]) // 将序列化后的消息去掉最后 24 字节的 M-I 属性, // 得到更新后的 stun_binary。 stun_binary = stun_msg.remove(Fingerprint).marshal_binary() stun_binary = stun_binary[0 : len(stun_binary) - 24] // 生成 HMAC key。 key = password // 计算 HMAC,得到 20 字节的 M-I 值。 h = hmac.new(hash.sha1, key); h.update(stun_binary); mi = h.Sum(null); // 比较 mi 是否和消息携带的 M-I 值一致。 memcmp( stun_msg.attributes.MessageIntegrity.value, mi, 20)
源码剖析
参考 WebRTC M88。
STUN 的 short-term 消息认证主要包括:构造 M-I 属性和验证 M-I 值。相关的类和函数如下:
class StunMessage { // Validates that a raw STUN message // has a correct MESSAGE-INTEGRITY value. static bool ValidateMessageIntegrity( const char* data, size_t size, const std::string& password); // Adds a MESSAGE-INTEGRITY attribute // that is valid for the current message. bool AddMessageIntegrity( const std::string& password); };
ValidateMessageIntegrityOfType 函数
该函数用于检验所收到的 STUN 消息的完整性,对消息的来源进行认证。可以结合上文 HMAC 输入
这一节中提到的 3 点来理解该函数验证 STUN 消息完整性的流程。
首先,验证消息的大小:
- STUN 消息头部大小固定为
kStunHeaderSize = 20
字节。 - STUN 消息的属性是 4 字节对齐的。
if ((size % 4) != 0 || size < kStunHeaderSize) { return false; }
因此,消息的长度不能小于 20 且要是 4 的倍数。
接着,从 STUN 消息的头部获取字段 message length
的值。
uint16_t msg_length = rtc::GetBE16(&data[2]); if (size != (msg_length + kStunHeaderSize)) { return false; }
message length
字段表示 STUN 消息的属性的长度,不包括 20 字节的 STUN 消息头部。因此,STUN 消息的大小 size = msg_length + kStunHeaderSize
。
接着,寻找 STUN 消息的 M-I 属性,定位其在整个消息中的位置 mi_pos
。在遍历寻找 M-I 属性的过程中,如果当前属性不是 M-I 属性,那么就需要跳到下一个属性,如果没有找到 M-I 属性,则返回 false,表示消息完整性校验失败。因为 STUN 消息的属性是按照 4 字节对齐,所以在计算 current_pos
的时候可能需要加上填充字节的长度。
比如,当前 STUN 消息的属性是
USERNAME
属性,属性长度为 5 字节,那么会有 3 字节的值为 0x00 的 padding 填充,从而保证 STUN 属性的 4 字节对齐的原则,此时current_pos
需要再加上 3。
size_t current_pos = kStunHeaderSize; bool has_message_integrity_attr = false; while (current_pos + 4 <= size) { uint16_t attr_type, attr_length; // Getting attribute type and length. attr_type = rtc::GetBE16(&data[current_pos]); attr_length = rtc::GetBE16( &data[current_pos + sizeof(attr_type)]); // If M-I, sanity check it, and break out. if (attr_type == mi_attr_type) { if (attr_length != mi_attr_size || current_pos + sizeof(attr_type) + sizeof(attr_length) + attr_length > size) { return false; } has_message_integrity_attr = true; break; } // Otherwise, skip to the next attribute. current_pos += sizeof(attr_type) + sizeof(attr_length) + attr_length; if ((attr_length % 4) != 0) { current_pos += (4 - (attr_length % 4)); } }
在找到 M-I 属性,并记录其在消息中的位置 mi_pos
之后,开始计算这个 STUN 消息的 M-I 值,用于和这个消息中自带的 M-I 值进行比较。
首先需要判断 STUN 消息的 M-I 属性的后面是否还有其他属性,比如 FINGERPRINT
。如果有,那么需要调整 STUN 头部字段 message length
的值,具体的做法就是减去 M-I 属性之后的所有属性的总长度。
size_t mi_pos = current_pos; std::unique_ptr<char[]> temp_data(new char[current_pos]); memcpy(temp_data.get(), data, current_pos); if (size > mi_pos + kStunAttributeHeaderSize + mi_attr_size) { // Stun message has other attributes // after message integrity. // Adjust the length parameter in stun // message to calculate HMAC. size_t extra_offset = size - (mi_pos + kStunAttributeHeaderSize + mi_attr_size); size_t new_adjusted_len = size - extra_offset - kStunHeaderSize; // Writing new length of the STUN // message @ Message Length in temp buffer. rtc::SetBE16(temp_data.get() + 2, static_cast<uint16_t>(new_adjusted_len)); }
在将调整后的 message length
的值设置到 temp_data
之后,开始计算 HMAC-SHA1 值,计算过程可参考 rfc2104。
char hmac[kStunMessageIntegritySize]; size_t ret = rtc::ComputeHmac(rtc::DIGEST_SHA_1, password.c_str(), password.size(), temp_data.get(), mi_pos, hmac, sizeof(hmac));
remark:
temp_data
和mi_pos
分别是参与 HMAC-SHA1 计算的消息内容与长度(不包括 M-I 属性)。
最后,比较计算得到的 M-I 值是否和 STUN 消息中 M-I 属性中的 M-I 值一致。
return memcmp( data + current_pos + kStunAttributeHeaderSize, hmac, mi_attr_size) == 0;
AddMessageIntegrityOfType 函数
该函数用于在发送 STUN 消息之前构造其 M-I 属性。
首先,增加伪值为 0 的 M-I 属性。
auto msg_integrity_attr_ptr = std::make_unique<StunByteStringAttribute>( attr_type, std::string(attr_size, '0')); auto* msg_integrity_attr = msg_integrity_attr_ptr.get(); AddAttribute(std::move(msg_integrity_attr_ptr));
接着,计算 STUN 消息的 HMAC 值:
- 将消息序列化为字节。
- 计算参与 HMAC 计算的消息内容长度
msg_len_for_hmac
,为消息总长度减去最后 24 字节的 M-I 属性的长度。 -
ComputeHmac
函数计算 M-I 值。
ByteBufferWriter buf; if (!Write(&buf)) return false; int msg_len_for_hmac = static_cast<int>( buf.Length() - kStunAttributeHeaderSize - msg_integrity_attr->length()); char hmac[kStunMessageIntegritySize]; size_t ret = rtc::ComputeHmac( rtc::DIGEST_SHA_1, key, keylen, buf.Data(), msg_len_for_hmac, hmac, sizeof(hmac));
remark: 计算 HMAC 时的输入内容取 M-I 属性之前的内容,不包括 M-I 本身。
remark: 此时消息还没有增加
FINGERPRINT
等 M-I 之后的属性,因此消息头部的message length
字段不需要调整。
最后,将计算好的 M-I 值替换掉之前的伪值。
msg_integrity_attr->CopyBytes(hmac, attr_size);
抓包分析
使用 wireshark 抓取 STUN binding request 消息,如下:
结合上图,可以直观的看到参与 HMAC 计算的内容为 M-I 属性上方的部分,不过在计算前要调整 message length
字段值,减去 8 字节的 FINGERPRINT
属性。
对应的 STUN binding response 消息如下:
观察 response 消息的 username,和 request 的 username 一致。另外,request 和 response 使用 HMAC-SHA1 计算 M-I 值所使用的 key 都是一样的,全部使用 responser 的 password(以 requester 为基准,对方的 password 作为 key)。
不过双方的 password 并不会出现在 STUN 消息中,一般是在 STUN 消息传输前通过单独的信令通道共享彼此的 password。
最后,我们发现在两个消息的 USERNAME
属性中,都有 3 字节的填充,值为 0x00。填充字节不算入 USERNAME
属性的长度。
下一篇,将会介绍 STUN 协议的数据包的格式以及如何与其他协议(DTLS/RTP/RTCP
)的数据包进行区分。感谢阅读。
参考
[1]
HMAC: https://tools.ietf.org/html/rfc2104
[2]
Session Traversal Utilities for NAT (STUN): https://tools.ietf.org/html/rfc8489?#section-9.1
[3]
RFC8489: MESSAGE-INTEGRITY: https://tools.ietf.org/html/rfc8489#section-14.5
「视频云技术」你最值得关注的音视频技术公众号,每周推送来自阿里云一线的实践技术文章,在这里与音视频领域一流工程师交流切磋。