Skip to content

Commit f682c0e

Browse files
authored
Use URLSessionWebSocketTask for mTLS to support TLS 1.3 (#115)
* Use URLSessionWebSocketTask for mTLS to support TLS 1.3 mTLS WebSocket connections used Starscream's FoundationTransport, which relies on the deprecated SecureTransport/CFStream API. That stack is capped at TLS 1.2, so servers requiring TLS 1.3 reject the handshake (errSSLPeerProtocolVersion, -9836), and it also forces a TLS handshake onto plain-http URLs when a client certificate is configured (errSSLRecordOverflow, -9847). Route mTLS connections through a URLSessionWebSocketTask-based engine instead, which supports TLS 1.3 and presents the client certificate via the standard authentication challenge (the same mechanism the REST API uses). FoundationTransport remains as a fallback below iOS 13. * Add tests for the URLSessionWebSocketTask mTLS engine Cover HAURLSessionWebSocketEngine: the authentication challenge handling (client certificate presentation, server trust evaluation success and failure, and default handling for other methods) plus the engine lifecycle and event broadcasting. * Use regular comments-free fixtures to satisfy docComments lint rule * Cancel and invalidate the URLSession task in the delegate callback test The WebSocket task was created but never resumed or cancelled, which aborts (SIGABRT) when URLSession deallocates it on CI. * Extract testable units to raise mTLS engine coverage Move the WebSocket receive handling into handleReceiveResult and the legacy CFStream fallback into legacyClientCertificateWebSocket, then test both directly. This covers the message-decoding paths (which otherwise require a live server) and the pre-iOS-13 fallback body. * Require iOS 13 and drop the CFStream mTLS fallback URLSessionWebSocketTask (HAURLSessionWebSocketEngine) is available from iOS 13 / macOS 10.15 / tvOS 13 / watchOS 6, so raise the deployment targets to match and remove the FoundationTransport-based fallback that existed only for older versions, along with its now-unused SSL stream configuration helpers. The native engine is now the sole mTLS path, which also removes the platform-gated code that could not be covered on the CI runner. * Fix flaky ThreadSanitizer race in FakeHARequestController testSendSettDataClearsOnCompletion reads requestController.cleared on the callback queue while HAConnectionImpl.sendWrite appends to it on the work queue. ThreadSanitizer flags the data race and aborts (SIGABRT), which intermittently fails the test job and truncates the coverage profdata. Guard the cleared array with a lock. * Extract send completion adapter for deterministic coverage The inline { _ in completion?() } send handlers only ran when a live socket completed a send, so their coverage was flaky. Extract the single adapter into sendCompletion(_:) and cover it directly.
1 parent 56591fb commit f682c0e

7 files changed

Lines changed: 539 additions & 136 deletions

HAKit.podspec

Lines changed: 5 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -8,10 +8,10 @@ Pod::Spec.new do |s|
88
s.license = { type: 'Apache 2', file: 'LICENSE.md' }
99
s.source = { git: 'https://github.com/home-assistant/HAKit.git', tag: s.version.to_s }
1010

11-
s.ios.deployment_target = '12.0'
12-
s.tvos.deployment_target = '12.0'
13-
s.watchos.deployment_target = '5.0'
14-
s.macos.deployment_target = '10.14'
11+
s.ios.deployment_target = '13.0'
12+
s.tvos.deployment_target = '13.0'
13+
s.watchos.deployment_target = '6.0'
14+
s.macos.deployment_target = '10.15'
1515

1616
s.swift_versions = ['5.3']
1717

@@ -38,7 +38,7 @@ Pod::Spec.new do |s|
3838
test_spec.dependency 'HAKit/Core'
3939
test_spec.dependency 'HAKit/PromiseKit'
4040
test_spec.dependency 'HAKit/Mocks'
41-
test_spec.macos.deployment_target = '10.14'
41+
test_spec.macos.deployment_target = '10.15'
4242
test_spec.source_files = 'Tests/*.swift'
4343
end
4444
end

Package.swift

Lines changed: 4 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -6,10 +6,10 @@ import PackageDescription
66
public let package = Package(
77
name: "HAKit",
88
platforms: [
9-
.iOS(.v12),
10-
.macOS(.v10_14),
11-
.tvOS(.v12),
12-
.watchOS(.v5),
9+
.iOS(.v13),
10+
.macOS(.v10_15),
11+
.tvOS(.v13),
12+
.watchOS(.v6),
1313
],
1414
products: [
1515
.library(

Source/HAConnectionInfo.swift

Lines changed: 3 additions & 70 deletions
Original file line numberDiff line numberDiff line change
@@ -1,18 +1,6 @@
11
import Foundation
22
import Starscream
33

4-
#if os(watchOS)
5-
// CFNetwork's CFSocketStream.h — which declares these SSL stream property keys — is not part of the
6-
// watchOS SDK headers, even though the symbols are present in the CFNetwork binary. The keys are
7-
// public, documented CFStrings whose values are equal to their names, so we re-declare them by value
8-
// here to enable client-certificate (mTLS) configuration of the WebSocket stream on watchOS too.
9-
// Kept `internal` (not `private`) so `@testable` watchOS test builds can reference them like the SDK
10-
// constants they stand in for.
11-
internal let kCFStreamSSLCertificates = "kCFStreamSSLCertificates" as CFString
12-
internal let kCFStreamSSLValidatesCertificateChain = "kCFStreamSSLValidatesCertificateChain" as CFString
13-
internal let kCFStreamPropertySSLSettings = "kCFStreamPropertySSLSettings" as CFString
14-
#endif
15-
164
/// Information for connecting to the server
175
public struct HAConnectionInfo: Equatable {
186
/// Thrown if connection info was not able to be created
@@ -132,16 +120,10 @@ public struct HAConnectionInfo: Equatable {
132120
if let engine = engine {
133121
webSocket = WebSocket(request: request, engine: engine)
134122
} else if let clientIdentity = clientIdentity {
135-
// Use FoundationTransport with stream configuration for mTLS
136-
let hasCertEval = evaluateCertificate != nil
137-
let transport = FoundationTransport(
138-
streamConfiguration: Self.makeStreamConfiguration(
139-
clientIdentity: clientIdentity,
140-
disableCertificateChainValidation: hasCertEval
141-
)
123+
let engine = HAURLSessionWebSocketEngine(
124+
clientIdentity: clientIdentity,
125+
evaluateCertificate: evaluateCertificate
142126
)
143-
let pinning = evaluateCertificate.flatMap { HAStarscreamCertificatePinningImpl(evaluateCertificate: $0) }
144-
let engine = WSEngine(transport: transport, certPinner: pinning)
145127
webSocket = WebSocket(request: request, engine: engine)
146128
} else {
147129
let pinning = evaluateCertificate.flatMap { HAStarscreamCertificatePinningImpl(evaluateCertificate: $0) }
@@ -178,55 +160,6 @@ public struct HAConnectionInfo: Equatable {
178160
return components.url!
179161
}
180162

181-
/// Builds the SSL stream settings dictionary for client certificate configuration.
182-
/// - Parameters:
183-
/// - certificateArray: Array of `SecIdentity`/`SecCertificate` for `kCFStreamSSLCertificates`, or nil
184-
/// - disableCertificateChainValidation: Pass true when custom certificate evaluation is in use
185-
/// - Returns: SSL settings dictionary; empty if no configuration is needed
186-
internal static func makeSSLSettings(
187-
certificateArray: CFArray?,
188-
disableCertificateChainValidation: Bool
189-
) -> [String: Any] {
190-
var settings: [String: Any] = [:]
191-
if let certificateArray {
192-
settings[kCFStreamSSLCertificates as String] = certificateArray
193-
}
194-
if disableCertificateChainValidation {
195-
settings[kCFStreamSSLValidatesCertificateChain as String] = false
196-
}
197-
return settings
198-
}
199-
200-
/// Returns a stream configuration closure suitable for use with `FoundationTransport`.
201-
/// - Parameters:
202-
/// - clientIdentity: Provider for the client identity used in mTLS
203-
/// - disableCertificateChainValidation: Pass true when custom certificate evaluation is in use
204-
/// - Returns: A closure that configures SSL settings on the given streams
205-
internal static func makeStreamConfiguration(
206-
clientIdentity: @escaping ClientIdentityProvider,
207-
disableCertificateChainValidation: Bool
208-
) -> (InputStream, OutputStream) -> Void {
209-
{ inStream, outStream in
210-
let certificateArray = clientIdentity().map { [$0] as CFArray }
211-
let sslSettings = makeSSLSettings(
212-
certificateArray: certificateArray,
213-
disableCertificateChainValidation: disableCertificateChainValidation
214-
)
215-
if !sslSettings.isEmpty {
216-
CFReadStreamSetProperty(
217-
inStream,
218-
CFStreamPropertyKey(rawValue: kCFStreamPropertySSLSettings),
219-
sslSettings as CFTypeRef
220-
)
221-
CFWriteStreamSetProperty(
222-
outStream,
223-
CFStreamPropertyKey(rawValue: kCFStreamPropertySSLSettings),
224-
sslSettings as CFTypeRef
225-
)
226-
}
227-
}
228-
}
229-
230163
public static func == (lhs: HAConnectionInfo, rhs: HAConnectionInfo) -> Bool {
231164
lhs.url == rhs.url
232165
}
Lines changed: 147 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,147 @@
1+
import Foundation
2+
import Starscream
3+
4+
/// A Starscream `Engine` backed by `URLSessionWebSocketTask`, used for mTLS connections.
5+
///
6+
/// The URL Loading System supports modern TLS versions and presents the client certificate through
7+
/// the standard authentication challenge, the same path the REST API uses.
8+
internal final class HAURLSessionWebSocketEngine: NSObject, Engine, URLSessionDataDelegate,
9+
URLSessionWebSocketDelegate {
10+
private var task: URLSessionWebSocketTask?
11+
private var session: URLSession?
12+
private weak var delegate: EngineDelegate?
13+
14+
private let clientIdentity: HAConnectionInfo.ClientIdentityProvider?
15+
private let evaluateCertificate: HAConnectionInfo.EvaluateCertificate?
16+
17+
init(
18+
clientIdentity: HAConnectionInfo.ClientIdentityProvider?,
19+
evaluateCertificate: HAConnectionInfo.EvaluateCertificate?
20+
) {
21+
self.clientIdentity = clientIdentity
22+
self.evaluateCertificate = evaluateCertificate
23+
super.init()
24+
}
25+
26+
func register(delegate: EngineDelegate) {
27+
self.delegate = delegate
28+
}
29+
30+
func start(request: URLRequest) {
31+
if session == nil {
32+
session = URLSession(configuration: .default, delegate: self, delegateQueue: nil)
33+
}
34+
task = session?.webSocketTask(with: request)
35+
doRead()
36+
task?.resume()
37+
}
38+
39+
func stop(closeCode: UInt16) {
40+
let closeCode = URLSessionWebSocketTask.CloseCode(rawValue: Int(closeCode)) ?? .normalClosure
41+
task?.cancel(with: closeCode, reason: nil)
42+
}
43+
44+
func forceStop() {
45+
stop(closeCode: UInt16(URLSessionWebSocketTask.CloseCode.abnormalClosure.rawValue))
46+
}
47+
48+
func write(string: String, completion: (() -> Void)?) {
49+
task?.send(.string(string), completionHandler: sendCompletion(completion))
50+
}
51+
52+
func write(data: Data, opcode: FrameOpCode, completion: (() -> Void)?) {
53+
switch opcode {
54+
case .binaryFrame:
55+
task?.send(.data(data), completionHandler: sendCompletion(completion))
56+
case .textFrame:
57+
write(string: String(decoding: data, as: UTF8.self), completion: completion)
58+
case .ping:
59+
task?.sendPing(pongReceiveHandler: sendCompletion(completion))
60+
default:
61+
break
62+
}
63+
}
64+
65+
func sendCompletion(_ completion: (() -> Void)?) -> (Error?) -> Void {
66+
{ _ in completion?() }
67+
}
68+
69+
private func doRead() {
70+
task?.receive { [weak self] result in
71+
self?.handleReceiveResult(result)
72+
}
73+
}
74+
75+
func handleReceiveResult(_ result: Result<URLSessionWebSocketTask.Message, Error>) {
76+
switch result {
77+
case let .success(message):
78+
if case let .string(string) = message {
79+
broadcast(event: .text(string))
80+
} else if case let .data(data) = message {
81+
broadcast(event: .binary(data))
82+
}
83+
doRead()
84+
case let .failure(error):
85+
broadcast(event: .error(error))
86+
}
87+
}
88+
89+
private func broadcast(event: WebSocketEvent) {
90+
delegate?.didReceive(event: event)
91+
}
92+
93+
func urlSession(
94+
_ session: URLSession,
95+
webSocketTask: URLSessionWebSocketTask,
96+
didOpenWithProtocol protocol: String?
97+
) {
98+
broadcast(event: .connected(["Sec-WebSocket-Protocol": `protocol` ?? ""]))
99+
}
100+
101+
func urlSession(
102+
_ session: URLSession,
103+
webSocketTask: URLSessionWebSocketTask,
104+
didCloseWith closeCode: URLSessionWebSocketTask.CloseCode,
105+
reason: Data?
106+
) {
107+
let reasonString = reason.flatMap { String(data: $0, encoding: .utf8) } ?? ""
108+
broadcast(event: .disconnected(reasonString, UInt16(closeCode.rawValue)))
109+
}
110+
111+
func urlSession(_ session: URLSession, task: URLSessionTask, didCompleteWithError error: Error?) {
112+
broadcast(event: .error(error))
113+
}
114+
115+
func urlSession(
116+
_ session: URLSession,
117+
didReceive challenge: URLAuthenticationChallenge,
118+
completionHandler: @escaping (URLSession.AuthChallengeDisposition, URLCredential?) -> Void
119+
) {
120+
switch challenge.protectionSpace.authenticationMethod {
121+
case NSURLAuthenticationMethodClientCertificate:
122+
if let identity = clientIdentity?() {
123+
completionHandler(
124+
.useCredential,
125+
URLCredential(identity: identity, certificates: nil, persistence: .forSession)
126+
)
127+
} else {
128+
completionHandler(.performDefaultHandling, nil)
129+
}
130+
case NSURLAuthenticationMethodServerTrust:
131+
guard let evaluateCertificate, let serverTrust = challenge.protectionSpace.serverTrust else {
132+
completionHandler(.performDefaultHandling, nil)
133+
return
134+
}
135+
evaluateCertificate(serverTrust) { result in
136+
switch result {
137+
case .success:
138+
completionHandler(.useCredential, URLCredential(trust: serverTrust))
139+
case .failure:
140+
completionHandler(.cancelAuthenticationChallenge, nil)
141+
}
142+
}
143+
default:
144+
completionHandler(.performDefaultHandling, nil)
145+
}
146+
}
147+
}

Tests/HAConnectionImpl.test.swift

Lines changed: 12 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1762,6 +1762,8 @@ private class FakeHARequestController: HARequestController {
17621762
weak var delegate: HARequestControllerDelegate?
17631763
var workQueue: DispatchQueue = .main
17641764

1765+
private let lock = NSLock()
1766+
17651767
var added: [HARequestInvocation] = []
17661768
func add(_ invocation: HARequestInvocation) {
17671769
added.append(invocation)
@@ -1810,9 +1812,17 @@ private class FakeHARequestController: HARequestController {
18101812
subscriptions[identifier]
18111813
}
18121814

1813-
var cleared: [HARequestInvocationSingle] = []
1815+
private var _cleared: [HARequestInvocationSingle] = []
1816+
var cleared: [HARequestInvocationSingle] {
1817+
lock.lock()
1818+
defer { lock.unlock() }
1819+
return _cleared
1820+
}
1821+
18141822
func clear(invocation: HARequestInvocationSingle) {
1815-
cleared.append(invocation)
1823+
lock.lock()
1824+
_cleared.append(invocation)
1825+
lock.unlock()
18161826
}
18171827
}
18181828

Tests/HAConnectionInfo.test.swift

Lines changed: 0 additions & 55 deletions
Original file line numberDiff line numberDiff line change
@@ -281,61 +281,6 @@ internal class HAConnectionInfoTests: XCTestCase {
281281
XCTAssertEqual(webSocket.request.url, url.appendingPathComponent("api/websocket"))
282282
}
283283

284-
func testMakeSSLSettingsEmpty() {
285-
let settings = HAConnectionInfo.makeSSLSettings(certificateArray: nil, disableCertificateChainValidation: false)
286-
XCTAssertTrue(settings.isEmpty)
287-
}
288-
289-
func testMakeSSLSettingsWithCertificateArray() {
290-
// CFArray is non-nil even when empty, so the kCFStreamSSLCertificates branch executes
291-
let settings = HAConnectionInfo.makeSSLSettings(
292-
certificateArray: [] as CFArray,
293-
disableCertificateChainValidation: false
294-
)
295-
XCTAssertFalse(settings.isEmpty)
296-
XCTAssertNotNil(settings[kCFStreamSSLCertificates as String])
297-
XCTAssertNil(settings[kCFStreamSSLValidatesCertificateChain as String])
298-
}
299-
300-
func testMakeSSLSettingsDisablesCertificateChainValidation() {
301-
let settings = HAConnectionInfo.makeSSLSettings(certificateArray: nil, disableCertificateChainValidation: true)
302-
XCTAssertFalse(settings.isEmpty)
303-
XCTAssertEqual(settings[kCFStreamSSLValidatesCertificateChain as String] as? Bool, false)
304-
XCTAssertNil(settings[kCFStreamSSLCertificates as String])
305-
}
306-
307-
func testMakeStreamConfigurationWithEmptySettings() {
308-
var inputStream: InputStream?
309-
var outputStream: OutputStream?
310-
Stream.getBoundStreams(withBufferSize: 4096, inputStream: &inputStream, outputStream: &outputStream)
311-
guard let inStream = inputStream, let outStream = outputStream else {
312-
XCTFail("Could not create bound streams")
313-
return
314-
}
315-
// No identity, no cert eval → empty settings → CFStreamSetProperty not called
316-
let config = HAConnectionInfo.makeStreamConfiguration(
317-
clientIdentity: { nil },
318-
disableCertificateChainValidation: false
319-
)
320-
config(inStream, outStream)
321-
}
322-
323-
func testMakeStreamConfigurationAppliesSSLSettings() {
324-
var inputStream: InputStream?
325-
var outputStream: OutputStream?
326-
Stream.getBoundStreams(withBufferSize: 4096, inputStream: &inputStream, outputStream: &outputStream)
327-
guard let inStream = inputStream, let outStream = outputStream else {
328-
XCTFail("Could not create bound streams")
329-
return
330-
}
331-
// No identity, cert eval disabled → non-empty settings → CFStreamSetProperty called
332-
let config = HAConnectionInfo.makeStreamConfiguration(
333-
clientIdentity: { nil },
334-
disableCertificateChainValidation: true
335-
)
336-
config(inStream, outStream)
337-
}
338-
339284
func testCreationWithClientIdentityInternalInit() throws {
340285
let url = try XCTUnwrap(URL(string: "http://example.com/with_client_identity_internal"))
341286
let engine = FakeEngine()

0 commit comments

Comments
 (0)