diff --git a/keyserver/src/push/send.js b/keyserver/src/push/send.js --- a/keyserver/src/push/send.js +++ b/keyserver/src/push/send.js @@ -25,7 +25,10 @@ rawThreadInfoFromServerThreadInfo, threadInfoFromRawThreadInfo, } from 'lib/shared/thread-utils.js'; -import { hasMinCodeVersion } from 'lib/shared/version-utils.js'; +import { + hasMinCodeVersion, + NEXT_CODE_VERSION, +} from 'lib/shared/version-utils.js'; import type { Platform, PlatformDetails } from 'lib/types/device-types.js'; import { messageTypes } from 'lib/types/message-types-enum.js'; import { @@ -69,6 +72,7 @@ wnsMaxNotificationPayloadByteSize, wnsPush, type WNSPushError, + blobServiceUpload, } from './utils.js'; import createIDs from '../creators/id-creator.js'; import { createUpdates } from '../creators/update-creator.js'; @@ -1022,6 +1026,23 @@ })); } + // The `messageInfos` field in notification payload is + // not used on MacOS so we can return early. + if (platformDetails.platform === 'macos') { + const macOSNotifsWithoutMessageInfos = + await prepareEncryptedAPNsNotifications( + devices, + notification, + platformDetails.codeVersion, + ); + return macOSNotifsWithoutMessageInfos.map( + ({ notification: notif, deviceToken }) => ({ + notification: notif, + deviceToken, + }), + ); + } + const notifsWithMessageInfos = await prepareEncryptedAPNsNotifications( devices, copyWithMessageInfos, @@ -1049,6 +1070,32 @@ ); } + const canQueryBlobService = hasMinCodeVersion(platformDetails, { + native: NEXT_CODE_VERSION, + }); + + let blobHash, encryptionKey, blobUploadError; + if (canQueryBlobService) { + ({ blobHash, encryptionKey, blobUploadError } = await blobServiceUpload( + copyWithMessageInfos.compile(), + )); + } + + if (blobUploadError) { + console.warn( + `Failed to upload payload of notification: ${uniqueID} ` + + `due to error: ${blobUploadError}`, + ); + } + + if (blobHash && encryptionKey) { + notification.payload = { + ...notification.payload, + blobHash, + encryptionKey, + }; + } + const notifsWithoutMessageInfos = await prepareEncryptedAPNsNotifications( devicesWithExcessiveSize, notification, diff --git a/native/ios/Comm.xcodeproj/project.pbxproj b/native/ios/Comm.xcodeproj/project.pbxproj --- a/native/ios/Comm.xcodeproj/project.pbxproj +++ b/native/ios/Comm.xcodeproj/project.pbxproj @@ -92,6 +92,8 @@ CBB0DF612B768007008E22FF /* CommMMKV.mm in Sources */ = {isa = PBXBuildFile; fileRef = CBB0DF5F2B768007008E22FF /* CommMMKV.mm */; }; CBCA09062A8E0E7400F75B3E /* StaffUtils.cpp in Sources */ = {isa = PBXBuildFile; fileRef = CBCA09052A8E0E6B00F75B3E /* StaffUtils.cpp */; }; CBCA09072A8E0E7D00F75B3E /* StaffUtils.cpp in Sources */ = {isa = PBXBuildFile; fileRef = CBCA09052A8E0E6B00F75B3E /* StaffUtils.cpp */; }; + CBCF984F2BA499DA00DBC3D9 /* CommIOSBlobClient.mm in Sources */ = {isa = PBXBuildFile; fileRef = CBCF984D2BA499DA00DBC3D9 /* CommIOSBlobClient.mm */; }; + CBCF98502BA49A0500DBC3D9 /* CommIOSBlobClient.mm in Sources */ = {isa = PBXBuildFile; fileRef = CBCF984D2BA499DA00DBC3D9 /* CommIOSBlobClient.mm */; }; CBDEC69B28ED867000C17588 /* GlobalDBSingleton.mm in Sources */ = {isa = PBXBuildFile; fileRef = CBDEC69A28ED867000C17588 /* GlobalDBSingleton.mm */; }; CBFBEEBA2B4ED90600729F1D /* RustBackupExecutor.cpp in Sources */ = {isa = PBXBuildFile; fileRef = CBFBEEB82B4ED90600729F1D /* RustBackupExecutor.cpp */; }; CBFE58292885852B003B94C9 /* ThreadOperations.cpp in Sources */ = {isa = PBXBuildFile; fileRef = CBFE58282885852B003B94C9 /* ThreadOperations.cpp */; }; @@ -315,6 +317,8 @@ CBCA09042A8E0E6B00F75B3E /* StaffUtils.h */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.h; path = StaffUtils.h; sourceTree = ""; }; CBCA09052A8E0E6B00F75B3E /* StaffUtils.cpp */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.cpp.cpp; path = StaffUtils.cpp; sourceTree = ""; }; CBCF57AB2B05096F00EC4BC0 /* AESCryptoModuleObjCCompat.h */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.c.h; name = AESCryptoModuleObjCCompat.h; path = Comm/CommAESCryptoUtils/AESCryptoModuleObjCCompat.h; sourceTree = ""; }; + CBCF984D2BA499DA00DBC3D9 /* CommIOSBlobClient.mm */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.cpp.objcpp; name = CommIOSBlobClient.mm; path = Comm/CommIOSServices/CommIOSBlobClient.mm; sourceTree = ""; }; + CBCF984E2BA499DA00DBC3D9 /* CommIOSBlobClient.h */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.c.h; name = CommIOSBlobClient.h; path = Comm/CommIOSServices/CommIOSBlobClient.h; sourceTree = ""; }; CBDEC69928ED859600C17588 /* GlobalDBSingleton.h */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.h; path = GlobalDBSingleton.h; sourceTree = ""; }; CBDEC69A28ED867000C17588 /* GlobalDBSingleton.mm */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.cpp.objcpp; name = GlobalDBSingleton.mm; path = Comm/GlobalDBSingleton.mm; sourceTree = ""; }; CBF9DAE22B595934000EE771 /* EntityQueryHelpers.h */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.h; path = EntityQueryHelpers.h; sourceTree = ""; }; @@ -413,6 +417,7 @@ 71B8CCB626BD30EC0040C0A2 /* CommCoreImplementations */ = { isa = PBXGroup; children = ( + CBCF984C2BA499C200DBC3D9 /* CommIOSServices */, CBB0DF5F2B768007008E22FF /* CommMMKV.mm */, CB74AB1B2B2AFF6E00CBB494 /* CommServicesAuthMetadataEmitter.mm */, DFD5E77D2B05264000C32B6A /* AESCrypto.mm */, @@ -783,6 +788,15 @@ name = CommAESCryptoUtils; sourceTree = ""; }; + CBCF984C2BA499C200DBC3D9 /* CommIOSServices */ = { + isa = PBXGroup; + children = ( + CBCF984E2BA499DA00DBC3D9 /* CommIOSBlobClient.h */, + CBCF984D2BA499DA00DBC3D9 /* CommIOSBlobClient.mm */, + ); + name = CommIOSServices; + sourceTree = ""; + }; CBED0E2C284E086100CD3863 /* PersistentStorageUtilities */ = { isa = PBXGroup; children = ( @@ -1158,6 +1172,7 @@ 8EF775682A74032C0046A385 /* CommRustModule.cpp in Sources */, 34055C152BAD31AC0008E713 /* SyncedMetadataStore.cpp in Sources */, 8E43C32C291E5B4A009378F5 /* TerminateApp.mm in Sources */, + CBCF984F2BA499DA00DBC3D9 /* CommIOSBlobClient.mm in Sources */, B3B02EBF2B8538980020D118 /* CommunityStore.cpp in Sources */, 8BC9568529FC49B00060AE4A /* JSIRust.cpp in Sources */, 8EA59BD92A73DAB000EB4F53 /* rustJSI-generated.cpp in Sources */, @@ -1217,6 +1232,7 @@ isa = PBXSourcesBuildPhase; buildActionMask = 2147483647; files = ( + CBCF98502BA49A0500DBC3D9 /* CommIOSBlobClient.mm in Sources */, CBCA09072A8E0E7D00F75B3E /* StaffUtils.cpp in Sources */, CB3C0A3B2A125C8F009BD4DA /* NotificationsCryptoModule.cpp in Sources */, CB90951F29534B32002F2A7F /* CommSecureStore.mm in Sources */, diff --git a/native/ios/Comm/CommIOSServices/CommIOSBlobClient.h b/native/ios/Comm/CommIOSServices/CommIOSBlobClient.h new file mode 100644 --- /dev/null +++ b/native/ios/Comm/CommIOSServices/CommIOSBlobClient.h @@ -0,0 +1,6 @@ +#import + +@interface CommIOSBlobClient : NSObject ++ (id)sharedInstance; +- (NSData *)getBlobSync:(NSString *)blobHash orSetError:(NSError **)error; +@end diff --git a/native/ios/Comm/CommIOSServices/CommIOSBlobClient.mm b/native/ios/Comm/CommIOSServices/CommIOSBlobClient.mm new file mode 100644 --- /dev/null +++ b/native/ios/Comm/CommIOSServices/CommIOSBlobClient.mm @@ -0,0 +1,168 @@ +#import "CommIOSBlobClient.h" +#import "CommSecureStore.h" +#import "Logger.h" + +#ifdef DEBUG +NSString const *blobServiceAddress = + @"https://blob.staging.commtechnologies.org"; +#else +NSString const *blobServiceAddress = @"https://blob.commtechnologies.org"; +#endif + +int const blobServiceQueryTimeLimit = 15; + +@interface CommIOSBlobClient () +@property(nonatomic, strong) NSURLSession *sharedBlobServiceSession; +@end + +@implementation CommIOSBlobClient + ++ (id)sharedInstance { + static CommIOSBlobClient *sharedBlobServiceClient = nil; + static dispatch_once_t onceToken; + + dispatch_once(&onceToken, ^{ + NSURLSessionConfiguration *config = + [NSURLSessionConfiguration ephemeralSessionConfiguration]; + + [config setTimeoutIntervalForRequest:blobServiceQueryTimeLimit]; + NSURLSession *session = + [NSURLSession sessionWithConfiguration:config + delegate:nil + delegateQueue:[NSOperationQueue mainQueue]]; + sharedBlobServiceClient = [[self alloc] init]; + sharedBlobServiceClient.sharedBlobServiceSession = session; + }); + return sharedBlobServiceClient; +} + +- (NSData *)getBlobSync:(NSString *)blobHash orSetError:(NSError **)error { + NSError *authTokenError = nil; + NSString *authToken = + [CommIOSBlobClient _getAuthTokenOrSetError:&authTokenError]; + + if (authTokenError) { + *error = authTokenError; + return nil; + } + + NSString *blobUrlStr = [blobServiceAddress + stringByAppendingString:[@"/blob/" stringByAppendingString:blobHash]]; + NSURL *blobUrl = [NSURL URLWithString:blobUrlStr]; + NSMutableURLRequest *blobRequest = + [NSMutableURLRequest requestWithURL:blobUrl]; + + // This is slightly against Apple docs: + // https://developer.apple.com/documentation/foundation/nsurlrequest?language=objc#1776617 + // but apparently there is no other way to + // do this and even Apple staff members + // advice to set this field manually to + // achieve token based authentication: + // https://developer.apple.com/forums/thread/89811 + [blobRequest setValue:authToken forHTTPHeaderField:@"Authorization"]; + + __block NSError *requestError = nil; + __block NSData *blobContent = nil; + + dispatch_semaphore_t semaphore = dispatch_semaphore_create(0); + NSURLSessionDataTask *task = [self.sharedBlobServiceSession + dataTaskWithRequest:blobRequest + completionHandler:^( + NSData *_Nullable data, + NSURLResponse *_Nullable response, + NSError *_Nullable error) { + @try { + NSHTTPURLResponse *httpResponse = (NSHTTPURLResponse *)response; + if (httpResponse.statusCode > 299) { + NSString *errorMessage = + [@"Fetching blob failed with the following reason: " + stringByAppendingString:[NSHTTPURLResponse + localizedStringForStatusCode: + httpResponse.statusCode]]; + requestError = [NSError + errorWithDomain:@"app.comm" + code:httpResponse.statusCode + userInfo:@{NSLocalizedDescriptionKey : errorMessage}]; + return; + } + if (error) { + requestError = error; + return; + } + blobContent = data; + } @catch (NSException *exception) { + comm::Logger::log( + "Received exception when fetching blob. Details: " + + std::string([exception.reason UTF8String])); + } @finally { + dispatch_semaphore_signal(semaphore); + } + }]; + + [task resume]; + dispatch_semaphore_wait( + semaphore, + dispatch_time( + DISPATCH_TIME_NOW, + (int64_t)(blobServiceQueryTimeLimit * NSEC_PER_SEC))); + if (requestError) { + *error = requestError; + return nil; + } + return blobContent; +} + ++ (NSString *)_getAuthTokenOrSetError:(NSError **)error { + // Authentication data are retrieved on every request + // since they might change while NSE process is running + // so we should not rely on caching them in memory. + + auto accessToken = comm::CommSecureStore::get( + comm::CommSecureStore::commServicesAccessToken); + auto userID = comm::CommSecureStore::get(comm::CommSecureStore::userID); + auto deviceID = comm::CommSecureStore::get(comm::CommSecureStore::deviceID); + + NSString *userIDObjC = userID.hasValue() + ? [NSString stringWithCString:userID.value().c_str() + encoding:NSUTF8StringEncoding] + : @""; + NSString *accessTokenObjC = accessToken.hasValue() + ? [NSString stringWithCString:accessToken.value().c_str() + encoding:NSUTF8StringEncoding] + : @""; + NSString *deviceIDObjC = deviceID.hasValue() + ? [NSString stringWithCString:deviceID.value().c_str() + encoding:NSUTF8StringEncoding] + : @""; + + NSDictionary *jsonAuthObject = @{ + @"userID" : userIDObjC, + @"accessToken" : accessTokenObjC, + @"deviceID" : deviceIDObjC, + }; + + NSData *binaryAuthObject = nil; + NSError *jsonError = nil; + + @try { + binaryAuthObject = [NSJSONSerialization dataWithJSONObject:jsonAuthObject + options:0 + error:&jsonError]; + } @catch (NSException *e) { + *error = [NSError errorWithDomain:@"app.comm" + code:NSFormattingError + userInfo:@{NSLocalizedDescriptionKey : e.reason}]; + return nil; + } + + if (jsonError) { + *error = jsonError; + return nil; + } + + return [@"Bearer " + stringByAppendingString:[binaryAuthObject + base64EncodedStringWithOptions:0]]; +} + +@end diff --git a/native/ios/NotificationService/NotificationService.mm b/native/ios/NotificationService/NotificationService.mm --- a/native/ios/NotificationService/NotificationService.mm +++ b/native/ios/NotificationService/NotificationService.mm @@ -1,4 +1,6 @@ #import "NotificationService.h" +#import "AESCryptoModuleObjCCompat.h" +#import "CommIOSBlobClient.h" #import "CommMMKV.h" #import "Logger.h" #import "NotificationsCryptoModule.h" @@ -14,6 +16,8 @@ NSString *const encryptionFailureKey = @"encryptionFailure"; NSString *const collapseIDKey = @"collapseID"; NSString *const keyserverIDKey = @"keyserverID"; +NSString *const blobHashKey = @"blobHash"; +NSString *const encryptionKeyLabel = @"encryptionKey"; // Those and future MMKV-related constants should match // similar constants in CommNotificationsHandler.java @@ -257,7 +261,32 @@ publicUserContent = badgeOnlyContent; } - // Step 7: notify main app that there is data + // Step 7: (optional) download notification paylaod + // from blob service in case it is large notification + if ([self isLargeNotification:content.userInfo]) { + std::string processLargeNotificationError; + try { + @try { + [self fetchAndPersistLargeNotifPayload:content]; + } @catch (NSException *e) { + processLargeNotificationError = + "Obj-C exception: " + std::string([e.name UTF8String]) + + " during large notification processing."; + } + } catch (const std::exception &e) { + processLargeNotificationError = + "C++ exception: " + std::string(e.what()) + + " during large notification processing."; + } + + if (processLargeNotificationError.size()) { + [errorMessages + addObject:[NSString stringWithUTF8String:processLargeNotificationError + .c_str()]]; + } + } + + // Step 8: notify main app that there is data // to transfer to SQLite and redux. [self sendNewMessageInfosNotification]; @@ -500,6 +529,32 @@ content.badge = @(totalUnreadCount); } +- (void)fetchAndPersistLargeNotifPayload: + (UNMutableNotificationContent *)content { + NSString *blobHash = content.userInfo[blobHashKey]; + + NSData *encryptionKey = [[NSData alloc] + initWithBase64EncodedString:content.userInfo[encryptionKeyLabel] + options:0]; + + __block NSError *fetchError = nil; + NSData *largePayloadBinary = + [CommIOSBlobClient.sharedInstance getBlobSync:blobHash + orSetError:&fetchError]; + + if (fetchError) { + comm::Logger::log( + "Failed to fetch notif payload from blob service. Details: " + + std::string([fetchError.localizedDescription UTF8String])); + return; + } + + NSDictionary *largePayload = + [NotificationService aesDecryptAndParse:largePayloadBinary + withKey:encryptionKey]; + [self persistMessagePayload:largePayload]; +} + - (BOOL)needsSilentBadgeUpdate:(NSDictionary *)payload { // TODO: refactor this check by introducing // badgeOnly property in iOS notification payload @@ -519,6 +574,10 @@ return payload[collapseIDKey]; } +- (BOOL)isLargeNotification:(NSDictionary *)payload { + return payload[blobHashKey] && payload[encryptionKeyLabel]; +} + - (UNNotificationContent *)getBadgeOnlyContentFor: (UNNotificationContent *)content { UNMutableNotificationContent *badgeOnlyContent = @@ -799,11 +858,55 @@ return memorySource; } +// AES Cryptography +static AESCryptoModuleObjCCompat *_aesCryptoModule = nil; + ++ (AESCryptoModuleObjCCompat *)processLocalAESCryptoModule { + return _aesCryptoModule; +} + ++ (NSDictionary *)aesDecryptAndParse:(NSData *)sealedData + withKey:(NSData *)key { + NSError *decryptError = nil; + NSInteger destinationLength = + [[NotificationService processLocalAESCryptoModule] + decryptedLength:sealedData]; + + NSMutableData *destination = [NSMutableData dataWithLength:destinationLength]; + [[NotificationService processLocalAESCryptoModule] + decryptWithKey:key + sealedData:sealedData + destination:destination + withError:&decryptError]; + + if (decryptError) { + comm::Logger::log( + "NSE: Notification aes decryption failure. Details: " + + std::string([decryptError.localizedDescription UTF8String])); + return nil; + } + + NSString *decryptedSerializedPayload = + [[NSString alloc] initWithData:destination encoding:NSUTF8StringEncoding]; + + return [NSJSONSerialization + JSONObjectWithData:[decryptedSerializedPayload + dataUsingEncoding:NSUTF8StringEncoding] + options:0 + error:nil]; +} + +// Process-local initialization code NSE may use different threads and instances +// of this class to process notifs, but it usually keeps the same process for +// extended period of time. Objects that can be initialized once and reused on +// each notif should be declared in a method below to avoid wasting resources + + (void)setUpNSEProcess { static dispatch_source_t memoryEventSource; static dispatch_once_t onceToken; dispatch_once(&onceToken, ^{ + _aesCryptoModule = [[AESCryptoModuleObjCCompat alloc] init]; memoryEventSource = [NotificationService registerForMemoryEvents]; }); }