package com.alexanderlogistics.ksef; import java.io.IOException; import java.net.URI; import java.net.http.HttpClient; import java.net.http.HttpRequest; import java.net.http.HttpResponse; import java.nio.charset.StandardCharsets; import java.security.InvalidAlgorithmParameterException; import java.security.InvalidKeyException; import java.security.NoSuchAlgorithmException; import java.security.PublicKey; import java.security.SecureRandom; import java.security.spec.MGF1ParameterSpec; import java.time.Duration; import java.time.Instant; import java.time.format.DateTimeFormatter; import java.util.Base64; import java.util.Optional; import java.util.logging.Logger; import javax.crypto.BadPaddingException; import javax.crypto.Cipher; import javax.crypto.IllegalBlockSizeException; import javax.crypto.NoSuchPaddingException; import javax.crypto.spec.OAEPParameterSpec; import javax.crypto.spec.PSource; import org.eclipse.microprofile.config.inject.ConfigProperty; import org.imixs.workflow.exceptions.InvalidAccessException; import org.imixs.workflow.exceptions.PluginException; import jakarta.annotation.PostConstruct; import jakarta.annotation.security.RunAs; import jakarta.ejb.Lock; import jakarta.ejb.LockType; import jakarta.ejb.Singleton; import jakarta.inject.Inject; import jakarta.json.JsonObject; import jakarta.json.bind.Jsonb; import jakarta.json.bind.JsonbBuilder; /** * Der KSeFAuthManager stellt Methoden bereit um einen Access Token für das KSeF * System in Polen zu erstellen. * * */ @Singleton @RunAs("org.imixs.ACCESSLEVEL.MANAGERACCESS") public class KSeFAuthManager { private static Logger logger = Logger.getLogger(KSeFAuthManager.class.getName()); public static final String ENV_KSEF_API_TOKEN = "ksef.api.token"; public static final String ENV_KSEF_API_NIP = "ksef.api.nip"; public static final String ENV_KSEF_API_ENDPOINT = "ksef.api.endpoint"; public static final String ENV_KSEF_API_DEBUG = "ksef.api.debug"; public static final String ERROR_CONFIG = "CONFIG_ERROR"; public static final String ERROR_API = "API_ERROR"; @Inject @ConfigProperty(name = ENV_KSEF_API_TOKEN) Optional ksefToken; @Inject @ConfigProperty(name = ENV_KSEF_API_NIP) Optional ksefNip; @Inject @ConfigProperty(name = ENV_KSEF_API_ENDPOINT) Optional ksefEndpoint; @Inject @ConfigProperty(name = ENV_KSEF_API_DEBUG, defaultValue = "false") boolean debug; private HttpClient httpClient; private String baseURI = ""; // public keys private String symmetricCertBase64; private String ksefTokenCertBase64; private PublicKey symmetricPublicKey; private PublicKey ksefTokenPublicKey; // Auth information private String challenge = ""; private String authRefNumber = null; private String authToken = null; private String challengeTimestamp = null; // Session information private String accessToken = null; private String sessionRefNumber; private String sessionValidUntil; private byte[] sessionAesKey; private byte[] sessionIv; @PostConstruct void init() { if (!ksefToken.isPresent()) { throw new InvalidAccessException("Missing environment parameter ksef.api.token!"); } if (!ksefEndpoint.isPresent()) { throw new InvalidAccessException("Missing environment parameter ksef.api.endpoint!"); } if (!ksefNip.isPresent()) { throw new InvalidAccessException("Missing environment parameter ksef.api.nip!"); } baseURI = ksefEndpoint.get(); if (baseURI.endsWith("/")) { baseURI = baseURI.substring(0, baseURI.length() - 1); } httpClient = HttpClient.newBuilder() .version(HttpClient.Version.HTTP_2) .connectTimeout(Duration.ofSeconds(10)) .build(); } public HttpClient getHttpClient() { return httpClient; } public boolean isDebug() { return debug; } public void setKsefToken(String token) { this.ksefToken = Optional.ofNullable(token); } public String getAccessToken() { return accessToken; } public void setKsefNip(String nip) { this.ksefNip = Optional.ofNullable(nip); } public void setKsefEndpoint(String endpoint) { this.ksefEndpoint = Optional.ofNullable(endpoint); } public String getSessionRefNumber() { return sessionRefNumber; } public void setDebug(boolean debug) { this.debug = debug; } public String getBaseURI() { return baseURI; } public String getAuthRefNumber() { return authRefNumber; } public String getAuthToken() { return authToken; } public PublicKey getSymmetricPublicKey() { return symmetricPublicKey; } public String getChallengeValue() { return challenge; } public byte[] getSessionAesKey() { return sessionAesKey; } public byte[] getSessionIv() { return sessionIv; } /** * This method opens a new interactive session to upload invoices. * you can call hasValidSession() to verify if a session already exists. * * @throws PluginException */ public void openSession() throws PluginException { logger.info("├── open Session..."); if (!hasValidSession()) { logger.info("│ ├── open new interactive KSeF Session..."); // open new session this.loadPublicKeyCertificates(); this.authChallenge(); this.authKSeFToken(); this.checkTokenStatus(); this.redeemToken(); this.openInteractiveSession(); } else { logger.info("│ ├── reuse existing KSeF Session..."); } } /** * Verifies if sessionValidUntil and sessionRefNumber is valid * * @return true if a valid session exists, false otherwise */ public boolean hasValidSession() { return false; // // Check if session reference number exists // if (sessionRefNumber == null || sessionRefNumber.isEmpty()) { // return false; // } // // Check if valid until timestamp exists // if (sessionValidUntil == null || sessionValidUntil.isEmpty()) { // return false; // } // try { // // Parse the validUntil timestamp (ISO 8601 format with UTC) // DateTimeFormatter formatter = DateTimeFormatter.ISO_OFFSET_DATE_TIME; // Instant validUntilInstant = Instant.from(formatter.parse(sessionValidUntil)); // // Get current time in UTC // Instant now = Instant.now(); // // Check if session is still valid // boolean isValid = now.isBefore(validUntilInstant); // if (debug && isValid) { // logger.info("├── Session is still valid until: " + sessionValidUntil); // } else if (debug) { // logger.info("├── Session expired at: " + sessionValidUntil); // } // return isValid; // } catch (Exception e) { // // If parsing fails, assume session is invalid // logger.warning("├── ⚠️ Error parsing sessionValidUntil timestamp: " + // e.getMessage()); // return false; // } } /** * Helper Method to read the certificates * * @throws Exception */ @Lock(LockType.WRITE) public void loadPublicKeyCertificates() throws PluginException { logger.info("├── ♻️ KSeF API load public-key-certificates..."); String uri = baseURI + "/security/public-key-certificates"; if (debug) { logger.info("│ ├── Endpoint: " + uri); } HttpRequest request = HttpRequest.newBuilder() .uri(URI.create(uri)) .header("Accept", "application/json") .GET() .build(); HttpResponse response = null; try { response = httpClient.send(request, HttpResponse.BodyHandlers.ofString()); } catch (IOException | InterruptedException e) { throw new PluginException(KSeFAuthManager.class.getSimpleName(), ERROR_API, "Unable to send request for KSeF certificates endpoint: " + e.getMessage()); } if (debug) { logger.info("│ ├── Response Code = " + response.statusCode()); logger.info("Response: " + response.body()); } if (response.statusCode() != 200) { throw new PluginException(KSeFAuthManager.class.getSimpleName(), ERROR_API, "Unexpected response from KSeF certificates endpoint: " + response.statusCode()); } // JSON parsen try (Jsonb jsonb = JsonbBuilder.create()) { var certArray = jsonb.fromJson(response.body(), JsonObject[].class); for (JsonObject entry : certArray) { // usage = ["KsefTokenEncryption"] oder ["SymmetricKeyEncryption"] var usages = entry.getJsonArray("usage"); if (usages.stream().anyMatch(v -> v.toString().contains("KsefTokenEncryption"))) { ksefTokenCertBase64 = entry.getString("certificate"); if (debug) { logger.info("│ ├── ✓ KsefTokenEncryption certificate found."); } // Public Key extrahieren ksefTokenPublicKey = extractPublicKeyFromCertificate(ksefTokenCertBase64); logger.info("│ ├── ✓ RSA PublicKey successfully extracted."); } if (usages.stream().anyMatch(v -> v.toString().contains("SymmetricKeyEncryption"))) { symmetricCertBase64 = entry.getString("certificate"); if (debug) { logger.info("│ ├── ✓ SymmetricKeyEncryption certificate found."); } // Public Key extrahieren symmetricPublicKey = extractPublicKeyFromCertificate(symmetricCertBase64); logger.info("│ ├── ✓ Symetric PublicKey successfully extracted."); } } if (ksefTokenPublicKey == null) { logger.warning("│ ├── ⚠️ No certificate with usage 'ksefTokenPublicKey' found!"); } if (symmetricPublicKey == null) { logger.warning("│ ├── ⚠️ No certificate with usage 'symmetricPublicKey' found!"); } } catch (Exception e) { throw new PluginException(KSeFAuthManager.class.getSimpleName(), ERROR_API, "Error parsing certificates JSON: " + e.getMessage()); } } /** * Diese method führt den challenge Request durch * * @throws Exception */ // @Lock(LockType.READ) @Lock(LockType.WRITE) public void authChallenge() throws PluginException { logger.info("├── 🛅 KSeF API Challenge..."); String uri = baseURI + "/auth/challenge"; if (debug) { logger.info("│ ├── Endpoint: " + uri); } HttpRequest request = HttpRequest.newBuilder() .uri(URI.create(uri)) // nur Endpoint checken .header("Accept", "application/json") .POST(HttpRequest.BodyPublishers.noBody()) // .build(); HttpResponse response; try { response = httpClient.send(request, HttpResponse.BodyHandlers.ofString()); if (debug) { logger.info("│ ├── Response Code = " + response.statusCode()); logger.info("Response: " + response.body()); } // Prüfen, dass wir eine API-Antwort bekommen, kein HTML if (response.statusCode() >= 500) { throw new PluginException(KSeFAuthManager.class.getSimpleName(), ERROR_CONFIG, "Unable to access KSeF API: response=" + response.statusCode()); } // Challenge aus Response extrahieren und speichern if (response.statusCode() == 200) { try (Jsonb jsonb = JsonbBuilder.create()) { // Annahme: response.body() ist ein String mit JSON-Inhalt JsonObject jsonObject = jsonb.fromJson(response.body(), JsonObject.class); challenge = jsonObject.getString("challenge"); challengeTimestamp = jsonObject.getString("timestamp"); logger.info("│ ├── ✓ challenge: " + challenge); logger.info("│ ├── ✓ timestamp: " + challengeTimestamp); } catch (Exception e) { logger.severe("├── ⚠️ Error parsing JSON response" + e.getMessage()); throw new PluginException(KSeFAuthManager.class.getSimpleName(), ERROR_API, "Error parsing JSON response: " + e.getMessage()); } } } catch (IOException | InterruptedException e) { throw new PluginException(KSeFAuthManager.class.getSimpleName(), ERROR_API, "API Error: Unable to parse KSeF API response"); } } /** * Helper method to request the KSeF Auth Token and referenceNumber * * @throws Exception */ @Lock(LockType.WRITE) public void authKSeFToken() throws PluginException { logger.info("├── 🛃 KSeF API auth ksef-token..."); String uri = baseURI + "/auth/ksef-token"; if (debug) { logger.info("│ ├── Endpoint: " + uri); } HttpResponse response = null; try { String jsonPayload = String.format( "{" + "\"challenge\": \"%s\"," + "\"contextIdentifier\": {" + " \"type\": \"Nip\"," + " \"value\": \"%s\"" + "}," + "\"encryptedToken\": \"%s\"" + "}", challenge, ksefNip.get(), getEncryptedToken()); HttpRequest request = HttpRequest.newBuilder() .uri(URI.create(uri)) .header("Accept", "application/json") .header("Content-Type", "application/json") .POST(HttpRequest.BodyPublishers.ofString(jsonPayload)) .build(); response = httpClient.send(request, HttpResponse.BodyHandlers.ofString()); } catch (IOException | InterruptedException e) { throw new PluginException(KSeFAuthManager.class.getSimpleName(), ERROR_API, "API Error: Unable to request ksef-token"); } if (debug) { logger.info("│ ├── Response Code = " + response.statusCode()); logger.info("Response: " + response.body()); } // Prüfen, dass wir eine API-Antwort bekommen if (response.statusCode() >= 500) { throw new PluginException(KSeFAuthManager.class.getSimpleName(), ERROR_CONFIG, "Unable to access KSeF API: response=" + response.statusCode()); } // JSON parsen und Werte speichern if (response.statusCode() == 200 || response.statusCode() == 202) { try (Jsonb jsonb = JsonbBuilder.create()) { JsonObject jsonObject = jsonb.fromJson(response.body(), JsonObject.class); // Reference Number speichern authRefNumber = jsonObject.getString("referenceNumber"); logger.info("│ ├── ✓ referenceNumber: " + authRefNumber); // Authentication Token speichern JsonObject authTokenObject = jsonObject.getJsonObject("authenticationToken"); authToken = authTokenObject.getString("token"); if (debug) { logger.info("│ ├── ✓ authenticationToken: " + authToken); } } catch (Exception e) { logger.severe("├── ⚠️ Error parsing JSON response" + e.getMessage()); throw new PluginException(KSeFAuthManager.class.getSimpleName(), ERROR_API, "Error parsing JSON response: " + e.getMessage()); } } } /** * Helper Method to open an interactive session. The method fetches a * sessionRefNumber which is mandatory to upload an invoice. * * @throws Exception */ @Lock(LockType.WRITE) public void openInteractiveSession() throws PluginException { if (accessToken == null) { throw new PluginException(KSeFAuthManager.class.getSimpleName(), ERROR_API, "Error - missing access token!"); } logger.info("├── 🔐 KSeF API open interactive session..."); String uri = baseURI + "/sessions/online"; if (debug) { logger.info("│ ├── Endpoint: " + uri); } HttpResponse response = null; try { // --- AES Schlüssel erzeugen --- SecureRandom rnd = new SecureRandom(); byte[] aesKeyBytes = new byte[32]; // 256-bit AES key rnd.nextBytes(aesKeyBytes); byte[] ivBytes = new byte[16]; // 128-bit IV rnd.nextBytes(ivBytes); // --- AES Key mit RSA encrypten --- Cipher rsa; rsa = Cipher.getInstance("RSA/ECB/OAEPWithSHA-1AndMGF1Padding"); rsa.init(Cipher.ENCRYPT_MODE, this.symmetricPublicKey); byte[] encryptedAesKey = rsa.doFinal(aesKeyBytes); String jsonPayload = String.format( "{" + "\"formCode\": {" + " \"systemCode\": \"FA (3)\"," + " \"schemaVersion\": \"1-0E\"," + " \"value\": \"FA\"" + "}," + "\"encryption\": {" + " \"encryptedSymmetricKey\": \"%s\"," + " \"initializationVector\": \"%s\"" + "}" + "}", Base64.getEncoder().encodeToString(encryptedAesKey), Base64.getEncoder().encodeToString(ivBytes)); if (debug) { logger.info("│ ├── Payload: " + jsonPayload); } HttpRequest request = HttpRequest.newBuilder() .uri(URI.create(uri)) // .header("Accept-Charset", "UTF-8") .header("Accept", "application/json") .header("Content-Type", "application/json") .header("Authorization", "Bearer " + accessToken) .POST(HttpRequest.BodyPublishers.ofString(jsonPayload)) .build(); response = httpClient.send(request, HttpResponse.BodyHandlers.ofString()); if (debug) { logger.info("│ ├── Response Code = " + response.statusCode()); logger.info("Response: " + response.body()); } if (response.statusCode() != 201) { throw new PluginException(KSeFAuthManager.class.getSimpleName(), ERROR_API, "Failed to open interactive session: " + response.statusCode()); } // parse JSON try (Jsonb jsonb = JsonbBuilder.create()) { // Annahme: response.body() ist ein String mit JSON-Inhalt JsonObject jsonObject = jsonb.fromJson(response.body(), JsonObject.class); sessionRefNumber = jsonObject.getString("referenceNumber"); sessionValidUntil = jsonObject.getString("validUntil"); if (true) { logger.info("│ ├── Session ReferenceNumber = " + sessionRefNumber); logger.info("│ └── Session ValidUntil = " + sessionValidUntil); } // IMPORTANT: Store the AES key - this key is mandatory to upload a invoice this.sessionAesKey = aesKeyBytes; this.sessionIv = ivBytes; } catch (Exception e) { logger.severe("├── ⚠️ Error parsing JSON response" + e.getMessage()); throw new PluginException(KSeFAuthManager.class.getSimpleName(), ERROR_API, "Error parsing JSON response: " + e.getMessage()); } } catch (NoSuchAlgorithmException | NoSuchPaddingException | IOException | InterruptedException | InvalidKeyException | IllegalBlockSizeException | BadPaddingException e) { logger.severe("├── ⚠️ API Error: " + e.getMessage()); throw new PluginException(KSeFAuthManager.class.getSimpleName(), ERROR_API, "API Error openInteractiveSession: " + e.getMessage()); } } /** * Verschlüsselt den KSeF-Token mit RSA-OAEP (SHA-256) für die Authentifizierung * * Format: token|timestamp_in_milliseconds * * @return Base64-kodierter verschlüsselter Token * @throws NoSuchPaddingException * @throws NoSuchAlgorithmException * @throws InvalidAlgorithmParameterException * @throws InvalidKeyException * @throws Exception */ public String getEncryptedToken() throws PluginException { String result = null; if (ksefTokenPublicKey == null) { throw new IllegalStateException("Public Key not loaded! Call loadPublicKeyCertificates() first."); } if (challengeTimestamp == null || challengeTimestamp.isEmpty()) { throw new IllegalStateException("Challenge timestamp is missing! Call authChallenge() first."); } try { // 1. Timestamp parsen - KSeF liefert zu viele Dezimalstellen // Format: "2025-11-14T14:18:35.606507+00:00" // Flexibler Parser der verschiedene Dezimalstellen akzeptiert DateTimeFormatter formatter = DateTimeFormatter.ISO_OFFSET_DATE_TIME; Instant instant = Instant.from(formatter.parse(challengeTimestamp)); long timestampMillis = instant.toEpochMilli(); logger.info("│ ├── Timestamp (ms): " + timestampMillis); // 2. Format erstellen: token|timestamp String plaintext = ksefToken.get() + "|" + timestampMillis; if (debug) { logger.info("│ ├── Plaintext format: [token]|[" + timestampMillis + "]"); } // 3. RSA-OAEP Verschlüsselung mit SHA-256 Cipher cipher; cipher = Cipher.getInstance("RSA/ECB/OAEPWithSHA-256AndMGF1Padding"); OAEPParameterSpec oaepParams = new OAEPParameterSpec( "SHA-256", "MGF1", MGF1ParameterSpec.SHA256, PSource.PSpecified.DEFAULT); cipher.init(Cipher.ENCRYPT_MODE, ksefTokenPublicKey, oaepParams); byte[] encrypted = cipher.doFinal(plaintext.getBytes(StandardCharsets.UTF_8)); // 4. Base64 kodieren result = Base64.getEncoder().encodeToString(encrypted); logger.info("│ ├── ✓ Token encrypted with RSA-OAEP"); } catch (NoSuchAlgorithmException | NoSuchPaddingException | InvalidKeyException | InvalidAlgorithmParameterException | IllegalBlockSizeException | BadPaddingException e) { throw new PluginException(KSeFAuthManager.class.getSimpleName(), ERROR_API, "API Error - unable to getEncryptedToken: " + e.getMessage()); } return result; } /** * Redeems the KSeF token and returns a session access token to be used to open * an Interactive Session * * POST /auth/token/redeem * * @return JsonObject with referenceNumber and sessionToken * @throws Exception */ @Lock(LockType.WRITE) public JsonObject redeemToken() throws PluginException { logger.info("├── 🛂 KSeF API redeem token..."); String uri = baseURI + "/auth/token/redeem"; if (debug) { logger.info("│ ├── Endpoint: " + uri); } HttpResponse response = null; try { HttpRequest request = HttpRequest.newBuilder() .uri(URI.create(uri)) .header("Accept", "application/json") .header("Authorization", "Bearer " + authToken) .POST(HttpRequest.BodyPublishers.noBody()) .build(); response = httpClient.send(request, HttpResponse.BodyHandlers.ofString()); } catch (IOException | InterruptedException e) { throw new PluginException(KSeFAuthManager.class.getSimpleName(), ERROR_API, "API Error - unable to redeemToken: " + e.getMessage()); } if (debug) { logger.info("│ ├── Response Code = " + response.statusCode()); logger.info("│ ├── Response: " + response.body()); } if (response.statusCode() >= 400) { throw new PluginException(KSeFAuthManager.class.getSimpleName(), ERROR_API, "Token redeem failed: " + response.statusCode() + " - " + response.body()); } try (Jsonb jsonb = JsonbBuilder.create()) { JsonObject jsonObject = jsonb.fromJson(response.body(), JsonObject.class); // Access Token extrahieren und speichern JsonObject accessTokenObj = jsonObject.getJsonObject("accessToken"); this.accessToken = accessTokenObj.getString("token"); logger.info("│ └── ☑️ Access Token received"); return jsonObject; } catch (Exception e) { logger.severe("├── ⚠️ Error parsing JSON response: " + e.getMessage()); throw new PluginException(KSeFAuthManager.class.getSimpleName(), ERROR_API, "Error parsing response: " + e.getMessage()); } } private PublicKey extractPublicKeyFromCertificate(String base64Cert) throws Exception { byte[] der = java.util.Base64.getDecoder().decode(base64Cert); java.security.cert.CertificateFactory factory = java.security.cert.CertificateFactory.getInstance("X.509"); var certificate = (java.security.cert.X509Certificate) factory .generateCertificate(new java.io.ByteArrayInputStream(der)); return certificate.getPublicKey(); } /** * This method verifies the actual status of the authentication process for the * current auth token (this is the token received from the start of the auth * procedure) * * https://ksef-test.mf.gov.pl/docs/v2/index.html#tag/Uzyskiwanie-dostepu/paths/~1api~1v2~1auth~1%7BreferenceNumber%7D/get * * @throws PluginException */ public void checkTokenStatus() throws PluginException { logger.info("├── 🌡️ KSeF API check auth status..."); String uri = baseURI + "/auth/" + authRefNumber; if (debug) { logger.info("│ ├── Endpoint: " + uri); } HttpResponse response = null; try { HttpRequest request = HttpRequest.newBuilder() .uri(URI.create(uri)) .header("Accept", "application/json") .header("Content-Type", "application/json") .header("Authorization", "Bearer " + authToken) .GET() .build(); response = httpClient.send(request, HttpResponse.BodyHandlers.ofString()); } catch (IOException | InterruptedException e) { throw new PluginException(KSeFAuthManager.class.getSimpleName(), ERROR_API, "API Error: Unable to request auth status: " + e.getMessage()); } if (true) { logger.info("│ ├── Response Code = " + response.statusCode()); logger.info("Response: " + response.body()); } try (Jsonb jsonb = JsonbBuilder.create()) { JsonObject jsonObject = jsonb.fromJson(response.body(), JsonObject.class); Boolean isRedeemed = jsonObject.containsKey("isTokenRedeemed") ? jsonObject.getBoolean("isTokenRedeemed") : null; String lastTokenRefreshDate = jsonObject.containsKey("lastTokenRefreshDate") ? jsonObject.getString("lastTokenRefreshDate") : null; String refreshTokenValidUntil = jsonObject.containsKey("refreshTokenValidUntil") ? jsonObject.getString("refreshTokenValidUntil") : null; logger.info("│ ├── › lastTokenRefreshDate: " + lastTokenRefreshDate); if (isRedeemed) { logger.info("│ ├── ✓ isTokenRedeemed: " + isRedeemed); logger.info("│ ├── ✓ refreshTokenValidUntil: " + refreshTokenValidUntil); } else { logger.info("│ ├── 𐄂 isTokenRedeemed: " + isRedeemed); } } catch (Exception e) { logger.severe("├── ⚠️ Error parsing JSON response: " + e.getMessage()); throw new PluginException(KSeFAuthManager.class.getSimpleName(), ERROR_API, "Error parsing JSON response: " + e.getMessage()); } } }