Sein eigenes Sicherheitssystem zu basteln, birgt die Gefahr potenzieller Schwächen, die Hacker aufspüren können. Beispielsweise werden Passwörter als Klartext gespeichert oder versendet, die Leitung zwischen Client und Server ist nicht geschützt oder die Entwickler haben gar einen eigenen Verschlüsselungsalgorithmus beziehungsweise ihr eigenes Session-Management entwickelt.
Falls ihr keine IT-Sicherheitsexperten seid, solltet ihr daher lieber auf Altbewährtes setzen. Die Sicherheitsarchitektur von Java, genannt Java SE, ist eine gute Wahl [4]. Sie beinhaltet eine große Zahl an Schnittstellen, Tools und Unterstützung für häufig genutzte Sicherheitsalgorithmen, -mechanismen und -protokolle. Mit der Java-Sicherheitsarchitektur könnt ihr folgende Aufgaben realisieren:
-
Kryptografie
-
Öffentliche Schlüssel generieren
-
Sichere Kommunikation
-
Authentifizierung
-
Zugriffskontrolle
Reduce your cyber security risk
New format in munich for the first time: 5 tracks | More than 20 sessions & workshops | Current topics in IT security | December 2 – 5, 2024 | Munich or online
Java SE bringt zudem Erweiterungen sowie Dienste mit, die beim Entwickeln einer sicheren Anwendung unterstützen. Hierzu zählen insbesondere:
-
JSSE (Java Secure Socket Extension): stellt SSL-bezogene Dienste zur Verfügung
-
JCE (Java Cryptography Extension): kryptografische Dienste
-
JAAS (Java Authentication and Authorization Service): Dienste, die sich mit Zugangskontrollen und Authentifizierung beschäftigen
Um eine Anwendung auf sicherheitsbezogene Ereignisse hin zu untersuchen, übergebt ihr dem Java-Befehl das folgende Argument:
-Djava.security.debug
Authentifizierung
Derzeit stehen drei Module für die Authentifizierung unter Java zur Verfügung:
-
Krb5LoginModule: Authentifizierung mittels Kerberos-Protokoll
-
JndiLoginModule: Anmeldung mit Benutzername und Passwort, indem LDAP- oder NIS- Datenbanken zum Einsatz kommen
-
KeyStoreLoginModule: Anmeldung am PKCS-#11-Schlüsselspeicher
Falls ihr euch für das geläufige Verfahren mit Benutzername und Passwort entscheidet, um die Anmeldung der Benutzer zu sichern, dann benutzt am besten das JndiLoginModule. Hierbei könnt ihr auf die Klasse LoginContext zurückgreifen, die ihr wie folgt initialisiert [5]:
LoginContext loginContext = new LoginContext("Sample", new SampleCallbackHandler());
loginContext.login();
Wichtig ist, dass ihr dem Log-in-Handler einen Konfigurationsnamen sowie den CallbackHandler übergebt. Der CallbackHandler legt fest, ob die Eingabeaufforderungen für den Benutzernamen und das Kennwort nacheinander erscheinen oder beides in einem einzigen Fenster eingeblendet wird [6]. In der Konfigurationsdatei jaas.conf gebt ihr dann das erforderliche Log-in-Modul ein, mit dem eure Anwendung die Authentifizierung vornimmt. In der Regel lässt sich das Log-in-Modul mit nur wenigen Zeilen konfigurieren:
Sample {
com.sun.security.auth.module.JndiLoginModule required;
};
Beim Starten der Java-Anwendung sind weitere Argumente erforderlich. So müssen der SecurityManager ex-tra aufgerufen und der Pfad zur Konfigurationsdatei jaas.conf angegeben werden:
java -Djava.security.manager -Djava.security.auth.login.config=/Pfad/zu/jaas.conf
Wem das nicht ausreicht, der kann auf weitere vordefinierte Log-in-Module [7] der Software AG zurückgreifen [8]. Zudem existieren Bibliotheken, die eine Implementierung mit geringerer Komplexität erlauben. So basiert etwa Apache Shiro auf der Java Security, vereinfacht jedoch die Konfiguration sowie Umsetzung von Authentifizierung, Autorisierung, Kryptografie und Benutzermanagement [9].
Zugriffskontrolle
Aber auch mit der Java Security kann eine Autorisierung für Benutzer implementiert werden. Hierbei ist eine Konfigurationsdatei mit der Endung .policy erforderlich, die sich mit policytool aus der JDK-Umgebung erstellen lässt (Abb. 1). Laut der Dokumentation des Java Security API läuft policytool zwar demnächst ab, doch gibt es derzeit keinen guten Ersatz dafür. policytool verfügt über eine grafische Oberfläche und lässt sich durch Eingabe des Befehls policytool auf der Konsole starten. Per Klick auf Policy-Eintrag hinzufügen öffnet sich ein neues Fenster, in dem sich unter anderem eine Berechtigung definieren lässt. Eine neue Berechtigung erzeugt man per Klick auf Berechtigung hinzufügen. Im Fenster Berechtigungen wird zunächst die Art der Berechtigung ausgewählt, in unserem Beispiel handelt es sich dabei um eine FilePermission, also Dateiberechtigung. Sie lässt sich auf eine bestimmte Datei oder auf alle Dateien unter Zielname anwenden. Bei Aktionen können zulässige Dateirechte wie lesen oder schreiben ausgewählt werden. Das Feld signiert von kann leer sein.
Nachdem alle Einstellungen vorgenommen wurden, kann die .policy-Datei mit Datei | Speichern abgespeichert werden:
/* AUTOMATICALLY GENERATED ON Mon Sep 12 18:46:47 CEST 2022*/
/* DO NOT EDIT */
grant {
permission java.io.FilePermission "<<ALL FILES>>", "read,write";
};
Die konkrete Umsetzung der Zusatzkontrolle mit Java-Bordmitteln lässt sich bewerkstelligen wie in Listing 1 gezeigt. Wichtig ist, dem Java-Befehl beim Starten des Java-Programms folgende Argumente zu übergeben:
-Djava.security.manager -Djava.security.policy=/Pfad/zu/Datei.policy
Um die Ressourcen vom SecurityManager beschützen zu lassen, muss dieser zunächst initialisiert werden. Anschließend könnt ihr mittels der Methode securityManager.checkPermission überprüfen, ob beispielsweise ein Benutzer eine Datei löschen darf.
Listing 1
SecurityManager SecurityManager = System.getSecurityManager();
String path = "files/test.txt";
try {
if (securityManager != null) {
securityManager.checkPermission(new FilePermission(path, "delete"));
System.out.println("it seems as if you're allowed to delete it");
}
} catch (AccessControlException e) {
System.out.println("Not allowed to perform this action: "+e.getMessage());
}
Geheimschlüssel
Werden Nachrichten, Benutzeranmeldedaten und weitere sensible Daten unverschlüsselt über das Netzwerk verschickt, haben Hacker leichtes Spiel. Die Kryptografie wird in Java daher sehr ernst genommen. Speziell das Framework Java Cryptography Architecture (JCA) wurde daher in die JDK- beziehungsweise JRE-Umgebung integriert [10]. Das JCA-Framework kann nützlich sein, wenn ihr euch unter anderem mit den folgenden Themen beschäftigen wollt:
-
digitale Signaturen
-
Message Digests (MD): kryptografische Hashfunktion [11]
-
Zertifikate sowie Zertifikatsvalidierung
-
Verschlüsselung (symmetrische/asymmetrische Block- und Stromchiffren)
-
Generierung sowie Verwaltung von Schlüsseln
-
sichere Zufallszahlengenerierung
Verschlüsselungsalgorithmen, die die Vertraulichkeit von Daten schützen, können entweder symmetrischer oder asymmetrischer Natur sein. So handelt es sich bei Geheimschlüsseln (Secret Keys) um symmetrische Algorithmen, da derselbe Schlüssel sowohl zur Verschlüsselung als auch zur Entschlüsselung benutzt wird. Anders verhält es sich bei asymmetrischen Algorithmen, bei denen unterschiedliche Schlüssel für die Verschlüsselung und die Entschlüsselung zum Einsatz kommen [1].
Ein Beispiel für symmetrische Algorithmen stellt der „Data Encryption Standard“ (DES) dar. Obwohl DES bereits in den 1970ern entwickelt und mittlerweile von diversen fortgeschrittenen Standards überholt wurde, ist er immer noch in Gebrauch. DES kommt vor allem im kommerziellen sowie im Finanzsektor zum Einsatz. Die eigentliche Schlüsselgröße von DES beträgt zwar 56 Bit, allerdings hat sich heutzutage vor allem die Option Triple DES mit einer Schlüsselgröße von 3 x 56 Bit durchgesetzt [1].
Listing 2
import java.security.spec.KeySpec;
import javax.crypto.Cipher;
import javax.crypto.SecretKey;
import javax.crypto.SecretKeyFactory;
import javax.crypto.spec.DESedeKeySpec;
import org.apache.commons.codec.binary.Base64;
public class TrippleDes {
private static final String UNICODE_FORMAT = "UTF8";
public static final String DESEDE_ENCRYPTION_SCHEME = "DESede";
private KeySpec ks;
private SecretKeyFactory skf;
private Cipher cipher;
byte[] arrayBytes;
private String myEncryptionKey;
private String myEncryptionScheme;
SecretKey key;
public TrippleDes(String sKey) throws Exception {
myEncryptionKey = sKey;
myEncryptionScheme = DESEDE_ENCRYPTION_SCHEME;
arrayBytes = myEncryptionKey.getBytes(UNICODE_FORMAT);
ks = new DESedeKeySpec(arrayBytes);
skf = SecretKeyFactory.getInstance(myEncryptionScheme);
cipher = Cipher.getInstance(myEncryptionScheme);
key = skf.generateSecret(ks);
}
public String encrypt(String unencryptedString) {
String encryptedString = null;
try {
cipher.init(Cipher.ENCRYPT_MODE, key);
byte[] plainText = unencryptedString.getBytes(UNICODE_FORMAT);
byte[] encryptedText = cipher.doFinal(plainText);
encryptedString = new String(Base64.encodeBase64(encryptedText));
} catch (Exception e) {
e.printStackTrace();
}
return encryptedString;
}
public String decrypt(String encryptedString) {
String decryptedText=null;
try {
cipher.init(Cipher.DECRYPT_MODE, key);
byte[] encryptedText = Base64.decodeBase64(encryptedString);
byte[] plainText = cipher.doFinal(encryptedText);
decryptedText= new String(plainText);
} catch (Exception e) {
e.printStackTrace();
}
return decryptedText;
}
}
Unter Java könnt ihr Triple DES implementieren wie in Listing 2 dargestellt [12]. Triple DES wird im Konstruktor eingerichtet, dem der Geheimschlüssel als Argument im Klartext übergeben wird. Im weiteren Verlauf der Einrichtung kommt die SecretKeyFactory zum Einsatz, um die Byterepräsentation des Geheimschlüssels in ein SecretKey-Objekt umzuwandeln. Anhand der Implementierung der encrypt– und decrypt-Methoden lässt sich gut erkennen, dass die Entschlüsselung eine Umkehrfunktion der Verschlüsselung ist. Anschließend könnt ihr einen String wie folgt ver- und entschlüsseln:
TrippleDes triple = new TrippleDes("Caesarsalad!Caesarsalad!");
String encPw = triple.encrypt("password123");
String decPw = triple.decrypt(encPw);
Abhörsichere Leitung
Vertrauliche Kommunikation über einen Kanal wie das öffentliche Internet erfordert die Verschlüsselung von Daten. Aus diesem Grund ist es absolut notwendig, das Verschlüsselungsprotokoll Transport Layer Security (TLS), ehemals bekannt als Secure Socket Layer (SSL), einzusetzen. Andernfalls könnte ein Hacker in Versuchung geraten, einen Packet Sniffer einzusetzen, der den Netzwerkverkehr analysiert. Beispielsweise lassen sich sensible Daten wie Kreditkartennummern vor dem Versand unverschlüsselt speichern. Die einfachste Lösung, um weder den Server noch den Client mit einem zu Fehlern neigenden Verschlüsselungscode zu überladen, besteht darin, Secure Sockets einzusetzen.
Als Beispiel soll eine verschlüsselte Kommunikation zwischen Server und Client dienen [13]. Beide verwenden einen Secure Socket, wobei die Daten in Form von JSON-Daten gesendet und empfangen werden. Wie sich der Server, bestehend aus einem Secure Socket, realisieren lässt, zeigt Listing 3. Falls der Server Teil einer Client-/Server-Anwendung ist, sollte er die Thread-Klasse erweitern. Dadurch wird die Anwendung multitaskingfähig. So kann der Server parallel mit den angeschlossenen Clients kommunizieren, während er weitere Aufgaben erledigt.
Listing 3
import java.io.*;
import java.net.*;
import java.security.KeyManagementException;
import java.security.KeyStore;
import java.security.KeyStoreException;
import java.security.NoSuchAlgorithmException;
import java.security.UnrecoverableKeyException;
import java.security.cert.CertificateException;
import java.util.concurrent.*;
import java.util.concurrent.atomic.AtomicBoolean;
import javax.net.*;
import javax.net.ssl.KeyManagerFactory;
import javax.net.ssl.SSLContext;
import javax.net.ssl.SSLServerSocket;
import javax.net.ssl.SSLServerSocketFactory;
public class ServerConnection extends Thread {
private final int port;
private AtomicBoolean running = new AtomicBoolean(false);
private ExecutorService pool;
public ServerConnection(int p) {
this.port = p;
pool = Executors.newFixedThreadPool(10000);
}
public void run() {
running.set(true);
this.setupSecureSocket();
}
public void setupSecureSocket() {
try {
ServerSocketFactory factory = ServerSocketFactory.getDefault();
ServerSocket socket = factory.createServerSocket(port);
System.out.println("creating server socket");
while (running.get()) {
try {
Socket s = socket.accept();
Runnable r = new ServerThread(s);
pool.submit(r);
} catch (IOException ex) {
System.out.println("Exception accepting connection");
} catch (RuntimeException ex) {
System.out.println("Unexpected error"+ex.getMessage());
}
}
System.out.println("shutting down server");
pool.shutdown();
socket.close();
} catch (BindException ex) {
System.out.println("Could not start server. "+ex.getMessage());
} catch (IOException ex) {
System.out.println("Error opening server socket: "+ex.getMessage());
}
}
public void interrupt() {
running.set(false);
}
private boolean isRunning() {
return running.get();
}
}
In Listing 3 wird der Server initialisiert, indem die Portnummer als Argument übergeben wird. Außerdem wird mittels des ExecutorService-Objekts die Zahl der Threads festgelegt, die der Server verarbeiten kann. So können in diesem Beispiel bis zu 10 000 Clients mit dem Server verbunden werden.
Da die Klasse ServerConnection von der Thread-Klasse die Methoden und Attribute erbt, ist es zusätzlich erforderlich, die bekannte run-Methode zu überschreiben. In diesem Fall reicht es aus, die selbst definierte Methode setupSecureSocket() aufzurufen. Dort wird zunächst der Secure Socket des Servers initialisiert, was dazu führt, dass der Server auf einem sicheren Kanal mit der Portnummer XY lauscht. Solange die Variable running vom Typ AtomicBoolean den Wert true aufweist, kann der Server neue Clients zulassen, indem jeder Client als ein Runnable-Objekt zum Pool hinzugefügt wird. Die Klasse ServerConnection stellt weitere Hilfsmethoden zur Verfügung, mit denen sich der Server steuern lässt. So kann mittels Aufruf der Methode interrupt() jederzeit das Verwalten der Clients unterbrochen werden. Der Aufruf der Methode isRunning() zeigt an, ob der Server noch läuft.
Bei der Anmeldung am Server wird jedem Client ein ServerThread-Objekt vom Typ Runnable zugeordnet (Listing 4). Die Implementierung der Runnable-Schnittstelle sorgt dafür, dass die run-Methode nicht mehr explizit aufgerufen werden muss. Zusätzlich wird beim Anlegen des ServerThread-Objekts der Socket des Clients übergeben, um damit den BufferedWriter sowie InputStreamReader zu initialisieren.
Der Aufruf von start() erfolgt in der überschriebenen run-Methode und sorgt dafür, dass der booleschen Variable shutdown der Wert false zugewiesen wird. Durch den anschließenden Aufruf von getIncomingData() wird der InputStreamReader gestartet, sodass in der while-Schleife konstant Nachrichten vom Client empfangen und zu einem JSON-Objekt geparst werden. Daneben beinhaltet die ServerThread-Klasse die Methode disconnect(), die die Verbindung zum Client kappt. Hierbei wird die boolesche Variable shutdown auf true gesetzt, was dazu führt, dass die while-Schleife unterbrochen wird.
Listing 4
import org.json.simple.JSONObject;
import org.json.simple.parser.JSONParser;
import org.json.simple.parser.ParseException;
import java.net.Socket;
import java.io.Writer;
import java.io.BufferedWriter;
import java.io.OutputStreamWriter;
import java.io.InputStreamReader;
import java.io.IOException;
import java.io.BufferedInputStream;
import java.io.Reader;
import java.io.BufferedReader;
public class ServerThread implements Runnable {
private final Socket connection;
private Writer out;
private Reader in;
private BufferedReader reader;
private volatile boolean shutdown;
public ServerThread(Socket s) {
this.connection = s;
}
public void run() {
try {
this.out = new BufferedWriter(new OutputStreamWriter(connection.getOutputStream(), "US-ASCII"));
System.out.println("client address: " + this.connection.getRemoteSocketAddress());
{
this.start();
getIncomingData();
} catch(IOException ex) {
System.out.println("Error talking to " + this.connection.getRemoteSocketAddress()+" "+ex.getMessage());
}
}
private void getIncomingData() {
JSONParser parser = new JSONParser();
try {
this.in = new InputStreamReader(new BufferedInputStream(connection.getInputStream()));
this.reader = new BufferedReader( this.in);
String line = "";
while(!shutdown){
if ((line = this.reader.readLine()) != null) {
System.out.println("line: "+line);
JSONObject json = (JSONObject) parser.parse(line);
System.out.println("reading in json "+json);
} else {
this.in.close();
disconnect();
}
}
} catch(IOException ex) {
System.out.println("could not read in data: " + ex.getMessage());
} catch(ParseException ex) {
System.out.println("could not parse JSONObject: " + ex.getMessage());
}
}
public void disconnect() {
try {
this.connection.close();
shutdown();
System.out.println("closing client socket ");
} catch (IOException ex) {
System.out.println("could not close client socket " + ex.getMessage());
}
}
public void shutdown() {
shutdown = true;
}
public void start() {
shutdown = false;
}
}
}
Auch beim Client muss ein Secure Socket erstellt werden, um darüber eine Verbindung zum Server aufzubauen. Die Umsetzung des Clients erfolgt durch die Klasse ClientConnection (Listing 5). So werden beim Erstellen des ClientConnection-Objekts sowohl die Portnummer als auch der Hostname des Servers als Argumente übergeben.
Das Herzstück der ClientConnection ist die connect-Methode, da dort der Secure Socket mittels SSLSocketFactory eingerichtet wird. Zudem werden dort sowohl der BufferedWriter, der zum Schreiben von Daten an den Server gebraucht wird, als auch der InputStream, über den Daten vom Server empfangen werden, initialisiert. Erwähnenswert ist in diesem Zusammenhang die Methode disconnect(), welche die Verbindung zum Server beendet. Durch den Aufruf von write() können Stringnachrichten an den Server verschickt werden.
Listing 5
import java.io.BufferedWriter;
import java.io.IOException;
import java.io.InputStream;
import java.io.OutputStream;
import java.io.OutputStreamWriter;
import java.io.Writer;
import java.net.Socket;
import java.util.concurrent.atomic.AtomicBoolean;
import javax.net.SocketFactory;
import javax.net.ssl.SSLSocketFactory;
public class ClientConnection implements Runnable {
private Socket socket;
private OutputStream out;
private Writer writer;
private InputStream in;
private String host = "";
private int port = 0;
private AtomicBoolean running = new AtomicBoolean(false);
public ClientConnection(String host, int p) {
this.host = host;
this.port = p;
}
public void run() {
read();
}
public void connect() {
try {
SocketFactory factory = SSLSocketFactory.getDefault();
this.socket = factory.createSocket(host, port);
this.socket.setSoTimeout(100000);
this.out = this.socket.getOutputStream();
this.writer = new BufferedWriter(new OutputStreamWriter(this.out, "UTF-8"));
this.in = this.socket.getInputStream();
System.out.println("Could establish connection to server.");
} catch (IOException ex) {
System.out.println("Could not connect to server."+ex.getMessage());
}
}
public void write(String msg) {
try {
if(this.writer != null) {
this.writer.write(msg);
this.writer.flush();
} else {
System.out.println("Could not write to server.");
}
} catch (IOException ex) {
System.out.println("Could not write to server. "+ex.getMessage());
}
}
public void disconnect() {
if (this.socket != null) {
try {
this.socket.close();
System.out.println("disconnected from server");
} catch (IOException ex) {
System.out.println("could not disconnect.");
}
}
}
public boolean isConnected() {
if ( this.socket != null ) {
System.out.println("connected to server");
return true;
} else {
System.out.println("not connected to server");
return false;
}
}
public void read() {
//TODO: read in from server
}
}
Die hier vorgestellte Client-/Serveranwendung kann anschließend in der Main-Methode aufgerufen werden. Den Server initialisiert ihr dabei unter Angabe der Portnummer wie folgt:
ServerConnection server = new ServerConnection(9200);
server.run();
Genauso wird der Client gestartet, außer das hier noch ein kleiner Test stattfindet. So versendet der Client persönliche Daten als JSON-String (Listing 6).
Listing 6
ClientConnection client = new ClientConnection("127.0.0.1", 9200);
client.connect();
JSONObject jObj = new JSONObject();
jObj.put("Name", "Max Mustermann");
jObj.put("Product-ID", "67x-89");
jObj.put("Address", "1280 Deniston Blvd, NY NY 10083");
jObj.put("Card Number", "4000-1234-5678-9017");
jObj.put("Expires", "10/29");
try {
TimeUnit.SECONDS.sleep(30);
} catch (InterruptedException e) {
e.printStackTrace();
}
client.writeToServer(jObj.toJSONString()+"\n");
Trotz des einfach gehaltenen Codes werden die vertraulichen Daten verschlüsselt an den Server gesendet. Überprüfen könnt ihr das unter Linux mit dem Kommandozeilentool Tcpflow [14]. Denn Tcpflow ist eine Art Packet Sniffer, der den gesamten oder auch einen Teil des TCP-Verkehrs aufzeichnet und in eine einfache Datei schreibt. Um beispielsweise den Port mittels Tcpflow abzuhören, der für dieses Beispielprogramm benutzt wird, verwendet ihr folgenden Befehl:
tcpflow -i any port 9200
Beim Aufruf von Tcpflow werden ein oder mehrere Dateien angelegt, welche die Daten beinhalten, die zwischen Client und Server gesendet worden sind. Dabei ähneln die Dateinamen dem folgenden Beispiel:
127.000.000.001.38226-127.000.000.001.09200
Unschwer zu erkennen ist, dass Daten vom Client mit der Portnummer 38226 an den Server mit der Portnummer 9200 gesendet worden sind. Schaut ihr euch allerdings den aufgezeichneten Netzwerkverkehr im Texteditor an, so werdet ihr abgesehen von kryptischen Symbolen keinerlei persönliche Daten entdecken (Abb. 2). Allerdings kann auch der implementierte Server mit den verschlüsselten Daten nichts anfangen, was daran liegt, dass der Server nicht weiß, welches Verschlüsselungsverfahren zum Einsatz kommt.
Zertifikatehandel
Der Server aus Listing 3 hat zwar den Vorteil, dass er relativ unkompliziert umsetzbar ist, allerdings unterstützt die factory, die von ServerSocketFactory.getDefault() zurückgegeben wird, lediglich die Authentifizierung [15]. Um die Verschlüsselung serverseitig zu implementieren, sind mehr Codezeilen nötig, was die Implementierung des Servers komplexer macht.
Zunächst legt ihr einen Keystore, einen Truststore sowie ein Zertifikat für den Server an. Genauso geht ihr beim Client vor, was allerdings nicht erforderlich ist, falls der Client allen Zertifikaten des Servers vertraut. Unter Linux stehen euch für solche Zwecke die Kommandozeilentools keytool sowie openssl zur Verfügung [16]:
-
privaten RSA-Schlüssel generieren: $ openssl genrsa -out diagserverCA.key 2048
-
x509-Zertifikat erstellen: $ openssl req -x509 -new -nodes -key diagserverCA.key -sha256 -days 1024 -out diagserverCA.pem
-
PKCS12 Keystore anhand des zuvor generierten Privatschlüssels und Zertifikats erzeugen: $ openssl pkcs12 -export -name server-cert -in diagserverCA.pem -inkey diagserverCA.key -out serverkeystore.p12
-
PKCS12-Keystore in einen Keystore vom Format JKS konvertieren: $ keytool -importkeystore -destkeystore server.keystore -srckeystore serverkeystore.p12 -srcstoretype pkcs12 -alias server-cert
-
falls der Client mit Zertifikaten arbeitet, das Clientzertifikat zum Truststore des Servers hinzufügen: $ keytool -import -alias client-cert -file ../client/diagclientCA.pem -keystore server.truststore
-
Serverzertifikat zum Truststore des Servers hinzufügen: keytool -import -alias server-cert -file diagserverCA.pem -keystore server.truststore
Anschließend ist es erforderlich, die Methode setup-SecureSocket() aus Listing 3 durch den Code aus Listing 7 zu ersetzen. Dabei definiert ihr zunächst den SSLContext (siehe setupSSLContext()), indem ihr eine TrustManagerFactory erstellt. Allerdings kann die TrustManagerFactory dem Wert 0 entsprechen, sofern eine KeyManagerFactory unter Angabe des Schlüsseltyps erzeugt wird. Dann erzeugt ihr ein KeyStore-Objekt vom Typ „JKS“ und befüllt es mit dem Server-Keystore, den ihr zuvor mit keytool erzeugt habt. Zusätzlich initialisiert ihr die KeyManagerFactory unter Angabe des Keystores und des dazugehörigen Passworts. Zu guter Letzt initialisiert ihr den SSLContext, indem ihr den Key-Manager vom Typ KeyManagerFactory, den Trust-Manager vom Typ TrustManagerFactory sowie eine sichere Zufallszahl, die SecureRandomNumber, dem SSLContext als Argumente übergebt. Die letzten beiden Argumente können null sein, falls die Standardeinstellungen ausreichen.
Zusätzlich braucht der Server einen Secure-Server-Socket, um darüber mit den Clients zu kommunizieren. Hierfür kommt eine SSLServerSocketFactory zum Einsatz, womit sich unter Angabe der Portnummer ein SSLServerSocket-Objekt erstellen lässt (siehe setupSSLSocket()). Außerdem lässt sich der Server-Socket weiter einrichten. So brauchen sich Clients nicht zu authentifizieren (vgl. setNeedClientAuth()).
Die letzte Konfiguration betrifft die Verschlüsselungsalgorithmen, die der Server unterstützt. In der Methode setupCipher() werden zunächst die von der aktuellen Java-Umgebung unterstützten Verschlüsselungssuiten geladen. Danach wird durch all diese Verschlüsselungssuiten iteriert, indem nur jene ausgewählt werden, die anonyme und unbestätigte Verbindungen zum SSLServerSocket zulassen. Das ist dann der Fall, wenn im Namen der Suite der String anon vorkommt. Das neue Array wird anschließend der Methode setEnabled-CipherSuites() übergeben.
Listing 7
(..)
private static final String SERVER_KEYSTORE = "server/server.keystore";
(..)
public void setupSSLSocket() {
SSLContext context = setupSSLContext();
try {
SSLServerSocketFactory factory = context.getServerSocketFactory();
SSLServerSocket socket = (SSLServerSocket) factory.createServerSocket(port);
socket.setNeedClientAuth(false);
socket.setWantClientAuth(false);
setupCipher(socket);
System.out.println("creating server socket");
while (running.get()) {
try {
Socket s = socket.accept();
Runnable r = new ServerThread(s);
pool.submit(r);
} catch (IOException ex) {
System.out.println("Exception accepting connection");
} catch (RuntimeException ex) {
System.out.println("Unexpected error"+ex.getMessage());
}
}
System.out.println("shutting down server");
pool.shutdown();
socket.close();
} catch (BindException ex) {
System.out.println("Could not start server. "+ex.getMessage());
} catch (IOException ex) {
System.out.println("Error opening server socket: "+ex.getMessage());
}
}
public SSLContext setupSSLContext() {
SSLContext context = null;
try {
context = SSLContext.getInstance(“SSL”);
KeyManagerFactory kmf =KeyManagerFactory.getInstance("SunX509");
KeyStore ks = KeyStore.getInstance("JKS");
char [] pw = "boguspw".toCharArray();
ks.load( new FileInputStream(SERVER_KEYSTORE), pw);
kmf.init(ks, pw);
context.init(kmf.getKeyManagers(), null, null);
} catch (NoSuchAlgorithmException e) {
System.out.println("wrong algorithm: "+e.getMessage());
e.printStackTrace();
} catch (KeyStoreException e) {
System.out.println("wrong keystore algo: "+e.getMessage());
e.printStackTrace();
} catch (CertificateException e) {
System.out.println("certificate invalid: "+e.getMessage());
e.printStackTrace();
} catch (UnrecoverableKeyException e) {
System.out.println("wrong password for keystore: "+e.getMessage());
e.printStackTrace();
} catch (KeyManagementException e) {
System.out.println("could not initialize context: "+e.getMessage());
e.printStackTrace();
} catch (FileNotFoundException e) {
System.out.println("file not found: "+e.getMessage());
e.printStackTrace();
} catch (IOException e) {
e.printStackTrace();
}
return context;
}
// anonyme Verschlüsselungssuiten hinzufügen
public void setupCipher(SSLServerSocket socket) {
String [] supported = socket.getSupportedCipherSuites();
String [] anonCipherSuitesSupported = new String [supported.length];
int numAnonCipherSuitesSupported = 0;
for (int i = 0; i < supported.length; i++) {
if(supported[i].indexOf("_anon_") > 0 ) {
anonCipherSuitesSupported[numAnonCipherSuitesSupported++] = supported[i];
}
}
String [] oldEnabled = socket.getEnabledCipherSuites();
String [] newEnabled = new String[oldEnabled.length + numAnonCipherSuitesSupported];
System.arraycopy(oldEnabled, 0, newEnabled, 0, oldEnabled.length);
System.arraycopy(anonCipherSuitesSupported, 0, newEnabled, oldEnabled.length, numAnonCipherSuitesSupported);
socket.setEnabledCipherSuites(newEnabled);
}
(...)
Darüber hinaus braucht der Client einen Socket, der mit dem SSLServerSocket kompatibel ist. In der connect-Methode der Klasse ClientConnection (siehe Listing 5) ersetzt ihr zunächst den Client-Socket durch die folgenden Zeilen:
SSLContext context = setupSSLContext();
SSLSocketFactory socketFactory = context.getSocketFactory();
SSLSocket socket = (SSLSocket) socketFactory.createSocket(host, port);
Außerdem stellt ihr beim Client ein, welche Zertifikate akzeptiert werden. So erlaubt die selbstdefinierte Methode setupSSLContext() alle eingehenden Zertifikate (Listing 8) [17].
Listing 8
// alle Zertifikate akzeptieren
public SSLContext setupSSLContext() {
SSLContext sc = null;
TrustManager[] trustAllCerts = new TrustManager[] { new X509TrustManager() {
public void checkClientTrusted(X509Certificate[] chain, String authType) throws CertificateException {
// TODO Auto-generated method stub
}
public void checkServerTrusted(X509Certificate[] chain, String authType) throws CertificateException {
// TODO Auto-generated method stub
}
public X509Certificate[] getAcceptedIssuers() {
// TODO Auto-generated method stub
return null;
}
} };
// Trust-Manager installieren
try {
sc = SSLContext.getInstance("SSL");
sc.init(null, trustAllCerts, new java.security.SecureRandom());
} catch (NoSuchAlgorithmException e) {
e.printStackTrace();
} catch (KeyManagementException e) {
e.printStackTrace();
}
return sc;
Dank der eingerichteten Verschlüsselung kann der Server die verschlüsselte Kreditkarteninformation entschlüsseln und weiterverarbeiten (Abb. 3).
XML-Signaturen
Daten, die in XML-Dateien exportiert und über das Internet versendet werden, sind ebenfalls von Missbrauch bedroht. Um ihre Integrität zu gewährleisten, könnt ihr den Empfehlungen des W3C folgen. Denn XML-Signaturen eignen sich zur Sicherung von Daten jeglicher Art. Hierfür stellt das Java API das Paket java.xml.crypto zur Verfügung. Damit unterstützt es die Erzeugung und Validierung von XML-Signaturen gemäß den empfohlenen Richtlinien.
In der Praxis lässt sich ein XML-Dokument wie in Listing 9 implementieren [18]. Hierbei wird der Klasse MySignature sowohl der Pfad des unsignierten als auch der des neuen XML-Dokuments übergeben. Die selbstdefinierte Methode createSignature() signiert das XML-Dokument in drei Schritten [19]:
-
Schlüsselpaar bestehend aus privatem und öffentlichem Schlüssel generieren
-
XML-Dokument importieren (siehe MySignature.importXML())
-
Original-XML-Dokument mittels des Schlüsselpaars signieren und ein weiteres XML-Dokument zusammen mit der digitalen Signatur erzeugen
Mittels der Klasse aus Listing 9 lässt sich ein bestehendes XML-Dokument wie folgt signieren:
Listing 9
import java.io.FileInputStream;
import java.io.FileNotFoundException;
import java.io.FileOutputStream;
import java.io.IOException;
import java.io.OutputStream;
import java.security.InvalidAlgorithmParameterException;
import java.security.KeyException;
import java.security.KeyPair;
import java.security.KeyPairGenerator;
import java.security.NoSuchAlgorithmException;
import java.util.Collections;
import java.util.Iterator;
import javax.xml.crypto.MarshalException;
import javax.xml.crypto.dsig.CanonicalizationMethod;
import javax.xml.crypto.dsig.DigestMethod;
import javax.xml.crypto.dsig.Reference;
import javax.xml.crypto.dsig.SignatureMethod;
import javax.xml.crypto.dsig.SignedInfo;
import javax.xml.crypto.dsig.Transform;
import javax.xml.crypto.dsig.XMLSignature;
import javax.xml.crypto.dsig.XMLSignatureException;
import javax.xml.crypto.dsig.XMLSignatureFactory;
import javax.xml.crypto.dsig.dom.DOMSignContext;
import javax.xml.crypto.dsig.dom.DOMValidateContext;
import javax.xml.crypto.dsig.keyinfo.KeyInfo;
import javax.xml.crypto.dsig.keyinfo.KeyInfoFactory;
import javax.xml.crypto.dsig.keyinfo.KeyValue;
import javax.xml.crypto.dsig.spec.C14NMethodParameterSpec;
import javax.xml.crypto.dsig.spec.TransformParameterSpec;
import javax.xml.parsers.DocumentBuilderFactory;
import javax.xml.parsers.ParserConfigurationException;
import javax.xml.transform.Transformer;
import javax.xml.transform.TransformerConfigurationException;
import javax.xml.transform.TransformerException;
import javax.xml.transform.TransformerFactory;
import javax.xml.transform.dom.DOMSource;
import javax.xml.transform.stream.StreamResult;
import org.w3c.dom.Document;
import org.w3c.dom.NodeList;
import org.xml.sax.SAXException;
public class MySignature {
private String path = "";
private String output = "";
public MySignature(String p,String dest) {
this.path = p;
this.output = dest;
}
public void createSignature() {
XMLSignatureFactory fac = XMLSignatureFactory.getInstance("DOM");
try {
Reference ref = fac.newReference("", fac.newDigestMethod(DigestMethod.SHA384, null), Collections.singletonList (fac.newTransform (Transform.ENVELOPED, (TransformParameterSpec) null)),null, null);
// SignedInfo-Element erstellen
SignedInfo si = fac.newSignedInfo(fac.newCanonicalizationMethod(Canoni-calizationMethod.INCLUSIVE, (C14NMethodParameterSpec) null), fac.newSignatureMethod(SignatureMethod.RSA_SHA384, null), Collections.singletonList(ref));
KeyPairGenerator kpg = KeyPairGenerator.getInstance("RSA");
kpg.initialize(2048);
KeyPair kp = kpg.generateKeyPair();
// KeyValue mit DSA-PublicKey erstellen
KeyInfoFactory kif = fac.getKeyInfoFactory();
KeyValue kv = kif.newKeyValue(kp.getPublic());
KeyInfo ki = kif.newKeyInfo(Collections.singletonList(kv));
// XML-Dokument importieren
Document doc = importXML(path);
// DOMSignContext erstellen und den DSA-PrivateKey
// sowie den Ort des Eltern-Elements angeben
DOMSignContext dsc = new DOMSignContext(kp.getPrivate(), doc.getDocumentElement());
// XMLSignatur-Objekt anlegen
XMLSignature signature = fac.newXMLSignature(si, ki);
// Kuvertierte Signatur zusammenstellen und generieren
signature.sign(dsc);
// neues Dokument abspeichern
OutputStream os = new FileOutputStream(this.output);
TransformerFactory tf = TransformerFactory.newInstance();
Transformer trans = tf.newTransformer();
trans.transform(new DOMSource(doc), new StreamResult(os));
} catch (..)
// TODO: Ausnahmen hinzufügen
}
}
// XML-Signatur überprüfen
public boolean isXmlDigitalSignatureValid() throws Exception {
boolean validFlag = false;
Document doc = importXML(this.output);
NodeList nl = doc.getElementsByTagNameNS(XMLSignature.XMLNS, "Signature");
if (nl.getLength() == 0) {
throw new Exception("No XML Digital Signature Found, document is discarded");
}
// DOMValidateContext-Objekt anlegen und einen KeyValue-KeySelector
// sowie Kontext des Dokuments angeben
DOMValidateContext valContext = new DOMValidateContext(new KeyValueKeySelector(), nl.item(0));
XMLSignatureFactory fac = XMLSignatureFactory.getInstance("DOM");
XMLSignature signature = fac.unmarshalXMLSignature(valContext);
validFlag = signature.validate(valContext);
if (validFlag == false) {
System.err.println("Signature failed core validation");
boolean sv = signature.getSignatureValue().validate(valContext);
System.out.println("signature validation status: " + sv);
// den Validierungsstatus jeder Referenz prüfen
Iterator i = signature.getSignedInfo().getReferences().iterator();
for (int j=0; i.hasNext(); j++) {
boolean refValid = ((Reference) i.next()).validate(valContext);
System.out.println("ref["+j+"] validity status: " + refValid);
}
} else {
System.out.println("Signature passed core validation");
}
return validFlag;
}
// XML-Dokument importieren
public Document importXML(String sPath) {
DocumentBuilderFactory dbf = DocumentBuilderFactory.newInstance();
dbf.setNamespaceAware(true);
Document doc = null;
try {
doc = dbf.newDocumentBuilder().parse(new FileInputStream(sPath));
} catch (SAXException | IOException | ParserConfigurationException e) {
e.printStackTrace();
}
return doc;
}
}
String path = "files/music-jaxb-en.xml";
String signedXML = "files/envelopedSignature.xml";
MySignature xmlSecurity = new MySignature(path,signedXML);
xmlSecurity.createSignature();
Abbildung 4 zeigt das signierte XML-Dokument, bei dem die digitale Signatur innerhalb des Wurzeltags eingefügt wird. Der hierfür verwendete Algorithmus, genannt RSA-SHA384, orientiert sich dabei an den Vorgaben von W3C.
Darüber hinaus lässt sich die Signatur des XML-Dokuments auf Richtigkeit hin validieren. Die selbst definierte Methode aus Listing 9, genannt isXmlDigitalSignatureValid(), lädt zunächst den Inhalt des Signature-Tags und übergibt diesen zusammen mit dem KeyValueKeySelector-Objekt (Listing 10) dem DOMValidateContext. Dann werden die eingelesenen XML-Knoten in Objekte umgewandelt und als XMLSignature-Objekt gespeichert. Das XMLSignature-Objekt bekommt anschließend das zuvor erstellte DOMValidateContext-Objekt übergeben. Die validate-Methode gibt true zurück, falls die Signatur laut den Regeln des W3C erfolgreich validiert worden ist; ansonsten gibt sie false zurück.
In Listing 10 ist der KeySelector definiert, der den öffentlichen Schlüssel aus dem KeyValue-Element abruft und ihn zurückgibt. Wenn der Schlüsselalgorithmus nicht mit dem Signaturalgorithmus übereinstimmt, wird der öffentliche Schlüssel ignoriert.
Listing 10
import java.security.KeyException;
import java.security.PublicKey;
import java.util.List;
import javax.xml.crypto.AlgorithmMethod;
import javax.xml.crypto.KeySelector;
import javax.xml.crypto.KeySelectorException;
import javax.xml.crypto.KeySelectorResult;
import javax.xml.crypto.XMLCryptoContext;
import javax.xml.crypto.XMLStructure;
import javax.xml.crypto.dsig.SignatureMethod;
import javax.xml.crypto.dsig.keyinfo.KeyInfo;
import javax.xml.crypto.dsig.keyinfo.KeyValue;
public class KeyValueKeySelector extends KeySelector {
public KeySelectorResult select(KeyInfo keyInfo,KeySelector.Purpose purpose,AlgorithmMethod method,XMLCryptoContext context)throws KeySelectorException {
if (keyInfo == null) {
throw new KeySelectorException("Null KeyInfo object!");
}
SignatureMethod sm = (SignatureMethod) method;
List list = keyInfo.getContent();
for (int i = 0; i < list.size(); i++) {
XMLStructure xmlStructure = (XMLStructure) list.get(i);
if (xmlStructure instanceof KeyValue) {
PublicKey pk = null;
try {
pk = ((KeyValue)xmlStructure).getPublicKey();
} catch (KeyException ke) {
throw new KeySelectorException(ke);
}
// prüfen, ob der Algorithmus kompatibel mit der Methode ist
if (algEquals(sm.getAlgorithm(), pk.getAlgorithm())) {
return new SimpleKeySelectorResult(pk);
}
}
}
throw new KeySelectorException("No KeyValue element found!");
}
private static boolean algEquals(String algURI, String algName) {
if (algName.equalsIgnoreCase("DSA") && algURI.equalsIgnoreCase("http://www.w3.org/2009/xmldsig11#dsa-sha256")) {
return true;
} else if (algName.equalsIgnoreCase("RSA") && algURI.equalsIgnoreCase("http://www.w3.org/2001/04/xmldsig-more#rsa-sha384")) {
return true;
} else {
return false;
}
}
}
Das in Listing 10 angelegte KeyValueKeySelector-Objekt wird benötigt, um Schlüssel für die Validierung der digitalen Signatur zu finden. Alternativ kann direkt ein öffentlicher Schlüssel angegeben werden. Allerdings ist es oft nicht vorhersehbar, welcher Schlüssel passt.
Der KeyValueKeySelector (Listing 10) leitet die abstrakte Klasse KeySelector ab. Er versucht, einen angemessenen öffentlichen Schlüssel zu ermitteln, indem er auf Daten der KeyValue-Elemente aus dem KeyInfo-Tag eines XMLSignature-Objekts zurückgreift. Diese Implementierung überprüft nicht, ob der Schlüssel verlässlich ist. Sobald der KeyValueKeySelector einen geeigneten öffentlichen Schlüssel findet, wird dieser über das SimpleKeySelectorResult-Objekt zurückgegeben (Listing 11).
Listing 11
import java.security.Key;
import java.security.PublicKey;
import javax.xml.crypto.KeySelectorResult;
public class SimpleKeySelectorResult implements KeySelectorResult {
private PublicKey pk;
public SimpleKeySelectorResult(PublicKey pk) {
this.pk = pk;
}
public Key getKey() {
return pk;
}
}
In Listing 12 könnt ihr sehen, wie sich die selbst definierte Klasse MySignature initialisieren und ausführen lässt.
Listing 12
String path = "files/music-jaxb-en.xml";
String signedXML = "files/envelopedSignature.xml";
MySignature xmlSecurity = new MySignature(path,signedXML);
xmlSecurity.createSignature();
try {
xmlSecurity.isXmlDigitalSignatureValid();
} catch (Exception e) {
e.printStackTrace();
}