From 6c4e72fe82b95ddb7ed5aa577a48671a5bd6d088 Mon Sep 17 00:00:00 2001 From: Ralph Soika Date: Sun, 16 Nov 2025 15:31:28 +0100 Subject: [PATCH] neuer Test Code - bessere Key Verwaltung --- .../ksef/api/KSeFAPIService.java | 352 +++++++++--------- .../ksef/api/KSeFAuthManager.java | 138 +++---- .../ksef/api/KSeFEncryptionHelper.java | 129 +++++++ .../ksef/api/SessionEncryption.java | 40 ++ .../ksef/api/KSeFAPIServiceTest.java | 21 +- .../ksef/api/KSeFAuthManagerTest.java | 288 +++++++------- 6 files changed, 555 insertions(+), 413 deletions(-) create mode 100644 office-alexander-logistics-app/src/main/java/com/alexanderlogistics/ksef/api/KSeFEncryptionHelper.java create mode 100644 office-alexander-logistics-app/src/main/java/com/alexanderlogistics/ksef/api/SessionEncryption.java diff --git a/office-alexander-logistics-app/src/main/java/com/alexanderlogistics/ksef/api/KSeFAPIService.java b/office-alexander-logistics-app/src/main/java/com/alexanderlogistics/ksef/api/KSeFAPIService.java index 5d5f76a..448c6b0 100644 --- a/office-alexander-logistics-app/src/main/java/com/alexanderlogistics/ksef/api/KSeFAPIService.java +++ b/office-alexander-logistics-app/src/main/java/com/alexanderlogistics/ksef/api/KSeFAPIService.java @@ -1,22 +1,16 @@ package com.alexanderlogistics.ksef.api; -import java.io.IOException; import java.net.URI; import java.net.http.HttpRequest; -import java.security.InvalidAlgorithmParameterException; -import java.security.InvalidKeyException; +import java.net.http.HttpResponse; import java.security.MessageDigest; import java.security.NoSuchAlgorithmException; -import java.security.PublicKey; -import java.security.SecureRandom; import java.util.ArrayList; import java.util.Base64; import java.util.List; import java.util.logging.Logger; -import javax.crypto.BadPaddingException; -import javax.crypto.IllegalBlockSizeException; -import javax.crypto.NoSuchPaddingException; +import javax.crypto.Cipher; import javax.crypto.spec.IvParameterSpec; import javax.crypto.spec.SecretKeySpec; @@ -40,183 +34,193 @@ import jakarta.json.bind.JsonbBuilder; * */ @DeclareRoles({ "org.imixs.ACCESSLEVEL.NOACCESS", "org.imixs.ACCESSLEVEL.READERACCESS", - "org.imixs.ACCESSLEVEL.AUTHORACCESS", "org.imixs.ACCESSLEVEL.EDITORACCESS", - "org.imixs.ACCESSLEVEL.MANAGERACCESS" }) + "org.imixs.ACCESSLEVEL.AUTHORACCESS", "org.imixs.ACCESSLEVEL.EDITORACCESS", + "org.imixs.ACCESSLEVEL.MANAGERACCESS" }) @RolesAllowed({ "org.imixs.ACCESSLEVEL.NOACCESS", "org.imixs.ACCESSLEVEL.READERACCESS", - "org.imixs.ACCESSLEVEL.AUTHORACCESS", "org.imixs.ACCESSLEVEL.EDITORACCESS", - "org.imixs.ACCESSLEVEL.MANAGERACCESS" }) + "org.imixs.ACCESSLEVEL.AUTHORACCESS", "org.imixs.ACCESSLEVEL.EDITORACCESS", + "org.imixs.ACCESSLEVEL.MANAGERACCESS" }) @Singleton @RunAs("org.imixs.ACCESSLEVEL.MANAGERACCESS") public class KSeFAPIService { - private static Logger logger = Logger.getLogger(KSeFAPIService.class.getName()); + private static Logger logger = Logger.getLogger(KSeFAPIService.class.getName()); - public static final String ERROR_API = "API_ERROR"; + public static final String ERROR_API = "API_ERROR"; - @Inject - KSeFAuthManager kseFAuthManager; + @Inject + KSeFAuthManager kseFAuthManager; - @Inject - DocumentService documentService; + @Inject + DocumentService documentService; - /** - * This method uploads an KSeF Invoice document (XML). - * The response of the uplaod is a 'referenceNumber' which is stored into the - * item ksef.referenceNumber - * - * - * @param companyName - * @param contactType - * @param accessToken - * @return - * @throws PluginException - */ - public String uploadInvoice(ItemCollection workitem, String fileName) - throws PluginException { - String referenceNumber = null; - logger.info("├── 📤 Upload Invoice..."); + /** + * This method uploads a KSeF Invoice document (XML). + * The response of the upload is a 'referenceNumber' which is stored into the + * item ksef.referenceNumber + * + * @param workitem workitem containing the invoice + * @param fileName name of the XML file to upload + * @return referenceNumber from KSeF + * @throws PluginException + */ + public String uploadInvoice(ItemCollection workitem, String fileName) throws PluginException { - // First open an interactive Session. The KSeFAuthManager automatically reuses - // an existing session + // First open an interactive session (reuses existing if valid) + kseFAuthManager.openSession(); + logger.info("├── 📤 Upload Invoice..."); - kseFAuthManager.openSession(); - - if (kseFAuthManager.getAccessToken() == null) { - throw new PluginException(KSeFAuthManager.class.getSimpleName(), ERROR_API, - "API Error - missing AccessToken!"); - } - if (kseFAuthManager.getSessionRefNumber() == null) { - throw new PluginException(KSeFAuthManager.class.getSimpleName(), ERROR_API, - "API Error - missing SessionRefNumber!"); - } - - // load invoice - FileData fileData = workitem.getFileData(fileName); - - byte[] invoiceXml = fileData.getContent(); - - logger.info("│ ├── XML Invoice loaded - " + invoiceXml.length + " bytes"); - try { - // ---------- 3) AES Key generieren ---------- - logger.info("│ ├── Generate AES Key..."); - SecureRandom random = new SecureRandom(); - - byte[] aesKeyBytes = new byte[32]; // 256-bit AES key - random.nextBytes(aesKeyBytes); - - byte[] ivBytes = new byte[16]; // 128-bit IV - random.nextBytes(ivBytes); - - SecretKeySpec aesKey = new SecretKeySpec(aesKeyBytes, "AES"); - IvParameterSpec iv = new IvParameterSpec(ivBytes); - - // ---------- 4) XML per AES-256-CBC verschlüsseln ---------- - javax.crypto.Cipher aesCipher = javax.crypto.Cipher.getInstance("AES/CBC/PKCS5Padding"); - aesCipher.init(javax.crypto.Cipher.ENCRYPT_MODE, aesKey, iv); - - byte[] encryptedInvoiceXml = aesCipher.doFinal(invoiceXml); - - // ---------- 5) AES Key per RSA verschlüsseln ---------- - PublicKey ksefPublicKey = kseFAuthManager.getSymmetricPublicKey(); - - javax.crypto.Cipher rsaCipher = javax.crypto.Cipher - .getInstance("RSA/ECB/OAEPWithSHA-256AndMGF1Padding"); - rsaCipher.init(javax.crypto.Cipher.ENCRYPT_MODE, ksefPublicKey); - - byte[] encryptedAesKey = rsaCipher.doFinal(aesKeyBytes); - - // ---------- 6) SHA-256 Hash des Klartext-XML berechnen ---------- - MessageDigest sha256 = MessageDigest.getInstance("SHA-256"); - - // hash origin invoice - byte[] xmlHashBytes = sha256.digest(invoiceXml); - String invoiceHash = Base64.getEncoder().encodeToString(xmlHashBytes); - - // hash encrypted invoice - xmlHashBytes = sha256.digest(encryptedInvoiceXml); - String encryptedInvoiceHash = Base64.getEncoder().encodeToString(xmlHashBytes); - - // ---------- 7) Request-Body bauen ---------- - // Struktur gemäß KSeF-Doku - String jsonBody = String.format("{ " + - "\"invoiceHash\": \"%s\", " + - "\"invoiceSize\": \"%s\", " + - "\"encryptedInvoiceHash\": \"%s\", " + - "\"encryptedInvoiceSize\": \"%s\", " + - "\"encryptedInvoiceContent\": \"%s\", " + - "\"offlineMode\": false " + - "}", - invoiceHash, - invoiceXml.length, - encryptedInvoiceHash, - encryptedInvoiceXml.length, - Base64.getEncoder().encodeToString(encryptedInvoiceXml)); - - // ---------- 8) HTTP POST vorbereiten ---------- - // String uri = manager.getBaseURI() + "/sessions/online/" + - // manager.getRefNumber() + "/invoices"; - String uri = kseFAuthManager.getBaseURI() + "/sessions/online/" - + kseFAuthManager.getSessionRefNumber() - + "/invoices"; - logger.info("│ ├── Endpoint: " + uri); - - if (kseFAuthManager.isDebug()) { - logger.info("Request: " + jsonBody); - } - HttpRequest request = HttpRequest.newBuilder() - .uri(URI.create(uri)) - .header("Content-Type", "application/json") - .header("Authorization", "Bearer " + kseFAuthManager.getAccessToken()) - .POST(HttpRequest.BodyPublishers.ofString(jsonBody)) - .build(); - - // ---------- 9) Call ausführen ---------- - var response = kseFAuthManager.getHttpClient().send(request, - java.net.http.HttpResponse.BodyHandlers.ofString()); - - System.out.println("Upload Response:"); - System.out.println(response.statusCode()); - System.out.println(response.body()); - - try (Jsonb jsonb = JsonbBuilder.create()) { - // Annahme: response.body() ist ein String mit JSON-Inhalt - JsonObject jsonObject = jsonb.fromJson(response.body(), JsonObject.class); - referenceNumber = jsonObject.getString("referenceNumber"); - - if (kseFAuthManager.isDebug()) { - logger.info("│ ├── referenceNumber: " + referenceNumber); - } - - } catch (Exception e) { - logger.severe("├── ⚠️ Error parsing JSON response" + e.getMessage()); - - kseFAuthManager.deleteCurrentSession(); - - throw new PluginException(KSeFAuthManager.class.getSimpleName(), ERROR_API, - "Error parsing JSON response: " + e.getMessage()); - - } - - if (response.statusCode() == 202) { - logger.info("├── ✅ Upload successful "); - } else { - logger.info("├── ⚠️ Upload failed! "); - } - } catch (NoSuchAlgorithmException | NoSuchPaddingException | IOException | InterruptedException - | InvalidKeyException | IllegalBlockSizeException | BadPaddingException - | InvalidAlgorithmParameterException e) { - // TODO Auto-generated catch block - e.printStackTrace(); - } - // finally set the referenceNumber - List textlist = new ArrayList(); - textlist.add(referenceNumber); - fileData.setAttribute("ksef.referenceNumber", textlist); - - kseFAuthManager.closeSession(); - kseFAuthManager.deleteCurrentSession(); - - return referenceNumber; + // Validate session + if (kseFAuthManager.getAccessToken() == null) { + throw new PluginException(KSeFAuthManager.class.getSimpleName(), ERROR_API, + "API Error - missing AccessToken!"); + } + if (kseFAuthManager.getSessionRefNumber() == null) { + throw new PluginException(KSeFAuthManager.class.getSimpleName(), ERROR_API, + "API Error - missing SessionRefNumber!"); + } + if (kseFAuthManager.getSessionEncryption() == null) { + throw new PluginException(KSeFAuthManager.class.getSimpleName(), ERROR_API, + "API Error - missing Session Encryption!"); } + // Load invoice XML + FileData fileData = workitem.getFileData(fileName); + byte[] invoiceXml = fileData.getContent(); + logger.info("│ ├── XML Invoice loaded - " + invoiceXml.length + " bytes"); + + try { + // Get the session encryption keys (already sent to KSeF during session + // creation!) + SessionEncryption encryption = kseFAuthManager.getSessionEncryption(); + + // Encrypt invoice with session keys + byte[] encryptedInvoice = encryptInvoiceWithSessionKeys(invoiceXml, encryption); + + // Calculate hashes + String invoiceHash = calculateSHA256Hash(invoiceXml); + String encryptedInvoiceHash = calculateSHA256Hash(encryptedInvoice); + + // Build JSON request body + String jsonBody = String.format( + "{" + + "\"invoiceHash\": \"%s\"," + + "\"invoiceSize\": %d," + + "\"encryptedInvoiceHash\": \"%s\"," + + "\"encryptedInvoiceSize\": %d," + + "\"encryptedInvoiceContent\": \"%s\"," + + "\"offlineMode\": false" + + "}", + invoiceHash, + invoiceXml.length, + encryptedInvoiceHash, + encryptedInvoice.length, + Base64.getEncoder().encodeToString(encryptedInvoice)); + + // Send upload request + String uri = kseFAuthManager.getBaseURI() + "/sessions/online/" + + kseFAuthManager.getSessionRefNumber() + "/invoices"; + + logger.info("│ ├── POST: " + uri); + + if (kseFAuthManager.isDebug()) { + logger.info("│ ├── Request Body: " + jsonBody); + } + + HttpRequest request = HttpRequest.newBuilder() + .uri(URI.create(uri)) + .header("Content-Type", "application/json") + .header("Authorization", "Bearer " + kseFAuthManager.getAccessToken()) + .POST(HttpRequest.BodyPublishers.ofString(jsonBody)) + .build(); + + HttpResponse response = kseFAuthManager.getHttpClient().send( + request, HttpResponse.BodyHandlers.ofString()); + + logger.info("│ ├── HTTP Response: " + response.statusCode()); + + // if (kseFAuthManager.isDebug()) { + logger.info("│ ├── Response Body: " + response.body()); + // } + + if (response.statusCode() != 202) { + throw new PluginException(KSeFAuthManager.class.getSimpleName(), ERROR_API, + "Invoice upload failed with status: " + response.statusCode() + + " - " + response.body()); + } + + // Parse response and get reference number + String referenceNumber = null; + try (Jsonb jsonb = JsonbBuilder.create()) { + JsonObject jsonObject = jsonb.fromJson(response.body(), JsonObject.class); + referenceNumber = jsonObject.getString("referenceNumber"); + + logger.info("│ ├── Reference Number: " + referenceNumber); + + } catch (Exception e) { + + throw new PluginException(KSeFAuthManager.class.getSimpleName(), ERROR_API, + "Error parsing upload response: " + e.getMessage()); + } + + // Store reference number in file metadata + List textlist = new ArrayList<>(); + textlist.add(referenceNumber); + fileData.setAttribute("ksef.referenceNumber", textlist); + + logger.info("├── ✅ Upload successful - Reference: " + referenceNumber); + + return referenceNumber; + + } catch (Exception e) { + logger.severe("├── ⚠️ Invoice upload failed: " + e.getMessage()); + + throw new PluginException(KSeFAuthManager.class.getSimpleName(), ERROR_API, + "Invoice upload failed: " + e.getMessage()); + } finally { + kseFAuthManager.closeSession(); + kseFAuthManager.deleteCurrentSession(); + } + } + + /** + * Encrypt invoice XML with the session encryption keys + * + * @param invoiceXml the invoice XML content + * @param encryption the session encryption containing AES key and IV + * @return encrypted invoice bytes + * @throws Exception if encryption fails + */ + private byte[] encryptInvoiceWithSessionKeys(byte[] invoiceXml, SessionEncryption encryption) + throws Exception { + + logger.info("│ ├── Encrypting invoice with session keys..."); + + // Use the SAME keys that were sent during session creation! + SecretKeySpec aesKey = new SecretKeySpec(encryption.getAesKeyBytes(), "AES"); + IvParameterSpec iv = new IvParameterSpec(encryption.getInitializationVector()); + + // Encrypt with AES-256-CBC + Cipher aesCipher = Cipher.getInstance("AES/CBC/PKCS5Padding"); + aesCipher.init(Cipher.ENCRYPT_MODE, aesKey, iv); + + byte[] encryptedData = aesCipher.doFinal(invoiceXml); + + logger.info("│ ├── Invoice encrypted - " + encryptedData.length + " bytes"); + + return encryptedData; + } + + /** + * Calculate SHA-256 hash of data + * + * @param data byte array to hash + * @return Base64-encoded hash + * @throws NoSuchAlgorithmException if SHA-256 is not available + */ + private String calculateSHA256Hash(byte[] data) throws NoSuchAlgorithmException { + MessageDigest sha256 = MessageDigest.getInstance("SHA-256"); + byte[] hashBytes = sha256.digest(data); + return Base64.getEncoder().encodeToString(hashBytes); + } + } diff --git a/office-alexander-logistics-app/src/main/java/com/alexanderlogistics/ksef/api/KSeFAuthManager.java b/office-alexander-logistics-app/src/main/java/com/alexanderlogistics/ksef/api/KSeFAuthManager.java index 150d476..d4eb4f7 100644 --- a/office-alexander-logistics-app/src/main/java/com/alexanderlogistics/ksef/api/KSeFAuthManager.java +++ b/office-alexander-logistics-app/src/main/java/com/alexanderlogistics/ksef/api/KSeFAuthManager.java @@ -10,7 +10,6 @@ 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; @@ -83,10 +82,10 @@ public class KSeFAuthManager { private String baseURI = ""; // public keys - private String symmetricCertBase64; - private String ksefTokenCertBase64; - private PublicKey symmetricPublicKey; - private PublicKey ksefTokenPublicKey; + private String sessionEncryptionCertBase64; + private String tokenEncryptionCertBase64; + private PublicKey sessionEncryptionPublicKey; + private PublicKey tokenEncryptionPublicKey; // Auth information private String challenge = ""; @@ -95,11 +94,10 @@ public class KSeFAuthManager { private String challengeTimestamp = null; // Session information + private SessionEncryption encryption = null; private String accessToken = null; private String sessionRefNumber; private String sessionValidUntil; - private byte[] sessionAesKey; - private byte[] sessionIv; @PostConstruct void init() { @@ -142,6 +140,10 @@ public class KSeFAuthManager { return accessToken; } + public SessionEncryption getSessionEncryption() { + return encryption; + } + public void setKsefNip(String nip) { this.ksefNip = Optional.ofNullable(nip); } @@ -170,20 +172,8 @@ public class KSeFAuthManager { return authToken; } - public PublicKey getSymmetricPublicKey() { - return symmetricPublicKey; - } - - public String getChallengeValue() { - return challenge; - } - - public byte[] getSessionAesKey() { - return sessionAesKey; - } - - public byte[] getSessionIv() { - return sessionIv; + public PublicKey getSessionEncryptionPublicKey() { + return sessionEncryptionPublicKey; } /** @@ -200,12 +190,12 @@ public class KSeFAuthManager { this.loadPublicKeyCertificates(); this.authChallenge(); this.authKSeFToken(); - this.waitSomeTime(3000); + // this.waitSomeTime(3000); // Jetzt Status abwarten this.waitForAuthStatus(); - waitSomeTime(5000); + // waitSomeTime(5000); this.redeemToken(); this.openInteractiveSession(); @@ -308,30 +298,30 @@ public class KSeFAuthManager { var usages = entry.getJsonArray("usage"); if (usages.stream().anyMatch(v -> v.toString().contains("KsefTokenEncryption"))) { - ksefTokenCertBase64 = entry.getString("certificate"); + tokenEncryptionCertBase64 = entry.getString("certificate"); if (debug) { logger.info("│ ├── ✓ KsefTokenEncryption certificate found."); } // Public Key extrahieren - ksefTokenPublicKey = extractPublicKeyFromCertificate(ksefTokenCertBase64); + tokenEncryptionPublicKey = extractPublicKeyFromCertificate(tokenEncryptionCertBase64); logger.info("│ ├── ✓ RSA PublicKey successfully extracted."); } if (usages.stream().anyMatch(v -> v.toString().contains("SymmetricKeyEncryption"))) { - symmetricCertBase64 = entry.getString("certificate"); + sessionEncryptionCertBase64 = entry.getString("certificate"); if (debug) { logger.info("│ ├── ✓ SymmetricKeyEncryption certificate found."); } // Public Key extrahieren - symmetricPublicKey = extractPublicKeyFromCertificate(symmetricCertBase64); + sessionEncryptionPublicKey = extractPublicKeyFromCertificate(sessionEncryptionCertBase64); logger.info("│ ├── ✓ Symetric PublicKey successfully extracted."); } } - if (ksefTokenPublicKey == null) { + if (tokenEncryptionPublicKey == null) { logger.warning("│ ├── ⚠️ No certificate with usage 'ksefTokenPublicKey' found!"); } - if (symmetricPublicKey == null) { + if (sessionEncryptionPublicKey == null) { logger.warning("│ ├── ⚠️ No certificate with usage 'symmetricPublicKey' found!"); } } catch (Exception e) { @@ -595,27 +585,16 @@ public class KSeFAuthManager { String uri = baseURI + "/sessions/online"; logger.info("│ ├── POST: " + 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); + // Generate encryption data using helper + encryption = KSeFEncryptionHelper + .createSessionEncryption(this.sessionEncryptionPublicKey); + // Build JSON payload String jsonPayload = String.format( "{" + "\"formCode\": {" + - " \"systemCode\": \"FA (3)\"," + + " \"systemCode\": \"FA (2)\"," + " \"schemaVersion\": \"1-0E\"," + " \"value\": \"FA\"" + "}," + @@ -624,28 +603,27 @@ public class KSeFAuthManager { " \"initializationVector\": \"%s\"" + "}" + "}", - Base64.getEncoder().encodeToString(encryptedAesKey), - Base64.getEncoder().encodeToString(ivBytes)); + encryption.getEncryptedAesKeyBase64(), encryption.getIvBase64()); if (debug) { logger.info("│ ├── Payload: " + jsonPayload); } + // Send HTTP request 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()); + HttpResponse response = httpClient.send(request, + HttpResponse.BodyHandlers.ofString()); - logger.info("│ ├── HTTP Response: " + response.statusCode()); + logger.info("│ ├── HTTP Response: " + response.statusCode()); if (debug) { - - logger.info("Response: " + response.body()); + logger.info("│ ├── Response Body: " + response.body()); } if (response.statusCode() != 201) { @@ -653,41 +631,31 @@ public class KSeFAuthManager { "Failed to open interactive session: " + response.statusCode()); } - // parse JSON + // Parse and store session data 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; + logger.info("│ ├── Session ReferenceNumber = " + sessionRefNumber); + logger.info("│ └── Session ValidUntil = " + sessionValidUntil); + } catch (Exception e) { - logger.severe("├── ⚠️ Error parsing JSON response" + e.getMessage()); + 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) { + + // WICHTIG: Warte kurz, damit die Session vollständig initialisiert wird + // logger.info("│ ├── Waiting for session to be ready..."); + // waitSomeTime(30000); // 2 Sekunden warten + + } catch (Exception e) { logger.severe("├── ⚠️ API Error: " + e.getMessage()); throw new PluginException(KSeFAuthManager.class.getSimpleName(), ERROR_API, "API Error openInteractiveSession: " + e.getMessage()); } - - // logger.info("│ ├── Sleep 5 sec..."); - // try { - // Thread.sleep(5000); - // } catch (InterruptedException e) { - // Thread.currentThread().interrupt(); - - // } - } /** @@ -704,7 +672,7 @@ public class KSeFAuthManager { */ public String getEncryptedToken() throws PluginException { String result = null; - if (ksefTokenPublicKey == null) { + if (tokenEncryptionPublicKey == null) { throw new IllegalStateException("Public Key not loaded! Call loadPublicKeyCertificates() first."); } @@ -737,7 +705,7 @@ public class KSeFAuthManager { "MGF1", MGF1ParameterSpec.SHA256, PSource.PSpecified.DEFAULT); - cipher.init(Cipher.ENCRYPT_MODE, ksefTokenPublicKey, oaepParams); + cipher.init(Cipher.ENCRYPT_MODE, tokenEncryptionPublicKey, oaepParams); byte[] encrypted = cipher.doFinal(plaintext.getBytes(StandardCharsets.UTF_8)); @@ -766,6 +734,7 @@ public class KSeFAuthManager { * 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) + * The method returns the aut status code (not the HTTP Response code!) * * https://ksef-test.mf.gov.pl/docs/v2/index.html#tag/Uzyskiwanie-dostepu/paths/~1api~1v2~1auth~1%7BreferenceNumber%7D/get * @@ -776,7 +745,7 @@ public class KSeFAuthManager { logger.info("├── ↩️ KSeF API check auth status..."); String uri = baseURI + "/auth/" + authRefNumber; int httpResponse = -1; - + int statusCode = -1; logger.info("│ ├── GET: " + uri); HttpResponse response = null; try { @@ -800,8 +769,19 @@ public class KSeFAuthManager { try (Jsonb jsonb = JsonbBuilder.create()) { JsonObject jsonObject = jsonb.fromJson(response.body(), JsonObject.class); + + // Status Code aus dem status-Objekt extrahieren + if (jsonObject.containsKey("status")) { + JsonObject statusObject = jsonObject.getJsonObject("status"); + if (statusObject.containsKey("code")) { + statusCode = statusObject.getInt("code"); + logger.info("│ ├── Status Code: " + statusCode); + logger.info("│ ├── Status Description: " + statusObject.getString("description")); + } + } + Boolean isRedeemed = jsonObject.containsKey("isTokenRedeemed") ? jsonObject.getBoolean("isTokenRedeemed") - : null; + : false; String lastTokenRefreshDate = jsonObject.containsKey("lastTokenRefreshDate") ? jsonObject.getString("lastTokenRefreshDate") @@ -824,7 +804,7 @@ public class KSeFAuthManager { "Error parsing JSON response: " + e.getMessage()); } - return httpResponse; + return statusCode; } /** diff --git a/office-alexander-logistics-app/src/main/java/com/alexanderlogistics/ksef/api/KSeFEncryptionHelper.java b/office-alexander-logistics-app/src/main/java/com/alexanderlogistics/ksef/api/KSeFEncryptionHelper.java new file mode 100644 index 0000000..2de6629 --- /dev/null +++ b/office-alexander-logistics-app/src/main/java/com/alexanderlogistics/ksef/api/KSeFEncryptionHelper.java @@ -0,0 +1,129 @@ +package com.alexanderlogistics.ksef.api; + +import java.security.InvalidKeyException; +import java.security.NoSuchAlgorithmException; +import java.security.PublicKey; +import java.security.SecureRandom; +import java.util.Base64; + +import javax.crypto.BadPaddingException; +import javax.crypto.Cipher; +import javax.crypto.IllegalBlockSizeException; +import javax.crypto.KeyGenerator; +import javax.crypto.NoSuchPaddingException; +import javax.crypto.SecretKey; +import javax.crypto.spec.SecretKeySpec; + +/** + * Helper class for KSeF encryption operations. + * Handles AES-256 symmetric key generation and RSA encryption. + */ +public class KSeFEncryptionHelper { + + private static final String AES_ALGORITHM = "AES"; + private static final String RSA_TRANSFORMATION = "RSA/ECB/OAEPWithSHA-1AndMGF1Padding"; + private static final int AES_KEY_SIZE = 256; + private static final int IV_SIZE = 16; + + /** + * Generate a new AES-256 symmetric key for session encryption + * + * @return SecretKey instance containing the generated AES-256 key + * @throws NoSuchAlgorithmException if AES algorithm is not available + */ + public static SecretKey generateAesKey() throws NoSuchAlgorithmException { + KeyGenerator keyGen = KeyGenerator.getInstance(AES_ALGORITHM); + keyGen.init(AES_KEY_SIZE, new SecureRandom()); + return keyGen.generateKey(); + } + + /** + * Generate a random initialization vector for AES encryption + * + * @return byte array containing 16 random bytes + */ + public static byte[] generateInitializationVector() { + byte[] iv = new byte[IV_SIZE]; + new SecureRandom().nextBytes(iv); + return iv; + } + + /** + * Encrypt a symmetric AES key with KSeF's RSA public key + * + * @param aesKey the AES symmetric key to encrypt + * @param ksefPublicKey KSeF's RSA public key + * @return Base64-encoded encrypted AES key + * @throws NoSuchAlgorithmException if RSA algorithm is not available + * @throws NoSuchPaddingException if padding scheme is not available + * @throws InvalidKeyException if the public key is invalid + * @throws IllegalBlockSizeException if the key size is invalid + * @throws BadPaddingException if padding is incorrect + */ + public static String encryptAesKey(SecretKey aesKey, PublicKey ksefPublicKey) + throws NoSuchAlgorithmException, NoSuchPaddingException, + InvalidKeyException, IllegalBlockSizeException, BadPaddingException { + + Cipher rsaCipher = Cipher.getInstance(RSA_TRANSFORMATION); + rsaCipher.init(Cipher.ENCRYPT_MODE, ksefPublicKey); + + byte[] encryptedKey = rsaCipher.doFinal(aesKey.getEncoded()); + return Base64.getEncoder().encodeToString(encryptedKey); + } + + /** + * Encode byte array to Base64 string + * + * @param data byte array to encode + * @return Base64-encoded string + */ + public static String toBase64(byte[] data) { + return Base64.getEncoder().encodeToString(data); + } + + /** + * Decode Base64 string to byte array + * + * @param base64 Base64-encoded string + * @return decoded byte array + */ + public static byte[] fromBase64(String base64) { + return Base64.getDecoder().decode(base64); + } + + /** + * Recreate SecretKey from raw bytes (useful when loading from storage) + * + * @param keyBytes raw AES key bytes + * @return SecretKey instance + */ + public static SecretKey bytesToAesKey(byte[] keyBytes) { + return new SecretKeySpec(keyBytes, AES_ALGORITHM); + } + + /** + * Generate complete session encryption data + * Creates AES key, IV, and encrypts the AES key with KSeF's public key + * + * @param ksefPublicKey KSeF's RSA public key + * @return SessionEncryption containing all encryption data + * @throws NoSuchAlgorithmException if algorithm is not available + * @throws NoSuchPaddingException if padding scheme is not available + * @throws InvalidKeyException if the public key is invalid + * @throws IllegalBlockSizeException if the key size is invalid + * @throws BadPaddingException if padding is incorrect + */ + public static SessionEncryption createSessionEncryption(PublicKey ksefPublicKey) + throws NoSuchAlgorithmException, NoSuchPaddingException, + InvalidKeyException, IllegalBlockSizeException, BadPaddingException { + + // Generate AES key and IV + SecretKey aesKey = generateAesKey(); + byte[] iv = generateInitializationVector(); + + // Encrypt AES key with KSeF's public key + String encryptedAesKey = encryptAesKey(aesKey, ksefPublicKey); + + return new SessionEncryption(aesKey, iv, encryptedAesKey); + } +} \ No newline at end of file diff --git a/office-alexander-logistics-app/src/main/java/com/alexanderlogistics/ksef/api/SessionEncryption.java b/office-alexander-logistics-app/src/main/java/com/alexanderlogistics/ksef/api/SessionEncryption.java new file mode 100644 index 0000000..7aaa1e6 --- /dev/null +++ b/office-alexander-logistics-app/src/main/java/com/alexanderlogistics/ksef/api/SessionEncryption.java @@ -0,0 +1,40 @@ +package com.alexanderlogistics.ksef.api; + +import java.util.Base64; + +import javax.crypto.SecretKey; + +/** + * Container class for session encryption data + */ +public class SessionEncryption { + private final SecretKey aesKey; + private final byte[] initializationVector; + private final String encryptedAesKeyBase64; + + public SessionEncryption(SecretKey aesKey, byte[] iv, String encryptedAesKeyBase64) { + this.aesKey = aesKey; + this.initializationVector = iv; + this.encryptedAesKeyBase64 = encryptedAesKeyBase64; + } + + public SecretKey getAesKey() { + return aesKey; + } + + public byte[] getAesKeyBytes() { + return aesKey.getEncoded(); + } + + public byte[] getInitializationVector() { + return initializationVector; + } + + public String getIvBase64() { + return Base64.getEncoder().encodeToString(initializationVector); + } + + public String getEncryptedAesKeyBase64() { + return encryptedAesKeyBase64; + } +} \ No newline at end of file diff --git a/office-alexander-logistics-app/src/test/java/com/alexanderlogistics/ksef/api/KSeFAPIServiceTest.java b/office-alexander-logistics-app/src/test/java/com/alexanderlogistics/ksef/api/KSeFAPIServiceTest.java index 67a520a..886c59b 100644 --- a/office-alexander-logistics-app/src/test/java/com/alexanderlogistics/ksef/api/KSeFAPIServiceTest.java +++ b/office-alexander-logistics-app/src/test/java/com/alexanderlogistics/ksef/api/KSeFAPIServiceTest.java @@ -14,18 +14,13 @@ import org.imixs.workflow.FileData; import org.imixs.workflow.ItemCollection; import org.junit.jupiter.api.BeforeEach; import org.junit.jupiter.api.DisplayName; -import org.junit.jupiter.api.MethodOrderer; -import org.junit.jupiter.api.Order; import org.junit.jupiter.api.Test; -import org.junit.jupiter.api.TestMethodOrder; -import org.junit.jupiter.api.extension.ExtendWith; -import org.mockito.junit.jupiter.MockitoExtension; import com.alexanderlogistics.TestLoggerConfig; //@Disabled -@ExtendWith(MockitoExtension.class) -@TestMethodOrder(MethodOrderer.OrderAnnotation.class) +//@ExtendWith(MockitoExtension.class) +// @TestMethodOrder(MethodOrderer.OrderAnnotation.class) public class KSeFAPIServiceTest { private static Logger logger = Logger.getLogger(KSeFAPIServiceTest.class.getName()); @@ -49,16 +44,6 @@ public class KSeFAPIServiceTest { // init() ausführen manager.init(); - // Open Session - // manager.openSession(); - - // manager.checkTokenStatus(); - // manager.loadPublicKeyCertificates(); - // manager.authChallenge(); - // manager.authKSeFToken(); - // manager.redeemToken(); - // manager.openInteractiveSession(); - // Create API Service and inject manager apiService = new KSeFAPIService(); apiService.kseFAuthManager = manager; @@ -66,7 +51,6 @@ public class KSeFAPIServiceTest { } @Test - @Order(4) @DisplayName("Upload Invoice XML (AES-256 + RSA)") public void testUploadInvoice() throws Exception { logger.info("==> Test: Upload Invoice XML"); @@ -74,6 +58,7 @@ public class KSeFAPIServiceTest { ItemCollection workitem = createWorkitem(); // Execute upload + String referenceNumber = apiService.uploadInvoice(workitem, "example-invoice-01.xml"); // Verify result diff --git a/office-alexander-logistics-app/src/test/java/com/alexanderlogistics/ksef/api/KSeFAuthManagerTest.java b/office-alexander-logistics-app/src/test/java/com/alexanderlogistics/ksef/api/KSeFAuthManagerTest.java index f15b26b..327979d 100644 --- a/office-alexander-logistics-app/src/test/java/com/alexanderlogistics/ksef/api/KSeFAuthManagerTest.java +++ b/office-alexander-logistics-app/src/test/java/com/alexanderlogistics/ksef/api/KSeFAuthManagerTest.java @@ -10,7 +10,6 @@ import java.nio.file.Path; import java.nio.file.Paths; import java.security.MessageDigest; import java.security.PublicKey; -import java.security.SecureRandom; import java.util.Base64; import java.util.logging.Logger; @@ -32,154 +31,159 @@ import com.alexanderlogistics.TestLoggerConfig; @TestMethodOrder(MethodOrderer.OrderAnnotation.class) public class KSeFAuthManagerTest { - private static Logger logger = Logger.getLogger(KSeFAuthManagerTest.class.getName()); + private static Logger logger = Logger.getLogger(KSeFAuthManagerTest.class.getName()); - private KSeFAuthManager manager; + private KSeFAuthManager manager; - @BeforeEach - void setup() throws Exception { - TestLoggerConfig.setupTestLogger(); - manager = new KSeFAuthManager(); + @BeforeEach + void setup() throws Exception { + TestLoggerConfig.setupTestLogger(); + manager = new KSeFAuthManager(); - // Test config - manager.setKsefToken(Params.TOKEN); - manager.setKsefNip(Params.NIP); - manager.setKsefEndpoint("https://ksef-test.mf.gov.pl/api/v2"); - manager.setDebug(false); + // Test config + manager.setKsefToken(Params.TOKEN); + manager.setKsefNip(Params.NIP); + manager.setKsefEndpoint("https://ksef-test.mf.gov.pl/api/v2"); + manager.setDebug(false); - // init() ausführen - manager.init(); - } - - // ------------------------------------------------------------ - // 1) Challenge abholen - // ------------------------------------------------------------ - @Test - @Order(1) - @DisplayName("POST /api/v2/auth/token/redeem") - void testRedeemToken() throws Exception { - - manager.loadPublicKeyCertificates(); - manager.authChallenge(); - manager.authKSeFToken(); - manager.redeemToken(); - - assertNotNull( - manager.getAuthRefNumber(), - "RefNumber darf nicht NULL sein"); - - assertNotNull( - manager.getAuthToken(), - "AuthToken darf nicht leer sein"); - - } - - @Test - @Order(4) - @DisplayName("Upload Invoice XML (AES-256 + RSA)") - public void testUploadInvoice() throws Exception { - - // ---------- 1) Vorbereitung ---------- - // API initialisieren - manager.loadPublicKeyCertificates(); - manager.authChallenge(); - manager.authKSeFToken(); - - manager.waitForAuthStatus(); - - manager.redeemToken(); - - manager.openInteractiveSession(); - - assertNotNull(manager.getAccessToken(), "AccessToken fehlt"); - // assertNotNull(manager.getRefNumber(), "ReferenceNumber fehlt"); - assertNotNull(manager.getSessionRefNumber(), "SessionReferenceNumber fehlt"); - - // ---------- 2) XML laden ---------- - Path xmlPath = Paths.get("src/test/resources/ksef/example-invoice-01.xml"); - byte[] invoiceXml = Files.readAllBytes(xmlPath); - - // ---------- 3) AES Key generieren ---------- - SecureRandom random = new SecureRandom(); - - byte[] aesKeyBytes = new byte[32]; // 256-bit AES key - random.nextBytes(aesKeyBytes); - - byte[] ivBytes = new byte[16]; // 128-bit IV - random.nextBytes(ivBytes); - - SecretKeySpec aesKey = new SecretKeySpec(aesKeyBytes, "AES"); - IvParameterSpec iv = new IvParameterSpec(ivBytes); - - // ---------- 4) XML per AES-256-CBC verschlüsseln ---------- - javax.crypto.Cipher aesCipher = javax.crypto.Cipher.getInstance("AES/CBC/PKCS5Padding"); - aesCipher.init(javax.crypto.Cipher.ENCRYPT_MODE, aesKey, iv); - - byte[] encryptedInvoiceXml = aesCipher.doFinal(invoiceXml); - - // ---------- 5) AES Key per RSA verschlüsseln ---------- - PublicKey ksefPublicKey = manager.getSymmetricPublicKey(); - - javax.crypto.Cipher rsaCipher = javax.crypto.Cipher - .getInstance("RSA/ECB/OAEPWithSHA-256AndMGF1Padding"); - rsaCipher.init(javax.crypto.Cipher.ENCRYPT_MODE, ksefPublicKey); - - byte[] encryptedAesKey = rsaCipher.doFinal(aesKeyBytes); - - // ---------- 6) SHA-256 Hash des Klartext-XML berechnen ---------- - MessageDigest sha256 = MessageDigest.getInstance("SHA-256"); - - // hash origin invoice - byte[] xmlHashBytes = sha256.digest(invoiceXml); - String invoiceHash = Base64.getEncoder().encodeToString(xmlHashBytes); - - // hash encrypted invoice - xmlHashBytes = sha256.digest(encryptedInvoiceXml); - String encryptedInvoiceHash = Base64.getEncoder().encodeToString(xmlHashBytes); - - // ---------- 7) Request-Body bauen ---------- - // Struktur gemäß KSeF-Doku - String jsonBody = String.format("{ " + - "\"invoiceHash\": \"%s\", " + - "\"invoiceSize\": \"%s\", " + - "\"encryptedInvoiceHash\": \"%s\", " + - "\"encryptedInvoiceSize\": \"%s\", " + - "\"encryptedInvoiceContent\": \"%s\", " + - "\"offlineMode\": false " + - "}", - invoiceHash, - invoiceXml.length, - encryptedInvoiceHash, - encryptedInvoiceXml.length, - Base64.getEncoder().encodeToString(encryptedInvoiceXml)); - - // ---------- 8) HTTP POST vorbereiten ---------- - // String uri = manager.getBaseURI() + "/sessions/online/" + - // manager.getRefNumber() + "/invoices"; - String uri = manager.getBaseURI() + "/sessions/online/" + manager.getSessionRefNumber() + "/invoices"; - logger.info("│ ├── Endpoint: " + uri); - - if (manager.isDebug()) { - logger.info("Request: " + jsonBody); + // init() ausführen + manager.init(); } - HttpRequest request = HttpRequest.newBuilder() - .uri(URI.create(uri)) - .header("Content-Type", "application/json") - .header("Authorization", "Bearer " + manager.getAccessToken()) - .POST(HttpRequest.BodyPublishers.ofString(jsonBody)) - .build(); - // ---------- 9) Call ausführen ---------- - var response = manager.getHttpClient().send(request, - java.net.http.HttpResponse.BodyHandlers.ofString()); + // ------------------------------------------------------------ + // 1) Challenge abholen + // ------------------------------------------------------------ + @Test + @Order(1) + @DisplayName("POST /api/v2/auth/token/redeem") + void testRedeemToken() throws Exception { - System.out.println("Upload Response:"); - System.out.println(response.statusCode()); - System.out.println(response.body()); + manager.loadPublicKeyCertificates(); + manager.authChallenge(); + manager.authKSeFToken(); + manager.redeemToken(); - // ---------- 10) Basic Assertions ---------- - assertTrue(response.statusCode() == 202, - "Upload muss erfolgreich sein"); - } + assertNotNull( + manager.getAuthRefNumber(), + "RefNumber darf nicht NULL sein"); + + assertNotNull( + manager.getAuthToken(), + "AuthToken darf nicht leer sein"); + + } + + @Test + @Order(4) + @DisplayName("Upload Invoice XML (AES-256 + RSA)") + public void testUploadInvoice() throws Exception { + + // ---------- 1) Vorbereitung ---------- + // API initialisieren + manager.loadPublicKeyCertificates(); + manager.authChallenge(); + manager.authKSeFToken(); + + manager.waitForAuthStatus(); + + manager.redeemToken(); + + manager.openInteractiveSession(); + + assertNotNull(manager.getAccessToken(), "AccessToken fehlt"); + // assertNotNull(manager.getRefNumber(), "ReferenceNumber fehlt"); + assertNotNull(manager.getSessionRefNumber(), "SessionReferenceNumber fehlt"); + + // ---------- 2) XML laden ---------- + Path xmlPath = Paths.get("src/test/resources/ksef/example-invoice-01.xml"); + byte[] invoiceXml = Files.readAllBytes(xmlPath); + + // ---------- 3) AES Key generieren ---------- + // SecureRandom random = new SecureRandom(); + // byte[] aesKeyBytes = new byte[32]; // 256-bit AES key + // random.nextBytes(aesKeyBytes); + // byte[] ivBytes = new byte[16]; // 128-bit IV + // random.nextBytes(ivBytes); + // SecretKeySpec aesKey = new SecretKeySpec(aesKeyBytes, "AES"); + // IvParameterSpec iv = new IvParameterSpec(ivBytes); + + // Statt neue Keys zu generieren: + SessionEncryption encryption = manager.getSessionEncryption(); + byte[] aesKeyBytes = encryption.getAesKeyBytes(); + byte[] ivBytes = encryption.getInitializationVector(); + + SecretKeySpec aesKey = new SecretKeySpec(aesKeyBytes, "AES"); + IvParameterSpec iv = new IvParameterSpec(ivBytes); + + // ---------- 4) XML per AES-256-CBC verschlüsseln ---------- + javax.crypto.Cipher aesCipher = javax.crypto.Cipher.getInstance("AES/CBC/PKCS5Padding"); + aesCipher.init(javax.crypto.Cipher.ENCRYPT_MODE, aesKey, iv); + + byte[] encryptedInvoiceXml = aesCipher.doFinal(invoiceXml); + + // ---------- 5) AES Key per RSA verschlüsseln ---------- + PublicKey ksefPublicKey = manager.getSessionEncryptionPublicKey(); + + javax.crypto.Cipher rsaCipher = javax.crypto.Cipher + .getInstance("RSA/ECB/OAEPWithSHA-256AndMGF1Padding"); + rsaCipher.init(javax.crypto.Cipher.ENCRYPT_MODE, ksefPublicKey); + + byte[] encryptedAesKey = rsaCipher.doFinal(aesKeyBytes); + + // ---------- 6) SHA-256 Hash des Klartext-XML berechnen ---------- + MessageDigest sha256 = MessageDigest.getInstance("SHA-256"); + + // hash origin invoice + byte[] xmlHashBytes = sha256.digest(invoiceXml); + String invoiceHash = Base64.getEncoder().encodeToString(xmlHashBytes); + + // hash encrypted invoice + xmlHashBytes = sha256.digest(encryptedInvoiceXml); + String encryptedInvoiceHash = Base64.getEncoder().encodeToString(xmlHashBytes); + + // ---------- 7) Request-Body bauen ---------- + // Struktur gemäß KSeF-Doku + String jsonBody = String.format("{ " + + "\"invoiceHash\": \"%s\", " + + "\"invoiceSize\": \"%s\", " + + "\"encryptedInvoiceHash\": \"%s\", " + + "\"encryptedInvoiceSize\": \"%s\", " + + "\"encryptedInvoiceContent\": \"%s\", " + + "\"offlineMode\": false " + + "}", + invoiceHash, + invoiceXml.length, + encryptedInvoiceHash, + encryptedInvoiceXml.length, + Base64.getEncoder().encodeToString(encryptedInvoiceXml)); + + // ---------- 8) HTTP POST vorbereiten ---------- + // String uri = manager.getBaseURI() + "/sessions/online/" + + // manager.getRefNumber() + "/invoices"; + String uri = manager.getBaseURI() + "/sessions/online/" + manager.getSessionRefNumber() + "/invoices"; + logger.info("│ ├── Endpoint: " + uri); + + if (manager.isDebug()) { + logger.info("Request: " + jsonBody); + } + HttpRequest request = HttpRequest.newBuilder() + .uri(URI.create(uri)) + .header("Content-Type", "application/json") + .header("Authorization", "Bearer " + manager.getAccessToken()) + .POST(HttpRequest.BodyPublishers.ofString(jsonBody)) + .build(); + + // ---------- 9) Call ausführen ---------- + var response = manager.getHttpClient().send(request, + java.net.http.HttpResponse.BodyHandlers.ofString()); + + System.out.println("Upload Response:"); + System.out.println(response.statusCode()); + System.out.println(response.body()); + + // ---------- 10) Basic Assertions ---------- + assertTrue(response.statusCode() == 202, + "Upload muss erfolgreich sein"); + } }