Skip to content

Commit

Permalink
refactor(backup): enhance error handling and test coverage
Browse files Browse the repository at this point in the history
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.
  • Loading branch information
vitorhugods committed Jan 24, 2025
1 parent 429f53d commit b761599
Show file tree
Hide file tree
Showing 6 changed files with 268 additions and 36 deletions.
Original file line number Diff line number Diff line change
Expand Up @@ -161,12 +161,12 @@ internal interface EncryptedStream<AuthenticationData> {

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

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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 {

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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()
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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<XChaChaPoly1305AuthenticationData> = 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
)
)
}

/**
Expand Down
Original file line number Diff line number Diff line change
@@ -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
)
)
Original file line number Diff line number Diff line change
@@ -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<XChaChaPoly1305AuthenticationData> = 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<XChaChaPoly1305AuthenticationData> by EncryptedStream.XChaCha20Poly1305 {
override suspend fun decrypt(
source: Source,
outputSink: Sink,
authenticationData: XChaChaPoly1305AuthenticationData
): DecryptionResult = decryptionResult
}
)
val result = subject.importBackup(Buffer(), "pass")

assertIs<BackupImportResult.Failure.UnknownError>(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<XChaChaPoly1305AuthenticationData> by EncryptedStream.XChaCha20Poly1305 {
override suspend fun decrypt(
source: Source,
outputSink: Sink,
authenticationData: XChaChaPoly1305AuthenticationData
): DecryptionResult = decryptionResult
}
)
val result = subject.importBackup(Buffer(), "pass")

assertIs<BackupImportResult.Failure.MissingOrWrongPassphrase>(result)
}

@Test
fun givenUnzippingThrows_whenImporting_thenFailureIsReturned() = runTest {
val throwable = IllegalStateException("something went wrong")
val subject = createSubject(
unzipEntries = { throw throwable },
encryptedStream = object : EncryptedStream<XChaChaPoly1305AuthenticationData> 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<BackupImportResult.Failure.UnzippingError>(result)
assertEquals(throwable.message, result.message)
}
}

0 comments on commit b761599

Please sign in to comment.