Skip to content
This repository has been archived by the owner on Jun 20, 2023. It is now read-only.

Commit

Permalink
Wrap EncryptedSharedPreferences initialization in a retry mechanism (…
Browse files Browse the repository at this point in the history
…EXPOSUREAPP-1851) (#1235)

* Add retry mechanism with backoff to EncryptedSharedPreferences creation.

* Increase default delays and make the actual delay a randomized value between the last and the new delay.

* Upgrade to "androidx.security:security-crypto:1.0.0-rc03"

* Further space retry delays.

* Unit tests for retry mechanism and factory init.

* klint

* Fixed flaky test.

Co-authored-by: harambasicluka <[email protected]>
  • Loading branch information
d4rken and harambasicluka authored Sep 28, 2020
1 parent 44090fa commit 7106941
Show file tree
Hide file tree
Showing 6 changed files with 247 additions and 20 deletions.
2 changes: 1 addition & 1 deletion Corona-Warn-App/build.gradle
Original file line number Diff line number Diff line change
Expand Up @@ -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'

Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,77 @@
package de.rki.coronawarnapp.util

import timber.log.Timber
import kotlin.math.pow
import kotlin.math.roundToLong

object RetryMechanism {

fun <T> 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
)
}
Original file line number Diff line number Diff line change
@@ -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)
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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)

Expand Down
Original file line number Diff line number Diff line change
@@ -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<RetryMechanism.Attempt>()
shouldThrow<RuntimeException> {
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
}
}
}
Original file line number Diff line number Diff line change
@@ -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 }
}
}

0 comments on commit 7106941

Please sign in to comment.