Coverage for /pythoncovmergedfiles/medio/medio/usr/local/lib/python3.11/site-packages/selkies/ice/stun.py: 72%
Shortcuts on this page
r m x toggle line displays
j k next/prev highlighted chunk
0 (zero) top of page
1 (one) first highlighted chunk
Shortcuts on this page
r m x toggle line displays
j k next/prev highlighted chunk
0 (zero) top of page
1 (one) first highlighted chunk
1# This Source Code Form is subject to the terms of the Mozilla Public
2# License, v. 2.0. If a copy of the MPL was not distributed with this
3# file, You can obtain one at https://mozilla.org/MPL/2.0/.
4#
5# This file incorporates work covered by the following copyright and
6# permission notice:
7#
8# Copyright (c) 2018-2019 Jeremy Lainé.
9# All rights reserved.
10#
11# Redistribution and use in source and binary forms, with or without
12# modification, are permitted provided that the following conditions are met:
13#
14# * Redistributions of source code must retain the above copyright notice,
15# this list of conditions and the following disclaimer.
16# * Redistributions in binary form must reproduce the above copyright notice,
17# this list of conditions and the following disclaimer in the documentation
18# and/or other materials provided with the distribution.
19# * Neither the name of aioice nor the names of its contributors may
20# be used to endorse or promote products derived from this software without
21# specific prior written permission.
22#
23# THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" AND
24# ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE IMPLIED
25# WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE
26# DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE LIABLE
27# FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL
28# DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR
29# SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER
30# CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY,
31# OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE
32# OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE.
34import asyncio
35import binascii
36import enum
37import hmac
38import ipaddress
39from collections.abc import Callable
40from struct import pack, unpack, error as StructError
41from typing import Optional, Protocol
43from .utils import random_transaction_id
45COOKIE = 0x2112A442
46FINGERPRINT_LENGTH = 8
47FINGERPRINT_XOR = 0x5354554E
48HEADER_LENGTH = 20
49INTEGRITY_LENGTH = 24
50IPV4_PROTOCOL = 1
51IPV6_PROTOCOL = 2
53RETRY_MAX = 6
54RETRY_RTO = 0.5
57def set_body_length(data: bytes, length: int) -> bytes:
58 return data[0:2] + pack("!H", length) + data[4:]
61def message_fingerprint(data: bytes) -> int:
62 check_data = set_body_length(data, len(data) - HEADER_LENGTH + FINGERPRINT_LENGTH)
63 return binascii.crc32(check_data) ^ FINGERPRINT_XOR
66def message_integrity(data: bytes, key: bytes) -> bytes:
67 check_data = set_body_length(data, len(data) - HEADER_LENGTH + INTEGRITY_LENGTH)
68 return hmac.new(key, check_data, "sha1").digest()
71def xor_data(data: bytes, transaction_id: bytes) -> bytes:
72 xpad = pack("!HI", COOKIE >> 16, COOKIE) + transaction_id
73 xdata = b""
74 for i in range(len(data)):
75 xdata += int.to_bytes(data[i] ^ xpad[i], 1, "big", signed=False)
76 return xdata
79def pack_address(
80 value: tuple[str, int],
81 xor: Callable[[bytes], bytes] = lambda x: x,
82) -> bytes:
83 ip_address = ipaddress.ip_address(value[0])
84 if isinstance(ip_address, ipaddress.IPv4Address):
85 protocol = IPV4_PROTOCOL
86 else:
87 protocol = IPV6_PROTOCOL
88 return pack("!BB", 0, protocol) + xor(pack("!H", value[1]) + ip_address.packed)
91def pack_bytes(value: bytes) -> bytes:
92 return value
95def pack_error_code(value: tuple[int, str]) -> bytes:
96 return pack("!HBB", 0, value[0] // 100, value[0] % 100) + value[1].encode("utf8")
99def pack_none(value: None) -> bytes:
100 return b""
103def pack_string(value: str) -> bytes:
104 return value.encode("utf8")
107def pack_unsigned(value: int) -> bytes:
108 return pack("!I", value)
111def pack_unsigned_short(value: int) -> bytes:
112 return pack("!H", value) + b"\x00\x00"
115def pack_unsigned_64(value: int) -> bytes:
116 return pack("!Q", value)
119def pack_xor_address(value: tuple[str, int], transaction_id: bytes) -> bytes:
120 return pack_address(value, lambda x: xor_data(x, transaction_id))
123def unpack_address(
124 data: bytes,
125 xor: Callable[[bytes], bytes] = lambda x: x,
126) -> tuple[str, int]:
127 if len(data) < 4:
128 raise ValueError("STUN address length is less than 4 bytes")
130 # The first two bytes are a reserved byte and the protocol family.
131 reserved, protocol = unpack("!BB", data[0:2])
133 # The remaining data represents a port and an address.
134 port_address = data[2:]
135 if protocol == IPV4_PROTOCOL:
136 # For IPv4 we expect 2 bytes for the port, 4 bytes for the address.
137 if len(port_address) != 6:
138 raise ValueError("STUN address has invalid length for IPv4")
139 port_address = xor(port_address)
140 return (
141 str(ipaddress.IPv4Address(port_address[2:])),
142 unpack("!H", port_address[0:2])[0],
143 )
144 elif protocol == IPV6_PROTOCOL:
145 # For IPv6 we expect 2 bytes for the port, 16 bytes for the address.
146 if len(port_address) != 18:
147 raise ValueError("STUN address has invalid length for IPv6")
148 port_address = xor(port_address)
149 return (
150 str(ipaddress.IPv6Address(port_address[2:])),
151 unpack("!H", port_address[0:2])[0],
152 )
153 else:
154 raise ValueError("STUN address has unknown protocol")
157def unpack_xor_address(data: bytes, transaction_id: bytes) -> tuple[str, int]:
158 return unpack_address(data, lambda x: xor_data(x, transaction_id))
161def unpack_bytes(data: bytes) -> bytes:
162 return data
165def unpack_error_code(data: bytes) -> tuple[int, str]:
166 if len(data) < 4:
167 raise ValueError("STUN error code is less than 4 bytes")
168 reserved, code_high, code_low = unpack("!HBB", data[0:4])
169 reason = data[4:].decode("utf8")
170 return (code_high * 100 + code_low, reason)
173def unpack_none(data: bytes) -> None:
174 return None
177def unpack_string(data: bytes) -> str:
178 return data.decode("utf8")
181def unpack_unsigned(data: bytes) -> int:
182 return unpack("!I", data)[0]
185def unpack_unsigned_short(data: bytes) -> int:
186 return unpack("!H", data[0:2])[0]
189def unpack_unsigned_64(data: bytes) -> int:
190 return unpack("!Q", data)[0]
193AttributeEntry = tuple[int, str, Callable, Callable]
195ATTRIBUTES: list[AttributeEntry] = [
196 (0x0001, "MAPPED-ADDRESS", pack_address, unpack_address),
197 (0x0003, "CHANGE-REQUEST", pack_unsigned, unpack_unsigned),
198 (0x0004, "SOURCE-ADDRESS", pack_address, unpack_address),
199 (0x0005, "CHANGED-ADDRESS", pack_address, unpack_address),
200 (0x0006, "USERNAME", pack_string, unpack_string),
201 (0x0008, "MESSAGE-INTEGRITY", pack_bytes, unpack_bytes),
202 (0x0009, "ERROR-CODE", pack_error_code, unpack_error_code),
203 (0x000C, "CHANNEL-NUMBER", pack_unsigned_short, unpack_unsigned_short),
204 (0x000D, "LIFETIME", pack_unsigned, unpack_unsigned),
205 (0x0012, "XOR-PEER-ADDRESS", pack_xor_address, unpack_xor_address),
206 (0x0014, "REALM", pack_string, unpack_string),
207 (0x0015, "NONCE", pack_bytes, unpack_bytes),
208 (0x0016, "XOR-RELAYED-ADDRESS", pack_xor_address, unpack_xor_address),
209 (0x0019, "REQUESTED-TRANSPORT", pack_unsigned, unpack_unsigned),
210 (0x0020, "XOR-MAPPED-ADDRESS", pack_xor_address, unpack_xor_address),
211 (0x0024, "PRIORITY", pack_unsigned, unpack_unsigned),
212 (0x0025, "USE-CANDIDATE", pack_none, unpack_none),
213 (0x8022, "SOFTWARE", pack_string, unpack_string),
214 (0x8028, "FINGERPRINT", pack_unsigned, unpack_unsigned),
215 (0x8029, "ICE-CONTROLLED", pack_unsigned_64, unpack_unsigned_64),
216 (0x802A, "ICE-CONTROLLING", pack_unsigned_64, unpack_unsigned_64),
217 (0x802B, "RESPONSE-ORIGIN", pack_address, unpack_address),
218 (0x802C, "OTHER-ADDRESS", pack_address, unpack_address),
219]
221ATTRIBUTES_BY_TYPE: dict[int, AttributeEntry] = {}
222ATTRIBUTES_BY_NAME: dict[str, AttributeEntry] = {}
223for attr in ATTRIBUTES:
224 ATTRIBUTES_BY_TYPE[attr[0]] = attr
225 ATTRIBUTES_BY_NAME[attr[1]] = attr
228class Class(enum.IntEnum):
229 REQUEST = 0x000
230 INDICATION = 0x010
231 RESPONSE = 0x100
232 ERROR = 0x110
235class Method(enum.IntEnum):
236 BINDING = 0x1
237 SHARED_SECRET = 0x2
238 ALLOCATE = 0x3
239 REFRESH = 0x4
240 SEND = 0x6
241 DATA = 0x7
242 CREATE_PERMISSION = 0x8
243 CHANNEL_BIND = 0x9
246class Message:
247 def __init__(
248 self,
249 message_method: Method,
250 message_class: Class,
251 transaction_id: Optional[bytes] = None,
252 attributes: Optional[dict] = None,
253 ) -> None:
254 self.message_method = message_method
255 self.message_class = message_class
256 self.transaction_id = transaction_id or random_transaction_id()
257 self.attributes = attributes or {}
259 def add_message_integrity(self, key: bytes) -> None:
260 """
261 Add MESSAGE-INTEGRITY and FINGERPRINT attributes to the message.
263 This must be the last step before sending out the message.
264 """
265 self.attributes.pop("MESSAGE-INTEGRITY", None)
266 self.attributes.pop("FINGERPRINT", None)
267 self.attributes["MESSAGE-INTEGRITY"] = message_integrity(bytes(self), key)
268 self.attributes["FINGERPRINT"] = message_fingerprint(bytes(self))
270 def __bytes__(self) -> bytes:
271 data = b""
272 for attr_name, attr_value in self.attributes.items():
273 attr_type, _, attr_pack, attr_unpack = ATTRIBUTES_BY_NAME[attr_name]
274 if attr_pack == pack_xor_address:
275 v = attr_pack(attr_value, self.transaction_id)
276 else:
277 v = attr_pack(attr_value)
279 attr_len = len(v)
280 pad_len = padding_length(attr_len)
281 data += pack("!HH", attr_type, attr_len) + v + bytes(pad_len)
282 return (
283 pack(
284 "!HHI12s",
285 self.message_method | self.message_class,
286 len(data),
287 COOKIE,
288 self.transaction_id,
289 )
290 + data
291 )
293 def __repr__(self) -> str:
294 return (
295 f"Message(message_method=Method.{self.message_method.name}, "
296 f"message_class=Class.{self.message_class.name}, "
297 f"transaction_id={self.transaction_id.hex()}, "
298 f"attributes={self.attributes})"
299 )
302class TransactionError(Exception):
303 response: Optional[Message] = None
306class TransactionFailed(TransactionError):
307 def __init__(self, response: Message) -> None:
308 self.response = response
310 def __str__(self) -> str:
311 out = "STUN transaction failed"
312 if "ERROR-CODE" in self.response.attributes:
313 out += " (%s - %s)" % self.response.attributes["ERROR-CODE"]
314 return out
317class TransactionTimeout(TransactionError):
318 def __str__(self) -> str:
319 return "STUN transaction timed out"
322class TransactionSender(Protocol):
323 def send_stun(self, message: Message, addr: tuple[str, int]) -> None: ...
326class Transaction:
327 def __init__(
328 self,
329 request: Message,
330 addr: tuple[str, int],
331 protocol: TransactionSender,
332 retransmissions: Optional[int] = None,
333 ) -> None:
334 self.__addr = addr
335 self.__future: asyncio.Future[tuple[Message, tuple[str, int]]] = (
336 asyncio.Future()
337 )
338 self.__request = request
339 self.__timeout_delay = RETRY_RTO
340 self.__timeout_handle: Optional[asyncio.TimerHandle] = None
341 self.__protocol = protocol
342 self.__tries = 0
343 self.__tries_max = 1 + (
344 retransmissions if retransmissions is not None else RETRY_MAX
345 )
347 def response_received(self, message: Message, addr: tuple[str, int]) -> None:
348 if not self.__future.done():
349 if message.message_class == Class.RESPONSE:
350 self.__future.set_result((message, addr))
351 else:
352 self.__future.set_exception(TransactionFailed(message))
354 async def run(self) -> tuple[Message, tuple[str, int]]:
355 try:
356 self.__retry()
357 return await self.__future
358 finally:
359 if self.__timeout_handle:
360 self.__timeout_handle.cancel()
362 def __retry(self) -> None:
363 if self.__tries >= self.__tries_max:
364 self.__future.set_exception(TransactionTimeout())
365 return
367 self.__protocol.send_stun(self.__request, self.__addr)
369 loop = asyncio.get_running_loop()
370 self.__timeout_handle = loop.call_later(self.__timeout_delay, self.__retry)
371 self.__timeout_delay *= 2
372 self.__tries += 1
375def padding_length(length: int) -> int:
376 """
377 STUN message attributes are padded to a 4-byte boundary.
378 """
379 rest = length % 4
380 if rest == 0:
381 return 0
382 else:
383 return 4 - rest
386def parse_message(data: bytes, integrity_key: Optional[bytes] = None) -> Message:
387 """
388 Parses a STUN message.
390 If the ``integrity_key`` parameter is given, the message's HMAC will be verified.
391 """
392 if len(data) < HEADER_LENGTH:
393 raise ValueError("STUN message length is less than 20 bytes")
394 message_type, length, cookie, transaction_id = unpack(
395 "!HHI12s", data[0:HEADER_LENGTH]
396 )
397 if len(data) != HEADER_LENGTH + length:
398 raise ValueError("STUN message length does not match")
400 attributes = {}
401 pos = HEADER_LENGTH
402 while pos <= len(data) - 4:
403 attr_type, attr_len = unpack("!HH", data[pos : pos + 4])
404 v = data[pos + 4 : pos + 4 + attr_len]
405 pad_len = padding_length(attr_len)
406 if attr_type in ATTRIBUTES_BY_TYPE:
407 _, attr_name, attr_pack, attr_unpack = ATTRIBUTES_BY_TYPE[attr_type]
408 try:
409 if attr_unpack == unpack_xor_address:
410 attributes[attr_name] = attr_unpack(v, transaction_id=transaction_id)
411 else:
412 attributes[attr_name] = attr_unpack(v)
413 except StructError as e:
414 # A fixed-width attribute shorter than its type is a malformed message like
415 # any other, which every receive path rejects as a ValueError.
416 raise ValueError(f"STUN attribute {attr_name} is truncated") from e
418 if attr_name == "FINGERPRINT":
419 if attributes[attr_name] != message_fingerprint(data[0:pos]):
420 raise ValueError("STUN message fingerprint does not match")
421 elif attr_name == "MESSAGE-INTEGRITY":
422 if integrity_key is not None and attributes[
423 attr_name
424 ] != message_integrity(data[0:pos], integrity_key):
425 raise ValueError("STUN message integrity does not match")
427 pos += 4 + attr_len + pad_len
429 return Message(
430 # An unknown method raises a `ValueError`.
431 message_method=Method(message_type & 0x3EEF),
432 # This cast cannot fail, as all 4 possible classes are defined.
433 message_class=Class(message_type & 0x0110),
434 transaction_id=transaction_id,
435 attributes=attributes,
436 )