Skip to content

Commit 0fbc42a

Browse files
SSD DDDclaude
authored andcommitted
feat(nat): STUN Binding client — the first verifiable brick of NAT traversal
The call has connected only same-subnet peers for its entire life; every wave report v0.7..v0.11 named "add STUN" as the #1 usability unlock and deferred it. Codecs, FEC, crypto and BWE are all polish on a call that cannot even be established across two networks. Root cause: neither side knows its own PUBLIC address, so there is nothing for a later hole-punch to aim at. StunClient.swift (pure + standalone, like MeshCrypto / VideoFEC) is a minimal STUN (RFC 5389) Binding client: encode a Binding Request, parse XOR-MAPPED-ADDRESS (IPv4 + IPv6), enumerate host candidates (getifaddrs), and a best-effort server-reflexive query over raw BSD UDP. The XOR unmasking is endian-sensitive: X-Port = port XOR (cookie>>16); X-Addr = addr XOR cookie (IPv4) X-Addr = addr XOR (cookie || transaction-id) (IPv6) Verified two independent ways: * OFFLINE, bit-exact against the IETF's own RFC 5769 reference vectors (2.2 -> 192.0.2.1:32853, 2.3 -> [2001:db8:...:6677]:32853) in smoke/harness/stun_vectors.swift, now the 8th test in verify.sh (verify: 8 passed, 0 failed). * LIVE end-to-end: gatherServerReflexive("stun.l.google.com",19302) returned this machine's real public ip:port; host candidates returned the LAN IPv4. RFC 5769's official vectors keep the gate hermetic (no network dependency, unlike the #41 Keychain hang). Not yet wired into the transport — exchanging candidates and hole-punching is the next brick, so StunClient.swift is harness-proven but not in project.yml until it is used. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
1 parent dcc71e0 commit 0fbc42a

4 files changed

Lines changed: 230 additions & 0 deletions

File tree

.claude/skills/tri-net-development/SKILL.md

Lines changed: 33 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2811,3 +2811,36 @@ Realizations:
28112811
Committing `.swift` under smoke/ is clean: hooks gate .rs/.t27/.v (ascii) and .sh outside
28122812
smoke/ (no-shell-scripts); a smoke/*.swift harness trips neither. phone/ is hand-written,
28132813
outside the golden pipeline, so no gen/ concern.
2814+
2815+
## WAVE 2026-07-23 #42 — STUN client: the first VERIFIABLE brick of NAT traversal
2816+
The call has connected only same-subnet peers for its entire life; every wave report v0.7..
2817+
v0.11 listed "add STUN" as the #1 usability unlock and deferred it. Codecs/FEC/crypto/BWE
2818+
are polish on a call that cannot even be established across two networks. Root cause: neither
2819+
side knows its own PUBLIC address, so there is nothing to hole-punch toward.
2820+
2821+
First brick, chosen because it is fully verifiable OFFLINE: a minimal STUN (RFC 5389) Binding
2822+
client — phone/desktop/TriNetVideo/StunClient.swift (pure + standalone like MeshCrypto). It
2823+
encodes a Binding Request and parses XOR-MAPPED-ADDRESS (IPv4 + IPv6). The unmasking is the
2824+
error-prone part:
2825+
X-Port = port XOR (cookie>>16); X-Addr = addr XOR cookie (IPv4);
2826+
X-Addr = addr XOR (cookie||txid) (IPv6 — txid is load-bearing).
2827+
2828+
Verified two independent ways:
2829+
* OFFLINE, bit-exact against the IETF's OWN reference bytes (RFC 5769 2.2 -> 192.0.2.1:32853,
2830+
2.3 -> [2001:db8:1234:5678:11:2233:4455:6677]:32853) in smoke/harness/stun_vectors.swift,
2831+
now the 8th test in verify.sh. A green run == agreement with every conformant STUN server.
2832+
* LIVE end-to-end: gatherServerReflexive("stun.l.google.com",19302) returned this machine's
2833+
real public ip:port. host candidates via getifaddrs returned the LAN IPv4.
2834+
2835+
Lessons:
2836+
- RFC 5769 gives official STUN test vectors — use them. They make the endian-sensitive XOR
2837+
unmask provable with ZERO network dependency, so the gate stays hermetic (contrast the #41
2838+
Keychain hang: never put an environment-dependent op in verify.sh).
2839+
- IPv4 X-Address XORs the cookie ALONE; IPv6 XORs cookie||transaction-id. A harness that only
2840+
tests IPv4 cannot catch a txid-handling bug — include the IPv6 vector with the RFC's exact txid.
2841+
- Bound every attribute walk by the buffer AND the declared msg-length; a length field that
2842+
runs past the end must return nil, never read out of bounds (tested explicitly).
2843+
- Live STUN worked here, but the gate must not depend on it — the RFC vectors are the proof of
2844+
record; the live query is a bonus, honestly reported when the network cooperates.
2845+
NOT yet wired into the transport (no signaling to exchange candidates, no hole-punch); that is
2846+
the next brick. StunClient.swift is harness-proven but not in project.yml until it is used.
Lines changed: 137 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,137 @@
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+
}

smoke/harness/stun_vectors.swift

Lines changed: 59 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,59 @@
1+
// Verifies the STUN codec in the ACTUAL StunClient.swift against the OFFICIAL RFC 5769
2+
// test vectors. The XOR-MAPPED-ADDRESS unmasking is the fiddly, endian-sensitive part, and
3+
// these are the IETF's own reference bytes, so a green run means bit-exact agreement with
4+
// every conformant STUN server. Pure + offline (no socket) — safe inside verify.sh.
5+
// swiftc StunClient.swift stun_vectors.swift -o /tmp/stun && /tmp/stun
6+
import Foundation
7+
8+
var fails = 0
9+
func check(_ c: Bool, _ l: String) { print("\(c ? "PASS" : "FAIL") \(l)"); if !c { fails += 1 } }
10+
11+
// RFC 5769 §2.1/§2.2/§2.3 all share this 96-bit transaction ID.
12+
let txid = Data([0xb7, 0xe7, 0xa7, 0x01, 0xbc, 0x34, 0xd6, 0x86, 0xfa, 0x87, 0xdf, 0xae])
13+
14+
print("== encoder: a Binding Request header is exactly type/len/cookie/txid ==")
15+
do {
16+
let req = Stun.bindingRequest(transactionID: txid)
17+
let expected = Data([0x00, 0x01, 0x00, 0x00, 0x21, 0x12, 0xa4, 0x42] + [UInt8](txid))
18+
check(req == expected, "Binding Request is 20 bytes: type=0x0001, len=0, magic cookie, txid")
19+
}
20+
21+
print("== RFC 5769 2.2: IPv4 XOR-MAPPED-ADDRESS decodes to 192.0.2.1:32853 ==")
22+
do {
23+
// header (success response 0x0101, msg-len = the 12-byte attribute) + the RFC's exact
24+
// XOR-MAPPED-ADDRESS bytes. IPv4 X-Address XORs only the magic cookie, so this unmask is
25+
// independent of the transaction ID.
26+
let attr: [UInt8] = [0x00, 0x20, 0x00, 0x08, 0x00, 0x01, 0xa1, 0x47, 0xe1, 0x12, 0xa6, 0x43]
27+
let msg = Data([0x01, 0x01, 0x00, 0x0c, 0x21, 0x12, 0xa4, 0x42] + [UInt8](txid) + attr)
28+
let m = Stun.parseBindingResponse(msg, transactionID: txid)
29+
check(m?.ip == "192.0.2.1", "X-Address unmasks to 192.0.2.1")
30+
check(m?.port == 32853, "X-Port unmasks to 32853")
31+
}
32+
33+
print("== RFC 5769 2.3: IPv6 XOR-MAPPED-ADDRESS decodes to the RFC address:32853 ==")
34+
do {
35+
// IPv6 X-Address XORs the magic cookie CONCATENATED WITH the transaction ID, so the
36+
// official txid above is load-bearing here (unlike IPv4).
37+
let attr: [UInt8] = [0x00, 0x20, 0x00, 0x14, 0x00, 0x02, 0xa1, 0x47,
38+
0x01, 0x13, 0xa9, 0xfa, 0xa5, 0xd3, 0xf1, 0x79,
39+
0xbc, 0x25, 0xf4, 0xb5, 0xbe, 0xd2, 0xb9, 0xd9]
40+
let msg = Data([0x01, 0x01, 0x00, 0x18, 0x21, 0x12, 0xa4, 0x42] + [UInt8](txid) + attr)
41+
let m = Stun.parseBindingResponse(msg, transactionID: txid)
42+
check(m?.ip == "2001:db8:1234:5678:11:2233:4455:6677", "X-Address unmasks to the RFC 5769 IPv6 address")
43+
check(m?.port == 32853, "X-Port unmasks to 32853")
44+
}
45+
46+
print("== robustness: non-STUN and truncated inputs are rejected, never crash ==")
47+
do {
48+
let attr: [UInt8] = [0x00, 0x20, 0x00, 0x08, 0x00, 0x01, 0xa1, 0x47, 0xe1, 0x12, 0xa6, 0x43]
49+
let badCookie = Data([0x01, 0x01, 0x00, 0x0c, 0xde, 0xad, 0xbe, 0xef] + [UInt8](txid) + attr)
50+
check(Stun.parseBindingResponse(badCookie, transactionID: txid) == nil, "wrong magic cookie -> nil (not a STUN response)")
51+
check(Stun.parseBindingResponse(Data([0x01, 0x01, 0x00, 0x00]), transactionID: txid) == nil, "too-short message -> nil, no crash")
52+
// an attribute whose declared length runs past the buffer must not read out of bounds
53+
// (msg-len = 4 admits the 4-byte attribute header into the loop; its value-len is 0xffff)
54+
let overrun = Data([0x01, 0x01, 0x00, 0x04, 0x21, 0x12, 0xa4, 0x42] + [UInt8](txid) + [0x00, 0x20, 0xff, 0xff])
55+
check(Stun.parseBindingResponse(overrun, transactionID: txid) == nil, "attribute length past end -> nil, no crash")
56+
}
57+
58+
print("\n\(fails == 0 ? "ALL PASS" : "\(fails) FAILURE(S)")")
59+
exit(fails == 0 ? 0 : 1)

smoke/verify.sh

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -32,6 +32,7 @@ SUITE=(
3232
"MeshCrypto replay: $SRC/MeshCrypto.swift: smoke/harness/crypto_replay.swift"
3333
"MeshCrypto identity: $SRC/MeshCrypto.swift: smoke/harness/crypto_identity.swift"
3434
"MeshCrypto keychain: $SRC/MeshCrypto.swift: smoke/harness/crypto_keychain.swift"
35+
"Stun RFC5769 : $SRC/StunClient.swift: smoke/harness/stun_vectors.swift"
3536
)
3637

3738
pass=0; fail=0

0 commit comments

Comments
 (0)