Use LibSignal for 1:1 message sends

This commit is contained in:
Max Radermacher 2026-05-11 18:50:54 -05:00 committed by GitHub
parent e8c925082e
commit dfd86b3b16
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
11 changed files with 403 additions and 301 deletions

View File

@ -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,

View File

@ -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 {

View File

@ -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
}

View File

@ -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,
)
}
}

View File

@ -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
}

View File

@ -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: -

View File

@ -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(),
]
}
}

View File

@ -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 {

View File

@ -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
}
}
}

View File

@ -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
}

View File

@ -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() {