mirror of
https://github.com/signalapp/Signal-Android.git
synced 2026-09-17 22:27:22 +01:00
Add MessageService and IndividualSendJobV2.
This commit is contained in:
committed by
Michelle Tang
parent
0284da2d0f
commit
f206487ede
@@ -0,0 +1,137 @@
|
||||
/*
|
||||
* Copyright 2026 Signal Messenger, LLC
|
||||
* SPDX-License-Identifier: AGPL-3.0-only
|
||||
*/
|
||||
|
||||
package org.signal.network.api
|
||||
|
||||
import kotlinx.serialization.Serializable
|
||||
import org.signal.core.util.serialization.ByteArrayToBase64Serializer
|
||||
import org.signal.core.util.serialization.SignalJson
|
||||
import org.signal.libsignal.net.BadRequestError
|
||||
import org.signal.libsignal.net.RequestResult
|
||||
import org.signal.network.websocket.WebSocketRequestMessage
|
||||
import org.signal.network.websocket.get
|
||||
import org.whispersystems.signalservice.api.crypto.SealedSenderAccess
|
||||
import org.whispersystems.signalservice.api.websocket.SignalWebSocket
|
||||
import java.io.IOException
|
||||
import kotlin.time.Duration
|
||||
|
||||
/**
|
||||
* Prekey endpoints. Uses [RequestResult] and kotlinx-serializable DTOs; no jackson, no libsignal-service response types.
|
||||
*/
|
||||
class KeysApiV2(
|
||||
private val authWebSocket: SignalWebSocket.AuthenticatedWebSocket,
|
||||
private val unauthWebSocket: SignalWebSocket.UnauthenticatedWebSocket
|
||||
) {
|
||||
/**
|
||||
* Fetch prekeys for a specific device.
|
||||
*
|
||||
* GET /v2/keys/[identifier]/[deviceId]
|
||||
* - 200: Success
|
||||
* - 401: Unauthorized
|
||||
* - 404: No keys found for address/device
|
||||
* - 429: Rate limited
|
||||
*/
|
||||
suspend fun getPreKey(
|
||||
identifier: String,
|
||||
deviceId: Int,
|
||||
sealedSenderAccess: SealedSenderAccess?
|
||||
): RequestResult<PreKeyResponse, GetPreKeysError> {
|
||||
return getPreKeysBySpecifier(identifier, deviceId.toString(), sealedSenderAccess)
|
||||
}
|
||||
|
||||
/**
|
||||
* Fetch prekeys for all of the recipient's devices. (Server returns a bundle per device.)
|
||||
*
|
||||
* Wildcard device specifier: `GET /v2/keys/{identifier}/{asterisk}`
|
||||
*/
|
||||
suspend fun getPreKeysForAllDevices(
|
||||
identifier: String,
|
||||
sealedSenderAccess: SealedSenderAccess?
|
||||
): RequestResult<PreKeyResponse, GetPreKeysError> {
|
||||
return getPreKeysBySpecifier(identifier, "*", sealedSenderAccess)
|
||||
}
|
||||
|
||||
private suspend fun getPreKeysBySpecifier(
|
||||
identifier: String,
|
||||
deviceSpecifier: String,
|
||||
sealedSenderAccess: SealedSenderAccess?
|
||||
): RequestResult<PreKeyResponse, GetPreKeysError> {
|
||||
val request = WebSocketRequestMessage.get("/v2/keys/$identifier/$deviceSpecifier")
|
||||
|
||||
return try {
|
||||
val response = if (sealedSenderAccess != null) {
|
||||
unauthWebSocket.requestSuspend(request, sealedSenderAccess)
|
||||
} else {
|
||||
authWebSocket.requestSuspend(request)
|
||||
}
|
||||
|
||||
when (response.status) {
|
||||
200 -> SignalJson.decode(PreKeyResponse.serializer(), response.body).fold(
|
||||
ifLeft = { RequestResult.ApplicationError(it.cause) },
|
||||
ifRight = { RequestResult.Success(it) }
|
||||
)
|
||||
401 -> RequestResult.NonSuccess(GetPreKeysError.Unauthorized)
|
||||
404 -> RequestResult.NonSuccess(GetPreKeysError.NotFound)
|
||||
429 -> RequestResult.NonSuccess(GetPreKeysError.RateLimited(response.retryAfter()))
|
||||
else -> RequestResult.ApplicationError(IllegalStateException("Unexpected response code: ${response.status}"))
|
||||
}
|
||||
} catch (e: IOException) {
|
||||
RequestResult.RetryableNetworkError(e)
|
||||
} catch (e: Throwable) {
|
||||
RequestResult.ApplicationError(e)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Full prekey bundle for a recipient, including the shared identity key and one entry per device.
|
||||
* Wire format for key/signature fields is base64; [ByteArrayToBase64Serializer] handles the conversion.
|
||||
*/
|
||||
@Serializable
|
||||
class PreKeyResponse(
|
||||
@Serializable(with = ByteArrayToBase64Serializer::class)
|
||||
val identityKey: ByteArray,
|
||||
val devices: List<PreKeyResponseItem> = emptyList()
|
||||
)
|
||||
|
||||
@Serializable
|
||||
data class PreKeyResponseItem(
|
||||
val deviceId: Int,
|
||||
val registrationId: Int,
|
||||
val signedPreKey: SignedPreKey? = null,
|
||||
val preKey: PreKey? = null,
|
||||
val pqPreKey: KyberPreKey? = null
|
||||
)
|
||||
|
||||
@Serializable
|
||||
class PreKey(
|
||||
val keyId: Long,
|
||||
@Serializable(with = ByteArrayToBase64Serializer::class)
|
||||
val publicKey: ByteArray
|
||||
)
|
||||
|
||||
@Serializable
|
||||
class SignedPreKey(
|
||||
val keyId: Long,
|
||||
@Serializable(with = ByteArrayToBase64Serializer::class)
|
||||
val publicKey: ByteArray,
|
||||
@Serializable(with = ByteArrayToBase64Serializer::class)
|
||||
val signature: ByteArray
|
||||
)
|
||||
|
||||
@Serializable
|
||||
class KyberPreKey(
|
||||
val keyId: Long,
|
||||
@Serializable(with = ByteArrayToBase64Serializer::class)
|
||||
val publicKey: ByteArray,
|
||||
@Serializable(with = ByteArrayToBase64Serializer::class)
|
||||
val signature: ByteArray
|
||||
)
|
||||
|
||||
sealed interface GetPreKeysError : BadRequestError {
|
||||
data object Unauthorized : GetPreKeysError
|
||||
data object NotFound : GetPreKeysError
|
||||
data class RateLimited(val retryAfter: Duration?) : GetPreKeysError
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,160 @@
|
||||
/*
|
||||
* Copyright 2026 Signal Messenger, LLC
|
||||
* SPDX-License-Identifier: AGPL-3.0-only
|
||||
*/
|
||||
|
||||
package org.signal.network.api
|
||||
|
||||
import arrow.core.getOrElse
|
||||
import kotlinx.serialization.Serializable
|
||||
import kotlinx.serialization.Transient
|
||||
import org.signal.core.util.serialization.SignalJson
|
||||
import org.signal.libsignal.net.BadRequestError
|
||||
import org.signal.libsignal.net.RequestResult
|
||||
import org.signal.network.websocket.WebSocketRequestMessage
|
||||
import org.signal.network.websocket.put
|
||||
import org.whispersystems.signalservice.api.crypto.SealedSenderAccess
|
||||
import org.whispersystems.signalservice.api.websocket.SignalWebSocket
|
||||
import java.io.IOException
|
||||
import kotlin.time.Duration
|
||||
|
||||
/**
|
||||
* Collection of message-related endpoints.
|
||||
*/
|
||||
class MessageApiV2(
|
||||
private val authWebSocket: SignalWebSocket.AuthenticatedWebSocket,
|
||||
private val unauthWebSocket: SignalWebSocket.UnauthenticatedWebSocket
|
||||
) {
|
||||
/**
|
||||
* Sends a message to a single recipient. Uses the unauthenticated websocket if [sealedSenderAccess] is provided,
|
||||
* and the authenticated websocket otherwise.
|
||||
*
|
||||
* PUT /v1/messages/[destination]?story=[story]
|
||||
* - 200: Success
|
||||
* - 401: Authorization or [sealedSenderAccess] is missing or incorrect
|
||||
* - 404: Recipient is not a registered Signal user
|
||||
* - 409: Mismatched devices for the recipient
|
||||
* - 410: Stale devices for some recipient devices
|
||||
* - 428: Sender must complete a challenge before proceeding
|
||||
* - 508: Server rejected the message
|
||||
*/
|
||||
suspend fun sendMessage(
|
||||
destination: String,
|
||||
messageList: SendMessageRequest,
|
||||
sealedSenderAccess: SealedSenderAccess?,
|
||||
story: Boolean
|
||||
): RequestResult<SendMessageResponse, SendMessageError> {
|
||||
val requestBody = SignalJson.encode(SendMessageRequest.serializer(), messageList).getOrElse { return RequestResult.ApplicationError(it.cause) }
|
||||
val request = WebSocketRequestMessage.put("/v1/messages/$destination?story=$story", requestBody)
|
||||
|
||||
return try {
|
||||
val response = if (sealedSenderAccess == null) {
|
||||
authWebSocket.requestSuspend(request)
|
||||
} else {
|
||||
unauthWebSocket.requestSuspend(request, sealedSenderAccess)
|
||||
}
|
||||
|
||||
when (response.status) {
|
||||
200 -> {
|
||||
SignalJson
|
||||
.decode(SendMessageResponse.serializer(), response.body)
|
||||
.map { it.copy(sentUnidentified = response.isUnidentified) }
|
||||
.fold(
|
||||
ifLeft = { RequestResult.ApplicationError(it.cause) },
|
||||
ifRight = { RequestResult.Success(it) }
|
||||
)
|
||||
}
|
||||
401 -> {
|
||||
RequestResult.NonSuccess(SendMessageError.Unauthorized)
|
||||
}
|
||||
404 -> {
|
||||
RequestResult.NonSuccess(SendMessageError.NotRegistered)
|
||||
}
|
||||
409 -> {
|
||||
SignalJson
|
||||
.decode(MismatchedDevices.serializer(), response.body)
|
||||
.fold(
|
||||
ifLeft = { RequestResult.ApplicationError(it.cause) },
|
||||
ifRight = { RequestResult.NonSuccess(SendMessageError.MismatchedDevicesError(it)) }
|
||||
)
|
||||
}
|
||||
410 -> {
|
||||
SignalJson
|
||||
.decode(StaleDevices.serializer(), response.body)
|
||||
.fold(
|
||||
ifLeft = { RequestResult.ApplicationError(it.cause) },
|
||||
ifRight = { RequestResult.NonSuccess(SendMessageError.StaleDevicesError(it)) }
|
||||
)
|
||||
}
|
||||
428 -> {
|
||||
SignalJson
|
||||
.decode(ProofRequiredResponseBody.serializer(), response.body)
|
||||
.fold(
|
||||
ifLeft = { RequestResult.ApplicationError(it.cause) },
|
||||
ifRight = { RequestResult.NonSuccess(SendMessageError.ChallengeRequired(it.token, it.options, response.retryAfter())) }
|
||||
)
|
||||
}
|
||||
429 -> RequestResult.NonSuccess(SendMessageError.RateLimited(response.retryAfter()))
|
||||
508 -> RequestResult.NonSuccess(SendMessageError.ServerRejected)
|
||||
else -> RequestResult.ApplicationError(IllegalStateException("Unexpected response code: ${response.status}"))
|
||||
}
|
||||
} catch (e: IOException) {
|
||||
RequestResult.RetryableNetworkError(e)
|
||||
} catch (e: Throwable) {
|
||||
RequestResult.ApplicationError(e)
|
||||
}
|
||||
}
|
||||
|
||||
@Serializable
|
||||
data class SendMessageRequest(
|
||||
val messages: List<Message>,
|
||||
val timestamp: Long,
|
||||
val online: Boolean = false,
|
||||
val urgent: Boolean = true
|
||||
)
|
||||
|
||||
@Serializable
|
||||
data class Message(
|
||||
val type: Int,
|
||||
val destinationDeviceId: Int,
|
||||
val destinationRegistrationId: Int,
|
||||
val content: String
|
||||
)
|
||||
|
||||
@Serializable
|
||||
data class SendMessageResponse(
|
||||
val needsSync: Boolean = false,
|
||||
@Transient val sentUnidentified: Boolean = false
|
||||
)
|
||||
|
||||
@Serializable
|
||||
data class MismatchedDevices(
|
||||
val missingDevices: List<Int> = emptyList(),
|
||||
val extraDevices: List<Int> = emptyList()
|
||||
)
|
||||
|
||||
@Serializable
|
||||
data class StaleDevices(
|
||||
val staleDevices: List<Int> = emptyList()
|
||||
)
|
||||
|
||||
/**
|
||||
* Body of a 428 response. [token] is the proof-required challenge token; [options] is the
|
||||
* list of supported challenge mechanisms (e.g. "captcha", "pushChallenge").
|
||||
*/
|
||||
@Serializable
|
||||
private data class ProofRequiredResponseBody(
|
||||
val token: String,
|
||||
val options: List<String> = emptyList()
|
||||
)
|
||||
|
||||
sealed class SendMessageError : BadRequestError {
|
||||
data object Unauthorized : SendMessageError()
|
||||
data object NotRegistered : SendMessageError()
|
||||
data class MismatchedDevicesError(val devices: MismatchedDevices) : SendMessageError()
|
||||
data class StaleDevicesError(val devices: StaleDevices) : SendMessageError()
|
||||
data class ChallengeRequired(val token: String, val options: List<String>, val retryAfter: Duration?) : SendMessageError()
|
||||
data class RateLimited(val retryAfter: Duration?) : SendMessageError()
|
||||
data object ServerRejected : SendMessageError()
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,19 @@
|
||||
/*
|
||||
* Copyright 2026 Signal Messenger, LLC
|
||||
* SPDX-License-Identifier: AGPL-3.0-only
|
||||
*/
|
||||
|
||||
package org.signal.network.api
|
||||
|
||||
import org.signal.network.websocket.WebsocketResponse
|
||||
import kotlin.time.Duration
|
||||
import kotlin.time.Duration.Companion.seconds
|
||||
|
||||
/**
|
||||
* Parses the `Retry-After` header as a whole number of seconds. Returns null if the header is
|
||||
* absent or can't be parsed (e.g. HTTP-date form, which the server does not currently use).
|
||||
*/
|
||||
internal fun WebsocketResponse.retryAfter(): Duration? {
|
||||
val raw = getHeader("retry-after") ?: return null
|
||||
return raw.toLongOrNull()?.seconds
|
||||
}
|
||||
@@ -0,0 +1,320 @@
|
||||
/*
|
||||
* Copyright 2026 Signal Messenger, LLC
|
||||
* SPDX-License-Identifier: AGPL-3.0-only
|
||||
*/
|
||||
|
||||
package org.signal.network.service
|
||||
|
||||
import arrow.core.Either
|
||||
import arrow.core.raise.Raise
|
||||
import arrow.core.raise.either
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.withContext
|
||||
import org.jetbrains.annotations.VisibleForTesting
|
||||
import org.signal.core.util.logging.Log
|
||||
import org.signal.libsignal.net.RequestResult
|
||||
import org.signal.libsignal.protocol.IdentityKey
|
||||
import org.signal.libsignal.protocol.InvalidKeyException
|
||||
import org.signal.libsignal.protocol.SessionBuilder
|
||||
import org.signal.libsignal.protocol.SignalProtocolAddress
|
||||
import org.signal.libsignal.protocol.UntrustedIdentityException
|
||||
import org.signal.libsignal.protocol.ecc.ECPublicKey
|
||||
import org.signal.libsignal.protocol.kem.KEMPublicKey
|
||||
import org.signal.libsignal.protocol.state.PreKeyBundle
|
||||
import org.signal.network.api.KeysApiV2
|
||||
import org.signal.network.api.MessageApiV2
|
||||
import org.whispersystems.signalservice.api.SignalServiceAccountDataStore
|
||||
import org.whispersystems.signalservice.api.SignalSessionLock
|
||||
import org.whispersystems.signalservice.api.crypto.EnvelopeContent
|
||||
import org.whispersystems.signalservice.api.crypto.SealedSenderAccess
|
||||
import org.whispersystems.signalservice.api.crypto.SignalServiceCipher
|
||||
import org.whispersystems.signalservice.api.crypto.SignalSessionBuilder
|
||||
import org.whispersystems.signalservice.api.push.SignalServiceAddress
|
||||
import org.whispersystems.signalservice.internal.push.OutgoingPushMessage
|
||||
import java.io.IOException
|
||||
import kotlin.time.Duration
|
||||
|
||||
/**
|
||||
* Sends an [EnvelopeContent] to a single recipient, driving the full one-to-one flow:
|
||||
* encrypt-per-device, send, recover mismatched / stale devices by fetching prekeys and rebuilding sessions.
|
||||
*
|
||||
* All server interaction is delegated to [MessageApiV2] and [KeysApiV2]. Encryption is delegated to
|
||||
* [cipher]. Session state is read from (and archived via) [protocolStore] under [sessionLock].
|
||||
*
|
||||
* Internal helpers return [Either] of [SendError] so orchestration is driven entirely by return
|
||||
* values rather than exceptions. Libsignal's checked exceptions (from `cipher.encrypt` and session
|
||||
* building) are caught at the single point they can be raised and `raise`d into the matching
|
||||
* [SendError] variant.
|
||||
*
|
||||
* Sync transcripts are the caller's responsibility — issue a second [sendMessage] to the local address
|
||||
* with a SyncMessage.Sent payload after a successful primary send.
|
||||
*/
|
||||
open class MessageService(
|
||||
private val localAddress: SignalServiceAddress,
|
||||
private val localDeviceId: Int,
|
||||
private val messageApi: MessageApiV2,
|
||||
private val keysApi: KeysApiV2,
|
||||
private val protocolStore: SignalServiceAccountDataStore,
|
||||
private val sessionLock: SignalSessionLock,
|
||||
private val cipher: SignalServiceCipher,
|
||||
private val maxContentSizeBytes: Long = 0L
|
||||
) {
|
||||
|
||||
companion object {
|
||||
private val TAG = Log.tag(MessageService::class)
|
||||
|
||||
private const val MAX_DEVICE_RECOVERY_ATTEMPTS = 3
|
||||
}
|
||||
|
||||
private val localProtocolAddress: SignalProtocolAddress = SignalProtocolAddress(localAddress.identifier, localDeviceId)
|
||||
|
||||
/**
|
||||
* Sends [envelopeContent] to [recipient]. Handles things like establishing sessions with newly-discovered linked devices.
|
||||
*/
|
||||
suspend fun sendMessage(
|
||||
recipient: SignalServiceAddress,
|
||||
envelopeContent: EnvelopeContent,
|
||||
timestamp: Long,
|
||||
sealedSenderAccess: SealedSenderAccess?,
|
||||
story: Boolean,
|
||||
isOnline: Boolean,
|
||||
urgent: Boolean = true,
|
||||
onEncrypted: (() -> Unit)? = null
|
||||
): Either<SendError, SendSuccess> = withContext(Dispatchers.IO) {
|
||||
either {
|
||||
val contentSize = envelopeContent.size().toLong()
|
||||
if (maxContentSizeBytes > 0 && contentSize > maxContentSizeBytes) {
|
||||
Log.w(TAG, "Content size $contentSize exceeds limit of $maxContentSizeBytes bytes; aborting send.")
|
||||
raise(SendError.ContentTooLarge(size = contentSize, maxAllowed = maxContentSizeBytes))
|
||||
}
|
||||
|
||||
var encryptedReported = false
|
||||
|
||||
// Certain errors self-resolve by mutating external state, like creating new sessions.
|
||||
// Trying several times in a loop lets us re-read that external state and use it in the next attempt.
|
||||
for (attempt in 0 until MAX_DEVICE_RECOVERY_ATTEMPTS) {
|
||||
val encrypted = encryptForAllDevices(recipient, envelopeContent, sealedSenderAccess)
|
||||
|
||||
if (!encryptedReported) {
|
||||
onEncrypted?.invoke()
|
||||
encryptedReported = true
|
||||
}
|
||||
|
||||
val request = MessageApiV2.SendMessageRequest(
|
||||
messages = encrypted.map { it.toWireMessage() },
|
||||
timestamp = timestamp,
|
||||
online = isOnline,
|
||||
urgent = urgent
|
||||
)
|
||||
|
||||
when (val result = messageApi.sendMessage(recipient.identifier, request, sealedSenderAccess, story)) {
|
||||
is RequestResult.Success -> {
|
||||
val response = result.result
|
||||
val devices = encrypted.map { it.destinationDeviceId }
|
||||
return@either SendSuccess(envelopeContent = envelopeContent, sentUnidentified = response.sentUnidentified, devices = devices)
|
||||
}
|
||||
is RequestResult.NonSuccess -> when (val err = result.error) {
|
||||
is MessageApiV2.SendMessageError.MismatchedDevicesError -> {
|
||||
handleMismatched(recipient, err.devices, sealedSenderAccess)
|
||||
}
|
||||
is MessageApiV2.SendMessageError.StaleDevicesError -> {
|
||||
for (deviceId in err.devices.staleDevices) {
|
||||
protocolStore.archiveSession(SignalProtocolAddress(recipient.identifier, deviceId))
|
||||
}
|
||||
}
|
||||
MessageApiV2.SendMessageError.Unauthorized -> raise(SendError.Unauthorized)
|
||||
MessageApiV2.SendMessageError.NotRegistered -> raise(SendError.NotRegistered)
|
||||
is MessageApiV2.SendMessageError.ChallengeRequired -> raise(SendError.ChallengeRequired(err.token, err.options, err.retryAfter))
|
||||
MessageApiV2.SendMessageError.ServerRejected -> raise(SendError.ServerRejected)
|
||||
is MessageApiV2.SendMessageError.RateLimited -> raise(SendError.RateLimited(err.retryAfter))
|
||||
}
|
||||
is RequestResult.RetryableNetworkError -> raise(SendError.NetworkError(result.networkError))
|
||||
is RequestResult.ApplicationError -> raise(SendError.ApplicationError(result.cause))
|
||||
}
|
||||
}
|
||||
|
||||
Log.w(TAG, "Exhausted device-recovery attempts for ${recipient.identifier}")
|
||||
raise(SendError.SessionAttemptsExhausted)
|
||||
}
|
||||
}
|
||||
|
||||
private fun Raise<SendError>.encryptForAllDevices(
|
||||
recipient: SignalServiceAddress,
|
||||
envelopeContent: EnvelopeContent,
|
||||
sealedSenderAccess: SealedSenderAccess?
|
||||
): List<OutgoingPushMessage> {
|
||||
return targetDeviceIds(recipient).map { deviceId ->
|
||||
val address = SignalProtocolAddress(recipient.identifier, deviceId)
|
||||
encryptContent(recipient, address, envelopeContent, sealedSenderAccess)
|
||||
}
|
||||
}
|
||||
|
||||
private fun Raise<SendError>.encryptContent(
|
||||
recipient: SignalServiceAddress,
|
||||
address: SignalProtocolAddress,
|
||||
envelopeContent: EnvelopeContent,
|
||||
sealedSenderAccess: SealedSenderAccess?
|
||||
): OutgoingPushMessage = try {
|
||||
cipher.encrypt(address, sealedSenderAccess, envelopeContent)
|
||||
} catch (e: UntrustedIdentityException) {
|
||||
raise(SendError.IdentityMismatch(recipient, e))
|
||||
} catch (e: InvalidKeyException) {
|
||||
raise(SendError.ApplicationError(e))
|
||||
}
|
||||
|
||||
private fun targetDeviceIds(recipient: SignalServiceAddress): List<Int> {
|
||||
val subDevices: MutableSet<Int> = (protocolStore.getSubDeviceSessions(recipient.identifier) + SignalServiceAddress.DEFAULT_DEVICE_ID).toMutableSet()
|
||||
|
||||
// When sending to self, skip our own device.
|
||||
if (recipient.matches(localAddress)) {
|
||||
subDevices -= localDeviceId
|
||||
}
|
||||
|
||||
return subDevices
|
||||
.filter { it == SignalServiceAddress.DEFAULT_DEVICE_ID || protocolStore.containsSession(SignalProtocolAddress(recipient.identifier, it)) }
|
||||
.toList()
|
||||
}
|
||||
|
||||
/**
|
||||
* Initialize a session with the target address, which requires fetching a prekey bundle.
|
||||
*/
|
||||
@VisibleForTesting
|
||||
internal open suspend fun Raise<SendError>.initializeSession(
|
||||
recipient: SignalServiceAddress,
|
||||
address: SignalProtocolAddress,
|
||||
sealedSenderAccess: SealedSenderAccess?
|
||||
) {
|
||||
val response = when (val result = keysApi.getPreKey(address.serviceId.toServiceIdString(), address.deviceId, sealedSenderAccess)) {
|
||||
is RequestResult.Success -> result.result
|
||||
is RequestResult.NonSuccess -> {
|
||||
when (val e = result.error) {
|
||||
KeysApiV2.GetPreKeysError.Unauthorized -> raise(SendError.Unauthorized)
|
||||
KeysApiV2.GetPreKeysError.NotFound -> raise(SendError.PreKeyUnavailable("No prekeys found for $address"))
|
||||
is KeysApiV2.GetPreKeysError.RateLimited -> raise(SendError.RateLimited(e.retryAfter))
|
||||
}
|
||||
}
|
||||
is RequestResult.RetryableNetworkError -> raise(SendError.NetworkError(result.networkError))
|
||||
is RequestResult.ApplicationError -> raise(SendError.ApplicationError(result.cause))
|
||||
}
|
||||
|
||||
val item = response.devices.firstOrNull { it.deviceId == address.deviceId }
|
||||
?: raise(SendError.PreKeyUnavailable("No prekey for $address"))
|
||||
|
||||
val bundle = buildPreKeyBundle(response.identityKey, item, address)
|
||||
|
||||
try {
|
||||
SignalSessionBuilder(sessionLock, SessionBuilder(protocolStore, address, localProtocolAddress)).process(bundle)
|
||||
} catch (e: UntrustedIdentityException) {
|
||||
raise(SendError.IdentityMismatch(recipient, e))
|
||||
} catch (e: InvalidKeyException) {
|
||||
raise(SendError.ApplicationError(e))
|
||||
}
|
||||
}
|
||||
|
||||
private suspend fun Raise<SendError>.handleMismatched(
|
||||
recipient: SignalServiceAddress,
|
||||
mismatched: MessageApiV2.MismatchedDevices,
|
||||
sealedSenderAccess: SealedSenderAccess?
|
||||
) {
|
||||
for (extra in mismatched.extraDevices) {
|
||||
protocolStore.archiveSession(SignalProtocolAddress(recipient.identifier, extra))
|
||||
}
|
||||
|
||||
for (missing in mismatched.missingDevices) {
|
||||
val address = SignalProtocolAddress(recipient.identifier, missing)
|
||||
initializeSession(recipient, address, sealedSenderAccess)
|
||||
}
|
||||
}
|
||||
|
||||
private fun OutgoingPushMessage.toWireMessage(): MessageApiV2.Message = MessageApiV2.Message(
|
||||
type = type,
|
||||
destinationDeviceId = destinationDeviceId,
|
||||
destinationRegistrationId = destinationRegistrationId,
|
||||
content = content
|
||||
)
|
||||
|
||||
private fun Raise<SendError>.buildPreKeyBundle(
|
||||
identityKey: ByteArray,
|
||||
item: KeysApiV2.PreKeyResponseItem,
|
||||
address: SignalProtocolAddress
|
||||
): PreKeyBundle {
|
||||
val signedPreKey = item.signedPreKey ?: raise(SendError.PreKeyUnavailable("No signed prekey for $address"))
|
||||
val kyberPreKey = item.pqPreKey ?: raise(SendError.PreKeyUnavailable("No kyber prekey for $address"))
|
||||
|
||||
return try {
|
||||
PreKeyBundle(
|
||||
item.registrationId,
|
||||
item.deviceId,
|
||||
item.preKey?.keyId?.toInt() ?: PreKeyBundle.NULL_PRE_KEY_ID,
|
||||
item.preKey?.let { ECPublicKey(it.publicKey) },
|
||||
signedPreKey.keyId.toInt(),
|
||||
ECPublicKey(signedPreKey.publicKey),
|
||||
signedPreKey.signature,
|
||||
IdentityKey(identityKey),
|
||||
kyberPreKey.keyId.toInt(),
|
||||
KEMPublicKey(kyberPreKey.publicKey, 0, kyberPreKey.publicKey.size),
|
||||
kyberPreKey.signature
|
||||
)
|
||||
} catch (e: InvalidKeyException) {
|
||||
raise(SendError.ApplicationError(e))
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Send completed successfully.
|
||||
*
|
||||
* [devices] is the set of recipient devices the encrypted payload was delivered to. Callers persisting
|
||||
* a [org.thoughtcrime.securesms.database.MessageSendLogTables] entry (or a pending PNI signature record)
|
||||
* need this to know which sessions the recipient may later reference in a retry receipt.
|
||||
*/
|
||||
data class SendSuccess(
|
||||
val envelopeContent: EnvelopeContent,
|
||||
val sentUnidentified: Boolean,
|
||||
val devices: List<Int>
|
||||
)
|
||||
|
||||
sealed interface SendError {
|
||||
/** You discovered a safety number change during sending. */
|
||||
data class IdentityMismatch(val recipient: SignalServiceAddress, val cause: UntrustedIdentityException) : SendError
|
||||
|
||||
/** The recipient is no longer registered. */
|
||||
data object NotRegistered : SendError
|
||||
|
||||
/** Invalid credentials. You are likely no longer registered. */
|
||||
data object Unauthorized : SendError
|
||||
|
||||
/**
|
||||
* The server wants you to complete a push challenge/captcha before continuing.
|
||||
* [token] is the challenge token; [options] enumerates the supported challenge mechanisms
|
||||
* (e.g. "captcha", "pushChallenge"). [retryAfter] is the Retry-After hint, if provided.
|
||||
*/
|
||||
data class ChallengeRequired(val token: String, val options: List<String>, val retryAfter: Duration?) : SendError
|
||||
|
||||
/** The server has fully rejected your request. This usually only happens during times of turmoil. Fail and require user action to resend. */
|
||||
data object ServerRejected : SendError
|
||||
|
||||
/**
|
||||
* The encoded content exceeded the configured size cap. Permanent failure for this message —
|
||||
* retrying with the same content won't help.
|
||||
*/
|
||||
data class ContentTooLarge(val size: Long, val maxAllowed: Long) : SendError
|
||||
|
||||
/**
|
||||
* Each send attempt may result in us having to establish sessions with linked devices and such. This indicates that we hit our max attempt count while
|
||||
* trying to handle these situations. It should be safe to retry with normal backoff.
|
||||
*/
|
||||
data object SessionAttemptsExhausted : SendError
|
||||
|
||||
/** We needed to establish a session, but the server was missing either a signed or kyber prekey for the user. */
|
||||
data class PreKeyUnavailable(val reason: String) : SendError
|
||||
|
||||
/** You're rate-limited. Use the [retryAfter] for your backoff. */
|
||||
data class RateLimited(val retryAfter: Duration?) : SendError
|
||||
|
||||
/** A generic, retryable network error. */
|
||||
data class NetworkError(val cause: IOException) : SendError
|
||||
|
||||
/** An unexpected error. You should likely crash. */
|
||||
data class ApplicationError(val cause: Throwable) : SendError
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,196 @@
|
||||
/*
|
||||
* Copyright 2026 Signal Messenger, LLC
|
||||
* SPDX-License-Identifier: AGPL-3.0-only
|
||||
*/
|
||||
|
||||
package org.signal.network.api
|
||||
|
||||
import assertk.assertThat
|
||||
import assertk.assertions.isEqualTo
|
||||
import assertk.assertions.isInstanceOf
|
||||
import assertk.assertions.isSameInstanceAs
|
||||
import io.mockk.coEvery
|
||||
import io.mockk.every
|
||||
import io.mockk.mockk
|
||||
import kotlinx.coroutines.test.runTest
|
||||
import org.junit.Test
|
||||
import org.signal.libsignal.net.RequestResult
|
||||
import org.signal.network.websocket.WebSocketRequestMessage
|
||||
import org.signal.network.websocket.WebsocketResponse
|
||||
import org.whispersystems.signalservice.api.crypto.SealedSenderAccess
|
||||
import org.whispersystems.signalservice.api.websocket.SignalWebSocket
|
||||
import java.io.IOException
|
||||
import kotlin.time.Duration.Companion.seconds
|
||||
|
||||
class MessageApiV2Test {
|
||||
|
||||
private val authSocket: SignalWebSocket.AuthenticatedWebSocket = mockk()
|
||||
private val unauthSocket: SignalWebSocket.UnauthenticatedWebSocket = mockk()
|
||||
private val api = MessageApiV2(authSocket, unauthSocket)
|
||||
|
||||
private val request = MessageApiV2.SendMessageRequest(
|
||||
messages = listOf(MessageApiV2.Message(type = 1, destinationDeviceId = 1, destinationRegistrationId = 42, content = "abc")),
|
||||
timestamp = 1_700_000_000L
|
||||
)
|
||||
|
||||
@Test
|
||||
fun `200 parses SendMessageResponse and flags sentUnidentified from response`() = runTest {
|
||||
stubAuth(status = 200, body = """{"needsSync": true}""", unidentified = true)
|
||||
|
||||
val result = api.sendMessage("destination-id", request, sealedSenderAccess = null, story = false)
|
||||
|
||||
assertThat(result).isInstanceOf(RequestResult.Success::class)
|
||||
val success = result as RequestResult.Success<MessageApiV2.SendMessageResponse>
|
||||
assertThat(success.result.needsSync).isEqualTo(true)
|
||||
assertThat(success.result.sentUnidentified).isEqualTo(true)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `401 maps to Unauthorized`() = runTest {
|
||||
stubAuth(status = 401)
|
||||
|
||||
val result = api.sendMessage("destination-id", request, sealedSenderAccess = null, story = false)
|
||||
|
||||
assertNonSuccess(result, MessageApiV2.SendMessageError.Unauthorized)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `404 maps to NotRegistered`() = runTest {
|
||||
stubAuth(status = 404)
|
||||
|
||||
val result = api.sendMessage("destination-id", request, sealedSenderAccess = null, story = false)
|
||||
|
||||
assertNonSuccess(result, MessageApiV2.SendMessageError.NotRegistered)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `409 parses MismatchedDevices body`() = runTest {
|
||||
stubAuth(status = 409, body = """{"missingDevices": [2, 3], "extraDevices": [5]}""")
|
||||
|
||||
val result = api.sendMessage("destination-id", request, sealedSenderAccess = null, story = false)
|
||||
|
||||
val nonSuccess = result as RequestResult.NonSuccess
|
||||
val err = nonSuccess.error as MessageApiV2.SendMessageError.MismatchedDevicesError
|
||||
assertThat(err.devices.missingDevices).isEqualTo(listOf(2, 3))
|
||||
assertThat(err.devices.extraDevices).isEqualTo(listOf(5))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `410 parses StaleDevices body`() = runTest {
|
||||
stubAuth(status = 410, body = """{"staleDevices": [2]}""")
|
||||
|
||||
val result = api.sendMessage("destination-id", request, sealedSenderAccess = null, story = false)
|
||||
|
||||
val nonSuccess = result as RequestResult.NonSuccess
|
||||
val err = nonSuccess.error as MessageApiV2.SendMessageError.StaleDevicesError
|
||||
assertThat(err.devices.staleDevices).isEqualTo(listOf(2))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `428 parses ProofRequired body and Retry-After header`() = runTest {
|
||||
val response: WebsocketResponse = mockk {
|
||||
every { status } returns 428
|
||||
every { body } returns """{"token": "abc123", "options": ["captcha", "pushChallenge"]}"""
|
||||
every { isUnidentified } returns false
|
||||
every { getHeader("retry-after") } returns "120"
|
||||
}
|
||||
coEvery { authSocket.requestSuspend(any<WebSocketRequestMessage>()) } returns response
|
||||
|
||||
val result = api.sendMessage("destination-id", request, sealedSenderAccess = null, story = false)
|
||||
|
||||
val err = (result as RequestResult.NonSuccess).error as MessageApiV2.SendMessageError.ChallengeRequired
|
||||
assertThat(err.token).isEqualTo("abc123")
|
||||
assertThat(err.options).isEqualTo(listOf("captcha", "pushChallenge"))
|
||||
assertThat(err.retryAfter).isEqualTo(120.seconds)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `429 with retry-after header maps to RateLimited with Duration`() = runTest {
|
||||
val response: WebsocketResponse = mockk {
|
||||
every { status } returns 429
|
||||
every { body } returns "{}"
|
||||
every { isUnidentified } returns false
|
||||
every { getHeader("retry-after") } returns "42"
|
||||
}
|
||||
coEvery { authSocket.requestSuspend(any<WebSocketRequestMessage>()) } returns response
|
||||
|
||||
val result = api.sendMessage("destination-id", request, sealedSenderAccess = null, story = false)
|
||||
|
||||
val err = (result as RequestResult.NonSuccess).error as MessageApiV2.SendMessageError.RateLimited
|
||||
assertThat(err.retryAfter).isEqualTo(42.seconds)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `429 without retry-after header maps to RateLimited with null Duration`() = runTest {
|
||||
val response: WebsocketResponse = mockk {
|
||||
every { status } returns 429
|
||||
every { body } returns "{}"
|
||||
every { isUnidentified } returns false
|
||||
every { getHeader("retry-after") } returns null
|
||||
}
|
||||
coEvery { authSocket.requestSuspend(any<WebSocketRequestMessage>()) } returns response
|
||||
|
||||
val result = api.sendMessage("destination-id", request, sealedSenderAccess = null, story = false)
|
||||
|
||||
val err = (result as RequestResult.NonSuccess).error as MessageApiV2.SendMessageError.RateLimited
|
||||
assertThat(err.retryAfter).isEqualTo(null)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `508 maps to ServerRejected`() = runTest {
|
||||
stubAuth(status = 508)
|
||||
|
||||
val result = api.sendMessage("destination-id", request, sealedSenderAccess = null, story = false)
|
||||
|
||||
assertNonSuccess(result, MessageApiV2.SendMessageError.ServerRejected)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `unexpected status maps to ApplicationError`() = runTest {
|
||||
stubAuth(status = 418)
|
||||
|
||||
val result = api.sendMessage("destination-id", request, sealedSenderAccess = null, story = false)
|
||||
|
||||
assertThat(result).isInstanceOf(RequestResult.ApplicationError::class)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `IOException from socket becomes RetryableNetworkError`() = runTest {
|
||||
val ioError = IOException("socket closed")
|
||||
coEvery { authSocket.requestSuspend(any<WebSocketRequestMessage>()) } throws ioError
|
||||
|
||||
val result = api.sendMessage("destination-id", request, sealedSenderAccess = null, story = false)
|
||||
|
||||
val retry = result as RequestResult.RetryableNetworkError
|
||||
assertThat(retry.networkError).isSameInstanceAs(ioError)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `sealedSenderAccess routes to unauthenticated socket`() = runTest {
|
||||
val sealed: SealedSenderAccess = mockk()
|
||||
val response: WebsocketResponse = mockk {
|
||||
every { status } returns 200
|
||||
every { body } returns """{"needsSync": false}"""
|
||||
every { isUnidentified } returns true
|
||||
}
|
||||
coEvery { unauthSocket.requestSuspend(any(), sealed) } returns response
|
||||
|
||||
val result = api.sendMessage("destination-id", request, sealedSenderAccess = sealed, story = false)
|
||||
|
||||
assertThat(result).isInstanceOf(RequestResult.Success::class)
|
||||
}
|
||||
|
||||
private fun stubAuth(status: Int, body: String = "{}", unidentified: Boolean = false) {
|
||||
val response: WebsocketResponse = mockk {
|
||||
every { this@mockk.status } returns status
|
||||
every { this@mockk.body } returns body
|
||||
every { isUnidentified } returns unidentified
|
||||
}
|
||||
coEvery { authSocket.requestSuspend(any<WebSocketRequestMessage>()) } returns response
|
||||
}
|
||||
|
||||
private fun assertNonSuccess(result: RequestResult<*, *>, expected: MessageApiV2.SendMessageError) {
|
||||
val nonSuccess = result as RequestResult.NonSuccess
|
||||
assertThat(nonSuccess.error).isEqualTo(expected)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,318 @@
|
||||
/*
|
||||
* Copyright 2026 Signal Messenger, LLC
|
||||
* SPDX-License-Identifier: AGPL-3.0-only
|
||||
*/
|
||||
|
||||
package org.signal.network.service
|
||||
|
||||
import arrow.core.Either
|
||||
import arrow.core.raise.Raise
|
||||
import assertk.assertThat
|
||||
import assertk.assertions.isEqualTo
|
||||
import assertk.assertions.isInstanceOf
|
||||
import io.mockk.coEvery
|
||||
import io.mockk.coVerify
|
||||
import io.mockk.every
|
||||
import io.mockk.mockk
|
||||
import io.mockk.spyk
|
||||
import io.mockk.verify
|
||||
import kotlinx.coroutines.test.runTest
|
||||
import org.junit.Test
|
||||
import org.signal.core.models.ServiceId
|
||||
import org.signal.libsignal.net.RequestResult
|
||||
import org.signal.libsignal.protocol.SignalProtocolAddress
|
||||
import org.signal.libsignal.protocol.UntrustedIdentityException
|
||||
import org.signal.network.api.KeysApiV2
|
||||
import org.signal.network.api.MessageApiV2
|
||||
import org.whispersystems.signalservice.api.SignalServiceAccountDataStore
|
||||
import org.whispersystems.signalservice.api.SignalSessionLock
|
||||
import org.whispersystems.signalservice.api.crypto.EnvelopeContent
|
||||
import org.whispersystems.signalservice.api.crypto.SealedSenderAccess
|
||||
import org.whispersystems.signalservice.api.crypto.SignalServiceCipher
|
||||
import org.whispersystems.signalservice.api.push.SignalServiceAddress
|
||||
import org.whispersystems.signalservice.internal.push.OutgoingPushMessage
|
||||
import java.io.IOException
|
||||
import java.util.UUID
|
||||
import kotlin.time.Duration.Companion.seconds
|
||||
|
||||
class MessageServiceTest {
|
||||
|
||||
private val messageApi: MessageApiV2 = mockk()
|
||||
private val keysApi: KeysApiV2 = mockk()
|
||||
private val protocolStore: SignalServiceAccountDataStore = mockk(relaxUnitFun = true)
|
||||
private val sessionLock: SignalSessionLock = mockk()
|
||||
private val cipher: SignalServiceCipher = mockk()
|
||||
|
||||
private val localAci = ServiceId.ACI.from(UUID.fromString("aaaaaaaa-0000-0000-0000-000000000001"))
|
||||
private val localAddress = SignalServiceAddress(localAci)
|
||||
|
||||
private val recipientAci = ServiceId.ACI.from(UUID.fromString("bbbbbbbb-0000-0000-0000-000000000002"))
|
||||
private val recipient = SignalServiceAddress(recipientAci)
|
||||
|
||||
private val timestamp = 1_700_000_000L
|
||||
private val envelopeContent: EnvelopeContent = mockk {
|
||||
every { size() } returns 0
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `happy path with existing session returns Success`() = runTest {
|
||||
val service = newService()
|
||||
every { protocolStore.getSubDeviceSessions(recipient.identifier) } returns emptyList()
|
||||
every { protocolStore.containsSession(SignalProtocolAddress(recipient.identifier, 1)) } returns true
|
||||
every { cipher.encrypt(any(), any(), any()) } returns OutgoingPushMessage(1, 1, 100, "payload")
|
||||
|
||||
coEvery { messageApi.sendMessage(recipient.identifier, any(), null, false) } returns
|
||||
RequestResult.Success(MessageApiV2.SendMessageResponse(sentUnidentified = true))
|
||||
|
||||
val result = service.sendMessage(recipient, envelopeContent, timestamp, sealedSenderAccess = null, story = false, isOnline = false)
|
||||
|
||||
val success = (result as Either.Right).value
|
||||
assertThat(success.sentUnidentified).isEqualTo(true)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `isOnline true is forwarded to the send request`() = runTest {
|
||||
val service = newService()
|
||||
every { protocolStore.getSubDeviceSessions(recipient.identifier) } returns emptyList()
|
||||
every { protocolStore.containsSession(SignalProtocolAddress(recipient.identifier, 1)) } returns true
|
||||
every { cipher.encrypt(any(), any(), any()) } returns OutgoingPushMessage(1, 1, 100, "payload")
|
||||
coEvery { messageApi.sendMessage(any(), any(), any(), any()) } returns
|
||||
RequestResult.Success(MessageApiV2.SendMessageResponse())
|
||||
|
||||
service.sendMessage(recipient, envelopeContent, timestamp, sealedSenderAccess = null, story = false, isOnline = true)
|
||||
|
||||
coVerify {
|
||||
messageApi.sendMessage(
|
||||
recipient.identifier,
|
||||
match<MessageApiV2.SendMessageRequest> { it.online },
|
||||
null,
|
||||
false
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `sub-device without session is excluded from target devices`() = runTest {
|
||||
val service = newService()
|
||||
every { protocolStore.getSubDeviceSessions(recipient.identifier) } returns listOf(2, 3)
|
||||
every { protocolStore.containsSession(SignalProtocolAddress(recipient.identifier, 2)) } returns true
|
||||
every { protocolStore.containsSession(SignalProtocolAddress(recipient.identifier, 3)) } returns false
|
||||
every { cipher.encrypt(any(), any(), any()) } returns OutgoingPushMessage(1, 1, 100, "payload")
|
||||
|
||||
coEvery { messageApi.sendMessage(recipient.identifier, any(), null, false) } returns
|
||||
RequestResult.Success(MessageApiV2.SendMessageResponse())
|
||||
|
||||
val result = service.sendMessage(recipient, envelopeContent, timestamp, sealedSenderAccess = null, story = false, isOnline = false)
|
||||
|
||||
assertThat(result).isInstanceOf(Either.Right::class)
|
||||
verify { cipher.encrypt(SignalProtocolAddress(recipient.identifier, 1), any(), any()) }
|
||||
verify { cipher.encrypt(SignalProtocolAddress(recipient.identifier, 2), any(), any()) }
|
||||
verify(exactly = 0) { cipher.encrypt(SignalProtocolAddress(recipient.identifier, 3), any(), any()) }
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `409 MismatchedDevices archives extras, fetches missing prekeys, and retries`() = runTest {
|
||||
val service = newService()
|
||||
every { protocolStore.getSubDeviceSessions(recipient.identifier) } returns emptyList()
|
||||
every { protocolStore.containsSession(any()) } returns true
|
||||
every { cipher.encrypt(any(), any(), any()) } returns OutgoingPushMessage(1, 1, 100, "payload")
|
||||
|
||||
val mismatched = MessageApiV2.MismatchedDevices(missingDevices = listOf(2), extraDevices = listOf(5))
|
||||
coEvery { messageApi.sendMessage(recipient.identifier, any(), null, false) } returnsMany listOf(
|
||||
RequestResult.NonSuccess(MessageApiV2.SendMessageError.MismatchedDevicesError(mismatched)),
|
||||
RequestResult.Success(MessageApiV2.SendMessageResponse())
|
||||
)
|
||||
coEvery { keysApi.getPreKey(recipient.identifier, 2, null) } returns
|
||||
RequestResult.Success(KeysApiV2.PreKeyResponse(identityKey = ByteArray(0), devices = emptyList()))
|
||||
|
||||
val result = service.sendMessage(recipient, envelopeContent, timestamp, sealedSenderAccess = null, story = false, isOnline = false)
|
||||
|
||||
assertThat(result).isInstanceOf(Either.Right::class)
|
||||
verify { protocolStore.archiveSession(SignalProtocolAddress(recipient.identifier, 5)) }
|
||||
coVerify { keysApi.getPreKey(recipient.identifier, 2, null) }
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `410 StaleDevices archives stales and retries`() = runTest {
|
||||
val service = newService()
|
||||
every { protocolStore.getSubDeviceSessions(recipient.identifier) } returns emptyList()
|
||||
every { protocolStore.containsSession(any()) } returns true
|
||||
every { cipher.encrypt(any(), any(), any()) } returns OutgoingPushMessage(1, 1, 100, "payload")
|
||||
|
||||
val stale = MessageApiV2.StaleDevices(staleDevices = listOf(3))
|
||||
coEvery { messageApi.sendMessage(recipient.identifier, any(), null, false) } returnsMany listOf(
|
||||
RequestResult.NonSuccess(MessageApiV2.SendMessageError.StaleDevicesError(stale)),
|
||||
RequestResult.Success(MessageApiV2.SendMessageResponse())
|
||||
)
|
||||
|
||||
val result = service.sendMessage(recipient, envelopeContent, timestamp, sealedSenderAccess = null, story = false, isOnline = false)
|
||||
|
||||
assertThat(result).isInstanceOf(Either.Right::class)
|
||||
verify { protocolStore.archiveSession(SignalProtocolAddress(recipient.identifier, 3)) }
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `repeated device conflicts exhaust retries`() = runTest {
|
||||
val service = newService()
|
||||
every { protocolStore.getSubDeviceSessions(recipient.identifier) } returns emptyList()
|
||||
every { protocolStore.containsSession(any()) } returns true
|
||||
every { cipher.encrypt(any(), any(), any()) } returns OutgoingPushMessage(1, 1, 100, "payload")
|
||||
|
||||
val stale = MessageApiV2.StaleDevices(staleDevices = listOf(4))
|
||||
coEvery { messageApi.sendMessage(recipient.identifier, any(), null, false) } returns
|
||||
RequestResult.NonSuccess(MessageApiV2.SendMessageError.StaleDevicesError(stale))
|
||||
|
||||
val result = service.sendMessage(recipient, envelopeContent, timestamp, sealedSenderAccess = null, story = false, isOnline = false)
|
||||
|
||||
assertThat(result).isEqualTo(Either.Left(MessageService.SendError.SessionAttemptsExhausted))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `401 maps to Unauthorized`() = runTest {
|
||||
val service = newService()
|
||||
every { protocolStore.getSubDeviceSessions(recipient.identifier) } returns emptyList()
|
||||
every { protocolStore.containsSession(any()) } returns true
|
||||
every { cipher.encrypt(any(), any(), any()) } returns OutgoingPushMessage(1, 1, 100, "payload")
|
||||
coEvery { messageApi.sendMessage(any(), any(), any(), any()) } returns
|
||||
RequestResult.NonSuccess(MessageApiV2.SendMessageError.Unauthorized)
|
||||
|
||||
val result = service.sendMessage(recipient, envelopeContent, timestamp, sealedSenderAccess = null, story = false, isOnline = false)
|
||||
|
||||
assertThat(result).isEqualTo(Either.Left(MessageService.SendError.Unauthorized))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `404 maps to NotRegistered`() = runTest {
|
||||
val service = newService()
|
||||
every { protocolStore.getSubDeviceSessions(recipient.identifier) } returns emptyList()
|
||||
every { protocolStore.containsSession(any()) } returns true
|
||||
every { cipher.encrypt(any(), any(), any()) } returns OutgoingPushMessage(1, 1, 100, "payload")
|
||||
coEvery { messageApi.sendMessage(any(), any(), any(), any()) } returns
|
||||
RequestResult.NonSuccess(MessageApiV2.SendMessageError.NotRegistered)
|
||||
|
||||
val result = service.sendMessage(recipient, envelopeContent, timestamp, sealedSenderAccess = null, story = false, isOnline = false)
|
||||
|
||||
assertThat(result).isEqualTo(Either.Left(MessageService.SendError.NotRegistered))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `send 429 propagates retry-after duration via SendResult RateLimited`() = runTest {
|
||||
val service = newService()
|
||||
every { protocolStore.getSubDeviceSessions(recipient.identifier) } returns emptyList()
|
||||
every { protocolStore.containsSession(any()) } returns true
|
||||
every { cipher.encrypt(any(), any(), any()) } returns OutgoingPushMessage(1, 1, 100, "payload")
|
||||
coEvery { messageApi.sendMessage(any(), any(), any(), any()) } returns
|
||||
RequestResult.NonSuccess(MessageApiV2.SendMessageError.RateLimited(retryAfter = 30.seconds))
|
||||
|
||||
val result = service.sendMessage(recipient, envelopeContent, timestamp, sealedSenderAccess = null, story = false, isOnline = false)
|
||||
|
||||
assertThat(result).isEqualTo(Either.Left(MessageService.SendError.RateLimited(retryAfter = 30.seconds)))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `prekey 429 during mismatched-device recovery propagates retry-after as RateLimited`() = runTest {
|
||||
val service = newService()
|
||||
every { protocolStore.getSubDeviceSessions(recipient.identifier) } returns emptyList()
|
||||
every { protocolStore.containsSession(any()) } returns true
|
||||
every { cipher.encrypt(any(), any(), any()) } returns OutgoingPushMessage(1, 1, 100, "payload")
|
||||
|
||||
val mismatched = MessageApiV2.MismatchedDevices(missingDevices = listOf(2), extraDevices = emptyList())
|
||||
coEvery { messageApi.sendMessage(recipient.identifier, any(), null, false) } returns
|
||||
RequestResult.NonSuccess(MessageApiV2.SendMessageError.MismatchedDevicesError(mismatched))
|
||||
coEvery { keysApi.getPreKey(recipient.identifier, 2, null) } returns
|
||||
RequestResult.NonSuccess(KeysApiV2.GetPreKeysError.RateLimited(retryAfter = 60.seconds))
|
||||
|
||||
val result = service.sendMessage(recipient, envelopeContent, timestamp, sealedSenderAccess = null, story = false, isOnline = false)
|
||||
|
||||
assertThat(result).isEqualTo(Either.Left(MessageService.SendError.RateLimited(retryAfter = 60.seconds)))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `IOException from send maps to NetworkError`() = runTest {
|
||||
val service = newService()
|
||||
every { protocolStore.getSubDeviceSessions(recipient.identifier) } returns emptyList()
|
||||
every { protocolStore.containsSession(any()) } returns true
|
||||
every { cipher.encrypt(any(), any(), any()) } returns OutgoingPushMessage(1, 1, 100, "payload")
|
||||
val ioError = IOException("down")
|
||||
coEvery { messageApi.sendMessage(any(), any(), any(), any()) } returns RequestResult.RetryableNetworkError(ioError)
|
||||
|
||||
val result = service.sendMessage(recipient, envelopeContent, timestamp, sealedSenderAccess = null, story = false, isOnline = false)
|
||||
|
||||
val network = (result as Either.Left).value as MessageService.SendError.NetworkError
|
||||
assertThat(network.cause).isEqualTo(ioError)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `UntrustedIdentityException during encryption maps to IdentityMismatch`() = runTest {
|
||||
val service = newService()
|
||||
every { protocolStore.getSubDeviceSessions(recipient.identifier) } returns emptyList()
|
||||
every { protocolStore.containsSession(any()) } returns true
|
||||
val untrusted = UntrustedIdentityException(recipient.identifier)
|
||||
every { cipher.encrypt(any(), any(), any()) } throws untrusted
|
||||
|
||||
val result = service.sendMessage(recipient, envelopeContent, timestamp, sealedSenderAccess = null, story = false, isOnline = false)
|
||||
|
||||
val mismatch = (result as Either.Left).value as MessageService.SendError.IdentityMismatch
|
||||
assertThat(mismatch.cause).isEqualTo(untrusted)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `prekey fetch 404 during mismatched-device recovery propagates as PreKeyUnavailable`() = runTest {
|
||||
val service = newService()
|
||||
every { protocolStore.getSubDeviceSessions(recipient.identifier) } returns emptyList()
|
||||
every { protocolStore.containsSession(any()) } returns true
|
||||
every { cipher.encrypt(any(), any(), any()) } returns OutgoingPushMessage(1, 1, 100, "payload")
|
||||
|
||||
val mismatched = MessageApiV2.MismatchedDevices(missingDevices = listOf(2), extraDevices = emptyList())
|
||||
coEvery { messageApi.sendMessage(recipient.identifier, any(), null, false) } returns
|
||||
RequestResult.NonSuccess(MessageApiV2.SendMessageError.MismatchedDevicesError(mismatched))
|
||||
coEvery { keysApi.getPreKey(recipient.identifier, 2, null) } returns
|
||||
RequestResult.NonSuccess(KeysApiV2.GetPreKeysError.NotFound)
|
||||
|
||||
val result = service.sendMessage(recipient, envelopeContent, timestamp, sealedSenderAccess = null, story = false, isOnline = false)
|
||||
|
||||
val left = (result as Either.Left).value
|
||||
assertThat(left).isInstanceOf(MessageService.SendError.PreKeyUnavailable::class)
|
||||
}
|
||||
|
||||
/**
|
||||
* Spy with `initializeSession` stubbed so tests don't exercise real crypto / native session building.
|
||||
* The stub still invokes [KeysApiV2.getPreKey] and forwards non-success [RequestResult]s as the real
|
||||
* implementation would; happy path is a no-op.
|
||||
*/
|
||||
private fun newService(): MessageService {
|
||||
val spy: MessageService = spyk(
|
||||
MessageService(
|
||||
localAddress = localAddress,
|
||||
localDeviceId = 1,
|
||||
messageApi = messageApi,
|
||||
keysApi = keysApi,
|
||||
protocolStore = protocolStore,
|
||||
sessionLock = sessionLock,
|
||||
cipher = cipher
|
||||
)
|
||||
)
|
||||
coEvery {
|
||||
with(spy) {
|
||||
any<Raise<MessageService.SendError>>().initializeSession(any(), any(), any())
|
||||
}
|
||||
} coAnswers {
|
||||
val raiseArg = arg<Raise<MessageService.SendError>>(0)
|
||||
val addressArg = arg<SignalProtocolAddress>(2)
|
||||
val sealedArg = arg<SealedSenderAccess?>(3)
|
||||
when (val r = keysApi.getPreKey(addressArg.name, addressArg.deviceId, sealedArg)) {
|
||||
is RequestResult.Success -> Unit
|
||||
is RequestResult.NonSuccess -> raiseArg.raise(
|
||||
when (val e = r.error) {
|
||||
KeysApiV2.GetPreKeysError.Unauthorized -> MessageService.SendError.Unauthorized
|
||||
KeysApiV2.GetPreKeysError.NotFound -> MessageService.SendError.PreKeyUnavailable("No prekeys found for $addressArg")
|
||||
is KeysApiV2.GetPreKeysError.RateLimited -> MessageService.SendError.RateLimited(e.retryAfter)
|
||||
}
|
||||
)
|
||||
is RequestResult.RetryableNetworkError -> raiseArg.raise(MessageService.SendError.NetworkError(r.networkError))
|
||||
is RequestResult.ApplicationError -> raiseArg.raise(MessageService.SendError.ApplicationError(r.cause))
|
||||
}
|
||||
}
|
||||
return spy
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user