From 1efa8e8f5983cf3df3601331b80b80ff6dec1b6e Mon Sep 17 00:00:00 2001 From: Torsten Grote Date: Fri, 6 Sep 2024 09:09:35 -0300 Subject: [PATCH] Add prototype plumbing for new v2 app backup --- .idea/codeStyles/Project.xml | 7 +- Android.bp | 3 + app/build.gradle.kts | 5 + .../seedvault/restore/RestorableBackup.kt | 7 +- .../seedvault/transport/SnapshotManager.kt | 83 +++++++++++ .../transport/backup/AppBackupManager.kt | 51 +++++++ .../transport/backup/BackupModule.kt | 6 + .../transport/backup/BackupReceiver.kt | 92 ++++++++++++ .../seedvault/transport/backup/BlobCreator.kt | 52 +++++++ .../seedvault/transport/backup/BlobsCache.kt | 69 +++++++++ .../transport/backup/SnapshotCreator.kt | 132 ++++++++++++++++++ .../seedvault/transport/restore/Loader.kt | 71 ++++++++++ .../transport/restore/RestoreModule.kt | 1 + .../seedvault/worker/WorkerModule.kt | 2 + .../transport/SnapshotManagerTest.kt | 100 +++++++++++++ .../transport/backup/BlobCreatorTest.kt | 71 ++++++++++ libs/Android.bp | 6 + libs/seedvault-chunker-0.1.jar | Bin 0 -> 21309 bytes 18 files changed, 751 insertions(+), 7 deletions(-) create mode 100644 app/src/main/java/com/stevesoltys/seedvault/transport/SnapshotManager.kt create mode 100644 app/src/main/java/com/stevesoltys/seedvault/transport/backup/AppBackupManager.kt create mode 100644 app/src/main/java/com/stevesoltys/seedvault/transport/backup/BackupReceiver.kt create mode 100644 app/src/main/java/com/stevesoltys/seedvault/transport/backup/BlobCreator.kt create mode 100644 app/src/main/java/com/stevesoltys/seedvault/transport/backup/BlobsCache.kt create mode 100644 app/src/main/java/com/stevesoltys/seedvault/transport/backup/SnapshotCreator.kt create mode 100644 app/src/main/java/com/stevesoltys/seedvault/transport/restore/Loader.kt create mode 100644 app/src/test/java/com/stevesoltys/seedvault/transport/SnapshotManagerTest.kt create mode 100644 app/src/test/java/com/stevesoltys/seedvault/transport/backup/BlobCreatorTest.kt create mode 100644 libs/seedvault-chunker-0.1.jar diff --git a/.idea/codeStyles/Project.xml b/.idea/codeStyles/Project.xml index 29332138..027ef2be 100644 --- a/.idea/codeStyles/Project.xml +++ b/.idea/codeStyles/Project.xml @@ -1,12 +1,7 @@ - - diff --git a/Android.bp b/Android.bp index db349e26..e5cfe7a0 100644 --- a/Android.bp +++ b/Android.bp @@ -32,6 +32,9 @@ android_app { "com.google.android.material_material", "kotlinx-coroutines-android", "kotlinx-coroutines-core", + // app backup related libs + "seedvault-lib-kotlin-logging-jvm", + "seedvault-lib-chunker", "seedvault-lib-zstd-jni", // our own gradle module libs "seedvault-lib-core", diff --git a/app/build.gradle.kts b/app/build.gradle.kts index 8af4a16a..22e6af61 100644 --- a/app/build.gradle.kts +++ b/app/build.gradle.kts @@ -157,6 +157,8 @@ dependencies { implementation(libs.google.protobuf.javalite) implementation(libs.google.tink.android) + implementation(libs.kotlin.logging) + implementation(libs.squareup.okio) /** * Storage Dependencies @@ -175,6 +177,7 @@ dependencies { implementation(fileTree("${rootProject.rootDir}/libs/koin-android").include("*.jar")) implementation(fileTree("${rootProject.rootDir}/libs/koin-android").include("*.aar")) + implementation(fileTree("${rootProject.rootDir}/libs").include("seedvault-chunker-0.1.jar")) implementation(fileTree("${rootProject.rootDir}/libs").include("zstd-jni-1.5.6-5.aar")) implementation(fileTree("${rootProject.rootDir}/libs").include("kotlin-bip39-jvm-1.0.6.jar")) @@ -188,6 +191,7 @@ dependencies { // anything less than 'implementation' fails tests run with gradlew testImplementation(aospLibs) testImplementation("androidx.test.ext:junit:1.1.5") + testImplementation("org.slf4j:slf4j-simple:2.0.3") testImplementation("org.robolectric:robolectric:4.12.2") testImplementation("org.hamcrest:hamcrest:2.2") testImplementation("org.junit.jupiter:junit-jupiter-api:${libs.versions.junit5.get()}") @@ -198,6 +202,7 @@ dependencies { ) testImplementation("app.cash.turbine:turbine:1.0.0") testImplementation("org.bitcoinj:bitcoinj-core:0.16.2") + testImplementation("com.github.luben:zstd-jni:1.5.6-5") testRuntimeOnly("org.junit.jupiter:junit-jupiter-engine:${libs.versions.junit5.get()}") testRuntimeOnly("org.junit.vintage:junit-vintage-engine:${libs.versions.junit5.get()}") diff --git a/app/src/main/java/com/stevesoltys/seedvault/restore/RestorableBackup.kt b/app/src/main/java/com/stevesoltys/seedvault/restore/RestorableBackup.kt index a895f91a..f47c7a81 100644 --- a/app/src/main/java/com/stevesoltys/seedvault/restore/RestorableBackup.kt +++ b/app/src/main/java/com/stevesoltys/seedvault/restore/RestorableBackup.kt @@ -7,8 +7,13 @@ package com.stevesoltys.seedvault.restore import com.stevesoltys.seedvault.metadata.BackupMetadata import com.stevesoltys.seedvault.metadata.PackageMetadataMap +import com.stevesoltys.seedvault.proto.Snapshot -data class RestorableBackup(val backupMetadata: BackupMetadata) { +data class RestorableBackup( + val backupMetadata: BackupMetadata, + val repoId: String? = null, + val snapshot: Snapshot? = null, +) { val name: String get() = backupMetadata.deviceName diff --git a/app/src/main/java/com/stevesoltys/seedvault/transport/SnapshotManager.kt b/app/src/main/java/com/stevesoltys/seedvault/transport/SnapshotManager.kt new file mode 100644 index 00000000..a6480dd3 --- /dev/null +++ b/app/src/main/java/com/stevesoltys/seedvault/transport/SnapshotManager.kt @@ -0,0 +1,83 @@ +/* + * SPDX-FileCopyrightText: 2024 The Calyx Institute + * SPDX-License-Identifier: Apache-2.0 + */ + +package com.stevesoltys.seedvault.transport + +import com.github.luben.zstd.ZstdOutputStream +import com.stevesoltys.seedvault.backend.BackendManager +import com.stevesoltys.seedvault.crypto.Crypto +import com.stevesoltys.seedvault.header.VERSION +import com.stevesoltys.seedvault.proto.Snapshot +import com.stevesoltys.seedvault.transport.restore.Loader +import io.github.oshai.kotlinlogging.KotlinLogging +import okio.Buffer +import okio.buffer +import okio.sink +import org.calyxos.seedvault.core.backends.AppBackupFileType +import org.calyxos.seedvault.core.backends.TopLevelFolder + +internal class SnapshotManager( + private val crypto: Crypto, + private val loader: Loader, + private val backendManager: BackendManager, +) { + + private val log = KotlinLogging.logger {} + + /** + * The latest [Snapshot]. May be stale if [loadSnapshots] has not returned + * or wasn't called since new snapshots have been created. + */ + var latestSnapshot: Snapshot? = null + private set + + suspend fun loadSnapshots(callback: (Snapshot) -> Unit) { + log.info { "Loading snapshots..." } + val handles = mutableListOf() + backendManager.backend.list( + topLevelFolder = TopLevelFolder(crypto.repoId), + AppBackupFileType.Snapshot::class, + ) { fileInfo -> + fileInfo.fileHandle as AppBackupFileType.Snapshot + handles.add(fileInfo.fileHandle as AppBackupFileType.Snapshot) + } + handles.forEach { fileHandle -> + // TODO is it a fatal error when one snapshot is corrupted or couldn't get loaded? + val snapshot = onSnapshotFound(fileHandle) + callback(snapshot) + } + } + + private suspend fun onSnapshotFound(snapshotHandle: AppBackupFileType.Snapshot): Snapshot { + // TODO set up local snapshot cache, so we don't need to download those all the time + val snapshot = loader.loadFile(snapshotHandle).use { inputStream -> + Snapshot.parseFrom(inputStream) + } + // update latest snapshot if this one is more recent + if (snapshot.token > (latestSnapshot?.token ?: 0)) latestSnapshot = snapshot + return snapshot + } + + suspend fun saveSnapshot(snapshot: Snapshot) { + val buffer = Buffer() + val bufferStream = buffer.outputStream() + bufferStream.write(VERSION.toInt()) + crypto.newEncryptingStream(bufferStream, crypto.getAdForVersion()).use { cryptoStream -> + ZstdOutputStream(cryptoStream).use { zstdOutputStream -> + snapshot.writeTo(zstdOutputStream) + } + } + val sha256ByteString = buffer.sha256() + val handle = AppBackupFileType.Snapshot(crypto.repoId, sha256ByteString.hex()) + // TODO exception handling + backendManager.backend.save(handle).use { outputStream -> + outputStream.sink().buffer().apply { + writeAll(buffer) + flush() // needs flushing + } + } + } + +} diff --git a/app/src/main/java/com/stevesoltys/seedvault/transport/backup/AppBackupManager.kt b/app/src/main/java/com/stevesoltys/seedvault/transport/backup/AppBackupManager.kt new file mode 100644 index 00000000..7949403e --- /dev/null +++ b/app/src/main/java/com/stevesoltys/seedvault/transport/backup/AppBackupManager.kt @@ -0,0 +1,51 @@ +/* + * SPDX-FileCopyrightText: 2024 The Calyx Institute + * SPDX-License-Identifier: Apache-2.0 + */ + +package com.stevesoltys.seedvault.transport.backup + +import com.stevesoltys.seedvault.transport.SnapshotManager +import io.github.oshai.kotlinlogging.KotlinLogging +import kotlinx.coroutines.delay + +internal class AppBackupManager( + private val blobsCache: BlobsCache, + private val snapshotManager: SnapshotManager, + private val snapshotCreatorFactory: SnapshotCreatorFactory, +) { + + private val log = KotlinLogging.logger {} + var snapshotCreator: SnapshotCreator? = null + private set + + suspend fun beforeBackup() { + log.info { "Before backup" } + snapshotCreator = snapshotCreatorFactory.createSnapshotCreator() + blobsCache.populateCache() + } + + suspend fun afterBackupFinished() { + log.info { "After backup finished" } + blobsCache.clear() + val snapshot = snapshotCreator?.finalizeSnapshot() ?: error("Had no snapshotCreator") + keepTrying { + snapshotManager.saveSnapshot(snapshot) + } + snapshotCreator = null + } + + private suspend fun keepTrying(n: Int = 3, block: suspend () -> Unit) { + for (i in 1..n) { + try { + block() + return + } catch (e: Exception) { + if (i == n) throw e + log.error(e) { "Error (#$i), we'll keep trying" } + delay(1000) + } + } + } + +} diff --git a/app/src/main/java/com/stevesoltys/seedvault/transport/backup/BackupModule.kt b/app/src/main/java/com/stevesoltys/seedvault/transport/backup/BackupModule.kt index bc18e0cb..206bee0d 100644 --- a/app/src/main/java/com/stevesoltys/seedvault/transport/backup/BackupModule.kt +++ b/app/src/main/java/com/stevesoltys/seedvault/transport/backup/BackupModule.kt @@ -5,11 +5,17 @@ package com.stevesoltys.seedvault.transport.backup +import com.stevesoltys.seedvault.transport.SnapshotManager import org.koin.android.ext.koin.androidContext import org.koin.dsl.module val backupModule = module { single { BackupInitializer(get()) } + single { BackupReceiver(get(), get(), get()) } + single { BlobsCache(get(), get(), get()) } + single { BlobCreator(get(), get()) } + single { SnapshotManager(get(), get(), get()) } + single { SnapshotCreatorFactory(androidContext(), get(), get(), get()) } single { InputFactory() } single { PackageService( diff --git a/app/src/main/java/com/stevesoltys/seedvault/transport/backup/BackupReceiver.kt b/app/src/main/java/com/stevesoltys/seedvault/transport/backup/BackupReceiver.kt new file mode 100644 index 00000000..38292fb2 --- /dev/null +++ b/app/src/main/java/com/stevesoltys/seedvault/transport/backup/BackupReceiver.kt @@ -0,0 +1,92 @@ +/* + * SPDX-FileCopyrightText: 2024 The Calyx Institute + * SPDX-License-Identifier: Apache-2.0 + */ + +package com.stevesoltys.seedvault.transport.backup + +import com.stevesoltys.seedvault.crypto.Crypto +import com.stevesoltys.seedvault.proto.Snapshot.Blob +import org.calyxos.seedvault.chunker.Chunk +import org.calyxos.seedvault.chunker.Chunker +import org.calyxos.seedvault.chunker.GearTableCreator +import org.calyxos.seedvault.core.toHexString +import java.io.InputStream + +data class BackupData( + val chunks: List, + val chunkMap: Map, +) + +internal class BackupReceiver( + private val blobsCache: BlobsCache, + private val blobCreator: BlobCreator, + private val crypto: Crypto, + private val replaceableChunker: Chunker? = null, +) { + + private val chunker: Chunker by lazy { + // crypto.gearTableKey is not available at creation time, so use lazy instantiation + replaceableChunker ?: Chunker( + minSize = 1536 * 1024, // 1.5 MB + avgSize = 3 * 1024 * 1024, // 3.0 MB + maxSize = 7680 * 1024, // 7.5 MB + normalization = 1, + gearTable = GearTableCreator.create(crypto.gearTableKey), + hashFunction = { bytes -> + crypto.sha256(bytes).toHexString() + }, + ) + } + private val chunks = mutableListOf() + private val chunkMap = mutableMapOf() + + suspend fun addBytes(bytes: ByteArray) { + chunker.addBytes(bytes).forEach { chunk -> + onNewChunk(chunk) + } + } + + suspend fun readFromStream(inputStream: InputStream) { + try { + val buffer = ByteArray(DEFAULT_BUFFER_SIZE) + var bytes = inputStream.read(buffer) + while (bytes >= 0) { + if (bytes == buffer.size) { + addBytes(buffer) + } else { + addBytes(buffer.copyOfRange(0, bytes)) + } + bytes = inputStream.read(buffer) + } + } catch (e: Exception) { + finalize() + throw e + } + } + + suspend fun finalize(): BackupData { + chunker.finalize().forEach { chunk -> + onNewChunk(chunk) + } + // copy chunks and chunkMap before clearing + val backupData = BackupData(chunks.toList(), chunkMap.toMap()) + chunks.clear() + chunkMap.clear() + return backupData + } + + private suspend fun onNewChunk(chunk: Chunk) { + chunks.add(chunk.hash) + + val existingBlob = blobsCache.getBlob(chunk.hash) + if (existingBlob == null) { + val blob = blobCreator.createNewBlob(chunk) + chunkMap[chunk.hash] = blob + blobsCache.saveNewBlob(chunk.hash, blob) + } else { + chunkMap[chunk.hash] = existingBlob + } + } + +} diff --git a/app/src/main/java/com/stevesoltys/seedvault/transport/backup/BlobCreator.kt b/app/src/main/java/com/stevesoltys/seedvault/transport/backup/BlobCreator.kt new file mode 100644 index 00000000..1a86158b --- /dev/null +++ b/app/src/main/java/com/stevesoltys/seedvault/transport/backup/BlobCreator.kt @@ -0,0 +1,52 @@ +/* + * SPDX-FileCopyrightText: 2024 The Calyx Institute + * SPDX-License-Identifier: Apache-2.0 + */ + +package com.stevesoltys.seedvault.transport.backup + +import com.github.luben.zstd.ZstdOutputStream +import com.google.protobuf.ByteString +import com.stevesoltys.seedvault.backend.BackendManager +import com.stevesoltys.seedvault.crypto.Crypto +import com.stevesoltys.seedvault.header.VERSION +import com.stevesoltys.seedvault.proto.Snapshot.Blob +import okio.Buffer +import okio.buffer +import okio.sink +import org.calyxos.seedvault.chunker.Chunk +import org.calyxos.seedvault.core.backends.AppBackupFileType + +internal class BlobCreator( + private val crypto: Crypto, + private val backendManager: BackendManager, +) { + + private val buffer = Buffer() + + suspend fun createNewBlob(chunk: Chunk): Blob { + buffer.clear() + val bufferStream = buffer.outputStream() + bufferStream.write(VERSION.toInt()) + crypto.newEncryptingStream(bufferStream, crypto.getAdForVersion()).use { cryptoStream -> + ZstdOutputStream(cryptoStream).use { zstdOutputStream -> + zstdOutputStream.write(chunk.data) + } + } + val sha256ByteString = buffer.sha256() + val handle = AppBackupFileType.Blob(crypto.repoId, sha256ByteString.hex()) + // TODO exception handling and retries + val size = backendManager.backend.save(handle).use { outputStream -> + val outputBuffer = outputStream.sink().buffer() + val length = outputBuffer.writeAll(buffer) + // flushing is important here, otherwise data doesn't get fully written! + outputBuffer.flush() + length + } + return Blob.newBuilder() + .setId(ByteString.copyFrom(sha256ByteString.asByteBuffer())) + .setLength(size.toInt()) + .setUncompressedLength(chunk.length) + .build() + } +} diff --git a/app/src/main/java/com/stevesoltys/seedvault/transport/backup/BlobsCache.kt b/app/src/main/java/com/stevesoltys/seedvault/transport/backup/BlobsCache.kt new file mode 100644 index 00000000..9d7f715e --- /dev/null +++ b/app/src/main/java/com/stevesoltys/seedvault/transport/backup/BlobsCache.kt @@ -0,0 +1,69 @@ +/* + * SPDX-FileCopyrightText: 2024 The Calyx Institute + * SPDX-License-Identifier: Apache-2.0 + */ + +package com.stevesoltys.seedvault.transport.backup + +import com.stevesoltys.seedvault.backend.BackendManager +import com.stevesoltys.seedvault.crypto.Crypto +import com.stevesoltys.seedvault.proto.Snapshot.Blob +import com.stevesoltys.seedvault.transport.SnapshotManager +import io.github.oshai.kotlinlogging.KotlinLogging +import org.calyxos.seedvault.core.backends.AppBackupFileType +import org.calyxos.seedvault.core.backends.TopLevelFolder + +internal class BlobsCache( + private val crypto: Crypto, + private val backendManager: BackendManager, + private val snapshotManager: SnapshotManager, +) { + + private val log = KotlinLogging.logger {} + private val blobMap = mutableMapOf() + + /** + * This must be called before saving files to the backend to avoid uploading duplicate blobs. + */ + suspend fun populateCache() { + log.info { "Getting all blobs from backend..." } + blobMap.clear() + val blobs = mutableSetOf() + backendManager.backend.list( + topLevelFolder = TopLevelFolder(crypto.repoId), + AppBackupFileType.Blob::class, + ) { fileInfo -> + fileInfo.fileHandle as AppBackupFileType.Blob + // TODO we could save size info here and later check it is as expected + blobs.add(fileInfo.fileHandle.name) + } + snapshotManager.loadSnapshots { snapshot -> + snapshot.blobsMap.forEach { (chunkId, blob) -> + // check if referenced blob still exists on backend + if (blobs.contains(blob.id.hexFromProto())) { + // only add blob to our mapping, if it still exists + blobMap.putIfAbsent(chunkId, blob)?.let { previous -> + if (previous.id != blob.id) log.warn { + "Chunk ID ${chunkId.substring(0..5)} had more than one blob" + } + } + } else log.warn { + "Blob ${blob.id.hexFromProto()} referenced in snapshot ${snapshot.token}" + } + } + } + } + + fun getBlob(hash: String): Blob? = blobMap[hash] + + fun saveNewBlob(chunkId: String, blob: Blob) { + blobMap[chunkId] = blob + // TODO persist this blob locally in case backup gets interrupted + } + + fun clear() { + log.info { "Clearing cache..." } + blobMap.clear() + } + +} diff --git a/app/src/main/java/com/stevesoltys/seedvault/transport/backup/SnapshotCreator.kt b/app/src/main/java/com/stevesoltys/seedvault/transport/backup/SnapshotCreator.kt new file mode 100644 index 00000000..adbf64eb --- /dev/null +++ b/app/src/main/java/com/stevesoltys/seedvault/transport/backup/SnapshotCreator.kt @@ -0,0 +1,132 @@ +/* + * SPDX-FileCopyrightText: 2024 The Calyx Institute + * SPDX-License-Identifier: Apache-2.0 + */ + +package com.stevesoltys.seedvault.transport.backup + +import android.annotation.SuppressLint +import android.content.Context +import android.content.pm.PackageInfo +import android.content.pm.PackageManager.PERMISSION_GRANTED +import android.os.Build +import android.os.UserManager +import android.provider.Settings +import android.provider.Settings.Secure.ANDROID_ID +import com.google.protobuf.ByteString +import com.stevesoltys.seedvault.Clock +import com.stevesoltys.seedvault.metadata.BackupType +import com.stevesoltys.seedvault.metadata.PackageState.APK_AND_DATA +import com.stevesoltys.seedvault.proto.Snapshot +import com.stevesoltys.seedvault.proto.Snapshot.Apk +import com.stevesoltys.seedvault.proto.Snapshot.App +import com.stevesoltys.seedvault.proto.Snapshot.Blob +import com.stevesoltys.seedvault.settings.SettingsManager +import org.calyxos.seedvault.core.toHexString + +internal class SnapshotCreatorFactory( + private val context: Context, + private val clock: Clock, + private val packageService: PackageService, + private val settingsManager: SettingsManager, +) { + fun createSnapshotCreator() = SnapshotCreator(context, clock, packageService, settingsManager) +} + +internal class SnapshotCreator( + private val context: Context, + private val clock: Clock, + private val packageService: PackageService, + private val settingsManager: SettingsManager, +) { + + private val snapshotBuilder = Snapshot.newBuilder() + .setToken(clock.time()) + private val appBuilderMap = mutableMapOf() + private val blobsMap = mutableMapOf() + + private val launchableSystemApps by lazy { + packageService.launchableSystemApps.map { it.activityInfo.packageName }.toSet() + } + + fun onApkBackedUp( + packageName: String, + apk: Apk, + chunkMap: Map, + ) { + val appBuilder = appBuilderMap.getOrPut(packageName) { + App.newBuilder() + } + appBuilder.setApk(apk) + blobsMap.putAll(chunkMap) + } + + fun onPackageBackedUp( + packageInfo: PackageInfo, + type: BackupType, + backupData: BackupData, + ) { + val packageName = packageInfo.packageName + val builder = appBuilderMap.getOrPut(packageName) { + App.newBuilder() + } + val isSystemApp = packageInfo.isSystemApp() + val chunkIds = backupData.chunks.forProto() + blobsMap.putAll(backupData.chunkMap) + builder + .setTime(clock.time()) + .setState(APK_AND_DATA.name) + .setType(type.forSnapshot()) + .setName(packageInfo.applicationInfo?.loadLabel(context.packageManager)?.toString()) + .setSystem(isSystemApp) + .setLaunchableSystemApp(isSystemApp && launchableSystemApps.contains(packageName)) + .addAllChunkIds(chunkIds) + } + + fun onIconsBackedUp(backupData: BackupData) { + snapshotBuilder.addAllIconChunkIds(backupData.chunks.forProto()) + blobsMap.putAll(backupData.chunkMap) + } + + fun finalizeSnapshot(): Snapshot { + val userName = getUserName() + val deviceName = if (userName == null) { + "${Build.MANUFACTURER} ${Build.MODEL}" + } else { + "${Build.MANUFACTURER} ${Build.MODEL} - $userName" + } + + @SuppressLint("HardwareIds") + val androidId = Settings.Secure.getString(context.contentResolver, ANDROID_ID) + val snapshot = snapshotBuilder + .setName(deviceName) + .setAndroidId(androidId) + .setSdkInt(Build.VERSION.SDK_INT) + .setAndroidIncremental(Build.VERSION.INCREMENTAL) + .setD2D(settingsManager.d2dBackupsEnabled()) + .putAllApps(appBuilderMap.mapValues { it.value.build() }) + .putAllBlobs(blobsMap) + .build() + appBuilderMap.clear() + snapshotBuilder.clear() + return snapshot + } + + private fun getUserName(): String? { + val perm = "android.permission.QUERY_USERS" + return if (context.checkSelfPermission(perm) == PERMISSION_GRANTED) { + val userManager = context.getSystemService(UserManager::class.java) ?: return null + userManager.userName + } else null + } + + private fun BackupType.forSnapshot(): Snapshot.BackupType = when (this) { + BackupType.KV -> Snapshot.BackupType.KV + BackupType.FULL -> Snapshot.BackupType.FULL + } + +} + +fun Iterable.forProto() = map { ByteString.fromHex(it) } +fun Iterable.hexFromProto() = map { it.toByteArray().toHexString() } +fun ByteString.hexFromProto() = toByteArray().toHexString() diff --git a/app/src/main/java/com/stevesoltys/seedvault/transport/restore/Loader.kt b/app/src/main/java/com/stevesoltys/seedvault/transport/restore/Loader.kt new file mode 100644 index 00000000..66a3246e --- /dev/null +++ b/app/src/main/java/com/stevesoltys/seedvault/transport/restore/Loader.kt @@ -0,0 +1,71 @@ +/* + * SPDX-FileCopyrightText: 2024 The Calyx Institute + * SPDX-License-Identifier: Apache-2.0 + */ + +package com.stevesoltys.seedvault.transport.restore + +import com.github.luben.zstd.ZstdInputStream +import com.stevesoltys.seedvault.backend.BackendManager +import com.stevesoltys.seedvault.crypto.Crypto +import com.stevesoltys.seedvault.header.UnsupportedVersionException +import com.stevesoltys.seedvault.header.VERSION +import kotlinx.coroutines.runBlocking +import org.calyxos.seedvault.core.backends.AppBackupFileType +import org.calyxos.seedvault.core.toHexString +import java.io.ByteArrayInputStream +import java.io.InputStream +import java.io.SequenceInputStream +import java.security.GeneralSecurityException +import java.util.Enumeration + +internal class Loader( + private val crypto: Crypto, + private val backendManager: BackendManager, +) { + + /** + * The responsibility with closing the returned stream lies with the caller. + */ + suspend fun loadFile(handle: AppBackupFileType): InputStream { + // We load the entire ciphertext into memory, + // so we can check the SHA-256 hash before decrypting and parsing the data. + val cipherText = backendManager.backend.load(handle).use { inputStream -> + inputStream.readAllBytes() + } + // check SHA-256 hash first thing + val sha256 = crypto.sha256(cipherText).toHexString() + val expectedHash = when (handle) { + is AppBackupFileType.Snapshot -> handle.hash + is AppBackupFileType.Blob -> handle.name + } + if (sha256 != expectedHash) { + throw GeneralSecurityException("File had wrong SHA-256 hash: $handle") + } + // check that we can handle the version of that snapshot + val version = cipherText[0] + if (version <= 1) throw GeneralSecurityException("Unexpected version: $version") + if (version > VERSION) throw UnsupportedVersionException(version) + // get associated data for version, used for authenticated decryption + val ad = crypto.getAdForVersion(version) + // skip first version byte when creating cipherText stream + val inputStream = ByteArrayInputStream(cipherText, 1, cipherText.size - 1) + // decrypt and decompress cipherText stream and parse snapshot + return ZstdInputStream(crypto.newDecryptingStream(inputStream, ad)) + } + + suspend fun loadFiles(handles: List): InputStream { + val enumeration: Enumeration = object : Enumeration { + val iterator = handles.iterator() + + override fun hasMoreElements(): Boolean { + return iterator.hasNext() + } + + override fun nextElement(): InputStream { + return runBlocking { loadFile(iterator.next()) } + } + } + return SequenceInputStream(enumeration) + } +} diff --git a/app/src/main/java/com/stevesoltys/seedvault/transport/restore/RestoreModule.kt b/app/src/main/java/com/stevesoltys/seedvault/transport/restore/RestoreModule.kt index 869e1b0c..fd1d835d 100644 --- a/app/src/main/java/com/stevesoltys/seedvault/transport/restore/RestoreModule.kt +++ b/app/src/main/java/com/stevesoltys/seedvault/transport/restore/RestoreModule.kt @@ -10,6 +10,7 @@ import org.koin.dsl.module val restoreModule = module { single { OutputFactory() } + single { Loader(get(), get()) } single { KVRestore(get(), get(), get(), get(), get(), get()) } single { FullRestore(get(), get(), get(), get(), get()) } single { diff --git a/app/src/main/java/com/stevesoltys/seedvault/worker/WorkerModule.kt b/app/src/main/java/com/stevesoltys/seedvault/worker/WorkerModule.kt index dea0b941..a41d1155 100644 --- a/app/src/main/java/com/stevesoltys/seedvault/worker/WorkerModule.kt +++ b/app/src/main/java/com/stevesoltys/seedvault/worker/WorkerModule.kt @@ -5,6 +5,7 @@ package com.stevesoltys.seedvault.worker +import com.stevesoltys.seedvault.transport.backup.AppBackupManager import org.koin.android.ext.koin.androidContext import org.koin.dsl.module @@ -23,6 +24,7 @@ val workerModule = module { crypto = get(), ) } + single { AppBackupManager(get(), get(), get()) } single { ApkBackup( pm = androidContext().packageManager, diff --git a/app/src/test/java/com/stevesoltys/seedvault/transport/SnapshotManagerTest.kt b/app/src/test/java/com/stevesoltys/seedvault/transport/SnapshotManagerTest.kt new file mode 100644 index 00000000..e1a1652b --- /dev/null +++ b/app/src/test/java/com/stevesoltys/seedvault/transport/SnapshotManagerTest.kt @@ -0,0 +1,100 @@ +/* + * SPDX-FileCopyrightText: 2024 The Calyx Institute + * SPDX-License-Identifier: Apache-2.0 + */ + +package com.stevesoltys.seedvault.transport + +import com.stevesoltys.seedvault.backend.BackendManager +import com.stevesoltys.seedvault.crypto.Crypto +import com.stevesoltys.seedvault.getRandomString +import com.stevesoltys.seedvault.proto.Snapshot +import com.stevesoltys.seedvault.transport.restore.Loader +import io.mockk.coEvery +import io.mockk.every +import io.mockk.mockk +import io.mockk.slot +import kotlinx.coroutines.runBlocking +import org.calyxos.seedvault.core.backends.AppBackupFileType +import org.calyxos.seedvault.core.backends.Backend +import org.calyxos.seedvault.core.backends.FileInfo +import org.calyxos.seedvault.core.backends.TopLevelFolder +import org.calyxos.seedvault.core.toByteArrayFromHex +import org.calyxos.seedvault.core.toHexString +import org.junit.jupiter.api.Assertions.assertEquals +import org.junit.jupiter.api.Assertions.assertTrue +import org.junit.jupiter.api.Test +import java.io.ByteArrayInputStream +import java.io.ByteArrayOutputStream +import java.io.InputStream +import java.io.OutputStream +import java.security.MessageDigest +import kotlin.random.Random + +class SnapshotManagerTest { + + private val crypto: Crypto = mockk() + private val backendManager: BackendManager = mockk() + private val backend: Backend = mockk() + + private val loader = Loader(crypto, backendManager) // need a real loader + private val snapshotManager = SnapshotManager(crypto, loader, backendManager) + + private val ad = Random.nextBytes(1) + private val passThroughOutputStream = slot() + private val passThroughInputStream = slot() + private val repoId = Random.nextBytes(32).toHexString() + private val snapshotHandle = slot() + private val snapshot = Snapshot.newBuilder() + .setToken(Random.nextLong()) + .setName(getRandomString()) + .setSdkInt(Random.nextInt()) + .putAllBlobs(mapOf(getRandomString() to Snapshot.Blob.getDefaultInstance())) + .build() + + @Test + fun `test saving and loading`() = runBlocking { + val outputStream = ByteArrayOutputStream() + + every { crypto.getAdForVersion() } returns ad + every { crypto.newEncryptingStream(capture(passThroughOutputStream), ad) } answers { + passThroughOutputStream.captured // not really encrypting here + } + every { crypto.repoId } returns repoId + every { backendManager.backend } returns backend + coEvery { backend.save(capture(snapshotHandle)) } returns outputStream + + snapshotManager.saveSnapshot(snapshot) + + // check that file content hash matches snapshot hash + val messageDigest = MessageDigest.getInstance("SHA-256") + assertEquals( + messageDigest.digest(outputStream.toByteArray()).toHexString(), + snapshotHandle.captured.hash, + ) + + val fileInfo = FileInfo(snapshotHandle.captured, Random.nextLong()) + assertTrue(outputStream.size() > 0) + val inputStream = ByteArrayInputStream(outputStream.toByteArray()) + coEvery { + backend.list( + topLevelFolder = TopLevelFolder(repoId), + AppBackupFileType.Snapshot::class, + callback = captureLambda<(FileInfo) -> Unit>() + ) + } answers { + lambda<(FileInfo) -> Unit>().captured.invoke(fileInfo) + } + coEvery { backend.load(snapshotHandle.captured) } returns inputStream + every { + crypto.sha256(outputStream.toByteArray()) + } returns snapshotHandle.captured.hash.toByteArrayFromHex() + every { crypto.newDecryptingStream(capture(passThroughInputStream), ad) } answers { + passThroughInputStream.captured + } + + var loadedSnapshot: Snapshot? = null + snapshotManager.loadSnapshots { loadedSnapshot = it } + assertEquals(snapshot, loadedSnapshot) + } +} diff --git a/app/src/test/java/com/stevesoltys/seedvault/transport/backup/BlobCreatorTest.kt b/app/src/test/java/com/stevesoltys/seedvault/transport/backup/BlobCreatorTest.kt new file mode 100644 index 00000000..3ec7fa8d --- /dev/null +++ b/app/src/test/java/com/stevesoltys/seedvault/transport/backup/BlobCreatorTest.kt @@ -0,0 +1,71 @@ +/* + * SPDX-FileCopyrightText: 2024 The Calyx Institute + * SPDX-License-Identifier: Apache-2.0 + */ + +package com.stevesoltys.seedvault.transport.backup + +import com.stevesoltys.seedvault.backend.BackendManager +import com.stevesoltys.seedvault.transport.TransportTest +import io.mockk.coEvery +import io.mockk.every +import io.mockk.mockk +import io.mockk.slot +import kotlinx.coroutines.runBlocking +import org.calyxos.seedvault.chunker.Chunk +import org.calyxos.seedvault.core.backends.AppBackupFileType +import org.calyxos.seedvault.core.backends.Backend +import org.calyxos.seedvault.core.toHexString +import org.junit.jupiter.api.Assertions.assertEquals +import org.junit.jupiter.api.Test +import java.io.ByteArrayOutputStream +import java.io.OutputStream +import java.security.MessageDigest +import kotlin.random.Random + +internal class BlobCreatorTest : TransportTest() { + + private val backendManager: BackendManager = mockk() + private val backend: Backend = mockk() + private val blobCreator = BlobCreator(crypto, backendManager) + + private val ad = Random.nextBytes(1) + private val passThroughOutputStream = slot() + private val blobHandle = slot() + + @Test + fun `test re-use for hashing two chunks`() = runBlocking { + val data1 = Random.nextBytes(1337) + val data2 = Random.nextBytes(2342) + val chunk1 = Chunk(0L, data1.size, data1, "doesn't matter here") + val chunk2 = Chunk(0L, data2.size, data2, "doesn't matter here") + val outputStream1 = ByteArrayOutputStream() + val outputStream2 = ByteArrayOutputStream() + + every { crypto.getAdForVersion() } returns ad + every { crypto.newEncryptingStream(capture(passThroughOutputStream), ad) } answers { + passThroughOutputStream.captured // not really encrypting here + } + every { crypto.repoId } returns repoId + every { backendManager.backend } returns backend + coEvery { backend.save(capture(blobHandle)) } returns outputStream1 + + blobCreator.createNewBlob(chunk1) + // check that file content hash matches snapshot hash + val messageDigest = MessageDigest.getInstance("SHA-256") + assertEquals( + messageDigest.digest(outputStream1.toByteArray()).toHexString(), + blobHandle.captured.name, + ) + + // use same BlobCreator to create another blob, because we re-use a single buffer + // and need to check clearing that does work as expected + coEvery { backend.save(capture(blobHandle)) } returns outputStream2 + blobCreator.createNewBlob(chunk2) + // check that file content hash matches snapshot hash + assertEquals( + messageDigest.digest(outputStream2.toByteArray()).toHexString(), + blobHandle.captured.name, + ) + } +} diff --git a/libs/Android.bp b/libs/Android.bp index 36158313..ae63f75a 100644 --- a/libs/Android.bp +++ b/libs/Android.bp @@ -16,6 +16,12 @@ java_import { sdk_version: "current", } +java_import { + name: "seedvault-lib-chunker", + jars: ["seedvault-chunker-0.1.jar"], + sdk_version: "current", +} + java_import { name: "seedvault-lib-kotlin-logging-jvm", jars: ["kotlin-logging-jvm-6.0.3.jar"], diff --git a/libs/seedvault-chunker-0.1.jar b/libs/seedvault-chunker-0.1.jar new file mode 100644 index 0000000000000000000000000000000000000000..a452dfe529902ad0c07d29ad8843114c25fdd52a GIT binary patch literal 21309 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