OpenSSL: Implementierung innerhalb eines Client- und Server-Programms, Teil 1

Servervorbereitung

Beim Server sind nach dem Erzeugen des Context-Objekts das zu verwendende Zertifikat und der zugehörige private Schlüssel zu laden und dem Context-Objekt zuzuweisen. Das Laden und Zuweisen des Zertifikats geschieht durch die Funktion SSL_CTX_use_certificate_file(). Für den privaten Schlüssel ist SSL_CTX_use_PrivateKey_file() zu verwenden. Neben dem Context-Objekt und dem Dateinamen erwarten die Funktionen noch den Typ der Datei. Es lässt sich zwischen SSL_FILETYPE_PEM für PEM-codierte – und SSL_FILETYPE_ASN1 für DER-codierte Daten wählen.

OpenSSL verwendet die eigene Abstraktionsschicht namens BIO, um Kommunikationsverbindungen zu behandeln. Sie kann sowohl mit verschlüsselten als auch mit unverschlüsselten Verbindungen arbeiten, kapselt in BIO-Objekten im Kern BSD-Sockets und bietet einen komfortablen Zugriff auf dieselben. Zusätzlich lassen sich BIO-Objekte aus dem Context-Objekt instanziieren oder mit einer SSL-Engine verbinden und so Verbindungen um SSL-Verschlüsselung anreichern.

Bei einem Server ist ein "Accept-BIO" notwendig, das auf einen Port auf einem bestimmten oder allen Interfaces lauscht. Es erzeugt die Funktion BIO_new_accept(), die einen Hostnamen als Argument erwartet. Der Begriff "Hostname" ist etwas irreführend, da der Parameter wesentlich leistungsfähiger ist. Neben dem Hostnamen, der lokal oder über DNS auflösbar sein muss, kann ebenso gut ein String mit einer IP-Adresse als Parameter Platz finden. Darüber hinaus lässt sich optional – getrennt durch einen Doppelpunkt – der Port an den Hostnamen oder die IP-Adresse anhängen. Er wird dabei entweder numerisch oder als Bezeichner übergeben.

Letzterer ist eine gültige Portbezeichnung aus der /etc/services beziehungsweise dem Pendant der jeweiligen Betriebssystemplattform. Kommt keine Portangabe zum Einsatz, wäre der Port separat über die Funktion BIO_set_conn_port() oder BIO_set_conn_int_port() zu setzen. Diesen Weg sieht WOPR sowohl beim Server als auch später beim Terminal nicht vor. Die Konfigurationsangabe hat bei WOPR zwingend in der Form "hostname:port" zu erfolgen.

Zusätzlich lassen sich statt einer IP-Adresse die Wildcard-Netzwerkadressen 0.0.0.0 (IPv4) und :: (IPv6) für alle Netzwerkadressen angeben. Bei der speziellen Adresse :: aus IPv6 ist jedoch auf die OpenSSL-Version zu achten. Auf die IPv6-Besonderheiten geht der zweite Teil näher ein.

Auch der * lässt sich als Host-Wildcard verwenden. Das ist jedoch mit Vorsicht zu genießen. Es gibt Betriebssysteme, wie NetBSD, die * nicht unterstützen. Auf den meisten unixoiden Systemen steht das Zeichen für "alle Interfaces", manchmal jedoch – insbesondere im Zusammenspiel mit OpenSSL 1.0.0 – nur für die Loopback-Adresse 127.0.0.1 oder ::1.

Den vorbereiteten Server der Klasse WOPR_server startet die Methode run(). Bislang ist der Accept-BIO zwar erzeugt, er lauscht aber noch nicht auf eingehende Verbindungen. Erst der erste Aufruf von BIO_do_accept() erzeugt das im BIO gekapselte Socket und lässt es auf die beim Aufruf von BIO_new_accept() übergebene Host-Port-Kombination lauschen.

Der Server tritt in eine Schleife ein, in der das Socket-Handle mit select() auf eingehende Daten geprüft wird. Stehen beim Socket Daten zum Lesen an (FD_ISSET liefert true), ruft das Server-Programm erneut BIO_do_accept() auf. Es wartet ab dem zweiten Aufruf auf eingehende Verbindungen. Da FD_ISSET geprüft hat, dass Daten vorliegen, kehrt BIO_do_accept() in jedem Fall sofort zurück. Andernfalls würde BIO_do_accept() blockieren, bis eine Verbindung eingeht.

Warum der Aufwand mit select() und nicht einfach "non-blocking" aktivieren? Die Antwort ist einfach: Sparen von wertvoller CPU-Zeit. Ist "non-blocking" aktiviert, kehrt zwar BIO_do_accept() in jedem Fall immer zurück. Wenn keine Verbindung ansteht, teilt das BIO_do_accept() per Rückgabewert mit. Eine Schleife würde in dem Fall ständig das System mit unnötigen Aufrufen von BIO_do_accept() beschäftigen und die CPU-Last hochtreiben. Das wäre nicht nur in Zeiten von "Green IT" ein zweifelhaftes Vorgehen.

Ging eine Verbindung ein, erfolgt ein fork(). Das Programm legt von sich eine Kopie als Kindprozess an. Als Erstes holt sich das Programm mit BIO_pop() ein BIO mit der eingegangenen Verbindung. Anschließend erzeugt es ein neues SSL-Objekt mit SSL_new() aus dem Context. SSL_set_accept_state() setzt für das SSL-Objekt den Server-Modus, um eingehende Verbindungen zu behandeln. SSL_set_bio() stellt schließlich die Verknüpfung zwischen dem SSL-Objekt und dem BIO her. Neben dem SSL-Objekt erwartet SSL_set_bio() zwei BIOs – eines fürs Lesen und eines fürs Schreiben von Daten. Da im Fall des WOPR-Servers beides über das gleiche BIO erfolgen soll, enthalten beide Argumente das eine BIO.

Nun erfolgt die Fallunterscheidung für Kind- und Elternprozess. Der Kindprozess (case 0), der die Verbindung behandeln soll, akzeptiert die Verbindungsanfrage des Client mit SSL_accept() und übergibt die Behandlung an die Methode handle_client().

Ist die Behandlung durch handle_client() abgeschlossen, testet der Server über SSL_get_shutdown(), ob der Client einen SSL-Shutdown (und damit einen Disconnect) eingeleitet hat. Ist das der Fall, fährt der Server die SSL-Engine über SSL_shutdown() herunter und sendet an den Client einen "close notify". Andernfalls wird die SSL-Verbindung per SSL_clear() zurückgesetzt. Einen "close notify" an den Client zu senden ergibt in dem Fall keinen Sinn. Die Verbindung ist ohnehin schon abgebrochen.

Die Behandlung der Client-Verbindung mit handle_client() erfolgt ebenfalls mit select() auf der Basis des Sockets. Damit select() auf dem Socket Handle operieren kann, ermittelt BIO_get_fd() es aus dem BIO. Das Socket Handle weist das Programm sowohl der Liste der zum Lesen bereiten Handles (rfds) als auch der Liste der zum Schreiben bereiten Handles (wfds) zu. Das Socket implementiert einen Multiplexbetrieb, der das gleichzeitige Lesen und Schreiben ermöglicht. Daher ist der Einsatz einer Funktion wie select() erforderlich. Sie prüft vor dem Lesen oder Schreiben, ob das jeweilige Handle überhaupt für die Operation bereit ist. Auf die Weise blockiert die Anwendung infolge einer Operation auf nicht bereitem Handle nicht. Liegen zum Beispiel keine Daten zum Lesen vor, wird auch nicht versucht zu lesen. Letzteres würde sonst in der Regel zum Blockieren führen. Mit anderen Worten: Die angestoßene Lesefunktion kehrt erst zurück, wenn sich Daten lesen lassen.

Nach dem Aufruf von select() prüft der Server zuerst mit FD_ISSET(cfd,&rfds), ob Daten zum Lesen anstehen. Ist das der Fall, liest SSL_read() die anstehenden Daten in den Puffer rbuf ein. Dabei lassen sich maximal BUFSIZE Bytes, die die Größe des Puffers repräsentieren, lesen. Die gelesenen Daten sind nichts anderes, als ein auf dem Client eingegebener Befehl. Ergibt das Prüfen auf Fehler mit SSL_get_error(), dass alles OK (SSL_ERROR_NONE) ist, erfolgt das Verarbeiten des Befehls.

Befindet sich der Server auf dem Mainframe unter z/OS, werden die gelesenen ASCII-Daten zunächst mit __atoe() nach EBCDIC konvertiert. Unabhängig vom Betriebssystem eliminiert die Methode trim_all() überschüssige Leer- und Tabulatorzeichen.

Anschließend erfolgt in der for-Schleife der Vergleich mit den akzeptierten Befehlen ohne das Berücksichtigen von Groß- und Kleinschreibung. Die Befehle sind im Klassenattribut cmd_n_resp definiert. Die Tabelle steuert das Verhalten des Servers und enthält für jeden Befehl eine struct des Typs CmdNResp (= "command and response"). Darin sind jeder Befehl und seine Reaktion definiert. Ein spezielles Bitmuster gibt an, wann der Befehl gültig ist (vor oder nach dem Login oder immer) und der Befehl den Modus (logged in, logged off, shutdown) ändert. Ist der gefundene Befehl im jeweiligen Kontext gültig, wird die vordefinierte Antwort in den Schreibpuffer wbuf kopiert. Es folgen dann noch ein paar Status-Updates. Auf z/OS erfolgt noch das Konvertieren der Antwort von EBCDIC nach ASCII.

Nun prüft das Programm wieder mit FD_ISSET(), ob Daten zum Schreiben anstehen. Ist das der Fall, erfolgt die Ausgabe des wbuf-Inhalts mit SSL_write() mit anschließender Fehlerprüfung analog zum vorherigen Schema zum Lesen. Trat kein Fehler auf, wird wbuf entsprechend aktualisiert.