diff --git a/app/src/main/java/com/stevesoltys/seedvault/metadata/Metadata.kt b/app/src/main/java/com/stevesoltys/seedvault/metadata/Metadata.kt index 9a68e713..5a9321ca 100644 --- a/app/src/main/java/com/stevesoltys/seedvault/metadata/Metadata.kt +++ b/app/src/main/java/com/stevesoltys/seedvault/metadata/Metadata.kt @@ -1,25 +1,50 @@ package com.stevesoltys.seedvault.metadata import android.os.Build -import android.os.Build.VERSION.SDK_INT import com.stevesoltys.seedvault.header.VERSION import java.io.InputStream data class BackupMetadata( internal val version: Byte = VERSION, internal val token: Long, - internal val androidVersion: Int = SDK_INT, - internal val deviceName: String = "${Build.MANUFACTURER} ${Build.MODEL}" + internal val time: Long = System.currentTimeMillis(), + internal val androidVersion: Int = Build.VERSION.SDK_INT, + internal val androidIncremental: String = Build.VERSION.INCREMENTAL, + internal val deviceName: String = "${Build.MANUFACTURER} ${Build.MODEL}", + internal val packageMetadata: Map = HashMap() ) -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" +internal const val JSON_METADATA = "@meta@" +internal const val JSON_METADATA_VERSION = "version" +internal const val JSON_METADATA_TOKEN = "token" +internal const val JSON_METADATA_TIME = "time" +internal const val JSON_METADATA_SDK_INT = "sdk_int" +internal const val JSON_METADATA_INCREMENTAL = "incremental" +internal const val JSON_METADATA_NAME = "name" + +data class PackageMetadata( + internal val time: Long, + internal val version: Long? = null, + internal val installer: String? = null, + internal val signatures: List? = null +) { + fun hasApk(): Boolean { + return version != null && signatures != null + } +} + +internal const val JSON_PACKAGE_TIME = "time" +internal const val JSON_PACKAGE_VERSION = "version" +internal const val JSON_PACKAGE_INSTALLER = "installer" +internal const val JSON_PACKAGE_SIGNATURES = "signatures" internal class DecryptionFailedException(cause: Throwable) : Exception(cause) -class EncryptedBackupMetadata private constructor(val token: Long, val inputStream: InputStream?, val error: Boolean) { +class EncryptedBackupMetadata private constructor( + val token: Long, + val inputStream: InputStream?, + val error: Boolean) { + constructor(token: Long, inputStream: InputStream) : this(token, inputStream, false) /** * Indicates that there was an error retrieving the encrypted backup metadata. diff --git a/app/src/main/java/com/stevesoltys/seedvault/metadata/MetadataReader.kt b/app/src/main/java/com/stevesoltys/seedvault/metadata/MetadataReader.kt index a8218cc7..2f53449e 100644 --- a/app/src/main/java/com/stevesoltys/seedvault/metadata/MetadataReader.kt +++ b/app/src/main/java/com/stevesoltys/seedvault/metadata/MetadataReader.kt @@ -43,19 +43,45 @@ internal class MetadataReaderImpl(private val crypto: Crypto) : MetadataReader { // matches the authenticated version and token in the JSON. try { val json = JSONObject(bytes.toString(Utf8)) - val version = json.getInt(JSON_VERSION).toByte() + // get backup metadata and check expectations + val meta = json.getJSONObject(JSON_METADATA) + val version = meta.getInt(JSON_METADATA_VERSION).toByte() if (version != expectedVersion) { throw SecurityException("Invalid version '${version.toInt()}' in metadata, expected '${expectedVersion.toInt()}'.") } - val token = json.getLong(JSON_TOKEN) + val token = meta.getLong(JSON_METADATA_TOKEN) if (token != expectedToken) { throw SecurityException("Invalid token '$token' in metadata, expected '$expectedToken'.") } + // get package metadata + val packageMetadata: HashMap = HashMap() + for (packageName in json.keys()) { + if (packageName == JSON_METADATA) continue + val p = json.getJSONObject(packageName) + val pVersion = p.optLong(JSON_PACKAGE_VERSION, 0L) + val pInstaller = p.optString(JSON_PACKAGE_INSTALLER, "") + val pSignatures = p.optJSONArray(JSON_PACKAGE_SIGNATURES) + val signatures = if (pSignatures == null) null else + ArrayList(pSignatures.length()).apply { + for (i in (0 until pSignatures.length())) { + add(pSignatures.getString(i)) + } + } + packageMetadata[packageName] = PackageMetadata( + time = p.getLong(JSON_PACKAGE_TIME), + version = if (pVersion == 0L) null else pVersion, + installer = if (pInstaller == "") null else pInstaller, + signatures = signatures + ) + } return BackupMetadata( version = version, token = token, - androidVersion = json.getInt(JSON_ANDROID_VERSION), - deviceName = json.getString(JSON_DEVICE_NAME) + time = meta.getLong(JSON_METADATA_TIME), + androidVersion = meta.getInt(JSON_METADATA_SDK_INT), + androidIncremental = meta.getString(JSON_METADATA_INCREMENTAL), + deviceName = meta.getString(JSON_METADATA_NAME), + packageMetadata = packageMetadata ) } catch (e: JSONException) { throw SecurityException(e) diff --git a/app/src/main/java/com/stevesoltys/seedvault/metadata/MetadataWriter.kt b/app/src/main/java/com/stevesoltys/seedvault/metadata/MetadataWriter.kt index 809425e6..55ceead3 100644 --- a/app/src/main/java/com/stevesoltys/seedvault/metadata/MetadataWriter.kt +++ b/app/src/main/java/com/stevesoltys/seedvault/metadata/MetadataWriter.kt @@ -3,6 +3,7 @@ package com.stevesoltys.seedvault.metadata import androidx.annotation.VisibleForTesting import com.stevesoltys.seedvault.Utf8 import com.stevesoltys.seedvault.crypto.Crypto +import org.json.JSONArray import org.json.JSONObject import java.io.IOException import java.io.OutputStream @@ -25,11 +26,24 @@ internal class MetadataWriterImpl(private val crypto: Crypto): MetadataWriter { @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) + val json = JSONObject().apply { + put(JSON_METADATA, JSONObject().apply { + put(JSON_METADATA_VERSION, metadata.version.toInt()) + put(JSON_METADATA_TOKEN, metadata.token) + put(JSON_METADATA_TIME, metadata.time) + put(JSON_METADATA_SDK_INT, metadata.androidVersion) + put(JSON_METADATA_INCREMENTAL, metadata.androidIncremental) + put(JSON_METADATA_NAME, metadata.deviceName) + }) + } + for ((packageName, packageMetadata) in metadata.packageMetadata) { + json.put(packageName, JSONObject().apply { + put(JSON_PACKAGE_TIME, packageMetadata.time) + packageMetadata.version?.let { put(JSON_PACKAGE_VERSION, it) } + packageMetadata.installer?.let { put(JSON_PACKAGE_INSTALLER, it) } + packageMetadata.signatures?.let { put(JSON_PACKAGE_SIGNATURES, JSONArray(it)) } + }) + } return json.toString().toByteArray(Utf8) } diff --git a/app/src/main/java/com/stevesoltys/seedvault/plugins/saf/DocumentsStorage.kt b/app/src/main/java/com/stevesoltys/seedvault/plugins/saf/DocumentsStorage.kt index e57658fb..567be9d8 100644 --- a/app/src/main/java/com/stevesoltys/seedvault/plugins/saf/DocumentsStorage.kt +++ b/app/src/main/java/com/stevesoltys/seedvault/plugins/saf/DocumentsStorage.kt @@ -17,7 +17,7 @@ import java.io.InputStream import java.io.OutputStream import java.util.concurrent.TimeUnit.MINUTES -const val DIRECTORY_ROOT = ".AndroidBackup" +const val DIRECTORY_ROOT = ".SeedVaultAndroidBackup" const val DIRECTORY_FULL_BACKUP = "full" const val DIRECTORY_KEY_VALUE_BACKUP = "kv" const val FILE_BACKUP_METADATA = ".backup.metadata" diff --git a/app/src/test/java/com/stevesoltys/seedvault/metadata/MetadataReaderTest.kt b/app/src/test/java/com/stevesoltys/seedvault/metadata/MetadataReaderTest.kt index e478bd23..c38e6d78 100644 --- a/app/src/test/java/com/stevesoltys/seedvault/metadata/MetadataReaderTest.kt +++ b/app/src/test/java/com/stevesoltys/seedvault/metadata/MetadataReaderTest.kt @@ -4,8 +4,9 @@ import com.stevesoltys.seedvault.Utf8 import com.stevesoltys.seedvault.crypto.Crypto import com.stevesoltys.seedvault.getRandomString import io.mockk.mockk +import org.json.JSONArray import org.json.JSONObject -import org.junit.jupiter.api.Assertions.assertThrows +import org.junit.jupiter.api.Assertions.* import org.junit.jupiter.api.Test import org.junit.jupiter.api.TestInstance import org.junit.jupiter.api.TestInstance.Lifecycle.PER_CLASS @@ -19,12 +20,7 @@ class MetadataReaderTest { private val encoder = MetadataWriterImpl(crypto) private val decoder = MetadataReaderImpl(crypto) - private val metadata = BackupMetadata( - version = 1.toByte(), - token = Random.nextLong(), - androidVersion = Random.nextInt(), - deviceName = getRandomString() - ) + private val metadata = getMetadata() private val metadataByteArray = encoder.encode(metadata) @Test @@ -55,10 +51,13 @@ class MetadataReaderTest { @Test fun `missing fields throws SecurityException`() { - val json = JSONObject() - json.put(JSON_VERSION, metadata.version.toInt()) - json.put(JSON_TOKEN, metadata.token) - json.put(JSON_ANDROID_VERSION, metadata.androidVersion) + val json = JSONObject().apply { + put(JSON_METADATA, JSONObject().apply { + put(JSON_METADATA_VERSION, metadata.version.toInt()) + put(JSON_METADATA_TOKEN, metadata.token) + put(JSON_METADATA_SDK_INT, metadata.androidVersion) + }) + } val jsonBytes = json.toString().toByteArray(Utf8) assertThrows(SecurityException::class.java) { @@ -66,4 +65,73 @@ class MetadataReaderTest { } } + @Test + fun `missing meta throws SecurityException`() { + val json = JSONObject().apply { + put("foo", "bat") + } + val jsonBytes = json.toString().toByteArray(Utf8) + + assertThrows(SecurityException::class.java) { + decoder.decode(jsonBytes, metadata.version, metadata.token) + } + } + + @Test + fun `package metadata gets read`() { + val packageMetadata = HashMap().apply { + put("org.example", PackageMetadata( + time = Random.nextLong(), + version = Random.nextLong(), + installer = getRandomString(), + signatures = listOf(getRandomString(), getRandomString()) + )) + } + val metadata = getMetadata(packageMetadata) + val metadataByteArray = encoder.encode(metadata) + decoder.decode(metadataByteArray, metadata.version, metadata.token) + } + + @Test + fun `package metadata with missing time throws`() { + val json = JSONObject(metadataByteArray.toString(Utf8)) + json.put("org.example", JSONObject().apply { + put(JSON_PACKAGE_VERSION, Random.nextLong()) + put(JSON_PACKAGE_INSTALLER, getRandomString()) + put(JSON_PACKAGE_SIGNATURES, JSONArray(listOf(getRandomString(), getRandomString()))) + }) + val jsonBytes = json.toString().toByteArray(Utf8) + assertThrows(SecurityException::class.java) { + decoder.decode(jsonBytes, metadata.version, metadata.token) + } + } + + @Test + fun `package metadata can only include time`() { + val json = JSONObject(metadataByteArray.toString(Utf8)) + json.put("org.example", JSONObject().apply { + put(JSON_PACKAGE_TIME, Random.nextLong()) + }) + val jsonBytes = json.toString().toByteArray(Utf8) + val result = decoder.decode(jsonBytes, metadata.version, metadata.token) + + assertEquals(1, result.packageMetadata.size) + val packageMetadata = result.packageMetadata.getOrElse("org.example") { fail() } + assertNull(packageMetadata.version) + assertNull(packageMetadata.installer) + assertNull(packageMetadata.signatures) + } + + private fun getMetadata(packageMetadata: Map = HashMap()): BackupMetadata { + return BackupMetadata( + version = 1.toByte(), + token = Random.nextLong(), + time = Random.nextLong(), + androidVersion = Random.nextInt(), + androidIncremental = getRandomString(), + deviceName = getRandomString(), + packageMetadata = packageMetadata + ) + } + } diff --git a/app/src/test/java/com/stevesoltys/seedvault/metadata/MetadataWriterDecoderTest.kt b/app/src/test/java/com/stevesoltys/seedvault/metadata/MetadataWriterDecoderTest.kt index 33dc9886..b525d181 100644 --- a/app/src/test/java/com/stevesoltys/seedvault/metadata/MetadataWriterDecoderTest.kt +++ b/app/src/test/java/com/stevesoltys/seedvault/metadata/MetadataWriterDecoderTest.kt @@ -17,16 +17,65 @@ internal class MetadataWriterDecoderTest { private val encoder = MetadataWriterImpl(crypto) private val decoder = MetadataReaderImpl(crypto) - private val metadata = BackupMetadata( - version = Random.nextBytes(1)[0], - token = Random.nextLong(), - androidVersion = Random.nextInt(), - deviceName = getRandomString() - ) - @Test - fun `encoded metadata matches decoded metadata`() { + fun `encoded metadata matches decoded metadata (no packages)`() { + val metadata = getMetadata() assertEquals(metadata, decoder.decode(encoder.encode(metadata), metadata.version, metadata.token)) } + @Test + fun `encoded metadata matches decoded metadata (with package, no apk info)`() { + val time = Random.nextLong() + val packages = HashMap().apply { + put(getRandomString(), PackageMetadata(time)) + } + val metadata = getMetadata(packages) + assertEquals(metadata, decoder.decode(encoder.encode(metadata), metadata.version, metadata.token)) + } + + @Test + fun `encoded metadata matches decoded metadata (full package)`() { + val packages = HashMap().apply { + put(getRandomString(), PackageMetadata( + time = Random.nextLong(), + version = Random.nextLong(), + installer = getRandomString(), + signatures = listOf(getRandomString(), getRandomString()))) + } + val metadata = getMetadata(packages) + assertEquals(metadata, decoder.decode(encoder.encode(metadata), metadata.version, metadata.token)) + } + + @Test + fun `encoded metadata matches decoded metadata (two full packages)`() { + val packages = HashMap().apply { + put(getRandomString(), PackageMetadata( + time = Random.nextLong(), + version = Random.nextLong(), + installer = getRandomString(), + signatures = listOf(getRandomString()) + )) + put(getRandomString(), PackageMetadata( + time = Random.nextLong(), + version = Random.nextLong(), + installer = getRandomString(), + signatures = listOf(getRandomString(), getRandomString()) + )) + } + val metadata = getMetadata(packages) + assertEquals(metadata, decoder.decode(encoder.encode(metadata), metadata.version, metadata.token)) + } + + private fun getMetadata(packageMetadata: Map = HashMap()): BackupMetadata { + return BackupMetadata( + version = Random.nextBytes(1)[0], + token = Random.nextLong(), + time = Random.nextLong(), + androidVersion = Random.nextInt(), + androidIncremental = getRandomString(), + deviceName = getRandomString(), + packageMetadata = packageMetadata + ) + } + } diff --git a/app/src/test/java/com/stevesoltys/seedvault/transport/restore/RestoreCoordinatorTest.kt b/app/src/test/java/com/stevesoltys/seedvault/transport/restore/RestoreCoordinatorTest.kt index b857d785..313d4fb9 100644 --- a/app/src/test/java/com/stevesoltys/seedvault/transport/restore/RestoreCoordinatorTest.kt +++ b/app/src/test/java/com/stevesoltys/seedvault/transport/restore/RestoreCoordinatorTest.kt @@ -41,6 +41,7 @@ internal class RestoreCoordinatorTest : TransportTest() { val metadata = BackupMetadata( token = token, androidVersion = Random.nextInt(), + androidIncremental = getRandomString(), deviceName = getRandomString()) every { plugin.getAvailableBackups() } returns sequenceOf(encryptedMetadata, encryptedMetadata)