Niedawno w pracy zetknąłem się z koniecznością podpisywania cyfrowego komunikatów przesyłanych przez jedną z naszych aplikacji. Akceptowanym przez odbiorcę formatem podpisu jest XAdES-BES. Po dłuższym przeszukiwaniu Google’a sprawa nie wyglądała dobrze – nie ma żadnej gotowego rozwiązania dla PHP umożliwiającego złożenie takiego podpisu. Co najgorsze, nie mieliśmy pojęcia o sposobie „ręcznego” tworzenia takiego podpisu, a kilkudziesięciostronicowa specyfikacja bez choćby jednego przykładu nie zachęcała do lektury. W sieci nie było też ani jednego snippeta, na którym można by się oprzeć.
tl;dr
Co znajdziesz w tym wpisie?
- opis sposobu wygenerowania podpisu w formacie XAdES-BES enveloping (w którym do podpisu dołączana jest podpisywana treść) na konkretnym przykładzie, krok po kroku,
- przykłady na realizację poszczególnych kroków w języku PHP,
- wybór przydatnych linków.
Czego nie znajdziesz w tym wpisie?
- gotowej implementacji generowania podpisu w PHP,
- opisu generowania podpisu enveloped (takiego, który dołączany jest do dokumentu).
Temat nie rokował zbyt dobrze, a czas gonił. Po głębszym zbadaniu temat wydawał się ciekawy, więc postanowiłem przebić się przez oficjalną specyfikację. Dokumentu jednak nie polecam – jak się później okazało, była to najmniej przydatna dla mnie lektura.
Co ciekawe, dużo poukładał mi w głowie lakoniczny artykuł na Wikipedii, który w zasadzie w jednym zdaniu opisywał XAdES:
XAdES jest rozwinięciem XML-DSig, wypełnia pole ds:Object, powiązany jest z ds:SignedInfo za pomocą elementu ds:Reference wskazującego na SignedProperties.
Skierowałem się więc w stronę XML-DSig. Tutaj już z materiałami było znacznie lepiej, a wiedząc o tym, że XAdES to w zasadzie XML-DSig z dodatkowymi informacjami, mogłem przystąpić do własnych prób podpisania czegokolwiek, na próbę.
Nieocenioną pomocą był ten opis, który zawierał strukturę przykładowego podpisu wraz ze sposobem wyliczania wartości digest. Dokładnie przeanalizowałem ten dokument i udało mi się (po dłuższych walkach) odtworzyć identyczne wartości. Potem sam podpisałem dokument za pomocą aplikacji Szafir Krajowej Izby Rozliczeniowej (głównie przez to, że jest używana przez odbiorcę do weryfikacji oraz ze względu na istnienie wersji pod Linuksa).
Podpisywana treść
Będziemy podpisywać następującego XML-a:
<?xml version="1.0" encoding="utf-8" ?> <test> <test>testowy węzeł</test> <test>drugi testowy węzeł</test> </test>
Sama treść nie jest jednak ważna, ponieważ całość zakodujemy i tak w BASE64.
Struktura podpisu
Aby nie przedłużać, tak wygląda podpisana wyżej treść po sformatowaniu:
<?xml version="1.0" encoding="UTF-8"?> <Signatures Id="ID-36e3801c-b360-4958-86e5-0b82cbd2d366"> <ds:Signature xmlns:ds="http://www.w3.org/2000/09/xmldsig#" Id="ID-88c32693-8f48-4cc8-9764-d10f0fea51b5"> <ds:SignedInfo Id="ID-4c61b0d9-e5da-4eea-97a1-623b9e4fa2b2"> <ds:CanonicalizationMethod Algorithm="http://www.w3.org/TR/2001/REC-xml-c14n-20010315" /> <ds:SignatureMethod Algorithm="http://www.w3.org/2000/09/xmldsig#rsa-sha1" /> <ds:Reference Id="ID-888d7eba-e17b-4f0c-8d5b-efa29359ebd7" URI="#ID-78a661eb-d6b2-4776-9b06-3d9e0afadab6"> <ds:DigestMethod Algorithm="http://www.w3.org/2000/09/xmldsig#sha1" /> <ds:DigestValue>MLaZQl/toVO57dJsz1JfLIyrrd4=</ds:DigestValue> </ds:Reference> <ds:Reference Id="ID-ad71d14c-447c-44a3-a00c-2e902bb73a03" URI="#ID-9542235a-b703-477a-8702-8999387d943e" Type="http://uri.etsi.org/01903#SignedProperties"> <ds:DigestMethod Algorithm="http://www.w3.org/2000/09/xmldsig#sha1" /> <ds:DigestValue>R2SnnM9tlyt4HZtC2qGWKB2XIUY=</ds:DigestValue> </ds:Reference> </ds:SignedInfo> <ds:SignatureValue Id="ID-c83aa77f-c39b-45ba-9368-25ba362bc57a">Td43...</ds:SignatureValue> <ds:KeyInfo> <ds:KeyValue> <ds:RSAKeyValue> <ds:Modulus>...</ds:Modulus> <ds:Exponent>...</ds:Exponent> </ds:RSAKeyValue> </ds:KeyValue> <ds:X509Data> <ds:X509Certificate>...</ds:X509Certificate> </ds:X509Data> </ds:KeyInfo> <ds:Object> <xades:QualifyingProperties xmlns:xades="http://uri.etsi.org/01903/v1.3.2#" Id="ID-e42ab543-3f7f-4473-9dba-6cb105b5ce27" Target="#ID-88c32693-8f48-4cc8-9764-d10f0fea51b5"> <xades:SignedProperties Id="ID-9542235a-b703-477a-8702-8999387d943e"> <xades:SignedSignatureProperties> <xades:SigningTime>2016-07-29T19:03:50Z</xades:SigningTime> <xades:SigningCertificate> <xades:Cert> <xades:CertDigest> <ds:DigestMethod Algorithm="http://www.w3.org/2000/09/xmldsig#sha1" /> <ds:DigestValue>...</ds:DigestValue> </xades:CertDigest> <xades:IssuerSerial> <ds:X509IssuerName>...</ds:X509IssuerName> <ds:X509SerialNumber>...</ds:X509SerialNumber> </xades:IssuerSerial> </xades:Cert> </xades:SigningCertificate> </xades:SignedSignatureProperties> <xades:SignedDataObjectProperties> <xades:DataObjectFormat ObjectReference="#ID-888d7eba-e17b-4f0c-8d5b-efa29359ebd7"> <xades:Description>Dokument w formacie xml [XML]</xades:Description> <xades:MimeType>text/plain</xades:MimeType> <xades:Encoding>http://www.w3.org/2000/09/xmldsig#base64</xades:Encoding> </xades:DataObjectFormat> </xades:SignedDataObjectProperties> </xades:SignedProperties> </xades:QualifyingProperties> </ds:Object> <ds:Object Encoding="http://www.w3.org/2000/09/xmldsig#base64" Id="ID-78a661eb-d6b2-4776-9b06-3d9e0afadab6" MimeType="text/plain">PD94bWwgdmVyc2lvbj0iMS4wIiBlbmNvZGluZz0idXRmLTgiID8+Cjx0ZXN0PgogIDx0ZXN0PnRlc3Rv d3kgd8SZemXFgjwvdGVzdD4KICA8dGVzdD5kcnVnaSB0ZXN0b3d5IHfEmXplxYI8L3Rlc3Q+CjwvdGVz dD4=</ds:Object> </ds:Signature> </Signatures>
Pozwoliłem sobie wyciąć dane o certyfikacie i wartości innych węzłów, których nie chcę upubliczniać.
Treść dokumentu w podpisie
Treść zawiera się w węźle:
<ds:Object Encoding="http://www.w3.org/2000/09/xmldsig#base64" Id="ID-78a661eb-d6b2-4776-9b06-3d9e0afadab6" MimeType="text/plain">PD94bWwgdmVyc2lvbj0iMS4wIiBlbmNvZGluZz0idXRmLTgiID8+Cjx0ZXN0PgogIDx0ZXN0PnRlc3Rv d3kgd8SZemXFgjwvdGVzdD4KICA8dGVzdD5kcnVnaSB0ZXN0b3d5IHfEmXplxYI8L3Rlc3Q+CjwvdGVz dD4=</ds:Object>
Zgodnie z atrybutami, jest zakodowana przy użyciu base64 – możesz sprawdzić, czy powyższa wartość jest zgodna z wynikiem funkcji base64_encode() z PHP.
Węzeł posiada unikalne ID – nie musi być akurat w takim formacie, jak powyżej, jednak zapisz sobie gdzieś to ID – będzie potrzebne do odwołań podpisu.
Pierwsze odwołanie w SignedInfo
Pierwsze odwołanie w SignedInfo dotyczy właśnie opisanego wyżej węzła z treścią i wygląda następująco:
<ds:Reference Id="ID-888d7eba-e17b-4f0c-8d5b-efa29359ebd7" URI="#ID-78a661eb-d6b2-4776-9b06-3d9e0afadab6"> <ds:DigestMethod Algorithm="http://www.w3.org/2000/09/xmldsig#sha1" /> <ds:DigestValue>MLaZQl/toVO57dJsz1JfLIyrrd4=</ds:DigestValue> </ds:Reference>
Atrybut „URI” wskazuje właśnie na ID węzła z treścią, który kazałem Ci zapisać. Odwołanie zawiera dwa węzły: DigestMethod oraz DigestValue. Wartość DigestValue jest obliczana przy pomocy funkcji skrótu określonej w DigestMethod, czyli w tym przypadku SHA1.
Zanim jednak zaczniesz liczyć skrót z treści, musisz zwrócić na węzeł znajdujący się trochę wyżej:
<ds:CanonicalizationMethod Algorithm="http://www.w3.org/TR/2001/REC-xml-c14n-20010315" />
Mówi on o tym, że musisz doprowadzić węzeł do formy kanonicznej, w tym przypadku za pomocą C14N.
Jak to zrobić? W PHP DOMDocument posiada do tego gotową metodę. Wystarczy, że wczytasz treść gałęzi i użyjesz metody C14N() :
$dom = new DOMDocument(); $dom->loadXML($content); $canonicalizedXml = $dom->C14N();
Taką treść możemy już poddać funkcji skrótu – w PHP sha1(), po czym należy zakodować wynik w BASE64. Jeżeli jednak zrobisz to w ten sposób, nie otrzymasz prawidłowego wyniku:
$hash = sha1($canonicalizedXml); $digestValue = base64_encode($hash);
W BASE64 musimy zakodować binarną wartość skrótu, więc musisz podać true jako drugi argument funkcji sha1() :
$hash = sha1($canonicalizedXml, true); $digestValue = base64_encode($hash);
Wartość w $digestValue w moim przypadku powinna być zgodna z tą w treści XML:
MLaZQl/toVO57dJsz1JfLIyrrd4=
DigestValue pierwszego odwołania za nami!
Drugie odwołanie w SignedInfo
Drugie odwołanie w SignedInfo wskazuje na węzeł SignedProperties.
Jako, iż mamy wyliczyć skrót, zakładamy, że na tym etapie mamy już wypełnioną całą treść tego węzła. Jak ją wypełnić pokażę później.
I tutaj ważna uwaga – ze względu na to, że w treści SignedProperties występują elementy zarówno z węzła z namespace xades, jak i ds, to nie możemy po prostu wyciąć go tak, jak przy poprzednim odwołaniu. Musimy wybrać przy pomocy DOMDocument tę konkretną wartość i przekształcić na formę kanoniczną tak, aby metoda C14N() miała dostęp do kontekstu XML-a i mogła na tej podstawie wstawić w SignedProperties dwa atrybuty xmlns. Powinno wyglądać to mniej więcej tak:
$dom = new DOMDocument(); $dom->loadXML($fullContent); $node = $dom->getElementsByTagName('SignedProperties')->item(0)->C14N();
Z takiej formy węzła SignedProperties znów liczymy skrót przy pomocy SHA1 (bo DigestMethod ma taką samą wartość, jak w poprzednim odwołaniu):
$hash = sha1($node, true); $digestValue = base64_encode($hash);
W $digestValue powinienem otrzymać (dla danych mojego certyfikatu, itp.):
R2SnnM9tlyt4HZtC2qGWKB2XIUY=
Kolejne DigestValue za nami.
Właściwy podpis
Mając SignedInfo z wyliczonymi wartościami digest, możemy przejść do liczenia właściwego podpisu.
Metoda jego liczenia jest określona wewnątrz SignedInfo poprzez:
<ds:SignatureMethod Algorithm="http://www.w3.org/2000/09/xmldsig#rsa-sha1" />
W tym wypadku nie użyjemy już zwykłego SHA1, jednak jest to najprostsza część wyliczeń – w PHP możemy skorzystać z funkcji openssl_sign() :
$privateKey = openssl_pkey_get_private('file://path'); $actualDigest = null; openssl_sign( $signedInfo, $actualDigest, $privateKey, 'sha1WithRSAEncryption' ); $actualDigestEncoded = base64_encode($actualDigest);
Kilka uwag:
- otwieramy plik w formacie PEM zawierający klucz prywatny, którym podpiszemy zawartość SignedInfo (pamiętaj o „file://” na początku, zaś przy podawaniu ścieżki na uniksach o tym, że powinny tam być trzy slashe, np.: „file:///var/cert.pem”),
- do $actualDigest zostanie zwrócona binarny wynik szyfrowanej funkcji skrótu,
- w $signedInfo podajemy formę kanoniczną węzła SignedInfo.
Na tym kończy się generowanie podpisu cyfrowego XAdES-BES. Z racji tego, że nie przedstawiłem wcześniej szczegółów wypełniania wartości dot. certyfikatu, informacje na ten temat zostawiam na koniec.
Retrospekcja – dane certyfikatu
We wpisie chciałem skoncentrować się na sposobie wyliczania wartości digest dla XAdES-BES, dlatego przedstawiłem te informacje w pierwszej kolejności. Jeżeli jednak masz problem z uzyskaniem danych certyfikatu występujących w pozostałych węzłach, to ta część może być dla Ciebie przydatna.
- DigestValue w CertDigest
Wartość liczymy z treści certyfikatu w PEM po wycięciu markerów:
$certResource = openssl_x509_read('file://path'); $certPem = null; openssl_x509_export($certResource, $certPem); $certContent = str_replace('-----BEGIN CERTIFICATE-----', '', $certPem); $certContent = trim(str_replace('-----END CERTIFICATE-----', '', $certContent)); $certFingerprint = base64_encode(sha1(base64_decode($certContent), true));
- X509Certificate
Wartość mamy w zmiennej $certContent z poprzedniego punktu.
- Modulus i Exponent z RSAKeyValue
$publicKey = openssl_pkey_get_public('file://path'); $data = openssl_pkey_get_details($publicKey); $modulusHex = '00' . bin2hex($data['rsa']['n']); $modulusEncoded = base64_encode(hex2bin($modulusHex)); $exponentEncoded = base64_encode($data['rsa']['e']);
Zauważyłem, że zwrócone $data[’rsa’][’n’] nie zawiera bajtu zerowego na początku, dlatego dopisałem go ręcznie. Nie udało mi się jednak ustalić, z czego to wynika – czy z używanej przez nas wersji PHP, z jakiegoś błędu, czy po prostu tak ma być, dlatego porównaj wyliczone wartości z wyjściem openssl:
openssl rsa -noout -text -in [public_key_filename]
- X509SerialNumber
$certData = openssl_x509_parse($certResource); $serialNumber = $certData['serialNumber'];
- IssuerName
$issuerArray = array(); foreach($certData['issuer'] as $issueKey => $issue) { $issuerArray[] = $issueKey . '=' . $issue; } $issuer = implode(',', $issuerArray);
Podsumowanie
Mam nadzieję, że powyższe informacje przydadzą się chociaż częściowo osobom, które zupełnie nie znają formatu XAdES-BES, a muszą wprowadzić go w swoich aplikacjach.
Pamiętaj, że podane w treści wpisu fragmenty kodu mają na celu jedynie zobrazowanie sposobu uzyskiwania celu, zaś produkcyjny kod powinien brać pod uwagę wszelkie możliwe błędy, które mogą wystąpić podczas całego procesu.
Jeżeli coś jest niejasne lub o czymś zapomniałem – daj znać w komentarzu, postaram się to poprawić.