Użycie podpisu elektronicznego w Javie

Kontytnuję opis korzystania z podpisu elektronicznego w Linuksie.

Poniższy wpis odwołuje się do przykładowej implementacji, którą umieściłem na githubie.

PKCS#11 w Java

Implementacja SunPKCS11

Java od wersji 1.5 ma wbudowaną implementację interfejsów kryptograficznych (JCE) korzystającą z PKCS#11. Jest to klasa sun.security.pkcs11.SunPKCS11 w implementacji Sun/Oracle oraz OpenJDK.

Implementacja ta jest nieco ułomna.

  • brakuje w niej obsługi szyfrowania RSAES-OAEP. Ten problem wymusił stosowanie Szafir SDK do szyfrowania kart transakcji na stronie GIIF, nawet dla transakcji bez podpisu elektronicznego).
  • nawet dostęp do publicznych obiektów na tokenie PKCS#11, wymaga zalogowania się do tokena (podania PINu).
  • KeyStore odnajduje wyłącznie te wpisy, dla których certyfikat jest przechowywany na karcie (tak konfigurowane są wszystkie obecnie wystawiane podpisy na kartach kryptograficznych, ale w HSMach nCipher i Gemalto na których pracowałem konieczne było dogranie certyfikatów do HSMa.)

Pomimo powyższych problemów wbudowana implementacja jest wystarczająca do składania podpisów.

Implementacja IAIK

Istnieje również komercyjna implementacja JCE korzystająca z PKCS#11, stworzona w Austrii.

Ta impementacja jest droga, ale jej twórcy udostępniają jako freeware wrapper dla bibliotek PKCS#11.

Wrapper jest niskopoziomy, pozwala na wywoływanie poszczególnych funkcji z PKCS#11 API. Użycie wrappera nie jest jednak przedmiotem poniższego wpisu.

BouncyCastle PKIX

Interfejsy kryptograficzne w Java, udostępniają podstawowe operacje kryptograficzne, ale nie umożliwiają tworzenia plików CAdES, czy plików zaszyfrowanych CMS Enveloped.

BouncyCastle PKIX jest biblioteką, służącą (między innymi) do tworzenia plików CMS (Crypto Message Syntax, specyfikacja budowy plików podpisanych i/lub zaszyfrowanych, na tejże specyfikacji oparto format pliku CAdES).

W przykładzie będę wykorzystywał bibliotekę w wersji org.bouncycastle:bcpkix-jdk15on:1.64.

Biblioteka udostępnia dwie wersje narzędzi do tworzenia plików CMS: zwykłą i strumieniową.

Wersja zwykła wymaga aby zarówno cały źródłowy dokument do podpisania, jak i cały wynikowy dokument podpisany znalazły się się na stosie (heap) JVM.

Wersja strumieniowa jest nieco mniej intuicyjna w konfiguracji, ale nie nakłada takiego ograniczenia.

Przegląd kodu

Zaszyta konfiguracja

Starałem się jak najbardziej uprościć kod, dlatego pliku Main.java w kodzie zaszyłem:

  • lokalizacja bibloteki PKCS#11
  • numer slotu na tokenie PKCS#11
  • PIN do tokena PKCS#11
  • numer seryjny certyfikatu do podpisywania
  • certyfikat do szyfrowania
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
public class Main {
// Wstaw lokalizację bibloteki implementującej PKCS#11
private static final String PKCS11_LIBRARY = "/home/ksm/src/securefile/szafir-plugin/src/main/binaries/linux-x64/pkcs11/cryptoCertum3PKCS-2.0.0.43.r2-MS.so";
// Wstaw numer slotu
private static final long TOKEN_SLOT = 1L;

// Tutaj możesz wstawić swój numer PIN, jeżeli pozostawisz tekst "USTAW_PIN",
// to użyty zostanie ConsoleCallbackHandler, który zapyta o PIN w terminalu.
private static final String TOKEN_PIN_STRING = "USTAW_PIN";
private static final char[] TOKEN_PIN = TOKEN_PIN_STRING.toCharArray();

// Tutaj wstaw numer Twojego certyfikatu kwalifikowanego.
private static final BigInteger CERT_SERIAL = new BigInteger("0123456789abcdef", 16);

// Certyfikat do szyfrowania
private static final String ENCRYPTION_CERTIFICATE_PEM = "-----BEGIN CERTIFICATE-----\n" +
"MIIG0DCCBLigAwIBAgIQSVemUhcVjMhEjlQA5XClTDANBgkqhkiG9w0BAQsFADCB\n" +
// [...]
"clQR0Mu3U9wOmKBhg7czoTwck1TDnTU9u+06cRebQe0rO0hKzbzQlDwc3Sn5bzFd\n" +
"xr/BJV7K4nvRkEuNoWEDiMgT0EY=\n" +
"-----END CERTIFICATE-----\n";
}

Dostęp do materiału kryptograficznego

Aby uzyskać dostęp do klucza prywatnego na karcie należy:

  • załadować provider, loadProvider
  • wczytać KeyStore, getKeyStore
  • odnaleźć alias dla certyfikatu i klucza, getAliasByCertificateSerialNumber
  • pobrać uchwyt klucza.
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
public class Main {
public static void main(String[] args) throws CertificateException, NoSuchAlgorithmException,
KeyStoreException, IOException,
UnrecoverableKeyException, CMSException,
OperatorCreationException {
Provider provider = loadProvider(PKCS11_LIBRARY, TOKEN_SLOT);
KeyStore keyStore = getKeyStore(provider);
String alias = getAliasByCertificateSerialNumber(keyStore, CERT_SERIAL);
X509Certificate certificate = (X509Certificate) keyStore.getCertificate(alias);
PrivateKey privateKey = null;
if ("USTAW_PIN".equals(TOKEN_PIN_STRING)) {
// używamy CallbackHandler
privateKey = (PrivateKey) keyStore.getKey(alias, null);
} else {
privateKey = (PrivateKey) keyStore.getKey(alias, TOKEN_PIN);
}

// [...]
}
}

Załadowanie proviedera PKCS#11

Konstruktor providera wymaga przekazania mu strumienia z plikiem konfiguracyjnym.

Plik konfiguracyjny ma następującą strukturę.

1
2
3
name = NazwaProvidera
library = /lokalizacja/biblioteki/implementującej/pkcs11.dll
slot = 0

NazwaProvidera jest wymagana przez Javę, ale do niczego jej potem nie używamy, najlepiej ograniczyć się do liter łacińskich, cyfr i podkreślnika.

slot jest zapisywany dziesiętnie, jest to ten sam numer, który pkcs11-tool pokazywało w nawiasie w zapisie szestnastkowym.

1
2
3
4
5
6
7
8
9
public class Main {
private static Provider loadProvider(String libraryFile, long slot) {
String config = String.format("name = %s%nlibrary = %s%nslot = %d%n",
"PodpisElektroniczny", libraryFile, slot);
InputStream providerParameter = new ByteArrayInputStream(config.getBytes(StandardCharsets.UTF_8));
Provider loadedProvider = new sun.security.pkcs11.SunPKCS11(providerParameter);
return loadedProvider;
}
}

Wczytanie KeyStore

KeyStore to obiekt pozwalający zajrzeć jakie certyfikaty i klucze znajdują się na karcie kryptograficznej.

Można go zainicjować na dwa sposoby.

  • przekazując wprost PIN (gałąź else)
  • przekazując obiekt typu CallbackHandler, który zapyta użytkownika o PIN (gałąź if)
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
public class Main {
private static KeyStore getKeyStore(Provider provider) throws KeyStoreException, CertificateException,
NoSuchAlgorithmException, IOException {
KeyStore keystore = null;
if ("USTAW_PIN".equals(TOKEN_PIN_STRING)) {
KeyStore.CallbackHandlerProtection chp =
new KeyStore.CallbackHandlerProtection(new ConsoleCallbackHandler());
KeyStore.Builder builder = KeyStore.Builder.newInstance("PKCS11", provider, chp);
keystore = builder.getKeyStore();
} else {
keystore = KeyStore.getInstance("PKCS11", provider);
keystore.load(null, TOKEN_PIN);
}
return keystore;
}
}

UWAGA, po trzeciem niepoprawnym podaniu PINu karta zostanie zablokowana!!!

Odnalezienie aliasu dla certyfikatu

KeyStore przypomina trochę słownik (Map), w którym aliasom typu String przypisane są obiekty typu Certificate oraz Key.

W implementacji SunPKCS11, alias jest pobierany z atrybutu CKA_LABEL. Jeżeli ten atrybut zawiera polskie znaki diakrytyczne, to alias jest nieprawidłowo dekodowany, co utrudnia pobranie aliasu wprost.

Zamiast tego przekazuję do metody numer seryjny certyfikatu, a w metodzie iteruję po wszystkich aliasach, aż znajdę certyfikat, o takim numerze seryjnym jak przekazałem.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
public class Main {
private static String getAliasByCertificateSerialNumber(KeyStore keyStore, BigInteger certSerial)
throws KeyStoreException {
Enumeration<String> aliases = keyStore.aliases();
while (aliases.hasMoreElements()) {
String alias = aliases.nextElement();
logger.log(Level.INFO, "Alias: {0}", alias);
X509Certificate cert = (X509Certificate) keyStore.getCertificate(alias);
logger.log(Level.INFO, " Certificate Serial: {0}", cert.getSerialNumber().toString(16));
if (CERT_SERIAL.equals(cert.getSerialNumber())) {
return alias;
}
}
throw new IllegalStateException("Brak certyfikatu o numerze " + certSerial.toString(16) + " w KeyStore.");
}
}

Podpisywanie

Podpisywanie wykonywane jest w trybie strumieniowym. Jest to implementacja wzorca projektowego dekorator.

Biblioteka BouncyCastle tworzy strumień (OutputStream), który konfiguruję, wskazując strumień, do którego zapisany zostanie podpisany plik, certyfikat i klucz prywatny używany do podpisu, algorytm podpisu i kilka innych szczegółów. Do tak utworzonego strumienia zapisuję dane, które chę podpisać.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
public class Main {
public static void main(String[] args) throws CertificateException, NoSuchAlgorithmException, KeyStoreException, IOException, UnrecoverableKeyException, CMSException, OperatorCreationException {
// [...]
try (OutputStream result = new FileOutputStream("/home/ksm/test.signed");
OutputStream signed =
new SigningStreamBuilder()
.setSigningCertificate(certificate)
.setSigningProvider(provider)
.setSigningKey(privateKey)
.setSignatureAlgorithm("SHA256withRSA")
.setDestination(result)
.build();
InputStream source = new FileInputStream("/home/ksm/test.plain")) {
IOUtils.copy(source, signed);
}
}
}

Konfigurowanie strumienia

Magia kryje się w metodzie SigningStreamBuilder::build. Poniższy kod opiera się głównie na przykładzie z metody testSHA1WithRSAEncapsulated w klasie NewSignedDataStreamTest.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
private static class SigningStreamBuilder {

public OutputStream build() throws IOException, CertificateEncodingException,
CMSException, OperatorCreationException,
NoSuchAlgorithmException {

final ContentSigner contentSigner =
new JcaContentSignerBuilder(signatureAlgorithm)
.setProvider(signingProvider)
.build(signingKey);

final DigestCalculatorProvider digestCalculatorProvider =
new JcaDigestCalculatorProviderBuilder()
.setProvider(signingProvider)
.build();

final SignerInfoGenerator signerInfoGenerator =
new JcaSignerInfoGeneratorBuilder(digestCalculatorProvider)
.setSignedAttributeGenerator(
buildSignedAttributeTableGenerator(
signingCertificate
)
)
.build(contentSigner, signingCertificate);

final CMSSignedDataStreamGenerator sgen =
new CMSSignedDataStreamGenerator();
sgen.addSignerInfoGenerator(signerInfoGenerator);
sgen.addCertificates(
new JcaCertStore(Collections.singleton(signingCertificate)));
return sgen.open(destination, true);
}
}

Jak widać strumień potrzebuje:

  • podpisywacza contentSigner, który złoży podpis RSA z użyciem klucza prywatnego z karty.
  • kalkulatora sumy kontrolnej digestCalculatorProvider, który wyliczy sumę kontrolną z wiadomości i dodatkowych podpisanych atrybutów.
  • generatora podpisywanej zawartości signerInfoGenerator, który połączy sumę kontrolną wiadomości, dodatkowych podpisywanych atrybutów i wywoła na niej podpisywanie.
  • listy certyfikatów do załączenia w podpisanym dokumencie.

SigningCertificateV2

Dodatkowo przy tworzeniu signerInfoGenerator pojawia się metoda buildSignedAttributeTableGenerator. Jej kod opiera się tej odpowiedzi ze StackOverflow.

Europejski podpis elektroniczny wymaga aby jednym z podpisanych atrybutów podpisu był identyfikator certyfikatu kwalifikowanego użytego do podpisu. Wymaga to zbudowania elementu ESSCertIDv2 i umieszczenia go wewnątrz SigningCertificateV2, co robi metoda constructSigningCertificateV2.

Tak przygotowany element, w metodzie buildSignedAttributeTableGenerator, opakowuje jako atrybut o OID wskazanym przez PKCSObjectIdentifiers.id_aa_signingCertificateV2 czyli 1.2.840.113549.1.9.16.2.47. Ten atrybut umieszczam w obiekcie klasy DefaultSignedAttributeTableGenerator. DefaultSignedAttributeTableGenerator łączy atrybuty przekazane przez użytkownika z domyślnymi wymaganymi atrybutami (np. signingTime).

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
private static class SigningStreamBuilder {
private CMSAttributeTableGenerator buildSignedAttributeTableGenerator(X509Certificate signingCert)
throws CertificateEncodingException, NoSuchAlgorithmException, IOException {
ASN1EncodableVector signedAttributes = new ASN1EncodableVector();
signedAttributes.add(
new Attribute(
PKCSObjectIdentifiers.id_aa_signingCertificateV2,
new DERSet(constructSigningCertificateV2(signingCert))
)
);
AttributeTable signedAttributesTable = new AttributeTable(signedAttributes);
return new DefaultSignedAttributeTableGenerator(signedAttributesTable);
}

private SigningCertificateV2 constructSigningCertificateV2(X509Certificate cert)
throws CertificateEncodingException, IOException, NoSuchAlgorithmException {
byte[] certEncoded = cert.getEncoded();
final X500Name issuerX500Name = new X509CertificateHolder(certEncoded).getIssuer();
final GeneralName generalName = new GeneralName(issuerX500Name);
final GeneralNames generalNames = new GeneralNames(generalName);
final BigInteger serialNumber = cert.getSerialNumber();
final IssuerSerial issuerSerial = new IssuerSerial(generalNames, serialNumber);
final ESSCertIDv2 essCertIDv2 =
new ESSCertIDv2(
new AlgorithmIdentifier(NISTObjectIdentifiers.id_sha256),
this.digest(certEncoded),
issuerSerial
);
return new SigningCertificateV2(essCertIDv2);
}

private byte[] digest(byte[] data) throws NoSuchAlgorithmException {
MessageDigest sha256 = MessageDigest.getInstance("SHA-256");
return sha256.digest(data);
}
}

Szyfrowanie

Szyfrowanie również wykonywane jest metodą strumieniową, i również wykorzystuje wzorzec dekoratora. 1. Otwieram strumień wyjściowy result. 2. Konfiguruję i otwieram strumień szyfrujący encrypted, zapisuje on wynik do strumienia result. 3. Konfiguruję i otwieram strumień kompresujący compressed, zapisuje on wynik do strumienia encrypted. 4. Konfiguruję i otwieram strumień podpisujący signed, zapisuje on wynik do strumienia compressed. 5. Otwieram strumień z danymi wejściowymi source. 6. Kopiuję dane ze strumienia source do signed.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
public class Main {
public static void main(String[] args) throws CertificateException, NoSuchAlgorithmException, KeyStoreException, IOException, UnrecoverableKeyException, CMSException, OperatorCreationException {
// [...]
try (OutputStream result = new FileOutputStream("/home/ksm/test.encrypted");
OutputStream encrypted =
new EncrypingStreamBuilder()
.setRecipientCertificate(toX509Certificate(ENCRYPTION_CERTIFICATE_PEM))
.setContentOID(CMSObjectIdentifiers.compressedData)
.setDestination(result)
.build();
OutputStream compressed =
new CompressingStreamBuilder()
.setContentOID(CMSObjectIdentifiers.signedData)
.setDestination(encrypted)
.build();
OutputStream signed =
new SigningStreamBuilder()
.setSigningCertificate(certificate)
.setSigningProvider(provider)
.setSigningKey(privateKey)
.setSignatureAlgorithm("SHA256withRSA")
.setDestination(compressed)
.build();
InputStream source = new FileInputStream("/home/ksm/test.plain")) {
IOUtils.copy(source, signed);
}
}
}

Konfiguracja strumienia kompresującego

Konfigurując strumień przekazuję:

  • contentOID czyli informację jakiego typu dane zostały skompresowane, domyślnie jest to CMSObjectIdentifiers.data czyli typ nieokreślony, wskazanie wprost CMSObjectIdentifiers.signedData pozwala oprogramowaniu rozszyfrowującemu na użycie parsera dla danych podpisanych CAdES bez konieczności rozpoznawania typu danych na podstawie zawartości.
  • outputCompressor jedynym dostępnym kompresorem jest ZLibCompressor
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
private static class CompressingStreamBuilder {


private ASN1ObjectIdentifier contentOID = CMSObjectIdentifiers.data;
private OutputStream destination;
private OutputCompressor outputCompressor = new ZlibCompressor();

// [...]

public OutputStream build() throws IOException {
CMSCompressedDataStreamGenerator cgen =
new CMSCompressedDataStreamGenerator();
return cgen.open(contentOID, destination, outputCompressor);
}
}

Konfiguracja strumienia szyfrującego

Szyfrowanie składa się z następujących faz:

  • Generowany jest losowy klucz symetryczny do zaszyfrowania danych.
  • Klucz symetryczny jest szyfrowany kluczem publicznym certyfikatu odbiorcy, informacja o certyfikacie odbiorcy (wystawca i numer seryjny) i zaszyfrowany klucz symetryczny są zapisywane na początku pliku zaszyfrowanego.
  • Dane zapisywane, które trafiają do strumienia szyfrującego są szyfrowane z użyciem klucza symetrycznego.

Konfigurując strumień przekazuję:

  • symmetricEncryptionAlgorithm, algorytm dla szyfrowania symetrycznego, domyślnie AES o długości klucza 256 pracujący w trybie CBC.
  • recipientCertificate, certyfikat odbiorcy, jeśli zmodyfikujemy kod, tak aby dodać kolekcję certyfikatów, to możemy stworzyć jeden plik zaszyfrowany do dwóch lub więcej odbiorców, na przykład do siebie, w ten sposób będziemy w stanie sami rozpakować dokładnie taki plik jaki został przesłany do GIIF.
  • contentOID czyli informację jakiego typu dane zostały skompresowane, domyślnie jest to CMSObjectIdentifiers.data czyli typ nieokreślony, wskazanie wprost CMSObjectIdentifiers.signedData lub CMSObjectIdentifiers.compressedData pozwala oprogramowaniu rozszyfrowującemu na użycie odpowiedniego parsera bez konieczności rozpoznawania typu danych na podstawie zawartości.
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
private static class EncrypingStreamBuilder {

private final ASN1ObjectIdentifier symmetricEncryptionAlgorithm = CMSAlgorithm.AES256_CBC;
private X509Certificate recipientCertificate;
private ASN1ObjectIdentifier contentOID = CMSObjectIdentifiers.data;
private OutputStream destination;

// [...]

public OutputStream build() throws CertificateEncodingException, CMSException, IOException {

final JceKeyTransRecipientInfoGenerator recipientInfoGenerator =
new JceKeyTransRecipientInfoGenerator(recipientCertificate);

final OutputEncryptor outputEncryptor =
new JceCMSContentEncryptorBuilder(symmetricEncryptionAlgorithm).build();

CMSEnvelopedDataStreamGenerator egen = new CMSEnvelopedDataStreamGenerator();
egen.addRecipientInfoGenerator(recipientInfoGenerator);
return egen.open(contentOID, destination, outputEncryptor);
}
}