Write an encrypted metadata file for each restore set

This commit is contained in:
Torsten Grote 2019-09-10 13:35:34 -03:00
parent 044ef01ba1
commit f9c8b657a0
No known key found for this signature in database
GPG key ID: 3E5F77D92CF891FF
12 changed files with 250 additions and 6 deletions

View file

@ -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)

View file

@ -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)
}
}
}

View file

@ -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)
}
}

View file

@ -6,6 +6,7 @@ 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.metadata.MetadataWriterImpl
import com.stevesoltys.backup.settings.getBackupFolderUri import com.stevesoltys.backup.settings.getBackupFolderUri
import com.stevesoltys.backup.settings.getDeviceName import com.stevesoltys.backup.settings.getDeviceName
import com.stevesoltys.backup.transport.backup.BackupCoordinator import com.stevesoltys.backup.transport.backup.BackupCoordinator
@ -30,6 +31,7 @@ class PluginManager(context: Context) {
private val headerReader = HeaderReaderImpl() private val headerReader = HeaderReaderImpl()
private val cipherFactory = CipherFactoryImpl(Backup.keyManager) private val cipherFactory = CipherFactoryImpl(Backup.keyManager)
private val crypto = CryptoImpl(cipherFactory, headerWriter, headerReader) private val crypto = CryptoImpl(cipherFactory, headerWriter, headerReader)
private val metadataWriter = MetadataWriterImpl(crypto)
private val backupPlugin = DocumentsProviderBackupPlugin(storage, context.packageManager) 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 fullBackup = FullBackup(backupPlugin.fullBackupPlugin, inputFactory, headerWriter, crypto)
private val notificationManager = (context.applicationContext as Backup).notificationManager 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) private val restorePlugin = DocumentsProviderRestorePlugin(storage)

View file

@ -6,6 +6,7 @@ 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.BackupNotificationManager import com.stevesoltys.backup.BackupNotificationManager
import com.stevesoltys.backup.metadata.MetadataWriter
import java.io.IOException import java.io.IOException
private val TAG = BackupCoordinator::class.java.simpleName private val TAG = BackupCoordinator::class.java.simpleName
@ -18,6 +19,7 @@ class BackupCoordinator(
private val plugin: BackupPlugin, private val plugin: BackupPlugin,
private val kv: KVBackup, private val kv: KVBackup,
private val full: FullBackup, private val full: FullBackup,
private val metadataWriter: MetadataWriter,
private val nm: BackupNotificationManager) { private val nm: BackupNotificationManager) {
private var calledInitialize = false private var calledInitialize = false
@ -49,6 +51,7 @@ class BackupCoordinator(
Log.i(TAG, "Initialize Device!") Log.i(TAG, "Initialize Device!")
return try { return try {
plugin.initializeDevice() plugin.initializeDevice()
writeBackupMetadata()
// [finishBackup] will only be called when we return [TRANSPORT_OK] here // [finishBackup] will only be called when we return [TRANSPORT_OK] here
// so we remember that we initialized successfully // so we remember that we initialized successfully
calledInitialize = true calledInitialize = true
@ -129,11 +132,11 @@ class BackupCoordinator(
fun finishBackup(): Int = when { fun finishBackup(): Int = when {
kv.hasState() -> { kv.hasState() -> {
if (full.hasState()) throw IllegalStateException() check(!full.hasState())
kv.finishBackup() kv.finishBackup()
} }
full.hasState() -> { full.hasState() -> {
if (kv.hasState()) throw IllegalStateException() check(!kv.hasState())
full.finishBackup() full.finishBackup()
} }
calledInitialize || calledClearBackupData -> { calledInitialize || calledClearBackupData -> {
@ -144,4 +147,10 @@ class BackupCoordinator(
else -> throw IllegalStateException() else -> throw IllegalStateException()
} }
@Throws(IOException::class)
private fun writeBackupMetadata() {
val outputStream = plugin.getMetadataOutputStream()
metadataWriter.write(outputStream)
}
} }

View file

@ -1,6 +1,7 @@
package com.stevesoltys.backup.transport.backup package com.stevesoltys.backup.transport.backup
import java.io.IOException import java.io.IOException
import java.io.OutputStream
interface BackupPlugin { interface BackupPlugin {
@ -14,6 +15,12 @@ interface BackupPlugin {
@Throws(IOException::class) @Throws(IOException::class)
fun initializeDevice() 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 * Returns the package name of the app that provides the backend storage
* which is used for the current backup location. * which is used for the current backup location.

View file

@ -5,6 +5,7 @@ import com.stevesoltys.backup.transport.backup.BackupPlugin
import com.stevesoltys.backup.transport.backup.FullBackupPlugin 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
import java.io.OutputStream
class DocumentsProviderBackupPlugin( class DocumentsProviderBackupPlugin(
private val storage: DocumentsStorage, private val storage: DocumentsStorage,
@ -28,10 +29,18 @@ class DocumentsProviderBackupPlugin(
val fullDir = storage.defaultFullBackupDir val fullDir = storage.defaultFullBackupDir
// wipe existing data // wipe existing data
storage.getSetDir()?.findFile(FILE_BACKUP_METADATA)?.delete()
kvDir?.deleteContents() kvDir?.deleteContents()
fullDir?.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 { override val providerPackageName: String? by lazy {
val authority = storage.rootBackupDir?.uri?.authority ?: return@lazy null val authority = storage.rootBackupDir?.uri?.authority ?: return@lazy null
val providerInfo = packageManager.resolveContentProvider(authority, 0) ?: return@lazy null val providerInfo = packageManager.resolveContentProvider(authority, 0) ?: return@lazy null

View file

@ -12,6 +12,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"
const val FILE_BACKUP_METADATA = ".backup.metadata"
private const val ROOT_DIR_NAME = ".AndroidBackup" private const val ROOT_DIR_NAME = ".AndroidBackup"
private const val NO_MEDIA = ".nomedia" private const val NO_MEDIA = ".nomedia"
private const val MIME_TYPE = "application/octet-stream" 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 { internal val rootBackupDir: DocumentFile? by lazy {
val folderUri = parentFolder ?: return@lazy null val folderUri = parentFolder ?: return@lazy null
// [fromTreeUri] should only return null when SDK_INT < 21
val parent = DocumentFile.fromTreeUri(context, folderUri) ?: throw AssertionError() val parent = DocumentFile.fromTreeUri(context, folderUri) ?: throw AssertionError()
try { try {
val rootDir = parent.createOrGetDirectory(ROOT_DIR_NAME) 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 if (token == DEFAULT_RESTORE_SET_TOKEN) return defaultSetDir
return deviceDir?.findFile(token.toString()) return deviceDir?.findFile(token.toString())
} }

View file

@ -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)
}
}
}

View file

@ -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))
}
}

View file

@ -14,6 +14,7 @@ import com.stevesoltys.backup.crypto.KeyManagerTestImpl
import com.stevesoltys.backup.encodeBase64 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.metadata.MetadataWriterImpl
import com.stevesoltys.backup.transport.backup.* import com.stevesoltys.backup.transport.backup.*
import com.stevesoltys.backup.transport.restore.* import com.stevesoltys.backup.transport.restore.*
import io.mockk.* import io.mockk.*
@ -32,6 +33,7 @@ internal class CoordinatorIntegrationTest : TransportTest() {
private val headerWriter = HeaderWriterImpl() private val headerWriter = HeaderWriterImpl()
private val headerReader = HeaderReaderImpl() private val headerReader = HeaderReaderImpl()
private val cryptoImpl = CryptoImpl(cipherFactory, headerWriter, headerReader) private val cryptoImpl = CryptoImpl(cipherFactory, headerWriter, headerReader)
private val metadataWriter = MetadataWriterImpl(cryptoImpl)
private val backupPlugin = mockk<BackupPlugin>() private val backupPlugin = mockk<BackupPlugin>()
private val kvBackupPlugin = mockk<KVBackupPlugin>() private val kvBackupPlugin = mockk<KVBackupPlugin>()
@ -39,7 +41,7 @@ internal class CoordinatorIntegrationTest : TransportTest() {
private val fullBackupPlugin = mockk<FullBackupPlugin>() private val fullBackupPlugin = mockk<FullBackupPlugin>()
private val fullBackup = FullBackup(fullBackupPlugin, inputFactory, headerWriter, cryptoImpl) private val fullBackup = FullBackup(fullBackupPlugin, inputFactory, headerWriter, cryptoImpl)
private val notificationManager = mockk<BackupNotificationManager>() 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 restorePlugin = mockk<RestorePlugin>()
private val kvRestorePlugin = mockk<KVRestorePlugin>() private val kvRestorePlugin = mockk<KVRestorePlugin>()

View file

@ -3,6 +3,7 @@ package com.stevesoltys.backup.transport.backup
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.BackupNotificationManager import com.stevesoltys.backup.BackupNotificationManager
import com.stevesoltys.backup.metadata.MetadataWriter
import io.mockk.Runs import io.mockk.Runs
import io.mockk.every import io.mockk.every
import io.mockk.just 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.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.OutputStream
import kotlin.random.Random import kotlin.random.Random
internal class BackupCoordinatorTest: BackupTest() { internal class BackupCoordinatorTest: BackupTest() {
@ -18,13 +20,17 @@ internal class BackupCoordinatorTest: BackupTest() {
private val plugin = mockk<BackupPlugin>() private val plugin = mockk<BackupPlugin>()
private val kv = mockk<KVBackup>() private val kv = mockk<KVBackup>()
private val full = mockk<FullBackup>() private val full = mockk<FullBackup>()
private val metadataWriter = mockk<MetadataWriter>()
private val notificationManager = mockk<BackupNotificationManager>() 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 @Test
fun `device initialization succeeds and delegates to plugin`() { fun `device initialization succeeds and delegates to plugin`() {
every { plugin.initializeDevice() } just Runs every { plugin.initializeDevice() } just Runs
expectWritingMetadata()
every { kv.hasState() } returns false every { kv.hasState() } returns false
every { full.hasState() } returns false every { full.hasState() } returns false
@ -110,4 +116,9 @@ internal class BackupCoordinatorTest: BackupTest() {
assertEquals(result, backup.finishBackup()) assertEquals(result, backup.finishBackup())
} }
private fun expectWritingMetadata() {
every { plugin.getMetadataOutputStream() } returns metadataOutputStream
every { metadataWriter.write(metadataOutputStream) } just Runs
}
} }