diff --git a/app/src/main/java/org/thoughtcrime/securesms/ApplicationContext.java b/app/src/main/java/org/thoughtcrime/securesms/ApplicationContext.java index b8d8b0e850..de71bee8d8 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/ApplicationContext.java +++ b/app/src/main/java/org/thoughtcrime/securesms/ApplicationContext.java @@ -38,6 +38,7 @@ import org.signal.aesgcmprovider.AesGcmProvider; import org.thoughtcrime.securesms.components.TypingStatusRepository; import org.thoughtcrime.securesms.components.TypingStatusSender; import org.thoughtcrime.securesms.crypto.IdentityKeyUtil; +import org.thoughtcrime.securesms.crypto.MasterSecretUtil; import org.thoughtcrime.securesms.crypto.ProfileKeyUtil; import org.thoughtcrime.securesms.crypto.storage.TextSecureSessionStore; import org.thoughtcrime.securesms.database.Address; @@ -562,24 +563,20 @@ public class ApplicationContext extends MultiDexApplication implements Dependenc }); } - //FIXME AC: Using this method to cleanup app data is unsafe due to potential concurrent - // activity that still might be using the data that is being deleted here. - // The most reliable and safe way to do this is to use official API call: - // https://developer.android.com/reference/android/app/ActivityManager.html#clearApplicationUserData() - // The downside is it kills the app in the process and there's no any conventional way to start - // another activity when the task is done. - // Dev community is in demand for such a feature, so check on it some time in the feature - // and replace our implementation with the API call when it's safe to do so. - // Here's a feature request related https://issuetracker.google.com/issues/174903931 - public void clearAllData() { + public void clearAllData(boolean isMigratingToV2KeyPair) { String token = TextSecurePreferences.getFCMToken(this); if (token != null && !token.isEmpty()) { LokiPushNotificationManager.unregister(token, this); } - boolean wasUnlinked = TextSecurePreferences.getWasUnlinked(this); + String displayName = TextSecurePreferences.getProfileName(this); + boolean isUsingFCM = TextSecurePreferences.isUsingFCM(this); TextSecurePreferences.clearAll(this); - TextSecurePreferences.setWasUnlinked(this, wasUnlinked); -// MasterSecretUtil.clear(this); + if (isMigratingToV2KeyPair) { + TextSecurePreferences.setIsMigratingKeyPair(this, true); + TextSecurePreferences.setIsUsingFCM(this, isUsingFCM); + TextSecurePreferences.setProfileName(this, displayName); + } + MasterSecretUtil.clear(this); if (!deleteDatabase("signal.db")) { Log.d("Loki", "Failed to delete database."); } @@ -605,38 +602,7 @@ public class ApplicationContext extends MultiDexApplication implements Dependenc @Override public void sendSessionRequestIfNeeded(@NotNull String publicKey) { -// // It's never necessary to establish a session with self -// String userPublicKey = TextSecurePreferences.getLocalNumber(this); -// if (publicKey.equals(userPublicKey)) { return; } -// // Check that we don't already have a session -// SignalProtocolAddress address = new SignalProtocolAddress(publicKey, SignalServiceAddress.DEFAULT_DEVICE_ID); -// boolean hasSession = new TextSecureSessionStore(this).containsSession(address); -// if (hasSession) { return; } -// // Check that we didn't already send a session request -// LokiAPIDatabase apiDB = DatabaseFactory.getLokiAPIDatabase(this); -// boolean hasSentSessionRequest = (apiDB.getSessionRequestSentTimestamp(publicKey) != null); -// boolean hasSentSessionRequestExpired = hasSentSessionRequestExpired(publicKey); -// if (hasSentSessionRequestExpired) { -// apiDB.setSessionRequestSentTimestamp(publicKey, 0); -// } -// if (hasSentSessionRequest && !hasSentSessionRequestExpired) { return; } -// // Send the session request -// long timestamp = new Date().getTime(); -// apiDB.setSessionRequestSentTimestamp(publicKey, timestamp); -// SessionRequestMessageSendJob job = new SessionRequestMessageSendJob(publicKey, timestamp); -// jobManager.add(job); - - // It's never necessary to establish a session with self - String userPublicKey = TextSecurePreferences.getLocalNumber(this); - if (publicKey.equals(userPublicKey)) { return; } - // Check that we don't already have a session - SignalProtocolAddress address = new SignalProtocolAddress(publicKey, SignalServiceAddress.DEFAULT_DEVICE_ID); - TextSecureSessionStore sessionStore = new TextSecureSessionStore(this); - boolean hasSession = sessionStore.containsSession(address); - if (!hasSession) { - sessionStore.storeSession(address, new SessionRecord()); - } } @Override diff --git a/app/src/main/java/org/thoughtcrime/securesms/attachments/DatabaseAttachmentProvider.kt b/app/src/main/java/org/thoughtcrime/securesms/attachments/DatabaseAttachmentProvider.kt index d9e11ebb27..4a1d25330a 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/attachments/DatabaseAttachmentProvider.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/attachments/DatabaseAttachmentProvider.kt @@ -4,11 +4,15 @@ import android.content.Context import com.google.protobuf.ByteString import org.greenrobot.eventbus.EventBus import org.session.libsession.database.MessageDataProvider +import org.session.libsession.messaging.sending_receiving.attachments.Attachment import org.session.libsession.messaging.sending_receiving.attachments.AttachmentState import org.session.libsession.messaging.sending_receiving.attachments.SessionServiceAttachmentPointer import org.session.libsession.messaging.sending_receiving.attachments.SessionServiceAttachmentStream +import org.session.libsession.messaging.threads.Address import org.session.libsignal.libsignal.util.guava.Optional import org.session.libsignal.service.api.messages.SignalServiceAttachment +import org.session.libsignal.service.api.messages.SignalServiceAttachmentPointer +import org.thoughtcrime.securesms.ApplicationContext import org.thoughtcrime.securesms.database.Database import org.thoughtcrime.securesms.database.DatabaseFactory import org.thoughtcrime.securesms.database.helpers.SQLCipherOpenHelper @@ -18,6 +22,7 @@ import org.thoughtcrime.securesms.mms.PartAuthority import org.thoughtcrime.securesms.util.MediaUtil import java.io.InputStream + class DatabaseAttachmentProvider(context: Context, helper: SQLCipherOpenHelper) : Database(context, helper), MessageDataProvider { override fun getAttachmentStream(attachmentId: Long): SessionServiceAttachmentStream? { @@ -26,6 +31,10 @@ class DatabaseAttachmentProvider(context: Context, helper: SQLCipherOpenHelper) return databaseAttachment.toAttachmentStream(context) } + override fun getAttachmentPointer(attachmentID: String): SignalServiceAttachmentPointer? { + TODO("Not yet implemented") + } + override fun getAttachmentPointer(attachmentId: Long): SessionServiceAttachmentPointer? { val attachmentDatabase = DatabaseFactory.getAttachmentDatabase(context) val databaseAttachment = attachmentDatabase.getAttachment(AttachmentId(attachmentId, 0)) ?: return null @@ -43,9 +52,21 @@ class DatabaseAttachmentProvider(context: Context, helper: SQLCipherOpenHelper) attachmentUploadJob.onRun() } - override fun insertAttachment(messageId: Long, attachmentId: Long, stream : InputStream) { + override fun getMessageForQuote(timestamp: Long, author: Address): Long? { + TODO("Not yet implemented") + } + + override fun getAttachmentsWithLinkPreviewFor(messageID: Long): List { + TODO("Not yet implemented") + } + + override fun getMessageBodyFor(messageID: Long): String { + TODO("Not yet implemented") + } + + override fun insertAttachment(messageId: Long, attachmentId: Long, stream: InputStream) { val attachmentDatabase = DatabaseFactory.getAttachmentDatabase(context) - attachmentDatabase.insertAttachmentsForPlaceholder(messageId, AttachmentId(attachmentId,0), stream) + attachmentDatabase.insertAttachmentsForPlaceholder(messageId, AttachmentId(attachmentId, 0), stream) } override fun isOutgoingMessage(timestamp: Long): Boolean { @@ -53,6 +74,16 @@ class DatabaseAttachmentProvider(context: Context, helper: SQLCipherOpenHelper) return smsDatabase.isOutgoingMessage(timestamp) } + override fun getMessageID(serverID: Long): Long? { + TODO("Not yet implemented") + } + + override fun deleteMessage(messageID: Long) { + TODO("Not yet implemented") + //val publicChatAPI = ApplicationContext.getInstance(context).publicChatAPI + //publicChatAPI?.deleteMessage(messageID) + } + } fun DatabaseAttachment.toAttachmentPointer(): SessionServiceAttachmentPointer { diff --git a/app/src/main/java/org/thoughtcrime/securesms/conversation/ConversationActivity.java b/app/src/main/java/org/thoughtcrime/securesms/conversation/ConversationActivity.java index 78cfa4575e..562349ba08 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/conversation/ConversationActivity.java +++ b/app/src/main/java/org/thoughtcrime/securesms/conversation/ConversationActivity.java @@ -143,6 +143,7 @@ import org.thoughtcrime.securesms.loki.database.LokiThreadDatabase; import org.thoughtcrime.securesms.loki.database.LokiThreadDatabaseDelegate; import org.thoughtcrime.securesms.loki.database.LokiUserDatabase; import org.thoughtcrime.securesms.loki.protocol.ClosedGroupsProtocol; +import org.thoughtcrime.securesms.loki.protocol.ClosedGroupsProtocolV2; import org.thoughtcrime.securesms.loki.protocol.SessionManagementProtocol; import org.thoughtcrime.securesms.loki.utilities.GeneralUtilitiesKt; import org.thoughtcrime.securesms.loki.utilities.MentionManagerUtilities; @@ -1080,7 +1081,18 @@ public class ConversationActivity extends PassphraseRequiredActionBarActivity builder.setTitle(getString(R.string.ConversationActivity_leave_group)); builder.setIconAttribute(R.attr.dialog_info_icon); builder.setCancelable(true); - builder.setMessage(getString(R.string.ConversationActivity_are_you_sure_you_want_to_leave_this_group)); + + GroupRecord group = DatabaseFactory.getGroupDatabase(this).getGroup(getRecipient().getAddress().toGroupString()).orNull(); + List
admins = group.getAdmins(); + String userPublicKey = TextSecurePreferences.getLocalNumber(this); + String message = getString(R.string.ConversationActivity_are_you_sure_you_want_to_leave_this_group); + for (Address admin : admins) { + if (admin.toPhoneString().equals(userPublicKey)) { + message = "Because you are the creator of this group it will be deleted for everyone. This cannot be undone."; + } + } + + builder.setMessage(message); builder.setPositiveButton(R.string.yes, (dialog, which) -> { Recipient groupRecipient = getRecipient(); String groupPublicKey; @@ -1094,7 +1106,7 @@ public class ConversationActivity extends PassphraseRequiredActionBarActivity } try { if (isSSKBasedClosedGroup) { - ClosedGroupsProtocol.leave(this, groupPublicKey); + ClosedGroupsProtocolV2.leave(this, groupPublicKey); initializeEnabledCheck(); } else if (ClosedGroupsProtocol.leaveLegacyGroup(this, groupRecipient)) { initializeEnabledCheck(); diff --git a/app/src/main/java/org/thoughtcrime/securesms/crypto/MasterCipher.java b/app/src/main/java/org/thoughtcrime/securesms/crypto/MasterCipher.java new file mode 100644 index 0000000000..1a68c065c7 --- /dev/null +++ b/app/src/main/java/org/thoughtcrime/securesms/crypto/MasterCipher.java @@ -0,0 +1,226 @@ +/** + * Copyright (C) 2011 Whisper Systems + * Copyright (C) 2013 Open Whisper Systems + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see . + */ +package org.thoughtcrime.securesms.crypto; + +import androidx.annotation.NonNull; + +import org.session.libsignal.libsignal.InvalidMessageException; +import org.session.libsignal.libsignal.ecc.Curve; +import org.session.libsignal.libsignal.ecc.ECPrivateKey; +import org.thoughtcrime.securesms.logging.Log; + +import org.thoughtcrime.securesms.util.Base64; +import org.thoughtcrime.securesms.util.Hex; + +import java.io.IOException; +import java.security.GeneralSecurityException; +import java.security.InvalidAlgorithmParameterException; +import java.security.InvalidKeyException; +import java.security.NoSuchAlgorithmException; +import java.util.Arrays; + +import javax.crypto.BadPaddingException; +import javax.crypto.Cipher; +import javax.crypto.IllegalBlockSizeException; +import javax.crypto.Mac; +import javax.crypto.NoSuchPaddingException; +import javax.crypto.spec.IvParameterSpec; +import javax.crypto.spec.SecretKeySpec; + +/** + * Class that handles encryption for local storage. + * + * The protocol format is roughly: + * + * 1) 16 byte random IV. + * 2) AES-CBC(plaintext) + * 3) HMAC-SHA1 of 1 and 2 + * + * @author Moxie Marlinspike + */ + +public class MasterCipher { + + private static final String TAG = MasterCipher.class.getSimpleName(); + + private final MasterSecret masterSecret; + private final Cipher encryptingCipher; + private final Cipher decryptingCipher; + private final Mac hmac; + + public MasterCipher(MasterSecret masterSecret) { + try { + this.masterSecret = masterSecret; + this.encryptingCipher = Cipher.getInstance("AES/CBC/PKCS5Padding"); + this.decryptingCipher = Cipher.getInstance("AES/CBC/PKCS5Padding"); + this.hmac = Mac.getInstance("HmacSHA1"); + } catch (NoSuchPaddingException | NoSuchAlgorithmException nspe) { + throw new AssertionError(nspe); + } + } + + public byte[] encryptKey(ECPrivateKey privateKey) { + return encryptBytes(privateKey.serialize()); + } + + public String encryptBody(@NonNull String body) { + return encryptAndEncodeBytes(body.getBytes()); + } + + public String decryptBody(String body) throws InvalidMessageException { + return new String(decodeAndDecryptBytes(body)); + } + + public ECPrivateKey decryptKey(byte[] key) + throws org.session.libsignal.libsignal.InvalidKeyException + { + try { + return Curve.decodePrivatePoint(decryptBytes(key)); + } catch (InvalidMessageException ime) { + throw new org.session.libsignal.libsignal.InvalidKeyException(ime); + } + } + + public byte[] decryptBytes(@NonNull byte[] decodedBody) throws InvalidMessageException { + try { + Mac mac = getMac(masterSecret.getMacKey()); + byte[] encryptedBody = verifyMacBody(mac, decodedBody); + + Cipher cipher = getDecryptingCipher(masterSecret.getEncryptionKey(), encryptedBody); + byte[] encrypted = getDecryptedBody(cipher, encryptedBody); + + return encrypted; + } catch (GeneralSecurityException ge) { + throw new InvalidMessageException(ge); + } + } + + public byte[] encryptBytes(byte[] body) { + try { + Cipher cipher = getEncryptingCipher(masterSecret.getEncryptionKey()); + Mac mac = getMac(masterSecret.getMacKey()); + + byte[] encryptedBody = getEncryptedBody(cipher, body); + byte[] encryptedAndMacBody = getMacBody(mac, encryptedBody); + + return encryptedAndMacBody; + } catch (GeneralSecurityException ge) { + Log.w("bodycipher", ge); + return null; + } + + } + + public boolean verifyMacFor(String content, byte[] theirMac) { + byte[] ourMac = getMacFor(content); + Log.i(TAG, "Our Mac: " + Hex.toString(ourMac)); + Log.i(TAG, "Thr Mac: " + Hex.toString(theirMac)); + return Arrays.equals(ourMac, theirMac); + } + + public byte[] getMacFor(String content) { + Log.w(TAG, "Macing: " + content); + try { + Mac mac = getMac(masterSecret.getMacKey()); + return mac.doFinal(content.getBytes()); + } catch (GeneralSecurityException ike) { + throw new AssertionError(ike); + } + } + + private byte[] decodeAndDecryptBytes(String body) throws InvalidMessageException { + try { + byte[] decodedBody = Base64.decode(body); + return decryptBytes(decodedBody); + } catch (IOException e) { + throw new InvalidMessageException("Bad Base64 Encoding...", e); + } + } + + private String encryptAndEncodeBytes(@NonNull byte[] bytes) { + byte[] encryptedAndMacBody = encryptBytes(bytes); + return Base64.encodeBytes(encryptedAndMacBody); + } + + private byte[] verifyMacBody(@NonNull Mac hmac, @NonNull byte[] encryptedAndMac) throws InvalidMessageException { + if (encryptedAndMac.length < hmac.getMacLength()) { + throw new InvalidMessageException("length(encrypted body + MAC) < length(MAC)"); + } + + byte[] encrypted = new byte[encryptedAndMac.length - hmac.getMacLength()]; + System.arraycopy(encryptedAndMac, 0, encrypted, 0, encrypted.length); + + byte[] remoteMac = new byte[hmac.getMacLength()]; + System.arraycopy(encryptedAndMac, encryptedAndMac.length - remoteMac.length, remoteMac, 0, remoteMac.length); + + byte[] localMac = hmac.doFinal(encrypted); + + if (!Arrays.equals(remoteMac, localMac)) + throw new InvalidMessageException("MAC doesen't match."); + + return encrypted; + } + + private byte[] getDecryptedBody(Cipher cipher, byte[] encryptedBody) throws IllegalBlockSizeException, BadPaddingException { + return cipher.doFinal(encryptedBody, cipher.getBlockSize(), encryptedBody.length - cipher.getBlockSize()); + } + + private byte[] getEncryptedBody(Cipher cipher, byte[] body) throws IllegalBlockSizeException, BadPaddingException { + byte[] encrypted = cipher.doFinal(body); + byte[] iv = cipher.getIV(); + + byte[] ivAndBody = new byte[iv.length + encrypted.length]; + System.arraycopy(iv, 0, ivAndBody, 0, iv.length); + System.arraycopy(encrypted, 0, ivAndBody, iv.length, encrypted.length); + + return ivAndBody; + } + + private Mac getMac(SecretKeySpec key) throws NoSuchAlgorithmException, InvalidKeyException { + // Mac hmac = Mac.getInstance("HmacSHA1"); + hmac.init(key); + + return hmac; + } + + private byte[] getMacBody(Mac hmac, byte[] encryptedBody) { + byte[] mac = hmac.doFinal(encryptedBody); + byte[] encryptedAndMac = new byte[encryptedBody.length + mac.length]; + + System.arraycopy(encryptedBody, 0, encryptedAndMac, 0, encryptedBody.length); + System.arraycopy(mac, 0, encryptedAndMac, encryptedBody.length, mac.length); + + return encryptedAndMac; + } + + private Cipher getDecryptingCipher(SecretKeySpec key, byte[] encryptedBody) throws InvalidKeyException, InvalidAlgorithmParameterException, NoSuchAlgorithmException, NoSuchPaddingException { + // Cipher cipher = Cipher.getInstance("AES/CBC/PKCS5Padding"); + IvParameterSpec iv = new IvParameterSpec(encryptedBody, 0, decryptingCipher.getBlockSize()); + decryptingCipher.init(Cipher.DECRYPT_MODE, key, iv); + + return decryptingCipher; + } + + private Cipher getEncryptingCipher(SecretKeySpec key) throws InvalidKeyException, NoSuchAlgorithmException, NoSuchPaddingException { + // Cipher cipher = Cipher.getInstance("AES/CBC/PKCS5Padding"); + encryptingCipher.init(Cipher.ENCRYPT_MODE, key); + + return encryptingCipher; + } + +} diff --git a/app/src/main/java/org/thoughtcrime/securesms/crypto/MasterSecret.java b/app/src/main/java/org/thoughtcrime/securesms/crypto/MasterSecret.java new file mode 100644 index 0000000000..ad8881bd18 --- /dev/null +++ b/app/src/main/java/org/thoughtcrime/securesms/crypto/MasterSecret.java @@ -0,0 +1,119 @@ +/** + * Copyright (C) 2011 Whisper Systems + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see . + */ +package org.thoughtcrime.securesms.crypto; + +import android.os.Parcel; +import android.os.Parcelable; + +import javax.crypto.spec.SecretKeySpec; +import java.util.Arrays; + +/** + * When a user first initializes TextSecure, a few secrets + * are generated. These are: + * + * 1) A 128bit symmetric encryption key. + * 2) A 160bit symmetric MAC key. + * 3) An ECC keypair. + * + * The first two, along with the ECC keypair's private key, are + * then encrypted on disk using PBE. + * + * This class represents 1 and 2. + * + * @author Moxie Marlinspike + */ + +public class MasterSecret implements Parcelable { + + private final SecretKeySpec encryptionKey; + private final SecretKeySpec macKey; + + public static final Parcelable.Creator CREATOR = new Parcelable.Creator() { + @Override + public MasterSecret createFromParcel(Parcel in) { + return new MasterSecret(in); + } + + @Override + public MasterSecret[] newArray(int size) { + return new MasterSecret[size]; + } + }; + + public MasterSecret(SecretKeySpec encryptionKey, SecretKeySpec macKey) { + this.encryptionKey = encryptionKey; + this.macKey = macKey; + } + + private MasterSecret(Parcel in) { + byte[] encryptionKeyBytes = new byte[in.readInt()]; + in.readByteArray(encryptionKeyBytes); + + byte[] macKeyBytes = new byte[in.readInt()]; + in.readByteArray(macKeyBytes); + + this.encryptionKey = new SecretKeySpec(encryptionKeyBytes, "AES"); + this.macKey = new SecretKeySpec(macKeyBytes, "HmacSHA1"); + + // SecretKeySpec does an internal copy in its constructor. + Arrays.fill(encryptionKeyBytes, (byte) 0x00); + Arrays.fill(macKeyBytes, (byte)0x00); + } + + + public SecretKeySpec getEncryptionKey() { + return this.encryptionKey; + } + + public SecretKeySpec getMacKey() { + return this.macKey; + } + + @Override + public void writeToParcel(Parcel out, int flags) { + out.writeInt(encryptionKey.getEncoded().length); + out.writeByteArray(encryptionKey.getEncoded()); + out.writeInt(macKey.getEncoded().length); + out.writeByteArray(macKey.getEncoded()); + } + + @Override + public int describeContents() { + return 0; + } + + public MasterSecret parcelClone() { + Parcel thisParcel = Parcel.obtain(); + Parcel thatParcel = Parcel.obtain(); + byte[] bytes = null; + + thisParcel.writeValue(this); + bytes = thisParcel.marshall(); + + thatParcel.unmarshall(bytes, 0, bytes.length); + thatParcel.setDataPosition(0); + + MasterSecret that = (MasterSecret)thatParcel.readValue(MasterSecret.class.getClassLoader()); + + thisParcel.recycle(); + thatParcel.recycle(); + + return that; + } + +} diff --git a/app/src/main/java/org/thoughtcrime/securesms/crypto/MasterSecretUtil.java b/app/src/main/java/org/thoughtcrime/securesms/crypto/MasterSecretUtil.java new file mode 100644 index 0000000000..e3442b548e --- /dev/null +++ b/app/src/main/java/org/thoughtcrime/securesms/crypto/MasterSecretUtil.java @@ -0,0 +1,375 @@ +/** + * Copyright (C) 2011 Whisper Systems + * Copyright (C) 2013 Open Whisper Systems + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see . + */ +package org.thoughtcrime.securesms.crypto; + +import android.content.Context; +import android.content.SharedPreferences; +import androidx.annotation.NonNull; +import androidx.annotation.Nullable; +import android.text.TextUtils; + +import org.session.libsignal.libsignal.InvalidKeyException; +import org.session.libsignal.libsignal.ecc.Curve; +import org.session.libsignal.libsignal.ecc.ECKeyPair; +import org.session.libsignal.libsignal.ecc.ECPrivateKey; +import org.session.libsignal.libsignal.ecc.ECPublicKey; +import org.thoughtcrime.securesms.logging.Log; + +import org.thoughtcrime.securesms.util.Base64; +import org.thoughtcrime.securesms.util.Util; + +import java.io.IOException; +import java.security.GeneralSecurityException; +import java.security.NoSuchAlgorithmException; +import java.security.SecureRandom; +import java.security.spec.InvalidKeySpecException; +import java.util.Arrays; + +import javax.crypto.Cipher; +import javax.crypto.KeyGenerator; +import javax.crypto.Mac; +import javax.crypto.SecretKey; +import javax.crypto.SecretKeyFactory; +import javax.crypto.spec.PBEKeySpec; +import javax.crypto.spec.PBEParameterSpec; +import javax.crypto.spec.SecretKeySpec; + +/** + * Helper class for generating and securely storing a MasterSecret. + * + * @author Moxie Marlinspike + */ + +public class MasterSecretUtil { + + public static final String UNENCRYPTED_PASSPHRASE = "unencrypted"; + public static final String PREFERENCES_NAME = "SecureSMS-Preferences"; + + private static final String ASYMMETRIC_LOCAL_PUBLIC_DJB = "asymmetric_master_secret_curve25519_public"; + private static final String ASYMMETRIC_LOCAL_PRIVATE_DJB = "asymmetric_master_secret_curve25519_private"; + + public static MasterSecret changeMasterSecretPassphrase(Context context, + MasterSecret masterSecret, + String newPassphrase) + { + try { + byte[] combinedSecrets = Util.combine(masterSecret.getEncryptionKey().getEncoded(), + masterSecret.getMacKey().getEncoded()); + + byte[] encryptionSalt = generateSalt(); + int iterations = generateIterationCount(newPassphrase, encryptionSalt); + byte[] encryptedMasterSecret = encryptWithPassphrase(encryptionSalt, iterations, combinedSecrets, newPassphrase); + byte[] macSalt = generateSalt(); + byte[] encryptedAndMacdMasterSecret = macWithPassphrase(macSalt, iterations, encryptedMasterSecret, newPassphrase); + + save(context, "encryption_salt", encryptionSalt); + save(context, "mac_salt", macSalt); + save(context, "passphrase_iterations", iterations); + save(context, "master_secret", encryptedAndMacdMasterSecret); + save(context, "passphrase_initialized", true); + + return masterSecret; + } catch (GeneralSecurityException gse) { + throw new AssertionError(gse); + } + } + + public static MasterSecret changeMasterSecretPassphrase(Context context, + String originalPassphrase, + String newPassphrase) + throws InvalidPassphraseException + { + MasterSecret masterSecret = getMasterSecret(context, originalPassphrase); + changeMasterSecretPassphrase(context, masterSecret, newPassphrase); + + return masterSecret; + } + + public static MasterSecret getMasterSecret(Context context, String passphrase) + throws InvalidPassphraseException + { + try { + byte[] encryptedAndMacdMasterSecret = retrieve(context, "master_secret"); + byte[] macSalt = retrieve(context, "mac_salt"); + int iterations = retrieve(context, "passphrase_iterations", 100); + byte[] encryptedMasterSecret = verifyMac(macSalt, iterations, encryptedAndMacdMasterSecret, passphrase); + byte[] encryptionSalt = retrieve(context, "encryption_salt"); + byte[] combinedSecrets = decryptWithPassphrase(encryptionSalt, iterations, encryptedMasterSecret, passphrase); + byte[] encryptionSecret = Util.split(combinedSecrets, 16, 20)[0]; + byte[] macSecret = Util.split(combinedSecrets, 16, 20)[1]; + + return new MasterSecret(new SecretKeySpec(encryptionSecret, "AES"), + new SecretKeySpec(macSecret, "HmacSHA1")); + } catch (GeneralSecurityException e) { + Log.w("keyutil", e); + return null; //XXX + } catch (IOException e) { + Log.w("keyutil", e); + return null; //XXX + } + } + + public static AsymmetricMasterSecret getAsymmetricMasterSecret(@NonNull Context context, + @Nullable MasterSecret masterSecret) + { + try { + byte[] djbPublicBytes = retrieve(context, ASYMMETRIC_LOCAL_PUBLIC_DJB); + byte[] djbPrivateBytes = retrieve(context, ASYMMETRIC_LOCAL_PRIVATE_DJB); + + ECPublicKey djbPublicKey = null; + ECPrivateKey djbPrivateKey = null; + + if (djbPublicBytes != null) { + djbPublicKey = Curve.decodePoint(djbPublicBytes, 0); + } + + if (masterSecret != null) { + MasterCipher masterCipher = new MasterCipher(masterSecret); + + if (djbPrivateBytes != null) { + djbPrivateKey = masterCipher.decryptKey(djbPrivateBytes); + } + } + + return new AsymmetricMasterSecret(djbPublicKey, djbPrivateKey); + } catch (InvalidKeyException | IOException ike) { + throw new AssertionError(ike); + } + } + + public static AsymmetricMasterSecret generateAsymmetricMasterSecret(Context context, + MasterSecret masterSecret) + { + MasterCipher masterCipher = new MasterCipher(masterSecret); + ECKeyPair keyPair = Curve.generateKeyPair(); + + save(context, ASYMMETRIC_LOCAL_PUBLIC_DJB, keyPair.getPublicKey().serialize()); + save(context, ASYMMETRIC_LOCAL_PRIVATE_DJB, masterCipher.encryptKey(keyPair.getPrivateKey())); + + return new AsymmetricMasterSecret(keyPair.getPublicKey(), keyPair.getPrivateKey()); + } + + public static MasterSecret generateMasterSecret(Context context, String passphrase) { + try { + byte[] encryptionSecret = generateEncryptionSecret(); + byte[] macSecret = generateMacSecret(); + byte[] masterSecret = Util.combine(encryptionSecret, macSecret); + byte[] encryptionSalt = generateSalt(); + int iterations = generateIterationCount(passphrase, encryptionSalt); + byte[] encryptedMasterSecret = encryptWithPassphrase(encryptionSalt, iterations, masterSecret, passphrase); + byte[] macSalt = generateSalt(); + byte[] encryptedAndMacdMasterSecret = macWithPassphrase(macSalt, iterations, encryptedMasterSecret, passphrase); + + save(context, "encryption_salt", encryptionSalt); + save(context, "mac_salt", macSalt); + save(context, "passphrase_iterations", iterations); + save(context, "master_secret", encryptedAndMacdMasterSecret); + save(context, "passphrase_initialized", true); + + return new MasterSecret(new SecretKeySpec(encryptionSecret, "AES"), + new SecretKeySpec(macSecret, "HmacSHA1")); + } catch (GeneralSecurityException e) { + Log.w("keyutil", e); + return null; + } + } + + public static boolean hasAsymmericMasterSecret(Context context) { + SharedPreferences settings = context.getSharedPreferences(PREFERENCES_NAME, 0); + return settings.contains(ASYMMETRIC_LOCAL_PUBLIC_DJB); + } + + public static boolean isPassphraseInitialized(Context context) { + SharedPreferences preferences = context.getSharedPreferences(PREFERENCES_NAME, 0); + return preferences.getBoolean("passphrase_initialized", false); + } + + public static void clear(Context context) { + context.getSharedPreferences(PREFERENCES_NAME, 0).edit().clear().commit(); + } + + private static void save(Context context, String key, int value) { + if (!context.getSharedPreferences(PREFERENCES_NAME, 0) + .edit() + .putInt(key, value) + .commit()) + { + throw new AssertionError("failed to save a shared pref in MasterSecretUtil"); + } + } + + private static void save(Context context, String key, byte[] value) { + if (!context.getSharedPreferences(PREFERENCES_NAME, 0) + .edit() + .putString(key, Base64.encodeBytes(value)) + .commit()) + { + throw new AssertionError("failed to save a shared pref in MasterSecretUtil"); + } + } + + private static void save(Context context, String key, boolean value) { + if (!context.getSharedPreferences(PREFERENCES_NAME, 0) + .edit() + .putBoolean(key, value) + .commit()) + { + throw new AssertionError("failed to save a shared pref in MasterSecretUtil"); + } + } + + private static byte[] retrieve(Context context, String key) throws IOException { + SharedPreferences settings = context.getSharedPreferences(PREFERENCES_NAME, 0); + String encodedValue = settings.getString(key, ""); + + if (TextUtils.isEmpty(encodedValue)) return null; + else return Base64.decode(encodedValue); + } + + private static int retrieve(Context context, String key, int defaultValue) throws IOException { + SharedPreferences settings = context.getSharedPreferences(PREFERENCES_NAME, 0); + return settings.getInt(key, defaultValue); + } + + private static byte[] generateEncryptionSecret() { + try { + KeyGenerator generator = KeyGenerator.getInstance("AES"); + generator.init(128); + + SecretKey key = generator.generateKey(); + return key.getEncoded(); + } catch (NoSuchAlgorithmException ex) { + Log.w("keyutil", ex); + return null; + } + } + + private static byte[] generateMacSecret() { + try { + KeyGenerator generator = KeyGenerator.getInstance("HmacSHA1"); + return generator.generateKey().getEncoded(); + } catch (NoSuchAlgorithmException e) { + Log.w("keyutil", e); + return null; + } + } + + private static byte[] generateSalt() { + SecureRandom random = new SecureRandom(); + byte[] salt = new byte[16]; + random.nextBytes(salt); + + return salt; + } + + private static int generateIterationCount(String passphrase, byte[] salt) { + int TARGET_ITERATION_TIME = 50; //ms + int MINIMUM_ITERATION_COUNT = 100; //default for low-end devices + int BENCHMARK_ITERATION_COUNT = 10000; //baseline starting iteration count + + try { + PBEKeySpec keyspec = new PBEKeySpec(passphrase.toCharArray(), salt, BENCHMARK_ITERATION_COUNT); + SecretKeyFactory skf = SecretKeyFactory.getInstance("PBEWITHSHA1AND128BITAES-CBC-BC"); + + long startTime = System.currentTimeMillis(); + skf.generateSecret(keyspec); + long finishTime = System.currentTimeMillis(); + + int scaledIterationTarget = (int) (((double)BENCHMARK_ITERATION_COUNT / (double)(finishTime - startTime)) * TARGET_ITERATION_TIME); + + if (scaledIterationTarget < MINIMUM_ITERATION_COUNT) return MINIMUM_ITERATION_COUNT; + else return scaledIterationTarget; + } catch (NoSuchAlgorithmException e) { + Log.w("MasterSecretUtil", e); + return MINIMUM_ITERATION_COUNT; + } catch (InvalidKeySpecException e) { + Log.w("MasterSecretUtil", e); + return MINIMUM_ITERATION_COUNT; + } + } + + private static SecretKey getKeyFromPassphrase(String passphrase, byte[] salt, int iterations) + throws GeneralSecurityException + { + PBEKeySpec keyspec = new PBEKeySpec(passphrase.toCharArray(), salt, iterations); + SecretKeyFactory skf = SecretKeyFactory.getInstance("PBEWITHSHA1AND128BITAES-CBC-BC"); + return skf.generateSecret(keyspec); + } + + private static Cipher getCipherFromPassphrase(String passphrase, byte[] salt, int iterations, int opMode) + throws GeneralSecurityException + { + SecretKey key = getKeyFromPassphrase(passphrase, salt, iterations); + Cipher cipher = Cipher.getInstance(key.getAlgorithm()); + cipher.init(opMode, key, new PBEParameterSpec(salt, iterations)); + + return cipher; + } + + private static byte[] encryptWithPassphrase(byte[] encryptionSalt, int iterations, byte[] data, String passphrase) + throws GeneralSecurityException + { + Cipher cipher = getCipherFromPassphrase(passphrase, encryptionSalt, iterations, Cipher.ENCRYPT_MODE); + return cipher.doFinal(data); + } + + private static byte[] decryptWithPassphrase(byte[] encryptionSalt, int iterations, byte[] data, String passphrase) + throws GeneralSecurityException, IOException + { + Cipher cipher = getCipherFromPassphrase(passphrase, encryptionSalt, iterations, Cipher.DECRYPT_MODE); + return cipher.doFinal(data); + } + + private static Mac getMacForPassphrase(String passphrase, byte[] salt, int iterations) + throws GeneralSecurityException + { + SecretKey key = getKeyFromPassphrase(passphrase, salt, iterations); + byte[] pbkdf2 = key.getEncoded(); + SecretKeySpec hmacKey = new SecretKeySpec(pbkdf2, "HmacSHA1"); + Mac hmac = Mac.getInstance("HmacSHA1"); + hmac.init(hmacKey); + + return hmac; + } + + private static byte[] verifyMac(byte[] macSalt, int iterations, byte[] encryptedAndMacdData, String passphrase) throws InvalidPassphraseException, GeneralSecurityException, IOException { + Mac hmac = getMacForPassphrase(passphrase, macSalt, iterations); + + byte[] encryptedData = new byte[encryptedAndMacdData.length - hmac.getMacLength()]; + System.arraycopy(encryptedAndMacdData, 0, encryptedData, 0, encryptedData.length); + + byte[] givenMac = new byte[hmac.getMacLength()]; + System.arraycopy(encryptedAndMacdData, encryptedAndMacdData.length-hmac.getMacLength(), givenMac, 0, givenMac.length); + + byte[] localMac = hmac.doFinal(encryptedData); + + if (Arrays.equals(givenMac, localMac)) return encryptedData; + else throw new InvalidPassphraseException("MAC Error"); + } + + private static byte[] macWithPassphrase(byte[] macSalt, int iterations, byte[] data, String passphrase) throws GeneralSecurityException { + Mac hmac = getMacForPassphrase(passphrase, macSalt, iterations); + byte[] mac = hmac.doFinal(data); + byte[] result = new byte[data.length + mac.length]; + + System.arraycopy(data, 0, result, 0, data.length); + System.arraycopy(mac, 0, result, data.length, mac.length); + + return result; + } +} diff --git a/app/src/main/java/org/thoughtcrime/securesms/database/GroupDatabase.java b/app/src/main/java/org/thoughtcrime/securesms/database/GroupDatabase.java index 1fed44d500..2406d4b014 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/database/GroupDatabase.java +++ b/app/src/main/java/org/thoughtcrime/securesms/database/GroupDatabase.java @@ -412,7 +412,8 @@ public class GroupDatabase extends Database implements LokiOpenGroupDatabaseProt cursor.getBlob(cursor.getColumnIndexOrThrow(AVATAR_DIGEST)), cursor.getInt(cursor.getColumnIndexOrThrow(MMS)) == 1, cursor.getString(cursor.getColumnIndexOrThrow(AVATAR_URL)), - cursor.getString(cursor.getColumnIndexOrThrow(ADMINS))); + cursor.getString(cursor.getColumnIndexOrThrow(ADMINS)), + cursor.getLong(cursor.getColumnIndexOrThrow(TIMESTAMP))); } @Override @@ -437,10 +438,12 @@ public class GroupDatabase extends Database implements LokiOpenGroupDatabaseProt private final boolean mms; private final String url; private final List
admins; + private final long createdAt; public GroupRecord(String id, String title, String members, byte[] avatar, long avatarId, byte[] avatarKey, String avatarContentType, - String relay, boolean active, byte[] avatarDigest, boolean mms, String url, String admins) + String relay, boolean active, byte[] avatarDigest, boolean mms, + String url, String admins, long createdAt) { this.id = id; this.title = title; @@ -453,6 +456,7 @@ public class GroupDatabase extends Database implements LokiOpenGroupDatabaseProt this.active = active; this.mms = mms; this.url = url; + this.createdAt = createdAt; if (!TextUtils.isEmpty(members)) this.members = Address.fromSerializedList(members, ','); else this.members = new LinkedList<>(); @@ -522,5 +526,7 @@ public class GroupDatabase extends Database implements LokiOpenGroupDatabaseProt public String getUrl() { return url; } public List
getAdmins() { return admins; } + + public long getCreatedAt() { return createdAt; } } } diff --git a/app/src/main/java/org/thoughtcrime/securesms/database/helpers/SQLCipherOpenHelper.java b/app/src/main/java/org/thoughtcrime/securesms/database/helpers/SQLCipherOpenHelper.java index 3b4f95802c..77add38b57 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/database/helpers/SQLCipherOpenHelper.java +++ b/app/src/main/java/org/thoughtcrime/securesms/database/helpers/SQLCipherOpenHelper.java @@ -33,6 +33,7 @@ import org.thoughtcrime.securesms.loki.database.LokiMessageDatabase; import org.thoughtcrime.securesms.loki.database.LokiThreadDatabase; import org.thoughtcrime.securesms.loki.database.LokiUserDatabase; import org.thoughtcrime.securesms.loki.database.SharedSenderKeysDatabase; +import org.thoughtcrime.securesms.loki.protocol.ClosedGroupsMigration; public class SQLCipherOpenHelper extends SQLiteOpenHelper { @@ -54,12 +55,13 @@ public class SQLCipherOpenHelper extends SQLiteOpenHelper { private static final int lokiV17 = 38; private static final int lokiV18_CLEAR_BG_POLL_JOBS = 39; //TODO Merge all "refactor" migrations to one before pushing to the main repo. - private static final int lokiV19_REFACTOR0 = 40; + private static final int lokiV19 = 40; private static final int lokiV19_REFACTOR1 = 41; private static final int lokiV19_REFACTOR2 = 42; + // Loki - onUpgrade(...) must be updated to use Loki version numbers if Signal makes any database changes - private static final int DATABASE_VERSION = lokiV19_REFACTOR1; + private static final int DATABASE_VERSION = lokiV19; private static final String DATABASE_NAME = "signal.db"; private final Context context; @@ -121,6 +123,8 @@ public class SQLCipherOpenHelper extends SQLiteOpenHelper { db.execSQL(LokiAPIDatabase.getCreateSessionRequestProcessedTimestampTableCommand()); db.execSQL(LokiAPIDatabase.getCreateOpenGroupPublicKeyTableCommand()); db.execSQL(LokiAPIDatabase.getCreateOpenGroupProfilePictureTableCommand()); + db.execSQL(LokiAPIDatabase.getCreateClosedGroupEncryptionKeyPairsTable()); + db.execSQL(LokiAPIDatabase.getCreateClosedGroupPublicKeysTable()); db.execSQL(LokiMessageDatabase.getCreateMessageIDTableCommand()); db.execSQL(LokiMessageDatabase.getCreateMessageToThreadMappingTableCommand()); db.execSQL(LokiMessageDatabase.getCreateErrorMessageTableCommand()); @@ -217,8 +221,10 @@ public class SQLCipherOpenHelper extends SQLiteOpenHelper { // Many classes were removed. We need to update DB structure and data to match the code changes. //TODO Merge "refactor" changes in one migration. - if (oldVersion < lokiV19_REFACTOR0) { - deleteJobRecords(db, "ServiceOutageDetectionJob"); + if (oldVersion < lokiV19) { + db.execSQL(LokiAPIDatabase.getCreateClosedGroupEncryptionKeyPairsTable()); + db.execSQL(LokiAPIDatabase.getCreateClosedGroupPublicKeysTable()); + ClosedGroupsMigration.INSTANCE.perform(db); } if (oldVersion < lokiV19_REFACTOR1) { db.execSQL("DROP TABLE identities"); diff --git a/app/src/main/java/org/thoughtcrime/securesms/jobs/JobManagerFactories.java b/app/src/main/java/org/thoughtcrime/securesms/jobs/JobManagerFactories.java index 894617587f..b5ab7a0e66 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/jobs/JobManagerFactories.java +++ b/app/src/main/java/org/thoughtcrime/securesms/jobs/JobManagerFactories.java @@ -17,6 +17,7 @@ import org.thoughtcrime.securesms.jobmanager.impl.SqlCipherMigrationConstraintOb import org.thoughtcrime.securesms.loki.api.PrepareAttachmentAudioExtrasJob; import org.thoughtcrime.securesms.loki.api.ResetThreadSessionJob; import org.thoughtcrime.securesms.loki.protocol.ClosedGroupUpdateMessageSendJob; +import org.thoughtcrime.securesms.loki.protocol.ClosedGroupUpdateMessageSendJobV2; import org.thoughtcrime.securesms.loki.protocol.NullMessageSendJob; import java.util.Arrays; @@ -32,6 +33,7 @@ public final class JobManagerFactories { put(AttachmentUploadJob.KEY, new AttachmentUploadJob.Factory()); put(AvatarDownloadJob.KEY, new AvatarDownloadJob.Factory()); put(ClosedGroupUpdateMessageSendJob.KEY, new ClosedGroupUpdateMessageSendJob.Factory()); + put(ClosedGroupUpdateMessageSendJobV2.KEY, new ClosedGroupUpdateMessageSendJobV2.Factory()); put(LocalBackupJob.KEY, new LocalBackupJob.Factory()); put(MmsDownloadJob.KEY, new MmsDownloadJob.Factory()); put(MmsReceiveJob.KEY, new MmsReceiveJob.Factory()); diff --git a/app/src/main/java/org/thoughtcrime/securesms/jobs/PushDecryptJob.java b/app/src/main/java/org/thoughtcrime/securesms/jobs/PushDecryptJob.java index 0d997a82e7..7fa8f1542d 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/jobs/PushDecryptJob.java +++ b/app/src/main/java/org/thoughtcrime/securesms/jobs/PushDecryptJob.java @@ -25,6 +25,7 @@ import org.session.libsignal.metadata.ProtocolLegacyMessageException; import org.session.libsignal.metadata.ProtocolNoSessionException; import org.session.libsignal.metadata.ProtocolUntrustedIdentityException; import org.session.libsignal.metadata.SelfSendException; +import org.session.libsignal.service.loki.api.crypto.SessionProtocol; import org.thoughtcrime.securesms.ApplicationContext; import org.thoughtcrime.securesms.attachments.Attachment; import org.thoughtcrime.securesms.attachments.DatabaseAttachment; @@ -64,9 +65,11 @@ 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; +import org.thoughtcrime.securesms.loki.protocol.ClosedGroupsProtocolV2; import org.thoughtcrime.securesms.loki.protocol.SessionManagementProtocol; import org.thoughtcrime.securesms.loki.protocol.SessionMetaProtocol; import org.thoughtcrime.securesms.loki.protocol.SessionResetImplementation; @@ -245,7 +248,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); @@ -268,8 +272,8 @@ public class PushDecryptJob extends BaseJob implements InjectableType { throw new UnsupportedOperationException("Device link operations are not supported!"); } else { - if (message.getClosedGroupUpdate().isPresent()) { - ClosedGroupsProtocol.handleSharedSenderKeysUpdate(context, message.getClosedGroupUpdate().get(), content.getSender()); + if (message.getClosedGroupUpdateV2().isPresent()) { + ClosedGroupsProtocolV2.handleMessage(context, message.getClosedGroupUpdateV2().get(), message.getTimestamp(), envelope.getSource(), content.getSender()); } if (message.isEndSession()) { @@ -358,6 +362,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/app/src/main/java/org/thoughtcrime/securesms/loki/activities/CreateClosedGroupActivity.kt b/app/src/main/java/org/thoughtcrime/securesms/loki/activities/CreateClosedGroupActivity.kt index 60599de1e2..5b693c4919 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/loki/activities/CreateClosedGroupActivity.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/loki/activities/CreateClosedGroupActivity.kt @@ -28,6 +28,7 @@ import org.thoughtcrime.securesms.mms.GlideApp import org.thoughtcrime.securesms.recipients.Recipient import org.thoughtcrime.securesms.util.TextSecurePreferences import org.session.libsignal.libsignal.util.guava.Optional +import org.thoughtcrime.securesms.loki.protocol.ClosedGroupsProtocolV2 import java.lang.ref.WeakReference //TODO Refactor to avoid using kotlinx.android.synthetic @@ -122,7 +123,7 @@ class CreateClosedGroupActivity : PassphraseRequiredActionBarActivity(), LoaderM val userPublicKey = TextSecurePreferences.getLocalNumber(this) isLoading = true loaderContainer.fadeIn() - ClosedGroupsProtocol.createClosedGroup(this, name.toString(), selectedMembers + setOf( userPublicKey )).successUi { groupID -> + ClosedGroupsProtocolV2.createClosedGroup(this, name.toString(), selectedMembers + setOf( userPublicKey )).successUi { groupID -> loaderContainer.fadeOut() isLoading = false val threadID = DatabaseFactory.getThreadDatabase(this).getOrCreateThreadIdFor(Recipient.from(this, Address.fromSerialized(groupID), false)) diff --git a/app/src/main/java/org/thoughtcrime/securesms/loki/activities/EditClosedGroupActivity.kt b/app/src/main/java/org/thoughtcrime/securesms/loki/activities/EditClosedGroupActivity.kt index 48de4ef3a4..bf25a80fc9 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/loki/activities/EditClosedGroupActivity.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/loki/activities/EditClosedGroupActivity.kt @@ -14,6 +14,7 @@ import androidx.loader.content.Loader import androidx.recyclerview.widget.LinearLayoutManager import androidx.recyclerview.widget.RecyclerView import network.loki.messenger.R +import nl.komponents.kovenant.Promise import nl.komponents.kovenant.ui.failUi import nl.komponents.kovenant.ui.successUi import org.session.libsignal.service.loki.utilities.toHexString @@ -23,6 +24,7 @@ import org.thoughtcrime.securesms.database.DatabaseFactory import org.thoughtcrime.securesms.groups.GroupManager import org.thoughtcrime.securesms.loki.dialogs.ClosedGroupEditingOptionsBottomSheet import org.thoughtcrime.securesms.loki.protocol.ClosedGroupsProtocol +import org.thoughtcrime.securesms.loki.protocol.ClosedGroupsProtocolV2 import org.thoughtcrime.securesms.loki.utilities.fadeIn import org.thoughtcrime.securesms.loki.utilities.fadeOut import org.thoughtcrime.securesms.mms.GlideApp @@ -174,8 +176,9 @@ class EditClosedGroupActivity : PassphraseRequiredActionBarActivity() { this.members.addAll(members) memberListAdapter.setMembers(members) - val userPublicKey = TextSecurePreferences.getLocalNumber(this) - memberListAdapter.setLockedMembers(arrayListOf(userPublicKey)) + val admins = DatabaseFactory.getGroupDatabase(this).getGroup(groupID).get().admins.map { it.toPhoneString() }.toMutableSet() + admins.remove(TextSecurePreferences.getLocalNumber(this)) + memberListAdapter.setLockedMembers(admins) mainContentContainer.visibility = if (members.isEmpty()) View.GONE else View.VISIBLE emptyStateContainer.visibility = if (members.isEmpty()) View.VISIBLE else View.GONE @@ -224,7 +227,7 @@ class EditClosedGroupActivity : PassphraseRequiredActionBarActivity() { } private fun commitChanges() { - val hasMemberListChanges = members != originalMembers + val hasMemberListChanges = (members != originalMembers) if (!hasNameChanged && !hasMemberListChanges) { return finish() @@ -258,16 +261,31 @@ class EditClosedGroupActivity : PassphraseRequiredActionBarActivity() { return Toast.makeText(this, R.string.activity_edit_closed_group_too_many_group_members_error, Toast.LENGTH_LONG).show() } + val userPublicKey = TextSecurePreferences.getLocalNumber(this) + val userAsRecipient = Recipient.from(this, Address.fromSerialized(userPublicKey), false) + + if (!members.contains(userAsRecipient) && !members.map { it.address.toPhoneString() }.containsAll(originalMembers.minus(userPublicKey))) { + val message = "Can't leave while adding or removing other members." + return Toast.makeText(this@EditClosedGroupActivity, message, Toast.LENGTH_LONG).show() + } + if (isSSKBasedClosedGroup) { isLoading = true loaderContainer.fadeIn() - ClosedGroupsProtocol.update(this, groupPublicKey!!, members.map { it.address.serialize() }, name).successUi { + val promise: Promise + if (!members.contains(Recipient.from(this, Address.fromSerialized(userPublicKey), false))) { + promise = ClosedGroupsProtocolV2.leave(this, groupPublicKey!!) + } else { + promise = ClosedGroupsProtocolV2.update(this, groupPublicKey!!, members.map { it.address.serialize() }, name) + } + promise.successUi { loaderContainer.fadeOut() isLoading = false finish() }.failUi { exception -> val message = if (exception is ClosedGroupsProtocol.Error) exception.description else "An error occurred" Toast.makeText(this@EditClosedGroupActivity, message, Toast.LENGTH_LONG).show() + loader.fadeOut() isLoading = false } } else { diff --git a/app/src/main/java/org/thoughtcrime/securesms/loki/activities/HomeActivity.kt b/app/src/main/java/org/thoughtcrime/securesms/loki/activities/HomeActivity.kt index 350208232c..45b0730df8 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/loki/activities/HomeActivity.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/loki/activities/HomeActivity.kt @@ -33,10 +33,6 @@ import org.thoughtcrime.securesms.database.Address import org.thoughtcrime.securesms.database.DatabaseFactory import org.thoughtcrime.securesms.database.model.ThreadRecord import org.thoughtcrime.securesms.loki.api.ResetThreadSessionJob -import org.thoughtcrime.securesms.loki.dialogs.ConversationOptionsBottomSheet -import org.thoughtcrime.securesms.loki.dialogs.LightThemeFeatureIntroBottomSheet -import org.thoughtcrime.securesms.loki.dialogs.MultiDeviceRemovalBottomSheet -import org.thoughtcrime.securesms.loki.dialogs.UserDetailsBottomSheet import org.thoughtcrime.securesms.loki.protocol.ClosedGroupsProtocol import org.thoughtcrime.securesms.loki.protocol.SessionResetImplementation import org.thoughtcrime.securesms.loki.utilities.* @@ -56,6 +52,8 @@ import org.session.libsignal.service.loki.protocol.sessionmanagement.SessionMana import org.session.libsignal.service.loki.protocol.shelved.multidevice.MultiDeviceProtocol import org.session.libsignal.service.loki.protocol.shelved.syncmessages.SyncMessagesProtocol import org.session.libsignal.service.loki.utilities.toHexString +import org.thoughtcrime.securesms.loki.dialogs.* +import org.thoughtcrime.securesms.loki.protocol.ClosedGroupsProtocolV2 import java.io.IOException class HomeActivity : PassphraseRequiredActionBarActivity, ConversationClickListener, SeedReminderViewDelegate, NewConversationButtonSetViewDelegate { @@ -201,14 +199,6 @@ class HomeActivity : PassphraseRequiredActionBarActivity, ConversationClickListe } this.broadcastReceiver = broadcastReceiver LocalBroadcastManager.getInstance(this).registerReceiver(broadcastReceiver, IntentFilter("blockedContactsChanged")) - // Clear all data if this is a secondary device - if (TextSecurePreferences.getMasterHexEncodedPublicKey(this) != null) { - TextSecurePreferences.setWasUnlinked(this, true) - ApplicationContext.getInstance(this).clearAllData() - } - - // Perform chat sessions reset if requested (usually happens after backup restoration). - scheduleResetAllSessionsIfRequested(this) } override fun onResume() { @@ -220,6 +210,21 @@ class HomeActivity : PassphraseRequiredActionBarActivity, ConversationClickListe if (hasViewedSeed || !isMasterDevice) { seedReminderView.visibility = View.GONE } + showKeyPairMigrationSheetIfNeeded() + showKeyPairMigrationSuccessSheetIfNeeded() + } + + private fun showKeyPairMigrationSheetIfNeeded() { + if (KeyPairUtilities.hasV2KeyPair(this)) { return } + val keyPairMigrationSheet = KeyPairMigrationBottomSheet() + keyPairMigrationSheet.show(supportFragmentManager, keyPairMigrationSheet.tag) + } + + private fun showKeyPairMigrationSuccessSheetIfNeeded() { + if (!KeyPairUtilities.hasV2KeyPair(this) || !TextSecurePreferences.getIsMigratingKeyPair(this)) { return } + val keyPairMigrationSuccessSheet = KeyPairMigrationSuccessBottomSheet() + keyPairMigrationSuccessSheet.show(supportFragmentManager, keyPairMigrationSuccessSheet.tag) + TextSecurePreferences.setIsMigratingKeyPair(this, false) } override fun onActivityResult(requestCode: Int, resultCode: Int, data: Intent?) { @@ -323,13 +328,23 @@ class HomeActivity : PassphraseRequiredActionBarActivity, ConversationClickListe val threadID = thread.threadId val recipient = thread.recipient val threadDB = DatabaseFactory.getThreadDatabase(this) - val dialogMessage = if (recipient.isGroupRecipient) R.string.activity_home_leave_group_dialog_message else R.string.activity_home_delete_conversation_dialog_message + val isClosedGroup = recipient.address.isClosedGroup + val dialogMessage: String + if (recipient.isGroupRecipient) { + val group = DatabaseFactory.getGroupDatabase(this).getGroup(recipient.address.toString()).orNull() + if (group != null && group.admins.map { it.toPhoneString() }.contains(TextSecurePreferences.getLocalNumber(this))) { + dialogMessage = "Because you are the creator of this group it will be deleted for everyone. This cannot be undone." + } else { + dialogMessage = resources.getString(R.string.activity_home_leave_group_dialog_message) + } + } else { + dialogMessage = resources.getString(R.string.activity_home_delete_conversation_dialog_message) + } val dialog = AlertDialog.Builder(this) dialog.setMessage(dialogMessage) dialog.setPositiveButton(R.string.yes) { _, _ -> lifecycleScope.launch(Dispatchers.Main) { val context = this@HomeActivity as Context - val isClosedGroup = recipient.address.isClosedGroup // Send a leave group message if this is an active closed group if (isClosedGroup && DatabaseFactory.getGroupDatabase(context).isActive(recipient.address.toGroupString())) { var isSSKBasedClosedGroup: Boolean @@ -342,7 +357,7 @@ class HomeActivity : PassphraseRequiredActionBarActivity, ConversationClickListe isSSKBasedClosedGroup = false } if (isSSKBasedClosedGroup) { - ClosedGroupsProtocol.leave(context, groupPublicKey!!) + ClosedGroupsProtocolV2.leave(context, groupPublicKey!!) } else if (!ClosedGroupsProtocol.leaveLegacyGroup(context, recipient)) { Toast.makeText(context, R.string.activity_home_leaving_group_failed_message, Toast.LENGTH_LONG).show() return@launch diff --git a/app/src/main/java/org/thoughtcrime/securesms/loki/api/SessionProtocolImpl.kt b/app/src/main/java/org/thoughtcrime/securesms/loki/api/SessionProtocolImpl.kt index 2009132adf..115e84ba5c 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/loki/api/SessionProtocolImpl.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/loki/api/SessionProtocolImpl.kt @@ -6,11 +6,13 @@ import com.goterl.lazycode.lazysodium.LazySodiumAndroid import com.goterl.lazycode.lazysodium.SodiumAndroid import com.goterl.lazycode.lazysodium.interfaces.Box import com.goterl.lazycode.lazysodium.interfaces.Sign +import org.session.libsignal.libsignal.ecc.ECKeyPair import org.session.libsignal.libsignal.util.Hex import org.session.libsignal.service.api.messages.SignalServiceEnvelope import org.session.libsignal.service.internal.push.SignalServiceProtos.Envelope.Type.CLOSED_GROUP_CIPHERTEXT_VALUE import org.session.libsignal.service.internal.push.SignalServiceProtos.Envelope.Type.UNIDENTIFIED_SENDER_VALUE import org.session.libsignal.service.loki.api.crypto.SessionProtocol +import org.session.libsignal.service.loki.utilities.hexEncodedPublicKey import org.session.libsignal.service.loki.utilities.removing05PrefixIfNeeded import org.session.libsignal.service.loki.utilities.toHexString import org.thoughtcrime.securesms.crypto.IdentityKeyUtil @@ -45,25 +47,10 @@ 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()) + Log.d("Test", "recipientX25519PublicKey: $recipientX25519PublicKey") val sodium = LazySodiumAndroid(SodiumAndroid()) val signatureSize = Sign.BYTES val ed25519PublicKeySize = Sign.PUBLICKEYBYTES diff --git a/app/src/main/java/org/thoughtcrime/securesms/loki/database/LokiAPIDatabase.kt b/app/src/main/java/org/thoughtcrime/securesms/loki/database/LokiAPIDatabase.kt index e0e47ec82a..6a81a881f8 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/loki/database/LokiAPIDatabase.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/loki/database/LokiAPIDatabase.kt @@ -2,6 +2,9 @@ package org.thoughtcrime.securesms.loki.database import android.content.ContentValues import android.content.Context +import org.session.libsignal.libsignal.ecc.DjbECPrivateKey +import org.session.libsignal.libsignal.ecc.DjbECPublicKey +import org.session.libsignal.libsignal.ecc.ECKeyPair import org.thoughtcrime.securesms.logging.Log import org.thoughtcrime.securesms.database.Database import org.thoughtcrime.securesms.database.helpers.SQLCipherOpenHelper @@ -9,6 +12,10 @@ import org.thoughtcrime.securesms.loki.utilities.* import org.session.libsignal.service.loki.api.Snode import org.session.libsignal.service.loki.database.LokiAPIDatabaseProtocol import org.session.libsignal.service.loki.protocol.shelved.multidevice.DeviceLink +import org.session.libsignal.service.loki.utilities.removing05PrefixIfNeeded +import org.session.libsignal.service.loki.utilities.toHexString +import org.thoughtcrime.securesms.crypto.IdentityKeyUtil +import org.thoughtcrime.securesms.util.Hex import org.thoughtcrime.securesms.util.TextSecurePreferences import java.util.* @@ -76,6 +83,17 @@ class LokiAPIDatabase(context: Context, helper: SQLCipherOpenHelper) : Database( public val openGroupProfilePictureTable = "open_group_avatar_cache" private val openGroupProfilePicture = "open_group_avatar" @JvmStatic val createOpenGroupProfilePictureTableCommand = "CREATE TABLE $openGroupProfilePictureTable ($publicChatID STRING PRIMARY KEY, $openGroupProfilePicture TEXT NULLABLE DEFAULT NULL);" + // Closed groups (V2) + public val closedGroupEncryptionKeyPairsTable = "closed_group_encryption_key_pairs_table" + public val closedGroupsEncryptionKeyPairIndex = "closed_group_encryption_key_pair_index" + public val encryptionKeyPairPublicKey = "encryption_key_pair_public_key" + public val encryptionKeyPairPrivateKey = "encryption_key_pair_private_key" + @JvmStatic + val createClosedGroupEncryptionKeyPairsTable = "CREATE TABLE $closedGroupEncryptionKeyPairsTable ($closedGroupsEncryptionKeyPairIndex STRING PRIMARY KEY, $encryptionKeyPairPublicKey STRING, $encryptionKeyPairPrivateKey STRING)" + public val closedGroupPublicKeysTable = "closed_group_public_keys_table" + public val groupPublicKey = "group_public_key" + @JvmStatic + val createClosedGroupPublicKeysTable = "CREATE TABLE $closedGroupPublicKeysTable ($groupPublicKey STRING PRIMARY KEY)" // region Deprecated private val deviceLinkCache = "loki_pairing_authorisation_cache" @@ -387,6 +405,61 @@ 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().removing05PrefixIfNeeded()), DjbECPrivateKey(keyPair.privateKey.serialize())) + } + + fun addClosedGroupEncryptionKeyPair(encryptionKeyPair: ECKeyPair, groupPublicKey: String) { + val database = databaseHelper.writableDatabase + val timestamp = Date().time.toString() + val index = "$groupPublicKey-$timestamp" + val encryptionKeyPairPublicKey = encryptionKeyPair.publicKey.serialize().toHexString().removing05PrefixIfNeeded() + val encryptionKeyPairPrivateKey = encryptionKeyPair.privateKey.serialize().toHexString() + val row = wrap(mapOf( Companion.closedGroupsEncryptionKeyPairIndex to index, Companion.encryptionKeyPairPublicKey to encryptionKeyPairPublicKey, + Companion.encryptionKeyPairPrivateKey to encryptionKeyPairPrivateKey )) + database.insertOrUpdate(closedGroupEncryptionKeyPairsTable, row, "${Companion.closedGroupsEncryptionKeyPairIndex} = ?", wrap(index)) + } + + override fun getClosedGroupEncryptionKeyPairs(groupPublicKey: String): List { + val database = databaseHelper.readableDatabase + val timestampsAndKeyPairs = database.getAll(closedGroupEncryptionKeyPairsTable, "${Companion.closedGroupsEncryptionKeyPairIndex} LIKE ?", wrap("$groupPublicKey%")) { cursor -> + val timestamp = cursor.getString(cursor.getColumnIndexOrThrow(Companion.closedGroupsEncryptionKeyPairIndex)).split("-").last() + val encryptionKeyPairPublicKey = cursor.getString(cursor.getColumnIndexOrThrow(Companion.encryptionKeyPairPublicKey)) + val encryptionKeyPairPrivateKey = cursor.getString(cursor.getColumnIndexOrThrow(Companion.encryptionKeyPairPrivateKey)) + val keyPair = ECKeyPair(DjbECPublicKey(Hex.fromStringCondensed(encryptionKeyPairPublicKey)), DjbECPrivateKey(Hex.fromStringCondensed(encryptionKeyPairPrivateKey))) + Pair(timestamp, keyPair) + } + return timestampsAndKeyPairs.sortedBy { it.first.toLong() }.map { it.second } + } + + override fun getLatestClosedGroupEncryptionKeyPair(groupPublicKey: String): ECKeyPair? { + return getClosedGroupEncryptionKeyPairs(groupPublicKey).lastOrNull() + } + + fun removeAllClosedGroupEncryptionKeyPairs(groupPublicKey: String) { + val database = databaseHelper.writableDatabase + database.delete(closedGroupEncryptionKeyPairsTable, "${Companion.closedGroupsEncryptionKeyPairIndex} LIKE ?", wrap("$groupPublicKey%")) + } + + fun addClosedGroupPublicKey(groupPublicKey: String) { + val database = databaseHelper.writableDatabase + val row = wrap(mapOf( Companion.groupPublicKey to groupPublicKey )) + database.insertOrUpdate(closedGroupPublicKeysTable, row, "${Companion.groupPublicKey} = ?", wrap(groupPublicKey)) + } + + fun getAllClosedGroupPublicKeys(): Set { + val database = databaseHelper.readableDatabase + return database.getAll(closedGroupPublicKeysTable, null, null) { cursor -> + cursor.getString(cursor.getColumnIndexOrThrow(Companion.groupPublicKey)) + }.toSet() + } + + fun removeClosedGroupPublicKey(groupPublicKey: String) { + val database = databaseHelper.writableDatabase + database.delete(closedGroupPublicKeysTable, "${Companion.groupPublicKey} = ?", wrap(groupPublicKey)) + } + // region Deprecated override fun getDeviceLinks(publicKey: String): Set { return setOf() diff --git a/app/src/main/java/org/thoughtcrime/securesms/loki/database/SharedSenderKeysDatabase.kt b/app/src/main/java/org/thoughtcrime/securesms/loki/database/SharedSenderKeysDatabase.kt index 7d9ff5cadb..b9de6a25c9 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/loki/database/SharedSenderKeysDatabase.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/loki/database/SharedSenderKeysDatabase.kt @@ -11,12 +11,13 @@ import org.session.libsignal.service.loki.protocol.closedgroups.ClosedGroupRatch import org.session.libsignal.service.loki.protocol.closedgroups.ClosedGroupSenderKey import org.session.libsignal.service.loki.protocol.closedgroups.SharedSenderKeysDatabaseProtocol import org.session.libsignal.service.loki.utilities.PublicKeyValidation +import org.thoughtcrime.securesms.database.DatabaseFactory class SharedSenderKeysDatabase(context: Context, helper: SQLCipherOpenHelper) : Database(context, helper), SharedSenderKeysDatabaseProtocol { companion object { // Shared - private val closedGroupPublicKey = "closed_group_public_key" + public val closedGroupPublicKey = "closed_group_public_key" // Ratchets private val oldClosedGroupRatchetTable = "old_closed_group_ratchet_table" private val currentClosedGroupRatchetTable = "closed_group_ratchet_table" @@ -32,8 +33,8 @@ class SharedSenderKeysDatabase(context: Context, helper: SQLCipherOpenHelper) : = "CREATE TABLE $currentClosedGroupRatchetTable ($closedGroupPublicKey STRING, $senderPublicKey STRING, $chainKey STRING, " + "$keyIndex INTEGER DEFAULT 0, $messageKeys TEXT, PRIMARY KEY ($closedGroupPublicKey, $senderPublicKey));" // Private keys - private val closedGroupPrivateKeyTable = "closed_group_private_key_table" - private val closedGroupPrivateKey = "closed_group_private_key" + public val closedGroupPrivateKeyTable = "closed_group_private_key_table" + public val closedGroupPrivateKey = "closed_group_private_key" @JvmStatic val createClosedGroupPrivateKeyTableCommand = "CREATE TABLE $closedGroupPrivateKeyTable ($closedGroupPublicKey STRING PRIMARY KEY, $closedGroupPrivateKey STRING);" } @@ -123,11 +124,14 @@ class SharedSenderKeysDatabase(context: Context, helper: SQLCipherOpenHelper) : override fun getAllClosedGroupPublicKeys(): Set { val database = databaseHelper.readableDatabase - return database.getAll(closedGroupPrivateKeyTable, null, null) { cursor -> + val result = mutableSetOf() + result.addAll(database.getAll(closedGroupPrivateKeyTable, null, null) { cursor -> cursor.getString(Companion.closedGroupPublicKey) }.filter { PublicKeyValidation.isValid(it) - }.toSet() + }) + result.addAll(DatabaseFactory.getLokiAPIDatabase(context).getAllClosedGroupPublicKeys()) + return result } // endregion diff --git a/app/src/main/java/org/thoughtcrime/securesms/loki/dialogs/KeyPairMigrationBottomSheet.kt b/app/src/main/java/org/thoughtcrime/securesms/loki/dialogs/KeyPairMigrationBottomSheet.kt new file mode 100644 index 0000000000..f50c69d493 --- /dev/null +++ b/app/src/main/java/org/thoughtcrime/securesms/loki/dialogs/KeyPairMigrationBottomSheet.kt @@ -0,0 +1,44 @@ +package org.thoughtcrime.securesms.loki.dialogs + +import android.app.AlertDialog +import android.app.Dialog +import android.os.Bundle +import android.view.LayoutInflater +import android.view.View +import android.view.ViewGroup +import android.widget.FrameLayout +import com.google.android.material.bottomsheet.BottomSheetBehavior +import com.google.android.material.bottomsheet.BottomSheetDialog +import com.google.android.material.bottomsheet.BottomSheetDialogFragment +import kotlinx.android.synthetic.main.fragment_key_pair_migration_bottom_sheet.* +import network.loki.messenger.R +import org.thoughtcrime.securesms.ApplicationContext + +class KeyPairMigrationBottomSheet : BottomSheetDialogFragment() { + + override fun onCreateView(inflater: LayoutInflater, container: ViewGroup?, savedInstanceState: Bundle?): View? { + return inflater.inflate(R.layout.fragment_key_pair_migration_bottom_sheet, container, false) + } + + override fun onViewCreated(view: View, savedInstanceState: Bundle?) { + super.onViewCreated(view, savedInstanceState) + upgradeNowButton.setOnClickListener { upgradeNow() } + } + + override fun onCreateDialog(savedInstanceState: Bundle?): Dialog { + val dialog = super.onCreateDialog(savedInstanceState) + dialog.setOnShowListener { + val d = dialog as BottomSheetDialog + val bottomSheet = d.findViewById(com.google.android.material.R.id.design_bottom_sheet)!! + BottomSheetBehavior.from(bottomSheet).state = BottomSheetBehavior.STATE_EXPANDED + BottomSheetBehavior.from(bottomSheet).isHideable = false + } + isCancelable = false + return dialog + } + + private fun upgradeNow() { + val applicationContext = requireContext().applicationContext as ApplicationContext + applicationContext.clearAllData(true) + } +} \ No newline at end of file diff --git a/app/src/main/java/org/thoughtcrime/securesms/loki/dialogs/KeyPairMigrationSuccessBottomSheet.kt b/app/src/main/java/org/thoughtcrime/securesms/loki/dialogs/KeyPairMigrationSuccessBottomSheet.kt new file mode 100644 index 0000000000..cc7f1dcb61 --- /dev/null +++ b/app/src/main/java/org/thoughtcrime/securesms/loki/dialogs/KeyPairMigrationSuccessBottomSheet.kt @@ -0,0 +1,55 @@ +package org.thoughtcrime.securesms.loki.dialogs + +import android.app.AlertDialog +import android.app.Dialog +import android.content.ClipData +import android.content.ClipboardManager +import android.content.Context +import android.os.Bundle +import android.view.LayoutInflater +import android.view.View +import android.view.ViewGroup +import android.widget.FrameLayout +import android.widget.Toast +import com.google.android.material.bottomsheet.BottomSheetBehavior +import com.google.android.material.bottomsheet.BottomSheetDialog +import com.google.android.material.bottomsheet.BottomSheetDialogFragment +import kotlinx.android.synthetic.main.fragment_key_pair_migration_success_bottom_sheet.* +import network.loki.messenger.R +import org.thoughtcrime.securesms.util.TextSecurePreferences + +class KeyPairMigrationSuccessBottomSheet : BottomSheetDialogFragment() { + + private val sessionID by lazy { + TextSecurePreferences.getLocalNumber(requireContext()) + } + + override fun onCreateView(inflater: LayoutInflater, container: ViewGroup?, savedInstanceState: Bundle?): View? { + return inflater.inflate(R.layout.fragment_key_pair_migration_success_bottom_sheet, container, false) + } + + override fun onViewCreated(view: View, savedInstanceState: Bundle?) { + super.onViewCreated(view, savedInstanceState) + sessionIDTextView.text = sessionID + copyButton.setOnClickListener { copySessionID() } + okButton.setOnClickListener { dismiss() } + } + + override fun onCreateDialog(savedInstanceState: Bundle?): Dialog { + val dialog = super.onCreateDialog(savedInstanceState) + // Expand the bottom sheet by default + dialog.setOnShowListener { + val d = dialog as BottomSheetDialog + val bottomSheet = d.findViewById(com.google.android.material.R.id.design_bottom_sheet) + BottomSheetBehavior.from(bottomSheet!!).setState(BottomSheetBehavior.STATE_EXPANDED); + } + return dialog + } + + private fun copySessionID() { + val clipboard = requireContext().getSystemService(Context.CLIPBOARD_SERVICE) as ClipboardManager + val clip = ClipData.newPlainText("Session ID", sessionID) + clipboard.setPrimaryClip(clip) + Toast.makeText(requireContext(), R.string.copied_to_clipboard, Toast.LENGTH_SHORT).show() + } +} \ No newline at end of file diff --git a/app/src/main/java/org/thoughtcrime/securesms/loki/protocol/ClosedGroupUpdateMessageSendJobV2.kt b/app/src/main/java/org/thoughtcrime/securesms/loki/protocol/ClosedGroupUpdateMessageSendJobV2.kt new file mode 100644 index 0000000000..e36e5abd59 --- /dev/null +++ b/app/src/main/java/org/thoughtcrime/securesms/loki/protocol/ClosedGroupUpdateMessageSendJobV2.kt @@ -0,0 +1,180 @@ +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.session.libsignal.libsignal.ecc.ECKeyPair +import org.session.libsignal.service.internal.push.SignalServiceProtos +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 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().removing05PrefixIfNeeded()) + 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().removing05PrefixIfNeeded()) + 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() { } +} \ No newline at end of file diff --git a/app/src/main/java/org/thoughtcrime/securesms/loki/protocol/ClosedGroupsMigration.kt b/app/src/main/java/org/thoughtcrime/securesms/loki/protocol/ClosedGroupsMigration.kt new file mode 100644 index 0000000000..bc829690bb --- /dev/null +++ b/app/src/main/java/org/thoughtcrime/securesms/loki/protocol/ClosedGroupsMigration.kt @@ -0,0 +1,52 @@ +package org.thoughtcrime.securesms.loki.protocol + +import android.content.ContentValues +import org.thoughtcrime.securesms.loki.database.LokiAPIDatabase +import org.thoughtcrime.securesms.loki.database.SharedSenderKeysDatabase +import org.thoughtcrime.securesms.loki.utilities.get +import org.thoughtcrime.securesms.loki.utilities.getAll +import org.thoughtcrime.securesms.loki.utilities.getString +import org.thoughtcrime.securesms.loki.utilities.insertOrUpdate +import org.thoughtcrime.securesms.util.Hex +import org.whispersystems.libsignal.ecc.DjbECPrivateKey +import org.whispersystems.libsignal.ecc.DjbECPublicKey +import org.whispersystems.libsignal.ecc.ECKeyPair +import org.whispersystems.signalservice.loki.utilities.PublicKeyValidation +import org.whispersystems.signalservice.loki.utilities.removing05PrefixIfNeeded +import org.whispersystems.signalservice.loki.utilities.toHexString +import java.util.* + +object ClosedGroupsMigration { + + fun perform(database: net.sqlcipher.database.SQLiteDatabase) { + val publicKeys = database.getAll(SharedSenderKeysDatabase.closedGroupPrivateKeyTable, null, null) { cursor -> + cursor.getString(SharedSenderKeysDatabase.closedGroupPublicKey) + }.filter { + PublicKeyValidation.isValid(it) + } + val keyPairs = mutableListOf() + for (publicKey in publicKeys) { + val query = "${SharedSenderKeysDatabase.closedGroupPublicKey} = ?" + val privateKey = database.get(SharedSenderKeysDatabase.closedGroupPrivateKeyTable, query, arrayOf( publicKey )) { cursor -> + cursor.getString(SharedSenderKeysDatabase.closedGroupPrivateKey) + } + val keyPair = ECKeyPair(DjbECPublicKey(Hex.fromStringCondensed(publicKey.removing05PrefixIfNeeded())), DjbECPrivateKey(Hex.fromStringCondensed(privateKey))) + keyPairs.add(keyPair) + val row = ContentValues(1) + row.put(LokiAPIDatabase.groupPublicKey, publicKey) + database.insertOrUpdate(LokiAPIDatabase.closedGroupPublicKeysTable, row, "${LokiAPIDatabase.groupPublicKey} = ?", arrayOf( publicKey )) + } + for (keyPair in keyPairs) { + // In this particular case keyPair.publicKey == groupPublicKey + val timestamp = Date().time.toString() + val index = "${keyPair.publicKey.serialize().toHexString()}-$timestamp" + val encryptionKeyPairPublicKey = keyPair.publicKey.serialize().toHexString().removing05PrefixIfNeeded() + val encryptionKeyPairPrivateKey = keyPair.privateKey.serialize().toHexString() + val row = ContentValues(3) + row.put(LokiAPIDatabase.closedGroupsEncryptionKeyPairIndex, index) + row.put(LokiAPIDatabase.encryptionKeyPairPublicKey, encryptionKeyPairPublicKey) + row.put(LokiAPIDatabase.encryptionKeyPairPrivateKey, encryptionKeyPairPrivateKey) + database.insertOrUpdate(LokiAPIDatabase.closedGroupEncryptionKeyPairsTable, row, "${LokiAPIDatabase.closedGroupsEncryptionKeyPairIndex} = ?", arrayOf( index )) + } + } +} \ No newline at end of file diff --git a/app/src/main/java/org/thoughtcrime/securesms/loki/protocol/ClosedGroupsProtocolV2.kt b/app/src/main/java/org/thoughtcrime/securesms/loki/protocol/ClosedGroupsProtocolV2.kt new file mode 100644 index 0000000000..74277021e1 --- /dev/null +++ b/app/src/main/java/org/thoughtcrime/securesms/loki/protocol/ClosedGroupsProtocolV2.kt @@ -0,0 +1,416 @@ +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.session.libsignal.libsignal.ecc.Curve +import org.session.libsignal.libsignal.ecc.DjbECPrivateKey +import org.session.libsignal.libsignal.ecc.DjbECPublicKey +import org.session.libsignal.libsignal.ecc.ECKeyPair +import org.session.libsignal.libsignal.util.guava.Optional +import org.session.libsignal.service.api.messages.SignalServiceGroup +import org.session.libsignal.service.internal.push.SignalServiceProtos +import org.session.libsignal.service.loki.utilities.hexEncodedPublicKey +import org.session.libsignal.service.loki.utilities.removing05PrefixIfNeeded +import org.session.libsignal.service.loki.utilities.toHexString +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.api.SessionProtocolImpl +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.util.GroupUtil +import org.thoughtcrime.securesms.util.Hex +import org.thoughtcrime.securesms.util.TextSecurePreferences +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, SignalServiceProtos.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): Promise { + 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 Promise.ofFail(Error.NoThread) + } + val name = group.title + val oldMembers = group.members.map { it.serialize() }.toSet() + val newMembers: Set + val isCurrentUserAdmin = group.admins.map { it.toPhoneString() }.contains(userPublicKey) + if (!isCurrentUserAdmin) { + newMembers = oldMembers.minus(userPublicKey) + } else { + newMembers = setOf() // If the admin leaves the group is destroyed + } + return update(context, groupPublicKey, newMembers, name) + } + + 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()) && members.isNotEmpty()) { + Log.d("Loki", "Can't remove admin from closed group unless the group is destroyed entirely.") + return@Thread deferred.reject(Error.InvalidUpdate) + } + val isUserLeaving = removedMembers.contains(userPublicKey) + if (isUserLeaving && members.isNotEmpty()) { + if (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) + apiDB.removeClosedGroupPublicKey(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) SignalServiceProtos.GroupContext.Type.QUIT else SignalServiceProtos.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) { + // Prepare + 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 + } + if (!group.admins.map { it.toPhoneString() }.contains(userPublicKey)) { + Log.d("Loki", "Can't distribute new encryption key pair as non-admin.") + return + } + // Generate the new encryption key pair + val newKeyPair = Curve.generateKeyPair() + // Distribute it + val proto = SignalServiceProtos.ClosedGroupUpdateV2.KeyPair.newBuilder() + proto.publicKey = ByteString.copyFrom(newKeyPair.publicKey.serialize().removing05PrefixIfNeeded()) + proto.privateKey = ByteString.copyFrom(newKeyPair.privateKey.serialize()) + val plaintext = proto.build().toByteArray() + val wrappers = targetMembers.mapNotNull { publicKey -> + val ciphertext = SessionProtocolImpl(context).encrypt(plaintext, publicKey) + ClosedGroupUpdateMessageSendJobV2.KeyPairWrapper(publicKey, ciphertext) + } + val job = ClosedGroupUpdateMessageSendJobV2(groupPublicKey, ClosedGroupUpdateMessageSendJobV2.Kind.EncryptionKeyPair(wrappers)) + job.setContext(context) + job.onRun() // Run the job immediately + // Store it * after * having sent out the message to the group + apiDB.addClosedGroupEncryptionKeyPair(newKeyPair, groupPublicKey) + } + + @JvmStatic + public fun handleMessage(context: Context, closedGroupUpdate: SignalServiceProtos.ClosedGroupUpdateV2, sentTimestamp: Long, groupPublicKey: String, senderPublicKey: String) { + if (!isValid(closedGroupUpdate)) { return; } + when (closedGroupUpdate.type) { + SignalServiceProtos.ClosedGroupUpdateV2.Type.NEW -> handleNewClosedGroup(context, closedGroupUpdate, senderPublicKey) + SignalServiceProtos.ClosedGroupUpdateV2.Type.UPDATE -> handleClosedGroupUpdate(context, closedGroupUpdate, sentTimestamp, groupPublicKey, senderPublicKey) + SignalServiceProtos.ClosedGroupUpdateV2.Type.ENCRYPTION_KEY_PAIR -> handleGroupEncryptionKeyPair(context, closedGroupUpdate, groupPublicKey, senderPublicKey) + else -> { + // Do nothing + } + } + } + + private fun isValid(closedGroupUpdate: SignalServiceProtos.ClosedGroupUpdateV2): Boolean { + when (closedGroupUpdate.type) { + SignalServiceProtos.ClosedGroupUpdateV2.Type.NEW -> { + return !closedGroupUpdate.publicKey.isEmpty && !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() + } + 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().removing05PrefixIfNeeded()), DjbECPrivateKey(encryptionKeyPairAsProto.privateKey.toByteArray())) + apiDB.addClosedGroupEncryptionKeyPair(encryptionKeyPair, groupPublicKey) + // Notify the user + insertIncomingInfoMessage(context, senderPublicKey, groupID, SignalServiceProtos.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, sentTimestamp: Long, groupPublicKey: String, senderPublicKey: String) { + // Prepare + val userPublicKey = TextSecurePreferences.getLocalNumber(context) + val apiDB = DatabaseFactory.getLokiAPIDatabase(context) + // Unwrap the message + 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 message isn't from before the group was created + if (group.createdAt > sentTimestamp) { + Log.d("Loki", "Ignoring closed group update from before thread was created.") + return + } + // 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 + } + // Check that the admin wasn't removed unless the group was destroyed entirely + if (!members.contains(group.admins.first().toPhoneString()) && members.isNotEmpty()) { + Log.d("Loki", "Ignoring invalid closed group update message.") + 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.removeClosedGroupPublicKey(groupPublicKey) + // Remove the key pairs + 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) SignalServiceProtos.GroupContext.Type.QUIT else SignalServiceProtos.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, groupPublicKey: String, senderPublicKey: String) { + // Prepare + val userPublicKey = TextSecurePreferences.getLocalNumber(context) + val apiDB = DatabaseFactory.getLokiAPIDatabase(context) + val userKeyPair = apiDB.getUserX25519KeyPair() + // Unwrap the message + val groupDB = DatabaseFactory.getGroupDatabase(context) + val groupID = doubleEncodeGroupID(groupPublicKey) + val group = groupDB.getGroup(groupID).orNull() + if (group == null) { + Log.d("Loki", "Ignoring closed group encryption key pair message for nonexistent group.") + return + } + if (!group.admins.map { it.toPhoneString() }.contains(senderPublicKey)) { + Log.d("Loki", "Ignoring closed group encryption key pair from non-admin.") + return + } + // Find our wrapper and decrypt it if possible + val wrapper = closedGroupUpdate.wrappersList.firstOrNull { it.publicKey.toByteArray().toHexString() == userPublicKey } ?: return + val encryptedKeyPair = wrapper.encryptedKeyPair.toByteArray() + val plaintext = SessionProtocolImpl(context).decrypt(encryptedKeyPair, userKeyPair).first + // Parse it + val proto = SignalServiceProtos.ClosedGroupUpdateV2.KeyPair.parseFrom(plaintext) + val keyPair = ECKeyPair(DjbECPublicKey(proto.publicKey.toByteArray().removing05PrefixIfNeeded()), DjbECPrivateKey(proto.privateKey.toByteArray())) + // Store it + apiDB.addClosedGroupEncryptionKeyPair(keyPair, groupPublicKey) + Log.d("Loki", "Received a new closed group encryption key pair") + } + + private fun insertIncomingInfoMessage(context: Context, senderPublicKey: String, groupID: String, type0: SignalServiceProtos.GroupContext.Type, type1: SignalServiceGroup.Type, + name: String, members: Collection, admins: Collection) { + val groupContextBuilder = SignalServiceProtos.GroupContext.newBuilder() + .setId(ByteString.copyFrom(GroupUtil.getDecodedId(groupID))) + .setType(type0) + .setName(name) + .addAllMembers(members) + .addAllAdmins(admins) + val group = SignalServiceGroup(type1, GroupUtil.getDecodedId(groupID), SignalServiceGroup.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: SignalServiceProtos.GroupContext.Type, name: String, + members: Collection, admins: Collection, threadID: Long) { + val recipient = Recipient.from(context, Address.fromSerialized(groupID), false) + val groupContextBuilder = SignalServiceProtos.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 diff --git a/app/src/main/java/org/thoughtcrime/securesms/util/TextSecurePreferences.java b/app/src/main/java/org/thoughtcrime/securesms/util/TextSecurePreferences.java index b6d87eab98..7bea3e87b8 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/util/TextSecurePreferences.java +++ b/app/src/main/java/org/thoughtcrime/securesms/util/TextSecurePreferences.java @@ -1246,6 +1246,22 @@ public class TextSecurePreferences { public static void setLastSnodePoolRefreshDate(Context context, Date date) { setLongPreference(context, "last_snode_pool_refresh_date", date.getTime()); } + + public static long getLastKeyPairMigrationNudge(Context context) { + return getLongPreference(context, "last_key_pair_migration_nudge", 0); + } + + public static void setLastKeyPairMigrationNudge(Context context, long newValue) { + setLongPreference(context, "last_key_pair_migration_nudge", newValue); + } + + public static boolean getIsMigratingKeyPair(Context context) { + return getBooleanPreference(context, "is_migrating_key_pair", false); + } + + public static void setIsMigratingKeyPair(Context context, boolean newValue) { + setBooleanPreference(context, "is_migrating_key_pair", newValue); + } // endregion // region Backup related diff --git a/app/src/main/res/drawable/ic_shield.xml b/app/src/main/res/drawable/ic_shield.xml new file mode 100644 index 0000000000..67a9845daa --- /dev/null +++ b/app/src/main/res/drawable/ic_shield.xml @@ -0,0 +1,9 @@ + + + diff --git a/app/src/main/res/layout/fragment_key_pair_migration_bottom_sheet.xml b/app/src/main/res/layout/fragment_key_pair_migration_bottom_sheet.xml new file mode 100644 index 0000000000..5588c2e933 --- /dev/null +++ b/app/src/main/res/layout/fragment_key_pair_migration_bottom_sheet.xml @@ -0,0 +1,52 @@ + + + + + + + + + + + +