You cannot select more than 25 topics Topics must start with a letter or number, can include dashes ('-') and can be up to 35 characters long.
session-ios/SessionMessagingKit/Sending & Receiving/Notifications/PushNotificationAPI.swift

566 lines
26 KiB
Swift

// Copyright © 2022 Rangeproof Pty Ltd. All rights reserved.
//
// stringlint:disable
import Foundation
import Combine
import GRDB
import SessionSnodeKit
import SessionUtilitiesKit
// MARK: - KeychainStorage
public extension KeychainStorage.DataKey { static let pushNotificationEncryptionKey: Self = "PNEncryptionKeyKey" }
// MARK: - Log.Category
private extension Log.Category {
static let cat: Log.Category = .create("PushNotificationAPI", defaultLevel: .info)
}
// MARK: - PushNotificationAPI
public enum PushNotificationAPI {
internal static let encryptionKeyLength: Int = 32
private static let maxRetryCount: Int = 4
private static let tokenExpirationInterval: TimeInterval = (12 * 60 * 60)
public static let server: FeatureValue<String> = FeatureValue(feature: .serviceNetwork) { feature in
switch feature {
case .mainnet: return "https://push.getsession.org"
case .testnet: return "http://push-testnet.getsession.org"
}
}
public static let serverPublicKey = "d7557fe563e2610de876c0ac7341b62f3c82d5eea4b62c702392ea4368f51b3b"
public static let legacyServer = "https://live.apns.getsession.org"
public static let legacyServerPublicKey = "642a6585919742e5a2d4dc51244964fbcd8bcab2b75612407de58b810740d049"
// MARK: - Batch Requests
public static func subscribeAll(
token: Data,
isForcedUpdate: Bool,
using dependencies: Dependencies
) -> AnyPublisher<Void, Error> {
typealias SubscribeAllPreparedRequests = (
Network.PreparedRequest<PushNotificationAPI.SubscribeResponse>,
Network.PreparedRequest<PushNotificationAPI.LegacyPushServerResponse>?
)
let hexEncodedToken: String = token.toHexString()
let oldToken: String? = dependencies[defaults: .standard, key: .deviceToken]
let lastUploadTime: Double = dependencies[defaults: .standard, key: .lastDeviceTokenUpload]
let now: TimeInterval = dependencies.dateNow.timeIntervalSince1970
guard isForcedUpdate || hexEncodedToken != oldToken || now - lastUploadTime > tokenExpirationInterval else {
Log.info(.cat, "Device token hasn't changed or expired; no need to re-upload.")
return Just(())
.setFailureType(to: Error.self)
.eraseToAnyPublisher()
}
return dependencies[singleton: .storage]
.readPublisher { db -> SubscribeAllPreparedRequests in
let userSessionId: SessionId = dependencies[cache: .general].sessionId
let preparedSubscriptionRequest = try PushNotificationAPI
.preparedSubscribe(
db,
token: token,
sessionIds: [userSessionId]
.appending(contentsOf: try ClosedGroup
.select(.threadId)
.filter(
ClosedGroup.Columns.threadId > SessionId.Prefix.group.rawValue &&
ClosedGroup.Columns.threadId < SessionId.Prefix.group.endOfRangeString
)
.filter(ClosedGroup.Columns.shouldPoll)
.asRequest(of: String.self)
.fetchSet(db)
.map { SessionId(.group, hex: $0) }
),
using: dependencies
)
.handleEvents(
receiveOutput: { _, response in
guard response.subResponses.first?.success == true else { return }
dependencies[defaults: .standard, key: .deviceToken] = hexEncodedToken
dependencies[defaults: .standard, key: .lastDeviceTokenUpload] = now
dependencies[defaults: .standard, key: .isUsingFullAPNs] = true
}
)
let preparedLegacyGroupRequest = try PushNotificationAPI
.preparedSubscribeToLegacyGroups(
forced: true,
token: hexEncodedToken,
userSessionId: userSessionId,
legacyGroupIds: try ClosedGroup
.select(.threadId)
.filter(
ClosedGroup.Columns.threadId > SessionId.Prefix.standard.rawValue &&
ClosedGroup.Columns.threadId < SessionId.Prefix.standard.endOfRangeString
)
.joining(
required: ClosedGroup.members
.filter(GroupMember.Columns.profileId == userSessionId.hexString)
)
.asRequest(of: String.self)
.fetchSet(db),
using: dependencies
)
return (
preparedSubscriptionRequest,
preparedLegacyGroupRequest
)
}
.flatMap { subscriptionRequest, legacyGroupRequest -> AnyPublisher<Void, Error> in
Publishers
.MergeMany(
[
subscriptionRequest
.send(using: dependencies)
.map { _, _ in () }
.eraseToAnyPublisher(),
// FIXME: Remove this once legacy groups are deprecated
legacyGroupRequest?
.send(using: dependencies)
.map { _, _ in () }
.eraseToAnyPublisher()
]
.compactMap { $0 }
)
.collect()
.map { _ in () }
.eraseToAnyPublisher()
}
.eraseToAnyPublisher()
}
public static func unsubscribeAll(
token: Data,
using dependencies: Dependencies
) -> AnyPublisher<Void, Error> {
typealias UnsubscribeAllPreparedRequests = (
Network.PreparedRequest<PushNotificationAPI.UnsubscribeResponse>,
[Network.PreparedRequest<PushNotificationAPI.LegacyPushServerResponse>]
)
return dependencies[singleton: .storage]
.readPublisher { db -> UnsubscribeAllPreparedRequests in
let userSessionId: SessionId = dependencies[cache: .general].sessionId
let preparedUnsubscribe = try PushNotificationAPI
.preparedUnsubscribe(
db,
token: token,
sessionIds: [userSessionId]
.appending(contentsOf: (try? ClosedGroup
.select(.threadId)
.filter(
ClosedGroup.Columns.threadId > SessionId.Prefix.group.rawValue &&
ClosedGroup.Columns.threadId < SessionId.Prefix.group.endOfRangeString
)
.asRequest(of: String.self)
.fetchSet(db))
.defaulting(to: [])
.map { SessionId(.group, hex: $0) }),
using: dependencies
)
.handleEvents(
receiveOutput: { _, response in
guard response.subResponses.first?.success == true else { return }
dependencies[defaults: .standard, key: .deviceToken] = nil
}
)
// FIXME: Remove this once legacy groups are deprecated
let preparedLegacyUnsubscribeRequests = (try? ClosedGroup
.select(.threadId)
.filter(
ClosedGroup.Columns.threadId > SessionId.Prefix.standard.rawValue &&
ClosedGroup.Columns.threadId < SessionId.Prefix.standard.endOfRangeString
)
.asRequest(of: String.self)
.fetchSet(db))
.defaulting(to: [])
.compactMap { legacyGroupId in
try? PushNotificationAPI.preparedUnsubscribeFromLegacyGroup(
legacyGroupId: legacyGroupId,
userSessionId: userSessionId,
using: dependencies
)
}
return (preparedUnsubscribe, preparedLegacyUnsubscribeRequests)
}
.flatMap { preparedUnsubscribe, 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
.MergeMany(preparedLegacyUnsubscribeRequests.map { $0.send(using: dependencies) })
.collect()
.subscribe(on: DispatchQueue.global(qos: .userInitiated), using: dependencies)
.receive(on: DispatchQueue.global(qos: .userInitiated), using: dependencies)
.sinkUntilComplete()
return preparedUnsubscribe.send(using: dependencies)
}
.map { _ in () }
.eraseToAnyPublisher()
}
// MARK: - Prepared Requests
public static func preparedSubscribe(
_ db: Database,
token: Data,
sessionIds: [SessionId],
using dependencies: Dependencies
) throws -> Network.PreparedRequest<SubscribeResponse> {
guard dependencies[defaults: .standard, key: .isUsingFullAPNs] else {
throw NetworkError.invalidPreparedRequest
}
guard let notificationsEncryptionKey: Data = try? getOrGenerateEncryptionKey(using: dependencies) else {
Log.error(.cat, "Unable to retrieve PN encryption key.")
throw StorageError.invalidKeySpec
}
return try Network.PreparedRequest(
request: Request(
method: .post,
endpoint: Endpoint.subscribe,
body: SubscribeRequest(
subscriptions: sessionIds.map { sessionId -> SubscribeRequest.Subscription in
SubscribeRequest.Subscription(
namespaces: {
switch sessionId.prefix {
case .group: return [
.groupMessages,
.configGroupKeys,
.configGroupInfo,
.configGroupMembers,
.revokedRetrievableGroupMessages
]
default: return [.default, .configConvoInfoVolatile]
}
}(),
// Note: Unfortunately we always need the message content because without the content
// control messages can't be distinguished from visible messages which results in the
// 'generic' notification being shown when receiving things like typing indicator updates
includeMessageData: true,
serviceInfo: ServiceInfo(
token: token.toHexString()
),
notificationsEncryptionKey: notificationsEncryptionKey,
authMethod: try Authentication.with(
db,
swarmPublicKey: sessionId.hexString,
using: dependencies
),
timestamp: (dependencies[cache: .snodeAPI].currentOffsetTimestampMs() / 1000) // Seconds
)
}
),
using: dependencies
),
responseType: SubscribeResponse.self,
retryCount: PushNotificationAPI.maxRetryCount,
using: dependencies
)
.handleEvents(
receiveOutput: { _, response in
zip(response.subResponses, sessionIds).forEach { subResponse, sessionId in
guard subResponse.success != true else { return }
Log.error(.cat, "Couldn't subscribe for push notifications for: \(sessionId) due to error (\(subResponse.error ?? -1)): \(subResponse.message ?? "nil").")
}
},
receiveCompletion: { result in
switch result {
case .finished: break
case .failure: Log.error(.cat, "Couldn't subscribe for push notifications.")
}
}
)
}
public static func preparedUnsubscribe(
_ db: Database,
token: Data,
sessionIds: [SessionId],
using dependencies: Dependencies
) throws -> Network.PreparedRequest<UnsubscribeResponse> {
return try Network.PreparedRequest(
request: Request(
method: .post,
endpoint: Endpoint.unsubscribe,
body: UnsubscribeRequest(
subscriptions: sessionIds.map { sessionId -> UnsubscribeRequest.Subscription in
UnsubscribeRequest.Subscription(
serviceInfo: ServiceInfo(
token: token.toHexString()
),
authMethod: try Authentication.with(
db,
swarmPublicKey: sessionId.hexString,
using: dependencies
),
timestamp: (dependencies[cache: .snodeAPI].currentOffsetTimestampMs() / 1000) // Seconds
)
}
),
using: dependencies
),
responseType: UnsubscribeResponse.self,
retryCount: PushNotificationAPI.maxRetryCount,
using: dependencies
)
.handleEvents(
receiveOutput: { _, response in
zip(response.subResponses, sessionIds).forEach { subResponse, sessionId in
guard subResponse.success != true else { return }
Log.error(.cat, "Couldn't unsubscribe for push notifications for: \(sessionId) due to error (\(subResponse.error ?? -1)): \(subResponse.message ?? "nil").")
}
},
receiveCompletion: { result in
switch result {
case .finished: break
case .failure: Log.error(.cat, "Couldn't unsubscribe for push notifications.")
}
}
)
}
// MARK: - Legacy Notifications
// FIXME: Remove this once legacy notifications and legacy groups are deprecated
public static func preparedLegacyNotify(
recipient: String,
with message: String,
maxRetryCount: Int? = nil,
using dependencies: Dependencies
) throws -> Network.PreparedRequest<LegacyPushServerResponse> {
return try Network.PreparedRequest(
request: Request(
method: .post,
endpoint: Endpoint.legacyNotify,
body: LegacyNotifyRequest(
data: message,
sendTo: recipient
),
using: dependencies
),
responseType: LegacyPushServerResponse.self,
retryCount: (maxRetryCount ?? PushNotificationAPI.maxRetryCount),
using: dependencies
)
.handleEvents(
receiveOutput: { _, response in
guard response.code != 0 else {
return Log.error(.cat, "Couldn't send push notification due to error: \(response.message ?? "nil").")
}
},
receiveCompletion: { result in
switch result {
case .finished: break
case .failure: Log.error(.cat, "Couldn't send push notification.")
}
}
)
}
// MARK: - Legacy Groups
// FIXME: Remove this once legacy groups are deprecated
public static func preparedSubscribeToLegacyGroups(
forced: Bool = false,
token: String? = nil,
userSessionId: SessionId,
legacyGroupIds: Set<String>,
using dependencies: Dependencies
) throws -> Network.PreparedRequest<LegacyPushServerResponse>? {
let isUsingFullAPNs: Bool = dependencies[defaults: .standard, key: .isUsingFullAPNs]
// Only continue if PNs are enabled and we have a device token
guard
!legacyGroupIds.isEmpty,
(forced || isUsingFullAPNs),
let deviceToken: String = (token ?? dependencies[defaults: .standard, key: .deviceToken])
else { return nil }
return try Network.PreparedRequest(
request: Request(
method: .post,
endpoint: .legacyGroupsOnlySubscribe,
body: LegacyGroupOnlyRequest(
token: deviceToken,
pubKey: userSessionId.hexString,
device: "ios",
legacyGroupPublicKeys: legacyGroupIds
),
using: dependencies
),
responseType: LegacyPushServerResponse.self,
retryCount: PushNotificationAPI.maxRetryCount,
using: dependencies
)
.handleEvents(
receiveOutput: { _, response in
guard response.code != 0 else {
return Log.error(.cat, "Couldn't subscribe for legacy groups due to error: \(response.message ?? "nil").")
}
},
receiveCompletion: { result in
switch result {
case .finished: break
case .failure(let error):
Log.error(.cat, "Couldn't subscribe for legacy groups due to error: \(error).")
}
}
)
}
// FIXME: Remove this once legacy groups are deprecated
public static func preparedUnsubscribeFromLegacyGroup(
legacyGroupId: String,
userSessionId: SessionId,
using dependencies: Dependencies
) throws -> Network.PreparedRequest<LegacyPushServerResponse> {
return try Network.PreparedRequest(
request: Request(
method: .post,
endpoint: .legacyGroupUnsubscribe,
body: LegacyGroupRequest(
pubKey: userSessionId.hexString,
closedGroupPublicKey: legacyGroupId
),
using: dependencies
),
responseType: LegacyPushServerResponse.self,
retryCount: PushNotificationAPI.maxRetryCount,
using: dependencies
)
.handleEvents(
receiveOutput: { _, response in
guard response.code != 0 else {
return Log.error(.cat, "Couldn't unsubscribe for legacy group: \(legacyGroupId) due to error: \(response.message ?? "nil").")
}
},
receiveCompletion: { result in
switch result {
case .finished: break
case .failure: Log.error(.cat, "Couldn't unsubscribe for legacy group: \(legacyGroupId).")
}
}
)
}
// MARK: - Notification Handling
public static func processNotification(
notificationContent: UNNotificationContent,
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 {
guard
let base64EncodedData: String = notificationContent.userInfo["ENCRYPTED_DATA"] as? String,
let data: Data = Data(base64Encoded: base64EncodedData)
else { return (nil, .invalid, .legacyFailure) }
// We only support legacy notifications for legacy group conversations
guard
let envelope: SNProtoEnvelope = try? MessageWrapper.unwrap(data: data),
envelope.type == .closedGroupMessage,
let metadata: NotificationMetadata = try? .legacyGroupMessage(envelope: envelope)
else { return (data, .invalid, .legacyForceSilent) }
return (data, metadata, .legacySuccess)
}
guard let base64EncodedEncString: String = notificationContent.userInfo["enc_payload"] as? String else {
return (nil, .invalid, .failureNoContent)
}
// Decrypt and decode the payload
guard
let encryptedData: Data = Data(base64Encoded: base64EncodedEncString),
let notificationsEncryptionKey: Data = try? getOrGenerateEncryptionKey(using: dependencies),
let decryptedData: Data = dependencies[singleton: .crypto].generate(
.plaintextWithPushNotificationPayload(
payload: encryptedData,
encKey: notificationsEncryptionKey
)
),
let notification: BencodeResponse<NotificationMetadata> = try? BencodeDecoder(using: dependencies)
.decode(BencodeResponse<NotificationMetadata>.self, from: decryptedData)
else { return (nil, .invalid, .failure) }
// If the metadata says that the message was too large then we should show the generic
// notification (this is a valid case)
guard !notification.info.dataTooLong else { return (nil, notification.info, .successTooLong) }
// Check that the body we were given is valid
guard
let notificationData: Data = notification.data,
notification.info.dataLength == notificationData.count
else { return (nil, notification.info, .failure) }
// Success, we have the notification content
return (notificationData, notification.info, .success)
}
// MARK: - Security
@discardableResult private static func getOrGenerateEncryptionKey(using dependencies: Dependencies) throws -> Data {
do {
try dependencies[singleton: .keychain].migrateLegacyKeyIfNeeded(
legacyKey: "PNEncryptionKeyKey",
legacyService: "PNKeyChainService",
toKey: .pushNotificationEncryptionKey
)
var encryptionKey: Data = try dependencies[singleton: .keychain].data(forKey: .pushNotificationEncryptionKey)
defer { encryptionKey.resetBytes(in: 0..<encryptionKey.count) }
guard encryptionKey.count == encryptionKeyLength else { throw StorageError.invalidKeySpec }
return encryptionKey
}
catch {
switch (error, (error as? KeychainStorageError)?.code) {
case (StorageError.invalidKeySpec, _), (_, errSecItemNotFound):
// No keySpec was found so we need to generate a new one
do {
var keySpec: Data = try dependencies[singleton: .crypto]
.tryGenerate(.randomBytes(encryptionKeyLength))
defer { keySpec.resetBytes(in: 0..<keySpec.count) } // Reset content immediately after use
try dependencies[singleton: .keychain].set(data: keySpec, forKey: .pushNotificationEncryptionKey)
return keySpec
}
catch {
Log.error(.cat, "Setting keychain value failed with error: \(error.localizedDescription)")
throw StorageError.keySpecCreationFailed
}
default:
// Because we use kSecAttrAccessibleAfterFirstUnlockThisDeviceOnly, the keychain will be inaccessible
// after device restart until device is unlocked for the first time. If the app receives a push
// notification, we won't be able to access the keychain to process that notification, so we should
// just terminate by throwing an uncaught exception
if dependencies[singleton: .appContext].isMainApp || dependencies[singleton: .appContext].isInBackground {
let appState: UIApplication.State = dependencies[singleton: .appContext].reportedApplicationState
Log.error(.cat, "CipherKeySpec inaccessible. New install or no unlock since device restart?, ApplicationState: \(appState.name)")
throw StorageError.keySpecInaccessible
}
Log.error(.cat, "CipherKeySpec inaccessible; not main app.")
throw StorageError.keySpecInaccessible
}
}
}
}