// // Copyright 2019 Signal Messenger, LLC // SPDX-License-Identifier: AGPL-3.0-only // import Foundation import LibSignalClient public class VersionedProfileRequestImpl: NSObject, VersionedProfileRequest { public let request: TSRequest public let requestContext: ProfileKeyCredentialRequestContext? public let profileKey: OWSAES256Key? public init(request: TSRequest, requestContext: ProfileKeyCredentialRequestContext?, profileKey: OWSAES256Key?) { self.request = request self.requestContext = requestContext self.profileKey = profileKey } } // MARK: - public class VersionedProfilesImpl: NSObject, VersionedProfilesSwift, VersionedProfiles { private enum CredentialStore { private static let deprecatedCredentialStore = SDSKeyValueStore(collection: "VersionedProfiles.credentialStore") private static let expiringCredentialStore = SDSKeyValueStore(collection: "VersionedProfilesImpl.expiringCredentialStore") private static func storeKey(for aci: Aci) -> String { return aci.serviceIdUppercaseString } static func dropDeprecatedCredentialsIfNecessary(transaction: SDSAnyWriteTransaction) { deprecatedCredentialStore.removeAll(transaction: transaction) } static func hasValidCredential( for aci: Aci, transaction: SDSAnyReadTransaction ) throws -> Bool { try getValidCredential(for: aci, transaction: transaction) != nil } static func getValidCredential( for aci: Aci, transaction: SDSAnyReadTransaction ) throws -> ExpiringProfileKeyCredential? { guard let credentialData = expiringCredentialStore.getData( storeKey(for: aci), transaction: transaction ) else { return nil } let credential = try ExpiringProfileKeyCredential(contents: [UInt8](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: SDSAnyWriteTransaction ) throws { let credentialData = credential.serialize().asData 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: SDSAnyWriteTransaction) { expiringCredentialStore.removeValue(forKey: storeKey(for: aci), transaction: transaction) } static func removeAll(transaction: SDSAnyWriteTransaction) { expiringCredentialStore.removeAll(transaction: transaction) } } // MARK: - Init override public init() { super.init() AppReadiness.runNowOrWhenMainAppDidBecomeReadyAsync { // Once we think all clients in the world have migrated to expiring // credentials we can remove this. self.databaseStorage.asyncWrite { transaction in CredentialStore.dropDeprecatedCredentialsIfNecessary(transaction: transaction) } } } // MARK: - public func clientZkProfileOperations() throws -> ClientZkProfileOperations { return ClientZkProfileOperations(serverPublicParams: try GroupsV2Protos.serverPublicParams()) } // MARK: - Update public func updateProfile( profileGivenName: OWSUserProfile.NameComponent?, profileFamilyName: OWSUserProfile.NameComponent?, profileBio: String?, profileBioEmoji: String?, profileAvatarMutation: VersionedProfileAvatarMutation, visibleBadgeIds: [String], profileKey: OWSAES256Key, 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().asData func fetchLocalPaymentAddressProtoData() async -> Data? { await databaseStorage.awaitableWrite { tx in Self.paymentsHelper.lastKnownLocalPaymentAddressProtoData(transaction: tx) } } let profilePaymentAddressData: Data? = await { guard paymentsHelper.arePaymentsEnabled, !paymentsHelper.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 } guard let stringData = value.data(using: .utf8) else { owsFailDebug("Invalid value.") return nil } 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(databaseStorage.read { tx in udManager.phoneNumberSharingMode(tx: tx.asV2Read).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 networkManager.makePromise(request: request).awaitable() 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, udAccessKey: SMKUDAccessKey?, auth: ChatServiceAuth ) throws -> VersionedProfileRequest { var requestContext: ProfileKeyCredentialRequestContext? var profileKeyVersionArg: String? var credentialRequestArg: Data? var profileKeyForRequest: OWSAES256Key? try databaseStorage.read { transaction in // We try to include the profile key if we have one. guard let profileKeyForAddress = self.profileManager.profileKey( for: SignalServiceAddress(aci), transaction: transaction) else { return } profileKeyForRequest = profileKeyForAddress let profileKey: ProfileKey = try self.parseProfileKey(profileKey: profileKeyForAddress) let profileKeyVersion = try profileKey.getProfileKeyVersion(userId: aci) profileKeyVersionArg = try profileKeyVersion.asHexadecimalString() // We need to request a credential if we don't have a valid one already. if !(try CredentialStore.hasValidCredential(for: aci, transaction: transaction)) { let clientZkProfileOperations = try self.clientZkProfileOperations() let context = try clientZkProfileOperations.createProfileKeyCredentialRequestContext( userId: aci, profileKey: profileKey ) requestContext = context let credentialRequest = try context.getRequest() credentialRequestArg = credentialRequest.serialize().asData } } let request = OWSRequestFactory.getVersionedProfileRequest( aci: AciObjC(aci), profileKeyVersion: profileKeyVersionArg, credentialRequest: credentialRequestArg, udAccessKey: udAccessKey, auth: auth ) return VersionedProfileRequestImpl(request: request, requestContext: requestContext, profileKey: profileKeyForRequest) } // MARK: - public func parseProfileKey(profileKey: OWSAES256Key) throws -> ProfileKey { let profileKeyData: Data = profileKey.keyData let profileKeyDataBytes = [UInt8](profileKeyData) return try ProfileKey(contents: profileKeyDataBytes) } public func didFetchProfile(profile: SignalServiceProfile, profileRequest: VersionedProfileRequest) async { do { guard let profileRequest = profileRequest as? VersionedProfileRequestImpl else { return } 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: [UInt8](credentialResponseData)) let clientZkProfileOperations = try self.clientZkProfileOperations() let profileKeyCredential = try clientZkProfileOperations.receiveExpiringProfileKeyCredential( profileKeyCredentialRequestContext: requestContext, profileKeyCredentialResponse: credentialResponse ) guard let requestProfileKey = profileRequest.profileKey else { throw OWSAssertionError("Missing profile key for credential from versioned profile fetch.") } // ACI TODO: This must be an Aci, but the compiler loses type information. Fix that. guard let aci = profile.serviceId as? Aci else { throw OWSAssertionError("Missing ACI.") } try await databaseStorage.awaitableWrite { tx throws in guard let currentProfileKey = self.profileManager.profileKey(for: SignalServiceAddress(aci), transaction: tx) else { throw OWSAssertionError("Missing profile key in database.") } guard requestProfileKey.keyData == currentProfileKey.keyData else { Logger.warn("Profile key for versioned profile fetch does not match current profile key.") return } try CredentialStore.setCredential(profileKeyCredential, for: aci, transaction: tx) } } catch { owsFailDebug("Invalid credential: \(error).") return } } // MARK: - Credentials public func validProfileKeyCredential( for aci: Aci, transaction: SDSAnyReadTransaction ) throws -> ExpiringProfileKeyCredential? { try CredentialStore.getValidCredential(for: aci, transaction: transaction) } @objc(clearProfileKeyCredentialForServiceId:transaction:) public func clearProfileKeyCredential( for aci: AciObjC, transaction: SDSAnyWriteTransaction ) { CredentialStore.removeValue(for: aci.wrappedAciValue, transaction: transaction) } public func clearProfileKeyCredentials(transaction: SDSAnyWriteTransaction) { 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() } }