Aussehen
Geheimbotschaften
Im Spionage Camp hast du bei den symatrischen Verschlüsselungen Cäsar und Vigenère geknackt: Cäsar, indem du alle Verschiebungen ausprobierst, Vigenère, weil sich das Schlüsselwort wiederholt und der Geheimtext dadurch Muster behält. Hier untersuchen wir die zeitgemäße und sichere Implementierung ChaCha20.
Mit ChaCha20 verschlüsselt TLS die Verbindungen zu vielen Webseiten, und VPN-Verbindungen wie WireGuard verwenden es. Du lernst zuerst, wie die Verschlüsselung rechnet und warum niemand aus dem Ergebnis den Schlüssel zurückrechnen kann. Danach baust du es in Python nach und prüfst es an den Testwerten der offiziellen Beschreibung.
Nur zum Lernen
Selbstgebaute Verschlüsselung gehört nie vor echte Geheimnisse: Ein kleiner Fehler macht ein Verfahren unbrauchbar, und dem Geheimtext sieht man das nicht an. Für Echtes nimmt man eine geprüfte Bibliothek.
Was „sicher“ heißt
Wer eine Nachricht abfängt, kennt das Verfahren. Davon geht Kerckhoffs’ Prinzip aus dem Spionage Camp aus: Geheim ist allein der Schlüssel. Oft kennt der Angreifer sogar ein Stück Klartext, zum Beispiel die Anrede eines Briefs. Dann bleiben ihm zwei Wege:
- Alle Schlüssel durchprobieren. Das heißt Brute Force. Dagegen hilft nur, dass es zu viele Schlüssel gibt.
- Eine Abkürzung nehmen. Der Geheimtext verrät etwas über den Klartext oder den Schlüssel, so wie die Wiederholungen bei Vigenère.
Ein gutes Verfahren versperrt beide Wege. An diesen zwei Wegen misst sich jede Stufe dieses Kapitels.
Stufe 1: XOR
Zeichen sind Zahlen
Verschlüsselt werden nicht Buchstaben, sondern Zahlen. Welche Zahl für welches Zeichen steht, legt eine Zeichenkodierung fest.
Zeichenkodierung
ord(zeichen) liefert die Nummer eines Zeichens im Unicode, dem Verzeichnis aller Schriftzeichen. chr(zahl) macht aus der Nummer wieder das Zeichen. Die Nummern 0 bis 127 sind ASCII: Buchstaben ohne Umlaute, Ziffern und Satzzeichen. Bis 255 stimmen die Nummern mit Latin-1 (ISO 8859-1) überein, darin stehen auch ä, ö, ü und ß. Eine solche Nummer passt in ein Byte (8 Bit, Werte von 0 bis 255). Andere Zeichen haben größere Nummern: € hat 8364, 🦔 hat 129428.
XOR
Verschlüsselt wird mit XOR (exklusives Oder, in Python ^). XOR verknüpft zwei Bits und ergibt 1, wenn genau eines von beiden 1 ist:
| a | b | a XOR b |
|---|---|---|
| 0 | 0 | 0 |
| 0 | 1 | 1 |
| 1 | 0 | 1 |
| 1 | 1 | 0 |
Zwei Zahlen verknüpft XOR Stelle für Stelle im Zweiersystem, ohne Übertrag. Das I hat die Nummer 73, im Zweiersystem 01001001 (64 + 8 + 1). Mit dem Schlüssel 42 wird daraus 99, das Zeichen c:
Wo der Schlüssel eine 1 hat, kippt das Bit, wo er eine 0 hat, bleibt es stehen. Verknüpft man den Geheimtext noch einmal mit demselben Schlüssel, kippen dieselben Bits zurück. Verschlüsseln und Entschlüsseln sind deshalb dieselbe Rechnung: Schlüssel XOR Schlüssel ergibt 0, und Klartext XOR 0 ergibt den Klartext.
Sicher ist das nicht. Der Schlüssel ist ein Byte, es gibt also nur 256 Schlüssel, und ein Rechner probiert sie in Sekundenbruchteilen durch. Eine Abkürzung gibt es auch: Gleiche Zeichen werden zu gleichen Zahlen, die Häufigkeitsanalyse aus dem Spionage Camp funktioniert unverändert.
Das Programm
Die beiden Schleifen setzen um, was die Abbildung zeigt: erst jedes Zeichen in seine Nummer verwandeln und mit dem Schlüssel verknüpfen, dann dasselbe noch einmal zurück.
Python
text = "Der Igel schläft im Laub"
schluessel = 42
geheim = []
for zeichen in text:
geheim.append(ord(zeichen) ^ schluessel)
print(geheim)
# Entschlüsseln ist dieselbe Rechnung
klar = ""
for zahl in geheim:
klar = klar + chr(zahl ^ schluessel)
print(klar)Das Programm gibt zuerst die verschlüsselten Zahlen aus, die Liste beginnt mit 110, 79, 88, 10. Danach steht der Satz wieder im Klartext da.
Probier es aus
- Rechne von Hand im Zweiersystem aus, was aus dem
Lvon „Laub“ wird (Nummer 76). Suche dein Ergebnis in der Ausgabe. - Ein Angreifer hat nur die Liste
geheim. Ergänze das Programm um eine Schleife, die alle 256 Schlüssel ausprobiert und jedes Ergebnis ausgibt. Woran erkennst du den richtigen Schlüssel?
Stufe 2: Ein Schlüsselwort
256 Schlüssel sind zu wenige. Ein Schlüsselwort aus mehreren Bytes vervielfacht die Zahl: Bei fünf Bytes wie FUCHS sind es 256⁵, gut eine Billion. Das Wort wird wie bei Vigenère wiederholt: Das erste Zeichen wird mit F verknüpft, das zweite mit U und das sechste wieder mit F.
Durchprobieren wird damit mühsam, aber zwei Abkürzungen bleiben:
- Das Wort wiederholt sich. Jedes fünfte Byte hängt am selben Schlüsselbyte. Wer die Länge des Worts kennt, etwa über die Wiederholungen im Geheimtext wie bei Vigenère, zerlegt den Geheimtext in fünf Spalten. Jede Spalte ist ein Fall aus Stufe 1: 256 Möglichkeiten, und die Häufigkeitsanalyse findet die richtige. Aus 256⁵ Versuchen werden 5 · 256 = 1280.
- Bekannter Klartext verrät den Schlüssel. Aus Klartext XOR Schlüssel = Geheimtext folgt Klartext XOR Geheimtext = Schlüssel. Wer ein Stück Klartext kennt, kennt an diesen Stellen den Schlüssel, und weil sich das Wort wiederholt, auch an allen anderen.
Damit das Programm mit Schlüsseln jeder Länge rechnen kann, bekommt es drei Funktionen. Sie stehen ganz oben:
Python
def text_zu_zahlen(text):
return [ord(zeichen) for zeichen in text]
def zahlen_zu_text(zahlen):
return "".join(chr(zahl) for zahl in zahlen)
def xor_mit(zahlen, schluessel):
ergebnis = []
for i in range(len(zahlen)):
# i % len(schluessel) läuft 0, 1, 2, 3, 4, 0, 1, ...: der Schlüssel wiederholt sich
ergebnis.append(zahlen[i] ^ schluessel[i % len(schluessel)])
return ergebnisDarunter wird das Programm kürzer, die beiden Schleifen aus Stufe 1 entfallen. Ab der Zeile mit text lautet es jetzt:
Python
text = "Der Igel schläft im Laub"
schluessel = text_zu_zahlen("FUCHS")
geheim = xor_mit(text_zu_zahlen(text), schluessel)
print(geheim)
# Entschlüsseln ist dieselbe Rechnung
print(zahlen_zu_text(xor_mit(geheim, schluessel)))Das ganze Programm dieser Stufe
Python
def text_zu_zahlen(text):
return [ord(zeichen) for zeichen in text]
def zahlen_zu_text(zahlen):
return "".join(chr(zahl) for zahl in zahlen)
def xor_mit(zahlen, schluessel):
ergebnis = []
for i in range(len(zahlen)):
# i % len(schluessel) läuft 0, 1, 2, 3, 4, 0, 1, ...: der Schlüssel wiederholt sich
ergebnis.append(zahlen[i] ^ schluessel[i % len(schluessel)])
return ergebnis
text = "Der Igel schläft im Laub"
schluessel = text_zu_zahlen("FUCHS")
geheim = xor_mit(text_zu_zahlen(text), schluessel)
print(geheim)
# Entschlüsseln ist dieselbe Rechnung
print(zahlen_zu_text(xor_mit(geheim, schluessel)))Probier es aus
- Verschlüssele einen Text aus 24 Mal
a. Woran erkennst du in der Ausgabe die Länge des Schlüsselworts? - Diese Zahlen sind mit einem anderen Schlüsselwort verschlüsselt. Du weißt nur, dass die Nachricht mit
Liebe Oma,beginnt (mit Leerzeichen am Ende). Finde das Schlüsselwort und entschlüssele die ganze Nachricht.Pythongeheim = [1, 40, 55, 38, 32, 114, 2, 44, 51, 104, 101, 54, 36, 36, 114, 5, 40, 33, 40, 45, 60, 100, 44, 63, 109, 6, 51, 54, 49, 55, 35, 97, 58, 37, 39, 55, 35, 97, 24, 49, 43, 53, 40, 111] - Ein Schlüsselwort besteht aus acht Großbuchstaben von A bis Z. Wie viele Versuche braucht, wer alle Wörter durchprobiert? Wie viele sind es mit der Spalten-Abkürzung?
Stufe 3: Der Einmalblock
Beide Abkürzungen aus Stufe 2 leben davon, dass sich der Schlüssel wiederholt. Also ohne Wiederholung: ein Schlüssel so lang wie die Nachricht, jedes Byte zufällig gewählt, und nur ein einziges Mal benutzt. Das ist der Einmalblock (englisch one-time pad). Er beantwortet die Frage vom Ende des Spionage Camps: Eine so verschlüsselte Nachricht ist nicht zu knacken, auch nicht mit beliebig viel Rechenzeit.
Das hat zwei Gründe. Erstens ergibt XOR mit einem zufälligen Bit ein zufälliges Bit, egal was im Klartext steht: In der Tabelle aus Stufe 1 ergibt jedes a mit den beiden möglichen b einmal 0 und einmal 1. Der Geheimtext sieht deshalb aus wie Zufall, eine Häufigkeitsanalyse findet nichts. Zweitens nützt Durchprobieren nichts, denn zu jedem Geheimtext gibt es für jeden Klartext derselben Länge einen passenden Schlüssel:
Beide Schlüssel sind gleich wahrscheinlich. Wer alle durchprobiert, bekommt jedes Wort aus vier Buchstaben heraus, Igel genauso wie Hase oder Wolf, und kann nicht entscheiden, welches gemeint war.
Im Programm ändert sich nur, woher der Schlüssel kommt. Ganz oben steht jetzt:
Python
import randomund statt schluessel = text_zu_zahlen("FUCHS"):
Python
# Für jedes Zeichen eine eigene Zufallszahl
schluessel = [random.randint(0, 255) for zeichen in text]Alles andere bleibt, denn xor_mit kommt mit einem Schlüssel jeder Länge zurecht.
Das ganze Programm dieser Stufe
Python
import random
def text_zu_zahlen(text):
return [ord(zeichen) for zeichen in text]
def zahlen_zu_text(zahlen):
return "".join(chr(zahl) for zahl in zahlen)
def xor_mit(zahlen, schluessel):
ergebnis = []
for i in range(len(zahlen)):
# i % len(schluessel) läuft 0, 1, 2, 3, 4, 0, 1, ...: der Schlüssel wiederholt sich
ergebnis.append(zahlen[i] ^ schluessel[i % len(schluessel)])
return ergebnis
text = "Der Igel schläft im Laub"
# Für jedes Zeichen eine eigene Zufallszahl
schluessel = [random.randint(0, 255) for zeichen in text]
geheim = xor_mit(text_zu_zahlen(text), schluessel)
print(geheim)
# Entschlüsseln ist dieselbe Rechnung
print(zahlen_zu_text(xor_mit(geheim, schluessel)))Pseudozufall
Das Modul random liefert Pseudozufall: Zahlen, die nach einer festen Rechenvorschrift aus einem Startwert entstehen. Wer den Startwert kennt, kennt alle Zahlen. Für echte Schlüssel nimmt man Zufall, den das Betriebssystem aus Hardware-Ereignissen sammelt; in Python liefert ihn das Modul secrets.
Der Einmalblock hat einen Haken: Der Schlüssel ist so lang wie alle Nachrichten zusammen und muss vorher auf sicherem Weg zum Empfänger. Wer einen solchen Weg für so viele Bytes hat, könnte auf ihm gleich die Nachricht schicken.
Verständnisfragen
- Starte das Programm mehrmals. Was ändert sich bei jedem Start, was bleibt gleich?
- Warum hilft einem Angreifer das Durchprobieren aller Schlüssel nicht?
- Was ist der Haken am Einmalblock?
Vom Einmalblock zur Stromchiffre
Die Idee des Einmalblocks bleibt, nur der Schlüssel kommt anders zustande. Sender und Empfänger vereinbaren einen kurzen Schlüssel, bei ChaCha20 sind das 32 Bytes (256 Bit). Ein Erzeuger berechnet daraus so viele Bytes, wie die Nachricht braucht, den Schlüsselstrom. Verschlüsselt wird wie beim Einmalblock: Klartext XOR Schlüsselstrom. Ein solches Verfahren heißt Stromchiffre.
Der Erzeuger muss drei Dinge können:
- Gleiche Eingabe, gleicher Strom. Der Empfänger rechnet mit demselben Schlüssel denselben Strom aus und entschlüsselt damit.
- Für jede Nachricht ein neuer Strom. Dafür bekommt der Erzeuger außer dem Schlüssel eine Nonce (number used once): eine Zahl, die für jede Nachricht neu gewählt und offen mitgeschickt wird. Ein Zähler nummeriert die Stücke des Stroms durch, bei ChaCha20 sind sie je 64 Bytes lang.
- Kein Weg zurück. Wer ein Stück des Stroms kennt, weil er ein Stück Klartext kennt (Stufe 2), darf daraus weder den Schlüssel noch den Rest des Stroms berechnen können.
An Punkt 3 scheitert der Zufallsgenerator von random: Aus 624 aufeinanderfolgenden Zahlen von random.getrandbits(32) lässt sich sein innerer Zustand berechnen und damit jede weitere Zahl.
Perfekt sicher wie der Einmalblock ist eine Stromchiffre nicht mehr: Bei ChaCha20 gibt es 2²⁵⁶ Schlüssel, und wer alle durchprobiert, findet den richtigen. 2²⁵⁶ ist eine Zahl mit 78 Stellen. Eine Milliarde Rechner, von denen jeder eine Milliarde Schlüssel pro Sekunde prüft, bräuchten dafür etwa 3,7 · 10⁵¹ Jahre; das Weltall ist etwa 1,4 · 10¹⁰ Jahre alt. Der erste Weg ist damit versperrt. Ob es den zweiten gibt, eine Abkürzung, hängt allein am Erzeuger.
Verständnisfragen
- Welche der drei Anforderungen erfüllt der Zufallsgenerator von
randomnicht? - Warum ist das Durchprobieren aller Schlüssel bei ChaCha20 trotzdem keine Gefahr?
ChaCha20 von innen
ChaCha20 hat Daniel J. Bernstein 2008 entworfen. Genau beschrieben ist es in RFC 8439, einem frei zugänglichen Dokument der Internet Research Task Force, samt Testwerten für jeden Rechenschritt.
Die Tafel
ChaCha20 rechnet mit Wörtern: Zahlen aus 32 Bit, also aus 4 Bytes. Sechzehn davon bilden eine Tafel aus 4 × 4 Feldern, den Zustand. Die Zahlen sind hexadezimal geschrieben, jede Ziffer von 0 bis f steht für vier Bit.
- Platz 0 bis 3: vier feste Zahlen. Als Bytes gelesen ergeben sie den Text
expand 32-byte k. Wozu sie gut sind, steht in Stufe 5. - Platz 4 bis 11: der Schlüssel, acht Wörter, zusammen 256 Bit.
- Platz 12: der Zähler.
- Platz 13 bis 15: die Nonce, drei Wörter, zusammen 96 Bit.
Aus Bytes werden Wörter, indem man je vier zusammenfasst, das erste Byte als das niedrigste: Aus den Bytes 00 01 02 03 wird das Wort 03020100. Diese Reihenfolge heißt Little Endian.
Die Viertelrunde
Gemischt wird die Tafel in Viertelrunden. Eine Viertelrunde nimmt vier Wörter a, b, c und d und verändert sie in zwölf Schritten. Dabei kommen nur drei Rechenarten vor:
- Addieren (
+): wie schriftliches Addieren, nur im Zweiersystem. Was über 32 Bit hinausgeht, fällt weg. - XOR (
^): wie in Stufe 1, Bit für Bit. - Drehen (
<<<): Alle Bits rücken um eine feste Zahl von Stellen nach links, und was links herausfällt, kommt rechts wieder herein.
Ein einzelner Schritt zum Nachrechnen, aus Abschnitt 2.1 des RFC:
text
c = c + d 0x77777777 + 0x01234567 = 0x789abcde
b = b ^ c 0x01020304 ^ 0x789abcde = 0x7998bfda
b = b <<< 7 0x7998bfda <<< 7 = 0xcc5fed3cBeim Drehen sieht man im Zweiersystem, was passiert: Die sieben Bits ganz links wandern ans rechte Ende.
text
0111100 1100110001011111111011010 0x7998bfda
1100110001011111111011010 0111100 0xcc5fed3cJede der drei Rechenarten allein mischt schlecht. Erst zusammen machen sie die Viertelrunde stark:
- XOR und Drehen lassen sich vollständig als Gleichungen über Bits aufschreiben: Jedes Bit des Ergebnisses ist das XOR bestimmter Bits der Eingabe. Bestünde ChaCha20 nur aus diesen beiden, könnte ein Angreifer mit einem Stück Schlüsselstrom die Gleichungen lösen und hätte den Schlüssel in Sekundenbruchteilen.
- Das Addieren passt nicht in solche Gleichungen. Sein Übertrag entsteht, wenn zwei Bits beide 1 sind, und das ist kein XOR. Der Übertrag läuft allerdings nur in eine Richtung, nach links zu den höheren Bits.
- Das Drehen holt die hohen Bits wieder nach rechts unten. Ohne Drehen hinge das unterste Bit jedes Worts nur von den untersten Bits der Eingabe ab, und ein Angreifer könnte sich Bit für Bit nach oben vorarbeiten, mit je 256 Versuchen pro Stelle.
Außerdem lässt sich jeder der zwölf Schritte umkehren: Aus Addieren wird Subtrahieren, aus Drehen nach links Drehen nach rechts, und XOR hebt sich selbst auf. Auf diese Eigenschaft kommt es bei der Rückaddition an.
Spalten und Diagonalen
Eine Viertelrunde mischt nur vier der sechzehn Wörter. ChaCha20 wendet sie deshalb abwechselnd auf zwei Arten an. In der Spaltenrunde mischt je eine Viertelrunde die Wörter einer Spalte, in der Diagonalrunde die Wörter einer Diagonale:
Mit Spaltenrunden allein blieben die Spalten unter sich: Ein Wort des Schlüssels in der ersten Spalte würde die Wörter der anderen Spalten nie berühren, und ein Angreifer könnte jede Spalte einzeln angreifen wie die Spalten bei Vigenère. Eine Diagonale nimmt aus jeder Spalte genau ein Wort. Nach einer Spalten- und einer Diagonalrunde hängt deshalb jedes Wort von jedem ab. Diese Doppelrunde rechnet ChaCha20 zehnmal hintereinander, das sind die 20 Runden im Namen.
Der Lawineneffekt
Wie gründlich das mischt, zeigt ein Versuch. Im Schlüssel wird ein einziges Bit geändert, sonst bleibt alles gleich, und man vergleicht die Tafeln vorher und nachher:
Nach einer Runde hat die Änderung nur die erste Spalte erreicht. Nach 20 Runden sind 248 der 512 Bits anders, und von den 64 Bytes ist kein einziges gleich geblieben. Im Mittel kippt ein geändertes Eingabebit die Hälfte aller Ausgabebits, so als würde jedes Ausgabebit neu ausgewürfelt. Das heißt Lawineneffekt. Zwei Schlüssel, die sich nur in einem Bit unterscheiden, liefern zwei Ströme, die nichts miteinander zu tun haben. Ob man mit einem geratenen Schlüssel „nah dran“ ist, sieht man dem Strom nicht an.
Schon nach wenigen Runden ist die Lawine vollständig. Die übrigen Runden sind Reserve: Die besten bekannten Angriffe schaffen ChaCha mit 7 bis 7½ Runden, und auch das nur mit einem Aufwand weit jenseits jedes Rechners. Gegen alle 20 Runden kennt man keinen Angriff, der schneller ist als das Durchprobieren der Schlüssel.
Die Rückaddition
Nach den 20 Runden folgt noch ein Schritt, und an ihm hängt die Antwort auf die Frage, warum man ChaCha20 nicht zurückrechnen kann. Zu jedem Wort wird das Wort addiert, das zu Beginn auf seinem Platz stand:
Warum dieser Schritt? Alle Schritte der Runden lassen sich umkehren. Gäbe ChaCha20 die Tafel nach den Runden direkt als Strom aus, dann könnte ein Angreifer, der ein Stück Klartext und damit 64 Bytes Strom kennt, die 20 Runden rückwärts rechnen bis zum Startzustand. Auf den Plätzen 4 bis 11 stünde der Schlüssel.
Mit der Rückaddition geht das nicht mehr. Der Angreifer kennt das Ergebnis, und er kennt die Hälfte des Startzustands: die festen Zahlen, den Zähler und die Nonce. Um die Runden rückwärts zu rechnen, bräuchte er aber die ganze Tafel nach den Runden. Die bekommt er nur, wenn er vom Ergebnis den ganzen Startzustand abzieht, samt Schlüssel. Für die acht Schlüsselwörter bleibt ihm nach allem, was man heute weiß, nur Raten: 2²⁵⁶ Möglichkeiten. Vorwärts ist ChaCha20 leicht zu rechnen, rückwärts nicht.
Zum Schluss wird jedes der 16 Wörter in seine 4 Bytes zerlegt, wieder Little Endian. Das ergibt 64 Bytes Schlüsselstrom. Für die nächsten 64 Bytes rechnet ChaCha20 denselben Block noch einmal, mit dem Zähler um eins erhöht.
Stufe 4: Die Viertelrunde bauen
Jetzt wird ChaCha20 in Python übersetzt, Schritt für Schritt und jeweils mit den Testwerten aus dem RFC. Leg dafür eine neue Datei an.
Addieren und XOR kann Python direkt (+ und ^). Für den Rest brauchst du vier Bitoperatoren:
Bitoperatoren
x << nschiebt alle Bits um n Stellen nach links, rechts kommen Nullen nach:0b0011 << 2ergibt0b1100.x >> nschiebt um n Stellen nach rechts, rechts fallen Bits weg:0b1100 >> 2ergibt0b0011.x & y(UND) ergibt 1 nur dort, wo beide 1 sind:0b1100 & 0b1010ergibt0b1000.x | y(ODER) ergibt 1 dort, wo mindestens eines 1 ist:0b1100 | 0b1010ergibt0b1110.
0b vor einer Zahl heißt Zweiersystem, 0x Hexadezimalsystem. 0xFFFFFFFF sind 32 Einsen.
Zwei Dinge musst du selbst erledigen. Python-Zahlen wachsen beliebig weit, ChaCha20 rechnet aber mit 32 Bit: Nach jeder Addition schneidet & MASKE alles ab, was darüber hinausgeht. Und einen Operator fürs Drehen gibt es nicht. Die Funktion drehe baut ihn aus zwei Verschiebungen: (x << n) & MASKE schiebt nach links und schneidet ab, was links übersteht; x >> (32 - n) holt genau diese n Bits ans rechte Ende; | setzt beides zusammen.
Python
MASKE = 0xFFFFFFFF # 32 Einsen
def drehe(x, n):
# n Bits nach links; was links herausfällt, kommt rechts wieder herein
return ((x << n) & MASKE) | (x >> (32 - n))
def viertelrunde(tafel, a, b, c, d):
tafel[a] = (tafel[a] + tafel[b]) & MASKE
tafel[d] = drehe(tafel[d] ^ tafel[a], 16)
tafel[c] = (tafel[c] + tafel[d]) & MASKE
tafel[b] = drehe(tafel[b] ^ tafel[c], 12)
tafel[a] = (tafel[a] + tafel[b]) & MASKE
tafel[d] = drehe(tafel[d] ^ tafel[a], 8)
tafel[c] = (tafel[c] + tafel[d]) & MASKE
tafel[b] = drehe(tafel[b] ^ tafel[c], 7)
# Test mit den Werten aus RFC 8439, Abschnitt 2.1.1
woerter = [0x11111111, 0x01020304, 0x9b8d6f43, 0x01234567]
viertelrunde(woerter, 0, 1, 2, 3)
print([hex(wort) for wort in woerter])
# richtig: ['0xea2a92f4', '0xcb1cf8ce', '0x4581472e', '0x5881c4bb']Die Funktion viertelrunde bekommt die ganze Tafel als Liste und dazu vier Platznummern. So kann sie später auf jeder Vierergruppe der Tafel arbeiten. Für den Test reicht eine Liste aus vier Wörtern.
Verständnisfragen
- Starte das Programm. Stimmen alle vier Zahlen mit dem Kommentar in der letzten Zeile überein?
- Wozu dient
& MASKEnach jeder Addition? - Was geschieht in
drehemit den Bits, die links herausfallen?
Stufe 5: Den Block bauen
Unter viertelrunde kommen die festen Zahlen und die Funktion block. Sie baut den Startzustand wie in der Abbildung der Tafel, rechnet zehn Doppelrunden, addiert den Startzustand und zerlegt die Wörter in Bytes:
Python
# als Text gelesen: "expand 32-byte k"
FESTE_ZAHLEN = [0x61707865, 0x3320646e, 0x79622d32, 0x6b206574]
def block(schluessel, zaehler, nonce):
start = FESTE_ZAHLEN + schluessel + [zaehler] + nonce
tafel = start[:] # eine Kopie, start bleibt für die Rückaddition erhalten
for doppelrunde in range(10):
# Spaltenrunde
viertelrunde(tafel, 0, 4, 8, 12)
viertelrunde(tafel, 1, 5, 9, 13)
viertelrunde(tafel, 2, 6, 10, 14)
viertelrunde(tafel, 3, 7, 11, 15)
# Diagonalrunde
viertelrunde(tafel, 0, 5, 10, 15)
viertelrunde(tafel, 1, 6, 11, 12)
viertelrunde(tafel, 2, 7, 8, 13)
viertelrunde(tafel, 3, 4, 9, 14)
strom = []
for i in range(16):
wort = (tafel[i] + start[i]) & MASKE # Rückaddition
# in vier Bytes zerlegen, das niedrigste zuerst
strom.append(wort & 255)
strom.append((wort >> 8) & 255)
strom.append((wort >> 16) & 255)
strom.append((wort >> 24) & 255)
return strom
def hexzeile(zahlen):
return "".join("%02x" % zahl for zahl in zahlen)Ohne start[:] wären tafel und start dieselbe Liste, und die Runden würden den Startzustand gleich mit verändern. hexzeile schreibt jede Zahl als zwei Hexadezimalziffern ("%02x" % 10 ergibt 0a), so wie das RFC seine Testwerte angibt.
Die festen Zahlen sorgen dafür, dass der Startzustand nie nur aus Nullen besteht, auch bei Schlüssel, Zähler und Nonce 0. Aus lauter Nullen würden die Runden nur Nullen machen: 0 + 0 = 0, 0 ^ 0 = 0, und eine gedrehte Null bleibt null.
Unter den Test der Viertelrunde kommt der Test des Blocks. Der Schlüssel aus dem RFC ist die Bytefolge 00 01 02 … 1f, als Wörter in Little Endian:
Python
# Test mit den Werten aus RFC 8439, Abschnitt 2.3.2
schluessel = [0x03020100, 0x07060504, 0x0b0a0908, 0x0f0e0d0c,
0x13121110, 0x17161514, 0x1b1a1918, 0x1f1e1d1c]
nonce = [0x09000000, 0x4a000000, 0x00000000]
strom = block(schluessel, 1, nonce)
print(len(strom), hexzeile(strom[:16]))
# richtig: 64 10f1e7e4d13b5915500fdd1fa32071c4Das ganze Programm dieser Stufe
Python
MASKE = 0xFFFFFFFF # 32 Einsen
def drehe(x, n):
# n Bits nach links; was links herausfällt, kommt rechts wieder herein
return ((x << n) & MASKE) | (x >> (32 - n))
def viertelrunde(tafel, a, b, c, d):
tafel[a] = (tafel[a] + tafel[b]) & MASKE
tafel[d] = drehe(tafel[d] ^ tafel[a], 16)
tafel[c] = (tafel[c] + tafel[d]) & MASKE
tafel[b] = drehe(tafel[b] ^ tafel[c], 12)
tafel[a] = (tafel[a] + tafel[b]) & MASKE
tafel[d] = drehe(tafel[d] ^ tafel[a], 8)
tafel[c] = (tafel[c] + tafel[d]) & MASKE
tafel[b] = drehe(tafel[b] ^ tafel[c], 7)
# als Text gelesen: "expand 32-byte k"
FESTE_ZAHLEN = [0x61707865, 0x3320646e, 0x79622d32, 0x6b206574]
def block(schluessel, zaehler, nonce):
start = FESTE_ZAHLEN + schluessel + [zaehler] + nonce
tafel = start[:] # eine Kopie, start bleibt für die Rückaddition erhalten
for doppelrunde in range(10):
# Spaltenrunde
viertelrunde(tafel, 0, 4, 8, 12)
viertelrunde(tafel, 1, 5, 9, 13)
viertelrunde(tafel, 2, 6, 10, 14)
viertelrunde(tafel, 3, 7, 11, 15)
# Diagonalrunde
viertelrunde(tafel, 0, 5, 10, 15)
viertelrunde(tafel, 1, 6, 11, 12)
viertelrunde(tafel, 2, 7, 8, 13)
viertelrunde(tafel, 3, 4, 9, 14)
strom = []
for i in range(16):
wort = (tafel[i] + start[i]) & MASKE # Rückaddition
# in vier Bytes zerlegen, das niedrigste zuerst
strom.append(wort & 255)
strom.append((wort >> 8) & 255)
strom.append((wort >> 16) & 255)
strom.append((wort >> 24) & 255)
return strom
def hexzeile(zahlen):
return "".join("%02x" % zahl for zahl in zahlen)
# Test mit den Werten aus RFC 8439, Abschnitt 2.1.1
woerter = [0x11111111, 0x01020304, 0x9b8d6f43, 0x01234567]
viertelrunde(woerter, 0, 1, 2, 3)
print([hex(wort) for wort in woerter])
# richtig: ['0xea2a92f4', '0xcb1cf8ce', '0x4581472e', '0x5881c4bb']
# Test mit den Werten aus RFC 8439, Abschnitt 2.3.2
schluessel = [0x03020100, 0x07060504, 0x0b0a0908, 0x0f0e0d0c,
0x13121110, 0x17161514, 0x1b1a1918, 0x1f1e1d1c]
nonce = [0x09000000, 0x4a000000, 0x00000000]
strom = block(schluessel, 1, nonce)
print(len(strom), hexzeile(strom[:16]))
# richtig: 64 10f1e7e4d13b5915500fdd1fa32071c4Verständnisfragen
- Starte das Programm. Stimmen die ersten 16 Bytes mit dem Kommentar überein?
- Warum steht in
blocktafel = start[:]und nicht einfachtafel = start? - Wozu sind die vier festen Zahlen da?
- Wozu dient die Rückaddition am Ende von
block?
Stufe 6: Eine ganze Nachricht
Für eine Nachricht braucht ChaCha20 so viele Blöcke, dass der Strom lang genug wird: Der Block mit Zähler 1 liefert die Bytes 0 bis 63, der mit Zähler 2 die Bytes 64 bis 127 und so weiter. Was am Ende übrig ist, wird weggeworfen. Danach geht es weiter wie in Stufe 3: Klartext XOR Strom. Der Empfänger rechnet mit demselben Schlüssel und der mitgeschickten Nonce denselben Strom aus und verknüpft ihn mit dem Geheimtext:
Der Zähler beginnt bei 1 wie im Test des RFC. Den Block mit Zähler 0 braucht das Verfahren ChaCha20-Poly1305 für seine Prüfsumme (siehe unten).
Unter hexzeile kommen die drei Funktionen aus Stufe 2, unverändert, und eine neue, die den Strom zusammensetzt:
Python
def text_zu_zahlen(text):
return [ord(zeichen) for zeichen in text]
def zahlen_zu_text(zahlen):
return "".join(chr(zahl) for zahl in zahlen)
def xor_mit(zahlen, schluessel):
ergebnis = []
for i in range(len(zahlen)):
# i % len(schluessel) läuft 0, 1, 2, 3, 4, 0, 1, ...: der Schlüssel wiederholt sich
ergebnis.append(zahlen[i] ^ schluessel[i % len(schluessel)])
return ergebnis
def schluesselstrom(schluessel, nonce, laenge):
strom = []
zaehler = 1
while len(strom) < laenge:
strom = strom + block(schluessel, zaehler, nonce)
zaehler = zaehler + 1
return strom[:laenge]Ans Ende des Programms kommt der Test aus Abschnitt 2.4.2 des RFC. Er verwendet denselben Schlüssel wie der Test des Blocks, aber eine andere Nonce:
Python
# Test mit den Werten aus RFC 8439, Abschnitt 2.4.2
nonce = [0x00000000, 0x4a000000, 0x00000000]
text = "Ladies and Gentlemen of the class of '99: If I could offer you only one tip for the future, sunscreen would be it."
klar = text_zu_zahlen(text)
geheim = xor_mit(klar, schluesselstrom(schluessel, nonce, len(klar)))
print(hexzeile(geheim[:16]))
# richtig: 6e2e359a2568f98041ba0728dd0d6981
# Entschlüsseln ist dieselbe Rechnung
print(zahlen_zu_text(xor_mit(geheim, schluesselstrom(schluessel, nonce, len(geheim)))))Das ganze Programm dieser Stufe
Python
MASKE = 0xFFFFFFFF # 32 Einsen
def drehe(x, n):
# n Bits nach links; was links herausfällt, kommt rechts wieder herein
return ((x << n) & MASKE) | (x >> (32 - n))
def viertelrunde(tafel, a, b, c, d):
tafel[a] = (tafel[a] + tafel[b]) & MASKE
tafel[d] = drehe(tafel[d] ^ tafel[a], 16)
tafel[c] = (tafel[c] + tafel[d]) & MASKE
tafel[b] = drehe(tafel[b] ^ tafel[c], 12)
tafel[a] = (tafel[a] + tafel[b]) & MASKE
tafel[d] = drehe(tafel[d] ^ tafel[a], 8)
tafel[c] = (tafel[c] + tafel[d]) & MASKE
tafel[b] = drehe(tafel[b] ^ tafel[c], 7)
# als Text gelesen: "expand 32-byte k"
FESTE_ZAHLEN = [0x61707865, 0x3320646e, 0x79622d32, 0x6b206574]
def block(schluessel, zaehler, nonce):
start = FESTE_ZAHLEN + schluessel + [zaehler] + nonce
tafel = start[:] # eine Kopie, start bleibt für die Rückaddition erhalten
for doppelrunde in range(10):
# Spaltenrunde
viertelrunde(tafel, 0, 4, 8, 12)
viertelrunde(tafel, 1, 5, 9, 13)
viertelrunde(tafel, 2, 6, 10, 14)
viertelrunde(tafel, 3, 7, 11, 15)
# Diagonalrunde
viertelrunde(tafel, 0, 5, 10, 15)
viertelrunde(tafel, 1, 6, 11, 12)
viertelrunde(tafel, 2, 7, 8, 13)
viertelrunde(tafel, 3, 4, 9, 14)
strom = []
for i in range(16):
wort = (tafel[i] + start[i]) & MASKE # Rückaddition
# in vier Bytes zerlegen, das niedrigste zuerst
strom.append(wort & 255)
strom.append((wort >> 8) & 255)
strom.append((wort >> 16) & 255)
strom.append((wort >> 24) & 255)
return strom
def hexzeile(zahlen):
return "".join("%02x" % zahl for zahl in zahlen)
def text_zu_zahlen(text):
return [ord(zeichen) for zeichen in text]
def zahlen_zu_text(zahlen):
return "".join(chr(zahl) for zahl in zahlen)
def xor_mit(zahlen, schluessel):
ergebnis = []
for i in range(len(zahlen)):
# i % len(schluessel) läuft 0, 1, 2, 3, 4, 0, 1, ...: der Schlüssel wiederholt sich
ergebnis.append(zahlen[i] ^ schluessel[i % len(schluessel)])
return ergebnis
def schluesselstrom(schluessel, nonce, laenge):
strom = []
zaehler = 1
while len(strom) < laenge:
strom = strom + block(schluessel, zaehler, nonce)
zaehler = zaehler + 1
return strom[:laenge]
# Test mit den Werten aus RFC 8439, Abschnitt 2.1.1
woerter = [0x11111111, 0x01020304, 0x9b8d6f43, 0x01234567]
viertelrunde(woerter, 0, 1, 2, 3)
print([hex(wort) for wort in woerter])
# richtig: ['0xea2a92f4', '0xcb1cf8ce', '0x4581472e', '0x5881c4bb']
# Test mit den Werten aus RFC 8439, Abschnitt 2.3.2
schluessel = [0x03020100, 0x07060504, 0x0b0a0908, 0x0f0e0d0c,
0x13121110, 0x17161514, 0x1b1a1918, 0x1f1e1d1c]
nonce = [0x09000000, 0x4a000000, 0x00000000]
strom = block(schluessel, 1, nonce)
print(len(strom), hexzeile(strom[:16]))
# richtig: 64 10f1e7e4d13b5915500fdd1fa32071c4
# Test mit den Werten aus RFC 8439, Abschnitt 2.4.2
nonce = [0x00000000, 0x4a000000, 0x00000000]
text = "Ladies and Gentlemen of the class of '99: If I could offer you only one tip for the future, sunscreen would be it."
klar = text_zu_zahlen(text)
geheim = xor_mit(klar, schluesselstrom(schluessel, nonce, len(klar)))
print(hexzeile(geheim[:16]))
# richtig: 6e2e359a2568f98041ba0728dd0d6981
# Entschlüsseln ist dieselbe Rechnung
print(zahlen_zu_text(xor_mit(geheim, schluesselstrom(schluessel, nonce, len(geheim)))))Und wer ändert die Nachricht?
ChaCha20 allein verschlüsselt nur. Wer unterwegs ein Bit des Geheimtexts kippt, kippt genau dieses Bit im Klartext, und der Empfänger merkt es nicht. Echte Programme hängen deshalb eine Prüfsumme an, die nur mit dem Schlüssel zu berechnen ist. Beim Verfahren ChaCha20-Poly1305 übernimmt das Poly1305.
Verständnisfragen
- Starte das Programm. Stimmen die ersten 16 Bytes des Geheimtexts, und kommt beim Entschlüsseln der Klartext wieder heraus?
- Was braucht der Empfänger, um den Geheimtext zu entschlüsseln?
- Aus welchem Block stammen die Bytes 64 bis 127 des Stroms?
- Jemand kippt unterwegs ein Bit des Geheimtexts. Was merkt der Empfänger davon?
Stufe 7: Dieselbe Nonce zweimal
ChaCha20 ist nur sicher, wenn jede Kombination aus Schlüssel und Nonce höchstens einmal vorkommt. Was sonst passiert, folgt direkt aus XOR. Zwei Nachrichten bekommen denselben Strom, und wer die beiden Geheimtexte miteinander verknüpft, dem kürzt sich der Strom heraus:
text
Geheimtext 1 XOR Geheimtext 2
= (Nachricht 1 XOR Strom) XOR (Nachricht 2 XOR Strom)
= Nachricht 1 XOR Nachricht 2Der Angreifer hat jetzt die beiden Klartexte miteinander verknüpft, ganz ohne Schlüssel. Wo beide Nachrichten dasselbe Zeichen haben, steht eine 0. Kennt er ein Stück der einen Nachricht, hat er an dieser Stelle die andere. Das ist die Abkürzung aus Stufe 2, nur liegt sie diesmal nicht am Verfahren, sondern an seiner Benutzung. Echte Programme zählen die Nonce deshalb hoch oder würfeln sie mit gutem Zufall aus.
Das Experiment kommt ans Ende des Programms aus Stufe 6. Es verwendet den Schlüssel und die Nonce aus dem letzten Test:
Python
# Das Experiment: zwei Nachrichten, derselbe Schlüssel, dieselbe Nonce
nachricht1 = "Der Igel schläft im Laub und träumt vom Apfel"
nachricht2 = "Die Eule jagt nachts über dem Feld nach Mäusen"
klar1 = text_zu_zahlen(nachricht1)
klar2 = text_zu_zahlen(nachricht2)
geheim1 = xor_mit(klar1, schluesselstrom(schluessel, nonce, len(klar1)))
geheim2 = xor_mit(klar2, schluesselstrom(schluessel, nonce, len(klar2)))
aus_geheimtexten = [a ^ b for a, b in zip(geheim1, geheim2)]
aus_klartexten = [a ^ b for a, b in zip(klar1, klar2)]
print(aus_geheimtexten)
print(aus_klartexten)
print(aus_geheimtexten == aus_klartexten)zip nimmt aus beiden Listen jeweils die Zahlen an derselben Stelle und hört auf, wenn die kürzere zu Ende ist.
Das ganze Programm dieser Stufe
Python
MASKE = 0xFFFFFFFF # 32 Einsen
def drehe(x, n):
# n Bits nach links; was links herausfällt, kommt rechts wieder herein
return ((x << n) & MASKE) | (x >> (32 - n))
def viertelrunde(tafel, a, b, c, d):
tafel[a] = (tafel[a] + tafel[b]) & MASKE
tafel[d] = drehe(tafel[d] ^ tafel[a], 16)
tafel[c] = (tafel[c] + tafel[d]) & MASKE
tafel[b] = drehe(tafel[b] ^ tafel[c], 12)
tafel[a] = (tafel[a] + tafel[b]) & MASKE
tafel[d] = drehe(tafel[d] ^ tafel[a], 8)
tafel[c] = (tafel[c] + tafel[d]) & MASKE
tafel[b] = drehe(tafel[b] ^ tafel[c], 7)
# als Text gelesen: "expand 32-byte k"
FESTE_ZAHLEN = [0x61707865, 0x3320646e, 0x79622d32, 0x6b206574]
def block(schluessel, zaehler, nonce):
start = FESTE_ZAHLEN + schluessel + [zaehler] + nonce
tafel = start[:] # eine Kopie, start bleibt für die Rückaddition erhalten
for doppelrunde in range(10):
# Spaltenrunde
viertelrunde(tafel, 0, 4, 8, 12)
viertelrunde(tafel, 1, 5, 9, 13)
viertelrunde(tafel, 2, 6, 10, 14)
viertelrunde(tafel, 3, 7, 11, 15)
# Diagonalrunde
viertelrunde(tafel, 0, 5, 10, 15)
viertelrunde(tafel, 1, 6, 11, 12)
viertelrunde(tafel, 2, 7, 8, 13)
viertelrunde(tafel, 3, 4, 9, 14)
strom = []
for i in range(16):
wort = (tafel[i] + start[i]) & MASKE # Rückaddition
# in vier Bytes zerlegen, das niedrigste zuerst
strom.append(wort & 255)
strom.append((wort >> 8) & 255)
strom.append((wort >> 16) & 255)
strom.append((wort >> 24) & 255)
return strom
def hexzeile(zahlen):
return "".join("%02x" % zahl for zahl in zahlen)
def text_zu_zahlen(text):
return [ord(zeichen) for zeichen in text]
def zahlen_zu_text(zahlen):
return "".join(chr(zahl) for zahl in zahlen)
def xor_mit(zahlen, schluessel):
ergebnis = []
for i in range(len(zahlen)):
# i % len(schluessel) läuft 0, 1, 2, 3, 4, 0, 1, ...: der Schlüssel wiederholt sich
ergebnis.append(zahlen[i] ^ schluessel[i % len(schluessel)])
return ergebnis
def schluesselstrom(schluessel, nonce, laenge):
strom = []
zaehler = 1
while len(strom) < laenge:
strom = strom + block(schluessel, zaehler, nonce)
zaehler = zaehler + 1
return strom[:laenge]
# Test mit den Werten aus RFC 8439, Abschnitt 2.1.1
woerter = [0x11111111, 0x01020304, 0x9b8d6f43, 0x01234567]
viertelrunde(woerter, 0, 1, 2, 3)
print([hex(wort) for wort in woerter])
# richtig: ['0xea2a92f4', '0xcb1cf8ce', '0x4581472e', '0x5881c4bb']
# Test mit den Werten aus RFC 8439, Abschnitt 2.3.2
schluessel = [0x03020100, 0x07060504, 0x0b0a0908, 0x0f0e0d0c,
0x13121110, 0x17161514, 0x1b1a1918, 0x1f1e1d1c]
nonce = [0x09000000, 0x4a000000, 0x00000000]
strom = block(schluessel, 1, nonce)
print(len(strom), hexzeile(strom[:16]))
# richtig: 64 10f1e7e4d13b5915500fdd1fa32071c4
# Test mit den Werten aus RFC 8439, Abschnitt 2.4.2
nonce = [0x00000000, 0x4a000000, 0x00000000]
text = "Ladies and Gentlemen of the class of '99: If I could offer you only one tip for the future, sunscreen would be it."
klar = text_zu_zahlen(text)
geheim = xor_mit(klar, schluesselstrom(schluessel, nonce, len(klar)))
print(hexzeile(geheim[:16]))
# richtig: 6e2e359a2568f98041ba0728dd0d6981
# Entschlüsseln ist dieselbe Rechnung
print(zahlen_zu_text(xor_mit(geheim, schluesselstrom(schluessel, nonce, len(geheim)))))
# Das Experiment: zwei Nachrichten, derselbe Schlüssel, dieselbe Nonce
nachricht1 = "Der Igel schläft im Laub und träumt vom Apfel"
nachricht2 = "Die Eule jagt nachts über dem Feld nach Mäusen"
klar1 = text_zu_zahlen(nachricht1)
klar2 = text_zu_zahlen(nachricht2)
geheim1 = xor_mit(klar1, schluesselstrom(schluessel, nonce, len(klar1)))
geheim2 = xor_mit(klar2, schluesselstrom(schluessel, nonce, len(klar2)))
aus_geheimtexten = [a ^ b for a, b in zip(geheim1, geheim2)]
aus_klartexten = [a ^ b for a, b in zip(klar1, klar2)]
print(aus_geheimtexten)
print(aus_klartexten)
print(aus_geheimtexten == aus_klartexten)Verständnisfragen
- Starte das Programm. Die letzte Zeile gibt
Trueaus. Was heißt das? - Was steht im verknüpften Ergebnis an den Stellen, an denen beide Nachrichten dasselbe Zeichen haben?
- Was gewinnt ein Angreifer, der ein Stück der einen Nachricht kennt?
- Was tun echte Programme, damit eine Nonce nicht zweimal vorkommt?
Wie es weitergeht
- Wie ChaCha20-Poly1305 die Prüfsumme berechnet, steht im RFC 8439 in den Abschnitten 2.5 bis 2.8, wieder mit Testwerten.
- In diesem Kapitel hatten Sender und Empfänger den Schlüssel schon. Wie zwei, die sich nie getroffen haben, über eine abgehörte Leitung einen gemeinsamen Schlüssel vereinbaren, löst der Diffie-Hellman-Schlüsseltausch. TLS macht genau das, bevor ChaCha20 loslegt.