Signal-iOS/SignalServiceKit/Usernames/LocalUsernameManager.swift
2023-07-31 14:58:07 -07:00

527 lines
19 KiB
Swift

//
// Copyright 2023 Signal Messenger, LLC
// SPDX-License-Identifier: AGPL-3.0-only
//
import SignalCoreKit
/// 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) -> Usernames.QRCodeColor
/// Sets the color to be used for the local user's username link QR code.
func setUsernameLinkQRCodeColor(
color: Usernames.QRCodeColor,
tx: DBWriteTransaction
)
// MARK: Usernames and the service
/// Reserve a username from the given set of candidates.
func reserveUsername(
usernameCandidates: Usernames.HashedUsername.GeneratedCandidates
) -> Promise<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,
tx: DBWriteTransaction
) -> Promise<Usernames.ConfirmationResult>
/// Delete the local user's username and username link.
func deleteUsername(tx: DBWriteTransaction) -> Promise<Void>
// MARK: Username links and the service
/// Rotate the local user's username link, without modifying their username.
func rotateUsernameLink(tx: DBWriteTransaction) -> Promise<Usernames.UsernameLink>
}
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
}
}
}
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(kvStoreFactory: KeyValueStoreFactory) {
kvStore = kvStoreFactory.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(kvStoreFactory: KeyValueStoreFactory) {
kvStore = kvStoreFactory.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) -> Usernames.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: Usernames.QRCodeColor, tx: DBWriteTransaction) {
try? kvStore.setCodable(color, key: Constants.usernameLinkQRCodeColorKey, transaction: tx)
}
}
private let db: DB
private let schedulers: Schedulers
private let storageServiceManager: StorageServiceManager
private let usernameApiClient: UsernameApiClient
private let usernameLinkManager: UsernameLinkManager
private let corruptionStore: CorruptionStore
private let usernameStore: UsernameStore
init(
db: DB,
kvStoreFactory: KeyValueStoreFactory,
schedulers: Schedulers,
storageServiceManager: StorageServiceManager,
usernameApiClient: UsernameApiClient,
usernameLinkManager: UsernameLinkManager
) {
self.db = db
self.schedulers = schedulers
self.storageServiceManager = storageServiceManager
self.usernameApiClient = usernameApiClient
self.usernameLinkManager = usernameLinkManager
corruptionStore = CorruptionStore(kvStoreFactory: kvStoreFactory)
usernameStore = UsernameStore(kvStoreFactory: kvStoreFactory)
}
// 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)
postLocalUsernameStateChangedNotification()
}
func setLocalUsernameWithCorruptedLink(
username: String,
tx: DBWriteTransaction
) {
corruptionStore.setUsernameCorrupted(false, tx: tx)
usernameStore.setUsername(username: username, tx: tx)
corruptionStore.setUsernameLinkCorrupted(true, tx: tx)
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)
postLocalUsernameStateChangedNotification()
}
func usernameLinkQRCodeColor(
tx: DBReadTransaction
) -> Usernames.QRCodeColor {
return usernameStore.usernameLinkColor(tx: tx)
}
func setUsernameLinkQRCodeColor(
color: Usernames.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)
postLocalUsernameStateChangedNotification()
}
private func markUsernameLinkCorrupted(_ value: Bool, tx: DBWriteTransaction) {
corruptionStore.setUsernameLinkCorrupted(value, tx: tx)
postLocalUsernameStateChangedNotification()
}
private func postLocalUsernameStateChangedNotification() {
NotificationCenter.default.postNotificationNameAsync(
Usernames.localUsernameStateChangedNotification,
object: nil
)
}
// MARK: Usernames and the service
func reserveUsername(
usernameCandidates: Usernames.HashedUsername.GeneratedCandidates
) -> Promise<Usernames.ReservationResult> {
return usernameApiClient.reserveUsernameCandidates(
usernameCandidates: usernameCandidates
)
}
/// Confirm the given reserved username, setting it as our username.
func confirmUsername(
reservedUsername: Usernames.HashedUsername,
tx syncTx: DBWriteTransaction
) -> Promise<Usernames.ConfirmationResult> {
let linkEntropy: Data
let linkEncryptedUsername: Data
do {
(
linkEntropy,
linkEncryptedUsername
) = try self.usernameLinkManager.generateEncryptedUsername(
username: reservedUsername.usernameString
)
} catch let error {
return Promise(error: error)
}
// 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.
markUsernameCorrupted(true, tx: syncTx)
return firstly(on: schedulers.global()) { () throws -> Promise<(Usernames.ApiClientConfirmationResult)> in
return self.usernameApiClient.confirmReservedUsername(
reservedUsername: reservedUsername,
encryptedUsernameForLink: linkEncryptedUsername
)
}.map(on: schedulers.global()) { apiClientConfirmationResult -> Usernames.ConfirmationResult in
self.db.write { tx 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
}
}
}.recover(on: schedulers.global()) { error throws -> Promise<Usernames.ConfirmationResult> in
UsernameLogger.shared.error(
"Error while confirming username. Username now assumed corrupted! \(error)"
)
throw error
}
}
func deleteUsername(tx syncTx: DBWriteTransaction) -> Promise<Void> {
// 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.
markUsernameCorrupted(true, tx: syncTx)
return firstly(on: schedulers.global()) { () -> Promise<Void> in
return self.usernameApiClient.deleteCurrentUsername()
}.done(on: schedulers.global()) {
self.db.write { tx in
self.clearLocalUsername(tx: tx)
}
// We back up the username and link in StorageService, so
// trigger a backup now.
self.storageServiceManager.recordPendingLocalAccountUpdates()
}.recover(on: schedulers.global()) { error -> Promise<Void> in
UsernameLogger.shared.error(
"Error while deleting username. Username now assumed corrupted! \(error)"
)
throw error
}
}
// MARK: Username links and the service
func rotateUsernameLink(
tx syncTx: DBWriteTransaction
) -> Promise<Usernames.UsernameLink> {
guard let currentUsername = usernameState(tx: syncTx).username else {
return Promise(error: OWSAssertionError(
"Tried to rotate link, but missing current username!"
))
}
let newEntropy: Data
let newEncryptedUsername: Data
do {
(
newEntropy,
newEncryptedUsername
) = try self.usernameLinkManager.generateEncryptedUsername(
username: currentUsername
)
} catch let error {
return Promise(error: error)
}
// 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: syncTx)
return firstly(on: schedulers.global()) { () -> Promise<UUID> in
return self.usernameApiClient.setUsernameLink(
encryptedUsername: newEncryptedUsername
)
}.map(on: schedulers.global()) { newHandle -> Usernames.UsernameLink in
guard let newUsernameLink = Usernames.UsernameLink(
handle: newHandle,
entropy: newEntropy
) else {
owsFail("This link should always be valid - we just generated the entropy ourselves!")
}
self.db.write { 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 newUsernameLink
}.recover(on: schedulers.global()) { error -> Promise<Usernames.UsernameLink> in
UsernameLogger.shared.error(
"Error while rotating username link. Username link now assumed corrupted! \(error)"
)
throw error
}
}
}