Files
2026-09-06 21:18:04 -05:00

119 lines
4.6 KiB
Python

#!/usr/bin/env python3
"""Check the installed WebRTC library's ICE role handling over real UDP sockets.
No cameras, device parameters, external servers, or live sessions are used.
Run this with the same Python environment as webrtcd when validating an image.
"""
import binascii
import hashlib
import hmac
import secrets
import socket
import struct
import time
from libdatachannel import Configuration, Description, PeerConnection
def attribute(kind: int, value: bytes) -> bytes:
return struct.pack("!HH", kind, len(value)) + value + bytes(-len(value) % 4)
def binding_request(username: str, password: str, roles: list[tuple[int, int]]) -> tuple[bytes, bytes]:
transaction = secrets.token_bytes(12)
attrs = attribute(0x0006, username.encode())
# Match Chromium's attribute ordering, including its optional network info.
attrs += attribute(0xC057, bytes(4))
for role, tiebreaker in roles:
attrs += attribute(role, struct.pack("!Q", tiebreaker))
attrs += attribute(0x0025, b"") + attribute(0x0024, struct.pack("!I", 1853824767))
def header(size: int) -> bytes:
return struct.pack("!HHI12s", 1, size, 0x2112A442, transaction)
integrity = hmac.new(password.encode(), header(len(attrs) + 24) + attrs, hashlib.sha1).digest()
attrs += attribute(0x0008, integrity)
packet = header(len(attrs) + 8) + attrs
packet += attribute(0x8028, struct.pack("!I", binascii.crc32(packet) ^ 0x5354554E))
return packet, transaction
def sdp_value(sdp: str, name: str) -> str:
prefix = f"a={name}:"
return next(line[len(prefix):] for line in sdp.splitlines() if line.startswith(prefix))
def check_binding(roles: list[tuple[int, int]], *, invalid_password: bool = False) -> int | None:
config = Configuration()
config.bind_address = "127.0.0.1"
config.disable_auto_negotiation = True
offerer = PeerConnection(config)
answerer = PeerConnection(config)
channel = offerer.create_data_channel("data")
try:
offerer.set_local_description(Description.Type.Offer)
offer = str(offerer.local_description())
# Only our probe sends checks; do not start an independent connection.
offer = "\r\n".join(line for line in offer.splitlines() if not line.startswith("a=candidate:")) + "\r\n"
answerer.set_remote_description(Description(offer, Description.Type.Offer))
answerer.set_local_description(Description.Type.Answer)
deadline = time.monotonic() + 2
while answerer.gathering_state() != PeerConnection.GatheringState.Complete:
if time.monotonic() >= deadline:
raise TimeoutError("Loopback candidate gathering timed out")
time.sleep(0.01)
answer = str(answerer.local_description())
candidate = sdp_value(answer, "candidate").split()
username = f"{sdp_value(answer, 'ice-ufrag')}:{sdp_value(offer, 'ice-ufrag')}"
password = "invalid-test-password" if invalid_password else sdp_value(answer, "ice-pwd")
packet, transaction = binding_request(username, password, roles)
with socket.socket(socket.AF_INET, socket.SOCK_DGRAM) as sock:
sock.bind(("127.0.0.1", 0))
sock.settimeout(1)
sock.sendto(packet, (candidate[4], int(candidate[5])))
deadline = time.monotonic() + 1
while time.monotonic() < deadline:
try:
response = sock.recv(2048)
except TimeoutError:
return None
if len(response) < 20 or response[8:20] != transaction:
continue
if response[:2] == b"\x01\x01":
return 200
offset = 20
while offset + 4 <= len(response):
kind, size = struct.unpack_from("!HH", response, offset)
value = response[offset + 4:offset + 4 + size]
if kind == 0x0009 and len(value) >= 4:
return value[2] * 100 + value[3]
offset += 4 + (size + 3) // 4 * 4
raise AssertionError("Unexpected ICE response")
return None
finally:
answerer.close()
offerer.close()
# Keep the wrapper alive until after its connection has closed.
assert channel is not None
def main() -> None:
cases = [
("nonzero controlling tiebreaker", [(0x802A, 123)], False, 200),
("zero controlling tiebreaker", [(0x802A, 0)], False, 200),
("missing role rejected", [], False, 400),
("both roles rejected", [(0x802A, 123), (0x8029, 456)], False, 400),
("invalid authentication rejected", [(0x802A, 0)], True, None),
]
failed = False
for name, roles, invalid_password, expected in cases:
actual = check_binding(roles, invalid_password=invalid_password)
passed = actual == expected
print(f"{'PASS' if passed else 'FAIL'}: {name}: expected {expected}, got {actual}", flush=True)
failed |= not passed
raise SystemExit(int(failed))
if __name__ == "__main__":
main()