@file:Suppress("NAME_SHADOWING") package org.session.libsession.snode import android.os.Build import com.goterl.lazysodium.LazySodiumAndroid import com.goterl.lazysodium.SodiumAndroid import com.goterl.lazysodium.exceptions.SodiumException import com.goterl.lazysodium.interfaces.GenericHash import com.goterl.lazysodium.interfaces.PwHash import com.goterl.lazysodium.interfaces.SecretBox import com.goterl.lazysodium.interfaces.Sign import com.goterl.lazysodium.utils.Key import nl.komponents.kovenant.Promise import nl.komponents.kovenant.all import nl.komponents.kovenant.deferred import nl.komponents.kovenant.functional.bind import nl.komponents.kovenant.functional.map import nl.komponents.kovenant.task import org.session.libsession.messaging.MessagingModuleConfiguration import org.session.libsession.messaging.utilities.MessageWrapper import org.session.libsignal.crypto.getRandomElement import org.session.libsignal.database.LokiAPIDatabaseProtocol import org.session.libsignal.protos.SignalServiceProtos import org.session.libsignal.utilities.Base64 import org.session.libsignal.utilities.Broadcaster import org.session.libsignal.utilities.HTTP import org.session.libsignal.utilities.Hex import org.session.libsignal.utilities.Log import org.session.libsignal.utilities.Snode import org.session.libsignal.utilities.ThreadUtils import org.session.libsignal.utilities.prettifiedDescription import org.session.libsignal.utilities.retryIfNeeded import java.security.SecureRandom import java.util.Date import java.util.Locale import kotlin.collections.component1 import kotlin.collections.component2 import kotlin.collections.set import kotlin.properties.Delegates.observable object SnodeAPI { private val sodium by lazy { LazySodiumAndroid(SodiumAndroid()) } private val database: LokiAPIDatabaseProtocol get() = SnodeModule.shared.storage private val broadcaster: Broadcaster get() = SnodeModule.shared.broadcaster internal var snodeFailureCount: MutableMap = mutableMapOf() internal var snodePool: Set get() = database.getSnodePool() set(newValue) { database.setSnodePool(newValue) } /** * The offset between the user's clock and the Service Node's clock. Used in cases where the * user's clock is incorrect. */ internal var clockOffset = 0L internal var forkInfo by observable(database.getForkInfo()) { _, oldValue, newValue -> if (newValue > oldValue) { Log.d("Loki", "Setting new fork info new: $newValue, old: $oldValue") database.setForkInfo(newValue) } } // Settings private val maxRetryCount = 6 private val minimumSnodePoolCount = 12 private val minimumSwarmSnodeCount = 3 // Use port 4433 if the API level can handle the network security configuration and enforce pinned certificates private val seedNodePort = if (Build.VERSION.SDK_INT < Build.VERSION_CODES.N) 443 else 4433 private val seedNodePool by lazy { if (useTestnet) { setOf( "http://public.loki.foundation:38157" ) } else { setOf( "https://storage.seed1.loki.network:$seedNodePort", "https://storage.seed3.loki.network:$seedNodePort", "https://public.loki.foundation:$seedNodePort" ) } } private const val snodeFailureThreshold = 3 private const val useOnionRequests = true const val useTestnet = false // Error internal sealed class Error(val description: String) : Exception(description) { object Generic : Error("An error occurred.") object ClockOutOfSync : Error("Your clock is out of sync with the Service Node network.") object NoKeyPair : Error("Missing user key pair.") object SigningFailed : Error("Couldn't sign verification data.") // ONS object DecryptionFailed : Error("Couldn't decrypt ONS name.") object HashingFailed : Error("Couldn't compute ONS name hash.") object ValidationFailed : Error("ONS name validation failed.") } // Internal API internal fun invoke(method: Snode.Method, snode: Snode, publicKey: String? = null, parameters: Map): RawResponsePromise { val url = "${snode.address}:${snode.port}/storage_rpc/v1" if (useOnionRequests) { return OnionRequestAPI.sendOnionRequest(method, parameters, snode, publicKey) } else { val deferred = deferred, Exception>() ThreadUtils.queue { val payload = mapOf( "method" to method.rawValue, "params" to parameters ) try { val json = HTTP.execute(HTTP.Verb.POST, url, payload) deferred.resolve(json) } catch (exception: Exception) { val httpRequestFailedException = exception as? HTTP.HTTPRequestFailedException if (httpRequestFailedException != null) { val error = handleSnodeError(httpRequestFailedException.statusCode, httpRequestFailedException.json, snode, publicKey) if (error != null) { return@queue deferred.reject(exception) } } Log.d("Loki", "Unhandled exception: $exception.") deferred.reject(exception) } } return deferred.promise } } internal fun getRandomSnode(): Promise { val snodePool = this.snodePool if (snodePool.count() < minimumSnodePoolCount) { val target = seedNodePool.random() val url = "$target/json_rpc" Log.d("Loki", "Populating snode pool using: $target.") val parameters = mapOf( "method" to "get_n_service_nodes", "params" to mapOf( "active_only" to true, "limit" to 256, "fields" to mapOf("public_ip" to true, "storage_port" to true, "pubkey_x25519" to true, "pubkey_ed25519" to true) ) ) val deferred = deferred() deferred() ThreadUtils.queue { try { val json = HTTP.execute(HTTP.Verb.POST, url, parameters, useSeedNodeConnection = true) val intermediate = json["result"] as? Map<*, *> val rawSnodes = intermediate?.get("service_node_states") as? List<*> if (rawSnodes != null) { val snodePool = rawSnodes.mapNotNull { rawSnode -> val rawSnodeAsJSON = rawSnode as? Map<*, *> val address = rawSnodeAsJSON?.get("public_ip") as? String val port = rawSnodeAsJSON?.get("storage_port") as? Int val ed25519Key = rawSnodeAsJSON?.get("pubkey_ed25519") as? String val x25519Key = rawSnodeAsJSON?.get("pubkey_x25519") as? String if (address != null && port != null && ed25519Key != null && x25519Key != null && address != "0.0.0.0") { Snode("https://$address", port, Snode.KeySet(ed25519Key, x25519Key)) } else { Log.d("Loki", "Failed to parse: ${rawSnode?.prettifiedDescription()}.") null } }.toMutableSet() Log.d("Loki", "Persisting snode pool to database.") this.snodePool = snodePool try { deferred.resolve(snodePool.getRandomElement()) } catch (exception: Exception) { Log.d("Loki", "Got an empty snode pool from: $target.") deferred.reject(SnodeAPI.Error.Generic) } } else { Log.d("Loki", "Failed to update snode pool from: ${(rawSnodes as List<*>?)?.prettifiedDescription()}.") deferred.reject(SnodeAPI.Error.Generic) } } catch (exception: Exception) { deferred.reject(exception) } } return deferred.promise } else { return Promise.of(snodePool.getRandomElement()) } } internal fun dropSnodeFromSwarmIfNeeded(snode: Snode, publicKey: String) { val swarm = database.getSwarm(publicKey)?.toMutableSet() if (swarm != null && swarm.contains(snode)) { swarm.remove(snode) database.setSwarm(publicKey, swarm) } } internal fun getSingleTargetSnode(publicKey: String): Promise { // SecureRandom() should be cryptographically secure return getSwarm(publicKey).map { it.shuffled(SecureRandom()).random() } } // Public API fun getSessionID(onsName: String): Promise { val deferred = deferred() val promise = deferred.promise val validationCount = 3 val sessionIDByteCount = 33 // Hash the ONS name using BLAKE2b val onsName = onsName.toLowerCase(Locale.US) val nameAsData = onsName.toByteArray() val nameHash = ByteArray(GenericHash.BYTES) if (!sodium.cryptoGenericHash(nameHash, nameHash.size, nameAsData, nameAsData.size.toLong())) { deferred.reject(Error.HashingFailed) return promise } val base64EncodedNameHash = Base64.encodeBytes(nameHash) // Ask 3 different snodes for the Session ID associated with the given name hash val parameters = mapOf( "endpoint" to "ons_resolve", "params" to mapOf( "type" to 0, "name_hash" to base64EncodedNameHash ) ) val promises = (1..validationCount).map { getRandomSnode().bind { snode -> retryIfNeeded(maxRetryCount) { invoke(Snode.Method.OxenDaemonRPCCall, snode, null, parameters) } } } all(promises).success { results -> val sessionIDs = mutableListOf() for (json in results) { val intermediate = json["result"] as? Map<*, *> val hexEncodedCiphertext = intermediate?.get("encrypted_value") as? String if (hexEncodedCiphertext != null) { val ciphertext = Hex.fromStringCondensed(hexEncodedCiphertext) val isArgon2Based = (intermediate["nonce"] == null) if (isArgon2Based) { // Handle old Argon2-based encryption used before HF16 val salt = ByteArray(PwHash.SALTBYTES) val key: ByteArray val nonce = ByteArray(SecretBox.NONCEBYTES) val sessionIDAsData = ByteArray(sessionIDByteCount) try { key = Key.fromHexString(sodium.cryptoPwHash(onsName, SecretBox.KEYBYTES, salt, PwHash.OPSLIMIT_MODERATE, PwHash.MEMLIMIT_MODERATE, PwHash.Alg.PWHASH_ALG_ARGON2ID13)).asBytes } catch (e: SodiumException) { deferred.reject(Error.HashingFailed) return@success } if (!sodium.cryptoSecretBoxOpenEasy(sessionIDAsData, ciphertext, ciphertext.size.toLong(), nonce, key)) { deferred.reject(Error.DecryptionFailed) return@success } sessionIDs.add(Hex.toStringCondensed(sessionIDAsData)) } else { val hexEncodedNonce = intermediate["nonce"] as? String if (hexEncodedNonce == null) { deferred.reject(Error.Generic) return@success } val nonce = Hex.fromStringCondensed(hexEncodedNonce) val key = ByteArray(GenericHash.BYTES) if (!sodium.cryptoGenericHash(key, key.size, nameAsData, nameAsData.size.toLong(), nameHash, nameHash.size)) { deferred.reject(Error.HashingFailed) return@success } val sessionIDAsData = ByteArray(sessionIDByteCount) if (!sodium.cryptoAeadXChaCha20Poly1305IetfDecrypt(sessionIDAsData, null, null, ciphertext, ciphertext.size.toLong(), null, 0, nonce, key)) { deferred.reject(Error.DecryptionFailed) return@success } sessionIDs.add(Hex.toStringCondensed(sessionIDAsData)) } } else { deferred.reject(Error.Generic) return@success } } if (sessionIDs.size == validationCount && sessionIDs.toSet().size == 1) { deferred.resolve(sessionIDs.first()) } else { deferred.reject(Error.ValidationFailed) } } return promise } fun getSwarm(publicKey: String): Promise, Exception> { val cachedSwarm = database.getSwarm(publicKey) if (cachedSwarm != null && cachedSwarm.size >= minimumSwarmSnodeCount) { val cachedSwarmCopy = mutableSetOf() // Workaround for a Kotlin compiler issue cachedSwarmCopy.addAll(cachedSwarm) return task { cachedSwarmCopy } } else { val parameters = mapOf( "pubKey" to publicKey ) return getRandomSnode().bind { invoke(Snode.Method.GetSwarm, it, publicKey, parameters) }.map { parseSnodes(it).toSet() }.success { database.setSwarm(publicKey, it) } } } fun getRawMessages(snode: Snode, publicKey: String, requiresAuth: Boolean = true, namespace: Int = 0): RawResponsePromise { val userED25519KeyPair = MessagingModuleConfiguration.shared.getUserED25519KeyPair() ?: return Promise.ofFail(Error.NoKeyPair) // Get last message hash val lastHashValue = database.getLastMessageHashValue(snode, publicKey, namespace) ?: "" val parameters = mutableMapOf( "pubKey" to publicKey, "last_hash" to lastHashValue, ) // Construct signature if (requiresAuth) { val timestamp = Date().time + SnodeAPI.clockOffset val ed25519PublicKey = userED25519KeyPair.publicKey.asHexString val signature = ByteArray(Sign.BYTES) val verificationData = if (namespace != 0) "retrieve$namespace$timestamp".toByteArray() else "retrieve$timestamp".toByteArray() try { sodium.cryptoSignDetached(signature, verificationData, verificationData.size.toLong(), userED25519KeyPair.secretKey.asBytes) } catch (exception: Exception) { return Promise.ofFail(Error.SigningFailed) } parameters["timestamp"] = timestamp parameters["pubkey_ed25519"] = ed25519PublicKey parameters["signature"] = Base64.encodeBytes(signature) } // If the namespace is default (0) here it will be implicitly read as 0 on the storage server // we only need to specify it explicitly if we want to (in future) or if it is non-zero if (namespace != 0) { parameters["namespace"] = namespace } // Make the request return invoke(Snode.Method.GetMessages, snode, publicKey, parameters) } fun getMessages(publicKey: String): MessageListPromise { return retryIfNeeded(maxRetryCount) { getSingleTargetSnode(publicKey).bind { snode -> getRawMessages(snode, publicKey).map { parseRawMessagesResponse(it, snode, publicKey) } } } } private fun getNetworkTime(snode: Snode): Promise, Exception> { return invoke(Snode.Method.Info, snode, null, emptyMap()).map { rawResponse -> val timestamp = rawResponse["timestamp"] as? Long ?: -1 snode to timestamp } } fun sendMessage(message: SnodeMessage, requiresAuth: Boolean = false, namespace: Int = 0): RawResponsePromise { val destination = message.recipient return retryIfNeeded(maxRetryCount) { val module = MessagingModuleConfiguration.shared val userED25519KeyPair = module.getUserED25519KeyPair() ?: return@retryIfNeeded Promise.ofFail(Error.NoKeyPair) val parameters = message.toJSON().toMutableMap() // Construct signature if (requiresAuth) { val sigTimestamp = System.currentTimeMillis() + SnodeAPI.clockOffset val ed25519PublicKey = userED25519KeyPair.publicKey.asHexString val signature = ByteArray(Sign.BYTES) // assume namespace here is non-zero, as zero namespace doesn't require auth val verificationData = "store$namespace$sigTimestamp".toByteArray() try { sodium.cryptoSignDetached(signature, verificationData, verificationData.size.toLong(), userED25519KeyPair.secretKey.asBytes) } catch (exception: Exception) { return@retryIfNeeded Promise.ofFail(Error.SigningFailed) } parameters["sig_timestamp"] = sigTimestamp parameters["pubkey_ed25519"] = ed25519PublicKey parameters["signature"] = Base64.encodeBytes(signature) } // If the namespace is default (0) here it will be implicitly read as 0 on the storage server // we only need to specify it explicitly if we want to (in future) or if it is non-zero if (namespace != 0) { parameters["namespace"] = namespace } getSingleTargetSnode(destination).bind { snode -> invoke(Snode.Method.SendMessage, snode, destination, parameters) } } } fun deleteMessage(publicKey: String, serverHashes: List): Promise, Exception> { return retryIfNeeded(maxRetryCount) { val module = MessagingModuleConfiguration.shared val userED25519KeyPair = module.getUserED25519KeyPair() ?: return@retryIfNeeded Promise.ofFail(Error.NoKeyPair) val userPublicKey = module.storage.getUserPublicKey() ?: return@retryIfNeeded Promise.ofFail(Error.NoKeyPair) getSingleTargetSnode(publicKey).bind { snode -> retryIfNeeded(maxRetryCount) { val signature = ByteArray(Sign.BYTES) val verificationData = (Snode.Method.DeleteMessage.rawValue + serverHashes.fold("") { a, v -> a + v }).toByteArray() sodium.cryptoSignDetached(signature, verificationData, verificationData.size.toLong(), userED25519KeyPair.secretKey.asBytes) val deleteMessageParams = mapOf( "pubkey" to userPublicKey, "pubkey_ed25519" to userED25519KeyPair.publicKey.asHexString, "messages" to serverHashes, "signature" to Base64.encodeBytes(signature) ) invoke(Snode.Method.DeleteMessage, snode, publicKey, deleteMessageParams).map { rawResponse -> val swarms = rawResponse["swarm"] as? Map ?: return@map mapOf() val result = swarms.mapNotNull { (hexSnodePublicKey, rawJSON) -> val json = rawJSON as? Map ?: return@mapNotNull null val isFailed = json["failed"] as? Boolean ?: false val statusCode = json["code"] as? String val reason = json["reason"] as? String hexSnodePublicKey to if (isFailed) { Log.e("Loki", "Failed to delete messages from: $hexSnodePublicKey due to error: $reason ($statusCode).") false } else { val hashes = json["deleted"] as List // Hashes of deleted messages val signature = json["signature"] as String val snodePublicKey = Key.fromHexString(hexSnodePublicKey) // The signature looks like ( PUBKEY_HEX || RMSG[0] || ... || RMSG[N] || DMSG[0] || ... || DMSG[M] ) val message = (userPublicKey + serverHashes.fold("") { a, v -> a + v } + hashes.fold("") { a, v -> a + v }).toByteArray() sodium.cryptoSignVerifyDetached(Base64.decode(signature), message, message.size, snodePublicKey.asBytes) } } return@map result.toMap() }.fail { e -> Log.e("Loki", "Failed to delete messages", e) } } } } } // Parsing private fun parseSnodes(rawResponse: Any): List { val json = rawResponse as? Map<*, *> val rawSnodes = json?.get("snodes") as? List<*> if (rawSnodes != null) { return rawSnodes.mapNotNull { rawSnode -> val rawSnodeAsJSON = rawSnode as? Map<*, *> val address = rawSnodeAsJSON?.get("ip") as? String val portAsString = rawSnodeAsJSON?.get("port") as? String val port = portAsString?.toInt() val ed25519Key = rawSnodeAsJSON?.get("pubkey_ed25519") as? String val x25519Key = rawSnodeAsJSON?.get("pubkey_x25519") as? String if (address != null && port != null && ed25519Key != null && x25519Key != null && address != "0.0.0.0") { Snode("https://$address", port, Snode.KeySet(ed25519Key, x25519Key)) } else { Log.d("Loki", "Failed to parse snode from: ${rawSnode?.prettifiedDescription()}.") null } } } else { Log.d("Loki", "Failed to parse snodes from: ${rawResponse.prettifiedDescription()}.") return listOf() } } fun deleteAllMessages(): Promise, Exception> { return retryIfNeeded(maxRetryCount) { val module = MessagingModuleConfiguration.shared val userED25519KeyPair = module.getUserED25519KeyPair() ?: return@retryIfNeeded Promise.ofFail(Error.NoKeyPair) val userPublicKey = module.storage.getUserPublicKey() ?: return@retryIfNeeded Promise.ofFail(Error.NoKeyPair) getSingleTargetSnode(userPublicKey).bind { snode -> retryIfNeeded(maxRetryCount) { getNetworkTime(snode).bind { (_, timestamp) -> val signature = ByteArray(Sign.BYTES) val verificationData = (Snode.Method.DeleteAll.rawValue + timestamp.toString()).toByteArray() sodium.cryptoSignDetached(signature, verificationData, verificationData.size.toLong(), userED25519KeyPair.secretKey.asBytes) val deleteMessageParams = mapOf( "pubkey" to userPublicKey, "pubkey_ed25519" to userED25519KeyPair.publicKey.asHexString, "timestamp" to timestamp, "signature" to Base64.encodeBytes(signature) ) invoke(Snode.Method.DeleteAll, snode, userPublicKey, deleteMessageParams).map { rawResponse -> parseDeletions(userPublicKey, timestamp, rawResponse) }.fail { e -> Log.e("Loki", "Failed to clear data", e) } } } } } } fun parseRawMessagesResponse(rawResponse: RawResponse, snode: Snode, publicKey: String, namespace: Int = 0): List> { val messages = rawResponse["messages"] as? List<*> return if (messages != null) { updateLastMessageHashValueIfPossible(snode, publicKey, messages, namespace) val newRawMessages = removeDuplicates(publicKey, messages, namespace) return parseEnvelopes(newRawMessages); } else { listOf() } } private fun updateLastMessageHashValueIfPossible(snode: Snode, publicKey: String, rawMessages: List<*>, namespace: Int) { val lastMessageAsJSON = rawMessages.lastOrNull() as? Map<*, *> val hashValue = lastMessageAsJSON?.get("hash") as? String if (hashValue != null) { database.setLastMessageHashValue(snode, publicKey, hashValue, namespace) } else if (rawMessages.isNotEmpty()) { Log.d("Loki", "Failed to update last message hash value from: ${rawMessages.prettifiedDescription()}.") } } private fun removeDuplicates(publicKey: String, rawMessages: List<*>, namespace: Int): List<*> { val receivedMessageHashValues = database.getReceivedMessageHashValues(publicKey, namespace)?.toMutableSet() ?: mutableSetOf() val result = rawMessages.filter { rawMessage -> val rawMessageAsJSON = rawMessage as? Map<*, *> val hashValue = rawMessageAsJSON?.get("hash") as? String if (hashValue != null) { val isDuplicate = receivedMessageHashValues.contains(hashValue) receivedMessageHashValues.add(hashValue) !isDuplicate } else { Log.d("Loki", "Missing hash value for message: ${rawMessage?.prettifiedDescription()}.") false } } database.setReceivedMessageHashValues(publicKey, receivedMessageHashValues, namespace) return result } private fun parseEnvelopes(rawMessages: List<*>): List> { return rawMessages.mapNotNull { rawMessage -> val rawMessageAsJSON = rawMessage as? Map<*, *> val base64EncodedData = rawMessageAsJSON?.get("data") as? String val data = base64EncodedData?.let { Base64.decode(it) } if (data != null) { try { Pair(MessageWrapper.unwrap(data), rawMessageAsJSON.get("hash") as? String) } catch (e: Exception) { Log.d("Loki", "Failed to unwrap data for message: ${rawMessage.prettifiedDescription()}.") null } } else { Log.d("Loki", "Failed to decode data for message: ${rawMessage?.prettifiedDescription()}.") null } } } @Suppress("UNCHECKED_CAST") private fun parseDeletions(userPublicKey: String, timestamp: Long, rawResponse: RawResponse): Map { val swarms = rawResponse["swarm"] as? Map ?: return mapOf() val result = swarms.mapNotNull { (hexSnodePublicKey, rawJSON) -> val json = rawJSON as? Map ?: return@mapNotNull null val isFailed = json["failed"] as? Boolean ?: false val statusCode = json["code"] as? String val reason = json["reason"] as? String hexSnodePublicKey to if (isFailed) { Log.e("Loki", "Failed to delete all messages from: $hexSnodePublicKey due to error: $reason ($statusCode).") false } else { val hashes = json["deleted"] as List // Hashes of deleted messages val signature = json["signature"] as String val snodePublicKey = Key.fromHexString(hexSnodePublicKey) // The signature looks like ( PUBKEY_HEX || TIMESTAMP || DELETEDHASH[0] || ... || DELETEDHASH[N] ) val message = (userPublicKey + timestamp.toString() + hashes.fold("") { a, v -> a + v }).toByteArray() sodium.cryptoSignVerifyDetached(Base64.decode(signature), message, message.size, snodePublicKey.asBytes) } } return result.toMap() } // endregion // Error Handling internal fun handleSnodeError(statusCode: Int, json: Map<*, *>?, snode: Snode, publicKey: String? = null): Exception? { fun handleBadSnode() { val oldFailureCount = snodeFailureCount[snode] ?: 0 val newFailureCount = oldFailureCount + 1 snodeFailureCount[snode] = newFailureCount Log.d("Loki", "Couldn't reach snode at $snode; setting failure count to $newFailureCount.") if (newFailureCount >= snodeFailureThreshold) { Log.d("Loki", "Failure threshold reached for: $snode; dropping it.") if (publicKey != null) { dropSnodeFromSwarmIfNeeded(snode, publicKey) } snodePool = snodePool.toMutableSet().minus(snode).toSet() Log.d("Loki", "Snode pool count: ${snodePool.count()}.") snodeFailureCount[snode] = 0 } } when (statusCode) { 400, 500, 502, 503 -> { // Usually indicates that the snode isn't up to date handleBadSnode() } 406 -> { Log.d("Loki", "The user's clock is out of sync with the service node network.") broadcaster.broadcast("clockOutOfSync") return Error.ClockOutOfSync } 421 -> { // The snode isn't associated with the given public key anymore if (publicKey != null) { fun invalidateSwarm() { Log.d("Loki", "Invalidating swarm for: $publicKey.") dropSnodeFromSwarmIfNeeded(snode, publicKey) } if (json != null) { val snodes = parseSnodes(json) if (snodes.isNotEmpty()) { database.setSwarm(publicKey, snodes.toSet()) } else { invalidateSwarm() } } else { invalidateSwarm() } } else { Log.d("Loki", "Got a 421 without an associated public key.") } } else -> { handleBadSnode() Log.d("Loki", "Unhandled response code: ${statusCode}.") return Error.Generic } } return null } } // Type Aliases typealias RawResponse = Map<*, *> typealias MessageListPromise = Promise>, Exception> typealias RawResponsePromise = Promise