tls sasl
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

This commit is contained in:
wt committed 2026-09-02 11:46:11 +07:00
1 parent e3fcee3351
commit d4b4c92759
29 files changed
+2177 -61

No files matched your search

@@ -0,0 +1,443 @@
import CryptoKit
import Foundation
import Martin
import OpenSSL
import Security
/// Negotiated TLS parameters of the live connection, captured by the TLS
/// processor and shared with the SASL layer (SCRAM channel binding) and the
/// server-information screen.
struct LumaTLSState: Sendable {
/// "TLSv1.3" / "TLSv1.2" — OpenSSL's SSL_get_version string.
let version: String
/// OpenSSL cipher-suite name, e.g. TLS_AES_256_GCM_SHA384.
let cipher: String
/// DER of the leaf certificate.
let leafCertificateDER: Data
/// RFC 9266 tls-exporter output (32 bytes), when the handshake version
/// supports exporters (TLS 1.3).
let exporter: Data?
}
/// Thread-safe holder of the negotiated TLS state plus the channel-binding
/// types the server advertised in its stream features.
final class LumaChannelBindingStore: @unchecked Sendable {
private let lock = NSLock()
private var tlsState: LumaTLSState?
private var advertisedTypes: Set<String> = []
func setTLSState(_ state: LumaTLSState) {
lock.lock()
tlsState = state
lock.unlock()
}
func setAdvertisedChannelBindingTypes(_ types: Set<String>) {
lock.lock()
advertisedTypes = types
lock.unlock()
}
func reset() {
lock.lock()
tlsState = nil
advertisedTypes = []
lock.unlock()
}
var negotiatedState: LumaTLSState? {
lock.lock()
defer { lock.unlock() }
return tlsState
}
/// The strongest channel-binding type both sides support: RFC 9266
/// tls-exporter first, then tls-server-end-point.
var preferredChannelBindingType: String? {
lock.lock()
defer { lock.unlock() }
guard let tlsState else { return nil }
if advertisedTypes.contains("tls-exporter"), tlsState.exporter != nil {
return "tls-exporter"
}
if advertisedTypes.contains("tls-server-end-point") {
return "tls-server-end-point"
}
return nil
}
/// Channel-binding data for a concrete type: RFC 9266 §4.2 for
/// tls-exporter and §4.3 for tls-server-end-point (SHA-256 of the leaf
/// certificate; RFC 5929's upgrade rules make this correct for the
/// SHA-256-signed certificates used in practice on TLS 1.2 as well).
func channelBindingData(for type: String) -> Data? {
lock.lock()
defer { lock.unlock() }
guard let tlsState else { return nil }
switch type {
case "tls-exporter":
return tlsState.exporter
case "tls-server-end-point":
return Data(SHA256.hash(data: tlsState.leafCertificateDER))
default:
return nil
}
}
var canUseChannelBinding: Bool {
preferredChannelBindingType != nil
}
}
/// Supplies Luma's OpenSSL-based TLS processor to Martin's legacy
/// SocketConnector. SecureTransport caps stream TLS at 1.2 and has no keying
/// exporter, so Luma runs its own TLS 1.3 stack (like Monal) while Martin
/// keeps owning the socket and the XMPP framing.
final class LumaTLSNetworkProcessorProvider: NetworkProcessorProvider {
var providedFeatures: [ConnectorFeature] = [.TLS]
private let channelBindingStore: LumaChannelBindingStore
init(channelBindingStore: LumaChannelBindingStore) {
self.channelBindingStore = channelBindingStore
}
func supply() -> SocketConnector.NetworkProcessor {
LumaTLSNetworkProcessor(channelBindingStore: channelBindingStore)
}
}
/// OpenSSL TLS 1.3 client processor driven by Martin's byte stream:
/// encrypted socket bytes go in through read(data:), plaintext goes out to
/// the connector; plaintext from the connector is encrypted and handed to
/// the socket through the write delegate.
final class LumaTLSNetworkProcessor: SocketConnector.NetworkProcessor, SSLNetworkProcessor {
var serverName: String?
var certificateValidation: SSLCertificateValidation = .default
var certificateValidationFailed: ((SecTrust?) -> Void)?
private let channelBindingStore: LumaChannelBindingStore
private var alpnProtocols: [String] = []
private var sslContext: OpaquePointer?
private var ssl: OpaquePointer?
/// C-string copy of the SNI hostname; OpenSSL keeps the pointer, so the
/// storage must outlive the SSL object.
private var storedServerName: [CChar]?
private var readBIO: OpaquePointer?
private var writeBIO: OpaquePointer?
private var handshakeCompleted = false
private var handshakeFailed = false
private var pendingPlaintext: [UInt8] = []
init(channelBindingStore: LumaChannelBindingStore) {
self.channelBindingStore = channelBindingStore
super.init()
}
deinit {
SSL_free(ssl)
SSL_CTX_free(sslContext)
}
func setALPNProtocols(_ protocols: [String]) {
alpnProtocols = protocols
}
// MARK: - Byte stream
/// Encrypted bytes from the socket.
override func read(data: Data) {
guard !handshakeFailed else { return }
ensureTLS()
guard let ssl, let readBIO else { return }
data.withUnsafeBytes { raw in
_ = BIO_write(readBIO, raw.baseAddress, Int32(raw.count))
}
drive()
}
/// Plaintext to encrypt and send to the socket.
override func write(data: Data, completion: WriteCompletion) {
guard !handshakeFailed else {
completion.completed(result: .failure(LumaTLSProcessorError.handshakeFailed))
return
}
pendingPlaintext.append(contentsOf: data)
ensureTLS()
drive()
completion.completed(result: .success(()))
}
// MARK: - TLS lifecycle
private func ensureTLS() {
guard ssl == nil, !handshakeFailed else { return }
guard let context = SSL_CTX_new(TLS_client_method()) else {
handshakeFailed = true
return
}
sslContext = context
// SSL_CTX_set_min/max_proto_version are C macros, so the Swift
// importer drops them. Drive the underlying control instead; the
// values come from <openssl/tls1.h> (TLS1_2_VERSION 0x0303,
// TLS1_3_VERSION 0x0304) and <openssl/ssl.h>
// (SSL_CTRL_SET_MIN_PROTO_VERSION 123, SSL_CTRL_SET_MAX_PROTO_VERSION
// 124). This pins the connection to TLS 1.2–1.3.
_ = SSL_CTX_ctrl(context, 123, 0x0303, nil)
_ = SSL_CTX_ctrl(context, 124, 0x0304, nil)
// The peer must present a certificate, but OpenSSL's own chain
// verification is disabled: it has no CA store and would reject the
// server mid-handshake with an "unknown ca" alert. Actual trust
// evaluation is performed by Luma after the handshake (SecTrust +
// configured validation), mirroring Martin's custom-validator flow.
SSL_CTX_set_verify(context, SSL_VERIFY_PEER, { _, _ in 1 })
guard let connection = SSL_new(context) else {
handshakeFailed = true
return
}
// This OpenSSL build does not pre-select the connection role in
// SSL_new; without it SSL_do_handshake fails with
// "connection type not set".
SSL_set_connect_state(connection)
ssl = connection
if let serverName {
// SSL_set_tlsext_host_name is a C macro; use SSL_ctrl with
// SSL_CTRL_SET_TLSEXT_HOSTNAME (55) and TLSEXT_NAMETYPE_host_name
// (0) instead. OpenSSL keeps the pointer, so the C string must
// stay alive for the lifetime of the handshake.
storedServerName = Array(serverName.utf8CString)
storedServerName?.withUnsafeMutableBufferPointer { buffer in
_ = SSL_ctrl(connection, 55, 0, buffer.baseAddress)
}
}
if !alpnProtocols.isEmpty {
var wire: [UInt8] = []
for proto in alpnProtocols {
let bytes = Array(proto.utf8)
wire.append(UInt8(bytes.count))
wire.append(contentsOf: bytes)
}
_ = SSL_set_alpn_protos(connection, wire, UInt32(wire.count))
}
let incoming = BIO_new(BIO_s_mem())
let outgoing = BIO_new(BIO_s_mem())
guard let incoming, let outgoing else {
handshakeFailed = true
return
}
readBIO = incoming
writeBIO = outgoing
SSL_set_bio(connection, incoming, outgoing)
}
private func drive() {
guard let ssl else { return }
if !handshakeCompleted, !handshakeFailed {
driveHandshake()
}
if handshakeCompleted, !pendingPlaintext.isEmpty {
// Copy the pending plaintext before the C call: the array cannot
// be mutated while it is borrowed by withUnsafeBufferPointer.
let buffer = pendingPlaintext
let written = buffer.withUnsafeBufferPointer { raw -> Int32 in
guard let base = raw.baseAddress, !raw.isEmpty else { return 0 }
return SSL_write(ssl, base, Int32(raw.count))
}
if written > 0 {
pendingPlaintext.removeFirst(Int(written))
}
}
flushEncryptedOutput()
if handshakeCompleted {
emitDecrypted()
flushEncryptedOutput()
}
}
private func driveHandshake() {
guard let ssl else { return }
let result = SSL_do_handshake(ssl)
if result == 1 {
handshakeCompleted = true
completeHandshake()
return
}
switch SSL_get_error(ssl, result) {
case SSL_ERROR_WANT_READ, SSL_ERROR_WANT_WRITE:
// Progress made; more bytes needed from either side.
break
default:
handshakeFailed = true
certificateValidationFailed?(nil)
}
}
private func flushEncryptedOutput() {
guard let writeBIO else { return }
var buffer = [UInt8](repeating: 0, count: 16_384)
while true {
let count = BIO_read(writeBIO, &buffer, Int32(buffer.count))
guard count > 0 else { break }
super.write(
data: Data(buffer[0..<Int(count)]),
completion: .none
)
}
}
private func emitDecrypted() {
guard let ssl else { return }
var buffer = [UInt8](repeating: 0, count: 65_536)
while true {
let count = SSL_read(ssl, &buffer, Int32(buffer.count))
if count > 0 {
super.read(data: Data(buffer[0..<Int(count)]))
continue
}
switch SSL_get_error(ssl, count) {
case SSL_ERROR_WANT_READ, SSL_ERROR_WANT_WRITE:
return
case SSL_ERROR_ZERO_RETURN:
return
default:
return
}
}
}
// MARK: - Handshake completion
private func completeHandshake() {
guard let ssl else { return }
let version = String(cString: SSL_get_version(ssl))
// SSL_get_cipher_name is a C macro over
// SSL_CIPHER_get_name(SSL_get_current_cipher(_:)).
let cipher: String
if let current = SSL_get_current_cipher(ssl) {
cipher = String(cString: SSL_CIPHER_get_name(current))
} else {
cipher = ""
}
var leafDER: Data?
var chainCertificates: [SecCertificate] = []
if let chain = SSL_get_peer_cert_chain(ssl) {
// sk_X509_num / sk_X509_value are C macros; the imported Swift
// surface exposes OPENSSL_sk_num / OPENSSL_sk_value instead.
let count = OPENSSL_sk_num(chain)
for index in 0..<count {
guard let raw = OPENSSL_sk_value(chain, index) else { continue }
let certificate = OpaquePointer(raw)
guard let der = x509DER(certificate) else { continue }
if leafDER == nil { leafDER = der }
if let secCertificate = SecCertificateCreateWithData(nil, der as CFData) {
chainCertificates.append(secCertificate)
}
}
}
if leafDER == nil, let certificate = SSL_get1_peer_certificate(ssl) {
if let der = x509DER(certificate) {
leafDER = der
if let secCertificate = SecCertificateCreateWithData(nil, der as CFData) {
chainCertificates.insert(secCertificate, at: 0)
}
}
X509_free(certificate)
}
guard let leafDER else {
handshakeFailed = true
certificateValidationFailed?(nil)
return
}
// Certificate trust evaluation via SecTrust, honouring the
// connector's configured validation (Luma uses the custom validator).
var trust: SecTrust?
if !chainCertificates.isEmpty {
SecTrustCreateWithCertificates(chainCertificates as CFArray, nil, &trust)
}
var validated = false
if let trust {
let domain = serverName ?? ""
switch certificateValidation {
case .default:
SecTrustSetPolicies(trust, SecPolicyCreateSSL(true, domain as CFString))
var result = SecTrustResultType.invalid
SecTrustEvaluate(trust, &result)
validated = result == .proceed || result == .unspecified
case .fingerprint(let fingerprint):
validated = Self.matchesFingerprint(leafDER, fingerprint: fingerprint)
case .customValidator(let validator):
validated = validator(trust)
}
}
guard validated else {
handshakeFailed = true
certificateValidationFailed?(trust)
return
}
// RFC 9266 tls-exporter channel binding (TLS 1.3).
var exporter: Data?
var exporterBuffer = [UInt8](repeating: 0, count: 32)
let exporterLabel = "EXPORTER-Channel-Binding"
let exporterResult = exporterLabel.withCString { label in
SSL_export_keying_material(
ssl,
&exporterBuffer,
exporterBuffer.count,
label,
exporterLabel.utf8.count,
nil,
0,
0
)
}
if exporterResult == 1 {
exporter = Data(exporterBuffer)
}
channelBindingStore.setTLSState(
LumaTLSState(
version: version,
cipher: cipher,
leafCertificateDER: leafDER,
exporter: exporter
)
)
}
private func x509DER(_ certificate: OpaquePointer) -> Data? {
// Encode into a fixed scratch buffer: X.509 certificates are far
// smaller than 16 KiB in practice, and this avoids manual OpenSSL
// memory management in Swift.
let length = i2d_X509(certificate, nil)
guard length > 0, length <= 16_384 else { return nil }
var buffer = [UInt8](repeating: 0, count: Int(length))
var copied: Int32 = 0
buffer.withUnsafeMutableBytes { raw in
var cursor = raw.baseAddress?.assumingMemoryBound(to: UInt8.self)
copied = i2d_X509(certificate, &cursor)
}
guard copied == length else { return nil }
return Data(buffer)
}
private static func matchesFingerprint(_ der: Data, fingerprint: String) -> Bool {
let expected = fingerprint
.replacingOccurrences(of: ":", with: "")
.replacingOccurrences(of: " ", with: "")
.lowercased()
let candidates = [
Insecure.SHA1.hash(data: der).map { String(format: "%02x", $0) }.joined(),
SHA256.hash(data: der).map { String(format: "%02x", $0) }.joined(),
]
return candidates.contains(expected)
}
}
enum LumaTLSProcessorError: Error {
case handshakeFailed
}