Use LibSignal for 1:1 message sends
This commit is contained in:
parent
e8c925082e
commit
dfd86b3b16
@ -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,
|
||||
|
||||
@ -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 {
|
||||
|
||||
@ -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
|
||||
}
|
||||
|
||||
@ -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,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@ -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
|
||||
}
|
||||
|
||||
|
||||
@ -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: -
|
||||
|
||||
@ -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
|
||||
// <https://github.com/signalapp/Signal-Server/blob/65da844d70369cb8b44966cfb2d2eb9b925a6ba4/service/src/main/java/org/whispersystems/textsecuregcm/entities/IncomingMessageList.java>.
|
||||
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 <https://github.com/signalapp/Signal-Server/blob/ab26a65/service/src/main/java/org/whispersystems/textsecuregcm/entities/IncomingMessage.java>.
|
||||
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(),
|
||||
]
|
||||
}
|
||||
}
|
||||
|
||||
@ -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<ChallengeOption>,
|
||||
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<ChallengeOption>, 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 {
|
||||
|
||||
@ -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
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@ -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
|
||||
}
|
||||
|
||||
@ -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() {
|
||||
|
||||
Loading…
Reference in New Issue
Block a user