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

/// Manages the local username and username link.
public protocol LocalUsernameManager {

    // MARK: Local state

    /// Returns the state of the local username.
    func usernameState(tx: DBReadTransaction) -> Usernames.LocalUsernameState

    /// Sets the local username and username link.
    func setLocalUsername(
        username: String,
        usernameLink: Usernames.UsernameLink,
        tx: DBWriteTransaction,
    )

    /// Sets the local username, and marks that the local username link is
    /// corrupted.
    ///
    /// Corruption indicates that the username link values we have locally may
    /// or do not decrypt the encrypted username stored by the service. This may
    /// occur due to an interrupted "update my username link" request, race
    /// between two devices simultaneously updating our username, and possibly
    /// other reasons.
    func setLocalUsernameWithCorruptedLink(
        username: String,
        tx: DBWriteTransaction,
    )

    /// Sets that the local username and username link are corrupted.
    ///
    /// Corruption indicates that the username value we have locally may or does
    /// not match the hash of our username stored by the service. This may occur
    /// due to an interrupted "update my username" request, race between two
    /// devices simultaneously updating our username, and possibly other
    /// reasons.
    func setLocalUsernameCorrupted(tx: DBWriteTransaction)

    /// Clears the local username and username link, whether they were corrupted
    /// or not.
    func clearLocalUsername(tx: DBWriteTransaction)

    /// Returns the color to be used for the local user's username link QR code.
    func usernameLinkQRCodeColor(tx: DBReadTransaction) -> QRCodeColor

    /// Sets the color to be used for the local user's username link QR code.
    func setUsernameLinkQRCodeColor(
        color: QRCodeColor,
        tx: DBWriteTransaction,
    )

    // MARK: Usernames and the service

    /// Reserve a username from the given set of candidates.
    func reserveUsername(
        usernameCandidates: Usernames.HashedUsername.GeneratedCandidates,
    ) async -> Usernames.RemoteMutationResult<Usernames.ReservationResult>

    /// Set the local user's username to the given reserved username, on the
    /// service and locally. Note that setting a new username also sets a
    /// corresponding username link.
    func confirmUsername(
        reservedUsername: Usernames.HashedUsername,
    ) async -> Usernames.RemoteMutationResult<Usernames.ConfirmationResult>

    /// Delete the local user's username and username link.
    func deleteUsername() async -> Usernames.RemoteMutationResult<Void>

    // MARK: Username links and the service

    /// Rotate the local user's username link, without modifying their username.
    func rotateUsernameLink() async -> Usernames.RemoteMutationResult<Usernames.UsernameLink>

    /// Update the case of the local user's existing username, as it will be
    /// visibly presented.
    ///
    /// This updates the local store to reflect the new username casing. It also
    /// performs an in-place update of the encrypted username used in the user's
    /// username link without modifying the link handle or entropy. The existing
    /// username link is consequently unaffected, but the username that is
    /// available via the link will reflect the new casing.
    ///
    /// - Important
    /// The new username must case-insensitively match the existing username
    /// when calling this API.
    func updateVisibleCaseOfExistingUsername(
        newUsername: String,
    ) async -> Usernames.RemoteMutationResult<Void>
}

public extension Usernames {
    static let localUsernameStateChangedNotification = NSNotification.Name(
        "localUsernameStateChanged",
    )

    /// Represents the states that the local user's username and username link
    /// can be in.
    enum LocalUsernameState: Equatable {
        /// The user deliberately has no username nor username link.
        case unset
        /// The user has both a username and username link.
        case available(username: String, usernameLink: UsernameLink)
        /// The user has a username, but something is wrong with the
        /// corresponding username link and it cannot be used.
        case linkCorrupted(username: String)
        /// The user has a username, but something is wrong with it and neither
        /// it nor the corresponding username link can be used.
        case usernameAndLinkCorrupted

        /// Whether the user explicitly does not have a username set.
        public var isExplicitlyUnset: Bool {
            switch self {
            case .unset:
                return true
            case .available, .linkCorrupted, .usernameAndLinkCorrupted:
                return false
            }
        }

        /// The username, if there is one available.
        public var username: String? {
            switch self {
            case let .available(username, _):
                return username
            case let .linkCorrupted(username):
                return username
            case .unset, .usernameAndLinkCorrupted:
                return nil
            }
        }

        /// The username link, if there is one available.
        public var usernameLink: Usernames.UsernameLink? {
            switch self {
            case let .available(_, usernameLink):
                return usernameLink
            case .unset, .linkCorrupted, .usernameAndLinkCorrupted:
                return nil
            }
        }
    }

    /// Errors related to the local user updating remote state pertaining to
    /// their username.
    enum RemoteMutationError: Error {
        case networkError
        case otherError
    }

    typealias RemoteMutationResult<T> = Result<T, RemoteMutationError>

    typealias ReservationResult = ApiClientReservationResult

    enum ConfirmationResult: Equatable {
        case success(
            username: String,
            usernameLink: UsernameLink,
        )
        case rejected
        case rateLimited
    }
}

// MARK: - Impl

class LocalUsernameManagerImpl: LocalUsernameManager {
    private struct CorruptionStore {
        private enum Constants {
            static let collection = "LocalUsernameCorruption"
            static let usernameKey = "username"
            static let usernameLinkKey = "link"
        }

        private let kvStore: KeyValueStore

        init() {
            kvStore = KeyValueStore(collection: Constants.collection)
        }

        func isUsernameCorrupted(tx: DBReadTransaction) -> Bool {
            return kvStore.getBool(Constants.usernameKey, defaultValue: false, transaction: tx)
        }

        func isUsernameLinkCorrupted(tx: DBReadTransaction) -> Bool {
            return kvStore.getBool(Constants.usernameLinkKey, defaultValue: false, transaction: tx)
        }

        func setUsernameCorrupted(_ value: Bool, tx: DBWriteTransaction) {
            kvStore.setBool(value, key: Constants.usernameKey, transaction: tx)
        }

        func setUsernameLinkCorrupted(_ value: Bool, tx: DBWriteTransaction) {
            kvStore.setBool(value, key: Constants.usernameLinkKey, transaction: tx)
        }
    }

    private struct UsernameStore {
        private enum Constants {
            static let collection = "LocalUsername"
            static let usernameKey = "username"
            static let usernameLinkHandleKey = "linkHandle"
            static let usernameLinkEntropyKey = "linkEntropy"
            static let usernameLinkQRCodeColorKey = "linkColor"
        }

        private let kvStore: KeyValueStore

        init() {
            kvStore = KeyValueStore(collection: Constants.collection)
        }

        func username(tx: DBReadTransaction) -> String? {
            return kvStore.getString(Constants.usernameKey, transaction: tx)
        }

        func usernameLink(tx: DBReadTransaction) -> Usernames.UsernameLink? {
            if
                let linkHandleData = kvStore.getData(Constants.usernameLinkHandleKey, transaction: tx),
                let linkHandle = UUID(data: linkHandleData),
                let linkEntropy = kvStore.getData(Constants.usernameLinkEntropyKey, transaction: tx),
                let link = Usernames.UsernameLink(handle: linkHandle, entropy: linkEntropy)
            {
                return link
            }

            return nil
        }

        func usernameLinkColor(tx: DBReadTransaction) -> QRCodeColor {
            return (try? kvStore.getCodableValue(
                forKey: Constants.usernameLinkQRCodeColorKey,
                transaction: tx,
            )) ?? .unknown
        }

        func setUsername(username: String?, tx: DBWriteTransaction) {
            kvStore.setString(username, key: Constants.usernameKey, transaction: tx)
        }

        func setUsernameLink(usernameLink: Usernames.UsernameLink?, tx: DBWriteTransaction) {
            kvStore.setData(usernameLink?.handle.data, key: Constants.usernameLinkHandleKey, transaction: tx)
            kvStore.setData(usernameLink?.entropy, key: Constants.usernameLinkEntropyKey, transaction: tx)
        }

        func setUsernameLinkColor(color: QRCodeColor, tx: DBWriteTransaction) {
            try? kvStore.setCodable(color, key: Constants.usernameLinkQRCodeColorKey, transaction: tx)
        }
    }

    /// Thrown when ``SSKReachability`` indicates we do not have network access,
    /// and that consequently we will not succeed in a usernames-related
    /// network request.
    ///
    /// Because we mark the username/link as corrupted while mutation requests
    /// are in-flight it's preferable to bail out early if we believe the
    /// request is doomed to fail, rather than unnecessarily leaving the
    /// username/link corrupted when the request fails.
    private struct NoReachabilityError: Error {}

    private let db: any DB
    private let reachabilityManager: SSKReachabilityManager
    private let storageServiceManager: StorageServiceManager
    private let usernameApiClient: UsernameApiClient
    private let usernameLinkManager: UsernameLinkManager

    private let corruptionStore: CorruptionStore
    private let usernameStore: UsernameStore

    private let maxNetworkRequestRetries: Int

    private var logger: PrefixedLogger { UsernameLogger.shared }

    init(
        db: any DB,
        reachabilityManager: SSKReachabilityManager,
        storageServiceManager: StorageServiceManager,
        usernameApiClient: UsernameApiClient,
        usernameLinkManager: UsernameLinkManager,
        maxNetworkRequestRetries: Int = 2,
    ) {
        self.db = db
        self.reachabilityManager = reachabilityManager
        self.storageServiceManager = storageServiceManager
        self.usernameApiClient = usernameApiClient
        self.usernameLinkManager = usernameLinkManager

        corruptionStore = CorruptionStore()
        usernameStore = UsernameStore()

        self.maxNetworkRequestRetries = maxNetworkRequestRetries
    }

    // MARK: - Local state

    func usernameState(
        tx: DBReadTransaction,
    ) -> Usernames.LocalUsernameState {
        if corruptionStore.isUsernameCorrupted(tx: tx) {
            return .usernameAndLinkCorrupted
        } else if let username = usernameStore.username(tx: tx) {
            if
                !corruptionStore.isUsernameLinkCorrupted(tx: tx),
                let usernameLink = usernameStore.usernameLink(tx: tx)
            {
                return .available(username: username, usernameLink: usernameLink)
            }

            return .linkCorrupted(username: username)
        }

        return .unset
    }

    func setLocalUsername(
        username: String,
        usernameLink: Usernames.UsernameLink,
        tx: DBWriteTransaction,
    ) {
        corruptionStore.setUsernameCorrupted(false, tx: tx)
        usernameStore.setUsername(username: username, tx: tx)

        corruptionStore.setUsernameLinkCorrupted(false, tx: tx)
        usernameStore.setUsernameLink(usernameLink: usernameLink, tx: tx)

        tx.addSyncCompletion {
            Task {
                await self.postLocalUsernameStateChangedNotification()
            }
        }
    }

    func setLocalUsernameWithCorruptedLink(
        username: String,
        tx: DBWriteTransaction,
    ) {
        corruptionStore.setUsernameCorrupted(false, tx: tx)
        usernameStore.setUsername(username: username, tx: tx)

        corruptionStore.setUsernameLinkCorrupted(true, tx: tx)

        tx.addSyncCompletion {
            Task {
                await self.postLocalUsernameStateChangedNotification()
            }
        }
    }

    func setLocalUsernameCorrupted(tx: DBWriteTransaction) {
        markUsernameCorrupted(true, tx: tx)
    }

    func clearLocalUsername(tx: DBWriteTransaction) {
        corruptionStore.setUsernameCorrupted(false, tx: tx)
        corruptionStore.setUsernameLinkCorrupted(false, tx: tx)

        usernameStore.setUsername(username: nil, tx: tx)
        usernameStore.setUsernameLink(usernameLink: nil, tx: tx)

        tx.addSyncCompletion {
            Task {
                await self.postLocalUsernameStateChangedNotification()
            }
        }
    }

    func usernameLinkQRCodeColor(
        tx: DBReadTransaction,
    ) -> QRCodeColor {
        return usernameStore.usernameLinkColor(tx: tx)
    }

    func setUsernameLinkQRCodeColor(
        color: QRCodeColor,
        tx: DBWriteTransaction,
    ) {
        usernameStore.setUsernameLinkColor(color: color, tx: tx)
    }

    private func markUsernameCorrupted(_ value: Bool, tx: DBWriteTransaction) {
        corruptionStore.setUsernameCorrupted(value, tx: tx)
        corruptionStore.setUsernameLinkCorrupted(value, tx: tx)

        tx.addSyncCompletion {
            Task {
                await self.postLocalUsernameStateChangedNotification()
            }
        }
    }

    private func markUsernameLinkCorrupted(_ value: Bool, tx: DBWriteTransaction) {
        corruptionStore.setUsernameLinkCorrupted(value, tx: tx)

        tx.addSyncCompletion {
            Task {
                await self.postLocalUsernameStateChangedNotification()
            }
        }
    }

    @MainActor
    private func postLocalUsernameStateChangedNotification() {
        NotificationCenter.default.post(
            name: Usernames.localUsernameStateChangedNotification,
            object: nil,
        )
    }

    // MARK: Usernames and the service

    func reserveUsername(
        usernameCandidates: Usernames.HashedUsername.GeneratedCandidates,
    ) async -> Usernames.RemoteMutationResult<Usernames.ReservationResult> {
        guard reachabilityManager.isReachable else {
            logger.warn("Not attempting to reserve username – Reachability indicates we will fail.")
            return .failure(.networkError)
        }

        do {
            let reservationResult = try await makeRequestWithNetworkRetries {
                return try await usernameApiClient.reserveUsernameCandidates(usernameCandidates: usernameCandidates)
            }
            return .success(reservationResult)
        } catch {
            if error.isNetworkFailureOrTimeout {
                return .failure(.networkError)
            }

            return .failure(.otherError)
        }
    }

    /// Confirm the given reserved username, setting it as our username.
    func confirmUsername(
        reservedUsername: Usernames.HashedUsername,
    ) async -> Usernames.RemoteMutationResult<Usernames.ConfirmationResult> {
        guard reachabilityManager.isReachable else {
            logger.warn("Not attempting to confirm username – Reachability indicates we will fail.")
            return .failure(.networkError)
        }

        let linkEntropy: Data
        let linkEncryptedUsername: Data
        do {
            (
                linkEntropy,
                linkEncryptedUsername,
            ) = try self.usernameLinkManager.generateEncryptedUsername(
                username: reservedUsername.usernameString,
                existingEntropy: nil,
            )
        } catch let error {
            UsernameLogger.shared.error("Failed to generate encrypted username! \(error)")
            return .failure(.otherError)
        }

        // Mark as corrupted in case we encounter an unexpected error while
        // confirming. If that happens we can't be sure if our new username was
        // set or not, so we conservatively leave it in the corrupted state.
        // If, however, we get a response we understand (affirmative or
        // negative), we remove the corrupted flag.
        await db.awaitableWrite { tx in
            markUsernameCorrupted(true, tx: tx)
        }

        do {
            let apiClientConfirmationResult = try await makeRequestWithNetworkRetries {
                return try await usernameApiClient.confirmReservedUsername(
                    reservedUsername: reservedUsername,
                    encryptedUsernameForLink: linkEncryptedUsername,
                    chatServiceAuth: .implicit(),
                )
            }
            let confirmationResult = await self.db.awaitableWrite { tx -> Usernames.ConfirmationResult in
                switch apiClientConfirmationResult {
                case let .success(linkHandle):
                    guard
                        let usernameLink = Usernames.UsernameLink(
                            handle: linkHandle,
                            entropy: linkEntropy,
                        )
                    else {
                        owsFail("This link should always be valid - we just generated the entropy ourselves!")
                    }

                    let username = reservedUsername.usernameString

                    self.setLocalUsername(
                        username: username,
                        usernameLink: usernameLink,
                        tx: tx,
                    )

                    // We back up the username and link in StorageService, so
                    // trigger a backup now.
                    self.storageServiceManager.recordPendingLocalAccountUpdates()

                    return .success(
                        username: username,
                        usernameLink: usernameLink,
                    )
                case .rejected:
                    self.markUsernameCorrupted(false, tx: tx)
                    return .rejected
                case .rateLimited:
                    self.markUsernameCorrupted(false, tx: tx)
                    return .rateLimited
                }
            }

            return .success(confirmationResult)
        } catch {
            if error.isNetworkFailureOrTimeout {
                UsernameLogger.shared.error("Network error while confirming username. Username now assumed corrupted!")
                return .failure(.networkError)
            }

            UsernameLogger.shared.error("Unknown error while confirming username. Username now assumed corrupted!")
            return .failure(.otherError)
        }
    }

    func deleteUsername() async -> Usernames.RemoteMutationResult<Void> {
        guard reachabilityManager.isReachable else {
            logger.warn("Not attempting to delete username – Reachability indicates we will fail.")
            return .failure(.networkError)
        }

        // Mark as corrupted in case we encounter an unexpected error while
        // deleting. If that happens we can't be sure if our new username was
        // deleted or not, so we conservatively leave it in the corrupted state.
        // If, however, we get a response, we remove the corrupted flag.
        await db.awaitableWrite { tx in
            markUsernameCorrupted(true, tx: tx)
        }

        do {
            try await makeRequestWithNetworkRetries {
                try await usernameApiClient.deleteCurrentUsername()
            }
            await self.db.awaitableWrite { tx in
                self.clearLocalUsername(tx: tx)
            }

            // We back up the username and link in StorageService, so
            // trigger a backup now.
            self.storageServiceManager.recordPendingLocalAccountUpdates()

            return .success(())
        } catch {
            if error.isNetworkFailureOrTimeout {
                UsernameLogger.shared.error("Network error while deleting username. Username now assumed corrupted!")
                return .failure(.networkError)
            }

            UsernameLogger.shared.error("Unknown error while deleting username. Username now assumed corrupted!")
            return .failure(.otherError)
        }
    }

    // MARK: Username links and the service

    func rotateUsernameLink() async -> Usernames.RemoteMutationResult<Usernames.UsernameLink> {
        guard reachabilityManager.isReachable else {
            logger.warn("Not attempting to rotate username link – Reachability indicates we will fail.")
            return .failure(.networkError)
        }

        guard
            let (currentUsername, newEntropy, newEncryptedUsername) = await db.awaitableWrite(block: { tx -> (String, Data, Data)? in
                guard let currentUsername = usernameState(tx: tx).username else {
                    owsFailDebug("Tried to rotate link, but missing current username!")
                    return nil
                }

                let newEntropy: Data
                let newEncryptedUsername: Data
                do {
                    (
                        newEntropy,
                        newEncryptedUsername,
                    ) = try self.usernameLinkManager.generateEncryptedUsername(
                        username: currentUsername,
                        existingEntropy: nil,
                    )
                } catch let error {
                    UsernameLogger.shared.error("Failed to generate encrypted username! \(error)")
                    return nil
                }

                // Mark as corrupted in case we encounter an unexpected error while
                // rotating. If that happens we can't be sure if our username link was
                // rotated or not, so we conservatively leave it in the corrupted state.
                // If, however, we get a response, we remove the corrupted flag.
                markUsernameLinkCorrupted(true, tx: tx)

                return (currentUsername, newEntropy, newEncryptedUsername)
            })
        else {
            return .failure(.otherError)
        }

        do {
            let newHandle = try await makeRequestWithNetworkRetries {
                try await usernameApiClient.setUsernameLink(encryptedUsername: newEncryptedUsername, keepLinkHandle: false)
            }

            guard
                let newUsernameLink = Usernames.UsernameLink(
                    handle: newHandle,
                    entropy: newEntropy,
                )
            else {
                owsFail("This link should always be valid - we just generated the entropy ourselves!")
            }

            await self.db.awaitableWrite { tx in
                self.setLocalUsername(
                    username: currentUsername,
                    usernameLink: newUsernameLink,
                    tx: tx,
                )
            }

            // We back up the username and link in StorageService, so
            // trigger a backup now.
            self.storageServiceManager.recordPendingLocalAccountUpdates()

            return .success(newUsernameLink)
        } catch {
            if error.isNetworkFailureOrTimeout {
                UsernameLogger.shared.error("Network error while rotating username link. Username link now assumed corrupted!")
                return .failure(.networkError)
            }

            UsernameLogger.shared.error("Error while rotating username link. Username link now assumed corrupted!")
            return .failure(.otherError)
        }
    }

    func updateVisibleCaseOfExistingUsername(
        newUsername: String,
    ) async -> Usernames.RemoteMutationResult<Void> {
        guard reachabilityManager.isReachable else {
            logger.warn("Not attempting to update visible username case – Reachability indicates we will fail.")
            return .failure(.networkError)
        }

        guard
            let (newEncryptedUsername, currentUsernameLink) = await db.awaitableWrite(block: { tx -> (Data, Usernames.UsernameLink)? in
                let currentUsernameState = usernameState(tx: tx)

                guard
                    let currentUsernameLink = currentUsernameState.usernameLink,
                    let currentUsername = currentUsernameState.username,
                    newUsername.lowercased() == currentUsername.lowercased()
                else {
                    owsFailDebug("Attempting to change username case, but new nickname does not match existing username!")
                    return nil
                }

                let newEncryptedUsername: Data
                do {
                    (_, newEncryptedUsername) = try usernameLinkManager.generateEncryptedUsername(
                        username: newUsername,
                        existingEntropy: currentUsernameLink.entropy,
                    )
                } catch let error {
                    UsernameLogger.shared.error("Failed to generate encrypted username! \(error)")
                    return nil
                }

                // Mark as corrupted in case we encounter an unexpected error while
                // setting the new encrypted username. If that happens we can't be sure
                // if our encrypted username was updated or not, so we conservatively
                // leave it in the corrupted state. If, however, we get a response, we
                // remove the corrupted flag.
                markUsernameLinkCorrupted(true, tx: tx)

                return (newEncryptedUsername, currentUsernameLink)
            })
        else {
            return .failure(.otherError)
        }

        defer {
            // We back up the username and link in StorageService, and in all
            // codepaths we've updated the username, so trigger a backup now.
            storageServiceManager.recordPendingLocalAccountUpdates()
        }

        do {
            let newHandle = try await makeRequestWithNetworkRetries {
                /// Pass `keepLinkHandle = true` here, to ask the service not to
                /// rotate the username link handle. That's key to keeping the
                /// existing link unaffected while updating the case of the
                /// visible username the link points to.
                return try await usernameApiClient.setUsernameLink(encryptedUsername: newEncryptedUsername, keepLinkHandle: true)
            }
            guard currentUsernameLink.handle == newHandle else {
                UsernameLogger.shared.error("Handle received while changing username case did not match existing! Is this a server bug?")
                throw OWSGenericError("")
            }

            await self.db.awaitableWrite { tx in
                self.setLocalUsername(
                    username: newUsername,
                    usernameLink: currentUsernameLink,
                    tx: tx,
                )
            }
            return .success(())
        } catch {
            // Even though we failed to update the link, we can save the new
            // nickname locally. If the user rotates their link to fix the
            // issue, the new link will reflect the updated nickname.
            await self.db.awaitableWrite { tx in
                self.setLocalUsernameWithCorruptedLink(
                    username: newUsername,
                    tx: tx,
                )
            }

            if error.isNetworkFailureOrTimeout {
                UsernameLogger.shared.error("Network error while updating username link for nickname case change. Username updated locally, but link now assumed corrupted!")
                return .failure(.networkError)
            }

            UsernameLogger.shared.error("Unknown error while updating username link for nickname case change. Username updated locally, but link now assumed corrupted!")
            return .failure(.otherError)
        }
    }
}

// MARK: - Network retries

private extension LocalUsernameManagerImpl {
    /// Make the request in the given block, with retries.
    ///
    /// Because a failed username mutation request leaves us in a corrupted
    /// state, add retries for network errors to avoid unnecessary corruption
    /// where possible.
    func makeRequestWithNetworkRetries<T>(
        requestBlock: () async throws -> T,
        retriesRemaining: Int? = nil,
    ) async throws -> T {
        do {
            return try await requestBlock()
        } catch {
            let retriesRemaining = retriesRemaining ?? self.maxNetworkRequestRetries

            guard error.isNetworkFailureOrTimeout else {
                UsernameLogger.shared.error("Non-network error during username request!")
                throw error
            }

            guard retriesRemaining > 0 else {
                UsernameLogger.shared.error("Exhausted retries during username request!")
                throw error
            }

            return try await self.makeRequestWithNetworkRetries(
                requestBlock: requestBlock,
                retriesRemaining: retriesRemaining - 1,
            )
        }
    }
}