session-android/libsession/src/main/java/org/session/libsession/snode/SnodeAPI.kt

1007 lines
46 KiB
Kotlin

@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.JsonUtil
import org.session.libsignal.utilities.Log
import org.session.libsignal.utilities.Namespace
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<Snode, Int> = mutableMapOf()
internal var snodePool: Set<Snode>
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 = 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.")
}
// Batch
data class SnodeBatchRequestInfo(
val method: String,
val params: Map<String, Any>,
@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<String, Any>,
publicKey: String? = null,
version: Version = Version.V3
): RawResponsePromise {
val url = "${snode.address}:${snode.port}/storage_rpc/v1"
val deferred = deferred<Map<*, *>, 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<Snode, Exception> {
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<Snode, Exception>()
deferred<Snode, Exception>()
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<Snode, Exception> {
// SecureRandom() should be cryptographically secure
return getSwarm(publicKey).map { it.shuffled(SecureRandom()).random() }
}
// Public API
fun getSessionID(onsName: String): Promise<String, Exception> {
val deferred = deferred<String, Exception>()
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<String>()
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<Set<Snode>, Exception> {
val cachedSwarm = database.getSwarm(publicKey)
return if (cachedSwarm != null && cachedSwarm.size >= minimumSwarmSnodeCount) {
val cachedSwarmCopy = mutableSetOf<Snode>() // 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<String, Any>(
"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<String, Any>()
// 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<String>, 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,
signingKey: ByteArray,
ed25519PublicKey: Key? = null): SnodeBatchRequestInfo? {
val lastHashValue = database.getLastMessageHashValue(snode, publicKey, namespace) ?: ""
val params = mutableMapOf<String, Any>(
"pubkey" to publicKey,
"last_hash" to lastHashValue
)
if (ed25519PublicKey != null) {
params["pubkey_ed25519"] = ed25519PublicKey.asHexString
}
val timestamp = nowWithOffset
val signature = ByteArray(Sign.BYTES)
val verificationData = if (namespace == 0) "retrieve$timestamp".toByteArray()
else "retrieve$namespace$timestamp".toByteArray()
try {
sodium.cryptoSignDetached(
signature,
verificationData,
verificationData.size.toLong(),
signingKey
)
} catch (e: Exception) {
Log.e("BatchRetrieve", "Signing data failed with provided signing key", e)
return null
}
params["timestamp"] = timestamp
params["signature"] = Base64.encodeBytes(signature)
if (namespace != 0) {
params["namespace"] = namespace
}
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,
secretKey,
ed25519PublicKey
)
}
fun buildAuthenticatedAlterTtlBatchRequest(
messageHashes: List<String>,
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<String>,
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<SnodeBatchRequestInfo>, sequence: Boolean = false): RawResponsePromise {
val parameters = mutableMapOf<String, Any>(
"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<RawResponse>)
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<String>, 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<String>,
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<String>,
newExpiry: Long,
publicKey: String,
signingKey: ByteArray,
pubKeyEd25519: String? = null,
extend: Boolean = false,
shorten: Boolean = false): Map<String, Any>? {
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<Pair<Snode,Long>, Exception> {
return invoke(Snode.Method.Info, snode, emptyMap()).map { rawResponse ->
val timestamp = rawResponse["timestamp"] as? Long ?: -1
snode to timestamp
}
}
fun sendAuthenticatedMessage(message: SnodeMessage, signingKey: ByteArray, namespace: Int): RawResponsePromise {
val pubKey = message.recipient
return retryIfNeeded(maxRetryCount) {
val signature = ByteArray(Sign.BYTES)
// assume namespace here is non-zero, as zero namespace doesn't require auth
val sigTimestamp = nowWithOffset
val verificationData = "store$namespace$sigTimestamp".toByteArray()
try {
sodium.cryptoSignDetached(signature, verificationData, verificationData.size.toLong(), signingKey)
} catch (exception: Exception) {
return@retryIfNeeded Promise.ofFail(Error.SigningFailed)
}
val parameters = message.toJSON().toMutableMap<String,Any>()
parameters += mapOf(
"timestamp" to sigTimestamp,
"signature" to Base64.encodeBytes(signature),
"namespace" to 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<String,Any>()
// 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<String>): Promise<Map<String,Boolean>, 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<String, Any> ?: return@map mapOf()
val result = swarms.mapNotNull { (hexSnodePublicKey, rawJSON) ->
val json = rawJSON as? Map<String, Any> ?: 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<String> // 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<Snode> {
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<Map<String,Boolean>, 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): List<Pair<SignalServiceProtos.Envelope, String?>> {
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)
} 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, 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<*>): List<Pair<SignalServiceProtos.Envelope, String?>> {
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<String, Boolean> {
val swarms = rawResponse["swarm"] as? Map<String, Any> ?: return mapOf()
val result = swarms.mapNotNull { (hexSnodePublicKey, rawJSON) ->
val json = rawJSON as? Map<String, Any> ?: 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<String,List<String>>).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
}
else -> {
handleBadSnode()
Log.d("Loki", "Unhandled response code: ${statusCode}.")
return Error.Generic
}
}
return null
}
}
// Type Aliases
typealias RawResponse = Map<*, *>
typealias MessageListPromise = Promise<List<Pair<SignalServiceProtos.Envelope, String?>>, Exception>
typealias RawResponsePromise = Promise<RawResponse, Exception>