Skip to content

Commit

Permalink
1433: Create user hashing for Koblenz
Browse files Browse the repository at this point in the history
  • Loading branch information
ztefanie committed Jun 19, 2024
1 parent 3780f9b commit 29ec97a
Show file tree
Hide file tree
Showing 13 changed files with 285 additions and 21 deletions.
5 changes: 5 additions & 0 deletions backend/build.gradle.kts
Original file line number Diff line number Diff line change
Expand Up @@ -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")
Expand All @@ -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
}

Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,10 @@
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? {
return System.getenv(name)
}
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -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
Original file line number Diff line number Diff line change
Expand Up @@ -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")
Original file line number Diff line number Diff line change
@@ -1,15 +1,35 @@
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!")
Expand All @@ -30,22 +50,8 @@ 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/")

}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,80 @@
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.apache.commons.codec.binary.Hex
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<T>[$v=<num>]$m=<num>,t=<num>,p=<num>$<bin>$<bin>}
**/
@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<String, Any>): Boolean {
val hasName = canonicalJson.get("1") != null
val hasExtensions = canonicalJson.get("3") as? Map<String, Any>
val hasKoblenzPassExtension = hasExtensions?.get("6") as? Map<String, String>
val hasKoblenzPassId = hasKoblenzPassExtension?.get("1") != null
return hasName && hasKoblenzPassId
}
}

}
Original file line number Diff line number Diff line change
@@ -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
Expand All @@ -15,6 +18,7 @@ import app.ehrenamtskarte.backend.exception.webservice.exceptions.RegionNotFound
import app.ehrenamtskarte.backend.mail.Mailer
import app.ehrenamtskarte.backend.matomo.Matomo
import app.ehrenamtskarte.backend.regions.database.repos.RegionsRepository
import app.ehrenamtskarte.backend.verification.CanonicalJson
import app.ehrenamtskarte.backend.verification.PEPPER_LENGTH
import app.ehrenamtskarte.backend.verification.database.CodeType
import app.ehrenamtskarte.backend.verification.database.repos.CardRepository
Expand All @@ -33,6 +37,7 @@ import extensionStartDayOrNull
import graphql.schema.DataFetchingEnvironment
import io.ktor.util.decodeBase64Bytes
import io.ktor.util.encodeBase64
import kotlinx.serialization.json.Json
import org.jetbrains.exposed.sql.transactions.transaction
import org.slf4j.LoggerFactory
import java.security.SecureRandom
Expand Down Expand Up @@ -163,6 +168,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<GraphQLContext>()
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,
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -6,7 +6,8 @@ enum class CardInfoTestSample {
Nuernberg,
NuernbergWithStartDay,
NuernbergWithPassId,
NuernbergWithPassNr
NuernbergWithPassNr,
KoblenzPass
}

object ExampleCardInfo {
Expand All @@ -25,6 +26,14 @@ object ExampleCardInfo {
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,
fullName: String? = null,
Expand All @@ -34,6 +43,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)
Expand All @@ -49,6 +59,7 @@ object ExampleCardInfo {
nuernbergPassIdIdentifier
)
}
if (koblenzPassId != null) extensions.extensionKoblenzPassIdBuilder.setPassId(koblenzPassId)
if (startDay != null) extensions.extensionStartDayBuilder.setStartDay(startDay)
return cardInfo.buildPartial()
}
Expand Down Expand Up @@ -83,6 +94,10 @@ object ExampleCardInfo {
nuernbergPassIdIdentifier = Card.NuernergPassIdentifier.passNr,
startDay = 365 * 2
)

CardInfoTestSample.KoblenzPass -> buildCardInfo(
koblenzBase
)
}
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,28 @@
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
import io.mockk.mockkStatic

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)
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -89,6 +89,22 @@ internal class CanonicalJsonTest {
)
}

@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
)
)
)
}

@Test
fun emptyArray() {
val input = emptyList<Any>()
Expand Down
Loading

0 comments on commit 29ec97a

Please sign in to comment.