diff --git a/doc/KSeF/README.md b/doc/KSeF/README.md index 821973e..ecb28a3 100644 --- a/doc/KSeF/README.md +++ b/doc/KSeF/README.md @@ -1,41 +1,174 @@ -# KSeF - Converter +# KSeF API Integration -Die Idee ist es Rechnungen nach Erhalt aus cargosoft über die KSeF FA(2) API an die Behörde zu senden. +Java integration for the Polish **Krajowy System e-Faktur (KSeF)** - the national e-invoicing system for Poland. -# API +## Overview -Dokumentation: https://ksef-test.mf.gov.pl/docs/v2/index.html +This integration provides secure communication with the KSeF API to upload electronic invoices. It handles the complex multi-step authentication flow and encryption requirements mandated by the Polish tax authorities. -Die Basis url ist: "https://ksef-test.mf.gov.pl/api/v2"; +## Components -Der Ablauf für die Übermittlung ist folgender: +### 1. KSeFAuthManager -- vom Polnischen Goverment (bzw. AGL/Taxman) haben wir ein Secret bekommen (KSeFToken) -- wir machen den call `/security/public-key-certificates` um Security Zertifikate zu erhalten -- wir machen den call `/auth/challenge` um eine Challenge ID zu erhalten -- wir machen den call `/auth/ksef-token` um eine ReferenceID und einen AccessToken zu erhalten -- wir senden die Rechnung - mit dem öffentlichen Schlüssel verschlüsselt zusammen mit unserer ReferenceID und dem AccessToken an das Goverment. +Manages authentication and session lifecycle for the KSeF API. -## Generieren einer Referenznummer. +**Key Responsibilities:** -Man benötigt eine Referenznummer (Session ID). Dise kann man wie folgt generieren: +- Multi-step authentication flow +- RSA certificate management +- Session key generation and encryption +- Access token lifecycle management +- Session reuse and validation -1.) "/auth/challenge" aufrufen um einen Challenge ID zu erhalten +### 2. KSeFAPIService -2.) "/auth/ksef-token" ksef Token + Challenge ID senden - man bekommt die Referenznummer (Gültig 1 Stunde) +Provides business-level methods for invoice operations. -## Rechnung senden +**Key Responsibilities:** -API Aufruf: https://ksef-test.mf.gov.pl/docs/v2/index.html#tag/Wysylka-interaktywna/paths/~1api~1v2~1sessions~1online~1%7BreferenceNumber%7D~1invoices/post +- Invoice encryption (AES-256-CBC) +- Invoice upload to KSeF +- Hash calculation and validation +- Error handling and reporting -Details: +## Authentication Flow -https://github.com/CIRFMF/ksef-docs/blob/main/sesja-interaktywna.md#2-wys%C5%82anie-faktury +The KSeF API requires a sophisticated multi-step authentication process: -Man muss die XML Datei mit dem öffentlichen Schlüssel der Behörde vor dem Versand verschlüsseln +``` +1. Load Public Keys + └─> GET /security/public-key-certificates -### Das Rechnungsformat: +2. Challenge Request + └─> POST /auth/challenge + └─> Returns: challenge + timestamp -XML Datei +3. Token Authentication + └─> POST /auth/ksef-token + └─> Encrypts: token|timestamp with RSA-OAEP SHA-256 + └─> Returns: authToken + referenceNumber -https://github.com/CIRFMF/ksef-docs/blob/main/faktury/weryfikacja-faktury.md +4. Token Redemption + └─> POST /auth/token/redeem + └─> Returns: accessToken + +5. Open Interactive Session + └─> POST /sessions/online + └─> Generates AES-256 key + IV + └─> Encrypts session key with RSA + └─> Returns: sessionRefNumber + validUntil +``` + +## Invoice Upload Flow + +Once authenticated, invoices can be uploaded: + +``` +1. Reuse or Create Session + └─> Validates existing session or creates new one + +2. Encrypt Invoice + └─> AES-256-CBC encryption using session key + +3. Calculate Hashes + └─> SHA-256 of original XML + └─> SHA-256 of encrypted XML + +4. Upload Invoice + └─> POST /sessions/online/{sessionRef}/invoices + └─> Returns: referenceNumber +``` + +## Security Features + +### Multi-Layer Encryption + +- **RSA-OAEP SHA-256**: Token encryption with timestamp binding +- **RSA-OAEP SHA-1**: Session key encryption +- **AES-256-CBC**: Invoice content encryption + +### Security Mechanisms + +- **Replay Attack Protection**: Timestamp-bound tokens +- **Session Management**: Automatic session reuse and validation +- **Certificate Validation**: Dynamic X.509 certificate loading +- **UTC Timezone Handling**: Prevents timezone-related vulnerabilities + +## Usage Example + +```java +// Initialize Auth Manager +KSeFAuthManager authManager = new KSeFAuthManager(); +authManager.setKsefToken("your-ksef-token"); +authManager.setKsefNip("1234567890"); +authManager.setKsefEndpoint("https://ksef-test.mf.gov.pl/api/v2"); +authManager.init(); + +// Initialize API Service +KSeFAPIService apiService = new KSeFAPIService(); +apiService.kseFAuthManager = authManager; + +// Upload Invoice +ItemCollection workitem = new ItemCollection(); +FileData fileData = new FileData("invoice.xml", xmlBytes, null, null); +workitem.addFileData(fileData); + +String referenceNumber = apiService.uploadInvoice(workitem, "invoice.xml"); +System.out.println("Invoice uploaded: " + referenceNumber); +``` + +## Configuration + +Required environment variables: + +```properties +ksef.api.token=your-ksef-authentication-token +ksef.api.nip=your-company-nip-number +ksef.api.endpoint=https://ksef-test.mf.gov.pl/api/v2 +ksef.api.debug=false +``` + +## Session Management + +The `KSeFAuthManager` automatically handles session lifecycle: + +- **Session Reuse**: Validates `sessionValidUntil` timestamp (UTC) +- **Auto-Renewal**: Opens new session if current one expired +- **Thread-Safe**: Uses `@Lock(LockType.WRITE)` for concurrent access + +## Testing + +Test environment endpoint: + +``` +https://ksef-test.mf.gov.pl/api/v2 +``` + +Production endpoint: + +``` +https://ksef.mf.gov.pl/api/v2 +``` + +See `KSeFAPIServiceTest.java` for complete test examples. + +## Error Handling + +The implementation uses `PluginException` for error handling with two error types: + +- `CONFIG_ERROR`: Configuration or setup issues +- `API_ERROR`: API communication or response errors + +## Dependencies + +- Jakarta EE (EJB, JSON-B) +- Java 11+ (HttpClient, Crypto APIs) +- Imixs Workflow (for document management) + +## References + +- [KSeF Official Documentation](https://www.gov.pl/web/kas/ksef) +- [KSeF API Specification](https://ksef-test.mf.gov.pl/docs/v2/index.html) +- [Upload Invoice](https://ksef-test.mf.gov.pl/docs/v2/index.html#tag/Wysylka-interaktywna/paths/~1api~1v2~1sessions~1online~1%7BreferenceNumber%7D~1invoices/post) +- [Facture Details](https://github.com/CIRFMF/ksef-docs/blob/main/sesja-interaktywna.md#2-wys%C5%82anie-faktury) +- [XML Invoice Example](https://github.com/CIRFMF/ksef-docs/blob/main/faktury/weryfikacja-faktury.md) diff --git a/office-alexander-logistics-app/src/main/java/com/alexanderlogistics/ksef/KSeFAPIService.java b/office-alexander-logistics-app/src/main/java/com/alexanderlogistics/ksef/KSeFAPIService.java new file mode 100644 index 0000000..61737c2 --- /dev/null +++ b/office-alexander-logistics-app/src/main/java/com/alexanderlogistics/ksef/KSeFAPIService.java @@ -0,0 +1,207 @@ +package com.alexanderlogistics.ksef; + +import java.io.IOException; +import java.net.URI; +import java.net.http.HttpRequest; +import java.security.InvalidAlgorithmParameterException; +import java.security.InvalidKeyException; +import java.security.MessageDigest; +import java.security.NoSuchAlgorithmException; +import java.util.Base64; +import java.util.logging.Logger; + +import javax.crypto.BadPaddingException; +import javax.crypto.IllegalBlockSizeException; +import javax.crypto.NoSuchPaddingException; +import javax.crypto.spec.IvParameterSpec; +import javax.crypto.spec.SecretKeySpec; + +import org.imixs.workflow.FileData; +import org.imixs.workflow.ItemCollection; +import org.imixs.workflow.exceptions.PluginException; + +import jakarta.annotation.PostConstruct; +import jakarta.annotation.security.DeclareRoles; +import jakarta.annotation.security.RolesAllowed; +import jakarta.annotation.security.RunAs; +import jakarta.ejb.Singleton; +import jakarta.inject.Inject; +import jakarta.json.JsonObject; +import jakarta.json.bind.Jsonb; +import jakarta.json.bind.JsonbBuilder; + +/** + * THe KSeFAPIService provides methods to open an interactive session and upload + * an invoice. + * + */ +@DeclareRoles({ "org.imixs.ACCESSLEVEL.NOACCESS", "org.imixs.ACCESSLEVEL.READERACCESS", + "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" }) +@Singleton +@RunAs("org.imixs.ACCESSLEVEL.MANAGERACCESS") +public class KSeFAPIService { + + private static Logger logger = Logger.getLogger(KSeFAPIService.class.getName()); + + public static final String ERROR_API = "API_ERROR"; + + @Inject + KSeFAuthManager kseFAuthManager; + + @PostConstruct + void init() { + + } + + /** + * 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 - the workitem containing the invoice file + * @param fileName - the name of the invoice file to upload + * @return referenceNumber - the KSeF reference number for the uploaded invoice + * @throws PluginException + */ + public String uploadInvoice(ItemCollection workitem, String fileName) + throws PluginException { + String referenceNumber = null; + logger.info("├── Upload Invoice..."); + + // First open an interactive Session. The KSeFAuthManager automatically reuses + // an existing session + 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!"); + } + + // Get session encryption keys + byte[] aesKeyBytes = kseFAuthManager.getSessionAesKey(); + byte[] ivBytes = kseFAuthManager.getSessionIv(); + + if (aesKeyBytes == null || ivBytes == null) { + throw new PluginException(KSeFAuthManager.class.getSimpleName(), ERROR_API, + "API Error - missing session encryption keys!"); + } + + // Load invoice file + FileData fileData = workitem.getFileData(fileName); + if (fileData == null) { + throw new PluginException(KSeFAPIService.class.getSimpleName(), ERROR_API, + "File not found: " + fileName); + } + + byte[] invoiceXml = fileData.getContent(); + logger.info("│ ├── XML Invoice loaded - " + invoiceXml.length + " bytes"); + + try { + // ---------- 1) Use session AES key and IV ---------- + logger.info("│ ├── Using session AES Key..."); + SecretKeySpec aesKey = new SecretKeySpec(aesKeyBytes, "AES"); + IvParameterSpec iv = new IvParameterSpec(ivBytes); + + // ---------- 2) Encrypt XML with AES-256-CBC ---------- + logger.info("│ ├── Encrypting invoice with AES..."); + 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); + + // ---------- 3) Calculate SHA-256 hashes ---------- + logger.info("│ ├── Calculating hashes..."); + MessageDigest sha256 = MessageDigest.getInstance("SHA-256"); + + // Hash of original invoice + byte[] xmlHashBytes = sha256.digest(invoiceXml); + String invoiceHash = Base64.getEncoder().encodeToString(xmlHashBytes); + + // Hash of encrypted invoice + xmlHashBytes = sha256.digest(encryptedInvoiceXml); + String encryptedInvoiceHash = Base64.getEncoder().encodeToString(xmlHashBytes); + + // ---------- 4) Build request body ---------- + String jsonBody = String.format("{ " + + "\"invoiceHash\": \"%s\", " + + "\"invoiceSize\": %d, " + + "\"encryptedInvoiceHash\": \"%s\", " + + "\"encryptedInvoiceSize\": %d, " + + "\"encryptedInvoiceContent\": \"%s\", " + + "\"offlineMode\": false " + + "}", + invoiceHash, + invoiceXml.length, + encryptedInvoiceHash, + encryptedInvoiceXml.length, + Base64.getEncoder().encodeToString(encryptedInvoiceXml)); + + // ---------- 5) Prepare HTTP POST request ---------- + String uri = kseFAuthManager.getBaseURI() + "/sessions/online/" + + kseFAuthManager.getSessionRefNumber() + "/invoices"; + logger.info("│ ├── Endpoint: " + 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(); + + // ---------- 6) Execute request ---------- + logger.info("│ ├── Sending request..."); + var response = kseFAuthManager.getHttpClient().send(request, + java.net.http.HttpResponse.BodyHandlers.ofString()); + + logger.info("│ ├── Response Code: " + response.statusCode()); + + if (kseFAuthManager.isDebug()) { + logger.info("│ ├── Response Body: " + response.body()); + } + + // ---------- 7) Validate response status ---------- + if (response.statusCode() != 202) { + throw new PluginException(KSeFAPIService.class.getSimpleName(), ERROR_API, + "Invoice upload failed: " + response.statusCode() + " - " + response.body()); + } + + // ---------- 8) Parse response and extract reference number ---------- + try (Jsonb jsonb = JsonbBuilder.create()) { + JsonObject jsonObject = jsonb.fromJson(response.body(), JsonObject.class); + referenceNumber = jsonObject.getString("referenceNumber"); + + logger.info("│ ├── Reference Number: " + referenceNumber); + logger.info("├── ✅ Invoice upload successful"); + + } catch (Exception e) { + logger.severe("├── ⚠️ Error parsing JSON response: " + e.getMessage()); + throw new PluginException(KSeFAPIService.class.getSimpleName(), ERROR_API, + "Error parsing JSON response: " + e.getMessage()); + } + + } catch (NoSuchAlgorithmException | NoSuchPaddingException | InvalidKeyException + | IllegalBlockSizeException | BadPaddingException | InvalidAlgorithmParameterException e) { + logger.severe("├── ⚠️ Encryption error: " + e.getMessage()); + throw new PluginException(KSeFAPIService.class.getSimpleName(), ERROR_API, + "Encryption error during invoice upload: " + e.getMessage()); + } catch (IOException | InterruptedException e) { + logger.severe("├── ⚠️ HTTP request error: " + e.getMessage()); + throw new PluginException(KSeFAPIService.class.getSimpleName(), ERROR_API, + "HTTP error during invoice upload: " + e.getMessage()); + } + + return referenceNumber; + } + +} diff --git a/office-alexander-logistics-app/src/main/java/com/alexanderlogistics/ksef/KSeFAuthManager.java b/office-alexander-logistics-app/src/main/java/com/alexanderlogistics/ksef/KSeFAuthManager.java index 7cae417..aebdae2 100644 --- a/office-alexander-logistics-app/src/main/java/com/alexanderlogistics/ksef/KSeFAuthManager.java +++ b/office-alexander-logistics-app/src/main/java/com/alexanderlogistics/ksef/KSeFAuthManager.java @@ -6,6 +6,9 @@ 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; @@ -16,7 +19,10 @@ 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; @@ -110,13 +116,16 @@ public class KSeFAuthManager { .connectTimeout(Duration.ofSeconds(10)) .build(); - // this.jsonb = JsonbBuilder.create(); } public HttpClient getHttpClient() { return httpClient; } + public boolean isDebug() { + return debug; + } + public void setKsefToken(String token) { this.ksefToken = Optional.ofNullable(token); } @@ -141,18 +150,6 @@ public class KSeFAuthManager { this.debug = debug; } - /** - * Encode the access token - * - * @return - */ - public String getEncodedToken() { - - String result = java.util.Base64.getEncoder().encodeToString(ksefToken.orElse("").getBytes()); - - return result; - } - public String getBaseURI() { return baseURI; } @@ -173,6 +170,77 @@ public class KSeFAuthManager { 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 new KSeF Session..."); + if (!hasValidSession()) { + // open new session + this.loadPublicKeyCertificates(); + + this.authChallenge(); + this.authKSeFToken(); + 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() { + // 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; + } + } + /** * Diese method führt den challenge Request durch * @@ -236,29 +304,36 @@ public class KSeFAuthManager { * @throws Exception */ @Lock(LockType.WRITE) - public void authKSeFToken() throws Exception { + public void authKSeFToken() throws PluginException { logger.info("├── KSeF API auth ksef-token..."); String uri = baseURI + "/auth/ksef-token"; logger.info("│ ├── Endpoint: " + uri); - 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(); + HttpResponse response = null; + try { + String jsonPayload = String.format( + "{" + + "\"challenge\": \"%s\"," + + "\"contextIdentifier\": {" + + " \"type\": \"Nip\"," + + " \"value\": \"%s\"" + + "}," + + "\"encryptedToken\": \"%s\"" + + "}", + challenge, ksefNip.get(), getEncryptedToken()); - HttpResponse response = httpClient.send(request, HttpResponse.BodyHandlers.ofString()); + 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"); + } logger.info("│ ├── Response Code = " + response.statusCode()); if (debug) { logger.info("Response: " + response.body()); @@ -293,12 +368,13 @@ public class KSeFAuthManager { } /** - * Helper Method to open an interactive session + * 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 Exception { + public void openInteractiveSession() throws PluginException { if (accessToken == null) { throw new PluginException(KSeFAuthManager.class.getSimpleName(), ERROR_API, @@ -308,81 +384,88 @@ public class KSeFAuthManager { logger.info("├── KSeF API open interactive session..."); String uri = baseURI + "/sessions/online"; 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 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; - // --- AES Key mit RSA encrypten --- - // Cipher rsa = Cipher.getInstance("RSA/ECB/OAEPWithSHA-256AndMGF1Padding"); - Cipher rsa = Cipher.getInstance("RSA/ECB/OAEPWithSHA-1AndMGF1Padding"); - rsa.init(Cipher.ENCRYPT_MODE, this.symmetricPublicKey); - byte[] encryptedAesKey = rsa.doFinal(aesKeyBytes); + rsa = Cipher.getInstance("RSA/ECB/OAEPWithSHA-1AndMGF1Padding"); - 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)); + rsa.init(Cipher.ENCRYPT_MODE, this.symmetricPublicKey); + byte[] encryptedAesKey = rsa.doFinal(aesKeyBytes); - if (debug) { - logger.info("│ ├── Payload: " + jsonPayload); - } + 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)); - 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(); - - HttpResponse response = httpClient.send(request, HttpResponse.BodyHandlers.ofString()); - - logger.info("│ ├── Response Code = " + response.statusCode()); - if (debug) { - logger.info("Response: " + response.body()); - } - - if (response.statusCode() != 201) { - throw new PluginException(KSeFAuthManager.class.getSimpleName(), ERROR_API, - "Failed to open interactive session: " + response.statusCode()); - } - - // JSON parsen - // Session RefNumber aus Response extrahieren und speichern - - 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 (debug) { - logger.info("│ ├── Session ReferenceNumber = " + sessionRefNumber); - logger.info("│ └── Session ValidUntil = " + sessionValidUntil); + logger.info("│ ├── Payload: " + jsonPayload); } - // --- WICHTIG: AES Schlüssel speichern, wir brauchen ihn beim Upload --- - 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()); + 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()); + + logger.info("│ ├── Response Code = " + response.statusCode()); + if (debug) { + 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 (debug) { + 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()); } } @@ -393,10 +476,14 @@ public class KSeFAuthManager { * 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 Exception { - + public String getEncryptedToken() throws PluginException { + String result = null; if (ksefTokenPublicKey == null) { throw new IllegalStateException("Public Key not loaded! Call loadPublicKeyCertificates() first."); } @@ -404,45 +491,54 @@ public class KSeFAuthManager { 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)); - // 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(); - long timestampMillis = instant.toEpochMilli(); + logger.info("│ ├── Timestamp (ms): " + timestampMillis); - logger.info("│ ├── Timestamp (ms): " + timestampMillis); + // 2. Format erstellen: token|timestamp + String plaintext = ksefToken.get() + "|" + 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()); - if (debug) { - logger.info("│ ├── Plaintext format: [token]|[" + timestampMillis + "]"); } - - // 3. RSA-OAEP Verschlüsselung mit SHA-256 - 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 - String result = Base64.getEncoder().encodeToString(encrypted); - - logger.info("│ ├── ✓ Token encrypted with RSA-OAEP"); - return result; } /** - * Redeems the KSeF token and returns a session token + * Redeems the KSeF token and returns a session access token to be used to open + * an Interactive Session * * POST /auth/token/redeem * @@ -450,21 +546,26 @@ public class KSeFAuthManager { * @throws Exception */ @Lock(LockType.WRITE) - public JsonObject redeemToken() throws Exception { + public JsonObject redeemToken() throws PluginException { logger.info("├── KSeF API redeem token..."); String uri = baseURI + "/auth/token/redeem"; 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(); - HttpRequest request = HttpRequest.newBuilder() - .uri(URI.create(uri)) - .header("Accept", "application/json") - .header("Authorization", "Bearer " + authToken) - .POST(HttpRequest.BodyPublishers.noBody()) - .build(); - - HttpResponse response = httpClient.send(request, - HttpResponse.BodyHandlers.ofString()); + 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()); + } logger.info("│ ├── Response Code = " + response.statusCode()); if (debug) { @@ -503,7 +604,7 @@ public class KSeFAuthManager { * @throws Exception */ @Lock(LockType.WRITE) - public void loadPublicKeyCertificates() throws Exception { + public void loadPublicKeyCertificates() throws PluginException { logger.info("├── KSeF API load public-key-certificates..."); String uri = baseURI + "/security/public-key-certificates"; @@ -515,7 +616,13 @@ public class KSeFAuthManager { .GET() .build(); - HttpResponse response = httpClient.send(request, HttpResponse.BodyHandlers.ofString()); + 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()); + } logger.info("│ ├── Response Code = " + response.statusCode()); if (debug) { diff --git a/office-alexander-logistics-app/src/test/java/com/alexanderlogistics/ksef/KSeFAPIServiceTest.java b/office-alexander-logistics-app/src/test/java/com/alexanderlogistics/ksef/KSeFAPIServiceTest.java new file mode 100644 index 0000000..5523ffe --- /dev/null +++ b/office-alexander-logistics-app/src/test/java/com/alexanderlogistics/ksef/KSeFAPIServiceTest.java @@ -0,0 +1,82 @@ +package com.alexanderlogistics.ksef; + +import static org.junit.jupiter.api.Assertions.assertFalse; +import static org.junit.jupiter.api.Assertions.assertNotNull; + +import java.io.IOException; +import java.nio.file.Files; +import java.nio.file.Path; +import java.nio.file.Paths; +import java.util.logging.Logger; + +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; + +@ExtendWith(MockitoExtension.class) +@TestMethodOrder(MethodOrderer.OrderAnnotation.class) +public class KSeFAPIServiceTest { + + private static Logger logger = Logger.getLogger(KSeFAPIServiceTest.class.getName()); + + private KSeFAuthManager manager; + private KSeFAPIService apiService; + + @BeforeEach + void setup() throws Exception { + 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); + + // init() ausführen + manager.init(); + + // Open Session + manager.openSession(); + // Create API Service and inject manager + apiService = new KSeFAPIService(); + apiService.kseFAuthManager = manager; + + } + + @Test + @Order(4) + @DisplayName("Upload Invoice XML (AES-256 + RSA)") + public void testUploadInvoice() throws Exception { + logger.info("==> Test: Upload Invoice XML"); + + ItemCollection workitem = createWorkitem(); + + // Execute upload + String referenceNumber = apiService.uploadInvoice(workitem, "example-invoice-01.xml"); + + // Verify result + assertNotNull(referenceNumber, "Reference number should not be null"); + assertFalse(referenceNumber.isEmpty(), "Reference number should not be empty"); + + logger.info("✅ Upload successful - Reference Number: " + referenceNumber); + } + + private ItemCollection createWorkitem() throws IOException { + ItemCollection workitem = new ItemCollection(); + // ---------- 2) XML laden ---------- + Path xmlPath = Paths.get("src/test/resources/ksef/example-invoice-01.xml"); + byte[] invoiceXml = Files.readAllBytes(xmlPath); + + FileData fileData = new FileData("example-invoice-01.xml", invoiceXml, null, null); + workitem.addFileData(fileData); + + return workitem; + } +} diff --git a/office-alexander-logistics-app/src/test/java/com/alexanderlogistics/ksef/KSeFAuthManagerTest.java b/office-alexander-logistics-app/src/test/java/com/alexanderlogistics/ksef/KSeFAuthManagerTest.java index 39b3db4..da894ca 100644 --- a/office-alexander-logistics-app/src/test/java/com/alexanderlogistics/ksef/KSeFAuthManagerTest.java +++ b/office-alexander-logistics-app/src/test/java/com/alexanderlogistics/ksef/KSeFAuthManagerTest.java @@ -42,7 +42,7 @@ public class KSeFAuthManagerTest { manager.setKsefToken(Params.TOKEN); manager.setKsefNip(Params.NIP); manager.setKsefEndpoint("https://ksef-test.mf.gov.pl/api/v2"); - manager.setDebug(true); + manager.setDebug(false); // init() ausführen manager.init(); @@ -152,7 +152,10 @@ public class KSeFAuthManagerTest { // manager.getRefNumber() + "/invoices"; String uri = manager.getBaseURI() + "/sessions/online/" + manager.getSessionRefNumber() + "/invoices"; logger.info("│ ├── Endpoint: " + uri); - logger.info("Request: " + jsonBody); + + if (manager.isDebug()) { + logger.info("Request: " + jsonBody); + } HttpRequest request = HttpRequest.newBuilder() .uri(URI.create(uri)) .header("Content-Type", "application/json") diff --git a/office-alexander-logistics-app/src/test/resources/ksef/example-invoice-01.xml b/office-alexander-logistics-app/src/test/resources/ksef/example-invoice-01.xml index f1b7ac9..7167e16 100644 --- a/office-alexander-logistics-app/src/test/resources/ksef/example-invoice-01.xml +++ b/office-alexander-logistics-app/src/test/resources/ksef/example-invoice-01.xml @@ -22,7 +22,7 @@ Lucja17@example.net - 86-588-03-53 + 86-588-03-553