@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 network.loki.messenger.libsession_util.GroupKeysConfig 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.JsonUtil import org.session.libsignal.utilities.Log import org.session.libsignal.utilities.Namespace import org.session.libsignal.utilities.SessionId 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.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()) } internal 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 @JvmStatic public val nowWithOffset get() = System.currentTimeMillis() + clockOffset 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 4443 private val seedNodePool by lazy { if (useTestnet) { setOf( "http://public.loki.foundation:38157" ) } else { setOf( "https://seed1.getsession.org:$seedNodePort", "https://seed2.getsession.org:$seedNodePort", "https://seed3.getsession.org:$seedNodePort", ) } } private const val snodeFailureThreshold = 3 private const val useOnionRequests = true const val useTestnet = true // 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.") } // Batch data class SnodeBatchRequestInfo( val method: String, val params: Map, @Transient val namespace: Int? ) // assume signatures, pubkey and namespaces are attached in parameters if required // Internal API internal fun invoke( method: Snode.Method, snode: Snode, parameters: Map, publicKey: String? = null, version: Version = Version.V3 ): RawResponsePromise { val url = "${snode.address}:${snode.port}/storage_rpc/v1" val deferred = deferred, Exception>() if (useOnionRequests) { OnionRequestAPI.sendOnionRequest(method, parameters, snode, version, publicKey).map { val body = it.body ?: throw Error.Generic deferred.resolve(JsonUtil.fromJson(body, Map::class.java)) }.fail { deferred.reject(it) } } else { ThreadUtils.queue { val payload = mapOf( "method" to method.rawValue, "params" to parameters ) try { val response = HTTP.execute(HTTP.Verb.POST, url, payload).toString() val json = JsonUtil.fromJson(response, Map::class.java) 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 response = HTTP.execute(HTTP.Verb.POST, url, parameters, useSeedNodeConnection = true) val json = try { JsonUtil.fromJson(response, Map::class.java) } catch (exception: Exception) { mapOf( "result" to response.toString()) } 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) } } 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, 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) return if (cachedSwarm != null && cachedSwarm.size >= minimumSwarmSnodeCount) { val cachedSwarmCopy = mutableSetOf() // Workaround for a Kotlin compiler issue cachedSwarmCopy.addAll(cachedSwarm) task { cachedSwarmCopy } } else { val parameters = mapOf( "pubKey" to publicKey ) getRandomSnode().bind { invoke(Snode.Method.GetSwarm, it, parameters, publicKey) }.map { parseSnodes(it).toSet() }.success { database.setSwarm(publicKey, it) } } } fun getRawMessages(snode: Snode, publicKey: String, requiresAuth: Boolean = true, namespace: Int = 0): RawResponsePromise { // 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 userED25519KeyPair = try { MessagingModuleConfiguration.shared.getUserED25519KeyPair() ?: return Promise.ofFail(Error.NoKeyPair) } catch (e: Exception) { Log.e("Loki", "Error getting KeyPair", e) return Promise.ofFail(Error.NoKeyPair) } val timestamp = System.currentTimeMillis() + 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.Retrieve, snode, parameters, publicKey) } fun buildAuthenticatedStoreBatchInfo(namespace: Int, message: SnodeMessage, signingKey: ByteArray, ed25519PubKey: String? = null): SnodeBatchRequestInfo { val params = mutableMapOf() // load the message data params into the sub request // currently loads: // pubKey // data // ttl // timestamp params.putAll(message.toJSON()) params["namespace"] = namespace // used for sig generation since it is also the value used in timestamp parameter val messageTimestamp = message.timestamp val signature = ByteArray(Sign.BYTES) val verificationData = "store$namespace$messageTimestamp".toByteArray() try { sodium.cryptoSignDetached( signature, verificationData, verificationData.size.toLong(), signingKey ) } catch (e: Exception) { Log.e("Loki", "Signing data failed with user secret key", e) } // timestamp already set if (ed25519PubKey != null) { params["pubkey_ed25519"] = ed25519PubKey } params["signature"] = Base64.encodeBytes(signature) return SnodeBatchRequestInfo( Snode.Method.SendMessage.rawValue, params, namespace ) } fun buildAuthenticatedStoreBatchInfo(namespace: Int, message: SnodeMessage): SnodeBatchRequestInfo? { val userEd25519KeyPair = try { MessagingModuleConfiguration.shared.getUserED25519KeyPair() ?: return null } catch (e: Exception) { return null } val ed25519PublicKey = userEd25519KeyPair.publicKey.asHexString return buildAuthenticatedStoreBatchInfo(namespace, message, userEd25519KeyPair.secretKey.asBytes, ed25519PublicKey) } /** * Message hashes can be shared across multiple namespaces (for a single public key destination) * @param publicKey the destination's identity public key to delete from (05...) * @param messageHashes a list of stored message hashes to delete from the server * @param required indicates that *at least one* message in the list is deleted from the server, otherwise it will return 404 */ fun buildAuthenticatedDeleteBatchInfo(publicKey: String, messageHashes: List, required: Boolean = false): SnodeBatchRequestInfo? { val params = mutableMapOf( "pubkey" to publicKey, "required" to required, // could be omitted technically but explicit here "messages" to messageHashes ) val userEd25519KeyPair = try { MessagingModuleConfiguration.shared.getUserED25519KeyPair() ?: return null } catch (e: Exception) { return null } val ed25519PublicKey = userEd25519KeyPair.publicKey.asHexString val signature = ByteArray(Sign.BYTES) val verificationData = "delete${messageHashes.joinToString("")}".toByteArray() try { sodium.cryptoSignDetached( signature, verificationData, verificationData.size.toLong(), userEd25519KeyPair.secretKey.asBytes ) } catch (e: Exception) { Log.e("Loki", "Signing data failed with user secret key", e) return null } params["pubkey_ed25519"] = ed25519PublicKey params["signature"] = Base64.encodeBytes(signature) return SnodeBatchRequestInfo( Snode.Method.DeleteMessage.rawValue, params, null ) } fun buildAuthenticatedRetrieveBatchRequest(snode: Snode, publicKey: String, namespace: Int, maxSize: Int? = null, signCallback: SignCallback, ed25519PublicKey: Key? = null): SnodeBatchRequestInfo? { val lastHashValue = database.getLastMessageHashValue(snode, publicKey, namespace) ?: "" val params = mutableMapOf( "pubkey" to publicKey, "last_hash" to lastHashValue ) if (ed25519PublicKey != null) { params["pubkey_ed25519"] = ed25519PublicKey.asHexString } val timestamp = nowWithOffset val verificationData = if (namespace == 0) "retrieve$timestamp" else "retrieve$namespace$timestamp" val signParameters = signCallback(verificationData, timestamp, namespace) params += signParameters if (maxSize != null) { params["max_size"] = maxSize } return SnodeBatchRequestInfo( Snode.Method.Retrieve.rawValue, params, namespace ) } fun buildAuthenticatedRetrieveBatchRequest(snode: Snode, publicKey: String, namespace: Int = 0, maxSize: Int? = null): SnodeBatchRequestInfo? { val userEd25519KeyPair = try { MessagingModuleConfiguration.shared.getUserED25519KeyPair() ?: return null } catch (e: Exception) { return null } val secretKey = userEd25519KeyPair.secretKey.asBytes val ed25519PublicKey = userEd25519KeyPair.publicKey return buildAuthenticatedRetrieveBatchRequest( snode, publicKey, namespace, maxSize, signingKeyCallback(secretKey), ed25519PublicKey ) } fun buildAuthenticatedAlterTtlBatchRequest( messageHashes: List, newExpiry: Long, publicKey: String, signingKey: ByteArray, pubKeyEd25519: String? = null, shorten: Boolean = false, extend: Boolean = false): SnodeBatchRequestInfo? { val params = buildAlterTtlParams(messageHashes, newExpiry, publicKey, signingKey, pubKeyEd25519, extend, shorten) ?: return null return SnodeBatchRequestInfo( Snode.Method.Expire.rawValue, params, null ) } fun buildAuthenticatedAlterTtlBatchRequest( messageHashes: List, newExpiry: Long, publicKey: String, shorten: Boolean = false, extend: Boolean = false): SnodeBatchRequestInfo? { val userEd25519KeyPair = MessagingModuleConfiguration.shared.getUserED25519KeyPair() ?: return null val signingKey = userEd25519KeyPair.secretKey.asBytes val pubKeyEd25519 = userEd25519KeyPair.publicKey.asHexString return buildAuthenticatedAlterTtlBatchRequest( messageHashes, newExpiry, publicKey, signingKey, pubKeyEd25519, shorten, extend ) } fun getRawBatchResponse(snode: Snode, publicKey: String, requests: List, sequence: Boolean = false): RawResponsePromise { val parameters = mutableMapOf( "requests" to requests ) return invoke(if (sequence) Snode.Method.Sequence else Snode.Method.Batch, snode, parameters, publicKey).success { rawResponses -> val responseList = (rawResponses["results"] as List) responseList.forEachIndexed { index, response -> if (response["code"] as? Int != 200) { Log.w("Loki", "response code was not 200") handleSnodeError( response["code"] as? Int ?: 0, response, snode, publicKey ) } } } } fun getExpiries(messageHashes: List, publicKey: String) : RawResponsePromise { val userEd25519KeyPair = MessagingModuleConfiguration.shared.getUserED25519KeyPair() ?: return Promise.ofFail(NullPointerException("No user key pair")) return retryIfNeeded(maxRetryCount) { val timestamp = System.currentTimeMillis() + clockOffset val params = mutableMapOf( "pubkey" to publicKey, "messages" to messageHashes, "timestamp" to timestamp ) val signData = "${Snode.Method.GetExpiries.rawValue}$timestamp${messageHashes.joinToString(separator = "")}".toByteArray() val ed25519PublicKey = userEd25519KeyPair.publicKey.asHexString val signature = ByteArray(Sign.BYTES) try { sodium.cryptoSignDetached( signature, signData, signData.size.toLong(), userEd25519KeyPair.secretKey.asBytes ) } catch (e: Exception) { Log.e("Loki", "Signing data failed with user secret key", e) return@retryIfNeeded Promise.ofFail(e) } params["pubkey_ed25519"] = ed25519PublicKey params["signature"] = Base64.encodeBytes(signature) getSingleTargetSnode(publicKey).bind { snode -> invoke(Snode.Method.GetExpiries, snode, params, publicKey) } } } fun alterTtl(messageHashes: List, newExpiry: Long, publicKey: String, extend: Boolean = false, shorten: Boolean = false): RawResponsePromise { return retryIfNeeded(maxRetryCount) { val userEd25519KeyPair = MessagingModuleConfiguration.shared.getUserED25519KeyPair() ?: return@retryIfNeeded Promise.ofFail( Exception("No user key pair to sign alter ttl message") ) val signingKey = userEd25519KeyPair.secretKey.asBytes val pubKeyEd25519 = userEd25519KeyPair.publicKey.asHexString val params = buildAlterTtlParams(messageHashes, newExpiry, publicKey, signingKey, pubKeyEd25519, extend, shorten) ?: return@retryIfNeeded Promise.ofFail( Exception("Couldn't build signed params for alterTtl request for newExpiry=$newExpiry, extend=$extend, shorten=$shorten") ) getSingleTargetSnode(publicKey).bind { snode -> invoke(Snode.Method.Expire, snode, params, publicKey) } } } private fun buildAlterTtlParams( messageHashes: List, newExpiry: Long, publicKey: String, signingKey: ByteArray, pubKeyEd25519: String? = null, extend: Boolean = false, shorten: Boolean = false): Map? { val params = mutableMapOf( "expiry" to newExpiry, "messages" to messageHashes, ) if (extend) { params["extend"] = true } else if (shorten) { params["shorten"] = true } val shortenOrExtend = if (extend) "extend" else if (shorten) "shorten" else "" val signData = "${Snode.Method.Expire.rawValue}$shortenOrExtend$newExpiry${messageHashes.joinToString(separator = "")}".toByteArray() val signature = ByteArray(Sign.BYTES) try { sodium.cryptoSignDetached( signature, signData, signData.size.toLong(), signingKey ) } catch (e: Exception) { Log.e("Loki", "Signing data failed with user secret key", e) return null } params["pubkey"] = publicKey if (pubKeyEd25519 != null) { params["pubkey_ed25519"] = pubKeyEd25519 } params["signature"] = Base64.encodeBytes(signature) return params } 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, emptyMap()).map { rawResponse -> val timestamp = rawResponse["timestamp"] as? Long ?: -1 snode to timestamp } } fun signingKeyCallback(signingKey: ByteArray): SignCallback = { message, timestamp, namespace -> val signature = ByteArray(Sign.BYTES) try { val verificationData = message.toByteArray() sodium.cryptoSignDetached(signature, verificationData, verificationData.size.toLong(), signingKey) } catch (exception: Exception) { throw Error.SigningFailed } val params = mutableMapOf( "timestamp" to timestamp, "signature" to Base64.encodeBytes(signature), ) if (namespace != Namespace.DEFAULT()) { params += "namespace" to namespace } params } fun subkeyCallback(authData: ByteArray, groupKeysConfig: GroupKeysConfig, freeAfter: Boolean = true): SignCallback = { message, timestamp, namespace -> val (subaccount, subaccountSig, sig) = groupKeysConfig.subAccountSign(message.toByteArray(),authData) val params = mutableMapOf( "subaccount" to subaccount, "subaccount_sig" to subaccountSig, "signature" to sig, "timestamp" to timestamp, ) if (namespace != Namespace.DEFAULT()) { params += "namespace" to namespace } if (freeAfter) { groupKeysConfig.free() } params } fun sendAuthenticatedMessage(message: SnodeMessage, signCallback: SignCallback, namespace: Int): RawResponsePromise { val pubKey = message.recipient return retryIfNeeded(maxRetryCount) { // assume namespace here is non-zero, as zero namespace doesn't require auth val sigTimestamp = nowWithOffset val messageToSign = "store$namespace$sigTimestamp" val parameters = message.toJSON().toMutableMap() parameters += signCallback(messageToSign, sigTimestamp, namespace) getSingleTargetSnode(pubKey).bind { targetSnode -> invoke(Snode.Method.SendMessage, targetSnode, parameters, pubKey) } } } 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 = nowWithOffset 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, parameters, destination) } } } 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, deleteMessageParams, publicKey).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 + Namespace.ALL() + 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), "namespace" to Namespace.ALL(), ) invoke(Snode.Method.DeleteAll, snode, deleteMessageParams, userPublicKey).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, updateLatestHash: Boolean = true, updateStoredHashes: Boolean = true, decrypt: ((ByteArray) -> Pair?)? = null ): List> { val messages = rawResponse["messages"] as? List<*> return if (messages != null) { if (updateLatestHash) { updateLastMessageHashValueIfPossible(snode, publicKey, messages, namespace) } val newRawMessages = removeDuplicates(publicKey, messages, namespace, updateStoredHashes) return parseEnvelopes(newRawMessages, decrypt) } else { listOf() } } 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()}.") } } fun removeDuplicates(publicKey: String, rawMessages: List<*>, namespace: Int, updateStoredHashes: Boolean): List<*> { val originalMessageHashValues = database.getReceivedMessageHashValues(publicKey, namespace)?.toMutableSet() ?: mutableSetOf() val receivedMessageHashValues = originalMessageHashValues.toMutableSet() 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 } } if (originalMessageHashValues != receivedMessageHashValues && updateStoredHashes) { database.setReceivedMessageHashValues(publicKey, receivedMessageHashValues, namespace) } return result } private fun parseEnvelopes(rawMessages: List<*>, decrypt: ((ByteArray)->Pair?)?): 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 { if (decrypt != null) { val (decrypted, sender) = decrypt(data)!! val envelope = SignalServiceProtos.Envelope.parseFrom(decrypted).toBuilder() envelope.source = sender.hexString() Pair(envelope.build(),rawMessageAsJSON.get("hash") as? String) } else 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 Map>).flatMap { (_, hashes) -> hashes }.sorted() // 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.joinToString(separator = "")).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.") } } 404 -> { Log.d("Loki", "404, probably no file found") return Error.Generic } 401 -> { Log.d("Loki", "401, check you're doing a sign properly") return Error.SigningFailed } else -> { handleBadSnode() Log.d("Loki", "Unhandled response code: ${statusCode}.") return Error.Generic } } return null } } typealias SignCallback = (String, Long, Int)->Map // Type Aliases typealias RawResponse = Map<*, *> typealias MessageListPromise = Promise>, Exception> typealias RawResponsePromise = Promise