Files
Luma/Sources/Shared/XMPP/TLSVersionProbe.swift
T
wt d4b4c92759
iOS CI / Build and Test SwiftUI App (push) Canceled after 0s
Build Unsigned iOS and macOS Apps / Build Unsigned iOS IPA (push) Canceled after 0s
Build Unsigned iOS and macOS Apps / Build macOS ZIP (push) Canceled after 0s
tls sasl
2026-09-02 11:46:11 +07:00

305 lines
11 KiB
Swift

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 + <starttls/>) 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<TLSVersionProbe> = []
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<CFReadStream>?
var writeStream: Unmanaged<CFWriteStream>?
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 =
"<?xml version='1.0'?>"
+ "<stream:stream to='\(expectedDomain)' xmlns='jabber:client' "
+ "xmlns:stream='http://etherx.jabber.org/streams' version='1.0'>"
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 = "<starttls xmlns='urn:ietf:params:xml:ns:xmpp-tls'/>"
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..<count])
}
guard phase == .expectingFeatures else { return }
let text = String(decoding: receivedData, as: UTF8.self)
if text.contains("<proceed") {
configureTLS()
} else if text.contains("starttls") {
sawStartTLSOffer = true
if !sentStartTLS { writeStartTLS() }
}
}
private func configureTLS() {
guard let inputStream, let outputStream else {
finish(nil)
return
}
let settings: [String: Any] = [
kCFStreamSSLValidatesCertificateChain as String: false,
kCFStreamSSLPeerName as String: expectedDomain,
kCFStreamSSLLevel as String: StreamSocketSecurityLevel.negotiatedSSL,
]
inputStream.setProperty(
settings,
forKey: Stream.PropertyKey(kCFStreamPropertySSLSettings as String)
)
outputStream.setProperty(
settings,
forKey: Stream.PropertyKey(kCFStreamPropertySSLSettings as String)
)
if let rawContext = outputStream.property(forKey: Stream.PropertyKey(
rawValue: kCFStreamPropertySSLContext as String
)) {
let context = rawContext as! SSLContext
SSLSetALPNProtocols(context, ["xmpp-client"] as CFArray)
sslContext = context
}
phase = .tlsNegotiating
inputStream.close()
outputStream.close()
inputStream.open()
outputStream.open()
}
private func reportTLSResult() {
guard phase != .finished else { return }
phase = .finished
var protocolVersion = SSLProtocol(rawValue: 0)!
var cipher: SSLCipherSuite = 0
if let sslContext {
SSLGetNegotiatedProtocolVersion(sslContext, &protocolVersion)
if SSLGetNegotiatedCipher(sslContext, &cipher) != errSecSuccess {
cipher = 0
}
}
guard let versionDescription = Self.protocolDescription(protocolVersion) else {
finish(nil)
return
}
finish(TLSProbeResult(
version: versionDescription,
cipher: Self.cipherDescription(cipher)
))
}
private func finish(_ result: TLSProbeResult?) {
guard !finished else { return }
finished = true
phase = .finished
inputStream?.close()
outputStream?.close()
inputStream = nil
outputStream = nil
completion(result)
if let runLoop {
runLoop.perform { CFRunLoopStop(CFRunLoopGetCurrent()) }
}
Self.activeLock.lock()
Self.activeProbes.remove(self)
Self.activeLock.unlock()
}
// MARK: - Descriptions
/// The protocol constants are marked deprecated on modern SDKs, so match
/// the raw SecureTransport values instead: TLS 1.3 = 10, TLS 1.2 = 8,
/// TLS 1.1 = 7, TLS 1.0 = 4, SSL 3.0 = 2.
static func protocolDescription(_ protocolVersion: SSLProtocol) -> 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)
}
}
}