Certificate trust prompt with URLSession?

I am building a small macOS app to connect to a web API on a server made by another company. This is an entirely new application. I don't have beta access, so I can only target up to 10.15.5.

I also don't have control over the server(s) my application will connect to. This server application generates self-signed certificates for the web API, and the vendor doesn't support using a certificate signed by a CA (even a private one). For now, I'm using code like this to hard-code a set of IPs my application trusts implicitly:

Code Block
class cpmSessionDelegate:NSObject, URLSessionDelegate {
let trustedHosts:[String] = ["10.20.30.40"]
func urlSession(_ session: URLSession,
didReceive challenge: URLAuthenticationChallenge,
completionHandler: @escaping (URLSession.AuthChallengeDisposition, URLCredential?) -> Void) {
switch challenge.protectionSpace.authenticationMethod {
case NSURLAuthenticationMethodServerTrust:
if trustedHosts.contains(challenge.protectionSpace.host) {
let credential = URLCredential(trust:challenge.protectionSpace.serverTrust!)
completionHandler(Foundation.URLSession.AuthChallengeDisposition.useCredential,credential)
}
default:
challenge.sender!.continueWithoutCredential(for: challenge)
}
}
}

This is clearly terrible. Is there a way to tell URLSession I want it to present untrusted certificates to the user and ask for a trust decision? I haven't run across anything like that, but I might be missing it.

If I can't get the system to handle the certificate display and trust prompt, I know I can add certificates to the keychain and set trust properties myself. Is there a standard control to use to show the certificate to the user for verification? Or do I need to use SecCertificateCopyValues and build my own UI in an NSAlert or something?

Accepted Reply

Is there a way to tell URLSession I want it to present untrusted certificates to the user and ask for a trust decision?

There is no API to display the contents of a certificate to a user, this could be something that you build yourself once the contents of the certificate is parsed. Keep in mind though that this delegate is operating during a time sensitive context. If the user takes too long to make a decision the request does run the risk of timing out.

To further interrogate the certificate you could dig into the SecTrust provided by the challenge protection space. A rough example of this could include creating a SSL SecPolicy and setting it on SecTrust to evaluate the SSL certificate chain with SecTrustEvaluateWithError. Based on the result of this you could further dig into this result and the properties of the certificate to make a trust decision.


Matt Eaton
DTS Engineering, CoreOS
meaton3@apple.com

Replies

Is there a way to tell URLSession I want it to present untrusted certificates to the user and ask for a trust decision?

There is no API to display the contents of a certificate to a user, this could be something that you build yourself once the contents of the certificate is parsed. Keep in mind though that this delegate is operating during a time sensitive context. If the user takes too long to make a decision the request does run the risk of timing out.

To further interrogate the certificate you could dig into the SecTrust provided by the challenge protection space. A rough example of this could include creating a SSL SecPolicy and setting it on SecTrust to evaluate the SSL certificate chain with SecTrustEvaluateWithError. Based on the result of this you could further dig into this result and the properties of the certificate to make a trust decision.


Matt Eaton
DTS Engineering, CoreOS
meaton3@apple.com
A rough example could look something like:

Code Block
func urlSession(_ session: URLSession, didReceive challenge: URLAuthenticationChallenge,
completionHandler: @escaping (URLSession.AuthChallengeDisposition, URLCredential?) -> Void) {
if challenge.protectionSpace.authenticationMethod == NSURLAuthenticationMethodServerTrust {
if let secTrustRef = challenge.protectionSpace.serverTrust {
let host = challenge.protectionSpace.host
let sslPolicy = SecPolicyCreateSSL(true, host as CFString)
/* Set SSL Policy */
SecTrustSetPolicies(secTrustRef, sslPolicy)
var error: CFError?
if !SecTrustEvaluateWithError(secTrustRef, &error) {
print("SecTrustEvaluateWithError failed: \(error.debugDescription)")
/* Decide whether to do further evaluation on the certificate or fail immediately
completionHandler(.cancelAuthenticationChallenge, nil) */
}
/* SecTrustCopyResult. (See Below) */
}
}
completionHandler(.cancelAuthenticationChallenge, nil)
}


Code Block swift
/* Perform further evaluation to decide whether the certificate can be trusted */
if let trustDetails = SecTrustCopyResult(secTrustRef) as? [String: AnyObject] {
/* Check trustDetails[kSecTrustCertificateTransparency as String] */
/* Check trustDetails[kSecTrustEvaluationDate as String] */
/* Check trustDetails[kSecTrustExtendedValidation as String] */
/* Check trustDetails[kSecTrustOrganizationName as String] */
/* Check trustDetails[kSecTrustResultValue as String] */
/* Check trustDetails[kSecTrustRevocationChecked as String] */
/* Check trustDetails[kSecTrustRevocationValidUntilDate as String] */
if let propertiesRef = SecTrustCopyProperties(secTrustRef) as [AnyObject]? {
/*
SecTrustCopyProperties: [{
error = "No error.";
title = "www.apple.com";
}, {
title = "DigiCert SHA2 Extended Validation Server CA";
}, {
title = "DigiCert High Assurance EV Root CA";
}]
*/
print("SecTrustCopyProperties: \(propertiesRef.debugDescription)")
var policyRef: CFArray?
SecTrustCopyPolicies(secTrustRef, &policyRef)
guard let policyArray = policyRef as? [SecPolicy],
policyArray.count > 0 else {
completionHandler(.cancelAuthenticationChallenge, nil)
return
}
/* [<SecPolicy: oid: 1.2.840.113635.100.1.3 name: sslServer options { ... } */
print("policyArray: \(policyArray.debugDescription)")
let firstPolicyRef = policyArray[0]
/* <SecPolicy: oid: 1.2.840.113635.100.1.3 name: sslServer options { ... } */
print("firstPolicyRef: \(String(describing: firstPolicyRef))")
if let firstPolicyRefDictionary =
SecPolicyCopyProperties(firstPolicyRef) as? [String: AnyObject] {
/* ["SecPolicyName": www.apple.com, "SecPolicyOid": 1.2.840.113635.100.1.3] */
print("First Policy Ref: \(firstPolicyRefDictionary.debugDescription)")
}
/* Capture the certificate information and make an informed decision
on whether trust should proceed or not. */
completionHandler(.performDefaultHandling, nil)
return
}
}




Matt Eaton
DTS Engineering, CoreOS
meaton3@apple.com

There is no API to display the contents of a certificate to a user, this could be something that you build yourself once the contents of the certificate is parsed.

That's what I figured. Seems like a relatively uncommon need.

Keep in mind though that this delegate is operating during a time sensitive context. If the user takes too long to make a decision the request does run the risk of timing out.

Makes sense. I'll have to experiment to find the best way to handle the timing.

Thanks for the help and the example code!
I wanted to follow up on my previous statement.

There is no API to display the contents of a certificate to a user, this could be something that you build yourself once the contents of the certificate is parsed.

There is SFCertificatePanel and SFCertificateView that you can take a look at.

Matt Eaton
DTS Engineering, CoreOS
meaton3@apple.com
While adding some tests, I realized I've been overcomplicating this. Specifically, urlSession(session: didReceive challenge: completionHandler:) gets called for all certificate validation, even for certificates which are trusted (I hadn't tried with trusted certs before). Writing my own is not the right way to handle certificate validation for my application. If you don't set a custom delegate, the application will trust all the certificates Safari trusts.

If the certificate isn't trusted yet, you will get a series of errors like this:

Connection 1: default TLS Trust evaluation failed(-9807)
Connection 1: TLS Trust encountered error 3:-9807
Connection 1: encountered error(3:-9807)
Connection 1: unable to determine interface type without an established connection
Task <F4695366-AE0C-41D9-A3AA-CEA0682E6413>.<1> HTTP load failed, 0/0 bytes (error code: -1202 [3:-9807])
Task <F4695366-AE0C-41D9-A3AA-CEA0682E6413>.<1> finished with error [-1202] Error Domain=NSURLErrorDomain Code=-1202 "The certificate for this server is invalid. You might be connecting to a server that is pretending to be “<destination>” which could put your confidential information at risk." UserInfo={NSLocalizedRecoverySuggestion=Would you like to connect to the server anyway?, _kCFStreamErrorDomainKey=3, NSErrorPeerCertificateChainKey=(
"<cert(0x7f91cd0a5600) s: <cert name> i: <cert name>>"
), NSErrorClientCertificateStateKey=0, NSErrorFailingURLKey=https://<destination>/<path>, NSErrorFailingURLStringKey=https://<destination>/<path>, NSUnderlyingError=0x600003d7eb50 {Error Domain=kCFErrorDomainCFNetwork Code=-1202 "(null)" UserInfo={_kCFStreamPropertySSLClientCertificateState=0, kCFStreamPropertySSLPeerTrust=<SecTrustRef: 0x6000001e4510>, _kCFNetworkCFStreamSSLErrorOriginalValue=-9807, _kCFStreamErrorDomainKey=3, _kCFStreamErrorCodeKey=-9807, kCFStreamPropertySSLPeerCertificates=(
"<cert(0x7f91cd0a5600) s: <cert name> i: <cert name>>"
)}}, _NSURLErrorRelatedURLSessionTaskErrorKey=(
"LocalDataTask <F4695366-AE0C-41D9-A3AA-CEA0682E6413>.<1>"
), _kCFStreamErrorCodeKey=-9807, _NSURLErrorFailingURLSessionTaskErrorKey=LocalDataTask <F4695366-AE0C-41D9-A3AA-CEA0682E6413>.<1>, NSURLErrorFailingURLPeerTrustErrorKey=<SecTrustRef: 0x6000001e4510>, NSLocalizedDescription=The certificate for this server is invalid. You might be connecting to a server that is pretending to be “<destination>” which could put your confidential information at risk.}

The error can then be handled separately. In my case, I'm initializing an object to represent the connection to this remote server, so if the connection doesn't work, I just throw the error back to the code trying to create this object. I use the error to create an NSAlert, which I show as a sheet with a button to cancel, and a button to trust the certificate. If the user picks the button to trust the certificate, the sheet completion handler then adds it to the keychain:

Code Block
case .failure(let error as NSError):
switch (error.domain,error.code) {
case (NSURLErrorDomain,-1202):
let errorSheet = NSAlert(error: error)
errorSheet.addButton(withTitle: "Cancel Connection")
errorSheet.addButton(withTitle: "Trust this Certificate")
errorSheet.beginSheetModal(for: self.myWindow!)
{ (response:NSApplication.ModalResponse) -> Void in
switch response{
case .alertFirstButtonReturn:
break
case .alertSecondButtonReturn:
let serverCertificate = (error.userInfo["NSErrorPeerCertificateChainKey"] as! [SecCertificate])[0]
let serverCertItemDictionary = [
kSecClass:kSecClassCertificate,
kSecValueRef:serverCertificate,
kSecReturnRef:true,
kSecReturnAttributes:true
] as [CFString : Any]
let serverCertDictionaryCF = serverCertItemDictionary as CFDictionary
var secItemAddReturn:CFTypeRef?
let secItemAddError = SecItemAdd(serverCertDictionaryCF, &secItemAddReturn)
switch secItemAddError {
case noErr:
break
case errSecDuplicateItem:
print("This server's certificate is already in the Keychain.")
default:
let errorString = SecCopyErrorMessageString(secItemAddError,nil)
print("SecItemAdd error: \(String(describing: errorString))")
}
default:
break
}
}
default:
let errorSheet = NSAlert(error: error)
errorSheet.beginSheetModal(for: self.myWindow!)
}

Extending the switch statements to handle other errors is relatively easy.