Security: Introduce backup file format v2
This switches the SQL based backup format to something JSON based. The SQL based format has always been prone to SQL injections that, for example, could delete other messages or preexisting accounts in the app. This hasn’t been a concern this far because why would anyone purposely try to restore a faulty backup? However the argument has been made that a user can be socially engineered to restore an exploited backup file. Before version 2.12.8 a third party app could even trigger the restore process, leaving the backup password entry dialog the only hurdle. On top of that it has been demonstrated that a backup file can be crafted in a way that puts preexisting credentials into a 'pending' message to an attacker ultimately leading to that information being leaked. While destorying information has always been deemed an acceptable risk, leaking information is one step too far. Starting with Conversations 2.12.9 Conversations will no longer be able to read v1 backup files. This means if you are restoring on a new device and you have a v1 backup file you must first install Conversations <= 2.12.8, restore the backup, and then upgrade to Conversations >= 2.12.9. ceb2txt¹ has support for v2 backup files. Conceivably ceb2txt could be extended to convert between v1 and v2 file formats. (ceb2txt already recreates the database from v1 files; It is relatively straight forward to create v2 files from that database. Pull requests welcome.) ¹: https://github.com/iNPUTmice/ceb2txt/
This commit is contained in:
parent
0677ddc59b
commit
09f6343ced
|
@ -6,6 +6,7 @@ import android.app.Notification;
|
|||
import android.app.NotificationManager;
|
||||
import android.app.PendingIntent;
|
||||
import android.app.Service;
|
||||
import android.content.ContentValues;
|
||||
import android.content.Context;
|
||||
import android.content.Intent;
|
||||
import android.database.Cursor;
|
||||
|
@ -22,6 +23,8 @@ import androidx.core.app.NotificationManagerCompat;
|
|||
import com.google.common.base.Charsets;
|
||||
import com.google.common.base.Stopwatch;
|
||||
import com.google.common.io.CountingInputStream;
|
||||
import com.google.gson.stream.JsonReader;
|
||||
import com.google.gson.stream.JsonToken;
|
||||
|
||||
import org.bouncycastle.crypto.engines.AESEngine;
|
||||
import org.bouncycastle.crypto.io.CipherInputStream;
|
||||
|
@ -40,6 +43,7 @@ import java.io.InputStream;
|
|||
import java.io.InputStreamReader;
|
||||
import java.util.ArrayList;
|
||||
import java.util.Arrays;
|
||||
import java.util.Collection;
|
||||
import java.util.Collections;
|
||||
import java.util.HashSet;
|
||||
import java.util.List;
|
||||
|
@ -53,6 +57,10 @@ import javax.crypto.BadPaddingException;
|
|||
|
||||
import eu.siacs.conversations.Config;
|
||||
import eu.siacs.conversations.R;
|
||||
import eu.siacs.conversations.crypto.axolotl.SQLiteAxolotlStore;
|
||||
import eu.siacs.conversations.entities.Account;
|
||||
import eu.siacs.conversations.entities.Conversation;
|
||||
import eu.siacs.conversations.entities.Message;
|
||||
import eu.siacs.conversations.persistance.DatabaseBackend;
|
||||
import eu.siacs.conversations.persistance.FileBackend;
|
||||
import eu.siacs.conversations.ui.ManageAccountActivity;
|
||||
|
@ -65,25 +73,28 @@ public class ImportBackupService extends Service {
|
|||
private static final int NOTIFICATION_ID = 21;
|
||||
private static final AtomicBoolean running = new AtomicBoolean(false);
|
||||
private final ImportBackupServiceBinder binder = new ImportBackupServiceBinder();
|
||||
private final SerialSingleThreadExecutor executor = new SerialSingleThreadExecutor(getClass().getSimpleName());
|
||||
private final Set<OnBackupProcessed> mOnBackupProcessedListeners = Collections.newSetFromMap(new WeakHashMap<>());
|
||||
private final SerialSingleThreadExecutor executor =
|
||||
new SerialSingleThreadExecutor(getClass().getSimpleName());
|
||||
private final Set<OnBackupProcessed> mOnBackupProcessedListeners =
|
||||
Collections.newSetFromMap(new WeakHashMap<>());
|
||||
private DatabaseBackend mDatabaseBackend;
|
||||
private NotificationManager notificationManager;
|
||||
|
||||
private static int count(String input, char c) {
|
||||
int count = 0;
|
||||
for (char aChar : input.toCharArray()) {
|
||||
if (aChar == c) {
|
||||
++count;
|
||||
}
|
||||
}
|
||||
return count;
|
||||
}
|
||||
private static final Collection<String> TABLE_ALLOW_LIST =
|
||||
Arrays.asList(
|
||||
Account.TABLENAME,
|
||||
Conversation.TABLENAME,
|
||||
Message.TABLENAME,
|
||||
SQLiteAxolotlStore.PREKEY_TABLENAME,
|
||||
SQLiteAxolotlStore.SIGNED_PREKEY_TABLENAME,
|
||||
SQLiteAxolotlStore.SESSION_TABLENAME,
|
||||
SQLiteAxolotlStore.IDENTITIES_TABLENAME);
|
||||
|
||||
@Override
|
||||
public void onCreate() {
|
||||
mDatabaseBackend = DatabaseBackend.getInstance(getBaseContext());
|
||||
notificationManager = (android.app.NotificationManager) getSystemService(Context.NOTIFICATION_SERVICE);
|
||||
notificationManager =
|
||||
(android.app.NotificationManager) getSystemService(Context.NOTIFICATION_SERVICE);
|
||||
}
|
||||
|
||||
@Override
|
||||
|
@ -105,16 +116,17 @@ public class ImportBackupService extends Service {
|
|||
return START_NOT_STICKY;
|
||||
}
|
||||
if (running.compareAndSet(false, true)) {
|
||||
executor.execute(() -> {
|
||||
startForegroundService();
|
||||
final boolean success = importBackup(uri, password);
|
||||
stopForeground(true);
|
||||
running.set(false);
|
||||
if (success) {
|
||||
notifySuccess();
|
||||
}
|
||||
stopSelf();
|
||||
});
|
||||
executor.execute(
|
||||
() -> {
|
||||
startForegroundService();
|
||||
final boolean success = importBackup(uri, password);
|
||||
stopForeground(true);
|
||||
running.set(false);
|
||||
if (success) {
|
||||
notifySuccess();
|
||||
}
|
||||
stopSelf();
|
||||
});
|
||||
} else {
|
||||
Log.d(Config.LOGTAG, "backup already running");
|
||||
}
|
||||
|
@ -126,42 +138,59 @@ public class ImportBackupService extends Service {
|
|||
}
|
||||
|
||||
public void loadBackupFiles(final OnBackupFilesLoaded onBackupFilesLoaded) {
|
||||
executor.execute(() -> {
|
||||
final List<Jid> accounts = mDatabaseBackend.getAccountJids(false);
|
||||
final ArrayList<BackupFile> backupFiles = new ArrayList<>();
|
||||
final Set<String> apps = new HashSet<>(Arrays.asList("Conversations", "Quicksy", getString(R.string.app_name)));
|
||||
final List<File> directories = new ArrayList<>();
|
||||
for (final String app : apps) {
|
||||
directories.add(FileBackend.getLegacyBackupDirectory(app));
|
||||
}
|
||||
directories.add(FileBackend.getBackupDirectory(this));
|
||||
for (final File directory : directories) {
|
||||
if (!directory.exists() || !directory.isDirectory()) {
|
||||
Log.d(Config.LOGTAG, "directory not found: " + directory.getAbsolutePath());
|
||||
continue;
|
||||
}
|
||||
final File[] files = directory.listFiles();
|
||||
if (files == null) {
|
||||
continue;
|
||||
}
|
||||
for (final File file : files) {
|
||||
if (file.isFile() && file.getName().endsWith(".ceb")) {
|
||||
try {
|
||||
final BackupFile backupFile = BackupFile.read(file);
|
||||
if (accounts.contains(backupFile.getHeader().getJid())) {
|
||||
Log.d(Config.LOGTAG, "skipping backup for " + backupFile.getHeader().getJid());
|
||||
} else {
|
||||
backupFiles.add(backupFile);
|
||||
executor.execute(
|
||||
() -> {
|
||||
final List<Jid> accounts = mDatabaseBackend.getAccountJids(false);
|
||||
final ArrayList<BackupFile> backupFiles = new ArrayList<>();
|
||||
final Set<String> apps =
|
||||
new HashSet<>(
|
||||
Arrays.asList(
|
||||
"Conversations",
|
||||
"Quicksy",
|
||||
getString(R.string.app_name)));
|
||||
final List<File> directories = new ArrayList<>();
|
||||
for (final String app : apps) {
|
||||
directories.add(FileBackend.getLegacyBackupDirectory(app));
|
||||
}
|
||||
directories.add(FileBackend.getBackupDirectory(this));
|
||||
for (final File directory : directories) {
|
||||
if (!directory.exists() || !directory.isDirectory()) {
|
||||
Log.d(
|
||||
Config.LOGTAG,
|
||||
"directory not found: " + directory.getAbsolutePath());
|
||||
continue;
|
||||
}
|
||||
final File[] files = directory.listFiles();
|
||||
if (files == null) {
|
||||
continue;
|
||||
}
|
||||
for (final File file : files) {
|
||||
if (file.isFile() && file.getName().endsWith(".ceb")) {
|
||||
try {
|
||||
final BackupFile backupFile = BackupFile.read(file);
|
||||
if (accounts.contains(backupFile.getHeader().getJid())) {
|
||||
Log.d(
|
||||
Config.LOGTAG,
|
||||
"skipping backup for "
|
||||
+ backupFile.getHeader().getJid());
|
||||
} else {
|
||||
backupFiles.add(backupFile);
|
||||
}
|
||||
} catch (IOException | IllegalArgumentException e) {
|
||||
Log.d(Config.LOGTAG, "unable to read backup file ", e);
|
||||
}
|
||||
}
|
||||
} catch (IOException | IllegalArgumentException e) {
|
||||
Log.d(Config.LOGTAG, "unable to read backup file ", e);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
Collections.sort(backupFiles, (a, b) -> a.header.getJid().toString().compareTo(b.header.getJid().toString()));
|
||||
onBackupFilesLoaded.onBackupFilesLoaded(backupFiles);
|
||||
});
|
||||
Collections.sort(
|
||||
backupFiles,
|
||||
(a, b) ->
|
||||
a.header
|
||||
.getJid()
|
||||
.toString()
|
||||
.compareTo(b.header.getJid().toString()));
|
||||
onBackupFilesLoaded.onBackupFilesLoaded(backupFiles);
|
||||
});
|
||||
}
|
||||
|
||||
private void startForegroundService() {
|
||||
|
@ -180,14 +209,16 @@ public class ImportBackupService extends Service {
|
|||
}
|
||||
final NotificationManagerCompat notificationManager = NotificationManagerCompat.from(this);
|
||||
try {
|
||||
notificationManager.notify(NOTIFICATION_ID, createImportBackupNotification(max, progress));
|
||||
notificationManager.notify(
|
||||
NOTIFICATION_ID, createImportBackupNotification(max, progress));
|
||||
} catch (final RuntimeException e) {
|
||||
Log.d(Config.LOGTAG, "unable to make notification", e);
|
||||
}
|
||||
}
|
||||
|
||||
private Notification createImportBackupNotification(final int max, final int progress) {
|
||||
NotificationCompat.Builder mBuilder = new NotificationCompat.Builder(getBaseContext(), "backup");
|
||||
NotificationCompat.Builder mBuilder =
|
||||
new NotificationCompat.Builder(getBaseContext(), "backup");
|
||||
mBuilder.setContentTitle(getString(R.string.restoring_backup))
|
||||
.setSmallIcon(R.drawable.ic_unarchive_white_24dp)
|
||||
.setProgress(max, progress, max == 1 && progress == 0);
|
||||
|
@ -212,7 +243,9 @@ public class ImportBackupService extends Service {
|
|||
fileSize = 0;
|
||||
} else {
|
||||
returnCursor.moveToFirst();
|
||||
fileSize = returnCursor.getLong(returnCursor.getColumnIndex(OpenableColumns.SIZE));
|
||||
fileSize =
|
||||
returnCursor.getLong(
|
||||
returnCursor.getColumnIndexOrThrow(OpenableColumns.SIZE));
|
||||
returnCursor.close();
|
||||
}
|
||||
inputStream = getContentResolver().openInputStream(uri);
|
||||
|
@ -242,40 +275,46 @@ public class ImportBackupService extends Service {
|
|||
final byte[] key = ExportBackupService.getKey(password, backupFileHeader.getSalt());
|
||||
|
||||
final AEADBlockCipher cipher = new GCMBlockCipher(new AESEngine());
|
||||
cipher.init(false, new AEADParameters(new KeyParameter(key), 128, backupFileHeader.getIv()));
|
||||
final CipherInputStream cipherInputStream = new CipherInputStream(countingInputStream, cipher);
|
||||
cipher.init(
|
||||
false,
|
||||
new AEADParameters(new KeyParameter(key), 128, backupFileHeader.getIv()));
|
||||
final CipherInputStream cipherInputStream =
|
||||
new CipherInputStream(countingInputStream, cipher);
|
||||
|
||||
final GZIPInputStream gzipInputStream = new GZIPInputStream(cipherInputStream);
|
||||
final BufferedReader reader = new BufferedReader(new InputStreamReader(gzipInputStream, Charsets.UTF_8));
|
||||
final BufferedReader reader =
|
||||
new BufferedReader(new InputStreamReader(gzipInputStream, Charsets.UTF_8));
|
||||
final JsonReader jsonReader = new JsonReader(reader);
|
||||
if (jsonReader.peek() == JsonToken.BEGIN_ARRAY) {
|
||||
jsonReader.beginArray();
|
||||
} else {
|
||||
throw new IllegalStateException("Backup file did not begin with array");
|
||||
}
|
||||
db.beginTransaction();
|
||||
String line;
|
||||
StringBuilder multiLineQuery = null;
|
||||
while ((line = reader.readLine()) != null) {
|
||||
int count = count(line, '\'');
|
||||
if (multiLineQuery != null) {
|
||||
multiLineQuery.append('\n');
|
||||
multiLineQuery.append(line);
|
||||
if (count % 2 == 1) {
|
||||
db.execSQL(multiLineQuery.toString());
|
||||
multiLineQuery = null;
|
||||
updateImportBackupNotification(fileSize, countingInputStream.getCount());
|
||||
}
|
||||
} else {
|
||||
if (count % 2 == 0) {
|
||||
db.execSQL(line);
|
||||
updateImportBackupNotification(fileSize, countingInputStream.getCount());
|
||||
} else {
|
||||
multiLineQuery = new StringBuilder(line);
|
||||
}
|
||||
while (jsonReader.hasNext()) {
|
||||
if (jsonReader.peek() == JsonToken.BEGIN_OBJECT) {
|
||||
importRow(db, jsonReader, backupFileHeader.getJid(), password);
|
||||
} else if (jsonReader.peek() == JsonToken.END_ARRAY) {
|
||||
jsonReader.endArray();
|
||||
continue;
|
||||
}
|
||||
updateImportBackupNotification(fileSize, countingInputStream.getCount());
|
||||
}
|
||||
db.setTransactionSuccessful();
|
||||
db.endTransaction();
|
||||
final Jid jid = backupFileHeader.getJid();
|
||||
final Cursor countCursor = db.rawQuery("select count(messages.uuid) from messages join conversations on conversations.uuid=messages.conversationUuid join accounts on conversations.accountUuid=accounts.uuid where accounts.username=? and accounts.server=?", new String[]{jid.getEscapedLocal(), jid.getDomain().toEscapedString()});
|
||||
final Cursor countCursor =
|
||||
db.rawQuery(
|
||||
"select count(messages.uuid) from messages join conversations on conversations.uuid=messages.conversationUuid join accounts on conversations.accountUuid=accounts.uuid where accounts.username=? and accounts.server=?",
|
||||
new String[] {
|
||||
jid.getEscapedLocal(), jid.getDomain().toEscapedString()
|
||||
});
|
||||
countCursor.moveToFirst();
|
||||
final int count = countCursor.getInt(0);
|
||||
Log.d(Config.LOGTAG, String.format("restored %d messages in %s", count, stopwatch.stop().toString()));
|
||||
Log.d(
|
||||
Config.LOGTAG,
|
||||
String.format(
|
||||
"restored %d messages in %s", count, stopwatch.stop().toString()));
|
||||
countCursor.close();
|
||||
stopBackgroundService();
|
||||
synchronized (mOnBackupProcessedListeners) {
|
||||
|
@ -286,7 +325,8 @@ public class ImportBackupService extends Service {
|
|||
return true;
|
||||
} catch (final Exception e) {
|
||||
final Throwable throwable = e.getCause();
|
||||
final boolean reasonWasCrypto = throwable instanceof BadPaddingException || e instanceof ZipException;
|
||||
final boolean reasonWasCrypto =
|
||||
throwable instanceof BadPaddingException || e instanceof ZipException;
|
||||
synchronized (mOnBackupProcessedListeners) {
|
||||
for (OnBackupProcessed l : mOnBackupProcessedListeners) {
|
||||
if (reasonWasCrypto) {
|
||||
|
@ -301,14 +341,71 @@ public class ImportBackupService extends Service {
|
|||
}
|
||||
}
|
||||
|
||||
private void importRow(
|
||||
final SQLiteDatabase db,
|
||||
final JsonReader jsonReader,
|
||||
final Jid account,
|
||||
final String passphrase)
|
||||
throws IOException {
|
||||
jsonReader.beginObject();
|
||||
final String firstParameter = jsonReader.nextName();
|
||||
if (!firstParameter.equals("table")) {
|
||||
throw new IllegalStateException("Expected key 'table'");
|
||||
}
|
||||
final String table = jsonReader.nextString();
|
||||
if (!TABLE_ALLOW_LIST.contains(table)) {
|
||||
throw new IOException(String.format("%s is not recognized for import", table));
|
||||
}
|
||||
final ContentValues contentValues = new ContentValues();
|
||||
final String secondParameter = jsonReader.nextName();
|
||||
if (!secondParameter.equals("values")) {
|
||||
throw new IllegalStateException("Expected key 'values'");
|
||||
}
|
||||
jsonReader.beginObject();
|
||||
while (jsonReader.peek() != JsonToken.END_OBJECT) {
|
||||
final String name = jsonReader.nextName();
|
||||
if (jsonReader.peek() == JsonToken.NULL) {
|
||||
jsonReader.nextNull();
|
||||
contentValues.putNull(name);
|
||||
} else if (jsonReader.peek() == JsonToken.NUMBER) {
|
||||
contentValues.put(name, jsonReader.nextLong());
|
||||
} else {
|
||||
contentValues.put(name, jsonReader.nextString());
|
||||
}
|
||||
}
|
||||
jsonReader.endObject();
|
||||
jsonReader.endObject();
|
||||
if (Account.TABLENAME.equals(table)) {
|
||||
final Jid jid =
|
||||
Jid.of(
|
||||
contentValues.getAsString(Account.USERNAME),
|
||||
contentValues.getAsString(Account.SERVER),
|
||||
null);
|
||||
final String password = contentValues.getAsString(Account.PASSWORD);
|
||||
if (jid.equals(account) && passphrase.equals(password)) {
|
||||
Log.d(Config.LOGTAG, "jid and password from backup header had matching row");
|
||||
} else {
|
||||
throw new IOException("jid or password in table did not match backup");
|
||||
}
|
||||
}
|
||||
db.insert(table, null, contentValues);
|
||||
}
|
||||
|
||||
private void notifySuccess() {
|
||||
NotificationCompat.Builder mBuilder = new NotificationCompat.Builder(getBaseContext(), "backup");
|
||||
NotificationCompat.Builder mBuilder =
|
||||
new NotificationCompat.Builder(getBaseContext(), "backup");
|
||||
mBuilder.setContentTitle(getString(R.string.notification_restored_backup_title))
|
||||
.setContentText(getString(R.string.notification_restored_backup_subtitle))
|
||||
.setAutoCancel(true)
|
||||
.setContentIntent(PendingIntent.getActivity(this, 145, new Intent(this, ManageAccountActivity.class), s()
|
||||
? PendingIntent.FLAG_IMMUTABLE | PendingIntent.FLAG_UPDATE_CURRENT
|
||||
: PendingIntent.FLAG_UPDATE_CURRENT))
|
||||
.setContentIntent(
|
||||
PendingIntent.getActivity(
|
||||
this,
|
||||
145,
|
||||
new Intent(this, ManageAccountActivity.class),
|
||||
s()
|
||||
? PendingIntent.FLAG_IMMUTABLE
|
||||
| PendingIntent.FLAG_UPDATE_CURRENT
|
||||
: PendingIntent.FLAG_UPDATE_CURRENT))
|
||||
.setSmallIcon(R.drawable.ic_unarchive_white_24dp);
|
||||
notificationManager.notify(NOTIFICATION_ID, mBuilder.build());
|
||||
}
|
||||
|
@ -391,4 +488,4 @@ public class ImportBackupService extends Service {
|
|||
return ImportBackupService.this;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
@ -19,11 +19,15 @@ import androidx.core.app.NotificationCompat;
|
|||
|
||||
import com.google.common.base.CharMatcher;
|
||||
import com.google.common.base.Strings;
|
||||
import com.google.gson.stream.JsonWriter;
|
||||
|
||||
import java.io.DataOutputStream;
|
||||
import java.io.File;
|
||||
import java.io.FileOutputStream;
|
||||
import java.io.IOException;
|
||||
import java.io.OutputStreamWriter;
|
||||
import java.io.PrintWriter;
|
||||
import java.nio.charset.StandardCharsets;
|
||||
import java.security.NoSuchAlgorithmException;
|
||||
import java.security.SecureRandom;
|
||||
import java.security.spec.InvalidKeySpecException;
|
||||
|
@ -61,16 +65,16 @@ public class ExportBackupService extends Service {
|
|||
public static final String MIME_TYPE = "application/vnd.conversations.backup";
|
||||
|
||||
private static final int NOTIFICATION_ID = 19;
|
||||
private static final int PAGE_SIZE = 20;
|
||||
private static final AtomicBoolean RUNNING = new AtomicBoolean(false);
|
||||
private DatabaseBackend mDatabaseBackend;
|
||||
private List<Account> mAccounts;
|
||||
private NotificationManager notificationManager;
|
||||
|
||||
private static List<Intent> getPossibleFileOpenIntents(final Context context, final String path) {
|
||||
private static List<Intent> getPossibleFileOpenIntents(
|
||||
final Context context, final String path) {
|
||||
|
||||
//http://www.openintents.org/action/android-intent-action-view/file-directory
|
||||
//do not use 'vnd.android.document/directory' since this will trigger system file manager
|
||||
// http://www.openintents.org/action/android-intent-action-view/file-directory
|
||||
// do not use 'vnd.android.document/directory' since this will trigger system file manager
|
||||
final Intent openIntent = new Intent(Intent.ACTION_VIEW);
|
||||
openIntent.addCategory(Intent.CATEGORY_DEFAULT);
|
||||
if (Compatibility.runsAndTargetsTwentyFour(context)) {
|
||||
|
@ -83,134 +87,95 @@ public class ExportBackupService extends Service {
|
|||
final Intent amazeIntent = new Intent(Intent.ACTION_VIEW);
|
||||
amazeIntent.setDataAndType(Uri.parse("com.amaze.filemanager:" + path), "resource/folder");
|
||||
|
||||
//will open a file manager at root and user can navigate themselves
|
||||
// will open a file manager at root and user can navigate themselves
|
||||
final Intent systemFallBack = new Intent(Intent.ACTION_VIEW);
|
||||
systemFallBack.addCategory(Intent.CATEGORY_DEFAULT);
|
||||
systemFallBack.setData(Uri.parse("content://com.android.externalstorage.documents/root/primary"));
|
||||
systemFallBack.setData(
|
||||
Uri.parse("content://com.android.externalstorage.documents/root/primary"));
|
||||
|
||||
return Arrays.asList(openIntent, amazeIntent, systemFallBack);
|
||||
}
|
||||
|
||||
private static void accountExport(final SQLiteDatabase db, final String uuid, final PrintWriter writer) {
|
||||
final StringBuilder builder = new StringBuilder();
|
||||
final Cursor accountCursor = db.query(Account.TABLENAME, null, Account.UUID + "=?", new String[]{uuid}, null, null, null);
|
||||
private static void accountExport(
|
||||
final SQLiteDatabase db, final String uuid, final JsonWriter writer)
|
||||
throws IOException {
|
||||
final Cursor accountCursor =
|
||||
db.query(
|
||||
Account.TABLENAME,
|
||||
null,
|
||||
Account.UUID + "=?",
|
||||
new String[] {uuid},
|
||||
null,
|
||||
null,
|
||||
null);
|
||||
while (accountCursor != null && accountCursor.moveToNext()) {
|
||||
builder.append("INSERT INTO ").append(Account.TABLENAME).append("(");
|
||||
writer.beginObject();
|
||||
writer.name("table");
|
||||
writer.value(Account.TABLENAME);
|
||||
writer.name("values");
|
||||
writer.beginObject();
|
||||
for (int i = 0; i < accountCursor.getColumnCount(); ++i) {
|
||||
if (i != 0) {
|
||||
builder.append(',');
|
||||
}
|
||||
builder.append(accountCursor.getColumnName(i));
|
||||
}
|
||||
builder.append(") VALUES(");
|
||||
for (int i = 0; i < accountCursor.getColumnCount(); ++i) {
|
||||
if (i != 0) {
|
||||
builder.append(',');
|
||||
}
|
||||
final String name = accountCursor.getColumnName(i);
|
||||
writer.name(name);
|
||||
final String value = accountCursor.getString(i);
|
||||
if (value == null || Account.ROSTERVERSION.equals(accountCursor.getColumnName(i))) {
|
||||
builder.append("NULL");
|
||||
} else if (Account.OPTIONS.equals(accountCursor.getColumnName(i)) && value.matches("\\d+")) {
|
||||
writer.nullValue();
|
||||
} else if (Account.OPTIONS.equals(accountCursor.getColumnName(i))
|
||||
&& value.matches("\\d+")) {
|
||||
int intValue = Integer.parseInt(value);
|
||||
intValue |= 1 << Account.OPTION_DISABLED;
|
||||
builder.append(intValue);
|
||||
writer.value(intValue);
|
||||
} else {
|
||||
appendEscapedSQLString(builder, value);
|
||||
writer.value(value);
|
||||
}
|
||||
}
|
||||
builder.append(")");
|
||||
builder.append(';');
|
||||
builder.append('\n');
|
||||
writer.endObject();
|
||||
writer.endObject();
|
||||
}
|
||||
if (accountCursor != null) {
|
||||
accountCursor.close();
|
||||
}
|
||||
writer.append(builder.toString());
|
||||
}
|
||||
|
||||
private static void appendEscapedSQLString(final StringBuilder sb, final String sqlString) {
|
||||
DatabaseUtils.appendEscapedSQLString(sb, CharMatcher.is('\u0000').removeFrom(sqlString));
|
||||
}
|
||||
|
||||
private static void simpleExport(SQLiteDatabase db, String table, String column, String uuid, PrintWriter writer) {
|
||||
final Cursor cursor = db.query(table, null, column + "=?", new String[]{uuid}, null, null, null);
|
||||
private static void simpleExport(
|
||||
final SQLiteDatabase db,
|
||||
final String table,
|
||||
final String column,
|
||||
final String uuid,
|
||||
final JsonWriter writer)
|
||||
throws IOException {
|
||||
final Cursor cursor =
|
||||
db.query(table, null, column + "=?", new String[] {uuid}, null, null, null);
|
||||
while (cursor != null && cursor.moveToNext()) {
|
||||
writer.write(cursorToString(table, cursor, PAGE_SIZE));
|
||||
writer.beginObject();
|
||||
writer.name("table");
|
||||
writer.value(table);
|
||||
writer.name("values");
|
||||
writer.beginObject();
|
||||
for (int i = 0; i < cursor.getColumnCount(); ++i) {
|
||||
final String name = cursor.getColumnName(i);
|
||||
writer.name(name);
|
||||
final String value = cursor.getString(i);
|
||||
writer.value(value);
|
||||
}
|
||||
writer.endObject();
|
||||
writer.endObject();
|
||||
}
|
||||
if (cursor != null) {
|
||||
cursor.close();
|
||||
}
|
||||
}
|
||||
|
||||
public static byte[] getKey(final String password, final byte[] salt) throws InvalidKeySpecException {
|
||||
public static byte[] getKey(final String password, final byte[] salt)
|
||||
throws InvalidKeySpecException {
|
||||
final SecretKeyFactory factory;
|
||||
try {
|
||||
factory = SecretKeyFactory.getInstance("PBKDF2WithHmacSHA1");
|
||||
} catch (NoSuchAlgorithmException e) {
|
||||
throw new IllegalStateException(e);
|
||||
}
|
||||
return factory.generateSecret(new PBEKeySpec(password.toCharArray(), salt, 1024, 128)).getEncoded();
|
||||
}
|
||||
|
||||
private static String cursorToString(final String table, final Cursor cursor, final int max) {
|
||||
return cursorToString(table, cursor, max, false);
|
||||
}
|
||||
|
||||
private static String cursorToString(final String table, final Cursor cursor, int max, boolean ignore) {
|
||||
final boolean identities = SQLiteAxolotlStore.IDENTITIES_TABLENAME.equals(table);
|
||||
StringBuilder builder = new StringBuilder();
|
||||
builder.append("INSERT ");
|
||||
if (ignore) {
|
||||
builder.append("OR IGNORE ");
|
||||
}
|
||||
builder.append("INTO ").append(table).append("(");
|
||||
int skipColumn = -1;
|
||||
for (int i = 0; i < cursor.getColumnCount(); ++i) {
|
||||
final String name = cursor.getColumnName(i);
|
||||
if (identities && SQLiteAxolotlStore.TRUSTED.equals(name)) {
|
||||
skipColumn = i;
|
||||
continue;
|
||||
}
|
||||
if (i != 0) {
|
||||
builder.append(',');
|
||||
}
|
||||
builder.append(name);
|
||||
}
|
||||
builder.append(") VALUES");
|
||||
for (int i = 0; i < max; ++i) {
|
||||
if (i != 0) {
|
||||
builder.append(',');
|
||||
}
|
||||
appendValues(cursor, builder, skipColumn);
|
||||
if (i < max - 1 && !cursor.moveToNext()) {
|
||||
break;
|
||||
}
|
||||
}
|
||||
builder.append(';');
|
||||
builder.append('\n');
|
||||
return builder.toString();
|
||||
}
|
||||
|
||||
private static void appendValues(final Cursor cursor, final StringBuilder builder, final int skipColumn) {
|
||||
builder.append("(");
|
||||
for (int i = 0; i < cursor.getColumnCount(); ++i) {
|
||||
if (i == skipColumn) {
|
||||
continue;
|
||||
}
|
||||
if (i != 0) {
|
||||
builder.append(',');
|
||||
}
|
||||
final String value = cursor.getString(i);
|
||||
if (value == null) {
|
||||
builder.append("NULL");
|
||||
} else if (value.matches("[0-9]+")) {
|
||||
builder.append(value);
|
||||
} else {
|
||||
appendEscapedSQLString(builder, value);
|
||||
}
|
||||
}
|
||||
builder.append(")");
|
||||
|
||||
return factory.generateSecret(new PBEKeySpec(password.toCharArray(), salt, 1024, 128))
|
||||
.getEncoded();
|
||||
}
|
||||
|
||||
@Override
|
||||
|
@ -223,49 +188,69 @@ public class ExportBackupService extends Service {
|
|||
@Override
|
||||
public int onStartCommand(Intent intent, int flags, int startId) {
|
||||
if (RUNNING.compareAndSet(false, true)) {
|
||||
new Thread(() -> {
|
||||
boolean success;
|
||||
List<File> files;
|
||||
try {
|
||||
files = export();
|
||||
success = true;
|
||||
} catch (final Exception e) {
|
||||
Log.d(Config.LOGTAG, "unable to create backup", e);
|
||||
success = false;
|
||||
files = Collections.emptyList();
|
||||
}
|
||||
stopForeground(true);
|
||||
RUNNING.set(false);
|
||||
if (success) {
|
||||
notifySuccess(files);
|
||||
}
|
||||
stopSelf();
|
||||
}).start();
|
||||
new Thread(
|
||||
() -> {
|
||||
boolean success;
|
||||
List<File> files;
|
||||
try {
|
||||
files = export();
|
||||
success = true;
|
||||
} catch (final Exception e) {
|
||||
Log.d(Config.LOGTAG, "unable to create backup", e);
|
||||
success = false;
|
||||
files = Collections.emptyList();
|
||||
}
|
||||
stopForeground(true);
|
||||
RUNNING.set(false);
|
||||
if (success) {
|
||||
notifySuccess(files);
|
||||
}
|
||||
stopSelf();
|
||||
})
|
||||
.start();
|
||||
return START_STICKY;
|
||||
} else {
|
||||
Log.d(Config.LOGTAG, "ExportBackupService. ignoring start command because already running");
|
||||
Log.d(
|
||||
Config.LOGTAG,
|
||||
"ExportBackupService. ignoring start command because already running");
|
||||
}
|
||||
return START_NOT_STICKY;
|
||||
}
|
||||
|
||||
private void messageExport(SQLiteDatabase db, String uuid, PrintWriter writer, Progress progress) {
|
||||
Cursor cursor = db.rawQuery("select messages.* from messages join conversations on conversations.uuid=messages.conversationUuid where conversations.accountUuid=?", new String[]{uuid});
|
||||
private void messageExport(
|
||||
final SQLiteDatabase db,
|
||||
final String uuid,
|
||||
final JsonWriter writer,
|
||||
final Progress progress)
|
||||
throws IOException {
|
||||
Cursor cursor =
|
||||
db.rawQuery(
|
||||
"select messages.* from messages join conversations on conversations.uuid=messages.conversationUuid where conversations.accountUuid=?",
|
||||
new String[] {uuid});
|
||||
int size = cursor != null ? cursor.getCount() : 0;
|
||||
Log.d(Config.LOGTAG, "exporting " + size + " messages for account " + uuid);
|
||||
int i = 0;
|
||||
int p = 0;
|
||||
while (cursor != null && cursor.moveToNext()) {
|
||||
writer.write(cursorToString(Message.TABLENAME, cursor, PAGE_SIZE, false));
|
||||
if (i + PAGE_SIZE > size) {
|
||||
i = size;
|
||||
} else {
|
||||
i += PAGE_SIZE;
|
||||
writer.beginObject();
|
||||
writer.name("table");
|
||||
writer.value(Message.TABLENAME);
|
||||
writer.name("values");
|
||||
writer.beginObject();
|
||||
for (int j = 0; j < cursor.getColumnCount(); ++j) {
|
||||
final String name = cursor.getColumnName(j);
|
||||
writer.name(name);
|
||||
final String value = cursor.getString(j);
|
||||
writer.value(value);
|
||||
}
|
||||
writer.endObject();
|
||||
writer.endObject();
|
||||
final int percentage = i * 100 / size;
|
||||
if (p < percentage) {
|
||||
p = percentage;
|
||||
notificationManager.notify(NOTIFICATION_ID, progress.build(p));
|
||||
}
|
||||
i++;
|
||||
}
|
||||
if (cursor != null) {
|
||||
cursor.close();
|
||||
|
@ -273,7 +258,8 @@ public class ExportBackupService extends Service {
|
|||
}
|
||||
|
||||
private List<File> export() throws Exception {
|
||||
NotificationCompat.Builder mBuilder = new NotificationCompat.Builder(getBaseContext(), "backup");
|
||||
NotificationCompat.Builder mBuilder =
|
||||
new NotificationCompat.Builder(getBaseContext(), "backup");
|
||||
mBuilder.setContentTitle(getString(R.string.notification_create_backup_title))
|
||||
.setSmallIcon(R.drawable.ic_archive_white_24dp)
|
||||
.setProgress(1, 0, false);
|
||||
|
@ -286,17 +272,34 @@ public class ExportBackupService extends Service {
|
|||
for (final Account account : this.mAccounts) {
|
||||
final String password = account.getPassword();
|
||||
if (Strings.nullToEmpty(password).trim().isEmpty()) {
|
||||
Log.d(Config.LOGTAG, String.format("skipping backup for %s because password is empty. unable to encrypt", account.getJid().asBareJid()));
|
||||
Log.d(
|
||||
Config.LOGTAG,
|
||||
String.format(
|
||||
"skipping backup for %s because password is empty. unable to encrypt",
|
||||
account.getJid().asBareJid()));
|
||||
continue;
|
||||
}
|
||||
Log.d(Config.LOGTAG, String.format("exporting data for account %s (%s)", account.getJid().asBareJid(), account.getUuid()));
|
||||
Log.d(
|
||||
Config.LOGTAG,
|
||||
String.format(
|
||||
"exporting data for account %s (%s)",
|
||||
account.getJid().asBareJid(), account.getUuid()));
|
||||
final byte[] IV = new byte[12];
|
||||
final byte[] salt = new byte[16];
|
||||
secureRandom.nextBytes(IV);
|
||||
secureRandom.nextBytes(salt);
|
||||
final BackupFileHeader backupFileHeader = new BackupFileHeader(getString(R.string.app_name), account.getJid(), System.currentTimeMillis(), IV, salt);
|
||||
final BackupFileHeader backupFileHeader =
|
||||
new BackupFileHeader(
|
||||
getString(R.string.app_name),
|
||||
account.getJid(),
|
||||
System.currentTimeMillis(),
|
||||
IV,
|
||||
salt);
|
||||
final Progress progress = new Progress(mBuilder, max, count);
|
||||
final File file = new File(FileBackend.getBackupDirectory(this), account.getJid().asBareJid().toEscapedString() + ".ceb");
|
||||
final File file =
|
||||
new File(
|
||||
FileBackend.getBackupDirectory(this),
|
||||
account.getJid().asBareJid().toEscapedString() + ".ceb");
|
||||
files.add(file);
|
||||
final File directory = file.getParentFile();
|
||||
if (directory != null && directory.mkdirs()) {
|
||||
|
@ -307,25 +310,38 @@ public class ExportBackupService extends Service {
|
|||
backupFileHeader.write(dataOutputStream);
|
||||
dataOutputStream.flush();
|
||||
|
||||
final Cipher cipher = Compatibility.twentyEight() ? Cipher.getInstance(CIPHERMODE) : Cipher.getInstance(CIPHERMODE, PROVIDER);
|
||||
final Cipher cipher =
|
||||
Compatibility.twentyEight()
|
||||
? Cipher.getInstance(CIPHERMODE)
|
||||
: Cipher.getInstance(CIPHERMODE, PROVIDER);
|
||||
final byte[] key = getKey(password, salt);
|
||||
SecretKeySpec keySpec = new SecretKeySpec(key, KEYTYPE);
|
||||
IvParameterSpec ivSpec = new IvParameterSpec(IV);
|
||||
cipher.init(Cipher.ENCRYPT_MODE, keySpec, ivSpec);
|
||||
CipherOutputStream cipherOutputStream = new CipherOutputStream(fileOutputStream, cipher);
|
||||
CipherOutputStream cipherOutputStream =
|
||||
new CipherOutputStream(fileOutputStream, cipher);
|
||||
|
||||
GZIPOutputStream gzipOutputStream = new GZIPOutputStream(cipherOutputStream);
|
||||
PrintWriter writer = new PrintWriter(gzipOutputStream);
|
||||
SQLiteDatabase db = this.mDatabaseBackend.getReadableDatabase();
|
||||
final GZIPOutputStream gzipOutputStream = new GZIPOutputStream(cipherOutputStream);
|
||||
final JsonWriter jsonWriter =
|
||||
new JsonWriter(
|
||||
new OutputStreamWriter(gzipOutputStream, StandardCharsets.UTF_8));
|
||||
jsonWriter.beginArray();
|
||||
final SQLiteDatabase db = this.mDatabaseBackend.getReadableDatabase();
|
||||
final String uuid = account.getUuid();
|
||||
accountExport(db, uuid, writer);
|
||||
simpleExport(db, Conversation.TABLENAME, Conversation.ACCOUNT, uuid, writer);
|
||||
messageExport(db, uuid, writer, progress);
|
||||
for (String table : Arrays.asList(SQLiteAxolotlStore.PREKEY_TABLENAME, SQLiteAxolotlStore.SIGNED_PREKEY_TABLENAME, SQLiteAxolotlStore.SESSION_TABLENAME, SQLiteAxolotlStore.IDENTITIES_TABLENAME)) {
|
||||
simpleExport(db, table, SQLiteAxolotlStore.ACCOUNT, uuid, writer);
|
||||
accountExport(db, uuid, jsonWriter);
|
||||
simpleExport(db, Conversation.TABLENAME, Conversation.ACCOUNT, uuid, jsonWriter);
|
||||
messageExport(db, uuid, jsonWriter, progress);
|
||||
for (final String table :
|
||||
Arrays.asList(
|
||||
SQLiteAxolotlStore.PREKEY_TABLENAME,
|
||||
SQLiteAxolotlStore.SIGNED_PREKEY_TABLENAME,
|
||||
SQLiteAxolotlStore.SESSION_TABLENAME,
|
||||
SQLiteAxolotlStore.IDENTITIES_TABLENAME)) {
|
||||
simpleExport(db, table, SQLiteAxolotlStore.ACCOUNT, uuid, jsonWriter);
|
||||
}
|
||||
writer.flush();
|
||||
writer.close();
|
||||
jsonWriter.endArray();
|
||||
jsonWriter.flush();
|
||||
jsonWriter.close();
|
||||
mediaScannerScanFile(file);
|
||||
Log.d(Config.LOGTAG, "written backup to " + file.getAbsoluteFile());
|
||||
count++;
|
||||
|
@ -346,9 +362,15 @@ public class ExportBackupService extends Service {
|
|||
|
||||
for (final Intent intent : getPossibleFileOpenIntents(this, path)) {
|
||||
if (intent.resolveActivityInfo(getPackageManager(), 0) != null) {
|
||||
openFolderIntent = PendingIntent.getActivity(this, 189, intent, s()
|
||||
? PendingIntent.FLAG_IMMUTABLE | PendingIntent.FLAG_UPDATE_CURRENT
|
||||
: PendingIntent.FLAG_UPDATE_CURRENT);
|
||||
openFolderIntent =
|
||||
PendingIntent.getActivity(
|
||||
this,
|
||||
189,
|
||||
intent,
|
||||
s()
|
||||
? PendingIntent.FLAG_IMMUTABLE
|
||||
| PendingIntent.FLAG_UPDATE_CURRENT
|
||||
: PendingIntent.FLAG_UPDATE_CURRENT);
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
@ -363,22 +385,39 @@ public class ExportBackupService extends Service {
|
|||
intent.putParcelableArrayListExtra(Intent.EXTRA_STREAM, uris);
|
||||
intent.addFlags(Intent.FLAG_GRANT_READ_URI_PERMISSION);
|
||||
intent.setType(MIME_TYPE);
|
||||
final Intent chooser = Intent.createChooser(intent, getString(R.string.share_backup_files));
|
||||
shareFilesIntent = PendingIntent.getActivity(this, 190, chooser, s()
|
||||
? PendingIntent.FLAG_IMMUTABLE | PendingIntent.FLAG_UPDATE_CURRENT
|
||||
: PendingIntent.FLAG_UPDATE_CURRENT);
|
||||
final Intent chooser =
|
||||
Intent.createChooser(intent, getString(R.string.share_backup_files));
|
||||
shareFilesIntent =
|
||||
PendingIntent.getActivity(
|
||||
this,
|
||||
190,
|
||||
chooser,
|
||||
s()
|
||||
? PendingIntent.FLAG_IMMUTABLE
|
||||
| PendingIntent.FLAG_UPDATE_CURRENT
|
||||
: PendingIntent.FLAG_UPDATE_CURRENT);
|
||||
}
|
||||
|
||||
NotificationCompat.Builder mBuilder = new NotificationCompat.Builder(getBaseContext(), "backup");
|
||||
NotificationCompat.Builder mBuilder =
|
||||
new NotificationCompat.Builder(getBaseContext(), "backup");
|
||||
mBuilder.setContentTitle(getString(R.string.notification_backup_created_title))
|
||||
.setContentText(getString(R.string.notification_backup_created_subtitle, path))
|
||||
.setStyle(new NotificationCompat.BigTextStyle().bigText(getString(R.string.notification_backup_created_subtitle, FileBackend.getBackupDirectory(this).getAbsolutePath())))
|
||||
.setStyle(
|
||||
new NotificationCompat.BigTextStyle()
|
||||
.bigText(
|
||||
getString(
|
||||
R.string.notification_backup_created_subtitle,
|
||||
FileBackend.getBackupDirectory(this)
|
||||
.getAbsolutePath())))
|
||||
.setAutoCancel(true)
|
||||
.setContentIntent(openFolderIntent)
|
||||
.setSmallIcon(R.drawable.ic_archive_white_24dp);
|
||||
|
||||
if (shareFilesIntent != null) {
|
||||
mBuilder.addAction(R.drawable.ic_share_white_24dp, getString(R.string.share_backup_files), shareFilesIntent);
|
||||
mBuilder.addAction(
|
||||
R.drawable.ic_share_white_24dp,
|
||||
getString(R.string.share_backup_files),
|
||||
shareFilesIntent);
|
||||
}
|
||||
|
||||
notificationManager.notify(NOTIFICATION_ID, mBuilder.build());
|
||||
|
|
|
@ -1,5 +1,7 @@
|
|||
package eu.siacs.conversations.utils;
|
||||
|
||||
import androidx.annotation.NonNull;
|
||||
|
||||
import java.io.DataInputStream;
|
||||
import java.io.DataOutputStream;
|
||||
import java.io.IOException;
|
||||
|
@ -8,7 +10,7 @@ import eu.siacs.conversations.xmpp.Jid;
|
|||
|
||||
public class BackupFileHeader {
|
||||
|
||||
private static final int VERSION = 1;
|
||||
private static final int VERSION = 2;
|
||||
|
||||
private final String app;
|
||||
private final Jid jid;
|
||||
|
@ -17,6 +19,7 @@ public class BackupFileHeader {
|
|||
private final byte[] salt;
|
||||
|
||||
|
||||
@NonNull
|
||||
@Override
|
||||
public String toString() {
|
||||
return "BackupFileHeader{" +
|
||||
|
@ -47,8 +50,8 @@ public class BackupFileHeader {
|
|||
|
||||
public static BackupFileHeader read(DataInputStream inputStream) throws IOException {
|
||||
final int version = inputStream.readInt();
|
||||
if (version > VERSION) {
|
||||
throw new IllegalArgumentException("Backup File version was " + version + " but app only supports up to version " + VERSION);
|
||||
if (version != VERSION) {
|
||||
throw new IllegalArgumentException("Backup File version was " + version + " but app only supports version " + VERSION);
|
||||
}
|
||||
String app = inputStream.readUTF();
|
||||
String jid = inputStream.readUTF();
|
||||
|
|
Loading…
Reference in a new issue