UrlSession with TLS client authentication

I'm using URLSession to make a get request with TLS 1.2 protocol and certificates (which are all self-signed) included in the main bundle. Hopefully I managed to do the pinning but server also requires a client certificate for authentication so I'm trying to respond to the AuthenticationChallenge with UrlCredential but it's not working: i keep getting NSURLErrorDomain Code=-1206 which is "The server “my_server_domain.it” requires a client certificate."


Here is my request:


    func makeGetRequest(){

        let configuration = URLSessionConfiguration.default
        var request = try! URLRequest(url: requestUrl, method: .get)

        let session = URLSession(configuration: configuration,
                                 delegate: self,
                                 delegateQueue: OperationQueue.main)


        let task = session.dataTask(with: request, completionHandler: { (data, response, error) in

            print("Data = \(data)")
            print("Response = \(response)")
            print("Error = \(error)")

        })

        task.resume()

    }


URLSessionDelegate, where I respond to the AuthenticationChallenge:


func urlSession(_ session: URLSession, didReceive challenge: URLAuthenticationChallenge, completionHandler: @escaping (URLSession.AuthChallengeDisposition, URLCredential?) -> Void) {

        let authenticationMethod = challenge.protectionSpace.authenticationMethod
        print("authenticationMethod=\(authenticationMethod)")

        if authenticationMethod == NSURLAuthenticationMethodClientCertificate {

            completionHandler(.useCredential, getClientUrlCredential())

        } else if authenticationMethod == NSURLAuthenticationMethodServerTrust {

            let serverCredential = getServerUrlCredential(protectionSpace: challenge.protectionSpace)
            guard serverCredential != nil else {
                completionHandler(.cancelAuthenticationChallenge, nil)
                return
            }
            completionHandler(.useCredential, serverCredential)
        }

    }


Server certificate pinning:


func getServerUrlCredential(protectionSpace:URLProtectionSpace)->URLCredential?{

        if let serverTrust = protectionSpace.serverTrust {
             //Check if is valid
            var result = SecTrustResultType.invalid
            let status = SecTrustEvaluate(serverTrust, &result)
            print("SecTrustEvaluate res = \(result.rawValue)")

            if(status == errSecSuccess),
                let serverCertificate = SecTrustGetCertificateAtIndex(serverTrust, 0) {
                    //Get Server Certificate Data
                    let serverCertificateData = SecCertificateCopyData(serverCertificate)
                    //Get Local Certificate NSData
                    let localServerCertNSData = certificateHelper.getCertificateNSData(withName: "localServerCertName", andExtension: "cer")
   
                    //Check if certificates are equals, otherwhise pinning fails and return nil
                    guard serverCertificateData == localServerCertNSData else{
                        print("Certificates doesn't match.")
                        return nil
                    }
                    //Certificates does match, so we can trust the server
                    return URLCredential(trust: serverTrust)
            }
        }

        return nil
    }


And here is where i obtain the client URLCredential from the PKCS12 (.pfx) certificate:


func getClientUrlCredential()->URLCredential {
   
        let userCertificate = certificateHelper.getCertificateNSData(withName: "userCertificateName",
                                                                     andExtension: "pfx")
        let userIdentityAndTrust = certificateHelper.extractIdentityAndTrust(fromCertificateData: userCertificate, certPassword: "cert_psw")
        //Create URLCredential
        let urlCredential = URLCredential(identity: userIdentityAndTrust.identityRef,
                                          certificates: userIdentityAndTrust.certArray as [AnyObject],
                                          persistence: URLCredential.Persistence.permanent)
   
        return urlCredential
    }


The func 'extractIdentityAndTrust' -successfully- returns a struct with pointers to identity, certificate-chain and trust extracted from the PKCS12; I know that identity and certificates should be stored in the keychain but at the moment I'm just including them in the bundle.

I've also added App Transport Security Settings to my Info.plist allowing arbitrary loads and configuring an exception domain.


It looks like client doesn't even try to authenticate, so I'm missing something, I guess...

Looking at your handling of

NSURLAuthenticationMethodClientCertificate
, it looks basically OK to me. There’s two things I’d change:
  • Pass

    .forSession
    to the
    persistence
    parameter — A persistence of
    .permanent
    has no effect for TLS credentials, so
    .forSession
    is the closest match to what you’re actually going to get (regardless of what you pass in, credential persistence is determined by the TLS session cache).
  • Pass nil to the

    certificates
    parameter — In most cases the server does not need any intermediate certificates in order to evaluate trust on your client certificate (it either has a fixed list of client certificates, or requires that the client certificate be issued by a specific issuer, in which case it already has any intermediates leading to that issuer), and thus you don’t need to include them. Moreover, I’ve seen some situations where including them actually causes problems.

If you make both of these changes and are still having problems, the next step is to look at things on the ‘wire’, starting with a CFNetwork diagnostic log and then potentially moving on to a packet trace.

Finally, some random notes:

  • Your authentication challenge handler must end with an

    else
    clause that completes unrecognised challenges with
    .performDefaultHandling
    . While this is unlikely to be the cause of your specific issue, its absence may cause other problems down the line.
  • I generally recommend that you avoid overriding server trust evaluation in your app, but instead use a custom certificate authority as outlined in QA1948 HTTPS and Test Servers.

  • I have a test project I use to explore various issues like this here in my office. Pasted in below you’ll find code that mirrors what you’re trying to do. It Works On My Machine™ (-:

    I test this using the TLSTool sample code as my server, run as follows:

    $ TLSTool s_server -accept 12345 -cert sully.local -authenticate require -cacert MouseCA -autorespond NetworkTrips/Resources/OKResponse.txt

    _

Share and Enjoy

Quinn “The Eskimo!”
Apple Developer Relations, Developer Technical Support, Core OS/Hardware

let myEmail = "eskimo" + "1" + "@apple.com"
func didReceive(serverTrustChallenge challenge: URLAuthenticationChallenge, completionHandler: @escaping (URLSession.AuthChallengeDisposition, URLCredential?) -> Void) {
    // We override server trust evaluation (`NSURLAuthenticationMethodServerTrust`) to allow the 
    // server to use a custom root certificate (`MouseCA.cer`).
    let customRoot = Bundle.main.certificate(named: "MouseCA")
    let trust = challenge.protectionSpace.serverTrust!
    if trust.evaluateAllowing(rootCertificates: [customRoot]) { 
        completionHandler(.useCredential, URLCredential(trust: trust))
    } else {
        completionHandler(.cancelAuthenticationChallenge, nil)
    }
}

func didReceive(clientIdentityChallenge challenge: URLAuthenticationChallenge, completionHandler: @escaping (URLSession.AuthChallengeDisposition, URLCredential?) -> Void) {
    // We handle the client identity authentication challenge (`NSURLAuthenticationMethodClientCertificate`) 
    // to give the server our `Frankie.p12` client identity.
    let identity = Bundle.main.identity(named: "Frankie", password: "test")
    completionHandler(.useCredential, URLCredential(identity: identity, certificates: nil, persistence: .forSession))
}

func urlSession(_ session: URLSession, didReceive challenge: URLAuthenticationChallenge, completionHandler: @escaping (URLSession.AuthChallengeDisposition, URLCredential?) -> Void) {

    // Look for specific authentication challenges and dispatch those to various helper methods.
    //
    // IMPORTANT: It's critical that, if you get a challenge you weren't expecting, 
    // you resolve that challenge with `.performDefaultHandling`.

    switch (challenge.protectionSpace.authenticationMethod, challenge.protectionSpace.host) {
        case (NSURLAuthenticationMethodServerTrust, "sully.local"):
            self.didReceive(serverTrustChallenge: challenge, completionHandler: completionHandler)
        case (NSURLAuthenticationMethodClientCertificate, "sully.local"):
            self.didReceive(clientIdentityChallenge: challenge, completionHandler: completionHandler)
        default:
            completionHandler(.performDefaultHandling, nil)
    }
}
extension Bundle {

    func certificate(named name: String) -> SecCertificate {
        let cerURL = self.url(forResource: name, withExtension: "cer")!
        let cerData = try! Data(contentsOf: cerURL)
        let cer = SecCertificateCreateWithData(nil, cerData as CFData)!
        return cer
    }

    func identity(named name: String, password: String) -> SecIdentity {
        let p12URL = self.url(forResource: name, withExtension: "p12")!
        let p12Data = try! Data(contentsOf: p12URL)

        var importedCF: CFArray? = nil
        let options = [kSecImportExportPassphrase as String: password]
        let err = SecPKCS12Import(p12Data as CFData, options as CFDictionary, &importedCF)
        precondition(err == errSecSuccess)
        let imported = importedCF! as NSArray as! [[String:AnyObject]]
        precondition(imported.count == 1)

        return (imported[0][kSecImportItemIdentity as String]!) as! SecIdentity
    }
}
extension SecTrust {

    func evaluate() -> Bool {
        var trustResult: SecTrustResultType = .invalid
        let err = SecTrustEvaluate(self, &trustResult)
        guard err == errSecSuccess else { return false }
        return [.proceed, .unspecified].contains(trustResult)
    }

    func evaluateAllowing(rootCertificates: [SecCertificate]) -> Bool {

        // Apply our custom root to the trust object.

        var err = SecTrustSetAnchorCertificates(self, rootCertificates as CFArray)
        guard err == errSecSuccess else { return false }

        // Re-enable the system's built-in root certificates.

        err = SecTrustSetAnchorCertificatesOnly(self, false)
        guard err == errSecSuccess else { return false }

        // Run a trust evaluation and only allow the connection if it succeeds.

        return self.evaluate()
    }
}

Hi Eskimo,

Hope you are doing well!!

I had implemented similar approach in one of my projects works perfectly fine in mobile application. However, found that the same fails in watchOS .

  1. When try to hit server from watchOS wearable app following error is displayed.

`CredStore - copyIdentPrefs - Error copying Identity cred.  Error=-25300, query={

    class = idnt;

    labl = "https://customeendpointurl:443/";

    "r_Ref" = 1;

}

  1. When I made following change in the project,

configuration.urlCredentialStorage = nil I can see no error.However, api's didn't hit server though.

Thanks in advance.

NSURLSession mutual TLS (mTLS) is tricky on watchOS because all the actual work is done out of process; on watchOS it’s kinda like every session in a background session, and mTLS still doesn’t work for NSURLSession background sessions (r. 19181642).

We have a bug on file for the watchOS aspect of this (r. 24523955) and I just took a quick look and it remains unresolved )-: If you need this feature, please do file your own bug about it. It’ll be dup’d to our existing bug but at least then you’ll be able to see if anything changes.

Share and Enjoy

Quinn “The Eskimo!” @ Developer Technical Support @ Apple
let myEmail = "eskimo" + "1" + "@" + "apple.com"

Thanks Eskimo!

However I would like you to know that our WatchOS app is a companion application to its parent mobile application. WatchConnectivity framework is used to communicate between watch and iPhone App.

Here I would like to know if all the network requests goes through parent app and parent app has TLS Client authentication implemented.

What could be the possible reason for receiving "cred store error" when initiating a request from Watch-app, for which my understanding is when requests goes indirectly through parent mobile app.

Thanks.

So, let’s see if I understand this correctly:

  • You have a watchOS app with an associated iOS app.

  • Those communicate with Watch Connectivity.

  • When the watchOS app wants to talk to your server, it does not do this directly.

  • Rather, it uses Watch Connectivity to request that the iOS app do this on its behalf.

  • The iOS app then experiences this problem.

Is that right?

Share and Enjoy

Quinn “The Eskimo!” @ Developer Technical Support @ Apple
let myEmail = "eskimo" + "1" + "@" + "apple.com"

Thanks for the quick response.

When the watchOS app wants to talk to server, it does this directly.

We are making REST Api calls using URLSession directly from watchOS. TLS Client Authentication both in iOS App and Today Widget works as expected. However, watchOS throws "Cred Store Error" as mentioned.

I am seeing the same Cred Store Error as above. Using .forSession for the persistence param on URLCredentials. Using .none didn't fix the issue. iOS app works perfectly. watchOS app fails every time with the cred store error printed to the console. The watchOS app performs it's own network calls.

Do we have any options here or is mTLS just not supported on watchOS?

… is mTLS just not supported on watchOS?

That’s my understanding based on the bug I referenced above (r. 24523955).

One workaround is to use Watch Connectivity to offload this work to your iOS app, where mTLS is available.

Share and Enjoy

Quinn “The Eskimo!” @ Developer Technical Support @ Apple
let myEmail = "eskimo" + "1" + "@" + "apple.com"

Hi eskimo, we would like our watch app to work without the phone nearby, so offloading the work to the iOS app is not an option for us.

Using console app, I can see three errors:

  1. The CredStore - copyIdentPrefs - Error copying Identity cred. Error=-25300 error described above.
  2. An exception Exception: decodeObjectForKey: Object of class "NSURLCredential" returned nil from -initWithCoder: while being decoded for key <no key>
  3. An error TLS Client Certificates encountered error 1:89

Using console app, I can see three errors:

Right. As I’ve said multiple times, watchOS does not support mTLS in NSURLSession. Your potential list of workarounds are:

  • Using Watch Connectivity to hand off the work to iOS, an option that you’ve already ruled out.

  • Use a lower-level networking API, but that only works if you’re an audio stream app (see Matt’s Low-Level Networking on watchOS post).

  • Change your server to use a different form of authentication.

Share and Enjoy

Quinn “The Eskimo!” @ Developer Technical Support @ Apple
let myEmail = "eskimo" + "1" + "@" + "apple.com"

UrlSession with TLS client authentication
 
 
Q