Book a Demo!
CoCalc Logo Icon
StoreFeaturesDocsShareSupportNewsAboutPoliciesSign UpSign In
signalapp
GitHub Repository: signalapp/Signal-iOS
Path: blob/main/SignalServiceKit/Messages/OWSMessageDecrypter.swift
1 views
//
// Copyright 2020 Signal Messenger, LLC
// SPDX-License-Identifier: AGPL-3.0-only
//

import LibSignalClient

public class OWSMessageDecrypter {

    private let senderIdsResetDuringCurrentBatch = AtomicValue<Set<String>>(Set(), lock: .init())

    private var placeholderCleanupTimer: Timer? {
        didSet { oldValue?.invalidate() }
    }

    public init(appReadiness: AppReadiness) {
        SwiftSingletons.register(self)

        appReadiness.runNowOrWhenAppDidBecomeReadySync {
            NotificationCenter.default.addObserver(
                self,
                selector: #selector(self.messageProcessorDidDrainQueue),
                name: MessageProcessor.messageProcessorDidDrainQueue,
                object: nil,
            )
        }

        appReadiness.runNowOrWhenAppDidBecomeReadyAsync { [weak self] in
            guard let self else { return }
            guard CurrentAppContext().isMainApp else { return }
            self.cleanUpExpiredPlaceholders()
        }
    }

    @objc
    func messageProcessorDidDrainQueue() {
        Task {
            // We don't want to send additional resets until we have received the
            // "empty" response from the WebSocket.
            guard await SSKEnvironment.shared.messageFetcherJobRef.hasCompletedInitialFetch else { return }

            // We clear all recently reset sender ids any time the decryption queue has
            // drained, so that any new messages that fail to decrypt will reset the
            // session again.
            senderIdsResetDuringCurrentBatch.update { $0.removeAll() }
        }
    }

    private func trySendNullMessage(
        in contactThread: TSContactThread,
        senderId: String,
        transaction: DBWriteTransaction,
    ) {
        if RemoteConfig.current.automaticSessionResetKillSwitch {
            Logger.warn("Skipping null message after undecryptable message from \(senderId) due to kill switch.")
            return
        }

        let store = KeyValueStore(collection: "OWSMessageDecrypter+NullMessage")

        let lastNullMessageDate = store.getDate(senderId, transaction: transaction)
        let timeSinceNullMessage = abs(lastNullMessageDate?.timeIntervalSinceNow ?? .infinity)
        guard timeSinceNullMessage > RemoteConfig.current.automaticSessionResetAttemptInterval else {
            Logger.warn(
                "Skipping null message after undecryptable message from \(senderId), " +
                    "last null message sent \(lastNullMessageDate!.ows_millisecondsSince1970).",
            )
            return
        }

        Logger.info("Sending null message to reset session after undecryptable message from: \(senderId)")
        store.setDate(Date(), key: senderId, transaction: transaction)

        transaction.addSyncCompletion {
            Task {
                let db = DependenciesBridge.shared.db
                let messageSenderJobQueue = SSKEnvironment.shared.messageSenderJobQueueRef

                await db.awaitableWrite { transaction in
                    let nullMessage = OutgoingNullMessage(contactThread: contactThread, tx: transaction)
                    let preparedMessage = PreparedOutgoingMessage.preprepared(
                        transientMessageWithoutAttachments: nullMessage,
                    )
                    messageSenderJobQueue.add(
                        .promise,
                        message: preparedMessage,
                        transaction: transaction,
                    ).done(on: DispatchQueue.global()) {
                        Logger.info(
                            "Successfully sent null message after session reset " +
                                "for undecryptable message from \(senderId)",
                        )
                    }.catch(on: DispatchQueue.global()) { error in
                        if error is UntrustedIdentityError {
                            Logger.info(
                                "Failed to send null message after session reset for " +
                                    "for undecryptable message from \(senderId) (\(error))",
                            )
                        } else {
                            owsFailDebug(
                                "Failed to send null message after session reset " +
                                    "for undecryptable message from \(senderId) (\(error))",
                            )
                        }
                    }
                }
            }
        }
    }

    private func trySendReactiveProfileKey(to sourceAci: Aci, tx transaction: DBWriteTransaction) {
        let store = KeyValueStore(collection: "OWSMessageDecrypter+ReactiveProfileKey")

        let lastProfileKeyMessageDate = store.getDate(sourceAci.serviceIdUppercaseString, transaction: transaction)
        let timeSinceProfileKeyMessage = abs(lastProfileKeyMessageDate?.timeIntervalSinceNow ?? .infinity)
        guard timeSinceProfileKeyMessage > RemoteConfig.current.reactiveProfileKeyAttemptInterval else {
            Logger.warn("Skipping reactive profile key for \(sourceAci), last reactive profile key message sent \(lastProfileKeyMessageDate!.ows_millisecondsSince1970).")
            return
        }

        Logger.info("Sending reactive profile key to \(sourceAci)")
        store.setDate(Date(), key: sourceAci.serviceIdUppercaseString, transaction: transaction)

        let contactThread = TSContactThread.getOrCreateThread(
            withContactAddress: SignalServiceAddress(sourceAci),
            transaction: transaction,
        )

        let profileManager = SSKEnvironment.shared.profileManagerRef
        guard let profileKey = profileManager.localProfileKey(tx: transaction) else {
            Logger.warn("Skipping reactive profile key because we don't have a profile key.")
            return
        }

        let profileKeyMessage = ProfileKeyMessage(
            thread: contactThread,
            profileKey: profileKey,
            tx: transaction,
        )
        let preparedMessage = PreparedOutgoingMessage.preprepared(
            transientMessageWithoutAttachments: profileKeyMessage,
        )
        SSKEnvironment.shared.messageSenderJobQueueRef.add(message: preparedMessage, transaction: transaction)
    }

    private struct UnsealedEnvelope {
        let sourceAci: Aci
        let sourceDeviceId: DeviceId
        let content: Data?
        let cipherType: CiphertextMessage.MessageType
        let untrustedGroupId: Data?
        let contentHint: SealedSenderContentHint
    }

    private func processError(
        _ error: Error,
        validatedEnvelope: ValidatedIncomingEnvelope,
        unsealedEnvelope: UnsealedEnvelope?,
        tx transaction: DBWriteTransaction,
    ) -> Error {
        let logString = "Error while decrypting \(Self.description(for: validatedEnvelope.envelope)), error: \(error)"

        if case SignalError.duplicatedMessage = error {
            Logger.warn(logString)
            // Duplicate messages are not recorded in the database.
            return OWSError(
                error: .failedToDecryptDuplicateMessage,
                description: "Duplicate message",
                isRetryable: false,
                userInfo: [NSUnderlyingErrorKey: error],
            )
        }

        Logger.warn(logString)

        let wrappedError: Error
        if (error as NSError).domain == OWSError.errorDomain {
            wrappedError = error
        } else {
            wrappedError = OWSError(
                error: .failedToDecryptMessage,
                description: "Decryption error",
                isRetryable: false,
                userInfo: [NSUnderlyingErrorKey: error],
            )
        }

        guard let unsealedEnvelope else {
            return wrappedError
        }

        let sourceAci = unsealedEnvelope.sourceAci
        let sourceAddress = SignalServiceAddress(sourceAci)

        if
            SSKEnvironment.shared.blockingManagerRef.isAddressBlocked(sourceAddress, transaction: transaction) ||
            DependenciesBridge.shared.recipientHidingManager.isHiddenAddress(sourceAddress, tx: transaction)
        {
            Logger.info("Ignoring decryption error for blocked or hidden user \(sourceAddress) \(wrappedError).")
            return wrappedError
        }

        let contactThread = TSContactThread.getOrCreateThread(withContactAddress: sourceAddress, transaction: transaction)

        let errorMessage: TSErrorMessage?

        switch validatedEnvelope.localIdentity {
        case .aci:
            if
                !RemoteConfig.current.messageResendKillSwitch,
                let modernResendErrorMessageBytes = buildResendRequestDecryptionError(
                    validatedEnvelope: validatedEnvelope,
                    unsealedEnvelope: unsealedEnvelope,
                )
            {
                Logger.info("Requesting modern resend of \(unsealedEnvelope.contentHint) content with timestamp \(validatedEnvelope.timestamp)")

                switch unsealedEnvelope.contentHint {
                case .default:
                    // If default, insert an error message right away
                    errorMessage = .failedDecryption(
                        sender: sourceAddress,
                        groupId: unsealedEnvelope.untrustedGroupId,
                        timestamp: validatedEnvelope.timestamp,
                        tx: transaction,
                    )
                case .resendable:
                    // If resendable, insert a placeholder
                    let recoverableErrorMessage = OWSRecoverableDecryptionPlaceholder(
                        failedEnvelopeTimestamp: validatedEnvelope.timestamp,
                        sourceAci: AciObjC(sourceAci),
                        untrustedGroupId: unsealedEnvelope.untrustedGroupId,
                        transaction: transaction,
                    )
                    if let recoverableErrorMessage {
                        schedulePlaceholderCleanupIfNecessary(for: recoverableErrorMessage)
                    }
                    errorMessage = recoverableErrorMessage
                case .implicit:
                    errorMessage = nil
                default:
                    owsFailDebug("Unexpected content hint")
                    errorMessage = nil
                }

                // We always send a resend request, even if the contentHint indicates the sender
                // won't be able to fulfill the request. This will notify the sender to reset
                // the session.
                sendResendRequest(
                    errorMessageBytes: modernResendErrorMessageBytes,
                    sourceAci: sourceAci,
                    failedEnvelopeGroupId: unsealedEnvelope.untrustedGroupId,
                    transaction: transaction,
                )
            } else {
                Logger.info("Performing legacy session reset of \(unsealedEnvelope.contentHint) content with timestamp \(validatedEnvelope.timestamp)")

                let didReset = resetSessionIfNecessary(
                    for: sourceAci,
                    sourceDeviceId: unsealedEnvelope.sourceDeviceId,
                    contactThread: contactThread,
                    transaction: transaction,
                )

                if didReset {
                    // Always notify the user that we have performed an automatic archive.
                    errorMessage = .sessionRefresh(thread: contactThread)
                } else {
                    errorMessage = nil
                }
            }
        case .pni:
            Logger.info("Not resetting or requesting resend of message sent to PNI.")

            let identityKeyMismatchManager = DependenciesBridge.shared.identityKeyMismatchManager
            identityKeyMismatchManager.recordSuspectedIssueWithPniIdentityKey(tx: transaction)
            Task {
                try await identityKeyMismatchManager.validateLocalPniIdentityKeyIfNecessary()
            }

            errorMessage = .failedDecryption(
                sender: sourceAddress,
                groupId: unsealedEnvelope.untrustedGroupId,
                timestamp: validatedEnvelope.timestamp,
                tx: transaction,
            )
        }

        switch error as? SignalError {
        case .untrustedIdentity:
            // Should no longer get here, since we now record the new identity for incoming messages.
            owsFailDebug("Failed to trust identity on incoming message from \(sourceAci)")
        case .duplicatedMessage:
            preconditionFailure("checked above")
        default: // another SignalError, or another kind of Error altogether
            break
        }

        if let errorMessage {
            errorMessage.anyInsert(transaction: transaction)
            SSKEnvironment.shared.notificationPresenterRef.notifyUser(forErrorMessage: errorMessage, thread: contactThread, transaction: transaction)
            SSKEnvironment.shared.notificationPresenterRef.notifyTestPopulation(ofErrorMessage: "Failed decryption of envelope: \(validatedEnvelope.timestamp)")
        }

        return wrappedError
    }

    private func buildResendRequestDecryptionError(
        validatedEnvelope: ValidatedIncomingEnvelope,
        unsealedEnvelope: UnsealedEnvelope,
    ) -> Data? {
        guard validatedEnvelope.localIdentity == .aci else {
            return nil
        }

        guard [.whisper, .senderKey, .preKey, .plaintext].contains(unsealedEnvelope.cipherType) else {
            return nil
        }

        guard let unsealedContent = unsealedEnvelope.content else {
            return nil
        }

        do {
            let errorMessage = try DecryptionErrorMessage(
                originalMessageBytes: unsealedContent,
                type: unsealedEnvelope.cipherType,
                timestamp: validatedEnvelope.timestamp,
                originalSenderDeviceId: unsealedEnvelope.sourceDeviceId.uint32Value,
            )
            return errorMessage.serialize()
        } catch {
            owsFailDebug("Could not build DecryptionError: \(error)")
            return nil
        }
    }

    private func sendResendRequest(
        errorMessageBytes: Data,
        sourceAci: Aci,
        failedEnvelopeGroupId: Data?,
        transaction: DBWriteTransaction,
    ) {
        let resendRequest = OutgoingResendRequest(
            errorMessageBytes: errorMessageBytes,
            sourceAci: sourceAci,
            failedEnvelopeGroupId: failedEnvelopeGroupId,
            tx: transaction,
        )
        let preparedMessage = PreparedOutgoingMessage.preprepared(
            transientMessageWithoutAttachments: resendRequest,
        )
        SSKEnvironment.shared.messageSenderJobQueueRef.add(message: preparedMessage, transaction: transaction)
    }

    private func resetSessionIfNecessary(
        for sourceAci: Aci,
        sourceDeviceId: DeviceId,
        contactThread: TSContactThread,
        transaction: DBWriteTransaction,
    ) -> Bool {
        // Since the message failed to decrypt, we want to reset our session
        // with this device to ensure future messages we receive are decryptable.
        // We achieve this by archiving our current session with this device.
        // It's important we don't do this if we've already recently reset the
        // session for a given device, for example if we're processing a backlog
        // of 50 message from Alice that all fail to decrypt we don't want to
        // reset the session 50 times. We accomplish this by tracking the UUID +
        // device ID pair that we have recently reset, so we can skip subsequent
        // resets. When the message decrypt queue is drained, the list of recently
        // reset IDs is cleared.
        let senderId = "\(sourceAci).\(sourceDeviceId)"
        if senderIdsResetDuringCurrentBatch.update(block: { $0.insert(senderId).inserted }) {
            // We don't reset sessions for messages sent to our PNI because those are
            // receive-only & we don't send retries FROM our PNI back to the sender.

            Logger.warn("Archiving session for undecryptable message from \(senderId)")
            let sessionStore = DependenciesBridge.shared.signalProtocolStoreManager.signalProtocolStore(for: .aci).sessionStore
            sessionStore.archiveSession(forServiceId: sourceAci, deviceId: sourceDeviceId, tx: transaction)
            trySendNullMessage(in: contactThread, senderId: senderId, transaction: transaction)
            return true
        } else {
            Logger.warn(
                "Skipping session reset for undecryptable message from \(senderId), " +
                    "already reset during this batch",
            )
            return false
        }
    }

    func decryptIdentifiedEnvelope(
        _ validatedEnvelope: ValidatedIncomingEnvelope,
        cipherType: CiphertextMessage.MessageType,
        localIdentifiers: LocalIdentifiers,
        localDeviceId: DeviceId,
        tx transaction: DBWriteTransaction,
    ) throws -> DecryptedIncomingEnvelope {
        // This method is only used for identified envelopes. If an unidentified
        // envelope is ever passed here, we'll reject on the next line because it
        // won't have a source.
        let (sourceAci, sourceDeviceId) = try validatedEnvelope.validateSource(Aci.self)
        let localIdentity = validatedEnvelope.localIdentity
        let plaintextData: Data
        do {
            guard let encryptedData = validatedEnvelope.envelope.content else {
                throw OWSError(
                    error: .failedToDecryptMessage,
                    description: "Envelope has no content",
                    isRetryable: false,
                )
            }

            let identityManager = DependenciesBridge.shared.identityManager
            let protocolAddress = ProtocolAddress(sourceAci, deviceId: sourceDeviceId)
            let signalProtocolStoreManager = DependenciesBridge.shared.signalProtocolStoreManager
            let signalProtocolStore = signalProtocolStoreManager.signalProtocolStore(for: validatedEnvelope.localIdentity)
            let preKeyStore = signalProtocolStoreManager.preKeyStore.forIdentity(validatedEnvelope.localIdentity)

            let plaintext: Data
            switch cipherType {
            case .whisper:
                let message = try SignalMessage(bytes: encryptedData)
                plaintext = try signalDecrypt(
                    message: message,
                    from: protocolAddress,
                    sessionStore: signalProtocolStore.sessionStore,
                    identityStore: identityManager.libSignalStore(for: localIdentity, tx: transaction),
                    context: transaction,
                )
                sendReactiveProfileKeyIfNecessary(to: sourceAci, tx: transaction)
            case .preKey:
                if DependenciesBridge.shared.tsAccountManager.registrationState(tx: transaction).isRegistered {
                    Task {
                        try? await DependenciesBridge.shared.preKeyManager.checkPreKeysIfNecessary()
                    }
                }
                let message = try PreKeySignalMessage(bytes: encryptedData)
                plaintext = try signalDecryptPreKey(
                    message: message,
                    from: protocolAddress,
                    localAddress: ProtocolAddress(validatedEnvelope.localServiceId, deviceId: localDeviceId),
                    sessionStore: signalProtocolStore.sessionStore,
                    identityStore: identityManager.libSignalStore(for: localIdentity, tx: transaction),
                    preKeyStore: preKeyStore,
                    signedPreKeyStore: preKeyStore,
                    kyberPreKeyStore: preKeyStore,
                    context: transaction,
                )
            case .senderKey:
                plaintext = try groupDecrypt(
                    encryptedData,
                    from: protocolAddress,
                    store: SSKEnvironment.shared.senderKeyStoreRef,
                    context: transaction,
                )
            case .plaintext:
                let plaintextMessage = try PlaintextContent(bytes: encryptedData)
                plaintext = plaintextMessage.body
            // FIXME: return this to @unknown default once cipherType is represented
            // as a finite enum.
            default:
                owsFailDebug("Unexpected ciphertext type: \(cipherType.rawValue)")
                throw OWSError(
                    error: .failedToDecryptMessage,
                    description: "Unexpected Ciphertext type.",
                    isRetryable: false,
                )
            }

            plaintextData = plaintext.withoutPadding()
        } catch {
            throw processError(
                error,
                validatedEnvelope: validatedEnvelope,
                unsealedEnvelope: UnsealedEnvelope(
                    sourceAci: sourceAci,
                    sourceDeviceId: sourceDeviceId,
                    content: validatedEnvelope.envelope.content,
                    cipherType: cipherType,
                    untrustedGroupId: nil,
                    contentHint: .default,
                ),
                tx: transaction,
            )
        }

        let decryptedEnvelope = try DecryptedIncomingEnvelope(
            validatedEnvelope: validatedEnvelope,
            updatedEnvelope: validatedEnvelope.envelope,
            sourceAci: sourceAci,
            sourceDeviceId: sourceDeviceId,
            wasReceivedByUD: false,
            plaintextData: plaintextData,
            isPlaintextCipher: cipherType == .plaintext,
        )

        processDecryptedEnvelope(
            decryptedEnvelope,
            localIdentifiers: localIdentifiers,
            mergeRecipient: { transaction in
                let recipientFetcher = DependenciesBridge.shared.recipientFetcher
                return recipientFetcher.fetchOrCreate(serviceId: sourceAci, tx: transaction)
            },
            tx: transaction,
        )

        return decryptedEnvelope
    }

    private func sendReactiveProfileKeyIfNecessary(to sourceAci: Aci, tx transaction: DBWriteTransaction) {
        if DependenciesBridge.shared.tsAccountManager.localIdentifiers(tx: transaction)?.aci == sourceAci {
            return
        }

        if SSKEnvironment.shared.blockingManagerRef.isAddressBlocked(SignalServiceAddress(sourceAci), transaction: transaction) {
            Logger.info("Skipping send of reactive profile key to blocked address")
            return
        }

        // We do this work in an async completion so we don't delay
        // receipt of this message.
        transaction.addSyncCompletion {
            Task {
                let db = DependenciesBridge.shared.db
                let profileManager = SSKEnvironment.shared.profileManagerRef

                let needsReactiveProfileKeyMessage: Bool = db.read { transaction in
                    // This user is whitelisted, they should have our profile key / be sending UD messages
                    // Send them our profile key in case they somehow lost it.
                    if
                        profileManager.isUser(
                            inProfileWhitelist: SignalServiceAddress(sourceAci),
                            transaction: transaction,
                        )
                    {
                        return true
                    }

                    // If we're in a V2 group with this user, they should also have our profile key /
                    // be sending UD messages. Send them it in case they somehow lost it.
                    var needsReactiveProfileKeyMessage = false
                    TSGroupThread.enumerateGroupThreads(
                        with: SignalServiceAddress(sourceAci),
                        transaction: transaction,
                    ) { thread, stop in
                        guard thread.isGroupV2Thread else { return }
                        guard thread.groupModel.groupMembership.isLocalUserFullMember else { return }
                        guard !thread.isTerminatedGroup else { return }
                        stop = true
                        needsReactiveProfileKeyMessage = true
                    }
                    return needsReactiveProfileKeyMessage
                }

                if needsReactiveProfileKeyMessage {
                    await db.awaitableWrite { transaction in
                        self.trySendReactiveProfileKey(to: sourceAci, tx: transaction)
                    }
                }
            }
        }
    }

    func decryptUnidentifiedSenderEnvelope(
        _ validatedEnvelope: ValidatedIncomingEnvelope,
        localIdentifiers: LocalIdentifiers,
        localDeviceId: DeviceId,
        tx transaction: DBWriteTransaction,
    ) throws -> DecryptedIncomingEnvelope {
        let localIdentity = validatedEnvelope.localIdentity
        guard let encryptedData = validatedEnvelope.envelope.content else {
            throw OWSAssertionError("UD Envelope is missing content.")
        }
        let identityManager = DependenciesBridge.shared.identityManager
        let signalProtocolStoreManager = DependenciesBridge.shared.signalProtocolStoreManager
        let signalProtocolStore = signalProtocolStoreManager.signalProtocolStore(for: localIdentity)
        let preKeyStore = signalProtocolStoreManager.preKeyStore.forIdentity(localIdentity)

        let cipher = try SMKSecretSessionCipher(
            sessionStore: signalProtocolStore.sessionStore,
            preKeyStore: preKeyStore,
            signedPreKeyStore: preKeyStore,
            kyberPreKeyStore: preKeyStore,
            identityStore: identityManager.libSignalStore(for: localIdentity, tx: transaction),
            senderKeyStore: SSKEnvironment.shared.senderKeyStoreRef,
        )

        let decryptResult: SMKDecryptResult
        do {
            decryptResult = try cipher.decryptMessage(
                trustRoots: SSKEnvironment.shared.udManagerRef.trustRoots,
                cipherTextData: encryptedData,
                timestamp: validatedEnvelope.serverTimestamp,
                localIdentifiers: localIdentifiers,
                localServiceId: validatedEnvelope.localServiceId,
                localDeviceId: localDeviceId,
                protocolContext: transaction,
            )
        } catch let outerError as SecretSessionKnownSenderError {
            throw handleUnidentifiedSenderDecryptionError(
                outerError.underlyingError,
                validatedEnvelope: validatedEnvelope,
                unsealedEnvelope: UnsealedEnvelope(
                    sourceAci: outerError.senderAci,
                    sourceDeviceId: outerError.senderDeviceId,
                    content: outerError.unsealedContent,
                    cipherType: outerError.cipherType,
                    untrustedGroupId: outerError.groupId,
                    contentHint: SealedSenderContentHint(outerError.contentHint),
                ),
                transaction: transaction,
            )
        } catch {
            throw handleUnidentifiedSenderDecryptionError(
                error,
                validatedEnvelope: validatedEnvelope,
                unsealedEnvelope: nil,
                transaction: transaction,
            )
        }

        if
            decryptResult.messageType == .prekey,
            DependenciesBridge.shared.tsAccountManager.registrationState(tx: transaction).isRegistered
        {
            Task {
                try? await DependenciesBridge.shared.preKeyManager.checkPreKeysIfNecessary()
            }
        }

        let envelopeBuilder = validatedEnvelope.envelope.asBuilder()
        envelopeBuilder.setSourceServiceIDBinary(decryptResult.senderAci.serviceIdBinary)
        envelopeBuilder.setSourceDevice(decryptResult.senderDeviceId.uint32Value)

        let decryptedEnvelope = try DecryptedIncomingEnvelope(
            validatedEnvelope: validatedEnvelope,
            updatedEnvelope: try envelopeBuilder.build(),
            sourceAci: decryptResult.senderAci,
            sourceDeviceId: decryptResult.senderDeviceId,
            wasReceivedByUD: !(validatedEnvelope.envelope.hasSourceServiceID || validatedEnvelope.envelope.hasSourceServiceIDBinary),
            plaintextData: decryptResult.paddedPayload.withoutPadding(),
            isPlaintextCipher: decryptResult.messageType == .plaintext,
        )

        processDecryptedEnvelope(
            decryptedEnvelope,
            localIdentifiers: localIdentifiers,
            mergeRecipient: { transaction in
                return DependenciesBridge.shared.recipientMerger.applyMergeFromSealedSender(
                    localIdentifiers: localIdentifiers,
                    aci: decryptResult.senderAci,
                    phoneNumber: E164(decryptResult.senderE164),
                    tx: transaction,
                )
            },
            tx: transaction,
        )

        return decryptedEnvelope
    }

    private func handleUnidentifiedSenderDecryptionError(
        _ error: Error,
        validatedEnvelope: ValidatedIncomingEnvelope,
        unsealedEnvelope: UnsealedEnvelope?,
        transaction: DBWriteTransaction,
    ) -> Error {
        switch error {
        case SMKSecretSessionCipherError.selfSentMessage:
            // Self-sent messages can be safely discarded. Return as-is.
            return error
        case is SignalError, PreKeyStore.Error.noPreKeyWithId:
            return processError(
                error,
                validatedEnvelope: validatedEnvelope,
                unsealedEnvelope: unsealedEnvelope,
                tx: transaction,
            )
        default:
            owsFailDebug("Could not decrypt UD message: \(error), source: \(String(describing: unsealedEnvelope?.sourceAci)), envelope: \(Self.description(for: validatedEnvelope.envelope))")
            return error
        }
    }

    private func processDecryptedEnvelope(
        _ decryptedEnvelope: DecryptedIncomingEnvelope,
        localIdentifiers: LocalIdentifiers,
        mergeRecipient: (DBWriteTransaction) -> SignalRecipient,
        tx: DBWriteTransaction,
    ) {
        // We need to handle the PNI signature first b/c `mergeRecipient()`
        // might produce a visible event and the PNI signature won't.
        handlePniSignatureIfNeeded(in: decryptedEnvelope, localIdentifiers: localIdentifiers, tx: tx)

        let recipientManager = DependenciesBridge.shared.recipientManager
        var mergedRecipient = mergeRecipient(tx)
        recipientManager.markAsRegisteredAndSave(
            &mergedRecipient,
            deviceId: decryptedEnvelope.sourceDeviceId,
            shouldUpdateStorageService: true,
            tx: tx,
        )
    }

    private func handlePniSignatureIfNeeded(
        in envelope: DecryptedIncomingEnvelope,
        localIdentifiers: LocalIdentifiers,
        tx: DBWriteTransaction,
    ) {
        guard let pniSignatureMessage = envelope.content?.pniSignatureMessage else {
            return
        }
        do {
            try PniSignatureProcessorImpl(
                identityManager: DependenciesBridge.shared.identityManager,
                recipientDatabaseTable: DependenciesBridge.shared.recipientDatabaseTable,
                recipientMerger: DependenciesBridge.shared.recipientMerger,
            ).handlePniSignature(
                pniSignatureMessage,
                from: envelope.sourceAci,
                localIdentifiers: localIdentifiers,
                tx: tx,
            )
        } catch {
            Logger.warn("Ignoring Pni signature message: \(error)")
        }
    }

    private func schedulePlaceholderCleanupIfNecessary(for placeholder: OWSRecoverableDecryptionPlaceholder) {
        DispatchQueue.main.async {
            self.schedulePlaceholderCleanup(noLaterThan: placeholder.expirationDate)
        }
    }

    private func schedulePlaceholderCleanup(noLaterThan expirationDate: Date) {
        let fireDate = placeholderCleanupTimer?.fireDate ?? .distantFuture
        // Only change the fireDate if it's changed "enough", where we consider
        // about 5 seconds of leeway sufficient.
        let latestAcceptableFireDate = expirationDate.addingTimeInterval(5)

        if latestAcceptableFireDate < fireDate {
            placeholderCleanupTimer = Timer.scheduledTimer(
                withTimeInterval: expirationDate.timeIntervalSinceNow,
                repeats: false,
                block: { [weak self] _ in self?.cleanUpExpiredPlaceholders() },
            )
        }
    }

    func cleanUpExpiredPlaceholders() {
        Task { await self._cleanUpExpiredPlaceholders() }
    }

    private func _cleanUpExpiredPlaceholders() async {
        let (expiredPlaceholderIds, nextExpirationDate) = SSKEnvironment.shared.databaseStorageRef.read { tx in
            var expiredPlaceholderIds = [String]()
            var nextExpirationDate: Date?
            InteractionFinder.enumeratePlaceholders(transaction: tx) { placeholder in
                guard placeholder.expirationDate.isBeforeNow else {
                    nextExpirationDate = [nextExpirationDate, placeholder.expirationDate].compacted().min()
                    return
                }
                expiredPlaceholderIds.append(placeholder.uniqueId)
            }
            return (expiredPlaceholderIds, nextExpirationDate)
        }

        let batchSize = 25
        var remainingPlaceholderIds = expiredPlaceholderIds[...]
        while !remainingPlaceholderIds.isEmpty {
            let thisBatchPlaceholderIds = remainingPlaceholderIds.prefix(batchSize)
            remainingPlaceholderIds = remainingPlaceholderIds.dropFirst(batchSize)

            await SSKEnvironment.shared.databaseStorageRef.awaitableWrite { tx in
                for placeholderId in thisBatchPlaceholderIds {
                    guard
                        let placeholder = OWSRecoverableDecryptionPlaceholder.fetchRecoverableDecryptionPlaceholderViaCache(
                            uniqueId: placeholderId,
                            transaction: tx,
                        )
                    else {
                        continue
                    }
                    Logger.info("Cleaning up placeholder \(placeholder.timestamp)")
                    DependenciesBridge.shared.interactionDeleteManager
                        .delete(placeholder, sideEffects: .default(), tx: tx)
                    guard let thread = placeholder.thread(tx: tx) else {
                        return
                    }
                    let errorMessage: TSErrorMessage = .failedDecryption(
                        thread: thread,
                        timestamp: MessageTimestampGenerator.sharedInstance.generateTimestamp(),
                        sender: placeholder.sender,
                    )
                    errorMessage.anyInsert(transaction: tx)
                    SSKEnvironment.shared.notificationPresenterRef.notifyUser(forErrorMessage: errorMessage, thread: thread, transaction: tx)
                }
            }
        }

        if let nextExpirationDate {
            await MainActor.run { self.schedulePlaceholderCleanup(noLaterThan: nextExpirationDate) }
        }
    }

    // MARK: - OWSMessageHandler methods

    private static func descriptionForEnvelopeType(_ envelope: SSKProtoEnvelope) -> String {
        guard envelope.hasType else {
            return "Missing Type."
        }
        switch envelope.unwrappedType {
        case .unknown:
            // Shouldn't happen
            return "Unknown"
        case .ciphertext:
            return "SignalEncryptedMessage"
        case .prekeyBundle:
            return "PreKeyEncryptedMessage"
        case .receipt:
            return "DeliveryReceipt"
        case .unidentifiedSender:
            return "UnidentifiedSender"
        case .plaintextContent:
            return "PlaintextContent"
        }
    }

    static func description(for envelope: SSKProtoEnvelope) -> String {
        let serverGuid = ValidatedIncomingEnvelope.parseServerGuid(fromEnvelope: envelope)
        return "<Envelope type: \(descriptionForEnvelopeType(envelope)), source: \(envelope.formattedAddress), timestamp: \(envelope.timestamp), serverTimestamp: \(envelope.serverTimestamp), serverGuid: \(serverGuid?.uuidString.lowercased() ?? "(null)"), content.length: \(envelope.content?.count ?? 0) />"
    }
}