From b761599dc1f1c1ae33c6eef97c88264ea89ef232 Mon Sep 17 00:00:00 2001 From: Vitor Hugo Schwaab Date: Fri, 24 Jan 2025 18:02:13 +0100 Subject: [PATCH] refactor(backup): enhance error handling and test coverage Refactored the backup import process to improve error handling by introducing more granular failure types and modularized the logic for decrypting and unzipping archives. Added comprehensive test coverage, including tests for edge cases in decryption, parsing, and unzipping. --- .../wire/backup/encryption/EncryptedStream.kt | 6 +- .../backup/filesystem/InMemoryEntryStorage.kt | 1 + .../wire/backup/ingest/BackupImportResult.kt | 10 ++ .../wire/backup/ingest/MPBackupImporter.kt | 115 +++++++++++----- .../envelope/header/FakeHeaderSerializer.kt | 48 +++++++ .../backup/ingest/MPBackupImporterTest.kt | 124 ++++++++++++++++++ 6 files changed, 268 insertions(+), 36 deletions(-) rename backup/src/{jsMain => commonMain}/kotlin/com/wire/backup/filesystem/InMemoryEntryStorage.kt (98%) create mode 100644 backup/src/commonTest/kotlin/com/wire/backup/envelope/header/FakeHeaderSerializer.kt create mode 100644 backup/src/commonTest/kotlin/com/wire/backup/ingest/MPBackupImporterTest.kt diff --git a/backup/src/commonMain/kotlin/com/wire/backup/encryption/EncryptedStream.kt b/backup/src/commonMain/kotlin/com/wire/backup/encryption/EncryptedStream.kt index fb00041e59b..11fbbeb72e1 100644 --- a/backup/src/commonMain/kotlin/com/wire/backup/encryption/EncryptedStream.kt +++ b/backup/src/commonMain/kotlin/com/wire/backup/encryption/EncryptedStream.kt @@ -161,12 +161,12 @@ internal interface EncryptedStream { internal sealed interface DecryptionResult { data object Success : DecryptionResult - data object Failure : DecryptionResult { + sealed interface Failure : DecryptionResult { /** * Wrong passphrase, salt, header, or additional data */ - data object AuthenticationFailure : DecryptionResult - data class Unknown(val message: String) : DecryptionResult + data object AuthenticationFailure : Failure + data class Unknown(val message: String) : Failure } } diff --git a/backup/src/jsMain/kotlin/com/wire/backup/filesystem/InMemoryEntryStorage.kt b/backup/src/commonMain/kotlin/com/wire/backup/filesystem/InMemoryEntryStorage.kt similarity index 98% rename from backup/src/jsMain/kotlin/com/wire/backup/filesystem/InMemoryEntryStorage.kt rename to backup/src/commonMain/kotlin/com/wire/backup/filesystem/InMemoryEntryStorage.kt index f9a672fb99e..8e9232e1b39 100644 --- a/backup/src/jsMain/kotlin/com/wire/backup/filesystem/InMemoryEntryStorage.kt +++ b/backup/src/commonMain/kotlin/com/wire/backup/filesystem/InMemoryEntryStorage.kt @@ -22,6 +22,7 @@ import okio.buffer /** * As JS on a browser doesn't have access to files, we just write stuff in memory. + * It is also useful for tests. */ internal class InMemoryEntryStorage : EntryStorage { diff --git a/backup/src/commonMain/kotlin/com/wire/backup/ingest/BackupImportResult.kt b/backup/src/commonMain/kotlin/com/wire/backup/ingest/BackupImportResult.kt index 33c1802a29b..4dcf8baf464 100644 --- a/backup/src/commonMain/kotlin/com/wire/backup/ingest/BackupImportResult.kt +++ b/backup/src/commonMain/kotlin/com/wire/backup/ingest/BackupImportResult.kt @@ -23,7 +23,17 @@ import kotlin.js.JsExport public sealed class BackupImportResult { public class Success(public val pager: BackupImportPager) : BackupImportResult() public sealed class Failure : BackupImportResult() { + /** + * The file has an incompatible format. + * _i.e._ it isn't a Wire Backup file, or it is from an unsupported version. + */ public data object ParsingFailure : Failure() public data object MissingOrWrongPassphrase : Failure() + + /** + * Error thrown during unzipping. + */ + public data class UnzippingError(public val message: String) : Failure() + public data class UnknownError(public val message: String) : Failure() } } diff --git a/backup/src/commonMain/kotlin/com/wire/backup/ingest/MPBackupImporter.kt b/backup/src/commonMain/kotlin/com/wire/backup/ingest/MPBackupImporter.kt index 548f7def2d9..9c39165ac3f 100644 --- a/backup/src/commonMain/kotlin/com/wire/backup/ingest/MPBackupImporter.kt +++ b/backup/src/commonMain/kotlin/com/wire/backup/ingest/MPBackupImporter.kt @@ -18,8 +18,10 @@ package com.wire.backup.ingest import com.wire.backup.data.BackupData +import com.wire.backup.encryption.DecryptionResult import com.wire.backup.encryption.EncryptedStream import com.wire.backup.encryption.XChaChaPoly1305AuthenticationData +import com.wire.backup.envelope.BackupHeader import com.wire.backup.envelope.BackupHeaderSerializer import com.wire.backup.envelope.HeaderParseResult import com.wire.backup.filesystem.EntryStorage @@ -34,48 +36,95 @@ import kotlin.js.JsExport * digestible data in [BackupData] format. */ @JsExport -public abstract class CommonMPBackupImporter { +public abstract class CommonMPBackupImporter internal constructor( + private val encryptedStream: EncryptedStream = EncryptedStream.XChaCha20Poly1305, + private val headerSerializer: BackupHeaderSerializer = BackupHeaderSerializer.Default +) { /** * Decrypt (if needed) and unzip the backup artifact. * The resulting [BackupImportResult.Success] contains a [BackupImportPager], that can be used to * consume pages of backed up application data, like messages, users and conversations. */ - internal suspend fun importBackup(source: Source, passphrase: String?): BackupImportResult { - return when (val result = BackupHeaderSerializer.Default.parseHeader(source)) { - HeaderParseResult.Failure.UnknownFormat -> BackupImportResult.Failure.ParsingFailure - is HeaderParseResult.Failure.UnsupportedVersion -> BackupImportResult.Failure.ParsingFailure - is HeaderParseResult.Success -> { - val header = result.header - val sink = getUnencryptedArchiveSink() - val isEncrypted = header.isEncrypted - if (isEncrypted && passphrase == null) { - BackupImportResult.Failure.MissingOrWrongPassphrase - } else { - if (isEncrypted && passphrase != null) { - EncryptedStream.decrypt( - source, - sink, - XChaChaPoly1305AuthenticationData( - passphrase, - header.hashData.salt, - BackupHeaderSerializer.Default.headerToBytes(header).toUByteArray(), - header.hashData.operationsLimit, - header.hashData.hashingMemoryLimit - ) - ) - } else { - // No need to decrypt. We skip the encryption header bytes and copy the zip archive to the destination - source.read(Buffer(), EncryptedStream.XCHACHA_20_POLY_1305_HEADER_LENGTH.toLong()) - val buffer = sink.buffer() - buffer.writeAll(source) - buffer.flush() - } + internal suspend fun importBackup( + source: Source, + passphrase: String? + ): BackupImportResult = when (val result = headerSerializer.parseHeader(source)) { + HeaderParseResult.Failure.UnknownFormat -> BackupImportResult.Failure.ParsingFailure + is HeaderParseResult.Failure.UnsupportedVersion -> BackupImportResult.Failure.ParsingFailure + is HeaderParseResult.Success -> handleCompatibleHeader(result, passphrase, source) + } + + private suspend fun handleCompatibleHeader( + result: HeaderParseResult.Success, + passphrase: String?, + source: Source + ): BackupImportResult { + val header = result.header + val sink = getUnencryptedArchiveSink() + val isEncrypted = header.isEncrypted + return if (isEncrypted && passphrase == null) { + BackupImportResult.Failure.MissingOrWrongPassphrase + } else { + extractDataArchiveFromBackupFile(isEncrypted, passphrase, source, sink, header) + } + } + + @Suppress("TooGenericExceptionCaught") + private suspend fun extractDataArchiveFromBackupFile( + isEncrypted: Boolean, + passphrase: String?, + source: Source, + sink: Sink, + header: BackupHeader + ): BackupImportResult { + if (isEncrypted && passphrase != null) { + val decryptionResult = decryptArchiveToDestination(source, sink, passphrase, header) + if (decryptionResult is DecryptionResult.Failure) { + return when (decryptionResult) { + DecryptionResult.Failure.AuthenticationFailure -> BackupImportResult.Failure.MissingOrWrongPassphrase + is DecryptionResult.Failure.Unknown -> BackupImportResult.Failure.UnknownError( + decryptionResult.message + ) } - sink.close() - BackupImportResult.Success(BackupImportPager(unzipAllEntries())) } + } else { + copyUnencryptedArchiveToDestination(source, sink) } + sink.close() + return try { + BackupImportResult.Success(BackupImportPager(unzipAllEntries())) + } catch (t: Throwable) { + BackupImportResult.Failure.UnzippingError(t.message ?: "Unknown zipping error.") + } + } + + private fun copyUnencryptedArchiveToDestination(source: Source, sink: Sink) { + // No need to decrypt. We skip the encryption header bytes and copy the zip archive to the destination + source.read(Buffer(), EncryptedStream.XCHACHA_20_POLY_1305_HEADER_LENGTH.toLong()) + val buffer = sink.buffer() + buffer.writeAll(source) + buffer.flush() + sink.close() + } + + private suspend fun decryptArchiveToDestination( + source: Source, + sink: Sink, + passphrase: String, + header: BackupHeader + ): DecryptionResult = header.hashData.run { + encryptedStream.decrypt( + source = source, + outputSink = sink, + authenticationData = XChaChaPoly1305AuthenticationData( + passphrase = passphrase, + salt = salt, + additionalData = headerSerializer.headerToBytes(header).toUByteArray(), + hashOpsLimit = operationsLimit, + hashMemLimit = hashingMemoryLimit + ) + ) } /** diff --git a/backup/src/commonTest/kotlin/com/wire/backup/envelope/header/FakeHeaderSerializer.kt b/backup/src/commonTest/kotlin/com/wire/backup/envelope/header/FakeHeaderSerializer.kt new file mode 100644 index 00000000000..c0146aaec6b --- /dev/null +++ b/backup/src/commonTest/kotlin/com/wire/backup/envelope/header/FakeHeaderSerializer.kt @@ -0,0 +1,48 @@ +/* + * Wire + * Copyright (C) 2025 Wire Swiss GmbH + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see http://www.gnu.org/licenses/. + */ +package com.wire.backup.envelope.header + +import com.wire.backup.envelope.BackupHeader +import com.wire.backup.envelope.BackupHeaderSerializer +import com.wire.backup.envelope.HashData +import com.wire.backup.envelope.HeaderParseResult +import okio.Source + +internal class FakeHeaderSerializer( + private val bytes: ByteArray = byteArrayOf(), + private val parseResult: HeaderParseResult = HeaderParseResult.Success(fakeBackupHeader()) +) : BackupHeaderSerializer { + override fun headerToBytes(header: BackupHeader): ByteArray { + return bytes + } + + override fun parseHeader(source: Source): HeaderParseResult { + return parseResult + } +} + +internal fun fakeBackupHeader() = BackupHeader( + version = 1, + isEncrypted = true, + hashData = HashData( + hashedUserId = UByteArray(HashData.HASHED_USER_ID_SIZE_IN_BYTES), + salt = UByteArray(HashData.SALT_SIZE_IN_BYTES), + operationsLimit = 4UL, + hashingMemoryLimit = HashData.MINIMUM_MEMORY_LIMIT + ) +) diff --git a/backup/src/commonTest/kotlin/com/wire/backup/ingest/MPBackupImporterTest.kt b/backup/src/commonTest/kotlin/com/wire/backup/ingest/MPBackupImporterTest.kt new file mode 100644 index 00000000000..42d1a55d235 --- /dev/null +++ b/backup/src/commonTest/kotlin/com/wire/backup/ingest/MPBackupImporterTest.kt @@ -0,0 +1,124 @@ +/* + * Wire + * Copyright (C) 2025 Wire Swiss GmbH + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see http://www.gnu.org/licenses/. + */ +package com.wire.backup.ingest + +import com.wire.backup.encryption.DecryptionResult +import com.wire.backup.encryption.EncryptedStream +import com.wire.backup.encryption.XChaChaPoly1305AuthenticationData +import com.wire.backup.envelope.BackupHeaderSerializer +import com.wire.backup.envelope.HeaderParseResult +import com.wire.backup.envelope.header.FakeHeaderSerializer +import com.wire.backup.filesystem.EntryStorage +import com.wire.backup.filesystem.InMemoryEntryStorage +import kotlinx.coroutines.test.runTest +import okio.Buffer +import okio.Sink +import okio.Source +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertIs + +class MPBackupImporterTest { + + private fun createSubject( + unzipEntries: () -> EntryStorage = { InMemoryEntryStorage() }, + encryptedStream: EncryptedStream = EncryptedStream.XChaCha20Poly1305, + headerSerializer: BackupHeaderSerializer = BackupHeaderSerializer.Default, + ): CommonMPBackupImporter = object : CommonMPBackupImporter(encryptedStream, headerSerializer) { + override fun getUnencryptedArchiveSink(): Sink = Buffer() + + override suspend fun unzipAllEntries() = unzipEntries() + } + + @Test + fun givenFailureToParseHeader_whenImporting_thenFailureIsReturned() = runTest { + val subject = createSubject( + headerSerializer = FakeHeaderSerializer(parseResult = HeaderParseResult.Failure.UnknownFormat) + ) + val result = subject.importBackup(Buffer(), null) + + assertEquals(BackupImportResult.Failure.ParsingFailure, result) + } + + @Test + fun givenNoPasswordAndBackupIsEncrypted_whenImporting_thenMissingPasswordIsReturned() = runTest { + val subject = createSubject( + headerSerializer = FakeHeaderSerializer() + ) + val result = subject.importBackup(Buffer(), null) + + assertEquals(BackupImportResult.Failure.MissingOrWrongPassphrase, result) + } + + @Test + fun givenDecryptionFailsForUnknownReason_whenImporting_thenFailureIsReturned() = runTest { + val decryptionResult = DecryptionResult.Failure.Unknown("Oopsie") + val subject = createSubject( + headerSerializer = FakeHeaderSerializer(), + encryptedStream = object : EncryptedStream by EncryptedStream.XChaCha20Poly1305 { + override suspend fun decrypt( + source: Source, + outputSink: Sink, + authenticationData: XChaChaPoly1305AuthenticationData + ): DecryptionResult = decryptionResult + } + ) + val result = subject.importBackup(Buffer(), "pass") + + assertIs(result) + assertEquals(decryptionResult.message, result.message) + } + + @Test + fun givenDecryptionFailsDueToWrongPassword_whenImporting_thenFailureIsReturned() = runTest { + val decryptionResult = DecryptionResult.Failure.AuthenticationFailure + val subject = createSubject( + headerSerializer = FakeHeaderSerializer(), + encryptedStream = object : EncryptedStream by EncryptedStream.XChaCha20Poly1305 { + override suspend fun decrypt( + source: Source, + outputSink: Sink, + authenticationData: XChaChaPoly1305AuthenticationData + ): DecryptionResult = decryptionResult + } + ) + val result = subject.importBackup(Buffer(), "pass") + + assertIs(result) + } + + @Test + fun givenUnzippingThrows_whenImporting_thenFailureIsReturned() = runTest { + val throwable = IllegalStateException("something went wrong") + val subject = createSubject( + unzipEntries = { throw throwable }, + encryptedStream = object : EncryptedStream by EncryptedStream.XChaCha20Poly1305 { + override suspend fun decrypt( + source: Source, + outputSink: Sink, + authenticationData: XChaChaPoly1305AuthenticationData + ): DecryptionResult = DecryptionResult.Success + }, + headerSerializer = FakeHeaderSerializer() + ) + val result = subject.importBackup(Buffer(), "pass") + + assertIs(result) + assertEquals(throwable.message, result.message) + } +}