From 32f5a18e00c977f76d3f3fcee8af81029573a857 Mon Sep 17 00:00:00 2001 From: Morgan Pretty Date: Wed, 1 Nov 2023 18:04:21 +1100 Subject: [PATCH] WIP adding PNs for updated groups and changes for enabling unit testing Started adding logic to subscribe and unsubscribe for updated group push notifications Updated the keychain service to be injected via dependencies Reworked the Dependencies logic to avoid a concurrent access issue Fixed an issue where some keychain data might not be cleared in some cases Fixed an issue where being kicked and readded to a group would make it seem to disappear ("invited" message wasn't getting created) --- Session.xcodeproj/project.pbxproj | 8 +- Session/Meta/SessionApp.swift | 3 +- Session/Onboarding/Onboarding.swift | 5 +- .../Database/Models/ClosedGroup.swift | 28 ++- .../MessageReceiver+Groups.swift | 6 +- .../MessageReceiver+LegacyClosedGroups.swift | 3 +- .../MessageSender+Groups.swift | 17 +- .../MessageSender+LegacyClosedGroups.swift | 3 +- .../Notifications/PushNotificationAPI.swift | 188 +++++++++-------- .../NotificationServiceExtension.swift | 3 +- .../Database/SSKKeychainStorage.swift | 112 ---------- SessionUtilitiesKit/Database/Storage.swift | 21 +- .../Dependency Injection/Dependencies.swift | 57 ++--- .../Utilities/KeychainStorageType.swift | 195 ++++++++++++++++++ _SharedTestUtilities/TestDependencies.swift | 67 ++---- 15 files changed, 413 insertions(+), 303 deletions(-) delete mode 100644 SessionUtilitiesKit/Database/SSKKeychainStorage.swift create mode 100644 SessionUtilitiesKit/Utilities/KeychainStorageType.swift diff --git a/Session.xcodeproj/project.pbxproj b/Session.xcodeproj/project.pbxproj index e1139890a..394cc24ac 100644 --- a/Session.xcodeproj/project.pbxproj +++ b/Session.xcodeproj/project.pbxproj @@ -278,7 +278,7 @@ C32C5A88256DBCF9003C73A2 /* MessageReceiver+LegacyClosedGroups.swift in Sources */ = {isa = PBXBuildFile; fileRef = C32C5A87256DBCF9003C73A2 /* MessageReceiver+LegacyClosedGroups.swift */; }; C32C5C3D256DCBAF003C73A2 /* AppReadiness.m in Sources */ = {isa = PBXBuildFile; fileRef = C33FDB75255A581000E217F9 /* AppReadiness.m */; }; C32C5C46256DCBB2003C73A2 /* AppReadiness.h in Headers */ = {isa = PBXBuildFile; fileRef = C33FDB01255A580700E217F9 /* AppReadiness.h */; settings = {ATTRIBUTES = (Public, ); }; }; - C32C5D83256DD5B6003C73A2 /* SSKKeychainStorage.swift in Sources */ = {isa = PBXBuildFile; fileRef = C33FDBBC255A581600E217F9 /* SSKKeychainStorage.swift */; }; + C32C5D83256DD5B6003C73A2 /* KeychainStorageType.swift in Sources */ = {isa = PBXBuildFile; fileRef = C33FDBBC255A581600E217F9 /* KeychainStorageType.swift */; }; C32C5DBF256DD743003C73A2 /* GroupPoller.swift in Sources */ = {isa = PBXBuildFile; fileRef = C33FDB34255A580B00E217F9 /* GroupPoller.swift */; }; C32C5DC9256DD935003C73A2 /* ProxiedContentDownloader.swift in Sources */ = {isa = PBXBuildFile; fileRef = C33FDAF2255A580500E217F9 /* ProxiedContentDownloader.swift */; }; C32C5DD2256DD9E5003C73A2 /* LRUCache.swift in Sources */ = {isa = PBXBuildFile; fileRef = C33FDAFD255A580600E217F9 /* LRUCache.swift */; }; @@ -1548,7 +1548,7 @@ C33FDBA8255A581500E217F9 /* LinkPreviewDraft.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = LinkPreviewDraft.swift; sourceTree = ""; }; C33FDBAB255A581500E217F9 /* OWSFileSystem.h */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.c.h; path = OWSFileSystem.h; sourceTree = ""; }; C33FDBB6255A581600E217F9 /* DataSource.m */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.c.objc; path = DataSource.m; sourceTree = ""; }; - C33FDBBC255A581600E217F9 /* SSKKeychainStorage.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = SSKKeychainStorage.swift; sourceTree = ""; }; + C33FDBBC255A581600E217F9 /* KeychainStorageType.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = KeychainStorageType.swift; sourceTree = ""; }; C33FDBD3255A581800E217F9 /* OWSSignalAddress.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = OWSSignalAddress.swift; sourceTree = ""; }; C33FDBDE255A581900E217F9 /* PushNotificationAPI.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = PushNotificationAPI.swift; sourceTree = ""; }; C33FDC1B255A581F00E217F9 /* OWSBackgroundTask.m */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.c.objc; path = OWSBackgroundTask.m; sourceTree = ""; }; @@ -2747,7 +2747,6 @@ FD28A4F527EAD44C00FF65E7 /* Storage.swift */, C33FDBAB255A581500E217F9 /* OWSFileSystem.h */, C33FDA8E255A57FD00E217F9 /* OWSFileSystem.m */, - C33FDBBC255A581600E217F9 /* SSKKeychainStorage.swift */, ); path = Database; sourceTree = ""; @@ -3790,6 +3789,7 @@ FDFBB74A2A1EFF4900CA7350 /* Bencode.swift */, FD97B23F2A3FEB050027DD57 /* ARC4RandomNumberGenerator.swift */, FDA8EB0F280F8238002B68E5 /* Codable+Utilities.swift */, + C33FDBBC255A581600E217F9 /* KeychainStorageType.swift */, FD3003692A3ADD6000B5A5FB /* CExceptionHelper.h */, FD30036D2A3AE26000B5A5FB /* CExceptionHelper.mm */, FD12A84A2AD6458800EEBA0D /* DifferenceKit+Utilities.swift */, @@ -6144,7 +6144,7 @@ FDE658A129418C7900A33BC1 /* CryptoKit+Utilities.swift in Sources */, FDFD645927F26C6800808CA1 /* Array+Utilities.swift in Sources */, 7B1D74B027C365960030B423 /* Timer+MainThread.swift in Sources */, - C32C5D83256DD5B6003C73A2 /* SSKKeychainStorage.swift in Sources */, + C32C5D83256DD5B6003C73A2 /* KeychainStorageType.swift in Sources */, FD559DF52A7368CB00C7C62A /* DispatchQueue+Utilities.swift in Sources */, FDF8488329405A12007DCAE5 /* BatchResponse.swift in Sources */, C3D9E39B256763C20040E4F3 /* AppContext.m in Sources */, diff --git a/Session/Meta/SessionApp.swift b/Session/Meta/SessionApp.swift index 8e6c8b4b6..851bfb554 100644 --- a/Session/Meta/SessionApp.swift +++ b/Session/Meta/SessionApp.swift @@ -121,10 +121,11 @@ public struct SessionApp { DDLog.flushLog() SessionUtil.clearMemoryState(using: dependencies) - Storage.resetAllStorage() + Storage.resetAllStorage(using: dependencies) DisplayPictureManager.resetStorage(using: dependencies) Attachment.resetAttachmentStorage() AppEnvironment.shared.notificationPresenter.clearAllNotifications() + dependencies[singleton: .keychain].removeAll() onReset?() exit(0) diff --git a/Session/Onboarding/Onboarding.swift b/Session/Onboarding/Onboarding.swift index b0b387181..9bcc18b9f 100644 --- a/Session/Onboarding/Onboarding.swift +++ b/Session/Onboarding/Onboarding.swift @@ -123,6 +123,9 @@ enum Onboarding { ) { let sessionId: SessionId = SessionId(.standard, publicKey: x25519KeyPair.publicKey) + // Reset the PushNotificationAPI keys (just in case they were left over from a prior install) + PushNotificationAPI.deleteKeys(using: dependencies) + // Store the user identity information dependencies[singleton: .storage].write { db in try Identity.store( @@ -185,7 +188,7 @@ enum Onboarding { // Only continue if this isn't a new account guard self != .register else { return } - // Fetch the + // Fetch the profile name Onboarding.profileNamePublisher .subscribe(on: DispatchQueue.global(qos: .userInitiated), using: dependencies) .sinkUntilComplete() diff --git a/SessionMessagingKit/Database/Models/ClosedGroup.swift b/SessionMessagingKit/Database/Models/ClosedGroup.swift index 5afd5d629..8c05ea3da 100644 --- a/SessionMessagingKit/Database/Models/ClosedGroup.swift +++ b/SessionMessagingKit/Database/Models/ClosedGroup.swift @@ -225,6 +225,19 @@ public extension ClosedGroup { /// Start polling dependencies[singleton: .groupsPoller].startIfNeeded(for: group.id, using: dependencies) + + /// Subscribe for group push notifications + if let token: String = dependencies[defaults: .standard, key: .deviceToken] { + try? PushNotificationAPI + .preparedSubscribe( + db, + token: Data(hex: token), + sessionId: SessionId(.group, hex: group.id), + using: dependencies + ) + .send(using: dependencies) + .sinkUntilComplete() + } } static func removeData( @@ -271,13 +284,24 @@ public extension ClosedGroup { try? PushNotificationAPI .preparedUnsubscribeFromLegacyGroup( legacyGroupId: threadId, - userSessionId: userSessionId + userSessionId: userSessionId, + using: dependencies ) .send(using: dependencies) .sinkUntilComplete() case .group: - break + if let token: String = dependencies[defaults: .standard, key: .deviceToken] { + try? PushNotificationAPI + .preparedUnsubscribe( + db, + token: Data(hex: token), + sessionId: userSessionId, + using: dependencies + ) + .send(using: dependencies) + .sinkUntilComplete() + } default: break } diff --git a/SessionMessagingKit/Sending & Receiving/Message Handling/MessageReceiver+Groups.swift b/SessionMessagingKit/Sending & Receiving/Message Handling/MessageReceiver+Groups.swift index 35f3a4aa8..63d8f69ea 100644 --- a/SessionMessagingKit/Sending & Receiving/Message Handling/MessageReceiver+Groups.swift +++ b/SessionMessagingKit/Sending & Receiving/Message Handling/MessageReceiver+Groups.swift @@ -132,6 +132,10 @@ extension MessageReceiver { /// or modified clients let inviteSenderIsApproved: Bool = ((try? Contact.fetchOne(db, id: sender))?.isApproved == true) let threadAlreadyExisted: Bool = ((try? SessionThread.exists(db, id: message.groupSessionId.hexString)) ?? false) + let wasKickedFromGroup: Bool = SessionUtil.wasKickedFromGroup( + groupSessionId: message.groupSessionId, + using: dependencies + ) try MessageReceiver.handleNewGroup( db, @@ -155,7 +159,7 @@ extension MessageReceiver { ).upsert(db) /// If the thread didn't already exist then insert an 'invited' info message - guard !threadAlreadyExisted else { return } + guard !threadAlreadyExisted || wasKickedFromGroup else { return } let interaction: Interaction = try Interaction( threadId: message.groupSessionId.hexString, diff --git a/SessionMessagingKit/Sending & Receiving/Message Handling/MessageReceiver+LegacyClosedGroups.swift b/SessionMessagingKit/Sending & Receiving/Message Handling/MessageReceiver+LegacyClosedGroups.swift index 10975fec8..b5b3064eb 100644 --- a/SessionMessagingKit/Sending & Receiving/Message Handling/MessageReceiver+LegacyClosedGroups.swift +++ b/SessionMessagingKit/Sending & Receiving/Message Handling/MessageReceiver+LegacyClosedGroups.swift @@ -263,7 +263,8 @@ extension MessageReceiver { ) .asRequest(of: String.self) .fetchSet(db) - .inserting(legacyGroupSessionId) // Insert the new key just to be sure + .inserting(legacyGroupSessionId), // Insert the new key just to be sure + using: dependencies )? .send(using: dependencies) .subscribe(on: DispatchQueue.global(qos: .default), using: dependencies) diff --git a/SessionMessagingKit/Sending & Receiving/Message Handling/MessageSender+Groups.swift b/SessionMessagingKit/Sending & Receiving/Message Handling/MessageSender+Groups.swift index 3c1f441df..aaa3f1c95 100644 --- a/SessionMessagingKit/Sending & Receiving/Message Handling/MessageSender+Groups.swift +++ b/SessionMessagingKit/Sending & Receiving/Message Handling/MessageSender+Groups.swift @@ -75,12 +75,17 @@ extension MessageSender { try createdInfo.members.forEach { try $0.insert(db) } // Prepare the notification subscription - let preparedNotificationSubscription = try? PushNotificationAPI - .preparedSubscribe( - db, - sessionId: createdInfo.groupSessionId, - using: dependencies - ) + var preparedNotificationSubscription: HTTP.PreparedRequest? + + if let token: String = dependencies[defaults: .standard, key: .deviceToken] { + preparedNotificationSubscription = try? PushNotificationAPI + .preparedSubscribe( + db, + token: Data(hex: token), + sessionId: createdInfo.groupSessionId, + using: dependencies + ) + } return ( createdInfo.groupSessionId, diff --git a/SessionMessagingKit/Sending & Receiving/Message Handling/MessageSender+LegacyClosedGroups.swift b/SessionMessagingKit/Sending & Receiving/Message Handling/MessageSender+LegacyClosedGroups.swift index 1b9e0cb79..4e584be18 100644 --- a/SessionMessagingKit/Sending & Receiving/Message Handling/MessageSender+LegacyClosedGroups.swift +++ b/SessionMessagingKit/Sending & Receiving/Message Handling/MessageSender+LegacyClosedGroups.swift @@ -144,7 +144,8 @@ extension MessageSender { try? PushNotificationAPI .preparedSubscribeToLegacyGroups( userSessionId: userSessionId, - legacyGroupIds: allActiveLegacyGroupIds + legacyGroupIds: allActiveLegacyGroupIds, + using: dependencies )? .map { _, _ in () } ) diff --git a/SessionMessagingKit/Sending & Receiving/Notifications/PushNotificationAPI.swift b/SessionMessagingKit/Sending & Receiving/Notifications/PushNotificationAPI.swift index 8fe94ca97..9fd862649 100644 --- a/SessionMessagingKit/Sending & Receiving/Notifications/PushNotificationAPI.swift +++ b/SessionMessagingKit/Sending & Receiving/Notifications/PushNotificationAPI.swift @@ -1,4 +1,6 @@ // Copyright © 2022 Rangeproof Pty Ltd. All rights reserved. +// +// stringlint:disable import Foundation import Combine @@ -6,9 +8,14 @@ import GRDB import SessionSnodeKit import SessionUtilitiesKit +// MARK: - KeychainStorage + +public extension KeychainStorage.ServiceKey { static let pushNotificationAPI: Self = "PNKeyChainService" } +public extension KeychainStorage.DataKey { static let pushNotificationEncryptionKey: Self = "PNEncryptionKeyKey" } + +// MARK: - PushNotificationAPI + public enum PushNotificationAPI { - private static let keychainService: String = "PNKeyChainService" - private static let encryptionKeyKey: String = "PNEncryptionKeyKey" private static let encryptionKeyLength: Int = 32 private static let maxRetryCount: Int = 4 private static let tokenExpirationInterval: TimeInterval = (12 * 60 * 60) @@ -23,10 +30,11 @@ public enum PushNotificationAPI { public static func subscribeAll( token: Data, isForcedUpdate: Bool, - using dependencies: Dependencies = Dependencies() + using dependencies: Dependencies ) -> AnyPublisher { typealias SubscribeAllPreparedRequests = ( HTTP.PreparedRequest, + [HTTP.PreparedRequest], HTTP.PreparedRequest? ) let hexEncodedToken: String = token.toHexString() @@ -47,6 +55,7 @@ public enum PushNotificationAPI { let preparedUserRequest = try PushNotificationAPI .preparedSubscribe( db, + token: token, sessionId: userSessionId, using: dependencies ) @@ -59,6 +68,21 @@ public enum PushNotificationAPI { dependencies[defaults: .standard, key: .isUsingFullAPNs] = true } ) + let preparedGroupRequests = try ClosedGroup + .select(.threadId) + .filter(ClosedGroup.Columns.threadId.like("\(SessionId.Prefix.group.rawValue)%")) + .filter(ClosedGroup.Columns.shouldPoll) + .asRequest(of: String.self) + .fetchSet(db) + .map { groupId in + try PushNotificationAPI + .preparedSubscribe( + db, + token: token, + sessionId: SessionId(.group, hex: groupId), + using: dependencies + ) + } let preparedLegacyGroupRequest = try PushNotificationAPI .preparedSubscribeToLegacyGroups( forced: true, @@ -78,10 +102,11 @@ public enum PushNotificationAPI { return ( preparedUserRequest, + preparedGroupRequests, preparedLegacyGroupRequest ) } - .flatMap { userRequest, legacyGroupRequest -> AnyPublisher in + .flatMap { userRequest, preparedGroupRequests, legacyGroupRequest -> AnyPublisher in Publishers .MergeMany( [ @@ -94,7 +119,16 @@ public enum PushNotificationAPI { .send(using: dependencies) .map { _, _ in () } .eraseToAnyPublisher() - ].compactMap { $0 } + ] + .appending( + contentsOf: preparedGroupRequests.map { request in + request + .send(using: dependencies) + .map { _, _ in () } + .eraseToAnyPublisher() + } + ) + .compactMap { $0 } ) .collect() .map { _ in () } @@ -105,27 +139,23 @@ public enum PushNotificationAPI { public static func unsubscribeAll( token: Data, - using dependencies: Dependencies = Dependencies() + using dependencies: Dependencies ) -> AnyPublisher { typealias UnsubscribeAllPreparedRequests = ( HTTP.PreparedRequest, + [HTTP.PreparedRequest], [HTTP.PreparedRequest] ) return dependencies[singleton: .storage] .readPublisher(using: dependencies) { db -> UnsubscribeAllPreparedRequests in - guard let userED25519KeyPair: KeyPair = Identity.fetchUserEd25519KeyPair(db) else { - throw SnodeAPIError.noKeyPair - } - let userSessionId: SessionId = getUserSessionId(db, using: dependencies) let preparedUserRequest = try PushNotificationAPI .preparedUnsubscribe( db, token: token, sessionId: userSessionId, - subkey: nil, - ed25519KeyPair: userED25519KeyPair + using: dependencies ) .handleEvents( receiveOutput: { _, response in @@ -134,6 +164,20 @@ public enum PushNotificationAPI { dependencies[defaults: .standard, key: .deviceToken] = nil } ) + let preparedGroupUnsubscribeRequests = (try? ClosedGroup + .select(.threadId) + .filter(ClosedGroup.Columns.threadId.like("\(SessionId.Prefix.group.rawValue)%")) + .asRequest(of: String.self) + .fetchSet(db)) + .defaulting(to: []) + .compactMap { groupId in + try? PushNotificationAPI.preparedUnsubscribe( + db, + token: token, + sessionId: SessionId(.group, hex: groupId), + using: dependencies + ) + } // FIXME: Remove this once legacy groups are deprecated let preparedLegacyUnsubscribeRequests = (try? ClosedGroup @@ -145,13 +189,14 @@ public enum PushNotificationAPI { .compactMap { legacyGroupId in try? PushNotificationAPI.preparedUnsubscribeFromLegacyGroup( legacyGroupId: legacyGroupId, - userSessionId: userSessionId + userSessionId: userSessionId, + using: dependencies ) } - return (preparedUserRequest, preparedLegacyUnsubscribeRequests) + return (preparedUserRequest, preparedGroupUnsubscribeRequests, preparedLegacyUnsubscribeRequests) } - .flatMap { preparedUserRequest, preparedLegacyUnsubscribeRequests in + .flatMap { preparedUserRequest, preparedGroupUnsubscribeRequests, preparedLegacyUnsubscribeRequests in // FIXME: Remove this once legacy groups are deprecated /// Unsubscribe from all legacy groups (including ones the user is no longer a member of, just in case) Publishers @@ -161,9 +206,24 @@ public enum PushNotificationAPI { .receive(on: DispatchQueue.global(qos: .userInitiated), using: dependencies) .sinkUntilComplete() - return preparedUserRequest.send(using: dependencies) + return Publishers + .MergeMany( + [ + preparedUserRequest + .send(using: dependencies) + .map { _, _ in () } + .eraseToAnyPublisher() + ] + .appending( + contentsOf: preparedGroupUnsubscribeRequests.map { request in + request + .send(using: dependencies) + .map { _, _ in () } + .eraseToAnyPublisher() + } + ) + ) } - .map { _ in () } .eraseToAnyPublisher() } @@ -171,13 +231,11 @@ public enum PushNotificationAPI { public static func preparedSubscribe( _ db: Database, + token: Data, sessionId: SessionId, - using dependencies: Dependencies = Dependencies() + using dependencies: Dependencies ) throws -> HTTP.PreparedRequest { - guard - dependencies[defaults: .standard, key: .isUsingFullAPNs], - let token: String = dependencies[defaults: .standard, key: .deviceToken] - else { throw HTTPError.invalidRequest } + guard dependencies[defaults: .standard, key: .isUsingFullAPNs] else { throw HTTPError.invalidRequest } guard let notificationsEncryptionKey: Data = try? getOrGenerateEncryptionKey(using: dependencies) else { SNLog("Unable to retrieve PN encryption key.") @@ -192,7 +250,7 @@ public enum PushNotificationAPI { body: SubscribeRequest( namespaces: { switch sessionId.prefix { - case .group: return [.default] + case .group: return [.groupMessages] default: return [.default, .configConvoInfoVolatile] } }(), @@ -201,7 +259,7 @@ public enum PushNotificationAPI { // 'generic' notification being shown when receiving things like typing indicator updates includeMessageData: true, serviceInfo: ServiceInfo( - token: token + token: token.toHexString() ), notificationsEncryptionKey: notificationsEncryptionKey, authMethod: try Authentication.with( @@ -215,7 +273,8 @@ public enum PushNotificationAPI { ) ), responseType: SubscribeResponse.self, - retryCount: PushNotificationAPI.maxRetryCount + retryCount: PushNotificationAPI.maxRetryCount, + using: dependencies ) .handleEvents( receiveOutput: { _, response in @@ -236,9 +295,7 @@ public enum PushNotificationAPI { _ db: Database, token: Data, sessionId: SessionId, - subkey: String?, - ed25519KeyPair: KeyPair, - using dependencies: Dependencies = Dependencies() + using dependencies: Dependencies ) throws -> HTTP.PreparedRequest { return try PushNotificationAPI .prepareRequest( @@ -260,7 +317,8 @@ public enum PushNotificationAPI { ) ), responseType: UnsubscribeResponse.self, - retryCount: PushNotificationAPI.maxRetryCount + retryCount: PushNotificationAPI.maxRetryCount, + using: dependencies ) .handleEvents( receiveOutput: { _, response in @@ -284,7 +342,7 @@ public enum PushNotificationAPI { recipient: String, with message: String, maxRetryCount: Int? = nil, - using dependencies: Dependencies = Dependencies() + using dependencies: Dependencies ) throws -> HTTP.PreparedRequest { return try PushNotificationAPI .prepareRequest( @@ -297,7 +355,8 @@ public enum PushNotificationAPI { ) ), responseType: LegacyPushServerResponse.self, - retryCount: (maxRetryCount ?? PushNotificationAPI.maxRetryCount) + retryCount: (maxRetryCount ?? PushNotificationAPI.maxRetryCount), + using: dependencies ) .handleEvents( receiveOutput: { _, response in @@ -322,7 +381,7 @@ public enum PushNotificationAPI { token: String? = nil, userSessionId: SessionId, legacyGroupIds: Set, - using dependencies: Dependencies = Dependencies() + using dependencies: Dependencies ) throws -> HTTP.PreparedRequest? { let isUsingFullAPNs = dependencies[defaults: .standard, key: .isUsingFullAPNs] @@ -346,7 +405,8 @@ public enum PushNotificationAPI { ) ), responseType: LegacyPushServerResponse.self, - retryCount: PushNotificationAPI.maxRetryCount + retryCount: PushNotificationAPI.maxRetryCount, + using: dependencies ) .handleEvents( receiveOutput: { _, response in @@ -367,7 +427,7 @@ public enum PushNotificationAPI { public static func preparedUnsubscribeFromLegacyGroup( legacyGroupId: String, userSessionId: SessionId, - using dependencies: Dependencies = Dependencies() + using dependencies: Dependencies ) throws -> HTTP.PreparedRequest { return try PushNotificationAPI .prepareRequest( @@ -380,7 +440,8 @@ public enum PushNotificationAPI { ) ), responseType: LegacyPushServerResponse.self, - retryCount: PushNotificationAPI.maxRetryCount + retryCount: PushNotificationAPI.maxRetryCount, + using: dependencies ) .handleEvents( receiveOutput: { _, response in @@ -401,7 +462,7 @@ public enum PushNotificationAPI { public static func processNotification( notificationContent: UNNotificationContent, - dependencies: Dependencies = Dependencies() + using dependencies: Dependencies ) -> (data: Data?, metadata: NotificationMetadata, result: ProcessResult) { // Make sure the notification is from the updated push server guard notificationContent.userInfo["spns"] != nil else { @@ -467,9 +528,9 @@ public enum PushNotificationAPI { @discardableResult private static func getOrGenerateEncryptionKey(using dependencies: Dependencies) throws -> Data { do { - var encryptionKey: Data = try SSKDefaultKeychainStorage.shared.data( - forService: keychainService, - key: encryptionKeyKey + var encryptionKey: Data = try dependencies[singleton: .keychain].data( + forService: .pushNotificationAPI, + key: .pushNotificationEncryptionKey ) defer { encryptionKey.resetBytes(in: 0..( - request: PushNotificationAPIRequest, - using dependencies: Dependencies - ) -> AnyPublisher<(ResponseInfoType, Data?), Error> { - guard - let url: URL = URL(string: "\(request.endpoint.server)/\(request.endpoint.path)"), - let payload: Data = try? JSONEncoder(using: dependencies).encode(request.body) - else { - return Fail(error: HTTPError.invalidJSON) - .eraseToAnyPublisher() - } - - guard Features.useOnionRequests else { - return HTTP - .execute( - .post, - "\(request.endpoint.server)/\(request.endpoint.path)", - body: payload - ) - .map { response in (HTTP.ResponseInfo(code: -1, headers: [:]), response) } - .eraseToAnyPublisher() - } - - var urlRequest: URLRequest = URLRequest(url: url) - urlRequest.httpMethod = "POST" - urlRequest.allHTTPHeaderFields = [ HTTPHeader.contentType: "application/json" ] - urlRequest.httpBody = payload - - return dependencies[singleton: .network] - .send( - .onionRequest( - urlRequest, - to: request.endpoint.server, - with: request.endpoint.serverPublicKey - ) - ) - .eraseToAnyPublisher() + public static func deleteKeys(using dependencies: Dependencies = Dependencies()) { + try? dependencies[singleton: .keychain].remove(service: .pushNotificationAPI, key: .pushNotificationEncryptionKey) } // MARK: - Convenience @@ -563,7 +587,7 @@ public enum PushNotificationAPI { responseType: R.Type, retryCount: Int = 0, timeout: TimeInterval = HTTP.defaultTimeout, - using dependencies: Dependencies = Dependencies() + using dependencies: Dependencies ) throws -> HTTP.PreparedRequest { return HTTP.PreparedRequest( request: request, diff --git a/SessionNotificationServiceExtension/NotificationServiceExtension.swift b/SessionNotificationServiceExtension/NotificationServiceExtension.swift index 134416282..23b9ec783 100644 --- a/SessionNotificationServiceExtension/NotificationServiceExtension.swift +++ b/SessionNotificationServiceExtension/NotificationServiceExtension.swift @@ -68,7 +68,8 @@ public final class NotificationServiceExtension: UNNotificationServiceExtension } let (maybeData, metadata, result) = PushNotificationAPI.processNotification( - notificationContent: notificationContent + notificationContent: notificationContent, + using: dependencies ) guard diff --git a/SessionUtilitiesKit/Database/SSKKeychainStorage.swift b/SessionUtilitiesKit/Database/SSKKeychainStorage.swift deleted file mode 100644 index 0ee3cd907..000000000 --- a/SessionUtilitiesKit/Database/SSKKeychainStorage.swift +++ /dev/null @@ -1,112 +0,0 @@ -// -// Copyright (c) 2018 Open Whisper Systems. All rights reserved. -// -// stringlint:disable - -import Foundation -import SAMKeychain - -public enum KeychainStorageError: Error { - case failure(code: Int32?, description: String) - - public var code: Int32? { - switch self { - case .failure(let code, _): return code - } - } -} - -// MARK: - - -@objc public protocol SSKKeychainStorage: AnyObject { - - @objc func string(forService service: String, key: String) throws -> String - - @objc(setString:service:key:error:) func set(string: String, service: String, key: String) throws - - @objc func data(forService service: String, key: String) throws -> Data - - @objc func set(data: Data, service: String, key: String) throws - - @objc func remove(service: String, key: String) throws -} - -// MARK: - - -@objc -public class SSKDefaultKeychainStorage: NSObject, SSKKeychainStorage { - - @objc public static let shared = SSKDefaultKeychainStorage() - - // Force usage as a singleton - override private init() { - super.init() - } - - @objc public func string(forService service: String, key: String) throws -> String { - var error: NSError? - let result = SAMKeychain.password(forService: service, account: key, error: &error) - if let error = error { - throw KeychainStorageError.failure(code: Int32(error.code), description: "\(logTag) error retrieving string: \(error)") - } - guard let string = result else { - throw KeychainStorageError.failure(code: nil, description: "\(logTag) could not retrieve string") - } - return string - } - - @objc public func set(string: String, service: String, key: String) throws { - - SAMKeychain.setAccessibilityType(kSecAttrAccessibleAfterFirstUnlockThisDeviceOnly) - - var error: NSError? - let result = SAMKeychain.setPassword(string, forService: service, account: key, error: &error) - if let error = error { - throw KeychainStorageError.failure(code: Int32(error.code), description: "\(logTag) error setting string: \(error)") - } - guard result else { - throw KeychainStorageError.failure(code: nil, description: "\(logTag) could not set string") - } - } - - @objc public func data(forService service: String, key: String) throws -> Data { - var error: NSError? - let result = SAMKeychain.passwordData(forService: service, account: key, error: &error) - if let error = error { - throw KeychainStorageError.failure(code: Int32(error.code), description: "\(logTag) error retrieving data: \(error)") - } - guard let data = result else { - throw KeychainStorageError.failure(code: nil, description: "\(logTag) could not retrieve data") - } - return data - } - - @objc public func set(data: Data, service: String, key: String) throws { - - SAMKeychain.setAccessibilityType(kSecAttrAccessibleAfterFirstUnlockThisDeviceOnly) - - var error: NSError? - let result = SAMKeychain.setPasswordData(data, forService: service, account: key, error: &error) - if let error = error { - throw KeychainStorageError.failure(code: Int32(error.code), description: "\(logTag) error setting data: \(error)") - } - guard result else { - throw KeychainStorageError.failure(code: nil, description: "\(logTag) could not set data") - } - } - - @objc public func remove(service: String, key: String) throws { - var error: NSError? - let result = SAMKeychain.deletePassword(forService: service, account: key, error: &error) - if let error = error { - // If deletion failed because the specified item could not be found in the keychain, consider it success. - if error.code == errSecItemNotFound { - return - } - throw KeychainStorageError.failure(code: Int32(error.code), description: "\(logTag) error removing data: \(error)") - } - guard result else { - throw KeychainStorageError.failure(code: nil, description: "\(logTag) could not remove data") - } - } -} diff --git a/SessionUtilitiesKit/Database/Storage.swift b/SessionUtilitiesKit/Database/Storage.swift index 4f0204b63..696bc660c 100644 --- a/SessionUtilitiesKit/Database/Storage.swift +++ b/SessionUtilitiesKit/Database/Storage.swift @@ -21,13 +21,16 @@ public extension Singleton { ) } +// MARK: - KeychainStorage + +public extension KeychainStorage.ServiceKey { static let storage: Self = "TSKeyChainService" } +public extension KeychainStorage.DataKey { static let dbCipherKeySpec: Self = "GRDBDatabaseCipherKeySpec" } + // MARK: - Storage open class Storage { public static let queuePrefix: String = "SessionDatabase" private static let dbFileName: String = "Session.sqlite" - private static let keychainService: String = "TSKeyChainService" - private static let dbCipherKeySpecKey: String = "GRDBDatabaseCipherKeySpec" private static let kSQLCipherKeySpecLength: Int = 48 private static let writeWarningThreadshold: TimeInterval = 3 @@ -394,15 +397,15 @@ open class Storage { // MARK: - Security - private static func getDatabaseCipherKeySpec() throws -> Data { - return try SSKDefaultKeychainStorage.shared.data(forService: keychainService, key: dbCipherKeySpecKey) + private static func getDatabaseCipherKeySpec(using dependencies: Dependencies = Dependencies()) throws -> Data { + return try dependencies[singleton: .keychain].data(forService: .storage, key: .dbCipherKeySpec) } @discardableResult private static func getOrGenerateDatabaseKeySpec( using dependencies: Dependencies = Dependencies() ) throws -> Data { do { - var keySpec: Data = try getDatabaseCipherKeySpec() + var keySpec: Data = try getDatabaseCipherKeySpec(using: dependencies) defer { keySpec.resetBytes(in: 0..(singleton singleton: SingletonConfig) -> S { - getValueSettingIfNull(singleton: singleton, &Dependencies.singletonInstances) + guard let value: S = (Dependencies.singletonInstances.wrappedValue[singleton.identifier] as? S) else { + let value: S = singleton.createInstance(self) + Dependencies.singletonInstances.mutate { $0[singleton.identifier] = value } + return value + } + + return value } public subscript(cache cache: CacheConfig) -> I { - getValueSettingIfNull(cache: cache, &Dependencies.cacheInstances) + getValueSettingIfNull(cache: cache) } public subscript(defaults defaults: UserDefaultsConfig) -> UserDefaultsType { - getValueSettingIfNull(defaults: defaults, &Dependencies.userDefaultsInstances) + guard let value: UserDefaultsType = Dependencies.userDefaultsInstances.wrappedValue[defaults.identifier] else { + let value: UserDefaultsType = defaults.createInstance(self) + Dependencies.userDefaultsInstances.mutate { $0[defaults.identifier] = value } + return value + } + + return value } // MARK: - Timing and Async Handling @@ -55,7 +67,7 @@ public class Dependencies { /// the below code we first call `getValueSettingIfNull` to ensure we have a proper instance stored /// in `Dependencies.cacheInstances` so that we can be reliably certail we aren't accessing some /// random instance that will go out of memory as soon as the mutation is completed - getValueSettingIfNull(cache: cache, &Dependencies.cacheInstances) + getValueSettingIfNull(cache: cache) let cacheWrapper: Atomic = ( Dependencies.cacheInstances.wrappedValue[cache.identifier] ?? @@ -77,7 +89,7 @@ public class Dependencies { /// the below code we first call `getValueSettingIfNull` to ensure we have a proper instance stored /// in `Dependencies.cacheInstances` so that we can be reliably certail we aren't accessing some /// random instance that will go out of memory as soon as the mutation is completed - getValueSettingIfNull(cache: cache, &Dependencies.cacheInstances) + getValueSettingIfNull(cache: cache) let cacheWrapper: Atomic = ( Dependencies.cacheInstances.wrappedValue[cache.identifier] ?? @@ -106,45 +118,16 @@ public class Dependencies { // MARK: - Instance upserting - @discardableResult private func getValueSettingIfNull( - singleton: SingletonConfig, - _ store: inout Atomic<[String: Any]> - ) -> S { - guard let value: S = (store.wrappedValue[singleton.identifier] as? S) else { - let value: S = singleton.createInstance(self) - store.mutate { $0[singleton.identifier] = value } - return value - } - - return value - } - - @discardableResult private func getValueSettingIfNull( - cache: CacheConfig, - _ store: inout Atomic<[String: Atomic]> - ) -> I { - guard let value: M = (store.wrappedValue[cache.identifier]?.wrappedValue as? M) else { + @discardableResult private func getValueSettingIfNull(cache: CacheConfig) -> I { + guard let value: M = (Dependencies.cacheInstances.wrappedValue[cache.identifier]?.wrappedValue as? M) else { let value: M = cache.createInstance(self) let mutableInstance: MutableCacheType = cache.mutableInstance(value) - store.mutate { $0[cache.identifier] = Atomic(mutableInstance) } + Dependencies.cacheInstances.mutate { $0[cache.identifier] = Atomic(mutableInstance) } return cache.immutableInstance(value) } return cache.immutableInstance(value) } - - @discardableResult private func getValueSettingIfNull( - defaults: UserDefaultsConfig, - _ store: inout Atomic<[String: UserDefaultsType]> - ) -> UserDefaultsType { - guard let value: UserDefaultsType = store.wrappedValue[defaults.identifier] else { - let value: UserDefaultsType = defaults.createInstance(self) - store.mutate { $0[defaults.identifier] = value } - return value - } - - return value - } } // MARK: - Storage Setting Convenience diff --git a/SessionUtilitiesKit/Utilities/KeychainStorageType.swift b/SessionUtilitiesKit/Utilities/KeychainStorageType.swift new file mode 100644 index 000000000..d187ba6cc --- /dev/null +++ b/SessionUtilitiesKit/Utilities/KeychainStorageType.swift @@ -0,0 +1,195 @@ +// Copyright © 2023 Rangeproof Pty Ltd. All rights reserved. +// +// stringlint:disable + +import Foundation +import SAMKeychain + +// MARK: - Singleton + +public extension Singleton { + static let keychain: SingletonConfig = Dependencies.create( + identifier: "keychain", + createInstance: { _ in KeychainStorage() } + ) +} + +public enum KeychainStorageError: Error { + case failure(code: Int32?, description: String) + + public var code: Int32? { + switch self { + case .failure(let code, _): return code + } + } +} + +// MARK: - KeychainStorageType + +public protocol KeychainStorageType { + func string(forService service: KeychainStorage.ServiceKey, key: KeychainStorage.StringKey) throws -> String + func set(string: String, service: KeychainStorage.ServiceKey, key: KeychainStorage.StringKey) throws + func remove(service: KeychainStorage.ServiceKey, key: KeychainStorage.StringKey) throws + + func data(forService service: KeychainStorage.ServiceKey, key: KeychainStorage.DataKey) throws -> Data + func set(data: Data, service: KeychainStorage.ServiceKey, key: KeychainStorage.DataKey) throws + func remove(service: KeychainStorage.ServiceKey, key: KeychainStorage.DataKey) throws + + func removeAll() +} + +// MARK: - KeychainStorage + +public class KeychainStorage: KeychainStorageType { + public func string(forService service: KeychainStorage.ServiceKey, key: KeychainStorage.StringKey) throws -> String { + var error: NSError? + let result: String? = SAMKeychain.password(forService: service.rawValue, account: key.rawValue, error: &error) + + switch (error, result) { + case (.some(let error), _): + throw KeychainStorageError.failure( + code: Int32(error.code), + description: "[KeychainStorage] Error retrieving string: \(error)" + ) + + case (_, .none): + throw KeychainStorageError.failure(code: nil, description: "[KeychainStorage] Could not retrieve string") + + case (_, .some(let string)): return string + } + } + + public func set(string: String, service: KeychainStorage.ServiceKey, key: KeychainStorage.StringKey) throws { + SAMKeychain.setAccessibilityType(kSecAttrAccessibleAfterFirstUnlockThisDeviceOnly) + + var error: NSError? + let result: Bool = SAMKeychain.setPassword(string, forService: service.rawValue, account: key.rawValue, error: &error) + + switch (error, result) { + case (.some(let error), _): + throw KeychainStorageError.failure( + code: Int32(error.code), + description: "[KeychainStorage] Error setting string: \(error)" + ) + + case (_, false): + throw KeychainStorageError.failure(code: nil, description: "[KeychainStorage] Could not set string") + + case (_, true): break + } + } + + public func remove(service: KeychainStorage.ServiceKey, key: KeychainStorage.StringKey) throws { + try remove(service: service.rawValue, key: key.rawValue) + } + + public func data(forService service: KeychainStorage.ServiceKey, key: KeychainStorage.DataKey) throws -> Data { + var error: NSError? + let result: Data? = SAMKeychain.passwordData(forService: service.rawValue, account: key.rawValue, error: &error) + + switch (error, result) { + case (.some(let error), _): + throw KeychainStorageError.failure( + code: Int32(error.code), + description: "[KeychainStorage] Error retrieving data: \(error)" + ) + + case (_, .none): + throw KeychainStorageError.failure(code: nil, description: "[KeychainStorage] Could not retrieve data") + + case (_, .some(let data)): return data + } + } + + public func set(data: Data, service: KeychainStorage.ServiceKey, key: KeychainStorage.DataKey) throws { + SAMKeychain.setAccessibilityType(kSecAttrAccessibleAfterFirstUnlockThisDeviceOnly) + + var error: NSError? + let result: Bool = SAMKeychain.setPasswordData(data, forService: service.rawValue, account: key.rawValue, error: &error) + + switch (error, result) { + case (.some(let error), _): + throw KeychainStorageError.failure( + code: Int32(error.code), + description: "[KeychainStorage] Error setting data: \(error)" + ) + + case (_, false): + throw KeychainStorageError.failure(code: nil, description: "[KeychainStorage] Could not set data") + + case (_, true): break + } + } + + public func remove(service: KeychainStorage.ServiceKey, key: KeychainStorage.DataKey) throws { + try remove(service: service.rawValue, key: key.rawValue) + } + + private func remove(service: String, key: String) throws { + var error: NSError? + let result: Bool = SAMKeychain.deletePassword(forService: service, account: key, error: &error) + + switch (error, result) { + case (.some(let error), _): + /// If deletion failed because the specified item could not be found in the keychain, consider it success + guard error.code != errSecItemNotFound else { return } + + throw KeychainStorageError.failure( + code: Int32(error.code), + description: "[KeychainStorage] Error removing data: \(error)" + ) + + case (_, false): + throw KeychainStorageError.failure(code: nil, description: "[KeychainStorage] Could not remove data") + + case (_, true): break + } + } + + public func removeAll() { + let allData: [[String: Any]] = SAMKeychain.allAccounts().defaulting(to: []) + + allData.forEach { keychainEntry in + guard + let service: String = keychainEntry[kSAMKeychainWhereKey] as? String, + let key: String = keychainEntry[kSAMKeychainAccountKey] as? String + else { return } + + try? remove(service: service, key: key) + } + } +} + +// MARK: - Keys + +public extension KeychainStorage { + struct ServiceKey: RawRepresentable, ExpressibleByStringLiteral, Hashable { + public let rawValue: String + + public init(_ rawValue: String) { self.rawValue = rawValue } + public init?(rawValue: String) { self.rawValue = rawValue } + public init(stringLiteral value: String) { self.init(value) } + public init(unicodeScalarLiteral value: String) { self.init(value) } + public init(extendedGraphemeClusterLiteral value: String) { self.init(value) } + } + + struct DataKey: RawRepresentable, ExpressibleByStringLiteral, Hashable { + public let rawValue: String + + public init(_ rawValue: String) { self.rawValue = rawValue } + public init?(rawValue: String) { self.rawValue = rawValue } + public init(stringLiteral value: String) { self.init(value) } + public init(unicodeScalarLiteral value: String) { self.init(value) } + public init(extendedGraphemeClusterLiteral value: String) { self.init(value) } + } + + struct StringKey: RawRepresentable, ExpressibleByStringLiteral, Hashable { + public let rawValue: String + + public init(_ rawValue: String) { self.rawValue = rawValue } + public init?(rawValue: String) { self.rawValue = rawValue } + public init(stringLiteral value: String) { self.init(value) } + public init(unicodeScalarLiteral value: String) { self.init(value) } + public init(extendedGraphemeClusterLiteral value: String) { self.init(value) } + } +} diff --git a/_SharedTestUtilities/TestDependencies.swift b/_SharedTestUtilities/TestDependencies.swift index 38bbb0165..15fdf75aa 100644 --- a/_SharedTestUtilities/TestDependencies.swift +++ b/_SharedTestUtilities/TestDependencies.swift @@ -14,7 +14,13 @@ public class TestDependencies: Dependencies { // MARK: - Subscript Access override public subscript(singleton singleton: SingletonConfig) -> S { - return getValueSettingIfNull(singleton: singleton, &singletonInstances) + guard let value: S = (singletonInstances[singleton.identifier] as? S) else { + let value: S = singleton.createInstance(self) + singletonInstances[singleton.identifier] = value + return value + } + + return value } public subscript(singleton singleton: SingletonConfig) -> S? { @@ -23,7 +29,14 @@ public class TestDependencies: Dependencies { } override public subscript(cache cache: CacheConfig) -> I { - return getValueSettingIfNull(cache: cache, &cacheInstances) + guard let value: M = (cacheInstances[cache.identifier] as? M) else { + let value: M = cache.createInstance(self) + let mutableInstance: MutableCacheType = cache.mutableInstance(value) + cacheInstances[cache.identifier] = mutableInstance + return cache.immutableInstance(value) + } + + return cache.immutableInstance(value) } public subscript(cache cache: CacheConfig) -> M? { @@ -32,7 +45,13 @@ public class TestDependencies: Dependencies { } override public subscript(defaults defaults: UserDefaultsConfig) -> UserDefaultsType { - return getValueSettingIfNull(defaults: defaults, &defaultsInstances) + guard let value: UserDefaultsType = defaultsInstances[defaults.identifier] else { + let value: UserDefaultsType = defaults.createInstance(self) + defaultsInstances[defaults.identifier] = value + return value + } + + return value } public subscript(defaults defaults: UserDefaultsConfig) -> UserDefaultsType? { @@ -142,48 +161,6 @@ public class TestDependencies: Dependencies { return result.map { elements.remove($0) } } - - // MARK: - Instance upserting - - @discardableResult private func getValueSettingIfNull( - singleton: SingletonConfig, - _ store: inout [String: Any] - ) -> S { - guard let value: S = (store[singleton.identifier] as? S) else { - let value: S = singleton.createInstance(self) - store[singleton.identifier] = value - return value - } - - return value - } - - @discardableResult private func getValueSettingIfNull( - cache: CacheConfig, - _ store: inout [String: MutableCacheType] - ) -> I { - guard let value: M = (store[cache.identifier] as? M) else { - let value: M = cache.createInstance(self) - let mutableInstance: MutableCacheType = cache.mutableInstance(value) - store[cache.identifier] = mutableInstance - return cache.immutableInstance(value) - } - - return cache.immutableInstance(value) - } - - @discardableResult private func getValueSettingIfNull( - defaults: UserDefaultsConfig, - _ store: inout [String: (any UserDefaultsType)] - ) -> UserDefaultsType { - guard let value: UserDefaultsType = store[defaults.identifier] else { - let value: UserDefaultsType = defaults.createInstance(self) - store[defaults.identifier] = value - return value - } - - return value - } } // MARK: - TestState Convenience