Implement rough V2 session protocol

This commit is contained in:
Niels Andriesse
2021-01-06 13:52:06 +11:00
parent 995164ab29
commit 688ec6b7ad
6 changed files with 581 additions and 20 deletions

View File

@@ -12,6 +12,7 @@ buildscript {
classpath "com.android.tools.build:gradle:4.0.1" classpath "com.android.tools.build:gradle:4.0.1"
classpath files('libs/gradle-witness.jar') classpath files('libs/gradle-witness.jar')
classpath "org.jetbrains.kotlin:kotlin-gradle-plugin:$kotlin_version" 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" classpath "com.google.gms:google-services:4.3.3"
} }
} }
@@ -22,6 +23,7 @@ apply plugin: 'kotlin-android-extensions'
apply plugin: 'witness' apply plugin: 'witness'
apply plugin: 'kotlin-kapt' apply plugin: 'kotlin-kapt'
apply plugin: 'com.google.gms.google-services' apply plugin: 'com.google.gms.google-services'
apply plugin: 'kotlinx-serialization'
repositories { repositories {
mavenLocal() 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:signal-service-android:2.13.2" // Run ./gradlew install from session-android-service to install
implementation "org.whispersystems:curve25519-java:0.5.0" implementation "org.whispersystems:curve25519-java:0.5.0"
// Remote: // Remote:
implementation "org.jetbrains.kotlinx:kotlinx-serialization-json:1.0.1"
implementation "com.goterl.lazycode:lazysodium-android:4.2.0@aar" implementation "com.goterl.lazycode:lazysodium-android:4.2.0@aar"
implementation "net.java.dev.jna:jna:5.5.0@aar" implementation "net.java.dev.jna:jna:5.5.0@aar"
implementation "com.google.protobuf:protobuf-java:2.5.0" implementation "com.google.protobuf:protobuf-java:2.5.0"

View File

@@ -66,6 +66,7 @@ import org.thoughtcrime.securesms.linkpreview.LinkPreviewUtil;
import org.thoughtcrime.securesms.logging.Log; import org.thoughtcrime.securesms.logging.Log;
import org.thoughtcrime.securesms.loki.activities.HomeActivity; import org.thoughtcrime.securesms.loki.activities.HomeActivity;
import org.thoughtcrime.securesms.loki.api.SessionProtocolImpl; 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.LokiMessageDatabase;
import org.thoughtcrime.securesms.loki.database.LokiThreadDatabase; import org.thoughtcrime.securesms.loki.database.LokiThreadDatabase;
import org.thoughtcrime.securesms.loki.protocol.ClosedGroupsProtocol; 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.multidevice.VerifiedMessage;
import org.whispersystems.signalservice.api.messages.shared.SharedContact; import org.whispersystems.signalservice.api.messages.shared.SharedContact;
import org.whispersystems.signalservice.api.push.SignalServiceAddress; 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.api.fileserver.FileServerAPI;
import org.whispersystems.signalservice.loki.crypto.LokiServiceCipher; import org.whispersystems.signalservice.loki.crypto.LokiServiceCipher;
import org.whispersystems.signalservice.loki.protocol.mentions.MentionsManager; import org.whispersystems.signalservice.loki.protocol.mentions.MentionsManager;
@@ -260,7 +262,8 @@ public class PushDecryptJob extends BaseJob implements InjectableType {
SignalProtocolStore axolotlStore = new SignalProtocolStoreImpl(context); SignalProtocolStore axolotlStore = new SignalProtocolStoreImpl(context);
SessionResetProtocol sessionResetProtocol = new SessionResetImplementation(context); SessionResetProtocol sessionResetProtocol = new SessionResetImplementation(context);
SignalServiceAddress localAddress = new SignalServiceAddress(TextSecurePreferences.getLocalNumber(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); SignalServiceContent content = cipher.decrypt(envelope);
@@ -380,6 +383,8 @@ public class PushDecryptJob extends BaseJob implements InjectableType {
Log.i(TAG, "Dropping UD message from self."); Log.i(TAG, "Dropping UD message from self.");
} catch (IOException e) { } catch (IOException e) {
Log.i(TAG, "IOException during message decryption."); Log.i(TAG, "IOException during message decryption.");
} catch (SessionProtocol.Exception e) {
Log.i(TAG, "Couldn't handle message due to error: " + e.getDescription());
} }
} }

View File

@@ -11,12 +11,15 @@ import org.thoughtcrime.securesms.crypto.IdentityKeyUtil
import org.thoughtcrime.securesms.database.DatabaseFactory import org.thoughtcrime.securesms.database.DatabaseFactory
import org.thoughtcrime.securesms.loki.utilities.KeyPairUtilities import org.thoughtcrime.securesms.loki.utilities.KeyPairUtilities
import org.thoughtcrime.securesms.util.TextSecurePreferences 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.libsignal.util.Hex
import org.whispersystems.signalservice.api.messages.SignalServiceEnvelope 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.CLOSED_GROUP_CIPHERTEXT_VALUE
import org.whispersystems.signalservice.internal.push.SignalServiceProtos.Envelope.Type.UNIDENTIFIED_SENDER_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.SnodeAPI
import org.whispersystems.signalservice.loki.api.crypto.SessionProtocol 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.removing05PrefixIfNeeded
import org.whispersystems.signalservice.loki.utilities.toHexString import org.whispersystems.signalservice.loki.utilities.toHexString
@@ -47,25 +50,9 @@ class SessionProtocolImpl(private val context: Context) : SessionProtocol {
return ciphertext return ciphertext
} }
override fun decrypt(envelope: SignalServiceEnvelope): Pair<ByteArray, String> { override fun decrypt(ciphertext: ByteArray, x25519KeyPair: ECKeyPair): Pair<ByteArray, String> {
val ciphertext = envelope.content ?: throw SessionProtocol.Exception.NoData val recipientX25519PrivateKey = x25519KeyPair.privateKey.serialize()
val recipientX25519PrivateKey: ByteArray val recipientX25519PublicKey = Hex.fromStringCondensed(x25519KeyPair.hexEncodedPublicKey.removing05PrefixIfNeeded())
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()
}
val sodium = LazySodiumAndroid(SodiumAndroid()) val sodium = LazySodiumAndroid(SodiumAndroid())
val signatureSize = Sign.BYTES val signatureSize = Sign.BYTES
val ed25519PublicKeySize = Sign.PUBLICKEYBYTES val ed25519PublicKeySize = Sign.PUBLICKEYBYTES

View File

@@ -10,6 +10,10 @@ import org.thoughtcrime.securesms.database.Database
import org.thoughtcrime.securesms.database.helpers.SQLCipherOpenHelper import org.thoughtcrime.securesms.database.helpers.SQLCipherOpenHelper
import org.thoughtcrime.securesms.loki.utilities.* import org.thoughtcrime.securesms.loki.utilities.*
import org.thoughtcrime.securesms.util.TextSecurePreferences 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.api.messages.SignalServiceEnvelope
import org.whispersystems.signalservice.loki.api.Snode import org.whispersystems.signalservice.loki.api.Snode
import org.whispersystems.signalservice.loki.database.LokiAPIDatabaseProtocol import org.whispersystems.signalservice.loki.database.LokiAPIDatabaseProtocol
@@ -392,6 +396,31 @@ class LokiAPIDatabase(context: Context, helper: SQLCipherOpenHelper) : Database(
TextSecurePreferences.setLastSnodePoolRefreshDate(context, date) 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<ECKeyPair> {
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 // region Deprecated
override fun getDeviceLinks(publicKey: String): Set<DeviceLink> { override fun getDeviceLinks(publicKey: String): Set<DeviceLink> {
return setOf() return setOf()

View File

@@ -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<ByteArray>, val admins: Collection<ByteArray>) : Kind()
class Update(val name: String, val members: Collection<ByteArray>) : Kind()
class EncryptionKeyPair(val wrappers: Collection<KeyPairWrapper>) : 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<ClosedGroupUpdateMessageSendJobV2> {
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<KeyPairWrapper> = 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() { }
}

View File

@@ -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<String>): Promise<String, Exception> {
val deferred = deferred<String, Exception>()
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<String>, name: String): Promise<Unit, Exception> {
val deferred = deferred<Unit, Exception>()
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<String>) {
}
@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<String>, admins: Collection<String>) {
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<String>, admins: Collection<String>, 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))
}
}