diff --git a/build.gradle b/build.gradle index 74887a3dcb..9417a4d7b0 100644 --- a/build.gradle +++ b/build.gradle @@ -12,6 +12,7 @@ buildscript { classpath "com.android.tools.build:gradle:4.0.1" classpath files('libs/gradle-witness.jar') classpath "org.jetbrains.kotlin:kotlin-gradle-plugin:$kotlin_version" + classpath "org.jetbrains.kotlin:kotlin-serialization:$kotlin_version" classpath "com.google.gms:google-services:4.3.3" } } @@ -22,6 +23,7 @@ apply plugin: 'kotlin-android-extensions' apply plugin: 'witness' apply plugin: 'kotlin-kapt' apply plugin: 'com.google.gms.google-services' +apply plugin: 'kotlinx-serialization' repositories { mavenLocal() @@ -154,6 +156,7 @@ dependencies { implementation "org.whispersystems:signal-service-android:2.13.2" // Run ./gradlew install from session-android-service to install implementation "org.whispersystems:curve25519-java:0.5.0" // Remote: + implementation "org.jetbrains.kotlinx:kotlinx-serialization-json:1.0.1" implementation "com.goterl.lazycode:lazysodium-android:4.2.0@aar" implementation "net.java.dev.jna:jna:5.5.0@aar" implementation "com.google.protobuf:protobuf-java:2.5.0" diff --git a/src/org/thoughtcrime/securesms/jobs/PushDecryptJob.java b/src/org/thoughtcrime/securesms/jobs/PushDecryptJob.java index 56bf2f2f16..2c3ca078ac 100644 --- a/src/org/thoughtcrime/securesms/jobs/PushDecryptJob.java +++ b/src/org/thoughtcrime/securesms/jobs/PushDecryptJob.java @@ -66,6 +66,7 @@ import org.thoughtcrime.securesms.linkpreview.LinkPreviewUtil; import org.thoughtcrime.securesms.logging.Log; import org.thoughtcrime.securesms.loki.activities.HomeActivity; import org.thoughtcrime.securesms.loki.api.SessionProtocolImpl; +import org.thoughtcrime.securesms.loki.database.LokiAPIDatabase; import org.thoughtcrime.securesms.loki.database.LokiMessageDatabase; import org.thoughtcrime.securesms.loki.database.LokiThreadDatabase; import org.thoughtcrime.securesms.loki.protocol.ClosedGroupsProtocol; @@ -126,6 +127,7 @@ import org.whispersystems.signalservice.api.messages.multidevice.StickerPackOper import org.whispersystems.signalservice.api.messages.multidevice.VerifiedMessage; import org.whispersystems.signalservice.api.messages.shared.SharedContact; import org.whispersystems.signalservice.api.push.SignalServiceAddress; +import org.whispersystems.signalservice.loki.api.crypto.SessionProtocol; import org.whispersystems.signalservice.loki.api.fileserver.FileServerAPI; import org.whispersystems.signalservice.loki.crypto.LokiServiceCipher; import org.whispersystems.signalservice.loki.protocol.mentions.MentionsManager; @@ -260,7 +262,8 @@ public class PushDecryptJob extends BaseJob implements InjectableType { SignalProtocolStore axolotlStore = new SignalProtocolStoreImpl(context); SessionResetProtocol sessionResetProtocol = new SessionResetImplementation(context); SignalServiceAddress localAddress = new SignalServiceAddress(TextSecurePreferences.getLocalNumber(context)); - LokiServiceCipher cipher = new LokiServiceCipher(localAddress, axolotlStore, DatabaseFactory.getSSKDatabase(context), new SessionProtocolImpl(context), sessionResetProtocol, UnidentifiedAccessUtil.getCertificateValidator()); + LokiAPIDatabase apiDB = DatabaseFactory.getLokiAPIDatabase(context); + LokiServiceCipher cipher = new LokiServiceCipher(localAddress, axolotlStore, DatabaseFactory.getSSKDatabase(context), new SessionProtocolImpl(context), sessionResetProtocol, apiDB, UnidentifiedAccessUtil.getCertificateValidator()); SignalServiceContent content = cipher.decrypt(envelope); @@ -380,6 +383,8 @@ public class PushDecryptJob extends BaseJob implements InjectableType { Log.i(TAG, "Dropping UD message from self."); } catch (IOException e) { Log.i(TAG, "IOException during message decryption."); + } catch (SessionProtocol.Exception e) { + Log.i(TAG, "Couldn't handle message due to error: " + e.getDescription()); } } diff --git a/src/org/thoughtcrime/securesms/loki/api/SessionProtocolImpl.kt b/src/org/thoughtcrime/securesms/loki/api/SessionProtocolImpl.kt index bc6ff3b029..e072b38d3b 100644 --- a/src/org/thoughtcrime/securesms/loki/api/SessionProtocolImpl.kt +++ b/src/org/thoughtcrime/securesms/loki/api/SessionProtocolImpl.kt @@ -11,12 +11,15 @@ import org.thoughtcrime.securesms.crypto.IdentityKeyUtil import org.thoughtcrime.securesms.database.DatabaseFactory import org.thoughtcrime.securesms.loki.utilities.KeyPairUtilities import org.thoughtcrime.securesms.util.TextSecurePreferences +import org.whispersystems.libsignal.IdentityKeyPair +import org.whispersystems.libsignal.ecc.ECKeyPair import org.whispersystems.libsignal.util.Hex import org.whispersystems.signalservice.api.messages.SignalServiceEnvelope import org.whispersystems.signalservice.internal.push.SignalServiceProtos.Envelope.Type.CLOSED_GROUP_CIPHERTEXT_VALUE import org.whispersystems.signalservice.internal.push.SignalServiceProtos.Envelope.Type.UNIDENTIFIED_SENDER_VALUE import org.whispersystems.signalservice.loki.api.SnodeAPI import org.whispersystems.signalservice.loki.api.crypto.SessionProtocol +import org.whispersystems.signalservice.loki.utilities.hexEncodedPublicKey import org.whispersystems.signalservice.loki.utilities.removing05PrefixIfNeeded import org.whispersystems.signalservice.loki.utilities.toHexString @@ -47,25 +50,9 @@ class SessionProtocolImpl(private val context: Context) : SessionProtocol { return ciphertext } - override fun decrypt(envelope: SignalServiceEnvelope): Pair { - val ciphertext = envelope.content ?: throw SessionProtocol.Exception.NoData - val recipientX25519PrivateKey: ByteArray - val recipientX25519PublicKey: ByteArray - when (envelope.type) { - UNIDENTIFIED_SENDER_VALUE -> { - recipientX25519PrivateKey = IdentityKeyUtil.getIdentityKeyPair(context).privateKey.serialize() - recipientX25519PublicKey = Hex.fromStringCondensed(TextSecurePreferences.getLocalNumber(context).removing05PrefixIfNeeded()) - } - CLOSED_GROUP_CIPHERTEXT_VALUE -> { - val hexEncodedGroupPublicKey = envelope.source - val sskDB = DatabaseFactory.getSSKDatabase(context) - if (!sskDB.isSSKBasedClosedGroup(hexEncodedGroupPublicKey)) { throw SessionProtocol.Exception.InvalidGroupPublicKey } - val hexEncodedGroupPrivateKey = sskDB.getClosedGroupPrivateKey(hexEncodedGroupPublicKey) ?: throw SessionProtocol.Exception.NoGroupPrivateKey - recipientX25519PrivateKey = Hex.fromStringCondensed(hexEncodedGroupPrivateKey) - recipientX25519PublicKey = Hex.fromStringCondensed(hexEncodedGroupPublicKey.removing05PrefixIfNeeded()) - } - else -> throw AssertionError() - } + override fun decrypt(ciphertext: ByteArray, x25519KeyPair: ECKeyPair): Pair { + val recipientX25519PrivateKey = x25519KeyPair.privateKey.serialize() + val recipientX25519PublicKey = Hex.fromStringCondensed(x25519KeyPair.hexEncodedPublicKey.removing05PrefixIfNeeded()) val sodium = LazySodiumAndroid(SodiumAndroid()) val signatureSize = Sign.BYTES val ed25519PublicKeySize = Sign.PUBLICKEYBYTES diff --git a/src/org/thoughtcrime/securesms/loki/database/LokiAPIDatabase.kt b/src/org/thoughtcrime/securesms/loki/database/LokiAPIDatabase.kt index 706a5d45ba..e43ae19bcf 100644 --- a/src/org/thoughtcrime/securesms/loki/database/LokiAPIDatabase.kt +++ b/src/org/thoughtcrime/securesms/loki/database/LokiAPIDatabase.kt @@ -10,6 +10,10 @@ import org.thoughtcrime.securesms.database.Database import org.thoughtcrime.securesms.database.helpers.SQLCipherOpenHelper import org.thoughtcrime.securesms.loki.utilities.* import org.thoughtcrime.securesms.util.TextSecurePreferences +import org.whispersystems.libsignal.IdentityKeyPair +import org.whispersystems.libsignal.ecc.DjbECPrivateKey +import org.whispersystems.libsignal.ecc.DjbECPublicKey +import org.whispersystems.libsignal.ecc.ECKeyPair import org.whispersystems.signalservice.api.messages.SignalServiceEnvelope import org.whispersystems.signalservice.loki.api.Snode import org.whispersystems.signalservice.loki.database.LokiAPIDatabaseProtocol @@ -392,6 +396,31 @@ class LokiAPIDatabase(context: Context, helper: SQLCipherOpenHelper) : Database( TextSecurePreferences.setLastSnodePoolRefreshDate(context, date) } + override fun getUserX25519KeyPair(): ECKeyPair { + val keyPair = IdentityKeyUtil.getIdentityKeyPair(context) + return ECKeyPair(DjbECPublicKey(keyPair.publicKey.serialize()), DjbECPrivateKey(keyPair.privateKey.serialize())) + } + + override fun getClosedGroupEncryptionKeyPairs(groupPublicKey: String): List { + return listOf() + } + + fun getLatestClosedGroupEncryptionKeyPair(groupPublicKey: String): ECKeyPair? { + return null + } + + fun addClosedGroupPublicKey(groupPublicKey: String) { + + } + + fun addClosedGroupEncryptionKeyPair(encryptionKeyPair: ECKeyPair, groupPublicKey: String) { + + } + + fun removeAllClosedGroupEncryptionKeyPairs(groupPublicKey: String) { + + } + // region Deprecated override fun getDeviceLinks(publicKey: String): Set { return setOf() diff --git a/src/org/thoughtcrime/securesms/loki/protocol/ClosedGroupUpdateMessageSendJobV2.kt b/src/org/thoughtcrime/securesms/loki/protocol/ClosedGroupUpdateMessageSendJobV2.kt new file mode 100644 index 0000000000..fb8706b496 --- /dev/null +++ b/src/org/thoughtcrime/securesms/loki/protocol/ClosedGroupUpdateMessageSendJobV2.kt @@ -0,0 +1,187 @@ +package org.thoughtcrime.securesms.loki.protocol + +import com.google.protobuf.ByteString +import kotlinx.serialization.Serializable +import kotlinx.serialization.decodeFromString +import kotlinx.serialization.encodeToString +import kotlinx.serialization.json.Json +import org.thoughtcrime.securesms.ApplicationContext +import org.thoughtcrime.securesms.crypto.UnidentifiedAccessUtil +import org.thoughtcrime.securesms.crypto.storage.SignalProtocolStoreImpl +import org.thoughtcrime.securesms.jobmanager.Data +import org.thoughtcrime.securesms.jobmanager.Job +import org.thoughtcrime.securesms.jobmanager.impl.NetworkConstraint +import org.thoughtcrime.securesms.jobs.BaseJob +import org.thoughtcrime.securesms.logging.Log +import org.thoughtcrime.securesms.loki.utilities.recipient +import org.thoughtcrime.securesms.util.Hex +import org.whispersystems.libsignal.SignalProtocolAddress +import org.whispersystems.libsignal.ecc.DjbECPrivateKey +import org.whispersystems.libsignal.ecc.DjbECPublicKey +import org.whispersystems.libsignal.ecc.ECKeyPair +import org.whispersystems.libsignal.ecc.ECPublicKey +import org.whispersystems.signalservice.api.push.SignalServiceAddress +import org.whispersystems.signalservice.internal.push.SignalServiceProtos +import org.whispersystems.signalservice.loki.protocol.closedgroups.ClosedGroupSenderKey +import org.whispersystems.signalservice.loki.protocol.meta.TTLUtilities +import org.whispersystems.signalservice.loki.utilities.toHexString +import java.util.* +import java.util.concurrent.TimeUnit + +class ClosedGroupUpdateMessageSendJobV2 private constructor(parameters: Parameters, private val destination: String, private val kind: Kind) : BaseJob(parameters) { + + sealed class Kind { + class New(val publicKey: ByteArray, val name: String, val encryptionKeyPair: ECKeyPair, val members: Collection, val admins: Collection) : Kind() + class Update(val name: String, val members: Collection) : Kind() + class EncryptionKeyPair(val wrappers: Collection) : Kind() // The new encryption key pair encrypted for each member individually + } + + companion object { + const val KEY = "ClosedGroupUpdateMessageSendJobV2" + } + + @Serializable + data class KeyPairWrapper(val publicKey: String, val encryptedKeyPair: ByteArray) { + + companion object { + + fun fromProto(proto: SignalServiceProtos.ClosedGroupUpdateV2.KeyPairWrapper): KeyPairWrapper { + return KeyPairWrapper(proto.publicKey.toString(), proto.encryptedKeyPair.toByteArray()) + } + } + + fun toProto(): SignalServiceProtos.ClosedGroupUpdateV2.KeyPairWrapper { + val result = SignalServiceProtos.ClosedGroupUpdateV2.KeyPairWrapper.newBuilder() + result.publicKey = ByteString.copyFrom(Hex.fromStringCondensed(publicKey)) + result.encryptedKeyPair = ByteString.copyFrom(encryptedKeyPair) + return result.build() + } + } + + constructor(destination: String, kind: Kind) : this(Parameters.Builder() + .addConstraint(NetworkConstraint.KEY) + .setQueue(KEY) + .setLifespan(TimeUnit.DAYS.toMillis(1)) + .setMaxAttempts(20) + .build(), + destination, + kind) + + override fun getFactoryKey(): String { return KEY } + + override fun serialize(): Data { + val builder = Data.Builder() + builder.putString("destination", destination) + when (kind) { + is Kind.New -> { + builder.putString("kind", "New") + builder.putByteArray("publicKey", kind.publicKey) + builder.putString("name", kind.name) + builder.putByteArray("encryptionKeyPairPublicKey", kind.encryptionKeyPair.publicKey.serialize()) + builder.putByteArray("encryptionKeyPairPrivateKey", kind.encryptionKeyPair.privateKey.serialize()) + val members = kind.members.joinToString(" - ") { it.toHexString() } + builder.putString("members", members) + val admins = kind.admins.joinToString(" - ") { it.toHexString() } + builder.putString("admins", admins) + } + is Kind.Update -> { + builder.putString("kind", "Update") + builder.putString("name", kind.name) + val members = kind.members.joinToString(" - ") { it.toHexString() } + builder.putString("members", members) + } + is Kind.EncryptionKeyPair -> { + builder.putString("kind", "EncryptionKeyPair") + val wrappers = kind.wrappers.joinToString(" - ") { Json.encodeToString(it) } + builder.putString("wrappers", wrappers) + } + } + return builder.build() + } + + class Factory : Job.Factory { + + override fun create(parameters: Parameters, data: Data): ClosedGroupUpdateMessageSendJobV2 { + val destination = data.getString("destination") + val rawKind = data.getString("kind") + val kind: Kind + when (rawKind) { + "New" -> { + val publicKey = data.getByteArray("publicKey") + val name = data.getString("name") + val encryptionKeyPairPublicKey = data.getByteArray("encryptionKeyPairPublicKey") + val encryptionKeyPairPrivateKey = data.getByteArray("encryptionKeyPairPrivateKey") + val encryptionKeyPair = ECKeyPair(DjbECPublicKey(encryptionKeyPairPublicKey), DjbECPrivateKey(encryptionKeyPairPrivateKey)) + val members = data.getString("members").split(" - ").map { Hex.fromStringCondensed(it) } + val admins = data.getString("admins").split(" - ").map { Hex.fromStringCondensed(it) } + kind = Kind.New(publicKey, name, encryptionKeyPair, members, admins) + } + "Update" -> { + val name = data.getString("name") + val members = data.getString("members").split(" - ").map { Hex.fromStringCondensed(it) } + kind = Kind.Update(name, members) + } + "EncryptionKeyPair" -> { + val wrappers: Collection = data.getString("wrappers").split(" - ").map { Json.decodeFromString(it) } + kind = Kind.EncryptionKeyPair(wrappers) + } + else -> throw Exception("Invalid closed group update message kind: $rawKind.") + } + return ClosedGroupUpdateMessageSendJobV2(parameters, destination, kind) + } + } + + public override fun onRun() { + val contentMessage = SignalServiceProtos.Content.newBuilder() + val dataMessage = SignalServiceProtos.DataMessage.newBuilder() + val closedGroupUpdate = SignalServiceProtos.ClosedGroupUpdateV2.newBuilder() + when (kind) { + is Kind.New -> { + closedGroupUpdate.type = SignalServiceProtos.ClosedGroupUpdateV2.Type.NEW + closedGroupUpdate.publicKey = ByteString.copyFrom(kind.publicKey) + closedGroupUpdate.name = kind.name + val encryptionKeyPair = SignalServiceProtos.ClosedGroupUpdateV2.KeyPair.newBuilder() + encryptionKeyPair.publicKey = ByteString.copyFrom(kind.encryptionKeyPair.publicKey.serialize()) + encryptionKeyPair.privateKey = ByteString.copyFrom(kind.encryptionKeyPair.privateKey.serialize()) + closedGroupUpdate.encryptionKeyPair = encryptionKeyPair.build() + closedGroupUpdate.addAllMembers(kind.members.map { ByteString.copyFrom(it) }) + closedGroupUpdate.addAllAdmins(kind.admins.map { ByteString.copyFrom(it) }) + } + is Kind.Update -> { + closedGroupUpdate.type = SignalServiceProtos.ClosedGroupUpdateV2.Type.UPDATE + closedGroupUpdate.name = kind.name + closedGroupUpdate.addAllMembers(kind.members.map { ByteString.copyFrom(it) }) + } + is Kind.EncryptionKeyPair -> { + closedGroupUpdate.type = SignalServiceProtos.ClosedGroupUpdateV2.Type.ENCRYPTION_KEY_PAIR + closedGroupUpdate.addAllWrappers(kind.wrappers.map { it.toProto() }) + } + } + dataMessage.closedGroupUpdateV2 = closedGroupUpdate.build() + contentMessage.dataMessage = dataMessage.build() + val serializedContentMessage = contentMessage.build().toByteArray() + val messageSender = ApplicationContext.getInstance(context).communicationModule.provideSignalMessageSender() + val address = SignalServiceAddress(destination) + val recipient = recipient(context, destination) + val udAccess = UnidentifiedAccessUtil.getAccessFor(context, recipient) + val ttl: Int + when (kind) { + is Kind.EncryptionKeyPair -> ttl = 4 * 24 * 60 * 60 * 1000 + else -> ttl = TTLUtilities.getTTL(TTLUtilities.MessageType.ClosedGroupUpdate) + } + try { + // isClosedGroup can always be false as it's only used in the context of legacy closed groups + messageSender.sendMessage(0, address, udAccess.get().targetUnidentifiedAccess, + Date().time, serializedContentMessage, false, ttl, false, + true, false, false, false) + } catch (e: Exception) { + Log.d("Loki", "Failed to send closed group update message to: $destination due to error: $e.") + } + } + + public override fun onShouldRetry(e: Exception): Boolean { + return true + } + + override fun onCanceled() { } +} diff --git a/src/org/thoughtcrime/securesms/loki/protocol/ClosedGroupsProtocolV2.kt b/src/org/thoughtcrime/securesms/loki/protocol/ClosedGroupsProtocolV2.kt new file mode 100644 index 0000000000..0eddec98e3 --- /dev/null +++ b/src/org/thoughtcrime/securesms/loki/protocol/ClosedGroupsProtocolV2.kt @@ -0,0 +1,350 @@ +package org.thoughtcrime.securesms.loki.protocol + +import android.content.Context +import android.util.Log +import com.google.protobuf.ByteString +import nl.komponents.kovenant.Promise +import nl.komponents.kovenant.deferred +import org.thoughtcrime.securesms.ApplicationContext +import org.thoughtcrime.securesms.database.Address +import org.thoughtcrime.securesms.database.DatabaseFactory +import org.thoughtcrime.securesms.loki.api.LokiPushNotificationManager +import org.thoughtcrime.securesms.loki.api.LokiPushNotificationManager.ClosedGroupOperation +import org.thoughtcrime.securesms.loki.utilities.recipient +import org.thoughtcrime.securesms.mms.OutgoingGroupMediaMessage +import org.thoughtcrime.securesms.recipients.Recipient +import org.thoughtcrime.securesms.sms.IncomingGroupMessage +import org.thoughtcrime.securesms.sms.IncomingTextMessage +import org.thoughtcrime.securesms.sms.MessageSender +import org.thoughtcrime.securesms.util.GroupUtil +import org.thoughtcrime.securesms.util.Hex +import org.thoughtcrime.securesms.util.TextSecurePreferences +import org.whispersystems.libsignal.ecc.Curve +import org.whispersystems.libsignal.ecc.DjbECPrivateKey +import org.whispersystems.libsignal.ecc.DjbECPublicKey +import org.whispersystems.libsignal.ecc.ECKeyPair +import org.whispersystems.libsignal.util.guava.Optional +import org.whispersystems.signalservice.api.messages.SignalServiceGroup +import org.whispersystems.signalservice.api.messages.SignalServiceGroup.GroupType +import org.whispersystems.signalservice.internal.push.SignalServiceProtos +import org.whispersystems.signalservice.internal.push.SignalServiceProtos.GroupContext +import org.whispersystems.signalservice.loki.protocol.closedgroups.ClosedGroupRatchet +import org.whispersystems.signalservice.loki.protocol.closedgroups.ClosedGroupRatchetCollectionType +import org.whispersystems.signalservice.loki.protocol.closedgroups.ClosedGroupSenderKey +import org.whispersystems.signalservice.loki.protocol.closedgroups.SharedSenderKeysImplementation +import org.whispersystems.signalservice.loki.utilities.hexEncodedPrivateKey +import org.whispersystems.signalservice.loki.utilities.hexEncodedPublicKey +import org.whispersystems.signalservice.loki.utilities.toHexString +import java.io.IOException +import java.util.* +import kotlin.jvm.Throws + +object ClosedGroupsProtocolV2 { + val groupSizeLimit = 20 + + sealed class Error(val description: String) : Exception() { + object NoThread : Error("Couldn't find a thread associated with the given group public key") + object NoKeyPair : Error("Couldn't find an encryption key pair associated with the given group public key.") + object InvalidUpdate : Error("Invalid group update.") + } + + public fun createClosedGroup(context: Context, name: String, members: Collection): Promise { + val deferred = deferred() + Thread { + // Prepare + val userPublicKey = TextSecurePreferences.getLocalNumber(context) + val membersAsData = members.map { Hex.fromStringCondensed(it) } + val apiDB = DatabaseFactory.getLokiAPIDatabase(context) + // Generate the group's public key + val groupPublicKey = Curve.generateKeyPair().hexEncodedPublicKey // Includes the "05" prefix + // Generate the key pair that'll be used for encryption and decryption + val encryptionKeyPair = Curve.generateKeyPair() + // Create the group + val groupID = doubleEncodeGroupID(groupPublicKey) + val admins = setOf( userPublicKey ) + val adminsAsData = admins.map { Hex.fromStringCondensed(it) } + DatabaseFactory.getGroupDatabase(context).create(groupID, name, LinkedList(members.map { Address.fromSerialized(it) }), + null, null, LinkedList(admins.map { Address.fromSerialized(it) })) + DatabaseFactory.getRecipientDatabase(context).setProfileSharing(Recipient.from(context, Address.fromSerialized(groupID), false), true) + // Send a closed group update message to all members individually + val closedGroupUpdateKind = ClosedGroupUpdateMessageSendJobV2.Kind.New(Hex.fromStringCondensed(groupPublicKey), name, encryptionKeyPair, membersAsData, adminsAsData) + for (member in members) { + if (member == userPublicKey) { continue } + val job = ClosedGroupUpdateMessageSendJobV2(member, closedGroupUpdateKind) + job.setContext(context) + job.onRun() // Run the job immediately to make all of this sync + } + // Add the group to the user's set of public keys to poll for + apiDB.addClosedGroupPublicKey(groupPublicKey) + // Store the encryption key pair + apiDB.addClosedGroupEncryptionKeyPair(encryptionKeyPair, groupPublicKey) + // Notify the user + val threadID = DatabaseFactory.getThreadDatabase(context).getOrCreateThreadIdFor(Recipient.from(context, Address.fromSerialized(groupID), false)) + insertOutgoingInfoMessage(context, groupID, GroupContext.Type.UPDATE, name, members, admins, threadID) + // Notify the PN server + LokiPushNotificationManager.performOperation(context, ClosedGroupOperation.Subscribe, groupPublicKey, userPublicKey) + // Fulfill the promise + deferred.resolve(groupID) + }.start() + // Return + return deferred.promise + } + + @JvmStatic + public fun leave(context: Context, groupPublicKey: String) { + val userPublicKey = TextSecurePreferences.getLocalNumber(context) + val groupDB = DatabaseFactory.getGroupDatabase(context) + val groupID = doubleEncodeGroupID(groupPublicKey) + val group = groupDB.getGroup(groupID).orNull() + if (group == null) { + Log.d("Loki", "Can't leave nonexistent closed group.") + return + } + val name = group.title + val oldMembers = group.members.map { it.serialize() }.toSet() + val newMembers = oldMembers.minus(userPublicKey) + return update(context, groupPublicKey, newMembers, name).get() + } + + public fun update(context: Context, groupPublicKey: String, members: Collection, name: String): Promise { + val deferred = deferred() + Thread { + val userPublicKey = TextSecurePreferences.getLocalNumber(context) + val apiDB = DatabaseFactory.getLokiAPIDatabase(context) + val groupDB = DatabaseFactory.getGroupDatabase(context) + val groupID = doubleEncodeGroupID(groupPublicKey) + val group = groupDB.getGroup(groupID).orNull() + if (group == null) { + Log.d("Loki", "Can't update nonexistent closed group.") + return@Thread deferred.reject(Error.NoThread) + } + val oldMembers = group.members.map { it.serialize() }.toSet() + val newMembers = members.minus(oldMembers) + val membersAsData = members.map { Hex.fromStringCondensed(it) } + val admins = group.admins.map { it.serialize() } + val adminsAsData = admins.map { Hex.fromStringCondensed(it) } + val encryptionKeyPair = apiDB.getLatestClosedGroupEncryptionKeyPair(groupPublicKey) + if (encryptionKeyPair == null) { + Log.d("Loki", "Couldn't get encryption key pair for closed group.") + return@Thread deferred.reject(Error.NoKeyPair) + } + val removedMembers = oldMembers.minus(members) + if (removedMembers.contains(admins.first())) { + Log.d("Loki", "Can't remove admin from closed group.") + return@Thread deferred.reject(Error.InvalidUpdate) + } + val isUserLeaving = removedMembers.contains(userPublicKey) + if (isUserLeaving && (removedMembers.count() != 1 || newMembers.isNotEmpty())) { + Log.d("Loki", "Can't remove self and add or remove others simultaneously.") + return@Thread deferred.reject(Error.InvalidUpdate) + } + // Send the update to the group + @Suppress("NAME_SHADOWING") + val closedGroupUpdateKind = ClosedGroupUpdateMessageSendJobV2.Kind.Update(name, membersAsData) + @Suppress("NAME_SHADOWING") + val job = ClosedGroupUpdateMessageSendJobV2(groupPublicKey, closedGroupUpdateKind) + job.setContext(context) + job.onRun() // Run the job immediately + if (isUserLeaving) { + // Remove the group private key and unsubscribe from PNs + apiDB.removeAllClosedGroupEncryptionKeyPairs(groupPublicKey) + // Mark the group as inactive + groupDB.setActive(groupID, false) + groupDB.removeMember(groupID, Address.fromSerialized(userPublicKey)) + // Notify the PN server + LokiPushNotificationManager.performOperation(context, ClosedGroupOperation.Unsubscribe, groupPublicKey, userPublicKey) + } else { + // Generate and distribute a new encryption key pair if needed + val wasAnyUserRemoved = removedMembers.isNotEmpty() + val isCurrentUserAdmin = admins.contains(userPublicKey) + if (wasAnyUserRemoved && isCurrentUserAdmin) { + generateAndSendNewEncryptionKeyPair(context, groupPublicKey, members.minus(newMembers)) + } + // Send closed group update messages to any new members individually + for (member in newMembers) { + @Suppress("NAME_SHADOWING") + val closedGroupUpdateKind = ClosedGroupUpdateMessageSendJobV2.Kind.New(Hex.fromStringCondensed(groupPublicKey), name, encryptionKeyPair, membersAsData, adminsAsData) + @Suppress("NAME_SHADOWING") + val job = ClosedGroupUpdateMessageSendJobV2(member, closedGroupUpdateKind) + ApplicationContext.getInstance(context).jobManager.add(job) + } + } + // Update the group + groupDB.updateTitle(groupID, name) + if (!isUserLeaving) { + // The call below sets isActive to true, so if the user is leaving we have to use groupDB.remove(...) instead + groupDB.updateMembers(groupID, members.map { Address.fromSerialized(it) }) + } + // Notify the user + val infoType = if (isUserLeaving) GroupContext.Type.QUIT else GroupContext.Type.UPDATE + val threadID = DatabaseFactory.getThreadDatabase(context).getOrCreateThreadIdFor(Recipient.from(context, Address.fromSerialized(groupID), false)) + insertOutgoingInfoMessage(context, groupID, infoType, name, members, admins, threadID) + deferred.resolve(Unit) + }.start() + return deferred.promise + } + + fun generateAndSendNewEncryptionKeyPair(context: Context, groupPublicKey: String, targetMembers: Collection) { + + } + + @JvmStatic + public fun handleSharedSenderKeysUpdate(context: Context, closedGroupUpdate: SignalServiceProtos.ClosedGroupUpdateV2, senderPublicKey: String) { + if (!isValid(closedGroupUpdate)) { return; } + when (closedGroupUpdate.type) { + SignalServiceProtos.ClosedGroupUpdateV2.Type.NEW -> handleNewClosedGroup(context, closedGroupUpdate, senderPublicKey) + SignalServiceProtos.ClosedGroupUpdateV2.Type.UPDATE -> handleClosedGroupUpdate(context, closedGroupUpdate, senderPublicKey) + SignalServiceProtos.ClosedGroupUpdateV2.Type.ENCRYPTION_KEY_PAIR -> handleGroupEncryptionKeyPair(context, closedGroupUpdate, senderPublicKey) + else -> { + // Do nothing + } + } + } + + private fun isValid(closedGroupUpdate: SignalServiceProtos.ClosedGroupUpdateV2): Boolean { + if (closedGroupUpdate.publicKey.isEmpty) { return false } + when (closedGroupUpdate.type) { + SignalServiceProtos.ClosedGroupUpdateV2.Type.NEW -> { + return !closedGroupUpdate.name.isNullOrEmpty() && !(closedGroupUpdate.encryptionKeyPair.privateKey ?: ByteString.copyFrom(ByteArray(0))).isEmpty + && !(closedGroupUpdate.encryptionKeyPair.publicKey ?: ByteString.copyFrom(ByteArray(0))).isEmpty && closedGroupUpdate.membersCount > 0 && closedGroupUpdate.adminsCount > 0 + } + SignalServiceProtos.ClosedGroupUpdateV2.Type.UPDATE -> { + return !closedGroupUpdate.name.isNullOrEmpty() && closedGroupUpdate.membersCount > 0 + } + SignalServiceProtos.ClosedGroupUpdateV2.Type.ENCRYPTION_KEY_PAIR -> return true + else -> return false + } + } + + public fun handleNewClosedGroup(context: Context, closedGroupUpdate: SignalServiceProtos.ClosedGroupUpdateV2, senderPublicKey: String) { + // Prepare + val userPublicKey = TextSecurePreferences.getLocalNumber(context) + val apiDB = DatabaseFactory.getLokiAPIDatabase(context) + // Unwrap the message + val groupPublicKey = closedGroupUpdate.publicKey.toByteArray().toHexString() + val name = closedGroupUpdate.name + val encryptionKeyPairAsProto = closedGroupUpdate.encryptionKeyPair + val members = closedGroupUpdate.membersList.map { it.toByteArray().toHexString() } + val admins = closedGroupUpdate.adminsList.map { it.toByteArray().toHexString() } + // Create the group + val groupID = doubleEncodeGroupID(groupPublicKey) + val groupDB = DatabaseFactory.getGroupDatabase(context) + if (groupDB.getGroup(groupID).orNull() != null) { + // Update the group + groupDB.updateTitle(groupID, name) + groupDB.updateMembers(groupID, members.map { Address.fromSerialized(it) }) + } else { + groupDB.create(groupID, name, LinkedList(members.map { Address.fromSerialized(it) }), + null, null, LinkedList(admins.map { Address.fromSerialized(it) })) + } + DatabaseFactory.getRecipientDatabase(context).setProfileSharing(Recipient.from(context, Address.fromSerialized(groupID), false), true) + // Add the group to the user's set of public keys to poll for + apiDB.addClosedGroupPublicKey(groupPublicKey) + // Store the encryption key pair + val encryptionKeyPair = ECKeyPair(DjbECPublicKey(encryptionKeyPairAsProto.publicKey.toByteArray()), DjbECPrivateKey(encryptionKeyPairAsProto.privateKey.toByteArray())) + apiDB.addClosedGroupEncryptionKeyPair(encryptionKeyPair, groupPublicKey) + // Notify the user + insertIncomingInfoMessage(context, senderPublicKey, groupID, GroupContext.Type.UPDATE, SignalServiceGroup.Type.UPDATE, name, members, admins) + // Notify the PN server + LokiPushNotificationManager.performOperation(context, ClosedGroupOperation.Subscribe, groupPublicKey, userPublicKey) + } + + public fun handleClosedGroupUpdate(context: Context, closedGroupUpdate: SignalServiceProtos.ClosedGroupUpdateV2, senderPublicKey: String) { + // Prepare + val userPublicKey = TextSecurePreferences.getLocalNumber(context) + val apiDB = DatabaseFactory.getLokiAPIDatabase(context) + // Unwrap the message + val groupPublicKey = closedGroupUpdate.publicKey.toByteArray().toHexString() + val name = closedGroupUpdate.name + val members = closedGroupUpdate.membersList.map { it.toByteArray().toHexString() } + val groupDB = DatabaseFactory.getGroupDatabase(context) + val groupID = doubleEncodeGroupID(groupPublicKey) + val group = groupDB.getGroup(groupID).orNull() + if (group == null) { + Log.d("Loki", "Ignoring closed group info message for nonexistent group.") + return + } + val oldMembers = group.members.map { it.serialize() } + val newMembers = members.toSet().minus(oldMembers) + // Check that the sender is a member of the group (before the update) + if (!oldMembers.contains(senderPublicKey)) { + Log.d("Loki", "Ignoring closed group info message from non-member.") + return + } + // Remove the group from the user's set of public keys to poll for if the current user was removed + val wasCurrentUserRemoved = !members.contains(userPublicKey) + if (wasCurrentUserRemoved) { + apiDB.removeAllClosedGroupEncryptionKeyPairs(groupPublicKey) + // Mark the group as inactive + groupDB.setActive(groupID, false) + groupDB.removeMember(groupID, Address.fromSerialized(userPublicKey)) + // Notify the PN server + LokiPushNotificationManager.performOperation(context, ClosedGroupOperation.Unsubscribe, groupPublicKey, userPublicKey) + } + // Generate and distribute a new encryption key pair if needed + val wasAnyUserRemoved = (members.toSet().intersect(oldMembers) != oldMembers.toSet()) + val isCurrentUserAdmin = group.admins.map { it.toPhoneString() }.contains(userPublicKey) + if (wasAnyUserRemoved && isCurrentUserAdmin) { + generateAndSendNewEncryptionKeyPair(context, groupPublicKey, members.minus(newMembers)) + } + // Update the group + groupDB.updateTitle(groupID, name) + if (!wasCurrentUserRemoved) { + // The call below sets isActive to true, so if the user is leaving we have to use groupDB.remove(...) instead + groupDB.updateMembers(groupID, members.map { Address.fromSerialized(it) }) + } + // Notify the user + val wasSenderRemoved = !members.contains(senderPublicKey) + val type0 = if (wasSenderRemoved) GroupContext.Type.QUIT else GroupContext.Type.UPDATE + val type1 = if (wasSenderRemoved) SignalServiceGroup.Type.QUIT else SignalServiceGroup.Type.UPDATE + insertIncomingInfoMessage(context, senderPublicKey, groupID, type0, type1, name, members, group.admins.map { it.toPhoneString() }) + } + + private fun handleGroupEncryptionKeyPair(context: Context, closedGroupUpdate: SignalServiceProtos.ClosedGroupUpdateV2, senderPublicKey: String) { + + } + + private fun insertIncomingInfoMessage(context: Context, senderPublicKey: String, groupID: String, type0: GroupContext.Type, type1: SignalServiceGroup.Type, + name: String, members: Collection, admins: Collection) { + val groupContextBuilder = GroupContext.newBuilder() + .setId(ByteString.copyFrom(GroupUtil.getDecodedId(groupID))) + .setType(type0) + .setName(name) + .addAllMembers(members) + .addAllAdmins(admins) + val group = SignalServiceGroup(type1, GroupUtil.getDecodedId(groupID), GroupType.SIGNAL, name, members.toList(), null, admins.toList()) + val m = IncomingTextMessage(Address.fromSerialized(senderPublicKey), 1, System.currentTimeMillis(), "", Optional.of(group), 0, true) + val infoMessage = IncomingGroupMessage(m, groupContextBuilder.build(), "") + val smsDB = DatabaseFactory.getSmsDatabase(context) + smsDB.insertMessageInbox(infoMessage) + } + + private fun insertOutgoingInfoMessage(context: Context, groupID: String, type: GroupContext.Type, name: String, + members: Collection, admins: Collection, threadID: Long) { + val recipient = Recipient.from(context, Address.fromSerialized(groupID), false) + val groupContextBuilder = GroupContext.newBuilder() + .setId(ByteString.copyFrom(GroupUtil.getDecodedId(groupID))) + .setType(type) + .setName(name) + .addAllMembers(members) + .addAllAdmins(admins) + val infoMessage = OutgoingGroupMediaMessage(recipient, groupContextBuilder.build(), null, System.currentTimeMillis(), 0, null, listOf(), listOf()) + val mmsDB = DatabaseFactory.getMmsDatabase(context) + val infoMessageID = mmsDB.insertMessageOutbox(infoMessage, threadID, false, null) + mmsDB.markAsSent(infoMessageID, true) + } + + // NOTE: Signal group ID handling is weird. The ID is double encoded in the database, but not in a `GroupContext`. + + @JvmStatic + @Throws(IOException::class) + public fun doubleEncodeGroupID(groupPublicKey: String): String { + return GroupUtil.getEncodedId(GroupUtil.getEncodedId(Hex.fromStringCondensed(groupPublicKey), false).toByteArray(), false) + } + + @JvmStatic + @Throws(IOException::class) + public fun doubleDecodeGroupID(groupID: String): ByteArray { + return GroupUtil.getDecodedId(GroupUtil.getDecodedStringId(groupID)) + } +} \ No newline at end of file