diff --git a/api/csr.go b/api/csr.go new file mode 100644 index 0000000..4a41b04 --- /dev/null +++ b/api/csr.go @@ -0,0 +1,89 @@ +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") + } + + 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 new file mode 100644 index 0000000..c4b3c95 --- /dev/null +++ b/api/ksef_certificate.go @@ -0,0 +1,334 @@ +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. +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,omitempty"` + OrganizationIdentifier string `json:"organizationIdentifier,omitempty"` +} + +// CertificateType identifies the target certificate flavor. +type CertificateType string + +const ( + CertificateTypeAuthentication CertificateType = "Authentication" + 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: + 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"` +} + +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"` +} + +// CertificateEnrollmentStatusResponse describes the status returned for an enrollment reference number. +type CertificateEnrollmentStatusResponse struct { + RequestDate time.Time `json:"requestDate"` + Status *CertificateEnrollmentStatus `json:"status"` + CertificateSerialNumber string `json:"certificateSerialNumber,omitempty"` +} + +// 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"` +} + +// 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) + 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 +} + +// 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") + } + if !certificateType.isValid() { + return nil, fmt.Errorf("certificateType is invalid: %s", certificateType) + } + if csr == "" { + return nil, fmt.Errorf("csr is required") + } + + token, err := c.getAccessToken(ctx) + if err != nil { + return nil, err + } + + request := &certificateEnrollmentRequest{ + CertificateName: certificateName, + CertificateType: certificateType, + CSR: csr, + ValidFrom: validFrom, + } + + response := &CertificateEnrollmentResponse{} + resp, err := c.client.R(). + SetContext(ctx). + SetAuthToken(token). + SetBody(request). + SetResult(response). + Post(c.url + "/certificates/enrollments") + if err != nil { + return nil, err + } + if resp.IsError() { + return nil, newErrorResponse(resp) + } + + 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 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 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 + } + + if statusResp.CertificateSerialNumber == "" { + 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") + } + 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. +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 == "" { + 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/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 new file mode 100644 index 0000000..f3fcbb2 --- /dev/null +++ b/api/ksef_certificate_test.go @@ -0,0 +1,60 @@ +package api + +import ( + "crypto/ecdsa" + "crypto/elliptic" + "crypto/rand" + "crypto/x509" + "encoding/base64" + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +func TestGenerateCSR(t *testing.T) { + key, err := ecdsa.GenerateKey(elliptic.P256(), rand.Reader) + 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(key) + 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())) +} diff --git a/api/qr.go b/api/qr.go index bdd0bc8..fbdce81 100644 --- a/api/qr.go +++ b/api/qr.go @@ -1,19 +1,23 @@ package api import ( + "crypto" + "crypto/ecdsa" + "crypto/rand" + "crypto/rsa" + "crypto/sha256" "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 +37,85 @@ 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 } + +// 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: + 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") + } + + base64InvoiceHash := base64.RawURLEncoding.EncodeToString(invoiceHash) + + return fmt.Sprintf("%s/certificate/Nip/%s/%s/%s/%s", + baseUrl, + contextNip, + sellerNip, + certificateId, + 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)) // KSeF requires SHA-256 + + 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 new file mode 100644 index 0000000..2b6ce2e --- /dev/null +++ b/api/qr_test.go @@ -0,0 +1,121 @@ +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) { + 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 := GenerateUnsignedCertificateQrCodeURL( + 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 := GenerateUnsignedCertificateQrCodeURL( + 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 := GenerateUnsignedCertificateQrCodeURL(EnvironmentTest, "", "222", "C", invoiceHash) + assert.Error(t, err) + _, err = GenerateUnsignedCertificateQrCodeURL(EnvironmentTest, "111", "", "C", invoiceHash) + assert.Error(t, err) + _, 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" + key, err := ecdsa.GenerateKey(elliptic.P256(), rand.Reader) + require.NoError(t, err) + + signedURL, err := GenerateSignedCertificateQrCodeURL(unsignedURL, key) + 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 := (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(&key.PublicKey, hash[:], r, s)) +} + +func TestGenerateSignedCertificateQrCodeURL_RSA(t *testing.T) { + unsignedURL := "qr.ksef.mf.gov.pl/certificate/Nip/3333333333/4444444444/CERT999/BBEFAA" + key, err := rsa.GenerateKey(rand.Reader, 2048) + require.NoError(t, err) + + signedURL, err := GenerateSignedCertificateQrCodeURL(unsignedURL, key) + 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(&key.PublicKey, crypto.SHA256, hash[:], signature, &rsa.PSSOptions{SaltLength: 32, Hash: crypto.SHA256})) +} diff --git a/authentication-requirements.md b/authentication-requirements.md new file mode 100644 index 0000000..65db44f --- /dev/null +++ b/authentication-requirements.md @@ -0,0 +1,134 @@ +# 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 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` +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 - see [offline mode](offline-mode.md) for details + +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 +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. + +### 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) + } + + // 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) + } + + // CreateKsefCertificate runs the whole flow: fetch data, generate CSR, submit, and poll. + certEntry, err := client.CreateKsefCertificate( + ctx, + "My Auth Cert", + ksef.CertificateTypeAuthentication, + privateKey, + nil, // optional validFrom + ) + if err != nil { + log.Fatal(err) + } + 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, certEntry.CertificateSerialNumber); err != nil { + log.Fatal(err) + } +} +``` diff --git a/authentication.md b/authentication.md index 540c89f..7493f56 100644 --- a/authentication.md +++ b/authentication.md @@ -1,31 +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. +Based on: -### 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`. +- [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 @@ -59,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 @@ -80,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`. @@ -90,11 +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, to allow company Y to act on your behalf in KSeF: -1. Company Y needs to obtain a [qualified EU certificate](https://eidas.ec.europa.eu/efda/trust-services/browse/eidas/tls). -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). -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. +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: -`NipVatUe` context binds a Polish company identified by NIP with EU business entity identified by EU VAT number. +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 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. diff --git a/offline-mode.md b/offline-mode.md new file mode 100644 index 0000000..7ba3c92 --- /dev/null +++ b/offline-mode.md @@ -0,0 +1,58 @@ +# 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/) +- [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: + +- 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 + +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