SSL Pinned Certificate (#8055)
This commit is contained in:
parent
c34a131cdd
commit
de6ccaef01
20 changed files with 440 additions and 115 deletions
4
android/app/src/main/assets/certs/.gitignore
vendored
Normal file
4
android/app/src/main/assets/certs/.gitignore
vendored
Normal file
|
|
@ -0,0 +1,4 @@
|
|||
# Ignore everything in this directory
|
||||
*
|
||||
# Except this file
|
||||
!.gitignore
|
||||
|
|
@ -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);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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<string, Client> = {};
|
||||
|
||||
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}));
|
||||
},
|
||||
}],
|
||||
);
|
||||
}
|
||||
};
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
|
|
|
|||
4
assets/certs/.gitignore
vendored
Normal file
4
assets/certs/.gitignore
vendored
Normal file
|
|
@ -0,0 +1,4 @@
|
|||
# Ignore everything in this directory
|
||||
*
|
||||
# Except this file
|
||||
!.gitignore
|
||||
|
|
@ -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]
|
||||
|
||||
|
|
|
|||
15
ios/Gekidou/Sources/Gekidou/Bundle+Extensions.swift
Normal file
15
ios/Gekidou/Sources/Gekidou/Bundle+Extensions.swift
Normal file
|
|
@ -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
|
||||
}
|
||||
}
|
||||
|
|
@ -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..<SecTrustGetCertificateCount(trust)).compactMap { index in
|
||||
SecTrustGetCertificateAtIndex(trust, index)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
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
ios/Gekidou/Sources/Gekidou/Networking/Network+Error.swift
Normal file
68
ios/Gekidou/Sources/Gekidou/Networking/Network+Error.swift
Normal file
|
|
@ -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."
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -6,6 +6,7 @@
|
|||
//
|
||||
|
||||
import Foundation
|
||||
import os.log
|
||||
|
||||
public typealias ResponseHandler = (_ data: Data?, _ response: URLResponse?, _ error: Error?) -> 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)")!
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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(
|
||||
|
|
|
|||
|
|
@ -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()
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
||||
|
|
|
|||
|
|
@ -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 = "<group>"; };
|
||||
7FCEFB9226B7934F006DC1DE /* SDWebImageDownloaderOperation+Swizzle.m */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.objc; path = "SDWebImageDownloaderOperation+Swizzle.m"; sourceTree = "<group>"; };
|
||||
7FD482282864D69700A5B18B /* Assets.xcassets */ = {isa = PBXFileReference; lastKnownFileType = folder.assetcatalog; path = Assets.xcassets; sourceTree = "<group>"; };
|
||||
7FDEF5352C2D01CC0093AADB /* certs */ = {isa = PBXFileReference; lastKnownFileType = folder; name = certs; path = ../assets/certs; sourceTree = "<group>"; };
|
||||
7FF9C03C2983E7C6005CDCF5 /* ErrorSharingView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ErrorSharingView.swift; sourceTree = "<group>"; };
|
||||
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 */,
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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<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()
|
||||
|
||||
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()
|
||||
|
|
|
|||
|
|
@ -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>): 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
|
||||
}
|
||||
|
|
|
|||
10
package-lock.json
generated
10
package-lock.json
generated
|
|
@ -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"
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
|
|
|
|||
Loading…
Reference in a new issue