Część przewodnika KSeF od środka. Tu rozbieramy
ksef-crypto-workerna części: jak wygląda uwierzytelnienie certyfikatem w KSeF 2.0, jak serwis zarządza sesjami i dlaczego jest tak mały, jak jest.
Problem: koniec tokenów statycznych
Od 1 stycznia 2027 Ministerstwo Finansów wyłącza logowanie do KSeF tokenem statycznym. Dostęp maszynowy wymaga od tego momentu Certyfikatu Uwierzytelniającego KSeF (Typ 1): klient buduje dokument AuthTokenRequest w XML, podpisuje go podpisem XAdES kluczem prywatnym certyfikatu i wymienia podpisany dokument na parę tokenów JWT: accessToken (ważny ok. 15 minut) i refreshToken (do 7 dni).
Naszym klientem KSeF jest n8n, a n8n nie wykona kanonikalizacji XML ani podpisu XAdES. ksef-crypto-worker to side-car w tej samej prywatnej sieci Docker, który przejmuje całą ceremonię i wystawia n8n proste REST API: „utwórz sesję”, „daj token”, „odśwież”, „odwołaj”. Zakres jest świadomie wąski i obejmuje tylko uwierzytelnianie. Wystawianie faktur, batch upload czy weryfikacja UPO pozostają po stronie n8n, już z tokenem w ręku.
Ceremonia uwierzytelnienia krok po kroku
Sercem serwisu jest KsefSdkFacade (warstwa Infrastructure), która orkiestruje pełny przepływ na oficjalnym SDK ministerstwa:
podpis XAdES kluczem certyfikatu W->>K: POST /auth/xades-signature K-->>W: referenceNumber loop co 500 ms, max 60 prób W->>K: GET /auth/{referenceNumber} end W->>K: POST /auth/token/redeem K-->>W: accessToken (~15 min) + refreshToken (do 7 dni) W-->>N: 201 Created (sessionId, accessToken, terminy)
Samego podpisu XAdES nie reimplementujemy. Wykonuje go SignatureService z oficjalnego SDK KSeF.Client (repozytorium CIRFMF/ksef-client-csharp, licencja MIT). To decyzja zapisana w ADR: od zgodności z formatem urzędowym jest kod urzędowy. SDK nie przychodzi jednak z NuGeta, tylko jest vendorowane jako klon w external/ z pinowanym commitem (tag 2.6.0). Build jest przez to powtarzalny, a aktualizacja wersji to świadoma zmiana w repo, nie niespodzianka przy restore.
Jeden fragment SDK został skopiowany i przerobiony celowo: pętla odpytywania o status uwierzytelnienia ma w oryginale zaszyte Task.Delay(1s), więc KsefSdkFacade zawiera własną wersję z wstrzykniętym TimeProvider. Dzięki temu testy przepływu są deterministyczne i nie czekają na prawdziwy zegar.
API
REST, prefiks /api/v1/auth/sessions, autoryzacja nagłówkiem X-Api-Key (poza health checkami). Błędy zwracane jako RFC 7807 ProblemDetails ze stabilnym kodem KCW-XXXX.
| Metoda | Ścieżka | Po co |
|---|---|---|
| POST | /api/v1/auth/sessions | Pełna ceremonia uwierzytelnienia, zwraca sessionId + accessToken |
| GET | /api/v1/auth/sessions/{id}/access-token | Token z cache, z leniwym odświeżeniem gdy zbliża się wygaśnięcie |
| POST | /api/v1/auth/sessions/{id}/refresh | Wymuszony refresh |
| DELETE | /api/v1/auth/sessions/{id} | Lokalne odwołanie sesji |
| GET | /health/live, /health/ready | Liveness / readiness (readiness sprawdza m.in. certyfikat) |
Sesje: pamięć, single-flight i restart jako feature
Sesje są przechowywane w InMemorySessionStore, czyli w zwykłym słowniku w pamięci procesu, bez Redisa i bez bazy. Gdy n8n prosi o token, a do wygaśnięcia zostało mniej niż skonfigurowany zapas, handler wykonuje leniwy, synchroniczny refresh pod per-sesyjnym SemaphoreSlim z double-check lockingiem. Równoległe żądania o ten sam token nie wywołają więc kilku refreshy naraz: pierwsze odświeża, reszta czeka i dostaje świeży token (wzorzec single-flight). Osobnego procesu odświeżającego w tle nie ma, bo nie jest potrzebny.
Restart kontenera kasuje wszystkie sesje i to jest zaakceptowany kompromis: n8n dostaje 404, tworzy nową sesję i po 1-3 sekundach działa dalej. Bezstanowość upraszcza przy okazji „rotację”: podmiana certyfikatu to restart kontenera z nowym sekretem.
Ciekawostka kontraktowa: odwołanie sesji po stronie KSeF jest oznaczone jako NotSupported (kod KCW-3098), bo SDK ministerstwa nie wystawia odwołania refresh-tokenu. DELETE usuwa sesję lokalnie, a realnym unieważnieniem pozostaje wygaśnięcie tokenów.
Certyfikaty i higiena kryptograficzna
FilesystemCertificateProvider ładuje certyfikat raz przy starcie, z auto-detekcją formatu PEM (certyfikat + zaszyfrowany klucz + passphrase) lub PKCS#12/PFX. Kilka decyzji wartych odnotowania:
- Klucz prywatny nie dotyka dysku poza sekretem. Na Linuksie certyfikat ładowany jest z flagami
EphemeralKeySet | MachineKeySet, więc klucz istnieje tylko w pamięci procesu i nie trafia do żadnego magazynu systemowego. Same pliki wchodzą do kontenera jako Docker secrets. - Zerowanie pamięci. Bufory z materiałem PFX i znakami passphrase są po użyciu czyszczone przez
CryptographicOperations.ZeroMemory. - Round-trip PEM przez PKCS#12. Klucz z PEM jest po załadowaniu re-eksportowany i importowany przez PKCS#12, żeby zagwarantować podpisywalny klucz na każdej platformie (Linux w produkcji, macOS/Windows w dev). Koszt płacony raz przy starcie.
- Walidacja typu certyfikatu. Worker przyjmuje wyłącznie Certyfikat Typ 1 (keyUsage
DigitalSignature, RSA-2048 lub EC P-256); certyfikat offline Typ 2 jest odrzucany z kodemKCW-3004. Zbliżające się wygaśnięcie generuje zdarzenie audytowe. - Klucz API bez porównywania stringów. Klucz od n8n jest hashowany przy starcie (SHA-256 + pepper), a porównanie per-request wykonuje
CryptographicOperations.FixedTimeEquals: stały czas, odporność na timing attack.
Do tego dochodzi redakcja danych wrażliwych w logach: tokeny nigdy nie są logowane w całości, a CN certyfikatu (może zawierać NIP) jest skracany do ostatnich trzech znaków.
Architektura i stack
.NET 10 (LTS), C# 14, ASP.NET Core Minimal API. Układ to klasyczna architektura heksagonalna z twardym kierunkiem zależności Api → Application → Domain (Infrastructure adaptuje porty Application). Domain nie zależy od niczego poza standardową biblioteką i modeluje sesję jako agregat AuthSession z maszyną stanów i zdarzeniami domenowymi; NIP, tokeny czy identyfikatory to value objects z walidacją w konstruktorze (NIP liczy sumę kontrolną mod-11, tokeny dekodują claim exp i odrzucają wygasłe). Layering nie jest tylko konwencją: pilnują go testy architektury na NetArchTest, więc próba dociągnięcia zależności w złą stronę wywala build.
Ruch do KSeF przechodzi przez pipeline Polly v8: timeout całościowy 30 s, trzy retry z jitterem, timeout per próba 10 s i circuit breaker. Pipeline jest wpięty w HttpClient, który worker współdzieli z SDK ministerstwa. Resilience obejmuje więc również wywołania, których kod workera sam nie wysyła.
Audyt załatwiają strukturalne logi Serilog (JSON na stdout) z polem eventType: ChallengeFetched, XadesSigned, TokenRedeemed, ApiKeyDenied, NipDenied i tak dalej. Zamiast dedykowanej tabeli audytowej mamy zdarzenia przeszukiwalne dowolnym narzędziem do logów.
Testy i CI
xUnit v3 z obowiązkowym wykonaniem równoległym, deterministycznym czasem (FakeTimeProvider) i zakazem Thread.Sleep. Jedyna integracja to WireMock.Net udający API KSeF: test KsefSdkFacadeFullFlowTests przepuszcza przez prawdziwe SDK cały przepływ uwierzytelnienia przeciwko zamockowanemu serwerowi. Bramka pokrycia wymaga co najmniej 80% linii dla warstw Domain+Application+Infrastructure i 70% dla Api; faktyczne pokrycie oscyluje między 91 a 98%.
CI działa podwójnie: GitHub Actions (format, build z warnaserror, testy, bramka coverage, skan podatności zależności i Trivy na systemie plików, wszystkie akcje pinowane po SHA) oraz GitLab CI, który dodatkowo buduje i wypycha obraz Dockera do rejestru GitLab. Obraz produkcyjny to chiseled Ubuntu (aspnet:10.0-noble-chiseled-extra), bez shella i menedżera pakietów, a proces działa jako non-root UID 1001.
Filozofia: świadomie małe
README zawiera tabelę „Wycięte / Dlaczego”, która dokumentuje, czego w serwisie nie ma i z jakiego powodu: bez Vaulta (sekrety Dockera wystarczają przy jednym hoście), bez Redisa (jedna instancja, sesje odtwarzalne w sekundy), bez Postgresa (audyt w logach), bez OpenTelemetry i rate-limitingu (jeden klient w zamkniętej sieci). Nawet kody błędów usuniętych funkcji są „wypalone” i nie wolno ich użyć ponownie, bo kontrakt API traktujemy jako wartość pierwszorzędną.
To podejście widać też w konfiguracji: wszystkie ustawienia wchodzą przez zmienne środowiskowe z prefiksem KSCW_ (klucz API, pepper, ścieżki certyfikatu, środowisko KSeF: Test/Demo/Production, opcjonalna lista dozwolonych NIP-ów), a błędna konfiguracja zatrzymuje start procesu zamiast objawić się w runtime.