diff --git a/app/src/main/java/org/thoughtcrime/securesms/database/Storage.kt b/app/src/main/java/org/thoughtcrime/securesms/database/Storage.kt index be4228e67..9b96c8c32 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/database/Storage.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/database/Storage.kt @@ -1291,9 +1291,11 @@ open class Storage( val membersConfig = configFactory.getGroupMemberConfig(sessionId) ?: return val infoConfig = configFactory.getGroupInfoConfig(sessionId) ?: return + // Filter out people who aren't already invited val filteredMembers = invitees.filter { membersConfig.get(it) == null } + // Create each member's contact info if we have it filteredMembers.forEach { memberSessionId -> val contact = getContactWithSessionID(memberSessionId) val name = contact?.name @@ -1310,6 +1312,7 @@ open class Storage( membersConfig.set(member) } + // re-key for new members val keysConfig = configFactory.getGroupKeysConfig( sessionId, info = infoConfig, @@ -1321,6 +1324,14 @@ open class Storage( val sentTimestamp = SnodeAPI.nowWithOffset + // build unrevocation, in case of re-adding members + val unrevocation = SnodeAPI.buildAuthenticatedUnrevokeSubKeyBatchRequest( + groupSessionId, + adminKey, + filteredMembers.map { keysConfig.getSubAccountToken(SessionId.from(it)) }.toTypedArray() + ) ?: return Log.e("ClosedGroup", "Failed to build revocation update") + + // Build and store the key update in group swarm val message = SnodeMessage( groupSessionId, Base64.encodeBytes(keysConfig.pendingConfig()!!), // should not be null from checking has pending @@ -1337,7 +1348,8 @@ open class Storage( SnodeAPI.getRawBatchResponse( snode, groupSessionId, - listOf(authenticatedBatch), + listOf(unrevocation, authenticatedBatch), + sequence = true ) } @@ -1345,6 +1357,7 @@ open class Storage( try { response.get() + // todo: error handling here val newConfigSync = ConfigurationSyncJob(destination) var exception: Exception? = null @@ -1441,7 +1454,7 @@ open class Storage( members.set(promoted) val message = GroupUpdated( - DataMessage.GroupUpdateMessage.newBuilder() + GroupUpdateMessage.newBuilder() .setPromoteMessage( DataMessage.GroupUpdatePromoteMessage.newBuilder() .setGroupIdentitySeed(ByteString.copyFrom(adminKey)) @@ -1460,7 +1473,7 @@ open class Storage( val messageToSign = "MEMBER_CHANGE${GroupUpdateMemberChangeMessage.Type.PROMOTED.name}$timestamp" val signature = SodiumUtilities.sign(messageToSign.toByteArray(), adminKey) val message = GroupUpdated( - DataMessage.GroupUpdateMessage.newBuilder() + GroupUpdateMessage.newBuilder() .setMemberChangeMessage( GroupUpdateMemberChangeMessage.newBuilder() .addAllMemberSessionIds(promotions.toList()) @@ -1475,6 +1488,108 @@ open class Storage( insertGroupInfoChange(message, closedGroupId) } + override fun removeMember(groupSessionId: String, removedMembers: Array, fromDelete: Boolean) { + val closedGroupId = SessionId.from(groupSessionId) + val adminKey = configFactory.userGroups?.getClosedGroup(groupSessionId)?.adminKey ?: return + if (adminKey.isEmpty()) { + return Log.e("ClosedGroup", "No admin key for group") + } + val info = configFactory.getGroupInfoConfig(closedGroupId) ?: return + val members = configFactory.getGroupMemberConfig(closedGroupId) ?: return + val keys = configFactory.getGroupKeysConfig(closedGroupId, info, members, free = false) ?: return + + removedMembers.forEach { sessionId -> + members.erase(sessionId) + } + + // Re-key for removed members + keys.rekey(info, members) + + val revocation = SnodeAPI.buildAuthenticatedRevokeSubKeyBatchRequest( + groupSessionId, + adminKey, + removedMembers.map { keys.getSubAccountToken(SessionId.from(it)) }.toTypedArray() + ) ?: return Log.e("ClosedGroup", "Failed to build revocation update") + + // Build and store the key update in group swarm + val storeKeyMessage = SnodeMessage( + groupSessionId, + Base64.encodeBytes(keys.pendingConfig()!!), // should not be null from checking has pending + SnodeMessage.CONFIG_TTL, + SnodeAPI.nowWithOffset + ) + val authenticatedBatch = SnodeAPI.buildAuthenticatedStoreBatchInfo( + keys.namespace(), + storeKeyMessage, + adminKey + ) + + val response = SnodeAPI.getSingleTargetSnode(groupSessionId).bind { snode -> + SnodeAPI.getRawBatchResponse( + snode, + groupSessionId, + listOf(revocation, authenticatedBatch), + sequence = true + ) + } + + try { + // handle new key update and revocations response + val rawResponse = response.get() + val results = (rawResponse["results"] as ArrayList).first() as Map + if (results["code"] as Int != 200) { + throw Exception("Response wasn't successful for revoke and key update: ${results["body"] as? String}") + } + + val newConfigSync = ConfigurationSyncJob(Destination.ClosedGroup(groupSessionId)) + + configFactory.saveGroupConfigs(keys, info, members) + info.free() + members.free() + keys.free() + + var exception: Exception? = null + val delegate = object: JobDelegate { + override fun handleJobSucceeded(job: Job, dispatcherName: String) {} + override fun handleJobFailed(job: Job, dispatcherName: String, error: Exception) { exception = error } + override fun handleJobFailedPermanently( + job: Job, + dispatcherName: String, + error: Exception + ) { exception = error } + } + newConfigSync.delegate = delegate + runBlocking { + newConfigSync.execute("updating-members") + } + + // rethrow failure + exception?.let { throw it } + + val timestamp = SnodeAPI.nowWithOffset + val messageToSign = "MEMBER_CHANGE${GroupUpdateMemberChangeMessage.Type.REMOVED.name}$timestamp" + val signature = SodiumUtilities.sign(messageToSign.toByteArray(), adminKey) + val updateMessage = GroupUpdateMessage.newBuilder() + .setMemberChangeMessage( + GroupUpdateMemberChangeMessage.newBuilder() + .addAllMemberSessionIds(removedMembers.toList()) + .setType(GroupUpdateMemberChangeMessage.Type.REMOVED) + .setAdminSignature(ByteString.copyFrom(signature)) + ) + .build() + val message = GroupUpdated( + updateMessage + ).apply { sentTimestamp = timestamp } + val groupDestination = Destination.ClosedGroup(groupSessionId) + MessageSender.send(message, groupDestination, false) + insertGroupInfoChange(message, closedGroupId) + } catch (e: Exception) { + info.free() + members.free() + keys.free() + } + } + override fun handlePromoted(keyPair: KeyPair) { val closedGroupId = SessionId(IdPrefix.GROUP, keyPair.pubKey) val ourSessionId = getUserPublicKey()!! @@ -1484,6 +1599,7 @@ open class Storage( val modified = closedGroup.copy(adminKey = keyPair.secretKey, authData = byteArrayOf()) userGroups.set(modified) + configFactory.scheduleUpdate(Destination.from(fromSerialized(getUserPublicKey()!!))) val info = configFactory.getGroupInfoConfig(closedGroupId) ?: return val members = configFactory.getGroupMemberConfig(closedGroupId) ?: return val keys = configFactory.getGroupKeysConfig(closedGroupId, info, members, free = false) ?: return diff --git a/app/src/main/java/org/thoughtcrime/securesms/groups/compose/EditGroup.kt b/app/src/main/java/org/thoughtcrime/securesms/groups/compose/EditGroup.kt index 2279847ac..7e223fba7 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/groups/compose/EditGroup.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/groups/compose/EditGroup.kt @@ -97,6 +97,9 @@ fun EditClosedGroupScreen( onPromote = { contact -> eventSink(EditGroupEvent.PromoteContact(contact)) }, + onRemove = { contact -> + eventSink(EditGroupEvent.RemoveContact(contact)) + }, viewState = viewState ) } @@ -192,6 +195,9 @@ class EditGroupViewModel @AssistedInject constructor( // do a buffer storage.promoteMember(groupSessionId, arrayOf(event.contactSessionId)) } + is EditGroupEvent.RemoveContact -> { + storage.removeMember(groupSessionId, arrayOf(event.contactSessionId)) + } } } } @@ -247,6 +253,7 @@ fun EditGroupView( onInvite: ()->Unit, onReinvite: (String)->Unit, onPromote: (String)->Unit, + onRemove: (String)->Unit, viewState: EditGroupViewState, ) { val scaffoldState = rememberScaffoldState() @@ -337,9 +344,11 @@ fun EditGroupView( .clip(CircleShape) .background( Color( - MaterialColors.getColor(LocalContext.current, + MaterialColors.getColor( + LocalContext.current, R.attr.colorControlHighlight, - MaterialTheme.colors.onPrimary.toArgb()) + MaterialTheme.colors.onPrimary.toArgb() + ) ) ) ) { @@ -357,9 +366,11 @@ fun EditGroupView( .clip(CircleShape) .background( Color( - MaterialColors.getColor(LocalContext.current, + MaterialColors.getColor( + LocalContext.current, R.attr.colorControlHighlight, - MaterialTheme.colors.onPrimary.toArgb()) + MaterialTheme.colors.onPrimary.toArgb() + ) ) ) ) { @@ -368,7 +379,24 @@ fun EditGroupView( color = MaterialTheme.colors.onPrimary ) } - + TextButton( + onClick = { + onRemove(member.memberSessionId) + }, + modifier = Modifier + .clip(CircleShape) + .background( + Color( + MaterialColors.getColor( + LocalContext.current, + R.attr.colorControlHighlight, + MaterialTheme.colors.onPrimary.toArgb() + ) + ) + ) + ) { + Icon(painter = painterResource(id = R.drawable.ic_baseline_close_24), contentDescription = null) + } } } } @@ -425,6 +453,7 @@ sealed class EditGroupEvent { val contacts: ContactList): EditGroupEvent() data class ReInviteContact(val contactSessionId: String): EditGroupEvent() data class PromoteContact(val contactSessionId: String): EditGroupEvent() + data class RemoveContact(val contactSessionId: String): EditGroupEvent() } data class EditGroupInviteViewState( @@ -443,16 +472,22 @@ fun PreviewList() { false ) val twoMember = MemberViewModel( - "Test User", + "Test User 2", "05abcd1234abcd1234abcd1234abcd1234abcd1234abcd1234abcd1234abcd1235", MemberState.InviteFailed, false ) + val threeMember = MemberViewModel( + "Test User 3", + "05abcd1234abcd1234abcd1234abcd1234abcd1234abcd1234abcd1234abcd1236", + MemberState.Member, + false + ) val viewState = EditGroupViewState( "Preview", "This is a preview description", - listOf(oneMember, twoMember), + listOf(oneMember, twoMember, threeMember), true ) @@ -462,6 +497,7 @@ fun PreviewList() { onInvite = {}, onReinvite = {}, onPromote = {}, + onRemove = {}, viewState = viewState ) } diff --git a/libsession-util/src/main/cpp/group_keys.cpp b/libsession-util/src/main/cpp/group_keys.cpp index 7c6b99db9..eaddcae46 100644 --- a/libsession-util/src/main/cpp/group_keys.cpp +++ b/libsession-util/src/main/cpp/group_keys.cpp @@ -247,6 +247,21 @@ Java_network_loki_messenger_libsession_1util_GroupKeysConfig_makeSubAccount(JNIE return jbytes; } +extern "C" +JNIEXPORT jbyteArray JNICALL +Java_network_loki_messenger_libsession_1util_GroupKeysConfig_getSubAccountToken(JNIEnv *env, + jobject thiz, + jobject session_id, + jboolean can_write, + jboolean can_delete) { + std::lock_guard lock{util::util_mutex_}; + auto ptr = ptrToKeys(env, thiz); + auto deserialized_id = util::deserialize_session_id(env, session_id); + auto token = ptr->swarm_subaccount_token(deserialized_id, can_write, can_delete); + auto jbytes = util::bytes_from_ustring(env, token); + return jbytes; +} + extern "C" JNIEXPORT jobject JNICALL Java_network_loki_messenger_libsession_1util_GroupKeysConfig_subAccountSign(JNIEnv *env, diff --git a/libsession-util/src/main/cpp/group_members.cpp b/libsession-util/src/main/cpp/group_members.cpp index 34395a9ed..80a7f80d6 100644 --- a/libsession-util/src/main/cpp/group_members.cpp +++ b/libsession-util/src/main/cpp/group_members.cpp @@ -45,13 +45,23 @@ Java_network_loki_messenger_libsession_1util_GroupMembersConfig_all(JNIEnv *env, extern "C" JNIEXPORT jboolean JNICALL -Java_network_loki_messenger_libsession_1util_GroupMembersConfig_erase(JNIEnv *env, jobject thiz, +Java_network_loki_messenger_libsession_1util_GroupMembersConfig_erase__Lnetwork_loki_messenger_libsession_1util_util_GroupMember_2(JNIEnv *env, jobject thiz, jobject group_member) { auto config = ptrToMembers(env, thiz); auto member = util::deserialize_group_member(env, group_member); return config->erase(member.session_id); } +extern "C" +JNIEXPORT jboolean JNICALL +Java_network_loki_messenger_libsession_1util_GroupMembersConfig_erase__Ljava_lang_String_2(JNIEnv *env, jobject thiz, jstring pub_key_hex) { + auto config = ptrToMembers(env, thiz); + auto member_id = env->GetStringUTFChars(pub_key_hex, nullptr); + auto erased = config->erase(member_id); + env->ReleaseStringUTFChars(pub_key_hex, member_id); + return erased; +} + extern "C" JNIEXPORT jobject JNICALL Java_network_loki_messenger_libsession_1util_GroupMembersConfig_get(JNIEnv *env, jobject thiz, diff --git a/libsession-util/src/main/java/network/loki/messenger/libsession_util/Config.kt b/libsession-util/src/main/java/network/loki/messenger/libsession_util/Config.kt index 4378f2270..01d5f4d28 100644 --- a/libsession-util/src/main/java/network/loki/messenger/libsession_util/Config.kt +++ b/libsession-util/src/main/java/network/loki/messenger/libsession_util/Config.kt @@ -284,6 +284,7 @@ class GroupMembersConfig(pointer: Long): ConfigBase(pointer), Closeable { external fun all(): Stack external fun erase(groupMember: GroupMember): Boolean + external fun erase(pubKeyHex: String): Boolean external fun get(pubKeyHex: String): GroupMember? external fun getOrConstruct(pubKeyHex: String): GroupMember external fun set(groupMember: GroupMember) @@ -335,6 +336,7 @@ class GroupKeysConfig(pointer: Long): ConfigSig(pointer) { external fun keys(): Stack external fun makeSubAccount(sessionId: SessionId, canWrite: Boolean = true, canDelete: Boolean = false): ByteArray + external fun getSubAccountToken(sessionId: SessionId, canWrite: Boolean = true, canDelete: Boolean = false): ByteArray external fun subAccountSign(message: ByteArray, signingValue: ByteArray): SwarmAuth diff --git a/libsession/src/main/java/org/session/libsession/database/StorageProtocol.kt b/libsession/src/main/java/org/session/libsession/database/StorageProtocol.kt index 1a2cde880..7aa2869a1 100644 --- a/libsession/src/main/java/org/session/libsession/database/StorageProtocol.kt +++ b/libsession/src/main/java/org/session/libsession/database/StorageProtocol.kt @@ -170,6 +170,7 @@ interface StorageProtocol { fun inviteClosedGroupMembers(groupSessionId: String, invitees: List) fun insertGroupInfoChange(message: GroupUpdated, closedGroup: SessionId) fun promoteMember(groupSessionId: String, promotions: Array) + fun removeMember(groupSessionId: String, removedMembers: Array, fromDelete: Boolean = false) fun handlePromoted(keyPair: KeyPair) fun leaveGroup(groupSessionId: String) diff --git a/libsession/src/main/java/org/session/libsession/snode/SnodeAPI.kt b/libsession/src/main/java/org/session/libsession/snode/SnodeAPI.kt index 48b3ec56c..7af87ad9f 100644 --- a/libsession/src/main/java/org/session/libsession/snode/SnodeAPI.kt +++ b/libsession/src/main/java/org/session/libsession/snode/SnodeAPI.kt @@ -553,7 +553,39 @@ object SnodeAPI { ) } + fun buildAuthenticatedUnrevokeSubKeyBatchRequest( + publicKeyDestination: String, + signingKey: ByteArray, + subAccounts: Array, + ): SnodeBatchRequestInfo? { + val params= buildUnrevokeAccountParams( + publicKeyDestination, + signingKey, + subAccounts + ) ?: return null + return SnodeBatchRequestInfo( + Snode.Method.UnrevokeSubAccount.rawValue, + params, + null + ) + } + fun buildAuthenticatedRevokeSubKeyBatchRequest( + publicKeyDestination: String, + signingKey: ByteArray, + subAccounts: Array, + ): SnodeBatchRequestInfo? { + val params = buildRevokeAccountParams( + publicKeyDestination, + signingKey, + subAccounts + ) ?: return null + return SnodeBatchRequestInfo( + Snode.Method.RevokeSubAccount.rawValue, + params, + null + ) + } fun getRawBatchResponse(snode: Snode, publicKey: String, requests: List, sequence: Boolean = false): RawResponsePromise { val parameters = mutableMapOf( @@ -672,6 +704,70 @@ object SnodeAPI { return params } + private fun buildUnrevokeAccountParams( + publicKey: String, + signingKey: ByteArray, + unrevoke: Array, + pubKeyEd25519: String? = null, + ): Map? { + val timestamp = nowWithOffset + val params = mutableMapOf( + "pubkey" to publicKey, + "timestamp" to timestamp, + "unrevoke" to unrevoke.map { Base64.encodeBytes(it) } + ) + val signData = "unrevoke_subaccount$timestamp".toByteArray() + unrevoke.reduce(ByteArray::plus) + val signature = ByteArray(Sign.BYTES) + try { + sodium.cryptoSignDetached( + signature, + signData, + signData.size.toLong(), + signingKey + ) + } catch (e: Exception) { + Log.e("Loki", "Signing data failed with secret key", e) + return null + } + params["signature"] = Base64.encodeBytes(signature) + if (pubKeyEd25519 != null) { + params["pubkey_ed25519"] = pubKeyEd25519 + } + return params + } + + private fun buildRevokeAccountParams( + publicKey: String, + signingKey: ByteArray, + revoke: Array, + pubKeyEd25519: String? = null, + ): Map? { + val timestamp = nowWithOffset + val params = mutableMapOf( + "pubkey" to publicKey, + "timestamp" to timestamp, + "revoke" to revoke.map { Base64.encodeBytes(it) }, + ) + val signData = "revoke_subaccount$timestamp".toByteArray() + revoke.reduce(ByteArray::plus) + val signature = ByteArray(Sign.BYTES) + try { + sodium.cryptoSignDetached( + signature, + signData, + signData.size.toLong(), + signingKey + ) + } catch (e: Exception) { + Log.e("Loki", "Signing data failed with secret key", e) + return null + } + params["signature"] = Base64.encodeBytes(signature) + if (pubKeyEd25519 != null) { + params["pubkey_ed25519"] = pubKeyEd25519 + } + return params + } + fun getMessages(publicKey: String): MessageListPromise { return retryIfNeeded(maxRetryCount) { getSingleTargetSnode(publicKey).bind { snode -> diff --git a/libsignal/src/main/java/org/session/libsignal/utilities/Snode.kt b/libsignal/src/main/java/org/session/libsignal/utilities/Snode.kt index 28f8aeb03..2261331ce 100644 --- a/libsignal/src/main/java/org/session/libsignal/utilities/Snode.kt +++ b/libsignal/src/main/java/org/session/libsignal/utilities/Snode.kt @@ -14,7 +14,9 @@ class Snode(val address: String, val port: Int, val publicKeySet: KeySet?) { Batch("batch"), Sequence("sequence"), Expire("expire"), - GetExpiries("get_expiries") + GetExpiries("get_expiries"), + RevokeSubAccount("revoke_subaccount"), + UnrevokeSubAccount("unrevoke_subaccount"), } data class KeySet(val ed25519Key: String, val x25519Key: String)