diff --git a/Corona-Warn-App/proguard-rules.pro b/Corona-Warn-App/proguard-rules.pro index 067b7d21c059535c5904f766d6e1ed0dd58f1a9c..b6efd7f86501984cf30beb1eb661ba51d78a0463 100644 --- a/Corona-Warn-App/proguard-rules.pro +++ b/Corona-Warn-App/proguard-rules.pro @@ -57,7 +57,4 @@ # With R8 full mode, it sees no subtypes of Retrofit interfaces since they are created with a Proxy # and replaces all potential values with null. Explicitly keeping the interfaces prevents this. -if interface * { @retrofit2.http.* <methods>; } --keep,allowobfuscation interface <1> - --keep class de.rki.coronawarnapp.http.requests.* { *; } --keep class de.rki.coronawarnapp.http.responses.* { *; } \ No newline at end of file +-keep,allowobfuscation interface <1> \ No newline at end of file diff --git a/Corona-Warn-App/src/deviceForTesters/java/de/rki/coronawarnapp/test/api/ui/TestForAPIFragment.kt b/Corona-Warn-App/src/deviceForTesters/java/de/rki/coronawarnapp/test/api/ui/TestForAPIFragment.kt index c7c45d7babc6cc52af544b1390ee3c23df91ad01..6f8e10d248095717d40e0de6284c41a731b541bc 100644 --- a/Corona-Warn-App/src/deviceForTesters/java/de/rki/coronawarnapp/test/api/ui/TestForAPIFragment.kt +++ b/Corona-Warn-App/src/deviceForTesters/java/de/rki/coronawarnapp/test/api/ui/TestForAPIFragment.kt @@ -33,7 +33,6 @@ import com.google.zxing.BarcodeFormat import com.google.zxing.integration.android.IntentIntegrator import com.google.zxing.integration.android.IntentResult import com.google.zxing.qrcode.QRCodeWriter -import de.rki.coronawarnapp.CoronaWarnApplication import de.rki.coronawarnapp.R import de.rki.coronawarnapp.RiskLevelAndKeyRetrievalBenchmark import de.rki.coronawarnapp.databinding.FragmentTestForAPIBinding @@ -55,6 +54,7 @@ import de.rki.coronawarnapp.storage.LocalData import de.rki.coronawarnapp.storage.tracing.TracingIntervalRepository import de.rki.coronawarnapp.transaction.RiskLevelTransaction import de.rki.coronawarnapp.ui.viewmodel.TracingViewModel +import de.rki.coronawarnapp.util.CWADebug import de.rki.coronawarnapp.util.KeyFileHelper import de.rki.coronawarnapp.util.di.AppInjector import de.rki.coronawarnapp.util.di.AutoInject @@ -298,9 +298,9 @@ class TestForAPIFragment : Fragment(R.layout.fragment_test_for_a_p_i), false } - binding.testLogfileToggle.isChecked = CoronaWarnApplication.fileLogger?.isLogging ?: false + binding.testLogfileToggle.isChecked = CWADebug.fileLogger?.isLogging ?: false binding.testLogfileToggle.setOnClickListener { buttonView -> - CoronaWarnApplication.fileLogger?.let { + CWADebug.fileLogger?.let { if (binding.testLogfileToggle.isChecked) { it.start() } else { @@ -310,7 +310,7 @@ class TestForAPIFragment : Fragment(R.layout.fragment_test_for_a_p_i), } binding.testLogfileShare.setOnClickListener { - CoronaWarnApplication.fileLogger?.let { + CWADebug.fileLogger?.let { lifecycleScope.launch { val targetPath = withContext(Dispatchers.IO) { async { diff --git a/Corona-Warn-App/src/main/java/de/rki/coronawarnapp/CoronaWarnApplication.kt b/Corona-Warn-App/src/main/java/de/rki/coronawarnapp/CoronaWarnApplication.kt index 184ec476975eeee93a524b35091a2208054fc6ad..abeedeea482c5d135171cb36b9cd80306fd62db4 100644 --- a/Corona-Warn-App/src/main/java/de/rki/coronawarnapp/CoronaWarnApplication.kt +++ b/Corona-Warn-App/src/main/java/de/rki/coronawarnapp/CoronaWarnApplication.kt @@ -6,14 +6,11 @@ import android.app.Application import android.content.Context import android.content.IntentFilter import android.content.pm.ActivityInfo -import android.net.wifi.WifiManager import android.os.Bundle -import android.os.PowerManager import androidx.lifecycle.Lifecycle import androidx.lifecycle.LifecycleObserver import androidx.lifecycle.OnLifecycleEvent import androidx.lifecycle.ProcessLifecycleOwner -import androidx.lifecycle.lifecycleScope import androidx.localbroadcastmanager.content.LocalBroadcastManager import androidx.work.Configuration import androidx.work.WorkManager @@ -23,187 +20,115 @@ import dagger.android.HasAndroidInjector import de.rki.coronawarnapp.exception.reporting.ErrorReportReceiver import de.rki.coronawarnapp.exception.reporting.ReportingConstants.ERROR_REPORT_LOCAL_BROADCAST_CHANNEL import de.rki.coronawarnapp.notification.NotificationHelper -import de.rki.coronawarnapp.storage.LocalData -import de.rki.coronawarnapp.transaction.RetrieveDiagnosisKeysTransaction import de.rki.coronawarnapp.util.CWADebug -import de.rki.coronawarnapp.util.ConnectivityHelper -import de.rki.coronawarnapp.util.debug.FileLogger +import de.rki.coronawarnapp.util.WatchdogService import de.rki.coronawarnapp.util.di.AppInjector import de.rki.coronawarnapp.util.di.ApplicationComponent import de.rki.coronawarnapp.worker.BackgroundWorkHelper -import de.rki.coronawarnapp.worker.BackgroundWorkScheduler -import kotlinx.coroutines.launch import org.conscrypt.Conscrypt import timber.log.Timber import java.security.Security -import java.util.UUID import javax.inject.Inject -class CoronaWarnApplication : Application(), LifecycleObserver, - Application.ActivityLifecycleCallbacks, HasAndroidInjector { +class CoronaWarnApplication : Application(), HasAndroidInjector { - companion object { - val TAG: String? = CoronaWarnApplication::class.simpleName - private lateinit var instance: CoronaWarnApplication - - /* describes if the app is in foreground - * Initialized to false, because app could also be started by a background job. - * For the cases where the app is started via the launcher icon, the onAppForegrounded - * event will be called, setting it to true - */ - var isAppInForeground = false - - fun getAppContext(): Context = - instance.applicationContext - - const val TEN_MINUTE_TIMEOUT_IN_MS = 10 * 60 * 1000L - var fileLogger: FileLogger? = null - } + @Inject lateinit var component: ApplicationComponent - private lateinit var errorReceiver: ErrorReportReceiver - - @Inject - lateinit var component: ApplicationComponent - - @Inject - lateinit var androidInjector: DispatchingAndroidInjector<Any> + @Inject lateinit var androidInjector: DispatchingAndroidInjector<Any> override fun androidInjector(): AndroidInjector<Any> = androidInjector + @Inject lateinit var watchdogService: WatchdogService + override fun onCreate() { instance = this super.onCreate() - AppInjector.init(this) + CWADebug.init(this) - if (CWADebug.isDebugBuildOrMode) System.setProperty("kotlinx.coroutines.debug", "on") + Timber.v("onCreate(): Initializing Dagger") + AppInjector.init(this) - val configuration = Configuration.Builder() - .setMinimumLoggingLevel(android.util.Log.DEBUG) - .build() - WorkManager.initialize(this, configuration) + Timber.v("onCreate(): Initializing WorkManager") + Configuration.Builder() + .apply { setMinimumLoggingLevel(android.util.Log.DEBUG) }.build() + .let { WorkManager.initialize(this, it) } NotificationHelper.createNotificationChannel() + // Enable Conscrypt for TLS1.3 Support below API Level 29 Security.insertProviderAt(Conscrypt.newProvider(), 1) - ProcessLifecycleOwner.get().lifecycle.addObserver(this) - registerActivityLifecycleCallbacks(this) - if (BuildConfig.DEBUG) { - Timber.plant(Timber.DebugTree()) - } - if ((BuildConfig.FLAVOR == "deviceForTesters" || BuildConfig.DEBUG)) { - fileLogger = FileLogger(this) - } + ProcessLifecycleOwner.get().lifecycle.addObserver(foregroundStateUpdater) + registerActivityLifecycleCallbacks(activityLifecycleCallback) - // notification to test the WakeUpService from Google when the app - // was force stopped + // notification to test the WakeUpService from Google when the app was force stopped BackgroundWorkHelper.sendDebugNotification( "Application onCreate", "App was woken up" ) - // Only do this if the background jobs are enabled - if (ConnectivityHelper.autoModeEnabled(applicationContext)) { - ProcessLifecycleOwner.get().lifecycleScope.launch { - // we want a wakelock as the OS does not handle this for us like in the background - // job execution - val wakeLock = createWakeLock() - // we keep a wifi lock to wake up the wifi connection in case the device is dozing - val wifiLock = createWifiLock() - try { - BackgroundWorkHelper.sendDebugNotification( - "Automatic mode is on", "Check if we have downloaded keys already today" - ) - RetrieveDiagnosisKeysTransaction.startWithConstraints() - } catch (e: Exception) { - BackgroundWorkHelper.sendDebugNotification( - "RetrieveDiagnosisKeysTransaction failed", - (e.localizedMessage - ?: "Unknown exception occurred in onCreate") + "\n\n" + (e.cause - ?: "Cause is unknown").toString() - ) - // retry the key retrieval in case of an error with a scheduled work - BackgroundWorkScheduler.scheduleDiagnosisKeyOneTimeWork() - } - - if (wifiLock.isHeld) wifiLock.release() - if (wakeLock.isHeld) wakeLock.release() - } + watchdogService.launch() + } + + private val foregroundStateUpdater = object : LifecycleObserver { + @OnLifecycleEvent(Lifecycle.Event.ON_START) + fun onAppForegrounded() { + isAppInForeground = true + Timber.v("App is in the foreground") + } - // if the user is onboarded we will schedule period background jobs - // in case the app was force stopped and woken up again by the Google WakeUpService - if (LocalData.onboardingCompletedTimestamp() != null) BackgroundWorkScheduler.startWorkScheduler() + @OnLifecycleEvent(Lifecycle.Event.ON_STOP) + fun onAppBackgrounded() { + isAppInForeground = false + Timber.v("App is in the background") } } - private fun createWakeLock(): PowerManager.WakeLock = - (getSystemService(Context.POWER_SERVICE) as PowerManager) - .run { - newWakeLock( - PowerManager.PARTIAL_WAKE_LOCK, - TAG + "-WAKE-" + UUID.randomUUID().toString() - ).apply { - acquire(TEN_MINUTE_TIMEOUT_IN_MS) - } - } + private val activityLifecycleCallback = object : ActivityLifecycleCallbacks { + private val localBM by lazy { + LocalBroadcastManager.getInstance(this@CoronaWarnApplication) + } + private var errorReceiver: ErrorReportReceiver? = null - private fun createWifiLock(): WifiManager.WifiLock = - (getSystemService(Context.WIFI_SERVICE) as WifiManager) - .run { - createWifiLock( - WifiManager.WIFI_MODE_FULL_HIGH_PERF, - TAG + "-WIFI-" + UUID.randomUUID().toString() - ).apply { - acquire() - } + override fun onActivityPaused(activity: Activity) { + errorReceiver?.let { + localBM.unregisterReceiver(it) + errorReceiver = null } + } - /** - * Callback when the app is open but backgrounded - */ - @OnLifecycleEvent(Lifecycle.Event.ON_STOP) - fun onAppBackgrounded() { - isAppInForeground = false - Timber.v("App backgrounded") - } + override fun onActivityStarted(activity: Activity) {} - /** - * Callback when the app is foregrounded - */ - @OnLifecycleEvent(Lifecycle.Event.ON_START) - fun onAppForegrounded() { - isAppInForeground = true - Timber.v("App foregrounded") - } + override fun onActivityDestroyed(activity: Activity) {} - override fun onActivityPaused(activity: Activity) { - // unregisters error receiver - LocalBroadcastManager.getInstance(this).unregisterReceiver(errorReceiver) - } + override fun onActivitySaveInstanceState(activity: Activity, outState: Bundle) {} - override fun onActivityStarted(activity: Activity) { - // does not override function. Empty on intention - } + override fun onActivityStopped(activity: Activity) {} - override fun onActivityDestroyed(activity: Activity) { - // does not override function. Empty on intention - } + @SuppressLint("SourceLockedOrientationActivity") + override fun onActivityCreated(activity: Activity, savedInstanceState: Bundle?) { + activity.requestedOrientation = ActivityInfo.SCREEN_ORIENTATION_USER_PORTRAIT + } - override fun onActivitySaveInstanceState(activity: Activity, outState: Bundle) { - // does not override function. Empty on intention - } + override fun onActivityResumed(activity: Activity) { + errorReceiver?.let { + localBM.unregisterReceiver(it) + errorReceiver = null + } - override fun onActivityStopped(activity: Activity) { - // does not override function. Empty on intention + errorReceiver = ErrorReportReceiver(activity).also { + localBM.registerReceiver(it, IntentFilter(ERROR_REPORT_LOCAL_BROADCAST_CHANNEL)) + } + } } - @SuppressLint("SourceLockedOrientationActivity") - override fun onActivityCreated(activity: Activity, savedInstanceState: Bundle?) { - // set screen orientation to portrait - activity.requestedOrientation = ActivityInfo.SCREEN_ORIENTATION_USER_PORTRAIT - } + companion object { + private lateinit var instance: CoronaWarnApplication + + /* describes if the app is in foreground + * Initialized to false, because app could also be started by a background job. + * For the cases where the app is started via the launcher icon, the onAppForegrounded + * event will be called, setting it to true + */ + var isAppInForeground = false - override fun onActivityResumed(activity: Activity) { - errorReceiver = - ErrorReportReceiver(activity) - LocalBroadcastManager.getInstance(this) - .registerReceiver(errorReceiver, IntentFilter(ERROR_REPORT_LOCAL_BROADCAST_CHANNEL)) + fun getAppContext(): Context = instance.applicationContext } } diff --git a/Corona-Warn-App/src/main/java/de/rki/coronawarnapp/appconfig/AppConfigProvider.kt b/Corona-Warn-App/src/main/java/de/rki/coronawarnapp/appconfig/AppConfigProvider.kt index 72130ea55e1d9c2017e172e378f6f76767ac7581..796f19a123ce9fd8886172f8389ebfb122aff32a 100644 --- a/Corona-Warn-App/src/main/java/de/rki/coronawarnapp/appconfig/AppConfigProvider.kt +++ b/Corona-Warn-App/src/main/java/de/rki/coronawarnapp/appconfig/AppConfigProvider.kt @@ -4,7 +4,7 @@ import androidx.annotation.VisibleForTesting import dagger.Lazy import de.rki.coronawarnapp.diagnosiskeys.server.LocationCode import de.rki.coronawarnapp.environment.download.DownloadCDNHomeCountry -import de.rki.coronawarnapp.server.protocols.ApplicationConfigurationOuterClass +import de.rki.coronawarnapp.server.protocols.ApplicationConfigurationOuterClass.ApplicationConfiguration import de.rki.coronawarnapp.util.ZipHelper.unzip import de.rki.coronawarnapp.util.security.VerificationKeys import kotlinx.coroutines.Dispatchers @@ -47,7 +47,13 @@ class AppConfigProvider @Inject constructor( return exportBinary!! } - private suspend fun getNewAppConfig(): ApplicationConfigurationOuterClass.ApplicationConfiguration? { + private fun tryParseConfig(byteArray: ByteArray?): ApplicationConfiguration? { + Timber.v("Parsing config (size=%dB)", byteArray?.size) + if (byteArray == null) return null + return ApplicationConfiguration.parseFrom(byteArray) + } + + private suspend fun getNewAppConfig(): ApplicationConfiguration? { val newConfigRaw = try { downloadAppConfig() } catch (e: Exception) { @@ -68,7 +74,7 @@ class AppConfigProvider @Inject constructor( } } - private fun getFallback(): ApplicationConfigurationOuterClass.ApplicationConfiguration { + private fun getFallback(): ApplicationConfiguration { val lastValidConfig = tryParseConfig(configStorage.appConfigRaw) return if (lastValidConfig != null) { Timber.d("Using fallback AppConfig.") @@ -79,22 +85,15 @@ class AppConfigProvider @Inject constructor( } } - suspend fun getAppConfig(): ApplicationConfigurationOuterClass.ApplicationConfiguration = - withContext(Dispatchers.IO) { - val newAppConfig = getNewAppConfig() + suspend fun getAppConfig(): ApplicationConfiguration = withContext(Dispatchers.IO) { + val newAppConfig = getNewAppConfig() - return@withContext if (newAppConfig != null) { - newAppConfig - } else { - Timber.w("No new config available, using last valid.") - getFallback() - } + return@withContext if (newAppConfig != null) { + newAppConfig + } else { + Timber.w("No new config available, using last valid.") + getFallback() } - - private fun tryParseConfig(byteArray: ByteArray?): ApplicationConfigurationOuterClass.ApplicationConfiguration? { - Timber.v("Parsing config (size=%dB)", byteArray?.size) - if (byteArray == null) return null - return ApplicationConfigurationOuterClass.ApplicationConfiguration.parseFrom(byteArray) } companion object { diff --git a/Corona-Warn-App/src/main/java/de/rki/coronawarnapp/appconfig/ApplicationConfigurationExtensions.kt b/Corona-Warn-App/src/main/java/de/rki/coronawarnapp/appconfig/ApplicationConfigurationExtensions.kt new file mode 100644 index 0000000000000000000000000000000000000000..48423b587ce0079e958f1d01b16700370ed79a7d --- /dev/null +++ b/Corona-Warn-App/src/main/java/de/rki/coronawarnapp/appconfig/ApplicationConfigurationExtensions.kt @@ -0,0 +1,11 @@ +package de.rki.coronawarnapp.appconfig + +import de.rki.coronawarnapp.server.protocols.ApplicationConfigurationOuterClass.ApplicationConfiguration + +fun ApplicationConfiguration.toNewConfig( + action: ApplicationConfiguration.Builder.() -> Unit +): ApplicationConfiguration { + val builder = this.toBuilder() + action(builder) + return builder.build() +} diff --git a/Corona-Warn-App/src/main/java/de/rki/coronawarnapp/environment/EnvironmentSetup.kt b/Corona-Warn-App/src/main/java/de/rki/coronawarnapp/environment/EnvironmentSetup.kt index dea26f41e610798f6dfcb8c3ed1ba958c1303365..1ca6cf28a1157c070f1f1213bf4aef857228de91 100644 --- a/Corona-Warn-App/src/main/java/de/rki/coronawarnapp/environment/EnvironmentSetup.kt +++ b/Corona-Warn-App/src/main/java/de/rki/coronawarnapp/environment/EnvironmentSetup.kt @@ -17,9 +17,6 @@ import javax.inject.Singleton class EnvironmentSetup @Inject constructor( private val context: Context ) { - companion object { - private const val PKEY_CURRENT_ENVINROMENT = "environment.current" - } enum class ENVKEY(val rawKey: String) { SUBMISSION("SUBMISSION_CDN_URL"), @@ -101,4 +98,8 @@ class EnvironmentSetup @Inject constructor( private fun String.toEnvironmentType(): Type = Type.values().single { it.rawKey == this } + + companion object { + private const val PKEY_CURRENT_ENVINROMENT = "environment.current" + } } diff --git a/Corona-Warn-App/src/main/java/de/rki/coronawarnapp/http/HttpModule.kt b/Corona-Warn-App/src/main/java/de/rki/coronawarnapp/http/HttpModule.kt index a8c68ad05ffbdfa4555d2d91188bc72235dc7d04..a921a828fdc1e36ebdeb51188ed4553e94a64cfd 100644 --- a/Corona-Warn-App/src/main/java/de/rki/coronawarnapp/http/HttpModule.kt +++ b/Corona-Warn-App/src/main/java/de/rki/coronawarnapp/http/HttpModule.kt @@ -8,8 +8,11 @@ import de.rki.coronawarnapp.http.config.HTTPVariables import de.rki.coronawarnapp.http.interceptor.RetryInterceptor import de.rki.coronawarnapp.http.interceptor.WebSecurityVerificationInterceptor import de.rki.coronawarnapp.risk.TimeVariables +import okhttp3.CipherSuite +import okhttp3.ConnectionSpec import okhttp3.Interceptor import okhttp3.OkHttpClient +import okhttp3.TlsVersion import okhttp3.logging.HttpLoggingInterceptor import retrofit2.converter.gson.GsonConverterFactory import retrofit2.converter.protobuf.ProtoConverterFactory @@ -52,5 +55,40 @@ class HttpModule { @Reusable @Provides - fun provideProtoonverter(): ProtoConverterFactory = ProtoConverterFactory.create() + fun provideProtoConverter(): ProtoConverterFactory = ProtoConverterFactory.create() + + @Reusable + @RestrictedConnectionSpecs + @Provides + fun restrictedConnectionSpecs(): List<ConnectionSpec> = ConnectionSpec + .Builder(ConnectionSpec.RESTRICTED_TLS) + .tlsVersions( + TlsVersion.TLS_1_2, + TlsVersion.TLS_1_3 + ) + .cipherSuites( + // TLS 1.2 with Perfect Forward Secrecy (BSI TR-02102-2) + CipherSuite.TLS_ECDH_ECDSA_WITH_AES_128_CBC_SHA256, + CipherSuite.TLS_ECDHE_ECDSA_WITH_AES_256_CBC_SHA384, + CipherSuite.TLS_ECDHE_ECDSA_WITH_AES_128_GCM_SHA256, + CipherSuite.TLS_ECDHE_ECDSA_WITH_AES_256_GCM_SHA384, + CipherSuite.TLS_ECDHE_RSA_WITH_AES_128_CBC_SHA256, + CipherSuite.TLS_ECDHE_RSA_WITH_AES_256_CBC_SHA384, + CipherSuite.TLS_ECDHE_RSA_WITH_AES_128_GCM_SHA256, + CipherSuite.TLS_ECDHE_RSA_WITH_AES_256_GCM_SHA384, + CipherSuite.TLS_DHE_DSS_WITH_AES_128_CBC_SHA256, + CipherSuite.TLS_DHE_DSS_WITH_AES_256_CBC_SHA256, + CipherSuite.TLS_DHE_DSS_WITH_AES_128_GCM_SHA256, + CipherSuite.TLS_DHE_DSS_WITH_AES_256_GCM_SHA384, + CipherSuite.TLS_DHE_RSA_WITH_AES_128_CBC_SHA256, + CipherSuite.TLS_DHE_RSA_WITH_AES_256_CBC_SHA256, + CipherSuite.TLS_DHE_RSA_WITH_AES_128_GCM_SHA256, + CipherSuite.TLS_DHE_RSA_WITH_AES_256_GCM_SHA384, + // TLS 1.3 (BSI TR-02102-2) + CipherSuite.TLS_AES_128_GCM_SHA256, + CipherSuite.TLS_AES_256_GCM_SHA384, + CipherSuite.TLS_AES_128_CCM_SHA256 + ) + .build() + .let { listOf(it) } } diff --git a/Corona-Warn-App/src/main/java/de/rki/coronawarnapp/http/RestrictedConnectionSpecs.kt b/Corona-Warn-App/src/main/java/de/rki/coronawarnapp/http/RestrictedConnectionSpecs.kt new file mode 100644 index 0000000000000000000000000000000000000000..396eb9a39d0cc47c463a2e8e67369d9eaf019b74 --- /dev/null +++ b/Corona-Warn-App/src/main/java/de/rki/coronawarnapp/http/RestrictedConnectionSpecs.kt @@ -0,0 +1,8 @@ +package de.rki.coronawarnapp.http + +import javax.inject.Qualifier + +@Qualifier +@MustBeDocumented +@Retention(AnnotationRetention.RUNTIME) +annotation class RestrictedConnectionSpecs diff --git a/Corona-Warn-App/src/main/java/de/rki/coronawarnapp/http/ServiceFactory.kt b/Corona-Warn-App/src/main/java/de/rki/coronawarnapp/http/ServiceFactory.kt deleted file mode 100644 index b659b337a6f947cc4ed110b679b17ded3f44bd0d..0000000000000000000000000000000000000000 --- a/Corona-Warn-App/src/main/java/de/rki/coronawarnapp/http/ServiceFactory.kt +++ /dev/null @@ -1,101 +0,0 @@ -package de.rki.coronawarnapp.http - -import de.rki.coronawarnapp.CoronaWarnApplication -import de.rki.coronawarnapp.environment.submission.SubmissionCDNServerUrl -import de.rki.coronawarnapp.environment.verification.VerificationCDNServerUrl -import de.rki.coronawarnapp.http.service.SubmissionService -import de.rki.coronawarnapp.http.service.VerificationService -import okhttp3.Cache -import okhttp3.CipherSuite -import okhttp3.ConnectionSpec -import okhttp3.OkHttpClient -import okhttp3.TlsVersion -import retrofit2.Retrofit -import retrofit2.converter.gson.GsonConverterFactory -import retrofit2.converter.protobuf.ProtoConverterFactory -import java.io.File -import javax.inject.Inject - -class ServiceFactory @Inject constructor( - @VerificationCDNServerUrl private val verificationCdnUrl: String, - @SubmissionCDNServerUrl private val submissionCdnUrl: String, - private val gsonConverterFactory: GsonConverterFactory, - private val protoConverterFactory: ProtoConverterFactory, - @HttpClientDefault private val defaultHttpClient: OkHttpClient -) { - - private val cache by lazy { - Cache( - directory = File(CoronaWarnApplication.getAppContext().cacheDir, HTTP_CACHE_FOLDER), - maxSize = HTTP_CACHE_SIZE - ) - } - private val okHttpClient by lazy { - defaultHttpClient - .newBuilder() - .connectionSpecs(getRestrictedSpecs()) - .cache(cache) - .build() - } - - fun verificationService(): VerificationService = verificationService - private val verificationService by lazy { - Retrofit.Builder() - .client(okHttpClient) - .baseUrl(verificationCdnUrl) - .addConverterFactory(gsonConverterFactory) - .build() - .create(VerificationService::class.java) - } - - fun submissionService(): SubmissionService = submissionService - private val submissionService by lazy { - Retrofit.Builder() - .client(okHttpClient) - .baseUrl(submissionCdnUrl) - .addConverterFactory(protoConverterFactory) - .addConverterFactory(gsonConverterFactory) - .build() - .create(SubmissionService::class.java) - } - - companion object { - private const val HTTP_CACHE_SIZE = 10L * 1024L * 1024L // 10 MiB - private const val HTTP_CACHE_FOLDER = "http_cache" // <pkg>/cache/http_cache - - /** - * For Submission and Verification we want to limit our specifications for TLS. - */ - private fun getRestrictedSpecs(): List<ConnectionSpec> = listOf( - ConnectionSpec.Builder(ConnectionSpec.RESTRICTED_TLS) - .tlsVersions( - TlsVersion.TLS_1_2, - TlsVersion.TLS_1_3 - ) - .cipherSuites( - // TLS 1.2 with Perfect Forward Secrecy (BSI TR-02102-2) - CipherSuite.TLS_ECDH_ECDSA_WITH_AES_128_CBC_SHA256, - CipherSuite.TLS_ECDHE_ECDSA_WITH_AES_256_CBC_SHA384, - CipherSuite.TLS_ECDHE_ECDSA_WITH_AES_128_GCM_SHA256, - CipherSuite.TLS_ECDHE_ECDSA_WITH_AES_256_GCM_SHA384, - CipherSuite.TLS_ECDHE_RSA_WITH_AES_128_CBC_SHA256, - CipherSuite.TLS_ECDHE_RSA_WITH_AES_256_CBC_SHA384, - CipherSuite.TLS_ECDHE_RSA_WITH_AES_128_GCM_SHA256, - CipherSuite.TLS_ECDHE_RSA_WITH_AES_256_GCM_SHA384, - CipherSuite.TLS_DHE_DSS_WITH_AES_128_CBC_SHA256, - CipherSuite.TLS_DHE_DSS_WITH_AES_256_CBC_SHA256, - CipherSuite.TLS_DHE_DSS_WITH_AES_128_GCM_SHA256, - CipherSuite.TLS_DHE_DSS_WITH_AES_256_GCM_SHA384, - CipherSuite.TLS_DHE_RSA_WITH_AES_128_CBC_SHA256, - CipherSuite.TLS_DHE_RSA_WITH_AES_256_CBC_SHA256, - CipherSuite.TLS_DHE_RSA_WITH_AES_128_GCM_SHA256, - CipherSuite.TLS_DHE_RSA_WITH_AES_256_GCM_SHA384, - // TLS 1.3 (BSI TR-02102-2) - CipherSuite.TLS_AES_128_GCM_SHA256, - CipherSuite.TLS_AES_256_GCM_SHA384, - CipherSuite.TLS_AES_128_CCM_SHA256 - ) - .build() - ) - } -} diff --git a/Corona-Warn-App/src/main/java/de/rki/coronawarnapp/http/WebRequestBuilder.kt b/Corona-Warn-App/src/main/java/de/rki/coronawarnapp/http/WebRequestBuilder.kt deleted file mode 100644 index d0bee06128aef56f255f3da69fdf383a135d8db6..0000000000000000000000000000000000000000 --- a/Corona-Warn-App/src/main/java/de/rki/coronawarnapp/http/WebRequestBuilder.kt +++ /dev/null @@ -1,179 +0,0 @@ -/****************************************************************************** - * Corona-Warn-App * - * * - * SAP SE and all other contributors / * - * copyright owners license this file to you under the Apache * - * License, Version 2.0 (the "License"); you may not use this * - * file except in compliance with the License. * - * You may obtain a copy of the License at * - * * - * http://www.apache.org/licenses/LICENSE-2.0 * - * * - * Unless required by applicable law or agreed to in writing, * - * software distributed under the License is distributed on an * - * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY * - * KIND, either express or implied. See the License for the * - * specific language governing permissions and limitations * - * under the License. * - ******************************************************************************/ - -package de.rki.coronawarnapp.http - -import KeyExportFormat -import com.google.protobuf.ByteString -import de.rki.coronawarnapp.http.requests.RegistrationRequest -import de.rki.coronawarnapp.http.requests.RegistrationTokenRequest -import de.rki.coronawarnapp.http.requests.TanRequestBody -import de.rki.coronawarnapp.http.service.SubmissionService -import de.rki.coronawarnapp.http.service.VerificationService -import de.rki.coronawarnapp.service.diagnosiskey.DiagnosisKeyConstants -import de.rki.coronawarnapp.service.submission.KeyType -import de.rki.coronawarnapp.service.submission.SubmissionConstants -import de.rki.coronawarnapp.util.di.AppInjector -import de.rki.coronawarnapp.util.security.HashHelper -import kotlinx.coroutines.Dispatchers -import kotlinx.coroutines.withContext -import timber.log.Timber -import kotlin.math.max - -class WebRequestBuilder( - private val verificationService: VerificationService, - private val submissionService: SubmissionService -) { - companion object { - private val TAG: String? = WebRequestBuilder::class.simpleName - - @Volatile - private var instance: WebRequestBuilder? = null - - fun getInstance(): WebRequestBuilder { - return instance ?: synchronized(this) { - instance ?: buildWebRequestBuilder().also { instance = it } - } - } - - private fun buildWebRequestBuilder(): WebRequestBuilder { - val serviceFactory = AppInjector.component.serviceFactory - return WebRequestBuilder( - serviceFactory.verificationService(), - serviceFactory.submissionService() - ) - } - } - - suspend fun asyncGetRegistrationToken( - key: String, - keyType: KeyType - ): String = withContext(Dispatchers.IO) { - val keyStr = if (keyType == KeyType.GUID) { - HashHelper.hash256(key) - } else { - key - } - - val paddingLength = when (keyType) { - KeyType.GUID -> SubmissionConstants.PADDING_LENGTH_BODY_REGISTRATION_TOKEN_GUID - KeyType.TELETAN -> SubmissionConstants.PADDING_LENGTH_BODY_REGISTRATION_TOKEN_TELETAN - } - - verificationService.getRegistrationToken( - SubmissionConstants.REGISTRATION_TOKEN_URL, - "0", - requestPadding(SubmissionConstants.PADDING_LENGTH_HEADER_REGISTRATION_TOKEN), - RegistrationTokenRequest(keyType.name, keyStr, requestPadding(paddingLength)) - ).registrationToken - } - - suspend fun asyncGetTestResult( - registrationToken: String - ): Int = withContext(Dispatchers.IO) { - verificationService.getTestResult( - SubmissionConstants.TEST_RESULT_URL, - "0", - requestPadding(SubmissionConstants.PADDING_LENGTH_HEADER_TEST_RESULT), - RegistrationRequest( - registrationToken, - requestPadding(SubmissionConstants.PADDING_LENGTH_BODY_TEST_RESULT) - ) - ).testResult - } - - suspend fun asyncGetTan( - registrationToken: String - ): String = withContext(Dispatchers.IO) { - verificationService.getTAN( - SubmissionConstants.TAN_REQUEST_URL, - "0", - requestPadding(SubmissionConstants.PADDING_LENGTH_HEADER_TAN), - TanRequestBody( - registrationToken, - requestPadding(SubmissionConstants.PADDING_LENGTH_BODY_TAN) - ) - ).tan - } - - suspend fun asyncFakeVerification() = withContext(Dispatchers.IO) { - verificationService.getTAN( - SubmissionConstants.TAN_REQUEST_URL, - "1", - requestPadding(SubmissionConstants.PADDING_LENGTH_HEADER_TAN), - TanRequestBody( - registrationToken = SubmissionConstants.DUMMY_REGISTRATION_TOKEN, - requestPadding = requestPadding(SubmissionConstants.PADDING_LENGTH_BODY_TAN_FAKE) - ) - ) - } - - suspend fun asyncSubmitKeysToServer( - authCode: String, - keyList: List<KeyExportFormat.TemporaryExposureKey> - ) = withContext(Dispatchers.IO) { - Timber.d("Writing ${keyList.size} Keys to the Submission Payload.") - - val randomAdditions = 0 // prepare for random addition of keys - val fakeKeyCount = - max(SubmissionConstants.minKeyCountForSubmission + randomAdditions - keyList.size, 0) - val fakeKeyPadding = requestPadding(SubmissionConstants.fakeKeySize * fakeKeyCount) - - val submissionPayload = KeyExportFormat.SubmissionPayload.newBuilder() - .addAllKeys(keyList) - .setPadding(ByteString.copyFromUtf8(fakeKeyPadding)) - .build() - submissionService.submitKeys( - DiagnosisKeyConstants.DIAGNOSIS_KEYS_SUBMISSION_URL, - authCode, - "0", - SubmissionConstants.EMPTY_HEADER, - submissionPayload - ) - return@withContext - } - - suspend fun asyncFakeSubmission() = withContext(Dispatchers.IO) { - - val randomAdditions = 0 // prepare for random addition of keys - val fakeKeyCount = SubmissionConstants.minKeyCountForSubmission + randomAdditions - - val fakeKeyPadding = - requestPadding(SubmissionConstants.fakeKeySize * fakeKeyCount) - - val submissionPayload = KeyExportFormat.SubmissionPayload.newBuilder() - .setPadding(ByteString.copyFromUtf8(fakeKeyPadding)) - .build() - - submissionService.submitKeys( - DiagnosisKeyConstants.DIAGNOSIS_KEYS_SUBMISSION_URL, - SubmissionConstants.EMPTY_HEADER, - "1", - requestPadding(SubmissionConstants.PADDING_LENGTH_HEADER_SUBMISSION_FAKE), - submissionPayload - ) - } - - private fun requestPadding(length: Int): String { - val allowedChars = ('A'..'Z') + ('a'..'z') + ('0'..'9') - return (1..length) - .map { allowedChars.random() } - .joinToString("") - } -} diff --git a/Corona-Warn-App/src/main/java/de/rki/coronawarnapp/http/requests/RegistrationRequest.kt b/Corona-Warn-App/src/main/java/de/rki/coronawarnapp/http/requests/RegistrationRequest.kt deleted file mode 100644 index 6a20e507c21ae3e0dc1c0358dc78e781af9620d2..0000000000000000000000000000000000000000 --- a/Corona-Warn-App/src/main/java/de/rki/coronawarnapp/http/requests/RegistrationRequest.kt +++ /dev/null @@ -1,10 +0,0 @@ -package de.rki.coronawarnapp.http.requests - -import com.google.gson.annotations.SerializedName - -data class RegistrationRequest( - @SerializedName("registrationToken") - val registrationToken: String? = null, - @SerializedName("requestPadding") - val requestPadding: String? = null -) diff --git a/Corona-Warn-App/src/main/java/de/rki/coronawarnapp/http/requests/RegistrationTokenRequest.kt b/Corona-Warn-App/src/main/java/de/rki/coronawarnapp/http/requests/RegistrationTokenRequest.kt deleted file mode 100644 index 9667101c70a328c36fe9d6edbca5e0737d4dc1d6..0000000000000000000000000000000000000000 --- a/Corona-Warn-App/src/main/java/de/rki/coronawarnapp/http/requests/RegistrationTokenRequest.kt +++ /dev/null @@ -1,12 +0,0 @@ -package de.rki.coronawarnapp.http.requests - -import com.google.gson.annotations.SerializedName - -data class RegistrationTokenRequest( - @SerializedName("keyType") - val keyType: String? = null, - @SerializedName("key") - val key: String? = null, - @SerializedName("requestPadding") - val requestPadding: String? = null -) diff --git a/Corona-Warn-App/src/main/java/de/rki/coronawarnapp/http/requests/TanRequestBody.kt b/Corona-Warn-App/src/main/java/de/rki/coronawarnapp/http/requests/TanRequestBody.kt deleted file mode 100644 index ce6c123551516d2c56975f877b451737e8aa2bea..0000000000000000000000000000000000000000 --- a/Corona-Warn-App/src/main/java/de/rki/coronawarnapp/http/requests/TanRequestBody.kt +++ /dev/null @@ -1,10 +0,0 @@ -package de.rki.coronawarnapp.http.requests - -import com.google.gson.annotations.SerializedName - -data class TanRequestBody( - @SerializedName("registrationToken") - val registrationToken: String? = null, - @SerializedName("requestPadding") - val requestPadding: String? = null -) diff --git a/Corona-Warn-App/src/main/java/de/rki/coronawarnapp/http/responses/RegistrationTokenResponse.kt b/Corona-Warn-App/src/main/java/de/rki/coronawarnapp/http/responses/RegistrationTokenResponse.kt deleted file mode 100644 index 4ec189715dc431a32c64e0a490815cc2abd4d90a..0000000000000000000000000000000000000000 --- a/Corona-Warn-App/src/main/java/de/rki/coronawarnapp/http/responses/RegistrationTokenResponse.kt +++ /dev/null @@ -1,8 +0,0 @@ -package de.rki.coronawarnapp.http.responses - -import com.google.gson.annotations.SerializedName - -data class RegistrationTokenResponse( - @SerializedName("registrationToken") - val registrationToken: String -) diff --git a/Corona-Warn-App/src/main/java/de/rki/coronawarnapp/http/responses/TanResponse.kt b/Corona-Warn-App/src/main/java/de/rki/coronawarnapp/http/responses/TanResponse.kt deleted file mode 100644 index 91f1ea9b7c00ab4230b442127f84e61edd0e9796..0000000000000000000000000000000000000000 --- a/Corona-Warn-App/src/main/java/de/rki/coronawarnapp/http/responses/TanResponse.kt +++ /dev/null @@ -1,8 +0,0 @@ -package de.rki.coronawarnapp.http.responses - -import com.google.gson.annotations.SerializedName - -data class TanResponse( - @SerializedName("tan") - val tan: String -) diff --git a/Corona-Warn-App/src/main/java/de/rki/coronawarnapp/http/responses/TestResultResponse.kt b/Corona-Warn-App/src/main/java/de/rki/coronawarnapp/http/responses/TestResultResponse.kt deleted file mode 100644 index 065a2356f20f1ae6eaa09cd935f07f3bdbd7bfe3..0000000000000000000000000000000000000000 --- a/Corona-Warn-App/src/main/java/de/rki/coronawarnapp/http/responses/TestResultResponse.kt +++ /dev/null @@ -1,8 +0,0 @@ -package de.rki.coronawarnapp.http.responses - -import com.google.gson.annotations.SerializedName - -data class TestResultResponse( - @SerializedName("testResult") - val testResult: Int -) diff --git a/Corona-Warn-App/src/main/java/de/rki/coronawarnapp/http/service/VerificationService.kt b/Corona-Warn-App/src/main/java/de/rki/coronawarnapp/http/service/VerificationService.kt deleted file mode 100644 index 9974fe50fa5666111dace01e73ff0f98e14d5016..0000000000000000000000000000000000000000 --- a/Corona-Warn-App/src/main/java/de/rki/coronawarnapp/http/service/VerificationService.kt +++ /dev/null @@ -1,39 +0,0 @@ -package de.rki.coronawarnapp.http.service - -import de.rki.coronawarnapp.http.requests.RegistrationTokenRequest -import de.rki.coronawarnapp.http.requests.RegistrationRequest -import de.rki.coronawarnapp.http.requests.TanRequestBody -import de.rki.coronawarnapp.http.responses.RegistrationTokenResponse -import de.rki.coronawarnapp.http.responses.TanResponse -import de.rki.coronawarnapp.http.responses.TestResultResponse -import retrofit2.http.Body -import retrofit2.http.Header -import retrofit2.http.POST -import retrofit2.http.Url - -interface VerificationService { - - @POST - suspend fun getRegistrationToken( - @Url url: String, - @Header("cwa-fake") fake: String, - @Header("cwa-header-padding") headerPadding: String?, - @Body requestBody: RegistrationTokenRequest - ): RegistrationTokenResponse - - @POST - suspend fun getTestResult( - @Url url: String, - @Header("cwa-fake") fake: String, - @Header("cwa-header-padding") headerPadding: String?, - @Body request: RegistrationRequest - ): TestResultResponse - - @POST - suspend fun getTAN( - @Url url: String, - @Header("cwa-fake") fake: String, - @Header("cwa-header-padding") headerPadding: String?, - @Body requestBody: TanRequestBody - ): TanResponse -} diff --git a/Corona-Warn-App/src/main/java/de/rki/coronawarnapp/http/playbook/BackgroundNoise.kt b/Corona-Warn-App/src/main/java/de/rki/coronawarnapp/playbook/BackgroundNoise.kt similarity index 72% rename from Corona-Warn-App/src/main/java/de/rki/coronawarnapp/http/playbook/BackgroundNoise.kt rename to Corona-Warn-App/src/main/java/de/rki/coronawarnapp/playbook/BackgroundNoise.kt index ea1005d3def6baeec8456de12c4bb9547a421d37..55b7fb55382c84c07c34b7b4279b3e7672cebeef 100644 --- a/Corona-Warn-App/src/main/java/de/rki/coronawarnapp/http/playbook/BackgroundNoise.kt +++ b/Corona-Warn-App/src/main/java/de/rki/coronawarnapp/playbook/BackgroundNoise.kt @@ -1,8 +1,7 @@ -package de.rki.coronawarnapp.http.playbook +package de.rki.coronawarnapp.playbook -import de.rki.coronawarnapp.http.WebRequestBuilder -import de.rki.coronawarnapp.service.submission.SubmissionConstants import de.rki.coronawarnapp.storage.LocalData +import de.rki.coronawarnapp.util.di.AppInjector import de.rki.coronawarnapp.worker.BackgroundConstants import de.rki.coronawarnapp.worker.BackgroundWorkScheduler import kotlin.random.Random @@ -21,6 +20,9 @@ class BackgroundNoise { } } + private val playbook: Playbook + get() = AppInjector.component.playbook + fun scheduleDummyPattern() { if (BackgroundConstants.NUMBER_OF_DAYS_TO_RUN_PLAYBOOK > 0) BackgroundWorkScheduler.scheduleBackgroundNoisePeriodicWork() @@ -29,9 +31,8 @@ class BackgroundNoise { suspend fun foregroundScheduleCheck() { if (LocalData.isAllowedToSubmitDiagnosisKeys() == true) { val chance = Random.nextFloat() * 100 - if (chance < SubmissionConstants.probabilityToExecutePlaybookWhenOpenApp) { - PlaybookImpl(WebRequestBuilder.getInstance()) - .dummy() + if (chance < DefaultPlaybook.PROBABILITY_TO_EXECUTE_PLAYBOOK_ON_APP_OPEN) { + playbook.dummy() } } } diff --git a/Corona-Warn-App/src/main/java/de/rki/coronawarnapp/http/playbook/PlaybookImpl.kt b/Corona-Warn-App/src/main/java/de/rki/coronawarnapp/playbook/DefaultPlaybook.kt similarity index 58% rename from Corona-Warn-App/src/main/java/de/rki/coronawarnapp/http/playbook/PlaybookImpl.kt rename to Corona-Warn-App/src/main/java/de/rki/coronawarnapp/playbook/DefaultPlaybook.kt index e5b3d78caed3af5e366be0dfa662c3150642897e..338a63f34b8fa33fd08ed609aa6e6a41044ae11c 100644 --- a/Corona-Warn-App/src/main/java/de/rki/coronawarnapp/http/playbook/PlaybookImpl.kt +++ b/Corona-Warn-App/src/main/java/de/rki/coronawarnapp/playbook/DefaultPlaybook.kt @@ -1,10 +1,9 @@ -package de.rki.coronawarnapp.http.playbook +package de.rki.coronawarnapp.playbook -import KeyExportFormat -import de.rki.coronawarnapp.http.WebRequestBuilder -import de.rki.coronawarnapp.service.submission.KeyType -import de.rki.coronawarnapp.service.submission.SubmissionConstants +import de.rki.coronawarnapp.submission.server.SubmissionServer import de.rki.coronawarnapp.util.formatter.TestResult +import de.rki.coronawarnapp.verification.server.VerificationKeyType +import de.rki.coronawarnapp.verification.server.VerificationServer import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.delay @@ -12,9 +11,13 @@ import kotlinx.coroutines.launch import timber.log.Timber import java.util.UUID import java.util.concurrent.TimeUnit +import javax.inject.Inject +import javax.inject.Singleton -class PlaybookImpl( - private val webRequestBuilder: WebRequestBuilder +@Singleton +class DefaultPlaybook @Inject constructor( + private val verificationServer: VerificationServer, + private val submissionServer: SubmissionServer ) : Playbook { private val uid = UUID.randomUUID().toString() @@ -22,25 +25,30 @@ class PlaybookImpl( override suspend fun initialRegistration( key: String, - keyType: KeyType + keyType: VerificationKeyType ): Pair<String, TestResult> { Timber.i("[$uid] New Initial Registration Playbook") // real registration val (registrationToken, registrationException) = - executeCapturingExceptions { webRequestBuilder.asyncGetRegistrationToken(key, keyType) } + executeCapturingExceptions { + verificationServer.retrieveRegistrationToken( + key, + keyType + ) + } // if the registration succeeded continue with the real test result retrieval // if it failed, execute a dummy request to satisfy the required playbook pattern val (testResult, testResultException) = if (registrationToken != null) { - executeCapturingExceptions { webRequestBuilder.asyncGetTestResult(registrationToken) } + executeCapturingExceptions { verificationServer.retrieveTestResults(registrationToken) } } else { - ignoreExceptions { webRequestBuilder.asyncFakeVerification() } + ignoreExceptions { verificationServer.retrieveTanFake() } null to null } // fake submission - ignoreExceptions { webRequestBuilder.asyncFakeSubmission() } + ignoreExceptions { submissionServer.submitKeysToServerFake() } coroutineScope.launch { followUpPlaybooks() } @@ -57,42 +65,43 @@ class PlaybookImpl( // real test result val (testResult, exception) = - executeCapturingExceptions { webRequestBuilder.asyncGetTestResult(registrationToken) } + executeCapturingExceptions { verificationServer.retrieveTestResults(registrationToken) } // fake verification - ignoreExceptions { webRequestBuilder.asyncFakeVerification() } + ignoreExceptions { verificationServer.retrieveTanFake() } // fake submission - ignoreExceptions { webRequestBuilder.asyncFakeSubmission() } + ignoreExceptions { submissionServer.submitKeysToServerFake() } coroutineScope.launch { followUpPlaybooks() } - return testResult?.let { TestResult.fromInt(it) } - ?: propagateException(exception) + return testResult?.let { TestResult.fromInt(it) } ?: propagateException(exception) } override suspend fun submission( - registrationToken: String, - keys: List<KeyExportFormat.TemporaryExposureKey> + data: Playbook.SubmissionData ) { Timber.i("[$uid] New Submission Playbook") - // real auth code val (authCode, exception) = executeCapturingExceptions { - webRequestBuilder.asyncGetTan( - registrationToken - ) + verificationServer.retrieveTan(data.registrationToken) } // fake verification - ignoreExceptions { webRequestBuilder.asyncFakeVerification() } + ignoreExceptions { verificationServer.retrieveTanFake() } // real submission if (authCode != null) { - webRequestBuilder.asyncSubmitKeysToServer(authCode, keys) + val serverSubmissionData = SubmissionServer.SubmissionData( + authCode = authCode, + keyList = data.temporaryExposureKeys, + consentToFederation = data.consentToFederation, + visistedCountries = data.visistedCountries + ) + submissionServer.submitKeysToServer(serverSubmissionData) coroutineScope.launch { followUpPlaybooks() } } else { - webRequestBuilder.asyncFakeSubmission() + submissionServer.submitKeysToServerFake() coroutineScope.launch { followUpPlaybooks() } propagateException(exception) } @@ -100,13 +109,13 @@ class PlaybookImpl( private suspend fun dummy(launchFollowUp: Boolean) { // fake verification - ignoreExceptions { webRequestBuilder.asyncFakeVerification() } + ignoreExceptions { verificationServer.retrieveTanFake() } // fake verification - ignoreExceptions { webRequestBuilder.asyncFakeVerification() } + ignoreExceptions { verificationServer.retrieveTanFake() } // fake submission - ignoreExceptions { webRequestBuilder.asyncFakeSubmission() } + ignoreExceptions { submissionServer.submitKeysToServerFake() } if (launchFollowUp) coroutineScope.launch { followUpPlaybooks() } @@ -116,15 +125,15 @@ class PlaybookImpl( private suspend fun followUpPlaybooks() { val runsToExecute = IntRange( - SubmissionConstants.minNumberOfSequentialPlaybooks - 1 /* one was already executed */, - SubmissionConstants.maxNumberOfSequentialPlaybooks - 1 /* one was already executed */ + MIN_NUMBER_OF_SEQUENTIAL_PLAYBOOKS - 1 /* one was already executed */, + MAX_NUMBER_OF_SEQUENTIAL_PLAYBOOKS - 1 /* one was already executed */ ).random() Timber.i("[$uid] Follow Up: launching $runsToExecute follow up playbooks") repeat(runsToExecute) { val executionDelay = IntRange( - SubmissionConstants.minDelayBetweenSequentialPlaybooks, - SubmissionConstants.maxDelayBetweenSequentialPlaybooks + MIN_DELAY_BETWEEN_SEQUENTIAL_PLAYBOOKS, + MAX_DELAY_BETWEEN_SEQUENTIAL_PLAYBOOKS ).random() Timber.i("[$uid] Follow Up: (${it + 1}/$runsToExecute) waiting $executionDelay[s]...") delay(TimeUnit.SECONDS.toMillis(executionDelay.toLong())) @@ -154,4 +163,12 @@ class PlaybookImpl( private fun propagateException(vararg exceptions: Exception?): Nothing { throw exceptions.filterNotNull().firstOrNull() ?: IllegalStateException() } + + companion object { + const val PROBABILITY_TO_EXECUTE_PLAYBOOK_ON_APP_OPEN = 0f + const val MIN_NUMBER_OF_SEQUENTIAL_PLAYBOOKS = 1 + const val MAX_NUMBER_OF_SEQUENTIAL_PLAYBOOKS = 1 + const val MIN_DELAY_BETWEEN_SEQUENTIAL_PLAYBOOKS = 0 + const val MAX_DELAY_BETWEEN_SEQUENTIAL_PLAYBOOKS = 0 + } } diff --git a/Corona-Warn-App/src/main/java/de/rki/coronawarnapp/http/playbook/Playbook.kt b/Corona-Warn-App/src/main/java/de/rki/coronawarnapp/playbook/Playbook.kt similarity index 63% rename from Corona-Warn-App/src/main/java/de/rki/coronawarnapp/http/playbook/Playbook.kt rename to Corona-Warn-App/src/main/java/de/rki/coronawarnapp/playbook/Playbook.kt index 5d37af5df193ba0d77768999d4a90205957bb71c..6babf297d09784ef6d8c4d7c2087ea48e238996a 100644 --- a/Corona-Warn-App/src/main/java/de/rki/coronawarnapp/http/playbook/Playbook.kt +++ b/Corona-Warn-App/src/main/java/de/rki/coronawarnapp/playbook/Playbook.kt @@ -1,8 +1,8 @@ -package de.rki.coronawarnapp.http.playbook +package de.rki.coronawarnapp.playbook -import KeyExportFormat -import de.rki.coronawarnapp.service.submission.KeyType +import de.rki.coronawarnapp.server.protocols.KeyExportFormat import de.rki.coronawarnapp.util.formatter.TestResult +import de.rki.coronawarnapp.verification.server.VerificationKeyType /** * The concept of Plausible Deniability aims to hide the existence of a positive test result by always using a defined “playbook pattern†of requests to the Verification Server and CWA Backend so it is impossible for an attacker to identify which communication was done. @@ -13,17 +13,19 @@ interface Playbook { suspend fun initialRegistration( key: String, - keyType: KeyType + keyType: VerificationKeyType ): Pair<String, TestResult> /* registration token & test result*/ - suspend fun testResult( - registrationToken: String - ): TestResult + suspend fun testResult(registrationToken: String): TestResult - suspend fun submission( - registrationToken: String, - keys: List<KeyExportFormat.TemporaryExposureKey> + data class SubmissionData( + val registrationToken: String, + val temporaryExposureKeys: List<KeyExportFormat.TemporaryExposureKey>, + val consentToFederation: Boolean, + val visistedCountries: List<String> ) + suspend fun submission(data: SubmissionData) + suspend fun dummy() } diff --git a/Corona-Warn-App/src/main/java/de/rki/coronawarnapp/playbook/PlaybookModule.kt b/Corona-Warn-App/src/main/java/de/rki/coronawarnapp/playbook/PlaybookModule.kt new file mode 100644 index 0000000000000000000000000000000000000000..d76e5654c2f1548d459db8cceb3db829bacda8d2 --- /dev/null +++ b/Corona-Warn-App/src/main/java/de/rki/coronawarnapp/playbook/PlaybookModule.kt @@ -0,0 +1,13 @@ +package de.rki.coronawarnapp.playbook + +import dagger.Module +import dagger.Provides +import javax.inject.Singleton + +@Module +class PlaybookModule { + + @Singleton + @Provides + fun providePlaybook(defaultPlayBook: DefaultPlaybook): Playbook = defaultPlayBook +} diff --git a/Corona-Warn-App/src/main/java/de/rki/coronawarnapp/service/diagnosiskey/DiagnosisKeyConstants.kt b/Corona-Warn-App/src/main/java/de/rki/coronawarnapp/service/diagnosiskey/DiagnosisKeyConstants.kt deleted file mode 100644 index 8acfd1c5ef294aa6c840e679d4cbf1864b22b51c..0000000000000000000000000000000000000000 --- a/Corona-Warn-App/src/main/java/de/rki/coronawarnapp/service/diagnosiskey/DiagnosisKeyConstants.kt +++ /dev/null @@ -1,42 +0,0 @@ -/****************************************************************************** - * Corona-Warn-App * - * * - * SAP SE and all other contributors / * - * copyright owners license this file to you under the Apache * - * License, Version 2.0 (the "License"); you may not use this * - * file except in compliance with the License. * - * You may obtain a copy of the License at * - * * - * http://www.apache.org/licenses/LICENSE-2.0 * - * * - * Unless required by applicable law or agreed to in writing, * - * software distributed under the License is distributed on an * - * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY * - * KIND, either express or implied. See the License for the * - * specific language governing permissions and limitations * - * under the License. * - ******************************************************************************/ - -package de.rki.coronawarnapp.service.diagnosiskey - -/** - * The Diagnosis Key constants - */ -object DiagnosisKeyConstants { - /** version resource variable for REST-like Service Calls */ - private const val VERSION = "version" - - /** diagnosis keys resource variable for REST-like Service Calls */ - private const val DIAGNOSIS_KEYS = "diagnosis-keys" - - /** resource variables but non-static context */ - private var CURRENT_VERSION = "v1" - - /** Submission URL built from CDN URL's and REST resources */ - private var VERSIONED_SUBMISSION_CDN_URL = "$VERSION/$CURRENT_VERSION" - - /** Diagnosis key Submission URL built from CDN URL's and REST resources */ - val DIAGNOSIS_KEYS_SUBMISSION_URL = "$VERSIONED_SUBMISSION_CDN_URL/$DIAGNOSIS_KEYS" - - const val SERVER_ERROR_CODE_403 = 403 -} diff --git a/Corona-Warn-App/src/main/java/de/rki/coronawarnapp/service/submission/SubmissionConstants.kt b/Corona-Warn-App/src/main/java/de/rki/coronawarnapp/service/submission/SubmissionConstants.kt deleted file mode 100644 index 43de684e2e6535aa2253b3198c82022b8af88991..0000000000000000000000000000000000000000 --- a/Corona-Warn-App/src/main/java/de/rki/coronawarnapp/service/submission/SubmissionConstants.kt +++ /dev/null @@ -1,52 +0,0 @@ -package de.rki.coronawarnapp.service.submission - -object SubmissionConstants { - private const val VERSION = "version" - private const val REGISTRATION_TOKEN = "registrationToken" - private const val TEST_RESULT = "testresult" - private const val TAN = "tan" - - private var CURRENT_VERSION = "v1" - - private val VERSIONED_VERIFICATION_CDN_URL = "$VERSION/$CURRENT_VERSION" - - val REGISTRATION_TOKEN_URL = "$VERSIONED_VERIFICATION_CDN_URL/$REGISTRATION_TOKEN" - val TEST_RESULT_URL = "$VERSIONED_VERIFICATION_CDN_URL/$TEST_RESULT" - val TAN_REQUEST_URL = "$VERSIONED_VERIFICATION_CDN_URL/$TAN" - - const val SERVER_ERROR_CODE_400 = 400 - - const val EMPTY_HEADER = "" - - // padding registration token - private const val VERIFICATION_BODY_FILL = 139 - - const val PADDING_LENGTH_HEADER_REGISTRATION_TOKEN = 0 - const val PADDING_LENGTH_BODY_REGISTRATION_TOKEN_TELETAN = 51 + VERIFICATION_BODY_FILL - const val PADDING_LENGTH_BODY_REGISTRATION_TOKEN_GUID = 0 + VERIFICATION_BODY_FILL - - // padding test result - const val PADDING_LENGTH_HEADER_TEST_RESULT = 7 - const val PADDING_LENGTH_BODY_TEST_RESULT = 31 + VERIFICATION_BODY_FILL - - // padding tan - const val PADDING_LENGTH_HEADER_TAN = 14 - const val PADDING_LENGTH_BODY_TAN = 31 + VERIFICATION_BODY_FILL - const val PADDING_LENGTH_BODY_TAN_FAKE = 31 + VERIFICATION_BODY_FILL - const val DUMMY_REGISTRATION_TOKEN = "11111111-2222-4444-8888-161616161616" - - const val PADDING_LENGTH_HEADER_SUBMISSION_FAKE = 36 - - const val probabilityToExecutePlaybookWhenOpenApp = 0f - const val minNumberOfSequentialPlaybooks = 1 - const val maxNumberOfSequentialPlaybooks = 1 - const val minDelayBetweenSequentialPlaybooks = 0 - const val maxDelayBetweenSequentialPlaybooks = 0 - - const val minKeyCountForSubmission = 14 - const val fakeKeySize = (1 * 16 /* key data*/) + (3 * 4 /* 3x int32*/) -} - -enum class KeyType { - GUID, TELETAN; -} diff --git a/Corona-Warn-App/src/main/java/de/rki/coronawarnapp/service/submission/SubmissionService.kt b/Corona-Warn-App/src/main/java/de/rki/coronawarnapp/service/submission/SubmissionService.kt index 8695e37cfa9bee099f71465faf9fd4ab52f2160b..be711b0ae28bdf4ac9b2d804691e45c80bd4cd8c 100644 --- a/Corona-Warn-App/src/main/java/de/rki/coronawarnapp/service/submission/SubmissionService.kt +++ b/Corona-Warn-App/src/main/java/de/rki/coronawarnapp/service/submission/SubmissionService.kt @@ -3,18 +3,22 @@ package de.rki.coronawarnapp.service.submission import com.google.android.gms.nearby.exposurenotification.TemporaryExposureKey import de.rki.coronawarnapp.exception.NoGUIDOrTANSetException import de.rki.coronawarnapp.exception.NoRegistrationTokenSetException -import de.rki.coronawarnapp.http.WebRequestBuilder -import de.rki.coronawarnapp.http.playbook.BackgroundNoise -import de.rki.coronawarnapp.http.playbook.PlaybookImpl +import de.rki.coronawarnapp.playbook.BackgroundNoise +import de.rki.coronawarnapp.playbook.Playbook import de.rki.coronawarnapp.storage.LocalData import de.rki.coronawarnapp.storage.SubmissionRepository import de.rki.coronawarnapp.submission.Symptoms import de.rki.coronawarnapp.transaction.SubmitDiagnosisKeysTransaction +import de.rki.coronawarnapp.util.di.AppInjector import de.rki.coronawarnapp.util.formatter.TestResult +import de.rki.coronawarnapp.verification.server.VerificationKeyType import de.rki.coronawarnapp.worker.BackgroundWorkScheduler object SubmissionService { + private val playbook: Playbook + get() = AppInjector.component.playbook + suspend fun asyncRegisterDevice() { val testGUID = LocalData.testGUID() val testTAN = LocalData.teletan() @@ -30,9 +34,9 @@ object SubmissionService { private suspend fun asyncRegisterDeviceViaGUID(guid: String) { val (registrationToken, testResult) = - PlaybookImpl(WebRequestBuilder.getInstance()).initialRegistration( + playbook.initialRegistration( guid, - KeyType.GUID + VerificationKeyType.GUID ) LocalData.registrationToken(registrationToken) @@ -42,9 +46,9 @@ object SubmissionService { private suspend fun asyncRegisterDeviceViaTAN(tan: String) { val (registrationToken, testResult) = - PlaybookImpl(WebRequestBuilder.getInstance()).initialRegistration( + playbook.initialRegistration( tan, - KeyType.TELETAN + VerificationKeyType.TELETAN ) LocalData.registrationToken(registrationToken) @@ -62,7 +66,7 @@ object SubmissionService { val registrationToken = LocalData.registrationToken() ?: throw NoRegistrationTokenSetException() - return PlaybookImpl(WebRequestBuilder.getInstance()).testResult(registrationToken) + return playbook.testResult(registrationToken) } fun containsValidGUID(scanResult: String): Boolean { diff --git a/Corona-Warn-App/src/main/java/de/rki/coronawarnapp/submission/DefaultKeyConverter.kt b/Corona-Warn-App/src/main/java/de/rki/coronawarnapp/submission/DefaultKeyConverter.kt index e467c0f033231f096dd3710771f18586ab402298..c852c232d35340ec5d6175f440fbbab5e07574e2 100644 --- a/Corona-Warn-App/src/main/java/de/rki/coronawarnapp/submission/DefaultKeyConverter.kt +++ b/Corona-Warn-App/src/main/java/de/rki/coronawarnapp/submission/DefaultKeyConverter.kt @@ -1,8 +1,8 @@ package de.rki.coronawarnapp.submission -import KeyExportFormat import com.google.android.gms.nearby.exposurenotification.TemporaryExposureKey import com.google.protobuf.ByteString +import de.rki.coronawarnapp.server.protocols.KeyExportFormat class DefaultKeyConverter : KeyConverter { diff --git a/Corona-Warn-App/src/main/java/de/rki/coronawarnapp/submission/KeyConverter.kt b/Corona-Warn-App/src/main/java/de/rki/coronawarnapp/submission/KeyConverter.kt index f50795ca52f4bdb3a31d6edb8b82aacb064ce721..d5ea977ca11c7aff45db3f95400c21f8629fc952 100644 --- a/Corona-Warn-App/src/main/java/de/rki/coronawarnapp/submission/KeyConverter.kt +++ b/Corona-Warn-App/src/main/java/de/rki/coronawarnapp/submission/KeyConverter.kt @@ -1,7 +1,7 @@ package de.rki.coronawarnapp.submission -import KeyExportFormat import com.google.android.gms.nearby.exposurenotification.TemporaryExposureKey +import de.rki.coronawarnapp.server.protocols.KeyExportFormat interface KeyConverter { diff --git a/Corona-Warn-App/src/main/java/de/rki/coronawarnapp/submission/SubmissionModule.kt b/Corona-Warn-App/src/main/java/de/rki/coronawarnapp/submission/SubmissionModule.kt new file mode 100644 index 0000000000000000000000000000000000000000..5ba29e45017611aefb14cc6d5efe489890c95ca9 --- /dev/null +++ b/Corona-Warn-App/src/main/java/de/rki/coronawarnapp/submission/SubmissionModule.kt @@ -0,0 +1,60 @@ +package de.rki.coronawarnapp.submission + +import android.content.Context +import dagger.Module +import dagger.Provides +import dagger.Reusable +import de.rki.coronawarnapp.environment.submission.SubmissionCDNServerUrl +import de.rki.coronawarnapp.http.HttpClientDefault +import de.rki.coronawarnapp.http.RestrictedConnectionSpecs +import de.rki.coronawarnapp.submission.server.SubmissionApiV1 +import de.rki.coronawarnapp.submission.server.SubmissionHttpClient +import okhttp3.Cache +import okhttp3.ConnectionSpec +import okhttp3.OkHttpClient +import retrofit2.Retrofit +import retrofit2.converter.gson.GsonConverterFactory +import retrofit2.converter.protobuf.ProtoConverterFactory +import java.io.File +import javax.inject.Singleton + +@Module +class SubmissionModule { + + @Reusable + @SubmissionHttpClient + @Provides + fun cdnHttpClient( + @HttpClientDefault defaultHttpClient: OkHttpClient, + @RestrictedConnectionSpecs connectionSpecs: List<ConnectionSpec> + ): OkHttpClient = + defaultHttpClient.newBuilder().connectionSpecs(connectionSpecs).build() + + @Singleton + @Provides + fun provideSubmissionApi( + context: Context, + @SubmissionHttpClient client: OkHttpClient, + @SubmissionCDNServerUrl url: String, + protoConverterFactory: ProtoConverterFactory, + gsonConverterFactory: GsonConverterFactory + ): SubmissionApiV1 { + val cache = Cache(File(context.cacheDir, "http_submission"), DEFAULT_CACHE_SIZE) + + val cachingClient = client.newBuilder().apply { + cache(cache) + }.build() + + return Retrofit.Builder() + .client(cachingClient) + .baseUrl(url) + .addConverterFactory(protoConverterFactory) + .addConverterFactory(gsonConverterFactory) + .build() + .create(SubmissionApiV1::class.java) + } + + companion object { + private const val DEFAULT_CACHE_SIZE = 5 * 1024 * 1024L // 5MB + } +} diff --git a/Corona-Warn-App/src/main/java/de/rki/coronawarnapp/http/service/SubmissionService.kt b/Corona-Warn-App/src/main/java/de/rki/coronawarnapp/submission/server/SubmissionApiV1.kt similarity index 62% rename from Corona-Warn-App/src/main/java/de/rki/coronawarnapp/http/service/SubmissionService.kt rename to Corona-Warn-App/src/main/java/de/rki/coronawarnapp/submission/server/SubmissionApiV1.kt index a2092edea1a5456b5f8949d1ae7afdd8ad595eda..ebb23fe4e97e91070e49d64f3d792f48ad076c1a 100644 --- a/Corona-Warn-App/src/main/java/de/rki/coronawarnapp/http/service/SubmissionService.kt +++ b/Corona-Warn-App/src/main/java/de/rki/coronawarnapp/submission/server/SubmissionApiV1.kt @@ -1,19 +1,17 @@ -package de.rki.coronawarnapp.http.service +package de.rki.coronawarnapp.submission.server -import KeyExportFormat -import okhttp3.ResponseBody +import de.rki.coronawarnapp.server.protocols.KeyExportFormat import retrofit2.http.Body import retrofit2.http.Header import retrofit2.http.POST -import retrofit2.http.Url -interface SubmissionService { - @POST +interface SubmissionApiV1 { + + @POST("version/v1/diagnosis-keys") suspend fun submitKeys( - @Url url: String, @Header("cwa-authorization") authCode: String?, @Header("cwa-fake") fake: String, @Header("cwa-header-padding") headerPadding: String?, @Body requestBody: KeyExportFormat.SubmissionPayload - ): ResponseBody + ) } diff --git a/Corona-Warn-App/src/main/java/de/rki/coronawarnapp/submission/server/SubmissionHttpClient.kt b/Corona-Warn-App/src/main/java/de/rki/coronawarnapp/submission/server/SubmissionHttpClient.kt new file mode 100644 index 0000000000000000000000000000000000000000..d7c83b129573eff9c62d5f602826fa1eea68ca79 --- /dev/null +++ b/Corona-Warn-App/src/main/java/de/rki/coronawarnapp/submission/server/SubmissionHttpClient.kt @@ -0,0 +1,8 @@ +package de.rki.coronawarnapp.submission.server + +import javax.inject.Qualifier + +@Qualifier +@MustBeDocumented +@Retention(AnnotationRetention.RUNTIME) +annotation class SubmissionHttpClient diff --git a/Corona-Warn-App/src/main/java/de/rki/coronawarnapp/submission/server/SubmissionServer.kt b/Corona-Warn-App/src/main/java/de/rki/coronawarnapp/submission/server/SubmissionServer.kt new file mode 100644 index 0000000000000000000000000000000000000000..a8c41429d38285a4d8f479905c4db7a610a31443 --- /dev/null +++ b/Corona-Warn-App/src/main/java/de/rki/coronawarnapp/submission/server/SubmissionServer.kt @@ -0,0 +1,85 @@ +package de.rki.coronawarnapp.submission.server + +import com.google.protobuf.ByteString +import dagger.Lazy +import de.rki.coronawarnapp.server.protocols.KeyExportFormat +import de.rki.coronawarnapp.util.PaddingTool.requestPadding +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.withContext +import timber.log.Timber +import javax.inject.Inject +import javax.inject.Singleton +import kotlin.math.max + +@Singleton +class SubmissionServer @Inject constructor( + private val submissionApi: Lazy<SubmissionApiV1> +) { + + private val api: SubmissionApiV1 + get() = submissionApi.get() + + data class SubmissionData( + val authCode: String, + val keyList: List<KeyExportFormat.TemporaryExposureKey>, + val consentToFederation: Boolean, + val visistedCountries: List<String> + ) + + suspend fun submitKeysToServer( + data: SubmissionData + ) = withContext(Dispatchers.IO) { + Timber.d("submitKeysToServer()") + val authCode = data.authCode + val keyList = data.keyList + Timber.d("Writing ${keyList.size} Keys to the Submission Payload.") + + val randomAdditions = 0 // prepare for random addition of keys + val fakeKeyCount = max( + MIN_KEY_COUNT_FOR_SUBMISSION + randomAdditions - keyList.size, + 0 + ) + val fakeKeyPadding = requestPadding(FAKE_KEY_SIZE * fakeKeyCount) + + val submissionPayload = KeyExportFormat.SubmissionPayload.newBuilder() + .addAllKeys(keyList) + .setPadding(ByteString.copyFromUtf8(fakeKeyPadding)) + .setConsentToFederation(data.consentToFederation) + .addAllVisitedCountries(data.visistedCountries) + .build() + + api.submitKeys( + authCode = authCode, + fake = "0", + headerPadding = EMPTY_HEADER, + requestBody = submissionPayload + ) + } + + suspend fun submitKeysToServerFake() = withContext(Dispatchers.IO) { + Timber.d("submitKeysToServerFake()") + + val randomAdditions = 0 // prepare for random addition of keys + val fakeKeyCount = MIN_KEY_COUNT_FOR_SUBMISSION + randomAdditions + + val fakeKeyPadding = requestPadding(FAKE_KEY_SIZE * fakeKeyCount) + + val submissionPayload = KeyExportFormat.SubmissionPayload.newBuilder() + .setPadding(ByteString.copyFromUtf8(fakeKeyPadding)) + .build() + + api.submitKeys( + authCode = EMPTY_HEADER, + fake = "1", + headerPadding = requestPadding(PADDING_LENGTH_HEADER_SUBMISSION_FAKE), + requestBody = submissionPayload + ) + } + + companion object { + const val EMPTY_HEADER = "" + const val PADDING_LENGTH_HEADER_SUBMISSION_FAKE = 36 + const val MIN_KEY_COUNT_FOR_SUBMISSION = 14 + const val FAKE_KEY_SIZE = (1 * 16 /* key data*/) + (3 * 4 /* 3x int32*/) + } +} diff --git a/Corona-Warn-App/src/main/java/de/rki/coronawarnapp/transaction/SubmitDiagnosisInjectionHelper.kt b/Corona-Warn-App/src/main/java/de/rki/coronawarnapp/transaction/SubmitDiagnosisInjectionHelper.kt index 7ddfa7fd1ac4fa01794024f022dc0230c4e68018..2ad1ed84cce1d179850dc2b0f9ce57ad9e2a110e 100644 --- a/Corona-Warn-App/src/main/java/de/rki/coronawarnapp/transaction/SubmitDiagnosisInjectionHelper.kt +++ b/Corona-Warn-App/src/main/java/de/rki/coronawarnapp/transaction/SubmitDiagnosisInjectionHelper.kt @@ -1,10 +1,14 @@ package de.rki.coronawarnapp.transaction +import de.rki.coronawarnapp.appconfig.AppConfigProvider +import de.rki.coronawarnapp.playbook.Playbook import javax.inject.Inject import javax.inject.Singleton // TODO Remove once we have refactored the transaction and it's no longer a singleton @Singleton data class SubmitDiagnosisInjectionHelper @Inject constructor( - val transactionScope: TransactionCoroutineScope + val transactionScope: TransactionCoroutineScope, + val playbook: Playbook, + val appConfigProvider: AppConfigProvider ) diff --git a/Corona-Warn-App/src/main/java/de/rki/coronawarnapp/transaction/SubmitDiagnosisKeysTransaction.kt b/Corona-Warn-App/src/main/java/de/rki/coronawarnapp/transaction/SubmitDiagnosisKeysTransaction.kt index ea6a4720f187cba0d66d1ea9916ce5152015838a..710a7814c0369bc373fa3bcb0484b40a4a9b8628 100644 --- a/Corona-Warn-App/src/main/java/de/rki/coronawarnapp/transaction/SubmitDiagnosisKeysTransaction.kt +++ b/Corona-Warn-App/src/main/java/de/rki/coronawarnapp/transaction/SubmitDiagnosisKeysTransaction.kt @@ -1,11 +1,13 @@ package de.rki.coronawarnapp.transaction import com.google.android.gms.nearby.exposurenotification.TemporaryExposureKey -import de.rki.coronawarnapp.http.WebRequestBuilder -import de.rki.coronawarnapp.http.playbook.PlaybookImpl +import de.rki.coronawarnapp.appconfig.AppConfigProvider +import de.rki.coronawarnapp.appconfig.toNewConfig +import de.rki.coronawarnapp.playbook.Playbook +import de.rki.coronawarnapp.server.protocols.ApplicationConfigurationOuterClass.ApplicationConfiguration import de.rki.coronawarnapp.service.submission.SubmissionService -import de.rki.coronawarnapp.submission.ExposureKeyHistoryCalculations import de.rki.coronawarnapp.submission.DefaultKeyConverter +import de.rki.coronawarnapp.submission.ExposureKeyHistoryCalculations import de.rki.coronawarnapp.submission.Symptoms import de.rki.coronawarnapp.submission.TransmissionRiskVectorDeterminator import de.rki.coronawarnapp.transaction.SubmitDiagnosisKeysTransaction.SubmitDiagnosisKeysTransactionState.CLOSE @@ -13,6 +15,7 @@ import de.rki.coronawarnapp.transaction.SubmitDiagnosisKeysTransaction.SubmitDia import de.rki.coronawarnapp.transaction.SubmitDiagnosisKeysTransaction.SubmitDiagnosisKeysTransactionState.RETRIEVE_TEMPORARY_EXPOSURE_KEY_HISTORY import de.rki.coronawarnapp.transaction.SubmitDiagnosisKeysTransaction.SubmitDiagnosisKeysTransactionState.STORE_SUCCESS import de.rki.coronawarnapp.util.di.AppInjector +import timber.log.Timber /** * The SubmitDiagnosisKeysTransaction is used to define an atomic Transaction for Key Reports. Its states allow an @@ -39,6 +42,7 @@ import de.rki.coronawarnapp.util.di.AppInjector */ object SubmitDiagnosisKeysTransaction : Transaction() { + private const val FALLBACK_COUNTRY = "DE" override val TAG: String? = SubmitDiagnosisKeysTransaction::class.simpleName /** possible transaction states */ @@ -54,39 +58,56 @@ object SubmitDiagnosisKeysTransaction : Transaction() { AppInjector.component.transSubmitDiagnosisInjection.transactionScope } + private val playbook: Playbook + get() = AppInjector.component.transSubmitDiagnosisInjection.playbook + + private val appConfigProvider: AppConfigProvider + get() = AppInjector.component.transSubmitDiagnosisInjection.appConfigProvider + /** initiates the transaction. This suspend function guarantees a successful transaction once completed. */ suspend fun start( registrationToken: String, keys: List<TemporaryExposureKey>, symptoms: Symptoms ) = lockAndExecute(unique = true, scope = transactionScope) { - /**************************************************** - * RETRIEVE TEMPORARY EXPOSURE KEY HISTORY - ****************************************************/ + val temporaryExposureKeyList = executeState(RETRIEVE_TEMPORARY_EXPOSURE_KEY_HISTORY) { ExposureKeyHistoryCalculations( TransmissionRiskVectorDeterminator(), DefaultKeyConverter() ).transformToKeyHistoryInExternalFormat(keys, symptoms) } - /**************************************************** - * RETRIEVE TAN & SUBMIT KEYS - ****************************************************/ + + val visistedCountries = + appConfigProvider.getAppConfig().performSanityChecks().supportedCountriesList + executeState(RETRIEVE_TAN_AND_SUBMIT_KEYS) { - PlaybookImpl(WebRequestBuilder.getInstance()).submission( - registrationToken, - temporaryExposureKeyList + val submissionData = Playbook.SubmissionData( + registrationToken = registrationToken, + temporaryExposureKeys = temporaryExposureKeyList, + consentToFederation = true, + visistedCountries = visistedCountries ) + playbook.submission(submissionData) } - /**************************************************** - * STORE SUCCESS - ****************************************************/ + executeState(STORE_SUCCESS) { SubmissionService.submissionSuccessful() } - /**************************************************** - * CLOSE TRANSACTION - ****************************************************/ + executeState(CLOSE) {} } + + private fun ApplicationConfiguration.performSanityChecks(): ApplicationConfiguration { + var sanityChecked = this + + if (sanityChecked.supportedCountriesList.isEmpty()) { + sanityChecked = sanityChecked.toNewConfig { + addSupportedCountries(FALLBACK_COUNTRY) + } + Timber.w("Country list was empty, corrected: %s", sanityChecked.supportedCountriesList) + } + + return sanityChecked + } } diff --git a/Corona-Warn-App/src/main/java/de/rki/coronawarnapp/ui/main/MainActivity.kt b/Corona-Warn-App/src/main/java/de/rki/coronawarnapp/ui/main/MainActivity.kt index 63af50f06e1e574c7a09740c299981e97248b7ea..9f468c223fbd4ddbd7788bf9de503b8d5a92787a 100644 --- a/Corona-Warn-App/src/main/java/de/rki/coronawarnapp/ui/main/MainActivity.kt +++ b/Corona-Warn-App/src/main/java/de/rki/coronawarnapp/ui/main/MainActivity.kt @@ -14,8 +14,8 @@ import dagger.android.AndroidInjector import dagger.android.DispatchingAndroidInjector import dagger.android.HasAndroidInjector import de.rki.coronawarnapp.R -import de.rki.coronawarnapp.http.playbook.BackgroundNoise import de.rki.coronawarnapp.nearby.InternalExposureNotificationClient +import de.rki.coronawarnapp.playbook.BackgroundNoise import de.rki.coronawarnapp.storage.LocalData import de.rki.coronawarnapp.ui.base.startActivitySafely import de.rki.coronawarnapp.ui.viewmodel.SettingsViewModel diff --git a/Corona-Warn-App/src/main/java/de/rki/coronawarnapp/util/CWADebug.kt b/Corona-Warn-App/src/main/java/de/rki/coronawarnapp/util/CWADebug.kt index cc614f2797b1f7b66ff248131d4cbc5860716793..4c0373fa49911ef8e3eaac545eeccafa7d61cace 100644 --- a/Corona-Warn-App/src/main/java/de/rki/coronawarnapp/util/CWADebug.kt +++ b/Corona-Warn-App/src/main/java/de/rki/coronawarnapp/util/CWADebug.kt @@ -1,8 +1,24 @@ package de.rki.coronawarnapp.util +import android.app.Application import de.rki.coronawarnapp.BuildConfig +import de.rki.coronawarnapp.util.debug.FileLogger +import timber.log.Timber object CWADebug { + var fileLogger: FileLogger? = null + + fun init(application: Application) { + if (isDebugBuildOrMode) System.setProperty("kotlinx.coroutines.debug", "on") + + if (BuildConfig.DEBUG) { + Timber.plant(Timber.DebugTree()) + } + if ((BuildConfig.FLAVOR == "deviceForTesters" || BuildConfig.DEBUG)) { + fileLogger = FileLogger(application) + } + } + val isDebugBuildOrMode: Boolean get() = BuildConfig.DEBUG || BuildConfig.BUILD_VARIANT == "deviceForTesters" } diff --git a/Corona-Warn-App/src/main/java/de/rki/coronawarnapp/util/KeyFileHelper.kt b/Corona-Warn-App/src/main/java/de/rki/coronawarnapp/util/KeyFileHelper.kt index 4ad00ef713d2b418944b429ba976ccaba517a340..ce325afe3e8f462095e4c5aea34647b76896a2de 100644 --- a/Corona-Warn-App/src/main/java/de/rki/coronawarnapp/util/KeyFileHelper.kt +++ b/Corona-Warn-App/src/main/java/de/rki/coronawarnapp/util/KeyFileHelper.kt @@ -1,10 +1,9 @@ package de.rki.coronawarnapp.util -import KeyExportFormat -import KeyExportFormat.TEKSignatureList -import KeyExportFormat.TEKSignatureList.newBuilder -import KeyExportFormat.TemporaryExposureKeyExport import de.rki.coronawarnapp.server.protocols.AppleLegacyKeyExchange +import de.rki.coronawarnapp.server.protocols.KeyExportFormat.TEKSignature +import de.rki.coronawarnapp.server.protocols.KeyExportFormat.TEKSignatureList +import de.rki.coronawarnapp.server.protocols.KeyExportFormat.TemporaryExposureKeyExport import de.rki.coronawarnapp.util.ProtoFormatConverterExtensions.convertToGoogleKey import de.rki.coronawarnapp.util.TimeAndDateExtensions.logUTCFormat import kotlinx.coroutines.Dispatchers @@ -51,7 +50,7 @@ object KeyFileHelper { .setStartTimestamp(file.header.startTimestamp) .setEndTimestamp(file.header.endTimestamp) .build(), - KeyExportFormat.TEKSignature.newBuilder() + TEKSignature.newBuilder() .setBatchNum(file.header.batchNum) .setBatchSize(file.header.batchSize) .setSignatureInfo(SignatureHelper.clientSig) @@ -75,7 +74,7 @@ object KeyFileHelper { private suspend fun createBinaryFile( storageDirectory: File?, zipFileName: String, - sourceWithTEKSignature: Pair<TemporaryExposureKeyExport, KeyExportFormat.TEKSignature> + sourceWithTEKSignature: Pair<TemporaryExposureKeyExport, TEKSignature> ): File { return withContext(Dispatchers.IO) { val exportFile = async { @@ -88,7 +87,9 @@ object KeyFileHelper { val exportSignatureFile = async { generateSignatureFile( storageDirectory, - newBuilder().addAllSignatures(listOf(sourceWithTEKSignature.second)).build() + TEKSignatureList.newBuilder() + .addAllSignatures(listOf(sourceWithTEKSignature.second)) + .build() ) } diff --git a/Corona-Warn-App/src/main/java/de/rki/coronawarnapp/util/PaddingTool.kt b/Corona-Warn-App/src/main/java/de/rki/coronawarnapp/util/PaddingTool.kt new file mode 100644 index 0000000000000000000000000000000000000000..1e80679aacc286e5e594eb53dba9917f5c5d77fb --- /dev/null +++ b/Corona-Warn-App/src/main/java/de/rki/coronawarnapp/util/PaddingTool.kt @@ -0,0 +1,9 @@ +package de.rki.coronawarnapp.util + +object PaddingTool { + fun requestPadding(length: Int): String = (1..length) + .map { PADDING_ITEMS.random() } + .joinToString("") + + private val PADDING_ITEMS = ('A'..'Z') + ('a'..'z') + ('0'..'9') +} diff --git a/Corona-Warn-App/src/main/java/de/rki/coronawarnapp/util/ProtoFormatConverterExtensions.kt b/Corona-Warn-App/src/main/java/de/rki/coronawarnapp/util/ProtoFormatConverterExtensions.kt index 6f3db1758c31fec40607709d11f494f9bd006f25..975d4f17f3e0e7261b2ccc3ea0fdc840c0d66dd9 100644 --- a/Corona-Warn-App/src/main/java/de/rki/coronawarnapp/util/ProtoFormatConverterExtensions.kt +++ b/Corona-Warn-App/src/main/java/de/rki/coronawarnapp/util/ProtoFormatConverterExtensions.kt @@ -1,7 +1,7 @@ package de.rki.coronawarnapp.util -import KeyExportFormat import de.rki.coronawarnapp.server.protocols.AppleLegacyKeyExchange +import de.rki.coronawarnapp.server.protocols.KeyExportFormat object ProtoFormatConverterExtensions { diff --git a/Corona-Warn-App/src/main/java/de/rki/coronawarnapp/util/SignatureHelper.kt b/Corona-Warn-App/src/main/java/de/rki/coronawarnapp/util/SignatureHelper.kt index 9ad36fcbdea6b43f843e95679895a7a122b8fa65..9ffa457377cdb14a04ba855e86d6020f2eb1a72f 100644 --- a/Corona-Warn-App/src/main/java/de/rki/coronawarnapp/util/SignatureHelper.kt +++ b/Corona-Warn-App/src/main/java/de/rki/coronawarnapp/util/SignatureHelper.kt @@ -1,9 +1,9 @@ package de.rki.coronawarnapp.util -import KeyExportFormat.SignatureInfo +import de.rki.coronawarnapp.server.protocols.KeyExportFormat object SignatureHelper { - val clientSig: SignatureInfo = SignatureInfo.newBuilder() + val clientSig: KeyExportFormat.SignatureInfo = KeyExportFormat.SignatureInfo.newBuilder() .setAndroidPackage("de.rki.coronawarnapp") .setAppBundleId("de.rki.coronawarnapp") .setSignatureAlgorithm("ECDSA") diff --git a/Corona-Warn-App/src/main/java/de/rki/coronawarnapp/util/WatchdogService.kt b/Corona-Warn-App/src/main/java/de/rki/coronawarnapp/util/WatchdogService.kt new file mode 100644 index 0000000000000000000000000000000000000000..d2e5aa97c9fc787f2874d65145e9c203a8457d9b --- /dev/null +++ b/Corona-Warn-App/src/main/java/de/rki/coronawarnapp/util/WatchdogService.kt @@ -0,0 +1,77 @@ +package de.rki.coronawarnapp.util + +import android.content.Context +import android.net.wifi.WifiManager +import android.os.PowerManager +import androidx.lifecycle.ProcessLifecycleOwner +import androidx.lifecycle.lifecycleScope +import de.rki.coronawarnapp.storage.LocalData +import de.rki.coronawarnapp.transaction.RetrieveDiagnosisKeysTransaction +import de.rki.coronawarnapp.worker.BackgroundWorkHelper +import de.rki.coronawarnapp.worker.BackgroundWorkScheduler +import kotlinx.coroutines.launch +import timber.log.Timber +import java.util.UUID +import javax.inject.Inject +import javax.inject.Singleton + +@Singleton +class WatchdogService @Inject constructor(private val context: Context) { + + private val powerManager by lazy { + context.getSystemService(Context.POWER_SERVICE) as PowerManager + } + private val wifiManager by lazy { + context.applicationContext.getSystemService(Context.WIFI_SERVICE) as WifiManager + } + + fun launch() { + // Only do this if the background jobs are enabled + if (!ConnectivityHelper.autoModeEnabled(context)) { + Timber.d("Background jobs are not enabled, aborting.") + return + } + + Timber.v("Acquiring wakelocks for watchdog routine.") + ProcessLifecycleOwner.get().lifecycleScope.launch { + // A wakelock as the OS does not handle this for us like in the background job execution + val wakeLock = createWakeLock() + // A wifi lock to wake up the wifi connection in case the device is dozing + val wifiLock = createWifiLock() + try { + BackgroundWorkHelper.sendDebugNotification( + "Automatic mode is on", "Check if we have downloaded keys already today" + ) + RetrieveDiagnosisKeysTransaction.startWithConstraints() + } catch (e: Exception) { + BackgroundWorkHelper.sendDebugNotification( + "RetrieveDiagnosisKeysTransaction failed", + (e.localizedMessage + ?: "Unknown exception occurred in onCreate") + "\n\n" + (e.cause + ?: "Cause is unknown").toString() + ) + // retry the key retrieval in case of an error with a scheduled work + BackgroundWorkScheduler.scheduleDiagnosisKeyOneTimeWork() + } + + if (wifiLock.isHeld) wifiLock.release() + if (wakeLock.isHeld) wakeLock.release() + } + + // if the user is onboarded we will schedule period background jobs + // in case the app was force stopped and woken up again by the Google WakeUpService + if (LocalData.onboardingCompletedTimestamp() != null) BackgroundWorkScheduler.startWorkScheduler() + } + + private fun createWakeLock(): PowerManager.WakeLock = powerManager + .newWakeLock(PowerManager.PARTIAL_WAKE_LOCK, "CWA-WAKE-${UUID.randomUUID()}") + .apply { acquire(TEN_MINUTE_TIMEOUT_IN_MS) } + + private fun createWifiLock(): WifiManager.WifiLock = wifiManager + .createWifiLock(WifiManager.WIFI_MODE_FULL_HIGH_PERF, "CWA-WIFI-${UUID.randomUUID()}") + .apply { acquire() } + + companion object { + private const val TEN_MINUTE_TIMEOUT_IN_MS = 10 * 60 * 1000L + } +} diff --git a/Corona-Warn-App/src/main/java/de/rki/coronawarnapp/util/di/ApplicationComponent.kt b/Corona-Warn-App/src/main/java/de/rki/coronawarnapp/util/di/ApplicationComponent.kt index 3d9b8556e85efb62167e716764fb5607c2943294..7f90b2cf5494101e1063517762db380aa86e1208 100644 --- a/Corona-Warn-App/src/main/java/de/rki/coronawarnapp/util/di/ApplicationComponent.kt +++ b/Corona-Warn-App/src/main/java/de/rki/coronawarnapp/util/di/ApplicationComponent.kt @@ -12,13 +12,15 @@ import de.rki.coronawarnapp.diagnosiskeys.download.KeyFileDownloader import de.rki.coronawarnapp.diagnosiskeys.storage.KeyCacheRepository import de.rki.coronawarnapp.environment.EnvironmentModule import de.rki.coronawarnapp.http.HttpModule -import de.rki.coronawarnapp.http.ServiceFactory import de.rki.coronawarnapp.nearby.ENFClient import de.rki.coronawarnapp.nearby.ENFModule +import de.rki.coronawarnapp.playbook.Playbook +import de.rki.coronawarnapp.playbook.PlaybookModule import de.rki.coronawarnapp.receiver.ReceiverBinder import de.rki.coronawarnapp.risk.RiskModule import de.rki.coronawarnapp.service.ServiceBinder import de.rki.coronawarnapp.storage.SettingsRepository +import de.rki.coronawarnapp.submission.SubmissionModule import de.rki.coronawarnapp.transaction.RetrieveDiagnosisInjectionHelper import de.rki.coronawarnapp.transaction.RiskLevelInjectionHelper import de.rki.coronawarnapp.transaction.SubmitDiagnosisInjectionHelper @@ -28,6 +30,7 @@ import de.rki.coronawarnapp.util.UtilModule 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.verification.VerificationModule import javax.inject.Singleton @Singleton @@ -47,7 +50,9 @@ import javax.inject.Singleton EnvironmentModule::class, DiagnosisKeysModule::class, AppConfigModule::class, - EnvironmentModule::class + SubmissionModule::class, + VerificationModule::class, + PlaybookModule::class ] ) interface ApplicationComponent : AndroidInjector<CoronaWarnApplication> { @@ -63,7 +68,6 @@ interface ApplicationComponent : AndroidInjector<CoronaWarnApplication> { val keyCacheRepository: KeyCacheRepository val keyFileDownloader: KeyFileDownloader - val serviceFactory: ServiceFactory val appConfigProvider: AppConfigProvider @@ -72,6 +76,8 @@ interface ApplicationComponent : AndroidInjector<CoronaWarnApplication> { val encryptedPreferencesFactory: EncryptedPreferencesFactory val errorResetTool: EncryptionErrorResetTool + val playbook: Playbook + @Component.Factory interface Factory { fun create(@BindsInstance app: CoronaWarnApplication): ApplicationComponent diff --git a/Corona-Warn-App/src/main/java/de/rki/coronawarnapp/util/security/VerificationKeys.kt b/Corona-Warn-App/src/main/java/de/rki/coronawarnapp/util/security/VerificationKeys.kt index 83f5eda69b4fc7c6552b1a697ad79c54281e60e5..35ac654628a44f338b4c982bd3ae413cdcdb3507 100644 --- a/Corona-Warn-App/src/main/java/de/rki/coronawarnapp/util/security/VerificationKeys.kt +++ b/Corona-Warn-App/src/main/java/de/rki/coronawarnapp/util/security/VerificationKeys.kt @@ -1,9 +1,9 @@ package de.rki.coronawarnapp.util.security -import KeyExportFormat import android.security.keystore.KeyProperties import android.util.Base64 import de.rki.coronawarnapp.environment.EnvironmentSetup +import de.rki.coronawarnapp.server.protocols.KeyExportFormat import timber.log.Timber import java.security.KeyFactory import java.security.Signature diff --git a/Corona-Warn-App/src/main/java/de/rki/coronawarnapp/util/viewmodel/CWAViewModel.kt b/Corona-Warn-App/src/main/java/de/rki/coronawarnapp/util/viewmodel/CWAViewModel.kt index 065a6bc2bf629d194c66dbb9976fd70776ba10b7..deaf4368cafc84eafcd3c6e4a6a5daf4150d93ad 100644 --- a/Corona-Warn-App/src/main/java/de/rki/coronawarnapp/util/viewmodel/CWAViewModel.kt +++ b/Corona-Warn-App/src/main/java/de/rki/coronawarnapp/util/viewmodel/CWAViewModel.kt @@ -2,6 +2,9 @@ package de.rki.coronawarnapp.util.viewmodel import androidx.annotation.CallSuper import androidx.lifecycle.ViewModel +import androidx.lifecycle.viewModelScope +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.launch import timber.log.Timber abstract class CWAViewModel : ViewModel() { @@ -12,6 +15,7 @@ abstract class CWAViewModel : ViewModel() { @CallSuper override fun onCleared() { + viewModelScope.launch(context = Dispatchers.Default) { } Timber.v("onCleared()") super.onCleared() } diff --git a/Corona-Warn-App/src/main/java/de/rki/coronawarnapp/verification/VerificationModule.kt b/Corona-Warn-App/src/main/java/de/rki/coronawarnapp/verification/VerificationModule.kt new file mode 100644 index 0000000000000000000000000000000000000000..8bd41f974462a36ff85a6d1d3e3b0560960aa85f --- /dev/null +++ b/Corona-Warn-App/src/main/java/de/rki/coronawarnapp/verification/VerificationModule.kt @@ -0,0 +1,57 @@ +package de.rki.coronawarnapp.verification + +import android.content.Context +import dagger.Module +import dagger.Provides +import dagger.Reusable +import de.rki.coronawarnapp.environment.verification.VerificationCDNServerUrl +import de.rki.coronawarnapp.http.HttpClientDefault +import de.rki.coronawarnapp.http.RestrictedConnectionSpecs +import de.rki.coronawarnapp.verification.server.VerificationApiV1 +import de.rki.coronawarnapp.verification.server.VerificationHttpClient +import okhttp3.Cache +import okhttp3.ConnectionSpec +import okhttp3.OkHttpClient +import retrofit2.Retrofit +import retrofit2.converter.gson.GsonConverterFactory +import java.io.File +import javax.inject.Singleton + +@Module +class VerificationModule { + + @Reusable + @VerificationHttpClient + @Provides + fun cdnHttpClient( + @HttpClientDefault defaultHttpClient: OkHttpClient, + @RestrictedConnectionSpecs connectionSpecs: List<ConnectionSpec> + ): OkHttpClient = + defaultHttpClient.newBuilder().connectionSpecs(connectionSpecs).build() + + @Singleton + @Provides + fun provideVerificationApi( + context: Context, + @VerificationHttpClient client: OkHttpClient, + @VerificationCDNServerUrl url: String, + gsonConverterFactory: GsonConverterFactory + ): VerificationApiV1 { + val cache = Cache(File(context.cacheDir, "http_verification"), DEFAULT_CACHE_SIZE) + + val cachingClient = client.newBuilder().apply { + cache(cache) + }.build() + + return Retrofit.Builder() + .client(cachingClient) + .baseUrl(url) + .addConverterFactory(gsonConverterFactory) + .build() + .create(VerificationApiV1::class.java) + } + + companion object { + private const val DEFAULT_CACHE_SIZE = 5 * 1024 * 1024L // 5MB + } +} diff --git a/Corona-Warn-App/src/main/java/de/rki/coronawarnapp/verification/server/VerificationApiV1.kt b/Corona-Warn-App/src/main/java/de/rki/coronawarnapp/verification/server/VerificationApiV1.kt new file mode 100644 index 0000000000000000000000000000000000000000..a45f6617de515e732f22c1e769bc37a788d7e301 --- /dev/null +++ b/Corona-Warn-App/src/main/java/de/rki/coronawarnapp/verification/server/VerificationApiV1.kt @@ -0,0 +1,58 @@ +package de.rki.coronawarnapp.verification.server + +import com.google.gson.annotations.SerializedName +import retrofit2.http.Body +import retrofit2.http.Header +import retrofit2.http.POST + +interface VerificationApiV1 { + + data class RegistrationTokenRequest( + @SerializedName("keyType") val keyType: String? = null, + @SerializedName("key") val key: String? = null, + @SerializedName("requestPadding") val requestPadding: String? = null + ) + + data class RegistrationTokenResponse( + @SerializedName("registrationToken") val registrationToken: String + ) + + @POST("version/v1/registrationToken") + suspend fun getRegistrationToken( + @Header("cwa-fake") fake: String, + @Header("cwa-header-padding") headerPadding: String?, + @Body requestBody: RegistrationTokenRequest + ): RegistrationTokenResponse + + data class RegistrationRequest( + @SerializedName("registrationToken") val registrationToken: String? = null, + @SerializedName("requestPadding") val requestPadding: String? = null + ) + + data class TestResultResponse( + @SerializedName("testResult") val testResult: Int + ) + + @POST("version/v1/testresult") + suspend fun getTestResult( + @Header("cwa-fake") fake: String, + @Header("cwa-header-padding") headerPadding: String?, + @Body request: RegistrationRequest + ): TestResultResponse + + data class TanRequestBody( + @SerializedName("registrationToken") val registrationToken: String? = null, + @SerializedName("requestPadding") val requestPadding: String? = null + ) + + data class TanResponse( + @SerializedName("tan") val tan: String + ) + + @POST("version/v1/tan") + suspend fun getTAN( + @Header("cwa-fake") fake: String, + @Header("cwa-header-padding") headerPadding: String?, + @Body requestBody: TanRequestBody + ): TanResponse +} diff --git a/Corona-Warn-App/src/main/java/de/rki/coronawarnapp/verification/server/VerificationHttpClient.kt b/Corona-Warn-App/src/main/java/de/rki/coronawarnapp/verification/server/VerificationHttpClient.kt new file mode 100644 index 0000000000000000000000000000000000000000..1c244ae61732a1e8ab53ca6acc6ec5303c5b203b --- /dev/null +++ b/Corona-Warn-App/src/main/java/de/rki/coronawarnapp/verification/server/VerificationHttpClient.kt @@ -0,0 +1,8 @@ +package de.rki.coronawarnapp.verification.server + +import javax.inject.Qualifier + +@Qualifier +@MustBeDocumented +@Retention(AnnotationRetention.RUNTIME) +annotation class VerificationHttpClient diff --git a/Corona-Warn-App/src/main/java/de/rki/coronawarnapp/verification/server/VerificationKeyType.kt b/Corona-Warn-App/src/main/java/de/rki/coronawarnapp/verification/server/VerificationKeyType.kt new file mode 100644 index 0000000000000000000000000000000000000000..f8112264c1a6c5491e6c5f0c7483c02044582f38 --- /dev/null +++ b/Corona-Warn-App/src/main/java/de/rki/coronawarnapp/verification/server/VerificationKeyType.kt @@ -0,0 +1,5 @@ +package de.rki.coronawarnapp.verification.server + +enum class VerificationKeyType { + GUID, TELETAN; +} diff --git a/Corona-Warn-App/src/main/java/de/rki/coronawarnapp/verification/server/VerificationServer.kt b/Corona-Warn-App/src/main/java/de/rki/coronawarnapp/verification/server/VerificationServer.kt new file mode 100644 index 0000000000000000000000000000000000000000..1411ca60db6ce02dcdbfb997bbe617345ee3b568 --- /dev/null +++ b/Corona-Warn-App/src/main/java/de/rki/coronawarnapp/verification/server/VerificationServer.kt @@ -0,0 +1,100 @@ +package de.rki.coronawarnapp.verification.server + +import dagger.Lazy +import de.rki.coronawarnapp.util.PaddingTool.requestPadding +import de.rki.coronawarnapp.util.security.HashHelper +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.withContext +import javax.inject.Inject +import javax.inject.Singleton + +@Singleton +class VerificationServer @Inject constructor( + private val verificationAPI: Lazy<VerificationApiV1> +) { + + private val api: VerificationApiV1 + get() = verificationAPI.get() + + suspend fun retrieveRegistrationToken( + key: String, + keyType: VerificationKeyType + ): String = withContext(Dispatchers.IO) { + val keyStr = if (keyType == VerificationKeyType.GUID) { + HashHelper.hash256(key) + } else { + key + } + + val paddingLength = when (keyType) { + VerificationKeyType.GUID -> PADDING_LENGTH_BODY_REGISTRATION_TOKEN_GUID + VerificationKeyType.TELETAN -> PADDING_LENGTH_BODY_REGISTRATION_TOKEN_TELETAN + } + + api.getRegistrationToken( + fake = "0", + headerPadding = requestPadding(PADDING_LENGTH_HEADER_REGISTRATION_TOKEN), + requestBody = VerificationApiV1.RegistrationTokenRequest( + keyType = keyType.name, + key = keyStr, + requestPadding = requestPadding(paddingLength) + ) + ).registrationToken + } + + suspend fun retrieveTestResults( + registrationToken: String + ): Int = withContext(Dispatchers.IO) { + api.getTestResult( + fake = "0", + headerPadding = requestPadding(PADDING_LENGTH_HEADER_TEST_RESULT), + request = VerificationApiV1.RegistrationRequest( + registrationToken, + requestPadding(PADDING_LENGTH_BODY_TEST_RESULT) + ) + ).testResult + } + + suspend fun retrieveTan( + registrationToken: String + ): String = withContext(Dispatchers.IO) { + api.getTAN( + fake = "0", + headerPadding = requestPadding(PADDING_LENGTH_HEADER_TAN), + requestBody = VerificationApiV1.TanRequestBody( + registrationToken, + requestPadding(PADDING_LENGTH_BODY_TAN) + ) + ).tan + } + + suspend fun retrieveTanFake() = withContext(Dispatchers.IO) { + api.getTAN( + fake = "1", + headerPadding = requestPadding(PADDING_LENGTH_HEADER_TAN), + requestBody = VerificationApiV1.TanRequestBody( + registrationToken = DUMMY_REGISTRATION_TOKEN, + requestPadding = requestPadding(PADDING_LENGTH_BODY_TAN_FAKE) + ) + ) + } + + companion object { + // padding registration token + private const val VERIFICATION_BODY_FILL = 139 + + const val PADDING_LENGTH_HEADER_REGISTRATION_TOKEN = 0 + const val PADDING_LENGTH_BODY_REGISTRATION_TOKEN_TELETAN = 51 + VERIFICATION_BODY_FILL + const val PADDING_LENGTH_BODY_REGISTRATION_TOKEN_GUID = 0 + VERIFICATION_BODY_FILL + + // padding test result + const val PADDING_LENGTH_HEADER_TEST_RESULT = 7 + const val PADDING_LENGTH_BODY_TEST_RESULT = 31 + VERIFICATION_BODY_FILL + + // padding tan + const val PADDING_LENGTH_HEADER_TAN = 14 + const val PADDING_LENGTH_BODY_TAN = 31 + VERIFICATION_BODY_FILL + const val PADDING_LENGTH_BODY_TAN_FAKE = 31 + VERIFICATION_BODY_FILL + const val DUMMY_REGISTRATION_TOKEN = "11111111-2222-4444-8888-161616161616" + } +} diff --git a/Corona-Warn-App/src/main/java/de/rki/coronawarnapp/worker/BackgroundNoiseOneTimeWorker.kt b/Corona-Warn-App/src/main/java/de/rki/coronawarnapp/worker/BackgroundNoiseOneTimeWorker.kt index 87e9a8c551721e4ac8c1dec420ed2803dd313744..724ac760cf52bd1843702506d759ca4f1616620d 100644 --- a/Corona-Warn-App/src/main/java/de/rki/coronawarnapp/worker/BackgroundNoiseOneTimeWorker.kt +++ b/Corona-Warn-App/src/main/java/de/rki/coronawarnapp/worker/BackgroundNoiseOneTimeWorker.kt @@ -3,8 +3,8 @@ package de.rki.coronawarnapp.worker import android.content.Context import androidx.work.CoroutineWorker import androidx.work.WorkerParameters -import de.rki.coronawarnapp.http.WebRequestBuilder -import de.rki.coronawarnapp.http.playbook.PlaybookImpl +import de.rki.coronawarnapp.playbook.Playbook +import de.rki.coronawarnapp.util.di.AppInjector /** * One time background noise worker @@ -17,9 +17,8 @@ class BackgroundNoiseOneTimeWorker( ) : CoroutineWorker(context, workerParams) { - companion object { - private val TAG: String? = BackgroundNoiseOneTimeWorker::class.simpleName - } + private val playbook: Playbook + get() = AppInjector.component.playbook /** * Work execution @@ -30,8 +29,7 @@ class BackgroundNoiseOneTimeWorker( var result = Result.success() try { - PlaybookImpl(WebRequestBuilder.getInstance()) - .dummy() + playbook.dummy() } catch (e: Exception) { // TODO: Should we even retry here? result = if (runAttemptCount > BackgroundConstants.WORKER_RETRY_COUNT_THRESHOLD) { diff --git a/Corona-Warn-App/src/main/res/values-de/strings.xml b/Corona-Warn-App/src/main/res/values-de/strings.xml index aeebdecde8991a4f5295ae1f7ea2900d1e971d3c..ab68325a65ac1bdcd09e8b386dd2a08b9bf5fcc6 100644 --- a/Corona-Warn-App/src/main/res/values-de/strings.xml +++ b/Corona-Warn-App/src/main/res/values-de/strings.xml @@ -1308,7 +1308,7 @@ <string name="country_name_sk">Slowakei</string> <!-- XHED: Title of the interoperbaility information view. --> - <string name="interoperability_title">Europaweite\nRisiko-Ermittlung</string> + <string name="interoperability_title">Länderübergreifende\nRisiko-Ermittlung</string> <!-- XHED: Setting title of interoperability in the tracing settings view --> <string name="settings_interoperability_title">Länderübergreifende Risiko-Ermittlung</string> @@ -1320,7 +1320,7 @@ <!-- XTXT: First section after the header of the interoperability information/configuration view --> <string name="interoperability_configuration_first_section">Die Corona-Warn-App warnt länderübergreifend vor möglichen Infektionen, indem Daten über eine europäische Infrastruktur ausgetauscht werden.</string> <!-- XTXT: Second section after the header of the interoperability information/configuration view --> - <string name="interoperability_configuration_second_section">Es werden keine persönlischen Daten ausgetauscht. Für den länderübergreifenden Datenaustausch über die Corona-Warn-App fallen keine zusätzlichen Kosten für Sie an.</string> + <string name="interoperability_configuration_second_section">Es werden keine persönlichen Daten ausgetauscht. Für den länderübergreifenden Datenaustausch über die Corona-Warn-App fallen keine zusätzlichen Kosten für Sie an.</string> <!-- XHED: Header right above the country list in the interoperability information/configuration view --> <string name="interoperability_configuration_list_title">Derzeit nehmen die folgenden Länder teil:</string> @@ -1336,7 +1336,7 @@ <string name="interoperability_onboarding_list_title">Derzeit nehmen die folgenden Länder teil:</string> <!-- XHED: Header of the delta onboarding screen for interoperability. If the user opens the app for the first time after the interoperability update --> - <string name="interoperability_onboarding_delta_title">Europaweite\nRisiko-Ermittlung</string> + <string name="interoperability_onboarding_delta_title">Länderübergreifende\nRisiko-Ermittlung</string> <!-- XTXT: Description of the interoperability extension of the app. Below interoperability_onboarding_delta_title --> <string name="interoperability_onboarding_delta_subtitle">Die Funktion der Corona-Warn-App wurde erweitert. Es arbeiten nun mehrere Länder in der EU zusammen, um über das gemeinsam betriebene Serversystem länderübergreifende Warnungen zu ermöglichen. So können bei der Risiko-Ermittlung jetzt auch die Kontakte mit Nutzern der offiziellen Corona-Apps anderer teilnehmender Länder berücksichtigt werden.</string> <!-- XHED: Header of private data in the delta onboarding interoperability view --> diff --git a/Corona-Warn-App/src/test/java/de/rki/coronawarnapp/appconfig/AppConfigServerTest.kt b/Corona-Warn-App/src/test/java/de/rki/coronawarnapp/appconfig/AppConfigProviderTest.kt similarity index 93% rename from Corona-Warn-App/src/test/java/de/rki/coronawarnapp/appconfig/AppConfigServerTest.kt rename to Corona-Warn-App/src/test/java/de/rki/coronawarnapp/appconfig/AppConfigProviderTest.kt index 31df2ce6a15d34bc9a66b19c7433bd13f055d521..b61f6775769bbd8f8055cd59a4c41bf9219ced86 100644 --- a/Corona-Warn-App/src/test/java/de/rki/coronawarnapp/appconfig/AppConfigServerTest.kt +++ b/Corona-Warn-App/src/test/java/de/rki/coronawarnapp/appconfig/AppConfigProviderTest.kt @@ -1,6 +1,5 @@ package de.rki.coronawarnapp.appconfig -import dagger.Lazy import de.rki.coronawarnapp.diagnosiskeys.server.LocationCode import de.rki.coronawarnapp.util.security.VerificationKeys import io.kotest.assertions.throwables.shouldThrow @@ -21,7 +20,7 @@ import testhelpers.BaseIOTest import java.io.File import java.io.IOException -class AppConfigServerTest : BaseIOTest() { +class AppConfigProviderTest : BaseIOTest() { @MockK lateinit var api: AppConfigApiV1 @MockK lateinit var verificationKeys: VerificationKeys @@ -52,7 +51,7 @@ class AppConfigServerTest : BaseIOTest() { private fun createDownloadServer( homeCountry: LocationCode = defaultHomeCountry ) = AppConfigProvider( - appConfigAPI = Lazy { api }, + appConfigAPI = { api }, verificationKeys = verificationKeys, homeCountry = homeCountry, configStorage = appConfigStorage @@ -185,6 +184,17 @@ class AppConfigServerTest : BaseIOTest() { } } + // Because the UI requires this to detect when to show alternative UI elements + @Test + fun `if supportedCountryList is empty, we do not insert DE as fallback`() { + coEvery { api.getApplicationConfiguration("DE") } returns APPCONFIG_BUNDLE.toResponseBody() + every { verificationKeys.hasInvalidSignature(any(), any()) } returns false + + runBlocking { + createDownloadServer().getAppConfig().supportedCountriesList shouldBe emptyList() + } + } + companion object { private val APPCONFIG_BUNDLE = ("504b0304140008080800856b22510000000000000000000000000a0000006578706f72742e62696ee3e016" + diff --git a/Corona-Warn-App/src/test/java/de/rki/coronawarnapp/appconfig/ApplicationConfigurationExtensionsTest.kt b/Corona-Warn-App/src/test/java/de/rki/coronawarnapp/appconfig/ApplicationConfigurationExtensionsTest.kt new file mode 100644 index 0000000000000000000000000000000000000000..2b0301482234f80c7bf19fca063a893adfeb194e --- /dev/null +++ b/Corona-Warn-App/src/test/java/de/rki/coronawarnapp/appconfig/ApplicationConfigurationExtensionsTest.kt @@ -0,0 +1,22 @@ +package de.rki.coronawarnapp.appconfig + +import de.rki.coronawarnapp.server.protocols.ApplicationConfigurationOuterClass.ApplicationConfiguration +import io.kotest.matchers.shouldBe +import org.junit.jupiter.api.Test +import testhelpers.BaseTest + +class ApplicationConfigurationExtensionsTest : BaseTest() { + + @Test + fun `to new Config`() { + val orig = ApplicationConfiguration.newBuilder().addSupportedCountries("NL").build() + orig.supportedCountriesList shouldBe listOf("NL") + + orig.toNewConfig { + clearSupportedCountries() + addSupportedCountries("DE") + }.supportedCountriesList shouldBe listOf("DE") + + orig.supportedCountriesList shouldBe listOf("NL") + } +} diff --git a/Corona-Warn-App/src/test/java/de/rki/coronawarnapp/http/WebRequestBuilderTest.kt b/Corona-Warn-App/src/test/java/de/rki/coronawarnapp/http/WebRequestBuilderTest.kt deleted file mode 100644 index cb1bb4a51e275f4d1c4e09c43bfd51eecf915985..0000000000000000000000000000000000000000 --- a/Corona-Warn-App/src/test/java/de/rki/coronawarnapp/http/WebRequestBuilderTest.kt +++ /dev/null @@ -1,35 +0,0 @@ -package de.rki.coronawarnapp.http - -import de.rki.coronawarnapp.http.service.SubmissionService -import de.rki.coronawarnapp.http.service.VerificationService -import de.rki.coronawarnapp.util.security.VerificationKeys -import io.mockk.MockKAnnotations -import io.mockk.impl.annotations.MockK -import io.mockk.unmockkAll -import org.junit.After -import org.junit.Before - -class WebRequestBuilderTest { - @MockK - private lateinit var verificationService: VerificationService - - @MockK - private lateinit var submissionService: SubmissionService - - @MockK - private lateinit var verificationKeys: VerificationKeys - - private lateinit var webRequestBuilder: WebRequestBuilder - - @Before - fun setUp() = run { - MockKAnnotations.init(this) - webRequestBuilder = WebRequestBuilder( - verificationService, - submissionService - ) - } - - @After - fun tearDown() = unmockkAll() -} diff --git a/Corona-Warn-App/src/test/java/de/rki/coronawarnapp/http/playbook/DefaultPlaybookTest.kt b/Corona-Warn-App/src/test/java/de/rki/coronawarnapp/http/playbook/DefaultPlaybookTest.kt new file mode 100644 index 0000000000000000000000000000000000000000..0b12d642f1af921448573e95ea908ac221fbb8e1 --- /dev/null +++ b/Corona-Warn-App/src/test/java/de/rki/coronawarnapp/http/playbook/DefaultPlaybookTest.kt @@ -0,0 +1,240 @@ +package de.rki.coronawarnapp.http.playbook + +import de.rki.coronawarnapp.playbook.DefaultPlaybook +import de.rki.coronawarnapp.playbook.Playbook +import de.rki.coronawarnapp.submission.server.SubmissionServer +import de.rki.coronawarnapp.util.formatter.TestResult +import de.rki.coronawarnapp.verification.server.VerificationKeyType +import de.rki.coronawarnapp.verification.server.VerificationServer +import io.kotest.assertions.throwables.shouldThrow +import io.kotest.matchers.shouldBe +import io.mockk.MockKAnnotations +import io.mockk.clearAllMocks +import io.mockk.coEvery +import io.mockk.coVerifySequence +import io.mockk.impl.annotations.MockK +import io.mockk.mockk +import kotlinx.coroutines.runBlocking +import org.junit.jupiter.api.AfterEach +import org.junit.jupiter.api.BeforeEach +import org.junit.jupiter.api.Test +import testhelpers.BaseTest +import testhelpers.exceptions.TestException + +class DefaultPlaybookTest : BaseTest() { + + @MockK lateinit var submissionServer: SubmissionServer + @MockK lateinit var verificationServer: VerificationServer + + @BeforeEach + fun setup() { + MockKAnnotations.init(this) + + coEvery { verificationServer.retrieveRegistrationToken(any(), any()) } returns "token" + coEvery { verificationServer.retrieveTestResults(any()) } returns 0 + coEvery { verificationServer.retrieveTanFake() } returns mockk() + coEvery { verificationServer.retrieveTan(any()) } returns "tan" + + coEvery { submissionServer.submitKeysToServer(any()) } returns mockk() + coEvery { submissionServer.submitKeysToServerFake() } returns mockk() + } + + @AfterEach + fun teardown() { + clearAllMocks() + } + + private fun createPlaybook() = DefaultPlaybook( + verificationServer = verificationServer, + submissionServer = submissionServer + ) + + @Test + fun `initial registration pattern matches`(): Unit = runBlocking { + coEvery { verificationServer.retrieveRegistrationToken(any(), any()) } returns "response" + + createPlaybook().initialRegistration("9A3B578UMG", VerificationKeyType.TELETAN) + + coVerifySequence { + // ensure request order is 2x verification and 1x submission + verificationServer.retrieveRegistrationToken(any(), any()) + verificationServer.retrieveTestResults(any()) + submissionServer.submitKeysToServerFake() + } + } + + @Test + fun ` registration pattern matches despite token failure`(): Unit = runBlocking { + coEvery { + verificationServer.retrieveRegistrationToken(any(), any()) + } throws TestException() + + shouldThrow<TestException> { + createPlaybook().initialRegistration("9A3B578UMG", VerificationKeyType.TELETAN) + } + + coVerifySequence { + // ensure request order is 2x verification and 1x submission + verificationServer.retrieveRegistrationToken(any(), any()) + verificationServer.retrieveTanFake() + submissionServer.submitKeysToServerFake() + } + } + + @Test + fun `submission matches request pattern`(): Unit = runBlocking { + coEvery { verificationServer.retrieveTan(any()) } returns "tan" + + createPlaybook().submission( + Playbook.SubmissionData( + registrationToken = "token", + temporaryExposureKeys = listOf(), + consentToFederation = true, + visistedCountries = listOf("DE") + ) + ) + + coVerifySequence { + // ensure request order is 2x verification and 1x submission + verificationServer.retrieveTan(any()) + verificationServer.retrieveTanFake() + submissionServer.submitKeysToServer(any()) + } + } + + @Test + fun `submission matches request pattern despite missing authcode`(): Unit = runBlocking { + coEvery { verificationServer.retrieveTan(any()) } throws TestException() + + shouldThrow<TestException> { + createPlaybook().submission( + Playbook.SubmissionData( + registrationToken = "token", + temporaryExposureKeys = listOf(), + consentToFederation = true, + visistedCountries = listOf("DE") + ) + ) + } + + coVerifySequence { + // ensure request order is 2x verification and 1x submission + verificationServer.retrieveTan(any()) + verificationServer.retrieveTanFake() + // Only called when null TAN is returned? But when does that happen? + submissionServer.submitKeysToServerFake() + } + } + + @Test + fun `test result retrieval matches pattern`(): Unit = runBlocking { + coEvery { verificationServer.retrieveTestResults(any()) } returns 0 + + createPlaybook().testResult("token") + + coVerifySequence { + // ensure request order is 2x verification and 1x submission + verificationServer.retrieveTestResults(any()) + verificationServer.retrieveTanFake() + submissionServer.submitKeysToServerFake() + } + } + + @Test + fun `dummy request pattern matches`(): Unit = runBlocking { + createPlaybook().dummy() + + coVerifySequence { + // ensure request order is 2x verification and 1x submission + verificationServer.retrieveTanFake() + verificationServer.retrieveTanFake() + submissionServer.submitKeysToServerFake() + } + } + + @Test + fun `failures during dummy requests should be ignored`(): Unit = runBlocking { + val expectedToken = "token" + coEvery { verificationServer.retrieveRegistrationToken(any(), any()) } returns expectedToken + val expectedResult = TestResult.PENDING + coEvery { verificationServer.retrieveTestResults(expectedToken) } returns expectedResult.value + coEvery { submissionServer.submitKeysToServerFake() } throws TestException() + + val (registrationToken, testResult) = createPlaybook() + .initialRegistration("key", VerificationKeyType.GUID) + + registrationToken shouldBe expectedToken + testResult shouldBe expectedResult + } + + @Test + fun `registration pattern matches despire token failure`(): Unit = runBlocking { + coEvery { + verificationServer.retrieveRegistrationToken(any(), any()) + } throws TestException() + + shouldThrow<TestException> { + createPlaybook().initialRegistration("9A3B578UMG", VerificationKeyType.TELETAN) + } + coVerifySequence { + // ensure request order is 2x verification and 1x submission + verificationServer.retrieveRegistrationToken(any(), any()) + verificationServer.retrieveTanFake() + submissionServer.submitKeysToServerFake() + } + } + + @Test + fun `registration pattern matches despite test result failure`(): Unit = runBlocking { + coEvery { verificationServer.retrieveTestResults(any()) } throws TestException() + + shouldThrow<TestException> { + createPlaybook().initialRegistration("9A3B578UMG", VerificationKeyType.TELETAN) + } + + coVerifySequence { + // ensure request order is 2x verification and 1x submission + verificationServer.retrieveRegistrationToken(any(), any()) + verificationServer.retrieveTestResults(any()) + submissionServer.submitKeysToServerFake() + } + } + + @Test + fun `test result pattern matches despite failure`(): Unit = runBlocking { + coEvery { verificationServer.retrieveTestResults(any()) } throws TestException() + + shouldThrow<TestException> { + createPlaybook().testResult("token") + } + + coVerifySequence { + // ensure request order is 2x verification and 1x submission + verificationServer.retrieveTestResults(any()) + verificationServer.retrieveTanFake() + submissionServer.submitKeysToServerFake() + } + } + + @Test + fun `submission pattern matches despite tan failure`(): Unit = runBlocking { + coEvery { verificationServer.retrieveTan(any()) } throws TestException() + + shouldThrow<TestException> { + createPlaybook().submission( + Playbook.SubmissionData( + registrationToken = "token", + temporaryExposureKeys = listOf(), + consentToFederation = true, + visistedCountries = listOf("DE") + ) + ) + } + coVerifySequence { + // ensure request order is 2x verification and 1x submission + verificationServer.retrieveTan(any()) + verificationServer.retrieveTanFake() + submissionServer.submitKeysToServerFake() + } + } +} diff --git a/Corona-Warn-App/src/test/java/de/rki/coronawarnapp/http/playbook/PlaybookImplTest.kt b/Corona-Warn-App/src/test/java/de/rki/coronawarnapp/http/playbook/PlaybookImplTest.kt deleted file mode 100644 index 508ed2d00de963e84cf299f0a07687d326c469cb..0000000000000000000000000000000000000000 --- a/Corona-Warn-App/src/test/java/de/rki/coronawarnapp/http/playbook/PlaybookImplTest.kt +++ /dev/null @@ -1,187 +0,0 @@ -package de.rki.coronawarnapp.http.playbook - -import de.rki.coronawarnapp.exception.http.InternalServerErrorException -import de.rki.coronawarnapp.service.submission.KeyType -import de.rki.coronawarnapp.util.formatter.TestResult -import de.rki.coronawarnapp.util.newWebRequestBuilder -import kotlinx.coroutines.runBlocking -import okhttp3.mockwebserver.MockResponse -import okhttp3.mockwebserver.MockWebServer -import org.hamcrest.MatcherAssert.assertThat -import org.hamcrest.Matchers -import org.hamcrest.Matchers.equalTo -import org.junit.Assert.fail -import org.junit.Test - -class PlaybookImplTest { - - @Test - fun hasRequestPattern_initialRegistration(): Unit = runBlocking { - val server = MockWebServer() - server.start() - - server.enqueue(MockResponse().setBody("""{"registrationToken":"response"}""")) - server.enqueue(MockResponse().setBody("{}")) - server.enqueue(MockResponse().setBody("{}")) - - PlaybookImpl(server.newWebRequestBuilder()) - .initialRegistration("9A3B578UMG", KeyType.TELETAN) - - // ensure request order is 2x verification and 1x submission - assertRequestPattern(server) - } - - @Test - fun hasRequestPattern_submission(): Unit = runBlocking { - val server = MockWebServer() - server.start() - - server.enqueue(MockResponse().setBody("""{"tan":"response"}""")) - server.enqueue(MockResponse().setBody("{}")) - server.enqueue(MockResponse().setBody("{}")) - - PlaybookImpl(server.newWebRequestBuilder()) - .submission("token", listOf()) - - // ensure request order is 2x verification and 1x submission - assertRequestPattern(server) - } - - @Test - fun hasRequestPattern_testResult(): Unit = runBlocking { - val server = MockWebServer() - server.start() - - server.enqueue(MockResponse().setBody("""{"testResult":0}""")) - server.enqueue(MockResponse().setBody("{}")) - server.enqueue(MockResponse().setBody("{}")) - - PlaybookImpl(server.newWebRequestBuilder()) - .testResult("token") - - // ensure request order is 2x verification and 1x submission - assertRequestPattern(server) - } - - @Test - fun hasRequestPattern_dummy(): Unit = runBlocking { - val server = MockWebServer() - server.start() - - server.enqueue(MockResponse().setBody("{}")) - server.enqueue(MockResponse().setBody("{}")) - server.enqueue(MockResponse().setBody("{}")) - - PlaybookImpl(server.newWebRequestBuilder()) - .dummy() - - // ensure request order is 2x verification and 1x submission - assertRequestPattern(server) - } - - @Test - fun shouldIgnoreFailuresForDummyRequests(): Unit = runBlocking { - val server = MockWebServer() - server.start() - - val expectedRegistrationToken = "token" - val expectedTestResult = TestResult.PENDING - server.enqueue(MockResponse().setBody("""{"registrationToken":"$expectedRegistrationToken"}""")) - server.enqueue(MockResponse().setBody("""{"testResult":${expectedTestResult.value}}""")) - server.enqueue(MockResponse().setResponseCode(500)) - - val (registrationToken, testResult) = PlaybookImpl(server.newWebRequestBuilder()) - .initialRegistration("key", KeyType.GUID) - - assertThat(registrationToken, equalTo(expectedRegistrationToken)) - assertThat(testResult, equalTo(expectedTestResult)) - } - - @Test - fun hasRequestPatternWhenRealRequestFails_initialRegistrationFirst(): Unit = runBlocking { - val server = MockWebServer() - server.start() - - server.enqueue(MockResponse().setResponseCode(500)) - server.enqueue(MockResponse().setBody("{}")) - server.enqueue(MockResponse().setBody("{}")) - - try { - - PlaybookImpl(server.newWebRequestBuilder()) - .initialRegistration("9A3B578UMG", KeyType.TELETAN) - fail("exception propagation expected") - } catch (e: InternalServerErrorException) { - } - - // ensure request order is 2x verification and 1x submission - assertRequestPattern(server) - } - - @Test - fun hasRequestPatternWhenRealRequestFails_initialRegistrationSecond(): Unit = runBlocking { - val server = MockWebServer() - server.start() - - server.enqueue(MockResponse().setBody("""{"registrationToken":"response"}""")) - server.enqueue(MockResponse().setResponseCode(500)) - server.enqueue(MockResponse().setBody("{}")) - - try { - PlaybookImpl(server.newWebRequestBuilder()) - .initialRegistration("9A3B578UMG", KeyType.TELETAN) - fail("exception propagation expected") - } catch (e: InternalServerErrorException) { - } - - // ensure request order is 2x verification and 1x submission - assertRequestPattern(server) - } - - @Test - fun hasRequestPatternWhenRealRequestFails_testResult(): Unit = runBlocking { - val server = MockWebServer() - server.start() - - server.enqueue(MockResponse().setResponseCode(500)) - server.enqueue(MockResponse().setBody("{}")) - server.enqueue(MockResponse().setBody("{}")) - - try { - - PlaybookImpl(server.newWebRequestBuilder()) - .testResult("token") - fail("exception propagation expected") - } catch (e: InternalServerErrorException) { - } - - // ensure request order is 2x verification and 1x submission - assertRequestPattern(server) - } - - @Test - fun hasRequestPatternWhenRealRequestFails_submission(): Unit = runBlocking { - val server = MockWebServer() - server.start() - - server.enqueue(MockResponse().setResponseCode(500)) - server.enqueue(MockResponse().setBody("{}")) - server.enqueue(MockResponse().setBody("{}")) - - try { - PlaybookImpl(server.newWebRequestBuilder()) - .submission("token", listOf()) - fail("exception propagation expected") - } catch (e: InternalServerErrorException) { - } - - // ensure request order is 2x verification and 1x submission - assertRequestPattern(server) - } - - private fun assertRequestPattern(server: MockWebServer) { - assertThat(server.takeRequest().path, Matchers.startsWith("/verification/")) - assertThat(server.takeRequest().path, Matchers.startsWith("/verification/")) - assertThat(server.takeRequest().path, Matchers.startsWith("/submission/")) - } -} diff --git a/Corona-Warn-App/src/test/java/de/rki/coronawarnapp/http/service/SubmissionServiceTest.kt b/Corona-Warn-App/src/test/java/de/rki/coronawarnapp/http/service/SubmissionServiceTest.kt deleted file mode 100644 index d8eae0c49d434fdee968072fa9afb1302672df73..0000000000000000000000000000000000000000 --- a/Corona-Warn-App/src/test/java/de/rki/coronawarnapp/http/service/SubmissionServiceTest.kt +++ /dev/null @@ -1,43 +0,0 @@ -package de.rki.coronawarnapp.http.service - -import de.rki.coronawarnapp.util.headerSizeIgnoringContentLength -import de.rki.coronawarnapp.util.newWebRequestBuilder -import kotlinx.coroutines.runBlocking -import okhttp3.mockwebserver.MockResponse -import okhttp3.mockwebserver.MockWebServer -import org.junit.Assert -import org.junit.Test - -class SubmissionServiceTest { - - @Test - fun allRequestHaveSameFootprintForPlausibleDeniability(): Unit = runBlocking { - - val server = MockWebServer() - server.start() - - val webRequestBuilder = server.newWebRequestBuilder() - - val authCodeExample = "39ec4930-7a1f-4d5d-921f-bfad3b6f1269" - - server.enqueue(MockResponse().setBody("{}")) - webRequestBuilder.asyncSubmitKeysToServer(authCodeExample, listOf()) - - server.enqueue(MockResponse().setBody("{}")) - webRequestBuilder.asyncFakeSubmission() - - val requests = listOf( - server.takeRequest(), - server.takeRequest() - ) - - // ensure all request have same size (header & body) - requests.zipWithNext().forEach { (a, b) -> - Assert.assertEquals( - "Header size mismatch: ", - a.headerSizeIgnoringContentLength(), - b.headerSizeIgnoringContentLength() - ) - } - } -} diff --git a/Corona-Warn-App/src/test/java/de/rki/coronawarnapp/http/service/VerificationServiceTest.kt b/Corona-Warn-App/src/test/java/de/rki/coronawarnapp/http/service/VerificationServiceTest.kt deleted file mode 100644 index 3dde34e4171d63c625a2d6db9a50acb6f19d2ee2..0000000000000000000000000000000000000000 --- a/Corona-Warn-App/src/test/java/de/rki/coronawarnapp/http/service/VerificationServiceTest.kt +++ /dev/null @@ -1,60 +0,0 @@ -package de.rki.coronawarnapp.http.service - -import de.rki.coronawarnapp.service.submission.KeyType -import de.rki.coronawarnapp.util.headerSizeIgnoringContentLength -import de.rki.coronawarnapp.util.newWebRequestBuilder -import kotlinx.coroutines.runBlocking -import okhttp3.mockwebserver.MockResponse -import okhttp3.mockwebserver.MockWebServer -import org.hamcrest.MatcherAssert.assertThat -import org.hamcrest.Matchers.equalTo -import org.junit.Test - -class VerificationServiceTest { - - @Test - fun allRequestHaveSameFootprintForPlausibleDeniability(): Unit = runBlocking { - - val server = MockWebServer() - server.start() - - val webRequestBuilder = server.newWebRequestBuilder() - - val guidExample = "3BF1D4-1C6003DD-733D-41F1-9F30-F85FA7406BF7" - val teletanExample = "9A3B578UMG" - val registrationTokenExample = "63b4d3ff-e0de-4bd4-90c1-17c2bb683a2f" - - server.enqueue(MockResponse().setBody("{}")) - webRequestBuilder.asyncGetRegistrationToken(guidExample, KeyType.GUID) - - server.enqueue(MockResponse().setBody("{}")) - webRequestBuilder.asyncGetRegistrationToken(teletanExample, KeyType.TELETAN) - - server.enqueue(MockResponse().setBody("{}")) - webRequestBuilder.asyncGetTestResult(registrationTokenExample) - - server.enqueue(MockResponse().setBody("{}")) - webRequestBuilder.asyncGetTan(registrationTokenExample) - - server.enqueue(MockResponse().setBody("{}")) - webRequestBuilder.asyncFakeVerification() - - val requests = listOf( - server.takeRequest(), - server.takeRequest(), - server.takeRequest(), - server.takeRequest(), - server.takeRequest() - ) - - // ensure all request have same size (header & body) - requests.forEach { assertThat(it.bodySize, equalTo(250L)) } - - requests.zipWithNext().forEach { (a, b) -> - assertThat( - a.headerSizeIgnoringContentLength(), - equalTo(b.headerSizeIgnoringContentLength()) - ) - } - } -} diff --git a/Corona-Warn-App/src/test/java/de/rki/coronawarnapp/service/applicationconfiguration/ApplicationConfigurationServiceTest.kt b/Corona-Warn-App/src/test/java/de/rki/coronawarnapp/service/applicationconfiguration/ApplicationConfigurationServiceTest.kt index a7fe73eef9a0051ef30f6e1efcb90c52941920ad..bfa68c95fc0416c210b03da08d6126c230b9bba7 100644 --- a/Corona-Warn-App/src/test/java/de/rki/coronawarnapp/service/applicationconfiguration/ApplicationConfigurationServiceTest.kt +++ b/Corona-Warn-App/src/test/java/de/rki/coronawarnapp/service/applicationconfiguration/ApplicationConfigurationServiceTest.kt @@ -1,7 +1,6 @@ package de.rki.coronawarnapp.service.applicationconfiguration import de.rki.coronawarnapp.appconfig.AppConfigProvider -import de.rki.coronawarnapp.http.WebRequestBuilder import de.rki.coronawarnapp.server.protocols.ApplicationConfigurationOuterClass import de.rki.coronawarnapp.util.CWADebug import de.rki.coronawarnapp.util.di.AppInjector @@ -25,7 +24,6 @@ class ApplicationConfigurationServiceTest : BaseTest() { CWADebug.isDebugBuildOrMode shouldBe true - mockkObject(WebRequestBuilder) val appConfig = mockk<ApplicationConfigurationOuterClass.ApplicationConfiguration>() val appConfigBuilder = mockk<ApplicationConfigurationOuterClass.ApplicationConfiguration.Builder>() diff --git a/Corona-Warn-App/src/test/java/de/rki/coronawarnapp/service/diagnosiskey/DiagnosisKeyConstantsTest.kt b/Corona-Warn-App/src/test/java/de/rki/coronawarnapp/service/diagnosiskey/DiagnosisKeyConstantsTest.kt deleted file mode 100644 index 2bb68c7fbbf33e2697e2b5b9cf95b6217be86904..0000000000000000000000000000000000000000 --- a/Corona-Warn-App/src/test/java/de/rki/coronawarnapp/service/diagnosiskey/DiagnosisKeyConstantsTest.kt +++ /dev/null @@ -1,13 +0,0 @@ -package de.rki.coronawarnapp.service.diagnosiskey - -import org.junit.Assert -import org.junit.Test - -class DiagnosisKeyConstantsTest { - - @Test - fun allDiagnosisKeyConstants() { - Assert.assertEquals(DiagnosisKeyConstants.SERVER_ERROR_CODE_403, 403) - Assert.assertEquals(DiagnosisKeyConstants.DIAGNOSIS_KEYS_SUBMISSION_URL, "version/v1/diagnosis-keys") - } -} diff --git a/Corona-Warn-App/src/test/java/de/rki/coronawarnapp/service/submission/SubmissionConstantsTest.kt b/Corona-Warn-App/src/test/java/de/rki/coronawarnapp/service/submission/SubmissionConstantsTest.kt index b65d00b6985fe7c66fadaf97c33cb4ee90915817..5b070bfb4607c79ee19e4c294493c9043ac02fd3 100644 --- a/Corona-Warn-App/src/test/java/de/rki/coronawarnapp/service/submission/SubmissionConstantsTest.kt +++ b/Corona-Warn-App/src/test/java/de/rki/coronawarnapp/service/submission/SubmissionConstantsTest.kt @@ -7,27 +7,8 @@ class SubmissionConstantsTest { @Test fun allSubmissionConstants() { - // TODO: Should we really keep these now? - Assert.assertEquals(KeyType.GUID.name, "GUID") - Assert.assertEquals(KeyType.TELETAN.name, "TELETAN") - - Assert.assertEquals( - SubmissionConstants.REGISTRATION_TOKEN_URL, - "version/v1/registrationToken" - ) - Assert.assertEquals(SubmissionConstants.TEST_RESULT_URL, "version/v1/testresult") - Assert.assertEquals(SubmissionConstants.TAN_REQUEST_URL, "version/v1/tan") - Assert.assertEquals(QRScanResult.MAX_QR_CODE_LENGTH, 150) Assert.assertEquals(QRScanResult.MAX_GUID_LENGTH, 80) Assert.assertEquals(QRScanResult.GUID_SEPARATOR, '?') - - Assert.assertEquals(SubmissionConstants.SERVER_ERROR_CODE_400, 400) - - // dummy token passes server verification - Assert.assertTrue( - Regex("^[a-f0-9]{8}-[a-f0-9]{4}-4[a-f0-9]{3}-[89aAbB][a-f0-9]{3}-[a-f0-9]{12}\$") - .matches(SubmissionConstants.DUMMY_REGISTRATION_TOKEN) - ) } } diff --git a/Corona-Warn-App/src/test/java/de/rki/coronawarnapp/service/submission/SubmissionServiceTest.kt b/Corona-Warn-App/src/test/java/de/rki/coronawarnapp/service/submission/SubmissionServiceTest.kt index 4768cb8c0beb02c4c1bc6d820d5b2b6b44ca0740..f5084219d09a5c1c661af6962dce58a69e44ea8a 100644 --- a/Corona-Warn-App/src/test/java/de/rki/coronawarnapp/service/submission/SubmissionServiceTest.kt +++ b/Corona-Warn-App/src/test/java/de/rki/coronawarnapp/service/submission/SubmissionServiceTest.kt @@ -2,15 +2,21 @@ package de.rki.coronawarnapp.service.submission import de.rki.coronawarnapp.exception.NoGUIDOrTANSetException import de.rki.coronawarnapp.exception.NoRegistrationTokenSetException -import de.rki.coronawarnapp.http.WebRequestBuilder -import de.rki.coronawarnapp.http.playbook.BackgroundNoise +import de.rki.coronawarnapp.playbook.BackgroundNoise +import de.rki.coronawarnapp.playbook.Playbook import de.rki.coronawarnapp.storage.LocalData import de.rki.coronawarnapp.storage.SubmissionRepository import de.rki.coronawarnapp.submission.Symptoms import de.rki.coronawarnapp.transaction.SubmitDiagnosisKeysTransaction +import de.rki.coronawarnapp.util.di.AppInjector +import de.rki.coronawarnapp.util.di.ApplicationComponent import de.rki.coronawarnapp.util.formatter.TestResult +import de.rki.coronawarnapp.verification.server.VerificationKeyType +import io.kotest.assertions.throwables.shouldThrow +import io.kotest.matchers.shouldBe import io.mockk.MockKAnnotations import io.mockk.Runs +import io.mockk.clearAllMocks import io.mockk.coEvery import io.mockk.every import io.mockk.impl.annotations.MockK @@ -18,29 +24,30 @@ import io.mockk.just import io.mockk.mockkObject import io.mockk.verify import kotlinx.coroutines.runBlocking -import org.hamcrest.CoreMatchers.equalTo -import org.hamcrest.MatcherAssert.assertThat -import org.junit.Before -import org.junit.Test +import org.junit.jupiter.api.AfterEach +import org.junit.jupiter.api.BeforeEach +import org.junit.jupiter.api.Test class SubmissionServiceTest { + private val guid = "123456-12345678-1234-4DA7-B166-B86D85475064" private val registrationToken = "asdjnskjfdniuewbheboqudnsojdff" private val testResult = TestResult.PENDING - @MockK - private lateinit var webRequestBuilder: WebRequestBuilder - - @MockK - private lateinit var backgroundNoise: BackgroundNoise + @MockK lateinit var backgroundNoise: BackgroundNoise + @MockK lateinit var mockPlaybook: Playbook + @MockK lateinit var appComponent: ApplicationComponent private val symptoms = Symptoms(Symptoms.StartOf.OneToTwoWeeksAgo, Symptoms.Indication.POSITIVE) - @Before + @BeforeEach fun setUp() { MockKAnnotations.init(this) - mockkObject(WebRequestBuilder.Companion) - every { WebRequestBuilder.getInstance() } returns webRequestBuilder + + mockkObject(AppInjector) + every { AppInjector.component } returns appComponent + + every { appComponent.playbook } returns mockPlaybook mockkObject(BackgroundNoise.Companion) every { BackgroundNoise.getInstance() } returns backgroundNoise @@ -56,9 +63,14 @@ class SubmissionServiceTest { every { LocalData.registrationToken() } returns null } - @Test(expected = NoGUIDOrTANSetException::class) - fun registerDeviceWithoutTANOrGUIDFails() { - runBlocking { + @AfterEach + fun cleanUp() { + clearAllMocks() + } + + @Test + fun registerDeviceWithoutTANOrGUIDFails(): Unit = runBlocking { + shouldThrow<NoGUIDOrTANSetException> { SubmissionService.asyncRegisterDevice() } } @@ -72,9 +84,9 @@ class SubmissionServiceTest { every { LocalData.devicePairingSuccessfulTimestamp(any()) } just Runs coEvery { - webRequestBuilder.asyncGetRegistrationToken(any(), KeyType.GUID) - } returns registrationToken - coEvery { webRequestBuilder.asyncGetTestResult(registrationToken) } returns testResult.value + mockPlaybook.initialRegistration(any(), VerificationKeyType.GUID) + } returns (registrationToken to TestResult.PENDING) + coEvery { mockPlaybook.testResult(registrationToken) } returns testResult every { backgroundNoise.scheduleDummyPattern() } just Runs @@ -100,9 +112,9 @@ class SubmissionServiceTest { every { LocalData.devicePairingSuccessfulTimestamp(any()) } just Runs coEvery { - webRequestBuilder.asyncGetRegistrationToken(any(), KeyType.TELETAN) - } returns registrationToken - coEvery { webRequestBuilder.asyncGetTestResult(registrationToken) } returns testResult.value + mockPlaybook.initialRegistration(any(), VerificationKeyType.TELETAN) + } returns (registrationToken to TestResult.PENDING) + coEvery { mockPlaybook.testResult(registrationToken) } returns testResult every { backgroundNoise.scheduleDummyPattern() } just Runs @@ -119,9 +131,9 @@ class SubmissionServiceTest { } } - @Test(expected = NoRegistrationTokenSetException::class) - fun requestTestResultWithoutRegistrationTokenFails() { - runBlocking { + @Test + fun requestTestResultWithoutRegistrationTokenFails(): Unit = runBlocking { + shouldThrow<NoRegistrationTokenSetException> { SubmissionService.asyncRequestTestResult() } } @@ -129,16 +141,16 @@ class SubmissionServiceTest { @Test fun requestTestResultSucceeds() { every { LocalData.registrationToken() } returns registrationToken - coEvery { webRequestBuilder.asyncGetTestResult(registrationToken) } returns TestResult.NEGATIVE.value + coEvery { mockPlaybook.testResult(registrationToken) } returns TestResult.NEGATIVE runBlocking { - assertThat(SubmissionService.asyncRequestTestResult(), equalTo(TestResult.NEGATIVE)) + SubmissionService.asyncRequestTestResult() shouldBe TestResult.NEGATIVE } } - @Test(expected = NoRegistrationTokenSetException::class) - fun submitExposureKeysWithoutRegistrationTokenFails() { - runBlocking { + @Test + fun submitExposureKeysWithoutRegistrationTokenFails(): Unit = runBlocking { + shouldThrow<NoRegistrationTokenSetException> { SubmissionService.asyncSubmitExposureKeys(listOf(), symptoms) } } @@ -146,7 +158,13 @@ class SubmissionServiceTest { @Test fun submitExposureKeysSucceeds() { every { LocalData.registrationToken() } returns registrationToken - coEvery { SubmitDiagnosisKeysTransaction.start(registrationToken, any(), symptoms) } just Runs + coEvery { + SubmitDiagnosisKeysTransaction.start( + registrationToken, + any(), + symptoms + ) + } just Runs runBlocking { SubmissionService.asyncSubmitExposureKeys(listOf(), symptoms) diff --git a/Corona-Warn-App/src/test/java/de/rki/coronawarnapp/submission/ExposureKeyHistoryCalculationsTest.kt b/Corona-Warn-App/src/test/java/de/rki/coronawarnapp/submission/ExposureKeyHistoryCalculationsTest.kt index eb5801c317ef42a1d5f3fc3cde27787ef1a20227..524d93eaf438ad3a40c5407f3b417ac9268db03c 100644 --- a/Corona-Warn-App/src/test/java/de/rki/coronawarnapp/submission/ExposureKeyHistoryCalculationsTest.kt +++ b/Corona-Warn-App/src/test/java/de/rki/coronawarnapp/submission/ExposureKeyHistoryCalculationsTest.kt @@ -1,7 +1,7 @@ package de.rki.coronawarnapp.submission -import KeyExportFormat import com.google.android.gms.nearby.exposurenotification.TemporaryExposureKey +import de.rki.coronawarnapp.server.protocols.KeyExportFormat import org.junit.Assert import org.junit.Before import org.junit.Test diff --git a/Corona-Warn-App/src/test/java/de/rki/coronawarnapp/submission/SubmissionModuleTest.kt b/Corona-Warn-App/src/test/java/de/rki/coronawarnapp/submission/SubmissionModuleTest.kt new file mode 100644 index 0000000000000000000000000000000000000000..ba8b4b91b1e824534587068ed8408cca176f9a64 --- /dev/null +++ b/Corona-Warn-App/src/test/java/de/rki/coronawarnapp/submission/SubmissionModuleTest.kt @@ -0,0 +1,85 @@ +package de.rki.coronawarnapp.submission + +import android.content.Context +import io.kotest.assertions.throwables.shouldNotThrowAny +import io.kotest.matchers.shouldBe +import io.mockk.MockKAnnotations +import io.mockk.clearAllMocks +import io.mockk.every +import io.mockk.impl.annotations.MockK +import io.mockk.verify +import okhttp3.ConnectionSpec +import okhttp3.OkHttpClient +import org.junit.jupiter.api.AfterEach +import org.junit.jupiter.api.BeforeEach +import org.junit.jupiter.api.Test +import retrofit2.converter.gson.GsonConverterFactory +import retrofit2.converter.protobuf.ProtoConverterFactory +import testhelpers.BaseIOTest +import java.io.File + +class SubmissionModuleTest : BaseIOTest() { + + @MockK lateinit var context: Context + + private val testDir = File(IO_TEST_BASEDIR, this::class.java.simpleName) + private val cacheFiles = File(testDir, "cache") + + @BeforeEach + fun setup() { + MockKAnnotations.init(this) + every { context.cacheDir } returns cacheFiles + + testDir.mkdirs() + testDir.exists() shouldBe true + } + + @AfterEach + fun teardown() { + clearAllMocks() + testDir.deleteRecursively() + } + + private fun createModule() = SubmissionModule() + + @Test + fun `side effect free instantiation`() { + shouldNotThrowAny { + createModule() + } + } + + @Test + fun `client creation uses connection specs`() { + val module = createModule() + + val specs = listOf(ConnectionSpec.MODERN_TLS) + val client = OkHttpClient.Builder().build() + + val newClient = module.cdnHttpClient( + defaultHttpClient = client, + connectionSpecs = specs + ) + + newClient.apply { + connectionSpecs shouldBe specs + } + } + + @Test + fun `api uses a cache`() { + val module = createModule() + + val client = OkHttpClient.Builder().build() + + module.provideSubmissionApi( + context = context, + client = client, + url = "https://testurl", + gsonConverterFactory = GsonConverterFactory.create(), + protoConverterFactory = ProtoConverterFactory.create() + ) + + verify { context.cacheDir } + } +} diff --git a/Corona-Warn-App/src/test/java/de/rki/coronawarnapp/submission/server/SubmissionApiV1Test.kt b/Corona-Warn-App/src/test/java/de/rki/coronawarnapp/submission/server/SubmissionApiV1Test.kt new file mode 100644 index 0000000000000000000000000000000000000000..68ac0a71049438798b0f02de956605b2e72d2ac2 --- /dev/null +++ b/Corona-Warn-App/src/test/java/de/rki/coronawarnapp/submission/server/SubmissionApiV1Test.kt @@ -0,0 +1,103 @@ +package de.rki.coronawarnapp.submission.server + +import android.content.Context +import com.google.protobuf.ByteString +import de.rki.coronawarnapp.http.HttpModule +import de.rki.coronawarnapp.server.protocols.KeyExportFormat +import de.rki.coronawarnapp.submission.SubmissionModule +import io.kotest.matchers.shouldBe +import io.mockk.MockKAnnotations +import io.mockk.clearAllMocks +import io.mockk.every +import io.mockk.impl.annotations.MockK +import kotlinx.coroutines.runBlocking +import okhttp3.ConnectionSpec +import okhttp3.mockwebserver.MockWebServer +import org.junit.jupiter.api.AfterEach +import org.junit.jupiter.api.BeforeEach +import org.junit.jupiter.api.Test +import testhelpers.BaseIOTest +import testhelpers.BaseTest +import testhelpers.extensions.toJsonResponse +import java.io.File +import java.util.concurrent.TimeUnit + +class SubmissionApiV1Test : BaseTest() { + + @MockK + private lateinit var context: Context + + private lateinit var webServer: MockWebServer + private lateinit var serverAddress: String + + private val testDir = File(BaseIOTest.IO_TEST_BASEDIR, this::class.java.simpleName) + private val cacheDir = File(testDir, "cache") + private val httpCacheDir = File(cacheDir, "http_submission") + + @BeforeEach + fun setup() { + MockKAnnotations.init(this) + every { context.cacheDir } returns cacheDir + + webServer = MockWebServer() + webServer.start() + serverAddress = "http://${webServer.hostName}:${webServer.port}" + } + + @AfterEach + fun teardown() { + clearAllMocks() + webServer.shutdown() + } + + private fun createAPI(): SubmissionApiV1 { + val httpModule = HttpModule() + val defaultHttpClient = httpModule.defaultHttpClient() + + return SubmissionModule().let { + val downloadHttpClient = it.cdnHttpClient( + defaultHttpClient, + listOf(ConnectionSpec.CLEARTEXT, ConnectionSpec.MODERN_TLS) + ) + it.provideSubmissionApi( + context = context, + client = downloadHttpClient, + url = serverAddress, + gsonConverterFactory = httpModule.provideGSONConverter(), + protoConverterFactory = httpModule.provideProtoConverter() + ) + } + } + + @Test + fun `test submitKeys`(): Unit = runBlocking { + val api = createAPI() + + """ + { + "tan": "testTan" + } + """.toJsonResponse().apply { webServer.enqueue(this) } + + val submissionPayload = KeyExportFormat.SubmissionPayload.newBuilder() + .setPadding(ByteString.copyFromUtf8("fakeKeyPadding")) + .build() + + api.submitKeys( + authCode = "testAuthCode", + fake = "0", + headerPadding = "testPadding", + requestBody = submissionPayload + ) + + webServer.takeRequest(5, TimeUnit.SECONDS)!!.apply { + headers["cwa-authorization"] shouldBe "testAuthCode" + headers["cwa-fake"] shouldBe "0" + headers["cwa-header-padding"] shouldBe "testPadding" + path shouldBe "/version/v1/diagnosis-keys" + body.readUtf8() shouldBe """fakeKeyPadding""" + } + + httpCacheDir.exists() shouldBe true + } +} diff --git a/Corona-Warn-App/src/test/java/de/rki/coronawarnapp/submission/server/SubmissionServerTest.kt b/Corona-Warn-App/src/test/java/de/rki/coronawarnapp/submission/server/SubmissionServerTest.kt new file mode 100644 index 0000000000000000000000000000000000000000..68031d83b88d7f3ae4dc74c2a57c0358a3117968 --- /dev/null +++ b/Corona-Warn-App/src/test/java/de/rki/coronawarnapp/submission/server/SubmissionServerTest.kt @@ -0,0 +1,166 @@ +package de.rki.coronawarnapp.submission.server + +import android.content.Context +import com.google.protobuf.ByteString +import de.rki.coronawarnapp.http.HttpModule +import de.rki.coronawarnapp.server.protocols.KeyExportFormat +import de.rki.coronawarnapp.submission.SubmissionModule +import de.rki.coronawarnapp.util.headerSizeIgnoringContentLength +import io.kotest.matchers.shouldBe +import io.mockk.MockKAnnotations +import io.mockk.clearAllMocks +import io.mockk.coEvery +import io.mockk.coVerify +import io.mockk.every +import io.mockk.impl.annotations.MockK +import kotlinx.coroutines.runBlocking +import okhttp3.ConnectionSpec +import okhttp3.mockwebserver.MockResponse +import okhttp3.mockwebserver.MockWebServer +import org.junit.jupiter.api.AfterEach +import org.junit.jupiter.api.BeforeEach +import org.junit.jupiter.api.Test +import testhelpers.BaseIOTest +import testhelpers.BaseTest +import java.io.File + +class SubmissionServerTest : BaseTest() { + @MockK lateinit var submissionApi: SubmissionApiV1 + @MockK lateinit var context: Context + + private lateinit var webServer: MockWebServer + private lateinit var serverAddress: String + + private val testDir = File(BaseIOTest.IO_TEST_BASEDIR, this::class.java.simpleName) + private val cacheDir = File(testDir, "cache") + private val httpCacheDir = File(cacheDir, "http_submission") + + @BeforeEach + fun setup() { + MockKAnnotations.init(this) + testDir.mkdirs() + testDir.exists() shouldBe true + every { context.cacheDir } returns cacheDir + + webServer = MockWebServer() + webServer.start() + serverAddress = "http://${webServer.hostName}:${webServer.port}" + } + + @AfterEach + fun teardown() { + clearAllMocks() + webServer.shutdown() + testDir.deleteRecursively() + } + + private fun createServer( + customApi: SubmissionApiV1 = submissionApi + ) = SubmissionServer(submissionApi = { customApi }) + + @Test + fun `normal submission`(): Unit = runBlocking { + val testKeyData = ByteString.copyFrom("TestKeyDataGoogle", Charsets.UTF_8) + + val server = createServer() + coEvery { submissionApi.submitKeys(any(), any(), any(), any()) } answers { + arg<String>(0) shouldBe "testAuthCode" + arg<String>(1) shouldBe "0" + arg<String>(2) shouldBe "" + arg<KeyExportFormat.SubmissionPayload>(3).apply { + keysList.single().keyData shouldBe testKeyData + padding.size() shouldBe 364 + hasConsentToFederation() shouldBe true + visitedCountriesList shouldBe listOf("DE") + } + Unit + } + + val googleKeyList = KeyExportFormat.TemporaryExposureKey + .newBuilder() + .setKeyData(testKeyData) + .build() + + val submissionData = SubmissionServer.SubmissionData( + authCode = "testAuthCode", + keyList = listOf(googleKeyList), + consentToFederation = true, + visistedCountries = listOf("DE") + ) + server.submitKeysToServer(submissionData) + + coVerify { submissionApi.submitKeys(any(), any(), any(), any()) } + } + + @Test + fun `fake submission`(): Unit = runBlocking { + val server = createServer() + coEvery { submissionApi.submitKeys(any(), any(), any(), any()) } answers { + arg<String>(0) shouldBe "" // cwa-authorization + arg<String>(1) shouldBe "1" // cwa-fake + arg<String>(2).length shouldBe 36 // cwa-header-padding + arg<KeyExportFormat.SubmissionPayload>(3).apply { + keysList.size shouldBe 0 + padding.size() shouldBe 392 + hasConsentToFederation() shouldBe false + visitedCountriesList shouldBe emptyList() + } + Unit + } + + server.submitKeysToServerFake() + + coVerify { submissionApi.submitKeys(any(), any(), any(), any()) } + } + + private fun createRealApi(): SubmissionApiV1 { + val httpModule = HttpModule() + val defaultHttpClient = httpModule.defaultHttpClient() + + return SubmissionModule().let { + val downloadHttpClient = it.cdnHttpClient( + defaultHttpClient, + listOf(ConnectionSpec.CLEARTEXT, ConnectionSpec.MODERN_TLS) + ) + it.provideSubmissionApi( + context = context, + client = downloadHttpClient, + url = serverAddress, + gsonConverterFactory = httpModule.provideGSONConverter(), + protoConverterFactory = httpModule.provideProtoConverter() + ) + } + } + + @Test + fun allRequestHaveSameFootprintForPlausibleDeniability(): Unit = runBlocking { + val server = createServer(createRealApi()) + + val testKeyData = ByteString.copyFrom("TestKeyDataGoogle", Charsets.UTF_8) + val googleKeyList = KeyExportFormat.TemporaryExposureKey + .newBuilder() + .setKeyData(testKeyData) + .build() + val submissionData = SubmissionServer.SubmissionData( + authCode = "39ec4930-7a1f-4d5d-921f-bfad3b6f1269", + keyList = listOf(googleKeyList), + consentToFederation = true, + visistedCountries = listOf("DE") + ) + webServer.enqueue(MockResponse().setBody("{}")) + server.submitKeysToServer(submissionData) + + webServer.enqueue(MockResponse().setBody("{}")) + server.submitKeysToServerFake() + + val requests = listOf( + webServer.takeRequest(), + webServer.takeRequest() + ) + + // ensure all request have same size (header & body) + requests.zipWithNext().forEach { (a, b) -> + a.headerSizeIgnoringContentLength() shouldBe b.headerSizeIgnoringContentLength() + } + } +} diff --git a/Corona-Warn-App/src/test/java/de/rki/coronawarnapp/transaction/SubmitDiagnosisKeysTransactionTest.kt b/Corona-Warn-App/src/test/java/de/rki/coronawarnapp/transaction/SubmitDiagnosisKeysTransactionTest.kt index 6840c334425ae639fc8357eac807863ba3215bf0..1bdd27360813d85da713af61bb1e6c3e298323df 100644 --- a/Corona-Warn-App/src/test/java/de/rki/coronawarnapp/transaction/SubmitDiagnosisKeysTransactionTest.kt +++ b/Corona-Warn-App/src/test/java/de/rki/coronawarnapp/transaction/SubmitDiagnosisKeysTransactionTest.kt @@ -1,62 +1,59 @@ package de.rki.coronawarnapp.transaction -import KeyExportFormat import com.google.android.gms.nearby.exposurenotification.TemporaryExposureKey -import de.rki.coronawarnapp.http.WebRequestBuilder -import de.rki.coronawarnapp.http.playbook.BackgroundNoise +import de.rki.coronawarnapp.appconfig.AppConfigProvider import de.rki.coronawarnapp.nearby.InternalExposureNotificationClient +import de.rki.coronawarnapp.playbook.BackgroundNoise +import de.rki.coronawarnapp.playbook.Playbook +import de.rki.coronawarnapp.server.protocols.ApplicationConfigurationOuterClass.ApplicationConfiguration import de.rki.coronawarnapp.service.submission.SubmissionService import de.rki.coronawarnapp.storage.LocalData import de.rki.coronawarnapp.submission.Symptoms import de.rki.coronawarnapp.util.di.AppInjector import de.rki.coronawarnapp.util.di.ApplicationComponent import de.rki.coronawarnapp.worker.BackgroundWorkScheduler +import io.kotest.matchers.shouldBe import io.mockk.MockKAnnotations import io.mockk.Runs +import io.mockk.clearAllMocks import io.mockk.coEvery -import io.mockk.coVerifyOrder +import io.mockk.coVerifySequence import io.mockk.every import io.mockk.impl.annotations.MockK import io.mockk.just -import io.mockk.mockk import io.mockk.mockkObject -import io.mockk.slot -import io.mockk.unmockkAll import kotlinx.coroutines.runBlocking -import org.hamcrest.CoreMatchers.`is` -import org.hamcrest.MatcherAssert.assertThat -import org.junit.After -import org.junit.Before -import org.junit.Test +import org.junit.jupiter.api.AfterEach +import org.junit.jupiter.api.BeforeEach +import org.junit.jupiter.api.Test class SubmitDiagnosisKeysTransactionTest { - @MockK - private lateinit var webRequestBuilder: WebRequestBuilder + @MockK lateinit var backgroundNoise: BackgroundNoise + @MockK lateinit var mockPlaybook: Playbook + @MockK lateinit var appConfigProvider: AppConfigProvider + @MockK lateinit var appComponent: ApplicationComponent - @MockK - private lateinit var backgroundNoise: BackgroundNoise - - private val authString = "authString" private val registrationToken = "123" private val symptoms = Symptoms(Symptoms.StartOf.OneToTwoWeeksAgo, Symptoms.Indication.POSITIVE) + private val defaultCountries = listOf("DE", "NL", "FR") - @Before + @BeforeEach fun setUp() { MockKAnnotations.init(this) + val appConfig = ApplicationConfiguration.newBuilder() + .addAllSupportedCountries(defaultCountries) + .build() + coEvery { appConfigProvider.getAppConfig() } returns appConfig + + every { appComponent.transSubmitDiagnosisInjection } returns SubmitDiagnosisInjectionHelper( + TransactionCoroutineScope(), mockPlaybook, appConfigProvider + ) mockkObject(AppInjector) - val appComponent = mockk<ApplicationComponent>().apply { - every { transSubmitDiagnosisInjection } returns SubmitDiagnosisInjectionHelper( - TransactionCoroutineScope() - ) - } every { AppInjector.component } returns appComponent - mockkObject(WebRequestBuilder.Companion) - every { WebRequestBuilder.getInstance() } returns webRequestBuilder - mockkObject(BackgroundNoise.Companion) every { BackgroundNoise.getInstance() } returns backgroundNoise @@ -66,54 +63,90 @@ class SubmitDiagnosisKeysTransactionTest { mockkObject(BackgroundWorkScheduler) every { BackgroundWorkScheduler.stopWorkScheduler() } just Runs every { LocalData.numberOfSuccessfulSubmissions(any()) } just Runs - coEvery { webRequestBuilder.asyncGetTan(registrationToken) } returns authString + } + + @AfterEach + fun cleanUp() { + clearAllMocks() } @Test - fun testTransactionNoKeys() { + fun `submission without keys`(): Unit = runBlocking { + coEvery { mockPlaybook.submission(any()) } returns Unit coEvery { InternalExposureNotificationClient.asyncGetTemporaryExposureKeyHistory() } returns listOf() - coEvery { webRequestBuilder.asyncSubmitKeysToServer(authString, listOf()) } just Runs - runBlocking { - SubmitDiagnosisKeysTransaction.start(registrationToken, listOf(), symptoms) + SubmitDiagnosisKeysTransaction.start(registrationToken, listOf(), symptoms) + + coVerifySequence { + appConfigProvider.getAppConfig() + mockPlaybook.submission( + Playbook.SubmissionData( + registrationToken = registrationToken, + temporaryExposureKeys = emptyList(), + consentToFederation = true, + visistedCountries = defaultCountries + ) + ) + SubmissionService.submissionSuccessful() + } + } - coVerifyOrder { - webRequestBuilder.asyncSubmitKeysToServer(authString, listOf()) - SubmissionService.submissionSuccessful() - } + @Test + fun `submission without keys and fallback country`(): Unit = runBlocking { + val appConfig = ApplicationConfiguration.newBuilder().build() + coEvery { appConfigProvider.getAppConfig() } returns appConfig + coEvery { mockPlaybook.submission(any()) } returns Unit + coEvery { InternalExposureNotificationClient.asyncGetTemporaryExposureKeyHistory() } returns listOf() + + SubmitDiagnosisKeysTransaction.start(registrationToken, listOf(), symptoms) + + coVerifySequence { + appConfigProvider.getAppConfig() + mockPlaybook.submission( + Playbook.SubmissionData( + registrationToken = registrationToken, + temporaryExposureKeys = emptyList(), + consentToFederation = true, + visistedCountries = listOf("DE") + ) + ) + SubmissionService.submissionSuccessful() } } @Test - fun testTransactionHasKeys() { + fun `submission with keys`(): Unit = runBlocking { val key = TemporaryExposureKey.TemporaryExposureKeyBuilder() .setKeyData(ByteArray(1)) .setRollingPeriod(1) .setRollingStartIntervalNumber(1) .setTransmissionRiskLevel(1) .build() - val testList = slot<List<KeyExportFormat.TemporaryExposureKey>>() + coEvery { InternalExposureNotificationClient.asyncGetTemporaryExposureKeyHistory() } returns listOf( key ) - coEvery { - webRequestBuilder.asyncSubmitKeysToServer(authString, capture(testList)) - } just Runs - - runBlocking { - SubmitDiagnosisKeysTransaction.start(registrationToken, listOf(key), symptoms) - - coVerifyOrder { - webRequestBuilder.asyncSubmitKeysToServer(authString, any()) - SubmissionService.submissionSuccessful() + coEvery { mockPlaybook.submission(any()) } answers { + arg<Playbook.SubmissionData>(0).also { + it.registrationToken shouldBe registrationToken + it.temporaryExposureKeys.single().apply { + keyData.toByteArray() shouldBe ByteArray(1) + rollingPeriod shouldBe 144 + rollingStartIntervalNumber shouldBe 1 + transmissionRiskLevel shouldBe 1 + } + it.consentToFederation shouldBe true + it.visistedCountries shouldBe defaultCountries } - assertThat(testList.isCaptured, `is`(true)) - assertThat(testList.captured.size, `is`(1)) + Unit } - } - @After - fun cleanUp() { - unmockkAll() + SubmitDiagnosisKeysTransaction.start(registrationToken, listOf(key), symptoms) + + coVerifySequence { + appConfigProvider.getAppConfig() + mockPlaybook.submission(any()) + SubmissionService.submissionSuccessful() + } } } diff --git a/Corona-Warn-App/src/test/java/de/rki/coronawarnapp/ui/viewmodel/SubmissionViewModelTest.kt b/Corona-Warn-App/src/test/java/de/rki/coronawarnapp/ui/viewmodel/SubmissionViewModelTest.kt index 5a93e5ac213f30f811bfc8e57e5b8ece230c71ae..009decb3cf789031e01a271e6daa31dd21fb77f7 100644 --- a/Corona-Warn-App/src/test/java/de/rki/coronawarnapp/ui/viewmodel/SubmissionViewModelTest.kt +++ b/Corona-Warn-App/src/test/java/de/rki/coronawarnapp/ui/viewmodel/SubmissionViewModelTest.kt @@ -1,10 +1,9 @@ package de.rki.coronawarnapp.ui.viewmodel -import androidx.arch.core.executor.testing.InstantTaskExecutorRule -import de.rki.coronawarnapp.http.WebRequestBuilder -import de.rki.coronawarnapp.http.playbook.BackgroundNoise +import de.rki.coronawarnapp.playbook.BackgroundNoise import de.rki.coronawarnapp.storage.LocalData import de.rki.coronawarnapp.ui.submission.ScanStatus +import io.kotest.matchers.shouldBe import io.mockk.MockKAnnotations import io.mockk.Runs import io.mockk.every @@ -12,42 +11,36 @@ import io.mockk.impl.annotations.MockK import io.mockk.just import io.mockk.mockkObject import org.junit.Assert -import org.junit.Before -import org.junit.Rule -import org.junit.Test +import org.junit.jupiter.api.BeforeEach +import org.junit.jupiter.api.Test +import org.junit.jupiter.api.extension.ExtendWith +import testhelpers.extensions.InstantExecutorExtension +@ExtendWith(InstantExecutorExtension::class) class SubmissionViewModelTest { - private var viewModel: SubmissionViewModel = SubmissionViewModel() - @JvmField - @Rule - var instantTaskExecutorRule: InstantTaskExecutorRule = InstantTaskExecutorRule() + @MockK lateinit var backgroundNoise: BackgroundNoise - @MockK - private lateinit var webRequestBuilder: WebRequestBuilder - - @MockK - private lateinit var backgroundNoise: BackgroundNoise - - @Before + @BeforeEach fun setUp() { MockKAnnotations.init(this) mockkObject(LocalData) every { LocalData.testGUID(any()) } just Runs - mockkObject(WebRequestBuilder.Companion) - every { WebRequestBuilder.getInstance() } returns webRequestBuilder mockkObject(BackgroundNoise.Companion) every { BackgroundNoise.getInstance() } returns backgroundNoise } + private fun createViewModel() = SubmissionViewModel() + @Test fun scanStatusValid() { + val viewModel = createViewModel() // start - viewModel.scanStatus.value?.getContent().let { Assert.assertEquals(ScanStatus.STARTED, it) } + viewModel.scanStatus.value!!.getContent() shouldBe ScanStatus.STARTED // valid guid val guid = "123456-12345678-1234-4DA7-B166-B86D85475064" diff --git a/Corona-Warn-App/src/test/java/de/rki/coronawarnapp/util/MockWebServerUtil.kt b/Corona-Warn-App/src/test/java/de/rki/coronawarnapp/util/MockWebServerUtil.kt index 0dd704f73135ef01964264e3e2c9dbf281e69536..6f31074edc2393b995c0f1e87b41356c74fa34c9 100644 --- a/Corona-Warn-App/src/test/java/de/rki/coronawarnapp/util/MockWebServerUtil.kt +++ b/Corona-Warn-App/src/test/java/de/rki/coronawarnapp/util/MockWebServerUtil.kt @@ -1,38 +1,7 @@ package de.rki.coronawarnapp.util -import de.rki.coronawarnapp.http.HttpErrorParser -import de.rki.coronawarnapp.http.WebRequestBuilder -import de.rki.coronawarnapp.http.interceptor.RetryInterceptor -import de.rki.coronawarnapp.http.service.SubmissionService -import de.rki.coronawarnapp.http.service.VerificationService -import okhttp3.OkHttpClient -import okhttp3.logging.HttpLoggingInterceptor -import okhttp3.mockwebserver.MockWebServer import okhttp3.mockwebserver.RecordedRequest import okio.utf8Size -import retrofit2.Retrofit -import retrofit2.converter.gson.GsonConverterFactory -import retrofit2.converter.protobuf.ProtoConverterFactory - -fun MockWebServer.newWebRequestBuilder(): WebRequestBuilder { - val httpClient = OkHttpClient.Builder() - .addInterceptor(HttpLoggingInterceptor().setLevel(HttpLoggingInterceptor.Level.BODY)) - .addInterceptor(RetryInterceptor()) - .addInterceptor(HttpErrorParser()) - .build() - - val retrofit = Retrofit.Builder() - .client(httpClient) - .addConverterFactory(ProtoConverterFactory.create()) - .addConverterFactory(GsonConverterFactory.create()) - - return WebRequestBuilder( - retrofit.baseUrl(this.url("/verification/")).build() - .create(VerificationService::class.java), - retrofit.baseUrl(this.url("/submission/")).build() - .create(SubmissionService::class.java) - ) -} fun RecordedRequest.requestHeaderWithoutContentLength() = listOf(this.requestLine) diff --git a/Corona-Warn-App/src/test/java/de/rki/coronawarnapp/util/PaddingToolTest.kt b/Corona-Warn-App/src/test/java/de/rki/coronawarnapp/util/PaddingToolTest.kt new file mode 100644 index 0000000000000000000000000000000000000000..e6cef8427918b00337c31d6e1cda6ed0c2c92d11 --- /dev/null +++ b/Corona-Warn-App/src/test/java/de/rki/coronawarnapp/util/PaddingToolTest.kt @@ -0,0 +1,23 @@ +package de.rki.coronawarnapp.util + +import io.kotest.matchers.shouldBe +import org.junit.jupiter.api.Test +import testhelpers.BaseTest +import kotlin.math.abs +import kotlin.random.Random + +class PaddingToolTest : BaseTest() { + + private val validPattern = "^([A-Za-z0-9]+)$".toRegex() + + @Test + fun `verify padding patterns`() { + repeat(100) { + val randomLength = abs(Random.nextInt(1024)) + PaddingTool.requestPadding(randomLength).apply { + length shouldBe randomLength + validPattern.matches(this) shouldBe true + } + } + } +} diff --git a/Corona-Warn-App/src/test/java/de/rki/coronawarnapp/verification/VerificationModuleTest.kt b/Corona-Warn-App/src/test/java/de/rki/coronawarnapp/verification/VerificationModuleTest.kt new file mode 100644 index 0000000000000000000000000000000000000000..8ea767b587f35699e874af1e05f6b29094f5a493 --- /dev/null +++ b/Corona-Warn-App/src/test/java/de/rki/coronawarnapp/verification/VerificationModuleTest.kt @@ -0,0 +1,83 @@ +package de.rki.coronawarnapp.verification + +import android.content.Context +import io.kotest.assertions.throwables.shouldNotThrowAny +import io.kotest.matchers.shouldBe +import io.mockk.MockKAnnotations +import io.mockk.clearAllMocks +import io.mockk.every +import io.mockk.impl.annotations.MockK +import io.mockk.verify +import okhttp3.ConnectionSpec +import okhttp3.OkHttpClient +import org.junit.jupiter.api.AfterEach +import org.junit.jupiter.api.BeforeEach +import org.junit.jupiter.api.Test +import retrofit2.converter.gson.GsonConverterFactory +import testhelpers.BaseIOTest +import java.io.File + +class VerificationModuleTest : BaseIOTest() { + + @MockK lateinit var context: Context + + private val testDir = File(IO_TEST_BASEDIR, this::class.java.simpleName) + private val cacheFiles = File(testDir, "cache") + + @BeforeEach + fun setup() { + MockKAnnotations.init(this) + every { context.cacheDir } returns cacheFiles + + testDir.mkdirs() + testDir.exists() shouldBe true + } + + @AfterEach + fun teardown() { + clearAllMocks() + testDir.deleteRecursively() + } + + private fun createModule() = VerificationModule() + + @Test + fun `side effect free instantiation`() { + shouldNotThrowAny { + createModule() + } + } + + @Test + fun `client creation uses connection specs`() { + val module = createModule() + + val specs = listOf(ConnectionSpec.MODERN_TLS) + val client = OkHttpClient.Builder().build() + + val newClient = module.cdnHttpClient( + defaultHttpClient = client, + connectionSpecs = specs + ) + + newClient.apply { + connectionSpecs shouldBe specs + } + } + + @Test + fun `api uses a cache`() { + val module = createModule() + + val client = OkHttpClient.Builder().build() + + module.provideVerificationApi( + context = context, + client = client, + url = "https://testurl", + gsonConverterFactory = GsonConverterFactory.create() + ) + + verify { context.cacheDir } + } +} diff --git a/Corona-Warn-App/src/test/java/de/rki/coronawarnapp/verification/server/VerificationApiV1Test.kt b/Corona-Warn-App/src/test/java/de/rki/coronawarnapp/verification/server/VerificationApiV1Test.kt new file mode 100644 index 0000000000000000000000000000000000000000..3fe5a0c47a1f30a09a681788c97646221d91821e --- /dev/null +++ b/Corona-Warn-App/src/test/java/de/rki/coronawarnapp/verification/server/VerificationApiV1Test.kt @@ -0,0 +1,181 @@ +package de.rki.coronawarnapp.verification.server + +import android.content.Context +import de.rki.coronawarnapp.http.HttpModule +import de.rki.coronawarnapp.verification.VerificationModule +import io.kotest.matchers.shouldBe +import io.mockk.MockKAnnotations +import io.mockk.clearAllMocks +import io.mockk.every +import io.mockk.impl.annotations.MockK +import kotlinx.coroutines.runBlocking +import okhttp3.ConnectionSpec +import okhttp3.mockwebserver.MockWebServer +import org.junit.jupiter.api.AfterEach +import org.junit.jupiter.api.BeforeEach +import org.junit.jupiter.api.Test +import testhelpers.BaseIOTest +import testhelpers.extensions.toComparableJson +import testhelpers.extensions.toJsonResponse +import java.io.File +import java.util.concurrent.TimeUnit + +class VerificationApiV1Test : BaseIOTest() { + + @MockK private lateinit var context: Context + + private lateinit var webServer: MockWebServer + private lateinit var serverAddress: String + + private val testDir = File(IO_TEST_BASEDIR, this::class.java.simpleName) + private val cacheDir = File(testDir, "cache") + private val httpCacheDir = File(cacheDir, "http_verification") + + @BeforeEach + fun setup() { + MockKAnnotations.init(this) + every { context.cacheDir } returns cacheDir + + webServer = MockWebServer() + webServer.start() + serverAddress = "http://${webServer.hostName}:${webServer.port}" + } + + @AfterEach + fun teardown() { + clearAllMocks() + webServer.shutdown() + testDir.deleteRecursively() + } + + private fun createAPI(): VerificationApiV1 { + val httpModule = HttpModule() + val defaultHttpClient = httpModule.defaultHttpClient() + val gsonConverterFactory = httpModule.provideGSONConverter() + + return VerificationModule().let { + val downloadHttpClient = it.cdnHttpClient( + defaultHttpClient, + listOf(ConnectionSpec.CLEARTEXT, ConnectionSpec.MODERN_TLS) + ) + it.provideVerificationApi( + context = context, + client = downloadHttpClient, + url = serverAddress, + gsonConverterFactory = gsonConverterFactory + ) + } + } + + @Test + fun `test getRegistrationToken`(): Unit = runBlocking { + val api = createAPI() + + """ + { + "registrationToken": "testRegistrationToken" + } + """.toJsonResponse().apply { webServer.enqueue(this) } + + val requestBody = VerificationApiV1.RegistrationTokenRequest( + keyType = "testKeyType", + key = "testKey", + requestPadding = "testRequestPadding" + ) + + api.getRegistrationToken( + fake = "0", + headerPadding = "testPadding", + requestBody + ) shouldBe VerificationApiV1.RegistrationTokenResponse( + registrationToken = "testRegistrationToken" + ) + + webServer.takeRequest(5, TimeUnit.SECONDS)!!.apply { + headers["cwa-fake"] shouldBe "0" + headers["cwa-header-padding"] shouldBe "testPadding" + path shouldBe "/version/v1/registrationToken" + body.readUtf8() shouldBe """ + { + "keyType": "testKeyType", + "key": "testKey", + "requestPadding": "testRequestPadding" + } + """.toComparableJson() + } + + httpCacheDir.exists() shouldBe true + } + + @Test + fun `test getTestResult`(): Unit = runBlocking { + val api = createAPI() + + """ + { + "testResult": 1 + } + """.toJsonResponse().apply { webServer.enqueue(this) } + + val requestBody = VerificationApiV1.RegistrationRequest( + registrationToken = "testRegistrationToken", + requestPadding = "testRequestPadding" + ) + + api.getTestResult( + fake = "0", + headerPadding = "testPadding", + requestBody + ) shouldBe VerificationApiV1.TestResultResponse( + testResult = 1 + ) + + webServer.takeRequest(5, TimeUnit.SECONDS)!!.apply { + headers["cwa-fake"] shouldBe "0" + headers["cwa-header-padding"] shouldBe "testPadding" + path shouldBe "/version/v1/testresult" + body.readUtf8() shouldBe """ + { + "registrationToken": "testRegistrationToken", + "requestPadding": "testRequestPadding" + } + """.toComparableJson() + } + } + + @Test + fun `test getTAN`(): Unit = runBlocking { + val api = createAPI() + + """ + { + "tan": "testTan" + } + """.toJsonResponse().apply { webServer.enqueue(this) } + + val requestBody = VerificationApiV1.TanRequestBody( + registrationToken = "testRegistrationToken", + requestPadding = "testRequestPadding" + ) + + api.getTAN( + fake = "0", + headerPadding = "testPadding", + requestBody + ) shouldBe VerificationApiV1.TanResponse( + tan = "testTan" + ) + + webServer.takeRequest(5, TimeUnit.SECONDS)!!.apply { + headers["cwa-fake"] shouldBe "0" + headers["cwa-header-padding"] shouldBe "testPadding" + path shouldBe "/version/v1/tan" + body.readUtf8() shouldBe """ + { + "registrationToken": "testRegistrationToken", + "requestPadding": "testRequestPadding" + } + """.toComparableJson() + } + } +} diff --git a/Corona-Warn-App/src/test/java/de/rki/coronawarnapp/verification/server/VerificationServerTest.kt b/Corona-Warn-App/src/test/java/de/rki/coronawarnapp/verification/server/VerificationServerTest.kt new file mode 100644 index 0000000000000000000000000000000000000000..463cb73d8daf556e681efa5fa61c09f099995727 --- /dev/null +++ b/Corona-Warn-App/src/test/java/de/rki/coronawarnapp/verification/server/VerificationServerTest.kt @@ -0,0 +1,215 @@ +package de.rki.coronawarnapp.verification.server + +import android.content.Context +import de.rki.coronawarnapp.http.HttpModule +import de.rki.coronawarnapp.util.headerSizeIgnoringContentLength +import de.rki.coronawarnapp.verification.VerificationModule +import io.kotest.matchers.shouldBe +import io.mockk.MockKAnnotations +import io.mockk.clearAllMocks +import io.mockk.coEvery +import io.mockk.coVerify +import io.mockk.every +import io.mockk.impl.annotations.MockK +import kotlinx.coroutines.runBlocking +import okhttp3.ConnectionSpec +import okhttp3.mockwebserver.MockResponse +import okhttp3.mockwebserver.MockWebServer +import org.junit.jupiter.api.AfterEach +import org.junit.jupiter.api.BeforeEach +import org.junit.jupiter.api.Test +import testhelpers.BaseIOTest +import java.io.File + +class VerificationServerTest : BaseIOTest() { + + @MockK lateinit var verificationApi: VerificationApiV1 + @MockK private lateinit var context: Context + + private lateinit var webServer: MockWebServer + private lateinit var serverAddress: String + + private val testDir = File(IO_TEST_BASEDIR, this::class.java.simpleName) + private val cacheDir = File(testDir, "cache") + private val httpCacheDir = File(cacheDir, "http_verification") + + @BeforeEach + fun setup() { + MockKAnnotations.init(this) + testDir.mkdirs() + testDir.exists() shouldBe true + every { context.cacheDir } returns cacheDir + + webServer = MockWebServer() + webServer.start() + serverAddress = "http://${webServer.hostName}:${webServer.port}" + } + + @AfterEach + fun teardown() { + clearAllMocks() + webServer.shutdown() + testDir.deleteRecursively() + } + + private fun createServer( + customApi: VerificationApiV1 = verificationApi + ) = VerificationServer(verificationAPI = { customApi }) + + @Test + fun `get registration token via GUID`(): Unit = runBlocking { + val server = createServer() + coEvery { verificationApi.getRegistrationToken(any(), any(), any()) } answers { + arg<String>(0) shouldBe "0" + arg<String>(1) shouldBe "" + arg<VerificationApiV1.RegistrationTokenRequest>(2).apply { + keyType shouldBe VerificationKeyType.GUID.name + key shouldBe "15291f67d99ea7bc578c3544dadfbb991e66fa69cb36ff70fe30e798e111ff5f" + requestPadding!!.length shouldBe 139 + } + VerificationApiV1.RegistrationTokenResponse( + registrationToken = "testRegistrationToken" + ) + } + + server.retrieveRegistrationToken( + "testKey", VerificationKeyType.GUID + ) shouldBe "testRegistrationToken" + + coVerify { verificationApi.getRegistrationToken(any(), any(), any()) } + } + + @Test + fun `get registration token via TELETAN`() = runBlocking { + val server = createServer() + coEvery { verificationApi.getRegistrationToken(any(), any(), any()) } answers { + arg<String>(0) shouldBe "0" + arg<String>(1) shouldBe "" + arg<VerificationApiV1.RegistrationTokenRequest>(2).apply { + keyType shouldBe VerificationKeyType.TELETAN.name + key shouldBe "testKey" + requestPadding!!.length shouldBe 190 + } + VerificationApiV1.RegistrationTokenResponse( + registrationToken = "testRegistrationToken" + ) + } + + server.retrieveRegistrationToken( + "testKey", VerificationKeyType.TELETAN + ) shouldBe "testRegistrationToken" + + coVerify { verificationApi.getRegistrationToken(any(), any(), any()) } + } + + @Test + fun `get test result`(): Unit = runBlocking { + val server = createServer() + coEvery { verificationApi.getTestResult(any(), any(), any()) } answers { + arg<String>(0) shouldBe "0" + arg<String>(1).length shouldBe 7 // Header-padding + arg<VerificationApiV1.RegistrationRequest>(2).apply { + registrationToken shouldBe "testRegistrationToken" + requestPadding!!.length shouldBe 170 + } + VerificationApiV1.TestResultResponse(testResult = 2) + } + + server.retrieveTestResults("testRegistrationToken") shouldBe 2 + + coVerify { verificationApi.getTestResult(any(), any(), any()) } + } + + @Test + fun `get TAN`(): Unit = runBlocking { + val server = createServer() + coEvery { verificationApi.getTAN(any(), any(), any()) } answers { + arg<String>(0) shouldBe "0" + arg<String>(1).length shouldBe 14 // Header-padding + arg<VerificationApiV1.TanRequestBody>(2).apply { + registrationToken shouldBe "testRegistrationToken" + requestPadding!!.length shouldBe 170 + } + VerificationApiV1.TanResponse(tan = "testTan") + } + + server.retrieveTan("testRegistrationToken") shouldBe "testTan" + + coVerify { verificationApi.getTAN(any(), any(), any()) } + } + + @Test + fun `get TAN with fake data`(): Unit = runBlocking { + val server = createServer() + coEvery { verificationApi.getTAN(any(), any(), any()) } answers { + arg<String>(0) shouldBe "1" + arg<String>(1).length shouldBe 14 // Header-padding + arg<VerificationApiV1.TanRequestBody>(2).apply { + registrationToken shouldBe "11111111-2222-4444-8888-161616161616" + requestPadding!!.length shouldBe 170 + } + VerificationApiV1.TanResponse(tan = "testTan") + } + + server.retrieveTanFake() shouldBe VerificationApiV1.TanResponse(tan = "testTan") + + coVerify { verificationApi.getTAN(any(), any(), any()) } + } + + private fun createRealApi(): VerificationApiV1 { + val httpModule = HttpModule() + val defaultHttpClient = httpModule.defaultHttpClient() + val gsonConverterFactory = httpModule.provideGSONConverter() + + return VerificationModule().let { + val downloadHttpClient = it.cdnHttpClient( + defaultHttpClient, + listOf(ConnectionSpec.CLEARTEXT, ConnectionSpec.MODERN_TLS) + ) + it.provideVerificationApi( + context = context, + client = downloadHttpClient, + url = serverAddress, + gsonConverterFactory = gsonConverterFactory + ) + } + } + + @Test + fun `all requests have the same footprint for pleasible deniability`(): Unit = runBlocking { + val guidExample = "3BF1D4-1C6003DD-733D-41F1-9F30-F85FA7406BF7" + val teletanExample = "9A3B578UMG" + val registrationTokenExample = "63b4d3ff-e0de-4bd4-90c1-17c2bb683a2f" + + val api = createServer(createRealApi()) + webServer.enqueue(MockResponse().setBody("{}")) + api.retrieveRegistrationToken(guidExample, VerificationKeyType.GUID) + + webServer.enqueue(MockResponse().setBody("{}")) + api.retrieveRegistrationToken(teletanExample, VerificationKeyType.TELETAN) + + webServer.enqueue(MockResponse().setBody("{}")) + api.retrieveTestResults(registrationTokenExample) + + webServer.enqueue(MockResponse().setBody("{}")) + api.retrieveTan(registrationTokenExample) + + webServer.enqueue(MockResponse().setBody("{}")) + api.retrieveTanFake() + + val requests = listOf( + webServer.takeRequest(), + webServer.takeRequest(), + webServer.takeRequest(), + webServer.takeRequest(), + webServer.takeRequest() + ) + + // ensure all request have same size (header & body) + requests.forEach { it.bodySize shouldBe 250L } + + requests.zipWithNext().forEach { (a, b) -> + a.headerSizeIgnoringContentLength() shouldBe b.headerSizeIgnoringContentLength() + } + } +} diff --git a/Corona-Warn-App/src/test/java/testhelpers/exceptions/TestException.kt b/Corona-Warn-App/src/test/java/testhelpers/exceptions/TestException.kt new file mode 100644 index 0000000000000000000000000000000000000000..5b88672366ae8e2b0e4eba1e4e11eae654c52bbf --- /dev/null +++ b/Corona-Warn-App/src/test/java/testhelpers/exceptions/TestException.kt @@ -0,0 +1,5 @@ +package testhelpers.exceptions + +import java.util.UUID + +class TestException(private val uuid: UUID = UUID.randomUUID()) : Exception() diff --git a/Corona-Warn-App/src/test/java/testhelpers/extensions/JsonExtensions.kt b/Corona-Warn-App/src/test/java/testhelpers/extensions/JsonExtensions.kt new file mode 100644 index 0000000000000000000000000000000000000000..bc1304959208fcebe18ef38a3c8fb7ebfaafd631 --- /dev/null +++ b/Corona-Warn-App/src/test/java/testhelpers/extensions/JsonExtensions.kt @@ -0,0 +1,13 @@ +package testhelpers.extensions + +import com.google.gson.Gson +import com.google.gson.JsonObject +import okhttp3.mockwebserver.MockResponse + +fun String.toComparableJson() = try { + Gson().fromJson(this, JsonObject::class.java).toString() +} catch (e: Exception) { + throw IllegalArgumentException("'$this' wasn't valid JSON") +} + +fun String.toJsonResponse(): MockResponse = MockResponse().setBody(this.toComparableJson()) diff --git a/Server-Protocol-Buffer/src/main/proto/keyExportFormat.proto b/Server-Protocol-Buffer/src/main/proto/keyExportFormat.proto index 3ef93b1da8622faedce917cb40d40e41b1f2d612..786af90d06fe4224064f16157ac5fc9067a87f76 100644 --- a/Server-Protocol-Buffer/src/main/proto/keyExportFormat.proto +++ b/Server-Protocol-Buffer/src/main/proto/keyExportFormat.proto @@ -1,4 +1,6 @@ syntax = "proto2"; +package de.rki.coronawarnapp.server.protocols; + message TemporaryExposureKeyExport { // Time window of keys in this batch based on arrival to server, in UTC seconds optional fixed64 start_timestamp = 1; @@ -30,6 +32,9 @@ message SignatureInfo { message SubmissionPayload { repeated TemporaryExposureKey keys = 1; optional bytes padding = 2; + repeated string visitedCountries = 3; + optional string origin = 4; + optional bool consentToFederation = 5; } message TemporaryExposureKey {