From dfd86b3b166694d0c00ce853feaf996a3d2da98a Mon Sep 17 00:00:00 2001 From: Max Radermacher Date: Mon, 11 May 2026 18:50:54 -0500 Subject: [PATCH] Use LibSignal for 1:1 message sends --- .../PniDistributionParameterBuilder.swift | 6 +- SignalServiceKit/Messages/DeviceMessage.swift | 47 +- .../Messages/MessageSender+SenderKey.swift | 24 +- SignalServiceKit/Messages/MessageSender.swift | 428 ++++++++++++------ .../Messages/OWSMessageSend.swift | 21 +- .../Messages/UD/OWSRequestMaker.swift | 12 +- .../API/Requests/OWSRequestFactory.swift | 43 +- .../Spam/SpamChallengeResolver.swift | 68 ++- SignalServiceKit/Util/Error+IsRetryable.swift | 12 + .../PniDistributionParameterBuilderTest.swift | 17 +- .../tests/Network/OWSRequestFactoryTest.swift | 26 -- 11 files changed, 403 insertions(+), 301 deletions(-) diff --git a/SignalServiceKit/Account/PniDistributionParameterBuilder.swift b/SignalServiceKit/Account/PniDistributionParameterBuilder.swift index 839d3c1caf..e562647a85 100644 --- a/SignalServiceKit/Account/PniDistributionParameterBuilder.swift +++ b/SignalServiceKit/Account/PniDistributionParameterBuilder.swift @@ -57,7 +57,7 @@ public enum PniDistribution { registrationId: UInt32, deviceMessage: DeviceMessage, ) { - owsPrecondition(deviceId == deviceMessage.destinationDeviceId) + owsPrecondition(deviceId == deviceMessage.deviceId) devicePniSignedPreKeys["\(deviceId)"] = signedPreKey devicePniPqLastResortPreKeys["\(deviceId)"] = pqLastResortPreKey @@ -206,9 +206,9 @@ final class PniDistributionParameterBuilderImpl: PniDistributionParamaterBuilder ) return deviceMessages.map { - let syncMessage = syncMessages[$0.destinationDeviceId]! + let syncMessage = syncMessages[$0.deviceId]! return LinkedDevicePniGenerationParams( - deviceId: $0.destinationDeviceId, + deviceId: $0.deviceId, signedPreKey: syncMessage.signedPreKey, pqLastResortPreKey: syncMessage.pqLastResortPreKey, registrationId: syncMessage.registrationId, diff --git a/SignalServiceKit/Messages/DeviceMessage.swift b/SignalServiceKit/Messages/DeviceMessage.swift index de73bab60a..493c58cbd9 100644 --- a/SignalServiceKit/Messages/DeviceMessage.swift +++ b/SignalServiceKit/Messages/DeviceMessage.swift @@ -6,11 +6,48 @@ import Foundation import LibSignalClient -struct DeviceMessage { - let type: SSKProtoEnvelopeType - let destinationDeviceId: DeviceId - let destinationRegistrationId: UInt32 - let content: Data +enum DeviceMessage { + case sealedSender(SingleOutboundSealedSenderMessage) + case unsealed(SingleOutboundUnsealedMessage) + + var type: SSKProtoEnvelopeType { + switch self { + case .sealedSender: + return .unidentifiedSender + case .unsealed(let message): + switch message.contents.messageType { + case .whisper: + return .ciphertext + case .preKey: + return .prekeyBundle + case .plaintext: + return .plaintextContent + default: + return .unknown + } + } + } + + var deviceId: DeviceId { + switch self { + case .sealedSender(let message): return message.deviceId + case .unsealed(let message): return message.deviceId + } + } + + var registrationId: UInt32 { + switch self { + case .sealedSender(let message): return message.registrationId + case .unsealed(let message): return message.registrationId + } + } + + var content: Data { + switch self { + case .sealedSender(let message): return message.contents + case .unsealed(let message): return message.contents.serialize() + } + } } struct SentDeviceMessage { diff --git a/SignalServiceKit/Messages/MessageSender+SenderKey.swift b/SignalServiceKit/Messages/MessageSender+SenderKey.swift index 6a15af68a0..e2fef8474c 100644 --- a/SignalServiceKit/Messages/MessageSender+SenderKey.swift +++ b/SignalServiceKit/Messages/MessageSender+SenderKey.swift @@ -403,7 +403,9 @@ extension MessageSender { let sealedSenderParameters = SealedSenderParameters( message: outgoingSKDM, senderCertificate: senderCertificate, - accessKey: (serviceId as? Aci).flatMap { udAccessMap[$0] }, + unidentifiedAccess: (serviceId as? Aci).flatMap({ + return SealedSenderParameters.UnidentifiedAccess(aci: $0, value: udAccessMap[$0]) + }), endorsement: endorsements?.tokenBuilder(forServiceId: serviceId), ) @@ -532,25 +534,7 @@ extension MessageSender { } catch SignalError.mismatchedDevices(entries: let entries, message: _) { let databaseStorage = SSKEnvironment.shared.databaseStorageRef await databaseStorage.awaitableWrite { tx in - for entry in entries { - // Incorrect device set. We should add/remove devices and try again. - if !entry.missingDevices.isEmpty || !entry.extraDevices.isEmpty { - handleMismatchedDevices( - serviceId: entry.account, - missingDevices: entry.missingDevices.compactMap(DeviceId.init(validating:)), - extraDevices: entry.extraDevices.compactMap(DeviceId.init(validating:)), - tx: tx, - ) - } - // Server reports stale devices. We should reset our session and try again. - if !entry.staleDevices.isEmpty { - handleStaleDevices( - serviceId: entry.account, - staleDevices: entry.staleDevices.compactMap(DeviceId.init(validating:)), - tx: tx, - ) - } - } + handleMismatchedDevices(entries: entries, tx: tx) } throw SenderKeyError.mismatchedDevices } diff --git a/SignalServiceKit/Messages/MessageSender.swift b/SignalServiceKit/Messages/MessageSender.swift index 5a90f7cfa3..2745ad5ed2 100644 --- a/SignalServiceKit/Messages/MessageSender.swift +++ b/SignalServiceKit/Messages/MessageSender.swift @@ -124,7 +124,7 @@ public class MessageSender { label: "Prekey Fetch", serviceId: serviceId, canUseStoryAuth: false, - accessKey: sealedSenderParameters?.accessKey, + accessKey: sealedSenderParameters?.unidentifiedAccess?.value, endorsement: sealedSenderParameters?.endorsement, authedAccount: .implicit(), options: requestOptions, @@ -957,7 +957,9 @@ public class MessageSender { var sealedSenderParameters = SealedSenderParameters( message: message, senderCertificate: senderCertificate, - accessKey: (serviceId as? Aci).flatMap({ sendingAccessMap[$0] }), + unidentifiedAccess: (serviceId as? Aci).flatMap({ + return SealedSenderParameters.UnidentifiedAccess(aci: $0, value: sendingAccessMap[$0]) + }), endorsement: endorsements?.tokenBuilder(forServiceId: serviceId), ) if localIdentifiers.contains(serviceId: serviceId) { @@ -1305,7 +1307,6 @@ public class MessageSender { private struct InnerRecoveryState { var canHandleMismatchedDevices = true - var canHandleStaleDevices = true var canHandleCaptcha = true func mutated(_ block: (inout Self) -> Void) -> Self { @@ -1369,28 +1370,12 @@ public class MessageSender { ) } - for deviceMessage in deviceMessages { - let hasValidMessageType: Bool = { - switch deviceMessage.type { - case .unidentifiedSender: - return sealedSenderParameters != nil - case .ciphertext, .prekeyBundle, .plaintextContent: - return sealedSenderParameters == nil - case .unknown, .receipt: - return false - } - }() - guard hasValidMessageType else { - throw OWSAssertionError("Invalid message type: \(deviceMessage.type)") - } - } - return try await sendDeviceMessages( deviceMessages, messageSend: messageSend, sealedSenderParameters: sealedSenderParameters, ) - } catch RequestMakerUDAuthError.udAuthFailure { + } catch SignalError.requestUnauthorized where sealedSenderParameters != nil, RequestMakerUDAuthError.udAuthFailure { owsPrecondition(sealedSenderParameters != nil) // This failure can happen on pre key fetches or message sends. return try await performMessageSendAttempt( @@ -1398,11 +1383,9 @@ public class MessageSender { recoveryState: recoveryState, sealedSenderParameters: nil, // Retry as an unsealed send. ) - } catch DeviceMessagesError.mismatchedDevices where recoveryState.canHandleMismatchedDevices { + } catch SignalError.mismatchedDevices where recoveryState.canHandleMismatchedDevices { retryRecoveryState = recoveryState.mutated({ $0.canHandleMismatchedDevices = false }) - } catch DeviceMessagesError.staleDevices where recoveryState.canHandleStaleDevices { - retryRecoveryState = recoveryState.mutated({ $0.canHandleStaleDevices = false }) - } catch where error.httpStatusCode == 428 && recoveryState.canHandleCaptcha { + } catch SignalError.rateLimitChallengeError where recoveryState.canHandleCaptcha { retryRecoveryState = recoveryState.mutated({ $0.canHandleCaptcha = false }) } return try await performMessageSendAttempt( @@ -1590,7 +1573,7 @@ public class MessageSender { deviceIds.removeAll(where: { localDeviceId == $0 }) } - let missingDeviceIds = Set(deviceIds).subtracting(deviceMessages.map(\.destinationDeviceId)) + let missingDeviceIds = Set(deviceIds).subtracting(deviceMessages.map(\.deviceId)) return try missingDeviceIds.map { do { @@ -1674,47 +1657,27 @@ public class MessageSender { } } - private enum DeviceMessagesError: Error, IsRetryableProvider { - case mismatchedDevices - case staleDevices - - var isRetryableProvider: Bool { true } - } - private func sendDeviceMessages( _ deviceMessages: [DeviceMessage], messageSend: OWSMessageSend, sealedSenderParameters: SealedSenderParameters?, ) async throws -> [SentDeviceMessage] { - let message = messageSend.message - - let requestMaker = RequestMaker( - label: "Message Send", - serviceId: messageSend.serviceId, - canUseStoryAuth: sealedSenderParameters?.message.isStorySend == true, - accessKey: sealedSenderParameters?.accessKey, - endorsement: sealedSenderParameters?.endorsement, - authedAccount: .implicit(), - options: [], - ) - - owsAssertDebug(!message.isStorySend || sealedSenderParameters != nil, "Story messages must use Sealed Sender.") + owsAssertDebug(!messageSend.message.isStorySend || sealedSenderParameters != nil, "Story messages must use Sealed Sender.") do { - let result = try await requestMaker.makeRequest { - return OWSRequestFactory.submitMessageRequest( - serviceId: messageSend.serviceId, - messages: deviceMessages, - timestamp: message.timestamp, - isOnline: message.isOnline, - isUrgent: message.isUrgent, - auth: $0, + if let sealedSenderParameters { + try await sendSealedDeviceMessages( + deviceMessages, + messageSend: messageSend, + sealedSenderParameters: sealedSenderParameters, ) + } else { + try await sendUnsealedDeviceMessages(deviceMessages, messageSend: messageSend) } return await messageSendDidSucceed( messageSend, deviceMessages: deviceMessages, - wasSentByUD: result.wasSentByUD, + wasSentByUD: sealedSenderParameters != nil, ) } catch { return try await messageSendDidFail( @@ -1725,6 +1688,187 @@ public class MessageSender { } } + private func sendSealedDeviceMessages( + _ deviceMessages: [DeviceMessage], + messageSend: OWSMessageSend, + sealedSenderParameters: SealedSenderParameters, + ) async throws { + var contents = [SingleOutboundSealedSenderMessage]() + for deviceMessage in deviceMessages { + switch deviceMessage { + case .sealedSender(let message): + contents.append(message) + case .unsealed: + throw OWSAssertionError("can't send unsealed message via sealed endpoint") + } + } + + // If it's a story, we always use story auth. + if sealedSenderParameters.message.isStorySend { + try await _sendSealedDeviceMessages(contents, messageSend: messageSend, auth: .story) + return + } + + var fallbackError: (any Error)? + + // Use a GSE if we have one; it's more likely to succeed. + if let endorsement = sealedSenderParameters.endorsement { + do { + try await _sendSealedDeviceMessages(contents, messageSend: messageSend, auth: .groupSend(endorsement.build())) + return + } catch { + switch error { + case SignalError.requestUnauthorized: + // store this for now and try the next mechanism + fallbackError = error + default: + throw error + } + } + } + + if let unidentifiedAccess = sealedSenderParameters.unidentifiedAccess { + do { + try await _performUnidentifiedAccessRequest(unidentifiedAccess: unidentifiedAccess) { + try await _sendSealedDeviceMessages(contents, messageSend: messageSend, auth: $0) + } + return + } catch { + switch error { + case SignalError.requestUnauthorized: + // store this for now and try the next mechanism + fallbackError = error + default: + throw error + } + } + } + + // We MUST have an error because SealedSenderParameters requires at least one mechanism. + throw fallbackError! + } + + private func _performUnidentifiedAccessRequest( + unidentifiedAccess: SealedSenderParameters.UnidentifiedAccess, + block: (UserBasedSendAuth) async throws -> Void, + ) async throws { + let databaseStorage = SSKEnvironment.shared.databaseStorageRef + let profileFetcher = SSKEnvironment.shared.profileFetcherRef + let udManager = SSKEnvironment.shared.udManagerRef + + do { + try await block({ + switch unidentifiedAccess.value.mode { + case .unknown, .enabled: + return .accessKey(unidentifiedAccess.value.key.keyData) + case .unrestricted: + return .unrestrictedUnauthenticatedAccess + } + }()) + + // If the request succeeds, and if the status is unknown, perform a profile + // fetch to reconcile the status. + if case .unknown = unidentifiedAccess.value.mode { + Task { + _ = try? await profileFetcher.fetchProfile(for: unidentifiedAccess.aci) + } + } + } catch { + switch error { + case SignalError.requestUnauthorized: + let newAccessMode: UnidentifiedAccessMode + switch unidentifiedAccess.value.mode { + case .unrestricted: + // If it was unrestricted, we *might* have the right profile key. + newAccessMode = .unknown + case .unknown, .enabled: + // If it was unknown, we may have tried the real key (if we had it) or a + // random key. In either of these cases, we don't want to try again because + // it won't work. + newAccessMode = .disabled + } + await databaseStorage.awaitableWrite { tx in + udManager.setUnidentifiedAccessMode(newAccessMode, for: unidentifiedAccess.aci, tx: tx) + } + Task { + _ = try? await profileFetcher.fetchProfile(for: unidentifiedAccess.aci) + } + default: + break + } + throw error + } + } + + private func _sendSealedDeviceMessages( + _ sealedMessages: [SingleOutboundSealedSenderMessage], + messageSend: OWSMessageSend, + auth: UserBasedSendAuth, + ) async throws { + let chatConnectionManager = DependenciesBridge.shared.chatConnectionManager + + let timeout = sendMessageTimeout(contentCounts: sealedMessages.map({ $0.contents.count })) + try await chatConnectionManager.withUnauthService(.messages, timeout: timeout) { + try await $0.sendMessage( + to: messageSend.serviceId, + timestamp: messageSend.message.timestamp, + contents: sealedMessages, + auth: auth, + onlineOnly: messageSend.message.isOnline, + urgent: messageSend.message.isUrgent, + ) + } + } + + private func sendUnsealedDeviceMessages( + _ deviceMessages: [DeviceMessage], + messageSend: OWSMessageSend, + ) async throws { + let chatConnectionManager = DependenciesBridge.shared.chatConnectionManager + + var unsealedMessages = [SingleOutboundUnsealedMessage]() + for deviceMessage in deviceMessages { + switch deviceMessage { + case .unsealed(let message): + unsealedMessages.append(message) + case .sealedSender: + throw OWSAssertionError("can't send sealed message via unsealed endpoint") + } + } + + let timeout = sendMessageTimeout(contentCounts: unsealedMessages.map({ $0.contents.serialize().count })) + try await chatConnectionManager.withAuthService(.attachments, timeout: timeout) { + if messageSend.isSelfSend { + try await $0.sendSyncMessage( + timestamp: messageSend.message.timestamp, + contents: unsealedMessages, + urgent: messageSend.message.isUrgent, + ) + } else { + try await $0.sendMessage( + to: messageSend.serviceId, + timestamp: messageSend.message.timestamp, + contents: unsealedMessages, + onlineOnly: messageSend.message.isOnline, + urgent: messageSend.message.isUrgent, + ) + } + } + } + + private func sendMessageTimeout(contentCounts: @autoclosure () -> [Int]) -> TimeInterval { + let remoteConfigProvider = SSKEnvironment.shared.remoteConfigManagerRef + let remoteConfig = remoteConfigProvider.currentConfig() + + guard remoteConfig.shouldUseDynamicSendMessageTimeout else { + return .infinity + } + + return OWSRequestFactory.sendMessageTimeout( + estimatedRequestSize: contentCounts().reduce(into: 0, { $0 += $1 + 50 }) + 100, + ) + } + private func messageSendDidSucceed( _ messageSend: OWSMessageSend, deviceMessages: [DeviceMessage], @@ -1736,8 +1880,8 @@ public class MessageSender { let sentDeviceMessages = deviceMessages.map { return SentDeviceMessage( - destinationDeviceId: $0.destinationDeviceId, - destinationRegistrationId: $0.destinationRegistrationId, + destinationDeviceId: $0.deviceId, + destinationRegistrationId: $0.registrationId, ) } @@ -1748,7 +1892,7 @@ public class MessageSender { messageSendLog.recordPendingDelivery( payloadId: payloadId, recipientAci: recipientAci, - recipientDeviceId: deviceMessage.destinationDeviceId, + recipientDeviceId: deviceMessage.deviceId, message: message, tx: transaction, ) @@ -1771,58 +1915,37 @@ public class MessageSender { return sentDeviceMessages } - struct MismatchedDevices: Decodable { - let extraDevices: [DeviceId] - let missingDevices: [DeviceId] - - fileprivate static func parse(_ responseData: Data) throws -> Self { - return try JSONDecoder().decode(Self.self, from: responseData) - } - } - - struct StaleDevices: Decodable { - let staleDevices: [DeviceId] - - fileprivate static func parse(_ responseData: Data) throws -> Self { - return try JSONDecoder().decode(Self.self, from: responseData) - } - } - private func messageSendDidFail( _ messageSend: OWSMessageSend, - responseError: Error, + responseError: any Error, sealedSenderParameters: SealedSenderParameters?, ) async throws -> [SentDeviceMessage] { let message = messageSend.message Logger.warn("\(type(of: message)) to \(messageSend.serviceId), timestamp: \(message.timestamp), error: \(responseError)") - switch responseError.httpStatusCode { - case 404: + switch responseError { + case SignalError.serviceIdNotFound: try await handle404(serviceId: messageSend.serviceId, isSelfSend: messageSend.isSelfSend) - case 409: - let response = try MismatchedDevices.parse(responseError.httpResponseData ?? Data()) - await SSKEnvironment.shared.databaseStorageRef.awaitableWrite { tx in - handleMismatchedDevices( - serviceId: messageSend.serviceId, - missingDevices: response.missingDevices, - extraDevices: response.extraDevices, - tx: tx, - ) + case SignalError.mismatchedDevices(entries: let entries, message: _): + let databaseStorage = SSKEnvironment.shared.databaseStorageRef + await databaseStorage.awaitableWrite { tx in + handleMismatchedDevices(entries: entries, tx: tx) } - throw DeviceMessagesError.mismatchedDevices - case 410: - let response = try StaleDevices.parse(responseError.httpResponseData ?? Data()) - await SSKEnvironment.shared.databaseStorageRef.awaitableWrite { tx in - handleStaleDevices(serviceId: messageSend.serviceId, staleDevices: response.staleDevices, tx: tx) - } - throw DeviceMessagesError.staleDevices - case 428: + throw responseError + case SignalError.rateLimitChallengeError( + token: let token, + options: let options, + retryAfter: let retryAfter, + message: _, + ): // SPAM TODO: Only retry messages with -hasRenderableContent Logger.warn("Server requested user complete spam challenge.") - try await SSKEnvironment.shared.spamChallengeResolverRef.tryToHandleSilently( - bodyData: responseError.httpResponseData, - retryAfter: responseError.httpRetryAfterDate, + let spamChallengeResolver = SSKEnvironment.shared.spamChallengeResolverRef + try await spamChallengeResolver.tryToHandleSilently( + token: token, + options: options, + retryAfter: retryAfter, ) // The resolver has 10s to asynchronously resolve a challenge If it // resolves, great! We'll let MessageSender auto-retry. Otherwise, it'll be @@ -1843,7 +1966,29 @@ public class MessageSender { // MARK: - Unregistered, Missing, & Stale Devices - func handleMismatchedDevices(serviceId: ServiceId, missingDevices: [DeviceId], extraDevices: [DeviceId], tx: DBWriteTransaction) { + func handleMismatchedDevices(entries: [MismatchedDeviceEntry], tx: DBWriteTransaction) { + for entry in entries { + // Incorrect device set. We should add/remove devices and try again. + if !entry.missingDevices.isEmpty || !entry.extraDevices.isEmpty { + _handleMismatchedDevices( + serviceId: entry.account, + missingDevices: entry.missingDevices.compactMap(DeviceId.init(validating:)), + extraDevices: entry.extraDevices.compactMap(DeviceId.init(validating:)), + tx: tx, + ) + } + // Server reports stale devices. We should reset our session and try again. + if !entry.staleDevices.isEmpty { + _handleStaleDevices( + serviceId: entry.account, + staleDevices: entry.staleDevices.compactMap(DeviceId.init(validating:)), + tx: tx, + ) + } + } + } + + private func _handleMismatchedDevices(serviceId: ServiceId, missingDevices: [DeviceId], extraDevices: [DeviceId], tx: DBWriteTransaction) { Logger.warn("Mismatched devices for \(serviceId): +\(missingDevices) -\(extraDevices)") self.updateDevices( serviceId: serviceId, @@ -1853,7 +1998,7 @@ public class MessageSender { ) } - func handleStaleDevices(serviceId: ServiceId, staleDevices: [DeviceId], tx: DBWriteTransaction) { + private func _handleStaleDevices(serviceId: ServiceId, staleDevices: [DeviceId], tx: DBWriteTransaction) { Logger.warn("Stale devices for \(serviceId): \(staleDevices)") let sessionStore = DependenciesBridge.shared.signalProtocolStoreManager.signalProtocolStore(for: .aci).sessionStore for staleDeviceId in staleDevices { @@ -1931,9 +2076,6 @@ public class MessageSender { let paddedPlaintext = plainText.paddedMessageBody - let serializedMessage: Data - let messageType: SSKProtoEnvelopeType - let identityManager = DependenciesBridge.shared.identityManager let signalProtocolStoreManager = DependenciesBridge.shared.signalProtocolStoreManager let signalProtocolStore = signalProtocolStoreManager.signalProtocolStore(for: .aci) @@ -1951,7 +2093,7 @@ public class MessageSender { senderKeyStore: SSKEnvironment.shared.senderKeyStoreRef, ) - serializedMessage = try secretCipher.encryptMessage( + let serializedMessage = try secretCipher.encryptMessage( for: protocolAddress, localAddress: localAddress, paddedPlaintext: paddedPlaintext, @@ -1961,7 +2103,17 @@ public class MessageSender { protocolContext: transaction, ) - messageType = .unidentifiedSender + // We had better have a session after encrypting for this recipient! + let session = try signalProtocolStore.sessionStore.loadSession( + for: protocolAddress, + context: transaction, + )! + + return .sealedSender(SingleOutboundSealedSenderMessage( + deviceId: destinationDeviceId, + registrationId: try session.remoteRegistrationId(), + contents: serializedMessage, + )) } else { let result = try signalEncrypt( @@ -1973,33 +2125,18 @@ public class MessageSender { context: transaction, ) - switch result.messageType { - case .whisper: - messageType = .ciphertext - case .preKey: - messageType = .prekeyBundle - case .plaintext: - messageType = .plaintextContent - default: - owsFailDebug("Unrecognized message type") - messageType = .unknown - } + // We had better have a session after encrypting for this recipient! + let session = try signalProtocolStore.sessionStore.loadSession( + for: protocolAddress, + context: transaction, + )! - serializedMessage = result.serialize() + return .unsealed(SingleOutboundUnsealedMessage( + deviceId: destinationDeviceId, + registrationId: try session.remoteRegistrationId(), + contents: result, + )) } - - // We had better have a session after encrypting for this recipient! - let session = try signalProtocolStore.sessionStore.loadSession( - for: protocolAddress, - context: transaction, - )! - - return DeviceMessage( - type: messageType, - destinationDeviceId: destinationDeviceId, - destinationRegistrationId: try session.remoteRegistrationId(), - content: serializedMessage, - ) } private func wrapPlaintextMessage( @@ -2016,9 +2153,6 @@ public class MessageSender { let plaintext = try PlaintextContent(bytes: rawPlaintext) - let serializedMessage: Data - let messageType: SSKProtoEnvelopeType - if let sealedSenderParameters { let usmc = try UnidentifiedSenderMessageContent( CiphertextMessage(plaintext), @@ -2033,22 +2167,24 @@ public class MessageSender { context: transaction, ) - serializedMessage = outerBytes - messageType = .unidentifiedSender + guard let session = try validSession(for: serviceId, deviceId: deviceId, tx: transaction) else { + throw SignalError.sessionNotFound("") + } + return .sealedSender(SingleOutboundSealedSenderMessage( + deviceId: deviceId, + registrationId: try session.remoteRegistrationId(), + contents: outerBytes, + )) } else { - serializedMessage = plaintext.serialize() - messageType = .plaintextContent + guard let session = try validSession(for: serviceId, deviceId: deviceId, tx: transaction) else { + throw SignalError.sessionNotFound("") + } + return .unsealed(SingleOutboundUnsealedMessage( + deviceId: deviceId, + registrationId: try session.remoteRegistrationId(), + contents: CiphertextMessage(plaintext), + )) } - - guard let session = try validSession(for: serviceId, deviceId: deviceId, tx: transaction) else { - throw SignalError.sessionNotFound("") - } - return DeviceMessage( - type: messageType, - destinationDeviceId: deviceId, - destinationRegistrationId: try session.remoteRegistrationId(), - content: serializedMessage, - ) } } diff --git a/SignalServiceKit/Messages/OWSMessageSend.swift b/SignalServiceKit/Messages/OWSMessageSend.swift index f6e5fa1348..1506b16d80 100644 --- a/SignalServiceKit/Messages/OWSMessageSend.swift +++ b/SignalServiceKit/Messages/OWSMessageSend.swift @@ -10,21 +10,34 @@ import LibSignalClient final class SealedSenderParameters { let message: any SendableMessage let senderCertificate: SenderCertificate - let accessKey: OWSUDAccess? + let unidentifiedAccess: UnidentifiedAccess? let endorsement: GroupSendFullTokenBuilder? + struct UnidentifiedAccess { + var aci: Aci + var value: OWSUDAccess + + init?(aci: Aci, value: OWSUDAccess?) { + guard let value else { + return nil + } + self.aci = aci + self.value = value + } + } + init?( message: any SendableMessage, senderCertificate: SenderCertificate, - accessKey: OWSUDAccess?, + unidentifiedAccess: UnidentifiedAccess?, endorsement: GroupSendFullTokenBuilder?, ) { self.message = message self.senderCertificate = senderCertificate - guard message.isStorySend || accessKey != nil || endorsement != nil else { + guard message.isStorySend || unidentifiedAccess != nil || endorsement != nil else { return nil } - self.accessKey = accessKey + self.unidentifiedAccess = unidentifiedAccess self.endorsement = endorsement } diff --git a/SignalServiceKit/Messages/UD/OWSRequestMaker.swift b/SignalServiceKit/Messages/UD/OWSRequestMaker.swift index 41c8abd1dc..33ad71e763 100644 --- a/SignalServiceKit/Messages/UD/OWSRequestMaker.swift +++ b/SignalServiceKit/Messages/UD/OWSRequestMaker.swift @@ -6,18 +6,8 @@ import Foundation import LibSignalClient -@objc -public enum RequestMakerUDAuthError: Int, Error, IsRetryableProvider { +public enum RequestMakerUDAuthError: Error { case udAuthFailure - - // MARK: - IsRetryableProvider - - public var isRetryableProvider: Bool { - switch self { - case .udAuthFailure: - return true - } - } } // MARK: - diff --git a/SignalServiceKit/Network/API/Requests/OWSRequestFactory.swift b/SignalServiceKit/Network/API/Requests/OWSRequestFactory.swift index 864763ed85..c522e2db1f 100644 --- a/SignalServiceKit/Network/API/Requests/OWSRequestFactory.swift +++ b/SignalServiceKit/Network/API/Requests/OWSRequestFactory.swift @@ -10,7 +10,6 @@ public enum OWSRequestFactory { static let textSecureAccountsAPI = "v1/accounts" static let textSecureAttributesAPI = "v1/accounts/attributes/" - static let textSecureMessagesAPI = "v1/messages/" static let textSecureKeysAPI = "v2/keys" static let textSecureSignedKeysAPI = "v2/keys/signed" static let textSecureDirectoryAPI = "v1/directory" @@ -96,38 +95,6 @@ public enum OWSRequestFactory { return request } - static func submitMessageRequest( - serviceId: ServiceId, - messages: [DeviceMessage], - timestamp: UInt64, - isOnline: Bool, - isUrgent: Bool, - auth: TSRequest.SealedSenderAuth?, - ) -> TSRequest { - // NOTE: messages may be empty; See comments in OWSDeviceManager. - owsAssertDebug(timestamp > 0) - - let path = "\(self.textSecureMessagesAPI)\(serviceId.serviceIdString)?story=\(auth?.isStory == true ? "true" : "false")" - - // Returns the per-account-message parameters used when submitting a message to - // the Signal Web Service. - // See - // . - let parameters: [String: Any] = [ - "messages": messages.map { $0.requestParameters() }, - "timestamp": timestamp, - "online": isOnline, - "urgent": isUrgent, - ] - - var request = TSRequest(url: URL(string: path)!, method: "PUT", parameters: parameters) - request.timeoutInterval = sendMessageTimeout(estimatedRequestSize: messages.reduce(into: 0, { $0 += $1.content.count + 50 }) + 100) - if let auth { - request.auth = .sealedSender(auth) - } - return request - } - static func sendMessageTimeout(estimatedRequestSize: Int) -> TimeInterval { let bandwidthEstimate: Double = 40_000 // kbit/s let transferEstimate = Double(estimatedRequestSize) / (bandwidthEstimate / 8) @@ -584,13 +551,15 @@ public enum OWSRequestFactory { extension DeviceMessage { /// Returns the per-device-message parameters when sending a message. /// + /// Note: This API is (currently) used only when changing your number. + /// /// See . func requestParameters() -> NSDictionary { return [ - "type": type.rawValue, - "destinationDeviceId": destinationDeviceId.uint32Value, - "destinationRegistrationId": Int32(bitPattern: destinationRegistrationId), - "content": content.base64EncodedString(), + "type": self.type.rawValue, + "destinationDeviceId": self.deviceId.uint32Value, + "destinationRegistrationId": Int32(bitPattern: self.registrationId), + "content": self.content.base64EncodedString(), ] } } diff --git a/SignalServiceKit/Spam/SpamChallengeResolver.swift b/SignalServiceKit/Spam/SpamChallengeResolver.swift index 12ead65798..75f0596961 100644 --- a/SignalServiceKit/Spam/SpamChallengeResolver.swift +++ b/SignalServiceKit/Spam/SpamChallengeResolver.swift @@ -4,6 +4,7 @@ // import Foundation +import LibSignalClient public class SpamChallengeResolver: NSObject, SpamChallengeSchedulingDelegate { @@ -192,51 +193,29 @@ extension SpamChallengeResolver { // MARK: - Server challenges -private struct ServerChallengePayload: Decodable { - let token: String - let options: [Options] - - enum Options: String, Decodable { - case captcha - case pushChallenge - case unrecognized - - init(from decoder: Decoder) throws { - let container = try decoder.singleValueContainer() - let string = try container.decode(String.self) - self = Options(rawValue: string) ?? .unrecognized - } - } -} - extension SpamChallengeResolver { - @objc - public func handleServerChallengeBody( - _ body: Data, + private func handleServerChallengeBody( + token: String, + options: Set, retryAfter: Date, - silentRecoveryCompletionHandler: ((Bool) -> Void)? = nil, + silentRecoveryCompletionHandler: @escaping (Bool) -> Void, ) { - guard appReadiness.isAppReady else { return owsFailDebug("App not ready") } - guard let payload = try? JSONDecoder().decode(ServerChallengePayload.self, from: body) else { - return owsFailDebug("Invalid server spam request response body: \(body)") - } - - Logger.info("Received incoming spam challenge: \(payload.options.map { $0.rawValue })") + Logger.info("Received incoming spam challenge: \(options)") workQueue.async { - // If we already have a pending captcha challenge, we should wait for that to resolve - // If we were given a silent recovery closure, reply with a failure + // If we already have a pending captcha challenge, wait for that to + // resolve. If we were given a silent recovery, reply with a failure. guard self.challenges?.contains(where: { $0 is CaptchaChallenge }) == false else { Logger.info("Captcha challenge already in progress") - silentRecoveryCompletionHandler?(false) + silentRecoveryCompletionHandler(false) return } - if payload.options.contains(.pushChallenge), let completion = silentRecoveryCompletionHandler { + if options.contains(.pushChallenge) { if let latestPushChallenge = self.challenges?.first(where: { $0 is PushChallenge && $0.isLive }) { Logger.info("Push challenge already in progress; attempting silent recovery") - latestPushChallenge.completionHandlers.append(completion) + latestPushChallenge.completionHandlers.append(silentRecoveryCompletionHandler) } else { Logger.info("Requesting push for silent recovery") let challenge = PushChallenge(expiry: Date(timeIntervalSinceNow: 10)) @@ -244,32 +223,41 @@ extension SpamChallengeResolver { challenge.completionHandlers.append({ didSucceed in Logger.info("Silent recovery \(didSucceed ? "did" : "did not") succeed") if !didSucceed { - self.handleServerChallengeBody(body, retryAfter: retryAfter) + var options = options + options.remove(.pushChallenge) + // Try again without the option to use a pushChallenge + self.handleServerChallengeBody( + token: token, + options: options, + retryAfter: retryAfter, + silentRecoveryCompletionHandler: { _ in }, + ) } - completion(didSucceed) + silentRecoveryCompletionHandler(didSucceed) }) self.challenges?.append(challenge) } self.recheckChallenges() - } else if payload.options.contains(.captcha) { + } else if options.contains(.captcha) { Logger.info("Registering captcha challenge") - let challenge = CaptchaChallenge(tokenIn: payload.token, expiry: retryAfter) + let challenge = CaptchaChallenge(tokenIn: token, expiry: retryAfter) challenge.schedulingDelegate = self self.challenges?.append(challenge) self.recheckChallenges() - silentRecoveryCompletionHandler?(false) + silentRecoveryCompletionHandler(false) } } } - func tryToHandleSilently(bodyData: Data?, retryAfter: Date?) async throws { - guard let bodyData, let retryAfter else { + func tryToHandleSilently(token: String, options: Set, retryAfter: TimeInterval?) async throws { + guard let retryAfter else { throw SpamChallengeRequiredError() } + let retryAfterDate = Date(timeIntervalSinceNow: retryAfter) try await withCheckedThrowingContinuation { continuation in - handleServerChallengeBody(bodyData, retryAfter: retryAfter) { didResolve in + handleServerChallengeBody(token: token, options: options, retryAfter: retryAfterDate) { didResolve in if didResolve { continuation.resume(returning: ()) } else { diff --git a/SignalServiceKit/Util/Error+IsRetryable.swift b/SignalServiceKit/Util/Error+IsRetryable.swift index c2bb76980b..44d3d10ba5 100644 --- a/SignalServiceKit/Util/Error+IsRetryable.swift +++ b/SignalServiceKit/Util/Error+IsRetryable.swift @@ -4,6 +4,7 @@ // import Foundation +public import LibSignalClient import ObjectiveC extension Error { @@ -60,3 +61,14 @@ public class OWSRetryableError: CustomNSError, IsRetryableProvider { public var isRetryableProvider: Bool { true } } + +// MARK: - SignalError + +extension SignalError: IsRetryableProvider { + public var isRetryableProvider: Bool { + switch self { + case .mismatchedDevices: return true + default: return false + } + } +} diff --git a/SignalServiceKit/tests/Account/PniDistributionParameterBuilderTest.swift b/SignalServiceKit/tests/Account/PniDistributionParameterBuilderTest.swift index 1310d65d73..7eb0210cc9 100644 --- a/SignalServiceKit/tests/Account/PniDistributionParameterBuilderTest.swift +++ b/SignalServiceKit/tests/Account/PniDistributionParameterBuilderTest.swift @@ -35,12 +35,11 @@ class PniDistributionParameterBuilderTest: XCTestCase { } private func buildDeviceMessage(deviceId: DeviceId, registrationId: UInt32) -> DeviceMessage { - return DeviceMessage( - type: .ciphertext, - destinationDeviceId: deviceId, - destinationRegistrationId: registrationId, - content: Data(), - ) + return .unsealed(SingleOutboundUnsealedMessage( + deviceId: deviceId, + registrationId: registrationId, + contents: CiphertextMessage(try! PlaintextContent(bytes: [0xC0])), + )) } func testBuildParametersHappyPath() async throws { @@ -76,8 +75,8 @@ class PniDistributionParameterBuilderTest: XCTestCase { ) XCTAssertEqual(parameters.deviceMessages.count, 1) - XCTAssertEqual(parameters.deviceMessages.first?.destinationDeviceId, DeviceId(validating: 123)!) - XCTAssertEqual(parameters.deviceMessages.first?.destinationRegistrationId, 456) + XCTAssertEqual(parameters.deviceMessages.first?.deviceId, DeviceId(validating: 123)!) + XCTAssertEqual(parameters.deviceMessages.first?.registrationId, 456) XCTAssertTrue(messageSenderMock.deviceMessagesMocks.get().isEmpty) } @@ -159,7 +158,7 @@ private class MessageSenderMock: PniDistributionParameterBuilderImpl.Shims.Messa let nextResult = deviceMessagesMocks.update { $0.removeFirst() } let result = try nextResult.get() try await self.db.awaitableWrite { tx in - try result.forEach { _ = try buildPlaintextContent($0.destinationDeviceId, tx) } + try result.forEach { _ = try buildPlaintextContent($0.deviceId, tx) } } return result } diff --git a/SignalServiceKit/tests/Network/OWSRequestFactoryTest.swift b/SignalServiceKit/tests/Network/OWSRequestFactoryTest.swift index 7cd8ef2b5b..4a1ff31b21 100644 --- a/SignalServiceKit/tests/Network/OWSRequestFactoryTest.swift +++ b/SignalServiceKit/tests/Network/OWSRequestFactoryTest.swift @@ -38,32 +38,6 @@ class OWSRequestFactoryTest: XCTestCase { XCTAssertEqual(request.parameters as! [String: String], ["body": "AQID"]) } - // MARK: - Message requests - - func testSubmitMessageRequest() throws { - let udAccessKey = getUdAccessKey() - - let serviceId = Aci.randomForTesting() - - let request = OWSRequestFactory.submitMessageRequest( - serviceId: serviceId, - messages: [], - timestamp: 1234, - isOnline: true, - isUrgent: false, - auth: .accessKey(udAccessKey), - ) - - XCTAssertEqual(request.method, "PUT") - XCTAssertEqual(request.url.path, "v1/messages/\(serviceId.serviceIdString)") - XCTAssertEqual(Set(request.parameters.keys), Set(["messages", "timestamp", "online", "urgent"])) - XCTAssertEqual(request.parameters["messages"] as? NSArray, []) - XCTAssertEqual(request.parameters["timestamp"] as? UInt64, 1234) - XCTAssertEqual(request.parameters["online"] as? Bool, true) - XCTAssertEqual(request.parameters["urgent"] as? Bool, false) - XCTAssertEqual(try queryItemsAsDictionary(url: request.url), ["story": "false"]) - } - // MARK: - Donations func testBoostStripeCreatePaymentIntentWithAmount() {