导读与 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。
先谈 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
作者:于吉太
来源:码神说
原文:https://mp.weixin.qq.com/s/BlJYvEgwPaBhSHdtcLffKA
版权声明:本文内容转自互联网,本文观点仅代表作者本人。本站仅提供信息存储空间服务,所有权归原作者所有。如发现本站有涉嫌抄袭侵权/违法违规的内容, 请发送邮件至1393616908@qq.com 举报,一经查实,本站将立刻删除。