Search code examples
iosswifturlsessionpublic-key-pinning

URLError from rejecting a URLAuthenticationChallenge is too generic


According to this article when communicating with hardware accessories in your local network over HTTPS securely, you should be pinning your certificates like this:

In the URLSessionDelegate implement this method:

func urlSession(_ session: URLSession, didReceive challenge: URLAuthenticationChallenge) async -> (URLSession.AuthChallengeDisposition, URLCredential?)

Then do your verification via SecTrustEvaluateWithError and return (.useCredential, URLCredential(trust: trust)) if it was successful and (.cancelAuthenticationChallenge, nil) if it was not.

However, the error returned when rejecting the challenge in try await URLSession(...).data(from:) is a URLError with the code -999 which is URLError.Code.cancelled.

How would I distinguish between other cancellations, so I could tell the user that the request failed because of an invalid certificate? The documentation of URLError.Code.cancelled is very generic, so I cannot solely rely on this error (or can I?).

This article suggests that we should be showing the error right in the URLSessionDelegate which does not seem like a good solution to me since user interface code would leak into networking code.

Are there any other techniques, I could apply here to specifically catch the error from URLSession when the certificate was declined by my own validation logic?


Solution

  • You need to solve this issue with a custom "HTTPClient" which internally uses a URLSession, and a URLSession API where you can specify a task specific delegate.

    The idea is, to use a dedicated task specific delegate, where you store the error of that request. Then, later when the URLSession data tasks returns with a cancellation error, check the delegate's error property if there is more information.

    The below code give you an idea to start with:

    First, you need a class conforming to URLSessionTaskDelegate, which can be instantiated as a separate object. In your case it specifies delegate method which handles the authentication challenge.

    Define the delegate:

    final class AuthenticationChallengeHandler: NSObject, URLSessionTaskDelegate  {
        
        struct Error: LocalizedError {
            var errorDescription: String? {
                "server authentication failed"
            }
        }
        
        init(expectedCertificate: SecCertificate? = nil) {
            self.expectedCertificate = expectedCertificate
        }
       
        private var expectedCertificate: SecCertificate?
        
        
        var error: Error?
    
        func shouldAllowHTTPSConnection(trust: SecTrust) async -> Bool {
            self.error = Error()
            return false
        }
    
        
        func urlSession(
            _ session: URLSession,
            didReceive challenge: URLAuthenticationChallenge
        ) async -> (URLSession.AuthChallengeDisposition, URLCredential?) {
            switch challenge.protectionSpace.authenticationMethod {
            case NSURLAuthenticationMethodServerTrust:
                let trust = challenge.protectionSpace.serverTrust!
                guard await self.shouldAllowHTTPSConnection(trust: trust) else {
                    return (.cancelAuthenticationChallenge, nil)
                }
                let credential = URLCredential(trust: trust)
                return (.useCredential, credential)
            default:
                return (.performDefaultHandling, nil)
            }
        }
        
    }
    

    Note, that the delegate has a property error.

    Second, you want to use a dedicated instance of this delegate for executing a single request. Here, you can use the following URLSession APIs which have a parameter for a dedicated URLSessionTaskDelegate. For example:

        /// Convenience method to load data using a URL, creates and 
        /// resumes a URLSessionDataTask internally.
        ///
        /// - Parameter url: The URL for which to load data.
        /// - Parameter delegate: Task-specific delegate.
        /// - Returns: Data and response.
        public func data(
            from url: URL, 
            delegate: (URLSessionTaskDelegate)? = nil
        ) async throws -> (Data, URLResponse)
    
    

    You can then use it like below:

    
        func testExample() async throws {
            let url = URL(string: "https://www.example.com")!
            let delegate = AuthenticationChallengeHandler()
            do {
                let (data, response) = try await URLSession.shared.data(
                   from: url, 
                   delegate: delegate
                )
            } catch {
                print(error.localizedDescription)
                print(delegate.error?.localizedDescription)
            }
            
        }
    

    Of course, you should improve this above code in your custom "HTTPClient" implementation.