作者:泰一
來源:
碼神說公衆号 本文是 STUN 協定系列第 1 篇- 導讀與 STUN 概覽
- 先談 HMAC
- 再看 Short-term Credential Mechanism
- 計算 Message Integrity
-
- HMAC 輸入
- HMAC key
- 計算過程
- 源碼剖析
-
- ValidateMessageIntegrityOfType 函數
- AddMessageIntegrityOfType 函數
- 抓包分析
- 參考
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,Keyed-Hashing for Message Authentication Code[1] 是一種基于加密哈希函數的消息認證機制,所能提供的消息認證包括兩方面:
- 消息完整性認證,能夠證明消息内容在傳送過程中沒有被修改。
- 信源身份認證,因為通信雙方共享了認證的密鑰,是以接收方能夠認證消息确實是發送方所發。
HMAC 運算利用雜湊演算法,以一個消息 M 和一個密鑰 K 作為輸入,生成一個定長的消息摘要作為輸出。HMAC 的一個典型應用是用在
挑戰/響應(Challenge/Response)
身份認證中,認證流程這裡不做介紹。
短期證書機制 short-term credential mechanism[2] 是一種對 STUN 消息進行完整性保護與認證的機制。使用短期證書機制的前提是:在 STUN 消息傳輸之前,用戶端和服務端已經通過其他協定交換了彼此的證書。比如在 ICE 的應用中,用戶端和服務端會通過單獨的信令通道來交換彼此的證書,證書在媒體會話期間适用。
證書由 username 和 password 組成,因為是短期證書,是以具有時效性,可以天然的降低重播攻擊的風險。證書用于對 STUN 請求與響應消息的完整性檢查,而具體的實作機制就是 HMAC,計算出的 HMAC 結果存儲在 STUN 的
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。
不過,是要用 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
的大小為 M-I 屬性之前的(包括 M-I)總的長度。message length
關于第 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 是如何運用的。
在 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:
-
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 為準:
-
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);
};
該函數用于檢驗所收到的 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 消息的屬性是屬性,屬性長度為 5 位元組,那麼會有 3 位元組的值為 0x00 的 padding 填充,進而保證 STUN 屬性的 4 位元組對齊的原則,此時
USERNAME
需要再加上 3。
current_pos
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
分别是參與 HMAC-SHA1 計算的消息内容與長度(不包括 M-I 屬性)。
mi_pos
最後,比較計算得到的 M-I 值是否和 STUN 消息中 M-I 屬性中的 M-I 值一緻。
return memcmp(
data + current_pos + kStunAttributeHeaderSize,
hmac, mi_attr_size) == 0;
該函數用于在發送 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 計算的消息内容長度
,為消息總長度減去最後 24 位元組的 M-I 屬性的長度。msg_len_for_hmac
-
函數計算 M-I 值。ComputeHmac
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: 此時消息還沒有增加
等 M-I 之後的屬性,是以消息頭部的
FINGERPRINT
字段不需要調整。
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「視訊雲技術」你最值得關注的音視訊技術公衆号,每周推送來自阿裡雲一線的實踐技術文章,在這裡與音視訊領域一流工程師交流切磋。