From 38164c72d29e9bea0abc1d3e31430b3bd3c647d0 Mon Sep 17 00:00:00 2001 From: ztefanie Date: Wed, 19 Jun 2024 10:25:02 +0200 Subject: [PATCH] 1433: Create user hashing for Koblenz --- backend/build.gradle.kts | 5 + .../backend/common/utils/Environment.kt | 8 ++ .../backend/common/webservice/Constants.kt | 2 + ...tion.kt => NotCorrectProjectExceptions.kt} | 2 + .../backend/regions/database/Setup.kt | 56 ++++++---- .../backend/verification/Argon2IdHasher.kt | 85 +++++++++++++++ .../webservice/schema/CardMutationService.kt | 22 ++++ .../backend/helper/ExampleCards.kt | 103 +++++++++++------- .../verification/Argon2IdHasherTest.kt | 27 +++++ .../backend/verification/CanonicalJsonTest.kt | 92 ++++++++++------ docs/CreateKoblenzHash.md | 69 ++++++++++++ specs/backend-api.graphql | 2 + specs/card.proto | 5 + 13 files changed, 384 insertions(+), 94 deletions(-) create mode 100644 backend/src/main/kotlin/app/ehrenamtskarte/backend/common/utils/Environment.kt rename backend/src/main/kotlin/app/ehrenamtskarte/backend/exception/service/{NotEakProjectException.kt => NotCorrectProjectExceptions.kt} (59%) create mode 100644 backend/src/main/kotlin/app/ehrenamtskarte/backend/verification/Argon2IdHasher.kt create mode 100644 backend/src/test/kotlin/app/ehrenamtskarte/backend/verification/Argon2IdHasherTest.kt create mode 100644 docs/CreateKoblenzHash.md diff --git a/backend/build.gradle.kts b/backend/build.gradle.kts index 802b86676..e929220ec 100644 --- a/backend/build.gradle.kts +++ b/backend/build.gradle.kts @@ -53,6 +53,8 @@ dependencies { // Use the Kotlin JUnit integration. testImplementation("org.jetbrains.kotlin:kotlin-test-junit") + testImplementation("io.mockk:mockk:1.13.11") + implementation("org.jetbrains.exposed:exposed-core:$exposedVersion") implementation("org.jetbrains.exposed:exposed-dao:$exposedVersion") implementation("org.jetbrains.exposed:exposed-jdbc:$exposedVersion") @@ -69,11 +71,14 @@ dependencies { implementation("com.fasterxml.jackson.datatype:jackson-datatype-jsr310:2.15.2") implementation("com.fasterxml.jackson.module:jackson-module-kotlin:2.15.2") implementation("de.grundid.opendatalab:geojson-jackson:1.14") + implementation("commons-codec:commons-codec:1.17.0") implementation("com.eatthepath:java-otp:0.4.0") // dynamic card verification implementation("com.auth0:java-jwt:4.4.0") // JSON web tokens implementation("at.favre.lib:bcrypt:0.10.2") + implementation("org.bouncycastle:bcpkix-jdk18on:1.76") + implementation("com.google.zxing:core:3.5.2") // QR-Codes } diff --git a/backend/src/main/kotlin/app/ehrenamtskarte/backend/common/utils/Environment.kt b/backend/src/main/kotlin/app/ehrenamtskarte/backend/common/utils/Environment.kt new file mode 100644 index 000000000..eeb01b87c --- /dev/null +++ b/backend/src/main/kotlin/app/ehrenamtskarte/backend/common/utils/Environment.kt @@ -0,0 +1,8 @@ +package app.ehrenamtskarte.backend.common.utils + +// This helper class was created to enable mocking getenv in Tests +class Environment { + companion object { + fun getVariable(name: String): String? = System.getenv(name) + } +} diff --git a/backend/src/main/kotlin/app/ehrenamtskarte/backend/common/webservice/Constants.kt b/backend/src/main/kotlin/app/ehrenamtskarte/backend/common/webservice/Constants.kt index 9bd2a3048..60d04a647 100644 --- a/backend/src/main/kotlin/app/ehrenamtskarte/backend/common/webservice/Constants.kt +++ b/backend/src/main/kotlin/app/ehrenamtskarte/backend/common/webservice/Constants.kt @@ -2,5 +2,7 @@ package app.ehrenamtskarte.backend.common.webservice const val EAK_BAYERN_PROJECT = "bayern.ehrenamtskarte.app" const val NUERNBERG_PASS_PROJECT = "nuernberg.sozialpass.app" +const val KOBLENZ_PASS_PROJECT = "koblenz.pass.app" +const val KOBLENZ_PEPPER_SYS_ENV = "KOBLENZ_PEPPER" const val SHOWCASE_PROJECT = "showcase.entitlementcard.app" const val DEFAULT_PROJECT = EAK_BAYERN_PROJECT diff --git a/backend/src/main/kotlin/app/ehrenamtskarte/backend/exception/service/NotEakProjectException.kt b/backend/src/main/kotlin/app/ehrenamtskarte/backend/exception/service/NotCorrectProjectExceptions.kt similarity index 59% rename from backend/src/main/kotlin/app/ehrenamtskarte/backend/exception/service/NotEakProjectException.kt rename to backend/src/main/kotlin/app/ehrenamtskarte/backend/exception/service/NotCorrectProjectExceptions.kt index 805997a44..6d7e75640 100644 --- a/backend/src/main/kotlin/app/ehrenamtskarte/backend/exception/service/NotEakProjectException.kt +++ b/backend/src/main/kotlin/app/ehrenamtskarte/backend/exception/service/NotCorrectProjectExceptions.kt @@ -2,3 +2,5 @@ package app.ehrenamtskarte.backend.exception.service class NotEakProjectException() : Exception("This query can only be used for EAK project") + +class NotKoblenzProjectException() : Exception("This query can only be used for Koblenz project") diff --git a/backend/src/main/kotlin/app/ehrenamtskarte/backend/regions/database/Setup.kt b/backend/src/main/kotlin/app/ehrenamtskarte/backend/regions/database/Setup.kt index 1a1223307..595ec7d90 100644 --- a/backend/src/main/kotlin/app/ehrenamtskarte/backend/regions/database/Setup.kt +++ b/backend/src/main/kotlin/app/ehrenamtskarte/backend/regions/database/Setup.kt @@ -1,18 +1,45 @@ package app.ehrenamtskarte.backend.regions.database import app.ehrenamtskarte.backend.common.webservice.EAK_BAYERN_PROJECT +import app.ehrenamtskarte.backend.common.webservice.KOBLENZ_PASS_PROJECT import app.ehrenamtskarte.backend.common.webservice.NUERNBERG_PASS_PROJECT import app.ehrenamtskarte.backend.projects.database.ProjectEntity import org.jetbrains.exposed.sql.transactions.transaction fun insertOrUpdateRegions() { - transaction { - val projects = ProjectEntity.all() - val dbRegions = RegionEntity.all() + val projects = ProjectEntity.all() + val dbRegions = RegionEntity.all() + + fun createOrUpdateRegion( + regionProjectId: String, + regionName: String, + regionPrefix: String, + regionWebsite: String + ) { + val project = + projects.firstOrNull { it.project == regionProjectId } + ?: throw Error("Required project '$regionProjectId' not found!") + val region = dbRegions.singleOrNull { it.projectId == project.id } + if (region == null) { + RegionEntity.new { + projectId = project.id + name = regionName + prefix = regionPrefix + regionIdentifier = null + website = regionWebsite + } + } else { + region.name = regionName + region.prefix = regionPrefix + region.website = regionWebsite + } + } + transaction { // Create or update eak regions in database - val eakProject = projects.firstOrNull { it.project == EAK_BAYERN_PROJECT } - ?: throw Error("Required project '$EAK_BAYERN_PROJECT' not found!") + val eakProject = + projects.firstOrNull { it.project == EAK_BAYERN_PROJECT } + ?: throw Error("Required project '$EAK_BAYERN_PROJECT' not found!") EAK_BAYERN_REGIONS.forEach { eakRegion -> val dbRegion = dbRegions.find { it.regionIdentifier == eakRegion[2] && it.projectId == eakProject.id } if (dbRegion == null) { @@ -30,22 +57,7 @@ fun insertOrUpdateRegions() { } } - // Create or update nuernberg region in database - val nuernbergPassProject = projects.firstOrNull { it.project == NUERNBERG_PASS_PROJECT } - ?: throw Error("Required project '$NUERNBERG_PASS_PROJECT' not found!") - val nuernbergRegion = dbRegions.singleOrNull { it.projectId == nuernbergPassProject.id } - if (nuernbergRegion == null) { - RegionEntity.new { - projectId = nuernbergPassProject.id - name = "Nürnberg" - prefix = "Stadt" - regionIdentifier = null - website = "https://nuernberg.de" - } - } else { - nuernbergRegion.name = "Nürnberg" - nuernbergRegion.prefix = "Stadt" - nuernbergRegion.website = "https://nuernberg.de" - } + createOrUpdateRegion(NUERNBERG_PASS_PROJECT, "Nürnberg", "Stadt", "https://nuernberg.de") + createOrUpdateRegion(KOBLENZ_PASS_PROJECT, "Koblenz", "Stadt", "https://koblenz.de/") } } diff --git a/backend/src/main/kotlin/app/ehrenamtskarte/backend/verification/Argon2IdHasher.kt b/backend/src/main/kotlin/app/ehrenamtskarte/backend/verification/Argon2IdHasher.kt new file mode 100644 index 000000000..b5d4ef1af --- /dev/null +++ b/backend/src/main/kotlin/app/ehrenamtskarte/backend/verification/Argon2IdHasher.kt @@ -0,0 +1,85 @@ +import app.ehrenamtskarte.backend.common.utils.Environment +import app.ehrenamtskarte.backend.common.webservice.KOBLENZ_PEPPER_SYS_ENV +import app.ehrenamtskarte.backend.verification.CanonicalJson +import org.bouncycastle.crypto.generators.Argon2BytesGenerator +import org.bouncycastle.crypto.params.Argon2Parameters +import java.nio.charset.StandardCharsets +import java.util.Base64 + +class Argon2IdHasher { + companion object { + /** + * Copied from spring-security Argon2EncodingUtils.java licenced under Apache 2.0 + * + * Encodes a raw Argon2-hash and its parameters into the standard Argon2-hash-string + * as specified in the reference implementation + * (https://github.com/P-H-C/phc-winner-argon2/blob/master/src/encoding.c#L244): + * + * {@code $argon2[$v=]$m=,t=,p=$$} + **/ + @Throws(IllegalArgumentException::class) + fun encode( + hash: ByteArray?, + parameters: Argon2Parameters + ): String? { + val b64encoder: Base64.Encoder = Base64.getEncoder().withoutPadding() + val stringBuilder = StringBuilder() + val type = + when (parameters.type) { + Argon2Parameters.ARGON2_d -> "\$argon2d" + Argon2Parameters.ARGON2_i -> "\$argon2i" + Argon2Parameters.ARGON2_id -> "\$argon2id" + else -> throw IllegalArgumentException("Invalid algorithm type: " + parameters.type) + } + stringBuilder.append(type) + stringBuilder + .append("\$v=") + .append(parameters.version) + .append("\$m=") + .append(parameters.memory) + .append(",t=") + .append(parameters.iterations) + .append(",p=") + .append(parameters.lanes) + if (parameters.salt != null) { + stringBuilder.append("$").append(b64encoder.encodeToString(parameters.salt)) + } + stringBuilder.append("$").append(b64encoder.encodeToString(hash)) + return stringBuilder.toString() + } + + fun hashUserData(cardInfo: Card.CardInfo): String? { + val canonicalJson = CanonicalJson.messageToMap(cardInfo) + val hashLength = 32 + if (!isCanonicalJsonValid(canonicalJson)) { + throw Exception("Invalid Json input for hashing") + } + + val pepper = Environment.getVariable(KOBLENZ_PEPPER_SYS_ENV) // TODO handle if Null + val pepperByteArray = pepper?.toByteArray(StandardCharsets.UTF_8) + val params = + Argon2Parameters + .Builder(Argon2Parameters.ARGON2_id) + .withVersion(19) + .withIterations(2) + .withSalt(pepperByteArray) + .withParallelism(1) + .withMemoryAsKB(16) + .build() + + val generator = Argon2BytesGenerator() + generator.init(params) + val result = ByteArray(hashLength) + generator.generateBytes(CanonicalJson.serializeToString(canonicalJson).toCharArray(), result) + return encode(result, params) + } + + private fun isCanonicalJsonValid(canonicalJson: Map): Boolean { + val hasName = canonicalJson.get("1") != null + val hasExtensions = canonicalJson.get("3") as? Map + val hasKoblenzPassExtension = hasExtensions?.get("6") as? Map + val hasKoblenzPassId = hasKoblenzPassExtension?.get("1") != null + return hasName && hasKoblenzPassId + } + } +} diff --git a/backend/src/main/kotlin/app/ehrenamtskarte/backend/verification/webservice/schema/CardMutationService.kt b/backend/src/main/kotlin/app/ehrenamtskarte/backend/verification/webservice/schema/CardMutationService.kt index a8ac4d33d..d0efacf66 100644 --- a/backend/src/main/kotlin/app/ehrenamtskarte/backend/verification/webservice/schema/CardMutationService.kt +++ b/backend/src/main/kotlin/app/ehrenamtskarte/backend/verification/webservice/schema/CardMutationService.kt @@ -1,11 +1,14 @@ package app.ehrenamtskarte.backend.verification.webservice.schema +import Argon2IdHasher import Card import app.ehrenamtskarte.backend.application.database.repos.ApplicationRepository import app.ehrenamtskarte.backend.auth.database.AdministratorEntity import app.ehrenamtskarte.backend.auth.service.Authorizer import app.ehrenamtskarte.backend.common.webservice.GraphQLContext +import app.ehrenamtskarte.backend.common.webservice.KOBLENZ_PASS_PROJECT import app.ehrenamtskarte.backend.exception.service.ForbiddenException +import app.ehrenamtskarte.backend.exception.service.NotKoblenzProjectException import app.ehrenamtskarte.backend.exception.service.ProjectNotFoundException import app.ehrenamtskarte.backend.exception.service.UnauthorizedException import app.ehrenamtskarte.backend.exception.webservice.exceptions.InvalidCardHashException @@ -163,6 +166,25 @@ class CardMutationService { return activationCodes } + @GraphQLDescription("Creates a new digital koblenz card and returns it") + fun createCardsByUserData( + dfe: DataFetchingEnvironment, + project: String, + encodedCardInfo: String + ): Boolean { // CardCreationResultModel { + val context = dfe.getContext() + val projectConfig = + context.backendConfiguration.projects.find { it.id == project } + ?: throw ProjectNotFoundException(project) + if (project != KOBLENZ_PASS_PROJECT) { + throw NotKoblenzProjectException() + } + val cardInfoBytes = encodedCardInfo.decodeBase64Bytes() + val cardInfo = Card.CardInfo.parseFrom(cardInfoBytes) + val hashedUserData = Argon2IdHasher.hashUserData(cardInfo) + return false // Will be done in #1421 + } + @GraphQLDescription("Activate a dynamic entitlement card") fun activateCard( project: String, diff --git a/backend/src/test/kotlin/app/ehrenamtskarte/backend/helper/ExampleCards.kt b/backend/src/test/kotlin/app/ehrenamtskarte/backend/helper/ExampleCards.kt index 904c87190..4dc5ccdac 100644 --- a/backend/src/test/kotlin/app/ehrenamtskarte/backend/helper/ExampleCards.kt +++ b/backend/src/test/kotlin/app/ehrenamtskarte/backend/helper/ExampleCards.kt @@ -6,24 +6,36 @@ enum class CardInfoTestSample { Nuernberg, NuernbergWithStartDay, NuernbergWithPassId, - NuernbergWithPassNr + NuernbergWithPassNr, + KoblenzPass } object ExampleCardInfo { - private val bavarianBase = buildCardInfo( - Card.CardInfo.getDefaultInstance(), - fullName = "Max Mustermann", - regionId = 16 - ) + private val bavarianBase = + buildCardInfo( + Card.CardInfo.getDefaultInstance(), + fullName = "Max Mustermann", + regionId = 16 + ) - private val nuernbergBase = buildCardInfo( - Card.CardInfo.getDefaultInstance(), - fullName = "Max Mustermann", - regionId = 93, - nuernbergPassId = 99999999, - birthDay = -365 * 10, - expirationDay = 365 * 40 // Equals 14.600 - ) + private val nuernbergBase = + buildCardInfo( + Card.CardInfo.getDefaultInstance(), + fullName = "Max Mustermann", + regionId = 93, + nuernbergPassId = 99999999, + birthDay = -365 * 10, + expirationDay = 365 * 40 // Equals 14.600 + ) + + private val koblenzBase = + buildCardInfo( + Card.CardInfo.getDefaultInstance(), + fullName = "Karla Koblenz", + regionId = 95, + koblenzPassId = "123K", + birthDay = 12213 // 10.06.2003 + ) private fun buildCardInfo( base: Card.CardInfo, @@ -34,6 +46,7 @@ object ExampleCardInfo { birthDay: Int? = null, nuernbergPassId: Int? = null, nuernbergPassIdIdentifier: Card.NuernergPassIdentifier? = null, + koblenzPassId: String? = null, startDay: Int? = null ): Card.CardInfo { val cardInfo = Card.CardInfo.newBuilder(base) @@ -49,40 +62,50 @@ object ExampleCardInfo { nuernbergPassIdIdentifier ) } + if (koblenzPassId != null) extensions.extensionKoblenzPassIdBuilder.setPassId(koblenzPassId) if (startDay != null) extensions.extensionStartDayBuilder.setStartDay(startDay) return cardInfo.buildPartial() } - fun get(cardInfoTestSample: CardInfoTestSample): Card.CardInfo { - return when (cardInfoTestSample) { - CardInfoTestSample.BavarianStandard -> buildCardInfo( - bavarianBase, - expirationDay = 365 * 40, // Equals 14.600 - bavariaCardType = Card.BavariaCardType.STANDARD - ) + fun get(cardInfoTestSample: CardInfoTestSample): Card.CardInfo = + when (cardInfoTestSample) { + CardInfoTestSample.BavarianStandard -> + buildCardInfo( + bavarianBase, + expirationDay = 365 * 40, // Equals 14.600 + bavariaCardType = Card.BavariaCardType.STANDARD + ) - CardInfoTestSample.BavarianGold -> buildCardInfo( - bavarianBase, - bavariaCardType = Card.BavariaCardType.GOLD - ) + CardInfoTestSample.BavarianGold -> + buildCardInfo( + bavarianBase, + bavariaCardType = Card.BavariaCardType.GOLD + ) CardInfoTestSample.Nuernberg -> nuernbergBase - CardInfoTestSample.NuernbergWithStartDay -> buildCardInfo( - nuernbergBase, - startDay = 365 * 2 - ) + CardInfoTestSample.NuernbergWithStartDay -> + buildCardInfo( + nuernbergBase, + startDay = 365 * 2 + ) - CardInfoTestSample.NuernbergWithPassId -> buildCardInfo( - nuernbergBase, - nuernbergPassIdIdentifier = Card.NuernergPassIdentifier.passId, - startDay = 365 * 2 - ) + CardInfoTestSample.NuernbergWithPassId -> + buildCardInfo( + nuernbergBase, + nuernbergPassIdIdentifier = Card.NuernergPassIdentifier.passId, + startDay = 365 * 2 + ) - CardInfoTestSample.NuernbergWithPassNr -> buildCardInfo( - nuernbergBase, - nuernbergPassIdIdentifier = Card.NuernergPassIdentifier.passNr, - startDay = 365 * 2 - ) + CardInfoTestSample.NuernbergWithPassNr -> + buildCardInfo( + nuernbergBase, + nuernbergPassIdIdentifier = Card.NuernergPassIdentifier.passNr, + startDay = 365 * 2 + ) + + CardInfoTestSample.KoblenzPass -> + buildCardInfo( + koblenzBase + ) } - } } diff --git a/backend/src/test/kotlin/app/ehrenamtskarte/backend/verification/Argon2IdHasherTest.kt b/backend/src/test/kotlin/app/ehrenamtskarte/backend/verification/Argon2IdHasherTest.kt new file mode 100644 index 000000000..419e1bf4c --- /dev/null +++ b/backend/src/test/kotlin/app/ehrenamtskarte/backend/verification/Argon2IdHasherTest.kt @@ -0,0 +1,27 @@ +package app.ehrenamtskarte.backend.verification +import Argon2IdHasher +import app.ehrenamtskarte.backend.common.utils.Environment +import app.ehrenamtskarte.backend.common.webservice.KOBLENZ_PEPPER_SYS_ENV +import app.ehrenamtskarte.backend.helper.CardInfoTestSample +import app.ehrenamtskarte.backend.helper.ExampleCardInfo +import io.mockk.every +import io.mockk.mockkObject +import kotlin.test.Test +import kotlin.test.assertEquals + +internal class Argon2IdHasherTest { + @Test + fun isHashingCorrectly() { + mockkObject(Environment) + every { Environment.getVariable(KOBLENZ_PEPPER_SYS_ENV) } returns "123456789ABC" + + assertEquals(Environment.getVariable("KOBLENZ_PEPPER"), "123456789ABC") + + val userData = ExampleCardInfo.get(CardInfoTestSample.KoblenzPass) + + val hash = Argon2IdHasher.hashUserData(userData) + val expectedHash = "\$argon2id\$v=19\$m=16,t=2,p=1\$MTIzNDU2Nzg5QUJD\$xJd35mCTBZT8u+FCGWCnmOtxWzcDTb1Pnt5DHWDap7Y" // This expected output was created with https://argon2.online/ + + assertEquals(expectedHash, hash) + } +} diff --git a/backend/src/test/kotlin/app/ehrenamtskarte/backend/verification/CanonicalJsonTest.kt b/backend/src/test/kotlin/app/ehrenamtskarte/backend/verification/CanonicalJsonTest.kt index 77be8b54f..eaa1753d9 100644 --- a/backend/src/test/kotlin/app/ehrenamtskarte/backend/verification/CanonicalJsonTest.kt +++ b/backend/src/test/kotlin/app/ehrenamtskarte/backend/verification/CanonicalJsonTest.kt @@ -8,7 +8,6 @@ import kotlin.test.assertEquals import kotlin.test.assertFailsWith internal class CanonicalJsonTest { - @Test fun mapEmptyCardInfo() { val cardInfo = Card.CardInfo.newBuilder().build() @@ -18,7 +17,11 @@ internal class CanonicalJsonTest { @Test fun mapCardInfoWithFullName() { val wildName = "Biene Maja ßäЦЧШܐܳܠܰܦ" - val cardInfo = Card.CardInfo.newBuilder().setFullName(wildName).build() + val cardInfo = + Card.CardInfo + .newBuilder() + .setFullName(wildName) + .build() assertEquals(CanonicalJson.messageToMap(cardInfo), mapOf("1" to wildName)) } @@ -31,10 +34,11 @@ internal class CanonicalJsonTest { mapOf( "1" to "Max Mustermann", "2" to "14600", - "3" to mapOf( - "1" to mapOf("1" to "16"), // extensionRegion - "4" to mapOf("1" to "0") // extensionBavariaCardType - ) + "3" to + mapOf( + "1" to mapOf("1" to "16"), // extensionRegion + "4" to mapOf("1" to "0") // extensionBavariaCardType + ) ) ) } @@ -46,10 +50,11 @@ internal class CanonicalJsonTest { CanonicalJson.messageToMap(cardInfo), mapOf( "1" to "Max Mustermann", - "3" to mapOf( - "1" to mapOf("1" to "16"), // extensionRegion - "4" to mapOf("1" to "1") // extensionBavariaCardType - ) + "3" to + mapOf( + "1" to mapOf("1" to "16"), // extensionRegion + "4" to mapOf("1" to "1") // extensionBavariaCardType + ) ) ) } @@ -62,11 +67,12 @@ internal class CanonicalJsonTest { mapOf( "1" to "Max Mustermann", "2" to "14600", - "3" to mapOf( - "1" to mapOf("1" to "93"), // extensionRegion - "2" to mapOf("1" to "-3650"), // extensionBirthday - "3" to mapOf("1" to "99999999") // extensionNuernbergPassId - ) + "3" to + mapOf( + "1" to mapOf("1" to "93"), // extensionRegion + "2" to mapOf("1" to "-3650"), // extensionBirthday + "3" to mapOf("1" to "99999999") // extensionNuernbergPassId + ) ) ) } @@ -79,12 +85,30 @@ internal class CanonicalJsonTest { mapOf( "1" to "Max Mustermann", "2" to "14600", - "3" to mapOf( - "1" to mapOf("1" to "93"), // extensionRegion - "2" to mapOf("1" to "-3650"), // extensionBirthday - "3" to mapOf("1" to "99999999"), // extensionNuernbergPassId - "5" to mapOf("1" to "730") // extensionStartDay - ) + "3" to + mapOf( + "1" to mapOf("1" to "93"), // extensionRegion + "2" to mapOf("1" to "-3650"), // extensionBirthday + "3" to mapOf("1" to "99999999"), // extensionNuernbergPassId + "5" to mapOf("1" to "730") // extensionStartDay + ) + ) + ) + } + + @Test + fun mapCardInfoForKoblenzPass() { + val cardInfo = ExampleCardInfo.get(CardInfoTestSample.KoblenzPass) + assertEquals( + CanonicalJson.messageToMap(cardInfo), + mapOf( + "1" to "Karla Koblenz", + "3" to + mapOf( + "1" to mapOf("1" to "95"), // Koblenz Region + "2" to mapOf("1" to "12213"), // extensionBirthday + "6" to mapOf("1" to "123K") // extensionKoblenzPassId + ) ) ) } @@ -180,16 +204,18 @@ internal class CanonicalJsonTest { @Test fun sortsAndEscapesProperly() { // taken from the rfc: https://www.rfc-editor.org/rfc/rfc8785#section-3.2.3 - val input = mapOf( - "\u20ac" to "Euro Sign", - "\r" to "Carriage Return", - "\ufb33" to "Hebrew Letter Dalet With Dagesh", - "1" to "One", - "\ud83d\ude00" to "Emoji to Grinning Face", - "\u0080" to "Control", - "\u00f6" to "Latin Small Letter O With Diaeresis" - ) - val expected = """{ + val input = + mapOf( + "\u20ac" to "Euro Sign", + "\r" to "Carriage Return", + "\ufb33" to "Hebrew Letter Dalet With Dagesh", + "1" to "One", + "\ud83d\ude00" to "Emoji to Grinning Face", + "\u0080" to "Control", + "\u00f6" to "Latin Small Letter O With Diaeresis" + ) + val expected = + """{ "\r":"Carriage Return", "1":"One", "${"\u0080"}":"Control", @@ -197,7 +223,9 @@ internal class CanonicalJsonTest { "${"\u20ac"}":"Euro Sign", "${"\ud83d\ude00"}":"Emoji to Grinning Face", "${"\ufb33"}":"Hebrew Letter Dalet With Dagesh" - }""".split("\n").joinToString(separator = "") { it.trim() } + }""".split("\n").joinToString(separator = "") { + it.trim() + } val actual = CanonicalJson.serializeToString(input) assertEquals(expected, actual) diff --git a/docs/CreateKoblenzHash.md b/docs/CreateKoblenzHash.md new file mode 100644 index 000000000..671b491ab --- /dev/null +++ b/docs/CreateKoblenzHash.md @@ -0,0 +1,69 @@ +# Creating Hashes for Koblenz Pass Data + +## Steps + +The example data is + +| Parameter | Value | +|--------------|---------------| +| Name | Karla Koblenz | +| Geburtstag | 10.06.2003 | +| Aktenzeichen | 123K | + + +### 1. Collect all data and merge it into an object +```agsl +//Example: +full_name: "Karla Koblenz" +extensions { + extension_region { + regionId: 95 + } + extension_birthday { + birthday: 12213 + } + extension_koblenz_pass_id { + pass_id: "123K" + } +} +``` + +- The full_name must be `FirstnameSpaceLastname`. Every char must exaclty match the user input, as otherwise there is not possiblity to match the data Koblenz transfers with the input the user makes. +e.g. `Karla Koblenz` will match neither with `Karla Lisa Koblenz` nor with `Karlá Koblenz`. +- The birthday is defined in our protobuf [card.proto](../frontend/card.proto) file: It counts the days since the birthday (calculated from 1970-01-01). + All values of this field are valid, including the 0, which indicates that the birthday is on 1970-01-01. Birthdays before 1970-01-01 have negative values. +- extension_region is always 95 for Koblenz +- extension_koblenz_pass_id is set to the "Aktenzeichen" + + +### 2. Convert this object to a Canonical Json + Result should be: + ``` + {"1":"Karla Koblenz","3":{"1":{"1":"95"},"2":{"1":"12213"},"6":{"1":"123K"}}} + ``` + +### 3. Hash it with Argon2id + +Hash with Argon2id with the following parameters: + +| Parameter | Value | +|--------------|--------------------------------------------------------------------------------| +| Version | 19 | +| Iterations | 2 | +| Parallellism | 1 | +| Memory | 16 | +| Salt | Secret Salt will be shared with Koblenz
for the example use `123456789ABC` | + + +### 4. The result... +...for the example data and example salt must be: + +`$argon2id$v=19$m=16,t=2,p=1$MTIzNDU2Nzg5QUJD$KStr3PVblyAh2bIleugv796G+p4pvRNiAON0MHVufVY` + + +## Additional Information + +- Online Argon2 hasher: https://argon2.online/ +- CanonicalJson creation is done in: [CanonicalJson.kt](../backend/src/main/kotlin/app/ehrenamtskarte/backend/verification/CanonicalJson.kt) +- Hashing is done in: [Argon2IdHasher.kt](../backend/src/main/kotlin/app/ehrenamtskarte/backend/verification/Argon2IdHasher.kt) + diff --git a/specs/backend-api.graphql b/specs/backend-api.graphql index 8d9953438..07dbc6a0f 100644 --- a/specs/backend-api.graphql +++ b/specs/backend-api.graphql @@ -128,6 +128,8 @@ type Mutation { createAdministrator(email: String!, project: String!, regionId: Int, role: Role!, sendWelcomeMail: Boolean!): Boolean! "Creates a new digital entitlementcard and returns it" createCardsByCardInfos(applicationIdToMarkAsProcessed: Int, encodedCardInfos: [String!]!, generateStaticCodes: Boolean!, project: String!): [CardCreationResultModel!]! + "Creates a new digital koblenz card and returns it" + createCardsByUserData(encodedCardInfo: String!, project: String!): Boolean! "Deletes an existing administrator" deleteAdministrator(adminId: Int!, project: String!): Boolean! "Deletes the application with specified id" diff --git a/specs/card.proto b/specs/card.proto index fc13270a8..391db3c73 100644 --- a/specs/card.proto +++ b/specs/card.proto @@ -40,6 +40,10 @@ message NuernbergPassIdExtension { optional NuernergPassIdentifier identifier = 2; } +message KoblenzPassIdExtension { + optional string pass_id = 1; +} + enum NuernergPassIdentifier { passNr = 0; passId = 1; @@ -51,6 +55,7 @@ message CardExtensions { optional NuernbergPassIdExtension extension_nuernberg_pass_id = 3; optional BavariaCardTypeExtension extension_bavaria_card_type = 4; optional StartDayExtension extension_start_day = 5; + optional KoblenzPassIdExtension extension_koblenz_pass_id = 6; } // For our hashing approach, we require that all fields (and subfields, recursively) of CardInfo are marked 'optional'.