|
| 1 | +// StunClient.swift — minimal STUN (RFC 5389) Binding client: the first brick of NAT |
| 2 | +// traversal. A serverless call today only connects two devices on the SAME subnet |
| 3 | +// because neither side knows its own public address. STUN asks a public server "what |
| 4 | +// address did my packet arrive from?", yielding the server-reflexive candidate that a |
| 5 | +// later hole-punching step needs to reach a peer across the internet. |
| 6 | +// |
| 7 | +// This file is pure + standalone (like MeshCrypto / VideoFEC): the XOR-MAPPED-ADDRESS |
| 8 | +// unmasking is endian-sensitive and easy to get subtly wrong, so it is proven bit-exact |
| 9 | +// against the OFFICIAL RFC 5769 test vectors by smoke/harness/stun_vectors.swift. Not yet |
| 10 | +// wired into the transport — that is the next brick (exchange candidates + hole punch). |
| 11 | +import Foundation |
| 12 | + |
| 13 | +enum Stun { |
| 14 | + static let magicCookie: UInt32 = 0x2112_A442 |
| 15 | + private static let bindingRequestType: UInt16 = 0x0001 |
| 16 | + private static let attrXorMappedAddress: UInt16 = 0x0020 |
| 17 | + |
| 18 | + struct MappedAddress: Equatable { let ip: String; let port: UInt16 } |
| 19 | + |
| 20 | + // A 20-byte Binding Request: type, length 0 (no attributes), magic cookie, 96-bit |
| 21 | + // transaction ID. The caller supplies the transaction ID so a reply can be matched to |
| 22 | + // its request and so the encoder is deterministic under test. |
| 23 | + static func bindingRequest(transactionID: Data) -> Data { |
| 24 | + precondition(transactionID.count == 12, "STUN transaction ID is 96 bits") |
| 25 | + var d = Data(capacity: 20) |
| 26 | + d.append(UInt8(bindingRequestType >> 8)); d.append(UInt8(bindingRequestType & 0xFF)) |
| 27 | + d.append(0); d.append(0) // message length = 0 |
| 28 | + for shift: UInt32 in [24, 16, 8, 0] { d.append(UInt8((magicCookie >> shift) & 0xFF)) } |
| 29 | + d.append(transactionID) |
| 30 | + return d |
| 31 | + } |
| 32 | + |
| 33 | + // Parse a Binding success response and return the XOR-MAPPED-ADDRESS it carries. We do |
| 34 | + // NOT verify MESSAGE-INTEGRITY / FINGERPRINT: those authenticate an ICE session, not a |
| 35 | + // plain address query, and a basic candidate gather does not need them. Returns nil on |
| 36 | + // a malformed message or a wrong magic cookie (i.e. not a STUN response). |
| 37 | + static func parseBindingResponse(_ data: Data, transactionID: Data) -> MappedAddress? { |
| 38 | + let b = [UInt8](data) |
| 39 | + guard b.count >= 20 else { return nil } |
| 40 | + let cookie = UInt32(b[4]) << 24 | UInt32(b[5]) << 16 | UInt32(b[6]) << 8 | UInt32(b[7]) |
| 41 | + guard cookie == magicCookie else { return nil } |
| 42 | + let msgLen = Int(b[2]) << 8 | Int(b[3]) |
| 43 | + guard 20 + msgLen <= b.count else { return nil } |
| 44 | + var i = 20 |
| 45 | + while i + 4 <= 20 + msgLen { |
| 46 | + let type = UInt16(b[i]) << 8 | UInt16(b[i + 1]) |
| 47 | + let len = Int(b[i + 2]) << 8 | Int(b[i + 3]) |
| 48 | + let valueStart = i + 4 |
| 49 | + guard valueStart + len <= b.count else { return nil } |
| 50 | + if type == attrXorMappedAddress { |
| 51 | + return decodeXorMapped(Array(b[valueStart ..< valueStart + len]), transactionID: transactionID) |
| 52 | + } |
| 53 | + i = valueStart + len + ((4 - (len & 3)) & 3) // attributes are 4-byte aligned |
| 54 | + } |
| 55 | + return nil |
| 56 | + } |
| 57 | + |
| 58 | + // XOR-MAPPED-ADDRESS value: [reserved 0x00][family][X-Port:2][X-Address:4 or 16]. |
| 59 | + // X-Port = port XOR (magic cookie >> 16) |
| 60 | + // X-Address = addr XOR magic cookie (IPv4) |
| 61 | + // X-Address = addr XOR (magic cookie || txid) (IPv6) |
| 62 | + private static func decodeXorMapped(_ v: [UInt8], transactionID: Data) -> MappedAddress? { |
| 63 | + guard v.count >= 8 else { return nil } |
| 64 | + let family = v[1] |
| 65 | + let port = (UInt16(v[2]) << 8 | UInt16(v[3])) ^ UInt16(magicCookie >> 16) |
| 66 | + let cookieBytes: [UInt8] = [0x21, 0x12, 0xA4, 0x42] |
| 67 | + if family == 0x01 { // IPv4 |
| 68 | + let a = (0..<4).map { v[4 + $0] ^ cookieBytes[$0] } |
| 69 | + return MappedAddress(ip: a.map(String.init).joined(separator: "."), port: port) |
| 70 | + } else if family == 0x02 { // IPv6 |
| 71 | + guard v.count >= 20 else { return nil } |
| 72 | + let mask = cookieBytes + [UInt8](transactionID) // 16-byte XOR mask |
| 73 | + guard mask.count == 16 else { return nil } |
| 74 | + let a = (0..<16).map { v[4 + $0] ^ mask[$0] } |
| 75 | + return MappedAddress(ip: formatIPv6(a), port: port) |
| 76 | + } |
| 77 | + return nil |
| 78 | + } |
| 79 | + |
| 80 | + // RFC 5952 basic form: each 16-bit group in hex with leading zeros stripped. The one |
| 81 | + // vector we validate (RFC 5769 §2.3) has no zero run to compress, so "::" is not needed. |
| 82 | + private static func formatIPv6(_ a: [UInt8]) -> String { |
| 83 | + stride(from: 0, to: 16, by: 2) |
| 84 | + .map { String(UInt16(a[$0]) << 8 | UInt16(a[$0 + 1]), radix: 16) } |
| 85 | + .joined(separator: ":") |
| 86 | + } |
| 87 | + |
| 88 | + // This machine's non-loopback IPv4 interface addresses — the host candidates. |
| 89 | + static func hostCandidates() -> [String] { |
| 90 | + var out: [String] = [] |
| 91 | + var ifap: UnsafeMutablePointer<ifaddrs>? |
| 92 | + guard getifaddrs(&ifap) == 0 else { return out } |
| 93 | + defer { freeifaddrs(ifap) } |
| 94 | + var p = ifap |
| 95 | + while let cur = p { |
| 96 | + defer { p = cur.pointee.ifa_next } |
| 97 | + guard let sa = cur.pointee.ifa_addr, sa.pointee.sa_family == UInt8(AF_INET) else { continue } |
| 98 | + var addr = sa.withMemoryRebound(to: sockaddr_in.self, capacity: 1) { $0.pointee.sin_addr } |
| 99 | + var buf = [CChar](repeating: 0, count: Int(INET_ADDRSTRLEN)) |
| 100 | + inet_ntop(AF_INET, &addr, &buf, socklen_t(INET_ADDRSTRLEN)) |
| 101 | + let ip = String(cString: buf) |
| 102 | + if ip != "127.0.0.1", !out.contains(ip) { out.append(ip) } |
| 103 | + } |
| 104 | + return out |
| 105 | + } |
| 106 | + |
| 107 | + // Server-reflexive candidate: send a Binding Request to a public STUN server (host may |
| 108 | + // be a name or an IP) and parse the address it saw. Raw BSD UDP, matching MeshTransport. |
| 109 | + // Best-effort: returns nil if the network blocks it — no server dependency is baked into |
| 110 | + // the hermetic tests, which prove the codec offline against the RFC vectors. |
| 111 | + static func gatherServerReflexive(host: String, port: UInt16, timeoutMs: Int32 = 2000) -> MappedAddress? { |
| 112 | + var hints = addrinfo() |
| 113 | + hints.ai_family = AF_INET |
| 114 | + hints.ai_socktype = SOCK_DGRAM |
| 115 | + var res: UnsafeMutablePointer<addrinfo>? |
| 116 | + guard getaddrinfo(host, String(port), &hints, &res) == 0, let info = res else { return nil } |
| 117 | + defer { freeaddrinfo(res) } |
| 118 | + |
| 119 | + let fd = socket(AF_INET, SOCK_DGRAM, 0) |
| 120 | + guard fd >= 0 else { return nil } |
| 121 | + defer { close(fd) } |
| 122 | + var tv = timeval(tv_sec: Int(timeoutMs / 1000), tv_usec: (timeoutMs % 1000) * 1000) |
| 123 | + setsockopt(fd, SOL_SOCKET, SO_RCVTIMEO, &tv, socklen_t(MemoryLayout<timeval>.size)) |
| 124 | + |
| 125 | + let txid = Data((0..<12).map { _ in UInt8.random(in: 0...255) }) |
| 126 | + let req = bindingRequest(transactionID: txid) |
| 127 | + let sent = req.withUnsafeBytes { rp in |
| 128 | + sendto(fd, rp.baseAddress, req.count, 0, info.pointee.ai_addr, info.pointee.ai_addrlen) |
| 129 | + } |
| 130 | + guard sent == req.count else { return nil } |
| 131 | + |
| 132 | + var buf = [UInt8](repeating: 0, count: 512) |
| 133 | + let n = recv(fd, &buf, buf.count, 0) |
| 134 | + guard n > 0 else { return nil } |
| 135 | + return parseBindingResponse(Data(buf.prefix(n)), transactionID: txid) |
| 136 | + } |
| 137 | +} |
0 commit comments