diff --git a/Corona-Warn-App/src/main/java/de/rki/coronawarnapp/coronatest/qrcode/RapidAntigenQrCodeExtractor.kt b/Corona-Warn-App/src/main/java/de/rki/coronawarnapp/coronatest/qrcode/RapidAntigenQrCodeExtractor.kt index 310cf7dc2126ffed32f375229897b36521c539d8..e05c6a6da773cb91a7df92ed8f0e298250054efd 100644 --- a/Corona-Warn-App/src/main/java/de/rki/coronawarnapp/coronatest/qrcode/RapidAntigenQrCodeExtractor.kt +++ b/Corona-Warn-App/src/main/java/de/rki/coronawarnapp/coronatest/qrcode/RapidAntigenQrCodeExtractor.kt @@ -19,7 +19,10 @@ class RapidAntigenQrCodeExtractor @Inject constructor() : QrCodeExtractor<Corona override fun extract(rawString: String): CoronaTestQRCode.RapidAntigen { Timber.v("extract(rawString=%s)", rawString) - val payload = extractData(rawString) + val payload = CleanPayload(extractData(rawString)) + + payload.requireValidPersonalData() + return CoronaTestQRCode.RapidAntigen( hash = payload.hash, createdAt = payload.createdAt, @@ -29,14 +32,14 @@ class RapidAntigenQrCodeExtractor @Inject constructor() : QrCodeExtractor<Corona ) } - private fun extractData(rawString: String): Payload { + private fun extractData(rawString: String): RawPayload { return rawString .removePrefix(PREFIX1) .removePrefix(PREFIX2) .decode() } - private fun String.decode(): Payload { + private fun String.decode(): RawPayload { val decoded = if ( this.contains("+") || this.contains("/") || @@ -49,54 +52,56 @@ class RapidAntigenQrCodeExtractor @Inject constructor() : QrCodeExtractor<Corona return Gson().fromJson(decoded.commonToUtf8String()) } - private data class Payload( - @SerializedName("hash") - val rawHash: String?, - @SerializedName("timestamp") - val rawTimestamp: Long?, - @SerializedName("fn") - val rawFirstName: String?, - @SerializedName("ln") - val rawLastName: String?, - @SerializedName("dob") - val rawDateOfBirth: String? - ) { - val hash: String - get() { - if (rawHash == null || !rawHash.isSha256Hash()) throw InvalidQRCodeException("Hash is invalid") - return rawHash - } + private data class RawPayload( + @SerializedName("hash") val hash: String?, + @SerializedName("timestamp") val timestamp: Long?, + @SerializedName("fn") val firstName: String?, + @SerializedName("ln") val lastName: String?, + @SerializedName("dob") val dateOfBirth: String? + ) - val createdAt: Instant - get() { - if (rawTimestamp == null || rawTimestamp <= 0) throw InvalidQRCodeException("Timestamp is invalid") - return Instant.ofEpochSecond(rawTimestamp) - } + private data class CleanPayload(val raw: RawPayload) { - val firstName: String? - get() { - if (rawFirstName.isNullOrEmpty()) return null - return rawFirstName - } + val hash: String by lazy { + if (raw.hash == null || !raw.hash.isSha256Hash()) throw InvalidQRCodeException("Hash is invalid") + raw.hash + } - val lastName: String? - get() { - if (rawLastName.isNullOrEmpty()) return null - return rawLastName - } + val createdAt: Instant by lazy { + if (raw.timestamp == null || raw.timestamp <= 0) throw InvalidQRCodeException("Timestamp is invalid") + Instant.ofEpochSecond(raw.timestamp) + } - val dateOfBirth: LocalDate? - get() { - if (rawDateOfBirth.isNullOrEmpty()) return null - return try { - LocalDate.parse(rawDateOfBirth) - } catch (e: Exception) { - Timber.e("Invalid date format") - throw InvalidQRCodeException( - "Date of birth has wrong format: $rawDateOfBirth. It should be YYYY-MM-DD" - ) - } + val firstName: String? by lazy { + if (raw.firstName.isNullOrEmpty()) null else raw.firstName + } + + val lastName: String? by lazy { + if (raw.lastName.isNullOrEmpty()) null else raw.lastName + } + + val dateOfBirth: LocalDate? by lazy { + if (raw.dateOfBirth.isNullOrEmpty()) return@lazy null + + try { + LocalDate.parse(raw.dateOfBirth) + } catch (e: Exception) { + Timber.e("Invalid date format") + throw InvalidQRCodeException( + "Date of birth has wrong format: ${raw.dateOfBirth}. It should be YYYY-MM-DD" + ) } + } + + fun requireValidPersonalData() { + val allOrNothing = listOf( + firstName != null, + lastName != null, + dateOfBirth != null, + ) + val complete = allOrNothing.all { it } || allOrNothing.all { !it } + if (!complete) throw InvalidQRCodeException("QRCode contains incomplete personal data: $raw") + } } companion object { diff --git a/Corona-Warn-App/src/test/java/de/rki/coronawarnapp/coronatest/qrcode/RapidAntigenQrCodeExtractorTest.kt b/Corona-Warn-App/src/test/java/de/rki/coronawarnapp/coronatest/qrcode/RapidAntigenQrCodeExtractorTest.kt index b01ffcd1951f42d090d8ffebd378b5a57c7df64a..459fff1d3ceb51ffacb9d2a6d1246e1ffe5aa298 100644 --- a/Corona-Warn-App/src/test/java/de/rki/coronawarnapp/coronatest/qrcode/RapidAntigenQrCodeExtractorTest.kt +++ b/Corona-Warn-App/src/test/java/de/rki/coronawarnapp/coronatest/qrcode/RapidAntigenQrCodeExtractorTest.kt @@ -1,6 +1,7 @@ package de.rki.coronawarnapp.coronatest.qrcode import de.rki.coronawarnapp.coronatest.type.CoronaTest +import io.kotest.assertions.throwables.shouldThrow import io.kotest.matchers.shouldBe import org.joda.time.Instant import org.joda.time.LocalDate @@ -53,4 +54,9 @@ class RapidAntigenQrCodeExtractorTest : BaseTest() { data.lastName shouldBe null data.firstName shouldBe null } + + @Test + fun `personal data is only valid if complete or completely missing`() { + shouldThrow<InvalidQRCodeException> { instance.extract(raQrIncompletePersonalData) } + } } diff --git a/Corona-Warn-App/src/test/java/de/rki/coronawarnapp/coronatest/qrcode/TestQrCodes.kt b/Corona-Warn-App/src/test/java/de/rki/coronawarnapp/coronatest/qrcode/TestQrCodes.kt index b23da88efe7bbadd9970e4a2b5e7ea110aa06641..c439951382bf5a228ab7b2a4f89b3c96611aff52 100644 --- a/Corona-Warn-App/src/test/java/de/rki/coronawarnapp/coronatest/qrcode/TestQrCodes.kt +++ b/Corona-Warn-App/src/test/java/de/rki/coronawarnapp/coronatest/qrcode/TestQrCodes.kt @@ -32,3 +32,15 @@ internal val raQrCode8 = // } internal val raQrCodeEmptyStrings = "https://s.coronawarn.app?v=1#ewogICAgImZuIjoiIiwKICAgICJsbiI6IiIsCiAgICAiZG9iIjoiIiwKICAgICJ0ZXN0aWQiOiIwZTYwMGI0Mi1jYzIxLTRlNzUtOTE0Yy03MDYwMzE0M2I2Y2IiLAogICAgInRpbWVzdGFtcCI6IDE2MTkwMTI5NTIsCiAgICAic2FsdCI6IjUxMGYzZDc1MGMyZmM2MzFmYmNmZTMyY2IwNmJiYmE5IiwKICAgICJoYXNoIjoiZDZlNGQwMTgxZDgxMDliZjA1YjM0NmEwZDJlMGVmMGNjNDcyZWVkNzBkOWRmOGM0YjlhZTVjN2EwMDlmM2UzNCIKfQ" + +// { +// "fn":"Max", +// "ln":"", +// "dob":"", +// "testid":"0e600b42-cc21-4e75-914c-70603143b6cb", +// "timestamp": 1619012952, +// "salt":"510f3d750c2fc631fbcfe32cb06bbba9", +// "hash":"d6e4d0181d8109bf05b346a0d2e0ef0cc472eed70d9df8c4b9ae5c7a009f3e34" +// } +internal val raQrIncompletePersonalData = + "https://s.coronawarn.app?v=1#ewogICAgImZuIjoiTWF4IiwKICAgICJsbiI6IiIsCiAgICAiZG9iIjoiIiwKICAgICJ0ZXN0aWQiOiIwZTYwMGI0Mi1jYzIxLTRlNzUtOTE0Yy03MDYwMzE0M2I2Y2IiLAogICAgInRpbWVzdGFtcCI6IDE2MTkwMTI5NTIsCiAgICAic2FsdCI6IjUxMGYzZDc1MGMyZmM2MzFmYmNmZTMyY2IwNmJiYmE5IiwKICAgICJoYXNoIjoiZDZlNGQwMTgxZDgxMDliZjA1YjM0NmEwZDJlMGVmMGNjNDcyZWVkNzBkOWRmOGM0YjlhZTVjN2EwMDlmM2UzNCIKfQ"