Add pem certificate in URLSession to call Phillips Hue device API

Accidentally close: https://developer.apple.com/forums/thread/707263

I want to connect to a Phillips Hue Api (something like that: https://<ip_address>/clip/v2/ressource/device).

The issue is that on the software Postman (to test), Phillips Hue says to disable "SSL Certificate Verification".

When I try to call with URLRequest in my app the same url, using URLSessions, I get this error:

Domain=kCFErrorDomainCFNetwork Code=-1202 NSLocalizedDescription=The certificate for this server is invalid. You might be connecting to a server that is pretending to be “<ip_address_of_Hue_bridge>” which could put your confidential information at risk.

I read those articles:

I don't want to disable all HTTPS server trust evaluation, it’s super insecure. I want to customise the trust evaluation to let the connection through to be as secure as possible.

But I don't really know how to do this with URLSession or another thing if needed. Phillips Hue is giving what looks like a pem certificate (see in attachements). I don't know how to implement this in my URLSession request.

Thank you for your help

Answered by Antoinette in 717375022

Here a code I implemented and working:

class NSURLSessionPinningDelegate: NSObject, URLSessionDelegate {
    let certFileName = "phillips-hue-cert"
    let certFileType = "pem"
    func urlSession(_ session: URLSession,
                      didReceive challenge: URLAuthenticationChallenge,
                      completionHandler: @escaping (URLSession.AuthChallengeDisposition, URLCredential?) -> Swift.Void) {
        if (challenge.protectionSpace.authenticationMethod == NSURLAuthenticationMethodServerTrust) {
            if let serverTrust = challenge.protectionSpace.serverTrust {
                var secresult = SecTrustResultType.invalid
                let status = SecTrustEvaluate(serverTrust, &secresult)
                if(errSecSuccess == status) {
                    if let serverCertificate = SecTrustGetCertificateAtIndex(serverTrust, 0) {
                        let serverCertificateData = SecCertificateCopyData(serverCertificate)
                        let data = CFDataGetBytePtr(serverCertificateData);
                        let size = CFDataGetLength(serverCertificateData);
                        let certificateOne = NSData(bytes: data, length: size)
                        let filePath = Bundle.main.path(forResource: self.certFileName,
                                                             ofType: self.certFileType)
                        if let file = filePath {
                            if let certificateTwo = NSData(contentsOfFile: file) {
                                    completionHandler(URLSession.AuthChallengeDisposition.useCredential,
                                                      URLCredential(trust:serverTrust))
                                    return
                            }
                        }
                    }
                }
            }
        }
        completionHandler(URLSession.AuthChallengeDisposition.cancelAuthenticationChallenge, nil)
    }
}

Then I replaced UrlSession by:

let session = URLSession(
                        configuration: URLSessionConfiguration.ephemeral,
                        delegate: NSURLSessionPinningDelegate(),
                        delegateQueue: OperationQueue.main)
        session.dataTask(with: request) { (data, response, error) in .......

That's working for me. I hope it's a good solution and a secured one. It looks good to me. If anyone as a better solution, I will be happy to hear about it.

OK, that certificate is a custom certificate authority (CA) root, that is, it’s self signed and indicates that it’s a CA in the Basic Constraints extension. Presumably the certificates returned by the accessory was issued by that CA. To talk to the accessory you’ll need to do the following:

  • Override HTTPS server trust evaluation for your session, as discussed in the NSURLSession section of Technote 2232 HTTPS Server Trust Evaluation.

  • Add the CA’s certificate to the trust object, as per the Custom Certificate Authority section of that technote.

  • Fix the name used in the trust evaluation to match the name in the accessory’s certificate. See Server Name Failures of that technote.

  • Evaluate the trust object, and then either allow or deny the connection based on that result.

Share and Enjoy

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

Okay thank you for your reply and those informations! I’ll try that today. I will let you know

Accepted Answer

Here a code I implemented and working:

class NSURLSessionPinningDelegate: NSObject, URLSessionDelegate {
    let certFileName = "phillips-hue-cert"
    let certFileType = "pem"
    func urlSession(_ session: URLSession,
                      didReceive challenge: URLAuthenticationChallenge,
                      completionHandler: @escaping (URLSession.AuthChallengeDisposition, URLCredential?) -> Swift.Void) {
        if (challenge.protectionSpace.authenticationMethod == NSURLAuthenticationMethodServerTrust) {
            if let serverTrust = challenge.protectionSpace.serverTrust {
                var secresult = SecTrustResultType.invalid
                let status = SecTrustEvaluate(serverTrust, &secresult)
                if(errSecSuccess == status) {
                    if let serverCertificate = SecTrustGetCertificateAtIndex(serverTrust, 0) {
                        let serverCertificateData = SecCertificateCopyData(serverCertificate)
                        let data = CFDataGetBytePtr(serverCertificateData);
                        let size = CFDataGetLength(serverCertificateData);
                        let certificateOne = NSData(bytes: data, length: size)
                        let filePath = Bundle.main.path(forResource: self.certFileName,
                                                             ofType: self.certFileType)
                        if let file = filePath {
                            if let certificateTwo = NSData(contentsOfFile: file) {
                                    completionHandler(URLSession.AuthChallengeDisposition.useCredential,
                                                      URLCredential(trust:serverTrust))
                                    return
                            }
                        }
                    }
                }
            }
        }
        completionHandler(URLSession.AuthChallengeDisposition.cancelAuthenticationChallenge, nil)
    }
}

Then I replaced UrlSession by:

let session = URLSession(
                        configuration: URLSessionConfiguration.ephemeral,
                        delegate: NSURLSessionPinningDelegate(),
                        delegateQueue: OperationQueue.main)
        session.dataTask(with: request) { (data, response, error) in .......

That's working for me. I hope it's a good solution and a secured one. It looks good to me. If anyone as a better solution, I will be happy to hear about it.

There’s one big picture problem here: When you get an authentication challenge you don’t care about, you must resolve it using .performDefaultHandling. Using .cancelAuthenticationChallenge will result in the connection failing unnecessarily if the system sends you a new type of challenge. For an outline of how to set up your challenge handler correctly, see the first snippet in TLS For Accessory Developers

Beyond that, your code to evaluate trust — the code equivalent to shouldAllowHTTPSConnection(trust:) in my snippet — has problems big and small. Let’s start with the small ones:

  • It’s not entirely clear why you’re calling SecTrustEvaluate up front. That’s always going to succeed and you never look at the trust result (secreult).

  • data and size are unnecessary. You can write this:

    let certificateOne = SecCertificateCopyData(serverCertificate) as Data
    
  • It’s better to use URLs rather than paths.

  • And Data rather than NSData.

  • There’s no point checking filePath for nil. The only reason that can happen is if your app is built incorrectly, so you might as well force unwrap and learn about the problem early.

The big one is that you’re not actually increasing security. You create certificateOne and certificateTwo but you never use them. What you want is something like this:

func shouldAllowHTTPSConnection(chain: [SecCertificate]) async throws -> Bool {
    let anchor = Bundle.main.certificateNamed("phillips-hue-cert")!
    let policy = SecPolicyCreateBasicX509()
    let trust = try secCall { SecTrustCreateWithCertificates(chain as NSArray, policy, $0) }
    let err = SecTrustSetAnchorCertificates(trust, [anchor] as NSArray)
    guard err == errSecSuccess else { throw NSError(domain: NSOSStatusErrorDomain, code: Int(err), userInfo: nil) }
    return SecTrustEvaluateWithError(trust, nil)
}

func shouldAllowHTTPSConnection(trust: SecTrust) async -> Bool {
    guard let chain = SecTrustCopyCertificateChain(trust) as? [SecCertificate] else { return false }
    do {
        return try await shouldAllowHTTPSConnection(chain: chain)
    } catch {
        return false
    }
}

This allows the connection if and only if the server has a certificate that was issued by the phillips-hue-cert root. The use of the basic X.509 policy means that it ignores any name info in the server’s certificate.

This code relies on two helpers:

Share and Enjoy

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


IMPORTANT The following requires the certificate to be in DER format. You can convert your PEM to DER using the openssl command-line tool.

extension Bundle {
    
    func certificateNamed(_ name: String) -> SecCertificate? {
        guard
            let certURL = self.url(forResource: name, withExtension: "cer"),
            let certData = try? Data(contentsOf: certURL),
            let cert = SecCertificateCreateWithData(nil, certData as NSData)
        else {
            return nil
        }
        return cert
    }
}

I'm also a novice with TLS and struggling with this interface. Following Quinn's example code I ended up with this for the session delegate:

import Foundation

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

        if (challenge.protectionSpace.authenticationMethod == NSURLAuthenticationMethodServerTrust) {
            if let serverTrust = challenge.protectionSpace.serverTrust {
                Task {
                    let result = await shouldAllowHTTPSConnection(trust: serverTrust)
                    if result == true {
                        completionHandler(URLSession.AuthChallengeDisposition.useCredential, URLCredential(trust: serverTrust))
                    } else {
                        completionHandler(URLSession.AuthChallengeDisposition.cancelAuthenticationChallenge, nil)
                    }
                }
            }
        }
    }

    func shouldAllowHTTPSConnection(chain: [SecCertificate]) async throws -> Bool {
        let anchor = Bundle.module.certificateNamed("phillips-hue-cert")!
        let policy = SecPolicyCreateBasicX509()
        let trust = try secCall { SecTrustCreateWithCertificates(chain as NSArray, policy, $0) }
        let err = SecTrustSetAnchorCertificates(trust, [anchor] as NSArray)
        guard err == errSecSuccess else {
            throw NSError(domain: NSOSStatusErrorDomain, code: Int(err), userInfo: nil)
        }

        var secresult: CFError? = nil
        let status = SecTrustEvaluateWithError(trust, &secresult)
        return status 
    }

    func shouldAllowHTTPSConnection(trust: SecTrust) async -> Bool {
        guard let chain = SecTrustCopyCertificateChain(trust) as? [SecCertificate] else { return false }
        do {
            return try await shouldAllowHTTPSConnection(chain: chain)
        } catch {
            return false
        }
    }

    func secCall<Result>(_ body: (_ resultPtr: UnsafeMutablePointer<Result?>) -> OSStatus  ) throws -> Result {
        var result: Result? = nil
        let err = body(&result)

        guard err == errSecSuccess else {
            throw NSError(domain: NSOSStatusErrorDomain, code: Int(err), userInfo: nil)
        }
        return result!
    }
}

extension Bundle {
    func certificateNamed(_ name: String) -> SecCertificate? {
        guard
            let certURL = self.url(forResource: name, withExtension: "cer"),
            let certData = try? Data(contentsOf: certURL),
            let cert = SecCertificateCreateWithData(nil, certData as NSData)
        else {
            return nil
        }
        return cert
    }
}

The delegate is called as expected and the result from shouldAllowHTTPSConnection is true!.
My URLSession is set up as:

        let session = URLSession(
            configuration: URLSessionConfiguration.default,
                                delegate: NSURLSessionPinningDelegate(),
                                delegateQueue: OperationQueue.main)

BUT a call to let (data, response) = try await session.data(for: request, delegate: nil) returns a 403 status. I'm at a loss how to debug that as it looks fine up to that point. Clearly I'm doing something wrong with the certificates (I suspect with the delegate callback) but I've no idea what...

Doh! The certificate processing was working properly, my issue was with the Hue device username - I should have realised that the Hue bridge was actually replying to my request (albeit with the 403!). Thanks to Quinn for the great advice and references - I've learned a lot about TLS!

Add pem certificate in URLSession to call Phillips Hue device API
 
 
Q