diff --git a/app/src/main/java/com/stevesoltys/backup/metadata/Metadata.kt b/app/src/main/java/com/stevesoltys/backup/metadata/Metadata.kt new file mode 100644 index 00000000..47a2fb23 --- /dev/null +++ b/app/src/main/java/com/stevesoltys/backup/metadata/Metadata.kt @@ -0,0 +1,20 @@ +package com.stevesoltys.backup.metadata + +import android.os.Build +import android.os.Build.VERSION.SDK_INT +import com.stevesoltys.backup.header.VERSION +import com.stevesoltys.backup.transport.DEFAULT_RESTORE_SET_TOKEN + +data class BackupMetadata( + internal val version: Byte = VERSION, + internal val token: Long = DEFAULT_RESTORE_SET_TOKEN, + internal val androidVersion: Int = SDK_INT, + internal val deviceName: String = "${Build.MANUFACTURER} ${Build.MODEL}" +) + +internal const val JSON_VERSION = "version" +internal const val JSON_TOKEN = "token" +internal const val JSON_ANDROID_VERSION = "androidVersion" +internal const val JSON_DEVICE_NAME = "deviceName" + +class FormatException(cause: Throwable) : Exception(cause) diff --git a/app/src/main/java/com/stevesoltys/backup/metadata/MetadataDecoder.kt b/app/src/main/java/com/stevesoltys/backup/metadata/MetadataDecoder.kt new file mode 100644 index 00000000..ae54c1c9 --- /dev/null +++ b/app/src/main/java/com/stevesoltys/backup/metadata/MetadataDecoder.kt @@ -0,0 +1,44 @@ +package com.stevesoltys.backup.metadata + +import com.stevesoltys.backup.Utf8 +import org.json.JSONException +import org.json.JSONObject + +interface MetadataDecoder { + + @Throws(FormatException::class, SecurityException::class) + fun decode(bytes: ByteArray, expectedVersion: Byte, expectedToken: Long): BackupMetadata + +} + +class MetadataDecoderImpl : MetadataDecoder { + + @Throws(FormatException::class, SecurityException::class) + override fun decode(bytes: ByteArray, expectedVersion: Byte, expectedToken: Long): BackupMetadata { + // NOTE: We don't do extensive validation of the parsed input here, + // because it was encrypted with authentication, so we should be able to trust it. + // + // However, it is important to ensure that the expected unauthenticated version and token + // matches the authenticated version and token in the JSON. + try { + val json = JSONObject(bytes.toString(Utf8)) + val version = json.getInt(JSON_VERSION).toByte() + if (version != expectedVersion) { + throw SecurityException("Invalid version '${version.toInt()}' in metadata, expected '${expectedVersion.toInt()}'.") + } + val token = json.getLong(JSON_TOKEN) + if (token != expectedToken) { + throw SecurityException("Invalid token '$expectedVersion' in metadata, expected '$expectedToken'.") + } + return BackupMetadata( + version = version, + token = token, + androidVersion = json.getInt(JSON_ANDROID_VERSION), + deviceName = json.getString(JSON_DEVICE_NAME) + ) + } catch (e: JSONException) { + throw FormatException(e) + } + } + +} diff --git a/app/src/main/java/com/stevesoltys/backup/metadata/MetadataWriter.kt b/app/src/main/java/com/stevesoltys/backup/metadata/MetadataWriter.kt new file mode 100644 index 00000000..34bf6a25 --- /dev/null +++ b/app/src/main/java/com/stevesoltys/backup/metadata/MetadataWriter.kt @@ -0,0 +1,37 @@ +package com.stevesoltys.backup.metadata + +import androidx.annotation.VisibleForTesting +import com.stevesoltys.backup.Utf8 +import com.stevesoltys.backup.crypto.Crypto +import com.stevesoltys.backup.transport.DEFAULT_RESTORE_SET_TOKEN +import org.json.JSONObject +import java.io.IOException +import java.io.OutputStream + +interface MetadataWriter { + + @Throws(IOException::class) + fun write(outputStream: OutputStream, token: Long = DEFAULT_RESTORE_SET_TOKEN) + +} + +class MetadataWriterImpl(private val crypto: Crypto): MetadataWriter { + + @Throws(IOException::class) + override fun write(outputStream: OutputStream, token: Long) { + val metadata = BackupMetadata(token = token) + outputStream.write(ByteArray(1).apply { this[0] = metadata.version }) + crypto.encryptSegment(outputStream, encode(metadata)) + } + + @VisibleForTesting + internal fun encode(metadata: BackupMetadata): ByteArray { + val json = JSONObject() + json.put(JSON_VERSION, metadata.version.toInt()) + json.put(JSON_TOKEN, metadata.token) + json.put(JSON_ANDROID_VERSION, metadata.androidVersion) + json.put(JSON_DEVICE_NAME, metadata.deviceName) + return json.toString().toByteArray(Utf8) + } + +} diff --git a/app/src/main/java/com/stevesoltys/backup/transport/PluginManager.kt b/app/src/main/java/com/stevesoltys/backup/transport/PluginManager.kt index 0346049c..44b3df0e 100644 --- a/app/src/main/java/com/stevesoltys/backup/transport/PluginManager.kt +++ b/app/src/main/java/com/stevesoltys/backup/transport/PluginManager.kt @@ -6,6 +6,7 @@ import com.stevesoltys.backup.crypto.CipherFactoryImpl import com.stevesoltys.backup.crypto.CryptoImpl import com.stevesoltys.backup.header.HeaderReaderImpl import com.stevesoltys.backup.header.HeaderWriterImpl +import com.stevesoltys.backup.metadata.MetadataWriterImpl import com.stevesoltys.backup.settings.getBackupFolderUri import com.stevesoltys.backup.settings.getDeviceName import com.stevesoltys.backup.transport.backup.BackupCoordinator @@ -30,6 +31,7 @@ class PluginManager(context: Context) { private val headerReader = HeaderReaderImpl() private val cipherFactory = CipherFactoryImpl(Backup.keyManager) private val crypto = CryptoImpl(cipherFactory, headerWriter, headerReader) + private val metadataWriter = MetadataWriterImpl(crypto) private val backupPlugin = DocumentsProviderBackupPlugin(storage, context.packageManager) @@ -38,7 +40,7 @@ class PluginManager(context: Context) { private val fullBackup = FullBackup(backupPlugin.fullBackupPlugin, inputFactory, headerWriter, crypto) private val notificationManager = (context.applicationContext as Backup).notificationManager - internal val backupCoordinator = BackupCoordinator(backupPlugin, kvBackup, fullBackup, notificationManager) + internal val backupCoordinator = BackupCoordinator(backupPlugin, kvBackup, fullBackup, metadataWriter, notificationManager) private val restorePlugin = DocumentsProviderRestorePlugin(storage) diff --git a/app/src/main/java/com/stevesoltys/backup/transport/backup/BackupCoordinator.kt b/app/src/main/java/com/stevesoltys/backup/transport/backup/BackupCoordinator.kt index a4d357f3..b09bdc61 100644 --- a/app/src/main/java/com/stevesoltys/backup/transport/backup/BackupCoordinator.kt +++ b/app/src/main/java/com/stevesoltys/backup/transport/backup/BackupCoordinator.kt @@ -6,6 +6,7 @@ import android.content.pm.PackageInfo import android.os.ParcelFileDescriptor import android.util.Log import com.stevesoltys.backup.BackupNotificationManager +import com.stevesoltys.backup.metadata.MetadataWriter import java.io.IOException private val TAG = BackupCoordinator::class.java.simpleName @@ -18,6 +19,7 @@ class BackupCoordinator( private val plugin: BackupPlugin, private val kv: KVBackup, private val full: FullBackup, + private val metadataWriter: MetadataWriter, private val nm: BackupNotificationManager) { private var calledInitialize = false @@ -49,6 +51,7 @@ class BackupCoordinator( Log.i(TAG, "Initialize Device!") return try { plugin.initializeDevice() + writeBackupMetadata() // [finishBackup] will only be called when we return [TRANSPORT_OK] here // so we remember that we initialized successfully calledInitialize = true @@ -129,11 +132,11 @@ class BackupCoordinator( fun finishBackup(): Int = when { kv.hasState() -> { - if (full.hasState()) throw IllegalStateException() + check(!full.hasState()) kv.finishBackup() } full.hasState() -> { - if (kv.hasState()) throw IllegalStateException() + check(!kv.hasState()) full.finishBackup() } calledInitialize || calledClearBackupData -> { @@ -144,4 +147,10 @@ class BackupCoordinator( else -> throw IllegalStateException() } + @Throws(IOException::class) + private fun writeBackupMetadata() { + val outputStream = plugin.getMetadataOutputStream() + metadataWriter.write(outputStream) + } + } diff --git a/app/src/main/java/com/stevesoltys/backup/transport/backup/BackupPlugin.kt b/app/src/main/java/com/stevesoltys/backup/transport/backup/BackupPlugin.kt index b3d6f5dc..ae9b8ce4 100644 --- a/app/src/main/java/com/stevesoltys/backup/transport/backup/BackupPlugin.kt +++ b/app/src/main/java/com/stevesoltys/backup/transport/backup/BackupPlugin.kt @@ -1,6 +1,7 @@ package com.stevesoltys.backup.transport.backup import java.io.IOException +import java.io.OutputStream interface BackupPlugin { @@ -14,6 +15,12 @@ interface BackupPlugin { @Throws(IOException::class) fun initializeDevice() + /** + * Returns an [OutputStream] for writing backup metadata. + */ + @Throws(IOException::class) + fun getMetadataOutputStream(): OutputStream + /** * Returns the package name of the app that provides the backend storage * which is used for the current backup location. diff --git a/app/src/main/java/com/stevesoltys/backup/transport/backup/plugins/DocumentsProviderBackupPlugin.kt b/app/src/main/java/com/stevesoltys/backup/transport/backup/plugins/DocumentsProviderBackupPlugin.kt index e15466c1..631b8902 100644 --- a/app/src/main/java/com/stevesoltys/backup/transport/backup/plugins/DocumentsProviderBackupPlugin.kt +++ b/app/src/main/java/com/stevesoltys/backup/transport/backup/plugins/DocumentsProviderBackupPlugin.kt @@ -5,6 +5,7 @@ import com.stevesoltys.backup.transport.backup.BackupPlugin import com.stevesoltys.backup.transport.backup.FullBackupPlugin import com.stevesoltys.backup.transport.backup.KVBackupPlugin import java.io.IOException +import java.io.OutputStream class DocumentsProviderBackupPlugin( private val storage: DocumentsStorage, @@ -28,10 +29,18 @@ class DocumentsProviderBackupPlugin( val fullDir = storage.defaultFullBackupDir // wipe existing data + storage.getSetDir()?.findFile(FILE_BACKUP_METADATA)?.delete() kvDir?.deleteContents() fullDir?.deleteContents() } + @Throws(IOException::class) + override fun getMetadataOutputStream(): OutputStream { + val setDir = storage.getSetDir() ?: throw IOException() + val metadataFile = setDir.createOrGetFile(FILE_BACKUP_METADATA) + return storage.getOutputStream(metadataFile) + } + override val providerPackageName: String? by lazy { val authority = storage.rootBackupDir?.uri?.authority ?: return@lazy null val providerInfo = packageManager.resolveContentProvider(authority, 0) ?: return@lazy null diff --git a/app/src/main/java/com/stevesoltys/backup/transport/backup/plugins/DocumentsStorage.kt b/app/src/main/java/com/stevesoltys/backup/transport/backup/plugins/DocumentsStorage.kt index 6cb16865..d72a7704 100644 --- a/app/src/main/java/com/stevesoltys/backup/transport/backup/plugins/DocumentsStorage.kt +++ b/app/src/main/java/com/stevesoltys/backup/transport/backup/plugins/DocumentsStorage.kt @@ -12,6 +12,7 @@ import java.io.OutputStream const val DIRECTORY_FULL_BACKUP = "full" const val DIRECTORY_KEY_VALUE_BACKUP = "kv" +const val FILE_BACKUP_METADATA = ".backup.metadata" private const val ROOT_DIR_NAME = ".AndroidBackup" private const val NO_MEDIA = ".nomedia" private const val MIME_TYPE = "application/octet-stream" @@ -24,6 +25,7 @@ class DocumentsStorage(context: Context, parentFolder: Uri?, deviceName: String) internal val rootBackupDir: DocumentFile? by lazy { val folderUri = parentFolder ?: return@lazy null + // [fromTreeUri] should only return null when SDK_INT < 21 val parent = DocumentFile.fromTreeUri(context, folderUri) ?: throw AssertionError() try { val rootDir = parent.createOrGetDirectory(ROOT_DIR_NAME) @@ -73,7 +75,7 @@ class DocumentsStorage(context: Context, parentFolder: Uri?, deviceName: String) } } - private fun getSetDir(token: Long = DEFAULT_RESTORE_SET_TOKEN): DocumentFile? { + fun getSetDir(token: Long = DEFAULT_RESTORE_SET_TOKEN): DocumentFile? { if (token == DEFAULT_RESTORE_SET_TOKEN) return defaultSetDir return deviceDir?.findFile(token.toString()) } diff --git a/app/src/test/java/com/stevesoltys/backup/metadata/MetadataDecoderTest.kt b/app/src/test/java/com/stevesoltys/backup/metadata/MetadataDecoderTest.kt new file mode 100644 index 00000000..c25d29c3 --- /dev/null +++ b/app/src/test/java/com/stevesoltys/backup/metadata/MetadataDecoderTest.kt @@ -0,0 +1,69 @@ +package com.stevesoltys.backup.metadata + +import com.stevesoltys.backup.Utf8 +import com.stevesoltys.backup.crypto.Crypto +import com.stevesoltys.backup.getRandomString +import io.mockk.mockk +import org.json.JSONObject +import org.junit.jupiter.api.Assertions.assertThrows +import org.junit.jupiter.api.Test +import org.junit.jupiter.api.TestInstance +import org.junit.jupiter.api.TestInstance.Lifecycle.PER_CLASS +import kotlin.random.Random + +@TestInstance(PER_CLASS) +class MetadataDecoderTest { + + private val crypto = mockk() + + private val encoder = MetadataWriterImpl(crypto) + private val decoder = MetadataDecoderImpl() + + private val metadata = BackupMetadata( + version = 1.toByte(), + token = Random.nextLong(), + androidVersion = Random.nextInt(), + deviceName = getRandomString() + ) + private val metadataByteArray = encoder.encode(metadata) + + @Test + fun `unexpected version should throw SecurityException`() { + assertThrows(SecurityException::class.java) { + decoder.decode(metadataByteArray, 2.toByte(), metadata.token) + } + } + + @Test + fun `unexpected token should throw SecurityException`() { + assertThrows(SecurityException::class.java) { + decoder.decode(metadataByteArray, metadata.version, metadata.token - 1) + } + } + + @Test + fun `expected version and token do not throw SecurityException`() { + decoder.decode(metadataByteArray, metadata.version, metadata.token) + } + + @Test + fun `malformed JSON throws FormatException`() { + assertThrows(FormatException::class.java) { + decoder.decode("{".toByteArray(Utf8), metadata.version, metadata.token) + } + } + + @Test + fun `missing fields throws FormatException`() { + val json = JSONObject() + json.put(JSON_VERSION, metadata.version.toInt()) + json.put(JSON_TOKEN, metadata.token) + json.put(JSON_ANDROID_VERSION, metadata.androidVersion) + val jsonBytes = json.toString().toByteArray(Utf8) + + assertThrows(FormatException::class.java) { + decoder.decode(jsonBytes, metadata.version, metadata.token) + } + } + +} diff --git a/app/src/test/java/com/stevesoltys/backup/metadata/MetadataWriterDecoderTest.kt b/app/src/test/java/com/stevesoltys/backup/metadata/MetadataWriterDecoderTest.kt new file mode 100644 index 00000000..1e139f32 --- /dev/null +++ b/app/src/test/java/com/stevesoltys/backup/metadata/MetadataWriterDecoderTest.kt @@ -0,0 +1,32 @@ +package com.stevesoltys.backup.metadata + +import com.stevesoltys.backup.crypto.Crypto +import com.stevesoltys.backup.getRandomString +import io.mockk.mockk +import org.junit.jupiter.api.Assertions.assertEquals +import org.junit.jupiter.api.Test +import org.junit.jupiter.api.TestInstance +import org.junit.jupiter.api.TestInstance.Lifecycle.PER_CLASS +import kotlin.random.Random + +@TestInstance(PER_CLASS) +internal class MetadataWriterDecoderTest { + + private val crypto = mockk() + + private val encoder = MetadataWriterImpl(crypto) + private val decoder = MetadataDecoderImpl() + + private val metadata = BackupMetadata( + version = Random.nextBytes(1)[0], + token = Random.nextLong(), + androidVersion = Random.nextInt(), + deviceName = getRandomString() + ) + + @Test + fun `encoded metadata matches decoded metadata`() { + assertEquals(metadata, decoder.decode(encoder.encode(metadata), metadata.version, metadata.token)) + } + +} diff --git a/app/src/test/java/com/stevesoltys/backup/transport/CoordinatorIntegrationTest.kt b/app/src/test/java/com/stevesoltys/backup/transport/CoordinatorIntegrationTest.kt index 2f9ceb3c..8dd97c3e 100644 --- a/app/src/test/java/com/stevesoltys/backup/transport/CoordinatorIntegrationTest.kt +++ b/app/src/test/java/com/stevesoltys/backup/transport/CoordinatorIntegrationTest.kt @@ -14,6 +14,7 @@ import com.stevesoltys.backup.crypto.KeyManagerTestImpl import com.stevesoltys.backup.encodeBase64 import com.stevesoltys.backup.header.HeaderReaderImpl import com.stevesoltys.backup.header.HeaderWriterImpl +import com.stevesoltys.backup.metadata.MetadataWriterImpl import com.stevesoltys.backup.transport.backup.* import com.stevesoltys.backup.transport.restore.* import io.mockk.* @@ -32,6 +33,7 @@ internal class CoordinatorIntegrationTest : TransportTest() { private val headerWriter = HeaderWriterImpl() private val headerReader = HeaderReaderImpl() private val cryptoImpl = CryptoImpl(cipherFactory, headerWriter, headerReader) + private val metadataWriter = MetadataWriterImpl(cryptoImpl) private val backupPlugin = mockk() private val kvBackupPlugin = mockk() @@ -39,7 +41,7 @@ internal class CoordinatorIntegrationTest : TransportTest() { private val fullBackupPlugin = mockk() private val fullBackup = FullBackup(fullBackupPlugin, inputFactory, headerWriter, cryptoImpl) private val notificationManager = mockk() - private val backup = BackupCoordinator(backupPlugin, kvBackup, fullBackup, notificationManager) + private val backup = BackupCoordinator(backupPlugin, kvBackup, fullBackup, metadataWriter, notificationManager) private val restorePlugin = mockk() private val kvRestorePlugin = mockk() diff --git a/app/src/test/java/com/stevesoltys/backup/transport/backup/BackupCoordinatorTest.kt b/app/src/test/java/com/stevesoltys/backup/transport/backup/BackupCoordinatorTest.kt index 8e8b38fb..8ff96fe7 100644 --- a/app/src/test/java/com/stevesoltys/backup/transport/backup/BackupCoordinatorTest.kt +++ b/app/src/test/java/com/stevesoltys/backup/transport/backup/BackupCoordinatorTest.kt @@ -3,6 +3,7 @@ package com.stevesoltys.backup.transport.backup import android.app.backup.BackupTransport.TRANSPORT_ERROR import android.app.backup.BackupTransport.TRANSPORT_OK import com.stevesoltys.backup.BackupNotificationManager +import com.stevesoltys.backup.metadata.MetadataWriter import io.mockk.Runs import io.mockk.every import io.mockk.just @@ -11,6 +12,7 @@ import org.junit.jupiter.api.Assertions.assertEquals import org.junit.jupiter.api.Assertions.assertThrows import org.junit.jupiter.api.Test import java.io.IOException +import java.io.OutputStream import kotlin.random.Random internal class BackupCoordinatorTest: BackupTest() { @@ -18,13 +20,17 @@ internal class BackupCoordinatorTest: BackupTest() { private val plugin = mockk() private val kv = mockk() private val full = mockk() + private val metadataWriter = mockk() private val notificationManager = mockk() - private val backup = BackupCoordinator(plugin, kv, full, notificationManager) + private val backup = BackupCoordinator(plugin, kv, full, metadataWriter, notificationManager) + + private val metadataOutputStream = mockk() @Test fun `device initialization succeeds and delegates to plugin`() { every { plugin.initializeDevice() } just Runs + expectWritingMetadata() every { kv.hasState() } returns false every { full.hasState() } returns false @@ -110,4 +116,9 @@ internal class BackupCoordinatorTest: BackupTest() { assertEquals(result, backup.finishBackup()) } + private fun expectWritingMetadata() { + every { plugin.getMetadataOutputStream() } returns metadataOutputStream + every { metadataWriter.write(metadataOutputStream) } just Runs + } + }