Encrypt values of key/value backups with multiple segments if needed

This turned out to be necessary, because some values on production
devices are exceeding the maximum segment size.
This commit is contained in:
Torsten Grote 2019-12-18 19:34:49 -03:00
parent 177e714001
commit 58a8f29b51
No known key found for this signature in database
GPG key ID: 3E5F77D92CF891FF
12 changed files with 146 additions and 16 deletions

View file

@ -6,7 +6,7 @@ import javax.crypto.Cipher.ENCRYPT_MODE
import javax.crypto.spec.GCMParameterSpec
private const val CIPHER_TRANSFORMATION = "AES/GCM/NoPadding"
private const val GCM_AUTHENTICATION_TAG_LENGTH = 128
internal const val GCM_AUTHENTICATION_TAG_LENGTH = 128
interface CipherFactory {
fun createEncryptionCipher(): Cipher

View file

@ -5,6 +5,8 @@ import java.io.EOFException
import java.io.IOException
import java.io.InputStream
import java.io.OutputStream
import javax.crypto.Cipher
import kotlin.math.min
/**
* A backup stream starts with a version byte followed by an encrypted [VersionHeader].
@ -50,6 +52,14 @@ interface Crypto {
@Throws(IOException::class)
fun encryptSegment(outputStream: OutputStream, cleartext: ByteArray)
/**
* Like [encryptSegment],
* but if the given cleartext [ByteArray] is larger than [MAX_SEGMENT_CLEARTEXT_LENGTH],
* multiple segments will be written.
*/
@Throws(IOException::class)
fun encryptMultipleSegments(outputStream: OutputStream, cleartext: ByteArray)
/**
* Reads and decrypts a [VersionHeader] from the given [InputStream]
* and ensures that the expected version, package name and key match
@ -69,6 +79,12 @@ interface Crypto {
*/
@Throws(EOFException::class, IOException::class, SecurityException::class)
fun decryptSegment(inputStream: InputStream): ByteArray
/**
* Like [decryptSegment], but decrypts multiple segments and does not throw [EOFException].
*/
@Throws(IOException::class, SecurityException::class)
fun decryptMultipleSegments(inputStream: InputStream): ByteArray
}
class CryptoImpl(
@ -90,8 +106,24 @@ class CryptoImpl(
check(cipher.getOutputSize(cleartext.size) <= MAX_SEGMENT_LENGTH) {
"Cipher's output size ${cipher.getOutputSize(cleartext.size)} is larger than maximum segment length ($MAX_SEGMENT_LENGTH)"
}
encryptSegment(cipher, outputStream, cleartext)
}
val encrypted = cipher.doFinal(cleartext)
@Throws(IOException::class)
override fun encryptMultipleSegments(outputStream: OutputStream, cleartext: ByteArray) {
var end = 0
while (end < cleartext.size) {
val start = end
end = min(cleartext.size, start + MAX_SEGMENT_CLEARTEXT_LENGTH)
val segment = cleartext.copyOfRange(start, end)
val cipher = cipherFactory.createEncryptionCipher()
encryptSegment(cipher, outputStream, segment)
}
}
@Throws(IOException::class)
private fun encryptSegment(cipher: Cipher, outputStream: OutputStream, segment: ByteArray) {
val encrypted = cipher.doFinal(segment)
val segmentHeader = SegmentHeader(encrypted.size.toShort(), cipher.iv)
headerWriter.writeSegmentHeader(outputStream, segmentHeader)
outputStream.write(encrypted)
@ -121,8 +153,21 @@ class CryptoImpl(
return decryptSegment(inputStream, MAX_SEGMENT_LENGTH)
}
@Throws(IOException::class, SecurityException::class)
override fun decryptMultipleSegments(inputStream: InputStream): ByteArray {
var result = ByteArray(0)
while (true) {
try {
result += decryptSegment(inputStream, MAX_SEGMENT_LENGTH)
} catch (e: EOFException) {
if (result.isEmpty()) throw IOException(e)
return result
}
}
}
@Throws(EOFException::class, IOException::class, SecurityException::class)
fun decryptSegment(inputStream: InputStream, maxSegmentLength: Int): ByteArray {
private fun decryptSegment(inputStream: InputStream, maxSegmentLength: Int): ByteArray {
val segmentHeader = headerReader.readSegmentHeader(inputStream)
if (segmentHeader.segmentLength > maxSegmentLength) {
throw SecurityException("Segment length too long: ${segmentHeader.segmentLength} > $maxSegmentLength")

View file

@ -1,5 +1,7 @@
package com.stevesoltys.seedvault.header
import com.stevesoltys.seedvault.crypto.GCM_AUTHENTICATION_TAG_LENGTH
internal const val VERSION: Byte = 0
internal const val MAX_PACKAGE_LENGTH_SIZE = 255
internal const val MAX_KEY_LENGTH_SIZE = MAX_PACKAGE_LENGTH_SIZE
@ -27,6 +29,7 @@ data class VersionHeader(
internal const val SEGMENT_LENGTH_SIZE: Int = Short.SIZE_BYTES
internal const val MAX_SEGMENT_LENGTH: Int = Short.MAX_VALUE.toInt()
internal const val MAX_SEGMENT_CLEARTEXT_LENGTH: Int = MAX_SEGMENT_LENGTH - GCM_AUTHENTICATION_TAG_LENGTH / 8
internal const val IV_SIZE: Int = 12
internal const val SEGMENT_HEADER_SIZE = SEGMENT_LENGTH_SIZE + IV_SIZE

View file

@ -26,7 +26,7 @@ class MetadataReaderImpl(private val crypto: Crypto) : MetadataReader {
if (version < 0) throw IOException()
if (version > VERSION) throw UnsupportedVersionException(version)
val metadataBytes = try {
crypto.decryptSegment(inputStream)
crypto.decryptMultipleSegments(inputStream)
} catch (e: AEADBadTagException) {
throw DecryptionFailedException(e)
}

View file

@ -20,7 +20,7 @@ class MetadataWriterImpl(private val crypto: Crypto): MetadataWriter {
override fun write(outputStream: OutputStream, token: Long) {
val metadata = BackupMetadata(token = token)
outputStream.write(ByteArray(1).apply { this[0] = metadata.version })
crypto.encryptSegment(outputStream, encode(metadata))
crypto.encryptMultipleSegments(outputStream, encode(metadata))
}
@VisibleForTesting

View file

@ -102,7 +102,7 @@ class KVBackup(
val header = VersionHeader(packageName = packageInfo.packageName, key = op.key)
headerWriter.writeVersion(outputStream, header)
crypto.encryptHeader(outputStream, header)
crypto.encryptSegment(outputStream, op.value)
crypto.encryptMultipleSegments(outputStream, op.value)
outputStream.flush()
closeQuietly(outputStream)
}

View file

@ -124,7 +124,7 @@ internal class KVRestore(
try {
val version = headerReader.readVersion(inputStream)
crypto.decryptHeader(inputStream, version, state.packageInfo.packageName, dKey.key)
val value = crypto.decryptSegment(inputStream)
val value = crypto.decryptMultipleSegments(inputStream)
val size = value.size
Log.v(TAG, " ... key=${dKey.key} size=$size")

View file

@ -7,11 +7,13 @@ import com.stevesoltys.seedvault.header.MAX_SEGMENT_LENGTH
import io.mockk.every
import io.mockk.mockk
import org.junit.jupiter.api.Assertions.assertArrayEquals
import org.junit.jupiter.api.Assertions.assertThrows
import org.junit.jupiter.api.Test
import org.junit.jupiter.api.TestInstance
import org.junit.jupiter.api.TestInstance.Lifecycle.PER_METHOD
import java.io.ByteArrayInputStream
import java.io.ByteArrayOutputStream
import java.io.IOException
import javax.crypto.Cipher
import kotlin.random.Random
@ -50,4 +52,12 @@ class CryptoImplTest {
assertArrayEquals(cleartext, crypto.decryptSegment(inputStream))
}
@Test
fun `decrypting multiple segments on empty stream throws`() {
val inputStream = ByteArrayInputStream(ByteArray(0))
assertThrows(IOException::class.java) {
crypto.decryptMultipleSegments(inputStream)
}
}
}

View file

@ -2,12 +2,16 @@ package com.stevesoltys.seedvault.crypto
import com.stevesoltys.seedvault.header.HeaderReaderImpl
import com.stevesoltys.seedvault.header.HeaderWriterImpl
import com.stevesoltys.seedvault.header.MAX_SEGMENT_CLEARTEXT_LENGTH
import com.stevesoltys.seedvault.header.MAX_SEGMENT_LENGTH
import org.junit.jupiter.api.Assertions.assertArrayEquals
import org.junit.jupiter.api.Assertions.assertEquals
import org.junit.jupiter.api.Test
import org.junit.jupiter.api.TestInstance
import org.junit.jupiter.api.TestInstance.Lifecycle.PER_METHOD
import java.io.ByteArrayInputStream
import java.io.ByteArrayOutputStream
import kotlin.random.Random
@TestInstance(PER_METHOD)
class CryptoIntegrationTest {
@ -41,4 +45,24 @@ class CryptoIntegrationTest {
assertArrayEquals(cleartext, crypto.decryptSegment(inputStream))
}
@Test
fun `multiple segments get encrypted and decrypted as expected`() {
val size = Random.nextInt(5) * MAX_SEGMENT_CLEARTEXT_LENGTH + Random.nextInt(0, 1337)
val cleartext = ByteArray(size).apply { Random.nextBytes(this) }
crypto.encryptMultipleSegments(outputStream, cleartext)
val inputStream = ByteArrayInputStream(outputStream.toByteArray())
assertArrayEquals(cleartext, crypto.decryptMultipleSegments(inputStream))
}
@Test
fun `test maximum lengths`() {
val cipher = cipherFactory.createEncryptionCipher()
val expectedDiff = MAX_SEGMENT_LENGTH - MAX_SEGMENT_CLEARTEXT_LENGTH
for (i in 1..(3 * MAX_SEGMENT_LENGTH + 42)) {
val outputSize = cipher.getOutputSize(i)
assertEquals(expectedDiff, outputSize - i)
}
}
}

View file

@ -14,6 +14,7 @@ import com.stevesoltys.seedvault.crypto.KeyManagerTestImpl
import com.stevesoltys.seedvault.encodeBase64
import com.stevesoltys.seedvault.header.HeaderReaderImpl
import com.stevesoltys.seedvault.header.HeaderWriterImpl
import com.stevesoltys.seedvault.header.MAX_SEGMENT_CLEARTEXT_LENGTH
import com.stevesoltys.seedvault.metadata.MetadataReaderImpl
import com.stevesoltys.seedvault.metadata.MetadataWriterImpl
import com.stevesoltys.seedvault.transport.backup.*
@ -123,6 +124,53 @@ internal class CoordinatorIntegrationTest : TransportTest() {
assertEquals(TRANSPORT_OK, restore.getRestoreData(fileDescriptor))
}
@Test
fun `test key-value backup with huge value`() {
val value = CapturingSlot<ByteArray>()
val size = Random.nextInt(5) * MAX_SEGMENT_CLEARTEXT_LENGTH + Random.nextInt(0, 1337)
val appData = ByteArray(size).apply { Random.nextBytes(this) }
val bOutputStream = ByteArrayOutputStream()
// read one key/value record and write it to output stream
every { kvBackupPlugin.hasDataForPackage(packageInfo) } returns false
every { kvBackupPlugin.ensureRecordStorageForPackage(packageInfo) } just Runs
every { inputFactory.getBackupDataInput(fileDescriptor) } returns backupDataInput
every { backupDataInput.readNextHeader() } returns true andThen false
every { backupDataInput.key } returns key
every { backupDataInput.dataSize } returns appData.size
every { backupDataInput.readEntityData(capture(value), 0, appData.size) } answers {
appData.copyInto(value.captured) // write the app data into the passed ByteArray
appData.size
}
every { kvBackupPlugin.getOutputStreamForRecord(packageInfo, key64) } returns bOutputStream
every { settingsManager.saveNewBackupTime() } just Runs
// start and finish K/V backup
assertEquals(TRANSPORT_OK, backup.performIncrementalBackup(packageInfo, fileDescriptor, 0))
assertEquals(TRANSPORT_OK, backup.finishBackup())
// start restore
assertEquals(TRANSPORT_OK, restore.startRestore(token, arrayOf(packageInfo)))
// find data for K/V backup
every { kvRestorePlugin.hasDataForPackage(token, packageInfo) } returns true
val restoreDescription = restore.nextRestorePackage() ?: fail()
assertEquals(packageInfo.packageName, restoreDescription.packageName)
assertEquals(RestoreDescription.TYPE_KEY_VALUE, restoreDescription.dataType)
// restore finds the backed up key and writes the decrypted value
val backupDataOutput = mockk<BackupDataOutput>()
val rInputStream = ByteArrayInputStream(bOutputStream.toByteArray())
every { kvRestorePlugin.listRecords(token, packageInfo) } returns listOf(key64)
every { outputFactory.getBackupDataOutput(fileDescriptor) } returns backupDataOutput
every { kvRestorePlugin.getInputStreamForRecord(token, packageInfo, key64) } returns rInputStream
every { backupDataOutput.writeEntityHeader(key, appData.size) } returns 1137
every { backupDataOutput.writeEntityData(appData, appData.size) } returns appData.size
assertEquals(TRANSPORT_OK, restore.getRestoreData(fileDescriptor))
}
@Test
fun `test full backup and restore with two chunks`() {
// return streams from plugin and app data

View file

@ -142,7 +142,7 @@ internal class KVBackupTest : BackupTest() {
writeHeaderAndEncrypt()
every { plugin.getOutputStreamForRecord(packageInfo, key64) } returns outputStream
every { headerWriter.writeVersion(outputStream, versionHeader) } just Runs
every { crypto.encryptSegment(outputStream, any()) } throws IOException()
every { crypto.encryptMultipleSegments(outputStream, any()) } throws IOException()
assertEquals(TRANSPORT_ERROR, backup.performBackup(packageInfo, data, 0))
assertFalse(backup.hasState())
@ -205,7 +205,7 @@ internal class KVBackupTest : BackupTest() {
every { plugin.getOutputStreamForRecord(packageInfo, key64) } returns outputStream
every { headerWriter.writeVersion(outputStream, versionHeader) } just Runs
every { crypto.encryptHeader(outputStream, versionHeader) } just Runs
every { crypto.encryptSegment(outputStream, any()) } just Runs
every { crypto.encryptMultipleSegments(outputStream, any()) } just Runs
}
}

View file

@ -88,7 +88,7 @@ internal class KVRestoreTest : RestoreTest() {
every { plugin.getInputStreamForRecord(token, packageInfo, key64) } returns inputStream
every { headerReader.readVersion(inputStream) } returns VERSION
every { crypto.decryptHeader(inputStream, VERSION, packageInfo.packageName, key) } returns versionHeader
every { crypto.decryptSegment(inputStream) } throws IOException()
every { crypto.decryptMultipleSegments(inputStream) } throws IOException()
streamsGetClosed()
assertEquals(TRANSPORT_ERROR, restore.getRestoreData(fileDescriptor))
@ -131,7 +131,7 @@ internal class KVRestoreTest : RestoreTest() {
every { plugin.getInputStreamForRecord(token, packageInfo, key64) } returns inputStream
every { headerReader.readVersion(inputStream) } returns VERSION
every { crypto.decryptHeader(inputStream, VERSION, packageInfo.packageName, key) } returns versionHeader
every { crypto.decryptSegment(inputStream) } returns data
every { crypto.decryptMultipleSegments(inputStream) } returns data
every { output.writeEntityHeader(key, data.size) } throws IOException()
streamsGetClosed()
@ -147,7 +147,7 @@ internal class KVRestoreTest : RestoreTest() {
every { plugin.getInputStreamForRecord(token, packageInfo, key64) } returns inputStream
every { headerReader.readVersion(inputStream) } returns VERSION
every { crypto.decryptHeader(inputStream, VERSION, packageInfo.packageName, key) } returns versionHeader
every { crypto.decryptSegment(inputStream) } returns data
every { crypto.decryptMultipleSegments(inputStream) } returns data
every { output.writeEntityHeader(key, data.size) } returns 42
every { output.writeEntityData(data, data.size) } throws IOException()
streamsGetClosed()
@ -164,7 +164,7 @@ internal class KVRestoreTest : RestoreTest() {
every { plugin.getInputStreamForRecord(token, packageInfo, key64) } returns inputStream
every { headerReader.readVersion(inputStream) } returns VERSION
every { crypto.decryptHeader(inputStream, VERSION, packageInfo.packageName, key) } returns versionHeader
every { crypto.decryptSegment(inputStream) } returns data
every { crypto.decryptMultipleSegments(inputStream) } returns data
every { output.writeEntityHeader(key, data.size) } returns 42
every { output.writeEntityData(data, data.size) } returns data.size
streamsGetClosed()
@ -184,14 +184,14 @@ internal class KVRestoreTest : RestoreTest() {
every { plugin.getInputStreamForRecord(token, packageInfo, key64) } returns inputStream
every { headerReader.readVersion(inputStream) } returns VERSION
every { crypto.decryptHeader(inputStream, VERSION, packageInfo.packageName, key) } returns versionHeader
every { crypto.decryptSegment(inputStream) } returns data
every { crypto.decryptMultipleSegments(inputStream) } returns data
every { output.writeEntityHeader(key, data.size) } returns 42
every { output.writeEntityData(data, data.size) } returns data.size
// second key/value
every { plugin.getInputStreamForRecord(token, packageInfo, key264) } returns inputStream2
every { headerReader.readVersion(inputStream2) } returns VERSION
every { crypto.decryptHeader(inputStream2, VERSION, packageInfo.packageName, key2) } returns versionHeader2
every { crypto.decryptSegment(inputStream2) } returns data2
every { crypto.decryptMultipleSegments(inputStream2) } returns data2
every { output.writeEntityHeader(key2, data2.size) } returns 42
every { output.writeEntityData(data2, data2.size) } returns data2.size
every { inputStream2.close() } just Runs