summaryrefslogtreecommitdiff
path: root/src
diff options
context:
space:
mode:
authorPaul-Christian Volkmer2026-10-05 09:32:16 +0200
committerGitHub2026-10-05 07:32:16 +0000
commit3e49d70ff69b5382e0ad62d8f321aa0c15b1f85b (patch)
tree812359ba361f290a5980eb8de9d3239587b16edc /src
parentd9961a9baccaff511662b1aa1de18bdd02f94f5e (diff)
feat: add SHA256-HMAC for use in build-in pseudonym generator (#324)
Diffstat (limited to 'src')
-rw-r--r--src/integrationTest/kotlin/dev/dnpm/etl/processor/config/AppConfigurationTest.kt12
-rw-r--r--src/main/kotlin/dev/dnpm/etl/processor/config/AppConfigProperties.kt1
-rw-r--r--src/main/kotlin/dev/dnpm/etl/processor/config/AppConfiguration.kt6
-rw-r--r--src/main/kotlin/dev/dnpm/etl/processor/pseudonym/AnonymizingGenerator.kt2
-rw-r--r--src/main/kotlin/dev/dnpm/etl/processor/pseudonym/AnonymizingHmacGenerator.kt52
-rw-r--r--src/test/kotlin/dev/dnpm/etl/processor/pseudonym/PseudonymizeServiceTest.kt123
6 files changed, 147 insertions, 49 deletions
diff --git a/src/integrationTest/kotlin/dev/dnpm/etl/processor/config/AppConfigurationTest.kt b/src/integrationTest/kotlin/dev/dnpm/etl/processor/config/AppConfigurationTest.kt
index f2bfd90..99ef0a2 100644
--- a/src/integrationTest/kotlin/dev/dnpm/etl/processor/config/AppConfigurationTest.kt
+++ b/src/integrationTest/kotlin/dev/dnpm/etl/processor/config/AppConfigurationTest.kt
@@ -32,6 +32,7 @@ import dev.dnpm.etl.processor.output.RestDipMtbFileSender
import dev.dnpm.etl.processor.output.RestNngmMtbFileSender
import dev.dnpm.etl.processor.output.RoutedRestNngmMtbFileSender
import dev.dnpm.etl.processor.pseudonym.AnonymizingGenerator
+import dev.dnpm.etl.processor.pseudonym.AnonymizingHmacGenerator
import dev.dnpm.etl.processor.pseudonym.GpasPseudonymGenerator
import dev.dnpm.etl.processor.pseudonym.GpasSoapPseudonymGenerator
import dev.dnpm.etl.processor.security.TokenRepository
@@ -228,6 +229,17 @@ class AppConfigurationTest {
}
@Nested
+ @TestPropertySource(properties = ["app.pseudonymize.generator=buildin", "app.pseudonymize.hmac-key=Test"])
+ inner class AppConfigurationPseudonymizeGeneratorBuildinHmacTest(
+ private val context: ApplicationContext,
+ ) {
+ @Test
+ fun shouldUseConfiguredGenerator() {
+ assertThat(context.getBean<AnonymizingHmacGenerator>()).isNotNull
+ }
+ }
+
+ @Nested
@TestPropertySource(
properties =
["app.pseudonymize.generator=gpas", "app.pseudonymize.gpas.uri=http://localhost/"],
diff --git a/src/main/kotlin/dev/dnpm/etl/processor/config/AppConfigProperties.kt b/src/main/kotlin/dev/dnpm/etl/processor/config/AppConfigProperties.kt
index fb93618..f0766a4 100644
--- a/src/main/kotlin/dev/dnpm/etl/processor/config/AppConfigProperties.kt
+++ b/src/main/kotlin/dev/dnpm/etl/processor/config/AppConfigProperties.kt
@@ -41,6 +41,7 @@ data class AppConfigProperties(
data class PseudonymizeConfigProperties(
var generator: PseudonymGenerator = PseudonymGenerator.BUILDIN,
val prefix: String = "UNKNOWN",
+ val hmacKey: String? = null,
) {
companion object {
const val NAME = "app.pseudonymize"
diff --git a/src/main/kotlin/dev/dnpm/etl/processor/config/AppConfiguration.kt b/src/main/kotlin/dev/dnpm/etl/processor/config/AppConfiguration.kt
index d780642..54f4c6f 100644
--- a/src/main/kotlin/dev/dnpm/etl/processor/config/AppConfiguration.kt
+++ b/src/main/kotlin/dev/dnpm/etl/processor/config/AppConfiguration.kt
@@ -155,7 +155,11 @@ class AppConfiguration {
matchIfMissing = true,
)
@Bean
- fun buildinPseudonymGenerator(): Generator {
+ fun buildinPseudonymGenerator(pseudonymizeConfigProperties: PseudonymizeConfigProperties): Generator {
+ if (null != pseudonymizeConfigProperties.hmacKey) {
+ logger.info("Selected 'BUILDIN Pseudonym Generator' using HMAC")
+ return AnonymizingHmacGenerator(pseudonymizeConfigProperties)
+ }
logger.info("Selected 'BUILDIN Pseudonym Generator'")
return AnonymizingGenerator()
}
diff --git a/src/main/kotlin/dev/dnpm/etl/processor/pseudonym/AnonymizingGenerator.kt b/src/main/kotlin/dev/dnpm/etl/processor/pseudonym/AnonymizingGenerator.kt
index b3c80e2..5e97568 100644
--- a/src/main/kotlin/dev/dnpm/etl/processor/pseudonym/AnonymizingGenerator.kt
+++ b/src/main/kotlin/dev/dnpm/etl/processor/pseudonym/AnonymizingGenerator.kt
@@ -25,8 +25,6 @@ import org.apache.commons.codec.digest.DigestUtils
import java.security.SecureRandom
class AnonymizingGenerator : Generator {
- companion object
-
fun getSecureRandom(): SecureRandom = SecureRandom()
override fun generate(id: String): String = Base32().encodeAsString(DigestUtils.sha256(id)).substring(0..41).lowercase()
diff --git a/src/main/kotlin/dev/dnpm/etl/processor/pseudonym/AnonymizingHmacGenerator.kt b/src/main/kotlin/dev/dnpm/etl/processor/pseudonym/AnonymizingHmacGenerator.kt
new file mode 100644
index 0000000..646f6d3
--- /dev/null
+++ b/src/main/kotlin/dev/dnpm/etl/processor/pseudonym/AnonymizingHmacGenerator.kt
@@ -0,0 +1,52 @@
+/*
+ * This file is part of ETL-Processor
+ *
+ * Copyright (c) 2023 Comprehensive Cancer Center Mainfranken
+ * Copyright (c) 2023-2026 Paul-Christian Volkmer
+ *
+ * This program is free software: you can redistribute it and/or modify
+ * it under the terms of the GNU Affero General Public License as published
+ * by the Free Software Foundation, either version 3 of the License, or
+ * (at your option) any later version.
+ *
+ * This program is distributed in the hope that it will be useful,
+ * but WITHOUT ANY WARRANTY; without even the implied warranty of
+ * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+ * GNU Affero General Public License for more details.
+ *
+ * You should have received a copy of the GNU Affero General Public License
+ * along with this program. If not, see <https://www.gnu.org/licenses/>.
+ */
+
+package dev.dnpm.etl.processor.pseudonym
+
+import dev.dnpm.etl.processor.config.AppConfigProperties
+import dev.dnpm.etl.processor.config.PseudonymizeConfigProperties
+import org.apache.commons.codec.binary.Base32
+import org.apache.commons.codec.digest.DigestUtils
+import org.apache.commons.codec.digest.HmacAlgorithms
+import org.apache.commons.codec.digest.HmacUtils
+import java.security.SecureRandom
+
+/**
+ * Variant of AnonymizingGenerator that uses app.pseudonymize.prefix as HMAC-SHA256 key
+ */
+class AnonymizingHmacGenerator(
+ private val pseudonymizeConfigProperties: PseudonymizeConfigProperties,
+) : Generator {
+ fun getSecureRandom(): SecureRandom = SecureRandom()
+
+ override fun generate(id: String): String =
+ Base32()
+ .encodeAsString(HmacUtils(HmacAlgorithms.HMAC_SHA_256, pseudonymizeConfigProperties.hmacKey).hmac(id))
+ .substring(0..41)
+ .lowercase()
+
+ @OptIn(ExperimentalStdlibApi::class)
+ override fun generateGenomDeTan(id: String): String {
+ val bytes = ByteArray(64 / 2)
+ getSecureRandom().nextBytes(bytes)
+
+ return bytes.joinToString("") { "%02x".format(it) }
+ }
+}
diff --git a/src/test/kotlin/dev/dnpm/etl/processor/pseudonym/PseudonymizeServiceTest.kt b/src/test/kotlin/dev/dnpm/etl/processor/pseudonym/PseudonymizeServiceTest.kt
index 00487d0..3ac4df6 100644
--- a/src/test/kotlin/dev/dnpm/etl/processor/pseudonym/PseudonymizeServiceTest.kt
+++ b/src/test/kotlin/dev/dnpm/etl/processor/pseudonym/PseudonymizeServiceTest.kt
@@ -20,6 +20,7 @@
package dev.dnpm.etl.processor.pseudonym
+import dev.dnpm.etl.processor.config.PseudonymGenerator
import dev.dnpm.etl.processor.config.PseudonymizeConfigProperties
import dev.pcvolkmer.mv64e.model.MtbEpisodeOfCare
import dev.pcvolkmer.mv64e.model.Patient
@@ -31,72 +32,102 @@ import java.util.*
import org.assertj.core.api.Assertions.assertThat
import org.junit.jupiter.api.Test
import org.junit.jupiter.api.extension.ExtendWith
+import org.junit.jupiter.params.ParameterizedTest
+import org.junit.jupiter.params.provider.Arguments
+import org.junit.jupiter.params.provider.MethodSource
import org.mockito.ArgumentMatchers.anyString
import org.mockito.Mock
import org.mockito.junit.jupiter.MockitoExtension
import org.mockito.kotlin.doAnswer
import org.mockito.kotlin.whenever
+import java.util.stream.Stream
@ExtendWith(MockitoExtension::class)
class PseudonymizeServiceTest {
- private val mtbFile =
- PatientRecord.builder()
- .patient(Patient.builder().id("123").build())
- .episodesOfCare(
- listOf(
- MtbEpisodeOfCare.builder()
- .id("1")
- .patient(Reference.builder().id("123").build())
- .period(
- PeriodDate.builder()
- .start(Date.from(Instant.parse("2021-01-01T00:00:00.00Z")))
- .build()
- )
- .build()
- )
- )
- .build()
-
- @Test
- fun shouldNotUsePseudonymPrefixForGpas(@Mock generator: GpasPseudonymGenerator) {
- doAnswer { it.arguments[0] }.whenever(generator).generate(anyString())
+ private val mtbFile =
+ PatientRecord.builder()
+ .patient(Patient.builder().id("123").build())
+ .episodesOfCare(
+ listOf(
+ MtbEpisodeOfCare.builder()
+ .id("1")
+ .patient(Reference.builder().id("123").build())
+ .period(
+ PeriodDate.builder()
+ .start(Date.from(Instant.parse("2021-01-01T00:00:00.00Z")))
+ .build()
+ )
+ .build()
+ )
+ )
+ .build()
+
+ @Test
+ fun shouldNotUsePseudonymPrefixForGpas(@Mock generator: GpasPseudonymGenerator) {
+ doAnswer { it.arguments[0] }.whenever(generator).generate(anyString())
+
+ val pseudonymizeService = PseudonymizeService(generator, PseudonymizeConfigProperties())
+
+ mtbFile.pseudonymizeWith(pseudonymizeService)
+
+ assertThat(mtbFile.patient?.id).isEqualTo("123")
+ }
- val pseudonymizeService = PseudonymizeService(generator, PseudonymizeConfigProperties())
+ @Test
+ fun sanitizeFileName() {
+ val result = GpasPseudonymGenerator.sanitizeValue("l://a\\bs;1*2?3>")
- mtbFile.pseudonymizeWith(pseudonymizeService)
+ assertThat(result).isEqualTo("l___a_bs_1_2_3_")
+ }
- assertThat(mtbFile.patient?.id).isEqualTo("123")
- }
+ @Test
+ fun shouldUsePseudonymPrefixForBuiltin(@Mock generator: AnonymizingGenerator) {
+ doAnswer { it.arguments[0] }.whenever(generator).generate(anyString())
- @Test
- fun sanitizeFileName() {
- val result = GpasPseudonymGenerator.sanitizeValue("l://a\\bs;1*2?3>")
+ val pseudonymizeService = PseudonymizeService(generator, PseudonymizeConfigProperties())
- assertThat(result).isEqualTo("l___a_bs_1_2_3_")
- }
+ mtbFile.pseudonymizeWith(pseudonymizeService)
- @Test
- fun shouldUsePseudonymPrefixForBuiltin(@Mock generator: AnonymizingGenerator) {
- doAnswer { it.arguments[0] }.whenever(generator).generate(anyString())
+ assertThat(mtbFile.patient?.id).isEqualTo("UNKNOWN_123")
+ }
- val pseudonymizeService = PseudonymizeService(generator, PseudonymizeConfigProperties())
+ @Test
+ fun shouldReturnDifferentValues() {
+ val ag = AnonymizingGenerator()
- mtbFile.pseudonymizeWith(pseudonymizeService)
+ val tans = HashSet<String>()
- assertThat(mtbFile.patient?.id).isEqualTo("UNKNOWN_123")
- }
+ (1..1000).forEach { i ->
+ val tan = ag.generateGenomDeTan("12345")
+ assertThat(tan).hasSize(64)
+ assertThat(tans.add(tan)).`as`("never the same result!").isTrue
+ }
+ }
- @Test
- fun shouldReturnDifferentValues() {
- val ag = AnonymizingGenerator()
+ @ParameterizedTest
+ @MethodSource("providePseudonymizeServiceTestProvider")
+ fun shouldGenerateExpectedPseudonym(generator: Generator, expected: String) {
+ val pseudonymizeService = PseudonymizeService(generator, PseudonymizeConfigProperties())
+ mtbFile.pseudonymizeWith(pseudonymizeService)
+ assertThat(mtbFile.patient?.id).isEqualTo(expected)
+ }
- val tans = HashSet<String>()
+ companion object {
+
+ @JvmStatic
+ fun providePseudonymizeServiceTestProvider(): Stream<Arguments> {
+ return Stream.of(
+ Arguments.of(AnonymizingGenerator(), "UNKNOWN_uzs2iwjaiixz2ql6jbt67xcpxcqeuhz774p2a7uzr2"),
+ Arguments.of(
+ AnonymizingHmacGenerator(
+ PseudonymizeConfigProperties(
+ hmacKey = "TEST"
+ )
+ ), "UNKNOWN_3u2nc3hzwbe2kylbmcrhoahgkisxl5jfbcwr3wonr3"
+ )
+ )
+ }
- (1..1000).forEach { i ->
- val tan = ag.generateGenomDeTan("12345")
- assertThat(tan).hasSize(64)
- assertThat(tans.add(tan)).`as`("never the same result!").isTrue
}
- }
}