Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

SSL Pinning support #129

Merged
merged 4 commits into from
Jul 20, 2024
Merged
Show file tree
Hide file tree
Changes from 2 commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -25,7 +25,7 @@ class ApiClientModuleImpl(reactApplicationContext: ReactApplicationContext) {
companion object {
const val NAME = "ApiClient"

internal lateinit var context: ReactApplicationContext
public lateinit var context: ReactApplicationContext
private val clients = mutableMapOf<HttpUrl, NetworkClient>()
private val calls = mutableMapOf<String, Call>()
private lateinit var sharedPreferences: SharedPreferences
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@ package com.mattermost.networkclient

import android.annotation.SuppressLint
import android.net.Uri
import android.util.Base64
import android.webkit.CookieManager
import com.facebook.react.bridge.Arguments
import com.facebook.react.bridge.Promise
Expand All @@ -22,15 +23,17 @@ import okhttp3.tls.HandshakeCertificates
import org.json.JSONArray
import org.json.JSONObject
import java.io.IOException
import java.io.InputStream
import java.net.URI
import java.security.MessageDigest
import java.security.SecureRandom
import java.security.cert.CertificateException
import java.security.cert.CertificateFactory
import java.security.cert.X509Certificate
import java.util.Locale
import javax.net.ssl.HttpsURLConnection
import javax.net.ssl.SSLContext
import javax.net.ssl.X509TrustManager
import kotlin.collections.HashMap
import kotlin.reflect.KProperty

internal class NetworkClient(private val context: ReactApplicationContext, private val baseUrl: HttpUrl? = null, options: ReadableMap? = null, cookieJar: CookieJar? = null) {
Expand Down Expand Up @@ -75,6 +78,18 @@ internal class NetworkClient(private val context: ReactApplicationContext, priva
applyClientBuilderConfiguration(options, cookieJar)
}

val fingerprintsMap = getCertificatesFingerPrints()
if (fingerprintsMap.isNotEmpty()) {
val pinner = CertificatePinner.Builder()
for ((domain, fingerprints) in fingerprintsMap) {
for (fingerprint in fingerprints) {
pinner.add(domain, "sha256/$fingerprint")
}
}
larkox marked this conversation as resolved.
Show resolved Hide resolved
val certificatePinner = pinner.build()
builder.certificatePinner(certificatePinner)
}

okHttpClient = builder.build()
}

Expand Down Expand Up @@ -186,6 +201,12 @@ internal class NetworkClient(private val context: ReactApplicationContext, priva
val call = okHttpClient.newCall(request)
call.enqueue(object : Callback {
override fun onFailure(call: Call, e: IOException) {
if (e is javax.net.ssl.SSLPeerUnverifiedException) {
cancelAllRequests()
emitInvalidPinnedCertificateError()
promise.reject(Exception("Server trust evaluation failed due to reason: Certificate pinning failed for host ${request.url.host}"))
return
}
promise.reject(e)
}

Expand Down Expand Up @@ -457,6 +478,14 @@ internal class NetworkClient(private val context: ReactApplicationContext, priva
ApiClientModuleImpl.sendJSEvent(ApiClientEvents.CLIENT_ERROR.event, data)
}

internal fun emitInvalidPinnedCertificateError() {
val data = Arguments.createMap()
data.putString("serverUrl", baseUrlString)
data.putInt("errorCode", -298)
enzowritescode marked this conversation as resolved.
Show resolved Hide resolved
data.putString("errorDescription", "Server trust evaluation failed due to reason: Certificate pinning failed for host ${URI(baseUrlString).host}")
enzowritescode marked this conversation as resolved.
Show resolved Hide resolved
ApiClientModuleImpl.sendJSEvent(ApiClientEvents.CLIENT_ERROR.event, data)
}

private fun buildHandshakeCertificates(options: ReadableMap?): HandshakeCertificates? {
if (options != null) {
// `trustSelfSignedServerCertificate` can be in `options.sessionConfiguration` for
Expand Down Expand Up @@ -677,4 +706,36 @@ internal class NetworkClient(private val context: ReactApplicationContext, priva
cookieManager.setCookie(domain, cookieParts[0].trim { it <= ' ' } + "=; Expires=Thurs, 1 Jan 1970 12:00:00 GMT")
}
}

private fun getCertificateFingerPrint(certInputStream: InputStream): String {
val certFactory = CertificateFactory.getInstance("X.509")
val certificate = certFactory.generateCertificate(certInputStream) as X509Certificate
val sha256 = MessageDigest.getInstance("SHA-256")
val fingerprintBytes = sha256.digest(certificate.publicKey.encoded)
return Base64.encodeToString(fingerprintBytes, Base64.NO_WRAP)
}

private fun getCertificatesFingerPrints(): Map<String, List<String>> {
val fingerprintsMap = mutableMapOf<String, MutableList<String>>()
val assetsManager = context.assets
val certFiles = assetsManager.list("certs")?.filter { it.endsWith(".cer") || it.endsWith(".crt") } ?: return emptyMap()
enzowritescode marked this conversation as resolved.
Show resolved Hide resolved

for (fileName in certFiles) {
val domain = fileName.substringBeforeLast(".")
enzowritescode marked this conversation as resolved.
Show resolved Hide resolved
if (baseUrl != null && baseUrl.host != domain) {
continue
}
val certInputStream = assetsManager.open("certs/$fileName")
enzowritescode marked this conversation as resolved.
Show resolved Hide resolved
certInputStream.use {
val fingerprint = getCertificateFingerPrint(it)
if (fingerprintsMap.containsKey(domain)) {
fingerprintsMap[domain]?.add(fingerprint)
} else {
fingerprintsMap[domain] = mutableListOf(fingerprint)
}
}
}

return fingerprintsMap
}
}
12 changes: 8 additions & 4 deletions ios/Adapters/BearerAuthenticationAdapter.swift
Original file line number Diff line number Diff line change
Expand Up @@ -9,7 +9,7 @@ import Alamofire
urlRequest.headers.add(.authorization(bearerToken: bearerToken))
}
} catch {
NotificationCenter.default.post(name: Notification.Name(API_CLIENT_EVENTS["CLIENT_ERROR"]!),
NotificationCenter.default.post(name: Notification.Name(ApiEvents.CLIENT_ERROR.rawValue),
object: nil,
userInfo: ["serverUrl": sessionBaseUrlString, "errorCode": error._code, "errorDescription": error.localizedDescription])
}
Expand All @@ -18,8 +18,12 @@ import Alamofire
}

public func adapt(_ urlRequest: URLRequest, for session: Session, completion: @escaping (Result<URLRequest, Error>) -> Void) {
let urlRequest = BearerAuthenticationAdapter.addAuthorizationBearerToken(to: urlRequest, withSessionBaseUrlString: session.baseUrl.absoluteString)

completion(.success(urlRequest))
if let baseUrl = session.baseUrl {
enzowritescode marked this conversation as resolved.
Show resolved Hide resolved
let urlRequest = BearerAuthenticationAdapter.addAuthorizationBearerToken(to: urlRequest, withSessionBaseUrlString: baseUrl.absoluteString)

completion(.success(urlRequest))
} else {
completion(.failure(BaseURLError.missingBaseURL))
}
}
}
19 changes: 7 additions & 12 deletions ios/ApiClient/ApiClientWrapper.swift
Original file line number Diff line number Diff line change
Expand Up @@ -2,12 +2,6 @@ import Alamofire
import SwiftyJSON
import React

let API_CLIENT_EVENTS = [
"UPLOAD_PROGRESS": "APIClient-UploadProgress",
"DOWNLOAD_PROGRESS": "APIClient-DownloadProgress",
"CLIENT_ERROR": "APIClient-Error"
]

@objc public class ApiClientWrapper: NSObject, NetworkClient {
@objc public weak var delegate: ApiClientDelegate? = nil
let requestsTable = NSMapTable<NSString, Request>.strongToWeakObjects()
Expand Down Expand Up @@ -119,7 +113,8 @@ let API_CLIENT_EVENTS = [
}

do {
try resolve(Keychain.importClientP12(withPath: path, withPassword: password, forHost: session.baseUrl.host!))
guard let baseUrl = session.baseUrl else { throw BaseURLError.missingBaseURL}
try resolve(Keychain.importClientP12(withPath: path, withPassword: password, forHost: baseUrl.host!))
} catch {
reject("\(error._code)", error.localizedDescription, error)
}
Expand Down Expand Up @@ -289,7 +284,7 @@ let API_CLIENT_EVENTS = [
self.delegate?.sendEvent(name: ApiEvents.UPLOAD_PROGRESS.rawValue, result: ["taskId": taskId, "fractionCompleted": progress.fractionCompleted, "bytesRead": progress.completedUnitCount])
}
.responseJSON { json in
self.resolveOrRejectJSONResponse(json, for:request, withResolver: resolve, withRejecter: reject)
self.resolveOrRejectJSONResponse(json, for: session, with: request, withResolver: resolve, withRejecter: reject)
}

self.requestsTable.setObject(request, forKey: taskId as NSString)
Expand Down Expand Up @@ -322,7 +317,7 @@ let API_CLIENT_EVENTS = [
self.delegate?.sendEvent(name: ApiEvents.UPLOAD_PROGRESS.rawValue, result: ["taskId": taskId, "fractionCompleted": fractionCompleted])
}
.responseJSON { json in
self.resolveOrRejectJSONResponse(json, withResolver: resolve, withRejecter: reject)
self.resolveOrRejectJSONResponse(json, for: session, withResolver: resolve, withRejecter: reject)
}

self.requestsTable.setObject(request, forKey: taskId as NSString)
Expand Down Expand Up @@ -366,11 +361,11 @@ let API_CLIENT_EVENTS = [
data.response?.allHeaderFields[tokenHeader.lowercased()] ??
data.response?.allHeaderFields[tokenHeader.firstUppercased]) as? String
}
if let token = token {
if let token = token, let baseUrl = session.baseUrl {
do {
try Keychain.setToken(token, forServerUrl: session.baseUrl.absoluteString)
try Keychain.setToken(token, forServerUrl: baseUrl.absoluteString)
} catch {
sendErrorEvent(for: session.baseUrl.absoluteString, withErrorCode: error._code, withErrorDescription: error.localizedDescription)
sendErrorEvent(for: baseUrl.absoluteString, withErrorCode: error._code, withErrorDescription: error.localizedDescription)
}
}
}
Expand Down
16 changes: 11 additions & 5 deletions ios/Delegates/ApiClientSessionDelegate.swift
Original file line number Diff line number Diff line change
Expand Up @@ -15,25 +15,31 @@ class ApiClientSessionDelegate: SessionDelegate {
if session.trustSelfSignedServerCertificate {
credential = URLCredential(trust: challenge.protectionSpace.serverTrust!)
disposition = .useCredential
completionHandler(disposition, credential)
esarafianou marked this conversation as resolved.
Show resolved Hide resolved
return
}
}
} else if authMethod == NSURLAuthenticationMethodClientCertificate {
if let session = SessionManager.default.getSession(for: urlSession) {
credential = SessionManager.default.getCredential(for: session.baseUrl)
if let session = SessionManager.default.getSession(for: urlSession),
let baseUrl = session.baseUrl {
credential = SessionManager.default.getCredential(for: baseUrl)
}
disposition = .useCredential
completionHandler(disposition, credential)
return
}

completionHandler(disposition, credential)
super.urlSession(urlSession, task: task, didReceive: challenge, completionHandler: completionHandler)
}

override open func urlSession(_ session: URLSession, task: URLSessionTask, didCompleteWithError error: Error?) {
if let err = error as? NSError,
let urlSession = SessionManager.default.getSession(for: session),
let baseUrl = urlSession.baseUrl,
err.domain == NSURLErrorDomain && err.code == NSURLErrorServerCertificateUntrusted {
NotificationCenter.default.post(name: Notification.Name(API_CLIENT_EVENTS["CLIENT_ERROR"]!),
NotificationCenter.default.post(name: Notification.Name(ApiEvents.CLIENT_ERROR.rawValue),
object: nil,
userInfo: ["serverUrl": urlSession.baseUrl.absoluteString, "errorCode": APIClientError.ServerCertificateInvalid.errorCode, "errorDescription": err.localizedDescription])
userInfo: ["serverUrl": baseUrl.absoluteString, "errorCode": APIClientError.ServerCertificateInvalid.errorCode, "errorDescription": err.localizedDescription])
}
super.urlSession(session, task: task, didCompleteWithError: error)
}
Expand Down
15 changes: 15 additions & 0 deletions ios/Extensions/ApiClientError.swift
Original file line number Diff line number Diff line change
Expand Up @@ -3,13 +3,26 @@ import Foundation
enum APIClientError: Error {
case ClientCertificateMissing
case ServerCertificateInvalid
case ServerTrustEvaluationFailed
}

enum BaseURLError: Error {
case missingBaseURL

var errorDescription: String? {
switch self {
case .missingBaseURL:
return "The base URL has not been set"
}
}
}

extension APIClientError: LocalizedError {
var errorCode: Int {
switch self {
case .ClientCertificateMissing: return -200
case .ServerCertificateInvalid: return -299
case .ServerTrustEvaluationFailed: return -298
}
}

Expand All @@ -19,6 +32,8 @@ extension APIClientError: LocalizedError {
return "Failed to authenticate: missing client certificate"
case .ServerCertificateInvalid:
return "Invalid or not trusted server certificate"
case .ServerTrustEvaluationFailed:
return ""
larkox marked this conversation as resolved.
Show resolved Hide resolved
}
}
}
4 changes: 2 additions & 2 deletions ios/Extensions/Session+Extensions.swift
Original file line number Diff line number Diff line change
Expand Up @@ -13,8 +13,8 @@ fileprivate var trustSelfSignedServerCertificate_FILEPRIVATE : [ObjectIdentifier
fileprivate var retryPolicy_FILEPRIVATE : [ObjectIdentifier:RetryPolicy] = [:]

extension Session {
var baseUrl: URL {
get { return baseUrl_FILEPRIVATE[ObjectIdentifier(self)]!}
var baseUrl: URL? {
get { return baseUrl_FILEPRIVATE[ObjectIdentifier(self)]}
set { baseUrl_FILEPRIVATE[ObjectIdentifier(self)] = newValue}
}

Expand Down
2 changes: 1 addition & 1 deletion ios/Extensions/SessionManager+Extensions.swift
Original file line number Diff line number Diff line change
Expand Up @@ -25,7 +25,7 @@ extension SessionManager {
throw APIClientError.ClientCertificateMissing
}
} catch {
NotificationCenter.default.post(name: Notification.Name(API_CLIENT_EVENTS["CLIENT_ERROR"]!),
NotificationCenter.default.post(name: Notification.Name(ApiEvents.CLIENT_ERROR.rawValue),
object: nil,
userInfo: ["serverUrl": baseUrl.absoluteString,
"errorCode": error._code,
Expand Down
10 changes: 9 additions & 1 deletion ios/GenericClient/GenericClientWrapper.swift
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,15 @@ import Alamofire
import SwiftyJSON

@objc public class GenericClientWrapper: NSObject, NetworkClient {
var session = Session(redirectHandler: Redirector(behavior: .follow))
var session: Session

public override init() {
if let serverTrustManager = SessionManager.default.serverTrustManager {
session = Session(serverTrustManager: serverTrustManager, redirectHandler: Redirector(behavior: .follow))
} else {
session = Session(redirectHandler: Redirector(behavior: .follow))
}
}

@objc public func head(url: String, options: Dictionary<String, Any>, resolve: @escaping RCTPromiseResolveBlock, reject: @escaping RCTPromiseRejectBlock) -> Void {
handleRequest(for: url, withMethod: .head, withSession: session, withOptions: JSON(options), withResolver: resolve, withRejecter: reject)
Expand Down
22 changes: 19 additions & 3 deletions ios/NetworkClient.swift
Original file line number Diff line number Diff line change
Expand Up @@ -28,7 +28,8 @@ protocol NetworkClient {
withRejecter reject: @escaping RCTPromiseRejectBlock)

func resolveOrRejectJSONResponse(_ json: AFDataResponse<Any>,
for request: Request?,
for session: Session,
with request: Request?,
withResolver resolve: @escaping RCTPromiseResolveBlock,
withRejecter reject: @escaping RCTPromiseRejectBlock)

Expand Down Expand Up @@ -67,7 +68,7 @@ extension NetworkClient {
request.validate(statusCode: 200...409)
.responseJSON { json in
self.handleResponse(for: session, withUrl: url, withData: json)
self.resolveOrRejectJSONResponse(json, for: request, withResolver: resolve, withRejecter: reject)
self.resolveOrRejectJSONResponse(json, for: session, with: request, withResolver: resolve, withRejecter: reject)
}
}

Expand Down Expand Up @@ -125,7 +126,8 @@ extension NetworkClient {
}

func resolveOrRejectJSONResponse(_ json: AFDataResponse<Any>,
for request: Request? = nil,
for session: Session,
with request: Request? = nil,
withResolver resolve: @escaping RCTPromiseResolveBlock,
withRejecter reject: @escaping RCTPromiseRejectBlock) -> Void {

Expand All @@ -152,11 +154,25 @@ extension NetworkClient {
case .failure(let error):
var responseCode = error.responseCode
var retriesExhausted = false

if error.isRequestRetryError, let underlyingError = error.underlyingError {
responseCode = underlyingError.asAFError?.responseCode
retriesExhausted = true
}

if error.isServerTrustEvaluationError {
session.cancelAllRequests()
if let baseUrl = session.baseUrl {
NotificationCenter.default.post(name: Notification.Name(ApiEvents.CLIENT_ERROR.rawValue),
object: nil,
userInfo: [
"serverUrl": baseUrl.absoluteString,
"errorCode": APIClientError.ServerTrustEvaluationFailed.errorCode,
"errorDescription": error.localizedDescription])
}

}

var response = [
"ok": false,
"headers": json.response?.allHeaderFields,
Expand Down
Loading