From 575036ccc2aa163ecb88bff3220403b7974761fd Mon Sep 17 00:00:00 2001 From: Chilja Gossow <49635654+chiljamgossow@users.noreply.github.com> Date: Mon, 10 May 2021 15:58:46 +0200 Subject: [PATCH] Vaccination qr code extraction (EXPOSUREAPP-6726) (#3107) * extractor * decoding * decoding, error handling * clean up * klint * detekt * simplify condition * comment failing unit test * unit test * change lib * Unit tests for RA/PCRCoronaTest.isFinal * Add license text. * add header * clean up * merge * comments * klint * revert version change Co-authored-by: Matthias Urhahn <matthias.urhahn@sap.com> --- .reuse/dep5 | 4 + Corona-Warn-App/build.gradle | 3 + .../qrcode/HealthCertificateCOSEDecoder.kt | 32 +++ .../InvalidHealthCertificateException.kt | 18 ++ .../VaccinationCertificateCOSEParser.kt | 64 ++--- .../core/qrcode/VaccinationCertificateData.kt | 6 +- .../qrcode/VaccinationCertificateHeader.kt | 9 + .../qrcode/VaccinationCertificateQRCode.kt | 10 +- .../core/qrcode/VaccinationCertificateV1.kt | 13 +- .../qrcode/VaccinationCertificateV1Parser.kt | 68 +++++ .../core/qrcode/VaccinationQRCodeExtractor.kt | 72 +++++ .../core/qrcode/VaccinationQRCodeValidator.kt | 19 +- .../storage/VaccinationContainer.kt | 16 +- .../server/proof/VaccinationProofServer.kt | 4 +- .../vaccination/decoder/Base45Decoder.kt | 90 ++++++ .../decoder/InvalidInputException.kt | 5 + .../vaccination/decoder/ZLIBDecompressor.kt | 22 ++ .../src/main/res/values/strings.xml | 2 +- .../vaccination/core/VaccinationTestData.kt | 48 +++- .../qrcode/VaccinationQRCodeExtractorTest.kt | 65 +++++ .../qrcode/VaccinationQrCodeTestData.java | 8 + .../storage/VaccinationStorageTest.kt | 258 +++++++++--------- 22 files changed, 635 insertions(+), 201 deletions(-) create mode 100644 Corona-Warn-App/src/main/java/de/rki/coronawarnapp/vaccination/core/qrcode/HealthCertificateCOSEDecoder.kt create mode 100644 Corona-Warn-App/src/main/java/de/rki/coronawarnapp/vaccination/core/qrcode/InvalidHealthCertificateException.kt create mode 100644 Corona-Warn-App/src/main/java/de/rki/coronawarnapp/vaccination/core/qrcode/VaccinationCertificateHeader.kt create mode 100644 Corona-Warn-App/src/main/java/de/rki/coronawarnapp/vaccination/core/qrcode/VaccinationCertificateV1Parser.kt create mode 100644 Corona-Warn-App/src/main/java/de/rki/coronawarnapp/vaccination/core/qrcode/VaccinationQRCodeExtractor.kt create mode 100644 Corona-Warn-App/src/main/java/de/rki/coronawarnapp/vaccination/decoder/Base45Decoder.kt create mode 100644 Corona-Warn-App/src/main/java/de/rki/coronawarnapp/vaccination/decoder/InvalidInputException.kt create mode 100644 Corona-Warn-App/src/main/java/de/rki/coronawarnapp/vaccination/decoder/ZLIBDecompressor.kt create mode 100644 Corona-Warn-App/src/test/java/de/rki/coronawarnapp/vaccination/core/qrcode/VaccinationQRCodeExtractorTest.kt create mode 100644 Corona-Warn-App/src/test/java/de/rki/coronawarnapp/vaccination/core/qrcode/VaccinationQrCodeTestData.java diff --git a/.reuse/dep5 b/.reuse/dep5 index 25db19164..9ce03ea67 100644 --- a/.reuse/dep5 +++ b/.reuse/dep5 @@ -58,4 +58,8 @@ License: Apache-2.0 Files: Corona-Warn-App/src/test/java/testhelpers/extensions/LiveDataTestUtil.kt Copyright: 2019 The Android Open Source Project +License: Apache-2.0 + +Files: Corona-Warn-App/src/main/java/de/rki/coronawarnapp/vaccination/decoder/Base45Decoder.kt +Copyright: Copyright 2021 A-SIT Plus GmbH License: Apache-2.0 \ No newline at end of file diff --git a/Corona-Warn-App/build.gradle b/Corona-Warn-App/build.gradle index ae065c3f0..290504842 100644 --- a/Corona-Warn-App/build.gradle +++ b/Corona-Warn-App/build.gradle @@ -436,4 +436,7 @@ dependencies { // ANIMATIONS implementation "com.airbnb.android:lottie:3.5.0" + + // HCert + implementation("com.upokecenter:cbor:4.4.1") } diff --git a/Corona-Warn-App/src/main/java/de/rki/coronawarnapp/vaccination/core/qrcode/HealthCertificateCOSEDecoder.kt b/Corona-Warn-App/src/main/java/de/rki/coronawarnapp/vaccination/core/qrcode/HealthCertificateCOSEDecoder.kt new file mode 100644 index 000000000..10f7f7878 --- /dev/null +++ b/Corona-Warn-App/src/main/java/de/rki/coronawarnapp/vaccination/core/qrcode/HealthCertificateCOSEDecoder.kt @@ -0,0 +1,32 @@ +package de.rki.coronawarnapp.vaccination.core.qrcode + +import com.upokecenter.cbor.CBORObject +import de.rki.coronawarnapp.vaccination.core.qrcode.InvalidHealthCertificateException.ErrorCode.HC_COSE_MESSAGE_INVALID +import de.rki.coronawarnapp.vaccination.core.qrcode.InvalidHealthCertificateException.ErrorCode.HC_COSE_TAG_INVALID +import timber.log.Timber +import javax.inject.Inject + +class HealthCertificateCOSEDecoder @Inject constructor() { + fun decode(input: RawCOSEObject): CBORObject { + return try { + val messageObject = CBORObject.DecodeFromBytes(input).validate() + val content = messageObject[2].GetByteString() + CBORObject.DecodeFromBytes(content) + } catch (e: InvalidHealthCertificateException) { + throw e + } catch (e: Throwable) { + Timber.e(e) + throw InvalidHealthCertificateException(HC_COSE_MESSAGE_INVALID) + } + } + + private fun CBORObject.validate(): CBORObject { + if (size() != 4) { + throw InvalidHealthCertificateException(HC_COSE_MESSAGE_INVALID) + } + if (!HasTag(18)) { + throw InvalidHealthCertificateException(HC_COSE_TAG_INVALID) + } + return this + } +} diff --git a/Corona-Warn-App/src/main/java/de/rki/coronawarnapp/vaccination/core/qrcode/InvalidHealthCertificateException.kt b/Corona-Warn-App/src/main/java/de/rki/coronawarnapp/vaccination/core/qrcode/InvalidHealthCertificateException.kt new file mode 100644 index 000000000..c3cb9a33a --- /dev/null +++ b/Corona-Warn-App/src/main/java/de/rki/coronawarnapp/vaccination/core/qrcode/InvalidHealthCertificateException.kt @@ -0,0 +1,18 @@ +package de.rki.coronawarnapp.vaccination.core.qrcode + +import de.rki.coronawarnapp.coronatest.qrcode.InvalidQRCodeException + +class InvalidHealthCertificateException( + val errorCode: ErrorCode +) : InvalidQRCodeException(errorCode.message) { + enum class ErrorCode( + val message: String + ) { + HC_BASE45_DECODING_FAILED("Base45 decoding failed."), + HC_ZLIB_DECOMPRESSION_FAILED("Zlib decompression failed."), + HC_COSE_TAG_INVALID("COSE tag invalid."), + HC_COSE_MESSAGE_INVALID("COSE message invalid."), + HC_CBOR_DECODING_FAILED("CBOR decoding failed."), + VC_NO_VACCINATION_ENTRY("Vaccination certificate missing.") + } +} diff --git a/Corona-Warn-App/src/main/java/de/rki/coronawarnapp/vaccination/core/qrcode/VaccinationCertificateCOSEParser.kt b/Corona-Warn-App/src/main/java/de/rki/coronawarnapp/vaccination/core/qrcode/VaccinationCertificateCOSEParser.kt index a88454e14..b2dd023f4 100644 --- a/Corona-Warn-App/src/main/java/de/rki/coronawarnapp/vaccination/core/qrcode/VaccinationCertificateCOSEParser.kt +++ b/Corona-Warn-App/src/main/java/de/rki/coronawarnapp/vaccination/core/qrcode/VaccinationCertificateCOSEParser.kt @@ -1,39 +1,39 @@ package de.rki.coronawarnapp.vaccination.core.qrcode -import okio.ByteString -import org.joda.time.LocalDate +import com.upokecenter.cbor.CBORObject +import de.rki.coronawarnapp.vaccination.core.qrcode.InvalidHealthCertificateException.ErrorCode.HC_CBOR_DECODING_FAILED +import de.rki.coronawarnapp.vaccination.core.qrcode.InvalidHealthCertificateException.ErrorCode.HC_COSE_MESSAGE_INVALID +import timber.log.Timber +import javax.inject.Inject -class VaccinationCertificateCOSEParser { +class VaccinationCertificateCOSEParser @Inject constructor( + private val healthCertificateCOSEDecoder: HealthCertificateCOSEDecoder, + private val vaccinationCertificateV1Parser: VaccinationCertificateV1Parser, +) { - fun parse(vaccinationCOSE: ByteString): VaccinationCertificateData { - // TODO - val cert = VaccinationCertificateV1( - version = "1.0.0", - nameData = VaccinationCertificateV1.NameData( - givenName = "François-Joan", - givenNameStandardized = "FRANCOIS<JOAN", - familyName = "d'Arsøns - van Halen", - familyNameStandardized = "DARSONS<VAN<HALEN", - ), - dateOfBirth = LocalDate.parse("2009-02-28"), - vaccinationDatas = listOf( - VaccinationCertificateV1.VaccinationData( - targetId = "840539006", - vaccineId = "1119349007", - medicalProductId = "EU/1/20/1528", - marketAuthorizationHolderId = "ORG-100030215", - doseNumber = 1, - totalSeriesOfDoses = 2, - vaccinatedAt = LocalDate.parse("2021-04-21"), - countryOfVaccination = "NL", - certificateIssuer = "Ministry of Public Health, Welfare and Sport", - uniqueCertificateIdentifier = "urn:uvci:01:NL:PlA8UWS60Z4RZXVALl6GAZ", - ) - ), - ) + fun parse(rawCOSEObject: RawCOSEObject): VaccinationCertificateData { + return rawCOSEObject + .decodeCOSEObject() + .decodeCBORObject() + } + + private fun RawCOSEObject.decodeCOSEObject(): CBORObject { + return try { + healthCertificateCOSEDecoder.decode(this) + } catch (e: InvalidHealthCertificateException) { + throw e + } catch (e: Exception) { + Timber.e(e) + throw InvalidHealthCertificateException(HC_COSE_MESSAGE_INVALID) + } + } - return VaccinationCertificateData( - vaccinationCertificate = cert - ) + private fun CBORObject.decodeCBORObject(): VaccinationCertificateData { + return try { + vaccinationCertificateV1Parser.decode(this) + } catch (e: Exception) { + Timber.e(e) + throw InvalidHealthCertificateException(HC_CBOR_DECODING_FAILED) + } } } diff --git a/Corona-Warn-App/src/main/java/de/rki/coronawarnapp/vaccination/core/qrcode/VaccinationCertificateData.kt b/Corona-Warn-App/src/main/java/de/rki/coronawarnapp/vaccination/core/qrcode/VaccinationCertificateData.kt index b22ec8052..a21dfacfb 100644 --- a/Corona-Warn-App/src/main/java/de/rki/coronawarnapp/vaccination/core/qrcode/VaccinationCertificateData.kt +++ b/Corona-Warn-App/src/main/java/de/rki/coronawarnapp/vaccination/core/qrcode/VaccinationCertificateData.kt @@ -3,7 +3,7 @@ package de.rki.coronawarnapp.vaccination.core.qrcode /** * Represents the information gained from data in COSE representation */ -data class VaccinationCertificateData constructor( - // Parsed json - val vaccinationCertificate: VaccinationCertificateV1 +data class VaccinationCertificateData( + val header: VaccinationCertificateHeader, + val vaccinationCertificate: VaccinationCertificateV1, ) diff --git a/Corona-Warn-App/src/main/java/de/rki/coronawarnapp/vaccination/core/qrcode/VaccinationCertificateHeader.kt b/Corona-Warn-App/src/main/java/de/rki/coronawarnapp/vaccination/core/qrcode/VaccinationCertificateHeader.kt new file mode 100644 index 000000000..8accdae8f --- /dev/null +++ b/Corona-Warn-App/src/main/java/de/rki/coronawarnapp/vaccination/core/qrcode/VaccinationCertificateHeader.kt @@ -0,0 +1,9 @@ +package de.rki.coronawarnapp.vaccination.core.qrcode + +import org.joda.time.Instant + +data class VaccinationCertificateHeader( + val issuer: String, + val issuedAt: Instant, + val expiresAt: Instant +) diff --git a/Corona-Warn-App/src/main/java/de/rki/coronawarnapp/vaccination/core/qrcode/VaccinationCertificateQRCode.kt b/Corona-Warn-App/src/main/java/de/rki/coronawarnapp/vaccination/core/qrcode/VaccinationCertificateQRCode.kt index 89a0837d3..e9efb122e 100644 --- a/Corona-Warn-App/src/main/java/de/rki/coronawarnapp/vaccination/core/qrcode/VaccinationCertificateQRCode.kt +++ b/Corona-Warn-App/src/main/java/de/rki/coronawarnapp/vaccination/core/qrcode/VaccinationCertificateQRCode.kt @@ -1,13 +1,13 @@ package de.rki.coronawarnapp.vaccination.core.qrcode -import okio.ByteString - -// TODO data class VaccinationCertificateQRCode( val parsedData: VaccinationCertificateData, - // COSE representation of the vaccination certificate (as byte sequence) - val certificateCOSE: ByteString, + val certificateCOSE: RawCOSEObject, ) { val uniqueCertificateIdentifier: String get() = parsedData.vaccinationCertificate.vaccinationDatas.single().uniqueCertificateIdentifier } + +typealias RawCOSEObject = ByteArray + +val EmptyRawCOSEObject = ByteArray(0) diff --git a/Corona-Warn-App/src/main/java/de/rki/coronawarnapp/vaccination/core/qrcode/VaccinationCertificateV1.kt b/Corona-Warn-App/src/main/java/de/rki/coronawarnapp/vaccination/core/qrcode/VaccinationCertificateV1.kt index 777fe822e..f4cad67e3 100644 --- a/Corona-Warn-App/src/main/java/de/rki/coronawarnapp/vaccination/core/qrcode/VaccinationCertificateV1.kt +++ b/Corona-Warn-App/src/main/java/de/rki/coronawarnapp/vaccination/core/qrcode/VaccinationCertificateV1.kt @@ -6,10 +6,9 @@ import org.joda.time.LocalDate data class VaccinationCertificateV1( @SerializedName("ver") val version: String, @SerializedName("nam") val nameData: NameData, - @SerializedName("dob") val dateOfBirth: LocalDate, + @SerializedName("dob") val dob: String, @SerializedName("v") val vaccinationDatas: List<VaccinationData>, ) { - data class NameData( @SerializedName("fn") val familyName: String?, @SerializedName("fnt") val familyNameStandardized: String, @@ -31,12 +30,18 @@ data class VaccinationCertificateV1( // Total Series of Doses, e.g. "sd": 2, @SerializedName("sd") val totalSeriesOfDoses: Int, // Date of Vaccination, e.g. "dt" : "2021-04-21" - @SerializedName("dt") val vaccinatedAt: LocalDate, + @SerializedName("dt") val dt: String, // Country of Vaccination, e.g. "co": "NL" @SerializedName("co") val countryOfVaccination: String, // Certificate Issuer, e.g. "is": "Ministry of Public Health, Welfare and Sport", @SerializedName("is") val certificateIssuer: String, // Unique Certificate Identifier, e.g. "ci": "urn:uvci:01:NL:PlA8UWS60Z4RZXVALl6GAZ" @SerializedName("ci") val uniqueCertificateIdentifier: String - ) + ) { + val vaccinatedAt: LocalDate + get() = LocalDate.parse(dt) + } + + val dateOfBirth: LocalDate + get() = LocalDate.parse(dob) } diff --git a/Corona-Warn-App/src/main/java/de/rki/coronawarnapp/vaccination/core/qrcode/VaccinationCertificateV1Parser.kt b/Corona-Warn-App/src/main/java/de/rki/coronawarnapp/vaccination/core/qrcode/VaccinationCertificateV1Parser.kt new file mode 100644 index 000000000..7e73d4bc1 --- /dev/null +++ b/Corona-Warn-App/src/main/java/de/rki/coronawarnapp/vaccination/core/qrcode/VaccinationCertificateV1Parser.kt @@ -0,0 +1,68 @@ +package de.rki.coronawarnapp.vaccination.core.qrcode + +import com.google.gson.Gson +import com.upokecenter.cbor.CBORObject +import de.rki.coronawarnapp.util.serialization.fromJson +import de.rki.coronawarnapp.vaccination.core.qrcode.InvalidHealthCertificateException.ErrorCode.HC_CBOR_DECODING_FAILED +import de.rki.coronawarnapp.vaccination.core.qrcode.InvalidHealthCertificateException.ErrorCode.VC_NO_VACCINATION_ENTRY +import org.joda.time.Instant +import javax.inject.Inject + +class VaccinationCertificateV1Parser @Inject constructor() { + + companion object { + private val keyEuDgcV1 = CBORObject.FromObject(1) + private val keyHCert = CBORObject.FromObject(-260) + private val keyIssuer = CBORObject.FromObject(1) + private val keyExpiresAt = CBORObject.FromObject(4) + private val keyIssuedAt = CBORObject.FromObject(6) + } + + fun decode(map: CBORObject): VaccinationCertificateData { + try { + var issuer: String? = null + map[keyIssuer]?.let { + issuer = it.AsString() + } + var issuedAt: Instant? = null + map[keyIssuedAt]?.let { + issuedAt = Instant.ofEpochSecond(it.AsNumber().ToInt64Checked()) + } + var expiresAt: Instant? = null + map[keyExpiresAt]?.let { + expiresAt = Instant.ofEpochSecond(it.AsNumber().ToInt64Checked()) + } + var certificate: VaccinationCertificateV1? = null + map[keyHCert]?.let { hcert -> + hcert[keyEuDgcV1]?.let { + val json = it.ToJSONString() + certificate = Gson().fromJson<VaccinationCertificateV1>(json) + } + } + val header = VaccinationCertificateHeader( + issuer = issuer!!, + issuedAt = issuedAt!!, + expiresAt = expiresAt!! + ) + return VaccinationCertificateData( + header, + certificate!!.validate() + ) + } catch (e: InvalidHealthCertificateException) { + throw e + } catch (e: Throwable) { + throw InvalidHealthCertificateException(HC_CBOR_DECODING_FAILED) + } + } + + private fun VaccinationCertificateV1.validate(): VaccinationCertificateV1 { + if (vaccinationDatas.isEmpty()) { + throw InvalidHealthCertificateException(VC_NO_VACCINATION_ENTRY) + } + dateOfBirth + vaccinationDatas.forEach { + it.vaccinatedAt + } + return this + } +} diff --git a/Corona-Warn-App/src/main/java/de/rki/coronawarnapp/vaccination/core/qrcode/VaccinationQRCodeExtractor.kt b/Corona-Warn-App/src/main/java/de/rki/coronawarnapp/vaccination/core/qrcode/VaccinationQRCodeExtractor.kt new file mode 100644 index 000000000..3c4ac6d49 --- /dev/null +++ b/Corona-Warn-App/src/main/java/de/rki/coronawarnapp/vaccination/core/qrcode/VaccinationQRCodeExtractor.kt @@ -0,0 +1,72 @@ +package de.rki.coronawarnapp.vaccination.core.qrcode + +import com.upokecenter.cbor.CBORObject +import de.rki.coronawarnapp.coronatest.qrcode.QrCodeExtractor +import de.rki.coronawarnapp.vaccination.core.qrcode.InvalidHealthCertificateException.ErrorCode.HC_BASE45_DECODING_FAILED +import de.rki.coronawarnapp.vaccination.core.qrcode.InvalidHealthCertificateException.ErrorCode.HC_CBOR_DECODING_FAILED +import de.rki.coronawarnapp.vaccination.core.qrcode.InvalidHealthCertificateException.ErrorCode.HC_COSE_MESSAGE_INVALID +import de.rki.coronawarnapp.vaccination.core.qrcode.InvalidHealthCertificateException.ErrorCode.HC_ZLIB_DECOMPRESSION_FAILED +import de.rki.coronawarnapp.vaccination.decoder.Base45Decoder +import de.rki.coronawarnapp.vaccination.decoder.ZLIBDecompressor +import timber.log.Timber +import javax.inject.Inject + +class VaccinationQRCodeExtractor @Inject constructor( + private val base45Decoder: Base45Decoder, + private val zLIBDecompressor: ZLIBDecompressor, + private val healthCertificateCOSEDecoder: HealthCertificateCOSEDecoder, + private val vaccinationCertificateV1Parser: VaccinationCertificateV1Parser, +) : QrCodeExtractor<VaccinationCertificateQRCode> { + + private val prefix = "HC1:" + + override fun canHandle(rawString: String): Boolean { + return rawString.startsWith(prefix) + } + + override fun extract(rawString: String): VaccinationCertificateQRCode { + val rawCOSEObject = rawString + .removePrefix(prefix) + .decodeBase45() + .decompress() + val certificate = rawCOSEObject + .decodeCOSEObject() + .decodeCBORObject() + return VaccinationCertificateQRCode( + parsedData = certificate, + certificateCOSE = rawCOSEObject, + ) + } + + private fun String.decodeBase45(): ByteArray = try { + base45Decoder.decode(this) + } catch (e: Exception) { + Timber.e(e) + throw InvalidHealthCertificateException(HC_BASE45_DECODING_FAILED) + } + + private fun ByteArray.decompress(): ByteArray = try { + zLIBDecompressor.decode(this) + } catch (e: Exception) { + Timber.e(e) + throw InvalidHealthCertificateException(HC_ZLIB_DECOMPRESSION_FAILED) + } + + private fun RawCOSEObject.decodeCOSEObject(): CBORObject = try { + healthCertificateCOSEDecoder.decode(this) + } catch (e: InvalidHealthCertificateException) { + throw e + } catch (e: Exception) { + Timber.e(e) + throw InvalidHealthCertificateException(HC_COSE_MESSAGE_INVALID) + } + + private fun CBORObject.decodeCBORObject(): VaccinationCertificateData = try { + vaccinationCertificateV1Parser.decode(this) + } catch (e: InvalidHealthCertificateException) { + throw e + } catch (e: Exception) { + Timber.e(e) + throw InvalidHealthCertificateException(HC_CBOR_DECODING_FAILED) + } +} diff --git a/Corona-Warn-App/src/main/java/de/rki/coronawarnapp/vaccination/core/qrcode/VaccinationQRCodeValidator.kt b/Corona-Warn-App/src/main/java/de/rki/coronawarnapp/vaccination/core/qrcode/VaccinationQRCodeValidator.kt index e3767500c..cf074f8c4 100644 --- a/Corona-Warn-App/src/main/java/de/rki/coronawarnapp/vaccination/core/qrcode/VaccinationQRCodeValidator.kt +++ b/Corona-Warn-App/src/main/java/de/rki/coronawarnapp/vaccination/core/qrcode/VaccinationQRCodeValidator.kt @@ -1,12 +1,25 @@ package de.rki.coronawarnapp.vaccination.core.qrcode import dagger.Reusable +import de.rki.coronawarnapp.coronatest.qrcode.InvalidQRCodeException +import de.rki.coronawarnapp.coronatest.qrcode.QrCodeExtractor +import timber.log.Timber import javax.inject.Inject @Reusable -class VaccinationQRCodeValidator @Inject constructor() { +class VaccinationQRCodeValidator @Inject constructor( + vaccinationQRCodeExtractor: VaccinationQRCodeExtractor +) { + private val extractors = setOf(vaccinationQRCodeExtractor) - fun validate(raw: String): VaccinationCertificateQRCode { - throw NotImplementedError() + fun validate(rawString: String): VaccinationCertificateQRCode { + return findExtractor(rawString) + ?.extract(rawString) + ?.also { Timber.i("Extracted data from QR code is $it") } + ?: throw InvalidQRCodeException() + } + + private fun findExtractor(rawString: String): QrCodeExtractor<VaccinationCertificateQRCode>? { + return extractors.find { it.canHandle(rawString) } } } diff --git a/Corona-Warn-App/src/main/java/de/rki/coronawarnapp/vaccination/core/repository/storage/VaccinationContainer.kt b/Corona-Warn-App/src/main/java/de/rki/coronawarnapp/vaccination/core/repository/storage/VaccinationContainer.kt index 87bb73192..978864bd0 100644 --- a/Corona-Warn-App/src/main/java/de/rki/coronawarnapp/vaccination/core/repository/storage/VaccinationContainer.kt +++ b/Corona-Warn-App/src/main/java/de/rki/coronawarnapp/vaccination/core/repository/storage/VaccinationContainer.kt @@ -6,19 +6,21 @@ import de.rki.coronawarnapp.ui.Country import de.rki.coronawarnapp.vaccination.core.VaccinatedPersonIdentifier import de.rki.coronawarnapp.vaccination.core.VaccinationCertificate import de.rki.coronawarnapp.vaccination.core.personIdentifier +import de.rki.coronawarnapp.vaccination.core.qrcode.EmptyRawCOSEObject +import de.rki.coronawarnapp.vaccination.core.qrcode.HealthCertificateCOSEDecoder +import de.rki.coronawarnapp.vaccination.core.qrcode.RawCOSEObject import de.rki.coronawarnapp.vaccination.core.qrcode.VaccinationCertificateCOSEParser import de.rki.coronawarnapp.vaccination.core.qrcode.VaccinationCertificateData import de.rki.coronawarnapp.vaccination.core.qrcode.VaccinationCertificateQRCode import de.rki.coronawarnapp.vaccination.core.qrcode.VaccinationCertificateV1 +import de.rki.coronawarnapp.vaccination.core.qrcode.VaccinationCertificateV1Parser import de.rki.coronawarnapp.vaccination.core.server.valueset.VaccinationValueSet - -import okio.ByteString import org.joda.time.Instant import org.joda.time.LocalDate @Keep data class VaccinationContainer( - @SerializedName("vaccinationCertificateCOSE") val vaccinationCertificateCOSE: ByteString, + @SerializedName("vaccinationCertificateCOSE") val vaccinationCertificateCOSE: RawCOSEObject, @SerializedName("scannedAt") val scannedAt: Instant, ) { @@ -26,11 +28,15 @@ data class VaccinationContainer( // Otherwise GSON unsafes reflection to create this class, and sets the LAZY to null @Suppress("unused") - constructor() : this(ByteString.EMPTY, Instant.EPOCH) + constructor() : this(EmptyRawCOSEObject, Instant.EPOCH) + // TODO DI/ error handling @delegate:Transient private val certificateData: VaccinationCertificateData by lazy { - preParsedData ?: VaccinationCertificateCOSEParser().parse(vaccinationCertificateCOSE) + preParsedData ?: VaccinationCertificateCOSEParser( + HealthCertificateCOSEDecoder(), + VaccinationCertificateV1Parser(), + ).parse(vaccinationCertificateCOSE) } val certificate: VaccinationCertificateV1 diff --git a/Corona-Warn-App/src/main/java/de/rki/coronawarnapp/vaccination/core/server/proof/VaccinationProofServer.kt b/Corona-Warn-App/src/main/java/de/rki/coronawarnapp/vaccination/core/server/proof/VaccinationProofServer.kt index 87aea53e0..3e4afbec9 100644 --- a/Corona-Warn-App/src/main/java/de/rki/coronawarnapp/vaccination/core/server/proof/VaccinationProofServer.kt +++ b/Corona-Warn-App/src/main/java/de/rki/coronawarnapp/vaccination/core/server/proof/VaccinationProofServer.kt @@ -1,7 +1,7 @@ package de.rki.coronawarnapp.vaccination.core.server.proof import dagger.Reusable -import okio.ByteString +import de.rki.coronawarnapp.vaccination.core.qrcode.RawCOSEObject import javax.inject.Inject /** @@ -11,7 +11,7 @@ import javax.inject.Inject class VaccinationProofServer @Inject constructor() { suspend fun getProofCertificate( - vaccinationCertificate: ByteString + vaccinationCertificate: RawCOSEObject ): ProofCertificateResponse { throw NotImplementedError() } diff --git a/Corona-Warn-App/src/main/java/de/rki/coronawarnapp/vaccination/decoder/Base45Decoder.kt b/Corona-Warn-App/src/main/java/de/rki/coronawarnapp/vaccination/decoder/Base45Decoder.kt new file mode 100644 index 000000000..0ac54fe7b --- /dev/null +++ b/Corona-Warn-App/src/main/java/de/rki/coronawarnapp/vaccination/decoder/Base45Decoder.kt @@ -0,0 +1,90 @@ +/* + Copyright 2021 A-SIT Plus GmbH + + Licensed 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. + + Modifications Copyright (c) 2021 SAP SE or an SAP affiliate company. +*/ +package de.rki.coronawarnapp.vaccination.decoder + +import java.math.BigInteger +import javax.inject.Inject + +/** + * Based on + * https://github.com/ehn-digital-green-development/hcert-kotlin/blob/23203fbb71f53524ee643a9df116264f87b5b32a/src/main/kotlin/ehn/techiop/hcert/kotlin/chain/common/Base45Encoder.kt + */ +class Base45Decoder @Inject constructor() { + private val alphabet = "0123456789ABCDEFGHIJKLMNOPQRSTUVWXYZ $%*+-./:" + private val int45 = BigInteger.valueOf(45) + private val int256 = BigInteger.valueOf(256) + + fun encode(input: ByteArray) = + input.asSequence() + .map { it.toUByte() } + .chunked(2) + .map(this::encodeTwoCharsPadded) + .flatten() + .joinToString(separator = "") + + private fun encodeTwoCharsPadded(input: List<UByte>): List<Char> { + val result = encodeTwoChars(input).toMutableList() + when (input.size) { + 1 -> if (result.size < 2) result += '0' + 2 -> while (result.size < 3) result += '0' + } + return result + } + + private fun encodeTwoChars(list: List<UByte>) = + generateSequenceByDivRem(toTwoCharValue(list), 45) + .map { alphabet[it] }.toList() + + private fun toTwoCharValue(list: List<UByte>) = + list.reversed().foldIndexed(0L) { index, acc, element -> + pow(int256, index) * element.toShort() + acc + } + + fun decode(input: String) = + input.chunked(3).map(this::decodeThreeCharsPadded) + .flatten().map { it.toByte() }.toByteArray() + + private fun decodeThreeCharsPadded(input: String): List<UByte> { + val result = decodeThreeChars(input).toMutableList() + when (input.length) { + 3 -> while (result.size < 2) result += 0U + } + return result.reversed() + } + + private fun decodeThreeChars(list: String) = + generateSequenceByDivRem(fromThreeCharValue(list), 256) + .map { it.toUByte() }.toList() + + private fun fromThreeCharValue(list: String): Long { + return list.foldIndexed( + 0L, + { index, acc: Long, element -> + if (!alphabet.contains(element)) + throw IllegalArgumentException(element.toString()) + pow(int45, index) * alphabet.indexOf(element) + acc + } + ) + } + + private fun generateSequenceByDivRem(seed: Long, divisor: Int) = + generateSequence(seed) { if (it >= divisor) it.div(divisor) else null } + .map { it.rem(divisor).toInt() } + + private fun pow(base: BigInteger, exp: Int) = base.pow(exp).toLong() +} diff --git a/Corona-Warn-App/src/main/java/de/rki/coronawarnapp/vaccination/decoder/InvalidInputException.kt b/Corona-Warn-App/src/main/java/de/rki/coronawarnapp/vaccination/decoder/InvalidInputException.kt new file mode 100644 index 000000000..6e2604b41 --- /dev/null +++ b/Corona-Warn-App/src/main/java/de/rki/coronawarnapp/vaccination/decoder/InvalidInputException.kt @@ -0,0 +1,5 @@ +package de.rki.coronawarnapp.vaccination.decoder + +class InvalidInputException( + message: String = "An error occurred while decoding input." +) : Exception(message) diff --git a/Corona-Warn-App/src/main/java/de/rki/coronawarnapp/vaccination/decoder/ZLIBDecompressor.kt b/Corona-Warn-App/src/main/java/de/rki/coronawarnapp/vaccination/decoder/ZLIBDecompressor.kt new file mode 100644 index 000000000..8355af38f --- /dev/null +++ b/Corona-Warn-App/src/main/java/de/rki/coronawarnapp/vaccination/decoder/ZLIBDecompressor.kt @@ -0,0 +1,22 @@ +package de.rki.coronawarnapp.vaccination.decoder + +import timber.log.Timber +import java.util.zip.InflaterInputStream +import javax.inject.Inject + +class ZLIBDecompressor @Inject constructor() { + fun decode(input: ByteArray): ByteArray = if ( + input.size >= 2 && + input[0] == 0x78.toByte() && + input[1] in listOf(0x01.toByte(), 0x5E.toByte(), 0x9C.toByte(), 0xDA.toByte()) + ) { + try { + input.inputStream().use { InflaterInputStream(it).readBytes() } + } catch (e: Throwable) { + Timber.e(e) + throw InvalidInputException("Zlib decompression failed.") + } + } else { + input + } +} diff --git a/Corona-Warn-App/src/main/res/values/strings.xml b/Corona-Warn-App/src/main/res/values/strings.xml index 7f19a6c8b..a31b14a00 100644 --- a/Corona-Warn-App/src/main/res/values/strings.xml +++ b/Corona-Warn-App/src/main/res/values/strings.xml @@ -2044,4 +2044,4 @@ <string name="incompatible_scanning_not_supported">"Your smartphone cannot send or receive COVID-19 notifications via Bluetooth. You can send and receive warnings that result from check-ins."</string> <!-- XTXT: Incompitability faq link --> <string name="incompatible_link">"https://www.coronawarn.app/en/faq/#incompatibility_warning"</string> -</resources> \ No newline at end of file +</resources> diff --git a/Corona-Warn-App/src/test/java/de/rki/coronawarnapp/vaccination/core/VaccinationTestData.kt b/Corona-Warn-App/src/test/java/de/rki/coronawarnapp/vaccination/core/VaccinationTestData.kt index 334f7be77..df5f31e9f 100644 --- a/Corona-Warn-App/src/test/java/de/rki/coronawarnapp/vaccination/core/VaccinationTestData.kt +++ b/Corona-Warn-App/src/test/java/de/rki/coronawarnapp/vaccination/core/VaccinationTestData.kt @@ -1,6 +1,7 @@ package de.rki.coronawarnapp.vaccination.core import de.rki.coronawarnapp.vaccination.core.qrcode.VaccinationCertificateData +import de.rki.coronawarnapp.vaccination.core.qrcode.VaccinationCertificateHeader import de.rki.coronawarnapp.vaccination.core.qrcode.VaccinationCertificateQRCode import de.rki.coronawarnapp.vaccination.core.qrcode.VaccinationCertificateV1 import de.rki.coronawarnapp.vaccination.core.repository.storage.PersonData @@ -11,6 +12,7 @@ import de.rki.coronawarnapp.vaccination.core.server.proof.ProofCertificateRespon import de.rki.coronawarnapp.vaccination.core.server.ProofCertificateV1 import okio.ByteString import okio.ByteString.Companion.decodeBase64 +import okio.internal.commonAsUtf8ToByteArray import org.joda.time.Instant import org.joda.time.LocalDate @@ -24,7 +26,7 @@ object VaccinationTestData { familyName = "d'Arsøns - van Halen", familyNameStandardized = "DARSONS<VAN<HALEN", ), - dateOfBirth = LocalDate.parse("2009-02-28"), + dob = "2009-02-28", vaccinationDatas = listOf( VaccinationCertificateV1.VaccinationData( targetId = "840539006", @@ -33,7 +35,7 @@ object VaccinationTestData { marketAuthorizationHolderId = "ORG-100030215", doseNumber = 1, totalSeriesOfDoses = 2, - vaccinatedAt = LocalDate.parse("2021-04-21"), + dt = "2021-04-21", countryOfVaccination = "NL", certificateIssuer = "Ministry of Public Health, Welfare and Sport", uniqueCertificateIdentifier = "urn:uvci:01:NL:PlA8UWS60Z4RZXVALl6GAZ", @@ -41,18 +43,25 @@ object VaccinationTestData { ), ) + val PERSON_A_VAC_1_HEADER = VaccinationCertificateHeader( + issuer = "Ministry of Public Health, Welfare and Sport", + issuedAt = Instant.ofEpochMilli(1620149204473), + expiresAt = Instant.ofEpochMilli(11620149234473) + ) + val PERSON_A_VAC_1_DATA = VaccinationCertificateData( + header = PERSON_A_VAC_1_HEADER, vaccinationCertificate = PERSON_A_VAC_1_JSON ) val PERSON_A_VAC_1_QRCODE = VaccinationCertificateQRCode( parsedData = PERSON_A_VAC_1_DATA, - certificateCOSE = "VGhlIENha2UgaXMgTm90IGEgTGll".decodeBase64()!! + certificateCOSE = "VGhlIENha2UgaXMgTm90IGEgTGll".toCOSEObject() ) val PERSON_A_VAC_1_CONTAINER = VaccinationContainer( scannedAt = Instant.ofEpochMilli(1620062834471), - vaccinationCertificateCOSE = "VGhlIGNha2UgaXMgYSBsaWUu".decodeBase64()!!, + vaccinationCertificateCOSE = "VGhlIGNha2UgaXMgYSBsaWUu".toCOSEObject(), ).apply { preParsedData = PERSON_A_VAC_1_DATA } @@ -65,7 +74,7 @@ object VaccinationTestData { familyName = "d'Arsøns - van Halen", familyNameStandardized = "DARSONS<VAN<HALEN", ), - dateOfBirth = LocalDate.parse("2009-02-28"), + dob = "2009-02-28", vaccinationDatas = listOf( VaccinationCertificateV1.VaccinationData( targetId = "840539006", @@ -74,7 +83,7 @@ object VaccinationTestData { marketAuthorizationHolderId = "ORG-100030215", doseNumber = 1, totalSeriesOfDoses = 2, - vaccinatedAt = LocalDate.parse("2021-04-22"), + dt = "2021-04-22", countryOfVaccination = "NL", certificateIssuer = "Ministry of Public Health, Welfare and Sport", uniqueCertificateIdentifier = "urn:uvci:01:NL:THECAKEISALIE", @@ -82,18 +91,25 @@ object VaccinationTestData { ), ) + val PERSON_A_VAC_2_HEADER = VaccinationCertificateHeader( + issuer = "Ministry of Public Health, Welfare and Sport", + issuedAt = Instant.ofEpochMilli(1620149204473), + expiresAt = Instant.ofEpochMilli(11620149234473) + ) + val PERSON_A_VAC_2_DATA = VaccinationCertificateData( + header = PERSON_A_VAC_2_HEADER, vaccinationCertificate = PERSON_A_VAC_2_JSON ) val PERSON_A_VAC_2_QRCODE = VaccinationCertificateQRCode( parsedData = PERSON_A_VAC_2_DATA, - certificateCOSE = "VGhlIGNha2UgaXMgYSBsaWUu".decodeBase64()!! + certificateCOSE = "VGhlIGNha2UgaXMgYSBsaWUu".toCOSEObject() ) val PERSON_A_VAC_2_CONTAINER = VaccinationContainer( scannedAt = Instant.ofEpochMilli(1620149234473), - vaccinationCertificateCOSE = "VGhlIENha2UgaXMgTm90IGEgTGll".decodeBase64()!!, + vaccinationCertificateCOSE = "VGhlIENha2UgaXMgTm90IGEgTGll".toCOSEObject(), ).apply { preParsedData = PERSON_A_VAC_2_DATA } @@ -162,7 +178,7 @@ object VaccinationTestData { familyName = "Von Mustermensch", familyNameStandardized = "VON<MUSTERMENSCH", ), - dateOfBirth = LocalDate.parse("1996-12-24"), + dob = "1996-12-24", vaccinationDatas = listOf( VaccinationCertificateV1.VaccinationData( targetId = "840539006", @@ -171,20 +187,28 @@ object VaccinationTestData { marketAuthorizationHolderId = "ORG-100030215", doseNumber = 1, totalSeriesOfDoses = 2, - vaccinatedAt = LocalDate.parse("2021-04-21"), + dt = "2021-04-21", countryOfVaccination = "NL", certificateIssuer = "Ministry of Public Health, Welfare and Sport", uniqueCertificateIdentifier = "urn:uvci:01:NL:PlA8UWS60Z4RZXVALl6GAZ", ) ) ) + + val PERSON_B_VAC_1_HEADER = VaccinationCertificateHeader( + issuer = "Ministry of Public Health, Welfare and Sport", + issuedAt = Instant.ofEpochMilli(1620149204473), + expiresAt = Instant.ofEpochMilli(11620149234473) + ) + val PERSON_B_VAC_1_DATA = VaccinationCertificateData( + header = PERSON_B_VAC_1_HEADER, vaccinationCertificate = PERSON_B_VAC_1_JSON ) val PERSON_B_VAC_1_CONTAINER = VaccinationContainer( scannedAt = Instant.ofEpochMilli(1620062834471), - vaccinationCertificateCOSE = "VGhpc0lzSmFrb2I".decodeBase64()!!, + vaccinationCertificateCOSE = "VGhpc0lzSmFrb2I".toCOSEObject(), ).apply { preParsedData = PERSON_B_VAC_1_DATA } @@ -194,3 +218,5 @@ object VaccinationTestData { proofs = emptySet() ) } + +private fun String.toCOSEObject() = commonAsUtf8ToByteArray() diff --git a/Corona-Warn-App/src/test/java/de/rki/coronawarnapp/vaccination/core/qrcode/VaccinationQRCodeExtractorTest.kt b/Corona-Warn-App/src/test/java/de/rki/coronawarnapp/vaccination/core/qrcode/VaccinationQRCodeExtractorTest.kt new file mode 100644 index 000000000..1ebc70a0d --- /dev/null +++ b/Corona-Warn-App/src/test/java/de/rki/coronawarnapp/vaccination/core/qrcode/VaccinationQRCodeExtractorTest.kt @@ -0,0 +1,65 @@ +package de.rki.coronawarnapp.vaccination.core.qrcode + +import de.rki.coronawarnapp.vaccination.core.qrcode.InvalidHealthCertificateException.ErrorCode.HC_BASE45_DECODING_FAILED +import de.rki.coronawarnapp.vaccination.core.qrcode.InvalidHealthCertificateException.ErrorCode.HC_CBOR_DECODING_FAILED +import de.rki.coronawarnapp.vaccination.core.qrcode.InvalidHealthCertificateException.ErrorCode.HC_ZLIB_DECOMPRESSION_FAILED +import de.rki.coronawarnapp.vaccination.core.qrcode.InvalidHealthCertificateException.ErrorCode.VC_NO_VACCINATION_ENTRY +import de.rki.coronawarnapp.vaccination.decoder.Base45Decoder +import de.rki.coronawarnapp.vaccination.decoder.ZLIBDecompressor +import io.kotest.assertions.throwables.shouldThrow +import io.kotest.matchers.shouldBe +import org.junit.jupiter.api.Test +import testhelpers.BaseTest + +class VaccinationQRCodeExtractorTest : BaseTest() { + + private val base45Decoder = Base45Decoder() + private val ZLIBDecompressor = ZLIBDecompressor() + private val healthCertificateCOSEDecoder = HealthCertificateCOSEDecoder() + private val vaccinationCertificateV1Decoder = VaccinationCertificateV1Parser() + + private val extractor = VaccinationQRCodeExtractor( + base45Decoder, + ZLIBDecompressor, + healthCertificateCOSEDecoder, + vaccinationCertificateV1Decoder + ) + + @Test + fun `happy path extraction`() { + extractor.extract(VaccinationQrCodeTestData.validVaccinationQrCode) + } + + @Test + fun `happy path extraction 2`() { + extractor.extract(VaccinationQrCodeTestData.validVaccinationQrCode2) + } + + @Test + fun `valid encoding but not a health certificate fails with HC_CBOR_DECODING_FAILED`() { + shouldThrow<InvalidHealthCertificateException> { + extractor.extract(VaccinationQrCodeTestData.validEncoded) + }.errorCode shouldBe HC_CBOR_DECODING_FAILED + } + + @Test + fun `random string fails with HC_BASE45_DECODING_FAILED`() { + shouldThrow<InvalidHealthCertificateException> { + extractor.extract("nothing here to see") + }.errorCode shouldBe HC_BASE45_DECODING_FAILED + } + + @Test + fun `uncompressed base45 string fails with HC_ZLIB_DECOMPRESSION_FAILED`() { + shouldThrow<InvalidHealthCertificateException> { + extractor.extract("6BFOABCDEFGHIJKLMNOPQRSTUVWXYZ %*+-./:") + }.errorCode shouldBe HC_ZLIB_DECOMPRESSION_FAILED + } + + @Test + fun `vaccination certificate missing fails with VC_NO_VACCINATION_ENTRY`() { + shouldThrow<InvalidHealthCertificateException> { + extractor.extract(VaccinationQrCodeTestData.certificateMissing) + }.errorCode shouldBe VC_NO_VACCINATION_ENTRY + } +} diff --git a/Corona-Warn-App/src/test/java/de/rki/coronawarnapp/vaccination/core/qrcode/VaccinationQrCodeTestData.java b/Corona-Warn-App/src/test/java/de/rki/coronawarnapp/vaccination/core/qrcode/VaccinationQrCodeTestData.java new file mode 100644 index 000000000..2bd0706ce --- /dev/null +++ b/Corona-Warn-App/src/test/java/de/rki/coronawarnapp/vaccination/core/qrcode/VaccinationQrCodeTestData.java @@ -0,0 +1,8 @@ +package de.rki.coronawarnapp.vaccination.core.qrcode; + +public class VaccinationQrCodeTestData { + static public String validVaccinationQrCode = "HC1:6BFOXN*TS0BI$ZD4N9:9S6RCVN5+O30K3/XIV0W23NTDEXWK G2EP4J0BGJLFX3R3VHXK.PJ:2DPF6R:5SVBHABVCNN95SWMPHQUHQN%A0SOE+QQAB-HQ/HQ7IR.SQEEOK9SAI4- 7Y15KBPD34 QWSP0WRGTQFNPLIR.KQNA7N95U/3FJCTG90OARH9P1J4HGZJKBEG%123ZC$0BCI757TLXKIBTV5TN%2LXK-$CH4TSXKZ4S/$K%0KPQ1HEP9.PZE9Q$95:UENEUW6646936HRTO$9KZ56DE/.QC$Q3J62:6LZ6O59++9-G9+E93ZM$96TV6NRN3T59YLQM1VRMP$I/XK$M8PK66YBTJ1ZO8B-S-*O5W41FD$ 81JP%KNEV45G1H*KESHMN2/TU3UQQKE*QHXSMNV25$1PK50C9B/9OK5NE1 9V2:U6A1ELUCT16DEETUM/UIN9P8Q:KPFY1W+UN MUNU8T1PEEG%5TW5A 6YO67N6BBEWED/3LS3N6YU.:KJWKPZ9+CQP2IOMH.PR97QC:ACZAH.SYEDK3EL-FIK9J8JRBC7ADHWQYSK48UNZGG NAVEHWEOSUI2L.9OR8FHB0T5HM7I"; + static public String validVaccinationQrCode2 = "HC1:NCFOXN%TS3DHZN4HAF*PQFKKGTNA.Q/R8WRU2FC6L9N*CH PC.IU:N AJPJPC%OQHIZC4.OI1RM8ZA.A53XHMKN4NN3F85QNCY0O%0VZ001HOC9JU0D0HT0HO1PM:K$$09B9LW4T*8+DC%H0PZBITH$*SBAKYE9*FJTJAHD4UDADPSDJIM4KF/B0C2SFIH:9$GCQOS62PR6WPHN6D7LLK*2HG%89UV-0LZ 2ZJJ4FF86O:HO73SM1IO-O.Z80GHS-O:S9UZ4+FJE 4Y3LL/II 07LPMIH-O9XZQSH9R$FXQGDVBK*RZP3:*DG1W7SGT$7S%RMSG2UQYI9*FGCPAXRQ3E2N+E .1:L7O:7X/5Q+MSA7G6MBYO+JQLHP71RJW63X7VUONC6V35HW6SZ6FT5D75W9AV88E34+V4YC5/HQWOQ6$S4N4N31SHPO3Q0E447H9VAK:6.5G$N3ZF7W2SBJT7QG+8UJII3MACIBG2U76MGX3$YB.S7PIJRVOBTN6DTEUIOS7ZKJJEL%.B PT2LO36KT8SP50M/O$4"; + static public String validEncoded = "6BFB 9B8OYK3DR3D92BSQAQAHSOMEQ3%1GEVQT4H4O8G3.13G$H6+DH.157SWEV21SD7F2OPY1O-9LRFG0NGCUEPS5LLKJ:1CEJTLA2SADI887A/P3UHL20FTA9ZTRPSVUXO19LEZBQF3VJE$77D5FFC91ZFKCPP%90VS09P2QDQBCMY7-AE0/RW1R:ICP76XRS5UGC82WDNRJ9R7SX331MI9C7WNE5ZL1795NTA/P-35.N65O65ZQ8SU2:KY:C9K9PKD6+K%DI$YQ-9A:CKZ+5HPQNIF7N3K UEU6GEKHCO03MC%QN+LN+C5TTB1B94EC$38QC5O5DP262N:X7JYR/XH/A8%-1KZFTODRY3I 859G-IS9TMY4JM21TAV$N2NK3%BW8K7GI6%O8DUKUT036EF$8:32RBK*0IHJISK5SLTT21KYE7 U/316$I08A/XBU4IZYAGD3UVOJQI2YH3JMXHS1IPE%FOJN$HOV%B3FWCDCP65/%RKP2W2M4A9X7GETNASOXZ0Q/Q5LUNMJ QH+-2:4FW$33+4 +AY7GV-15/717GXY4H4O.:RM/USWV70PV8NGL5XP15NQ3K217GC:1WQEJNBK1RU6J.4K9/J%VQOHA+EW I0YMQ 0"; + static public String certificateMissing = "HC1:NCFNA0%00FFWTWGVLKJ99K83X4C8DTTMMX*4P8B3XK2F3$8JVJG2F3$%IQJG/IC6TAY50.FK6ZK6:ETPCBEC8ZKW.CNWE.Y92OAGY82+8UB8-R7/0A1OA1C9K09UIAW.CE$E7%E7WE KEVKER EB39W4N*6K3/D5$CMPCG/DA8DBB85IAAY8WY8I3DA8D0EC*KE: CZ CO/EZKEZ96446C56GVC*JC1A6NA73W5KF6TF627BSKL*8F.MLCM6$-I99MG$8THRJSCJVM/*V:0EY1QU 77*D9KR$SKIP5S-I2-RA1CC06+CHPYQX96*SUF3WZ36NM3XPK1P8.MAFZ6SHB"; +} diff --git a/Corona-Warn-App/src/test/java/de/rki/coronawarnapp/vaccination/core/repository/storage/VaccinationStorageTest.kt b/Corona-Warn-App/src/test/java/de/rki/coronawarnapp/vaccination/core/repository/storage/VaccinationStorageTest.kt index 713989766..0c8a1f3ee 100644 --- a/Corona-Warn-App/src/test/java/de/rki/coronawarnapp/vaccination/core/repository/storage/VaccinationStorageTest.kt +++ b/Corona-Warn-App/src/test/java/de/rki/coronawarnapp/vaccination/core/repository/storage/VaccinationStorageTest.kt @@ -1,143 +1,131 @@ package de.rki.coronawarnapp.vaccination.core.repository.storage -import android.content.Context -import androidx.core.content.edit -import de.rki.coronawarnapp.util.serialization.SerializationModule -import de.rki.coronawarnapp.vaccination.core.VaccinationTestData -import io.kotest.matchers.shouldBe -import io.mockk.MockKAnnotations -import io.mockk.every -import io.mockk.impl.annotations.MockK -import okio.ByteString.Companion.decodeBase64 import org.junit.Ignore -import org.junit.jupiter.api.BeforeEach -import org.junit.jupiter.api.Test import testhelpers.BaseTest -import testhelpers.extensions.toComparableJsonPretty -import testhelpers.preferences.MockSharedPreferences @Ignore class VaccinationStorageTest : BaseTest() { - - @MockK lateinit var context: Context - private lateinit var mockPreferences: MockSharedPreferences - - @BeforeEach - fun setup() { - MockKAnnotations.init(this) - - mockPreferences = MockSharedPreferences() - - every { - context.getSharedPreferences("vaccination_localdata", Context.MODE_PRIVATE) - } returns mockPreferences - } - - private fun createInstance() = VaccinationStorage( - context = context, - baseGson = SerializationModule().baseGson() - ) - - @Test - fun `init is sideeffect free`() { - createInstance() - } - - @Test - fun `storing empty set deletes data`() { - mockPreferences.edit { - putString("dontdeleteme", "test") - putString("vaccination.person.test", "test") - } - createInstance().personContainers = emptySet() - - mockPreferences.dataMapPeek.keys.single() shouldBe "dontdeleteme" - } - - @Test - fun `store one fully vaccinated person`() { - val instance = createInstance() - instance.personContainers = setOf(VaccinationTestData.PERSON_A_DATA_2VAC_PROOF) - - val json = - (mockPreferences.dataMapPeek["vaccination.person.2009-02-28#DARSONS<VAN<HALEN#FRANCOIS<JOAN"] as String) - - json.toComparableJsonPretty() shouldBe """ - { - "vaccinationData": [ - { - "vaccinationCertificateCOSE": "VGhlIGNha2UgaXMgYSBsaWUu", - "scannedAt": 1620062834471 - }, - { - "vaccinationCertificateCOSE": "VGhlIENha2UgaXMgTm90IGEgTGll", - "scannedAt": 1620149234473 - } - ], - "proofData": [ - { - "proofCOSE": "VGhpc0lzQVByb29mQ09TRQ==", - "receivedAt": 1620062834474 - } - ], - "lastSuccessfulProofCertificateRun": 0, - "proofCertificateRunPending": false - } - """.toComparableJsonPretty() - - instance.personContainers.single().apply { - this shouldBe VaccinationTestData.PERSON_A_DATA_2VAC_PROOF - this.vaccinations.map { it.vaccinationCertificateCOSE } shouldBe setOf( - "VGhlIGNha2UgaXMgYSBsaWUu".decodeBase64()!!, - "VGhlIENha2UgaXMgTm90IGEgTGll".decodeBase64()!!, - ) - this.proofs.map { it.proofCOSE } shouldBe setOf( - "VGhpc0lzQVByb29mQ09TRQ==".decodeBase64()!!, - ) - } - } - - @Test - fun `store incompletely vaccinated person`() { - val instance = createInstance() - instance.personContainers = setOf(VaccinationTestData.PERSON_B_DATA_1VAC_NOPROOF) - - val json = (mockPreferences.dataMapPeek["vaccination.person.1996-12-24#VON<MUSTERMENSCH#SIR<JAKOB"] as String) - - json.toComparableJsonPretty() shouldBe """ - { - "vaccinationData": [ - { - "vaccinationCertificateCOSE": "VGhpc0lzSmFrb2I=", - "scannedAt": 1620062834471 - } - ], - "proofData": [], - "lastSuccessfulProofCertificateRun": 0, - "proofCertificateRunPending": false - } - """.toComparableJsonPretty() - - instance.personContainers.single().apply { - this shouldBe VaccinationTestData.PERSON_B_DATA_1VAC_NOPROOF - this.vaccinations.single().vaccinationCertificateCOSE shouldBe "VGhpc0lzSmFrb2I=".decodeBase64()!! - } - } - - @Test - fun `store two persons`() { - createInstance().apply { - personContainers = setOf( - VaccinationTestData.PERSON_B_DATA_1VAC_NOPROOF, - VaccinationTestData.PERSON_A_DATA_2VAC_PROOF - ) - personContainers shouldBe setOf( - VaccinationTestData.PERSON_B_DATA_1VAC_NOPROOF, - VaccinationTestData.PERSON_A_DATA_2VAC_PROOF - ) - - personContainers = emptySet() - personContainers shouldBe emptySet() - } - } +// TODO rawCOSEObject data type + +// @MockK lateinit var context: Context +// private lateinit var mockPreferences: MockSharedPreferences +// +// @BeforeEach +// fun setup() { +// MockKAnnotations.init(this) +// +// mockPreferences = MockSharedPreferences() +// +// every { +// context.getSharedPreferences("vaccination_localdata", Context.MODE_PRIVATE) +// } returns mockPreferences +// } +// +// private fun createInstance() = VaccinationStorage( +// context = context, +// baseGson = SerializationModule().baseGson() +// ) +// +// @Test +// fun `init is sideeffect free`() { +// createInstance() +// } +// +// @Test +// fun `storing empty set deletes data`() { +// mockPreferences.edit { +// putString("dontdeleteme", "test") +// putString("vaccination.person.test", "test") +// } +// createInstance().personContainers = emptySet() +// +// mockPreferences.dataMapPeek.keys.single() shouldBe "dontdeleteme" +// } +// +// @Test +// fun `store one fully vaccinated person`() { +// val instance = createInstance() +// instance.personContainers = setOf(VaccinationTestData.PERSON_A_DATA_2VAC_PROOF) +// +// val json = +// (mockPreferences.dataMapPeek["vaccination.person.2009-02-28#DARSONS<VAN<HALEN#FRANCOIS<JOAN"] as String) +// +// json.toComparableJsonPretty() shouldBe """ +// { +// "vaccinationData": [ +// { +// "vaccinationCertificateCOSE": "VGhlIGNha2UgaXMgYSBsaWUu", +// "scannedAt": 1620062834471 +// }, +// { +// "vaccinationCertificateCOSE": "VGhlIENha2UgaXMgTm90IGEgTGll", +// "scannedAt": 1620149234473 +// } +// ], +// "proofData": [ +// { +// "proofCOSE": "VGhpc0lzQVByb29mQ09TRQ==", +// "receivedAt": 1620062834474 +// } +// ], +// "lastSuccessfulProofCertificateRun": 0, +// "proofCertificateRunPending": false +// } +// """.toComparableJsonPretty() +// +// instance.personContainers.single().apply { +// this shouldBe VaccinationTestData.PERSON_A_DATA_2VAC_PROOF +// this.vaccinations.map { it.vaccinationCertificateCOSE } shouldBe setOf( +// "VGhlIGNha2UgaXMgYSBsaWUu".decodeBase64()!!, +// "VGhlIENha2UgaXMgTm90IGEgTGll".decodeBase64()!!, +// ) +// this.proofs.map { it.proofCOSE } shouldBe setOf( +// "VGhpc0lzQVByb29mQ09TRQ==".decodeBase64()!!, +// ) +// } +// } +// +// @Test +// fun `store incompletely vaccinated person`() { +// val instance = createInstance() +// instance.personContainers = setOf(VaccinationTestData.PERSON_B_DATA_1VAC_NOPROOF) +// +// val json = (mockPreferences.dataMapPeek["vaccination.person.1996-12-24#VON<MUSTERMENSCH#SIR<JAKOB"] as String) +// +// json.toComparableJsonPretty() shouldBe """ +// { +// "vaccinationData": [ +// { +// "vaccinationCertificateCOSE": "VGhpc0lzSmFrb2I=", +// "scannedAt": 1620062834471 +// } +// ], +// "proofData": [], +// "lastSuccessfulProofCertificateRun": 0, +// "proofCertificateRunPending": false +// } +// """.toComparableJsonPretty() +// +// instance.personContainers.single().apply { +// this shouldBe VaccinationTestData.PERSON_B_DATA_1VAC_NOPROOF +// this.vaccinations.single().vaccinationCertificateCOSE shouldBe "VGhpc0lzSmFrb2I=".decodeBase64()!! +// } +// } +// +// @Test +// fun `store two persons`() { +// createInstance().apply { +// personContainers = setOf( +// VaccinationTestData.PERSON_B_DATA_1VAC_NOPROOF, +// VaccinationTestData.PERSON_A_DATA_2VAC_PROOF +// ) +// personContainers shouldBe setOf( +// VaccinationTestData.PERSON_B_DATA_1VAC_NOPROOF, +// VaccinationTestData.PERSON_A_DATA_2VAC_PROOF +// ) +// +// personContainers = emptySet() +// personContainers shouldBe emptySet() +// } +// } } -- GitLab