Skip to content
Snippets Groups Projects
Unverified Commit cf77a1b4 authored by Matthias Urhahn's avatar Matthias Urhahn Committed by GitHub
Browse files

SafetyNet Implementation (EXPOSUREAPP-4754) (#2242)


* First safetynet draft.
TODO: Additional integrity checks.

* Add nonce and APK check.
Move JWS data access helpers up to the wrapper.

* Add device time and time_since_onboarding check.

* Increase test coverage, check for null jwsResults.

* Add data donation test menu.
(Currently only shows SafetyNet JWS)

* Improve exception message.

* Additional test cases for different JSON fields.
Log optional error message that may be returned.

* Fix test regression due to missing mock

* Address Lukas PR comments.

* Add attestation max timeout (30s) + test.

Co-authored-by: default avatarharambasicluka <64483219+harambasicluka@users.noreply.github.com>
Co-authored-by: default avatarchris-cwa <69595386+chris-cwa@users.noreply.github.com>
parent fd2ad875
No related branches found
No related tags found
No related merge requests found
Showing
with 626 additions and 19 deletions
package de.rki.coronawarnapp.test.datadonation.ui
import android.annotation.SuppressLint
import android.os.Bundle
import android.view.View
import android.widget.Toast
import androidx.core.app.ShareCompat
import androidx.fragment.app.Fragment
import de.rki.coronawarnapp.R
import de.rki.coronawarnapp.databinding.FragmentTestDatadonationBinding
import de.rki.coronawarnapp.test.menu.ui.TestMenuItem
import de.rki.coronawarnapp.util.di.AutoInject
import de.rki.coronawarnapp.util.ui.observe2
import de.rki.coronawarnapp.util.ui.viewBindingLazy
import de.rki.coronawarnapp.util.viewmodel.CWAViewModelFactoryProvider
import de.rki.coronawarnapp.util.viewmodel.cwaViewModels
import org.json.JSONObject
import javax.inject.Inject
@SuppressLint("SetTextI18n")
class DataDonationTestFragment : Fragment(R.layout.fragment_test_datadonation), AutoInject {
@Inject lateinit var viewModelFactory: CWAViewModelFactoryProvider.Factory
private val vm: DataDonationTestFragmentViewModel by cwaViewModels { viewModelFactory }
private val binding: FragmentTestDatadonationBinding by viewBindingLazy()
override fun onViewCreated(view: View, savedInstanceState: Bundle?) {
super.onViewCreated(view, savedInstanceState)
vm.currentReport.observe2(this) {
binding.safetynetBody.text = it?.body?.toString()?.let { json ->
JSONObject(json).toString(4)
}
}
binding.apply {
safetynetCreateReport.setOnClickListener { vm.createSafetyNetReport() }
safetynetCopyJws.setOnClickListener { vm.copyJWS() }
}
vm.copyJWSEvent.observe2(this) { jws ->
val intent = ShareCompat.IntentBuilder.from(requireActivity()).apply {
setType("text/plain")
setSubject("JWS")
setText(jws)
}.createChooserIntent()
startActivity(intent)
}
vm.errorEvents.observe2(this) {
Toast.makeText(requireContext(), it.toString(), Toast.LENGTH_LONG).show()
}
}
companion object {
val MENU_ITEM = TestMenuItem(
title = "Data Donation",
description = "SafetyNet, Analytics, Surveys et al.",
targetId = R.id.test_datadonation_fragment
)
}
}
package de.rki.coronawarnapp.test.datadonation.ui
import dagger.Binds
import dagger.Module
import dagger.multibindings.IntoMap
import de.rki.coronawarnapp.util.viewmodel.CWAViewModel
import de.rki.coronawarnapp.util.viewmodel.CWAViewModelFactory
import de.rki.coronawarnapp.util.viewmodel.CWAViewModelKey
@Module
abstract class DataDonationTestFragmentModule {
@Binds
@IntoMap
@CWAViewModelKey(DataDonationTestFragmentViewModel::class)
abstract fun dataDonation(
factory: DataDonationTestFragmentViewModel.Factory
): CWAViewModelFactory<out CWAViewModel>
}
package de.rki.coronawarnapp.test.datadonation.ui
import androidx.lifecycle.asLiveData
import dagger.assisted.AssistedFactory
import dagger.assisted.AssistedInject
import de.rki.coronawarnapp.datadonation.safetynet.SafetyNetClientWrapper
import de.rki.coronawarnapp.util.coroutine.DispatcherProvider
import de.rki.coronawarnapp.util.ui.SingleLiveEvent
import de.rki.coronawarnapp.util.viewmodel.CWAViewModel
import de.rki.coronawarnapp.util.viewmodel.SimpleCWAViewModelFactory
import kotlinx.coroutines.flow.MutableStateFlow
import timber.log.Timber
import java.security.SecureRandom
class DataDonationTestFragmentViewModel @AssistedInject constructor(
dispatcherProvider: DispatcherProvider,
private val safetyNetClientWrapper: SafetyNetClientWrapper,
private val secureRandom: SecureRandom
) : CWAViewModel(dispatcherProvider = dispatcherProvider) {
private val currentReportInternal = MutableStateFlow<SafetyNetClientWrapper.Report?>(null)
val currentReport = currentReportInternal.asLiveData(context = dispatcherProvider.Default)
val errorEvents = SingleLiveEvent<Throwable>()
val copyJWSEvent = SingleLiveEvent<String>()
fun createSafetyNetReport() {
launch {
val nonce = ByteArray(16)
secureRandom.nextBytes(nonce)
try {
val report = safetyNetClientWrapper.attest(nonce)
currentReportInternal.value = report
} catch (e: Exception) {
Timber.e(e, "attest() failed.")
errorEvents.postValue(e)
}
}
}
fun copyJWS() {
launch {
val value = currentReport.value?.jwsResult ?: ""
copyJWSEvent.postValue(value)
}
}
@AssistedFactory
interface Factory : SimpleCWAViewModelFactory<DataDonationTestFragmentViewModel>
}
......@@ -7,6 +7,7 @@ import de.rki.coronawarnapp.miscinfo.MiscInfoFragment
import de.rki.coronawarnapp.test.appconfig.ui.AppConfigTestFragment
import de.rki.coronawarnapp.test.contactdiary.ui.ContactDiaryTestFragment
import de.rki.coronawarnapp.test.crash.ui.SettingsCrashReportFragment
import de.rki.coronawarnapp.test.datadonation.ui.DataDonationTestFragment
import de.rki.coronawarnapp.test.debugoptions.ui.DebugOptionsFragment
import de.rki.coronawarnapp.test.keydownload.ui.KeyDownloadTestFragment
import de.rki.coronawarnapp.test.risklevel.ui.TestRiskLevelCalculationFragment
......@@ -28,7 +29,8 @@ class TestMenuFragmentViewModel @AssistedInject constructor() : CWAViewModel() {
SubmissionTestFragment.MENU_ITEM,
SettingsCrashReportFragment.MENU_ITEM,
MiscInfoFragment.MENU_ITEM,
ContactDiaryTestFragment.MENU_ITEM
ContactDiaryTestFragment.MENU_ITEM,
DataDonationTestFragment.MENU_ITEM
).let { MutableLiveData(it) }
}
val showTestScreenEvent = SingleLiveEvent<TestMenuItem>()
......
......@@ -8,6 +8,8 @@ import de.rki.coronawarnapp.test.appconfig.ui.AppConfigTestFragment
import de.rki.coronawarnapp.test.appconfig.ui.AppConfigTestFragmentModule
import de.rki.coronawarnapp.test.contactdiary.ui.ContactDiaryTestFragment
import de.rki.coronawarnapp.test.contactdiary.ui.ContactDiaryTestFragmentModule
import de.rki.coronawarnapp.test.datadonation.ui.DataDonationTestFragment
import de.rki.coronawarnapp.test.datadonation.ui.DataDonationTestFragmentModule
import de.rki.coronawarnapp.test.debugoptions.ui.DebugOptionsFragment
import de.rki.coronawarnapp.test.debugoptions.ui.DebugOptionsFragmentModule
import de.rki.coronawarnapp.test.keydownload.ui.KeyDownloadTestFragment
......@@ -50,4 +52,7 @@ abstract class MainActivityTestModule {
@ContributesAndroidInjector(modules = [ContactDiaryTestFragmentModule::class])
abstract fun contactDiaryTest(): ContactDiaryTestFragment
@ContributesAndroidInjector(modules = [DataDonationTestFragmentModule::class])
abstract fun dataDonation(): DataDonationTestFragment
}
<?xml version="1.0" encoding="utf-8"?>
<layout xmlns:android="http://schemas.android.com/apk/res/android"
xmlns:app="http://schemas.android.com/apk/res-auto"
xmlns:tools="http://schemas.android.com/tools"
tools:ignore="HardcodedText">
<androidx.core.widget.NestedScrollView
android:layout_width="match_parent"
android:layout_height="match_parent"
android:fillViewport="true">
<LinearLayout
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:layout_margin="@dimen/spacing_tiny"
android:orientation="vertical">
<androidx.constraintlayout.widget.ConstraintLayout
android:id="@+id/debug_container"
style="@style/Card"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:layout_margin="@dimen/spacing_tiny">
<TextView
android:id="@+id/safetynet_title"
style="@style/headline6"
android:layout_width="0dp"
android:layout_height="wrap_content"
android:layout_marginEnd="8dp"
android:text="SafetyNet Report"
app:layout_constraintEnd_toEndOf="parent"
app:layout_constraintStart_toStartOf="parent"
app:layout_constraintTop_toTopOf="parent" />
<TextView
android:id="@+id/safetynet_body"
android:layout_width="match_parent"
android:textIsSelectable="true"
android:layout_height="wrap_content"
android:layout_marginTop="@dimen/spacing_tiny"
app:layout_constraintEnd_toEndOf="parent"
app:layout_constraintStart_toStartOf="parent"
app:layout_constraintTop_toBottomOf="@+id/safetynet_title"
tools:text="Body" />
<Button
android:id="@+id/safetynet_copy_jws"
android:layout_width="0dp"
android:layout_height="wrap_content"
android:layout_marginTop="8dp"
android:text="Copy JWS"
app:layout_constraintEnd_toStartOf="@+id/safetynet_create_report"
app:layout_constraintStart_toStartOf="parent"
app:layout_constraintTop_toBottomOf="@id/safetynet_body" />
<Button
android:id="@+id/safetynet_create_report"
android:layout_width="0dp"
android:layout_height="wrap_content"
android:layout_marginTop="8dp"
android:text="Create"
app:layout_constraintEnd_toEndOf="parent"
app:layout_constraintStart_toEndOf="@id/safetynet_copy_jws"
app:layout_constraintTop_toBottomOf="@id/safetynet_body" />
</androidx.constraintlayout.widget.ConstraintLayout>
</LinearLayout>
</androidx.core.widget.NestedScrollView>
</layout>
......@@ -37,6 +37,9 @@
<action
android:id="@+id/action_test_menu_fragment_to_contactDiaryTestFragment"
app:destination="@id/test_contact_diary_fragment" />
<action
android:id="@+id/action_test_menu_fragment_to_dataDonationFragment"
app:destination="@id/test_datadonation_fragment" />
</fragment>
<fragment
......@@ -100,5 +103,10 @@
android:name="de.rki.coronawarnapp.test.contactdiary.ui.ContactDiaryTestFragment"
android:label="ContactDiaryTestFragment"
tools:layout="@layout/fragment_test_contact_diary" />
<fragment
android:id="@+id/test_datadonation_fragment"
tools:layout="@layout/fragment_test_datadonation"
android:name="de.rki.coronawarnapp.test.datadonation.ui.DataDonationTestFragment"
android:label="DataDonationFragment" />
</navigation>
......@@ -29,6 +29,8 @@ interface ConfigData : ConfigMapping {
*/
val isDeviceTimeCorrect: Boolean
val deviceTimeState: DeviceTimeState
/**
* Returns the type config this is.
*/
......@@ -57,6 +59,23 @@ interface ConfigData : ConfigMapping {
*/
fun isValid(nowUTC: Instant): Boolean
enum class DeviceTimeState(val key: String) {
/**
* Device time was compared against server time and deemed correct
*/
CORRECT("CORRECT"),
/**
* Device time was not compared against server time for various reasons
*/
ASSUMED_CORRECT("ASSUMED_CORRECT"),
/**
* Device time was compared against server time and deemed incorrect
*/
INCORRECT("INCORRECT")
}
companion object {
val DEVICE_TIME_GRACE_RANGE: Duration = Duration.standardHours(2)
}
......
......@@ -2,12 +2,14 @@ package de.rki.coronawarnapp.appconfig.internal
import dagger.Reusable
import de.rki.coronawarnapp.appconfig.ConfigData
import de.rki.coronawarnapp.appconfig.ConfigData.DeviceTimeState.CORRECT
import de.rki.coronawarnapp.appconfig.sources.fallback.DefaultAppConfigSource
import de.rki.coronawarnapp.appconfig.sources.local.LocalAppConfigSource
import de.rki.coronawarnapp.appconfig.sources.remote.RemoteAppConfigSource
import de.rki.coronawarnapp.main.CWASettings
import de.rki.coronawarnapp.util.TimeStamper
import org.joda.time.Duration
import org.joda.time.Instant
import timber.log.Timber
import javax.inject.Inject
......@@ -49,6 +51,20 @@ class AppConfigSource @Inject constructor(
Timber.tag(TAG).i("Resetting previous incorrect device time acknowledgement.")
cwaSettings.wasDeviceTimeIncorrectAcknowledged = false
}
if (remoteConfig.deviceTimeState == CORRECT && cwaSettings.firstReliableDeviceTime == Instant.EPOCH) {
Timber.tag(TAG).i("Setting firstReliableDeviceTime to NOW (UTC). ")
cwaSettings.firstReliableDeviceTime = timeStamper.nowUTC
}
if (remoteConfig.deviceTimeState != cwaSettings.lastDeviceTimeStateChangeState) {
Timber.tag(TAG).i(
"New device time state, saving timestamp (old=%s(%s), new=%s#)",
cwaSettings.lastDeviceTimeStateChangeState,
cwaSettings.lastDeviceTimeStateChangeAt,
remoteConfig.deviceTimeState
)
cwaSettings.lastDeviceTimeStateChangeState = remoteConfig.deviceTimeState
cwaSettings.lastDeviceTimeStateChangeAt = timeStamper.nowUTC
}
remoteConfig
}
localConfig != null -> {
......
......@@ -15,7 +15,14 @@ data class ConfigDataContainer(
) : ConfigData, ConfigMapping by mappedConfig {
override val isDeviceTimeCorrect: Boolean
get() = !isDeviceTimeCheckEnabled || localOffset.abs() < ConfigData.DEVICE_TIME_GRACE_RANGE
get() = deviceTimeState != ConfigData.DeviceTimeState.INCORRECT
override val deviceTimeState: ConfigData.DeviceTimeState
get() = when {
!isDeviceTimeCheckEnabled -> ConfigData.DeviceTimeState.ASSUMED_CORRECT
localOffset.abs() < ConfigData.DEVICE_TIME_GRACE_RANGE -> ConfigData.DeviceTimeState.CORRECT
else -> ConfigData.DeviceTimeState.INCORRECT
}
override val updatedAt: Instant = serverTime.plus(localOffset)
......
package de.rki.coronawarnapp.datadonation.safetynet
import de.rki.coronawarnapp.appconfig.SafetyNetRequirements
import de.rki.coronawarnapp.datadonation.safetynet.SafetyNetException.Type
import de.rki.coronawarnapp.server.protocols.internal.ppdd.PpacAndroid
import okio.ByteString.Companion.toByteString
internal data class AttestationContainer(
private val ourSalt: ByteArray,
private val report: SafetyNetClientWrapper.Report
) : DeviceAttestation.Result {
override val accessControlProtoBuf: PpacAndroid.PPACAndroid
get() = PpacAndroid.PPACAndroid.newBuilder().apply {
salt = ourSalt.toByteString().base64()
safetyNetJws = report.jwsResult
}.build()
override fun requirePass(reqs: SafetyNetRequirements) {
if (reqs.requireBasicIntegrity && !report.basicIntegrity) {
throw SafetyNetException(
Type.BASIC_INTEGRITY_REQUIRED,
"Requirement 'basicIntegrity' not met (${report.advice})."
)
}
if (reqs.requireCTSProfileMatch && !report.ctsProfileMatch) {
throw SafetyNetException(
Type.CTS_PROFILE_MATCH_REQUIRED,
"Requirement 'ctsProfileMatch' not met (${report.advice})."
)
}
if (reqs.requireBasicIntegrity && !report.evaluationTypes.contains("BASIC")) {
throw SafetyNetException(
Type.EVALUATION_TYPE_BASIC_REQUIRED,
"Evaluation type 'BASIC' not met (${report.advice})."
)
}
if (reqs.requireEvaluationTypeHardwareBacked && !report.evaluationTypes.contains("HARDWARE_BACKED")) {
throw SafetyNetException(
Type.EVALUATION_TYPE_HARDWARE_BACKED_REQUIRED,
"Evaluation type 'HARDWARE_BACKED' not met (${report.advice})."
)
}
}
override fun equals(other: Any?): Boolean {
if (this === other) return true
if (javaClass != other?.javaClass) return false
other as AttestationContainer
if (!ourSalt.contentEquals(other.ourSalt)) return false
if (report != other.report) return false
return true
}
override fun hashCode(): Int {
var result = ourSalt.contentHashCode()
result = 31 * result + report.hashCode()
return result
}
}
package de.rki.coronawarnapp.datadonation.safetynet
import de.rki.coronawarnapp.appconfig.SafetyNetRequirements
import de.rki.coronawarnapp.server.protocols.internal.ppdd.PpacAndroid
import android.content.Context
import androidx.annotation.VisibleForTesting
import de.rki.coronawarnapp.appconfig.AppConfigProvider
import de.rki.coronawarnapp.appconfig.ConfigData
import de.rki.coronawarnapp.datadonation.safetynet.SafetyNetException.Type
import de.rki.coronawarnapp.main.CWASettings
import de.rki.coronawarnapp.util.HashExtensions.toSHA256
import de.rki.coronawarnapp.util.TimeStamper
import de.rki.coronawarnapp.util.di.AppContext
import de.rki.coronawarnapp.util.gplay.GoogleApiVersion
import org.joda.time.Duration
import org.joda.time.Instant
import timber.log.Timber
import java.security.SecureRandom
import javax.inject.Inject
import javax.inject.Singleton
@Singleton
class CWASafetyNet @Inject constructor() : DeviceAttestation {
class CWASafetyNet @Inject constructor(
@AppContext private val context: Context,
private val client: SafetyNetClientWrapper,
private val secureRandom: SecureRandom,
private val appConfigProvider: AppConfigProvider,
private val googleApiVersion: GoogleApiVersion,
private val cwaSettings: CWASettings,
private val timeStamper: TimeStamper
) : DeviceAttestation {
@VisibleForTesting(otherwise = VisibleForTesting.PRIVATE)
fun generateSalt(): ByteArray = ByteArray(16).apply {
secureRandom.nextBytes(this)
}
@VisibleForTesting(otherwise = VisibleForTesting.PRIVATE)
fun calculateNonce(salt: ByteArray, payload: ByteArray): String {
val concat = salt + payload
return concat.toSHA256()
}
override suspend fun attest(request: DeviceAttestation.Request): DeviceAttestation.Result {
return object : DeviceAttestation.Result {
override val accessControlProtoBuf: PpacAndroid.PPACAndroid = PpacAndroid.PPACAndroid.getDefaultInstance()
if (!googleApiVersion.isPlayServicesVersionAvailable(13000000)) {
throw SafetyNetException(Type.PLAY_SERVICES_VERSION_MISMATCH, "Google Play Services too old.")
}
override fun requirePass(requirements: SafetyNetRequirements) {
// Passed
appConfigProvider.getAppConfig().apply {
if (deviceTimeState == ConfigData.DeviceTimeState.ASSUMED_CORRECT) {
throw SafetyNetException(Type.DEVICE_TIME_UNVERIFIED, "Device time is unverified")
}
if (deviceTimeState == ConfigData.DeviceTimeState.INCORRECT) {
throw SafetyNetException(Type.DEVICE_TIME_INCORRECT, "Device time is incorrect")
}
}
val firstReliableTimeStamp = cwaSettings.firstReliableDeviceTime
if (firstReliableTimeStamp == Instant.EPOCH) {
throw SafetyNetException(Type.TIME_SINCE_ONBOARDING_UNVERIFIED, "No first reliable timestamp available")
} else if (Duration(firstReliableTimeStamp, timeStamper.nowUTC) < Duration.standardHours(24)) {
throw SafetyNetException(Type.TIME_SINCE_ONBOARDING_UNVERIFIED, "Time since first reliable timestamp <24h")
}
val salt = generateSalt()
val nonce = calculateNonce(salt = salt, payload = request.scenarioPayload)
Timber.tag(TAG).d("With salt=%s and payload=%s, we created nonce=%s", salt, request.scenarioPayload, nonce)
val report = client.attest(nonce.toByteArray())
report.error?.let {
Timber.tag(TAG).w("SafetyNet Response has an error message: %s", it)
}
if (nonce != report.nonce) {
throw SafetyNetException(
Type.NONCE_MISMATCH,
"Request nonce doesn't match response ($nonce != ${report.nonce})"
)
}
if (context.packageName != report.apkPackageName) {
throw SafetyNetException(
Type.APK_PACKAGE_NAME_MISMATCH,
"Our APK name doesn't match response (${context.packageName} != ${report.apkPackageName})"
)
}
return AttestationContainer(salt, report)
}
companion object {
private const val TAG = "CWASafetyNet"
}
}
package de.rki.coronawarnapp.datadonation.safetynet
import com.google.android.gms.common.api.ApiException
import com.google.android.gms.common.api.CommonStatusCodes
import com.google.android.gms.safetynet.SafetyNetApi
import com.google.android.gms.safetynet.SafetyNetClient
import com.google.gson.JsonObject
import com.google.gson.JsonParser
import dagger.Reusable
import de.rki.coronawarnapp.datadonation.safetynet.SafetyNetException.Type
import de.rki.coronawarnapp.environment.EnvironmentSetup
import kotlinx.coroutines.TimeoutCancellationException
import kotlinx.coroutines.withTimeout
import okio.ByteString.Companion.decodeBase64
import timber.log.Timber
import javax.inject.Inject
import kotlin.coroutines.resume
import kotlin.coroutines.resumeWithException
import kotlin.coroutines.suspendCoroutine
@Reusable
class SafetyNetClientWrapper @Inject constructor(
private val safetyNetClient: SafetyNetClient,
private val environmentSetup: EnvironmentSetup
) {
suspend fun attest(nonce: ByteArray): Report {
val response = try {
withTimeout(30 * 1000L) { callClient(nonce) }
} catch (e: TimeoutCancellationException) {
throw SafetyNetException(Type.ATTESTATION_FAILED, "Attestation timeout.", e)
}
val jwsResult = response.jwsResult ?: throw SafetyNetException(
Type.ATTESTATION_FAILED,
"JWS was null"
)
val components = jwsResult.split(".")
if (components.size != 3) throw SafetyNetException(
Type.ATTESTATION_FAILED,
"Invalid JWS: Components are missing."
)
val header = try {
components[0].decodeBase64Json()
} catch (e: Exception) {
throw SafetyNetException(Type.ATTESTATION_FAILED, "Failed to decode JWS header.", e)
}
val body = try {
components[1].decodeBase64Json()
} catch (e: Exception) {
throw SafetyNetException(Type.ATTESTATION_FAILED, "Failed to decode JWS body.", e)
}
val signature = try {
components[2].decodeBase64()!!.toByteArray()
} catch (e: Exception) {
throw SafetyNetException(Type.ATTESTATION_FAILED, "Failed to decode JWS signature.", e)
}
return Report(
jwsResult = jwsResult,
header = header,
body = body,
signature = signature
)
}
private fun String.decodeBase64Json(): JsonObject {
val rawJson = decodeBase64()!!.string(Charsets.UTF_8)
return JsonParser.parseString(rawJson).asJsonObject
}
private suspend fun callClient(nonce: ByteArray): SafetyNetApi.AttestationResponse = suspendCoroutine { cont ->
safetyNetClient.attest(nonce, environmentSetup.safetyNetApiKey)
.addOnSuccessListener {
Timber.tag(TAG).v("Attestation finished with %s", it)
cont.resume(it)
}
.addOnFailureListener {
Timber.tag(TAG).w(it, "Attestation failed.")
val wrappedError = if (it is ApiException && it.statusCode == CommonStatusCodes.NETWORK_ERROR) {
SafetyNetException(Type.ATTESTATION_REQUEST_FAILED, "Network error", it)
} else {
SafetyNetException(Type.ATTESTATION_FAILED, "SafetyNet client returned an error.", it)
}
cont.resumeWithException(wrappedError)
}
}
data class Report(
val jwsResult: String,
val header: JsonObject,
val body: JsonObject,
val signature: ByteArray
) {
val nonce: String? = body.get("nonce")?.asString
val apkPackageName: String? = body.get("apkPackageName")?.asString
val basicIntegrity: Boolean = body.get("basicIntegrity")?.asBoolean == true
val ctsProfileMatch = body.get("ctsProfileMatch")?.asBoolean == true
val evaluationTypes = body.get("evaluationType")?.asString
?.split(",")?.map { it.trim() } ?: emptyList()
val error: String? = body.get("error")?.asString
val advice: String? = body.get("advice")?.asString
override fun equals(other: Any?): Boolean {
if (this === other) return true
if (javaClass != other?.javaClass) return false
other as Report
if (jwsResult != other.jwsResult) return false
return true
}
override fun hashCode(): Int = jwsResult.hashCode()
}
companion object {
private const val TAG = "SafetyNetWrapper"
}
}
package de.rki.coronawarnapp.datadonation.safetynet
class SafetyNetException constructor(
message: String?,
val type: Type,
message: String,
cause: Throwable? = null
) : Exception(message, cause)
) : Exception("$type: $message", cause) {
enum class Type {
// TRY_AGAIN_LATER (Text Key)
APK_PACKAGE_NAME_MISMATCH,
ATTESTATION_FAILED,
ATTESTATION_REQUEST_FAILED,
DEVICE_TIME_UNVERIFIED,
NONCE_MISMATCH,
// DEVICE_NOT_TRUSTED (Text Key)
BASIC_INTEGRITY_REQUIRED,
CTS_PROFILE_MATCH_REQUIRED,
EVALUATION_TYPE_BASIC_REQUIRED,
EVALUATION_TYPE_HARDWARE_BACKED_REQUIRED,
// CHANGE_DEVICE_TIME (Text Key)
DEVICE_TIME_INCORRECT,
// UPDATE_PLAY_SERVICES (Text Key)
PLAY_SERVICES_VERSION_MISMATCH,
// TIME_SINCE_ONBOARDING_UNVERIFIED (Text Key)
TIME_SINCE_ONBOARDING_UNVERIFIED
}
}
......@@ -2,9 +2,11 @@ package de.rki.coronawarnapp.main
import android.content.Context
import androidx.core.content.edit
import de.rki.coronawarnapp.appconfig.ConfigData
import de.rki.coronawarnapp.util.di.AppContext
import de.rki.coronawarnapp.util.preferences.clearAndNotify
import de.rki.coronawarnapp.util.preferences.createFlowPreference
import org.joda.time.Instant
import javax.inject.Inject
/**
......@@ -25,6 +27,21 @@ class CWASettings @Inject constructor(
get() = prefs.getBoolean(PKEY_DEVICE_TIME_INCORRECT_ACK, false)
set(value) = prefs.edit { putBoolean(PKEY_DEVICE_TIME_INCORRECT_ACK, value) }
var firstReliableDeviceTime: Instant
get() = Instant.ofEpochMilli(prefs.getLong(PKEY_DEVICE_TIME_FIRST_RELIABLE, 0L))
set(value) = prefs.edit { putLong(PKEY_DEVICE_TIME_FIRST_RELIABLE, value.millis) }
var lastDeviceTimeStateChangeAt: Instant
get() = Instant.ofEpochMilli(prefs.getLong(PKEY_DEVICE_TIME_LAST_STATE_CHANGE_TIME, 0L))
set(value) = prefs.edit { putLong(PKEY_DEVICE_TIME_LAST_STATE_CHANGE_TIME, value.millis) }
var lastDeviceTimeStateChangeState: ConfigData.DeviceTimeState
get() = prefs.getString(
PKEY_DEVICE_TIME_LAST_STATE_CHANGE_STATE,
ConfigData.DeviceTimeState.INCORRECT.key
).let { raw -> ConfigData.DeviceTimeState.values().single { it.key == raw } }
set(value) = prefs.edit { putString(PKEY_DEVICE_TIME_LAST_STATE_CHANGE_STATE, value.key) }
val lastChangelogVersion = prefs.createFlowPreference(
key = LAST_CHANGELOG_VERSION,
defaultValue = DEFAULT_APP_VERSION
......@@ -36,6 +53,9 @@ class CWASettings @Inject constructor(
companion object {
private const val PKEY_DEVICE_TIME_INCORRECT_ACK = "devicetime.incorrect.acknowledged"
private const val PKEY_DEVICE_TIME_FIRST_RELIABLE = "devicetime.correct.first"
private const val PKEY_DEVICE_TIME_LAST_STATE_CHANGE_TIME = "devicetime.laststatechange.timestamp"
private const val PKEY_DEVICE_TIME_LAST_STATE_CHANGE_STATE = "devicetime.laststatechange.state"
private const val LAST_CHANGELOG_VERSION = "update.changelog.lastversion"
private const val DEFAULT_APP_VERSION = 1L
}
......
......@@ -12,6 +12,8 @@ import androidx.lifecycle.LifecycleOwner
import androidx.lifecycle.ProcessLifecycleOwner
import androidx.navigation.NavDeepLinkBuilder
import androidx.work.WorkManager
import com.google.android.gms.safetynet.SafetyNet
import com.google.android.gms.safetynet.SafetyNetClient
import dagger.Module
import dagger.Provides
import de.rki.coronawarnapp.CoronaWarnApplication
......@@ -70,4 +72,8 @@ class AndroidModule {
@Singleton
@ProcessLifecycle
fun procressLifecycleOwner(): LifecycleOwner = ProcessLifecycleOwner.get()
@Provides
@Singleton
fun safetyNet(@AppContext context: Context): SafetyNetClient = SafetyNet.getClient(context)
}
......@@ -37,6 +37,7 @@ import de.rki.coronawarnapp.util.coroutine.CoroutineModule
import de.rki.coronawarnapp.util.device.DeviceModule
import de.rki.coronawarnapp.util.security.EncryptedPreferencesFactory
import de.rki.coronawarnapp.util.security.EncryptionErrorResetTool
import de.rki.coronawarnapp.util.security.SecurityModule
import de.rki.coronawarnapp.util.serialization.SerializationModule
import de.rki.coronawarnapp.util.worker.WorkerBinder
import de.rki.coronawarnapp.verification.VerificationModule
......@@ -70,7 +71,8 @@ import javax.inject.Singleton
SerializationModule::class,
WorkerBinder::class,
StatisticsModule::class,
DataDonationModule::class
DataDonationModule::class,
SecurityModule::class
]
)
interface ApplicationComponent : AndroidInjector<CoronaWarnApplication> {
......
package de.rki.coronawarnapp.util.gplay
import android.content.Context
import com.google.android.gms.common.ConnectionResult
import com.google.android.gms.common.GoogleApiAvailability
import dagger.Reusable
import de.rki.coronawarnapp.util.di.AppContext
import javax.inject.Inject
@Reusable
class GoogleApiVersion @Inject constructor(
@AppContext private val context: Context
) {
private val apiAvailability = GoogleApiAvailability.getInstance()
fun isPlayServicesVersionAvailable(requiredVersion: Int): Boolean {
return apiAvailability.isGooglePlayServicesAvailable(context, requiredVersion) == ConnectionResult.SUCCESS
}
}
......@@ -2,7 +2,6 @@ package de.rki.coronawarnapp.util.security
import android.annotation.SuppressLint
import android.content.SharedPreferences
import android.os.Build
import android.util.Base64
import androidx.annotation.VisibleForTesting
import de.rki.coronawarnapp.exception.CwaSecurityException
......@@ -14,7 +13,6 @@ import de.rki.coronawarnapp.util.security.SecurityConstants.DB_PASSWORD_MAX_LENG
import de.rki.coronawarnapp.util.security.SecurityConstants.DB_PASSWORD_MIN_LENGTH
import de.rki.coronawarnapp.util.security.SecurityConstants.ENCRYPTED_SHARED_PREFERENCES_FILE
import timber.log.Timber
import java.security.SecureRandom
/**
* Key Store and Password Access
......@@ -78,11 +76,7 @@ object SecurityHelper {
}
private fun generateDBPassword(): ByteArray {
val secureRandom = if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) {
SecureRandom.getInstanceStrong()
} else {
SecureRandom()
}
val secureRandom = SecurityModule().secureRandom()
val max = DB_PASSWORD_MAX_LENGTH
val min = DB_PASSWORD_MIN_LENGTH
val passwordLength = secureRandom.nextInt(max - min + 1) + min
......
package de.rki.coronawarnapp.util.security
import android.os.Build
import dagger.Module
import dagger.Provides
import java.security.SecureRandom
@Module
class SecurityModule {
@Provides
fun secureRandom(): SecureRandom = if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) {
SecureRandom.getInstanceStrong()
} else {
SecureRandom()
}
}
0% Loading or .
You are about to add 0 people to the discussion. Proceed with caution.
Finish editing this message first!
Please register or to comment