Fix MM 56723 (#7883)

* Fix MM 56723 (iOS)

* Add android

* Android fixes and version checking

* Add version check to ios

* Address feedback

* Add all versions to android

* Check all versions on iOS

* Fix unhandled version case

* Add comments

* Add final version numbers
This commit is contained in:
Daniel Espino García 2024-04-24 17:12:56 +02:00 committed by GitHub
parent 205816cb93
commit c5e6e34fea
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
17 changed files with 561 additions and 4 deletions

View file

@ -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 {

View file

@ -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.* { *; }

View file

@ -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);

View file

@ -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(

View file

@ -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")
}

View file

@ -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);
}

View file

@ -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';

View file

@ -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();

View file

@ -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.",

View file

@ -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(

View file

@ -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<NotificationClaims>(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
}
}

View file

@ -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<String>("id")
let valueCol = Expression<String>("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<String>("id")

View file

@ -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")

View file

@ -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(

View file

@ -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"
}
}
]
},

View file

@ -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)

View file

@ -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, *),