Skip to content

Commit 405365c

Browse files
SSD DDDclaude
authored andcommitted
feat(nat): confidential candidate exchange — secure the rendezvous before building it
The three NAT bricks (STUN #42, punch #43, ICE session #44) all assume the peers already hold each other's candidate lists. Whatever rendezvous carries that list must NOT be trusted to read or forge it: an injected candidate redirects the call to a machine the attacker controls (classic ICE candidate-injection / call hijack; WebRTC blocks it with signed SDP + a DTLS fingerprint). This is a prerequisite for a rendezvous, not an afterthought — shipping candidate exchange unsealed is a call-hijack hole. CandidateOffer.swift (pure; reuses MeshCrypto.inviteAuthKey + Ice serialization): seal [version][tiebreaker:8][expiry:8][Ice candidate list] under a room-derived key, with an expiry so a captured offer cannot be replayed later, and an ICE controlling/controlled tiebreaker. The offer key is domain-separated from the invite key by one HKDF step so a candidate offer and an invite can never be cross-interpreted. Verified in smoke/harness/candidate_offer.swift (11th verify.sh test, 13 checks, verify: 11 passed, 0 failed): honest round-trip recovers the list + tiebreaker; wrong room passphrase -> nil (confidential); flipped auth-tag byte -> nil (unforgeable); the offer does NOT open under the raw invite key (domain separation); past-TTL -> nil (stale, un-replayable); role resolves oppositely for the two peers. Clock is injected so expiry is deterministic; only static room-key derivation is used, so no MeshCrypto() is built and the Keychain is untouched. Four harness-proven NAT/exchange modules now exist (StunClient, HolePunch, IceSession, CandidateOffer); none are in project.yml / wired into CallManager yet — that integration, fed by a rendezvous carrying these sealed offers, is next. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
1 parent 5b17ea8 commit 405365c

4 files changed

Lines changed: 156 additions & 0 deletions

File tree

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

Lines changed: 31 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2909,3 +2909,34 @@ Boundary unchanged: loopback proves serialize/exchange/connect/nominate over rea
29092909
real-NAT traversal (two separate NATs). The three bricks (#42 STUN, #43 punch, #44 session) are
29102910
harness-proven but still not in project.yml / not wired into CallManager — that integration
29112911
(run connect() before the media socket opens, feed it the room's exchanged candidates) is next.
2912+
2913+
## WAVE 2026-07-23 #45 — confidential candidate exchange (secure the rendezvous BEFORE building it)
2914+
The three NAT bricks (#42-44) all assume the peers already hold each other's candidate lists.
2915+
Whatever rendezvous carries that list must NOT be trusted to read or forge it: an injected
2916+
candidate redirects the call to an attacker's machine (classic ICE candidate-injection / call
2917+
hijack; WebRTC blocks it with signed SDP + DTLS fingerprint). This is a prerequisite for a
2918+
rendezvous, not an afterthought -- shipping candidate exchange unsealed is a call-hijack hole.
2919+
2920+
CandidateOffer.swift (pure; reuses MeshCrypto.inviteAuthKey + Ice serialization): seal
2921+
[version][tiebreaker:8][expiry:8][Ice list] under a room-derived key, with an expiry so a
2922+
captured offer cannot be replayed later, and an ICE controlling/controlled tiebreaker.
2923+
smoke/harness/candidate_offer.swift (11th verify.sh test, 13 checks): honest round-trip;
2924+
wrong room -> nil (confidential); flipped tag -> nil (unforgeable); expired -> nil (fresh);
2925+
role resolves oppositely for the two peers.
2926+
2927+
Lessons:
2928+
- Domain-separate the offer key from the invite key with one HKDF step (ikm = inviteAuthKey,
2929+
distinct salt) so a candidate offer and an invite can never be cross-interpreted even though
2930+
both are room-authenticated. Verified: the offer does NOT open under the raw invite key.
2931+
- The pre-handshake exchange cannot use the forward-secret SESSION key (no handshake yet); the
2932+
ROOM key (inviteAuthKey) is the right trust boundary -- "anyone with the room passphrase",
2933+
which is exactly who is allowed to join the call, and excludes a passphrase-less rendezvous.
2934+
- A long-lived room key has no built-in anti-replay (unlike the session-key seal path, which
2935+
runs acceptNonce). Put an EXPIRY inside the sealed blob and check it on open, or a captured
2936+
offer is replayable forever.
2937+
- Pass the clock in (now: Date = Date()) so expiry tests are deterministic -- never let a test
2938+
depend on wall-clock time.
2939+
verify.sh already supported multi-file sources (#44); CandidateOffer needs four
2940+
(MeshCrypto+HolePunch+IceSession+CandidateOffer). Uses only static room-key derivation, so no
2941+
MeshCrypto() is built and the Keychain is never touched (the #41 hang cannot recur). Four
2942+
harness-proven NAT/exchange modules now exist, none yet in project.yml / wired into CallManager.
Lines changed: 62 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,62 @@
1+
// CandidateOffer.swift — a confidential, authenticated, time-bounded candidate exchange.
2+
// The three NAT bricks (STUN #42, punch #43, ICE session #44) all assume the two peers
3+
// already hold each other's candidate lists. Whatever rendezvous carries that list — a relay,
4+
// the mesh, a pasted code — MUST NOT be trusted to read or forge it: an attacker who injects
5+
// candidates redirects the call to a machine it controls (a classic ICE candidate-injection /
6+
// call hijack; WebRTC blocks it with signed SDP + a DTLS fingerprint). We seal the offer under
7+
// a key derivable ONLY from the room passphrase (a rendezvous host does not have it), stamp it
8+
// with an expiry so a captured offer cannot be replayed later, and resolve the ICE
9+
// controlling/controlled role by tiebreaker.
10+
//
11+
// Pure; reuses MeshCrypto.inviteAuthKey (room-derived) and Ice candidate serialization. The
12+
// offer key is domain-separated from the invite key by HKDF so a candidate offer and an invite
13+
// can never be cross-interpreted even though both are room-authenticated.
14+
import Foundation
15+
import CryptoKit
16+
17+
enum CandidateOffer {
18+
static let version: UInt8 = 1
19+
20+
// Domain-separated offer key: HKDF(invite-key) so it is cryptographically independent of
21+
// the invite path while still gated on the room passphrase.
22+
static func offerKey(room: String) -> SymmetricKey {
23+
SymmetricKey(data: HKDF<SHA256>.deriveKey(
24+
inputKeyMaterial: MeshCrypto.inviteAuthKey(room: room),
25+
salt: Data("trinet/candidate-offer/v1".utf8),
26+
info: Data(room.utf8), outputByteCount: 32))
27+
}
28+
29+
// Sealed inner plaintext: [version:1][tiebreaker:8 BE][expiry unix-ms:8 BE][Ice list].
30+
static func make(candidates: [Ice.Candidate], tiebreaker: UInt64, room: String, ttlMs: Int, now: Date = Date()) -> Data {
31+
let expiry = UInt64((now.timeIntervalSince1970 * 1000).rounded()) + UInt64(ttlMs)
32+
var inner = Data([version])
33+
inner.append(contentsOf: be(tiebreaker))
34+
inner.append(contentsOf: be(expiry))
35+
inner.append(Ice.encode(candidates))
36+
return ((try? ChaChaPoly.seal(inner, using: offerKey(room: room)))?.combined) ?? Data()
37+
}
38+
39+
struct Offer: Equatable { let candidates: [Ice.Candidate]; let tiebreaker: UInt64 }
40+
41+
// nil if: wrong room passphrase, tampered ciphertext, bad version, expired, or a malformed
42+
// candidate list. Every rejection is silent-safe (no crash on any input).
43+
static func open(_ data: Data, room: String, now: Date = Date()) -> Offer? {
44+
guard let box = try? ChaChaPoly.SealedBox(combined: data),
45+
let inner = try? ChaChaPoly.open(box, using: offerKey(room: room)) else { return nil }
46+
let b = [UInt8](inner)
47+
guard b.count >= 17, b[0] == version else { return nil }
48+
let tiebreaker = u64(b[1..<9])
49+
let expiry = u64(b[9..<17])
50+
let nowMs = UInt64((now.timeIntervalSince1970 * 1000).rounded())
51+
guard expiry >= nowMs else { return nil } // stale: cannot be replayed later
52+
guard let cands = Ice.decode(Data(b[17...])) else { return nil }
53+
return Offer(candidates: cands, tiebreaker: tiebreaker)
54+
}
55+
56+
// ICE role (RFC 8445 §6.1.1): the peer with the higher tiebreaker is controlling. 64-bit
57+
// random tiebreakers never tie in practice; resolve a tie deterministically anyway.
58+
static func isControlling(mine: UInt64, peer: UInt64) -> Bool { mine > peer }
59+
60+
private static func be(_ x: UInt64) -> [UInt8] { (0..<8).map { UInt8((x >> (56 - 8 * $0)) & 0xFF) } }
61+
private static func u64(_ s: ArraySlice<UInt8>) -> UInt64 { s.reduce(UInt64(0)) { ($0 << 8) | UInt64($1) } }
62+
}
Lines changed: 62 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,62 @@
1+
// Verifies the confidential candidate exchange in the ACTUAL CandidateOffer.swift (+ Ice +
2+
// MeshCrypto). Only static room-key derivation is used, so no MeshCrypto() is constructed and
3+
// the Keychain is never touched (the #41 hang cannot recur here). The clock is passed in, so
4+
// expiry is deterministic.
5+
// swiftc MeshCrypto.swift HolePunch.swift IceSession.swift CandidateOffer.swift candidate_offer.swift -o /tmp/co && /tmp/co
6+
import Foundation
7+
import CryptoKit
8+
9+
var fails = 0
10+
func check(_ c: Bool, _ l: String) { print("\(c ? "PASS" : "FAIL") \(l)"); if !c { fails += 1 } }
11+
12+
let cands = [Ice.Candidate(ip: "192.168.1.50", port: 5000, kind: .host),
13+
Ice.Candidate(ip: "203.0.113.50", port: 61000, kind: .srflx)]
14+
let t0 = Date(timeIntervalSince1970: 1_700_000_000) // fixed clock
15+
16+
print("== honest round-trip under the same room ==")
17+
do {
18+
let offer = CandidateOffer.make(candidates: cands, tiebreaker: 0xAABB_CCDD, room: "team-alpha", ttlMs: 30_000, now: t0)
19+
let got = CandidateOffer.open(offer, room: "team-alpha", now: t0.addingTimeInterval(5))
20+
check(got?.candidates == cands, "the peer's candidate list is recovered intact")
21+
check(got?.tiebreaker == 0xAABB_CCDD, "the ICE tiebreaker is recovered")
22+
check(offer.count > 17, "the offer is sealed (nonce+ciphertext+tag), not plaintext")
23+
}
24+
25+
print("== a rendezvous without the room passphrase can neither read nor forge ==")
26+
do {
27+
let offer = CandidateOffer.make(candidates: cands, tiebreaker: 1, room: "team-alpha", ttlMs: 30_000, now: t0)
28+
check(CandidateOffer.open(offer, room: "team-beta", now: t0) == nil, "a different room passphrase -> nil (cannot read)")
29+
var tampered = [UInt8](offer); tampered[tampered.count - 1] ^= 0xFF
30+
check(CandidateOffer.open(Data(tampered), room: "team-alpha", now: t0) == nil, "flipped auth-tag byte -> nil (cannot forge a candidate)")
31+
// the offer key is domain-separated from the raw invite key: opening under it must fail
32+
let underInviteKey = { () -> Bool in
33+
guard let box = try? ChaChaPoly.SealedBox(combined: offer),
34+
let _ = try? ChaChaPoly.open(box, using: MeshCrypto.inviteAuthKey(room: "team-alpha")) else { return false }
35+
return true
36+
}()
37+
check(!underInviteKey, "the offer does NOT open under the raw invite key (domain separation holds)")
38+
}
39+
40+
print("== a captured offer cannot be replayed after it expires ==")
41+
do {
42+
let offer = CandidateOffer.make(candidates: cands, tiebreaker: 1, room: "r", ttlMs: 10_000, now: t0)
43+
check(CandidateOffer.open(offer, room: "r", now: t0.addingTimeInterval(9)) != nil, "within TTL -> accepted")
44+
check(CandidateOffer.open(offer, room: "r", now: t0.addingTimeInterval(11)) == nil, "past TTL -> rejected (stale, un-replayable)")
45+
}
46+
47+
print("== ICE role resolves deterministically and oppositely for the two peers ==")
48+
do {
49+
check(CandidateOffer.isControlling(mine: 100, peer: 50), "higher tiebreaker -> controlling")
50+
check(!CandidateOffer.isControlling(mine: 50, peer: 100), "lower tiebreaker -> controlled")
51+
check(CandidateOffer.isControlling(mine: 100, peer: 50) != CandidateOffer.isControlling(mine: 50, peer: 100),
52+
"the two peers pick OPPOSITE roles (no double-controlling)")
53+
}
54+
55+
print("== garbage / truncated input never crashes ==")
56+
do {
57+
check(CandidateOffer.open(Data(), room: "r", now: t0) == nil, "empty -> nil")
58+
check(CandidateOffer.open(Data([1, 2, 3, 4, 5]), room: "r", now: t0) == nil, "junk -> nil")
59+
}
60+
61+
print("\n\(fails == 0 ? "ALL PASS" : "\(fails) FAILURE(S)")")
62+
exit(fails == 0 ? 0 : 1)

smoke/verify.sh

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -35,6 +35,7 @@ SUITE=(
3535
"Stun RFC5769 : $SRC/StunClient.swift: smoke/harness/stun_vectors.swift"
3636
"HolePunch : $SRC/HolePunch.swift : smoke/harness/holepunch.swift"
3737
"IceSession : $SRC/HolePunch.swift $SRC/IceSession.swift: smoke/harness/ice_session.swift"
38+
"CandidateOffer : $SRC/MeshCrypto.swift $SRC/HolePunch.swift $SRC/IceSession.swift $SRC/CandidateOffer.swift: smoke/harness/candidate_offer.swift"
3839
)
3940

4041
pass=0; fail=0

0 commit comments

Comments
 (0)