// // Copyright 2019 Signal Messenger, LLC // SPDX-License-Identifier: AGPL-3.0-only // import Foundation public import LibSignalClient public struct VersionedProfileRequest { public let aci: Aci public let request: TSRequest public let profileKey: ProfileKey public let requestContext: ProfileKeyCredentialRequestContext? public init( aci: Aci, request: TSRequest, profileKey: ProfileKey, requestContext: ProfileKeyCredentialRequestContext? ) { self.aci = aci self.request = request self.profileKey = profileKey self.requestContext = requestContext } } // MARK: - public class VersionedProfilesImpl: VersionedProfiles { private enum CredentialStore { private static let deprecatedCredentialStore = KeyValueStore(collection: "VersionedProfiles.credentialStore") private static let expiringCredentialStore = KeyValueStore(collection: "VersionedProfilesImpl.expiringCredentialStore") private static func storeKey(for aci: Aci) -> String { return aci.serviceIdUppercaseString } static func dropDeprecatedCredentialsIfNecessary(transaction: DBWriteTransaction) { deprecatedCredentialStore.removeAll(transaction: transaction) } static func getValidCredential( for aci: Aci, transaction: DBReadTransaction ) throws -> ExpiringProfileKeyCredential? { guard let credentialData = expiringCredentialStore.getData( storeKey(for: aci), transaction: transaction ) else { return nil } let credential = try ExpiringProfileKeyCredential(contents: credentialData) guard credential.isValid else { // Safe to leave the expired credential here - we can't clear it // because we're in a read-only transaction. When we try and // fetch a new credential for this address we'll overwrite this // expired one. return nil } return credential } static func setCredential( _ credential: ExpiringProfileKeyCredential, for aci: Aci, transaction: DBWriteTransaction ) throws { let credentialData = credential.serialize() guard !credentialData.isEmpty else { throw OWSAssertionError("Invalid credential data") } expiringCredentialStore.setData( credentialData, key: storeKey(for: aci), transaction: transaction ) } static func removeValue(for aci: Aci, transaction: DBWriteTransaction) { expiringCredentialStore.removeValue(forKey: storeKey(for: aci), transaction: transaction) } static func removeAll(transaction: DBWriteTransaction) { expiringCredentialStore.removeAll(transaction: transaction) } } // MARK: - Init public init(appReadiness: AppReadiness) { appReadiness.runNowOrWhenMainAppDidBecomeReadyAsync { // Once we think all clients in the world have migrated to expiring // credentials we can remove this. SSKEnvironment.shared.databaseStorageRef.asyncWrite { transaction in CredentialStore.dropDeprecatedCredentialsIfNecessary(transaction: transaction) } } } // MARK: - public func clientZkProfileOperations() -> ClientZkProfileOperations { return ClientZkProfileOperations(serverPublicParams: GroupsV2Protos.serverPublicParams()) } // MARK: - Update public func updateProfile( profileGivenName: OWSUserProfile.NameComponent?, profileFamilyName: OWSUserProfile.NameComponent?, profileBio: String?, profileBioEmoji: String?, profileAvatarMutation: VersionedProfileAvatarMutation, visibleBadgeIds: [String], profileKey: Aes256Key, authedAccount: AuthedAccount ) async throws -> VersionedProfileUpdate { let tsAccountManager = DependenciesBridge.shared.tsAccountManager let localAci = try tsAccountManager.localIdentifiersWithMaybeSneakyTransaction(authedAccount: authedAccount).aci let localProfileKey = try self.parseProfileKey(profileKey: profileKey) let commitment = try localProfileKey.getCommitment(userId: localAci) let commitmentData = commitment.serialize() func fetchLocalPaymentAddressProtoData() async -> Data? { await SSKEnvironment.shared.databaseStorageRef.awaitableWrite { tx in SSKEnvironment.shared.paymentsHelperRef.lastKnownLocalPaymentAddressProtoData(transaction: tx) } } let profilePaymentAddressData: Data? = await { guard SSKEnvironment.shared.paymentsHelperRef.arePaymentsEnabled, !SSKEnvironment.shared.paymentsHelperRef.isKillSwitchActive else { return nil } guard let addressProtoData = await fetchLocalPaymentAddressProtoData(), addressProtoData.count > 0 else { owsFailDebug("Payments enabled, but paymentAddress is missing or empty.") return nil } var result = Data() result.append(UInt32(addressProtoData.count).littleEndianData) result.append(addressProtoData) return result }() let nameValue: ProfileValue? = try { guard let profileGivenName else { return nil } return try OWSUserProfile.encrypt( givenName: profileGivenName, familyName: profileFamilyName, profileKey: profileKey ) }() func encryptOptionalData(_ value: Data?, paddedLengths: [Int]) throws -> ProfileValue? { guard let value, !value.isEmpty else { return nil } return try OWSUserProfile.encrypt(data: value, profileKey: profileKey, paddedLengths: paddedLengths) } func encryptOptionalString(_ value: String?, paddedLengths: [Int]) throws -> ProfileValue? { guard let value, !value.isEmpty else { return nil } let stringData = Data(value.utf8) return try encryptOptionalData(stringData, paddedLengths: paddedLengths) } func encryptBoolean(_ value: Bool) throws -> ProfileValue { let encodedValue = Data([value ? 1 : 0]) let encryptedData = try OWSUserProfile.encrypt(profileData: encodedValue, profileKey: profileKey) return ProfileValue(encryptedData: encryptedData) } let bioValue = try encryptOptionalString(profileBio, paddedLengths: [128, 254, 512]) let bioEmojiValue = try encryptOptionalString(profileBioEmoji, paddedLengths: [32]) let paymentAddressValue = try encryptOptionalData(profilePaymentAddressData, paddedLengths: [554]) let phoneNumberSharingValue = try encryptBoolean(SSKEnvironment.shared.databaseStorageRef.read { tx in SSKEnvironment.shared.udManagerRef.phoneNumberSharingMode(tx: tx).orDefault == .everybody }) let profileKeyVersion = try localProfileKey.getProfileKeyVersion(userId: localAci) let profileKeyVersionString = try profileKeyVersion.asHexadecimalString() let hasAvatar: Bool let sameAvatar: Bool switch profileAvatarMutation { case .keepAvatar: hasAvatar = true sameAvatar = true case .clearAvatar: hasAvatar = false sameAvatar = false case .changeAvatar: hasAvatar = true sameAvatar = false } let request = OWSRequestFactory.setVersionedProfileRequest( name: nameValue, bio: bioValue, bioEmoji: bioEmojiValue, hasAvatar: hasAvatar, sameAvatar: sameAvatar, paymentAddress: paymentAddressValue, phoneNumberSharing: phoneNumberSharingValue, visibleBadgeIds: visibleBadgeIds, version: profileKeyVersionString, commitment: commitmentData, auth: authedAccount.chatServiceAuth ) let response = try await SSKEnvironment.shared.networkManagerRef.asyncRequest(request) let avatarUrlPath: OptionalChange switch profileAvatarMutation { case .keepAvatar: avatarUrlPath = .noChange case .clearAvatar: avatarUrlPath = .setTo(nil) case .changeAvatar(let avatarData): let encryptedAvatarData = try OWSUserProfile.encrypt(profileData: avatarData, profileKey: profileKey) avatarUrlPath = .setTo(try await uploadAvatar( formResponseData: response.responseBodyData, encryptedAvatarData: encryptedAvatarData )) } return VersionedProfileUpdate(avatarUrlPath: avatarUrlPath) } private func uploadAvatar(formResponseData: Data?, encryptedAvatarData: Data) async throws -> String { guard let formResponseData, let uploadForm = try? JSONDecoder().decode(Upload.CDN0.Form.self, from: formResponseData) else { throw OWSAssertionError("Could not parse response.") } return try await Upload.CDN0.upload(data: encryptedAvatarData, uploadForm: uploadForm) } // MARK: - Get public func versionedProfileRequest( for aci: Aci, profileKey: ProfileKey, shouldRequestCredential: Bool, udAccessKey: SMKUDAccessKey?, auth chatServiceAuth: ChatServiceAuth ) throws -> VersionedProfileRequest { // We need to request a credential if we don't have a valid one already. var requestContext: ProfileKeyCredentialRequestContext? if shouldRequestCredential { requestContext = try self.clientZkProfileOperations().createProfileKeyCredentialRequestContext( userId: aci, profileKey: profileKey ) } let auth: TSRequest.Auth if let udAccessKey { auth = .sealedSender(.accessKey(udAccessKey)) } else { auth = .identified(chatServiceAuth) } return VersionedProfileRequest( aci: aci, request: OWSRequestFactory.getVersionedProfileRequest( aci: aci, profileKeyVersion: try profileKey.getProfileKeyVersion(userId: aci).asHexadecimalString(), credentialRequest: try requestContext?.getRequest().serialize(), auth: auth ), profileKey: profileKey, requestContext: requestContext ) } // MARK: - public func parseProfileKey(profileKey: Aes256Key) throws -> ProfileKey { let profileKeyData: Data = profileKey.keyData return try ProfileKey(contents: profileKeyData) } public func didFetchProfile(profile: SignalServiceProfile, profileRequest: VersionedProfileRequest) async { do { guard let credentialResponseData = profile.credential else { return } guard credentialResponseData.count > 0 else { throw OWSAssertionError("Invalid credential response.") } guard let requestContext = profileRequest.requestContext else { throw OWSAssertionError("Missing request context.") } let credentialResponse = try ExpiringProfileKeyCredentialResponse(contents: credentialResponseData) let clientZkProfileOperations = self.clientZkProfileOperations() let profileKeyCredential = try clientZkProfileOperations.receiveExpiringProfileKeyCredential( profileKeyCredentialRequestContext: requestContext, profileKeyCredentialResponse: credentialResponse ) guard profile.serviceId == profileRequest.aci else { throw OWSAssertionError("Missing ACI.") } try await SSKEnvironment.shared.databaseStorageRef.awaitableWrite { tx throws in let profileManager = SSKEnvironment.shared.profileManagerRef let userProfile = profileManager.userProfile(for: SignalServiceAddress(profileRequest.aci), tx: tx) guard let userProfile else { throw OWSAssertionError("Missing profile in database.") } guard profileRequest.profileKey.serialize() == userProfile.profileKey?.keyData else { Logger.warn("Profile key for versioned profile fetch does not match current profile key.") return } try CredentialStore.setCredential(profileKeyCredential, for: profileRequest.aci, transaction: tx) } } catch { owsFailDebug("Invalid credential: \(error).") return } } // MARK: - Credentials public func validProfileKeyCredential( for aci: Aci, transaction: DBReadTransaction ) throws -> ExpiringProfileKeyCredential? { try CredentialStore.getValidCredential(for: aci, transaction: transaction) } public func clearProfileKeyCredential(for aci: Aci, transaction: DBWriteTransaction) { CredentialStore.removeValue(for: aci, transaction: transaction) } public func clearProfileKeyCredentials(transaction: DBWriteTransaction) { CredentialStore.removeAll(transaction: transaction) } public func clearProfileKeyCredentials(tx: DBWriteTransaction) { clearProfileKeyCredentials(transaction: SDSDB.shimOnlyBridge(tx)) } } extension ExpiringProfileKeyCredential { /// Checks if the credential is valid. /// /// `fileprivate` here since callers into this file should only ever receive /// valid credentials, and so we should discourage redundant validity /// checking elsewhere. fileprivate var isValid: Bool { return expirationTime > Date() } }