diff --git a/android/app/build.gradle b/android/app/build.gradle index 204fb02a5..1319a6038 100644 --- a/android/app/build.gradle +++ b/android/app/build.gradle @@ -212,6 +212,12 @@ dependencies { androidTestImplementation('com.wix:detox:+') implementation project(':reactnativenotifications') implementation project(':watermelondb-jsi') + + api('io.jsonwebtoken:jjwt-api:0.12.5') + runtimeOnly('io.jsonwebtoken:jjwt-impl:0.12.5') + runtimeOnly('io.jsonwebtoken:jjwt-orgjson:0.12.5') { + exclude(group: 'org.json', module: 'json') //provided by Android natively + } } configurations.all { diff --git a/android/app/proguard-rules.pro b/android/app/proguard-rules.pro index 11b025724..2f86106f0 100644 --- a/android/app/proguard-rules.pro +++ b/android/app/proguard-rules.pro @@ -8,3 +8,8 @@ # http://developer.android.com/guide/developing/tools/proguard.html # Add any project specific keep options here: +-keepattributes InnerClasses + +-keep class io.jsonwebtoken.** { *; } +-keepnames class io.jsonwebtoken.* { *; } +-keepnames interface io.jsonwebtoken.* { *; } diff --git a/android/app/src/main/java/com/mattermost/helpers/CustomPushNotificationHelper.java b/android/app/src/main/java/com/mattermost/helpers/CustomPushNotificationHelper.java index 392f5f1b7..b2172509c 100644 --- a/android/app/src/main/java/com/mattermost/helpers/CustomPushNotificationHelper.java +++ b/android/app/src/main/java/com/mattermost/helpers/CustomPushNotificationHelper.java @@ -19,6 +19,7 @@ import android.graphics.RectF; import android.os.Build; import android.os.Bundle; import android.text.TextUtils; +import android.util.Base64; import android.util.Log; import androidx.annotation.NonNull; @@ -32,14 +33,24 @@ import com.mattermost.rnbeta.*; import com.nozbe.watermelondb.WMDatabase; import java.io.IOException; +import java.security.KeyFactory; +import java.security.PublicKey; +import java.security.spec.X509EncodedKeySpec; import java.util.Date; import java.util.Objects; +import io.jsonwebtoken.IncorrectClaimException; +import io.jsonwebtoken.JwtException; +import io.jsonwebtoken.Jwts; +import io.jsonwebtoken.MissingClaimException; import okhttp3.OkHttpClient; import okhttp3.Request; import okhttp3.Response; import static com.mattermost.helpers.database_extension.GeneralKt.getDatabaseForServer; +import static com.mattermost.helpers.database_extension.GeneralKt.getDeviceToken; +import static com.mattermost.helpers.database_extension.SystemKt.queryConfigServerVersion; +import static com.mattermost.helpers.database_extension.SystemKt.queryConfigSigningKey; import static com.mattermost.helpers.database_extension.UserKt.getLastPictureUpdate; public class CustomPushNotificationHelper { @@ -227,6 +238,154 @@ public class CustomPushNotificationHelper { } } + public static boolean verifySignature(final Context context, String signature, String serverUrl, String ackId) { + if (signature == null) { + // Backward compatibility with old push proxies + Log.i("Mattermost Notifications Signature verification", "No signature in the notification"); + return true; + } + + if (serverUrl == null) { + Log.i("Mattermost Notifications Signature verification", "No server_url for server_id"); + return false; + } + + DatabaseHelper dbHelper = DatabaseHelper.Companion.getInstance(); + if (dbHelper == null) { + Log.i("Mattermost Notifications Signature verification", "Cannot access the database"); + return false; + } + + WMDatabase db = getDatabaseForServer(dbHelper, context, serverUrl); + if (db == null) { + Log.i("Mattermost Notifications Signature verification", "Cannot access the server database"); + return false; + } + + if (signature.equals("NO_SIGNATURE")) { + String version = queryConfigServerVersion(db); + if (version == null) { + Log.i("Mattermost Notifications Signature verification", "No server version"); + return false; + } + + if (!version.matches("[0-9]+(\\.[0-9]+)*")) { + Log.i("Mattermost Notifications Signature verification", "Invalid server version"); + return false; + } + + String[] parts = version.split("\\."); + int major = parts.length > 0 ? Integer.parseInt(parts[0]) : 0; + int minor = parts.length > 1 ? Integer.parseInt(parts[1]) : 0; + int patch = parts.length > 2 ? Integer.parseInt(parts[2]) : 0; + + int[][] targets = {{9,8,0},{9,7,3},{9,6,3},{9,5,5},{8,1,14}}; + boolean rejected = false; + for (int i = 0; i < targets.length; i++) { + boolean first = i == 0; + int[] targetVersion = targets[i]; + int majorTarget = targetVersion[0]; + int minorTarget = targetVersion[1]; + int patchTarget = targetVersion[2]; + + if (major > majorTarget) { + // Only reject if we are considering the first (highest) version. + // Any version in between should be acceptable. + rejected = first; + break; + } + + if (major < majorTarget) { + // Continue to see if it complies with a smaller target + continue; + } + + // Same major + if (minor > minorTarget) { + // Only reject if we are considering the first (highest) version. + // Any version in between should be acceptable. + rejected = first; + break; + } + + if (minor < minorTarget) { + // Continue to see if it complies with a smaller target + continue; + } + + // Same major and same minor + if (patch >= patchTarget) { + rejected = true; + break; + } + + // Patch is lower than target + return true; + } + + if (rejected) { + Log.i("Mattermost Notifications Signature verification", "Server version should send signature"); + return false; + } + + // Version number is below any of the targets, so it should not send the signature + return true; + } + + String signingKey = queryConfigSigningKey(db); + if (signingKey == null) { + Log.i("Mattermost Notifications Signature verification", "No signing key"); + return false; + } + + try { + byte[] encoded = Base64.decode(signingKey, 0); + KeyFactory kf = KeyFactory.getInstance("EC"); + PublicKey pubKey = (PublicKey) kf.generatePublic(new X509EncodedKeySpec(encoded)); + + String storedDeviceToken = getDeviceToken(dbHelper); + if (storedDeviceToken == null) { + Log.i("Mattermost Notifications Signature verification", "No device token stored"); + return false; + } + String[] tokenParts = storedDeviceToken.split(":", 2); + if (tokenParts.length != 2) { + Log.i("Mattermost Notifications Signature verification", "Wrong stored device token format"); + return false; + } + String deviceToken = tokenParts[1].substring(0, tokenParts[1].length() -1 ); + if (deviceToken.isEmpty()) { + Log.i("Mattermost Notifications Signature verification", "Empty stored device token"); + return false; + } + + Jwts.parser() + .require("ack_id", ackId) + .require("device_id", deviceToken) + .verifyWith((PublicKey) pubKey) + .build() + .parseSignedClaims(signature); + } catch (MissingClaimException e) { + Log.i("Mattermost Notifications Signature verification", String.format("Missing claim: %s", e.getMessage())); + e.printStackTrace(); + return false; + } catch (IncorrectClaimException e) { + Log.i("Mattermost Notifications Signature verification", String.format("Incorrect claim: %s", e.getMessage())); + e.printStackTrace(); + return false; + } catch (JwtException e) { + Log.i("Mattermost Notifications Signature verification", String.format("Cannot verify JWT: %s", e.getMessage())); + e.printStackTrace(); + return false; + } catch (Exception e) { + Log.i("Mattermost Notifications Signature verification", String.format("Exception while parsing JWT: %s", e.getMessage())); + e.printStackTrace(); + return false; + } + + return true; + } + private static Bitmap getCircleBitmap(Bitmap bitmap) { final Bitmap output = Bitmap.createBitmap(bitmap.getWidth(), bitmap.getHeight(), Bitmap.Config.ARGB_8888); diff --git a/android/app/src/main/java/com/mattermost/helpers/database_extension/General.kt b/android/app/src/main/java/com/mattermost/helpers/database_extension/General.kt index 64c31b8cf..a3789cbab 100644 --- a/android/app/src/main/java/com/mattermost/helpers/database_extension/General.kt +++ b/android/app/src/main/java/com/mattermost/helpers/database_extension/General.kt @@ -56,7 +56,7 @@ fun DatabaseHelper.getDatabaseForServer(context: Context?, serverUrl: String): W defaultDatabase!!.rawQuery(query, arrayOf(serverUrl)).use { cursor -> if (cursor.count == 1) { cursor.moveToFirst() - val databasePath = cursor.getString(0) + val databasePath = String.format("file://%s", cursor.getString(0)) return WMDatabase.getInstance(databasePath, context!!) } } @@ -67,6 +67,22 @@ fun DatabaseHelper.getDatabaseForServer(context: Context?, serverUrl: String): W return null } +fun DatabaseHelper.getDeviceToken(): String? { + try { + val query = "SELECT value FROM Global WHERE id=?" + defaultDatabase!!.rawQuery(query, arrayOf("deviceToken")).use { cursor -> + if (cursor.count == 1) { + cursor.moveToFirst() + return cursor.getString(0); + } + } + } catch (e: Exception) { + e.printStackTrace() + } + + return null +} + fun find(db: WMDatabase, tableName: String, id: String?): ReadableMap? { try { db.rawQuery( diff --git a/android/app/src/main/java/com/mattermost/helpers/database_extension/System.kt b/android/app/src/main/java/com/mattermost/helpers/database_extension/System.kt index efbf48449..147448b45 100644 --- a/android/app/src/main/java/com/mattermost/helpers/database_extension/System.kt +++ b/android/app/src/main/java/com/mattermost/helpers/database_extension/System.kt @@ -30,3 +30,11 @@ fun queryConfigDisplayNameSetting(db: WMDatabase): String? { return null } + +fun queryConfigSigningKey(db: WMDatabase): String? { + return find(db, "Config", "AsymmetricSigningPublicKey")?.getString("value") +} + +fun queryConfigServerVersion(db: WMDatabase): String? { + return find(db, "Config", "Version")?.getString("value") +} diff --git a/android/app/src/main/java/com/mattermost/rnbeta/CustomPushNotification.java b/android/app/src/main/java/com/mattermost/rnbeta/CustomPushNotification.java index 04d77fb7e..b0054eb23 100644 --- a/android/app/src/main/java/com/mattermost/rnbeta/CustomPushNotification.java +++ b/android/app/src/main/java/com/mattermost/rnbeta/CustomPushNotification.java @@ -53,6 +53,7 @@ public class CustomPushNotification extends PushNotification { final String ackId = initialData.getString("ack_id"); final String postId = initialData.getString("post_id"); final String channelId = initialData.getString("channel_id"); + final String signature = initialData.getString("signature"); final boolean isIdLoaded = initialData.getString("id_loaded") != null && initialData.getString("id_loaded").equals("true"); int notificationId = NotificationHelper.getNotificationId(initialData); @@ -70,6 +71,11 @@ public class CustomPushNotification extends PushNotification { } } + if (!CustomPushNotificationHelper.verifySignature(mContext, signature, serverUrl, ackId)) { + Log.i("Mattermost Notifications Signature verification", "Notification skipped because we could not verify it."); + return; + } + finishProcessingNotification(serverUrl, type, channelId, notificationId); } diff --git a/app/constants/push_notification.ts b/app/constants/push_notification.ts index 37ed85d3b..c1b603112 100644 --- a/app/constants/push_notification.ts +++ b/app/constants/push_notification.ts @@ -1,6 +1,14 @@ // Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved. // See LICENSE.txt for license information. +import {defineMessage} from 'react-intl'; + +// Needed for localization on iOS native side +export const notVerifiedErrorMessage = defineMessage({ + id: 'native.ios.notifications.not_verified', + defaultMessage: 'We could not verify this notification with the server', +}); + export const CATEGORY = 'CAN_REPLY'; export const REPLY_ACTION = 'REPLY_ACTION'; diff --git a/app/init/push_notifications.ts b/app/init/push_notifications.ts index 517224c55..f25259328 100644 --- a/app/init/push_notifications.ts +++ b/app/init/push_notifications.ts @@ -31,7 +31,7 @@ import EphemeralStore from '@store/ephemeral_store'; import NavigationStore from '@store/navigation_store'; import {isBetaApp} from '@utils/general'; import {isMainActivity, isTablet} from '@utils/helpers'; -import {logInfo} from '@utils/log'; +import {logDebug, logInfo} from '@utils/log'; import {convertToNotificationData} from '@utils/notification'; class PushNotifications { @@ -232,6 +232,10 @@ class PushNotifications { // This triggers when the app was in the background (iOS) onNotificationReceivedBackground = async (incoming: Notification, completion: (response: NotificationBackgroundFetchResult) => void) => { + if (incoming.payload.verified === 'false') { + logDebug('not handling background notification because it was not verified, ackId=', incoming.payload.ackId); + return; + } const notification = convertToNotificationData(incoming, false); this.processNotification(notification); @@ -241,6 +245,10 @@ class PushNotifications { // This triggers when the app was in the foreground (Android and iOS) // Also triggers when the app was in the background (Android) onNotificationReceivedForeground = (incoming: Notification, completion: (response: NotificationCompletion) => void) => { + if (incoming.payload.verified === 'false') { + logDebug('not handling foreground notification because it was not verified, ackId=', incoming.payload.ackId); + return; + } const notification = convertToNotificationData(incoming, false); if (AppState.currentState !== 'inactive') { notification.foreground = AppState.currentState === 'active' && isMainActivity(); diff --git a/assets/base/i18n/en.json b/assets/base/i18n/en.json index 4a4ec368d..8ca853605 100644 --- a/assets/base/i18n/en.json +++ b/assets/base/i18n/en.json @@ -768,6 +768,7 @@ "more_messages.text": "{count} new {count, plural, one {message} other {messages}}", "msg_typing.areTyping": "{users} and {last} are typing...", "msg_typing.isTyping": "{user} is typing...", + "native.ios.notifications.not_verified": "We could not verify this notification with the server", "notification_settings.auto_responder": "Automatic Replies", "notification_settings.auto_responder.default_message": "Hello, I am out of office and unable to respond to messages.", "notification_settings.auto_responder.footer.message": "Set a custom message that is automatically sent in response to direct messages, such as an out of office or vacation reply. Enabling this setting changes your status to Out of Office and disables notifications.", diff --git a/ios/Gekidou/Package.swift b/ios/Gekidou/Package.swift index 3b3aac297..004fcf474 100644 --- a/ios/Gekidou/Package.swift +++ b/ios/Gekidou/Package.swift @@ -14,7 +14,8 @@ let package = Package( ], dependencies: [ // Dependencies declare other packages that this package depends on. - .package(url: "https://github.com/stephencelis/SQLite.swift.git", from: "0.14.1") + .package(url: "https://github.com/stephencelis/SQLite.swift.git", from: "0.14.1"), + .package(url: "https://github.com/Kitura/Swift-JWT.git", from:"3.6.1") ], targets: [ // Targets are the basic building blocks of a package. A target can define a module or a test suite. @@ -22,7 +23,8 @@ let package = Package( .target( name: "Gekidou", dependencies: [ - .product(name: "SQLite", package: "SQLite.swift") + .product(name: "SQLite", package: "SQLite.swift"), + .product(name: "SwiftJWT", package: "Swift-JWT"), ] ), .testTarget( diff --git a/ios/Gekidou/Sources/Gekidou/PushNotification/PushNotification+Signature.swift b/ios/Gekidou/Sources/Gekidou/PushNotification/PushNotification+Signature.swift new file mode 100644 index 000000000..0fe1da18c --- /dev/null +++ b/ios/Gekidou/Sources/Gekidou/PushNotification/PushNotification+Signature.swift @@ -0,0 +1,205 @@ +import Foundation +import UserNotifications +import os.log +import SwiftJWT + +struct NotificationClaims : Claims { + var ack_id: String; + var device_id: String; +} + +extension PushNotification { + public func verifySignatureFromNotification(_ notification: UNMutableNotificationContent) -> Bool { + return self.verifySignature(notification.userInfo) + } + public func verifySignature(_ userInfo: [AnyHashable : Any]) -> Bool { + guard let signature = userInfo["signature"] as? String + else { + // Backward compatibility with old push proxies + os_log( + OSLogType.default, + "Mattermost Notifications: Signature verification: No signature in the notification" + ) + return true + } + + guard let serverId = userInfo["server_id"] as? String + else { + os_log( + OSLogType.default, + "Mattermost Notifications: Signature verification: No server_id in the notification" + ) + return false + } + + guard let serverUrl = try? Database.default.getServerUrlForServer(serverId) + else { + os_log( + OSLogType.default, + "Mattermost Notifications: Signature verification: No server_url for server_id" + ) + return false + } + + if signature == "NO_SIGNATURE" { + guard let version = Database.default.getConfig(serverUrl, "Version") + else { + os_log( + OSLogType.default, + "Mattermost Notifications: Signature verification: No server version" + ) + return false + } + + let parts = version.components(separatedBy: "."); + if (parts.count < 3) { + os_log( + OSLogType.default, + "Mattermost Notifications: Signature verification: Invalid server version" + ) + return false + } + guard let major = Int(parts[0]), + let minor = Int(parts[1]), + let patch = Int(parts[2]) + else { + os_log( + OSLogType.default, + "Mattermost Notifications: Signature verification: Invalid server version" + ) + return false + } + + let versionTargets = [[9,8,0], [9,7,3], [9,6,3], [9,5,5], [8,1,14]] + var rejected = false + for (index, versionTarget) in versionTargets.enumerated() { + let first = index == 0; + let majorTarget = versionTarget[0] + let minorTarget = versionTarget[1] + let patchTarget = versionTarget[2] + + if (major > majorTarget) { + // Only reject if we are considering the first (highest) version. + // Any version in between should be acceptable. + rejected = first; + break; + } + + if (major < majorTarget) { + // Continue to see if it complies with a smaller target + continue; + } + + // Same major + if (minor > minorTarget) { + // Only reject if we are considering the first (highest) version. + // Any version in between should be acceptable. + rejected = first; + break; + } + + if (minor < minorTarget) { + // Continue to see if it complies with a smaller target + continue; + } + + // Same major and same minor + if (patch >= patchTarget) { + rejected = true; + break; + } + + // Patch is lower than target + return true; + } + + if (rejected) { + os_log( + OSLogType.default, + "Mattermost Notifications: Signature verification: Server version should send signature" + ) + return false; + } + + // Version number is below any of the targets, so it should not send the signature + return true + } + + guard let signingKey = Database.default.getConfig(serverUrl, "AsymmetricSigningPublicKey") + else { + os_log( + OSLogType.default, + "Mattermost Notifications: Signature verification: No signing key" + ) + return false + } + + let keyPEM = """ +-----BEGIN PUBLIC KEY----- +\(signingKey) +-----END PUBLIC KEY----- +""" + let jwtVerifier = JWTVerifier.es256(publicKey: keyPEM.data(using: .utf8)!) + guard let newJWT = try? JWT(jwtString: signature, verifier: jwtVerifier) + else { + os_log( + OSLogType.default, + "Mattermost Notifications: Signature verification: Cannot verify the signature" + ) + return false + } + + guard let ackId = userInfo["ack_id"] as? String + else { + os_log( + OSLogType.default, + "Mattermost Notifications: Signature verification: No ack_id in the notification" + ) + return false + } + + if (ackId != newJWT.claims.ack_id) { + os_log( + OSLogType.default, + "Mattermost Notifications: Signature verification: ackId is different" + ) + return false + } + + guard let storedDeviceToken = Database.default.getDeviceToken() + else { + os_log( + OSLogType.default, + "Mattermost Notifications: Signature verification: No device token" + ) + return false + } + + let tokenParts = storedDeviceToken.components(separatedBy: ":") + if (tokenParts.count != 2) { + os_log( + OSLogType.default, + "Mattermost Notifications: Signature verification: Wrong stored device token format" + ) + return false + } + let deviceToken = tokenParts[1].dropLast(1) + if (deviceToken.isEmpty) { + os_log( + OSLogType.default, + "Mattermost Notifications: Signature verification: Empty stored device token" + ) + return false + } + + if (deviceToken != newJWT.claims.device_id) { + os_log( + OSLogType.default, + "Mattermost Notifications: Signature verification: Device token is different" + ) + return false + } + + return true + } +} diff --git a/ios/Gekidou/Sources/Gekidou/Storage/Database+System.swift b/ios/Gekidou/Sources/Gekidou/Storage/Database+System.swift index 9608bf4dc..0956ea2d5 100644 --- a/ios/Gekidou/Sources/Gekidou/Storage/Database+System.swift +++ b/ios/Gekidou/Sources/Gekidou/Storage/Database+System.swift @@ -10,6 +10,19 @@ import Foundation import SQLite extension Database { + public func getDeviceToken() -> String? { + if let db = try? Connection(DEFAULT_DB_PATH) { + let idCol = Expression("id") + let valueCol = Expression("value") + let query = globalTable.select(valueCol).filter(idCol == "deviceToken") + if let result = try? db.pluck(query) { + return try? result.get(valueCol) + } + } + + return nil + } + public func getConfig(_ serverUrl: String, _ key: String) -> String? { if let db = try? getDatabaseForServer(serverUrl) { let id = Expression("id") diff --git a/ios/Gekidou/Sources/Gekidou/Storage/Database.swift b/ios/Gekidou/Sources/Gekidou/Storage/Database.swift index 3f64c4247..1caf68c4a 100644 --- a/ios/Gekidou/Sources/Gekidou/Storage/Database.swift +++ b/ios/Gekidou/Sources/Gekidou/Storage/Database.swift @@ -40,6 +40,7 @@ public class Database: NSObject { internal var defaultDB: OpaquePointer? = nil internal var serversTable = Table("Servers") + internal var globalTable = Table("Global") internal var systemTable = Table("System") internal var teamTable = Table("Team") internal var myTeamTable = Table("MyTeam") diff --git a/ios/GekidouWrapper.swift b/ios/GekidouWrapper.swift index 8a82055a6..32bedf951 100644 --- a/ios/GekidouWrapper.swift +++ b/ios/GekidouWrapper.swift @@ -23,6 +23,10 @@ import Gekidou }) } + @objc func verifySignature(_ notification: [AnyHashable:Any]) -> Bool { + return PushNotification.default.verifySignature(notification) + } + @objc func attachSession(_ id: String, completionHandler: @escaping () -> Void) { let shareExtension = ShareExtension() shareExtension.attachSession( diff --git a/ios/Mattermost.xcworkspace/xcshareddata/swiftpm/Package.resolved b/ios/Mattermost.xcworkspace/xcshareddata/swiftpm/Package.resolved index 9e215be82..8d3d7c36f 100644 --- a/ios/Mattermost.xcworkspace/xcshareddata/swiftpm/Package.resolved +++ b/ios/Mattermost.xcworkspace/xcshareddata/swiftpm/Package.resolved @@ -1,6 +1,51 @@ { "object": { "pins": [ + { + "package": "Cryptor", + "repositoryURL": "https://github.com/Kitura/BlueCryptor.git", + "state": { + "branch": null, + "revision": "cec97c24b111351e70e448972a7d3fe68a756d6d", + "version": "2.0.2" + } + }, + { + "package": "CryptorECC", + "repositoryURL": "https://github.com/Kitura/BlueECC.git", + "state": { + "branch": null, + "revision": "1485268a54f8135435a825a855e733f026fa6cc8", + "version": "1.2.201" + } + }, + { + "package": "CryptorRSA", + "repositoryURL": "https://github.com/Kitura/BlueRSA.git", + "state": { + "branch": null, + "revision": "440f78db26d8bb073f29590f1c7bd31004da09ae", + "version": "1.0.201" + } + }, + { + "package": "KituraContracts", + "repositoryURL": "https://github.com/Kitura/KituraContracts.git", + "state": { + "branch": null, + "revision": "8a4778c3aa7833e9e1af884e8819d436c237cd70", + "version": "1.2.201" + } + }, + { + "package": "LoggerAPI", + "repositoryURL": "https://github.com/Kitura/LoggerAPI.git", + "state": { + "branch": null, + "revision": "e82d34eab3f0b05391082b11ea07d3b70d2f65bb", + "version": "1.9.200" + } + }, { "package": "OpenGraph", "repositoryURL": "https://github.com/satoshi-takano/OpenGraph.git", @@ -27,6 +72,24 @@ "revision": "7a2e3cd27de56f6d396e84f63beefd0267b55ccb", "version": "0.14.1" } + }, + { + "package": "SwiftJWT", + "repositoryURL": "https://github.com/Kitura/Swift-JWT.git", + "state": { + "branch": null, + "revision": "47c6384b6923e9bb1f214d2ba4bd52af39440588", + "version": "3.6.201" + } + }, + { + "package": "swift-log", + "repositoryURL": "https://github.com/apple/swift-log.git", + "state": { + "branch": null, + "revision": "e97a6fcb1ab07462881ac165fdbb37f067e205d5", + "version": "1.5.4" + } } ] }, diff --git a/ios/Mattermost/AppDelegate.mm b/ios/Mattermost/AppDelegate.mm index 97ec2bd54..0315aa57f 100644 --- a/ios/Mattermost/AppDelegate.mm +++ b/ios/Mattermost/AppDelegate.mm @@ -84,6 +84,13 @@ NSString* const NOTIFICATION_TEST_ACTION = @"test"; return; } + if (![[GekidouWrapper default] verifySignature:userInfo]) { + NSMutableDictionary *notification = [userInfo mutableCopy]; + [notification setValue:@"false" forKey:@"verified"]; + [RNNotifications didReceiveBackgroundNotification:notification withCompletionHandler:completionHandler]; + return; + } + if (isClearAction) { // When CRT is OFF: // If received a notification that a channel was read, remove all notifications from that channel (only with app in foreground/background) diff --git a/ios/NotificationService/NotificationService.swift b/ios/NotificationService/NotificationService.swift index abb06f6f4..22942dd40 100644 --- a/ios/NotificationService/NotificationService.swift +++ b/ios/NotificationService/NotificationService.swift @@ -20,6 +20,10 @@ class NotificationService: UNNotificationServiceExtension { PushNotification.default.postNotificationReceipt(bestAttemptContent, completionHandler: {[weak self] notification in if let notification = notification { self?.bestAttemptContent = notification + if (!PushNotification.default.verifySignatureFromNotification(notification)) { + self?.sendInvalidNotificationIntent() + return + } if (Gekidou.Preferences.default.object(forKey: "ApplicationIsRunning") as? String != "true") { PushNotification.default.fetchAndStoreDataForPushNotification(bestAttemptContent, withContentHandler: {notification in os_log(OSLogType.default, "Mattermost Notifications: processed data for db. Will call sendMessageIntent") @@ -75,6 +79,47 @@ class NotificationService: UNNotificationServiceExtension { } } + private func sendInvalidNotificationIntent() { + guard let notification = bestAttemptContent else { return } + os_log(OSLogType.default, "Mattermost Notifications: creating invalid intent") + + bestAttemptContent?.body = NSLocalizedString( "native.ios.notifications.not_verified", + value: "We could not verify this notification with the server", + comment: "") + bestAttemptContent?.userInfo.updateValue("false", forKey: "verified") + + if #available(iOSApplicationExtension 15.0, *) { + let intent = INSendMessageIntent(recipients: nil, + outgoingMessageType: .outgoingMessageText, + content: "We could not verify this notification with the server", + speakableGroupName: nil, + conversationIdentifier: "NOT_VERIFIED", + serviceName: nil, + sender: nil, + attachments: nil) + + let interaction = INInteraction(intent: intent, response: nil) + interaction.direction = .incoming + interaction.donate { error in + if error != nil { + self.contentHandler?(notification) + os_log(OSLogType.default, "Mattermost Notifications: sendMessageIntent intent error %{public}@", error! as CVarArg) + } + + do { + let updatedContent = try notification.updating(from: intent) + os_log(OSLogType.default, "Mattermost Notifications: present updated notification") + self.contentHandler?(updatedContent) + } catch { + os_log(OSLogType.default, "Mattermost Notifications: something failed updating the notification %{public}@", error as CVarArg) + self.contentHandler?(notification) + } + } + } else { + self.contentHandler?(notification) + } + } + private func sendMessageIntentCompletion(_ avatarData: Data?) { guard let notification = bestAttemptContent else { return } if #available(iOSApplicationExtension 15.0, *),