From 3e49d70ff69b5382e0ad62d8f321aa0c15b1f85b Mon Sep 17 00:00:00 2001 From: Paul-Christian Volkmer Date: Mon, 5 Oct 2026 09:32:16 +0200 Subject: feat: add SHA256-HMAC for use in build-in pseudonym generator (#324) --- .../etl/processor/config/AppConfigurationTest.kt | 12 ++ .../etl/processor/config/AppConfigProperties.kt | 1 + .../dnpm/etl/processor/config/AppConfiguration.kt | 6 +- .../processor/pseudonym/AnonymizingGenerator.kt | 2 - .../pseudonym/AnonymizingHmacGenerator.kt | 52 +++++++++ .../processor/pseudonym/PseudonymizeServiceTest.kt | 123 +++++++++++++-------- 6 files changed, 147 insertions(+), 49 deletions(-) create mode 100644 src/main/kotlin/dev/dnpm/etl/processor/pseudonym/AnonymizingHmacGenerator.kt (limited to 'src') 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 @@ -227,6 +228,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()).isNotNull + } + } + @Nested @TestPropertySource( properties = 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 . + */ + +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() - 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() + companion object { + + @JvmStatic + fun providePseudonymizeServiceTestProvider(): Stream { + 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 } - } } -- cgit v1.2.3