Add a unique ID to the device folder name to avoid collisions

when using several devices of the same model with the same account
This commit is contained in:
Torsten Grote 2019-08-01 10:34:31 +02:00
parent 2ce625ac87
commit 1ee443a3d8
No known key found for this signature in database
GPG key ID: 3E5F77D92CF891FF
20 changed files with 108 additions and 36 deletions

View file

@ -1,5 +1,13 @@
LOCAL_PATH := $(call my-dir) LOCAL_PATH := $(call my-dir)
include $(CLEAR_VARS)
LOCAL_MODULE := default-permissions_com.stevesoltys.backup.xml
LOCAL_MODULE_CLASS := ETC
LOCAL_MODULE_TAGS := optional
LOCAL_MODULE_PATH := $(TARGET_OUT_ETC)/default-permissions
LOCAL_SRC_FILES := $(LOCAL_MODULE)
include $(BUILD_PREBUILT)
include $(CLEAR_VARS) include $(CLEAR_VARS)
LOCAL_MODULE := permissions_com.stevesoltys.backup.xml LOCAL_MODULE := permissions_com.stevesoltys.backup.xml
LOCAL_MODULE_CLASS := ETC LOCAL_MODULE_CLASS := ETC

View file

@ -14,7 +14,9 @@
android:name="android.permission.BACKUP" android:name="android.permission.BACKUP"
tools:ignore="ProtectedPermissions" /> tools:ignore="ProtectedPermissions" />
<uses-permission android:name="android.permission.RECEIVE_BOOT_COMPLETED" /> <!-- This is needed to retrieve the serial number of the device,
so we can store the backups for each device in a unique location -->
<uses-permission android:name="android.permission.READ_PHONE_STATE" />
<application <application
android:name=".Backup" android:name=".Backup"
@ -35,7 +37,7 @@
<activity <activity
android:name="com.stevesoltys.backup.activity.MainActivity" android:name="com.stevesoltys.backup.activity.MainActivity"
android:label="@string/app_name"/> android:label="@string/app_name" />
<activity <activity
android:name="com.stevesoltys.backup.activity.backup.CreateBackupActivity" android:name="com.stevesoltys.backup.activity.backup.CreateBackupActivity"

View file

@ -1,14 +1,27 @@
package com.stevesoltys.backup package com.stevesoltys.backup
import android.Manifest
import android.Manifest.permission.READ_PHONE_STATE
import android.app.Application import android.app.Application
import android.app.backup.IBackupManager import android.app.backup.IBackupManager
import android.content.Context.BACKUP_SERVICE import android.content.Context.BACKUP_SERVICE
import android.content.pm.PackageManager.PERMISSION_GRANTED
import android.os.Build
import android.os.ServiceManager.getService import android.os.ServiceManager.getService
import android.util.Log
import com.stevesoltys.backup.crypto.KeyManager import com.stevesoltys.backup.crypto.KeyManager
import com.stevesoltys.backup.crypto.KeyManagerImpl import com.stevesoltys.backup.crypto.KeyManagerImpl
import com.stevesoltys.backup.settings.getDeviceName
import com.stevesoltys.backup.settings.setDeviceName
import io.github.novacrypto.hashing.Sha256
import io.github.novacrypto.hashing.Sha256.sha256
import io.github.novacrypto.hashing.Sha256.sha256Twice
import java.lang.AssertionError
const val JOB_ID_BACKGROUND_BACKUP = 1 const val JOB_ID_BACKGROUND_BACKUP = 1
private val TAG = Backup::class.java.simpleName
/** /**
* @author Steve Soltys * @author Steve Soltys
* @author Torsten Grote * @author Torsten Grote
@ -24,4 +37,25 @@ class Backup : Application() {
} }
} }
override fun onCreate() {
super.onCreate()
storeDeviceName()
}
private fun storeDeviceName() {
if (getDeviceName(this) != null) return // we already have a stored device name
val permission = READ_PHONE_STATE
if (checkSelfPermission(permission) != PERMISSION_GRANTED) {
throw AssertionError("You need to grant the $permission permission.")
}
// TODO consider just using a hash for the entire device name and store metadata in an encrypted file
val id = sha256Twice(Build.getSerial().toByteArray(Utf8))
.copyOfRange(0, 8)
.encodeBase64()
val name = "${Build.MANUFACTURER} ${Build.MODEL} ($id)"
Log.i(TAG, "Initialized device name to: $name")
setDeviceName(this, name)
}
} }

View file

@ -0,0 +1,18 @@
package com.stevesoltys.backup
import java.nio.charset.Charset
import java.util.*
val Utf8: Charset = Charset.forName("UTF-8")
fun ByteArray.encodeBase64(): String {
return Base64.getUrlEncoder().withoutPadding().encodeToString(this)
}
fun String.encodeBase64(): String {
return toByteArray(Utf8).encodeBase64()
}
fun String.decodeBase64(): String {
return String(Base64.getUrlDecoder().decode(this))
}

View file

@ -1,7 +1,5 @@
package com.stevesoltys.backup.header package com.stevesoltys.backup.header
import java.nio.charset.Charset
internal const val VERSION: Byte = 0 internal const val VERSION: Byte = 0
internal const val MAX_PACKAGE_LENGTH_SIZE = 255 internal const val MAX_PACKAGE_LENGTH_SIZE = 255
internal const val MAX_KEY_LENGTH_SIZE = MAX_PACKAGE_LENGTH_SIZE internal const val MAX_KEY_LENGTH_SIZE = MAX_PACKAGE_LENGTH_SIZE
@ -39,5 +37,3 @@ class SegmentHeader(
check(nonce.size == IV_SIZE) check(nonce.size == IV_SIZE)
} }
} }
val Utf8: Charset = Charset.forName("UTF-8")

View file

@ -1,5 +1,6 @@
package com.stevesoltys.backup.header package com.stevesoltys.backup.header
import com.stevesoltys.backup.Utf8
import java.io.EOFException import java.io.EOFException
import java.io.IOException import java.io.IOException
import java.io.InputStream import java.io.InputStream

View file

@ -1,5 +1,6 @@
package com.stevesoltys.backup.header package com.stevesoltys.backup.header
import com.stevesoltys.backup.Utf8
import java.io.IOException import java.io.IOException
import java.io.OutputStream import java.io.OutputStream
import java.nio.ByteBuffer import java.nio.ByteBuffer

View file

@ -5,8 +5,8 @@ import android.net.Uri
import android.preference.PreferenceManager.getDefaultSharedPreferences import android.preference.PreferenceManager.getDefaultSharedPreferences
private const val PREF_KEY_BACKUP_URI = "backupUri" private const val PREF_KEY_BACKUP_URI = "backupUri"
private const val PREF_KEY_DEVICE_NAME = "deviceName"
private const val PREF_KEY_BACKUP_PASSWORD = "backupLegacyPassword" private const val PREF_KEY_BACKUP_PASSWORD = "backupLegacyPassword"
private const val PREF_KEY_BACKUPS_SCHEDULED = "backupsScheduled"
fun setBackupFolderUri(context: Context, uri: Uri) { fun setBackupFolderUri(context: Context, uri: Uri) {
getDefaultSharedPreferences(context) getDefaultSharedPreferences(context)
@ -21,6 +21,17 @@ fun getBackupFolderUri(context: Context): Uri? {
return Uri.parse(uriStr) return Uri.parse(uriStr)
} }
fun setDeviceName(context: Context, name: String) {
getDefaultSharedPreferences(context)
.edit()
.putString(PREF_KEY_DEVICE_NAME, name)
.apply()
}
fun getDeviceName(context: Context): String? {
return getDefaultSharedPreferences(context).getString(PREF_KEY_DEVICE_NAME, null)
}
/** /**
* This is insecure and not supposed to be part of a release, * This is insecure and not supposed to be part of a release,
* but rather an intermediate step towards a generated passphrase. * but rather an intermediate step towards a generated passphrase.
@ -38,6 +49,7 @@ fun getBackupPassword(context: Context): String? {
return getDefaultSharedPreferences(context).getString(PREF_KEY_BACKUP_PASSWORD, null) return getDefaultSharedPreferences(context).getString(PREF_KEY_BACKUP_PASSWORD, null)
} }
@Deprecated("Our own scheduling will be removed")
fun setBackupsScheduled(context: Context) { fun setBackupsScheduled(context: Context) {
getDefaultSharedPreferences(context) getDefaultSharedPreferences(context)
.edit() .edit()
@ -45,6 +57,7 @@ fun setBackupsScheduled(context: Context) {
.apply() .apply()
} }
@Deprecated("Our own scheduling will be removed")
fun areBackupsScheduled(context: Context): Boolean { fun areBackupsScheduled(context: Context): Boolean {
return getDefaultSharedPreferences(context).getBoolean(PREF_KEY_BACKUPS_SCHEDULED, false) return getDefaultSharedPreferences(context).getBoolean(PREF_KEY_BACKUPS_SCHEDULED, false)
} }

View file

@ -1,13 +1,13 @@
package com.stevesoltys.backup.transport package com.stevesoltys.backup.transport
import android.content.Context import android.content.Context
import android.os.Build
import com.stevesoltys.backup.Backup import com.stevesoltys.backup.Backup
import com.stevesoltys.backup.crypto.CipherFactoryImpl import com.stevesoltys.backup.crypto.CipherFactoryImpl
import com.stevesoltys.backup.crypto.CryptoImpl import com.stevesoltys.backup.crypto.CryptoImpl
import com.stevesoltys.backup.header.HeaderReaderImpl import com.stevesoltys.backup.header.HeaderReaderImpl
import com.stevesoltys.backup.header.HeaderWriterImpl import com.stevesoltys.backup.header.HeaderWriterImpl
import com.stevesoltys.backup.settings.getBackupFolderUri import com.stevesoltys.backup.settings.getBackupFolderUri
import com.stevesoltys.backup.settings.getDeviceName
import com.stevesoltys.backup.transport.backup.BackupCoordinator import com.stevesoltys.backup.transport.backup.BackupCoordinator
import com.stevesoltys.backup.transport.backup.FullBackup import com.stevesoltys.backup.transport.backup.FullBackup
import com.stevesoltys.backup.transport.backup.InputFactory import com.stevesoltys.backup.transport.backup.InputFactory
@ -24,7 +24,7 @@ class PluginManager(context: Context) {
// We can think about using an injection framework such as Dagger to simplify this. // We can think about using an injection framework such as Dagger to simplify this.
private val storage = DocumentsStorage(context, getBackupFolderUri(context), getDeviceName()) private val storage = DocumentsStorage(context, getBackupFolderUri(context), getDeviceName(context)!!)
private val headerWriter = HeaderWriterImpl() private val headerWriter = HeaderWriterImpl()
private val headerReader = HeaderReaderImpl() private val headerReader = HeaderReaderImpl()
@ -47,10 +47,4 @@ class PluginManager(context: Context) {
internal val restoreCoordinator = RestoreCoordinator(restorePlugin, kvRestore, fullRestore) internal val restoreCoordinator = RestoreCoordinator(restorePlugin, kvRestore, fullRestore)
private fun getDeviceName(): String {
// TODO add device specific unique ID to the end
return "${Build.MANUFACTURER} ${Build.MODEL}"
}
} }

View file

@ -8,6 +8,7 @@ interface FullBackupPlugin {
fun getQuota(): Long fun getQuota(): Long
// TODO consider using a salted hash for the package name to not leak it to the storage server
@Throws(IOException::class) @Throws(IOException::class)
fun getOutputStream(targetPackage: PackageInfo): OutputStream fun getOutputStream(targetPackage: PackageInfo): OutputStream

View file

@ -5,12 +5,11 @@ import android.content.pm.PackageInfo
import android.os.ParcelFileDescriptor import android.os.ParcelFileDescriptor
import android.util.Log import android.util.Log
import com.stevesoltys.backup.crypto.Crypto import com.stevesoltys.backup.crypto.Crypto
import com.stevesoltys.backup.encodeBase64
import com.stevesoltys.backup.header.HeaderWriter import com.stevesoltys.backup.header.HeaderWriter
import com.stevesoltys.backup.header.Utf8
import com.stevesoltys.backup.header.VersionHeader import com.stevesoltys.backup.header.VersionHeader
import libcore.io.IoUtils.closeQuietly import libcore.io.IoUtils.closeQuietly
import java.io.IOException import java.io.IOException
import java.util.Base64.getUrlEncoder
class KVBackupState(internal val packageName: String) class KVBackupState(internal val packageName: String)
@ -139,7 +138,7 @@ class KVBackup(
} }
// encode key // encode key
val key = changeSet.key val key = changeSet.key
val base64Key = getUrlEncoder().withoutPadding().encodeToString(key.toByteArray(Utf8)) val base64Key = key.encodeBase64()
val dataSize = changeSet.dataSize val dataSize = changeSet.dataSize
// read and encrypt value // read and encrypt value

View file

@ -11,6 +11,7 @@ interface KVBackupPlugin {
*/ */
fun getQuota(): Long fun getQuota(): Long
// TODO consider using a salted hash for the package name (and key) to not leak it to the storage server
/** /**
* Return true if there are records stored for the given package. * Return true if there are records stored for the given package.
*/ */

View file

@ -6,8 +6,6 @@ import com.stevesoltys.backup.transport.backup.FullBackupPlugin
import com.stevesoltys.backup.transport.backup.KVBackupPlugin import com.stevesoltys.backup.transport.backup.KVBackupPlugin
import java.io.IOException import java.io.IOException
private const val NO_MEDIA = ".nomedia"
class DocumentsProviderBackupPlugin( class DocumentsProviderBackupPlugin(
private val storage: DocumentsStorage, private val storage: DocumentsStorage,
packageManager: PackageManager) : BackupPlugin { packageManager: PackageManager) : BackupPlugin {
@ -25,9 +23,6 @@ class DocumentsProviderBackupPlugin(
// get or create root backup dir // get or create root backup dir
val rootDir = storage.rootBackupDir ?: throw IOException() val rootDir = storage.rootBackupDir ?: throw IOException()
// create .nomedia file to prevent Android's MediaScanner from trying to index the backup
rootDir.createOrGetFile(NO_MEDIA)
// create backup folders // create backup folders
val kvDir = storage.defaultKvBackupDir val kvDir = storage.defaultKvBackupDir
val fullDir = storage.defaultFullBackupDir val fullDir = storage.defaultFullBackupDir

View file

@ -13,6 +13,7 @@ import java.io.OutputStream
const val DIRECTORY_FULL_BACKUP = "full" const val DIRECTORY_FULL_BACKUP = "full"
const val DIRECTORY_KEY_VALUE_BACKUP = "kv" const val DIRECTORY_KEY_VALUE_BACKUP = "kv"
private const val ROOT_DIR_NAME = ".AndroidBackup" private const val ROOT_DIR_NAME = ".AndroidBackup"
private const val NO_MEDIA = ".nomedia"
private const val MIME_TYPE = "application/octet-stream" private const val MIME_TYPE = "application/octet-stream"
private val TAG = DocumentsStorage::class.java.simpleName private val TAG = DocumentsStorage::class.java.simpleName
@ -25,7 +26,10 @@ class DocumentsStorage(context: Context, parentFolder: Uri?, deviceName: String)
val folderUri = parentFolder ?: return@lazy null val folderUri = parentFolder ?: return@lazy null
val parent = DocumentFile.fromTreeUri(context, folderUri) ?: throw AssertionError() val parent = DocumentFile.fromTreeUri(context, folderUri) ?: throw AssertionError()
try { try {
parent.createOrGetDirectory(ROOT_DIR_NAME) val rootDir = parent.createOrGetDirectory(ROOT_DIR_NAME)
// create .nomedia file to prevent Android's MediaScanner from trying to index the backup
rootDir.createOrGetFile(NO_MEDIA)
rootDir
} catch (e: IOException) { } catch (e: IOException) {
Log.e(TAG, "Error creating root backup dir.", e) Log.e(TAG, "Error creating root backup dir.", e)
null null

View file

@ -7,12 +7,12 @@ import android.content.pm.PackageInfo
import android.os.ParcelFileDescriptor import android.os.ParcelFileDescriptor
import android.util.Log import android.util.Log
import com.stevesoltys.backup.crypto.Crypto import com.stevesoltys.backup.crypto.Crypto
import com.stevesoltys.backup.decodeBase64
import com.stevesoltys.backup.header.HeaderReader import com.stevesoltys.backup.header.HeaderReader
import com.stevesoltys.backup.header.UnsupportedVersionException import com.stevesoltys.backup.header.UnsupportedVersionException
import libcore.io.IoUtils.closeQuietly import libcore.io.IoUtils.closeQuietly
import java.io.IOException import java.io.IOException
import java.util.* import java.util.*
import java.util.Base64.getUrlDecoder
private class KVRestoreState( private class KVRestoreState(
internal val token: Long, internal val token: Long,
@ -132,7 +132,7 @@ internal class KVRestore(
} }
private class DecodedKey(internal val base64Key: String) : Comparable<DecodedKey> { private class DecodedKey(internal val base64Key: String) : Comparable<DecodedKey> {
internal val key = String(getUrlDecoder().decode(base64Key)) internal val key = base64Key.decodeBase64()
override fun compareTo(other: DecodedKey) = key.compareTo(other.key) override fun compareTo(other: DecodedKey) = key.compareTo(other.key)
} }

View file

@ -1,5 +1,6 @@
package com.stevesoltys.backup.header package com.stevesoltys.backup.header
import com.stevesoltys.backup.Utf8
import com.stevesoltys.backup.assertContains import com.stevesoltys.backup.assertContains
import com.stevesoltys.backup.getRandomString import com.stevesoltys.backup.getRandomString
import org.junit.jupiter.api.Assertions.* import org.junit.jupiter.api.Assertions.*

View file

@ -10,17 +10,16 @@ import android.os.ParcelFileDescriptor
import com.stevesoltys.backup.crypto.CipherFactoryImpl import com.stevesoltys.backup.crypto.CipherFactoryImpl
import com.stevesoltys.backup.crypto.CryptoImpl import com.stevesoltys.backup.crypto.CryptoImpl
import com.stevesoltys.backup.crypto.KeyManagerTestImpl import com.stevesoltys.backup.crypto.KeyManagerTestImpl
import com.stevesoltys.backup.transport.backup.* import com.stevesoltys.backup.encodeBase64
import com.stevesoltys.backup.header.HeaderReaderImpl import com.stevesoltys.backup.header.HeaderReaderImpl
import com.stevesoltys.backup.header.HeaderWriterImpl import com.stevesoltys.backup.header.HeaderWriterImpl
import com.stevesoltys.backup.header.Utf8 import com.stevesoltys.backup.transport.backup.*
import com.stevesoltys.backup.transport.restore.* import com.stevesoltys.backup.transport.restore.*
import io.mockk.* import io.mockk.*
import org.junit.jupiter.api.Assertions.* import org.junit.jupiter.api.Assertions.*
import org.junit.jupiter.api.Test import org.junit.jupiter.api.Test
import java.io.ByteArrayInputStream import java.io.ByteArrayInputStream
import java.io.ByteArrayOutputStream import java.io.ByteArrayOutputStream
import java.util.*
import kotlin.random.Random import kotlin.random.Random
internal class CoordinatorIntegrationTest : TransportTest() { internal class CoordinatorIntegrationTest : TransportTest() {
@ -53,9 +52,9 @@ internal class CoordinatorIntegrationTest : TransportTest() {
private val appData = ByteArray(42).apply { Random.nextBytes(this) } private val appData = ByteArray(42).apply { Random.nextBytes(this) }
private val appData2 = ByteArray(1337).apply { Random.nextBytes(this) } private val appData2 = ByteArray(1337).apply { Random.nextBytes(this) }
private val key = "RestoreKey" private val key = "RestoreKey"
private val key64 = Base64.getUrlEncoder().withoutPadding().encodeToString(key.toByteArray(Utf8)) private val key64 = key.encodeBase64()
private val key2 = "RestoreKey2" private val key2 = "RestoreKey2"
private val key264 = Base64.getUrlEncoder().withoutPadding().encodeToString(key2.toByteArray(Utf8)) private val key264 = key2.encodeBase64()
init { init {
every { backupPlugin.kvBackupPlugin } returns kvBackupPlugin every { backupPlugin.kvBackupPlugin } returns kvBackupPlugin

View file

@ -2,9 +2,9 @@ package com.stevesoltys.backup.transport.backup
import android.app.backup.BackupDataInput import android.app.backup.BackupDataInput
import android.app.backup.BackupTransport.* import android.app.backup.BackupTransport.*
import com.stevesoltys.backup.Utf8
import com.stevesoltys.backup.getRandomString import com.stevesoltys.backup.getRandomString
import com.stevesoltys.backup.header.MAX_KEY_LENGTH_SIZE import com.stevesoltys.backup.header.MAX_KEY_LENGTH_SIZE
import com.stevesoltys.backup.header.Utf8
import com.stevesoltys.backup.header.VersionHeader import com.stevesoltys.backup.header.VersionHeader
import io.mockk.Runs import io.mockk.Runs
import io.mockk.every import io.mockk.every

View file

@ -3,9 +3,9 @@ package com.stevesoltys.backup.transport.restore
import android.app.backup.BackupDataOutput import android.app.backup.BackupDataOutput
import android.app.backup.BackupTransport.TRANSPORT_ERROR import android.app.backup.BackupTransport.TRANSPORT_ERROR
import android.app.backup.BackupTransport.TRANSPORT_OK import android.app.backup.BackupTransport.TRANSPORT_OK
import com.stevesoltys.backup.encodeBase64
import com.stevesoltys.backup.getRandomByteArray import com.stevesoltys.backup.getRandomByteArray
import com.stevesoltys.backup.header.UnsupportedVersionException import com.stevesoltys.backup.header.UnsupportedVersionException
import com.stevesoltys.backup.header.Utf8
import com.stevesoltys.backup.header.VERSION import com.stevesoltys.backup.header.VERSION
import com.stevesoltys.backup.header.VersionHeader import com.stevesoltys.backup.header.VersionHeader
import io.mockk.* import io.mockk.*
@ -14,7 +14,6 @@ import org.junit.jupiter.api.Assertions.assertThrows
import org.junit.jupiter.api.Test import org.junit.jupiter.api.Test
import java.io.IOException import java.io.IOException
import java.io.InputStream import java.io.InputStream
import java.util.Base64.getUrlEncoder
import kotlin.random.Random import kotlin.random.Random
internal class KVRestoreTest : RestoreTest() { internal class KVRestoreTest : RestoreTest() {
@ -24,10 +23,10 @@ internal class KVRestoreTest : RestoreTest() {
private val restore = KVRestore(plugin, outputFactory, headerReader, crypto) private val restore = KVRestore(plugin, outputFactory, headerReader, crypto)
private val key = "Restore Key" private val key = "Restore Key"
private val key64 = getUrlEncoder().withoutPadding().encodeToString(key.toByteArray(Utf8)) private val key64 = key.encodeBase64()
private val versionHeader = VersionHeader(VERSION, packageInfo.packageName, key) private val versionHeader = VersionHeader(VERSION, packageInfo.packageName, key)
private val key2 = "Restore Key2" private val key2 = "Restore Key2"
private val key264 = getUrlEncoder().withoutPadding().encodeToString(key2.toByteArray(Utf8)) private val key264 = key2.encodeBase64()
private val versionHeader2 = VersionHeader(VERSION, packageInfo.packageName, key2) private val versionHeader2 = VersionHeader(VERSION, packageInfo.packageName, key2)
@Test @Test

View file

@ -0,0 +1,6 @@
<?xml version="1.0" encoding="utf-8" standalone="yes"?>
<exceptions>
<exception package="com.stevesoltys.backup">
<permission name="android.permission.READ_PHONE_STATE" fixed="true"/>
</exception>
</exceptions>