From be4a7de9940c432bd963627e8b0ccf9d2f9e677b Mon Sep 17 00:00:00 2001 From: Mieszko Gulinski Date: Wed, 28 Jan 2026 10:42:35 +0100 Subject: [PATCH 01/18] add more information about using authentication --- authentication-requirements.md | 72 ++++++++++++++++++++++++++++++++++ authentication.md | 48 +++++++---------------- 2 files changed, 86 insertions(+), 34 deletions(-) create mode 100644 authentication-requirements.md diff --git a/authentication-requirements.md b/authentication-requirements.md new file mode 100644 index 0000000..a90b8c1 --- /dev/null +++ b/authentication-requirements.md @@ -0,0 +1,72 @@ +# Authentication requirements + +Based on: +- [Authentication in KSEF](https://github.com/CIRFMF/ksef-docs/blob/main/uwierzytelnianie.md) (in Polish) + +Environments: + +- Test environment is an environment where a self-signed certificate can be used, and login information and invoice data should be fake. +- Demo environment is an environment where real login information should be used, but invoice data should be fake. + +## Test environment + +Certificates in the test environment can be self-signed. + +There is an online process to register a company: + +- [Test Company login](https://ksef-test.mf.gov.pl/web/login) +- [Generate a fake NIP (tax ID)](http://generatory.it/) + +Once inside the test environment, you can create an Authorization token to use to make requests to the API. + +### How to generate a test certificate + +Based on [this](https://github.com/CIRFMF/ksef-docs/blob/main/auth/testowe-certyfikaty-i-podpisy-xades.md) document. + +There is a CLI application in .NET that allows to generate a self-signed certificate that can be used in the test environment. To run the application: + +1. Install .NET 10.0 SDK +2. Download the repository: `git clone https://github.com/CIRFMF/ksef-client-csharp.git` +3. Go to the application directory: `cd ksef-client-csharp/KSeF.Client.Tests.CertTestApp` +4. Run the application: `dotnet run --framework net10.0 --output file --nip 8976111986 --no-startup-warnings` +5. The application will generate a self-signed certificate and save it to the current directory. It will generate two files: `cert-{timestamp}.pfx` and `cert-{timestamp}.cer`. + +## Production and demo environments + +Authentication with production and demo KSeF environments can be done: + +1. Using a qualified digital certificate. Qualified means that it's issued by trusted service providers on the European Union [Trusted List](https://eidas.ec.europa.eu/efda/trust-services/browse/eidas/tls). +2. Using a KSeF certificate. KSeF certificates are generated on demand, and intended for client applications, so that client applications won't have access to the qualified certificate. +3. Using ePUAP (a system where individuals with PESEL - Polish individual personal number - can access various government services). +4. Using a KSeF token, obtained similarly to KSeF certificates (deprecated, will work until end of 2026). + +### What is a KSeF certificate and how to obtain it + +It's a type of certificate that is: +- generated on demand +- intended for KSeF client applications, so that client applications can save it for later use without having to use owner's qualified certificate +- revocable by the owner in case of e.g. security breach, or when the company stops using the client application - a revoked certificate cannot be used again and a new one must be generated. + +It can be obtained using using the following methods: +1. Company owner / approved employee logs into Aplikacja Podatnika ("Taxpayer's Application") - it's both a website and a mobile application - and generates a KSeF certificate there, downloads it to disk, and uploads it (certificate file, private key, password) to the client application. This application is available since February 2026. This is the simplest method for non-technical users. +2. Company owner / approved employee logs into MCU (moduł certyfikatów i uprawnień - certificate and permissions module), and does the same thing as above. MCU is available until the end of January 2026, and it's very similar to Aplikacja Podatnika. +3. Owner of qualified certificate uses it to log into KSeF API and calls and endpoint to generate a KSeF certificate. This is possible to do using this library (gobl.ksef) if the certificate is in a `.p12` / `.pfx` file. +4. Using ePUAP - described below, as it's a more complex process. + +There are two types of KSeF certificate: +- Online - for logging into API +- Offline - for signing invoices in case of KSeF system unavailability + +Both certificate types must be obtained separately. + +### Logging into API using ePUAP + +1. User logs into ePUAP +2. Client application generates an XML file with the authentication request +3. User uses ePUAP's feature to sign an XML file - uploads the XML file to ePUAP application +4. ePUAP returns the signed XML file +5. User uploads the signed XML file to the client application +6. Client application proceeds with login with the signed XML file +7. Client application, being authenticated as the user, generates a KSeF certificate and saves it for later use + +Steps from 2 to 6 must be done in 5 minutes, otherwise the authentication challenge expires. See [here](./authentication.md) for details about the authentication process. diff --git a/authentication.md b/authentication.md index 300f425..7493f56 100644 --- a/authentication.md +++ b/authentication.md @@ -1,35 +1,10 @@ # Authentication -- [Authentication in KSEF](https://github.com/CIRFMF/ksef-docs/blob/main/uwierzytelnianie.md) (in Polish) -- [XAdEs digital signature](https://github.com/CIRFMF/ksef-docs/blob/main/auth/podpis-xades.md) (in Polish) -- [How to use the official .NET client to generate a test XAdEs certificate](https://github.com/CIRFMF/ksef-docs/blob/main/auth/testowe-certyfikaty-i-podpisy-xades.md) (in Polish) - -## How to obtain a certificate - -Authentication with production KSeF needs to be done using a digital certificate issued by trusted service providers on the European Union [Trusted List](https://eidas.ec.europa.eu/efda/trust-services/browse/eidas/tls). Certificates in the test environment can be self-signed. - -There is an online process to register a company: - -- [Test Company login](https://ksef-test.mf.gov.pl/web/login) -- [Generate a fake NIP (tax ID)](http://generatory.it/) - -Once inside the test environment, you can create an Authorization token to use to make requests to the API. - -### How to generate a test certificate +Based on: -Based on [this](https://github.com/CIRFMF/ksef-docs/blob/main/auth/testowe-certyfikaty-i-podpisy-xades.md) document. - -There is a CLI application in .NET that allows to generate a self-signed certificate that can be used in the test environment. To run the application: - -1. Install .NET 10.0 SDK -2. Download the repository: `git clone https://github.com/CIRFMF/ksef-client-csharp.git` -3. Go to the application directory: `cd ksef-client-csharp/KSeF.Client.Tests.CertTestApp` -4. Run the application: `dotnet run --framework net10.0 --output file --nip 8976111986 --no-startup-warnings` -5. The application will generate a self-signed certificate and save it to the current directory. It will generate two files: `cert-{timestamp}.pfx` and `cert-{timestamp}.cer`. - -## Login using the client - -The client provides a simple way to login to the KSeF API using a certificate. Internally, it uses the same process as described below. See the [README](../api/README.md) for more details. +- [Authentication in KSEF](https://github.com/CIRFMF/ksef-docs/blob/main/uwierzytelnianie.md) (in Polish) +- [XAdES digital signature](https://github.com/CIRFMF/ksef-docs/blob/main/auth/podpis-xades.md) (in Polish) +- [How to use the official .NET client to generate a test XAdES certificate](https://github.com/CIRFMF/ksef-docs/blob/main/auth/testowe-certyfikaty-i-podpisy-xades.md) (in Polish) ## Login to the KSeF API @@ -63,7 +38,11 @@ Using an API endpoint, a subject having appropriate permissions to a given conte ### How to login using XAdES digital signature? -At first, it's necessary to have a certificate. For the testing environment, a self-signed certificate is allowed. For the production environment, a certificate issued by a [trusted service provider recognized by EU](https://eidas.ec.europa.eu/efda/trust-services/browse/eidas/tls) is required. +At first, it's necessary to have a certificate. For the testing environment, a self-signed certificate is allowed. For the production environment, it's necessary to have: +- a qualified certificate issued by a [trusted service provider recognized by EU](https://eidas.ec.europa.eu/efda/trust-services/browse/eidas/tls) +- a KSeF certificate - it's a type of certificate only for accessing KSeF and generated on demand using KSeF, intended for client applications and other automated operations. + +See [./authentication-requirements.md](here) for details. For a login request, create an XML document containing: - challenge string @@ -84,7 +63,7 @@ KSEF token (separate type from authentication token, access token and refresh to Users logged in with XAdES can create, list and delete KSEF tokens using the API. -### How to obtain the public key +## How to obtain KSeF's public key To obtain the public key certificate, use `GET /security/public-key-certificates`. @@ -94,9 +73,10 @@ Public key is needed to: 1. Login with KSeF token using the `POST /auth/ksef-token` endpoint. 2. Encrypt a symmetric AES key when uploading invoices (in both online and batch formats) and exporting (batch) incoming invoices. File upload and export endpoints don't require `accessToken`, but they accept or return chunks of the data respectively encrypted with the provided symmetric AES key. Online upload endpoint is a regular HTTP endpoint using `accessToken` for authentication, but also requires providing a key, and the invoice must be encrypted with that key. -## How to authorize an external company to act on your behalf +## How to authorize a non-Polish company to access KSeF + +If you are a Polish company X, having a contract with company Y based in another European Union country, it's possible to give access permissions to company Y to see and upload invoices: -If you are a Polish company X, to allow company Y to act on your behalf in KSeF: 1. Company Y needs to obtain an appropriate certificate - a qualified EU certificate -2. The Polish company X needs to give company Y permissions. It's possible to do this through the API endpoint for this purpose, `permissions/eu-entities/administration/grants`, where company X provides company Y's certificate fingerprint, EU VAT number and company name. It's also possible to do this through the KSeF web interface - [a video showing how to do it is here](https://youtu.be/COXvohndNCA). +2. The Polish company X needs to give company Y permissions. It's possible to do this through the API endpoint for this purpose, `permissions/eu-entities/administration/grants`, where company X provides company Y's certificate fingerprint, EU VAT number and company name. It's also possible to do this through the web interface - [a video showing how to do it is here](https://youtu.be/COXvohndNCA). 3. After that, company Y can login to KSeF API using the qualified EU certificate, and providing context identifier (NipVatUe) containing company X's NIP (Polish business entity identifier) and Y's EU VAT number. From 987568d27e9f0d5d987ccc2c5ab342808349167f Mon Sep 17 00:00:00 2001 From: Mieszko Gulinski Date: Wed, 28 Jan 2026 11:22:10 +0100 Subject: [PATCH 02/18] add video tutorial --- authentication-requirements.md | 2 ++ 1 file changed, 2 insertions(+) diff --git a/authentication-requirements.md b/authentication-requirements.md index a90b8c1..0b4ebfd 100644 --- a/authentication-requirements.md +++ b/authentication-requirements.md @@ -59,6 +59,8 @@ There are two types of KSeF certificate: Both certificate types must be obtained separately. +A video tutorial (in Polish) about using Aplikacja Podatnika is available [here](https://www.youtube.com/watch?v=KkyNw_tBN2s). + ### Logging into API using ePUAP 1. User logs into ePUAP From 0f961c3f1846dcbbf83de2e8553530d683333962 Mon Sep 17 00:00:00 2001 From: Mieszko Gulinski Date: Wed, 28 Jan 2026 12:05:48 +0100 Subject: [PATCH 03/18] add offline mode initial documentation --- offline-mode.md | 36 ++++++++++++++++++++++++++++++++++++ 1 file changed, 36 insertions(+) create mode 100644 offline-mode.md diff --git a/offline-mode.md b/offline-mode.md new file mode 100644 index 0000000..b25348a --- /dev/null +++ b/offline-mode.md @@ -0,0 +1,36 @@ +# Offline mode + +Based on +- [article about offline mode](https://ksef.podatki.gov.pl/informacje-ogolne-ksef-20/tryb-offline24/) +- [article about offline mode due to KSeF failure](https://ksef.podatki.gov.pl/informacje-ogolne-ksef-20/tryb-offline-niedostepnosc-ksef) +- [article about KSeF failure mode](https://ksef.podatki.gov.pl/informacje-ogolne-ksef-20/tryb-awaryjny) +- [article about QR codes](https://ksef.podatki.gov.pl/informacje-ogolne-ksef-20/kody-weryfikujace-qr/) + +This is a mode where invoices are not uploaded immediately to KSeF, but are stored locally and uploaded later. It can be used in the following cases: + +- Network failure or other issues preventing the invoice from being uploaded to KSeF, on the side of the company / client application - in this case, the invoice must be uploaded at most on the next working day since the invoice was issued +- Failure of KSeF system itself, announced on the official website and in KSeF interface - in this case, the time extends to 7 days since the invoice was issued +- Failure of KSeF system announced publicly, where the KSeF interface and website is not available - in this case, it's not needed to upload the invoice to KSeF at all + +To use this mode, it's necessary to have a KSeF **offline** certificate - see [here](authentication-requirements.md) for information about obtaining KSeF certificates. + +Offline invoice has two QR codes: +- QR code with "Offline" text below +- QR code with "Certyfikat" (certificate) text below + +## How to generate QR code with "Offline" text below + +The code is in `api/qr.go` file in this repository, in `GenerateQrCodeURL` function. This function should be used both in online and offline mode, with a difference that: + +- in online mode, the text below the QR code should be the KSeF number of the invoice +- in offline mode, the text below the QR code should be "Offline" + +The URL contains: +1. Base URL, depending on environment (production, demo, test) +2. NIP (Polish tax ID) +3. Invoicing date +4. Invoice hash + +## How to generate QR code with "Certyfikat" text below + +.. \ No newline at end of file From f26ed4c27cb86444174db73d3e34ba779dc83b53 Mon Sep 17 00:00:00 2001 From: Mieszko Gulinski Date: Wed, 28 Jan 2026 13:12:10 +0100 Subject: [PATCH 04/18] (incomplete) add code to generate QR code for verification of the certificate --- api/qr.go | 51 ++++++++++++++++++++++----- api/qr_test.go | 64 ++++++++++++++++++++++++++++++++++ authentication-requirements.md | 4 +-- 3 files changed, 109 insertions(+), 10 deletions(-) create mode 100644 api/qr_test.go diff --git a/api/qr.go b/api/qr.go index bdd0bc8..a3f44fc 100644 --- a/api/qr.go +++ b/api/qr.go @@ -3,17 +3,16 @@ package api import ( "encoding/base64" "fmt" - "strings" "time" ) const ( - EnvironmentProductionQrUrl = "https://qr.ksef.mf.gov.pl/invoice" - EnvironmentDemoQrUrl = "https://qr-demo.ksef.mf.gov.pl/invoice" - EnvironmentTestQrUrl = "https://qr-test.ksef.mf.gov.pl/invoice" + EnvironmentProductionQrUrl = "qr.ksef.mf.gov.pl" + EnvironmentDemoQrUrl = "qr-demo.ksef.mf.gov.pl" + EnvironmentTestQrUrl = "qr-test.ksef.mf.gov.pl" ) -// GenerateQrCodeURL builds the verification URL for an uploaded invoice. +// GenerateQrCodeURL builds the verification URL for an invoice, both in online and offline mode. func GenerateQrCodeURL(environment Environment, nip string, invoicingDate time.Time, invoiceHash []byte) (string, error) { var baseUrl string switch environment { @@ -33,11 +32,47 @@ func GenerateQrCodeURL(environment Environment, nip string, invoicingDate time.T base64URLHash := base64.RawURLEncoding.EncodeToString(invoiceHash) - base := strings.TrimRight(baseUrl, "/") - return fmt.Sprintf("%s/%s/%s/%s", - base, + return fmt.Sprintf("https://%s/invoice/%s/%s/%s", + baseUrl, nip, invoicingDate.Format("02-01-2006"), base64URLHash, ), nil } + +// GenerateCertificateQrCodeURL builds the certificate verification URL for an offline invoice. +// The assembler url has format: base url / certificate / Nip / (contextNip) / (sellerNip) / (certificateId) / (invoiceHash) +// The url must not have a trailing slash or protocol. +func GenerateCertificateQrCodeURL(environment Environment, contextNip string, sellerNip string, certificateId string, invoiceHash []byte) (string, error) { + var baseUrl string + switch environment { + case EnvironmentProduction: + baseUrl = EnvironmentProductionQrUrl + case EnvironmentDemo: + baseUrl = EnvironmentDemoQrUrl + case EnvironmentTest: + baseUrl = EnvironmentTestQrUrl + default: + return "", fmt.Errorf("invalid environment: %s", environment) + } + + if contextNip == "" { + return "", fmt.Errorf("contextNip is empty") + } + if sellerNip == "" { + return "", fmt.Errorf("sellerNip is empty") + } + if certificateId == "" { + return "", fmt.Errorf("certificateId is empty") + } + + base64URLHash := base64.RawURLEncoding.EncodeToString(invoiceHash) + + return fmt.Sprintf("%s/certificate/Nip/%s/%s/%s/%s", + baseUrl, + contextNip, + sellerNip, + certificateId, + base64URLHash, + ), nil +} diff --git a/api/qr_test.go b/api/qr_test.go new file mode 100644 index 0000000..3065ff5 --- /dev/null +++ b/api/qr_test.go @@ -0,0 +1,64 @@ +package api + +import ( + "testing" + "time" + + "github.com/stretchr/testify/assert" +) + +func TestGenerateQrCodeURL(t *testing.T) { + invoiceHash := []byte("hash1234567890") // Example hash + + t.Run("production", func(t *testing.T) { + date, _ := time.Parse("2006-01-02", "2023-10-25") + url, err := GenerateQrCodeURL(EnvironmentProduction, "1234567890", date, invoiceHash) + assert.NoError(t, err) + assert.Equal(t, "https://qr.ksef.mf.gov.pl/invoice/1234567890/25-10-2023/aGFzaDEyMzQ1Njc4OTA", url) + }) + + t.Run("test", func(t *testing.T) { + date, _ := time.Parse("2006-01-02", "2023-10-25") + url, err := GenerateQrCodeURL(EnvironmentTest, "1234567890", date, invoiceHash) + assert.NoError(t, err) + assert.Equal(t, "https://qr-test.ksef.mf.gov.pl/invoice/1234567890/25-10-2023/aGFzaDEyMzQ1Njc4OTA", url) + }) +} + +func TestGenerateCertificateQrCodeURL(t *testing.T) { + invoiceHash := []byte{0x00, 0x01, 0x02, 0x03} + // Base64 RawURL of 00010203 -> AAECAw + + t.Run("test environment", func(t *testing.T) { + url, err := GenerateCertificateQrCodeURL( + EnvironmentTest, + "1111111111", // contextNip + "2222222222", // sellerNip + "CERT123", // certificateId + invoiceHash, + ) + assert.NoError(t, err) + assert.Equal(t, "qr-test.ksef.mf.gov.pl/certificate/Nip/1111111111/2222222222/CERT123/AAECAw", url) + }) + + t.Run("production environment", func(t *testing.T) { + url, err := GenerateCertificateQrCodeURL( + EnvironmentProduction, + "1111111111", + "2222222222", + "CERT123", + invoiceHash, + ) + assert.NoError(t, err) + assert.Equal(t, "qr.ksef.mf.gov.pl/certificate/Nip/1111111111/2222222222/CERT123/AAECAw", url) + }) + + t.Run("empty args", func(t *testing.T) { + _, err := GenerateCertificateQrCodeURL(EnvironmentTest, "", "222", "C", invoiceHash) + assert.Error(t, err) + _, err = GenerateCertificateQrCodeURL(EnvironmentTest, "111", "", "C", invoiceHash) + assert.Error(t, err) + _, err = GenerateCertificateQrCodeURL(EnvironmentTest, "111", "222", "", invoiceHash) + assert.Error(t, err) + }) +} diff --git a/authentication-requirements.md b/authentication-requirements.md index 0b4ebfd..ef64fc5 100644 --- a/authentication-requirements.md +++ b/authentication-requirements.md @@ -23,7 +23,7 @@ Once inside the test environment, you can create an Authorization token to use t Based on [this](https://github.com/CIRFMF/ksef-docs/blob/main/auth/testowe-certyfikaty-i-podpisy-xades.md) document. -There is a CLI application in .NET that allows to generate a self-signed certificate that can be used in the test environment. To run the application: +There is a CLI application in .NET that allows to generate a self-signed certificate that can be used to log into the test environment. To run the application: 1. Install .NET 10.0 SDK 2. Download the repository: `git clone https://github.com/CIRFMF/ksef-client-csharp.git` @@ -55,7 +55,7 @@ It can be obtained using using the following methods: There are two types of KSeF certificate: - Online - for logging into API -- Offline - for signing invoices in case of KSeF system unavailability +- Offline - for signing invoices in case of KSeF system unavailability - see [offline mode](offline-mode.md) for details Both certificate types must be obtained separately. From 12e1f6caaf56c6b7240c2bafd4fd1de68bd40952 Mon Sep 17 00:00:00 2001 From: Mieszko Gulinski Date: Wed, 28 Jan 2026 15:09:54 +0100 Subject: [PATCH 05/18] update documentation about using offline mode --- offline-mode.md | 24 +++++++++++++++++++++++- 1 file changed, 23 insertions(+), 1 deletion(-) diff --git a/offline-mode.md b/offline-mode.md index b25348a..7ba3c92 100644 --- a/offline-mode.md +++ b/offline-mode.md @@ -5,6 +5,7 @@ Based on - [article about offline mode due to KSeF failure](https://ksef.podatki.gov.pl/informacje-ogolne-ksef-20/tryb-offline-niedostepnosc-ksef) - [article about KSeF failure mode](https://ksef.podatki.gov.pl/informacje-ogolne-ksef-20/tryb-awaryjny) - [article about QR codes](https://ksef.podatki.gov.pl/informacje-ogolne-ksef-20/kody-weryfikujace-qr/) +- [QR codes documentation](https://github.com/CIRFMF/ksef-docs/blob/main/kody-qr.md) This is a mode where invoices are not uploaded immediately to KSeF, but are stored locally and uploaded later. It can be used in the following cases: @@ -33,4 +34,25 @@ The URL contains: ## How to generate QR code with "Certyfikat" text below -.. \ No newline at end of file +Use `api/qr.go` file in this repository, in `GenerateCertificateQrCodeURL` function. + +The function assembles the URL containing: + +1. Base URL, depending on environment (production, demo, test) +2. Context NIP +3. Seller NIP +4. Certificate serial number +5. Invoice hash + +Then, the URL is hashed and signed using the provided **offline** KSeF certificate (**not** the one used for authentication). Other certificates (qualified, KSeF online) won't work. + +When using a RSA certificate, use the following parameters for signing: +- RSASSA-PSS algorithm +- Hash algorithm: SHA-256 +- MGF1 with SHA-256 +- Salt length: 32 bytes + +When using a ECDSA certificate, use the following parameters for signing: +- Hash algorithm: SHA-256 +- Curve: secp256r1 +- IEEE P1363 format From 9b92051e51a56a73d2d983e86a513b8de0c1d021 Mon Sep 17 00:00:00 2001 From: Mieszko Gulinski Date: Wed, 28 Jan 2026 16:36:39 +0100 Subject: [PATCH 06/18] add functions for generating qr url for offline invoices (untested with actual ksef certificates) --- api/qr.go | 55 ++++++++++++++++++++++++++++++++++++----- api/qr_test.go | 67 ++++++++++++++++++++++++++++++++++++++++++++++---- 2 files changed, 111 insertions(+), 11 deletions(-) diff --git a/api/qr.go b/api/qr.go index a3f44fc..32f69a8 100644 --- a/api/qr.go +++ b/api/qr.go @@ -1,6 +1,11 @@ package api import ( + "crypto" + "crypto/ecdsa" + "crypto/rand" + "crypto/rsa" + "crypto/sha256" "encoding/base64" "fmt" "time" @@ -40,10 +45,9 @@ func GenerateQrCodeURL(environment Environment, nip string, invoicingDate time.T ), nil } -// GenerateCertificateQrCodeURL builds the certificate verification URL for an offline invoice. -// The assembler url has format: base url / certificate / Nip / (contextNip) / (sellerNip) / (certificateId) / (invoiceHash) -// The url must not have a trailing slash or protocol. -func GenerateCertificateQrCodeURL(environment Environment, contextNip string, sellerNip string, certificateId string, invoiceHash []byte) (string, error) { +// GenerateUnsignedCertificateQrCodeURL builds the unsigned certificate verification URL for an offline invoice. +// Then, the URL must be signed with GenerateSignedCertificateQrCodeURL function. +func GenerateUnsignedCertificateQrCodeURL(environment Environment, contextNip string, sellerNip string, certificateId string, invoiceHash []byte) (string, error) { var baseUrl string switch environment { case EnvironmentProduction: @@ -66,13 +70,52 @@ func GenerateCertificateQrCodeURL(environment Environment, contextNip string, se return "", fmt.Errorf("certificateId is empty") } - base64URLHash := base64.RawURLEncoding.EncodeToString(invoiceHash) + base64InvoiceHash := base64.RawURLEncoding.EncodeToString(invoiceHash) return fmt.Sprintf("%s/certificate/Nip/%s/%s/%s/%s", baseUrl, contextNip, sellerNip, certificateId, - base64URLHash, + base64InvoiceHash, ), nil } + +// GenerateSignedCertificateQrCodeURL creates a signed URL to be shown as QR code, for certificate verification of offline invoices. +// Private key must come from a KSeF offline certificate (important!). Example how to obtain it: +// privateKey, _, _, err := pkcs12.DecodeChain(certificateData, certificatePassword) +func GenerateSignedCertificateQrCodeURL(unsignedUrl string, privateKey crypto.Signer) (string, error) { + urlHash := sha256.Sum256([]byte(unsignedUrl)) + + var signature []byte + var err error + + // KSeF requires signature in the following format: + // - For ECDSA: IEEE P1363 format (r || s) + // - For RSA: PSS signature + switch key := privateKey.(type) { + case *ecdsa.PrivateKey: + r, s, err := ecdsa.Sign(rand.Reader, key, urlHash[:]) + if err != nil { + return "", err + } + // IEEE P1363 + size := (key.Curve.Params().BitSize + 7) / 8 // 32 for P-256 + + rb := r.FillBytes(make([]byte, size)) + sb := s.FillBytes(make([]byte, size)) + + signature = append(rb, sb...) + case *rsa.PrivateKey: + signature, err = key.Sign(rand.Reader, urlHash[:], &rsa.PSSOptions{SaltLength: 32, Hash: crypto.SHA256}) + if err != nil { + return "", err + } + default: + return "", fmt.Errorf("Certificate private key must be ECDSA or RSA") + } + + signatureBase64 := base64.RawURLEncoding.EncodeToString(signature) + + return fmt.Sprintf("https://%s/%s", unsignedUrl, signatureBase64), nil +} diff --git a/api/qr_test.go b/api/qr_test.go index 3065ff5..1b1b0a8 100644 --- a/api/qr_test.go +++ b/api/qr_test.go @@ -1,10 +1,20 @@ package api import ( + "crypto" + "crypto/ecdsa" + "crypto/elliptic" + "crypto/rand" + "crypto/rsa" + "crypto/sha256" + "encoding/base64" + "math/big" + "strings" "testing" "time" "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" ) func TestGenerateQrCodeURL(t *testing.T) { @@ -30,7 +40,7 @@ func TestGenerateCertificateQrCodeURL(t *testing.T) { // Base64 RawURL of 00010203 -> AAECAw t.Run("test environment", func(t *testing.T) { - url, err := GenerateCertificateQrCodeURL( + url, err := GenerateUnsignedCertificateQrCodeURL( EnvironmentTest, "1111111111", // contextNip "2222222222", // sellerNip @@ -42,7 +52,7 @@ func TestGenerateCertificateQrCodeURL(t *testing.T) { }) t.Run("production environment", func(t *testing.T) { - url, err := GenerateCertificateQrCodeURL( + url, err := GenerateUnsignedCertificateQrCodeURL( EnvironmentProduction, "1111111111", "2222222222", @@ -54,11 +64,58 @@ func TestGenerateCertificateQrCodeURL(t *testing.T) { }) t.Run("empty args", func(t *testing.T) { - _, err := GenerateCertificateQrCodeURL(EnvironmentTest, "", "222", "C", invoiceHash) + _, err := GenerateUnsignedCertificateQrCodeURL(EnvironmentTest, "", "222", "C", invoiceHash) assert.Error(t, err) - _, err = GenerateCertificateQrCodeURL(EnvironmentTest, "111", "", "C", invoiceHash) + _, err = GenerateUnsignedCertificateQrCodeURL(EnvironmentTest, "111", "", "C", invoiceHash) assert.Error(t, err) - _, err = GenerateCertificateQrCodeURL(EnvironmentTest, "111", "222", "", invoiceHash) + _, err = GenerateUnsignedCertificateQrCodeURL(EnvironmentTest, "111", "222", "", invoiceHash) assert.Error(t, err) }) } + +func TestGenerateSignedCertificateQrCodeURL_ECDSA(t *testing.T) { + unsignedURL := "qr.ksef.mf.gov.pl/certificate/Nip/1111111111/2222222222/CERT123/AAECAw" + privateKey, err := ecdsa.GenerateKey(elliptic.P256(), rand.Reader) + require.NoError(t, err) + + signedURL, err := GenerateSignedCertificateQrCodeURL(unsignedURL, privateKey) + require.NoError(t, err) + + prefix := "https://" + unsignedURL + "/" + if !assert.True(t, strings.HasPrefix(signedURL, prefix)) { + return + } + + sigEncoded := strings.TrimPrefix(signedURL, prefix) + signature, err := base64.RawURLEncoding.DecodeString(sigEncoded) + require.NoError(t, err) + + size := (privateKey.Params().BitSize + 7) / 8 + require.Equal(t, 2*size, len(signature)) + + r := new(big.Int).SetBytes(signature[:size]) + s := new(big.Int).SetBytes(signature[size:]) + hash := sha256.Sum256([]byte(unsignedURL)) + assert.True(t, ecdsa.Verify(&privateKey.PublicKey, hash[:], r, s)) +} + +func TestGenerateSignedCertificateQrCodeURL_RSA(t *testing.T) { + unsignedURL := "qr.ksef.mf.gov.pl/certificate/Nip/3333333333/4444444444/CERT999/BBEFAA" + privateKey, err := rsa.GenerateKey(rand.Reader, 2048) + require.NoError(t, err) + + signedURL, err := GenerateSignedCertificateQrCodeURL(unsignedURL, privateKey) + require.NoError(t, err) + + prefix := "https://" + unsignedURL + "/" + if !assert.True(t, strings.HasPrefix(signedURL, prefix)) { + return + } + + sigEncoded := strings.TrimPrefix(signedURL, prefix) + signature, err := base64.RawURLEncoding.DecodeString(sigEncoded) + require.NoError(t, err) + + hash := sha256.Sum256([]byte(unsignedURL)) + assert.NoError(t, rsa.VerifyPSS(&privateKey.PublicKey, crypto.SHA256, hash[:], signature, &rsa.PSSOptions{SaltLength: 32, Hash: crypto.SHA256})) +} From 1d4d6741ea66c4918d41e6bb2624cd4109eacfec Mon Sep 17 00:00:00 2001 From: Mieszko Gulinski Date: Wed, 28 Jan 2026 16:49:42 +0100 Subject: [PATCH 07/18] add code to create certificate signing request (Csr) --- api/ksef_certificate.go | 126 +++++++++++++++++++++++++++++++++++ api/ksef_certificate_test.go | 59 ++++++++++++++++ 2 files changed, 185 insertions(+) create mode 100644 api/ksef_certificate.go create mode 100644 api/ksef_certificate_test.go diff --git a/api/ksef_certificate.go b/api/ksef_certificate.go new file mode 100644 index 0000000..632fe4a --- /dev/null +++ b/api/ksef_certificate.go @@ -0,0 +1,126 @@ +package api + +import ( + "context" + "crypto/rand" + "crypto/rsa" + "crypto/x509" + "crypto/x509/pkix" + "encoding/asn1" + "encoding/base64" + "fmt" +) + +// CertificateEnrollmentData stores the subject data required to prepare a certificate request. +type CertificateEnrollmentData struct { + CommonName string `json:"commonName"` + Surname string `json:"surname,omitempty"` + SerialNumber string `json:"serialNumber,omitempty"` + CountryName string `json:"countryName"` + OrganizationName string `json:"organizationName,omitempty"` + GivenName string `json:"givenName,omitempty"` + UniqueIdentifier string `json:"uniqueIdentifier"` + OrganizationIdentifier string `json:"organizationIdentifier,omitempty"` +} + +var ( + oidSurname = asn1.ObjectIdentifier{2, 5, 4, 4} + oidGivenName = asn1.ObjectIdentifier{2, 5, 4, 42} + oidUniqueIdentifier = asn1.ObjectIdentifier{2, 5, 4, 45} + oidOrganizationIdentifier = asn1.ObjectIdentifier{2, 5, 4, 97} +) + +// GetCertificateEnrollmentData returns the identification data used when building a new CSR. +func (c *Client) GetCertificateEnrollmentData(ctx context.Context) (*CertificateEnrollmentData, error) { + token, err := c.getAccessToken(ctx) + if err != nil { + return nil, err + } + + response := &CertificateEnrollmentData{} + resp, err := c.client.R(). + SetContext(ctx). + SetAuthToken(token). + SetResult(response). + Get(c.url + "/certificates/enrollments/data") + if err != nil { + return nil, err + } + if resp.IsError() { + return nil, newErrorResponse(resp) + } + + return response, nil +} + +// GenerateCSR builds a PKCS#10 certificate signing request encoded in Base64. +// The private key must be RSA and match the public key that will be embedded in the CSR. +func (d *CertificateEnrollmentData) GenerateCSR(privateKey *rsa.PrivateKey) (string, error) { + if d == nil { + return "", fmt.Errorf("certificate enrollment data is nil") + } + if privateKey == nil { + return "", fmt.Errorf("private key is nil") + } + if err := privateKey.Validate(); err != nil { + return "", fmt.Errorf("invalid private key: %w", err) + } + + subject, err := d.asPkixName() + if err != nil { + return "", err + } + + template := &x509.CertificateRequest{ + Subject: subject, + SignatureAlgorithm: x509.SHA256WithRSA, + } + + csr, err := x509.CreateCertificateRequest(rand.Reader, template, privateKey) + if err != nil { + return "", fmt.Errorf("create certificate request: %w", err) + } + + return base64.StdEncoding.EncodeToString(csr), nil +} + +func (d *CertificateEnrollmentData) asPkixName() (pkix.Name, error) { + if d.CommonName == "" { + return pkix.Name{}, fmt.Errorf("commonName is empty") + } + if d.CountryName == "" { + return pkix.Name{}, fmt.Errorf("countryName is empty") + } + if d.UniqueIdentifier == "" { + return pkix.Name{}, fmt.Errorf("uniqueIdentifier is empty") + } + + name := pkix.Name{ + CommonName: d.CommonName, + SerialNumber: d.SerialNumber, + } + + if d.CountryName != "" { + name.Country = []string{d.CountryName} + } + if d.OrganizationName != "" { + name.Organization = []string{d.OrganizationName} + } + + name.ExtraNames = appendAttribute(name.ExtraNames, oidSurname, d.Surname) + name.ExtraNames = appendAttribute(name.ExtraNames, oidGivenName, d.GivenName) + name.ExtraNames = appendAttribute(name.ExtraNames, oidUniqueIdentifier, d.UniqueIdentifier) + name.ExtraNames = appendAttribute(name.ExtraNames, oidOrganizationIdentifier, d.OrganizationIdentifier) + + return name, nil +} + +func appendAttribute(attrs []pkix.AttributeTypeAndValue, oid asn1.ObjectIdentifier, value string) []pkix.AttributeTypeAndValue { + if value == "" { + return attrs + } + return append(attrs, pkix.AttributeTypeAndValue{ + Type: oid, + Value: value, + }) +} diff --git a/api/ksef_certificate_test.go b/api/ksef_certificate_test.go new file mode 100644 index 0000000..18d4a80 --- /dev/null +++ b/api/ksef_certificate_test.go @@ -0,0 +1,59 @@ +package api + +import ( + "crypto/rand" + "crypto/rsa" + "crypto/x509" + "encoding/base64" + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +func TestGenerateCSR(t *testing.T) { + privateKey, err := rsa.GenerateKey(rand.Reader, 2048) + require.NoError(t, err) + + data := &CertificateEnrollmentData{ + CommonName: "Firma Example Cert", + CountryName: "PL", + OrganizationName: "Firma Example Sp. z o.o.", + SerialNumber: "ABC123456", + Surname: "Kowalski", + GivenName: "Jan", + UniqueIdentifier: "123e4567-e89b-12d3-a456-426614174000", + OrganizationIdentifier: "1234567890", + } + + csrBase64, err := data.GenerateCSR(privateKey) + require.NoError(t, err) + + csrDER, err := base64.StdEncoding.DecodeString(csrBase64) + require.NoError(t, err) + + csr, err := x509.ParseCertificateRequest(csrDER) + require.NoError(t, err) + require.NoError(t, csr.CheckSignature()) + + assert.Equal(t, data.CommonName, csr.Subject.CommonName) + assert.Equal(t, []string{data.CountryName}, csr.Subject.Country) + assert.Equal(t, []string{data.OrganizationName}, csr.Subject.Organization) + assert.Equal(t, data.SerialNumber, csr.Subject.SerialNumber) + + attrValue := func(oid string) string { + for _, attr := range csr.Subject.Names { + if attr.Type.String() == oid { + if str, ok := attr.Value.(string); ok { + return str + } + } + } + return "" + } + + assert.Equal(t, data.Surname, attrValue(oidSurname.String())) + assert.Equal(t, data.GivenName, attrValue(oidGivenName.String())) + assert.Equal(t, data.UniqueIdentifier, attrValue(oidUniqueIdentifier.String())) + assert.Equal(t, data.OrganizationIdentifier, attrValue(oidOrganizationIdentifier.String())) +} From 1161839449ab269af9361a3749908f4d0d41e810 Mon Sep 17 00:00:00 2001 From: Mieszko Gulinski Date: Wed, 28 Jan 2026 16:53:25 +0100 Subject: [PATCH 08/18] use EC to sign CSR instead of RSA (recommended) --- api/ksef_certificate.go | 14 ++++++++------ api/ksef_certificate_test.go | 5 +++-- 2 files changed, 11 insertions(+), 8 deletions(-) diff --git a/api/ksef_certificate.go b/api/ksef_certificate.go index 632fe4a..c002bdd 100644 --- a/api/ksef_certificate.go +++ b/api/ksef_certificate.go @@ -2,8 +2,9 @@ package api import ( "context" + "crypto/ecdsa" + "crypto/elliptic" "crypto/rand" - "crypto/rsa" "crypto/x509" "crypto/x509/pkix" "encoding/asn1" @@ -54,16 +55,17 @@ func (c *Client) GetCertificateEnrollmentData(ctx context.Context) (*Certificate } // GenerateCSR builds a PKCS#10 certificate signing request encoded in Base64. -// The private key must be RSA and match the public key that will be embedded in the CSR. -func (d *CertificateEnrollmentData) GenerateCSR(privateKey *rsa.PrivateKey) (string, error) { +// The private key must be EC (secp256r1) and match the public key that will be embedded in the CSR. +// API documentation says that both RSA and EC keys are supported, but EC is recommended. +func (d *CertificateEnrollmentData) GenerateCSR(privateKey *ecdsa.PrivateKey) (string, error) { if d == nil { return "", fmt.Errorf("certificate enrollment data is nil") } if privateKey == nil { return "", fmt.Errorf("private key is nil") } - if err := privateKey.Validate(); err != nil { - return "", fmt.Errorf("invalid private key: %w", err) + if privateKey.Curve != elliptic.P256() { + return "", fmt.Errorf("unsupported EC curve: expected P-256") } subject, err := d.asPkixName() @@ -73,7 +75,7 @@ func (d *CertificateEnrollmentData) GenerateCSR(privateKey *rsa.PrivateKey) (str template := &x509.CertificateRequest{ Subject: subject, - SignatureAlgorithm: x509.SHA256WithRSA, + SignatureAlgorithm: x509.ECDSAWithSHA256, } csr, err := x509.CreateCertificateRequest(rand.Reader, template, privateKey) diff --git a/api/ksef_certificate_test.go b/api/ksef_certificate_test.go index 18d4a80..0dd5ad2 100644 --- a/api/ksef_certificate_test.go +++ b/api/ksef_certificate_test.go @@ -1,8 +1,9 @@ package api import ( + "crypto/ecdsa" + "crypto/elliptic" "crypto/rand" - "crypto/rsa" "crypto/x509" "encoding/base64" "testing" @@ -12,7 +13,7 @@ import ( ) func TestGenerateCSR(t *testing.T) { - privateKey, err := rsa.GenerateKey(rand.Reader, 2048) + privateKey, err := ecdsa.GenerateKey(elliptic.P256(), rand.Reader) require.NoError(t, err) data := &CertificateEnrollmentData{ From 1662e603c93eecf089b3d2fb5a7c07ed51b55733 Mon Sep 17 00:00:00 2001 From: Mieszko Gulinski Date: Wed, 28 Jan 2026 17:04:03 +0100 Subject: [PATCH 09/18] split away csr to separate file --- api/csr.go | 92 +++++++++++++++++++++++++++++ api/ksef_certificate.go | 125 ++++++++++++++++++---------------------- 2 files changed, 147 insertions(+), 70 deletions(-) create mode 100644 api/csr.go diff --git a/api/csr.go b/api/csr.go new file mode 100644 index 0000000..0e1231f --- /dev/null +++ b/api/csr.go @@ -0,0 +1,92 @@ +package api + +import ( + "crypto/ecdsa" + "crypto/elliptic" + "crypto/rand" + "crypto/x509" + "crypto/x509/pkix" + "encoding/asn1" + "encoding/base64" + "fmt" +) + +var ( + oidSurname = asn1.ObjectIdentifier{2, 5, 4, 4} + oidGivenName = asn1.ObjectIdentifier{2, 5, 4, 42} + oidUniqueIdentifier = asn1.ObjectIdentifier{2, 5, 4, 45} + oidOrganizationIdentifier = asn1.ObjectIdentifier{2, 5, 4, 97} +) + +// GenerateCSR builds a PKCS#10 certificate signing request encoded in Base64. +// The private key must be EC (secp256r1) and match the public key that will be embedded in the CSR. +// API documentation says that both RSA and EC keys are supported, but EC is recommended. +func (d *CertificateEnrollmentData) GenerateCSR(privateKey *ecdsa.PrivateKey) (string, error) { + if d == nil { + return "", fmt.Errorf("certificate enrollment data is nil") + } + if privateKey == nil { + return "", fmt.Errorf("private key is nil") + } + if privateKey.Curve != elliptic.P256() { + return "", fmt.Errorf("unsupported EC curve: expected P-256") + } + + subject, err := d.asPkixName() + if err != nil { + return "", err + } + + template := &x509.CertificateRequest{ + Subject: subject, + SignatureAlgorithm: x509.ECDSAWithSHA256, + } + + csr, err := x509.CreateCertificateRequest(rand.Reader, template, privateKey) + if err != nil { + return "", fmt.Errorf("create certificate request: %w", err) + } + + return base64.StdEncoding.EncodeToString(csr), nil +} + +func (d *CertificateEnrollmentData) asPkixName() (pkix.Name, error) { + if d.CommonName == "" { + return pkix.Name{}, fmt.Errorf("commonName is empty") + } + if d.CountryName == "" { + return pkix.Name{}, fmt.Errorf("countryName is empty") + } + if d.UniqueIdentifier == "" { + return pkix.Name{}, fmt.Errorf("uniqueIdentifier is empty") + } + + name := pkix.Name{ + CommonName: d.CommonName, + SerialNumber: d.SerialNumber, + } + + if d.CountryName != "" { + name.Country = []string{d.CountryName} + } + if d.OrganizationName != "" { + name.Organization = []string{d.OrganizationName} + } + + name.ExtraNames = appendAttribute(name.ExtraNames, oidSurname, d.Surname) + name.ExtraNames = appendAttribute(name.ExtraNames, oidGivenName, d.GivenName) + name.ExtraNames = appendAttribute(name.ExtraNames, oidUniqueIdentifier, d.UniqueIdentifier) + name.ExtraNames = appendAttribute(name.ExtraNames, oidOrganizationIdentifier, d.OrganizationIdentifier) + + return name, nil +} + +func appendAttribute(attrs []pkix.AttributeTypeAndValue, oid asn1.ObjectIdentifier, value string) []pkix.AttributeTypeAndValue { + if value == "" { + return attrs + } + return append(attrs, pkix.AttributeTypeAndValue{ + Type: oid, + Value: value, + }) +} diff --git a/api/ksef_certificate.go b/api/ksef_certificate.go index c002bdd..982c177 100644 --- a/api/ksef_certificate.go +++ b/api/ksef_certificate.go @@ -2,14 +2,8 @@ package api import ( "context" - "crypto/ecdsa" - "crypto/elliptic" - "crypto/rand" - "crypto/x509" - "crypto/x509/pkix" - "encoding/asn1" - "encoding/base64" "fmt" + "time" ) // CertificateEnrollmentData stores the subject data required to prepare a certificate request. @@ -24,13 +18,36 @@ type CertificateEnrollmentData struct { OrganizationIdentifier string `json:"organizationIdentifier,omitempty"` } -var ( - oidSurname = asn1.ObjectIdentifier{2, 5, 4, 4} - oidGivenName = asn1.ObjectIdentifier{2, 5, 4, 42} - oidUniqueIdentifier = asn1.ObjectIdentifier{2, 5, 4, 45} - oidOrganizationIdentifier = asn1.ObjectIdentifier{2, 5, 4, 97} +// CertificateType identifies the target certificate flavor. +type CertificateType string + +const ( + CertificateTypeAuthentication CertificateType = "Authentication" + CertificateTypeOffline CertificateType = "Offline" ) +func (t CertificateType) isValid() bool { + switch t { + case CertificateTypeAuthentication, CertificateTypeOffline: + return true + default: + return false + } +} + +type certificateEnrollmentRequest struct { + CertificateName string `json:"certificateName"` + CertificateType CertificateType `json:"certificateType"` + CSR string `json:"csr"` + ValidFrom *time.Time `json:"validFrom,omitempty"` +} + +// CertificateEnrollmentResponse stores the response metadata returned after submitting a CSR. +type CertificateEnrollmentResponse struct { + ReferenceNumber string `json:"referenceNumber"` + Timestamp string `json:"timestamp"` +} + // GetCertificateEnrollmentData returns the identification data used when building a new CSR. func (c *Client) GetCertificateEnrollmentData(ctx context.Context) (*CertificateEnrollmentData, error) { token, err := c.getAccessToken(ctx) @@ -54,75 +71,43 @@ func (c *Client) GetCertificateEnrollmentData(ctx context.Context) (*Certificate return response, nil } -// GenerateCSR builds a PKCS#10 certificate signing request encoded in Base64. -// The private key must be EC (secp256r1) and match the public key that will be embedded in the CSR. -// API documentation says that both RSA and EC keys are supported, but EC is recommended. -func (d *CertificateEnrollmentData) GenerateCSR(privateKey *ecdsa.PrivateKey) (string, error) { - if d == nil { - return "", fmt.Errorf("certificate enrollment data is nil") +// EnrollCertificate submits the generated CSR and returns reference metadata. +func (c *Client) EnrollCertificate(ctx context.Context, certificateName string, certificateType CertificateType, csr string, validFrom *time.Time) (*CertificateEnrollmentResponse, error) { + if certificateName == "" { + return nil, fmt.Errorf("certificateName is required") } - if privateKey == nil { - return "", fmt.Errorf("private key is nil") + if !certificateType.isValid() { + return nil, fmt.Errorf("certificateType is invalid: %s", certificateType) } - if privateKey.Curve != elliptic.P256() { - return "", fmt.Errorf("unsupported EC curve: expected P-256") + if csr == "" { + return nil, fmt.Errorf("csr is required") } - subject, err := d.asPkixName() + token, err := c.getAccessToken(ctx) if err != nil { - return "", err + return nil, err } - template := &x509.CertificateRequest{ - Subject: subject, - SignatureAlgorithm: x509.ECDSAWithSHA256, + request := &certificateEnrollmentRequest{ + CertificateName: certificateName, + CertificateType: certificateType, + CSR: csr, + ValidFrom: validFrom, } - csr, err := x509.CreateCertificateRequest(rand.Reader, template, privateKey) + response := &CertificateEnrollmentResponse{} + resp, err := c.client.R(). + SetContext(ctx). + SetAuthToken(token). + SetBody(request). + SetResult(response). + Post(c.url + "/certificates/enrollments") if err != nil { - return "", fmt.Errorf("create certificate request: %w", err) - } - - return base64.StdEncoding.EncodeToString(csr), nil -} - -func (d *CertificateEnrollmentData) asPkixName() (pkix.Name, error) { - if d.CommonName == "" { - return pkix.Name{}, fmt.Errorf("commonName is empty") - } - if d.CountryName == "" { - return pkix.Name{}, fmt.Errorf("countryName is empty") - } - if d.UniqueIdentifier == "" { - return pkix.Name{}, fmt.Errorf("uniqueIdentifier is empty") - } - - name := pkix.Name{ - CommonName: d.CommonName, - SerialNumber: d.SerialNumber, - } - - if d.CountryName != "" { - name.Country = []string{d.CountryName} + return nil, err } - if d.OrganizationName != "" { - name.Organization = []string{d.OrganizationName} + if resp.IsError() { + return nil, newErrorResponse(resp) } - name.ExtraNames = appendAttribute(name.ExtraNames, oidSurname, d.Surname) - name.ExtraNames = appendAttribute(name.ExtraNames, oidGivenName, d.GivenName) - name.ExtraNames = appendAttribute(name.ExtraNames, oidUniqueIdentifier, d.UniqueIdentifier) - name.ExtraNames = appendAttribute(name.ExtraNames, oidOrganizationIdentifier, d.OrganizationIdentifier) - - return name, nil -} - -func appendAttribute(attrs []pkix.AttributeTypeAndValue, oid asn1.ObjectIdentifier, value string) []pkix.AttributeTypeAndValue { - if value == "" { - return attrs - } - return append(attrs, pkix.AttributeTypeAndValue{ - Type: oid, - Value: value, - }) + return response, nil } From 05f17f30b6aa048fc752798378cc3db2b34784ea Mon Sep 17 00:00:00 2001 From: Mieszko Gulinski Date: Wed, 28 Jan 2026 17:14:30 +0100 Subject: [PATCH 10/18] add checking enrollment status --- api/ksef_certificate.go | 76 +++++++++++++++++++++++++++++++++++++++++ api/qr.go | 2 +- 2 files changed, 77 insertions(+), 1 deletion(-) diff --git a/api/ksef_certificate.go b/api/ksef_certificate.go index 982c177..e929bf3 100644 --- a/api/ksef_certificate.go +++ b/api/ksef_certificate.go @@ -2,7 +2,9 @@ package api import ( "context" + "errors" "fmt" + "net/url" "time" ) @@ -26,6 +28,11 @@ const ( CertificateTypeOffline CertificateType = "Offline" ) +var ( + ErrCertificateEnrollmentPollingCountExceeded = errors.New("certificate enrollment polling count exceeded") + ErrCertificateEnrollmentFailed = errors.New("certificate enrollment failed") +) + func (t CertificateType) isValid() bool { switch t { case CertificateTypeAuthentication, CertificateTypeOffline: @@ -48,6 +55,19 @@ type CertificateEnrollmentResponse struct { Timestamp string `json:"timestamp"` } +// CertificateEnrollmentStatusResponse describes the status returned for an enrollment reference number. +type CertificateEnrollmentStatusResponse struct { + RequestDate time.Time `json:"requestDate"` + Status *CertificateEnrollmentStatus `json:"status"` +} + +// CertificateEnrollmentStatus mirrors the StatusInfo structure returned by the API. +type CertificateEnrollmentStatus struct { + Code int `json:"code"` + Description string `json:"description"` + Details []string `json:"details,omitempty"` +} + // GetCertificateEnrollmentData returns the identification data used when building a new CSR. func (c *Client) GetCertificateEnrollmentData(ctx context.Context) (*CertificateEnrollmentData, error) { token, err := c.getAccessToken(ctx) @@ -111,3 +131,59 @@ func (c *Client) EnrollCertificate(ctx context.Context, certificateName string, return response, nil } + +// GetCertificateEnrollmentStatus returns processing status for the specified certificate request reference number. +func (c *Client) GetCertificateEnrollmentStatus(ctx context.Context, referenceNumber string) (*CertificateEnrollmentStatusResponse, error) { + if referenceNumber == "" { + return nil, fmt.Errorf("referenceNumber is required") + } + + token, err := c.getAccessToken(ctx) + if err != nil { + return nil, err + } + + response := &CertificateEnrollmentStatusResponse{} + resp, err := c.client.R(). + SetContext(ctx). + SetAuthToken(token). + SetResult(response). + Get(c.url + "/certificates/enrollments/" + url.PathEscape(referenceNumber)) + if err != nil { + return nil, err + } + if resp.IsError() { + return nil, newErrorResponse(resp) + } + + return response, nil +} + +// PollCertificateEnrollmentStatus keeps querying enrollment status until it succeeds or fails. +func (c *Client) PollCertificateEnrollmentStatus(ctx context.Context, referenceNumber string) (*CertificateEnrollmentStatusResponse, error) { + attempt := 0 + for { + attempt++ + if attempt > 30 { + return nil, ErrCertificateEnrollmentPollingCountExceeded + } + + statusResp, err := c.GetCertificateEnrollmentStatus(ctx, referenceNumber) + if err != nil { + return nil, err + } + if statusResp == nil || statusResp.Status == nil { + return nil, fmt.Errorf("certificate enrollment status response missing status") + } + + switch statusResp.Status.Code { + case 200: + return statusResp, nil + case 100: + time.Sleep(2 * time.Second) + continue + default: + return nil, fmt.Errorf("%w: %s", ErrCertificateEnrollmentFailed, statusResp.Status.Description) + } + } +} diff --git a/api/qr.go b/api/qr.go index 32f69a8..465374e 100644 --- a/api/qr.go +++ b/api/qr.go @@ -85,7 +85,7 @@ func GenerateUnsignedCertificateQrCodeURL(environment Environment, contextNip st // Private key must come from a KSeF offline certificate (important!). Example how to obtain it: // privateKey, _, _, err := pkcs12.DecodeChain(certificateData, certificatePassword) func GenerateSignedCertificateQrCodeURL(unsignedUrl string, privateKey crypto.Signer) (string, error) { - urlHash := sha256.Sum256([]byte(unsignedUrl)) + urlHash := sha256.Sum256([]byte(unsignedUrl)) // KSeF requires SHA-256 var signature []byte var err error From 517ecea4f69e523dac00ce355efbf63fa7bf97db Mon Sep 17 00:00:00 2001 From: Mieszko Gulinski Date: Wed, 28 Jan 2026 17:24:10 +0100 Subject: [PATCH 11/18] add certificate revoke --- api/ksef_certificate.go | 25 +++++++++++++++++++++++++ 1 file changed, 25 insertions(+) diff --git a/api/ksef_certificate.go b/api/ksef_certificate.go index e929bf3..73207a2 100644 --- a/api/ksef_certificate.go +++ b/api/ksef_certificate.go @@ -132,6 +132,31 @@ func (c *Client) EnrollCertificate(ctx context.Context, certificateName string, return response, nil } +// RevokeCertificate submits a revocation request for the provided certificate serial number. +func (c *Client) RevokeCertificate(ctx context.Context, certificateSerialNumber string) error { + if certificateSerialNumber == "" { + return fmt.Errorf("certificateSerialNumber is required") + } + + token, err := c.getAccessToken(ctx) + if err != nil { + return err + } + + resp, err := c.client.R(). + SetContext(ctx). + SetAuthToken(token). + Post(c.url + "/certificates/" + url.PathEscape(certificateSerialNumber) + "/revoke") + if err != nil { + return err + } + if resp.IsError() { + return newErrorResponse(resp) + } + + return nil +} + // GetCertificateEnrollmentStatus returns processing status for the specified certificate request reference number. func (c *Client) GetCertificateEnrollmentStatus(ctx context.Context, referenceNumber string) (*CertificateEnrollmentStatusResponse, error) { if referenceNumber == "" { From 9b084e686e05e84d33cd8556b11c9397b7e1f710 Mon Sep 17 00:00:00 2001 From: Mieszko Gulinski Date: Wed, 28 Jan 2026 17:49:42 +0100 Subject: [PATCH 12/18] better docs --- api/ksef_certificate.go | 1 + authentication-requirements.md | 78 ++++++++++++++++++++++++++++++++++ 2 files changed, 79 insertions(+) diff --git a/api/ksef_certificate.go b/api/ksef_certificate.go index 73207a2..2e62066 100644 --- a/api/ksef_certificate.go +++ b/api/ksef_certificate.go @@ -92,6 +92,7 @@ func (c *Client) GetCertificateEnrollmentData(ctx context.Context) (*Certificate } // EnrollCertificate submits the generated CSR and returns reference metadata. +// validFrom is optional, if not provided, the certificate will be valid from the current date. func (c *Client) EnrollCertificate(ctx context.Context, certificateName string, certificateType CertificateType, csr string, validFrom *time.Time) (*CertificateEnrollmentResponse, error) { if certificateName == "" { return nil, fmt.Errorf("certificateName is required") diff --git a/authentication-requirements.md b/authentication-requirements.md index ef64fc5..9ab912b 100644 --- a/authentication-requirements.md +++ b/authentication-requirements.md @@ -72,3 +72,81 @@ A video tutorial (in Polish) about using Aplikacja Podatnika is available [here] 7. Client application, being authenticated as the user, generates a KSeF certificate and saves it for later use Steps from 2 to 6 must be done in 5 minutes, otherwise the authentication challenge expires. See [here](./authentication.md) for details about the authentication process. + +### Creating a KSeF certificate using this library + +`gobl.ksef` library exposes functions that allow requesting a KSeF certificate. + +How to use them: +```go +package main + +import ( + "context" + "crypto/ecdsa" + "crypto/elliptic" + "crypto/rand" + "log" + "time" + + ksef "github.com/invopop/gobl.ksef/api" +) + +func main() { + ctx := context.Background() + + // Load the certificate used to authenticate against KSeF (not the new one yet). + certificateData, err := ksef.LoadCertificate("./path/to/auth-certificate.pfx") + if err != nil { + log.Fatal(err) + } + + client := ksef.NewClient(&ksef.ContextIdentifier{Nip: "1234567890"}, certificateData) + if err := client.Authenticate(ctx); err != nil { + log.Fatal(err) + } + + // 1. Obtain required data that must go into the CSR subject + enrollmentData, err := client.GetCertificateEnrollmentData(ctx) + if err != nil { + log.Fatal(err) + } + + // 2. Create ECDSA key pair that will represent the new KSeF certificate + privateKey, err := ecdsa.GenerateKey(elliptic.P256(), rand.Reader) + if err != nil { + log.Fatal(err) + } + + // 3. Create the CSR using the attributes returned by the API + csr, err := enrollmentData.GenerateCSR(privateKey) + if err != nil { + log.Fatal(err) + } + + // 4. Submit request to create the certificate + enrollmentResp, err := client.EnrollCertificate( + ctx, + "My Auth Cert", + ksef.CertificateTypeAuthentication, + csr, + nil, // optional validFrom + ) + if err != nil { + log.Fatal(err) + } + + // 5. Poll endpoint until KSeF finishes processing the request (status 200) + statusResp, err := client.PollCertificateEnrollmentStatus(ctx, enrollmentResp.ReferenceNumber) + if err != nil { + log.Fatal(err) + } + log.Printf("Certificate request completed at %s", statusResp.RequestDate.Format(time.RFC3339)) + + // 6. Once you learn the certificate serial number from subsequent steps, + // you can revoke it at any time: + if err := client.RevokeCertificate(ctx, "0123ABCD4567EF89"); err != nil { + log.Fatal(err) + } +} +``` From fa709fd10585075037587610ff7db8a400585693 Mon Sep 17 00:00:00 2001 From: Mieszko Gulinski Date: Wed, 28 Jan 2026 17:55:13 +0100 Subject: [PATCH 13/18] create function that performs full certification creation flow --- api/ksef_certificate.go | 39 ++++++++++++++++++++++++++++++++++ authentication-requirements.md | 32 ++++++---------------------- 2 files changed, 46 insertions(+), 25 deletions(-) diff --git a/api/ksef_certificate.go b/api/ksef_certificate.go index 2e62066..dadde36 100644 --- a/api/ksef_certificate.go +++ b/api/ksef_certificate.go @@ -2,6 +2,7 @@ package api import ( "context" + "crypto/ecdsa" "errors" "fmt" "net/url" @@ -55,6 +56,12 @@ type CertificateEnrollmentResponse struct { Timestamp string `json:"timestamp"` } +// CertificateCreationResult bundles the responses returned during certificate creation. +type CertificateCreationResult struct { + Enrollment *CertificateEnrollmentResponse + Status *CertificateEnrollmentStatusResponse +} + // CertificateEnrollmentStatusResponse describes the status returned for an enrollment reference number. type CertificateEnrollmentStatusResponse struct { RequestDate time.Time `json:"requestDate"` @@ -133,6 +140,38 @@ func (c *Client) EnrollCertificate(ctx context.Context, certificateName string, return response, nil } +// CreateCertificate orchestrates the full flow of requesting a KSeF certificate using the provided key material. +func (c *Client) CreateCertificate(ctx context.Context, certificateName string, certificateType CertificateType, privateKey *ecdsa.PrivateKey, validFrom *time.Time) (*CertificateCreationResult, error) { + if privateKey == nil { + return nil, fmt.Errorf("private key is required") + } + + enrollmentData, err := c.GetCertificateEnrollmentData(ctx) + if err != nil { + return nil, err + } + + csr, err := enrollmentData.GenerateCSR(privateKey) + if err != nil { + return nil, err + } + + enrollmentResp, err := c.EnrollCertificate(ctx, certificateName, certificateType, csr, validFrom) + if err != nil { + return nil, err + } + + statusResp, err := c.PollCertificateEnrollmentStatus(ctx, enrollmentResp.ReferenceNumber) + if err != nil { + return nil, err + } + + return &CertificateCreationResult{ + Enrollment: enrollmentResp, + Status: statusResp, + }, nil +} + // RevokeCertificate submits a revocation request for the provided certificate serial number. func (c *Client) RevokeCertificate(ctx context.Context, certificateSerialNumber string) error { if certificateSerialNumber == "" { diff --git a/authentication-requirements.md b/authentication-requirements.md index 9ab912b..3bcaf3c 100644 --- a/authentication-requirements.md +++ b/authentication-requirements.md @@ -106,45 +106,27 @@ func main() { log.Fatal(err) } - // 1. Obtain required data that must go into the CSR subject - enrollmentData, err := client.GetCertificateEnrollmentData(ctx) - if err != nil { - log.Fatal(err) - } - - // 2. Create ECDSA key pair that will represent the new KSeF certificate + // Create ECDSA key pair that will represent the new KSeF certificate privateKey, err := ecdsa.GenerateKey(elliptic.P256(), rand.Reader) if err != nil { log.Fatal(err) } - // 3. Create the CSR using the attributes returned by the API - csr, err := enrollmentData.GenerateCSR(privateKey) - if err != nil { - log.Fatal(err) - } - - // 4. Submit request to create the certificate - enrollmentResp, err := client.EnrollCertificate( + // CreateCertificate runs the whole flow: fetch data, generate CSR, submit, and poll. + result, err := client.CreateCertificate( ctx, "My Auth Cert", ksef.CertificateTypeAuthentication, - csr, + privateKey, nil, // optional validFrom ) if err != nil { log.Fatal(err) } + log.Printf("Certificate request completed at %s", result.Status.RequestDate.Format(time.RFC3339)) - // 5. Poll endpoint until KSeF finishes processing the request (status 200) - statusResp, err := client.PollCertificateEnrollmentStatus(ctx, enrollmentResp.ReferenceNumber) - if err != nil { - log.Fatal(err) - } - log.Printf("Certificate request completed at %s", statusResp.RequestDate.Format(time.RFC3339)) - - // 6. Once you learn the certificate serial number from subsequent steps, - // you can revoke it at any time: + // Once you obtain the certificate serial number from subsequent steps, + // you can revoke it at any time: if err := client.RevokeCertificate(ctx, "0123ABCD4567EF89"); err != nil { log.Fatal(err) } From bd037acaabb7cea30a20867fda4f438707cae794 Mon Sep 17 00:00:00 2001 From: Mieszko Gulinski Date: Wed, 28 Jan 2026 18:07:57 +0100 Subject: [PATCH 14/18] simplify --- api/ksef_certificate.go | 33 +++++++++++++++------------------ authentication-requirements.md | 6 +++--- 2 files changed, 18 insertions(+), 21 deletions(-) diff --git a/api/ksef_certificate.go b/api/ksef_certificate.go index dadde36..3a6dfa7 100644 --- a/api/ksef_certificate.go +++ b/api/ksef_certificate.go @@ -57,15 +57,11 @@ type CertificateEnrollmentResponse struct { } // CertificateCreationResult bundles the responses returned during certificate creation. -type CertificateCreationResult struct { - Enrollment *CertificateEnrollmentResponse - Status *CertificateEnrollmentStatusResponse -} - // CertificateEnrollmentStatusResponse describes the status returned for an enrollment reference number. type CertificateEnrollmentStatusResponse struct { - RequestDate time.Time `json:"requestDate"` - Status *CertificateEnrollmentStatus `json:"status"` + RequestDate time.Time `json:"requestDate"` + Status *CertificateEnrollmentStatus `json:"status"` + CertificateSerialNumber string `json:"certificateSerialNumber,omitempty"` } // CertificateEnrollmentStatus mirrors the StatusInfo structure returned by the API. @@ -140,36 +136,37 @@ func (c *Client) EnrollCertificate(ctx context.Context, certificateName string, return response, nil } -// CreateCertificate orchestrates the full flow of requesting a KSeF certificate using the provided key material. -func (c *Client) CreateCertificate(ctx context.Context, certificateName string, certificateType CertificateType, privateKey *ecdsa.PrivateKey, validFrom *time.Time) (*CertificateCreationResult, error) { +// CreateKsefCertificate orchestrates the full flow of requesting a KSeF certificate using the provided key material. +// Returns the certificate serial number once the request succeeds. +func (c *Client) CreateKsefCertificate(ctx context.Context, certificateName string, certificateType CertificateType, privateKey *ecdsa.PrivateKey, validFrom *time.Time) (string, error) { if privateKey == nil { - return nil, fmt.Errorf("private key is required") + return "", fmt.Errorf("private key is required") } enrollmentData, err := c.GetCertificateEnrollmentData(ctx) if err != nil { - return nil, err + return "", err } csr, err := enrollmentData.GenerateCSR(privateKey) if err != nil { - return nil, err + return "", err } enrollmentResp, err := c.EnrollCertificate(ctx, certificateName, certificateType, csr, validFrom) if err != nil { - return nil, err + return "", err } statusResp, err := c.PollCertificateEnrollmentStatus(ctx, enrollmentResp.ReferenceNumber) if err != nil { - return nil, err + return "", err } - return &CertificateCreationResult{ - Enrollment: enrollmentResp, - Status: statusResp, - }, nil + if statusResp.CertificateSerialNumber == "" { + return "", fmt.Errorf("certificate serial number missing in enrollment status response") + } + return statusResp.CertificateSerialNumber, nil } // RevokeCertificate submits a revocation request for the provided certificate serial number. diff --git a/authentication-requirements.md b/authentication-requirements.md index 3bcaf3c..5241310 100644 --- a/authentication-requirements.md +++ b/authentication-requirements.md @@ -112,8 +112,8 @@ func main() { log.Fatal(err) } - // CreateCertificate runs the whole flow: fetch data, generate CSR, submit, and poll. - result, err := client.CreateCertificate( + // CreateKsefCertificate runs the whole flow: fetch data, generate CSR, submit, and poll. + serialNumber, err := client.CreateKsefCertificate( ctx, "My Auth Cert", ksef.CertificateTypeAuthentication, @@ -123,7 +123,7 @@ func main() { if err != nil { log.Fatal(err) } - log.Printf("Certificate request completed at %s", result.Status.RequestDate.Format(time.RFC3339)) + log.Printf("Certificate request completed, serial number %s", serialNumber) // Once you obtain the certificate serial number from subsequent steps, // you can revoke it at any time: From c0a4fbb61967aa3d64528c36f83df29f63cf038d Mon Sep 17 00:00:00 2001 From: Mieszko Gulinski Date: Wed, 28 Jan 2026 18:19:06 +0100 Subject: [PATCH 15/18] fix: unique identifier is optional --- api/csr.go | 3 --- api/ksef_certificate.go | 2 +- 2 files changed, 1 insertion(+), 4 deletions(-) diff --git a/api/csr.go b/api/csr.go index 0e1231f..4a41b04 100644 --- a/api/csr.go +++ b/api/csr.go @@ -57,9 +57,6 @@ func (d *CertificateEnrollmentData) asPkixName() (pkix.Name, error) { if d.CountryName == "" { return pkix.Name{}, fmt.Errorf("countryName is empty") } - if d.UniqueIdentifier == "" { - return pkix.Name{}, fmt.Errorf("uniqueIdentifier is empty") - } name := pkix.Name{ CommonName: d.CommonName, diff --git a/api/ksef_certificate.go b/api/ksef_certificate.go index 3a6dfa7..87cbf02 100644 --- a/api/ksef_certificate.go +++ b/api/ksef_certificate.go @@ -17,7 +17,7 @@ type CertificateEnrollmentData struct { CountryName string `json:"countryName"` OrganizationName string `json:"organizationName,omitempty"` GivenName string `json:"givenName,omitempty"` - UniqueIdentifier string `json:"uniqueIdentifier"` + UniqueIdentifier string `json:"uniqueIdentifier,omitempty"` OrganizationIdentifier string `json:"organizationIdentifier,omitempty"` } From e84998db26c8a6d92ad73e9288c51f04ab8947b8 Mon Sep 17 00:00:00 2001 From: Mieszko Gulinski Date: Wed, 28 Jan 2026 18:54:05 +0100 Subject: [PATCH 16/18] save generated certificate to a file --- api/ksef_certificate.go | 103 +++++++++++++++++++++++++++++---- authentication-requirements.md | 6 +- 2 files changed, 96 insertions(+), 13 deletions(-) diff --git a/api/ksef_certificate.go b/api/ksef_certificate.go index 87cbf02..c4b3c95 100644 --- a/api/ksef_certificate.go +++ b/api/ksef_certificate.go @@ -3,10 +3,14 @@ package api import ( "context" "crypto/ecdsa" + "crypto/x509" + "encoding/base64" "errors" "fmt" "net/url" "time" + + "software.sslmate.com/src/go-pkcs12" ) // CertificateEnrollmentData stores the subject data required to prepare a certificate request. @@ -50,13 +54,16 @@ type certificateEnrollmentRequest struct { ValidFrom *time.Time `json:"validFrom,omitempty"` } +type certificateRetrieveRequest struct { + CertificateSerialNumbers []string `json:"certificateSerialNumbers"` +} + // CertificateEnrollmentResponse stores the response metadata returned after submitting a CSR. type CertificateEnrollmentResponse struct { ReferenceNumber string `json:"referenceNumber"` Timestamp string `json:"timestamp"` } -// CertificateCreationResult bundles the responses returned during certificate creation. // CertificateEnrollmentStatusResponse describes the status returned for an enrollment reference number. type CertificateEnrollmentStatusResponse struct { RequestDate time.Time `json:"requestDate"` @@ -71,6 +78,19 @@ type CertificateEnrollmentStatus struct { Details []string `json:"details,omitempty"` } +// CertificateRetrieveEntry represents a single certificate returned by /certificates/retrieve. +type CertificateRetrieveEntry struct { + Certificate string `json:"certificate"` + CertificateName string `json:"certificateName"` + CertificateSerialNumber string `json:"certificateSerialNumber"` + CertificateType string `json:"certificateType"` +} + +// CertificateRetrieveResponse mirrors the API response for POST /certificates/retrieve. +type CertificateRetrieveResponse struct { + Certificates []CertificateRetrieveEntry `json:"certificates"` +} + // GetCertificateEnrollmentData returns the identification data used when building a new CSR. func (c *Client) GetCertificateEnrollmentData(ctx context.Context) (*CertificateEnrollmentData, error) { token, err := c.getAccessToken(ctx) @@ -136,37 +156,100 @@ func (c *Client) EnrollCertificate(ctx context.Context, certificateName string, return response, nil } +// RetrieveCertificate downloads metadata and content for the provided certificate serial number. +func (c *Client) RetrieveCertificate(ctx context.Context, certificateSerialNumber string) (*CertificateRetrieveEntry, error) { + if certificateSerialNumber == "" { + return nil, fmt.Errorf("certificate serial number is required") + } + + token, err := c.getAccessToken(ctx) + if err != nil { + return nil, err + } + + request := &certificateRetrieveRequest{ + CertificateSerialNumbers: []string{certificateSerialNumber}, + } + + response := &CertificateRetrieveResponse{} + resp, err := c.client.R(). + SetContext(ctx). + SetAuthToken(token). + SetBody(request). + SetResult(response). + Post(c.url + "/certificates/retrieve") + if err != nil { + return nil, err + } + if resp.IsError() { + return nil, newErrorResponse(resp) + } + if len(response.Certificates) != 1 { + return nil, fmt.Errorf("expected exactly 1 certificate, got %d", len(response.Certificates)) + } + + return &response.Certificates[0], nil +} + // CreateKsefCertificate orchestrates the full flow of requesting a KSeF certificate using the provided key material. -// Returns the certificate serial number once the request succeeds. -func (c *Client) CreateKsefCertificate(ctx context.Context, certificateName string, certificateType CertificateType, privateKey *ecdsa.PrivateKey, validFrom *time.Time) (string, error) { +// Returns the retrieved certificate response once the request succeeds. +func (c *Client) CreateKsefCertificate(ctx context.Context, certificateName string, certificateType CertificateType, privateKey *ecdsa.PrivateKey, validFrom *time.Time) (*CertificateRetrieveEntry, error) { if privateKey == nil { - return "", fmt.Errorf("private key is required") + return nil, fmt.Errorf("private key is required") } enrollmentData, err := c.GetCertificateEnrollmentData(ctx) if err != nil { - return "", err + return nil, err } csr, err := enrollmentData.GenerateCSR(privateKey) if err != nil { - return "", err + return nil, err } enrollmentResp, err := c.EnrollCertificate(ctx, certificateName, certificateType, csr, validFrom) if err != nil { - return "", err + return nil, err } statusResp, err := c.PollCertificateEnrollmentStatus(ctx, enrollmentResp.ReferenceNumber) if err != nil { - return "", err + return nil, err } if statusResp.CertificateSerialNumber == "" { - return "", fmt.Errorf("certificate serial number missing in enrollment status response") + return nil, fmt.Errorf("certificate serial number missing in enrollment status response") + } + return c.RetrieveCertificate(ctx, statusResp.CertificateSerialNumber) +} + +// BuildPKCS12Certificate is a helper that bundles the retrieved certificate and the private key into a PKCS#12 archive. +// Note: there are multiple possible encodings of PKCS#12 (LegacyRC2, Legacy, Modern). LegacyRC2 is insecure, and Modern may be not compatible with older systems. +func BuildPKCS12Certificate(entry *CertificateRetrieveEntry, privateKey *ecdsa.PrivateKey, password string) ([]byte, error) { + if entry == nil { + return nil, fmt.Errorf("certificate entry is nil") } - return statusResp.CertificateSerialNumber, nil + if privateKey == nil { + return nil, fmt.Errorf("private key is nil") + } + + certBytes, err := base64.StdEncoding.DecodeString(entry.Certificate) + if err != nil { + return nil, fmt.Errorf("decode certificate: %w", err) + } + + cert, err := x509.ParseCertificate(certBytes) + if err != nil { + return nil, fmt.Errorf("parse certificate: %w", err) + } + + pfx, err := pkcs12.Legacy.Encode(privateKey, cert, nil, password) + if err != nil { + return nil, fmt.Errorf("encode pkcs12: %w", err) + } + + return pfx, nil } // RevokeCertificate submits a revocation request for the provided certificate serial number. diff --git a/authentication-requirements.md b/authentication-requirements.md index 5241310..65db44f 100644 --- a/authentication-requirements.md +++ b/authentication-requirements.md @@ -113,7 +113,7 @@ func main() { } // CreateKsefCertificate runs the whole flow: fetch data, generate CSR, submit, and poll. - serialNumber, err := client.CreateKsefCertificate( + certEntry, err := client.CreateKsefCertificate( ctx, "My Auth Cert", ksef.CertificateTypeAuthentication, @@ -123,11 +123,11 @@ func main() { if err != nil { log.Fatal(err) } - log.Printf("Certificate request completed, serial number %s", serialNumber) + log.Printf("Certificate request completed, serial number %s", certEntry.CertificateSerialNumber) // Once you obtain the certificate serial number from subsequent steps, // you can revoke it at any time: - if err := client.RevokeCertificate(ctx, "0123ABCD4567EF89"); err != nil { + if err := client.RevokeCertificate(ctx, certEntry.CertificateSerialNumber); err != nil { log.Fatal(err) } } From 711a499cd2eedcc6a8e798447b7cac54119196aa Mon Sep 17 00:00:00 2001 From: Mieszko Gulinski Date: Wed, 28 Jan 2026 18:59:32 +0100 Subject: [PATCH 17/18] better naming --- api/ksef_certificate_pkcs12_test.go | 53 +++++++++++++++++++++++++++++ api/ksef_certificate_test.go | 4 +-- api/qr_test.go | 14 ++++---- 3 files changed, 62 insertions(+), 9 deletions(-) create mode 100644 api/ksef_certificate_pkcs12_test.go diff --git a/api/ksef_certificate_pkcs12_test.go b/api/ksef_certificate_pkcs12_test.go new file mode 100644 index 0000000..5645891 --- /dev/null +++ b/api/ksef_certificate_pkcs12_test.go @@ -0,0 +1,53 @@ +package api + +import ( + "crypto/ecdsa" + "crypto/elliptic" + "crypto/rand" + "crypto/x509" + "crypto/x509/pkix" + "encoding/base64" + "math/big" + "testing" + "time" + + "github.com/stretchr/testify/require" + "software.sslmate.com/src/go-pkcs12" +) + +func TestBuildPKCS12Certificate(t *testing.T) { + key, err := ecdsa.GenerateKey(elliptic.P256(), rand.Reader) + require.NoError(t, err) + + template := &x509.Certificate{ + SerialNumber: big.NewInt(123456789), + Subject: pkix.Name{CommonName: "Test Cert"}, + NotBefore: time.Now().Add(-time.Hour), + NotAfter: time.Now().Add(time.Hour), + KeyUsage: x509.KeyUsageDigitalSignature, + BasicConstraintsValid: true, + } + + certDER, err := x509.CreateCertificate(rand.Reader, template, template, &key.PublicKey, key) + require.NoError(t, err) + + entry := &CertificateRetrieveEntry{ + Certificate: base64.StdEncoding.EncodeToString(certDER), + CertificateName: "Test", + CertificateSerialNumber: "123456", + CertificateType: "Authentication", + } + + password := "secret" + pfx, err := BuildPKCS12Certificate(entry, key, password) + require.NoError(t, err) + require.NotEmpty(t, pfx) + + decodedKey, cert, _, err := pkcs12.DecodeChain(pfx, password) + require.NoError(t, err) + require.NotNil(t, cert) + + decodedECDSAKey, ok := decodedKey.(*ecdsa.PrivateKey) + require.True(t, ok) + require.Equal(t, key.D, decodedECDSAKey.D) +} diff --git a/api/ksef_certificate_test.go b/api/ksef_certificate_test.go index 0dd5ad2..f3fcbb2 100644 --- a/api/ksef_certificate_test.go +++ b/api/ksef_certificate_test.go @@ -13,7 +13,7 @@ import ( ) func TestGenerateCSR(t *testing.T) { - privateKey, err := ecdsa.GenerateKey(elliptic.P256(), rand.Reader) + key, err := ecdsa.GenerateKey(elliptic.P256(), rand.Reader) require.NoError(t, err) data := &CertificateEnrollmentData{ @@ -27,7 +27,7 @@ func TestGenerateCSR(t *testing.T) { OrganizationIdentifier: "1234567890", } - csrBase64, err := data.GenerateCSR(privateKey) + csrBase64, err := data.GenerateCSR(key) require.NoError(t, err) csrDER, err := base64.StdEncoding.DecodeString(csrBase64) diff --git a/api/qr_test.go b/api/qr_test.go index 1b1b0a8..2b6ce2e 100644 --- a/api/qr_test.go +++ b/api/qr_test.go @@ -75,10 +75,10 @@ func TestGenerateCertificateQrCodeURL(t *testing.T) { func TestGenerateSignedCertificateQrCodeURL_ECDSA(t *testing.T) { unsignedURL := "qr.ksef.mf.gov.pl/certificate/Nip/1111111111/2222222222/CERT123/AAECAw" - privateKey, err := ecdsa.GenerateKey(elliptic.P256(), rand.Reader) + key, err := ecdsa.GenerateKey(elliptic.P256(), rand.Reader) require.NoError(t, err) - signedURL, err := GenerateSignedCertificateQrCodeURL(unsignedURL, privateKey) + signedURL, err := GenerateSignedCertificateQrCodeURL(unsignedURL, key) require.NoError(t, err) prefix := "https://" + unsignedURL + "/" @@ -90,21 +90,21 @@ func TestGenerateSignedCertificateQrCodeURL_ECDSA(t *testing.T) { signature, err := base64.RawURLEncoding.DecodeString(sigEncoded) require.NoError(t, err) - size := (privateKey.Params().BitSize + 7) / 8 + size := (key.Params().BitSize + 7) / 8 require.Equal(t, 2*size, len(signature)) r := new(big.Int).SetBytes(signature[:size]) s := new(big.Int).SetBytes(signature[size:]) hash := sha256.Sum256([]byte(unsignedURL)) - assert.True(t, ecdsa.Verify(&privateKey.PublicKey, hash[:], r, s)) + assert.True(t, ecdsa.Verify(&key.PublicKey, hash[:], r, s)) } func TestGenerateSignedCertificateQrCodeURL_RSA(t *testing.T) { unsignedURL := "qr.ksef.mf.gov.pl/certificate/Nip/3333333333/4444444444/CERT999/BBEFAA" - privateKey, err := rsa.GenerateKey(rand.Reader, 2048) + key, err := rsa.GenerateKey(rand.Reader, 2048) require.NoError(t, err) - signedURL, err := GenerateSignedCertificateQrCodeURL(unsignedURL, privateKey) + signedURL, err := GenerateSignedCertificateQrCodeURL(unsignedURL, key) require.NoError(t, err) prefix := "https://" + unsignedURL + "/" @@ -117,5 +117,5 @@ func TestGenerateSignedCertificateQrCodeURL_RSA(t *testing.T) { require.NoError(t, err) hash := sha256.Sum256([]byte(unsignedURL)) - assert.NoError(t, rsa.VerifyPSS(&privateKey.PublicKey, crypto.SHA256, hash[:], signature, &rsa.PSSOptions{SaltLength: 32, Hash: crypto.SHA256})) + assert.NoError(t, rsa.VerifyPSS(&key.PublicKey, crypto.SHA256, hash[:], signature, &rsa.PSSOptions{SaltLength: 32, Hash: crypto.SHA256})) } From 66363cda4e2805999d224acc99b8ebb1bfb2540b Mon Sep 17 00:00:00 2001 From: Mieszko Gulinski Date: Thu, 29 Jan 2026 14:32:20 +0100 Subject: [PATCH 18/18] change style --- api/qr.go | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/api/qr.go b/api/qr.go index 465374e..fbdce81 100644 --- a/api/qr.go +++ b/api/qr.go @@ -112,7 +112,7 @@ func GenerateSignedCertificateQrCodeURL(unsignedUrl string, privateKey crypto.Si return "", err } default: - return "", fmt.Errorf("Certificate private key must be ECDSA or RSA") + return "", fmt.Errorf("certificate private key must be ECDSA or RSA") } signatureBase64 := base64.RawURLEncoding.EncodeToString(signature)