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

In C/C++-Programmen lässt sich mit OpenSSL verschlüsselte, sichere Netzkommunikation einfach realisieren. heise Developer stellt in einem Zweiteiler die Implementierung eines Client- und Server-Programms vor. Den Anfang macht die Serverprogrammierung.

In Pocket speichern vorlesen Druckansicht
Lesezeit: 17 Min.
Von
  • Oliver Müller
Inhaltsverzeichnis

OpenSSL ist seit über zehn Jahren eine verlässliche und plattformunabhängige SSL/TLS-Implementierung. In eigenen C/C++-Programmen lässt sich mit der Bibliothek verschlüsselte, sichere Netzkommunikation einfach realisieren. Abseits von grauer Theorie stellt heise Developer die Implementierung eines Client- und Server-Programms vor – genannt "WOPR". Den Anfang macht die Serverprogrammierung.

WOPR ist eine Hommage an den gleichnamigen Server aus dem Film "War Games". Das Beispiel stellt den mehr oder weniger logischen Dialog zwischen Terminal und Server aus dem Film nach. Die Funktionen sind an der Stelle nicht wichtig. Vielmehr lässt sich durch das Gespann aus lokalem Client und entferntem Server ein Muster zum Entwickeln von SSL-Server- und -Client-Systemen ableiten. Die Terminal-Kommunikation mit ihren entgegengesetzten Datenströmen zeigt anschaulich die Realisierung einer bidirektionalen Kommunikation und die hieraus entstehenden Herausforderungen für die SSL-Programmierung.

Dieser erste Teil geht primär auf die Programmierung des Servers ein. Ebenfalls finden die für Server und Client gleichermaßen gültigen Grundlagen Betrachtung. Im zweiten Teil folgen die Programmierung des Clients sowie ein Ausblick auf die Portabilität von OpenSSL-Programmen und die IPv6-Unterstützung.

WOPR ist für Unix-kompatible und -ähnliche Betriebssysteme ausgelegt. Das WOPR-Beispiel ist unverändert lauffähig auf Unix-Derivaten wie BSD, AIX und Solaris, auf unixoiden Systemen wie Linux und QNX sowie Unix-Layern wie die z/OS Unix System Services (USS) auf dem IBM-Mainframe und Cygwin unter Microsofts Windows. Es setzt insbesondere das Vorhandensein der Unix-Funktionen select() und fork() voraus. Die sind aber keine Bedingungen für OpenSSL, sondern lediglich der systemtechnische Rahmen, um das Beispiel überschaubar, aber zugleich auf einer breiten Palette von Plattformen ablauffähig und nachvollziehbar zu gestalten. Durch den Unix-Ansatz reicht die unterstützte Palette immerhin vom Windows-PC über die Systeme der Unix-/Linux-Welt bis hin zum IBM-Großrechner. Am Ende des zweiten Beitrags geben darüber hinaus weitere Hinweise Hilfestellung zur Portierung auf andere Plattformen.

Mehr Infos

Beispielcode

Den für für die Umsetzung in der Praxis benötigten Beispielcode findet man hier (mueller_openssl_wopr.tar).

Zum Kompilieren und Linken setzt WOPR einen modernen C++-Compiler, wie GNUs g++ ab Version 3, sowie die Installation der OpenSSL-Entwicklungspakete voraus. Das Makefile von WOPR ist ausschließlich für GNU make ausgelegt. Es kann den Build-Prozess anderer Make-Tools wie BSD make oder Microsofts nmake nicht steuern.

Beide Programme sind kommandozeilenorientiert. Sie erhalten ihre Parameter für Verbindung, Zertifikate und Schlüssel über Argumente der Kommandozeile. Gibt man gar keine, nicht genügend oder zu viele Argumente an, liefern beide Programme eine kurze "Usage" als Hilfestellung.

Der WOPR-Server besteht aus den Dateien main.cpp, wopr.cpp und woprexcept.cpp sowie den zugehörigen Header-Files wopr.h und woprexcept.h. main.cpp enthält das Hauptprogramm, das die übergebenen Kommandozeilenargumente prüft und den WOPR-Server instanziiert. woprexcept.h deklariert einfache Exception-Klassen für den WOPR-Server, die in woprexcept.cpp implementiert sind.

Der eigentliche Server ist in der Klasse WOPR_server gekapselt (siehe Dateien wopr.cpp und wopr.h). Sie implementiert einen Server, der – wie allgemein üblich – mehrere konkurrierende Verbindungen parallel bedienen kann. Hierzu setzt das System auf den alten Fork-Ansatz. Das heißt: Wann immer eine neue Client-Verbindung beim Server eingeht, erzeugt er mit der Unix-Funktion fork() eine Prozesskopie (Kindprozess) von sich, die dediziert diesen einen Client bedient. Endet die Verbindung mit dem Client, beendet sich der korrespondierende Server-Kindprozess.

Grundsätzlich wäre es ohne Weiteres realisierbar, den WOPR-Server "multithreaded" zu implementieren, denn OpenSSL unterstützt Multithread-Programme. Das würde allerdings – durch die Nebenläufigkeiten innerhalb des dann einzigen Prozesses – ein wenig mehr Programmieraufwand bedeuten. Da die bezweckte Einführung in OpenSSL dadurch Gefahr liefe, aus dem Fokus zu geraten, seien an der Stelle keine modernen Threads, sondern altmodische (Unix-)Prozesse verwendet.

Der erste Schritt, OpenSSL verwenden zu können, ist die Initialisierung der Bibliothek mit SSL_library_init(). Die Funktion registriert hauptsächlich die verfügbaren Kryptoalgorithmen und Digests. Erst danach ist das Verwenden von SSL-Funktionen überhaupt möglich. Der Aufruf befindet sich innerhalb des WOPR-Servers im Konstruktor der Klasse WOPR_server. Das boolesche Klassenattribut ssl_initialized steuert hierbei instanzübergreifend, ob die Initialisierung stattgefunden hat.

Der Aufruf von SSL_library_init() muss für ein Programm nur ein einziges Mal erfolgen. Es wäre schon aus Gründen der Performanz nicht ratsam, die Funktion mehrmals im Programm aufzurufen. Daher nutzt die Klasse WOPR_server das steuernde klasseneigene Flag.

Zugegeben, dessen Bereitstellung ist derzeit nicht notwendig, da die Implementierung von WOPR nur einen einzigen Server verwendet. Startet man jedoch mehrere Serverinstanzen in einem Programm – beispielsweise um unterschiedliche Netzwerke unterschiedlich zu bedienen oder auf mehreren Ports mehrere SSL-gesicherte Dienste anzubieten –, ist das Vorgehen über das Flag essenziell. Es empfiehlt sich, in diesem Fall das Klassenattribut in einer (abstrakten) Basisklasse zu definieren und alle weiteren Serverklassen von ihr abzuleiten. Der Aufruf von SSL_library_init() kann dann in Abhängigkeit des Flag-Werts zentral im Konstruktor der Basisklasse erfolgen.

Nach dem Aufruf von SSL_library_init() folgt obligatorisch das Laden der Fehlermeldungen. Auf diese Weise ist es im Programmverlauf möglich, statt kryptischer Fehlernummern von OpenSSL aussagekräftige Meldungen zu erhalten.

Der nächste Schritt ist das Erzeugen eines SSL-Kontext mit SSL_CTX_new(). Das Objekt dient dazu, interne SSL-Strukturen zu erzeugen und die schlüsselrelevanten Informationen zentral zu halten. Außerdem lassen sich damit Daten zwischen mehreren SSL-Verbindungen teilen. Neue Verbindungen generiert man später mit dem Context-Objekt, ohne zeitraubend Schlüssel und Zertifikate erneut zu laden und zentrale verbindungsunabhängige interne Strukturen nochmals zu initialisieren.

SSL_CTX_new() erwartet einen Zeiger vom Typ SSL_METHOD. Über den Parameter lässt sich einerseits festlegen, welchen SSL/TLS-Standard man unterstützt, andererseits, ob das Programm als Server oder Client fungieren soll. Derzeit liefern je vier Funktionen für Client und Server die SSL_METHOD-Daten. Die Namen der Funktionen haben den Aufbau "<Protokoll>_<Typ>_method()". "<Protokoll>" ist entweder SSLv2, SSLv3, TLSv1 oder SSLv23. Die ersten drei legen das jeweilige namensgleiche Protokoll verbindlich fest. SSLv23 erlaubt hingegen Verbindungen vom Typ SSLv2, SSLv3 und TLSv1; davon abhängig, was die Gegenseite akzeptiert. "<Typ>" steht entweder für Client oder für Server – je nachdem ob das Programm Verbindungen über diesen Kontext aufbauen (Client) oder auf eingehende Verbindungen warten will (Server).

Alle Schritte sind sowohl bei einem Server, als auch bei einem Client identisch auszuführen. Nach dem Aufruf von SSL_CTX_new() trennen sich nun aber die Wege von Server und Client.

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.

Nun ist es an der Zeit für einen ersten Test. Da der Client erst im nächsten Teil folgt, soll der erste Test an der Stelle mit OpenSSL-Bordmitteln erfolgen. Die Verbindung zum WOPR-Server erfolgt vorerst mit dem s_client von OpenSSL.

Der Server lässt sich nun wie folgt starten:

./wopr <host>:<port> servercert.pem private.key

In der Kommandozeile finden sich neben dem Host (oder 0.0.0.0, :: oder *) und dem zu belauschenden Port noch das Server-Zertifikat (servercert.pem) und der dazugehörige private Schlüssel (private.key). Das Server-Zertifikat, das das Makefile erzeugt, ist "self-signed". Alternativ lässt sich auch ein von einer CA signiertes Zertifikat einsetzen.

Der folgende Befehl startet den s_client und verbindet ihn mit dem WOPR-Server:

openssl s_client -connect <host>:<port> -ssl3

Sowie sich der s_client mit dem Server verbunden hat, kann der Dialog analog zum Film
stattfinden. Die Befehle HELP LOGON, HELP GAMES und LIST GAMES lassen sich sowohl auf dem LOGON-Prompt als auch nach dem Logon eingeben. Sie antworten immer mit dem passenden Text.

Als Logon-Namen sind JOSHUA und MCKITTRICK definiert. Je nach Logon-Namen erscheint eine passende Begrüßungsmeldung. Nur nach dem Logon kann man die Befehle TIC-TAC-TOE, DISCONNECT, LOGOFF und alle in der Liste von LIST GAMES aufgeführten Spielenamen angeben.

Falsche Befehle und falsche Logon-Namen in der Logon-Phase führen zu einem Disconnect. Sie lassen sich als falsche Logons interpretieren. Falsche Befehle nach dem erfolgreichen Logon liefern eine Fehlermeldung darüber, dass der Befehl nicht gültig ist. Falsche Logon-Namen zeigen schön, wie ein Server die Verbindung trennt und der Client darauf reagiert. Ein DISCONNECT nach dem Logon zeigt hingegen, wie der Client die Verbindung trennt und der Server darauf reagiert.

Zugegeben, das Verhalten der Befehle ist nicht immer logisch. Es entspricht jedoch dem Dialog des Films. Was die Befehle an der Stelle für Reaktionen produzieren, ist technisch an der Stelle auch nicht wichtig. Das Programm zeigt jedoch, wie einfach es im Grunde ist, sichere Netzwerkkommunikation in C/C++-Programmen über OpenSSL bereitzustellen. Außerdem lässt sich der grundsätzliche Aufbau von WOPR auf andere, neue OpenSSL-Programme übertragen und dadurch als Schablone verwenden.

s_client bietet noch keine schöne Darstellung. Im zweiten Teil ändert sich das jedoch. Er geht auf die OpenSSL-Programmierung des Clients – das WOPR-Terminal – ein.

Oliver Müller
ist Geschäftsführer der OMSE Software Engineering GmbH. Er leitet die Bereiche Software Engineering, Nearshoring und IT-Sicherheit.
(ane)