zurück zum Artikel

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

Oliver Müller

Der erste Artikel zur OpenSSL-Implementierung legte der Fokus auf den SSL-Server. Der zweite Teil konzentriert sich auf das zukünftige Client-Programm. Mit dem Gespann aus Server und Client des Beispiels ergibt sich eine abgeschlossene Sicht auf die OpenSSL-Programmierung.

Der erste Artikel [1] zur OpenSSL-Implementierung legte der Fokus auf den SSL-Server. Dieser Beitrag konzentriert sich auf das zukünftige Client-Programm. Mit dem Gespann aus Server und Client des Beispiels ergibt sich eine abgeschlossene Sicht auf die OpenSSL-Programmierung und eine Grundlage für eigene Experimente.

Der erste Teil behandelte den SSL-Server "WOPR". Erste Tests der Server-Funktionen waren mit OpenSSLs eigenem s_client möglich. An der Stelle soll nun ein speziell für WOPR programmierter SSL-Client – das Terminal – folgen. Auch der zweite Teil orientiert sich an dem bisweilen recht eigenwilligen Dialog mit dem Computer WOPR aus dem Film "War Games".

Für das Client-Programm gelten die gleichen Bedingungen fürs Kompilieren und Linken aus Teil 1: Ein moderner C++-Compiler, wie GNUs g++, GNU make und ein Unix-kompatibles System oder eine Unix-kompatible Systemschicht, zum Beispiel Cygwin, sind unabdingbar.

Mehr Infos

OpenSSL – Struktur und Beispielcode

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

Im Gegensatz zum WOPR-Server ist das WOPR-Terminal eher schlicht gestrickt. Es wird vollständig in der Datei terminal.cpp implementiert. Den Aufwand, eine separate Klasse zu erzeugen, spart man durch die einfachen Funktionen. Das Terminal arbeitet die Initialisierungsschritte sequenziell ab und endet in einer Schleife, die das Wechselspiel aus Befehlseingabe und Serverausgabe verarbeitet. Alles liegt in der – wenn auch etwas größeren, aber dennoch überschaubaren – main()-Funktion.

Die ersten Schritte hin zu den OpenSSL-Funktionen sind beim Client identisch mit denen des Servers.Im Terminal-Programm ist SSL_library_init() zum Initialisieren von OpenSSL die erste aufgerufene Funktion. Anschließend laden SSL_load_error_strings() und ERR_load_BIO_strings() die Fehlermeldungen. Schließlich erzeugt wieder SSL_CTX_new() den SSL-Kontext – dieses Mal jedoch für einen SSLv3-Client. Ab jetzt geht es clientspezifisch weiter.

Der nächste Schritt für den Client ist, ihm mitzuteilen, wo er die CA-Zertifikate findet. Auf diese Weise kann der Client Server dahingehend prüfen, ob sie diejenigen sind, für die sie sich ausgeben. Grundsätzlich lassen sich bei OpenSSL mehrere CA-Zertifikate in einer einzigen, großen PEM-kodierten Datei und/oder in einem Verzeichnis mit einzelnen PEM-Dateien für jedes CA-Zertifikat bereitstellen. Beide Orte kann man parallel über den zweiten und dritten Parameter der Funktion SSL_CTX_load_verify_locations() übergeben. Soll einer der beiden nicht genutzt werden, setzt man ihn auf NULL. Als erster Parameter ist das Context-Objekt zu übergeben, für das die CA-Lokationen zu setzen sind. Anschließend können Verbindungen, die man von dem Context-Objekt ableitet, Serverzertifikate auf ihre Gültigkeit überprüfen.

An der Stelle nutzt das WOPR-Terminal lediglich die Option, eine einzige PEM-Datei (gegebenenfalls mit mehreren CA-Zertifikaten) zu laden. Der Verzeichnisparameter ist daher auf NULL gesetzt. Optional erfolgt jetzt das Initialisieren des "pseudo random number generator" (PRNG). Doch hierzu später mehr.

Wie im ersten Teil angesprochen, verwendet OpenSSL eine eigene Abstraktionsschicht zum Kapseln von BSD-Netzwerk-Sockets. Diese BIO-Objekte lassen sich aus dem Context-Objekt um SSL-Verschlüsselung aufgewertet instanziieren. Das ist der nächste Schritt im Terminal-Programm. Die Funktion BIO_new_ssl_connect() erzeugt aus dem Context-Objekt heraus ein neues BIO-Objekt mit SSL-Verschlüsselung.

Über BIO_get_ssl() lässt sich die auf dem BIO thronende SSL-Engine (= SSL-Objekt) ermitteln. Hieran erkennt man das Schichtenmodell. Das SSL-Objekt verwendet das darunter liegende BIO, um seine verschlüsselten Daten ins Netz zu senden und daraus zu empfangen. Das BIO wiederum setzt auf einem Netzwerk-Socket auf, um die Verbindung zum Netz herzustellen. Über das SSL-Objekt als obersten Teil des "Stapels" lassen sich später alle Schreib- und Leseoperationen realisieren.

An der Stelle nutzt das Terminal das SSL-Objekt auch, um in der SSL-Engine das Flag SSL_MODE_AUTO_RETRY zu setzen. Damit geht die SSL-Engine in den "Blocking Mode". Lese- und Schreib-Funktionen kehren nur bei erfolgreich abgeschlossenem "handshake" zurück. Andernfalls würden die Funktionen Fehler zurückliefern, die gesondert in der Applikation zu behandeln wären.

Bislang ist definiert, wie das BIO eine Verbindung mit SSL/TLS in der Protokollversion mit Prüfung über die festgelegten CA-Zertifikate aufbauen soll. Was vor dem eigentlichen Connect noch fehlt, ist das Festlegen des Wohin, also Server-Name, IP-Adresse und entsprechender Port.

Wie die Manpage BIO_s_connect(3) zeigt, gibt es mehrere Wege dafür, entweder Server-Name, IP-Adresse oder Port, gemeinsam oder getrennt, als String oder in binärer Form zu setzen. Der einfachste Weg für Strings, wie sie in Konfigurationsdateien und auf der Kommandozeile des Terminal-Programms vorkommen, ist die Funktion BIO_set_conn_hostname(). Sie erwartet neben dem BIO für das Setzen des "Wohins" einen String mit dem Hostnamen.

Der "Hostname" ist ebenso flexibel und leistungsfähig wie der der Server-Funktion BIO_new_accept() für das Accept-BIO. Lediglich Wildcard-Netzwerkadressen, wie 0.0.0.0, :: und *, sind beim Client freilich nicht zu realisieren. Der Client baut schließlich eine Verbindung zu einem konkreten Server auf. Durch Doppelpunkt abgesetzte Ports sind aber bei BIO_set_conn_hostname() ebenso möglich.

Nun folgt mit dem Aufruf von BIO_do_connect() der entscheidende Moment. Das Terminal versucht, sich mit dem Server zu verbinden. War das erfolgreich, kann man Daten verschlüsselt über das SSL-Objekt zum Server senden oder von ihm empfangen. Allerdings weiß das Programm bislang nicht, ob der Server tatsächlich der ist, für den er sich ausgibt. Grundsätzlich wurde das jedoch im Zuge der zweiten Phase des SSL-Handshakes geprüft. Nur das aufrufende Programm kennt das Ergebnis noch nicht.

Über die Funktion SSL_get_verify_result() lässt sich mit dem SSL-Objekt das Prüfungsergebnis abrufen. Bei "X509_V_OK" ist das Zertifikat gültig, bei einem anderen Rückgabewert ist es ungültig. Das WOPR-Terminal beschränkt sich lediglich darauf, einen Hinweis zu geben, ob das Zertifikat gültig war oder nicht. Weitere Maßnahmen ergreift es nicht.

Des Weiteren wäre es an der Stelle möglich (und im Echtbetrieb sinnvoll), über die Funktion SSL_get_peer_certificate() das Zertifikat des Servers auszulesen und näher zu untersuchen. Es ließe sich über X509_get_subject_name() etwa die Subject-DN des Zertifikats mit dem Hostnamen beziehungsweise den aufgelösten Namen der IP-Adresse vergleichen. Auf die Prüfungen, die unter Umständen Namensauflösungen nach sich ziehen, verzichtet das Beispiel im Zuge eines überschaubareren Programmcodes. Damit ist das Terminal für das Übertragen der Daten via Netzwerk vorbereitet.

Erwähnt seien noch ein paar Worte zum Initialisieren des PRNG. In der ersten Phase des SSL-Handshake sendet der Client eine 32-Bit-Zufallszahl an den Server, die später verwendet wird, um das "pre master secret" zu erstellen. Für das Erzeugen der Zufallszahl verwendet OpenSSL den PRNG. Er ist, bevor er seine Arbeit aufnehmen kann, mit einer Saat (= Seed) aus zufälligen Bytes zu befüllen. Das erfolgt bei OpenSSL implizit, sofern das Betriebssystem die notwendigen Random-Devices (etwa /dev/random oder /dev/urandom) bereitstellt. Auf manchen Systemplattformen sind sie jedoch optional oder gar nicht vorhanden. Beispielsweise war bei Solaris noch in Version 8 ein separater Patch einzuspielen, um ein solches Gerät bereitzustellen. Auf z/OS ist es heute noch notwendig, die "Integrated Cryptographic Service Facility" (ICSF) explizit zu aktivieren.

Ließ sich der PRNG-Acker mangels Unterstützung seitens des Systems nicht automatisch "säen", erntet man beim Connect die Fehlermeldung "PRNG not seeded". Es hilft, den PRNG händisch mit der notwendigen Saat auszustatten. Über die Funktion RAND_load_file() lassen sich Bytes aus einer Datei als Seed laden. Der erste Parameter ist die zu lesende Datei, der zweite die Anzahl Bytes.

Auf der Kommandozeile des Terminals kann man eine solche Seed-Datei angeben. Ist sie übergeben, liest das Terminal-Programm mit RAND_load_file() ein MByte an Seed-Daten für den PRNG ein. Eine solche Seed-Datei ist mit Vorsicht zu wählen. Sie muss vor unbefugtem Zugriff gesichert sein. Einem PRNG-Device wie /dev/random sollte man aus Sicherheitserwägungen den Vorzug einräumen.

Nach dem Abschließen der Vorarbeiten und dem Zustandekommen einer verifizierten Verbindung tritt der Client in eine Schleife ein. In ihr prüft das Programm mit select(), an welchem Handle (stdin oder Socket des BIO) Daten zum Lesen oder Schreiben anliegen. Je nachdem wo Daten vorzufinden sind, leitet der Client die Lese- oder Schreiboperationen ein.

Wie im WOPR-Server aus dem ersten Teil geht der Client den Weg über select(), um effizient den Multiplexbetrieb über das Socket zu realisieren. Nachdem für das Realisieren des Multiplexbetriebs für die Netzwerkverbindung ohnehin select() zum Einsatz kommt, bietet es sich für die Tastatureingaben ebenfalls an. Unix führt seinen universellen "alles ist eine Datei"-Ansatz auch und gerade in den Systemfunktionen fort. Ob ein Socket oder eine Datei – alles wird über ein Handle angesprochen. Damit lassen sich anstehende Tastatureingaben – genauer Daten über den Standardeingabekanal – ebenfalls über select() abfragen. Das Hinzufügen des Handles von stdin zu rfds genügt.

Damit ergeben sich drei parallel zu bedienende Ein-/Ausgabekanäle:

  1. Eingaben über das BIO,
  2. Ausgaben über das BIO und
  3. Eingaben über stdin.

Exakt in der Reihenfolge arbeitet das Programm die bereiten Kanäle ab und es räumt Serverantworten eine höhere Priorität ein, vor dem Senden neuer über Tastatur eingegebener Kommandos. Damit unterstützt es den zeitlichen Ablauf des Dialogs zwischen Client und Server.

Gibt FD_ISSET() zurück, dass das Socket-Handle zum Lesen bereit ist, liest SSL_read() die anstehenden Daten in den Lesepuffer rbuf ein. Dabei werden maximal BUFSIZE Bytes, die die Größe des Puffers repräsentieren, gelesen. Weist SSL_get_error() aus, dass kein Fehler (SSL_ERROR_NONE) aufgetreten ist, gibt das Programm die gelesenen Daten auf dem Standardausgabekanal aus.

Sollte das Programm unter z/OS UNIX System Services laufen, erfolgt zuvor das Konvertieren von ASCII zu EBCDIC mit __atoe(). Damit die WOPR-Varianten auf den unterschiedlichen Plattformen zusammenarbeiten können, ist der Schritt notwendig. Die Netzwerkkommunikation bei WOPR erfolgt per Definition in ASCII.

Sollte SSL_get_error() einen Fehler liefern, gehen Fehlermeldungen auf cerr (stderr) heraus. Eine Sonderstellung nimmt der Fehler SSL_ERROR_ZERO_RETURN ein, der lediglich angibt, dass 0 Bytes geliefert wurden. In dem Fall wurde schlicht die Verbindung getrennt.

Zugegeben, die alten goto-Anweisungen könnten an sich von schlechtem Stil deuten. Mit ihnen ist es jedoch möglich, platzsparend und effizient zu arbeiten. Daher kommen sie hier wohlüberlegt zum Einsatz. Da nur maximal BUFSIZE Bytes gelesen wurden, könnten noch Daten zum Lesen anstehen. Daher erfolgt das Lesen in einer Schleife. Ob noch Daten anstehen, liefert die Funktion SSL_pending().

Das Ausgeben von Daten über das BIO und das Eingeben über die Tastatur stehen in engem Zusammenhang. Schließlich sind die Eingaben Befehle, die über das BIO an den Server gehen sollen. Um das Lesen und Schreiben der Befehle zu entkoppeln, gehen die Eingaben in einen Schreibpuffer wbuf, der beim Ausgeben der Daten ausgelesen wird.

Ist das Socket-Handle zum Schreiben bereit und liegen Daten in wbuf, gehen sie über die Funktion SSL_write() ins Netzwerk. Der gesamte Puffer wandert in einem Aufruf ins Netz. War er erfolgreich, positioniert man den Schreibzeiger wbuf_offset.

Warum ein Schreibzeiger, mag sich der eine oder andere fragen. Es kann vorkommen, dass SSL_write() nicht alle Daten in einem Aufruf versenden konnte. Die Funktion liefert deshalb die Anzahl der gesendeten Bytes zurück. Ließen sich nicht alle Daten schreiben, geht wbuf_offset auf das erste noch nicht gesendete Byte. Beim nächsten Aufruf setzt das Senden an dem Byte wieder an. Konnte man hingegen alle Bytes senden, geht wbuf_offset auf das erste Byte von wbuf zurück, das man auf ein Nullbyte setzt.

Liegen Dateneingaben für stdin an, gehen sie über read() in einen temporären Puffer. Die gelesenen Daten hängt strcat() an die Daten in wbuf an. Auf z/OS erfolgt zuvor noch die Konvertierung von EBCDIC nach ASCII mit __atoe().

Das Beispiel ist recht einfach gehalten, um die Verständlichkeit zu fördern. Der Client ist in der Form durch das ungeprüfte Anhängen von Daten an wbuf anfällig für einen Buffer-Overflow. Für ein produktives System wäre das zu vermeiden.

Wie im ersten Teil gezeigt lässt sich der Server starten:

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

Folgender Befehl startet nun das zugehörtige WOPR-Terminal:

./terminal <host>:<port> <trust-certificates.pem>

Neben dem Host und dem Port, auf dem die Verbindung erfolgen soll, benötigt man eine Trust-Zertifikatsdatei im PEM-Format. Im einfachsten Fall lässt sich einfach das vom Makefile erzeugte servercert.pem mit dem selbst signierten Server-Zertifikat angeben. Darauf meldet der Client beim Connect, dass das Server-Zertifikat gültig ist. Kommt ein CA-signiertes Server-Zertifikat zum Einsatz, muss sich das CA-Zertifikat, das das Server-Zertifikat signiert hat, in der PEM-Datei enthalten sein. Optional lässt sich als zusätzliches Argument eine Seed für den PRNG anhängen. Beispielsweise eine Datei oder ein "special device".

Sowie sich der Client mit dem Server verbunden hat, kann der Dialog zwischen den beiden beginnen. Wie am Ende des ersten Teils erläutert, lassen sich nun die Befehle aus "War Games" absetzen.

Ein paar Worte bedarf es noch zu IPv6. Obwohl das Protokoll seit geraumer Zeit Einzug in die Praxis hält, hat OpenSSL noch heute Schwierigkeiten mit ihm. Die BIO-Bibliothek, die Sockets kapselt, konnte bis Version 1.0.0 von OpenSSL überhaupt nicht mit IPv6 umgehen. Seitdem kann wenigstens BIO_new_accept() mit "Hostnamen" wie fc9e:21a7:a92c:2424::1:7777, die sich in den Host fc9e:21a7:a92c:2424::1 und den Port 7777 teilen, umgehen. Somit lassen sich wenigstens Server-Prozesse jetzt unverändert in die schöne neue IPv6-Welt befördern.

Die an sich leistungsfähige Funktion BIO_set_conn_hostname() streckt jedoch auch in Version 1.0.0 angesichts eines solchen Hostnamens die Waffen. Fehlermeldungen der Art

3147065860:error:2006A066:BIO routines:BIO_get_host_ip:
bad hostname lookup:crypto/bio/b_sock.c:149:host=fc9e

zieren den Bildschirm. Offensichtlich geschieht das Teilen in Hostname und Port beim ersten Auftreten eines Doppelpunkts.

Nun kann man für Client-Programme warten, bis eine (hoffentlich baldige) Lösung für das Problem am Horizont aufsteigt. Alternativ lassen sich Clients jedoch so umstricken, dass sie nicht mehr BIOs über BIO_new_ssl_connect() erzeugen. Stattdessen kann man Sockets vom Typ AF_INET6 mit socket() erzeugen und über connect() mit dem Server verbinden. Ein SSL-Objekt ist anschließend durch SSL_new() aus dem Context-Objekt heraus zu erzeugen. Danach lässt sich das Socket-Handle mit SSL_set_fd() dem SSL-Objekt zuweisen. Ein SSL_connect( rundet das SSL-Handshake ab. Ab dem Punkt geht es gewohnt mit SSL_get_verify_result() und den entsprechenden Schreib- und Leseoperationen weiter.

Eigentlich sollte die BIO-Library solche Ausflüge in die Welt der Socket-Programmierung verhindern. Bleibt zu hoffen, dass die nächste OpenSSL-Version IPv6 vollumfänglich unterstützt.

OpenSSL ist nicht zuletzt durch die freie Verfügbarkeit der Quelltexte inzwischen auf sehr unterschiedlichen Plattformen verfügbar. Das Beispiel WOPR zeigt, wie einfach die Portierung einer Unix-konformen Lösung ist. WOPR läuft mit unveränderten Quelltexten auf einer Vielzahl Unix-ähnlicher Systeme. Unix-Derivate, wie *BSD, AIX und Solaris, unixoide Systeme wie Linux und QNX, Windows mit Cygwin und sogar Exoten wie Syllable oder MINIX und der Dinosaurier der Systeme – z/OS mit seinen Unix System Services – werden unterstützt.

Grundsätzlich wäre die Lösung mit OpenSSL auf anderen nicht Unix-ähnlichen Betriebssystemen lauffähig – beispielsweise unter nativen Windows-Systemen ohne Unix-Nachbildungen wie Cygwin und unter OpenVMS. Allerdings bedürfte das einiger Änderungen im Programm. Sowohl Windows als auch OpenVMS implementieren zwar ein weitgehend Unix-kompatibles select(), allerdings ist das nur auf Sockets anwendbar. Ein select() auf einem Datei-Handle wie stdin unterstützen beide Plattformen nicht. Hier wären Änderungen im WOPR-Terminal unabdingbar.

Außerdem implementieren beide Plattformen kein fork(). Unter Windows wären die API-Funktionen CreateProcess() oder CreateThread(), unter OpenVMS ein Gespann aus vfork() mit decc$set_child_standard_streams() und den exec*()-Funktionen die adäquate Lösung. Die Ansätze sind jedoch mit der hinter dem Unix-fork() stehenden Philosophie nicht vereinbar.

Doch die Änderungen beziehen sich nicht auf den Einsatz der OpenSSL-Funktionen. Die Änderungen sind beschränkt auf systemspezifisches "Drumherum". Die eigentlichen SSL-spezifischen Programmteile blieben unverändert einsetzbar. Die Unabhängigkeit ist der Vorteil von OpenSSL gegenüber anderen SSL-Implementierungen. OpenSSL funktioniert systemübergreifend. Damit sind OpenSSL-Programme an sich nicht schwieriger zu portieren als beliebige andere Programme.

Deutlich zu sehen ist das bei eComStation (vormals OS/2). Mit kNIX existiert eine Unix-kompatible Ausführungsschicht aus dem Open-Source-Lager. Sowohl der WOPR-Server als auch das WOPR-Terminal lassen sich mit GNU C++ 3.x und höher problemlos kompilieren und linken. fork() ist implementiert und funktioniert einwandfrei. Der Server kann damit ohne weitere Modifikationen auf der im Kern auf dem Stand von Anfang der 2000er stehen gebliebenen Plattform seine Dienste bereitstellen.

select() arbeitet jedoch nicht einwandfrei mit Datei-Handles zusammen. Das WOPR-Terminal startet einwandfrei und verbindet sich auf den Server. Es kann aber durch die select()-Einschränkung keine Tastatureingaben erkennen. WOPR-Terminals anderer Plattformen arbeiten jedoch problemlos mit dem WOPR-Server auf eComstation beziehungsweise OS/2 Warp 4.x zusammen.

Außen vor bleiben lediglich Systeme, die für OpenSSL nicht die entsprechende Umgebung schaffen. Beispielsweise ist eine Portierung von OpenSSL auf das einst von AT&T als Unix-Nachfolger geplante Plan9 durch den eigenwilligen C-Dialekt schwierig. Selbst wenn OpenSSL auf Plan9 verfügbar wäre, würde es einen mittleren Gewaltakt bedeuten, WOPR auf Plan9 zum Laufen zu bringen. Auf dem Betriebssystem steht lediglich mit zusätzlichem Aufwand ein alter AT&T cfront als C++-Compiler zur Verfügung. Er ist aber nicht in der Lage, moderne C++-Programme zu kompilieren. Vergleichbares gilt für Forks des Plan9-Projekts wie Inferno und Plan B.

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


URL dieses Artikels:
https://www.heise.de/-1058771

Links in diesem Artikel:
[1] https://www.heise.de/ratgeber/OpenSSL-Implementierung-innerhalb-eines-Client-und-Server-Programms-Teil-1-1050619.html
[2] https://www.heise.de/ratgeber/OpenSSL-Implementierung-innerhalb-eines-Client-und-Server-Programms-Teil-1-1050619.html
[3] ftp://ftp.heise.de/pub/ix/developer/
[4] mailto:ane@heise.de