From de6ccaef01ed6dec21dee74957b6dadf9444aa2c Mon Sep 17 00:00:00 2001 From: Elias Nahum Date: Tue, 23 Jul 2024 18:26:20 +0800 Subject: [PATCH] SSL Pinned Certificate (#8055) --- android/app/src/main/assets/certs/.gitignore | 4 + app/client/websocket/index.ts | 11 +- app/managers/network_manager.ts | 49 ++++++- assets/base/i18n/en.json | 1 + assets/certs/.gitignore | 4 + fastlane/Fastfile | 5 + .../Sources/Gekidou/Bundle+Extensions.swift | 15 +++ .../Gekidou/Networking/Network+Delegate.swift | 122 ++++++++++++++++-- .../Gekidou/Networking/Network+Error.swift | 68 ++++++++++ .../Sources/Gekidou/Networking/Network.swift | 39 ++++++ .../Networking/ShareExtension+Delegate.swift | 95 ++++++++------ .../Networking/ShareExtension+Post.swift | 15 ++- .../Gekidou/Networking/ShareExtension.swift | 1 + .../PushNotification/PushNotification.swift | 7 +- ios/Mattermost.xcodeproj/project.pbxproj | 4 + ios/Podfile.lock | 4 +- .../com/mattermost/rnshare/ShareWorker.kt | 55 +++++++- .../rnshare/helpers/RealPathUtil.kt | 44 +------ package-lock.json | 10 +- package.json | 2 +- 20 files changed, 440 insertions(+), 115 deletions(-) create mode 100644 android/app/src/main/assets/certs/.gitignore create mode 100644 assets/certs/.gitignore create mode 100644 ios/Gekidou/Sources/Gekidou/Bundle+Extensions.swift create mode 100644 ios/Gekidou/Sources/Gekidou/Networking/Network+Error.swift diff --git a/android/app/src/main/assets/certs/.gitignore b/android/app/src/main/assets/certs/.gitignore new file mode 100644 index 000000000..86d0cb272 --- /dev/null +++ b/android/app/src/main/assets/certs/.gitignore @@ -0,0 +1,4 @@ +# Ignore everything in this directory +* +# Except this file +!.gitignore \ No newline at end of file diff --git a/app/client/websocket/index.ts b/app/client/websocket/index.ts index 181e8ed9f..0b98f0617 100644 --- a/app/client/websocket/index.ts +++ b/app/client/websocket/index.ts @@ -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}); @@ -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; @@ -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; @@ -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); } diff --git a/app/managers/network_manager.ts b/app/managers/network_manager.ts index 74cdbde80..dd82b82fc 100644 --- a/app/managers/network_manager.ts +++ b/app/managers/network_manager.ts @@ -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'; @@ -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 = {}; + private intl = createIntl({ + locale: DEFAULT_LOCALE, + messages: getTranslations(DEFAULT_LOCALE), + }); + private DEFAULT_CONFIG: APIClientConfiguration = { headers: { 'X-Requested-With': 'XMLHttpRequest', @@ -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})); + }, + }], ); } }; diff --git a/assets/base/i18n/en.json b/assets/base/i18n/en.json index e7c879c22..3705d2bd0 100644 --- a/assets/base/i18n/en.json +++ b/assets/base/i18n/en.json @@ -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", diff --git a/assets/certs/.gitignore b/assets/certs/.gitignore new file mode 100644 index 000000000..86d0cb272 --- /dev/null +++ b/assets/certs/.gitignore @@ -0,0 +1,4 @@ +# Ignore everything in this directory +* +# Except this file +!.gitignore \ No newline at end of file diff --git a/fastlane/Fastfile b/fastlane/Fastfile index 40dd1c10c..60496ae2d 100644 --- a/fastlane/Fastfile +++ b/fastlane/Fastfile @@ -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 @@ -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] diff --git a/ios/Gekidou/Sources/Gekidou/Bundle+Extensions.swift b/ios/Gekidou/Sources/Gekidou/Bundle+Extensions.swift new file mode 100644 index 000000000..71feade25 --- /dev/null +++ b/ios/Gekidou/Sources/Gekidou/Bundle+Extensions.swift @@ -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 + } +} diff --git a/ios/Gekidou/Sources/Gekidou/Networking/Network+Delegate.swift b/ios/Gekidou/Sources/Gekidou/Networking/Network+Delegate.swift index 413da51e8..e69ee25aa 100644 --- a/ios/Gekidou/Sources/Gekidou/Networking/Network+Delegate.swift +++ b/ios/Gekidou/Sources/Gekidou/Networking/Network+Delegate.swift @@ -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 - - 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) - } - disposition = .useCredential + 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) + } - completionHandler(disposition, credential) + 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.. Void @@ -13,12 +14,15 @@ public class Network: NSObject { internal var session: URLSession? internal let queue = OperationQueue() internal let urlVersion = "/api/v4" + internal var certificates: [String: [SecCertificate]] = [:] @objc public static let `default` = Network() override private init() { super.init() + loadPinnedCertificates() + queue.maxConcurrentOperationCount = 1 let config = URLSessionConfiguration.default @@ -33,6 +37,41 @@ public class Network: NSObject { ) } + internal func loadPinnedCertificates() { + guard let certsPath = Bundle.app.resourceURL?.appendingPathComponent("certs") else { + return + } + + let fileManager = FileManager.default + do { + let certsArray = try fileManager.contentsOfDirectory(at: certsPath, includingPropertiesForKeys: nil, options: .skipsHiddenFiles) + let certs = certsArray.filter{ $0.pathExtension == "crt" || $0.pathExtension == "cer"} + for cert in certs { + if let domain = URL(string: cert.absoluteString)?.deletingPathExtension().lastPathComponent, + let certData = try? Data(contentsOf: cert), + let certificate = SecCertificateCreateWithData(nil, certData as CFData){ + if certificates[domain] != nil { + certificates[domain]?.append(certificate) + } else { + certificates[domain] = [certificate] + } + os_log("Gekidou: loaded certificate %{public}@ for domain %{public}@", + log: .default, + type: .info, + cert.lastPathComponent, domain + ) + } + } + } catch { + os_log( + "Gekidou: Error loading pinned certificates -- %{public}@", + log: .default, + type: .error, + String(describing: error) + ) + } + } + internal func buildApiUrl(_ serverUrl: String, _ endpoint: String) -> URL { return URL(string: "\(serverUrl)\(urlVersion)\(endpoint)")! } diff --git a/ios/Gekidou/Sources/Gekidou/Networking/ShareExtension+Delegate.swift b/ios/Gekidou/Sources/Gekidou/Networking/ShareExtension+Delegate.swift index f622c26b0..ae71b1f38 100644 --- a/ios/Gekidou/Sources/Gekidou/Networking/ShareExtension+Delegate.swift +++ b/ios/Gekidou/Sources/Gekidou/Networking/ShareExtension+Delegate.swift @@ -61,8 +61,9 @@ extension ShareExtension: URLSessionDataDelegate { let uploadData = getUploadSessionData(id: id) else { os_log( - OSLogType.default, "Mattermost BackgroundSession: didReceived failed to getUploadSessionData identifier=%{public}@", + log: .default, + type: .error, session.configuration.identifier ?? "no identifier" ) return @@ -95,6 +96,8 @@ extension ShareExtension: URLSessionDataDelegate { filename, fileId ) + + postMessageIfUploadFinished(forSession: session) } else { os_log( OSLogType.default, @@ -105,8 +108,9 @@ extension ShareExtension: URLSessionDataDelegate { } } catch { os_log( - OSLogType.default, "Mattermost BackgroundSession: Failed to receive data identifier=%{public}@ error=%{public}", + log: .default, + type: .error, id, error.localizedDescription ) @@ -116,48 +120,22 @@ extension ShareExtension: URLSessionDataDelegate { } public func urlSession(_ session: URLSession, task: URLSessionTask, didCompleteWithError error: Error?) { - if error == nil, - let id = session.configuration.identifier { - guard let data = getUploadSessionData(id: id) - else { - os_log( - OSLogType.default, - "Mattermost BackgroundSession: didCompleteWithError delegate failed to getUploadSessionData identifier=%{public}@", - session.configuration.identifier ?? "no identifier" - ) - return - - } - - let total = data.totalFiles - let count = data.fileIds.count + if error != nil { os_log( - OSLogType.default, - "Mattermost BackgroundSession: didCompleteWithError delegate for identifier=%{public}@ total files %{public}@ of %{public}@", - id, - "\(count)", - "\(total)" - ) - if data.fileIds.count == data.totalFiles { - ProcessInfo().performExpiringActivity( - withReason: "Need to post the message") {expires in - os_log( - OSLogType.default, - "Mattermost BackgroundSession: posting message for identifier=%{public}@", - id - ) - self.postMessageForSession(withId: id) - self.urlSessionDidFinishEvents(forBackgroundURLSession: session) - } - } - } else if error != nil { - os_log( - OSLogType.default, "Mattermost BackgroundSession: didCompleteWithError delegate failed identifier=%{public}@ with error %{public}@", + log: .default, + type: .error, session.configuration.identifier ?? "no identifier", error?.localizedDescription ?? "no error description available" ) + return } + + os_log( + OSLogType.default, + "Mattermost BackgroundSession: didCompleteWithError delegate for identifier=%{public}@ after calling postMessageIfUploadFinished", + session.configuration.identifier ?? "no identifier" + ) } @@ -180,4 +158,45 @@ extension ShareExtension: URLSessionDataDelegate { } }) } + + private func postMessageIfUploadFinished(forSession session: URLSession) { + lock.lock() + defer { lock.unlock() } + + guard let id = session.configuration.identifier, let data = getUploadSessionData(id: id) + else { + os_log( + "Mattermost BackgroundSession: postMessageIfUploadFinished failed to getUploadSessionData identifier=%{public}@", + log: .default, + type: .error, + session.configuration.identifier ?? "no identifier" + ) + return + } + + let total = data.totalFiles + let count = data.fileIds.count + + if count == total { + os_log( + OSLogType.default, + "Mattermost BackgroundSession: posting message for identifier=%{public}@", + id + ) + + ProcessInfo().performExpiringActivity( + withReason: "Need to post the message") {expires in + self.postMessageForSession(withId: id) + self.urlSessionDidFinishEvents(forBackgroundURLSession: session) + } + } else { + os_log( + OSLogType.default, + "Mattermost BackgroundSession: finish uploading files %{public}@ of %{public}@ for identifier=%{public}@", + "\(count)", + "\(total)", + id + ) + } + } } diff --git a/ios/Gekidou/Sources/Gekidou/Networking/ShareExtension+Post.swift b/ios/Gekidou/Sources/Gekidou/Networking/ShareExtension+Post.swift index 968feda62..8e548b410 100644 --- a/ios/Gekidou/Sources/Gekidou/Networking/ShareExtension+Post.swift +++ b/ios/Gekidou/Sources/Gekidou/Networking/ShareExtension+Post.swift @@ -98,9 +98,11 @@ extension ShareExtension { id ) return - } + self.removeUploadSessionData(id: id) + self.deleteUploadedFiles(files: data.files) + if let serverUrl = data.serverUrl, let channelId = data.channelId { Network.default.createPost( @@ -109,8 +111,15 @@ extension ShareExtension { message: data.message, fileIds: data.fileIds, completionHandler: {info, reponse, error in - self.removeUploadSessionData(id: id) - self.deleteUploadedFiles(files: data.files) + if let err = error { + os_log( + "Mattermost BackgroundSession: error to create post for session identifier=%{public}@ -- %{public}@", + log: .default, + type: .error, + id, + err.localizedDescription + ) + } if let handler = completionHandler { os_log( diff --git a/ios/Gekidou/Sources/Gekidou/Networking/ShareExtension.swift b/ios/Gekidou/Sources/Gekidou/Networking/ShareExtension.swift index 64504061f..24b13733e 100644 --- a/ios/Gekidou/Sources/Gekidou/Networking/ShareExtension.swift +++ b/ios/Gekidou/Sources/Gekidou/Networking/ShareExtension.swift @@ -54,6 +54,7 @@ public class ShareExtension: NSObject { private let fileMgr = FileManager.default private let preferences = Preferences.default public var completionHandler: (() -> Void)? + internal let lock = NSLock() public override init() { super.init() diff --git a/ios/Gekidou/Sources/Gekidou/PushNotification/PushNotification.swift b/ios/Gekidou/Sources/Gekidou/PushNotification/PushNotification.swift index 4e67f2c46..e91c65e70 100644 --- a/ios/Gekidou/Sources/Gekidou/PushNotification/PushNotification.swift +++ b/ios/Gekidou/Sources/Gekidou/PushNotification/PushNotification.swift @@ -34,7 +34,7 @@ public class PushNotification: NSObject { "Mattermost Notifications: process receipt response for serverUrl %{public}@ does not contain data", ackNotification.serverUrl ) - completionHandler(notification) + completionHandler(nil) return } notification.userInfo["server_url"] = ackNotification.serverUrl @@ -75,9 +75,14 @@ public class PushNotification: NSObject { url, withMethod: "POST", withBody: jsonData, andHeaders: headers, forServerUrl: ackNotification.serverUrl) {[weak self] data, response, error in if error != nil && ackNotification.isIdLoaded, + let nsError = error as? NSError, let fibonacciBackoffsInSeconds = self?.fibonacciBackoffsInSeconds, let retryIndex = self?.retryIndex, fibonacciBackoffsInSeconds.count > retryIndex { + if nsError.code == NSURLErrorCancelled { + completionHandler(nil) + return + } let backoffInSeconds = fibonacciBackoffsInSeconds[retryIndex] self?.retryIndex += 1 diff --git a/ios/Mattermost.xcodeproj/project.pbxproj b/ios/Mattermost.xcodeproj/project.pbxproj index 0861788fd..a5ccccb5d 100644 --- a/ios/Mattermost.xcodeproj/project.pbxproj +++ b/ios/Mattermost.xcodeproj/project.pbxproj @@ -168,6 +168,7 @@ 7FD482672864DC5900A5B18B /* OpenSans-Regular.ttf in Resources */ = {isa = PBXBuildFile; fileRef = BC977883E2624E05975CA65B /* OpenSans-Regular.ttf */; }; 7FD482682864DC5900A5B18B /* OpenSans-SemiBold.ttf in Resources */ = {isa = PBXBuildFile; fileRef = E5C16B14E1CE4868886A1A00 /* OpenSans-SemiBold.ttf */; }; 7FD482692864DC5900A5B18B /* OpenSans-SemiBoldItalic.ttf in Resources */ = {isa = PBXBuildFile; fileRef = 0DB14DFDF6E04FA69FE769DC /* OpenSans-SemiBoldItalic.ttf */; }; + 7FDEF5362C2D01CC0093AADB /* certs in Resources */ = {isa = PBXBuildFile; fileRef = 7FDEF5352C2D01CC0093AADB /* certs */; }; 7FF9C03D2983E7C6005CDCF5 /* ErrorSharingView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 7FF9C03C2983E7C6005CDCF5 /* ErrorSharingView.swift */; }; 83ABFD122C1C90D90029685B /* calls_dynamic.mp3 in Resources */ = {isa = PBXBuildFile; fileRef = 83ABFD0E2C1C90D90029685B /* calls_dynamic.mp3 */; }; 83ABFD132C1C90D90029685B /* calls_cheerful.mp3 in Resources */ = {isa = PBXBuildFile; fileRef = 83ABFD0F2C1C90D90029685B /* calls_cheerful.mp3 */; }; @@ -360,6 +361,7 @@ 7FCEFB9126B7934F006DC1DE /* SDWebImageDownloaderOperation+Swizzle.h */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.h; path = "SDWebImageDownloaderOperation+Swizzle.h"; sourceTree = ""; }; 7FCEFB9226B7934F006DC1DE /* SDWebImageDownloaderOperation+Swizzle.m */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.objc; path = "SDWebImageDownloaderOperation+Swizzle.m"; sourceTree = ""; }; 7FD482282864D69700A5B18B /* Assets.xcassets */ = {isa = PBXFileReference; lastKnownFileType = folder.assetcatalog; path = Assets.xcassets; sourceTree = ""; }; + 7FDEF5352C2D01CC0093AADB /* certs */ = {isa = PBXFileReference; lastKnownFileType = folder; name = certs; path = ../assets/certs; sourceTree = ""; }; 7FF9C03C2983E7C6005CDCF5 /* ErrorSharingView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ErrorSharingView.swift; sourceTree = ""; }; 7FFE32B51FD9CCAA0038C7A0 /* FLAnimatedImage.framework */ = {isa = PBXFileReference; explicitFileType = wrapper.framework; path = FLAnimatedImage.framework; sourceTree = BUILT_PRODUCTS_DIR; }; 7FFE32B61FD9CCAA0038C7A0 /* KSCrash.framework */ = {isa = PBXFileReference; explicitFileType = wrapper.framework; path = KSCrash.framework; sourceTree = BUILT_PRODUCTS_DIR; }; @@ -1004,6 +1006,7 @@ 83CBB9F61A601CBA00E9B192 = { isa = PBXGroup; children = ( + 7FDEF5352C2D01CC0093AADB /* certs */, 27C667AB2952425700E590D5 /* ErrorReporting */, 13B07FAE1A68108700A75B9A /* Mattermost */, 7F581D33221ED5C60099E66B /* NotificationService */, @@ -1214,6 +1217,7 @@ isa = PBXResourcesBuildPhase; buildActionMask = 2147483647; files = ( + 7FDEF5362C2D01CC0093AADB /* certs in Resources */, 83ABFD132C1C90D90029685B /* calls_cheerful.mp3 in Resources */, 13B07FBF1A68108700A75B9A /* Images.xcassets in Resources */, 7F0F4B0A24BA173900E14C60 /* LaunchScreen.storyboard in Resources */, diff --git a/ios/Podfile.lock b/ios/Podfile.lock index e564e2462..ddb5e0452 100644 --- a/ios/Podfile.lock +++ b/ios/Podfile.lock @@ -1169,7 +1169,7 @@ PODS: - Yoga - react-native-netinfo (11.3.2): - React-Core - - react-native-network-client (1.6.0): + - react-native-network-client (1.7.0): - Alamofire (~> 5.9.1) - DoubleConversion - glog @@ -2040,7 +2040,7 @@ SPEC CHECKSUMS: react-native-emm: ecab44d78fb1cc7d7b7901914f48fb6309c46ff2 react-native-image-picker: c3afe5472ef870d98a4b28415fc0b928161ee5f7 react-native-netinfo: 076df4f9b07f6670acf4ce9a75aac8d34c2e2ccc - react-native-network-client: a862d041763721fddb9802a24fb6bb28208781cb + react-native-network-client: 65294121f883670b5e3b238a82693c7a810d74f5 react-native-notifications: 4601a5a8db4ced6ae7cfc43b44d35fe437ac50c4 react-native-paste-input: 011a9916ef3acf809a7da122847c61ca0f93a60e react-native-performance: ff93f8af3b2ee9519fd7879896aa9b8b8272691d diff --git a/libraries/@mattermost/rnshare/android/src/main/java/com/mattermost/rnshare/ShareWorker.kt b/libraries/@mattermost/rnshare/android/src/main/java/com/mattermost/rnshare/ShareWorker.kt index 79063c2a0..1b1e34df0 100644 --- a/libraries/@mattermost/rnshare/android/src/main/java/com/mattermost/rnshare/ShareWorker.kt +++ b/libraries/@mattermost/rnshare/android/src/main/java/com/mattermost/rnshare/ShareWorker.kt @@ -1,12 +1,14 @@ package com.mattermost.rnshare import android.content.Context +import android.util.Base64 import android.util.Log import androidx.core.app.NotificationCompat import androidx.work.ForegroundInfo import androidx.work.Worker import androidx.work.WorkerParameters import com.mattermost.rnshare.helpers.RealPathUtil +import okhttp3.CertificatePinner import okhttp3.MediaType import okhttp3.MediaType.Companion.toMediaType import okhttp3.MediaType.Companion.toMediaTypeOrNull @@ -22,11 +24,60 @@ import org.json.JSONException import org.json.JSONObject import java.io.File import java.io.IOException +import java.io.InputStream +import java.security.MessageDigest +import java.security.cert.CertificateFactory +import java.security.cert.X509Certificate import java.util.Objects -class ShareWorker(context: Context, workerParameters: WorkerParameters) : Worker(context, workerParameters) { - private val okHttpClient = OkHttpClient() +class ShareWorker(private val context: Context, workerParameters: WorkerParameters) : Worker(context, workerParameters) { private val jsonType: MediaType? = "application/json; charset=utf-8".toMediaTypeOrNull() + private val okHttpClient: OkHttpClient + get() { + val builder = OkHttpClient.Builder() + val fingerprintsMap = getCertificatesFingerPrints() + if (fingerprintsMap.isNotEmpty()) { + val pinner = CertificatePinner.Builder() + for ((domain, fingerprints) in fingerprintsMap) { + for (fingerprint in fingerprints) { + pinner.add(domain, "sha256/$fingerprint") + } + } + val certificatePinner = pinner.build() + builder.certificatePinner(certificatePinner) + } + return builder.build() + } + + 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") + Base64.encodeToString(sha256.digest(certificate.publicKey.encoded), Base64.NO_WRAP) + val fingerprintBytes = sha256.digest(certificate.publicKey.encoded) + return Base64.encodeToString(fingerprintBytes, Base64.NO_WRAP) + } + + private fun getCertificatesFingerPrints(): Map> { + val fingerprintsMap = mutableMapOf>() + val assetsManager = context.assets + val certFiles = assetsManager.list("certs")?.filter { it.endsWith(".cer") || it.endsWith(".crt") } ?: return emptyMap() + + for (fileName in certFiles) { + val domain = fileName.substringBeforeLast(".") + val certInputStream = assetsManager.open("certs/$fileName") + certInputStream.use { + val fingerprint = getCertificateFingerPrint(it) + if (fingerprintsMap.containsKey(domain)) { + fingerprintsMap[domain]?.add(fingerprint) + } else { + fingerprintsMap[domain] = mutableListOf(fingerprint) + } + } + } + + return fingerprintsMap + } override fun doWork(): Result { val jsonString = inputData.getString("json_data") ?: return Result.failure() diff --git a/libraries/@mattermost/rnshare/android/src/main/java/com/mattermost/rnshare/helpers/RealPathUtil.kt b/libraries/@mattermost/rnshare/android/src/main/java/com/mattermost/rnshare/helpers/RealPathUtil.kt index 1cad57c84..4d9b6eae7 100644 --- a/libraries/@mattermost/rnshare/android/src/main/java/com/mattermost/rnshare/helpers/RealPathUtil.kt +++ b/libraries/@mattermost/rnshare/android/src/main/java/com/mattermost/rnshare/helpers/RealPathUtil.kt @@ -1,10 +1,8 @@ package com.mattermost.rnshare.helpers import android.content.Context -import android.database.Cursor import android.net.Uri import android.provider.DocumentsContract -import android.provider.MediaStore import android.provider.OpenableColumns import android.text.TextUtils import android.util.Log @@ -49,25 +47,10 @@ object RealPathUtil { return null } } - } else if (isMediaDocument(uri)) { + } + else if (isMediaDocument(uri)) { // MediaProvider - val docId = DocumentsContract.getDocumentId(uri) - val split = docId.split((":").toRegex()).dropLastWhile { it.isEmpty() }.toTypedArray() - val type = split[0] - var contentUri: Uri? = null - when (type) { - "image" -> { - contentUri = MediaStore.Images.Media.EXTERNAL_CONTENT_URI - } - "video" -> { - contentUri = MediaStore.Video.Media.EXTERNAL_CONTENT_URI - } - "audio" -> { - contentUri = MediaStore.Audio.Media.EXTERNAL_CONTENT_URI - } - } - val selectionArgs = arrayOf(split[1]) - return contentUri?.let { getDataColumn(context, it, selectionArgs) } + return getPathFromSavingTempFile(context, uri) } } if ("content".equals(uri.scheme, ignoreCase = true)) { @@ -133,27 +116,6 @@ object RealPathUtil { return f.name } - private fun getDataColumn(context:Context, uri:Uri, selectionArgs:Array): String? { - var cursor: Cursor? = null - val column = "_data" - val selection = "_id=?" - val projection = arrayOf(column) - try - { - cursor = context.contentResolver.query(uri, projection, selection, selectionArgs, null) - if (cursor != null && cursor.moveToFirst()) { - val index = cursor.getColumnIndexOrThrow(column) - return cursor.getString(index) - } - } - finally - { - cursor?.close() - } - - return null - } - private fun isExternalStorageDocument(uri:Uri):Boolean { return "com.android.externalstorage.documents" == uri.authority } diff --git a/package-lock.json b/package-lock.json index 2ff687247..6a4c696d6 100644 --- a/package-lock.json +++ b/package-lock.json @@ -22,7 +22,7 @@ "@mattermost/hardware-keyboard": "file:./libraries/@mattermost/hardware-keyboard", "@mattermost/keyboard-tracker": "file:./libraries/@mattermost/keyboard-tracker", "@mattermost/react-native-emm": "1.5.0", - "@mattermost/react-native-network-client": "1.6.0", + "@mattermost/react-native-network-client": "1.7.0", "@mattermost/react-native-paste-input": "0.8.0", "@mattermost/react-native-turbo-log": "0.4.0", "@mattermost/rnshare": "file:./libraries/@mattermost/rnshare", @@ -49,7 +49,7 @@ "emoji-regex": "10.3.0", "expo": "51.0.14", "expo-application": "5.9.1", - "expo-crypto": "~13.0.2", + "expo-crypto": "13.0.2", "expo-device": "6.0.2", "expo-image": "1.12.12", "expo-linear-gradient": "13.0.2", @@ -5609,9 +5609,9 @@ } }, "node_modules/@mattermost/react-native-network-client": { - "version": "1.6.0", - "resolved": "https://registry.npmjs.org/@mattermost/react-native-network-client/-/react-native-network-client-1.6.0.tgz", - "integrity": "sha512-SUXmF0sF4iyAgVovK8VYtUCF38MchAI89GwrvhkvaB+MQKMruDsqaKBLiwW4Rs6jq4dVbmTLV/7FTP0akTiyRQ==", + "version": "1.7.0", + "resolved": "https://registry.npmjs.org/@mattermost/react-native-network-client/-/react-native-network-client-1.7.0.tgz", + "integrity": "sha512-m/O1BVXHYZkDjJONifyAfFygjE0SVpzUCrvhaqK0icG9PGoMoycSVtMqN/cHFaG2jASgEQY4pTgDz0UhcNCWDw==", "dependencies": { "validator": "13.12.0", "zod": "3.23.8" diff --git a/package.json b/package.json index c36d56c8e..0f7def730 100644 --- a/package.json +++ b/package.json @@ -23,7 +23,7 @@ "@mattermost/hardware-keyboard": "file:./libraries/@mattermost/hardware-keyboard", "@mattermost/keyboard-tracker": "file:./libraries/@mattermost/keyboard-tracker", "@mattermost/react-native-emm": "1.5.0", - "@mattermost/react-native-network-client": "1.6.0", + "@mattermost/react-native-network-client": "1.7.0", "@mattermost/react-native-paste-input": "0.8.0", "@mattermost/react-native-turbo-log": "0.4.0", "@mattermost/rnshare": "file:./libraries/@mattermost/rnshare",