feat(middleware): Add middleware

This commit is contained in:
2025-07-13 09:00:40 +02:00
parent 0a252aa4df
commit db654695f2
17 changed files with 1189 additions and 0 deletions

View File

@@ -0,0 +1,11 @@
package com.betriebsratkanzlei.legalconsenthub_middleware
import org.springframework.boot.autoconfigure.SpringBootApplication
import org.springframework.boot.runApplication
@SpringBootApplication
class LegalconsenthubMiddlewareApplication
fun main(args: Array<String>) {
runApplication<LegalconsenthubMiddlewareApplication>(*args)
}

View File

@@ -0,0 +1,73 @@
package com.betriebsratkanzlei.legalconsenthub_middleware.exception
import com.betriebsratkanzlei.legalconsenthub_middleware.smartcard.SmartCardNotFoundException
import com.betriebsratkanzlei.legalconsenthub_middleware.smartcard.SmartCardReadException
import com.betriebsratkanzlei.legalconsenthub_middleware.smartcard.SmartCardUnavailableException
import org.springframework.http.HttpStatus
import org.springframework.http.ResponseEntity
import org.springframework.web.bind.annotation.ExceptionHandler
import org.springframework.web.bind.annotation.RestControllerAdvice
import java.time.Instant
@RestControllerAdvice
class GlobalExceptionHandler {
@ExceptionHandler(SmartCardNotFoundException::class)
fun handleSmartCardNotFoundException(ex: SmartCardNotFoundException): ResponseEntity<Map<String, Any>> {
val problem = mutableMapOf<String, Any>()
problem["type"] = "https://problems-registry.smartbear.com/not-found"
problem["title"] = "Smart Card Not Found"
problem["status"] = HttpStatus.NOT_FOUND.value()
problem["detail"] = ex.message ?: "Smart card not found"
problem["instance"] = "/smart-card/info"
problem["timestamp"] = Instant.now().toString()
return ResponseEntity.status(HttpStatus.NOT_FOUND)
.header("Content-Type", "application/problem+json")
.body(problem)
}
@ExceptionHandler(SmartCardReadException::class)
fun handleSmartCardReadException(ex: SmartCardReadException): ResponseEntity<Map<String, Any>> {
val problem = mutableMapOf<String, Any>()
problem["type"] = "https://problems-registry.smartbear.com/server-error"
problem["title"] = "Smart Card Read Error"
problem["status"] = HttpStatus.INTERNAL_SERVER_ERROR.value()
problem["detail"] = ex.message ?: "Error reading smart card"
problem["instance"] = "/smart-card/info"
problem["timestamp"] = Instant.now().toString()
return ResponseEntity.status(HttpStatus.INTERNAL_SERVER_ERROR)
.header("Content-Type", "application/problem+json")
.body(problem)
}
@ExceptionHandler(SmartCardUnavailableException::class)
fun handleSmartCardUnavailableException(ex: SmartCardUnavailableException): ResponseEntity<Map<String, Any>> {
val problem = mutableMapOf<String, Any>()
problem["type"] = "https://problems-registry.smartbear.com/service-unavailable"
problem["title"] = "Smart Card Service Unavailable"
problem["status"] = HttpStatus.SERVICE_UNAVAILABLE.value()
problem["detail"] = ex.message ?: "Smart card service unavailable"
problem["instance"] = "/smart-card/info"
problem["timestamp"] = Instant.now().toString()
return ResponseEntity.status(HttpStatus.SERVICE_UNAVAILABLE)
.header("Content-Type", "application/problem+json")
.body(problem)
}
@ExceptionHandler(Exception::class)
fun handleGenericException(ex: Exception): ResponseEntity<Map<String, Any>> {
val problem = mutableMapOf<String, Any>()
problem["type"] = "about:blank"
problem["title"] = "Internal Server Error"
problem["status"] = HttpStatus.INTERNAL_SERVER_ERROR.value()
problem["detail"] = "An unexpected error occurred: ${ex.message ?: "Unknown error"}"
problem["timestamp"] = Instant.now().toString()
return ResponseEntity.status(HttpStatus.INTERNAL_SERVER_ERROR)
.header("Content-Type", "application/problem+json")
.body(problem)
}
}

View File

@@ -0,0 +1,41 @@
package com.betriebsratkanzlei.legalconsenthub_middleware.signature
import com.betriebsratkanzlei.legalconsenthub_middleware_api.api.SignatureApi
import com.betriebsratkanzlei.legalconsenthub_middleware_api.model.VerifySignatureResponseDto
import org.springframework.http.ResponseEntity
import org.springframework.web.bind.annotation.RestController
import org.springframework.web.multipart.MultipartFile
@RestController
class SignatureController(
private val signatureService: SignatureService
) : SignatureApi {
override fun signPdfHash(
document: MultipartFile,
certificateId: String,
hashAlgorithm: String
): ResponseEntity<String> {
val signature = signatureService.signPdfHash(
document = document.bytes,
certificateId = certificateId,
hashAlgorithm = hashAlgorithm
)
return ResponseEntity.ok(signature)
}
override fun verifySignature(
document: MultipartFile,
signature: String,
certificateId: String?,
hashAlgorithm: String
): ResponseEntity<VerifySignatureResponseDto> {
val verificationResult = signatureService.verifySignature(
document = document.bytes,
signature = signature,
certificateId = certificateId,
hashAlgorithm = hashAlgorithm
)
return ResponseEntity.ok(verificationResult)
}
}

View File

@@ -0,0 +1,63 @@
package com.betriebsratkanzlei.legalconsenthub_middleware.signature
import com.betriebsratkanzlei.legalconsenthub_middleware.smartcard.SmartCardService
import com.betriebsratkanzlei.legalconsenthub_middleware_api.model.VerifySignatureResponseDto
import com.betriebsratkanzlei.legalconsenthub_middleware_api.model.CertificateDto
import org.springframework.stereotype.Service
import java.util.*
@Service
class SignatureService(
private val smartCardService: SmartCardService
) {
fun signPdfHash(
document: ByteArray,
certificateId: String,
hashAlgorithm: String
): String {
val hashBase64 = smartCardService.calculateHash(document, hashAlgorithm)
val certificates = smartCardService.getSmartCardCertificates()
certificates.find { it.id == certificateId }
?: throw IllegalArgumentException("Certificate not found: $certificateId")
// Sign the hash using smart card (PIN will be entered on device)
return smartCardService.signHash(hashBase64, certificateId)
}
fun verifySignature(
document: ByteArray,
signature: String,
certificateId: String?,
hashAlgorithm: String
): VerifySignatureResponseDto {
try {
val certificates = smartCardService.getSmartCardCertificates()
val certificate = if (certificateId != null) {
certificates.find { it.id == certificateId }
?: throw IllegalArgumentException("Certificate not found: $certificateId")
} else {
// If no specific certificate ID provided, try to find the certificate from the signature
// For now, we'll use the first available certificate
certificates.firstOrNull()
?: throw IllegalArgumentException("No certificates available for verification")
}
val isValid = smartCardService.verifySignature(document, signature, certificate.id, hashAlgorithm)
return VerifySignatureResponseDto(
isValid = isValid,
certificateInfo = certificate,
verificationDetails = "Signature verified using pkcs11-tool with ${hashAlgorithm} algorithm"
)
} catch (e: Exception) {
return VerifySignatureResponseDto(
isValid = false,
certificateInfo = null,
verificationDetails = "Verification failed: ${e.message}"
)
}
}
}

View File

@@ -0,0 +1,24 @@
package com.betriebsratkanzlei.legalconsenthub_middleware.smartcard
import com.betriebsratkanzlei.legalconsenthub_middleware_api.api.SmartCardApi
import com.betriebsratkanzlei.legalconsenthub_middleware_api.model.CertificateDto
import com.betriebsratkanzlei.legalconsenthub_middleware_api.model.SmartCardInfoDto
import org.springframework.http.ResponseEntity
import org.springframework.web.bind.annotation.RestController
import java.time.LocalDateTime
@RestController
class SmartCardController(
private val smartCardService: SmartCardService
) : SmartCardApi {
override fun getSmartCardInfo(): ResponseEntity<SmartCardInfoDto> {
val smartCardInfo = smartCardService.getSmartCardInfo()
return ResponseEntity.ok(smartCardInfo)
}
override fun getSmartCardCertificates(): ResponseEntity<List<CertificateDto>> {
val certificates = smartCardService.getSmartCardCertificates()
return ResponseEntity.ok(certificates)
}
}

View File

@@ -0,0 +1,5 @@
package com.betriebsratkanzlei.legalconsenthub_middleware.smartcard
class SmartCardNotFoundException(message: String) : RuntimeException(message)
class SmartCardReadException(message: String, cause: Throwable? = null) : RuntimeException(message, cause)
class SmartCardUnavailableException(message: String) : RuntimeException(message)

View File

@@ -0,0 +1,281 @@
package com.betriebsratkanzlei.legalconsenthub_middleware.smartcard
import com.betriebsratkanzlei.legalconsenthub_middleware_api.model.CertificateDto
import com.betriebsratkanzlei.legalconsenthub_middleware_api.model.SmartCardInfoDto
import jakarta.annotation.PostConstruct
import org.apache.commons.exec.CommandLine
import org.apache.commons.exec.DefaultExecutor
import org.apache.commons.exec.PumpStreamHandler
import org.springframework.beans.factory.annotation.Value
import org.springframework.core.io.ResourceLoader
import org.springframework.stereotype.Service
import java.io.ByteArrayOutputStream
import java.io.File
import java.time.LocalDateTime
import java.util.*
import java.nio.file.Files
import java.nio.file.StandardCopyOption
@Service
class SmartCardService(
@Value("\${opensc.pkcs11.library.path}") private val openscPkcs11LibPath: String,
private val resourceLoader: ResourceLoader
) {
private lateinit var resolvedLibraryPath: String
@PostConstruct
fun init() {
resolvedLibraryPath = resolveLibraryPath()
}
// macOS doesn't allow loading native libraries using classpath URLs. The library needs to be loaded from an absolute file path.
private fun resolveLibraryPath(): String {
val resource = resourceLoader.getResource(openscPkcs11LibPath)
if (!resource.exists()) {
throw IllegalStateException("PKCS11 library not found: $openscPkcs11LibPath")
}
val tempFile = Files.createTempFile("opensc-pkcs11", ".so").toFile()
tempFile.deleteOnExit()
resource.inputStream.use { input ->
Files.copy(input, tempFile.toPath(), StandardCopyOption.REPLACE_EXISTING)
}
tempFile.setExecutable(true)
tempFile.setReadable(true)
return tempFile.absolutePath
}
fun getSmartCardInfo(): SmartCardInfoDto {
val commandLine = CommandLine.parse("pkcs11-tool --module $resolvedLibraryPath --show-info")
val executor = DefaultExecutor()
val outputStream = ByteArrayOutputStream()
val errorStream = ByteArrayOutputStream()
executor.streamHandler = PumpStreamHandler(outputStream, errorStream)
return try {
val exitCode = executor.execute(commandLine)
if (exitCode == 0) {
val output = outputStream.toString()
parseSmartCardInfo(output)
} else {
val errorOutput = errorStream.toString()
throw SmartCardNotFoundException("No smart card detected: $errorOutput")
}
} catch (e: SmartCardNotFoundException) {
throw e
} catch (e: Exception) {
throw SmartCardReadException("Error reading smart card: ${e.message}", e)
}
}
fun getSmartCardCertificates(): List<CertificateDto> {
val commandLine = CommandLine.parse("pkcs11-tool --module $resolvedLibraryPath --list-objects --type cert")
val executor = DefaultExecutor()
val outputStream = ByteArrayOutputStream()
val errorStream = ByteArrayOutputStream()
executor.streamHandler = PumpStreamHandler(outputStream, errorStream)
return try {
val exitCode = executor.execute(commandLine)
if (exitCode == 0) {
val output = outputStream.toString()
parseCertificates(output)
} else {
val errorOutput = errorStream.toString()
throw SmartCardNotFoundException("No smart card detected or no certificates found: $errorOutput")
}
} catch (e: SmartCardNotFoundException) {
throw e
} catch (e: Exception) {
throw SmartCardReadException("Error reading smart card certificates: ${e.message}", e)
}
}
fun signHash(hash: String, certificateId: String): String {
val hashBytes = Base64.getDecoder().decode(hash)
val tempFile = File.createTempFile("hash_to_sign", ".bin")
return try {
tempFile.writeBytes(hashBytes)
// Build pkcs11-tool command to sign the hash (PIN will be prompted on device)
val commandLine =
CommandLine.parse("pkcs11-tool --module $resolvedLibraryPath --sign --mechanism RSA-PKCS --id $certificateId --input-file ${tempFile.absolutePath}")
val executor = DefaultExecutor()
val outputStream = ByteArrayOutputStream()
val errorStream = ByteArrayOutputStream()
executor.streamHandler = PumpStreamHandler(outputStream, errorStream)
val exitCode = executor.execute(commandLine)
if (exitCode == 0) {
val signature = outputStream.toByteArray()
Base64.getEncoder().encodeToString(signature)
} else {
val errorOutput = errorStream.toString()
throw RuntimeException("Failed to sign hash: $errorOutput")
}
} catch (e: Exception) {
throw RuntimeException("Error signing hash: ${e.message}", e)
} finally {
if (tempFile.exists()) {
tempFile.delete()
}
}
}
fun calculateHash(document: ByteArray, hashAlgorithm: String): String {
return try {
val javaHashAlgorithm = when (hashAlgorithm.uppercase()) {
"SHA1" -> "SHA-1"
"SHA256" -> "SHA-256"
"SHA384" -> "SHA-384"
"SHA512" -> "SHA-512"
else -> "SHA-256"
}
val messageDigest = java.security.MessageDigest.getInstance(javaHashAlgorithm)
val hashBytes = messageDigest.digest(document)
Base64.getEncoder().encodeToString(hashBytes)
} catch (e: Exception) {
throw RuntimeException("Error calculating hash: ${e.message}", e)
}
}
fun verifySignature(originalData: ByteArray, signature: String, certificateId: String, hashAlgorithm: String = "SHA256"): Boolean {
return try {
val signatureBytes = Base64.getDecoder().decode(signature)
// Calculate hash of the original data using the same algorithm that was used for signing
val hashBase64 = calculateHash(originalData, hashAlgorithm)
val hashBytes = Base64.getDecoder().decode(hashBase64)
val hashFile = File.createTempFile("hash_to_verify", ".bin")
val signatureFile = File.createTempFile("signature_to_verify", ".bin")
try {
hashFile.writeBytes(hashBytes)
signatureFile.writeBytes(signatureBytes)
// Verify against the hash, not the original document, since the signature was created from the hash
val verifyCommand = CommandLine.parse("pkcs11-tool --module $resolvedLibraryPath -m RSA-PKCS --verify --id $certificateId --input-file ${hashFile.absolutePath} --signature-file ${signatureFile.absolutePath}")
val executor = DefaultExecutor()
val outputStream = ByteArrayOutputStream()
val errorStream = ByteArrayOutputStream()
executor.streamHandler = PumpStreamHandler(outputStream, errorStream)
val exitCode = executor.execute(verifyCommand)
if (exitCode == 0) {
println("Signature verification successful")
true
} else {
println("Signature verification failed: ${errorStream.toString()}")
false
}
} finally {
if (hashFile.exists()) hashFile.delete()
if (signatureFile.exists()) signatureFile.delete()
}
} catch (e: Exception) {
println("Error during signature verification: ${e.message}")
// If any error occurs during verification, consider it invalid
false
}
}
private fun parseSmartCardInfo(output: String): SmartCardInfoDto {
val lines = output.split("\n")
var label = "Unknown"
var serialNumber: String? = null
var manufacturer: String? = null
var model: String? = null
for (line in lines) {
when {
line.contains("Token label") -> {
label = line.split(":").getOrNull(1)?.trim() ?: "Unknown"
}
line.contains("Serial number") -> {
serialNumber = line.split(":").getOrNull(1)?.trim()
}
line.contains("Manufacturer") -> {
manufacturer = line.split(":").getOrNull(1)?.trim()
}
line.contains("Model") -> {
model = line.split(":").getOrNull(1)?.trim()
}
}
}
return SmartCardInfoDto(
isPresent = true,
label = label,
serialNumber = serialNumber,
manufacturer = manufacturer,
model = model
)
}
private fun parseCertificates(output: String): List<CertificateDto> {
val certificates = mutableListOf<CertificateDto>()
val lines = output.split("\n")
// This is a simplified parser - in reality, you'd need more sophisticated parsing
var currentId: String? = null
var currentLabel: String? = null
for (line in lines) {
when {
line.contains("Certificate Object") -> {
currentId = UUID.randomUUID().toString()
currentLabel = null
}
line.contains("label:") -> {
currentLabel = line.split(":").getOrNull(1)?.trim()
}
line.contains("ID:") -> {
val id = line.split(":").getOrNull(1)?.trim()
if (currentId != null && currentLabel != null) {
certificates.add(createCertificateDto(currentLabel, id))
}
}
}
}
return certificates
}
// TODO: Replace with actual certificate parsing logic
private fun createCertificateDto(label: String, keyId: String?): CertificateDto {
return CertificateDto(
id = keyId ?: "",
subject = label,
issuer = "Unknown Issuer",
validFrom = LocalDateTime.now().minusYears(1),
validTo = LocalDateTime.now().plusYears(3),
keyUsage = listOf("digitalSignature", "keyEncipherment"),
fingerprint = "Unknown"
)
}
}

View File

@@ -0,0 +1,29 @@
spring:
application:
name: legalconsenthub-middleware
servlet:
multipart:
max-file-size: 10MB
max-request-size: 10MB
server:
port: 8081
servlet:
context-path: /
logging:
level:
com.betriebsratkanzlei.legalconsenthub_middleware: DEBUG
org.springframework.security: DEBUG
#springdoc:
# api-docs:
# path: /v3/api-docs
# swagger-ui:
# path: /swagger-ui.html
# operationsSorter: method
opensc:
pkcs11:
library:
path: classpath:binaries/opensc-pkcs11.so