mirror of
https://github.com/firestar5683/StarPilot.git
synced 2026-09-10 18:23:44 +08:00
119 lines
4.6 KiB
Python
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()
|