Jim Zajkowski

Keychain client https certificates and URLSession with Swift 5

Apr 15, 2024

We issue client identity certificates to our Mac fleet with MDM; we also want to use those identifies with our own client-side tools and backends.

I didn’t find a good end-to-end example, just snippets of partial answers on Stack Overflow or the Apple Developer forums, so I hope this code is useful for someone else.

KeychainCertificateDelegate is a URLSession delegate that finds the correct identity by handling urlSession:didReceive:completionHandler:

KeychainCertificateDelegate
 1class KeychainCertificateDelegate: NSObject, URLSessionDelegate {
 2    func urlSession(_: URLSession, didReceive challenge: URLAuthenticationChallenge, completionHandler: @escaping (URLSession.AuthChallengeDisposition, URLCredential?) -> Void) {
 3        if challenge.protectionSpace.authenticationMethod == NSURLAuthenticationMethodClientCertificate {
 4            // Get the DNs the server will accept
 5            guard let expectedDNs = challenge.protectionSpace.distinguishedNames else {
 6                completionHandler(.cancelAuthenticationChallenge, nil)
 7                return
 8            }
 9
10            // Ask Keychain to search, based on the DNs the server presented on its cert
11            var identityRefs: CFTypeRef? = nil
12            let err = SecItemCopyMatching([
13                kSecClass: kSecClassIdentity,
14                kSecMatchLimit: kSecMatchLimitAll,
15                kSecMatchIssuers: expectedDNs,
16                kSecReturnRef: true,
17            ] as NSDictionary, &identityRefs)
18
19            // can't get keychain certs, get out
20            if err != errSecSuccess {
21                completionHandler(.cancelAuthenticationChallenge, nil)
22                return
23            }
24
25            // just use the first identity
26            guard let identities = identityRefs as? [SecIdentity],
27                  let identity = identities.first
28            else {
29                completionHandler(.cancelAuthenticationChallenge, nil)
30                return
31            }
32
33            // DEBUGGING -- obtain the cert details to print it out
34            //              you can just delete this entire block
35            var certificateRef: SecCertificate?
36            let status = SecIdentityCopyCertificate(identity, &certificateRef)
37            guard status == errSecSuccess, let certificateRef
38            else {
39                completionHandler(.cancelAuthenticationChallenge, nil)
40                return
41            }
42            debugPrint(certificateRef)
43            // END DEBUG
44
45            let credential = URLCredential(identity: identity, certificates: nil, persistence: .forSession)
46            completionHandler(.useCredential, credential)
47            return
48
49        } else {
50            // Handle any other kinds of authentication challenge
51            completionHandler(.performDefaultHandling, nil)
52        }
53    }
54}

KeychainCertificateDelegate obtains the list of valid issuers from the server, using the challenge.protectedSpace.distinguishedNames attribute. Then, we ask Keychain for a matching SecIdentity using SecItemCopyMatching(). Finally, we wrap that identity in a URLCredential and call the completion handler.

Using this delegate is relatively simple from a command line tool - just create a new instance of KeychainCertificateDelegate and set it as your delegate (lines 6-8) when creating a new URLSession, and then call that new session for your requests.

 1main
 2struct HelloWorld {
 3    static func main() async throws {
 4        let url = URL(string: "https://some-server.goes.here")!
 5
 6        let config = URLSessionConfiguration.default
 7        let keyDelegate = KeychainCertificateDelegate()
 8        let session = URLSession(configuration: config, delegate: keyDelegate, delegateQueue: nil)
 9
10        let (data, response) = try await session.data(from: url)
11
12        guard let httpResponse = response as? HTTPURLResponse,
13              (200 ... 299).contains(httpResponse.statusCode)
14        else {
15            throw URLError(.badServerResponse)
16        }
17
18        print(String(data: data, encoding: .utf8)!)
19    }
20}

Hope this helps someone else!