Von C nach Java, Teil 4: Datenkompression und Verschlüsselung

Seite 3: Komprimierung mit C

Inhaltsverzeichnis

Zusätzlich zur Komprimierung wendet der Autor nun eine professionelle Verschlüsselung an, also mindestens eine Methode, die dem AES (Advanced Encryption Standard) entspricht. Derartige Methoden machen es – trotz vorliegendem Ver- und Entschlüsselungsquelltext – nahezu unmöglich, die Datei zu entschlüsseln, ohne dass das entsprechende Passwort bekannt ist.

Der Zugriff auf externe Bibliotheken ist hierzu unter C unumgänglich. Auch die Wahl der Plattform ist zu beachten. Der Artikel beschreibt die Funktionen rund um wincrypt (für die Windows-Portierung), unter den Unix-Derivaten gibt es im Open-Source-Umfeld genügend andere Angebote. Als weitere Aufgabe, soll das Programm hinsichtlich seines Verhaltens optimiert werden. Es geht darum, beim Einsatz mehrerer Prozessorkerne diese auch zu nutzen, also "multithreaded" zu programmieren.

Der wohl am CPU-intensivste Programmteil ist der für die Komprimierung und Verschlüsselung des Puffers zuständige. Hier können durch geschickte Entwicklung mehrere Threads gleichzeitig Datenblöcke komprimieren und verschlüsseln. Einzig das Schreiben des Blocks muss koordiniert ablaufen, schließlich kann nicht jeder Thread, wenn er seine Arbeit verrichtet hat, wahllos das Ergebnis in die Zieldatei schreiben.

Im weiteren Verlauf werden das Programm in C und abschließend das Pendant hierzu in Java vorgestellt. Bei der Java-Implementierung ist aufgrund der schlankeren Umsetzung sogar noch eine grafische Oberfläche dabei, ohne dass der Quelltext hierfür größer wäre als im C-Programm.

Doch nun zum entsprechendem C-Programm. Es soll über die Kommandozeile zu steuern sein, wobei eine Reihe an Optionen unterstützt wird (siehe nachfolgende Liste, die die für das Programm FileCompress unterstützten Optionen anzeigt.

Option Argument Default-Wert Beschreibung
-b Block Size in KByte 8192 Blockgröße des Ein-/Ausgabepuffer in Kilobytes
-d N/A N/A Programm dekodiert die Eingangsdateien
-D N/A aus Schaltet den Debug-Modus ein
-f N/A aus force overwriting output file (Ausgabedatei wird überschrieben)
-p Passwort im Klartext N/A Passwort - hiermit wird das Verschlüsseln aktiviert
-r N/A N/A Löscht die Quelldatei nach Beendigung
-v N/A aus set verbose flag - vermehrte Statusausgaben während des (De-)Kodierens

Die Optionen lassen sich auch kombinieren, beispielsweise ist -vb24 denkbar, das den Verbose-Modus einschaltet und die Blockgröße auf 24 KByte setzt.

Im Listing 3 sieht man den Quelltext für das Parsen der Kommandozeile. Aus Platzgründen wird beim Allokieren kleiner Speicherbereiche die Abfrage nach Rücklieferung eines NULL-Pointers verzichtet, der in aller Regel auf zu wenig zur Verfügung stehenden Arbeitsspeicher deuten würde. Die Programmoptionen sind als globale Flags abgebildet, während das Beispiel die zu verarbeitenden Dateien in einer globalen Liste ablegt. Ist die Liste leer, ließe sich später von der Standardeingabe lesen. Die Hauptschleife arbeitet die Liste mit den zuvor aus der Kommandozeile ermittelten Dateinamen ab:

int processFileList(char **fileList, int fileListCount) {
int i;

for (i=0;i<fileListCount;i++) {
processFile(fileList);
}
return fileListCount;
}

Die Hauptarbeit findet in der Funktion [i]processFile() statt, die als Argument die zu bearbeitende Datei besitzt. Das beschriebene Programm kann zwar über die Kommandozeile Daten von der Standardeingabe verarbeiten, jedoch ist der Code hierzu aus Platzgründen nicht implementiert. Um das im Nachhinein zu bewerkstelligen, ließen sich beispielsweise im Vorfeld der Standardeingabe Daten in eine temporäre Datei einlesen, die anschließend eben die Funktion processFile() verarbeitet und abschließend löscht. Listing 4 zeigt den Quelltext der Funktion processFile().

Zu Beginn wird ein Name für die Ausgabedatei erzeugt, der stets nach dem folgenden Muster aufgebaut ist:

char *outFileNamePattern="outFile__%012x.fcc"

Die aktuelle Anzahl an Millisekunden nach dem 1.1.1970, die der Aufruf von ftime() zurückgibt, dient der eindeutigen Bestimmung des Ausgabedateinamens. Auf das Verwenden (von Teilen) des ursprünglichen Dateinamens verzichtet der Autor bewusst. Der Name wird zu Beginn der Ausgabedatei – rudimentär verschlüsselt – abgelegt. Eingangs der Ausgabedatei steht ein vier Byte großer "Magic String":

static unsigned char magicHdr[] = { 0xfd, 0xe8, 0xdf, 0x80 };

Das letzte Byte hiervon ist entweder 0x80 (unverschlüsselt) oder 0x81 für einen verschlüsselten Eintrag. Sollte die Datei verschlüsselt sein, folgt nun eine 16 Byte große MD5-Checksumme des
verwendeten Passworts. Mit der Methode lässt sich beim Dekodieren das Passwort überprüfen, ohne dass es reproduzierbar in der Zieldatei abgespeichert wäre. Im C-Programm übernimmt die wincrypt API mit entsprechenden Funktionen das Bilden der MD5-Summe. Dann folgt die Länge des Dateinamens, die in einem Byte abgelegt ist, womit der Pfadname der Datei auf 255 Zeichen Länge begrenzt ist. Es schließen sich die einzelnen Blöcke mit den komprimierten und gegebenenfalls verschlüsselten Daten an. Zu Beginn eines jeden Blocks steht in 2 Byte die Länge des Blocks, die für den Dekodierer unabdingbar ist.

Für das Komprimieren und Verschlüsseln des Blocks ist eine eigene Funktion vorgesehen – nämlich die als Thread implementierte processBlock(). Deshalb lässt sich nur eine einzige Variable übergeben, weswegen die benötigten Parameter in eine Struktur platziert werden. Wichtig ist außerdem, dass man den benötigten Speicherblock jeweils neu allokiert und nach Schreiben des Blocks entsprechend wieder freigibt.

Die vorbereitenden Handlungen vor Aufruf von processBlock() bestehen also darin, die für den Kodierer benötigten Variablen wie Blocknummer, Blockgröße oder den Zeiger auf die Eingangsdaten in eine Struktur unterzubringen, deren Zeiger als Option an processBlock() übergeben wird. Da diese Funktion als Thread verarbeitet werden soll, aktiviert man sie nicht direkt, sondern wie in diesem Beispiel über die Windows-API-Funktion CreateThread(). Zu beachten ist nun, dass die weitere Ausführung der Schleife sofort weitergeht, also wird zeitgleich zum Kodieren des ersten Blocks bereits der zweite eingelesen.

Nach erfolgter Kodierung muss in dem Thread das Schreiben in die Zieldatei erfolgen. Der Vorgang unterliegt unbedingt einer Synchronisierung, denn die Reihenfolge der Blöcke und die exklusive Verarbeitung der Schreibvorgänge sind einzuhalten, um ein heilloses Durcheinander zu verhindern, das bestenfalls zum Vermischen der Daten und wahrscheinlicher sogar zum bösen Absturz des Programms führen würde. Hierzu gibt es im Betriebssystem geeignete Mechanismen wie Semaphoren beziehungsweise die vereinfachte Form, das sogenannte Mutex. Letzteres ist ein Betriebssystemobjekt, das die exklusive Ausführung von Programmcode bei Verwendung von Threads ermöglicht, die die gleichen Programmteile ausführen.

Im Grunde reichen wenige Aufrufe aus, um das zu bewerkstelligen, zum Beispiel unter Windows:

Aufruf Beschreibung
CreateMutex() Legt das Mutex an
WaitForSingleObject() Wartet auf Freigabe des Mutex durch andere Threads
Exklusiv ausgeführter Code (innerhalb eines Threads) Hier wird Programmcode exklusiv ausgeführt
ReleaseMutex() Gibt das Mutex nach exklusiver Verarbeitung frei

In Listing 5 sieht man nun den Programmcode von processBlock(), einer als Thread ausgeführten Funktion, die die Komprimierung, optional die Verschlüsselung und abschließend das Schreiben umfasst, das unbedingt in fester Reihenfolge passieren muss. Wie im Listing ersichtlich, ist die Komprimierung mit nur einer einzigen Zeile das weitaus unkompliziertere Unterfangen. Die Verschlüsselung verschlingt wohl am meisten Programmcode und bedient sich jetzt der wincrypt API. Wäre das gesamte Programm nicht "multithreaded" angelegt, hätten sich die meisten Aufrufe in der Schleife vermeiden lassen. Pro Verschlüsselungsvorgang hätten ein Handle beziehungsweise Hashkey ausgereicht und sich das Verschlüsseln eines von mehreren Blöcken ebenfalls in nur einer Zeile erledigt, nämlich dem Aufruf von CryptEncrypt(). Da aber mehrere Threads gleichzeitig zu verschlüsseln sind und die winCrypt API nicht Multithread-fähig ist, ist für jeden Block ein eigener Handle beziehungsweise Hashkey zu erzeugen.

Zum Abschluss muss der Entwickler den komprimierten (und gegebenenfalls verschlüsselten) Block schreiben. Das muss synchronisiert erfolgen. Das heißt, der Block mit der ID "n" darf erst geschrieben werden, wenn das Schreiben des Blocks "n-1" abgeschlossen ist. Damit der Thread jetzt an der Stelle nicht blockiert, wird das Warten auf den Vorgängerblock ebenfalls in einem neu erzeugten Thread erledigt (Funktion flushBlockToFile()). Darauf folgt der Code der Thread-Funktion, die den aktuellen Block schreibt.

int flushBlockToFile(PBP_S pbp_s) {
int i, os=pbp_s->outputSize;

if (pbp_s->blockNo > 0) {
fprintf(stderr,"Waiting for block #%d to be written ...\n",
pbp_s->blockNo-1); fflush(stderr);
waitForBlockNoWritten(pbp_s->blockNo-1);
fprintf(stderr,"Waiting for block #%d to be written succeeded!\n",
pbp_s->blockNo-1); fflush(stderr);
}
if (pbp_s->outputSize <= 0) return -1;
for (i=0;i<4;i++) {
fputc(os & 0xff, pbp_s->ofp); os >>= 8;
}
if (fwrite(pbp_s->outputBlock, 1, pbp_s->outputSize, pbp_s->ofp)
<pbp_s->outputSize) {
fprintf(stderr,"Could not write buffer - filesystem
full?\nprocessFile() failed!\n");
fclose(pbp_s->ofp);
return -1;
}
#ifdef WIN32
WaitForSingleObject(blocksWrittenMutex, INFINITE);
blocksWrittenList=realloc(blocksWrittenList,
(1+blocksWrittenCount)*sizeof(int));
blocksWrittenList[blocksWrittenCount]=pbp_s->blockNo;
blocksWrittenCount++;
ReleaseMutex(blocksWrittenMutex);
free(pbp_s->outputBlock);
#endif
return 1;
}

Der umgekehrte Fall, also die Dekomprimierung und Entschlüsselung, ist das einfachere Unterfangen, denn das Dekomprimieren ist aus Sicht des Algorithmus weniger komplex und daher weniger zeitintensiv. Deshalb ist ein Verpacken der Dekodierung in Threads nicht unbedingt notwendig.

Über die Kommandozeile wird dem Programm FileCompress mit der Option -d mitgeteilt, dass die Dekodierung erwünscht ist. Dementsprechend wird innerhalb von processFile() die Funktion processDecompression(fileName) aufgerufen, die das weitere Geschehen abwickelt und in Listing 6 näher beschrieben ist.

Nach dem Öffnen der Datei wird zunächst der 4 Byte umfassende Header gelesen. Die ersten 3 Byte müssen dem Magic Code (0xfd, 0xe8, 0xdf, 0x80 oder 0x81) entsprechen, im gegenteiligen Fall wird die Funktion nach Ausgabe einer entsprechenden Fehlermeldung mit dem Code -1 verlassen. Das vierte Byte entscheidet darüber, ob neben der Komprimierung eine Verschlüsselung vorgenommen wurde (0x80 bedeutet keine, 0x81, dass verschlüsselt wurde).

Bei Verschlüsselung ist das über die -p-Option der Kommandozeile zuvor übermittelte Passwort erforderlich. Eine Fehlermeldung wird ausgegeben, wenn das Passwort nicht übergeben wurde, alternativ kann der Leser an der Stelle das Programm dahingehend erweitern, dass es das Passwort über die Standardeingabe abfragt (wobei das "Echo" ausgeschaltet sein sollte).

Beim Vorliegen einer verschlüsselten Datei sind die nächsten 16 Byte die MD5-Summe des vergebenen Passworts, die der Kodierer zuvor in die Zieldatei geschrieben hat. So lässt sich einfach und unkompliziert das Passwort überprüfen. Sollte es nicht stimmen, wird die Funktion nach Ausgabe einer entsprechenden Fehlermeldung mit -1 verlassen.

Ebenfalls in die Zieldatei hat der Kodierer zuvor den Dateinamen der Quelldatei abgelegt, der mit einer simplen Methode "unkenntlich" gemacht wurde (XOR-Verknüpfung mit Zufallszahlen). Nach der Ermittlung des ursprünglichen Dateinamens wird auf ein etwaiges Vorhandensein der Quelldatei geprüft. Sollte sie existieren, muss zum Überschreiben die -f-Option (f = force overwrite) über die Kommandozeile mitgegeben worden sein.

Als letzter Parameter steht nun in 2 Bytes die Quell-Blockgröße in KByte. Mit dem Wert, der für die spätere Verwendung in blockSizeKB gespeichert wird, lässt sich der Zielpuffer exakt allokieren. Darauf erfolgt die Dekodierung Block für Block in einer Schleife. Zu Beginn des Blocks steht in einer vier Byte großen Speicherzelle die Blocklänge (mit dem niederwertigen Byte zuerst). Bei Verschlüsselung des Blocks wird dieser im Folgenden (im Beispiel mit der wincrypt API) entschlüsselt, wobei der Zielblock dem des Quellblocks entspricht, also der Quellblock überschrieben wird. Dann erfolgt die Dekomprimierung in die Zieldatei, womit im Wesentlichen die Dekodierung der Datei beschrieben wäre.

Das Listing 6 verdeutlicht, wie viel Code zum Umsetzen des eigentlich recht unkomplexen Vorgangs der Dekodierung in C erforderlich ist. Wenn das Programm unter Linux laufen soll, sind entsprechende Modifikationen an dem Quelltext erforderlich. Sollte jetzt noch eine grafische Oberfläche zur bequemen Steuerung des Vorgangs hinzukommen, steigen die Aufwände in beträchtlichem Maße, ganz gleich, für welche Methode sich der Entwickler zur Umsetzung der grafischen Steuerung entscheidet.