WebRTC STUN | Short-term 消息认证

作者:泰一

来源:码神说公众号


本文是 STUN 协议系列第 1

  • 导读与 STUN 概览
  • 先谈 HMAC
  • 再看 Short-term Credential Mechanism
  • 计算 Message Integrity
    • HMAC 输入
    • HMAC key
    • 计算过程
  • 源码剖析
    • ValidateMessageIntegrityOfType 函数
    • AddMessageIntegrityOfType 函数
  • 抓包分析
  • 参考

导读与 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 的建立,可以作为中继数据的载体(比如 sendindicationdataindication)。也就是说,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] 是一种基于加密哈希函数的消息认证机制,所能提供的消息认证包括两方面:

  1. 消息完整性认证,能够证明消息内容在传送过程中没有被修改。
  2. 信源身份认证,因为通信双方共享了认证的密钥,所以接收方能够认证消息确实是发送方所发。

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 值,流程都是完全一样的,只需要理解好三个点:

  1. STUN 消息的 M-I 属性之前的(不包括 M-I),包括头部在内的所有内容作为 HMAC 的输入数据。
  2. STUN 消息的 M-I 属性之前的(不包括 M-I),包括头部在内的所有内容的长度作为 HMAC 的输入长度。
  3. 在 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:

  1. username: N+vv:sLop
  2. short-term 使用的 HMAC key 应该是 SFU 的 password: da2vlP6ZJrd4VbnSEP/AdjcW

SFU 收到来自 taiyi 的 BindingRequest 后,就可以使用自己的 password 计算消息的 M-I 值,以进行消息认证。认证成功后,SFU 回复 BindingResponse 给 taiyi:

  1. username: N+vv:sLop
  2. 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 为准:

  1. username: sLop:N+vv
  2. short-term 使用的 HMAC key  应该是 taiyi 的 password: GCR3LqC+baeBQ7NxdWb8Q4Oc

为了能够更深刻的理解上述流程,我画了一张图,如下:

WebRTC STUN | Short-term 消息认证

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 消息完整性的流程。

首先,验证消息的大小:

  1. STUN 消息头部大小固定为 kStunHeaderSize = 20 字节。
  2. 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_datami_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 值:

  1. 将消息序列化为字节。
  2. 计算参与 HMAC 计算的消息内容长度 msg_len_for_hmac ,为消息总长度减去最后 24 字节的 M-I 属性的长度。
  3. 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 消息,如下:

WebRTC STUN | Short-term 消息认证

结合上图,可以直观的看到参与 HMAC 计算的内容为 M-I 属性上方的部分,不过在计算前要调整 message length 字段值,减去 8 字节的 FINGERPRINT 属性。

对应的 STUN binding response 消息如下:

WebRTC STUN | Short-term 消息认证

观察 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


「视频云技术」你最值得关注的音视频技术公众号,每周推送来自阿里云一线的实践技术文章,在这里与音视频领域一流工程师交流切磋。

WebRTC STUN | Short-term 消息认证

上一篇:产品百科 |RTC SDK 如何将通讯模式升级至互动模式


下一篇:产品百科 |阿里云 RTC iOS SDK 常见问题汇总