1// TCPTransport.swift2// SophaxChatCore3//4// Pluggable TCP transport — lets SophaxChat communicate over the internet.5//6// The cryptographic layer (X3DH + Double Ratchet + ChaCha20-Poly1305) is7// identical to the local BLE/WiFi path. TCP is just a carrier of already-8// encrypted WireMessage objects. The transport is trustless by design.9//10// Security properties preserved over TCP:11// • Transport encryption: TLS 1.3 with an ephemeral self-signed P-256 cert12// — prevents passive observers from seeing message sizes and timing13// • Cert pinning: outbound connections verify the peer's cert fingerprint14// from their PreKeyBundle — no CA, no trust chain required15// • End-to-end encryption: messages are sealed before hitting the wire16// • Authentication: every WireMessage carries an Ed25519 signature17// • Forward secrecy: Double Ratchet rotates keys per message18// • Sealed sender: relay nodes cannot correlate sender and recipient19//20// Anonymity options:21// a) System Tor via Orbot "VPN mode" — all iOS network traffic goes22// through Tor automatically, no app-side config needed.23// b) SOCKS5 proxy — set socksProxy = "127.0.0.1:9050" in Config24// (requires Orbot or another local SOCKS5 proxy, iOS 17+).25//26// Peer discovery over TCP:27// Peers exchange their TCP address (host:port) via QR code or out-of-band.28// The address is also included in Hello messages so nearby (BLE/WiFi)29// peers automatically learn each other's internet address.30//31// Framing: 4-byte big-endian length prefix + JSON-encoded WireMessage.32//33// Protocol:34// After TCP connect, both peers immediately exchange their Hello message35// (the same PreKeyBundle format used over BLE/WiFi). The peerID is36// learned from WireMessage.senderID of the received Hello.3738import Foundation39import Network40import CryptoKit41import Security4243// MARK: - Errors4445public enum TCPTransportError: Error, LocalizedError {46 case peerNotConnected47 case invalidAddress48 case helloNotAvailable4950 public var errorDescription: String?{51 switch self {52 case .peerNotConnected: return "Peer is not connected via TCP"53 case .invalidAddress: return "Invalid address — expected host:port"54 case .helloNotAvailable: return "Cannot send TCP Hello: hello provider not set"55}56}57}5859// MARK: - Delegate6061public protocol TCPTransportDelegate: AnyObject {62/// A TCP connection completed Hello exchange. `address` is the remote host:port.63 func tcpTransport(_ transport: TCPTransport, didConnectToPeer peerID: String, address: String)64/// A TCP connection was lost.65 func tcpTransport(_ transport: TCPTransport, didDisconnectFromPeer peerID: String)66/// A WireMessage was received over a verified TCP connection.67 func tcpTransport(_ transport: TCPTransport,68 didReceiveMessage message: WireMessage, fromPeer peerID: String)69/// A `send` call failed.70 func tcpTransport(_ transport: TCPTransport, sendDidFailForPeer peerID: String, error: Error)71/// The listener started and is ready to accept connections.72 func tcpTransportDidStartListening(_ transport: TCPTransport, onPort port: UInt16)73}7475// MARK: - TCPTransport7677// Threading contract: TCPTransport is internally thread-safe. All mutable78// state is protected by `lock` (NSLock). Delegate callbacks are always79// dispatched to the main thread.80public final class TCPTransport:@unchecked Sendable {8182// MARK: - Config8384 public struct Config: Codable, Sendable {85/// Local TCP port to listen on (default 25519 — homage to Curve25519).86 public var port: UInt1687/// Optional SOCKS5 proxy "host:port" for routing through Tor (iOS 17+).88/// Example: "127.0.0.1:9050" for a local Orbot SOCKS5 proxy.89 public var socksProxy: String?9091 public init(port: UInt16 = 25519, socksProxy: String?= nil){92 self.port = port93 self.socksProxy = socksProxy94}9596 var proxyComponents:(host: String, port: UInt16)?{97 guard let s = socksProxy else { return nil }98 let parts = s.split(separator:":", maxSplits: 1)99 guard parts.count == 2, let p = UInt16(parts[1]) else { return nil }100 return (String(parts[0]), p)101}102}103104// MARK: - Private state105106 public let config: Config107108/// Called whenever TCPTransport needs to send a Hello to a newly-connected peer.109/// ChatManager sets this to return a freshly-signed HelloMessage WireMessage.110 public var helloProvider:(()-> WireMessage?)?111112 private let lock = NSLock()113114/// peerID → active NWConnection (post Hello exchange).115 private var connections:[String: NWConnection]=[:]116/// ObjectIdentifier → receive buffer (for connections whose Hello is still pending).117 private var receiveBuffers:[ObjectIdentifier: Data]=[:]118/// ObjectIdentifier → resolved remote address string (filled on TCP connect).119 private var pendingAddresses:[ObjectIdentifier: String]=[:]120121 private var listener: NWListener?122123 private static let helloTimeout: TimeInterval = 10124// 3 MiB: maxFileAttachmentBytes (2 MB) + generous overhead for JSON wrapper and headers.125// Keeping this tight limits the memory a slow peer can occupy while streaming a frame.126 private static let maxFrameSize: Int = 3 * 1024 * 1024127/// Idle timeout for established connections: if no complete frame arrives within this window128/// the connection is dropped. Prevents a slow peer from holding a slot indefinitely by129/// trickling bytes without completing a frame (memory exhaustion DoS).130 private static let idleTimeout: TimeInterval = 120131/// ObjectIdentifier → idle timer (fires if no complete frame received within idleTimeout)132 private var idleTimers:[ObjectIdentifier: DispatchSourceTimer]=[:]133134/// Ephemeral TLS identity (key + self-signed cert) from TLSCertManager.135/// Set this before calling start(). When nil, TLS still runs but without136/// presenting a local certificate (accepted by most peers for inbound paths).137 public var tlsIdentity: SecIdentity?138139 public weak var delegate: TCPTransportDelegate?140141// MARK: - Init142143 public init(config: Config){144 self.config = config145}146147// MARK: - Lifecycle148149 public func start(){150 let params = makeNWParameters(isListener: true)// inbound: accept any cert151 guard let listener = try? NWListener(using: params,152 on: NWEndpoint.Port(rawValue: config.port)??.any) else { return }153 self.listener = listener154155 listener.stateUpdateHandler ={[weak self] state in156 guard let self else { return }157 if case .ready = state, let port = listener.port?.rawValue {158 DispatchQueue.main.async { self.delegate?.tcpTransportDidStartListening(self, onPort: port)}159}160}161 listener.newConnectionHandler ={[weak self] conn in162 self?.accept(connection: conn)163}164 listener.start(queue:.global(qos:.userInitiated))165}166167 public func stop(){168 listener?.cancel()169 listener = nil170 lock.lock()171 let allConns = Array(connections.values)172 connections.removeAll()173 receiveBuffers.removeAll()174 pendingAddresses.removeAll()175 let timers = Array(idleTimers.values)176 idleTimers.removeAll()177 lock.unlock()178 timers.forEach { $0.cancel()}179 allConns.forEach { $0.cancel()}180}181182// MARK: - Outbound connect183184/// Connect to a remote peer. Address must be "host:port".185/// Pass `expectedCertFingerprint` (from the peer's PreKeyBundle) to pin the TLS186/// connection to the peer's ephemeral certificate — no CA required.187 public func connect(to address: String, expectedCertFingerprint: Data?= nil) throws {188 guard let (host, port)= parseAddress(address),189 let nwPort = NWEndpoint.Port(rawValue: port) else {190 throw TCPTransportError.invalidAddress191}192 let params = makeNWParameters(isListener: false,193 expectedFingerprint: expectedCertFingerprint)194 let endpoint = NWEndpoint.hostPort(host: NWEndpoint.Host(host), port: nwPort)195 let connection = NWConnection(to: endpoint, using: params)196 let oid = ObjectIdentifier(connection)197 lock.lock()198 receiveBuffers[oid]= Data()199 pendingAddresses[oid]= address200 lock.unlock()201 setup(connection: connection)202}203204// MARK: - Send205206 public func send(_ message: WireMessage, toPeerID peerID: String) throws {207 lock.lock()208 let conn = connections[peerID]209 lock.unlock()210 guard let conn else { throw TCPTransportError.peerNotConnected }211 guard let data = try? JSONEncoder().encode(message) else { return }212 sendFramed(data, over: conn, failurePeerID: peerID)213}214215// MARK: - Query216217 public func isConnected(peerID: String)-> Bool {218 lock.lock(); defer { lock.unlock()}219 return connections[peerID]!= nil220}221222 public var connectedPeerCount: Int {223 lock.lock(); defer { lock.unlock()}224 return connections.count225}226227 public var connectedPeerIDs:[String]{228 lock.lock(); defer { lock.unlock()}229 return Array(connections.keys)230}231232// MARK: - Private: accept inbound233234 private func accept(connection: NWConnection){235 let oid = ObjectIdentifier(connection)236 let addr = addressString(for: connection)237 lock.lock()238 receiveBuffers[oid]= Data()239 pendingAddresses[oid]= addr240 lock.unlock()241 setup(connection: connection)242}243244// MARK: - Private: connection setup245246 private func setup(connection: NWConnection){247 connection.stateUpdateHandler ={[weak self, weak connection] state in248 guard let self, let connection else { return }249 switch state {250 case .ready:251// Send our Hello immediately after TCP handshake252 if let hello = self.helloProvider?(),253 let data = try? JSONEncoder().encode(hello){254 self.sendFramed(data, over: connection, failurePeerID: nil)255}256 self.scheduleHelloTimeout(for: connection)257 self.scheduleIdleTimeout(for: connection)258 self.startReceiving(from: connection)259260 case .failed,.cancelled:261 self.teardown(connection: connection)262263 default: break264}265}266 connection.start(queue:.global(qos:.userInitiated))267}268269// MARK: - Private: Hello timeout270271 private func scheduleHelloTimeout(for connection: NWConnection){272 let oid = ObjectIdentifier(connection)273 DispatchQueue.global().asyncAfter(deadline:.now()+ TCPTransport.helloTimeout){274[weak self, weak connection] in275 guard let self, let connection else { return }276 self.lock.lock()277 let isPending = self.receiveBuffers[oid]!= nil &&278!self.connections.values.contains { ObjectIdentifier($0)== oid }279 self.lock.unlock()280 if isPending { connection.cancel()}281}282}283284// MARK: - Private: idle timeout285286 private func scheduleIdleTimeout(for connection: NWConnection){287 let oid = ObjectIdentifier(connection)288 let timer = DispatchSource.makeTimerSource(queue:.global())289 timer.schedule(deadline:.now()+ TCPTransport.idleTimeout, repeating:.never)290 timer.setEventHandler {[weak self, weak connection] in291 guard let connection else { return }292 self?.teardown(connection: connection)293}294 lock.lock()295 idleTimers[oid]?.cancel()296 idleTimers[oid]= timer297 lock.unlock()298 timer.resume()299}300301 private func resetIdleTimeout(for connection: NWConnection){302// Reschedule the idle timer after each successfully parsed frame303 scheduleIdleTimeout(for: connection)304}305306// MARK: - Private: receive loop307308 private func startReceiving(from connection: NWConnection){309 connection.receive(minimumIncompleteLength: 1, maximumLength: 65536){310[weak self, weak connection] data, _, isComplete, error in311 guard let self, let connection else { return }312 if let data { self.didReceive(data: data, from: connection)}313 if isComplete || error != nil { self.teardown(connection: connection); return }314 self.startReceiving(from: connection)315}316}317318// MARK: - Private: framing319320 private func didReceive(data: Data, from connection: NWConnection){321 let oid = ObjectIdentifier(connection)322 lock.lock()323// Reject data that would push the buffer past the frame size cap before appending.324// Without this check a slow sender can fill 4 MiB in memory byte-by-byte without325// ever triggering the frame-size guard inside the parsing loop.326 if (receiveBuffers[oid]?.count ?? 0)+ data.count > TCPTransport.maxFrameSize {327 lock.unlock()328 connection.cancel()329 lock.lock()330 receiveBuffers.removeValue(forKey: oid)331 lock.unlock()332 return333}334 receiveBuffers[oid, default: Data()].append(data)335 var buf = receiveBuffers[oid]?? Data()336 var frames:[Data]=[]337 var oversized = false338 while buf.count >= 4 {339 let lengthU32 = buf.prefix(4).withUnsafeBytes { $0.load(as: UInt32.self).bigEndian }340// Explicit Int conversion after bounds check — safe on 64-bit (maxFrameSize = 4 MiB).341 guard Int(lengthU32)<= TCPTransport.maxFrameSize else {342// Protocol violation: cancel the connection instead of silently dropping data.343 buf.removeAll()344 oversized = true345 break346}347 let length = Int(lengthU32)348 guard buf.count >= 4 + length else { break }349 frames.append(Data(buf[4..<(4 + length)]))350 buf = Data(buf[(4 + length)...])351}352 receiveBuffers[oid]= buf353 lock.unlock()354 if oversized { connection.cancel(); return }355 if !frames.isEmpty { resetIdleTimeout(for: connection)}356 frames.forEach { dispatch(frame: $0, from: connection)}357}358359 private func sendFramed(_ data: Data, over connection: NWConnection, failurePeerID: String?){360 var len = UInt32(data.count).bigEndian361 let frame = Data(bytes:&len, count: 4)+ data362 connection.send(content: frame, completion:.contentProcessed {[weak self] error in363 guard let error, let self, let pid = failurePeerID else { return }364 DispatchQueue.main.async {365 self.delegate?.tcpTransport(self, sendDidFailForPeer: pid, error: error)366}367})368}369370// MARK: - Private: dispatch received frame371372 private func dispatch(frame: Data, from connection: NWConnection){373 guard let message = try? JSONDecoder().decode(WireMessage.self, from: frame) else { return }374 let oid = ObjectIdentifier(connection)375376 lock.lock()377 let knownPeerID = connections.first(where:{ ObjectIdentifier($0.value)== oid })?.key378 lock.unlock()379380 if let peerID = knownPeerID {381// Normal message from a known peer382 DispatchQueue.main.async {[weak self] in383 guard let self else { return }384 self.delegate?.tcpTransport(self, didReceiveMessage: message, fromPeer: peerID)385}386 return387}388389// Pending Hello — the first received message MUST be a Hello390 guard message.type ==.hello else { connection.cancel(); return }391 let peerID = message.senderID392393 lock.lock()394// Evict any stale connection for this peer (reconnect race)395 connections[peerID]?.cancel()396 connections[peerID]= connection397 let addr = pendingAddresses[oid]?? addressString(for: connection)398 pendingAddresses.removeValue(forKey: oid)399 lock.unlock()400401 let address = addr402 DispatchQueue.main.async {[weak self] in403 guard let self else { return }404 self.delegate?.tcpTransport(self, didConnectToPeer: peerID, address: address)405// Deliver the Hello to ChatManager so it can process the PreKeyBundle406 self.delegate?.tcpTransport(self, didReceiveMessage: message, fromPeer: peerID)407}408}409410// MARK: - Private: teardown411412 private func teardown(connection: NWConnection){413 let oid = ObjectIdentifier(connection)414 lock.lock()415 let peerID = connections.first(where:{ ObjectIdentifier($0.value)== oid })?.key416 if let p = peerID { connections.removeValue(forKey: p)}417 receiveBuffers.removeValue(forKey: oid)418 pendingAddresses.removeValue(forKey: oid)419 let timer = idleTimers.removeValue(forKey: oid)420 lock.unlock()421 timer?.cancel()422 connection.cancel()423 if let peerID {424 DispatchQueue.main.async {[weak self] in425 guard let self else { return }426 self.delegate?.tcpTransport(self, didDisconnectFromPeer: peerID)427}428}429}430431// MARK: - Private: NWParameters432433/// Build NWParameters with TLS 1.3 and optional certificate pinning.434///435/// - `isListener`: true for the inbound NWListener, false for outbound NWConnection.436/// - `expectedFingerprint`: SHA-256 of the expected remote cert DER (from PreKeyBundle).437/// When non-nil, the TLS verify block rejects any cert that doesn't match.438/// When nil (listener / unknown peer), the verify block accepts any cert — the439/// application-layer Ed25519 signature on every WireMessage provides authentication.440 private func makeNWParameters(isListener: Bool,441 expectedFingerprint: Data?= nil)-> NWParameters {442 let tlsOptions = NWProtocolTLS.Options()443444// Present our ephemeral self-signed cert to the peer.445 if let identity = tlsIdentity,446 let secId = sec_identity_create(identity){447 sec_protocol_options_set_local_identity(448 tlsOptions.securityProtocolOptions, secId)449}450451// Custom cert verification:452// • Outbound with known fingerprint → pin to expected cert (MITM detection).453// • Inbound or fingerprint unknown → accept any cert (trust established via454// application-layer Ed25519 signature verification on the first WireMessage).455 sec_protocol_options_set_verify_block(456 tlsOptions.securityProtocolOptions,457{[expectedFingerprint] _, trust, complete in458 guard let fp = expectedFingerprint else {459 complete(true)// No pinning required for this connection460 return461}462 let secTrust = sec_trust_copy_ref(trust).takeRetainedValue()463 guard let chain = SecTrustCopyCertificateChain(secTrust) as?[SecCertificate],464 let leaf = chain.first else {465 complete(false)466 return467}468 let derData = SecCertificateCopyData(leaf) as Data469 complete(Data(SHA256.hash(data: derData))== fp)470},471.global(qos:.userInitiated)472)473474 let params = NWParameters(tls: tlsOptions, tcp: NWProtocolTCP.Options())475476// SOCKS5 proxy (iOS 17+ / macOS 14+): routes connections through Tor or similar.477 if #available(iOS 17.0, macOS 14.0,*),478 let (host, port)= config.proxyComponents,479 let nwPort = NWEndpoint.Port(rawValue: port){480 let proxyEndpoint = NWEndpoint.hostPort(host: NWEndpoint.Host(host), port: nwPort)481 let proxyCfg = ProxyConfiguration(socksv5Proxy: proxyEndpoint)482 let privacyCtx = NWParameters.PrivacyContext(description:"SophaxChat")483 privacyCtx.proxyConfigurations =[proxyCfg]484 params.setPrivacyContext(privacyCtx)485}486 return params487}488489// MARK: - Private: utilities490491 private func parseAddress(_ s: String)->(host: String, port: UInt16)?{492 let parts = s.split(separator:":", maxSplits: 1)493 guard parts.count == 2, let p = UInt16(parts[1]) else { return nil }494 return (String(parts[0]), p)495}496497 private func addressString(for connection: NWConnection)-> String {498 switch connection.endpoint {499 case .hostPort(let host, let port):500 return "\(host):\(port)"501 default:502 return connection.endpoint.debugDescription503}504}505}