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
4 changes: 4 additions & 0 deletions backend/build.gradle.kts
Original file line number Diff line number Diff line change
Expand Up @@ -58,6 +58,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 @@ -79,6 +81,8 @@ dependencies {
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,73 @@

import app.ehrenamtskarte.backend.common.utils.Environment
import app.ehrenamtskarte.backend.common.webservice.KOBLENZ_PEPPER_SYS_ENV
import app.ehrenamtskarte.backend.user.KoblenzUser
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, removed the salt from the result
*
* 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)
private fun encodeWithoutSalt(
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)
stringBuilder.append("$").append(b64encoder.encodeToString(hash))
return stringBuilder.toString()
}

fun hashKoblenzUserData(userData: KoblenzUser): String? {
val canonicalJson = CanonicalJson.koblenzUserToString(userData)
val hashLength = 32

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(19)
.build()

val generator = Argon2BytesGenerator()
generator.init(params)
val result = ByteArray(hashLength)
generator.generateBytes(canonicalJson.toByteArray(), result)
return encodeWithoutSalt(result, params)
}
}
}
Original file line number Diff line number Diff line change
@@ -1,5 +1,8 @@
package app.ehrenamtskarte.backend.cards

import app.ehrenamtskarte.backend.user.KoblenzUser
import com.fasterxml.jackson.core.type.TypeReference
import com.fasterxml.jackson.databind.ObjectMapper
import com.google.protobuf.Descriptors
import com.google.protobuf.Descriptors.FieldDescriptor.Type
import com.google.protobuf.GeneratedMessageV3
Expand Down Expand Up @@ -89,6 +92,11 @@ class CanonicalJson {
}
}

fun koblenzUserToString(koblenzUser: KoblenzUser): String {
val map = ObjectMapper().convertValue(koblenzUser, object : TypeReference<Map<String, Any>>() {})
return serializeToString(map)
}

fun serializeToString(message: GeneratedMessageV3) = serializeToString(messageToMap(message))

/**
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.sozialpass.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
@@ -0,0 +1,6 @@

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")

This file was deleted.

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,7 @@
package app.ehrenamtskarte.backend.user
f1sh1918 marked this conversation as resolved.
Show resolved Hide resolved

import com.fasterxml.jackson.annotation.JsonProperty
import com.fasterxml.jackson.annotation.JsonPropertyOrder

@JsonPropertyOrder(alphabetic = true)
data class KoblenzUser(@get:JsonProperty("1") val fullname: String, @get:JsonProperty("2") val birthday: Int, @get:JsonProperty("3") val referenceNumber: String)
Original file line number Diff line number Diff line change
@@ -0,0 +1,23 @@
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.user.KoblenzUser
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 hash = Argon2IdHasher.hashKoblenzUserData(KoblenzUser("Karla Koblenz", 12213, "123K"))
val expectedHash = "\$argon2id\$v=19\$m=19,t=2,p=1\$78SNlzz8llqPwetRlp8Lnag/WZGPc5XbimYbShpwVMQ" // This expected output was created with https://argon2.online/
assertEquals(expectedHash, hash)
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -3,12 +3,13 @@ package app.ehrenamtskarte.backend.cards
import Card
import app.ehrenamtskarte.backend.helper.CardInfoTestSample
import app.ehrenamtskarte.backend.helper.ExampleCardInfo
import app.ehrenamtskarte.backend.helper.koblenzTestUser
import app.ehrenamtskarte.backend.verification.CanonicalJson.Companion.koblenzUserToString
import kotlin.test.Test
import kotlin.test.assertEquals
import kotlin.test.assertFailsWith

internal class CanonicalJsonTest {

@Test
fun mapEmptyCardInfo() {
val cardInfo = Card.CardInfo.newBuilder().build()
Expand All @@ -18,7 +19,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))
}

Expand All @@ -31,10 +36,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
)
)
)
}
Expand All @@ -46,10 +52,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
)
)
)
}
Expand All @@ -62,11 +69,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
)
)
)
}
Expand All @@ -79,16 +87,23 @@ 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 mapUserInfoForKoblenzPass() {
val expected = koblenzUserToString(koblenzTestUser)
assertEquals("{\"1\":\"Karla Koblenz\",\"2\":12213,\"3\":\"123K\"}", expected)
}

@Test
fun emptyArray() {
val input = emptyList<Any>()
Expand Down Expand Up @@ -180,24 +195,28 @@ 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",
"${"\u00f6"}":"Latin Small Letter O With Diaeresis",
"${"\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)
Expand Down
Loading