diff --git a/backup/build.gradle.kts b/backup/build.gradle.kts index fc1ad043c12..8b21f8cabc6 100644 --- a/backup/build.gradle.kts +++ b/backup/build.gradle.kts @@ -29,6 +29,13 @@ kaliumLibrary { multiplatform { enableJs.set(true) } } +android { + // Because of native libraries, we can only test Android code on instrumentation tests + testOptions.unitTests.all { + it.enabled = false + } +} + @Suppress("UnusedPrivateProperty") kotlin { // Makes visibility modifiers mandatory @@ -49,6 +56,9 @@ kotlin { generateTypeScriptDefinitions() } sourceSets { + all { + languageSettings.optIn("kotlin.ExperimentalUnsignedTypes") + } val commonMain by getting { dependencies { implementation(project(":data")) diff --git a/backup/src/commonMain/kotlin/com/wire/backup/encryption/EncryptedStream.kt b/backup/src/commonMain/kotlin/com/wire/backup/encryption/EncryptedStream.kt new file mode 100644 index 00000000000..010e2be98a9 --- /dev/null +++ b/backup/src/commonMain/kotlin/com/wire/backup/encryption/EncryptedStream.kt @@ -0,0 +1,209 @@ +/* + * Wire + * Copyright (C) 2024 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.encryption + +import com.ionspin.kotlin.crypto.LibsodiumInitializer +import com.ionspin.kotlin.crypto.pwhash.PasswordHash +import com.ionspin.kotlin.crypto.pwhash.crypto_pwhash_SALTBYTES +import com.ionspin.kotlin.crypto.pwhash.crypto_pwhash_argon2id_ALG_ARGON2ID13 +import com.ionspin.kotlin.crypto.secretstream.SecretStream +import com.ionspin.kotlin.crypto.secretstream.SecretStreamCorruptedOrTamperedDataException +import com.ionspin.kotlin.crypto.secretstream.crypto_secretstream_xchacha20poly1305_ABYTES +import com.ionspin.kotlin.crypto.secretstream.crypto_secretstream_xchacha20poly1305_TAG_FINAL +import com.ionspin.kotlin.crypto.secretstream.crypto_secretstream_xchacha20poly1305_TAG_MESSAGE +import com.ionspin.kotlin.crypto.stream.crypto_stream_chacha20_KEYBYTES +import com.wire.backup.envelope.cryptography.BackupPassphrase +import okio.Buffer +import okio.Sink +import okio.Source +import okio.buffer +import kotlin.random.Random +import kotlin.random.nextUBytes + +/** + * Provides functions to encrypt and decrypt streams of data. + * Streams of data: big amounts of data, that can be split into smaller chunks / messages. + * For example, a huge file can be read in chunks, encrypted in chunks and then decrypted in chunks. + * This is done using [Source] and [Sink]. + */ +internal interface EncryptedStream { + /** + * Encrypts the [source] data using the provided [authenticationData]. + * The result is fed into the [outputSink]. + * @return a [UByteArray] containing the extra data that might be needed for decryption depending on the implementation. + * @see decrypt + */ + suspend fun encrypt(source: Source, outputSink: Sink, authenticationData: AuthenticationData): UByteArray + + /** + * Decrypts the [source] data using the provided [authenticationData] and [encryptionHeader]. + * @param encryptionHeader the result of the [encrypt] function. + * The result is fed into the [outputSink]. + * @see encrypt + */ + suspend fun decrypt( + source: Source, + outputSink: Sink, + authenticationData: AuthenticationData, + encryptionHeader: UByteArray + ): DecryptionResult + + /** + * Implementation of [EncryptedStream] that relies on Libsodium's + * [SecretStream](https://libsodium.gitbook.io/doc/secret-key_cryptography/secretstream). + * It will encrypt the whole [Source] into an output [Sink] in smaller messages of [INDIVIDUAL_PLAINTEXT_MESSAGE_SIZE]. + */ + companion object XChaCha20Poly1305 : EncryptedStream { + private const val KEY_LENGTH = crypto_stream_chacha20_KEYBYTES + private const val INDIVIDUAL_PLAINTEXT_MESSAGE_SIZE = 4096L + private val INDIVIDUAL_ENCRYPTED_MESSAGE_SIZE = INDIVIDUAL_PLAINTEXT_MESSAGE_SIZE + crypto_secretstream_xchacha20poly1305_ABYTES + + private suspend fun initializeLibSodiumIfNeeded() { + if (!LibsodiumInitializer.isInitialized()) { + LibsodiumInitializer.initialize() + } + } + + override suspend fun encrypt(source: Source, outputSink: Sink, authenticationData: XChaChaPoly1305AuthenticationData): UByteArray { + initializeLibSodiumIfNeeded() + val key = generateChaCha20Key(authenticationData) + val stateHeader = SecretStream.xChaCha20Poly1305InitPush(key) + val state = stateHeader.state + val chaChaHeader = stateHeader.header + val readBuffer = Buffer() + val output = outputSink.buffer() + + // iterate with stuff + var readBytes: Long + var isTheLastMessage = false + while (!isTheLastMessage) { + readBytes = source.read(readBuffer, INDIVIDUAL_PLAINTEXT_MESSAGE_SIZE) + if (readBytes <= 0L) break // Nothing else to read + isTheLastMessage = readBytes < INDIVIDUAL_PLAINTEXT_MESSAGE_SIZE + + val appendingTag = if (isTheLastMessage) { + crypto_secretstream_xchacha20poly1305_TAG_FINAL + } else { + crypto_secretstream_xchacha20poly1305_TAG_MESSAGE + } + + val plainTextMessage = readBuffer.readByteArray(readBytes).toUByteArray() + val encryptedMessage = SecretStream.xChaCha20Poly1305Push( + state = state, + message = plainTextMessage, + associatedData = authenticationData.additionalData, + tag = appendingTag.toUByte(), + ) + output.write(encryptedMessage.toByteArray()) + } + source.close() + output.close() + outputSink.close() + return chaChaHeader + } + + override suspend fun decrypt( + source: Source, + outputSink: Sink, + authenticationData: XChaChaPoly1305AuthenticationData, + encryptionHeader: UByteArray + ): DecryptionResult { + initializeLibSodiumIfNeeded() + var decryptedDataSize = 0L + val outputBuffer = outputSink.buffer() + val readBuffer = Buffer() + val key = generateChaCha20Key(authenticationData) + return try { + + val stateHeader = SecretStream.xChaCha20Poly1305InitPull(key, encryptionHeader) + var hasReadLastMessage = false + while (!hasReadLastMessage) { + val readBytes = source.read(readBuffer, INDIVIDUAL_ENCRYPTED_MESSAGE_SIZE) + if (readBytes <= 0) break + val encryptedData = readBuffer.readByteArray().toUByteArray() + val (decryptedData, tag) = SecretStream.xChaCha20Poly1305Pull( + stateHeader.state, + encryptedData, + authenticationData.additionalData + ) + decryptedDataSize += decryptedData.size + outputBuffer.write(decryptedData.toByteArray()) + val isEndOfEncryptedStream = tag.toInt() == crypto_secretstream_xchacha20poly1305_TAG_FINAL + hasReadLastMessage = isEndOfEncryptedStream + } + source.close() + outputBuffer.close() + outputSink.close() + DecryptionResult.Success + } catch (tamperedException: SecretStreamCorruptedOrTamperedDataException) { + DecryptionResult.Failure.AuthenticationFailure + } + } + + private fun generateChaCha20Key(authData: XChaChaPoly1305AuthenticationData): UByteArray = PasswordHash.pwhash( + outputLength = KEY_LENGTH, + password = authData.passphrase.value, + salt = authData.salt, + opsLimit = authData.hashOpsLimit, + memLimit = authData.hashMemLimit, + algorithm = crypto_pwhash_argon2id_ALG_ARGON2ID13 + ) + } +} + +internal sealed interface DecryptionResult { + data object Success : DecryptionResult + data object Failure : DecryptionResult { + /** + * Wrong passphrase, salt, header, or additional data + */ + data object AuthenticationFailure : DecryptionResult + data class Unknown(val message: String) : DecryptionResult + } +} + +/** + * @param passphrase the password created by the user when encrypting + * @param salt the random bytes to spice things up, can be created with [XChaChaPoly1305AuthenticationData.newSalt]. + * @param additionalData extra data that can be used and can be validated + * together with the encrypted data. + * + * It _is_ optional and an empty array can be used, if no extra plaintext data needs to be validated. + * + * For the purposes of backup, we can use a hash of the non-encrypted + * bytes of the file as additional data. This way, if these bytes were tempered with, the decryption + * will also fail. The idea is to do not trust any fruit from the poisoned tree. + */ +internal data class XChaChaPoly1305AuthenticationData( + val passphrase: BackupPassphrase, + val salt: UByteArray, + val additionalData: UByteArray = ubyteArrayOf(), + val hashOpsLimit: ULong = HASH_OPS_LIMIT, + val hashMemLimit: Int = HASH_MEM_LIMIT, +) { + companion object { + const val SALT_LENGTH = crypto_pwhash_SALTBYTES + + // crypto_pwhash_argon2i_OPSLIMIT_INTERACTIVE + private const val HASH_OPS_LIMIT = 4UL + + // crypto_pwhash_argon2i_MEMLIMIT_INTERACTIVE + private const val HASH_MEM_LIMIT = 33554432 + fun newSalt() = Random.nextUBytes(SALT_LENGTH) + } +} diff --git a/backup/src/commonMain/kotlin/com/wire/backup/envelope/cryptography/BackupPassphrase.kt b/backup/src/commonMain/kotlin/com/wire/backup/envelope/cryptography/BackupPassphrase.kt new file mode 100644 index 00000000000..dbde3d26713 --- /dev/null +++ b/backup/src/commonMain/kotlin/com/wire/backup/envelope/cryptography/BackupPassphrase.kt @@ -0,0 +1,24 @@ +/* + * Wire + * Copyright (C) 2024 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.cryptography + +import kotlin.js.JsExport + +@JsExport +public data class BackupPassphrase(val value: String) diff --git a/backup/src/commonTest/kotlin/com/wire/backup/encryption/XChaChaPoly1305EncryptedStreamTest.kt b/backup/src/commonTest/kotlin/com/wire/backup/encryption/XChaChaPoly1305EncryptedStreamTest.kt new file mode 100644 index 00000000000..557efae71e2 --- /dev/null +++ b/backup/src/commonTest/kotlin/com/wire/backup/encryption/XChaChaPoly1305EncryptedStreamTest.kt @@ -0,0 +1,212 @@ +/* + * Wire + * Copyright (C) 2024 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.encryption + +import com.ionspin.kotlin.crypto.pwhash.crypto_pwhash_SALTBYTES +import com.wire.backup.envelope.cryptography.BackupPassphrase +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.launch +import kotlinx.coroutines.test.runTest +import okio.Buffer +import okio.ByteString.Companion.encodeUtf8 +import kotlin.random.Random +import kotlin.test.Ignore +import kotlin.test.Test +import kotlin.test.assertContentEquals +import kotlin.test.assertEquals + +class XChaChaPoly1305EncryptedStreamTest { + + private val stream = EncryptedStream.XChaCha20Poly1305 + + @Test + fun givenEncryptedMessageAndCorrectAuthentication_whenDecrypting_thenShouldReturnOriginalMessage() = runTest { + val messageToEncrypt = "Hello Alice!" + testCorrectDecryptionOfMessage(messageToEncrypt.encodeToByteArray()) + } + + private suspend fun testCorrectDecryptionOfMessage(messageToEncrypt: ByteArray) { + val originalBuffer = Buffer() + originalBuffer.write(messageToEncrypt) + val originalBufferData = originalBuffer.peek().readByteArray() + + val encryptedBuffer = Buffer() + val passphrase = BackupPassphrase("password") + val salt = XChaChaPoly1305AuthenticationData.newSalt() + val additionalData = "additionalData".encodeToByteArray().toUByteArray() + val authenticationData = XChaChaPoly1305AuthenticationData(passphrase, salt, additionalData) + val encryptionHeader = stream.encrypt( + originalBuffer, + encryptedBuffer, + authenticationData + ) + + val decryptedBuffer = Buffer() + val result = stream.decrypt(encryptedBuffer, decryptedBuffer, authenticationData, encryptionHeader) + assertEquals(DecryptionResult.Success, result) + assertContentEquals(originalBufferData, decryptedBuffer.readByteArray()) + } + + private suspend fun testWithRandomMessageOfSpecificSize(size: Int) { + val message = Random.Default.nextBytes(size) + testCorrectDecryptionOfMessage(message) + } + + @Test + fun givenEncryptedMessagesOfSizeOneAndCorrectAuthentication_whenDecrypting_thenShouldReturnOriginalMessage() = runTest { + testWithRandomMessageOfSpecificSize(1) + } + + @Test + fun givenEncryptedMessageOfSizeSmallerThanAPageAndCorrectAuthentication_whenDecrypting_thenShouldReturnOriginalMessage() = runTest { + testWithRandomMessageOfSpecificSize(4095) + } + + @Test + fun givenEncryptedMessageWithExactlyOnePageOfSizeAndCorrectAuthentication_whenDecrypting_thenShouldReturnOriginalMessage() = runTest { + testWithRandomMessageOfSpecificSize(4096) + } + + @Test + fun givenEncryptedMessageSlightlyBiggerThanAPageAndCorrectAuthentication_whenDecrypting_thenShouldReturnOriginalMessage() = runTest { + testWithRandomMessageOfSpecificSize(4097) + } + + @Test + fun givenEncryptedMessageSmallerThanTwoPagesAndCorrectAuthentication_whenDecrypting_thenShouldReturnOriginalMessage() = runTest { + testWithRandomMessageOfSpecificSize(8191) + } + + @Test + fun givenEncryptedMessageExactlyTwoPagesLongAndCorrectAuthentication_whenDecrypting_thenShouldReturnOriginalMessage() = runTest { + testWithRandomMessageOfSpecificSize(8192) + } + + @Test + fun givenEncryptedMessageBiggerThanTwoPagesAndCorrectAuthentication_whenDecrypting_thenShouldReturnOriginalMessage() = runTest { + testWithRandomMessageOfSpecificSize(8193) + } + + @Test + fun givenEncryptedMessageAndWrongAdditionalData_whenDecrypting_thenShouldFailDecryption() = runTest { + val originalBuffer = Buffer() + originalBuffer.writeUtf8("Hello Alice!") + + val encryptedBuffer = Buffer() + val passphrase = BackupPassphrase("password") + val salt = XChaChaPoly1305AuthenticationData.newSalt() + val additionalData = "additionalData".encodeToByteArray().toUByteArray() + val authenticationData = XChaChaPoly1305AuthenticationData(passphrase, salt, additionalData) + val encryptionHeader = stream.encrypt( + originalBuffer, + encryptedBuffer, + authenticationData + ) + + val result = stream.decrypt( + encryptedBuffer, Buffer(), + authenticationData.copy( + additionalData = "INCORRECT".encodeToByteArray().toUByteArray(), + ), + encryptionHeader, + ) + assertEquals(DecryptionResult.Failure.AuthenticationFailure, result) + } + + @Test + fun givenEncryptedMessageAndWrongSalt_whenDecrypting_thenShouldFailDecryption() = runTest { + val originalBuffer = Buffer() + originalBuffer.writeUtf8("Hello Alice!") + + val encryptedBuffer = Buffer() + val passphrase = BackupPassphrase("password") + val salt = XChaChaPoly1305AuthenticationData.newSalt() + val additionalData = "additionalData".encodeToByteArray().toUByteArray() + val authenticationData = XChaChaPoly1305AuthenticationData(passphrase, salt, additionalData) + val encryptionHeader = stream.encrypt( + originalBuffer, + encryptedBuffer, + authenticationData + ) + + val wrongSalt = UByteArray(crypto_pwhash_SALTBYTES) + for (i in wrongSalt.indices) { + wrongSalt[i] = 42U + } + val result = stream.decrypt( + encryptedBuffer, Buffer(), + authenticationData.copy( + salt = wrongSalt, + ), + encryptionHeader, + ) + assertEquals(DecryptionResult.Failure.AuthenticationFailure, result) + } + + @Test + fun givenEncryptedMessageAndWrongPassword_whenDecrypting_thenShouldFailDecryption() = runTest { + val originalBuffer = Buffer() + originalBuffer.writeUtf8("Hello Alice!") + + val encryptedBuffer = Buffer() + val passphrase = BackupPassphrase("password") + val salt = XChaChaPoly1305AuthenticationData.newSalt() + val additionalData = "additionalData".encodeToByteArray().toUByteArray() + val authenticationData = XChaChaPoly1305AuthenticationData(passphrase, salt, additionalData) + val encryptionHeader = stream.encrypt( + originalBuffer, + encryptedBuffer, + authenticationData + ) + + val result = stream.decrypt( + encryptedBuffer, Buffer(), + authenticationData.copy( + passphrase = BackupPassphrase("WRONG PASSWORD"), + ), + encryptionHeader, + ) + assertEquals(DecryptionResult.Failure.AuthenticationFailure, result) + } + + @Test + fun givenEncryptedMessageAndWrongHeader_whenDecrypting_thenShouldFailDecryption() = runTest { + val originalBuffer = Buffer() + originalBuffer.writeUtf8("Hello Alice!") + + val encryptedBuffer = Buffer() + val passphrase = BackupPassphrase("password") + val salt = XChaChaPoly1305AuthenticationData.newSalt() + val additionalData = "additionalData".encodeToByteArray().toUByteArray() + val authenticationData = XChaChaPoly1305AuthenticationData(passphrase, salt, additionalData) + val encryptionHeader = stream.encrypt( + originalBuffer, + encryptedBuffer, + authenticationData + ) + + encryptionHeader[0] = (encryptionHeader[0] + 0x01U).toUByte() + + val result = stream.decrypt( + encryptedBuffer, Buffer(), + authenticationData, + encryptionHeader, + ) + assertEquals(DecryptionResult.Failure.AuthenticationFailure, result) + } +}