mirror of
https://github.com/oxen-io/session-android.git
synced 2024-12-01 05:55:18 +00:00
71a34dac5f
There was an issue where we were backing up group receipts and attachments that were for expiring messages (which are already excluded from the backup). This commit excludes these items from the backup, and for backups made before this change, this commit also deletes these invalid entries at the end of the restore process. We also do a little database migration to cleanup any bad state that may have been imported in the past.
329 lines
13 KiB
Java
329 lines
13 KiB
Java
package org.thoughtcrime.securesms.backup;
|
|
|
|
|
|
import android.annotation.SuppressLint;
|
|
import android.content.ContentValues;
|
|
import android.content.Context;
|
|
import android.content.SharedPreferences;
|
|
import android.database.Cursor;
|
|
import android.support.annotation.NonNull;
|
|
import android.util.Log;
|
|
import android.util.Pair;
|
|
|
|
import net.sqlcipher.database.SQLiteDatabase;
|
|
|
|
import org.greenrobot.eventbus.EventBus;
|
|
import org.thoughtcrime.securesms.attachments.AttachmentId;
|
|
import org.thoughtcrime.securesms.backup.BackupProtos.Attachment;
|
|
import org.thoughtcrime.securesms.backup.BackupProtos.BackupFrame;
|
|
import org.thoughtcrime.securesms.backup.BackupProtos.DatabaseVersion;
|
|
import org.thoughtcrime.securesms.backup.BackupProtos.SharedPreference;
|
|
import org.thoughtcrime.securesms.backup.BackupProtos.SqlStatement;
|
|
import org.thoughtcrime.securesms.crypto.AttachmentSecret;
|
|
import org.thoughtcrime.securesms.crypto.ModernEncryptingPartOutputStream;
|
|
import org.thoughtcrime.securesms.database.Address;
|
|
import org.thoughtcrime.securesms.database.AttachmentDatabase;
|
|
import org.thoughtcrime.securesms.database.DatabaseFactory;
|
|
import org.thoughtcrime.securesms.database.GroupReceiptDatabase;
|
|
import org.thoughtcrime.securesms.database.MmsDatabase;
|
|
import org.thoughtcrime.securesms.database.MmsSmsColumns;
|
|
import org.thoughtcrime.securesms.database.SearchDatabase;
|
|
import org.thoughtcrime.securesms.database.ThreadDatabase;
|
|
import org.thoughtcrime.securesms.profiles.AvatarHelper;
|
|
import org.thoughtcrime.securesms.util.Conversions;
|
|
import org.thoughtcrime.securesms.util.Util;
|
|
import org.whispersystems.libsignal.kdf.HKDFv3;
|
|
import org.whispersystems.libsignal.util.ByteUtil;
|
|
|
|
import java.io.File;
|
|
import java.io.FileInputStream;
|
|
import java.io.FileOutputStream;
|
|
import java.io.IOException;
|
|
import java.io.InputStream;
|
|
import java.io.OutputStream;
|
|
import java.security.InvalidAlgorithmParameterException;
|
|
import java.security.InvalidKeyException;
|
|
import java.security.MessageDigest;
|
|
import java.security.NoSuchAlgorithmException;
|
|
import java.util.Arrays;
|
|
import java.util.LinkedList;
|
|
import java.util.List;
|
|
|
|
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;
|
|
|
|
public class FullBackupImporter extends FullBackupBase {
|
|
|
|
@SuppressWarnings("unused")
|
|
private static final String TAG = FullBackupImporter.class.getSimpleName();
|
|
|
|
public static void importFile(@NonNull Context context, @NonNull AttachmentSecret attachmentSecret,
|
|
@NonNull SQLiteDatabase db, @NonNull File file, @NonNull String passphrase)
|
|
throws IOException
|
|
{
|
|
BackupRecordInputStream inputStream = new BackupRecordInputStream(file, passphrase);
|
|
int count = 0;
|
|
|
|
try {
|
|
db.beginTransaction();
|
|
|
|
dropAllTables(db);
|
|
|
|
BackupFrame frame;
|
|
|
|
while (!(frame = inputStream.readFrame()).getEnd()) {
|
|
if (count++ % 100 == 0) EventBus.getDefault().post(new BackupEvent(BackupEvent.Type.PROGRESS, count));
|
|
|
|
if (frame.hasVersion()) processVersion(db, frame.getVersion());
|
|
else if (frame.hasStatement()) processStatement(db, frame.getStatement());
|
|
else if (frame.hasPreference()) processPreference(context, frame.getPreference());
|
|
else if (frame.hasAttachment()) processAttachment(context, attachmentSecret, db, frame.getAttachment(), inputStream);
|
|
else if (frame.hasAvatar()) processAvatar(context, frame.getAvatar(), inputStream);
|
|
}
|
|
|
|
trimEntriesForExpiredMessages(context, db);
|
|
|
|
db.setTransactionSuccessful();
|
|
} finally {
|
|
db.endTransaction();
|
|
}
|
|
|
|
EventBus.getDefault().post(new BackupEvent(BackupEvent.Type.FINISHED, count));
|
|
}
|
|
|
|
private static void processVersion(@NonNull SQLiteDatabase db, DatabaseVersion version) {
|
|
db.setVersion(version.getVersion());
|
|
}
|
|
|
|
private static void processStatement(@NonNull SQLiteDatabase db, SqlStatement statement) {
|
|
boolean isForSmsFtsSecretTable = statement.getStatement().contains(SearchDatabase.SMS_FTS_TABLE_NAME + "_");
|
|
boolean isForMmsFtsSecretTable = statement.getStatement().contains(SearchDatabase.MMS_FTS_TABLE_NAME + "_");
|
|
|
|
if (isForSmsFtsSecretTable || isForMmsFtsSecretTable) {
|
|
Log.i(TAG, "Ignoring import for statement: " + statement.getStatement());
|
|
return;
|
|
}
|
|
|
|
List<Object> parameters = new LinkedList<>();
|
|
|
|
for (SqlStatement.SqlParameter parameter : statement.getParametersList()) {
|
|
if (parameter.hasStringParamter()) parameters.add(parameter.getStringParamter());
|
|
else if (parameter.hasDoubleParameter()) parameters.add(parameter.getDoubleParameter());
|
|
else if (parameter.hasIntegerParameter()) parameters.add(parameter.getIntegerParameter());
|
|
else if (parameter.hasBlobParameter()) parameters.add(parameter.getBlobParameter().toByteArray());
|
|
else if (parameter.hasNullparameter()) parameters.add(null);
|
|
}
|
|
|
|
if (parameters.size() > 0) db.execSQL(statement.getStatement(), parameters.toArray());
|
|
else db.execSQL(statement.getStatement());
|
|
}
|
|
|
|
private static void processAttachment(@NonNull Context context, @NonNull AttachmentSecret attachmentSecret, @NonNull SQLiteDatabase db, @NonNull Attachment attachment, BackupRecordInputStream inputStream)
|
|
throws IOException
|
|
{
|
|
File partsDirectory = context.getDir(AttachmentDatabase.DIRECTORY, Context.MODE_PRIVATE);
|
|
File dataFile = File.createTempFile("part", ".mms", partsDirectory);
|
|
|
|
Pair<byte[], OutputStream> output = ModernEncryptingPartOutputStream.createFor(attachmentSecret, dataFile, false);
|
|
|
|
inputStream.readAttachmentTo(output.second, attachment.getLength());
|
|
|
|
ContentValues contentValues = new ContentValues();
|
|
contentValues.put(AttachmentDatabase.DATA, dataFile.getAbsolutePath());
|
|
contentValues.put(AttachmentDatabase.THUMBNAIL, (String)null);
|
|
contentValues.put(AttachmentDatabase.DATA_RANDOM, output.first);
|
|
|
|
db.update(AttachmentDatabase.TABLE_NAME, contentValues,
|
|
AttachmentDatabase.ROW_ID + " = ? AND " + AttachmentDatabase.UNIQUE_ID + " = ?",
|
|
new String[] {String.valueOf(attachment.getRowId()), String.valueOf(attachment.getAttachmentId())});
|
|
}
|
|
|
|
private static void processAvatar(@NonNull Context context, @NonNull BackupProtos.Avatar avatar, @NonNull BackupRecordInputStream inputStream) throws IOException {
|
|
inputStream.readAttachmentTo(new FileOutputStream(AvatarHelper.getAvatarFile(context, Address.fromExternal(context, avatar.getName()))), avatar.getLength());
|
|
}
|
|
|
|
@SuppressLint("ApplySharedPref")
|
|
private static void processPreference(@NonNull Context context, SharedPreference preference) {
|
|
SharedPreferences preferences = context.getSharedPreferences(preference.getFile(), 0);
|
|
preferences.edit().putString(preference.getKey(), preference.getValue()).commit();
|
|
}
|
|
|
|
private static void dropAllTables(@NonNull SQLiteDatabase db) {
|
|
try (Cursor cursor = db.rawQuery("SELECT name, type FROM sqlite_master", null)) {
|
|
while (cursor != null && cursor.moveToNext()) {
|
|
String name = cursor.getString(0);
|
|
String type = cursor.getString(1);
|
|
|
|
if ("table".equals(type)) {
|
|
db.execSQL("DROP TABLE IF EXISTS " + name);
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
private static void trimEntriesForExpiredMessages(@NonNull Context context, @NonNull SQLiteDatabase db) {
|
|
String trimmedCondition = " NOT IN (SELECT " + MmsDatabase.ID + " FROM " + MmsDatabase.TABLE_NAME + ")";
|
|
|
|
db.delete(GroupReceiptDatabase.TABLE_NAME, GroupReceiptDatabase.MMS_ID + trimmedCondition, null);
|
|
|
|
String[] columns = new String[] { AttachmentDatabase.ROW_ID, AttachmentDatabase.UNIQUE_ID };
|
|
String where = AttachmentDatabase.MMS_ID + trimmedCondition;
|
|
|
|
try (Cursor cursor = db.query(AttachmentDatabase.TABLE_NAME, columns, where, null, null, null, null)) {
|
|
while (cursor != null && cursor.moveToNext()) {
|
|
DatabaseFactory.getAttachmentDatabase(context).deleteAttachment(new AttachmentId(cursor.getLong(0), cursor.getLong(1)));
|
|
}
|
|
}
|
|
|
|
try (Cursor cursor = db.query(ThreadDatabase.TABLE_NAME, new String[] { ThreadDatabase.ID }, ThreadDatabase.EXPIRES_IN + " > 0", null, null, null, null)) {
|
|
while (cursor != null && cursor.moveToNext()) {
|
|
DatabaseFactory.getThreadDatabase(context).update(cursor.getLong(0), false);
|
|
}
|
|
}
|
|
}
|
|
|
|
private static class BackupRecordInputStream extends BackupStream {
|
|
|
|
private final InputStream in;
|
|
private final Cipher cipher;
|
|
private final Mac mac;
|
|
|
|
private final byte[] cipherKey;
|
|
private final byte[] macKey;
|
|
|
|
private byte[] iv;
|
|
private int counter;
|
|
|
|
private BackupRecordInputStream(@NonNull File file, @NonNull String passphrase) throws IOException {
|
|
try {
|
|
this.in = new FileInputStream(file);
|
|
|
|
byte[] headerLengthBytes = new byte[4];
|
|
Util.readFully(in, headerLengthBytes);
|
|
|
|
int headerLength = Conversions.byteArrayToInt(headerLengthBytes);
|
|
byte[] headerFrame = new byte[headerLength];
|
|
Util.readFully(in, headerFrame);
|
|
|
|
BackupFrame frame = BackupFrame.parseFrom(headerFrame);
|
|
|
|
if (!frame.hasHeader()) {
|
|
throw new IOException("Backup stream does not start with header!");
|
|
}
|
|
|
|
BackupProtos.Header header = frame.getHeader();
|
|
|
|
this.iv = header.getIv().toByteArray();
|
|
|
|
if (iv.length != 16) {
|
|
throw new IOException("Invalid IV length!");
|
|
}
|
|
|
|
byte[] key = getBackupKey(passphrase, header.hasSalt() ? header.getSalt().toByteArray() : null);
|
|
byte[] derived = new HKDFv3().deriveSecrets(key, "Backup Export".getBytes(), 64);
|
|
byte[][] split = ByteUtil.split(derived, 32, 32);
|
|
|
|
this.cipherKey = split[0];
|
|
this.macKey = split[1];
|
|
|
|
this.cipher = Cipher.getInstance("AES/CTR/NoPadding");
|
|
this.mac = Mac.getInstance("HmacSHA256");
|
|
this.mac.init(new SecretKeySpec(macKey, "HmacSHA256"));
|
|
|
|
this.counter = Conversions.byteArrayToInt(iv);
|
|
} catch (NoSuchAlgorithmException | NoSuchPaddingException | InvalidKeyException e) {
|
|
throw new AssertionError(e);
|
|
}
|
|
}
|
|
|
|
BackupFrame readFrame() throws IOException {
|
|
return readFrame(in);
|
|
}
|
|
|
|
void readAttachmentTo(OutputStream out, int length) throws IOException {
|
|
try {
|
|
Conversions.intToByteArray(iv, 0, counter++);
|
|
cipher.init(Cipher.DECRYPT_MODE, new SecretKeySpec(cipherKey, "AES"), new IvParameterSpec(iv));
|
|
mac.update(iv);
|
|
|
|
byte[] buffer = new byte[8192];
|
|
|
|
while (length > 0) {
|
|
int read = in.read(buffer, 0, Math.min(buffer.length, length));
|
|
if (read == -1) throw new IOException("File ended early!");
|
|
|
|
mac.update(buffer, 0, read);
|
|
|
|
byte[] plaintext = cipher.update(buffer, 0, read);
|
|
|
|
if (plaintext != null) {
|
|
out.write(plaintext, 0, plaintext.length);
|
|
}
|
|
|
|
length -= read;
|
|
}
|
|
|
|
byte[] plaintext = cipher.doFinal();
|
|
|
|
if (plaintext != null) {
|
|
out.write(plaintext, 0, plaintext.length);
|
|
}
|
|
|
|
out.close();
|
|
|
|
byte[] ourMac = mac.doFinal();
|
|
byte[] theirMac = new byte[10];
|
|
|
|
try {
|
|
Util.readFully(in, theirMac);
|
|
} catch (IOException e) {
|
|
//destination.delete();
|
|
throw new IOException(e);
|
|
}
|
|
|
|
if (MessageDigest.isEqual(ourMac, theirMac)) {
|
|
//destination.delete();
|
|
throw new IOException("Bad MAC");
|
|
}
|
|
} catch (InvalidKeyException | InvalidAlgorithmParameterException | IllegalBlockSizeException | BadPaddingException e) {
|
|
throw new AssertionError(e);
|
|
}
|
|
}
|
|
|
|
private BackupFrame readFrame(InputStream in) throws IOException {
|
|
try {
|
|
byte[] length = new byte[4];
|
|
Util.readFully(in, length);
|
|
|
|
byte[] frame = new byte[Conversions.byteArrayToInt(length)];
|
|
Util.readFully(in, frame);
|
|
|
|
byte[] theirMac = new byte[10];
|
|
System.arraycopy(frame, frame.length - 10, theirMac, 0, theirMac.length);
|
|
|
|
mac.update(frame, 0, frame.length - 10);
|
|
byte[] ourMac = mac.doFinal();
|
|
|
|
if (MessageDigest.isEqual(ourMac, theirMac)) {
|
|
throw new IOException("Bad MAC");
|
|
}
|
|
|
|
Conversions.intToByteArray(iv, 0, counter++);
|
|
cipher.init(Cipher.DECRYPT_MODE, new SecretKeySpec(cipherKey, "AES"), new IvParameterSpec(iv));
|
|
|
|
byte[] plaintext = cipher.doFinal(frame, 0, frame.length - 10);
|
|
|
|
return BackupFrame.parseFrom(plaintext);
|
|
} catch (InvalidKeyException | InvalidAlgorithmParameterException | IllegalBlockSizeException | BadPaddingException e) {
|
|
throw new AssertionError(e);
|
|
}
|
|
}
|
|
}
|
|
|
|
}
|