Signal-iOS/SignalMessaging/utils/APNSRotationStore.swift
Harry 6556f389c7
Rotate APNS tokens automatically when the NSE fails to receive notifications
* Allow using a transaction for checking APNS token

* Add APNSRotationStore

* add APNSRotationStoreTest

* Rotate APNS tokens in SyncPushTokensJob

* Mark NSE as having processed messages for token rotation

* Check for APNS token rotations after flushing incoming message queue

* pr comments

* Simplify APNS rotation conditions: we mark APNS as working when getting a push in either the NSE or the main app, and rotate if we missed a message since then.

* Mark APNS tokens as working if we ever get a push. Don't rotate known-good tokens.

* Added logging

* add remote flags to control behavior

* pr comments

* fix bad merge

* invert kill switch

* PR nit comments

* Avoid write transactions on the SyncPushTokensJob hot path

* Initial PR feedback, more to come

* Change of strategy: rotate if we have new messages to process on app startup

* PR comments

* use null for calltype column on incoming message rows
2022-12-19 11:10:15 -08:00

226 lines
11 KiB
Swift

//
// Copyright 2022 Signal Messenger, LLC
// SPDX-License-Identifier: AGPL-3.0-only
//
import SignalServiceKit
/// This object serves as a store of data for interop between the NSE and SyncPushTokensJob.
/// It lets the NSE record when it handles messages, so that if it hasn't done so in a while,
/// SyncPushTokensJob can rotate the APNS token to try and recover.
public final class APNSRotationStore: NSObject {
private static let kvStore = SDSKeyValueStore(collection: "APNSRotationStore")
// exposed for testing. we need a better way to do this.
internal static var nowMs: () -> UInt64 = { Date().ows_millisecondsSince1970 }
@objc
public static func didReceiveAPNSPush(transaction: SDSAnyWriteTransaction) {
// See comments on `setAppVersionTimeForAPNSRotationIfNeeded`.
// If we actually get an APNS push, even before the app version bake time
// has passed, we have all the state we need and don't need the app version
// check anymore. Bypass it by writing a timestamp far enough in the past that
// we think the bake time has passed already.
kvStore.setUInt64(
nowMs() - Constants.appVersionBakeTimeMs - 1,
key: Constants.apnsRotationAppVersionUpdateTimestampKey,
transaction: transaction
)
// Mark the current token as one we know works!
guard let token = preferences.getPushToken(transaction) else {
owsFailDebug("Got a push without a push token; not marking any token as working.")
return
}
kvStore.setString(
token,
key: Constants.lastKnownWorkingAPNSTokenKey,
transaction: transaction
)
}
public static func didRotateAPNSToken(transaction: SDSAnyWriteTransaction) {
kvStore.setUInt64(
nowMs(),
key: Constants.lastAPNSRotationTimestampKey,
transaction: transaction
)
kvStore.removeValue(
forKey: Constants.lastKnownWorkingAPNSTokenKey,
transaction: transaction
)
}
/// Call this on app startup once launched, registered, and ready.
/// Takes a closure that rotates the APNS token. If the token should be rotated, calls this closure.
/// Returns an optional closure, which if present should be run once the message queue is flushed and
/// all pending messages are done processing.
/// Note the passed in closure may be called after the returned closure is called.
public static func rotateIfNeededOnAppLaunchAndReadiness(performRotation: @escaping () -> Void) -> (() -> Void)? {
// Structuring as efficiently as possible: in a single read we check if we
// are eligible to rotate, if not whether we need to open an expensive write transaction
// to write the app version to (which we should only ever do once),
// and if we are eligible to rotate fetch the latest message for later comparison.
// Presence of a latestMessageTimestamp implies we should attempt a rotation after message processing.
let (needsAppVersionWrite, latestMessageTimestamp) = databaseStorage.read { transaction -> (Bool, UInt64?) in
if APNSRotationStore.needsAppVersionWrite(transaction: transaction) {
// We need to do a write to set the app version check.
// No need to actually check if we need a rotation, we definitely don't
// if we haven't written the app version time.
return (true, nil)
}
let canRotate = APNSRotationStore.canRotateAPNSToken(transaction: transaction)
if canRotate {
return (false, InteractionFinder.lastInsertedIncomingMessage(transaction: transaction)?.timestamp)
} else {
return (false, nil)
}
}
if let latestMessageTimestampBeforeProcessing = latestMessageTimestamp {
// We are eligible to rotate the APNS token. Wait for fetching and processing to finish,
// and if the latest message changed that means we had new messages to process
// and therefore missed messages when the app wasn't active.
return {
let latestMessageTimestamp = Self.databaseStorage.read { transaction -> UInt64? in
return InteractionFinder.lastInsertedIncomingMessage(transaction: transaction)?.timestamp
}
if let latestMessageTimestamp, latestMessageTimestamp != latestMessageTimestampBeforeProcessing {
// Rotate.
Logger.info("New messages seen on app startup, rotating APNS token.")
performRotation()
return
}
}
} else if needsAppVersionWrite {
databaseStorage.asyncWrite { transaction in
APNSRotationStore.setAppVersionTimeForAPNSRotationIfNeeded(transaction: transaction)
}
return nil
} else {
return nil
}
}
public static func canRotateAPNSToken(transaction: SDSAnyReadTransaction) -> Bool {
Logger.info("Checking if push token should be rotated...")
guard let currentToken = self.preferences.getPushToken(transaction) else {
// No need to rotate if we don't even have a token yet.
Logger.info("No push token available, not rotating.")
return false
}
guard RemoteConfig.enableAutoAPNSRotation else {
Logger.info("Not enabled remotely, not rotating token.")
return false
}
guard isClientEligibleForAPNSTokenRotation(transaction: transaction) else {
// We gotta give this client time to sit on the app release that added
// this check before we attempt a rotation.
Logger.info("Letting client update bake before rotating push token.")
return false
}
guard hasLockoutPeriodElapsed(transaction: transaction) else {
// We rotated too recently!
Logger.info("Last push token rotation too recent; not rotating again.")
return false
}
let knownGoodToken = self.kvStore.getString(
Constants.lastKnownWorkingAPNSTokenKey,
transaction: transaction
)
let isUsingKnownGoodToken = currentToken == knownGoodToken
if isUsingKnownGoodToken {
// Our current token is a known working one, don't rotate.
Logger.warn("Has known-good APNS token, skipping rotation.")
return false
}
return true
}
/// See comments on `setAppVersionTimeForAPNSRotationIfNeeded`.
/// This is a read transaction (faster) way to check if we _need_ to write anything before
/// committing to a write transaction that blocks on a serial queue.
private static func needsAppVersionWrite(transaction: SDSAnyReadTransaction) -> Bool {
return kvStore.getUInt64(
Constants.apnsRotationAppVersionUpdateTimestampKey,
transaction: transaction
) == nil
}
/// Consumers of this class should call this when they call `shouldRotateAPNSToken`
/// (before or after, doesn't matter).
/// This ensures that after an app version update, we give enough baking time to write state
/// before attempting a rotation.
static func setAppVersionTimeForAPNSRotationIfNeeded(transaction: SDSAnyWriteTransaction) {
// Consider this scnario: the user updates their app to the first version which includes
// this rotation checking code. We check and see that we haven't marked down any incoming
// APNS pushes in `lastAPNSPushLocalTimestampKey` (we weren't storing that before this code was added!)
// but we do have missed messages, so the code thinks we should rotate!
//
// To avoid this, we give the version update time to bake, marking down when we first
// attempted a rotation (i.e. the first time we ran an app version with this code present)
// and not rotating until enough time has passed (or we get an APNS push).
guard needsAppVersionWrite(transaction: transaction) else {
return
}
kvStore.setUInt64(
nowMs(),
key: Constants.apnsRotationAppVersionUpdateTimestampKey,
transaction: transaction
)
}
private static func isClientEligibleForAPNSTokenRotation(transaction: SDSAnyReadTransaction) -> Bool {
// Clients will update to a version that runs this code for the first time.
// There might not be any APNS pushes since updating, but that doesn't mean it didn't
// run for previous incoming messages; we just weren't storing anything
// at the time, prior to the update.
// Make sure its been some time since we first started checking to avoid this.
let nowMs = self.nowMs()
guard
let clientUpdateTime = kvStore.getUInt64(
Constants.apnsRotationAppVersionUpdateTimestampKey,
transaction: transaction
),
// Protect against negative UInt64 values if the clock changes back in time.
nowMs > clientUpdateTime,
nowMs - clientUpdateTime >= Constants.appVersionBakeTimeMs
else {
return false
}
return true
}
private static func hasLockoutPeriodElapsed(transaction: SDSAnyReadTransaction) -> Bool {
guard let lastRotationTime = kvStore.getUInt64(
Constants.lastAPNSRotationTimestampKey,
transaction: transaction
) else {
// We haven't rotated before
return true
}
let nowMs = self.nowMs()
// Protect against negative UInt64 values if the clock changes back in time.
return nowMs > lastRotationTime && nowMs - lastRotationTime > Constants.minRotationInterval
}
internal enum Constants {
/// When we get an APNS push, we store the current token under this key
/// since we know it is working.
fileprivate static let lastKnownWorkingAPNSTokenKey = "lastKnownWorkingAPNSTokenKey"
/// See comments on `setAppVersionTimeForAPNSRotationIfNeeded`.
/// Time we wait after the app first updates to a version with this code before we issue
/// a token rotation due to missed messages.
internal static let appVersionBakeTimeMs: UInt64 = kWeekInMs
/// See comments on `setAppVersionTimeForAPNSRotationIfNeeded`.
/// This is the key where we store when we have updated.
fileprivate static let apnsRotationAppVersionUpdateTimestampKey = "apnsRotationAppVersionUpdateTimestampKey"
/// Don't ever rotate tokens more often than this.
internal static let minRotationInterval: UInt64 = kWeekInMs
fileprivate static let lastAPNSRotationTimestampKey = "lastAPNSRotationTimestampKey"
}
}