Aby "zalogować" się do API KSeF za pomoca certyfikatu, trzeba podpisać XML z żądaniem autoryzacji podpisem XAdES.
Kilka razy widziałem w tym wątku pytania i spekulacje na temat "podpisywania kartą", więc chciałbym pokazać jeden ze sposobów wykonania takiego podpisu.
W bibliotece .NET dostarczonej przez MF do obsługi API 2.0 przygotowano do tego pomocniczy serwis ISignatureService, który implementuje jedną metodę: Sign(string xml, X509Certificate2 certyficate) (por. przykład C# w Uwierzytelnienie, sekcja 2.1, p. 2).
Tak się składa, że klasa implementująca ISignatureService (KSeF.Client.Api.Services.SignatureService) nie wymaga żadnego połączenia z serwerem KSeF. W związku z tym można ją wypróbować już teraz.
Przygotowuję na potrzeby mojej firmy "wrapper" wokół bilblioteki .NET MF. Poniżej zamieszczam jego fragment, który wykorzystuje ISignatureService do podpisania dokumentu XML:
- za pomocą mojego podpisu kwalifikowanego na karcie;
- za pomocą testowych plików z certyfikatem i kluczem prywatnym. (Zrobiłem szybko w OpenSSl taki certyfikat "self-signed", aby zasymulować obsługę certyfikatu KSeF).
Struktury danych
Dane we/wy w prezentowanych dalej metodach są przekazywane w stringach JSON (przyczyny pominę). Poniżej odpowiadające im struktury, wraz z opisem:
Kopiuj
//Struktura danych wejściowych (w JSON pierwsze litery nazw pól mają być małe):
protected class InputData
{
public required string SrcFile { get; set; } //ścieżka do pliku XML do podpisania (może być względna)
public string? CertificateFile { get; set; } //ścieżka do pliku certyfikatu (*.pem)
public string? PrivateKeyFile { get; set; } //ścieżka do pliku z kluczem prywatnym (*.pem)
public string? CertificateName { get; set; } //nazwa ("Friendly Name") certyfikatu
//w domyślnym magazynie lokalnym Windows
//aktualnie zalogowanego użytkownika
public string? DstFile { get; set; } //ścieżka, w której ma być zapisany podpisany plik XML
}
/* Uwagi:
1. Jeżeli "privateKeyFile" jest pominięty, program zakłada, że klucz prywatny znajduje się także w "certificateFile"
2. Należy podać wartość "certificateFile"[+"privateKeyFile"] LUB "certificateName". To alternatywa.
Brak jakiejkolwiek informacji o certyfikacie wywoła wyjątek.
3. Jeżeli nie podano DstFile, to podpisany XML zostanie zapisany w pliku "srcFile" (plik ulegnie zmianie).
*/
//Struktura danych wyjściowych:
protected class Results
{
public string SignedFile { get; set; } = string.Empty; //ścieżka do podpisanego pliku (tak - dla potwierdzenia)
}
//Struktura wewnętrzna, na przetworzone dane wejściowe:
protected class Params
{
public string xml = ""; //XML do podpisania (zawartość srcFile)
public X509Certificate2? certificate; //certyfikat, którym mamy podpisać plik
}
Prywatne dane klasy
Podpisywanie w moim programie jest realizowane przez trzy metody pewnej klasy, które wykorzystują poniższe pola:
Kopiuj
protected Params _input = new ();
protected Results _output = new();
Załadowanie certyfikatów (i pliku XML)
Pierwsza metoda wypełnia pola _input oraz, trochę paradoksalnie - _output,
(bo _output zawiera tylko nazwę pliku, który ma powstać):
Kopiuj
public override Task PrepareInputAsync(string data, CancellationToken stopToken)
{
var inp = JsonUtil.Deserialize<InputData>(data);
if (inp == null) throw new ArgumentException($"Cannot parse expression '{data}'", nameof(data));
_input.xml = File.ReadAllText(Program.FullPath(inp.SrcFile));
if (inp.CertificateName != null) //Certyfikat z lokalnego magazynu?
{
_input.certificate = RetrieveFromStore(inp.CertificateName); //w tym momencie może się wyświetlić okno dialogowe z PIN-em
if (_input.certificate == null)
throw new KeyNotFoundException( $"Did not found any valid certificate with Friendly Name = '{inp.CertificateName}' " +
$"in the local store of the current user ({Environment.UserName})");
}
else //Certyfikat z pliku
{
if (inp.CertificateFile == null) throw new ArgumentException($"Missing certificate reference in '{data}'",
"certificateFile");
if (inp.PrivateKeyFile == null) //w takim przypadku klucz prywatny powinien być w pliku certyfikatu
_input.certificate = X509Certificate2.CreateFromPemFile(Program.FullPath(inp.CertificateFile));
else //OK, wskazano oddzielne pliki certyfikatu i klucza prywatnego:
_input.certificate = X509Certificate2.CreateFromPemFile(Program.FullPath(inp.CertificateFile),
Program.FullPath(inp.PrivateKeyFile));
}
_output.SignedFile = Program.FullPath(inp.DstFile??inp.SrcFile);
return Task.CompletedTask;
}
Nie zwracajcie uwagi na zwracany Task i "Async" w nazwie tej metody: to wymogi interfejsu, któy implementuje.
Przy okazji używam pomocniczą klasę z biblioteki MF: JsonUtil. (To taki wrapper wokół standardowego JsonSerilizer .NET, z poustawianymi różnymi opcjami).
Statyczna metoda Program.FullPath() to drobiazg, który zamienia ewentualne ścieżki względne na bezwzględne (katalogiem "root" jest położenie programu, stąd klasa Program)
Przykładowe dane, przekazywane tej metodzie:
- gdy certyfikat i klucz są w plikach .pem:
Kopiuj
{
"srcFile" : "../Request.xml",
"certificateFile" : "../certificate.pem",
"privateKeyFile" : "../privatekey.pem",
"dstFile" : "../RequestSigned.xml"
}
- gdy podpisuję za pomoca mojego certyfikatu kwalifikowanego (z karty), podaję "Friendly Name" certyfikatu umieszczonego w Windows Store:
Kopiuj
{
"srcFile" : "../Request.xml",
"certificateName" : "Witold Jaworski",
"dstFile" : "../RequestSigned.xml"
}
Pomocnicza funkcja, pobierająca certyfikat o podanej nazwie z Windows Store (aktualnego użytkownika) wygląda tak:
Kopiuj
//Pomocnicza funkcja, odczytująca certyfikat z lokalnego magazynu Windows (aktualnego użytkownika)
//Argumenty:
// friendlyName: "nazwa potoczna", pod którą certyfikat figuruje na liście magazynu
//UWAGA: sprawdza także terminy ważności certyfikatów, i wybiera tylko spośród aktualnie obowiązujących.
private static X509Certificate2? RetrieveFromStore(string friendlyName)
{
var store = new X509Store(StoreLocation.CurrentUser);
store.Open(OpenFlags.ReadOnly);
var certificates = store.Certificates;
foreach (var certificate in certificates)
{
if (certificate.FriendlyName == friendlyName
&& certificate.NotAfter > DateTime.Now && certificate.NotBefore < DateTime.Now)
{
return certificate;
}
}
return null;
}
W momencie pobrania certyfikatu z karty system wyświetla okno dialogowe sterownika karty i prosi o wpisanie PIN. (U każdego dostawcy podpisów jest to inne okno).
Podpisanie XML
Poniższa procedura podpisuje XML podpisem XAdES:
Kopiuj
//Podpisanie pliku za pomocą pobranego certyfikatu
public async override Task ProcessAsync(CancellationToken stopToken)
{
var signatureService = _services?.GetRequiredService(typeof(ISignatureService)) as ISignatureService;
Debug.Assert(signatureService != null, $"Missing service: {typeof(ISignatureService)}");
string signedXML = await signatureService.Sign(_input.xml, _input.certificate);
if (signedXML != null) File.WriteAllText(_output.SignedFile, signedXML);
return;
}
Mój program wykorzystuje (zgodnie z sugestią twórców biblioteki) .NET Generic Host. Pole _services to przekazany (w konstruktorze klasy) interfejs IServiceCollection związany z zakresem (scope) utworzonym do obsługi żądania podpisania dokumentu XML. Stąd dwie pierwsze linie są tak złożone.
W bardziej klasycznym programie wystarczyłoby zastąpić dwie górne linie tej metody prostym stworzeniem nowego obiektu klasy SignatureService:
Kopiuj
var signatureService = new SignatureService();
(Tak te "serwisy" wykorzystuje np. towarzyszący bibliotece KSeF.Client projekt KSeF.Client.Tests)
Weryfikacja i zwrócenie rezultatu
Dla porządku, zanim zwrócę rezultat, staram się ten podpis zweryfikować:
Kopiuj
public override string SerializeResults()
{
//Na wszelki wypadek, sprawdźmy to, co powstało:
if (Verify(_output.SignedFile)) //niezależna weryfikacja podpisu, m.in. liczy ponownie hash (digest) podpisanego XML-a
return JsonUtil.Serialize<Results>(_output);
else throw new InvalidDataException($"Invalid signature in '{_output.SignedFile}'"); //umieszczam te throw dla porządku,
//bo Verify zgłasza wyjątki gdy coś jest nie tak.
}
Funkcja Verify() jest przepisana z przykładu Microsoft:
Kopiuj
//Na wszelki wypadek: niezależnie sprawdzenie poprawności podpisu
//Argument:
// filePath: nazwa pliku XML do sprawdzenia
public static bool Verify(string filePath)
{
try
{
XmlDocument xmlDocument = new()
{
PreserveWhitespace = true //bez tego ustawienia programowi wyjdzie inny hash pliku (digest)
};
xmlDocument.Load(filePath);
if (xmlDocument.DocumentElement == null) throw new InvalidOperationException($"Cannot load XML data from the source file");
SignedXml signedXml = new(xmlDocument);
XmlNodeList nodeList = xmlDocument.GetElementsByTagName("Signature"); //Spróbuj znaleźć element <Signature>
if (nodeList.Count <= 0) throw new CryptographicException("Cannot find 'Signature' element in this XML file.");
//Wątpię, by kiedykolwiek to się zdarzyło, ale na wszelki wypadek:
if (nodeList[0] is not XmlElement signature) throw new CryptographicException($"'Signature' element is empty");
signedXml.LoadXml(signature); // informacja towarzysząca: zawartość węzła <Signature>
// Sprawdź poprawność (może zgłosić wyjątek)
return signedXml.CheckSignature(); //Sprawdza, m.in liczy ponownie hash (digest) badanego pliku XML
}
catch (Exception exc)
{
throw new Exception($"Signature applied to '{filePath}' is invalid", exc);
}
}
Nazwę pliku wynikowego określiłem w parametrach wejściowych, więc ta procedura zwraca ją dla potwierdzenia, że nie wystąpił żaden wyjątek (równie dobrze mogłaby zwracać jakiś kod "OK"):
Kopiuj
{"signedFile":"C:\\Users\\Hyperbook\\source\\repos\\KSeF-API\\KSeF.Services\\bin\\Debug\\RequestSigned.xml"}
Uwagi końcowe
Używam podpisu kwalifikowanego Certum, ale sądzę, że przedstawiona metoda pobierania certyfikatu z magazynu (RetrieveFromStore()) jest uniwersalna.
Załączam zip z plikiem XML Request: przed i po podpisie, oraz skrypt generate.bat, którego użyłem do stworzenia pliku certyfikatu i jego klucza prywatnego. XadesSignExample.zip