diff --git a/keyserver/src/push/crypto.js b/keyserver/src/push/crypto.js --- a/keyserver/src/push/crypto.js +++ b/keyserver/src/push/crypto.js @@ -31,6 +31,7 @@ notification: apn.Notification, codeVersion?: ?number, notificationSizeValidator?: apn.Notification => boolean, + blobHolder?: ?string, ): Promise<{ +notification: apn.Notification, +payloadSizeExceeded: boolean, @@ -47,6 +48,11 @@ encryptedNotification.id = notification.id; encryptedNotification.payload.id = notification.id; + + if (blobHolder) { + encryptedNotification.payload.blobHolder = blobHolder; + } + encryptedNotification.payload.keyserverID = notification.payload.keyserverID; encryptedNotification.topic = notification.topic; encryptedNotification.sound = notification.aps.sound; @@ -334,12 +340,13 @@ }>, > { const notificationPromises = devices.map( - async ({ cookieID, deviceToken }) => { + async ({ cookieID, deviceToken, blobHolder }) => { const notif = await encryptAPNsNotification( cookieID, notification, codeVersion, notificationSizeValidator, + blobHolder, ); return { cookieID, deviceToken, ...notif }; }, 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 @@ -1050,11 +1050,11 @@ notificationSizeValidator, ); - const devicesWithExcessiveSize = notifsWithMessageInfos + const devicesWithExcessiveSizeNoHolders = notifsWithMessageInfos .filter(({ payloadSizeExceeded }) => payloadSizeExceeded) .map(({ deviceToken, cookieID }) => ({ deviceToken, cookieID })); - if (devicesWithExcessiveSize.length === 0) { + if (devicesWithExcessiveSizeNoHolders.length === 0) { return notifsWithMessageInfos.map( ({ notification: notif, @@ -1074,11 +1074,13 @@ native: NEXT_CODE_VERSION, }); - let blobHash, encryptionKey, blobUploadError; + let blobHash, blobHolders, encryptionKey, blobUploadError; if (canQueryBlobService) { - ({ blobHash, encryptionKey, blobUploadError } = await blobServiceUpload( - copyWithMessageInfos.compile(), - )); + ({ blobHash, blobHolders, encryptionKey, blobUploadError } = + await blobServiceUpload( + copyWithMessageInfos.compile(), + devicesWithExcessiveSizeNoHolders.length, + )); } if (blobUploadError) { @@ -1088,12 +1090,23 @@ ); } - if (blobHash && encryptionKey) { + let devicesWithExcessiveSize = devicesWithExcessiveSizeNoHolders; + if ( + blobHash && + encryptionKey && + blobHolders && + blobHolders.length === devicesWithExcessiveSize.length + ) { notification.payload = { ...notification.payload, blobHash, encryptionKey, }; + + devicesWithExcessiveSize = blobHolders.map((holder, idx) => ({ + ...devicesWithExcessiveSize[idx], + blobHolder: holder, + })); } const notifsWithoutMessageInfos = await prepareEncryptedAPNsNotifications( diff --git a/keyserver/src/push/types.js b/keyserver/src/push/types.js --- a/keyserver/src/push/types.js +++ b/keyserver/src/push/types.js @@ -81,4 +81,5 @@ export type NotificationTargetDevice = { +cookieID: string, +deviceToken: string, + +blobHolder?: string, }; diff --git a/keyserver/src/push/utils.js b/keyserver/src/push/utils.js --- a/keyserver/src/push/utils.js +++ b/keyserver/src/push/utils.js @@ -28,7 +28,7 @@ TargetedWNSNotification, } from './types.js'; import { dbQuery, SQL } from '../database/database.js'; -import { upload } from '../services/blob.js'; +import { upload, assignHolder } from '../services/blob.js'; const fcmTokenInvalidationErrors = new Set([ 'messaging/registration-token-not-registered', @@ -397,23 +397,36 @@ } } -async function blobServiceUpload(payload: string): Promise< +async function blobServiceUpload( + payload: string, + numberOfHolders: number, +): Promise< | { + +blobHolders: $ReadOnlyArray, +blobHash: string, +encryptionKey: string, } | { +blobUploadError: string }, > { - const blobHolder = uuid.v4(); + const blobHolders = Array.from({ length: numberOfHolders }, () => uuid.v4()); try { const { encryptionKey, encryptedPayload, encryptedPayloadHash } = await encryptBlobPayload(payload); - await upload(encryptedPayload, { + const [blobHolder, ...additionalHolders] = blobHolders; + + const uploadPromise = upload(encryptedPayload, { hash: encryptedPayloadHash, holder: blobHolder, }); + + const additionalHoldersPromises = additionalHolders.map(holder => + assignHolder({ hash: encryptedPayloadHash, holder }), + ); + + await Promise.all([uploadPromise, Promise.all(additionalHoldersPromises)]); return { blobHash: encryptedPayloadHash, + blobHolders, encryptionKey, }; } catch (e) { diff --git a/native/ios/Comm/AppDelegate.mm b/native/ios/Comm/AppDelegate.mm --- a/native/ios/Comm/AppDelegate.mm +++ b/native/ios/Comm/AppDelegate.mm @@ -40,6 +40,7 @@ #import "CommConstants.h" #import "CommCoreModule.h" +#import "CommIOSBlobClient.h" #import "CommMMKV.h" #import "CommRustModule.h" #import "CommUtilsModule.h" @@ -98,6 +99,7 @@ RCTAppSetupPrepareApp(application); [self moveMessagesToDatabase:NO]; + [self scheduleNSEBlobsDeletion]; [[AVAudioSession sharedInstance] setCategory:AVAudioSessionCategoryAmbient error:nil]; @@ -386,6 +388,7 @@ - (void)didReceiveNewMessageInfosNSNotification:(NSNotification *)notification { [self moveMessagesToDatabase:YES]; + [self scheduleNSEBlobsDeletion]; } - (void)registerForNewMessageInfosNotifications { @@ -404,6 +407,22 @@ CFNotificationSuspensionBehaviorDeliverImmediately); } +// NSE has limited time to process notifications. Therefore +// deferable and low priority networking such as fetched +// blob deletion from blob service should be handled by the +// main app on a low priority background thread. + +- (void)scheduleNSEBlobsDeletion { + dispatch_async( + dispatch_get_global_queue(DISPATCH_QUEUE_PRIORITY_BACKGROUND, 0), ^{ + [CommIOSBlobClient.sharedInstance deleteStoredBlobs]; + }); +} + +- (void)applicationWillResignActive:(UIApplication *)application { + [[CommIOSBlobClient sharedInstance] cancelOngoingRequests]; +} + // Copied from // ReactAndroid/src/main/java/com/facebook/hermes/reactexecutor/OnLoad.cpp static ::hermes::vm::RuntimeConfig diff --git a/native/ios/Comm/CommIOSServices/CommIOSBlobClient.h b/native/ios/Comm/CommIOSServices/CommIOSBlobClient.h --- a/native/ios/Comm/CommIOSServices/CommIOSBlobClient.h +++ b/native/ios/Comm/CommIOSServices/CommIOSBlobClient.h @@ -3,4 +3,12 @@ @interface CommIOSBlobClient : NSObject + (id)sharedInstance; - (NSData *)getBlobSync:(NSString *)blobHash orSetError:(NSError **)error; +- (void)deleteBlobAsyncWithHash:(NSString *)blobHash + andHolder:(NSString *)blobHolder + withSuccessHandler:(void (^)())successHandler + andFailureHandler:(void (^)(NSError *))failureHandler; +- (void)storeBlobForDeletionWithHash:(NSString *)blobHash + andHolder:(NSString *)blobHolder; +- (void)deleteStoredBlobs; +- (void)cancelOngoingRequests; @end diff --git a/native/ios/Comm/CommIOSServices/CommIOSBlobClient.mm b/native/ios/Comm/CommIOSServices/CommIOSBlobClient.mm --- a/native/ios/Comm/CommIOSServices/CommIOSBlobClient.mm +++ b/native/ios/Comm/CommIOSServices/CommIOSBlobClient.mm @@ -1,4 +1,5 @@ #import "CommIOSBlobClient.h" +#import "CommMMKV.h" #import "CommSecureStore.h" #import "Logger.h" @@ -10,6 +11,11 @@ #endif int const blobServiceQueryTimeLimit = 15; +const std::string mmkvBlobHolderPrefix = "BLOB_HOLDER."; +// The blob service expects slightly different keys in +// delete reuqest payload than we use in notif payload +NSString *const blobServiceHashKey = @"blob_hash"; +NSString *const blobServiceHolderKey = @"holder"; @interface CommIOSBlobClient () @property(nonatomic, strong) NSURLSession *sharedBlobServiceSession; @@ -112,6 +118,129 @@ return blobContent; } +- (void)deleteBlobAsyncWithHash:(NSString *)blobHash + andHolder:(NSString *)blobHolder + withSuccessHandler:(void (^)())successHandler + andFailureHandler:(void (^)(NSError *))failureHandler { + NSError *authTokenError = nil; + NSString *authToken = + [CommIOSBlobClient _getAuthTokenOrSetError:&authTokenError]; + + if (authTokenError) { + comm::Logger::log( + "Failed to create blob service auth token. Reason: " + + std::string([authTokenError.localizedDescription UTF8String])); + return; + } + + NSString *blobUrlStr = [blobServiceAddress stringByAppendingString:@"/blob"]; + NSURL *blobUrl = [NSURL URLWithString:blobUrlStr]; + + NSMutableURLRequest *deleteRequest = + [NSMutableURLRequest requestWithURL:blobUrl]; + + [deleteRequest setValue:authToken forHTTPHeaderField:@"Authorization"]; + [deleteRequest setValue:@"application/json" + forHTTPHeaderField:@"content-type"]; + + deleteRequest.HTTPMethod = @"DELETE"; + deleteRequest.HTTPBody = [NSJSONSerialization dataWithJSONObject:@{ + blobServiceHolderKey : blobHolder, + blobServiceHashKey : blobHash + } + options:0 + error:nil]; + + NSURLSessionDataTask *task = [self.sharedBlobServiceSession + dataTaskWithRequest:deleteRequest + completionHandler:^( + NSData *_Nullable data, + NSURLResponse *_Nullable response, + NSError *_Nullable error) { + NSHTTPURLResponse *httpResponse = (NSHTTPURLResponse *)response; + + if (httpResponse.statusCode > 299) { + NSString *errorMessage = + [@"Deleting blob failed with the following reason: " + stringByAppendingString:[NSHTTPURLResponse + localizedStringForStatusCode: + httpResponse.statusCode]]; + failureHandler([NSError + errorWithDomain:@"app.comm" + code:httpResponse.statusCode + userInfo:@{NSLocalizedDescriptionKey : errorMessage}]); + return; + } + + if (error) { + failureHandler(error); + return; + } + + successHandler(); + }]; + + [task resume]; +} + +- (void)storeBlobForDeletionWithHash:(NSString *)blobHash + andHolder:(NSString *)blobHolder { + std::string blobHashCpp = std::string([blobHash UTF8String]); + std::string blobHolderCpp = std::string([blobHolder UTF8String]); + + std::string mmkvBlobHolderKey = mmkvBlobHolderPrefix + blobHolderCpp; + comm::CommMMKV::setString(mmkvBlobHolderKey, blobHashCpp); +} + +- (void)deleteStoredBlobs { + std::vector allKeys = comm::CommMMKV::getAllKeys(); + NSMutableArray *blobsDataForDeletion = + [[NSMutableArray alloc] init]; + + for (const auto &key : allKeys) { + if (key.size() <= mmkvBlobHolderPrefix.size() || + key.compare(0, mmkvBlobHolderPrefix.size(), mmkvBlobHolderPrefix)) { + continue; + } + + std::optional blobHash = comm::CommMMKV::getString(key); + if (!blobHash.has_value()) { + continue; + } + std::string blobHolder = key.substr(mmkvBlobHolderPrefix.size()); + + NSString *blobHolderObjC = + [NSString stringWithCString:blobHolder.c_str() + encoding:NSUTF8StringEncoding]; + NSString *blobHashObjC = + [NSString stringWithCString:blobHash.value().c_str() + encoding:NSUTF8StringEncoding]; + + [self deleteBlobAsyncWithHash:blobHashObjC + andHolder:blobHolderObjC + withSuccessHandler:^{ + std::string mmkvBlobHolderKey = mmkvBlobHolderPrefix + blobHolder; + comm::CommMMKV::removeKeys({mmkvBlobHolderKey}); + } + andFailureHandler:^(NSError *error) { + comm::Logger::log( + "Failed to delete blob hash " + blobHash.value() + + " from blob service. Details: " + + std::string([error.localizedDescription UTF8String])); + }]; + } +} + +- (void)cancelOngoingRequests { + [self.sharedBlobServiceSession + getAllTasksWithCompletionHandler:^( + NSArray<__kindof NSURLSessionTask *> *_Nonnull tasks) { + for (NSURLSessionTask *task in tasks) { + [task cancel]; + } + }]; +} + + (NSString *)_getAuthTokenOrSetError:(NSError **)error { // Authentication data are retrieved on every request // since they might change while NSE process is running 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 @@ -17,6 +17,7 @@ NSString *const collapseIDKey = @"collapseID"; NSString *const keyserverIDKey = @"keyserverID"; NSString *const blobHashKey = @"blobHash"; +NSString *const blobHolderKey = @"blobHolder"; NSString *const encryptionKeyLabel = @"encryptionKey"; // Those and future MMKV-related constants should match @@ -553,6 +554,9 @@ [NotificationService aesDecryptAndParse:largePayloadBinary withKey:encryptionKey]; [self persistMessagePayload:largePayload]; + [CommIOSBlobClient.sharedInstance + storeBlobForDeletionWithHash:blobHash + andHolder:content.userInfo[blobHolderKey]]; } - (BOOL)needsSilentBadgeUpdate:(NSDictionary *)payload { @@ -575,7 +579,8 @@ } - (BOOL)isLargeNotification:(NSDictionary *)payload { - return payload[blobHashKey] && payload[encryptionKeyLabel]; + return payload[blobHashKey] && payload[encryptionKeyLabel] && + payload[blobHolderKey]; } - (UNNotificationContent *)getBadgeOnlyContentFor: