import Foundation import Security /// Result of probing the negotiated TLS parameters of the XMPP server: /// the protocol version and the cipher suite actually agreed upon. struct TLSProbeResult: Sendable { let version: String let cipher: String } /// Standalone, best-effort probe that negotiates TLS against the same host /// and port the XMPP connection uses, and reports the negotiated TLS version /// and cipher suite. It performs the STARTTLS stream handshake (stream /// header + ) when the account does not use Direct TLS, mirroring /// what Martin's SocketConnector does, then reads the result from /// SecureTransport. The app's real connection negotiates TLS the same way, /// so the probe accurately reflects what the server screen displays. final class TLSVersionProbe: NSObject, StreamDelegate { private let host: String private let port: UInt32 private let directTLS: Bool private let expectedDomain: String private let completion: (TLSProbeResult?) -> Void private enum Phase { case starting case expectingFeatures case tlsNegotiating case finished } private var phase: Phase = .starting private var inputStream: InputStream? private var outputStream: OutputStream? private var receivedData = Data() private var sentStreamHeader = false private var sentStartTLS = false private var sawStartTLSOffer = false private var sslContext: SSLContext? private var runLoop: RunLoop? private var finished = false private static let activeLock = NSLock() private static var activeProbes: Set = [] static func run( host: String, port: UInt32, directTLS: Bool, expectedDomain: String, completion: @escaping (TLSProbeResult?) -> Void ) { let probe = TLSVersionProbe( host: host, port: port, directTLS: directTLS, expectedDomain: expectedDomain, completion: completion ) activeLock.lock() activeProbes.insert(probe) activeLock.unlock() probe.start() } private init( host: String, port: UInt32, directTLS: Bool, expectedDomain: String, completion: @escaping (TLSProbeResult?) -> Void ) { self.host = host self.port = port self.directTLS = directTLS self.expectedDomain = expectedDomain self.completion = completion super.init() } private func start() { let thread = Thread { [weak self] in guard let self else { return } self.runLoop = RunLoop.current var readStream: Unmanaged? var writeStream: Unmanaged? CFStreamCreatePairWithSocketToHost( kCFAllocatorDefault, self.host as CFString, self.port, &readStream, &writeStream ) guard let readStream, let writeStream else { self.finish(nil) return } self.inputStream = readStream.takeRetainedValue() self.outputStream = writeStream.takeRetainedValue() self.inputStream?.delegate = self self.outputStream?.delegate = self self.inputStream?.schedule(in: .current, forMode: .default) self.outputStream?.schedule(in: .current, forMode: .default) self.inputStream?.open() self.outputStream?.open() DispatchQueue.global(qos: .utility).asyncAfter(deadline: .now() + 12) { [weak self] in guard let self, !self.finished else { return } self.finish(nil) } RunLoop.current.run() } thread.name = "app.luma.tlsProbe" thread.start() } // MARK: - StreamDelegate func stream(_ aStream: Stream, handle eventCode: Stream.Event) { switch eventCode { case .openCompleted: if phase == .tlsNegotiating { reportTLSResult() } else if directTLS, phase == .starting { configureTLS() } else if !sentStreamHeader { writeStreamHeader() } case .hasSpaceAvailable: if directTLS { return } if !sentStreamHeader { writeStreamHeader() } else if sawStartTLSOffer, !sentStartTLS { writeStartTLS() } case .hasBytesAvailable: readAvailableBytes() case .errorOccurred, .endEncountered: if phase == .tlsNegotiating { reportTLSResult() } else if phase != .finished { finish(nil) } default: break } } // MARK: - Steps private func writeStreamHeader() { guard let outputStream else { return } let header = "" + "" let data = Data(header.utf8) sentStreamHeader = data.count == outputStream.write( [UInt8](data), maxLength: data.count ) if sentStreamHeader, !directTLS { phase = .expectingFeatures } } private func writeStartTLS() { guard let outputStream else { return } let startTLS = "" let data = Data(startTLS.utf8) sentStartTLS = data.count == outputStream.write( [UInt8](data), maxLength: data.count ) } private func readAvailableBytes() { guard let inputStream else { return } var chunk = [UInt8](repeating: 0, count: 4096) while inputStream.hasBytesAvailable { let count = inputStream.read(&chunk, maxLength: chunk.count) guard count > 0 else { break } receivedData.append(contentsOf: chunk[0.. String? { switch protocolVersion.rawValue { case 10: return "TLS 1.3" case 8: return "TLS 1.2" case 7: return "TLS 1.1" case 4: return "TLS 1.0" case 2: return "SSL 3.0" default: return nil } } static func cipherDescription(_ cipher: SSLCipherSuite) -> String { switch cipher { case 0x1301: return "TLS_AES_128_GCM_SHA256" case 0x1302: return "TLS_AES_256_GCM_SHA384" case 0x1303: return "TLS_CHACHA20_POLY1305_SHA256" case 0xC02F: return "TLS_ECDHE_RSA_WITH_AES_128_GCM_SHA256" case 0xC02B: return "TLS_ECDHE_ECDSA_WITH_AES_128_GCM_SHA256" case 0xC030: return "TLS_ECDHE_RSA_WITH_AES_256_GCM_SHA384" case 0xC02C: return "TLS_ECDHE_ECDSA_WITH_AES_256_GCM_SHA384" case 0xCCA8: return "TLS_ECDHE_RSA_WITH_CHACHA20_POLY1305_SHA256" case 0xCCA9: return "TLS_ECDHE_ECDSA_WITH_CHACHA20_POLY1305_SHA256" case 0xCCAA: return "TLS_DHE_RSA_WITH_CHACHA20_POLY1305_SHA256" case 0x009E: return "TLS_DHE_RSA_WITH_AES_128_GCM_SHA256" case 0x009F: return "TLS_DHE_RSA_WITH_AES_256_GCM_SHA384" case 0x009C: return "TLS_RSA_WITH_AES_128_GCM_SHA256" case 0x009D: return "TLS_RSA_WITH_AES_256_GCM_SHA384" case 0xC013: return "TLS_ECDHE_RSA_WITH_AES_128_CBC_SHA" case 0xC014: return "TLS_ECDHE_RSA_WITH_AES_256_CBC_SHA" case 0x003C: return "TLS_RSA_WITH_AES_128_CBC_SHA256" case 0x002F: return "TLS_RSA_WITH_AES_128_CBC_SHA" case 0x0035: return "TLS_RSA_WITH_AES_256_CBC_SHA" default: return String(format: "0x%04X", cipher) } } }