diff --git a/Corona-Warn-App/build.gradle b/Corona-Warn-App/build.gradle index 1401fcf7b48..dd4151fe962 100644 --- a/Corona-Warn-App/build.gradle +++ b/Corona-Warn-App/build.gradle @@ -323,7 +323,7 @@ dependencies { implementation 'joda-time:joda-time:2.10.6' // SECURITY - implementation "androidx.security:security-crypto:1.0.0-rc02" + implementation "androidx.security:security-crypto:1.0.0-rc03" implementation 'net.zetetic:android-database-sqlcipher:4.4.0' implementation 'org.conscrypt:conscrypt-android:2.4.0' diff --git a/Corona-Warn-App/src/main/java/de/rki/coronawarnapp/util/RetryMechanism.kt b/Corona-Warn-App/src/main/java/de/rki/coronawarnapp/util/RetryMechanism.kt new file mode 100644 index 00000000000..ba2a372b51a --- /dev/null +++ b/Corona-Warn-App/src/main/java/de/rki/coronawarnapp/util/RetryMechanism.kt @@ -0,0 +1,77 @@ +package de.rki.coronawarnapp.util + +import timber.log.Timber +import kotlin.math.pow +import kotlin.math.roundToLong + +object RetryMechanism { + + fun retryWithBackOff( + delayCalculator: (Attempt) -> Long? = createDelayCalculator(), + delayOperation: (Long) -> Unit = { Thread.sleep(it) }, + retryCondition: (Attempt) -> Boolean = { true }, + action: () -> T + ): T { + var current = Attempt() + while (true) { + Timber.v("Executing attempt: %s", current) + try { + return action() + } catch (e: Exception) { + current = current.copy(exception = e) + } + + if (!retryCondition(current)) throw current.exception!! + + val newDelay = delayCalculator(current) + + if (newDelay == null) { + Timber.w("Retrycondition exceeded: %s", current) + throw current.exception!! + } else { + delayOperation(newDelay) + } + + current = current.copy( + count = current.count + 1, + lastDelay = newDelay, + totalDelay = current.totalDelay + newDelay + ) + } + } + + private const val DEFAULT_TOTAL_MAX_RETRY = 15 * 1000L // 15 seconds total delay + private const val DEFAULT_MAX_DELAY = 3 * 1000L // 3 seconds max between retries + private const val DEFAULT_MIN_DELAY = 25L // Almost immediate retry + private const val DEFAULT_RETRY_MULTIPLIER = 1.5 + + fun createDelayCalculator( + maxTotalDelay: Long = DEFAULT_TOTAL_MAX_RETRY, + maxDelay: Long = DEFAULT_MAX_DELAY, + minDelay: Long = DEFAULT_MIN_DELAY, + multiplier: Double = DEFAULT_RETRY_MULTIPLIER + ): (Attempt) -> Long? = { attempt -> + if (attempt.totalDelay > maxTotalDelay) { + Timber.w("Max retry duration exceeded.") + null + } else { + val exp = 2.0.pow(attempt.count.toDouble()) + val calculatedDelay = (multiplier * exp).roundToLong() + + val newDelay = if (calculatedDelay > attempt.lastDelay) { + (attempt.lastDelay..calculatedDelay).random() + } else { + (calculatedDelay..attempt.lastDelay).random() + } + + newDelay.coerceAtMost(maxDelay).coerceAtLeast(minDelay) + } + } + + data class Attempt( + val count: Int = 1, + val totalDelay: Long = 0L, + val lastDelay: Long = 0L, + val exception: Exception? = null + ) +} diff --git a/Corona-Warn-App/src/main/java/de/rki/coronawarnapp/util/security/EncryptedPreferencesFactory.kt b/Corona-Warn-App/src/main/java/de/rki/coronawarnapp/util/security/EncryptedPreferencesFactory.kt new file mode 100644 index 00000000000..34bd1d35588 --- /dev/null +++ b/Corona-Warn-App/src/main/java/de/rki/coronawarnapp/util/security/EncryptedPreferencesFactory.kt @@ -0,0 +1,40 @@ +package de.rki.coronawarnapp.util.security + +import android.content.Context +import android.content.SharedPreferences +import androidx.security.crypto.EncryptedSharedPreferences +import androidx.security.crypto.MasterKeys +import de.rki.coronawarnapp.util.RetryMechanism +import timber.log.Timber +import java.security.KeyException +import javax.inject.Inject +import javax.inject.Singleton + +@Singleton +class EncryptedPreferencesFactory @Inject constructor( + private val context: Context +) { + + private val masterKeyAlias by lazy { + MasterKeys.getOrCreate(MasterKeys.AES256_GCM_SPEC) + } + + private fun createInstance(fileName: String) = EncryptedSharedPreferences.create( + fileName, + masterKeyAlias, + context, + EncryptedSharedPreferences.PrefKeyEncryptionScheme.AES256_SIV, + EncryptedSharedPreferences.PrefValueEncryptionScheme.AES256_GCM + ) + + fun create(fileName: String): SharedPreferences = try { + RetryMechanism.retryWithBackOff { + Timber.d("Creating EncryptedSharedPreferences instance.") + createInstance(fileName).also { + Timber.d("Instance created, %d entries.", it.all.size) + } + } + } catch (e: Exception) { + throw KeyException("Permantly failed to instantiate encrypted preferences", e) + } +} diff --git a/Corona-Warn-App/src/main/java/de/rki/coronawarnapp/util/security/SecurityHelper.kt b/Corona-Warn-App/src/main/java/de/rki/coronawarnapp/util/security/SecurityHelper.kt index 57617a039fa..5760b934361 100644 --- a/Corona-Warn-App/src/main/java/de/rki/coronawarnapp/util/security/SecurityHelper.kt +++ b/Corona-Warn-App/src/main/java/de/rki/coronawarnapp/util/security/SecurityHelper.kt @@ -20,12 +20,9 @@ package de.rki.coronawarnapp.util.security import android.annotation.SuppressLint -import android.content.Context import android.content.SharedPreferences import android.os.Build import android.util.Base64 -import androidx.security.crypto.EncryptedSharedPreferences -import androidx.security.crypto.MasterKeys import de.rki.coronawarnapp.CoronaWarnApplication import de.rki.coronawarnapp.exception.CwaSecurityException import de.rki.coronawarnapp.util.security.SecurityConstants.CWA_APP_SQLITE_DB_PW @@ -38,28 +35,14 @@ import java.security.SecureRandom * Key Store and Password Access */ object SecurityHelper { - private val keyGenParameterSpec = MasterKeys.AES256_GCM_SPEC - private val masterKeyAlias = MasterKeys.getOrCreate(keyGenParameterSpec) val globalEncryptedSharedPreferencesInstance: SharedPreferences by lazy { + val factory = EncryptedPreferencesFactory(CoronaWarnApplication.getAppContext()) withSecurityCatch { - CoronaWarnApplication.getAppContext() - .getEncryptedSharedPrefs(ENCRYPTED_SHARED_PREFERENCES_FILE) + factory.create(ENCRYPTED_SHARED_PREFERENCES_FILE) } } - /** - * Initializes the private encrypted key store - */ - private fun Context.getEncryptedSharedPrefs(fileName: String) = EncryptedSharedPreferences - .create( - fileName, - masterKeyAlias, - this, - EncryptedSharedPreferences.PrefKeyEncryptionScheme.AES256_SIV, - EncryptedSharedPreferences.PrefValueEncryptionScheme.AES256_GCM - ) - private val String.toPreservedByteArray: ByteArray get() = Base64.decode(this, Base64.NO_WRAP) diff --git a/Corona-Warn-App/src/test/java/de/rki/coronawarnapp/util/RetryMechanismTest.kt b/Corona-Warn-App/src/test/java/de/rki/coronawarnapp/util/RetryMechanismTest.kt new file mode 100644 index 00000000000..c0697e18d92 --- /dev/null +++ b/Corona-Warn-App/src/test/java/de/rki/coronawarnapp/util/RetryMechanismTest.kt @@ -0,0 +1,92 @@ +package de.rki.coronawarnapp.util + +import de.rki.coronawarnapp.util.RetryMechanism.createDelayCalculator +import io.kotest.assertions.throwables.shouldThrow +import io.kotest.matchers.longs.beInRange +import io.kotest.matchers.should +import io.kotest.matchers.shouldBe +import io.kotest.matchers.shouldNotBe +import io.kotest.matchers.types.instanceOf +import io.mockk.MockKAnnotations +import io.mockk.clearAllMocks +import io.mockk.every +import io.mockk.impl.annotations.MockK +import io.mockk.verify +import org.junit.jupiter.api.AfterEach +import org.junit.jupiter.api.BeforeEach +import org.junit.jupiter.api.Test +import testhelpers.BaseTest +import java.util.UUID + +class RetryMechanismTest : BaseTest() { + @MockK lateinit var mockFunction: () -> String + + @BeforeEach + fun setup() { + MockKAnnotations.init(this) + every { mockFunction.invoke() } answers { + throw RuntimeException(UUID.randomUUID().toString()) + } + } + + @AfterEach + fun teardown() { + clearAllMocks() + } + + @Test + fun `simple retry`() { + val attempts = mutableListOf() + shouldThrow { + RetryMechanism.retryWithBackOff( + delayCalculator = createDelayCalculator(), + delayOperation = { Thread.sleep(it) }, + retryCondition = { + attempts.add(it) + it.count < 3 + }, + action = mockFunction + ) + } + verify(exactly = 3) { mockFunction() } + + attempts[0].apply { + count shouldBe 1 + totalDelay shouldBe 0L + lastDelay shouldBe 0L + exception should instanceOf(RuntimeException::class) + } + attempts[1].apply { + count shouldBe 2 + totalDelay shouldBe lastDelay + lastDelay shouldBe totalDelay + exception should instanceOf(RuntimeException::class) + } + attempts[2].apply { + count shouldBe 3 + totalDelay shouldBe attempts[1].totalDelay + lastDelay + lastDelay shouldBe totalDelay - attempts[1].totalDelay + exception should instanceOf(RuntimeException::class) + } + attempts[0].exception shouldNotBe attempts[1].exception + attempts[1].exception shouldNotBe attempts[2].exception + } + + @Test + fun `test clamping`() { + val calculator = createDelayCalculator() + RetryMechanism.Attempt(count = -5, lastDelay = 20).let { + calculator(it) shouldBe 25 // -X .. 20 -> clamp to min (25) + } + RetryMechanism.Attempt(count = 100, lastDelay = 3 * 1000L).let { + calculator(it) shouldBe 3 * 1000L // lastDelay .. HugeNewDelay -> clamp to max (3k) + } + + RetryMechanism.Attempt(count = 10, lastDelay = 16 * 1000L).let { + calculator(it) shouldBe beInRange(1536L..3000L) + } + RetryMechanism.Attempt(count = 100, lastDelay = 1).let { + calculator(it) shouldBe 3 * 1000L + } + } +} diff --git a/Corona-Warn-App/src/test/java/de/rki/coronawarnapp/util/security/EncryptedPreferencesFactoryTest.kt b/Corona-Warn-App/src/test/java/de/rki/coronawarnapp/util/security/EncryptedPreferencesFactoryTest.kt new file mode 100644 index 00000000000..a2c7fd375da --- /dev/null +++ b/Corona-Warn-App/src/test/java/de/rki/coronawarnapp/util/security/EncryptedPreferencesFactoryTest.kt @@ -0,0 +1,35 @@ +package de.rki.coronawarnapp.util.security + +import android.content.Context +import io.kotest.assertions.throwables.shouldNotThrowAny +import io.mockk.Called +import io.mockk.MockKAnnotations +import io.mockk.clearAllMocks +import io.mockk.impl.annotations.MockK +import io.mockk.verify +import org.junit.jupiter.api.AfterEach +import org.junit.jupiter.api.BeforeEach +import org.junit.jupiter.api.Test +import testhelpers.BaseTest + +class EncryptedPreferencesFactoryTest : BaseTest() { + @MockK lateinit var context: Context + + @BeforeEach + fun setup() { + MockKAnnotations.init(this) + } + + @AfterEach + fun teardown() { + clearAllMocks() + } + + @Test + fun `sideeffect free init`() { + shouldNotThrowAny { + EncryptedPreferencesFactory(context) + } + verify { context.getSharedPreferences(any(), any()) wasNot Called } + } +}