Teil 2: Zufallszahlengeneratoren und symmetrische Verschlüsselung

Kryptografie für Anfänger: Zufallszahlengeneratoren und symmetrische Verschlüsselungen
Keine Kommentare

Computer sind letztlich Rechenmaschinen, daher arbeiten sie deterministisch. Wenn aber Zufall eine große Rolle in der Kryptografie spielt, wie lässt sich das miteinander vereinbaren?

Im ersten Teil dieser Serie wurde im Zusammenhang mit dem One-Time Pad (OTP) erwähnt, wie wichtig das Verwenden von Zufall beziehungsweise Zufallszahlen ist, um wiederkehrende Muster zu vermeiden. Diese können in der Verschlüsselung rasch auftreten, da zumindest Textnachrichten in natürlichen Sprachen verfasst werden, die per se besondere Eigenheiten und Muster aufweisen. Diese gilt es aufzubrechen.

Artikelserie

In Anbetracht der enormen Rechenleistung, die heute selbst in Mobiltelefonen zur Verfügung steht, stellt man sich die Frage, wie schwer es denn sein kann, ein paar Zufallszahlen zu erzeugen. Diese Aufgabe sollte doch im 21. Jahrhundert verhältnismäßig einfach zu lösen sein – meint man. Wie schwer das jedoch tatsächlich ist, merkt man, wenn man selbst einmal von Hand versucht, eine zufällige Reihe von Nullen und Einsen aufzuschreiben. Im Folgenden der Versuch des Autors, der durch spontanes Tippen ohne weiteres Nachdenken entstanden ist:

001010101011010101001010101001010010101001010

Analysiert man diese Reihe, stellt man fest, dass weder die eine noch die andere Ziffer mehr als zwei Mal in Folge auftritt – und selbst das ist schon selten. Tatsächlich wechseln sich die beiden Ziffern im Wesentlichen mit schöner Regelmäßigkeit ab. In einer echt zufälligen Reihe müssten auch längere Sequenzen enthalten sein, im Extremfall könnte sie sogar so aussehen:

000000000000000000001111111111111111111111111

Rein statistisch betrachtet ist diese Reihe nicht wahrscheinlicher oder unwahrscheinlicher als die vorige. Auf einen Menschen wirkt sie aber geplant und systematisch.

Die fehlende Unregelmäßigkeit in dem vermeintlich zufällig erzeugten Ergebnis lässt sich durch das Maß der Entropie beschreiben, das angibt, wie unstrukturiert ein Text ist. Verwandt damit ist die Redundanz, die quasi das Gegenteil der Entropie beschreibt: Ein echt zufälliger Text hat eine Redundanz von 0, in natürlich-sprachlichen Texten liegt sie weitaus höher.

Eine eigene Zufallsfunktion bauen

Nun überlässt man in der Kryptografie das Erzeugen von Zufallszahlen selten einem Menschen, sondern verwendet dafür die Mittel der jeweiligen Programmiersprache. Praktisch jede Sprache kennt dafür geeignete Funktionen, wie beispielsweise Math.random in JavaScript. Warum also nicht diese Funktionen verwenden?

Tatsächlich ist das, was diese Funktionen liefern, kein echter Zufall, sondern sogenannter Pseudozufall. Da Computer, wie bereits erwähnt, deterministische Rechenmaschinen sind, können sie per Definition keinen Zufall erzeugen – andernfalls wären sie ja nicht mehr deterministisch. Stattdessen wird für Pseudozufallszahlen auf mathematische Funktionen zurückgegriffen, die so komplex sind, dass ihre Ergebnisse zufällig aussehen, auch wenn sie es streng genommen nicht sind.

Ein einfaches Beispiel dafür bietet die Funktion in Listing 1.

const getRandomNumber = function (seed) {
  const randomNumber = (seed * 7) % 11;

  return randomNumber;
};

Ruft man diese Funktion auf und initialisiert sie mit dem Wert 1, erhält man im ersten Schritt als Ergebnis die Zahl 7. Verwendet man dieses Ergebnis nun als Ausgangswert für die nächste Iteration, erhält man 5. Anschließend erhält man 2, 3, und so weiter. Die Modulo-Division, die durch % 11 ausgeführt wird, bewirkt, dass die Ergebnisse allesamt zwischen 0 und 10 liegen. Erzeugt man nun zehn Zufallszahlen, erhält man mit dieser Funktion die folgende Reihe:

7 5 2 3 10 4 6 9 8 1

Wie man sieht, ist diese Reihe gar nicht schlecht gewählt. Jede Zahl zwischen 1 und 10 kommt genau einmal vor, das heißt, die Zahlen sind prinzipiell gleichverteilt. Außerdem steigen die Zahlen teilweise, teilweise fallen sie. Manche Zahlen liegen dicht beieinander (wie die 9 und die 8), andere hingegen sind weit voneinander entfernt (wie die 3 und die 10). Alles in allem macht diese simple Funktion also bereits einen guten ersten Eindruck.

Was passiert nun, wenn man mehr als zehn Zufallszahlen benötigt? Leider ist absehbar, was geschehen wird: Da die 1 das zehnte Ergebnis darstellt, wird als elfter Wert wieder die Zahl 7 berechnet werden. Ab der elften (vermeintlichen) Zufallszahl wiederholen sich die Ergebnisse also. Man spricht in diesem Fall davon, dass die gegebene Zufallsfunktion eine Periodität von 10 hat. Auch das initiale Auswählen eines anderen Startwerts ändert daran nicht wirklich etwas, da das nur den Beginn der Reihe verschiebt, nicht jedoch ihre Periodität.

Auffällig ist, dass die Periodität genau jenem Bereich entspricht, auf den die einzelnen Ergebnisse durch die Modulo-Division eingeschränkt werden. Es liegt also nahe, dass man die Periodität vergrößern und die Funktion damit verbessern könnte, indem man einen größeren Wert für die Modulo-Division auswählt, beispielsweise 101. Dann ergeben sich folgende Zahlen für die ersten hundert Aufrufe:

7 49 40 78 41 85 90 24 67 65 51 54 75
20 39 71 93 45 12 84 83 76 27 88 10 70
86 97 73 6 42 92 38 64 44 5 35 43 99 87
3 21 46 19 32 22 53 68 72 100 94 52 61
23 60 16 11 77 34 36 50 47 26 81 62 30
8 56 89 17 18 25 74 13 91 31 15 4 28 95
59 9 63 37 57 96 66 58 2 14 98 80 55 82
69 79 48 33 29 1

Wie man sieht, treten die vorher genannten positiven Aspekte nach wie vor auf, lediglich die Bandbreite an Zahlen hat sich erhöht. Allerdings ist durch den größeren Divisor auch der Wertebereich größer geworden, was wahrscheinlich unerwünscht ist. Um das zu vermeiden, trennt man das Berechnen der nächsten Zufallszahl von der Berechnung des nächsten Initialwerts: Während der zweite auf dem nun bekannten Weg berechnet wird, ergibt sich die Zufallszahl, indem man den neuen Initialwert wiederum % 10 teilt, um einen kleineren Wertebereich zu erhalten. Daraus ergibt sich der Code, der in Listing 2 zu sehen ist.

const getRandomNumber = function (seed) {
  const nextSeed = (seed * 7) % 101;
  const randomNumber = nextSeed % 11;

  return { randomNumber, seed: nextSeed };
};

Führt man diese Funktion nun hundert Mal aus, erhält man die folgenden Zahlen:

7 5 7 1 8 8 2 2 1 10 7 10 9 9 6 5 5 1 1
7 6 10 5 0 10 4 9 9 7 6 9 4 5 9 0 5 2 10
0 10 3 10 2 8 10 0 9 2 6 1 6 8 6 1 5 5 0
0 1 3 6 3 4 4 7 8 8 1 1 6 7 3 8 2 3 9 4
4 6 7 4 9 8 4 2 8 0 3 2 3 10 3 0 5 3 2 4
0 7 1

Nun ist das Ergebnis schon sehr gut: Alle Zahlen zwischen 0 und 10 werden getroffen, die bisherigen positiven Aspekte wurden beibehalten, und gelegentlich treten Zahlen sogar auch mehrfach hintereinander auf. Und, zu guter Letzt: Erst nach hundert Zufallszahlen wiederholt sich die Reihe. Wählt man nun an Stelle von 101 einen noch größeren Divisor, wird die Periodität weiter erhöht.

Trotz diesem guten Ergebnis sollte man in der Praxis darauf verzichten, eine eigene Implementierung einer Zufallszahlenfunktion zu schreiben. Dafür gibt es hinreichend gute, bereits vorgefertigte Algorithmen.

Zu guter Letzt bleibt noch die Frage, warum gerade die Zahlen 7, 11 und 101 ausgewählt wurden. Hätte es mit anderen Zahlen ebenso funktioniert? Die Antwort ist simpel: Prinzipiell funktioniert das Vorgehen auch mit anderen Zahlen, aber man sollte die Zahlen so wählen, dass es sich bei ihnen um Primzahlen handelt. Andernfalls nutzt man nicht den vollständigen Bereich der potenziellen Ergebnisse aus, und verschlechtert das Ergebnis unnötigerweise.

Echten Zufall verwenden

Auch wenn man auf die gezeigte Art gute Ergebnisse für Pseudozufall erzielen kann, sind die Ergebnisse nicht gut genug, um kryptografischen Anforderungen gerecht zu werden. Dafür wird tatsächlich echter Zufall benötigt, oder zumindest etwas, was echtem Zufall möglichst nahekommt. Die Bezeichnung dafür ist kryptografisch sicherer Zufall. Auch hierfür gibt es in Computern Möglichkeiten, wobei hier auf einen anderen Ansatz zurückgegriffen wird.

Statt Zufallszahlen zu berechnen, werden sie hierbei anhand physischer Effekte ermittelt, beispielsweise durch Temperaturschwankungen des Prozessors, Messungenauigkeiten von Sensoren und ähnlicher Werte, die sich gar nicht oder (wenn überhaupt) nur unter Laborbedingungen vorhersagen lassen. Alle gängigen Betriebssysteme bieten entsprechende Schnittstellen an, um derartige Werte abzurufen, die von Programmiersprachen anschließend genutzt werden können.

In Node.js dient dazu die Funktion randomBytes aus dem Modul crypto, der man die Anzahl der gewünschten zufällig erzeugten Bytes und einen Callback mitgibt (Listing 3). Alternativ kann die Funktion auch synchron, das heißt ohne Callback, aufgerufen werden, allerdings blockiert sie dann die weitere Ausführung des Programms, weshalb die asynchrone Variante vorzuziehen ist.

const crypto = require('crypto');

crypto.randomBytes(256, (err, buffer) => {
  if (err) {
    console.log('Failed to get random bytes.');
    return;
  }

  console.log(buffer.toString('hex'));
});

Führt man dieses Programm aus, erhält man die Ausgabe der erzeugten Bytes in Form von hexadezimalen Codes, ähnlich dem Auszug in Listing 4.

41904d20e5a0e82a6b6c0d6df672a1ab
c556358bfeaab922c583a592b21457be
9d46d357828634ef8b2c5f4c34100711
4cc3eaae8208977cb13d25ce658d3e4b
a8b77dd1082b925d57cac6484df7b65a
9a07159f58c5563f61df13c12d1bf6f2
502f83114c43b6a3d8eb6104b806e84f
83d4fe7dbc1d031e45166e55992ca946
a3a791cae6458d2e6b115761c959f049
019cc94c9d5d83245ccffa29436ae3ca
55f222f4f2dc38e613d66437451fd505
7c547db7a6763a31ef8d3c4e5ef84a83
6be6d69caadc826b8718c0beaf3d236e
7a98173a07c50db8eaff7659652beede
78c007660bc0ab88e3872082c767d16a
067e505858140e04d601d829b20292b2

Je nach Anwendungsfall muss man den Buffer natürlich noch in das gewünschte Datenformat umwandeln, aber das ist dann nur noch eine reine Fleißaufgabe. Damit lässt sich nun ein sicheres OTP betreiben.

Schlüssel wiederverwenden mit AES

Trotzdem bleibt nach wie vor das Problem, dass man den Schlüssel für das OTP nur ein einziges Mal verwenden kann. Der Schlüsselaustausch ist also nach wie vor sehr aufwendig, weshalb in der Praxis häufig andere Verfahren als das OTP zum Einsatz kommen. Historisch war das DES-Verfahren (Data Encryption Standard) lange im Einsatz, der jedoch inzwischen als unsicher gilt, ebenso wie seine Variante 3DES.

Seit der Jahrtausendwende gibt es mit AES, dem Advanced Encryption Standard, einen De-facto-Standard, den man nahezu überall antrifft. Anders als Cäsar, Vigenère und OTP verschlüsselt AES nicht Zeichen für Zeichen, sondern ganze Blöcke von Zeichen. Daher handelt es sich bei AES um eine sogenannte Blockchiffre. Die gängigen Blocklängen sind 128 Bit, 192 Bit und 256 Bit, wobei jeweils ein passender Schlüssel erforderlich ist. Je nach gewählter Blocklänge spricht man daher von AES-128, AES-192 oder AES-256.

Das grundlegende Verfahren arbeitet dabei so, dass jeder Block zunächst in einzelne Bestandteile zerlegt wird. Dazu wird eine Tabelle mit vier Zeilen, aber je nach Blockgröße unterschiedlich vielen Spalten angelegt: Bei 128 Bit sind es vier Spalten, bei 192 Bit sechs und bei 256 Bit schließlich acht Spalten. Anschließend werden die einzelnen Zellen dieser Tabelle mehrfach verschlüsselt und vertauscht, wobei jeweils unterschiedliche Bereiche des Schlüssels verwendet werden. Wie viele Iterationen durchgeführt werden, hängt von der gewählten Blocklänge ab: Für 128 Bit werden zehn Runden durchlaufen, für 192 Bit zwölf Runden und für 256 Bit vierzehn Runden.

Darüber hinaus gibt es noch verschiedene Betriebsmodi für AES. Am einfachsten ist der ECB-Modus (Electronic Code Book), bei dem jeder Block einzeln für sich verschlüsselt wird. Das geht allerdings mit einer gewissen Unsicherheit einher, da mehrere gleichartige Blöcke identisch verschlüsselt werden: Damit hat man wieder jene Muster und Wiederholungen innerhalb des verschlüsselten Textes, die es eigentlich zu vermeiden galt. Daher wird der ECB-Modus generell als unsicher betrachtet, und sollte nicht eingesetzt werden.

Deutlich besser verhält es sich mit dem CBC-Modus (Cipher Block Chaining), bei dem in die Verschlüsselung eines jeden Blocks auch noch das Ergebnis der Verschlüsselung seines Vorgängers einfließt. Da dieser wiederum durch seinen Vorgänger beeinflusst wurde, werden gleichartige Blöcke stets unterschiedlich verschlüsselt, da sie sozusagen eine unterschiedliche Vorgeschichte haben. Im Prinzip ähnelt das Vorgehen dem von Git oder jenem der Blockchain, wo auch jeweils für den digitalen Fingerabdruck einer Informationseinheit Informationen der vorigen zu Rate gezogen werden.

Allerdings wirft das ein Problem auf: Für den ersten Block gibt es keinen Vorgänger. Daher greift man an dieser Stelle auf einen Initialisierungsvektor (IV) zurück. Dabei handelt es sich um einen künstlich erzeugten nullten Block, der schlichtweg mit Zufallsdaten gefüllt wird. So wird auch vermieden, dass zwei Nachrichten, die gleich beginnen, identisch verschlüsselt werden. Das funktioniert allerdings nur, wenn für jede Nachricht ein eigener IV generiert wird. Da der IV auch zum Entschlüsseln wieder benötigt wird, muss er ebenfalls gespeichert werden. Allerdings muss er nicht verschlüsselt vorliegen, stattdessen kann er durchaus im Klartext neben der verschlüsselten Nachricht abgelegt werden.

In Node.js stellt das Modul crypto auch dazu wieder passende Funktionen zur Verfügung. Am wichtigsten ist die Funktion createCipheriv, mit der ein Stream zum Verschlüsseln auf Basis eines IV erzeugt wird. Anschließend kann die zu verschlüsselnde Nachricht in diesen Stream geschrieben werden. Da es sich bei dem Stream um einen Transform Stream handelt, erhält man das Ergebnis, indem man die verschlüsselten Daten aus dem Stream wieder ausliest. Analog dazu erzeugt die Funktion createDecipheriv einen Stream, mit dem sich die zuvor verschlüsselten Daten wieder entschlüsseln lassen.

Eine einfache Funktion zum Verschlüsseln eines Textes könnte daher wie in Listing 5 gezeigt aussehen.

const encrypt = async function (text, password, iv) {
  const cipher = crypto.createCipheriv('aes-256-cbc', password, iv);

  cipher.write(text, { encoding: 'utf8' });
  cipher.end();

  let encrypted = '';

  for await (const chunk of cipher) {
    encrypted += chunk.toString('hex');
  }

  return encrypted;
};

Das Kennwort und der IV müssen zuvor natürlich erzeugt werden, wobei für AES-256 ein Kennwort mit 32 Bytes Länge und ein IV mit 16 Bytes Länge erforderlich sind. Das Entschlüsseln funktioniert anschließend analog dazu (Listing 6).

const decrypt = async function (text, password, iv) {
  const decipher = crypto.createDecipheriv('aes-256-cbc', password, iv);

  decipher.write(text, { encoding: 'hex' });
  decipher.end();

  let decrypted = '';

  for await (const chunk of decipher) {
    decrypted += chunk.toString('utf8');
  }

  return decrypted;
};

AES mit 256 Bit im CBC-Modus stellt derzeit den De-facto-Standard dar, wenn Daten zu verschlüsseln sind. Es gilt auf absehbare Zeit als ausreichend sicher, weshalb man es gut im Alltag verwenden kann. Da es zudem von nahezu allen gängigen Programmiersprachen und Plattformen serienmäßig unterstützt wird, hat man auch keine Probleme mit der Interoperabilität und dem Datenaustausch von verschlüsselten Nachrichten.

Fazit und Ausblick

Obwohl das OTP also eine mathematisch nachweislich absolut sichere Verschlüsselung ermöglicht, sind die Anforderungen und Einschränkungen zu groß, als dass es im Alltag funktionieren würde. Daher haben sich im Lauf der Zeit verschiedene andere Verschlüsselungsalgorithmen etabliert, von denen der modernste und heutzutage gängigste AES ist. Bei AES ist darauf zu achten, eine ausreichend große Blockgröße (im Idealfall 256 Bit) und vor allem die richtige Betriebsart zu wählen: Der Einsatz von ECB gilt als unsicher, vorzuziehen ist hier auf jeden Fall der CBC-Modus.

Doch auch wenn man nun mit AES eine gute Verschlüsselung an der Hand hat, sind noch lange nicht alle Probleme gelöst: Der Schlüsselaustausch zwischen Sender und Empfänger ist nach wie vor ein Problem, und die reine Verschlüsselung schützt nicht vor Manipulation. Bevor wir uns dem Problem des Schlüsselaustauschs widmen, wird es daher in der nächsten Folge dieser Serie zunächst darum gehen, eine Manipulation von Daten zu erkennen, indem man sie mit digitalen Fingerabdrücken und Prüfcodes versieht.

Wichtig bei der Kryptografie ist also nicht nur, zu wissen, welche Algorithmen es gibt und welche davon man verwenden kann, sondern auch Kenntnisse darüber zu haben, welche Sicherheiten welcher Algorithmus bietet, damit man im Bedarfsfall die geeigneten Bausteine passgenau kombinieren kann.

Unsere Redaktion empfiehlt:

Relevante Beiträge

Hinterlasse einen Kommentar

Hinterlasse den ersten Kommentar!

avatar
400
  Subscribe  
Benachrichtige mich zu:
X
- Gib Deinen Standort ein -
- or -