Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

1433: Create user hashing for Koblenz #1499

Merged
merged 12 commits into from
Jul 30, 2024
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")
ztefanie marked this conversation as resolved.
Show resolved Hide resolved

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,8 @@
package app.ehrenamtskarte.backend.common.utils

// This helper class was created to enable mocking getenv in Tests
f1sh1918 marked this conversation as resolved.
Show resolved Hide resolved
class Environment {
companion object {
fun getVariable(name: String): String? = 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"
ztefanie marked this conversation as resolved.
Show resolved Hide resolved
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,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 {
michael-markl marked this conversation as resolved.
Show resolved Hide resolved
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) {
Expand All @@ -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/")
f1sh1918 marked this conversation as resolved.
Show resolved Hide resolved
}
}
Original file line number Diff line number Diff line change
@@ -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<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
maxammann marked this conversation as resolved.
Show resolved Hide resolved
val pepperByteArray = pepper?.toByteArray(StandardCharsets.UTF_8)
val params =
Argon2Parameters
.Builder(Argon2Parameters.ARGON2_id)
.withVersion(19)
ztefanie marked this conversation as resolved.
Show resolved Hide resolved
.withIterations(2)
.withSalt(pepperByteArray)
f1sh1918 marked this conversation as resolved.
Show resolved Hide resolved
.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)
michael-markl marked this conversation as resolved.
Show resolved Hide resolved
}

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 Down Expand Up @@ -163,6 +166,25 @@ class CardMutationService {
return activationCodes
}

@GraphQLDescription("Creates a new digital koblenz card and returns it")
f1sh1918 marked this conversation as resolved.
Show resolved Hide resolved
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,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,
Expand All @@ -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)
Expand All @@ -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
)
}
}
}
Loading