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

public import LibSignalClient

/// Source of a backup being imported, that also includes the necessary information
/// for decryption, since the encryption scheme varies by purpose (and has different inputs).
public enum BackupImportSource {
    /// A backup uploaded to the remote media tier cdn.
    /// Encryption makes use of...
    /// 1. AEP (used to derive ``MessageRootBackupKey/backupKey``
    /// 2. ACI (used with backup key to derive ``MessageRootBackupKey/backupId``)
    /// 3. Nonce metadata, which we either have locally, or need to pull from SVRB.
    case remote(key: MessageRootBackupKey, nonceSource: NonceMetadataSource)

    /// A link'n'sync backup which uses a one-time ephemeral key (which we still use the BackupKey type for).
    /// This ephemeral key is combined with the ACI to derive the encryption key for the synced backup file.
    case linkNsync(ephemeralKey: BackupKey, aci: Aci)

    // TODO: [Backups] add local backup case. This should just have the backup key
    // and the backupId we pull out of the local backup metadata file, no aci.

    public enum NonceMetadataSource {
        /// We already have the forward secrecy token from Quick Restore;
        /// the old primary device can provide the new primary with the last
        /// forward secrecy token it used to generate a backup.
        case provisioningMessage(BackupForwardSecrecyToken)

        /// We need to fetch the forward secrecy token from SVRB. Callers generally don't
        /// worry about making requests; all that is needed is:
        /// 1. The header from the backup file (has the key for SVRB lookup)
        /// 2. Chat server auth (to fetch SVRB auth credentials from the chat server)
        case svrB(header: BackupNonce.MetadataHeader, auth: ChatServiceAuth)
    }
}

/// Wrapper around the purpose behind a given backup (whether importing or exporting)
/// that also includes the necessary information for encryption/decryption, since what
/// the encryption scheme is varies by purpose (and has different inputs).
public enum BackupExportPurpose {
    /// A backup to upload to the remote media tier cdn.
    /// Encryption makes use of...
    /// 1. AEP (used to derive ``MessageRootBackupKey/backupKey``
    /// 2. ACI (used with backup key to derive ``MessageRootBackupKey/backupId``)
    /// 3. Nonce metadata, which we generate locally.
    ///
    /// Chat auth is required because we always upload to SVRB when we generate a new
    /// forward secrecy token (and we need chat service auth to get a SVRB auth credential).
    case remoteExport(key: MessageRootBackupKey, chatAuth: ChatServiceAuth)

    /// A link'n'sync backup which uses a one-time ephemeral key (which we still use the BackupKey type for).
    /// This ephemeral key is combined with the ACI to derive the encryption key for the synced backup file.
    case linkNsync(ephemeralKey: BackupKey, aci: Aci)

    // TODO: [Backups] add local backup case. This should just have the backup key;
    // internally we will generate a backupId without the aci.
}

// MARK: - Libsignal.MessageBackupPurpose

extension BackupImportSource {
    var libsignalPurpose: LibSignalClient.MessageBackupPurpose {
        switch self {
        case .linkNsync:
            return .deviceTransfer
        case .remote:
            return .remoteBackup
        }
    }
}

extension BackupExportPurpose {
    var libsignalPurpose: LibSignalClient.MessageBackupPurpose {
        switch self {
        case .linkNsync:
            return .deviceTransfer
        case .remoteExport:
            return .remoteBackup
        }
    }
}

// MARK: - Errors

public enum SVRBError: Error, Equatable, IsRetryableProvider {
    /// Network errors and other transient errors that
    /// can usually be resolved by an automatic retry, at
    /// computer time scale.
    case retryableAutomatically
    /// Some error that may be resolved by trying again later (e.g. temporary outage)
    /// at human time scale.
    case retryableByUser
    /// An unrecoverable error; SVRB data is potentially lost forever.
    case unrecoverable
    /// Couldn't recover SVRB data because the backup key is incorrect;
    /// may be recoverable by entering a different AEP.
    case incorrectRecoveryKey
    /// A caught-and-rethrown `CancellationError`.
    case cancellationError

    public var isRetryableProvider: Bool {
        switch self {
        case .retryableAutomatically:
            return true
        case .retryableByUser:
            // This is for automatic retries;
            // these errors can be retried by
            // the user.
            return false
        case .unrecoverable, .incorrectRecoveryKey, .cancellationError:
            return false
        }
    }
}

// MARK: - Encryption Key Derivation

extension BackupImportSource {
    private var logger: PrefixedLogger { .init(prefix: "[Backups]") }

    /// Derive the encryption key used to decrypt the backup file, potentially
    /// performing a fetch from SVRB, depending on the purpose and available data.
    func deriveBackupEncryptionKeyWithSVRBIfNeeded(
        backupRequestManager: BackupRequestManager,
        db: any DB,
        libsignalNet: LibSignalClient.Net,
        nonceStore: BackupNonceMetadataStore,
        logger: PrefixedLogger,
    ) async throws -> MessageBackupKey {
        switch self {
        case let .remote(key, noneSource):
            let forwardSecrecyToken: BackupForwardSecrecyToken?
            switch noneSource {
            case let .provisioningMessage(token):
                forwardSecrecyToken = token
            case let .svrB(metadataHeader, chatAuth):
                var isRetry = false
                forwardSecrecyToken = try await Retry.performWithBackoff(
                    maxAttempts: 2,
                    block: {
                        do throws(SVRBError) {
                            return try await self.fetchForwardSecrecyTokenFromSvr(
                                key: key,
                                metadataHeader: metadataHeader,
                                chatAuth: chatAuth,
                                isRetry: isRetry,
                                backupRequestManager: backupRequestManager,
                                db: db,
                                libsignalNet: libsignalNet,
                                nonceStore: nonceStore,
                                logger: logger,
                            )
                        } catch .cancellationError {
                            throw CancellationError()
                        } catch let error {
                            isRetry = true
                            throw error
                        }
                    },
                )
            }

            return try MessageBackupKey(
                backupKey: key.backupKey,
                backupId: key.backupId,
                forwardSecrecyToken: forwardSecrecyToken,
            )

        case let .linkNsync(ephemeralKey, aci):
            return try MessageBackupKey(
                backupKey: ephemeralKey,
                backupId: ephemeralKey.deriveBackupId(aci: aci),
                forwardSecrecyToken: nil,
            )
        }
    }

    private func fetchForwardSecrecyTokenFromSvr(
        key: MessageRootBackupKey,
        metadataHeader: BackupNonce.MetadataHeader,
        chatAuth: ChatServiceAuth,
        isRetry: Bool,
        backupRequestManager: BackupRequestManager,
        db: any DB,
        libsignalNet: LibSignalClient.Net,
        nonceStore: BackupNonceMetadataStore,
        logger: PrefixedLogger,
    ) async throws(SVRBError) -> BackupForwardSecrecyToken {
        let svrBAuth: LibSignalClient.Auth
        do {
            svrBAuth = try await backupRequestManager.fetchSVRBAuthCredential(
                key: key,
                chatServiceAuth: chatAuth,
                // Force fetch new credentials on retries to make sure
                // it wasn't stale credentials that caused the problem.
                forceRefresh: isRetry,
                logger: logger,
            )
        } catch is CancellationError {
            throw .cancellationError
        } catch let error {
            if error.isNetworkFailureOrTimeout {
                throw .retryableAutomatically
            } else if error.isRetryable {
                throw .retryableAutomatically
            } else {
                owsFailDebug("Permanently failed to fetch svrB auth")
                throw .unrecoverable
            }
        }

        let svrB = libsignalNet.svrB(auth: svrBAuth)

        let response: SvrB.RestoreBackupResponse
        do {
            response = try await svrB.restore(
                backupKey: key.backupKey,
                metadata: metadataHeader.data,
            )
        } catch is CancellationError {
            throw .cancellationError
        } catch let error {
            switch error as? LibSignalClient.SignalError {
            case .invalidArgument:
                // Metadata is malformed. Totally unrecoverable.
                logger.error("SVRB metadata header malformed; cannot recover backup")
                throw .unrecoverable
            case .svrRestoreFailed:
                // Some SVRB error that means data is lost. Totally unrecoverable.
                logger.error("SVRB restore failed; cannot recover backup")
                throw .unrecoverable
            case .svrDataMissing:
                logger.error("SVRB data missing; cannot recover backup")
                throw .incorrectRecoveryKey
            case .rateLimitedError(let retryAfter, _):
                // Do a quite rudimentary thing where we just wait
                // for the retry time, which will leave the user with
                // a spinner. But we never really expect this to happen.
                logger.warn("Rate-limited SVRB restore, waiting...")
                try? await Task.sleep(nanoseconds: retryAfter.clampedNanoseconds)
                return try await fetchForwardSecrecyTokenFromSvr(
                    key: key,
                    metadataHeader: metadataHeader,
                    chatAuth: chatAuth,
                    isRetry: false, /* not that kind of retry */
                    backupRequestManager: backupRequestManager,
                    db: db,
                    libsignalNet: libsignalNet,
                    nonceStore: nonceStore,
                    logger: logger,
                )
            case .connectionFailed, .connectionTimeoutError, .ioError, .webSocketError:
                // Network-level failures mostly end up in these buckets;
                // these can be retried automatically.
                throw .retryableAutomatically
            default:
                // Everything else let the user retry. This will inevitably
                // include things that are bugs, leaving users in retry loops.
                logger.error("Failed SVRB restore w/ unknown error: \(error)")
                throw .retryableByUser
            }
        }

        let forwardSecrecyToken = response.forwardSecrecyToken
        // Set the next secret metadata immediately; we won't use
        // it until we next create a backup and it will ensure that
        // when we do, this previous backup remains decryptable
        // if that next backups fails at the upload to cdn step.
        // It is ok if the restore process fails after this point,
        // either we try again and overwrite this, or we skip
        // and then the next time we make a backup we still use
        // this key which is at worst as good as a random starting point.
        await db.awaitableWrite { tx in
            nonceStore.setNextSecretMetadata(
                BackupNonce.NextSecretMetadata(data: response.nextBackupSecretData),
                for: key,
                tx: tx,
            )
        }
        return forwardSecrecyToken
    }
}

extension BackupExportPurpose {
    private var logger: PrefixedLogger { .init(prefix: "[Backups]") }

    struct EncryptionMetadata {
        let encryptionKey: MessageBackupKey
        let backupId: Data
        /// If non-nil, this header should be prepended to the backup file
        /// in plaintext.
        let metadataHeader: BackupNonce.MetadataHeader?
        /// If non-nil, this metadata should be persisted after the upload
        /// of a backup succeeds (and ONLY if it succeeds).
        let nonceMetadata: NonceMetadata?
    }

    struct NonceMetadata {
        let forwardSecrecyToken: BackupForwardSecrecyToken
        let nextSecretMetadata: BackupNonce.NextSecretMetadata
    }

    /// Derive the encryption key used to encrypt the backup file, potentially
    /// performing an upload to SVRB, depending on the purpose and available data.
    func deriveEncryptionMetadataWithSVRBIfNeeded(
        backupRequestManager: BackupRequestManager,
        db: any DB,
        libsignalNet: LibSignalClient.Net,
        nonceStore: BackupNonceMetadataStore,
    ) async throws -> EncryptionMetadata {
        switch self {
        case let .remoteExport(key, chatAuth):
            var isRetry = false
            return try await Retry.performWithBackoff(
                maxAttempts: 2,
                block: {
                    do throws(SVRBError) {
                        return try await storeEncryptionMetadataToSVRB(
                            key: key,
                            chatAuth: chatAuth,
                            isRetry: isRetry,
                            backupRequestManager: backupRequestManager,
                            db: db,
                            libsignalNet: libsignalNet,
                            nonceStore: nonceStore,
                        )
                    } catch .cancellationError {
                        throw CancellationError()
                    } catch let error {
                        isRetry = true
                        throw error
                    }
                },
            )
        case let .linkNsync(ephemeralKey, aci):
            let backupId = ephemeralKey.deriveBackupId(aci: aci)
            let encryptionKey = try MessageBackupKey(
                backupKey: ephemeralKey,
                backupId: backupId,
                forwardSecrecyToken: nil,
            )
            return BackupExportPurpose.EncryptionMetadata(
                encryptionKey: encryptionKey,
                backupId: backupId,
                metadataHeader: nil,
                nonceMetadata: nil,
            )
        }
    }

    private func storeEncryptionMetadataToSVRB(
        key: MessageRootBackupKey,
        chatAuth: ChatServiceAuth,
        isRetry: Bool,
        backupRequestManager: BackupRequestManager,
        db: any DB,
        libsignalNet: LibSignalClient.Net,
        nonceStore: BackupNonceMetadataStore,
    ) async throws(SVRBError) -> EncryptionMetadata {
        let svrBAuth: LibSignalClient.Auth
        do {
            svrBAuth = try await backupRequestManager.fetchSVRBAuthCredential(
                key: key,
                chatServiceAuth: chatAuth,
                // Force fetch new credentials on retries to make sure
                // it wasn't stale credentials that caused the problem.
                forceRefresh: isRetry,
                logger: logger,
            )
        } catch let error {
            if error is CancellationError {
                throw .cancellationError
            } else if error.isNetworkFailureOrTimeout {
                throw .retryableAutomatically
            } else if error.isRetryable {
                throw .retryableAutomatically
            } else {
                owsFailDebug("Permanently failed to fetch svrB auth")
                throw .unrecoverable
            }
        }

        let svrB = libsignalNet.svrB(auth: svrBAuth)

        // We want what was the "next" secret metadata from the _last_ backup we made.
        // This is used as an input into the generator for the metadata for this new
        // backup (which is the "next" backup from that last time).
        let mostRecentSecretData: BackupNonce.NextSecretMetadata
        if
            let storedSecretData = db.read(block: { tx in
                nonceStore.getNextSecretMetadata(for: key, tx: tx)
            })
        {
            mostRecentSecretData = storedSecretData
        } else {
            mostRecentSecretData = BackupNonce.NextSecretMetadata(data: svrB.createNewBackupChain(backupKey: key.backupKey))
            await db.awaitableWrite { tx in
                nonceStore.setNextSecretMetadata(
                    mostRecentSecretData,
                    for: key,
                    tx: tx,
                )
            }
        }

        let response: SvrB.StoreBackupResponse
        do {
            response = try await svrB.store(backupKey: key.backupKey, previousSecretData: mostRecentSecretData.data)
        } catch is CancellationError {
            throw .cancellationError
        } catch let error {
            switch error as? LibSignalClient.SignalError {
            case .invalidArgument:
                // This happens when the "previousSecretData" is invalid.
                // To recover, we have to start over with `createNewBackupChain`.
                logger.error("Failed SVRB store w/ invalid argument, wiping next secret metadata")
                await db.awaitableWrite { tx in
                    nonceStore.deleteNextSecretMetadata(tx: tx)
                }
                return try await storeEncryptionMetadataToSVRB(
                    key: key,
                    chatAuth: chatAuth,
                    isRetry: false, /* Its not that kind of retry */
                    backupRequestManager: backupRequestManager,
                    db: db,
                    libsignalNet: libsignalNet,
                    nonceStore: nonceStore,
                )
            case .rateLimitedError(let retryAfter, _):
                // Do a quite rudimentary thing where we just wait
                // for the retry time, which will leave the user with
                // a spinner. But we never really expect this to happen.
                logger.warn("Rate-limited SVRB store, waiting...")
                try? await Task.sleep(nanoseconds: NSEC_PER_MSEC * UInt64(retryAfter * 1000))
                return try await storeEncryptionMetadataToSVRB(
                    key: key,
                    chatAuth: chatAuth,
                    isRetry: false, /* Its not that kind of retry */
                    backupRequestManager: backupRequestManager,
                    db: db,
                    libsignalNet: libsignalNet,
                    nonceStore: nonceStore,
                )
            case .connectionFailed, .connectionTimeoutError, .ioError, .webSocketError:
                // Network-level failures mostly end up in these buckets;
                // these can be retried automatically.
                throw .retryableAutomatically
            default:
                // Everything else let the user retry. This will inevitably
                // include things that are bugs, leaving users in retry loops.
                logger.error("Failed SVRB store w/ unknown error: \(error)")
                throw .retryableByUser
            }
        }

        let encryptionKey: MessageBackupKey
        do {
            encryptionKey = try MessageBackupKey(
                backupKey: key.backupKey,
                backupId: key.backupId,
                forwardSecrecyToken: response.forwardSecrecyToken,
            )
        } catch {
            owsFailDebug("Failed to derive encryption key!")
            throw .unrecoverable
        }

        return BackupExportPurpose.EncryptionMetadata(
            encryptionKey: encryptionKey,
            backupId: key.backupId,
            metadataHeader: BackupNonce.MetadataHeader(data: response.metadata),
            nonceMetadata: NonceMetadata(
                forwardSecrecyToken: response.forwardSecrecyToken,
                nextSecretMetadata: BackupNonce.NextSecretMetadata(data: response.nextBackupSecretData),
            ),
        )
    }
}