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

Personal data is only valid if all is available, or nothing. (#2916)


Co-authored-by: default avatarI502720 <axel.herbstreith@sap.com>
parent 9050510f
No related branches found
No related tags found
No related merge requests found
...@@ -19,7 +19,10 @@ class RapidAntigenQrCodeExtractor @Inject constructor() : QrCodeExtractor<Corona ...@@ -19,7 +19,10 @@ class RapidAntigenQrCodeExtractor @Inject constructor() : QrCodeExtractor<Corona
override fun extract(rawString: String): CoronaTestQRCode.RapidAntigen { override fun extract(rawString: String): CoronaTestQRCode.RapidAntigen {
Timber.v("extract(rawString=%s)", rawString) Timber.v("extract(rawString=%s)", rawString)
val payload = extractData(rawString) val payload = CleanPayload(extractData(rawString))
payload.requireValidPersonalData()
return CoronaTestQRCode.RapidAntigen( return CoronaTestQRCode.RapidAntigen(
hash = payload.hash, hash = payload.hash,
createdAt = payload.createdAt, createdAt = payload.createdAt,
...@@ -29,14 +32,14 @@ class RapidAntigenQrCodeExtractor @Inject constructor() : QrCodeExtractor<Corona ...@@ -29,14 +32,14 @@ class RapidAntigenQrCodeExtractor @Inject constructor() : QrCodeExtractor<Corona
) )
} }
private fun extractData(rawString: String): Payload { private fun extractData(rawString: String): RawPayload {
return rawString return rawString
.removePrefix(PREFIX1) .removePrefix(PREFIX1)
.removePrefix(PREFIX2) .removePrefix(PREFIX2)
.decode() .decode()
} }
private fun String.decode(): Payload { private fun String.decode(): RawPayload {
val decoded = if ( val decoded = if (
this.contains("+") || this.contains("+") ||
this.contains("/") || this.contains("/") ||
...@@ -49,54 +52,56 @@ class RapidAntigenQrCodeExtractor @Inject constructor() : QrCodeExtractor<Corona ...@@ -49,54 +52,56 @@ class RapidAntigenQrCodeExtractor @Inject constructor() : QrCodeExtractor<Corona
return Gson().fromJson(decoded.commonToUtf8String()) return Gson().fromJson(decoded.commonToUtf8String())
} }
private data class Payload( private data class RawPayload(
@SerializedName("hash") @SerializedName("hash") val hash: String?,
val rawHash: String?, @SerializedName("timestamp") val timestamp: Long?,
@SerializedName("timestamp") @SerializedName("fn") val firstName: String?,
val rawTimestamp: Long?, @SerializedName("ln") val lastName: String?,
@SerializedName("fn") @SerializedName("dob") val dateOfBirth: String?
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
}
val createdAt: Instant private data class CleanPayload(val raw: RawPayload) {
get() {
if (rawTimestamp == null || rawTimestamp <= 0) throw InvalidQRCodeException("Timestamp is invalid")
return Instant.ofEpochSecond(rawTimestamp)
}
val firstName: String? val hash: String by lazy {
get() { if (raw.hash == null || !raw.hash.isSha256Hash()) throw InvalidQRCodeException("Hash is invalid")
if (rawFirstName.isNullOrEmpty()) return null raw.hash
return rawFirstName }
}
val lastName: String? val createdAt: Instant by lazy {
get() { if (raw.timestamp == null || raw.timestamp <= 0) throw InvalidQRCodeException("Timestamp is invalid")
if (rawLastName.isNullOrEmpty()) return null Instant.ofEpochSecond(raw.timestamp)
return rawLastName }
}
val dateOfBirth: LocalDate? val firstName: String? by lazy {
get() { if (raw.firstName.isNullOrEmpty()) null else raw.firstName
if (rawDateOfBirth.isNullOrEmpty()) return null }
return try {
LocalDate.parse(rawDateOfBirth) val lastName: String? by lazy {
} catch (e: Exception) { if (raw.lastName.isNullOrEmpty()) null else raw.lastName
Timber.e("Invalid date format") }
throw InvalidQRCodeException(
"Date of birth has wrong format: $rawDateOfBirth. It should be YYYY-MM-DD" 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 { companion object {
......
package de.rki.coronawarnapp.coronatest.qrcode package de.rki.coronawarnapp.coronatest.qrcode
import de.rki.coronawarnapp.coronatest.type.CoronaTest import de.rki.coronawarnapp.coronatest.type.CoronaTest
import io.kotest.assertions.throwables.shouldThrow
import io.kotest.matchers.shouldBe import io.kotest.matchers.shouldBe
import org.joda.time.Instant import org.joda.time.Instant
import org.joda.time.LocalDate import org.joda.time.LocalDate
...@@ -53,4 +54,9 @@ class RapidAntigenQrCodeExtractorTest : BaseTest() { ...@@ -53,4 +54,9 @@ class RapidAntigenQrCodeExtractorTest : BaseTest() {
data.lastName shouldBe null data.lastName shouldBe null
data.firstName shouldBe null data.firstName shouldBe null
} }
@Test
fun `personal data is only valid if complete or completely missing`() {
shouldThrow<InvalidQRCodeException> { instance.extract(raQrIncompletePersonalData) }
}
} }
...@@ -32,3 +32,15 @@ internal val raQrCode8 = ...@@ -32,3 +32,15 @@ internal val raQrCode8 =
// } // }
internal val raQrCodeEmptyStrings = internal val raQrCodeEmptyStrings =
"https://s.coronawarn.app?v=1#ewogICAgImZuIjoiIiwKICAgICJsbiI6IiIsCiAgICAiZG9iIjoiIiwKICAgICJ0ZXN0aWQiOiIwZTYwMGI0Mi1jYzIxLTRlNzUtOTE0Yy03MDYwMzE0M2I2Y2IiLAogICAgInRpbWVzdGFtcCI6IDE2MTkwMTI5NTIsCiAgICAic2FsdCI6IjUxMGYzZDc1MGMyZmM2MzFmYmNmZTMyY2IwNmJiYmE5IiwKICAgICJoYXNoIjoiZDZlNGQwMTgxZDgxMDliZjA1YjM0NmEwZDJlMGVmMGNjNDcyZWVkNzBkOWRmOGM0YjlhZTVjN2EwMDlmM2UzNCIKfQ" "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"
0% Loading or .
You are about to add 0 people to the discussion. Proceed with caution.
Finish editing this message first!
Please register or to comment