Read RestoreSets from encrypted backup metadata file

This commit is contained in:
Torsten Grote 2019-09-10 16:27:22 -03:00
parent f9c8b657a0
commit 8b6656a350
No known key found for this signature in database
GPG key ID: 3E5F77D92CF891FF
10 changed files with 126 additions and 30 deletions

View file

@ -4,6 +4,7 @@ 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
import java.io.InputStream
data class BackupMetadata(
internal val version: Byte = VERSION,
@ -18,3 +19,8 @@ internal const val JSON_ANDROID_VERSION = "androidVersion"
internal const val JSON_DEVICE_NAME = "deviceName"
class FormatException(cause: Throwable) : Exception(cause)
class EncryptedBackupMetadata private constructor(val token: Long, val inputStream: InputStream?, val error: Boolean) {
constructor(token: Long, inputStream: InputStream) : this(token, inputStream, false)
constructor(token: Long, error: Boolean) : this(token, null, false)
}

View file

@ -1,20 +1,36 @@
package com.stevesoltys.backup.metadata
import androidx.annotation.VisibleForTesting
import com.stevesoltys.backup.Utf8
import com.stevesoltys.backup.crypto.Crypto
import com.stevesoltys.backup.header.UnsupportedVersionException
import com.stevesoltys.backup.header.VERSION
import org.json.JSONException
import org.json.JSONObject
import java.io.IOException
import java.io.InputStream
interface MetadataDecoder {
interface MetadataReader {
@Throws(FormatException::class, SecurityException::class)
fun decode(bytes: ByteArray, expectedVersion: Byte, expectedToken: Long): BackupMetadata
@Throws(FormatException::class, SecurityException::class, UnsupportedVersionException::class, IOException::class)
fun readMetadata(inputStream: InputStream, expectedToken: Long): BackupMetadata
}
class MetadataDecoderImpl : MetadataDecoder {
class MetadataReaderImpl(private val crypto: Crypto) : MetadataReader {
@Throws(FormatException::class, SecurityException::class, UnsupportedVersionException::class, IOException::class)
override fun readMetadata(inputStream: InputStream, expectedToken: Long): BackupMetadata {
val version = inputStream.read().toByte()
if (version < 0) throw IOException()
if (version > VERSION) throw UnsupportedVersionException(version)
val metadataBytes = crypto.decryptSegment(inputStream)
return decode(metadataBytes, version, expectedToken)
}
@VisibleForTesting
@Throws(FormatException::class, SecurityException::class)
override fun decode(bytes: ByteArray, expectedVersion: Byte, expectedToken: Long): BackupMetadata {
internal 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.
//
@ -28,7 +44,7 @@ class MetadataDecoderImpl : MetadataDecoder {
}
val token = json.getLong(JSON_TOKEN)
if (token != expectedToken) {
throw SecurityException("Invalid token '$expectedVersion' in metadata, expected '$expectedToken'.")
throw SecurityException("Invalid token '$token' in metadata, expected '$expectedToken'.")
}
return BackupMetadata(
version = version,

View file

@ -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.MetadataReaderImpl
import com.stevesoltys.backup.metadata.MetadataWriterImpl
import com.stevesoltys.backup.settings.getBackupFolderUri
import com.stevesoltys.backup.settings.getDeviceName
@ -32,6 +33,7 @@ class PluginManager(context: Context) {
private val cipherFactory = CipherFactoryImpl(Backup.keyManager)
private val crypto = CryptoImpl(cipherFactory, headerWriter, headerReader)
private val metadataWriter = MetadataWriterImpl(crypto)
private val metadataReader = MetadataReaderImpl(crypto)
private val backupPlugin = DocumentsProviderBackupPlugin(storage, context.packageManager)
@ -48,6 +50,6 @@ class PluginManager(context: Context) {
private val kvRestore = KVRestore(restorePlugin.kvRestorePlugin, outputFactory, headerReader, crypto)
private val fullRestore = FullRestore(restorePlugin.fullRestorePlugin, outputFactory, headerReader, crypto)
internal val restoreCoordinator = RestoreCoordinator(restorePlugin, kvRestore, fullRestore)
internal val restoreCoordinator = RestoreCoordinator(restorePlugin, kvRestore, fullRestore, metadataReader)
}

View file

@ -8,6 +8,10 @@ import android.app.backup.RestoreSet
import android.content.pm.PackageInfo
import android.os.ParcelFileDescriptor
import android.util.Log
import com.stevesoltys.backup.header.UnsupportedVersionException
import com.stevesoltys.backup.metadata.FormatException
import com.stevesoltys.backup.metadata.MetadataReader
import libcore.io.IoUtils.closeQuietly
import java.io.IOException
private class RestoreCoordinatorState(
@ -19,13 +23,45 @@ private val TAG = RestoreCoordinator::class.java.simpleName
internal class RestoreCoordinator(
private val plugin: RestorePlugin,
private val kv: KVRestore,
private val full: FullRestore) {
private val full: FullRestore,
private val metadataReader: MetadataReader) {
private var state: RestoreCoordinatorState? = null
/**
* Get the set of all backups currently available over this transport.
*
* @return Descriptions of the set of restore images available for this device,
* or null if an error occurred (the attempt should be rescheduled).
**/
fun getAvailableRestoreSets(): Array<RestoreSet>? {
return plugin.getAvailableRestoreSets()
.apply { Log.i(TAG, "Got available restore sets: $this") }
val availableBackups = plugin.getAvailableBackups() ?: return null
val restoreSets = ArrayList<RestoreSet>()
for (encryptedMetadata in availableBackups) {
if (encryptedMetadata.error) continue
check(encryptedMetadata.inputStream != null) // if there's no error, there must be a stream
try {
val metadata = metadataReader.readMetadata(encryptedMetadata.inputStream, encryptedMetadata.token)
val set = RestoreSet(metadata.deviceName, metadata.deviceName, metadata.token)
restoreSets.add(set)
} catch (e: IOException) {
Log.e(TAG, "Error while getting restore sets", e)
return null
} catch (e: FormatException) {
Log.e(TAG, "Error while getting restore sets", e)
return null
} catch (e: SecurityException) {
Log.e(TAG, "Error while getting restore sets", e)
return null
} catch (e: UnsupportedVersionException) {
Log.w(TAG, "Backup with unsupported version read", e)
continue
} finally {
closeQuietly(encryptedMetadata.inputStream)
}
}
Log.i(TAG, "Got available restore sets: $restoreSets")
return restoreSets.toTypedArray()
}
fun getCurrentRestoreSet(): Long {

View file

@ -1,6 +1,6 @@
package com.stevesoltys.backup.transport.restore
import android.app.backup.RestoreSet
import com.stevesoltys.backup.metadata.EncryptedBackupMetadata
interface RestorePlugin {
@ -11,10 +11,10 @@ interface RestorePlugin {
/**
* Get the set of all backups currently available for restore.
*
* @return Descriptions of the set of restore images available for this device,
* @return metadata for the set of restore images available,
* or null if an error occurred (the attempt should be rescheduled).
**/
fun getAvailableRestoreSets(): Array<RestoreSet>?
fun getAvailableBackups(): Sequence<EncryptedBackupMetadata>?
/**
* Get the identifying token of the backup set currently being stored from this device.

View file

@ -1,11 +1,17 @@
package com.stevesoltys.backup.transport.restore.plugins
import android.app.backup.RestoreSet
import android.util.Log
import androidx.documentfile.provider.DocumentFile
import com.stevesoltys.backup.metadata.EncryptedBackupMetadata
import com.stevesoltys.backup.transport.DEFAULT_RESTORE_SET_TOKEN
import com.stevesoltys.backup.transport.backup.plugins.DocumentsStorage
import com.stevesoltys.backup.transport.backup.plugins.FILE_BACKUP_METADATA
import com.stevesoltys.backup.transport.restore.FullRestorePlugin
import com.stevesoltys.backup.transport.restore.KVRestorePlugin
import com.stevesoltys.backup.transport.restore.RestorePlugin
import java.io.IOException
private val TAG = DocumentsProviderRestorePlugin::class.java.simpleName
class DocumentsProviderRestorePlugin(private val storage: DocumentsStorage) : RestorePlugin {
@ -17,16 +23,28 @@ class DocumentsProviderRestorePlugin(private val storage: DocumentsStorage) : Re
DocumentsProviderFullRestorePlugin(storage)
}
override fun getAvailableRestoreSets(): Array<RestoreSet>? {
override fun getAvailableBackups(): Sequence<EncryptedBackupMetadata>? {
val rootDir = storage.rootBackupDir ?: return null
val restoreSets = ArrayList<RestoreSet>()
val files = ArrayList<DocumentFile>()
for (file in rootDir.listFiles()) {
if (file.isDirectory && file.findFile(DEFAULT_RESTORE_SET_TOKEN.toString()) != null) {
// TODO include time of last backup
file.name?.let { restoreSets.add(RestoreSet(it, it, DEFAULT_RESTORE_SET_TOKEN)) }
file.isDirectory || continue
val set = file.findFile(DEFAULT_RESTORE_SET_TOKEN.toString()) ?: continue
val metadata = set.findFile(FILE_BACKUP_METADATA) ?: continue
files.add(metadata)
}
val iterator = files.iterator()
return generateSequence {
if (!iterator.hasNext()) return@generateSequence null // end sequence
val metadata = iterator.next()
val token = metadata.parentFile!!.name!!.toLong()
try {
val stream = storage.getInputStream(metadata)
EncryptedBackupMetadata(token, stream)
} catch (e: IOException) {
Log.e(TAG, "Error getting InputStream for backup metadata.", e)
EncryptedBackupMetadata(token, true)
}
}
return restoreSets.toTypedArray()
}
override fun getCurrentRestoreSet(): Long {

View file

@ -12,12 +12,12 @@ import org.junit.jupiter.api.TestInstance.Lifecycle.PER_CLASS
import kotlin.random.Random
@TestInstance(PER_CLASS)
class MetadataDecoderTest {
class MetadataReaderTest {
private val crypto = mockk<Crypto>()
private val encoder = MetadataWriterImpl(crypto)
private val decoder = MetadataDecoderImpl()
private val decoder = MetadataReaderImpl(crypto)
private val metadata = BackupMetadata(
version = 1.toByte(),

View file

@ -15,7 +15,7 @@ internal class MetadataWriterDecoderTest {
private val crypto = mockk<Crypto>()
private val encoder = MetadataWriterImpl(crypto)
private val decoder = MetadataDecoderImpl()
private val decoder = MetadataReaderImpl(crypto)
private val metadata = BackupMetadata(
version = Random.nextBytes(1)[0],

View file

@ -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.MetadataReaderImpl
import com.stevesoltys.backup.metadata.MetadataWriterImpl
import com.stevesoltys.backup.transport.backup.*
import com.stevesoltys.backup.transport.restore.*
@ -34,6 +35,7 @@ internal class CoordinatorIntegrationTest : TransportTest() {
private val headerReader = HeaderReaderImpl()
private val cryptoImpl = CryptoImpl(cipherFactory, headerWriter, headerReader)
private val metadataWriter = MetadataWriterImpl(cryptoImpl)
private val metadataReader = MetadataReaderImpl(cryptoImpl)
private val backupPlugin = mockk<BackupPlugin>()
private val kvBackupPlugin = mockk<KVBackupPlugin>()
@ -48,7 +50,7 @@ internal class CoordinatorIntegrationTest : TransportTest() {
private val kvRestore = KVRestore(kvRestorePlugin, outputFactory, headerReader, cryptoImpl)
private val fullRestorePlugin = mockk<FullRestorePlugin>()
private val fullRestore = FullRestore(fullRestorePlugin, outputFactory, headerReader, cryptoImpl)
private val restore = RestoreCoordinator(restorePlugin, kvRestore, fullRestore)
private val restore = RestoreCoordinator(restorePlugin, kvRestore, fullRestore, metadataReader)
private val backupDataInput = mockk<BackupDataInput>()
private val fileDescriptor = mockk<ParcelFileDescriptor>(relaxed = true)

View file

@ -3,9 +3,12 @@ package com.stevesoltys.backup.transport.restore
import android.app.backup.BackupTransport.TRANSPORT_OK
import android.app.backup.RestoreDescription
import android.app.backup.RestoreDescription.*
import android.app.backup.RestoreSet
import android.content.pm.PackageInfo
import android.os.ParcelFileDescriptor
import com.stevesoltys.backup.getRandomString
import com.stevesoltys.backup.metadata.BackupMetadata
import com.stevesoltys.backup.metadata.EncryptedBackupMetadata
import com.stevesoltys.backup.metadata.MetadataReader
import com.stevesoltys.backup.transport.TransportTest
import io.mockk.Runs
import io.mockk.every
@ -14,6 +17,7 @@ import io.mockk.mockk
import org.junit.jupiter.api.Assertions.*
import org.junit.jupiter.api.Test
import java.io.IOException
import java.io.InputStream
import kotlin.random.Random
internal class RestoreCoordinatorTest : TransportTest() {
@ -21,21 +25,33 @@ internal class RestoreCoordinatorTest : TransportTest() {
private val plugin = mockk<RestorePlugin>()
private val kv = mockk<KVRestore>()
private val full = mockk<FullRestore>()
private val metadataReader = mockk<MetadataReader>()
private val restore = RestoreCoordinator(plugin, kv, full)
private val restore = RestoreCoordinator(plugin, kv, full, metadataReader)
private val token = Random.nextLong()
private val inputStream = mockk<InputStream>()
private val packageInfo2 = PackageInfo().apply { packageName = "org.example2" }
private val packageInfoArray = arrayOf(packageInfo)
private val packageInfoArray2 = arrayOf(packageInfo, packageInfo2)
@Test
fun `getAvailableRestoreSets() delegates to plugin`() {
val restoreSets = Array(1) { RestoreSet() }
fun `getAvailableRestoreSets() builds set from plugin response`() {
val encryptedMetadata = EncryptedBackupMetadata(token, inputStream)
val metadata = BackupMetadata(
token = token,
androidVersion = Random.nextInt(),
deviceName = getRandomString())
every { plugin.getAvailableRestoreSets() } returns restoreSets
every { plugin.getAvailableBackups() } returns sequenceOf(encryptedMetadata, encryptedMetadata)
every { metadataReader.readMetadata(inputStream, token) } returns metadata
every { inputStream.close() } just Runs
assertEquals(restoreSets, restore.getAvailableRestoreSets())
val sets = restore.getAvailableRestoreSets() ?: fail()
assertEquals(2, sets.size)
assertEquals(metadata.deviceName, sets[0].device)
assertEquals(metadata.deviceName, sets[0].name)
assertEquals(metadata.token, sets[0].token)
}
@Test