Add MessageService and IndividualSendJobV2.

This commit is contained in:
Greyson Parrelli
2026-05-27 14:01:28 -04:00
committed by Michelle Tang
parent 0284da2d0f
commit f206487ede
33 changed files with 2506 additions and 43 deletions
@@ -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
}
}