KSeF Impl and docu

This commit is contained in:
Ralph Soika 2025-11-14 23:22:24 +01:00
parent 26b08eb507
commit 11b28ef9c3
6 changed files with 700 additions and 168 deletions

View file

@ -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) Manages authentication and session lifecycle for the KSeF API.
- 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.
## 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)

View file

@ -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;
}
}

View file

@ -6,6 +6,9 @@ import java.net.http.HttpClient;
import java.net.http.HttpRequest; import java.net.http.HttpRequest;
import java.net.http.HttpResponse; import java.net.http.HttpResponse;
import java.nio.charset.StandardCharsets; import java.nio.charset.StandardCharsets;
import java.security.InvalidAlgorithmParameterException;
import java.security.InvalidKeyException;
import java.security.NoSuchAlgorithmException;
import java.security.PublicKey; import java.security.PublicKey;
import java.security.SecureRandom; import java.security.SecureRandom;
import java.security.spec.MGF1ParameterSpec; import java.security.spec.MGF1ParameterSpec;
@ -16,7 +19,10 @@ import java.util.Base64;
import java.util.Optional; import java.util.Optional;
import java.util.logging.Logger; import java.util.logging.Logger;
import javax.crypto.BadPaddingException;
import javax.crypto.Cipher; import javax.crypto.Cipher;
import javax.crypto.IllegalBlockSizeException;
import javax.crypto.NoSuchPaddingException;
import javax.crypto.spec.OAEPParameterSpec; import javax.crypto.spec.OAEPParameterSpec;
import javax.crypto.spec.PSource; import javax.crypto.spec.PSource;
@ -110,13 +116,16 @@ public class KSeFAuthManager {
.connectTimeout(Duration.ofSeconds(10)) .connectTimeout(Duration.ofSeconds(10))
.build(); .build();
// this.jsonb = JsonbBuilder.create();
} }
public HttpClient getHttpClient() { public HttpClient getHttpClient() {
return httpClient; return httpClient;
} }
public boolean isDebug() {
return debug;
}
public void setKsefToken(String token) { public void setKsefToken(String token) {
this.ksefToken = Optional.ofNullable(token); this.ksefToken = Optional.ofNullable(token);
} }
@ -141,18 +150,6 @@ public class KSeFAuthManager {
this.debug = debug; 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() { public String getBaseURI() {
return baseURI; return baseURI;
} }
@ -173,6 +170,77 @@ public class KSeFAuthManager {
return challenge; 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 * Diese method führt den challenge Request durch
* *
@ -236,29 +304,36 @@ public class KSeFAuthManager {
* @throws Exception * @throws Exception
*/ */
@Lock(LockType.WRITE) @Lock(LockType.WRITE)
public void authKSeFToken() throws Exception { public void authKSeFToken() throws PluginException {
logger.info("├── KSeF API auth ksef-token..."); logger.info("├── KSeF API auth ksef-token...");
String uri = baseURI + "/auth/ksef-token"; String uri = baseURI + "/auth/ksef-token";
logger.info("│ ├── Endpoint: " + uri); 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() HttpResponse<String> response = null;
.uri(URI.create(uri)) try {
.header("Accept", "application/json") String jsonPayload = String.format(
.header("Content-Type", "application/json") "{" +
.POST(HttpRequest.BodyPublishers.ofString(jsonPayload)) "\"challenge\": \"%s\"," +
.build(); "\"contextIdentifier\": {" +
" \"type\": \"Nip\"," +
" \"value\": \"%s\"" +
"}," +
"\"encryptedToken\": \"%s\"" +
"}",
challenge, ksefNip.get(), getEncryptedToken());
HttpResponse<String> 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()); logger.info("│ ├── Response Code = " + response.statusCode());
if (debug) { if (debug) {
logger.info("Response: " + response.body()); 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 * @throws Exception
*/ */
@Lock(LockType.WRITE) @Lock(LockType.WRITE)
public void openInteractiveSession() throws Exception { public void openInteractiveSession() throws PluginException {
if (accessToken == null) { if (accessToken == null) {
throw new PluginException(KSeFAuthManager.class.getSimpleName(), ERROR_API, throw new PluginException(KSeFAuthManager.class.getSimpleName(), ERROR_API,
@ -308,81 +384,88 @@ public class KSeFAuthManager {
logger.info("├── KSeF API open interactive session..."); logger.info("├── KSeF API open interactive session...");
String uri = baseURI + "/sessions/online"; String uri = baseURI + "/sessions/online";
logger.info("│ ├── Endpoint: " + uri); logger.info("│ ├── Endpoint: " + uri);
HttpResponse<String> 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 --- // --- AES Key mit RSA encrypten ---
SecureRandom rnd = new SecureRandom(); Cipher rsa;
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 --- rsa = Cipher.getInstance("RSA/ECB/OAEPWithSHA-1AndMGF1Padding");
// 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);
String jsonPayload = String.format( rsa.init(Cipher.ENCRYPT_MODE, this.symmetricPublicKey);
"{" + byte[] encryptedAesKey = rsa.doFinal(aesKeyBytes);
"\"formCode\": {" +
" \"systemCode\": \"FA (3)\"," +
" \"schemaVersion\": \"1-0E\"," +
" \"value\": \"FA\"" +
"}," +
"\"encryption\": {" +
" \"encryptedSymmetricKey\": \"%s\"," +
" \"initializationVector\": \"%s\"" +
"}" +
"}",
Base64.getEncoder().encodeToString(encryptedAesKey),
Base64.getEncoder().encodeToString(ivBytes));
if (debug) { String jsonPayload = String.format(
logger.info("│ ├── Payload: " + jsonPayload); "{" +
} "\"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<String> 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) { if (debug) {
logger.info("│ ├── Session ReferenceNumber = " + sessionRefNumber); logger.info("│ ├── Payload: " + jsonPayload);
logger.info("│ └── Session ValidUntil = " + sessionValidUntil);
} }
// --- WICHTIG: AES Schlüssel speichern, wir brauchen ihn beim Upload --- HttpRequest request = HttpRequest.newBuilder()
this.sessionAesKey = aesKeyBytes; .uri(URI.create(uri))
this.sessionIv = ivBytes; // .header("Accept-Charset", "UTF-8")
} catch (Exception e) { .header("Accept", "application/json")
logger.severe("├── ⚠️ Error parsing JSON response" + e.getMessage()); .header("Content-Type", "application/json")
throw new PluginException(KSeFAuthManager.class.getSimpleName(), ERROR_API, .header("Authorization", "Bearer " + accessToken)
"Error parsing JSON response: " + e.getMessage()); .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 * Format: token|timestamp_in_milliseconds
* *
* @return Base64-kodierter verschlüsselter Token * @return Base64-kodierter verschlüsselter Token
* @throws NoSuchPaddingException
* @throws NoSuchAlgorithmException
* @throws InvalidAlgorithmParameterException
* @throws InvalidKeyException
* @throws Exception * @throws Exception
*/ */
public String getEncryptedToken() throws Exception { public String getEncryptedToken() throws PluginException {
String result = null;
if (ksefTokenPublicKey == null) { if (ksefTokenPublicKey == null) {
throw new IllegalStateException("Public Key not loaded! Call loadPublicKeyCertificates() first."); throw new IllegalStateException("Public Key not loaded! Call loadPublicKeyCertificates() first.");
} }
@ -404,45 +491,54 @@ public class KSeFAuthManager {
if (challengeTimestamp == null || challengeTimestamp.isEmpty()) { if (challengeTimestamp == null || challengeTimestamp.isEmpty()) {
throw new IllegalStateException("Challenge timestamp is missing! Call authChallenge() first."); 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 long timestampMillis = instant.toEpochMilli();
// 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);
logger.info("│ ├── Timestamp (ms): " + timestampMillis); // 2. Format erstellen: token|timestamp
String plaintext = ksefToken.get() + "|" + timestampMillis;
// 2. Format erstellen: token|timestamp if (debug) {
String plaintext = ksefToken.get() + "|" + timestampMillis; 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; 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 * POST /auth/token/redeem
* *
@ -450,21 +546,26 @@ public class KSeFAuthManager {
* @throws Exception * @throws Exception
*/ */
@Lock(LockType.WRITE) @Lock(LockType.WRITE)
public JsonObject redeemToken() throws Exception { public JsonObject redeemToken() throws PluginException {
logger.info("├── KSeF API redeem token..."); logger.info("├── KSeF API redeem token...");
String uri = baseURI + "/auth/token/redeem"; String uri = baseURI + "/auth/token/redeem";
logger.info("│ ├── Endpoint: " + uri); logger.info("│ ├── Endpoint: " + uri);
HttpResponse<String> 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() response = httpClient.send(request,
.uri(URI.create(uri)) HttpResponse.BodyHandlers.ofString());
.header("Accept", "application/json") } catch (IOException | InterruptedException e) {
.header("Authorization", "Bearer " + authToken) throw new PluginException(KSeFAuthManager.class.getSimpleName(), ERROR_API,
.POST(HttpRequest.BodyPublishers.noBody()) "API Error - unable to redeemToken: " + e.getMessage());
.build(); }
HttpResponse<String> response = httpClient.send(request,
HttpResponse.BodyHandlers.ofString());
logger.info("│ ├── Response Code = " + response.statusCode()); logger.info("│ ├── Response Code = " + response.statusCode());
if (debug) { if (debug) {
@ -503,7 +604,7 @@ public class KSeFAuthManager {
* @throws Exception * @throws Exception
*/ */
@Lock(LockType.WRITE) @Lock(LockType.WRITE)
public void loadPublicKeyCertificates() throws Exception { public void loadPublicKeyCertificates() throws PluginException {
logger.info("├── KSeF API load public-key-certificates..."); logger.info("├── KSeF API load public-key-certificates...");
String uri = baseURI + "/security/public-key-certificates"; String uri = baseURI + "/security/public-key-certificates";
@ -515,7 +616,13 @@ public class KSeFAuthManager {
.GET() .GET()
.build(); .build();
HttpResponse<String> response = httpClient.send(request, HttpResponse.BodyHandlers.ofString()); HttpResponse<String> 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()); logger.info("│ ├── Response Code = " + response.statusCode());
if (debug) { if (debug) {

View file

@ -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;
}
}

View file

@ -42,7 +42,7 @@ public class KSeFAuthManagerTest {
manager.setKsefToken(Params.TOKEN); manager.setKsefToken(Params.TOKEN);
manager.setKsefNip(Params.NIP); manager.setKsefNip(Params.NIP);
manager.setKsefEndpoint("https://ksef-test.mf.gov.pl/api/v2"); manager.setKsefEndpoint("https://ksef-test.mf.gov.pl/api/v2");
manager.setDebug(true); manager.setDebug(false);
// init() ausführen // init() ausführen
manager.init(); manager.init();
@ -152,7 +152,10 @@ public class KSeFAuthManagerTest {
// manager.getRefNumber() + "/invoices"; // manager.getRefNumber() + "/invoices";
String uri = manager.getBaseURI() + "/sessions/online/" + manager.getSessionRefNumber() + "/invoices"; String uri = manager.getBaseURI() + "/sessions/online/" + manager.getSessionRefNumber() + "/invoices";
logger.info("│ ├── Endpoint: " + uri); logger.info("│ ├── Endpoint: " + uri);
logger.info("Request: " + jsonBody);
if (manager.isDebug()) {
logger.info("Request: " + jsonBody);
}
HttpRequest request = HttpRequest.newBuilder() HttpRequest request = HttpRequest.newBuilder()
.uri(URI.create(uri)) .uri(URI.create(uri))
.header("Content-Type", "application/json") .header("Content-Type", "application/json")

View file

@ -22,7 +22,7 @@
</Adres> </Adres>
<DaneKontaktowe> <DaneKontaktowe>
<Email>Lucja17@example.net</Email> <Email>Lucja17@example.net</Email>
<Telefon>86-588-03-53</Telefon> <Telefon>86-588-03-553</Telefon>
</DaneKontaktowe> </DaneKontaktowe>
</Podmiot1> </Podmiot1>
<Podmiot2> <Podmiot2>