Skip to content

Commit

Permalink
SSL Pinned Certificate (#8055)
Browse files Browse the repository at this point in the history
  • Loading branch information
enahum authored Jul 23, 2024
1 parent c34a131 commit de6ccae
Show file tree
Hide file tree
Showing 20 changed files with 439 additions and 114 deletions.
4 changes: 4 additions & 0 deletions android/app/src/main/assets/certs/.gitignore
Original file line number Diff line number Diff line change
@@ -0,0 +1,4 @@
# Ignore everything in this directory
*
# Except this file
!.gitignore
11 changes: 9 additions & 2 deletions app/client/websocket/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -9,7 +9,7 @@ import DatabaseManager from '@database/manager';
import {getConfigValue} from '@queries/servers/system';
import {hasReliableWebsocket} from '@utils/config';
import {toMilliseconds} from '@utils/datetime';
import {logError, logInfo, logWarning} from '@utils/log';
import {logDebug, logError, logInfo, logWarning} from '@utils/log';

const MAX_WEBSOCKET_FAILS = 7;
const WEBSOCKET_TIMEOUT = toMilliseconds({seconds: 30});
Expand All @@ -18,6 +18,7 @@ const MAX_WEBSOCKET_RETRY_TIME = toMilliseconds({minutes: 5});
const DEFAULT_OPTIONS = {
forceConnection: true,
};
const TLS_HANDSHARE_ERROR = 1015;

export default class WebSocketClient {
private conn?: WebSocketClientInterface;
Expand Down Expand Up @@ -179,7 +180,7 @@ export default class WebSocketClient {
this.connectFailCount = 0;
});

this.conn!.onClose(() => {
this.conn!.onClose((ev) => {
clearTimeout(this.connectionTimeout);
this.conn = undefined;
this.responseSequence = 1;
Expand All @@ -190,6 +191,12 @@ export default class WebSocketClient {
// reliable websockets are enabled this won't trigger a new sync.
this.shouldSkipSync = false;

if (ev.message && typeof ev.message === 'object' && 'code' in ev.message && ev.message.code === TLS_HANDSHARE_ERROR) {
logDebug('websocket did not connect', this.url, ev.message.reason);
this.closeCallback?.(this.connectFailCount);
return;
}

if (this.connectFailCount === 0) {
logInfo('websocket closed', this.url);
}
Expand Down
49 changes: 42 additions & 7 deletions app/managers/network_manager.ts
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,7 @@ import {
} from '@mattermost/react-native-network-client';
import {nativeApplicationVersion, nativeBuildVersion} from 'expo-application';
import {modelName, osName, osVersion} from 'expo-device';
import {defineMessages, createIntl} from 'react-intl';
import {Alert, DeviceEventEmitter} from 'react-native';
import urlParse from 'url-parse';

Expand All @@ -19,18 +20,41 @@ import {Client} from '@client/rest';
import * as ClientConstants from '@client/rest/constants';
import ClientError from '@client/rest/error';
import {CERTIFICATE_ERRORS} from '@constants/network';
import {DEFAULT_LOCALE, getLocalizedMessage, t} from '@i18n';
import {DEFAULT_LOCALE, getTranslations} from '@i18n';
import ManagedApp from '@init/managed_app';
import {toMilliseconds} from '@utils/datetime';
import {logDebug, logError} from '@utils/log';
import {getCSRFFromCookie} from '@utils/security';

const CLIENT_CERTIFICATE_IMPORT_ERROR_CODES = [-103, -104, -105, -108];
const CLIENT_CERTIFICATE_MISSING_ERROR_CODE = -200;
const SERVER_CERTIFICATE_INVALID = -299;
const SERVER_TRUST_EVALUATION_FAILED = -298;
let showingServerTrustAlert = false;

const messages = defineMessages({
invalidSslTitle: {
id: 'server.invalid.certificate.title',
defaultMessage: 'Invalid SSL certificate',
},
invalidSslDescription: {
id: 'server.invalid.certificate.description',
defaultMessage: 'The certificate for this server is invalid.\nYou might be connecting to a server that is pretending to be “{hostname}” which could put your confidential information at risk.',
},
invalidPinningTitle: {
id: 'server.invalid.pinning.title',
defaultMessage: 'Invalid pinned SSL certificate',
},
});

class NetworkManager {
private clients: Record<string, Client> = {};

private intl = createIntl({
locale: DEFAULT_LOCALE,
messages: getTranslations(DEFAULT_LOCALE),
});

private DEFAULT_CONFIG: APIClientConfiguration = {
headers: {
'X-Requested-With': 'XMLHttpRequest',
Expand Down Expand Up @@ -129,12 +153,23 @@ class NetworkManager {
logDebug('Invalid SSL certificate:', event.errorDescription);
const parsed = urlParse(event.serverUrl);
Alert.alert(
getLocalizedMessage(DEFAULT_LOCALE, t('server.invalid.certificate.title'), 'Invalid SSL certificate'),
getLocalizedMessage(
DEFAULT_LOCALE,
t('server.invalid.certificate.description'),
'The certificate for this server is invalid.\nYou might be connecting to a server that is pretending to be “{hostname}” which could put your confidential information at risk.',
).replace('{hostname}', parsed.hostname),
this.intl.formatMessage(messages.invalidSslTitle),
this.intl.formatMessage(messages.invalidSslDescription, {hostname: parsed.hostname}),
);
} else if (SERVER_TRUST_EVALUATION_FAILED === event.errorCode && !showingServerTrustAlert) {
logDebug('Invalid SSL Pinning:', event.errorDescription);
showingServerTrustAlert = true;
Alert.alert(
this.intl.formatMessage(messages.invalidPinningTitle),
event.errorDescription,
[{
text: this.intl.formatMessage({id: 'server_upgrade.dismiss', defaultMessage: 'Dismiss'}),
onPress: () => {
setTimeout(() => {
showingServerTrustAlert = false;
}, toMilliseconds({hours: 23}));
},
}],
);
}
};
Expand Down
1 change: 1 addition & 0 deletions assets/base/i18n/en.json
Original file line number Diff line number Diff line change
Expand Up @@ -1009,6 +1009,7 @@
"server_upgrade.learn_more": "Learn More",
"server.invalid.certificate.description": "The certificate for this server is invalid.\nYou might be connecting to a server that is pretending to be “{hostname}” which could put your confidential information at risk.",
"server.invalid.certificate.title": "Invalid SSL certificate",
"server.invalid.pinning.title": "Invalid pinned SSL certificate",
"server.logout.alert_description": "All associated data will be removed",
"server.logout.alert_title": "Are you sure you want to log out of {displayName}?",
"server.remove.alert_description": "This will remove it from your list of servers. All associated data will be removed",
Expand Down
4 changes: 4 additions & 0 deletions assets/certs/.gitignore
Original file line number Diff line number Diff line change
@@ -0,0 +1,4 @@
# Ignore everything in this directory
*
# Except this file
!.gitignore
5 changes: 5 additions & 0 deletions fastlane/Fastfile
Original file line number Diff line number Diff line change
Expand Up @@ -699,6 +699,7 @@ platform :android do
end
update_identifiers
replace_assets
pinned_certificates
link_sentry({:os_type => "android"})
build_android
move_android_to_root
Expand Down Expand Up @@ -816,6 +817,10 @@ platform :android do
sh 'cp -R ../assets/sounds/* ../android/app/src/main/res/raw/'
end

lane :pinned_certificates do
sh 'cp ../assets/certs/* ../android/app/src/main/assets/certs'
end

lane :deploy do |options|
file_path = options[:file]

Expand Down
15 changes: 15 additions & 0 deletions ios/Gekidou/Sources/Gekidou/Bundle+Extensions.swift
Original file line number Diff line number Diff line change
@@ -0,0 +1,15 @@
import Foundation

extension Bundle {
static var app: Bundle {
var components = main.bundleURL.path.split(separator: "/")
var bundle: Bundle?

if let index = components.lastIndex(where: { $0.hasSuffix(".app") }) {
components.removeLast((components.count - 1) - index)
bundle = Bundle(path: components.joined(separator: "/"))
}

return bundle ?? main
}
}
120 changes: 108 additions & 12 deletions ios/Gekidou/Sources/Gekidou/Networking/Network+Delegate.swift
Original file line number Diff line number Diff line change
@@ -1,24 +1,120 @@
import Foundation
import os.log

extension Network: URLSessionDelegate, URLSessionTaskDelegate {
typealias ChallengeEvaluation = (disposition: URLSession.AuthChallengeDisposition, credential: URLCredential?, error: NetworkError?)

public func urlSession(_ session: URLSession,
task: URLSessionTask,
didReceive challenge: URLAuthenticationChallenge,
completionHandler: @escaping (URLSession.AuthChallengeDisposition, URLCredential?) -> Void) {
var credential: URLCredential? = nil
var disposition: URLSession.AuthChallengeDisposition = .performDefaultHandling
var evaluation: ChallengeEvaluation
switch challenge.protectionSpace.authenticationMethod {
#if canImport(Security)
case NSURLAuthenticationMethodServerTrust:
evaluation = attemptServerTrustAuthentication(with: challenge)
case NSURLAuthenticationMethodClientCertificate:
evaluation = attemptClientAuthentication(with: challenge)
#endif
default:
evaluation = (.performDefaultHandling, nil, nil)
}

if let error = evaluation.error {
os_log("Gekidou: %{public}@",
log: .default,
type: .error,
error.localizedDescription
)
}

completionHandler(evaluation.disposition, evaluation.credential)
}

func attemptClientAuthentication(with challenge: URLAuthenticationChallenge) -> ChallengeEvaluation{
let host = challenge.protectionSpace.host

guard let (identity, certificate) = try? Keychain.default.getClientIdentityAndCertificate(for: host) else {
return (.performDefaultHandling, nil, nil)
}

return (.useCredential, URLCredential(identity: identity,
certificates: [certificate],
persistence: URLCredential.Persistence.permanent
), nil)
}

func attemptServerTrustAuthentication(with challenge: URLAuthenticationChallenge) -> ChallengeEvaluation {
let host = challenge.protectionSpace.host

guard challenge.protectionSpace.authenticationMethod == NSURLAuthenticationMethodServerTrust,
let trust = challenge.protectionSpace.serverTrust
else {
return (.performDefaultHandling, nil, nil)
}

do {
guard let certs = certificates[host], !certs.isEmpty else {
return (.performDefaultHandling, nil, nil)
}

let authMethod = challenge.protectionSpace.authenticationMethod
if authMethod == NSURLAuthenticationMethodClientCertificate {
let host = task.currentRequest!.url!.host!
if let (identity, certificate) = try? Keychain.default.getClientIdentityAndCertificate(for: host) {
credential = URLCredential(identity: identity,
certificates: [certificate],
persistence: URLCredential.Persistence.permanent)
try performDefaultValidation(trust)

try performValidation(trust, forHost: host)

try evaluate(trust, forHost: host, withCerts: certs)

return (.useCredential, URLCredential(trust: trust), nil)
} catch {
os_log("Gekidou: %{public}@",
log: .default,
type: .error,
error.localizedDescription
)
return (.cancelAuthenticationChallenge, nil, error as? NetworkError)
}
}

private func getServerTrustCertificates(_ trust: SecTrust) -> [SecCertificate] {
if #available(iOS 15, macOS 12, tvOS 15, watchOS 8, visionOS 1, *) {
return (SecTrustCopyCertificateChain(trust) as? [SecCertificate]) ?? []
} else {
return (0..<SecTrustGetCertificateCount(trust)).compactMap { index in
SecTrustGetCertificateAtIndex(trust, index)
}
disposition = .useCredential
}

completionHandler(disposition, credential)
}

private func performDefaultValidation(_ trust: SecTrust) throws {
let policy = SecPolicyCreateSSL(true, nil)
try evaluate(trust, afterApplying: policy)
}

private func performValidation(_ trust: SecTrust, forHost host: String) throws {
let policy = SecPolicyCreateSSL(true, host as CFString)
try evaluate(trust, afterApplying: policy)
}

private func evaluate(_ trust: SecTrust, afterApplying policy: SecPolicy) throws {
let status = SecTrustSetPolicies(trust, policy)
guard status == errSecSuccess else {
throw NetworkError.serverTrustEvaluationFailed(reason: .policyApplicationFailed(trust: trust, policy: policy, status: status))
}

var error: CFError?
let evaluationSucceeded = SecTrustEvaluateWithError(trust, &error)
if !evaluationSucceeded {
throw NetworkError.serverTrustEvaluationFailed(reason: .trustEvaluationFailed(error: error))
}
}

private func evaluate(_ trust: SecTrust, forHost host: String, withCerts certs: [SecCertificate]) throws {
let serverCertificates = getServerTrustCertificates(trust)
let serverCertificatesData = Set(serverCertificates.map { SecCertificateCopyData($0) as Data })
let pinnedCertificatesData = Set(certs.map { SecCertificateCopyData($0) as Data })
let pinnedCertificatesInServerData = !serverCertificatesData.isDisjoint(with: pinnedCertificatesData)
if !pinnedCertificatesInServerData {
throw NetworkError.serverTrustEvaluationFailed(reason: .certificatePinningFailed(host: host, trust: trust, pinnedCertificates: certs, serverCertificates: serverCertificates))
}
}
}
68 changes: 68 additions & 0 deletions ios/Gekidou/Sources/Gekidou/Networking/Network+Error.swift
Original file line number Diff line number Diff line change
@@ -0,0 +1,68 @@
import Foundation

public enum NetworkError: Error {
public enum ServerTrustFailureReason {
/// The output of a server trust evaluation.
public struct Output {
/// The host for which the evaluation was performed.
public let host: String
/// The `SecTrust` value which was evaluated.
public let trust: SecTrust
/// The `OSStatus` of evaluation operation.
public let status: OSStatus
/// The result of the evaluation operation.
public let result: SecTrustResultType

/// Creates an `Output` value from the provided values.
init(_ host: String, _ trust: SecTrust, _ status: OSStatus, _ result: SecTrustResultType) {
self.host = host
self.trust = trust
self.status = status
self.result = result
}
}

/// No certificates were found with which to perform the trust evaluation.
case noCertificatesFound
/// During evaluation, application of the associated `SecPolicy` failed.
case policyApplicationFailed(trust: SecTrust, policy: SecPolicy, status: OSStatus)
/// `SecTrust` evaluation failed with the associated `Error`, if one was produced.
case trustEvaluationFailed(error: Error?)
/// Default evaluation failed with the associated `Output`.
case defaultEvaluationFailed(output: Output)
/// Host validation failed with the associated `Output`.
case hostValidationFailed(output: Output)
/// Certificate pinning failed.
case certificatePinningFailed(host: String, trust: SecTrust, pinnedCertificates: [SecCertificate], serverCertificates: [SecCertificate])
}

case serverTrustEvaluationFailed(reason: ServerTrustFailureReason)
}

extension NetworkError: LocalizedError {
public var errorDescription: String? {
switch self {
case let .serverTrustEvaluationFailed(reason):
return "Server trust evaluation failed due to reason: \(reason.localizedDescription)"
}
}
}

extension NetworkError.ServerTrustFailureReason {
var localizedDescription: String {
switch self {
case .noCertificatesFound:
return "No certificates were found or provided for evaluation."
case .policyApplicationFailed:
return "Attempting to set a SecPolicy failed."
case let .trustEvaluationFailed(error):
return "SecTrust evaluation failed with error: \(error?.localizedDescription ?? "None")"
case let .defaultEvaluationFailed(output):
return "Default evaluation failed for host \(output.host)."
case let .hostValidationFailed(output):
return "Host validation failed for host \(output.host)."
case let .certificatePinningFailed(host, _, pinnedCertificates, _):
return "Certificate pinning failed for host \(host) after evaluating \(pinnedCertificates.count) installed certificates."
}
}
}
Loading

0 comments on commit de6ccae

Please sign in to comment.