Skip to content

Dev/impl helse #139

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

Draft
wants to merge 23 commits into
base: main
Choose a base branch
from
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
23 commits
Select commit Hold shift + click to select a range
d054f16
feat: added HelseID validation to get NIN in Processor.kt flow valida…
ivanskodje May 20, 2025
53a76fd
chore: ktlinth
ivanskodje May 20, 2025
e184840
refactor: simplified tests, fixed invalid testdata
ivanskodje May 20, 2025
9cae9f2
refactor: removed a lot of unused code
ivanskodje May 21, 2025
bbd1a32
refactor: removed a lot of unused code
ivanskodje May 21, 2025
b6d048f
refactor: removed a lot of unused code
ivanskodje May 21, 2025
1f4e458
refactor: removed a lot of unused code
ivanskodje May 21, 2025
b809fb3
refactor: removed a lot of unused code
ivanskodje May 21, 2025
3d999f2
refactor: removed a lot of unused code
ivanskodje May 21, 2025
c9f93df
refactor: simplified XPath code, removed unused code, simplified Hels…
ivanskodje May 22, 2025
049f171
refactor: simplified namespace context code and made it understandabl…
ivanskodje May 22, 2025
fa4b84f
refactor: extracted out a common class NinResolver to handle both OCS…
ivanskodje May 23, 2025
b1da126
refactor: simplified NinResolver
ivanskodje May 23, 2025
b9fd5d3
chore: ktlint
ivanskodje May 23, 2025
8252959
fix: set timezone to Europe/Oslo to ensure we dont get issue with dif…
ivanskodje May 23, 2025
090a067
chore: ktlint
ivanskodje May 23, 2025
1fdee21
chore: ktlint and hopefully timezone fix
ivanskodje May 23, 2025
fb6c443
Merge remote-tracking branch 'origin/main' into dev/impl-helse-id
ivanskodje May 27, 2025
ef75145
Merge branch 'main' into dev/impl-helse-id
RettIProd May 30, 2025
6b8db65
Endret accepted scopes
RettIProd May 30, 2025
7cd91b7
ktlintformat
RettIProd May 30, 2025
f97afa8
Tester borked ifm accepted scopes
RettIProd May 30, 2025
83890f7
Merge remote-tracking branch 'origin/main' into dev/impl-helse-id
ivanskodje Jun 10, 2025
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions ebms-payload/build.gradle.kts
Original file line number Diff line number Diff line change
Expand Up @@ -55,6 +55,7 @@ dependencies {
implementation(libs.bundles.prometheus)
implementation(libs.emottak.payload.xsd)
implementation(libs.emottak.utils)
implementation("net.sf.saxon:Saxon-HE:12.7")
implementation("com.fasterxml.jackson.module:jackson-module-kotlin:2.7.1-2")
implementation("com.fasterxml.jackson.dataformat:jackson-dataformat-yaml:2.7.1")
runtimeOnly("net.java.dev.jna:jna:5.12.1")
Expand Down
38 changes: 17 additions & 21 deletions ebms-payload/src/main/kotlin/no/nav/emottak/payload/Processor.kt
Original file line number Diff line number Diff line change
@@ -1,14 +1,12 @@
package no.nav.emottak.payload

import no.nav.emottak.crypto.KeyStoreManager
import no.nav.emottak.message.model.Payload
import no.nav.emottak.message.model.PayloadRequest
import no.nav.emottak.payload.crypto.Dekryptering
import no.nav.emottak.payload.crypto.Kryptering
import no.nav.emottak.payload.crypto.PayloadSignering
import no.nav.emottak.payload.crypto.payloadSigneringConfig
import no.nav.emottak.payload.helseid.NinResolver
import no.nav.emottak.payload.juridisklogg.JuridiskLoggService
import no.nav.emottak.payload.ocspstatus.OcspStatusService
import no.nav.emottak.payload.util.GZipUtil
import no.nav.emottak.util.createDocument
import no.nav.emottak.util.createX509Certificate
Expand All @@ -20,22 +18,17 @@ import org.slf4j.Marker
import java.io.ByteArrayInputStream

val processor = Processor()

class Processor(
private val kryptering: Kryptering = Kryptering(),
private val dekryptering: Dekryptering = Dekryptering(),
private val signering: PayloadSignering = PayloadSignering(),
private val gZipUtil: GZipUtil = GZipUtil(),
private val signatureVerifisering: SignaturVerifisering = SignaturVerifisering(),
private val juridiskLogging: JuridiskLoggService = JuridiskLoggService()
private val juridiskLogging: JuridiskLoggService = JuridiskLoggService(),
private val ninResolver: NinResolver = NinResolver()
) {

private val ocspStatusService = OcspStatusService(
defaultHttpClient().invoke(),
KeyStoreManager(
payloadSigneringConfig() // TODO add commfides config
)
)

suspend fun loggMessageToJuridiskLogg(payloadRequest: PayloadRequest): String? {
log.info(payloadRequest.marker(), "Save message to juridisk logg")
try {
Expand All @@ -62,27 +55,28 @@ class Processor(
)
}

suspend fun validateReadablePayload(marker: Marker, payload: Payload, validateSignature: Boolean, validateOcsp: Boolean): Payload {
suspend fun validateReadablePayload(
marker: Marker,
payload: Payload,
validateSignature: Boolean,
validateOcsp: Boolean
): Payload {
if (validateSignature) {
log.debug(marker, "Validating signature for payload")

signatureVerifisering.validate(payload.bytes)
}
return if (validateOcsp) {
log.debug(marker, "Validating OCSP for payload: Step 1 create DOM")
val dom = createDocument(ByteArrayInputStream(payload.bytes))
log.debug(marker, "Validating for payload in validateOcsp flow")
val domDocument = createDocument(ByteArrayInputStream(payload.bytes))

log.debug(marker, "Validating OCSP for payload: Step 2 retrieve signature element")
val xmlSignature = dom.retrieveSignatureElement()
val xmlSignature = domDocument.retrieveSignatureElement()

log.debug(marker, "Validating OCSP for payload: Step 3 get certificate from signature")
val certificateFromSignature = xmlSignature.keyInfo.x509Certificate

log.debug(marker, "Validating OCSP for payload: Step 4 fnr from getOCSPStatus")
val signedBy = ocspStatusService.getOCSPStatus(certificateFromSignature).fnr
var signedByFnr: String? = ninResolver.resolve(domDocument, certificateFromSignature)

log.debug(marker, "Validating OCSP for payload: Step 5 copy")
payload.copy(signedBy = signedBy)
payload.copy(signedBy = signedByFnr)
} else {
payload
}
Expand All @@ -101,6 +95,7 @@ class Processor(
)
.also { log.info(payloadRequest.marker(), "Payload signert") }
}

false -> it.bytes
}
}.let {
Expand All @@ -118,6 +113,7 @@ class Processor(
}
}
}

false -> payloadRequest.payload.copy(bytes = it)
}
}
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,167 @@
package no.nav.emottak.payload.helseid

import com.nimbusds.jose.JOSEObjectType
import com.nimbusds.jose.JWSAlgorithm
import com.nimbusds.jwt.JWTClaimNames
import com.nimbusds.jwt.JWTClaimsSet
import com.nimbusds.jwt.JWTParser
import com.nimbusds.jwt.SignedJWT
import no.nav.emottak.payload.helseid.util.XPathEvaluator
import no.nav.emottak.payload.helseid.util.msgHeadNamespaceContext
import no.nav.emottak.utils.environment.getEnvVar
import org.w3c.dom.Document
import java.text.ParseException
import java.time.Instant
import java.time.ZoneId
import java.time.format.DateTimeFormatter
import java.util.Base64
import java.util.Date
import java.util.Locale

val HELSE_ID_ISSUER = getEnvVar("HELSE_ID_ISSUER", "https://helseid-sts.test.nhn.no")

class HelseIdTokenValidator(
private val issuer: String = HELSE_ID_ISSUER,
private val allowedClockSkewInMs: Long = 0
) {
fun getValidatedNin(base64Token: String, timestamp: Instant): String? = parseSignedJwt(base64Token).also {
validateHeader(it)
validateClaims(it, Date.from(timestamp))
}.let(::extractNin)

fun getNin(base64Token: String): String = extractNin(parseSignedJwt(base64Token))

fun getNin(signedJwt: SignedJWT): String = extractNin(signedJwt)

fun getHelseIdTokenFromDocument(document: Document): String? {
val nodes = XPathEvaluator.nodesAt(
document,
msgHeadNamespaceContext,
"/mh:MsgHead/mh:Document/mh:RefDoc[mh:MsgType/@V='A']" + "[mh:MimeType/text()='application/jwt' or mh:MimeType/text()='application/json']" + "/mh:Content/bas:Base64Container"
)
return when (nodes.size) {
0 -> null
1 -> XPathEvaluator.stringAt(nodes[0], msgHeadNamespaceContext, "text()")
else -> error("unable to determine which of the ${nodes.size} attachments that is HelseID-token")
}
}

private fun parseSignedJwt(base64Token: String): SignedJWT {
val compactToken = if ('.' in base64Token) base64Token else String(Base64.getDecoder().decode(base64Token))

val jwt = try {
JWTParser.parse(compactToken)
} catch (ex: ParseException) {
throw RuntimeException("Failed to parse token", ex)
}

if (jwt !is SignedJWT) error("token is unsigned")
if (jwt.header.algorithm !in SUPPORTED_ALGORITHMS) error("Token is not signed with an approved algorithm")
return jwt
}

private fun validateHeader(jwt: SignedJWT) {
if (jwt.header.type !in SUPPORTED_JWT_TYPES) error("Unsupported token type ${jwt.header.type}")
}

private fun validateClaims(jwt: SignedJWT, timestamp: Date) = try {
jwt.jwtClaimsSet
} catch (ex: ParseException) {
throw RuntimeException("Failed to parse claims", ex)
}.also { claims ->
validateTimestamps(claims, timestamp)
if (claims.issuer != issuer) error("Invalid issuer ${claims.issuer}")
validateEssentialClaims(claims)
}

private fun validateTimestamps(claims: JWTClaimsSet, now: Date) {
issuedAt(claims)?.let { iat ->
if (now.time < iat.time - allowedClockSkewInMs) {
error("${timePrefix(now)} is before issued time ${timePrefix(iat)}")
}
}
claims.expirationTime?.let { exp ->
if (now.time > exp.time + allowedClockSkewInMs) {
error("${timePrefix(now)} is after expiry time ${timePrefix(exp)}")
}
}
claims.notBeforeTime?.let { nbf ->
if (now.time < nbf.time - allowedClockSkewInMs) {
error("${timePrefix(now)} is before not-before time ${timePrefix(nbf)}")
}
}
authTime(claims)?.let { at ->
if (now.time < at.time - allowedClockSkewInMs) {
error("${timePrefix(now)} is before auth-time ${timePrefix(at)}")
}
}
}

private fun validateEssentialClaims(claims: JWTClaimsSet) {
if (claims.getClaim(SECURITY_LEVEL_CLAIM) != SECURITY_LEVEL) {
error("Invalid security-level")
}
if (claims.audience.none { it in SUPPORTED_AUDIENCE }) {
error("Token does not contain required audience")
}
if (getStringArray(claims, "scope").none { it in SUPPORTED_SCOPES }) {
error("Token does not contain required scope")
}
}

private fun extractNin(jwt: SignedJWT): String = getString(jwt.jwtClaimsSet, PID_CLAIM)

private fun issuedAt(claims: JWTClaimsSet): Date? =
(claims.getClaim(JWTClaimNames.ISSUED_AT) as? Number)?.let { Date(it.toLong() * 1000) }

private fun authTime(claims: JWTClaimsSet): Date? =
(claims.getClaim("auth_time") as? Number)?.let { Date(it.toLong()) }

private fun getString(claims: JWTClaimsSet, name: String): String = try {
claims.getStringClaim(name)
} catch (ex: ParseException) {
throw RuntimeException("failed to read claim '$name'", ex)
}

private fun getStringArray(claims: JWTClaimsSet, name: String): Array<String> = try {
claims.getStringArrayClaim(name)
} catch (ex: ParseException) {
throw RuntimeException("failed to read claim '$name'", ex)
}

private fun timePrefix(date: Date): String = DATE_FMT.format(date.toInstant())

companion object {
private val OSLO_ZONE: ZoneId = ZoneId.of("Europe/Oslo")
private val DATE_FMT: DateTimeFormatter =
DateTimeFormatter
.ofPattern("EEE MMM dd HH:mm:ss zzz yyyy", Locale.US)
.withZone(OSLO_ZONE)
private val SUPPORTED_ALGORITHMS = listOf(
JWSAlgorithm.RS256,
JWSAlgorithm.RS384,
JWSAlgorithm.RS512,
JWSAlgorithm.PS256,
JWSAlgorithm.PS384,
JWSAlgorithm.PS512,
JWSAlgorithm.ES256,
JWSAlgorithm.ES384,
JWSAlgorithm.ES512
)
internal val SUPPORTED_AUDIENCE = listOf(
"nav:sign-message"
)
internal val SUPPORTED_SCOPES = listOf(
"nav:sign-message/msghead"
)
private val SUPPORTED_JWT_TYPES = listOf(
JOSEObjectType.JWT,
JOSEObjectType("at+jwt"),
JOSEObjectType("application/at+jwt")
)

private const val SECURITY_LEVEL = "4"
private const val SECURITY_LEVEL_CLAIM = "helseid://claims/identity/security_level"
private const val PID_CLAIM = "helseid://claims/identity/pid"
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,45 @@
package no.nav.emottak.payload.helseid

import no.nav.emottak.crypto.KeyStoreManager
import no.nav.emottak.payload.crypto.payloadSigneringConfig
import no.nav.emottak.payload.defaultHttpClient
import no.nav.emottak.payload.helseid.util.msgHeadNamespaceContext
import no.nav.emottak.payload.ocspstatus.OcspStatusService
import org.slf4j.LoggerFactory
import org.w3c.dom.Document
import java.security.cert.X509Certificate
import java.time.Instant
import java.time.OffsetDateTime
import java.time.format.DateTimeFormatter

class NinResolver(
private val tokenValidator: HelseIdTokenValidator = HelseIdTokenValidator(),
private val ocspStatusService: OcspStatusService = OcspStatusService(
defaultHttpClient().invoke(),
KeyStoreManager(payloadSigneringConfig())
)
) {
private val log = LoggerFactory.getLogger(NinResolver::class.java)

suspend fun resolve(document: Document, certificate: X509Certificate): String? {
val token = tokenValidator.getHelseIdTokenFromDocument(document)

val nin = token?.let {
runCatching {
tokenValidator.getValidatedNin(it, parseDateOrThrow(extractGeneratedDate(document)))
}.onFailure { log.error("HelseID validation failed", it) }.getOrNull()
}

return nin ?: ocspStatusService.getOCSPStatus(certificate).fnr
}

private fun extractGeneratedDate(document: Document): String? {
val ns = msgHeadNamespaceContext.getNamespaceURI("mh") ?: return null
return document.getElementsByTagNameNS(ns, "GenDate").item(0)?.textContent
}

private fun parseDateOrThrow(date: String?): Instant {
requireNotNull(date) { "GenDate missing or empty in document" }
return OffsetDateTime.parse(date, DateTimeFormatter.ISO_OFFSET_DATE_TIME).toInstant()
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,25 @@
package no.nav.emottak.payload.helseid.util

import org.apache.xml.security.utils.Constants
import javax.xml.XMLConstants
import javax.xml.namespace.NamespaceContext

val msgHeadNamespaceContext: NamespaceContext = object : NamespaceContext {

private val prefixToUri = mapOf(
"dsig" to Constants.SignatureSpecNS,
"xades" to "http://uri.etsi.org/01903/v1.3.2#",
"dss" to "urn:oasis:names:tc:dss:1.0:core:schema",
"mh" to "http://www.kith.no/xmlstds/msghead/2006-05-24",
"bas" to "http://www.kith.no/xmlstds/base64container"
)

override fun getNamespaceURI(prefix: String?): String =
prefixToUri[prefix] ?: XMLConstants.NULL_NS_URI

override fun getPrefix(namespaceUri: String?): String? =
prefixToUri.entries.firstOrNull { it.value == namespaceUri }?.key

override fun getPrefixes(namespaceUri: String?): Iterator<String> =
prefixToUri.filterValues { it == namespaceUri }.keys.iterator()
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,45 @@
package no.nav.emottak.payload.helseid.util

import org.w3c.dom.Node
import org.w3c.dom.NodeList
import javax.xml.namespace.NamespaceContext
import javax.xml.namespace.QName
import javax.xml.xpath.XPathConstants
import javax.xml.xpath.XPathExpressionException
import javax.xml.xpath.XPathFactory

object XPathEvaluator {

private val xPathFactory: XPathFactory = XPathFactory.newInstance()

fun stringAt(node: Node, namespaceContext: NamespaceContext?, xPath: String): String? =
(evaluate(node, namespaceContext, xPath, XPathConstants.STRING) as String?)
?.trim()
?.replace(MULTIPLE_WHITESPACE_REGEX, " ")
?.ifBlank { null }

fun nodesAt(node: Node, namespaceContext: NamespaceContext?, xPath: String): List<Node> {
val nodeList = evaluate(node, namespaceContext, xPath, XPathConstants.NODESET) as NodeList
val nodes = ArrayList<Node>(nodeList.length)
for (index in 0 until nodeList.length) nodes.add(nodeList.item(index))
return nodes
}

private fun evaluate(
contextNode: Node,
namespaceContext: NamespaceContext?,
xPathExpression: String,
expectedResultType: QName
) = try {
val xPath = xPathFactory.newXPath()
if (namespaceContext != null) {
xPath.namespaceContext = namespaceContext
}
val compiledExpression = xPath.compile(xPathExpression)
compiledExpression.evaluate(contextNode, expectedResultType)
} catch (ex: XPathExpressionException) {
throw RuntimeException("Invalid XPath expression", ex)
}

private val MULTIPLE_WHITESPACE_REGEX = Regex("\\s+")
}
Loading