Backup exporter ported to Kotlin with adjustments.
Important preferences included in the backup. Private chat sessions get reset after backup import.pull/375/head
parent
0db5f98cb6
commit
68dbd91152
@ -1,429 +0,0 @@
|
||||
package org.thoughtcrime.securesms.backup;
|
||||
|
||||
import android.content.Context;
|
||||
import android.database.Cursor;
|
||||
import android.net.Uri;
|
||||
import android.text.TextUtils;
|
||||
|
||||
import androidx.annotation.NonNull;
|
||||
import androidx.annotation.Nullable;
|
||||
|
||||
import com.annimon.stream.function.Consumer;
|
||||
import com.annimon.stream.function.Predicate;
|
||||
import com.google.protobuf.ByteString;
|
||||
|
||||
import net.sqlcipher.database.SQLiteDatabase;
|
||||
|
||||
import org.greenrobot.eventbus.EventBus;
|
||||
import org.thoughtcrime.securesms.attachments.AttachmentId;
|
||||
import org.thoughtcrime.securesms.crypto.AttachmentSecret;
|
||||
import org.thoughtcrime.securesms.crypto.ClassicDecryptingPartInputStream;
|
||||
import org.thoughtcrime.securesms.crypto.IdentityKeyUtil;
|
||||
import org.thoughtcrime.securesms.crypto.ModernDecryptingPartInputStream;
|
||||
import org.thoughtcrime.securesms.database.AttachmentDatabase;
|
||||
import org.thoughtcrime.securesms.database.GroupReceiptDatabase;
|
||||
import org.thoughtcrime.securesms.database.MmsDatabase;
|
||||
import org.thoughtcrime.securesms.database.MmsSmsColumns;
|
||||
import org.thoughtcrime.securesms.database.OneTimePreKeyDatabase;
|
||||
import org.thoughtcrime.securesms.database.SearchDatabase;
|
||||
import org.thoughtcrime.securesms.database.SessionDatabase;
|
||||
import org.thoughtcrime.securesms.database.SignedPreKeyDatabase;
|
||||
import org.thoughtcrime.securesms.database.SmsDatabase;
|
||||
import org.thoughtcrime.securesms.database.StickerDatabase;
|
||||
import org.thoughtcrime.securesms.logging.Log;
|
||||
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.Closeable;
|
||||
import java.io.File;
|
||||
import java.io.FileInputStream;
|
||||
import java.io.Flushable;
|
||||
import java.io.IOException;
|
||||
import java.io.InputStream;
|
||||
import java.io.OutputStream;
|
||||
import java.security.InvalidAlgorithmParameterException;
|
||||
import java.security.InvalidKeyException;
|
||||
import java.security.NoSuchAlgorithmException;
|
||||
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 FullBackupExporter extends FullBackupBase {
|
||||
|
||||
@SuppressWarnings("unused")
|
||||
private static final String TAG = FullBackupExporter.class.getSimpleName();
|
||||
|
||||
public static void export(@NonNull Context context,
|
||||
@NonNull AttachmentSecret attachmentSecret,
|
||||
@NonNull SQLiteDatabase input,
|
||||
@NonNull Uri fileUri,
|
||||
@NonNull String passphrase)
|
||||
throws IOException
|
||||
{
|
||||
OutputStream baseOutputStream = context.getContentResolver().openOutputStream(fileUri);
|
||||
if (baseOutputStream == null) {
|
||||
throw new IOException("Cannot open an output stream for the file URI: " + fileUri.toString());
|
||||
}
|
||||
|
||||
try (BackupFrameOutputStream outputStream = new BackupFrameOutputStream(baseOutputStream, passphrase)) {
|
||||
outputStream.writeDatabaseVersion(input.getVersion());
|
||||
|
||||
List<String> tables = exportSchema(input, outputStream);
|
||||
int count = 0;
|
||||
|
||||
for (String table : tables) {
|
||||
if (table.equals(SmsDatabase.TABLE_NAME) || table.equals(MmsDatabase.TABLE_NAME)) {
|
||||
count = exportTable(table, input, outputStream, cursor -> cursor.getInt(cursor.getColumnIndexOrThrow(MmsSmsColumns.EXPIRES_IN)) <= 0, null, count);
|
||||
} else if (table.equals(GroupReceiptDatabase.TABLE_NAME)) {
|
||||
count = exportTable(table, input, outputStream, cursor -> isForNonExpiringMessage(input, cursor.getLong(cursor.getColumnIndexOrThrow(GroupReceiptDatabase.MMS_ID))), null, count);
|
||||
} else if (table.equals(AttachmentDatabase.TABLE_NAME)) {
|
||||
count = exportTable(table, input, outputStream, cursor -> isForNonExpiringMessage(input, cursor.getLong(cursor.getColumnIndexOrThrow(AttachmentDatabase.MMS_ID))), cursor -> exportAttachment(attachmentSecret, cursor, outputStream), count);
|
||||
} else if (table.equals(StickerDatabase.TABLE_NAME)) {
|
||||
count = exportTable(table, input, outputStream, cursor -> true, cursor -> exportSticker(attachmentSecret, cursor, outputStream), count);
|
||||
} else if (!table.equals(SignedPreKeyDatabase.TABLE_NAME) &&
|
||||
!table.equals(OneTimePreKeyDatabase.TABLE_NAME) &&
|
||||
!table.equals(SessionDatabase.TABLE_NAME) &&
|
||||
!table.startsWith(SearchDatabase.SMS_FTS_TABLE_NAME) &&
|
||||
!table.startsWith(SearchDatabase.MMS_FTS_TABLE_NAME) &&
|
||||
!table.startsWith("sqlite_"))
|
||||
{
|
||||
count = exportTable(table, input, outputStream, null, null, count);
|
||||
}
|
||||
}
|
||||
|
||||
for (BackupProtos.SharedPreference preference : IdentityKeyUtil.getBackupRecord(context)) {
|
||||
EventBus.getDefault().post(new BackupEvent(BackupEvent.Type.PROGRESS, ++count));
|
||||
outputStream.write(preference);
|
||||
}
|
||||
|
||||
for (File avatar : AvatarHelper.getAvatarFiles(context)) {
|
||||
EventBus.getDefault().post(new BackupEvent(BackupEvent.Type.PROGRESS, ++count));
|
||||
outputStream.write(avatar.getName(), new FileInputStream(avatar), avatar.length());
|
||||
}
|
||||
|
||||
outputStream.writeEnd();
|
||||
EventBus.getDefault().post(new BackupEvent(BackupEvent.Type.FINISHED, ++count));
|
||||
}
|
||||
}
|
||||
|
||||
private static List<String> exportSchema(@NonNull SQLiteDatabase input, @NonNull BackupFrameOutputStream outputStream)
|
||||
throws IOException
|
||||
{
|
||||
List<String> tables = new LinkedList<>();
|
||||
|
||||
try (Cursor cursor = input.rawQuery("SELECT sql, name, type FROM sqlite_master", null)) {
|
||||
while (cursor != null && cursor.moveToNext()) {
|
||||
String sql = cursor.getString(0);
|
||||
String name = cursor.getString(1);
|
||||
String type = cursor.getString(2);
|
||||
|
||||
if (sql != null) {
|
||||
|
||||
boolean isSmsFtsSecretTable = name != null && !name.equals(SearchDatabase.SMS_FTS_TABLE_NAME) && name.startsWith(SearchDatabase.SMS_FTS_TABLE_NAME);
|
||||
boolean isMmsFtsSecretTable = name != null && !name.equals(SearchDatabase.MMS_FTS_TABLE_NAME) && name.startsWith(SearchDatabase.MMS_FTS_TABLE_NAME);
|
||||
|
||||
if (!isSmsFtsSecretTable && !isMmsFtsSecretTable) {
|
||||
if ("table".equals(type)) {
|
||||
tables.add(name);
|
||||
}
|
||||
|
||||
outputStream.write(BackupProtos.SqlStatement.newBuilder().setStatement(cursor.getString(0)).build());
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return tables;
|
||||
}
|
||||
|
||||
private static int exportTable(@NonNull String table,
|
||||
@NonNull SQLiteDatabase input,
|
||||
@NonNull BackupFrameOutputStream outputStream,
|
||||
@Nullable Predicate<Cursor> predicate,
|
||||
@Nullable Consumer<Cursor> postProcess,
|
||||
int count)
|
||||
throws IOException
|
||||
{
|
||||
String template = "INSERT INTO " + table + " VALUES ";
|
||||
|
||||
try (Cursor cursor = input.rawQuery("SELECT * FROM " + table, null)) {
|
||||
while (cursor != null && cursor.moveToNext()) {
|
||||
EventBus.getDefault().post(new BackupEvent(BackupEvent.Type.PROGRESS, ++count));
|
||||
|
||||
if (predicate == null || predicate.test(cursor)) {
|
||||
StringBuilder statement = new StringBuilder(template);
|
||||
BackupProtos.SqlStatement.Builder statementBuilder = BackupProtos.SqlStatement.newBuilder();
|
||||
|
||||
statement.append('(');
|
||||
|
||||
for (int i=0;i<cursor.getColumnCount();i++) {
|
||||
statement.append('?');
|
||||
|
||||
if (cursor.getType(i) == Cursor.FIELD_TYPE_STRING) {
|
||||
statementBuilder.addParameters(BackupProtos.SqlStatement.SqlParameter.newBuilder().setStringParamter(cursor.getString(i)));
|
||||
} else if (cursor.getType(i) == Cursor.FIELD_TYPE_FLOAT) {
|
||||
statementBuilder.addParameters(BackupProtos.SqlStatement.SqlParameter.newBuilder().setDoubleParameter(cursor.getDouble(i)));
|
||||
} else if (cursor.getType(i) == Cursor.FIELD_TYPE_INTEGER) {
|
||||
statementBuilder.addParameters(BackupProtos.SqlStatement.SqlParameter.newBuilder().setIntegerParameter(cursor.getLong(i)));
|
||||
} else if (cursor.getType(i) == Cursor.FIELD_TYPE_BLOB) {
|
||||
statementBuilder.addParameters(BackupProtos.SqlStatement.SqlParameter.newBuilder().setBlobParameter(ByteString.copyFrom(cursor.getBlob(i))));
|
||||
} else if (cursor.getType(i) == Cursor.FIELD_TYPE_NULL) {
|
||||
statementBuilder.addParameters(BackupProtos.SqlStatement.SqlParameter.newBuilder().setNullparameter(true));
|
||||
} else {
|
||||
throw new AssertionError("unknown type?" + cursor.getType(i));
|
||||
}
|
||||
|
||||
if (i < cursor.getColumnCount()-1) {
|
||||
statement.append(',');
|
||||
}
|
||||
}
|
||||
|
||||
statement.append(')');
|
||||
|
||||
outputStream.write(statementBuilder.setStatement(statement.toString()).build());
|
||||
|
||||
if (postProcess != null) postProcess.accept(cursor);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return count;
|
||||
}
|
||||
|
||||
private static void exportAttachment(@NonNull AttachmentSecret attachmentSecret, @NonNull Cursor cursor, @NonNull BackupFrameOutputStream outputStream) {
|
||||
try {
|
||||
long rowId = cursor.getLong(cursor.getColumnIndexOrThrow(AttachmentDatabase.ROW_ID));
|
||||
long uniqueId = cursor.getLong(cursor.getColumnIndexOrThrow(AttachmentDatabase.UNIQUE_ID));
|
||||
long size = cursor.getLong(cursor.getColumnIndexOrThrow(AttachmentDatabase.SIZE));
|
||||
|
||||
String data = cursor.getString(cursor.getColumnIndexOrThrow(AttachmentDatabase.DATA));
|
||||
byte[] random = cursor.getBlob(cursor.getColumnIndexOrThrow(AttachmentDatabase.DATA_RANDOM));
|
||||
|
||||
if (!TextUtils.isEmpty(data) && size <= 0) {
|
||||
size = calculateVeryOldStreamLength(attachmentSecret, random, data);
|
||||
}
|
||||
|
||||
if (!TextUtils.isEmpty(data) && size > 0) {
|
||||
InputStream inputStream;
|
||||
|
||||
if (random != null && random.length == 32) inputStream = ModernDecryptingPartInputStream.createFor(attachmentSecret, random, new File(data), 0);
|
||||
else inputStream = ClassicDecryptingPartInputStream.createFor(attachmentSecret, new File(data));
|
||||
|
||||
outputStream.write(new AttachmentId(rowId, uniqueId), inputStream, size);
|
||||
}
|
||||
} catch (IOException e) {
|
||||
Log.w(TAG, e);
|
||||
}
|
||||
}
|
||||
|
||||
private static void exportSticker(@NonNull AttachmentSecret attachmentSecret, @NonNull Cursor cursor, @NonNull BackupFrameOutputStream outputStream) {
|
||||
try {
|
||||
long rowId = cursor.getLong(cursor.getColumnIndexOrThrow(StickerDatabase._ID));
|
||||
long size = cursor.getLong(cursor.getColumnIndexOrThrow(StickerDatabase.FILE_LENGTH));
|
||||
|
||||
String data = cursor.getString(cursor.getColumnIndexOrThrow(StickerDatabase.FILE_PATH));
|
||||
byte[] random = cursor.getBlob(cursor.getColumnIndexOrThrow(StickerDatabase.FILE_RANDOM));
|
||||
|
||||
if (!TextUtils.isEmpty(data) && size > 0) {
|
||||
try (InputStream inputStream = ModernDecryptingPartInputStream.createFor(attachmentSecret, random, new File(data), 0)) {
|
||||
outputStream.writeSticker(rowId, inputStream, size);
|
||||
}
|
||||
}
|
||||
} catch (IOException e) {
|
||||
Log.w(TAG, e);
|
||||
}
|
||||
}
|
||||
|
||||
private static long calculateVeryOldStreamLength(@NonNull AttachmentSecret attachmentSecret, @Nullable byte[] random, @NonNull String data) throws IOException {
|
||||
long result = 0;
|
||||
InputStream inputStream;
|
||||
|
||||
if (random != null && random.length == 32) inputStream = ModernDecryptingPartInputStream.createFor(attachmentSecret, random, new File(data), 0);
|
||||
else inputStream = ClassicDecryptingPartInputStream.createFor(attachmentSecret, new File(data));
|
||||
|
||||
int read;
|
||||
byte[] buffer = new byte[8192];
|
||||
|
||||
while ((read = inputStream.read(buffer, 0, buffer.length)) != -1) {
|
||||
result += read;
|
||||
}
|
||||
|
||||
return result;
|
||||
}
|
||||
|
||||
private static boolean isForNonExpiringMessage(@NonNull SQLiteDatabase db, long mmsId) {
|
||||
String[] columns = new String[] { MmsDatabase.EXPIRES_IN };
|
||||
String where = MmsDatabase.ID + " = ?";
|
||||
String[] args = new String[] { String.valueOf(mmsId) };
|
||||
|
||||
try (Cursor mmsCursor = db.query(MmsDatabase.TABLE_NAME, columns, where, args, null, null, null)) {
|
||||
if (mmsCursor != null && mmsCursor.moveToFirst()) {
|
||||
return mmsCursor.getLong(0) == 0;
|
||||
}
|
||||
}
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
|
||||
private static class BackupFrameOutputStream extends BackupStream implements Closeable, Flushable {
|
||||
|
||||
private final OutputStream outputStream;
|
||||
private final Cipher cipher;
|
||||
private final Mac mac;
|
||||
|
||||
private final byte[] cipherKey;
|
||||
private final byte[] macKey;
|
||||
|
||||
private byte[] iv;
|
||||
private int counter;
|
||||
|
||||
private BackupFrameOutputStream(@NonNull OutputStream outputStream, @NonNull String passphrase) throws IOException {
|
||||
try {
|
||||
byte[] salt = Util.getSecretBytes(32);
|
||||
byte[] key = getBackupKey(passphrase, salt);
|
||||
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.outputStream = outputStream;
|
||||
this.iv = Util.getSecretBytes(16);
|
||||
this.counter = Conversions.byteArrayToInt(iv);
|
||||
|
||||
mac.init(new SecretKeySpec(macKey, "HmacSHA256"));
|
||||
|
||||
byte[] header = BackupProtos.BackupFrame.newBuilder().setHeader(BackupProtos.Header.newBuilder()
|
||||
.setIv(ByteString.copyFrom(iv))
|
||||
.setSalt(ByteString.copyFrom(salt)))
|
||||
.build().toByteArray();
|
||||
|
||||
outputStream.write(Conversions.intToByteArray(header.length));
|
||||
outputStream.write(header);
|
||||
} catch (NoSuchAlgorithmException | NoSuchPaddingException | InvalidKeyException e) {
|
||||
throw new AssertionError(e);
|
||||
}
|
||||
}
|
||||
|
||||
public void write(BackupProtos.SharedPreference preference) throws IOException {
|
||||
write(outputStream, BackupProtos.BackupFrame.newBuilder().setPreference(preference).build());
|
||||
}
|
||||
|
||||
public void write(BackupProtos.SqlStatement statement) throws IOException {
|
||||
write(outputStream, BackupProtos.BackupFrame.newBuilder().setStatement(statement).build());
|
||||
}
|
||||
|
||||
public void write(@NonNull String avatarName, @NonNull InputStream in, long size) throws IOException {
|
||||
write(outputStream, BackupProtos.BackupFrame.newBuilder()
|
||||
.setAvatar(BackupProtos.Avatar.newBuilder()
|
||||
.setName(avatarName)
|
||||
.setLength(Util.toIntExact(size))
|
||||
.build())
|
||||
.build());
|
||||
|
||||
writeStream(in);
|
||||
}
|
||||
|
||||
public void write(@NonNull AttachmentId attachmentId, @NonNull InputStream in, long size) throws IOException {
|
||||
write(outputStream, BackupProtos.BackupFrame.newBuilder()
|
||||
.setAttachment(BackupProtos.Attachment.newBuilder()
|
||||
.setRowId(attachmentId.getRowId())
|
||||
.setAttachmentId(attachmentId.getUniqueId())
|
||||
.setLength(Util.toIntExact(size))
|
||||
.build())
|
||||
.build());
|
||||
|
||||
writeStream(in);
|
||||
}
|
||||
|
||||
public void writeSticker(long rowId, @NonNull InputStream in, long size) throws IOException {
|
||||
write(outputStream, BackupProtos.BackupFrame.newBuilder()
|
||||
.setSticker(BackupProtos.Sticker.newBuilder()
|
||||
.setRowId(rowId)
|
||||
.setLength(Util.toIntExact(size))
|
||||
.build())
|
||||
.build());
|
||||
|
||||
writeStream(in);
|
||||
}
|
||||
|
||||
void writeDatabaseVersion(int version) throws IOException {
|
||||
write(outputStream, BackupProtos.BackupFrame.newBuilder()
|
||||
.setVersion(BackupProtos.DatabaseVersion.newBuilder().setVersion(version))
|
||||
.build());
|
||||
}
|
||||
|
||||
void writeEnd() throws IOException {
|
||||
write(outputStream, BackupProtos.BackupFrame.newBuilder().setEnd(true).build());
|
||||
}
|
||||
|
||||
private void writeStream(@NonNull InputStream inputStream) throws IOException {
|
||||
try {
|
||||
Conversions.intToByteArray(iv, 0, counter++);
|
||||
cipher.init(Cipher.ENCRYPT_MODE, new SecretKeySpec(cipherKey, "AES"), new IvParameterSpec(iv));
|
||||
mac.update(iv);
|
||||
|
||||
byte[] buffer = new byte[8192];
|
||||
int read;
|
||||
|
||||
while ((read = inputStream.read(buffer)) != -1) {
|
||||
byte[] ciphertext = cipher.update(buffer, 0, read);
|
||||
|
||||
if (ciphertext != null) {
|
||||
outputStream.write(ciphertext);
|
||||
mac.update(ciphertext);
|
||||
}
|
||||
}
|
||||
|
||||
byte[] remainder = cipher.doFinal();
|
||||
outputStream.write(remainder);
|
||||
mac.update(remainder);
|
||||
|
||||
byte[] attachmentDigest = mac.doFinal();
|
||||
outputStream.write(attachmentDigest, 0, 10);
|
||||
} catch (InvalidKeyException | InvalidAlgorithmParameterException | IllegalBlockSizeException | BadPaddingException e) {
|
||||
throw new AssertionError(e);
|
||||
}
|
||||
}
|
||||
|
||||
private void write(@NonNull OutputStream out, @NonNull BackupProtos.BackupFrame frame) throws IOException {
|
||||
try {
|
||||
Conversions.intToByteArray(iv, 0, counter++);
|
||||
cipher.init(Cipher.ENCRYPT_MODE, new SecretKeySpec(cipherKey, "AES"), new IvParameterSpec(iv));
|
||||
|
||||
byte[] frameCiphertext = cipher.doFinal(frame.toByteArray());
|
||||
byte[] frameMac = mac.doFinal(frameCiphertext);
|
||||
byte[] length = Conversions.intToByteArray(frameCiphertext.length + 10);
|
||||
|
||||
out.write(length);
|
||||
out.write(frameCiphertext);
|
||||
out.write(frameMac, 0, 10);
|
||||
} catch (InvalidKeyException | InvalidAlgorithmParameterException | IllegalBlockSizeException | BadPaddingException e) {
|
||||
throw new AssertionError(e);
|
||||
}
|
||||
}
|
||||
|
||||
@Override
|
||||
public void flush() throws IOException {
|
||||
outputStream.flush();
|
||||
}
|
||||
|
||||
@Override
|
||||
public void close() throws IOException {
|
||||
outputStream.close();
|
||||
}
|
||||
}
|
||||
}
|
@ -0,0 +1,440 @@
|
||||
package org.thoughtcrime.securesms.backup
|
||||
|
||||
import android.content.Context
|
||||
import android.database.Cursor
|
||||
import android.net.Uri
|
||||
import android.text.TextUtils
|
||||
import com.annimon.stream.function.Consumer
|
||||
import com.annimon.stream.function.Predicate
|
||||
import com.google.protobuf.ByteString
|
||||
import net.sqlcipher.database.SQLiteDatabase
|
||||
import org.greenrobot.eventbus.EventBus
|
||||
import org.thoughtcrime.securesms.attachments.AttachmentId
|
||||
import org.thoughtcrime.securesms.backup.BackupProtos.*
|
||||
import org.thoughtcrime.securesms.crypto.AttachmentSecret
|
||||
import org.thoughtcrime.securesms.crypto.ClassicDecryptingPartInputStream
|
||||
import org.thoughtcrime.securesms.crypto.IdentityKeyUtil
|
||||
import org.thoughtcrime.securesms.crypto.ModernDecryptingPartInputStream
|
||||
import org.thoughtcrime.securesms.database.*
|
||||
import org.thoughtcrime.securesms.logging.Log
|
||||
import org.thoughtcrime.securesms.loki.database.LokiAPIDatabase
|
||||
import org.thoughtcrime.securesms.loki.database.LokiBackupFilesDatabase
|
||||
import org.thoughtcrime.securesms.profiles.AvatarHelper
|
||||
import org.thoughtcrime.securesms.util.Conversions
|
||||
import org.thoughtcrime.securesms.util.TextSecurePreferences
|
||||
import org.thoughtcrime.securesms.util.Util
|
||||
import org.whispersystems.libsignal.kdf.HKDFv3
|
||||
import org.whispersystems.libsignal.util.ByteUtil
|
||||
import java.io.*
|
||||
import java.lang.Exception
|
||||
import java.security.InvalidAlgorithmParameterException
|
||||
import java.security.InvalidKeyException
|
||||
import java.security.NoSuchAlgorithmException
|
||||
import java.util.*
|
||||
import javax.crypto.*
|
||||
import javax.crypto.spec.IvParameterSpec
|
||||
import javax.crypto.spec.SecretKeySpec
|
||||
|
||||
object FullBackupExporter : FullBackupBase() {
|
||||
private val TAG = FullBackupExporter::class.java.simpleName
|
||||
|
||||
@JvmStatic
|
||||
@Throws(IOException::class)
|
||||
fun export(context: Context,
|
||||
attachmentSecret: AttachmentSecret,
|
||||
input: SQLiteDatabase,
|
||||
fileUri: Uri,
|
||||
passphrase: String) {
|
||||
|
||||
val baseOutputStream = context.contentResolver.openOutputStream(fileUri)
|
||||
?: throw IOException("Cannot open an output stream for the file URI: $fileUri")
|
||||
|
||||
var count = 0
|
||||
try {
|
||||
BackupFrameOutputStream(baseOutputStream, passphrase).use { outputStream ->
|
||||
outputStream.writeDatabaseVersion(input.version)
|
||||
val tables = exportSchema(input, outputStream)
|
||||
for (table in tables) if (shouldExportTable(table)) {
|
||||
count = when (table) {
|
||||
SmsDatabase.TABLE_NAME, MmsDatabase.TABLE_NAME -> {
|
||||
exportTable(table, input, outputStream,
|
||||
{ cursor: Cursor ->
|
||||
cursor.getInt(cursor.getColumnIndexOrThrow(MmsSmsColumns.EXPIRES_IN)) <= 0
|
||||
},
|
||||
null,
|
||||
count)
|
||||
}
|
||||
GroupReceiptDatabase.TABLE_NAME -> {
|
||||
exportTable(table, input, outputStream,
|
||||
{ cursor: Cursor ->
|
||||
isForNonExpiringMessage(input, cursor.getLong(cursor.getColumnIndexOrThrow(GroupReceiptDatabase.MMS_ID)))
|
||||
},
|
||||
null,
|
||||
count)
|
||||
}
|
||||
AttachmentDatabase.TABLE_NAME -> {
|
||||
exportTable(table, input, outputStream,
|
||||
{ cursor: Cursor ->
|
||||
isForNonExpiringMessage(input, cursor.getLong(cursor.getColumnIndexOrThrow(AttachmentDatabase.MMS_ID)))
|
||||
},
|
||||
{ cursor: Cursor ->
|
||||
exportAttachment(attachmentSecret, cursor, outputStream)
|
||||
},
|
||||
count)
|
||||
}
|
||||
StickerDatabase.TABLE_NAME -> {
|
||||
exportTable(table, input, outputStream,
|
||||
{ true },
|
||||
{ cursor: Cursor -> exportSticker(attachmentSecret, cursor, outputStream) },
|
||||
count)
|
||||
}
|
||||
else -> {
|
||||
exportTable(table, input, outputStream, null, null, count)
|
||||
}
|
||||
}
|
||||
}
|
||||
for (preference in IdentityKeyUtil.getBackupRecords(context)) {
|
||||
EventBus.getDefault().post(BackupEvent.createProgress(++count))
|
||||
outputStream.writePreferenceEntry(preference)
|
||||
}
|
||||
for (preference in TextSecurePreferences.getBackupRecords(context)) {
|
||||
EventBus.getDefault().post(BackupEvent.createProgress(++count))
|
||||
outputStream.writePreferenceEntry(preference)
|
||||
}
|
||||
for (avatar in AvatarHelper.getAvatarFiles(context)) {
|
||||
EventBus.getDefault().post(BackupEvent.createProgress(++count))
|
||||
outputStream.writeAvatar(avatar.name, FileInputStream(avatar), avatar.length())
|
||||
}
|
||||
outputStream.writeEnd()
|
||||
}
|
||||
EventBus.getDefault().post(BackupEvent.createFinished())
|
||||
} catch (e: Exception) {
|
||||
Log.e(TAG, "Failed to make full backup.", e)
|
||||
EventBus.getDefault().post(BackupEvent.createFinished(e))
|
||||
throw e
|
||||
}
|
||||
}
|
||||
|
||||
private inline fun shouldExportTable(table: String): Boolean {
|
||||
return table != SignedPreKeyDatabase.TABLE_NAME &&
|
||||
table != OneTimePreKeyDatabase.TABLE_NAME &&
|
||||
table != SessionDatabase.TABLE_NAME &&
|
||||
table != PushDatabase.TABLE_NAME &&
|
||||
// table != DraftDatabase.TABLE_NAME &&
|
||||
|
||||
table != LokiBackupFilesDatabase.TABLE_NAME &&
|
||||
table != LokiAPIDatabase.openGroupProfilePictureTable &&
|
||||
|
||||
table != JobDatabase.Jobs.TABLE_NAME &&
|
||||
table != JobDatabase.Constraints.TABLE_NAME &&
|
||||
table != JobDatabase.Dependencies.TABLE_NAME &&
|
||||
|
||||
!table.startsWith(SearchDatabase.SMS_FTS_TABLE_NAME) &&
|
||||
!table.startsWith(SearchDatabase.MMS_FTS_TABLE_NAME) &&
|
||||
!table.startsWith("sqlite_")
|
||||
}
|
||||
|
||||
@Throws(IOException::class)
|
||||
private fun exportSchema(input: SQLiteDatabase, outputStream: BackupFrameOutputStream): List<String> {
|
||||
val tables: MutableList<String> = LinkedList()
|
||||
input.rawQuery("SELECT sql, name, type FROM sqlite_master", null).use { cursor ->
|
||||
while (cursor != null && cursor.moveToNext()) {
|
||||
val sql = cursor.getString(0)
|
||||
val name = cursor.getString(1)
|
||||
val type = cursor.getString(2)
|
||||
if (sql != null) {
|
||||
val isSmsFtsSecretTable = name != null && name != SearchDatabase.SMS_FTS_TABLE_NAME && name.startsWith(SearchDatabase.SMS_FTS_TABLE_NAME)
|
||||
val isMmsFtsSecretTable = name != null && name != SearchDatabase.MMS_FTS_TABLE_NAME && name.startsWith(SearchDatabase.MMS_FTS_TABLE_NAME)
|
||||
if (!isSmsFtsSecretTable && !isMmsFtsSecretTable) {
|
||||
if ("table" == type) {
|
||||
tables.add(name)
|
||||
}
|
||||
outputStream.writeSql(SqlStatement.newBuilder().setStatement(cursor.getString(0)).build())
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
return tables
|
||||
}
|
||||
|
||||
@Throws(IOException::class)
|
||||
private fun exportTable(table: String,
|
||||
input: SQLiteDatabase,
|
||||
outputStream: BackupFrameOutputStream,
|
||||
predicate: Predicate<Cursor>?,
|
||||
postProcess: Consumer<Cursor>?,
|
||||
count: Int): Int {
|
||||
var count = count
|
||||
val template = "INSERT INTO $table VALUES "
|
||||
input.rawQuery("SELECT * FROM $table", null).use { cursor ->
|
||||
while (cursor != null && cursor.moveToNext()) {
|
||||
EventBus.getDefault().post(BackupEvent.createProgress(++count))
|
||||
if (predicate != null && !predicate.test(cursor)) continue
|
||||
|
||||
val statement = StringBuilder(template)
|
||||
val statementBuilder = SqlStatement.newBuilder()
|
||||
statement.append('(')
|
||||
for (i in 0 until cursor.columnCount) {
|
||||
statement.append('?')
|
||||
when (cursor.getType(i)) {
|
||||
Cursor.FIELD_TYPE_STRING -> {
|
||||
statementBuilder.addParameters(SqlStatement.SqlParameter.newBuilder()
|
||||
.setStringParamter(cursor.getString(i)))
|
||||
}
|
||||
Cursor.FIELD_TYPE_FLOAT -> {
|
||||
statementBuilder.addParameters(SqlStatement.SqlParameter.newBuilder()
|
||||
.setDoubleParameter(cursor.getDouble(i)))
|
||||
}
|
||||
Cursor.FIELD_TYPE_INTEGER -> {
|
||||
statementBuilder.addParameters(SqlStatement.SqlParameter.newBuilder()
|
||||
.setIntegerParameter(cursor.getLong(i)))
|
||||
}
|
||||
Cursor.FIELD_TYPE_BLOB -> {
|
||||
statementBuilder.addParameters(SqlStatement.SqlParameter.newBuilder()
|
||||
.setBlobParameter(ByteString.copyFrom(cursor.getBlob(i))))
|
||||
}
|
||||
Cursor.FIELD_TYPE_NULL -> {
|
||||
statementBuilder.addParameters(SqlStatement.SqlParameter.newBuilder()
|
||||
.setNullparameter(true))
|
||||
}
|
||||
else -> {
|
||||
throw AssertionError("unknown type?" + cursor.getType(i))
|
||||
}
|
||||
}
|
||||
if (i < cursor.columnCount - 1) {
|
||||
statement.append(',')
|
||||
}
|
||||
}
|
||||
statement.append(')')
|
||||
outputStream.writeSql(statementBuilder.setStatement(statement.toString()).build())
|
||||
postProcess?.accept(cursor)
|
||||
}
|
||||
}
|
||||
return count
|
||||
}
|
||||
|
||||
private fun exportAttachment(attachmentSecret: AttachmentSecret, cursor: Cursor, outputStream: BackupFrameOutputStream) {
|
||||
try {
|
||||
val rowId = cursor.getLong(cursor.getColumnIndexOrThrow(AttachmentDatabase.ROW_ID))
|
||||
val uniqueId = cursor.getLong(cursor.getColumnIndexOrThrow(AttachmentDatabase.UNIQUE_ID))
|
||||
var size = cursor.getLong(cursor.getColumnIndexOrThrow(AttachmentDatabase.SIZE))
|
||||
val data = cursor.getString(cursor.getColumnIndexOrThrow(AttachmentDatabase.DATA))
|
||||
val random = cursor.getBlob(cursor.getColumnIndexOrThrow(AttachmentDatabase.DATA_RANDOM))
|
||||
if (!TextUtils.isEmpty(data) && size <= 0) {
|
||||
size = calculateVeryOldStreamLength(attachmentSecret, random, data)
|
||||
}
|
||||
if (!TextUtils.isEmpty(data) && size > 0) {
|
||||
val inputStream: InputStream = if (random != null && random.size == 32) {
|
||||
ModernDecryptingPartInputStream.createFor(attachmentSecret, random, File(data), 0)
|
||||
} else {
|
||||
ClassicDecryptingPartInputStream.createFor(attachmentSecret, File(data))
|
||||
}
|
||||
outputStream.writeAttachment(AttachmentId(rowId, uniqueId), inputStream, size)
|
||||
}
|
||||
} catch (e: IOException) {
|
||||
Log.w(TAG, e)
|
||||
}
|
||||
}
|
||||
|
||||
private fun exportSticker(attachmentSecret: AttachmentSecret, cursor: Cursor, outputStream: BackupFrameOutputStream) {
|
||||
try {
|
||||
val rowId = cursor.getLong(cursor.getColumnIndexOrThrow(StickerDatabase._ID))
|
||||
val size = cursor.getLong(cursor.getColumnIndexOrThrow(StickerDatabase.FILE_LENGTH))
|
||||
val data = cursor.getString(cursor.getColumnIndexOrThrow(StickerDatabase.FILE_PATH))
|
||||
val random = cursor.getBlob(cursor.getColumnIndexOrThrow(StickerDatabase.FILE_RANDOM))
|
||||
if (!TextUtils.isEmpty(data) && size > 0) {
|
||||
ModernDecryptingPartInputStream.createFor(attachmentSecret, random, File(data), 0).use { inputStream -> outputStream.writeSticker(rowId, inputStream, size) }
|
||||
}
|
||||
} catch (e: IOException) {
|
||||
Log.w(TAG, e)
|
||||
}
|
||||
}
|
||||
|
||||
@Throws(IOException::class)
|
||||
private fun calculateVeryOldStreamLength(attachmentSecret: AttachmentSecret, random: ByteArray?, data: String): Long {
|
||||
var result: Long = 0
|
||||
val inputStream: InputStream = if (random != null && random.size == 32) {
|
||||
ModernDecryptingPartInputStream.createFor(attachmentSecret, random, File(data), 0)
|
||||
} else {
|
||||
ClassicDecryptingPartInputStream.createFor(attachmentSecret, File(data))
|
||||
}
|
||||
var read: Int
|
||||
val buffer = ByteArray(8192)
|
||||
while (inputStream.read(buffer, 0, buffer.size).also { read = it } != -1) {
|
||||
result += read.toLong()
|
||||
}
|
||||
return result
|
||||
}
|
||||
|
||||
private fun isForNonExpiringMessage(db: SQLiteDatabase, mmsId: Long): Boolean {
|
||||
val columns = arrayOf(MmsDatabase.EXPIRES_IN)
|
||||
val where = MmsDatabase.ID + " = ?"
|
||||
val args = arrayOf(mmsId.toString())
|
||||
db.query(MmsDatabase.TABLE_NAME, columns, where, args, null, null, null).use { mmsCursor ->
|
||||
if (mmsCursor != null && mmsCursor.moveToFirst()) {
|
||||
return mmsCursor.getLong(0) == 0L
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
private class BackupFrameOutputStream : BackupStream, Closeable, Flushable {
|
||||
|
||||
private val outputStream: OutputStream
|
||||
private var cipher: Cipher
|
||||
private var mac: Mac
|
||||
private val cipherKey: ByteArray
|
||||
private val macKey: ByteArray
|
||||
private val iv: ByteArray
|
||||
|
||||
private var counter: Int = 0
|
||||
|
||||
constructor(outputStream: OutputStream, passphrase: String) : super() {
|
||||
try {
|
||||
val salt = Util.getSecretBytes(32)
|
||||
val key = getBackupKey(passphrase, salt)
|
||||
val derived = HKDFv3().deriveSecrets(key, "Backup Export".toByteArray(), 64)
|
||||
val split = ByteUtil.split(derived, 32, 32)
|
||||
cipherKey = split[0]
|
||||
macKey = split[1]
|
||||
cipher = Cipher.getInstance("AES/CTR/NoPadding")
|
||||
mac = Mac.getInstance("HmacSHA256")
|
||||
this.outputStream = outputStream
|
||||
iv = Util.getSecretBytes(16)
|
||||
counter = Conversions.byteArrayToInt(iv)
|
||||
mac.init(SecretKeySpec(macKey, "HmacSHA256"))
|
||||
val header = BackupFrame.newBuilder().setHeader(Header.newBuilder()
|
||||
.setIv(ByteString.copyFrom(iv))
|
||||
.setSalt(ByteString.copyFrom(salt)))
|
||||
.build().toByteArray()
|
||||
outputStream.write(Conversions.intToByteArray(header.size))
|
||||
outputStream.write(header)
|
||||
} catch (e: NoSuchAlgorithmException) {
|
||||
throw AssertionError(e)
|
||||
} catch (e: NoSuchPaddingException) {
|
||||
throw AssertionError(e)
|
||||
} catch (e: InvalidKeyException) {
|
||||
throw AssertionError(e)
|
||||
}
|
||||
}
|
||||
|
||||
@Throws(IOException::class)
|
||||
fun writeSql(statement: SqlStatement) {
|
||||
write(outputStream, BackupFrame.newBuilder().setStatement(statement).build())
|
||||
}
|
||||
|
||||
@Throws(IOException::class)
|
||||
fun writePreferenceEntry(preference: SharedPreference?) {
|
||||
write(outputStream, BackupFrame.newBuilder().setPreference(preference).build())
|
||||
}
|
||||
|
||||
@Throws(IOException::class)
|
||||
fun writeAvatar(avatarName: String, inputStream: InputStream, size: Long) {
|
||||
write(outputStream, BackupFrame.newBuilder()
|
||||
.setAvatar(Avatar.newBuilder()
|
||||
.setName(avatarName)
|
||||
.setLength(Util.toIntExact(size))
|
||||
.build())
|
||||
.build())
|
||||
writeStream(inputStream)
|
||||
}
|
||||
|
||||
@Throws(IOException::class)
|
||||
fun writeAttachment(attachmentId: AttachmentId, inputStream: InputStream, size: Long) {
|
||||
write(outputStream, BackupFrame.newBuilder()
|
||||
.setAttachment(Attachment.newBuilder()
|
||||
.setRowId(attachmentId.rowId)
|
||||
.setAttachmentId(attachmentId.uniqueId)
|
||||
.setLength(Util.toIntExact(size))
|
||||
.build())
|
||||
.build())
|
||||
writeStream(inputStream)
|
||||
}
|
||||
|
||||
@Throws(IOException::class)
|
||||
fun writeSticker(rowId: Long, inputStream: InputStream, size: Long) {
|
||||
write(outputStream, BackupFrame.newBuilder()
|
||||
.setSticker(Sticker.newBuilder()
|
||||
.setRowId(rowId)
|
||||
.setLength(Util.toIntExact(size))
|
||||
.build())
|
||||
.build())
|
||||
writeStream(inputStream)
|
||||
}
|
||||
|
||||
@Throws(IOException::class)
|
||||
fun writeDatabaseVersion(version: Int) {
|
||||
write(outputStream, BackupFrame.newBuilder()
|
||||
.setVersion(DatabaseVersion.newBuilder().setVersion(version))
|
||||
.build())
|
||||
}
|
||||
|
||||
@Throws(IOException::class)
|
||||
fun writeEnd() {
|
||||
write(outputStream, BackupFrame.newBuilder().setEnd(true).build())
|
||||
}
|
||||
|
||||
@Throws(IOException::class)
|
||||
private fun writeStream(inputStream: InputStream) {
|
||||
try {
|
||||
Conversions.intToByteArray(iv, 0, counter++)
|
||||
cipher.init(Cipher.ENCRYPT_MODE, SecretKeySpec(cipherKey, "AES"), IvParameterSpec(iv))
|
||||
mac.update(iv)
|
||||
val buffer = ByteArray(8192)
|
||||
var read: Int
|
||||
while (inputStream.read(buffer).also { read = it } != -1) {
|
||||
val ciphertext = cipher.update(buffer, 0, read)
|
||||
if (ciphertext != null) {
|
||||
outputStream.write(ciphertext)
|
||||
mac.update(ciphertext)
|
||||
}
|
||||
}
|
||||
val remainder = cipher.doFinal()
|
||||
outputStream.write(remainder)
|
||||
mac.update(remainder)
|
||||
val attachmentDigest = mac.doFinal()
|
||||
outputStream.write(attachmentDigest, 0, 10)
|
||||
} catch (e: InvalidKeyException) {
|
||||
throw AssertionError(e)
|
||||
} catch (e: InvalidAlgorithmParameterException) {
|
||||
throw AssertionError(e)
|
||||
} catch (e: IllegalBlockSizeException) {
|
||||
throw AssertionError(e)
|
||||
} catch (e: BadPaddingException) {
|
||||
throw AssertionError(e)
|
||||
}
|
||||
}
|
||||
|
||||
@Throws(IOException::class)
|
||||
private fun write(out: OutputStream, frame: BackupFrame) {
|
||||
try {
|
||||
Conversions.intToByteArray(iv, 0, counter++)
|
||||
cipher.init(Cipher.ENCRYPT_MODE, SecretKeySpec(cipherKey, "AES"), IvParameterSpec(iv))
|
||||
val frameCiphertext = cipher.doFinal(frame.toByteArray())
|
||||
val frameMac = mac.doFinal(frameCiphertext)
|
||||
val length = Conversions.intToByteArray(frameCiphertext.size + 10)
|
||||
out.write(length)
|
||||
out.write(frameCiphertext)
|
||||
out.write(frameMac, 0, 10)
|
||||
} catch (e: InvalidKeyException) {
|
||||
throw AssertionError(e)
|
||||
} catch (e: InvalidAlgorithmParameterException) {
|
||||
throw AssertionError(e)
|
||||
} catch (e: IllegalBlockSizeException) {
|
||||
throw AssertionError(e)
|
||||
} catch (e: BadPaddingException) {
|
||||
throw AssertionError(e)
|
||||
}
|
||||
}
|
||||
|
||||
@Throws(IOException::class)
|
||||
override fun flush() {
|
||||
outputStream.flush()
|
||||
}
|
||||
|
||||
@Throws(IOException::class)
|
||||
override fun close() {
|
||||
outputStream.close()
|
||||
}
|
||||
}
|
||||
}
|
@ -0,0 +1,71 @@
|
||||
package org.thoughtcrime.securesms.loki.api
|
||||
|
||||
import org.thoughtcrime.securesms.database.Address
|
||||
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.recipients.Recipient
|
||||
import org.thoughtcrime.securesms.sms.MessageSender
|
||||
import org.thoughtcrime.securesms.sms.OutgoingEndSessionMessage
|
||||
import org.thoughtcrime.securesms.sms.OutgoingTextMessage
|
||||
import java.util.concurrent.TimeUnit
|
||||
|
||||
class ResetThreadSessionJob private constructor(
|
||||
parameters: Parameters,
|
||||
private val address: Address,
|
||||
private val threadId: Long)
|
||||
: BaseJob(parameters) {
|
||||
|
||||
companion object {
|
||||
const val KEY = "ResetThreadSessionJob"
|
||||
const val DATA_KEY_ADDRESS = "address"
|
||||
const val DATA_KEY_THREAD_ID = "thread_id"
|
||||
}
|
||||
|
||||
constructor(address: Address, threadId: Long) : this(Parameters.Builder()
|
||||
.addConstraint(NetworkConstraint.KEY)
|
||||
.setQueue(KEY)
|
||||
.setLifespan(TimeUnit.DAYS.toMillis(1))
|
||||
.setMaxAttempts(Parameters.UNLIMITED)
|
||||
.build(),
|
||||
address,
|
||||
threadId)
|
||||
|
||||
override fun serialize(): Data {
|
||||
return Data.Builder()
|
||||
.putParcelable(DATA_KEY_ADDRESS, address)
|
||||
.putLong(DATA_KEY_THREAD_ID, threadId)
|
||||
.build()
|
||||
}
|
||||
|
||||
override fun getFactoryKey(): String { return KEY }
|
||||
|
||||
public override fun onRun() {
|
||||
val recipient = Recipient.from(context, address, false)
|
||||
|
||||
// Only reset sessions for private chats.
|
||||
if (recipient.isGroupRecipient) return
|
||||
|
||||
Log.v(KEY, "Resetting session for thread: \"$threadId\", recipient: \"${address.serialize()}\"")
|
||||
|
||||
val message = OutgoingEndSessionMessage(OutgoingTextMessage(recipient, "TERMINATE", 0, -1))
|
||||
MessageSender.send(context, message, threadId, false, null)
|
||||
}
|
||||
|
||||
public override fun onShouldRetry(e: Exception): Boolean {
|
||||
return false
|
||||
}
|
||||
|
||||
override fun onCanceled() { }
|
||||
|
||||
class Factory : Job.Factory<ResetThreadSessionJob> {
|
||||
|
||||
override fun create(parameters: Parameters, data: Data): ResetThreadSessionJob {
|
||||
val address = data.getParcelable(DATA_KEY_ADDRESS, Address.CREATOR)
|
||||
val threadId = data.getLong(DATA_KEY_THREAD_ID)
|
||||
return ResetThreadSessionJob(parameters, address, threadId)
|
||||
}
|
||||
}
|
||||
}
|
Loading…
Reference in New Issue