Write an encrypted metadata file for each restore set
This commit is contained in:
parent
044ef01ba1
commit
f9c8b657a0
12 changed files with 250 additions and 6 deletions
|
@ -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)
|
|
@ -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)
|
||||
}
|
||||
}
|
||||
|
||||
}
|
|
@ -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)
|
||||
}
|
||||
|
||||
}
|
|
@ -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)
|
||||
|
|
|
@ -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)
|
||||
}
|
||||
|
||||
}
|
||||
|
|
|
@ -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.
|
||||
|
|
|
@ -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
|
||||
|
|
|
@ -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())
|
||||
}
|
||||
|
|
|
@ -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<Crypto>()
|
||||
|
||||
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)
|
||||
}
|
||||
}
|
||||
|
||||
}
|
|
@ -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<Crypto>()
|
||||
|
||||
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))
|
||||
}
|
||||
|
||||
}
|
|
@ -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<BackupPlugin>()
|
||||
private val kvBackupPlugin = mockk<KVBackupPlugin>()
|
||||
|
@ -39,7 +41,7 @@ internal class CoordinatorIntegrationTest : TransportTest() {
|
|||
private val fullBackupPlugin = mockk<FullBackupPlugin>()
|
||||
private val fullBackup = FullBackup(fullBackupPlugin, inputFactory, headerWriter, cryptoImpl)
|
||||
private val notificationManager = mockk<BackupNotificationManager>()
|
||||
private val backup = BackupCoordinator(backupPlugin, kvBackup, fullBackup, notificationManager)
|
||||
private val backup = BackupCoordinator(backupPlugin, kvBackup, fullBackup, metadataWriter, notificationManager)
|
||||
|
||||
private val restorePlugin = mockk<RestorePlugin>()
|
||||
private val kvRestorePlugin = mockk<KVRestorePlugin>()
|
||||
|
|
|
@ -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<BackupPlugin>()
|
||||
private val kv = mockk<KVBackup>()
|
||||
private val full = mockk<FullBackup>()
|
||||
private val metadataWriter = mockk<MetadataWriter>()
|
||||
private val notificationManager = mockk<BackupNotificationManager>()
|
||||
|
||||
private val backup = BackupCoordinator(plugin, kv, full, notificationManager)
|
||||
private val backup = BackupCoordinator(plugin, kv, full, metadataWriter, notificationManager)
|
||||
|
||||
private val metadataOutputStream = mockk<OutputStream>()
|
||||
|
||||
@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
|
||||
}
|
||||
|
||||
}
|
||||
|
|
Loading…
Reference in a new issue