Blog

IT Security Summit 2020
Das große Trainingsevent für IT Security
23. - 25. November 2020 | München
28
Sep

Rubinrote Sicherheit
Teil 2: Nichtdeterministisch wirds sicherer

Im ersten Teil dieser Artikelserie haben wir an Beispielen gesehen, dass eine deterministische Verschlüsselung zu Problemen führen kann. Die Beispiele haben unsere Definition von Sicherheit, nämlich nicht zuzulassen, dass Information gewonnen werden, verletzt. Von Martin Boßlet

Deterministische Verschlüsselungsverfahren, d. h. immer gleiche Ciphertexte bei immer gleichen Plaintexten unter denselben Schlüssel, können grundsätzlich nicht zu einer sicheren Mehrfachverschlüsselung mit dem gleichen Schlüssel führen [1]. Natürlich ist es für die Praxis wichtig, dass wir mehr als einmal mit dem gleichen Schlüssel verschlüsseln können, gerade in Hinblick darauf, welch großes Problem der sichere Schlüsselaustausch darstellt. Allzu häufiger Schlüsseltausch zwischen zwei Parteien ist unerwünscht. Die Lösung ist nichtdeterministische Verschlüsselung („Randomized Encryption“). Wenn man mit diesem Verfahren den gleichen Plaintext zigmal verschlüsselt, erhält man mit sehr großer Wahrscheinlichkeit zig unterschiedliche Ergebnisse. Die Stream-Cipher-Konstruktion, wie wir sie bisher kennengelernt haben, ist im Kern ein zutiefst deterministischer Prozess. Zwar setzen wir für die Sicherheit auf einen sicheren Pseudozufallsgenerator, doch dieser wirkt nur auf den Uneingeweihten wie zufällig. Für alle im Besitz des Schlüssels ist der Pseudozufallsgenerator auch nur ein deterministischer Prozess, der, wenn er mit dem Schlüssel gefüttert wird, immer das exakt gleiche Ergebnis ausspucken wird.

Um unsere Verschlüsselung also schlussendlich nichtdeterministisch zu machen, müssen wir auf einem anderen Weg Zufälligkeit in die Berechnung einstreuen, da der Verschlüsselungsalgorithmus als solcher streng deterministisch arbeitet. Wer mit Passwörtern und Passwortsicherheit hantiert hat, findet sich jetzt vielleicht an die „Salt“-Idee erinnert – genau diese Idee liefert auch hier die Lösung. Wir erzeugen ein einmaliges, zufälliges Wegwerfelement, das wir in die Berechnung mit einfließen lassen. Damit der Empfänger dieses Element seinerseits in der Entschlüsselung berücksichtigen kann, teilt der Sender dieses Element dem Empfänger einfach mit. Das Geniale daran: In diesem Wegwerfelement steckt keine Sicherheit, wir können es getrost öffentlich publizieren, ohne dass wir die Sicherheit unserer Verschlüsselung gefährden.

Ausführen der Codebeispiele:

Sollten Sie Ruby noch nicht installiert haben, können Sie dies über www.ruby-lang.org/de vornehmen oder mit einem Tool wie www.rvm.io oder www.github.com/rbenv/rbenv.

Damit Sie auch die neueren Algorithmen wie ChaCha20/Poly1305 nutzen können, sollten Sie auf Ihrem System OpenSSL in einer Version >= 1.1.0 installiert haben.

Danach können Sie die Codebeispiele einfach ausführen, indem Sie das Git Repository unter www.github.com/emboss/stream-ciphers klonen, in das Verzeichnis code wechseln und dort die Beispiele ausführen mit ruby <beispiel.rb>

In der Verschlüsselung bezeichnet man dieses Zufallselement häufig als „Nonce“ (Kunstwort für number used once) oder, meist im Block-Cipher-Umfeld, als IV (Initialization Vector). Wie können wir einen solchen IV nutzen, um unsere Stream-Cipher-Konstruktion nichtdeterministisch arbeiten zu lassen? Listing 1 zeigt ein Beispiel für unseren hausgemachten Stream Cipher [2].

  • Listing 1
require 'securerandom'

require_relative 'rng'
require_relative 'string_refinements'

using StringRefinements

class StreamCipher

  attr_reader :key, :iv, :rng

  def initialize(key:, iv:)
    randomized_key = key.xor(iv)
    @rng = RNG.new(randomized_key)
  end

  def encrypt(data)
    pad = rng.bytes(data.size)
    data.xor(pad)
  end
  alias_method :decrypt, :encrypt

end

key = "\x00" * 16
iv = SecureRandom.bytes(16)
data = "Attack at dawn"
puts "Data length: #{data.size}"

encrypter = StreamCipher.new(key: key, iv: iv)
encrypted = encrypter.encrypt(data)

p encrypted # => Nichtdeterministisch

decrypter = StreamCipher.new(key: key, iv: iv)
decrypted = decrypter.decrypt(encrypted)

puts decrypted # => Attack at dawn
puts data == decrypted # => true

Wir verknüpfen den IV einfach mit dem Schlüssel via XOR. Da der IV mit einem kryptografisch sicheren Zufallsgenerator erzeugt wurde, reicht das aus, um das Verschlüsselungsergebnis willkürlich erscheinen zu lassen. Und auch die Entschlüsselung klappt einwandfrei, wie man schnell sieht, da sich die beiden k und iv am Ende rauskürzen.

c ⊕ (k ⊕ iv) = (m ⊕ k ⊕ iv) ⊕ (k ⊕ iv) = m ⊕ (k ⊕ k) ⊕ (iv ⊕ iv) = m

Ununterscheidbarkeit

Moderne Stream Cipher unterstützen diese Mehrfachverschlüsselung mittels IV von Haus aus, RC4 wie gesehen hingegen nicht. Das Sicherheitsmodell, das unsere bisherigen Betrachtungen um diesen Aspekt erweitert, wird als IND-CPA [3] bezeichnet. Der Begriff Ununterscheidbarkeit (Indistinguishability), der sich darin wiederfindet, spielt eine fundamentale Rolle in der modernen Verschlüsselung. Die Tatsache, dass eine sichere Verschlüsselung nichtdeterministisch sein muss, impliziert außerdem, dass eine sichere Verschlüsselung nicht unterscheidbar von willkürlich gewählten zufälligen Daten ist. Das ist ein mächtiges Kriterium bei der Bewertung eines Verschlüsselungsverfahrens: Sobald Sie im Ciphertext Muster erkennen können oder irgendeine andere Form des Determinismus, können Sie das Verfahren sofort als ein schlechtes aussortieren. Wohlgemerkt, Sie wissen an der Stelle zwar noch nicht, wie Sie es brechen würden, Sie wissen aber sehr wohl, dass es gebrochen werden kann, da es nicht den Anforderungen entspricht.

In den letzten Jahren konnte man den Eindruck gewinnen, dass Stream Cipher aus der Mode gekommen sind. Neben den oben besprochenen Tücken von RC4, die zwar allesamt lösbar sind, gab es immer mehr Forschungsergebnisse, die an der Sicherheit von RC4 selbst rütteln konnten. Da RC4 wegen seiner Performanz und vergleichsweise simplen Implementierung sehr beliebt war, bestand lange Zeit einfach keine Notwendigkeit, einen Nachfolger oder Konkurrenten im Stream-Cipher-Bereich zu schaffen. Als immer mehr abzusehen war, dass RC4 wackelt, wurde er aussortiert und meist der Block Cipher AES als Ersatz gewählt, da kein adäquater, weit verbreiteter Stream Cipher verfügbar war.

Das heißt aber nicht, dass Block Cipher den Stream Ciphern überlegen wären. Seit der Einführung von TLS in der Version 1.3 findet mittlerweile auch ein moderner Stream Cipher, der den Anforderungen von IND-CPA genügt, immer mehr Verbreitung. Es handelt sich um ChaCha20 [4], den Sie auch in Ruby OpenSSL nutzen können, vorausgesetzt, die zugrunde liegende native OpenSSL-Library ist aktuell genug [5] (Listing 2).

  • Listing 2
require 'openssl'
require 'securerandom'

data = "Attack at dawn"
puts "Data length: #{data.size}" # => 14

encryptor = OpenSSL::Cipher.new('chacha20')
key = encryptor.key = "\x00" * encryptor.key_len
iv = encryptor.iv = SecureRandom.bytes(encryptor.iv_len)
encryptor.encrypt
encrypted = encryptor.update(data)

p encrypted # => nichtdeterministisch
puts encrypted.size # => 14

decryptor = OpenSSL::Cipher.new('chacha20')
decryptor.key = key
decryptor.iv = iv
decryptor.decrypt
decrypted = decryptor.update(encrypted)

puts decrypted
puts data == decrypted # => true
puts "ChaCha20 key length: #{encryptor.key_len}" # => 32
puts "ChaCha20 IV length: #{encryptor.iv_len}" # => 16

Obwohl wir einen Schlüssel aus lauter Nullbytes verwenden, ist das Ergebnis nichtdeterministisch, was natürlich an dem verwendeten IV liegt. Ganz wichtig für die Sicherheit beim IV ist, dass er mit einem kryptografisch sicheren Zufallsgenerator, hier z. B. SecureRandom, erzeugt wird, da ein vorhersehbarer oder gar statischer IV die Sicherheit extrem gefährdet und im Prinzip wieder die gleichen Probleme mit sich bringen würde, die eine deterministische Verschlüsselung aufwirft. Bei den meisten Verfahren, die einen IV verwenden, ist dessen Größe fest vorgegeben oder es gibt zumindest eine optimale Größe, die Sie in Ruby OpenSSL dynamisch mit Cipher#iv_len abfragen können. Bei ChaCha20 sind dies 16 Bytes à 128 Bit, die empfohlene Schlüssellänge beträgt 32 Bytes à 256 Bit.

Ciphertext Malleability

Sind wir damit am Ende unserer Betrachtungen angelangt? Sind IND-CPA und die Verschlüsselungsalgorithmen, die es erfüllen, ausreichend? Leider noch nicht ganz. Zwar galten sie bis vor nicht allzu langer Zeit als ausreichend, aber es gab schon seit geraumer Zeit ein Problem bei der Verschlüsselung, das man aber ignorierte, da es nicht die Verschlüsselung im Sinne der Geheimhaltung angriff. Salopp formuliert garantiert Ihnen IND-CPA „Secrecy“, also die Geheimhaltung, eines Plaintexts. Ein Angreifer kann mit effizient berechenbarem Aufwand keine partielle Information über den verschlüsselten Plaintext gewinnen. Was IND-CPA aber nicht verspricht, ist die Integrität und die Authentizität eines Ciphertexts. Das bedeutet, Sie können einem Ciphertext nicht ansehen, ob er sich im Originalzustand befindet, und Sie haben auch keinen Anhaltspunkt, wer den Ciphertext erzeugt hat. Man kann argumentieren, dass dies ja auch nicht in erster Linie die Aufgabe von Verschlüsselung ist, dennoch hat es zu einer Vielzahl von Angriffen geführt, die diesen Umstand geschickt ausnutzen.

Stellen wir uns vor, unsere Verschlüsselung würde genutzt, um Gehaltszettel zu verschlüsseln. Dank IND-CPA ist sichergestellt, dass kein Angreifer die Gehaltszettel entschlüsseln kann, um z. B. herauszufinden, was eine bestimmte Person verdient. Sollte der Angreifer aber Zugriff auf die verschlüsselten Daten haben, hindert ihn erst einmal niemand daran, mutwillig den Ciphertext zu modifizieren. Was das bringen soll? Da wird es doch sicher eine Fehlermeldung geben, oder nicht? Schauen wir uns das einmal in Listing 3 an [6].

  • Listing 3
# encoding: UTF-8
require 'openssl'
require 'securerandom'

salary = "0002500€"

encryptor = OpenSSL::Cipher.new('chacha20')
key = encryptor.key = SecureRandom.bytes(encryptor.key_len)
iv = encryptor.iv = SecureRandom.bytes(encryptor.iv_len)
encryptor.encrypt
encrypted = encryptor.update(salary)

p encrypted

(0..255).each do |i|
  encrypted.setbyte(0, i)
  decryptor = OpenSSL::Cipher.new('chacha20')
  decryptor.key = key
  decryptor.iv = iv
  decryptor.decrypt
  decrypted = decryptor.update(encrypted)

  puts decrypted
end

Wenn Sie den Code ausführen, werden Sie feststellen, dass es erst einmal keinerlei Fehlermeldung gibt. Wir ändern das erste Byte des Ciphertexts so ab, dass jeder der möglichen Werte 0-255 eingesetzt wird und danach entschlüsseln wir das Ganze. Tatsächlich führt jeder Ciphertext zu einem Ergebnis. Unter den 256 Ergebnissen ist auch irgendwo das richtige dabei. Die meisten liefern keinen sinnvollen Wert für das erste Zeichen. Diese Fälle würden vermutlich auf Applikationsebene zu einem Fehler führen. Die wirklich gefährlichen Fälle sind aber diejenigen, bei denen an erster Stelle eben doch eine Zahl erscheint. Diese würden auf Applikationsebene mit großer Wahrscheinlichkeit keinen Fehler verursachen. Stellen Sie sich das Glücksgefühl vor, Sie hätten die Variante mit der 9 am Anfang erwischt!

Was ist davon zu halten? Erste Feststellung: Das ist durchaus ein valider Angriff, denn die Verschlüsselung verrichtet stumpf ihren Dienst und warnt Sie nicht vor etwaigen Ungereimtheiten im entschlüsselten Plaintext. Zweite Feststellung: Die Chance auf einen Treffer ist überraschend hoch. Wenn es, wie hier, darum geht, aus einem Byte eine Zahl zu erzwingen, ist Ihre Trefferchance 10 aus 256. Das ist weitaus besser, als Lotto zu spielen.

Da ein Verschlüsselungsalgorithmus am Ende des Tages einfach nur eine dumpfe Rechenvorschrift darstellt, spielt es keine Rolle, was man ihm zum Entschlüsseln vorlegt. Er wird einfach immer brav ein Ergebnis liefern, die Interpretation des Ganzen ist immer Aufgabe des Empfängers. Wie gesehen, kann dies aber schnell zu Problemen führen, etwa wenn plötzlich mehrere gültige Varianten auftauchen. Diese Problematik wird als „Cipher Text Malleability“ bezeichnet, was auf Deutsch so viel wie Formbarkeit bedeutet. Ein Ciphertext kann abgeändert werden und führt dann zu abgeänderten Ergebnissen, ohne dass ein Empfänger die Möglichkeit hätte, diese auf ihre Integrität und Authentizität zu prüfen

Authenticated Encryption

Um diesem Problem zu entgehen, muss die Verschlüsselung zusätzlich abgesichert werden, dem Empfänger muss ein Hilfsmittel zur Verfügung stehen, anhand dessen die Integrität und Authentizität des Ciphertexts verifiziert werden kann. Verfahren, die diese Garantie leisten, werden unter dem Oberbegriff „Authenticated Encryption“ [7] zusammengefasst. Das Sicherheitsmodell IND-CPA wird zu diesem Zweck erweitert zu IND-CCA [8] und bezieht zusätzlich diesen Authentizitätsgedanken in die Betrachtungen mit ein.

Reine Verschlüsselung kann uns diesen Aspekt nicht liefern, aber sehr wohl ein anderes kryptografisches Hilfsmittel: die sogenannten MACs (Message Authentication Code) [9]. Ein MAC liefert genau die gewünschte Authentizitätsaussage, die für Authenticated Encryption gebraucht wird. Grob gesprochen wird der MAC mit den zu authentifizierenden Daten gefüttert und spuckt als Ergebnis ein „Tag“, eine Art Prüfsumme, aus. Da der MAC selbst auch an einen geheimen Schlüssel gebunden ist, kann nur derjenige selbst valide Tags erzeugen oder diese überprüfen, der im Besitz des MAC-Schlüssels ist. Nachgeprüft wird ein Tag einfach, indem der Empfänger eines solchen Tags mit Hilfe des Schlüssels auf Basis der zu verifizierenden Daten selbst ein solches Tag nachrechnet. Ist es identisch mit dem ihm zuvor übermittelten Tag, weiß man, dass die Daten noch in dem gleichen Zustand sind wie zum Zeitpunkt des ursprünglichen Tags. Kommt man zu einem anderen Ergebnis, kann es dafür zwei Gründe geben:

  1. Die Daten haben sich in der Zwischenzeit verändert (Bruch der Integrität).
  2. Die Daten sind zwar unverändert, aber das Originaltag wurde mit einem anderen Schlüssel erzeugt (Bruch der Authentizität).

Angewandt auf Verschlüsselung stellt sich schnell die Frage, auf welcher Grundlage man das MAC-Tag berechnet und wie man Ciphertext und Tag an den Empfänger weiterreichen soll. Die einzig beweisbar sichere Variante ist das sogenannte „Encrypt-Then-Authenticate“ bzw. „Encrypt-Then-Mac“ [10], wie es heute meist genannt wird. Wird diese Methodik mit einem IND-CPA-sicheren Cipher implementiert, genügt das, um Authenticated Encryption zu implementieren, und ist somit IND-CCA-sicher. In Ruby OpenSSL können wir „Encrypt-Then-Mac“ z. B. mit ChaCha20 als Cipher und einem HMAC-SHA-256 als MAC wie in Listing 4 gezeigt implementieren [11].

  • Listing 4
# encoding: UTF-8
require 'openssl'
require 'securerandom'

require_relative 'string_refinements'

using StringRefinements

salary = "0002500€"

encryptor = OpenSSL::Cipher.new('chacha20')
key = encryptor.random_key
iv = encryptor.random_iv
encryptor.encrypt
mac_key = SecureRandom.bytes(32)
mac = OpenSSL::HMAC.new(mac_key, OpenSSL::Digest::SHA256.new)

# encrypt-
encrypted = encryptor.update(salary)
# then-mac
mac.update(encrypted)
tag = mac.digest

# Modifikation
encrypted.setbyte(0, encrypted.getbyte(0) + 1)
p encrypted

decryptor = OpenSSL::Cipher.new('chacha20')
decryptor.key = key
decryptor.iv = iv
decryptor.decrypt
verifier = OpenSSL::HMAC.new(mac_key, OpenSSL::Digest::SHA256.new)

# encrypt-then-mac, daher umgekehrte Reihenfolge
verifier.update(encrypted)
recomputed_tag = verifier.digest
# Entschlüsselter String ist immer ASCII-8BIT aka Encoding::BINARY
# Muss daher explizit als UTF-8 interpretiert werden
decrypted = decryptor.update(encrypted).force_encoding(Encoding::UTF_8)

puts decrypted

puts salary == decrypted

unless tag.secure_equals?(recomputed_tag)
  puts "Ciphertext wurde modifiziert"
else
  puts "Ciphertext im Originalzustand"
end

Wir nutzen hier zum ersten Mal die Komfortfunktionen von Cipher zum Erzeugen eines Schlüssels und eines IVs mittels Cipher.random_key und Cipher.random_iv. Diese sind insoweit empfehlenswert, als so sichergestellt ist, dass sowohl Schlüssel als auch IV die richtige Länge haben, insbesondere aber auch, dass sie mit einem kryptografisch sicheren Zufallsgenerator erzeugt werden. Weiterhin sehen wir auch eine Besonderheit, die einem schlaflose Nächte bereiten kann. Man hat hundert Mal überprüft, dass alle Parameter der Verschlüsselung korrekt gesetzt sind und trotzdem kommt nicht das erwünschte Ergebnis als Entschlüsselung zurück? Dann ist häufig das Encoding schuld. Solange die Plaintexte nur ASCII- oder beliebige Binärdaten darstellen, tritt das Problem nicht auf. Aber wenn, wie hier im Beispiel, Strings mit einem bestimmten Encoding genutzt werden, muss daran gedacht werden, im Endergebnis das richtige Encoding zu setzen, denn der String, der vom Entschlüsseln vom Cipher zurückgegeben wird, hat immer Encoding::BINARY bzw. Encoding::ASCII-8BIT, was einem rohen Binär-Encoding entspricht.

Wenn Sie die Zeile mit der Modifikation auskommentieren, wird der Ciphertext entschlüsselt, aber sobald er abgeändert wird, wird diese Änderung durch den HMAC detektiert. Insgesamt ist dies doch ein spürbarer Mehraufwand, den man betreiben muss, um Authenticated Encryption zu gewährleisten. Es braucht einen weiteren Schlüssel für den MAC, man muss aufpassen, dass man die richtige Reihenfolge für „Encrypt-Then-Mac“ einhält und insgesamt hat man fast doppelt so viel Code wie beim „alten“ Verschlüsseln. Und dann, was hat es mit tag.secure_equals? auf sich? Kann man die beiden Tags nicht einfach normal mit == vergleichen? Kann man leider nicht. Dies ist eine der häufigsten Sicherheitslücken im Umgang mit MACs. Werden sie mit normalen Gleichheitsprüfungen verglichen, ist dieser Code anfällig für einen Timing-Angriff. Ein Angreifer kann durch Messen der Ausführungszeit des Codes mit genügend Beispielen das korrekte Tag erhalten, ohne im Besitz des geheimen Schlüssels zu sein. Eine sehr anschauliche Einführung in die Problematik finden Sie in [12] und [13]. Es ist also wichtig, dass die Überprüfung eines MAC-Tags immer in konstant der gleichen Zeit passiert, um möglichen Angriffen keine Informationen darüber zu geben, wie das erwartete Tag aussieht. Es gibt verschiedene Lösungen, eine populäre ist die „Sticky-XOR“-Lösung, die wir in Listing 5 nutzen [14].

  • Listing 5
module StringRefinements
  refine String do
    def secure_equals?(other)
      return false unless bytesize == other.bytesize
      (each_byte.zip(other.each_byte).inject(0) { |memo, (a, b)|
        memo | (a ^ b)
      }) == 0
    end
  end
end

Man initialisiert einen Akkumulator mit dem Wert 0 und verknüpft Byte für Byte via bitweisem XOR. Das Ergebnis wird mit bitweisem AND mit dem Akkumulator verknüpft. Wenn alle Bytes identisch sind, ist das Ergebnis der XOR-Operation immer 0. Dementsprechend bleibt dann auch der Akkumulator 0. Ist auf der Strecke dahin auch nur ein Bytepärchen unterschiedlich gewesen, ist das XOR-Ergebnis 1, und in der Folge wird auch der Akkumulator zu 1. Das bitweise AND bleibt dann auch weiterhin 1 (sticky), egal wie die weiteren Prüfungen verlaufen. Wer genau aufgepasst hat, wird einwerfen, dass wir auch hier Informationen über die Länge verraten, da die Funktion frühzeitig aussteigt, sollten die zu vergleichenden Strings unterschiedliche Länge haben. Das ist in der Tat der Fall. Um dieses Problem weiter zu entschärfen, könnte man vorab noch beide Strings hashen, um sie auf die gleiche Länge zu bringen und so die Länge zu verschleiern. Im Allgemeinen nimmt man es aber in Kauf, dass die Länge verraten wird, da sie z. B. bei einem MAC ohnehin im Voraus bekannt ist.

Der heilige Gral von Authenticated Encryption

Keine Angst, Sie müssen sich das nicht alles merken, denn die Kryptografen hatten Erbarmen mit uns. Zwar fordern Sie, dass moderne Software überall ausschließlich Authenticated-Encryption-Verfahren einsetzen soll, doch es wurde schnell klar, dass man den Entwicklern die manuelle Implementation mit MAC und sicherem Tagvergleich etc. nicht zumuten sollte. Daher hat man integrierte Verschlüsselungsverfahren entwickelt, bei denen die Authentifizierung parallel zur Verschlüsselung berechnet wird. Das Geniale dabei ist, dass sich der Einsatz nur unwesentlich unterscheidet, die Authentifizierung des Ciphertexts passiert dabei unbemerkt unter der Haube.

Somit sind wir am Ende unserer Reise angelangt und haben nun alles parat, was eine moderne Verschlüsselung ausmacht. Grundsätzlich sollte jede Verschlüsselung, die heutzutage zum Einsatz kommt, Authenticated Encryption unterstützen. Wenn Sie die freie Wahl bei der Auswahl der verwendeten Algorithmen haben, verwenden Sie am besten ein integriertes Verfahren wie ChaCha20/Poly1305, das wir gleich noch im Beispiel verwenden werden. Doch selbst, wenn der Kontext sie zwingt, einen älteren Algorithmus zu verwenden, der vielleicht nur IND-CPA unterstützt (häufig ist dies z. B. AES im CBC-Modus), können Sie immer noch Authenticated Encryption nachrüsten, indem Sie zusätzlich manuell einen MAC berechnen, wie im Beispiel weiter oben. Das ist die gute Nachricht: Es gibt heute kaum einen Grund, Authenticated Encryption nicht zu unterstützen, da man es zur Not von Hand hinzufügen kann.

Kommen wir nun zum Abschluss zu dem Beispiel von ChaCha20/Poly1305, einem modernen Stream Cipher, der Authenticated Encryption unterstützt und somit IND-CCA-Sicherheit gewährleistet. Es handelt sich hierbei um den „normalen“ ChaCha20, bei dessen Berechnung noch zusätzlich ein „Authenticator“ mitgerechnet wird, der hier die Rolle des MACs übernimmt. Der MAC, der verwendet wird, ist Poly1305 [15]. Dieser Stream Cipher kommt auch bei TLS 1.3 zum Einsatz und kann auch unter Ruby genutzt werden [16], wie Listing 6 zeigt.

  • Listing 6
# encoding: UTF-8
require 'openssl'

salary = "0002500€"

encryptor = OpenSSL::Cipher.new('chacha20-poly1305')
key = encryptor.random_key
iv = encryptor.random_iv
encryptor.encrypt
encrypted = encryptor.update(salary) << encryptor.final tag = encryptor.auth_tag p encrypted puts encrypted.size puts "ChaCha20/Poly1305 key length: #{encryptor.key_len}" # => 32
puts "ChaCha20/Poly1305 IV length: #{encryptor.iv_len}" # => 12

# Modifikation
encrypted.setbyte(0, encrypted.getbyte(0) + 1)

decryptor = OpenSSL::Cipher.new('chacha20-poly1305')
decryptor.key = key
decryptor.iv = iv
decryptor.auth_tag = tag
decryptor.decrypt

begin
  decrypted = decryptor.update(encrypted) << decryptor.final puts decrypted.encoding # Entschlüsselter String ist immer ASCII-8BIT aka Encoding::BINARY # Muss daher explizit als UTF-8 interpretiert werden decrypted.force_encoding(Encoding::UTF_8) puts decrypted puts salary == decrypted # => true
  puts "Ciphertext im Originalzustand"
rescue OpenSSL::Cipher::CipherError => e
  puts "Ciphertext wurde modifiziert"
end

Aufgepasst: Im Unterschied zum normalen ChaCha20 ist die IV-Länge bei ChaCha20/Poly1305 12 Byte, also 96 Bit. Dies ist im Standard [16] so festgelegt worden.

Wie man sieht, ist das das gleiche Ergebnis wie beim Verschlüsseln mit HMAC-Authentifizierung, aber vom Code her fast identisch zum reinen ChaCha20-Beispiel. Ein paar winzige Neuerungen kommen allerdings hinzu. Das Tag der MAC-Berechnung kann man am Cipher mit Cipher.auth_tag abgreifen. Dieses muss bei der Initialisierung der Cipher-Instanz, die zum Entschlüsseln dient, via Cipher#auth_tag= gesetzt werden. Ganz wichtig ist dann auch, dass man zum Abschluss der Entschlüsselung unbedingt Cipher#final aufruft. Bei diesem Aufruf weiß die Cipher-Instanz, dass man mit der Entschlüsselung fertig ist, und daraufhin wird das MAC-Tag finalisiert und mit dem Wert verglichen, der anfangs bei der Initialisierung gesetzt wurde. Unterlässt man den Aufruf von Cipher#final, findet diese Prüfung nicht statt! Bei Block-Ciphern wird Cipher#final genutzt, um das Padding des letzten Blocks zu finalisieren, in der Regel ist bei Block-Ciphern der Rückgabewert der tatsächlich letzte Block des finalen Ciphertexts. Da Stream Cipher kein Padding benötigen, ist dort normalerweise der Aufruf von Cipher#final gar nicht notwendig. Wir hatten ihn in den Beispielen zuvor auch bewusst weggelassen. Im Fall von Authenticated Encryption ist er aber zwingend notwendig, um die eventuelle Modifikation des Ciphertexts zu detektieren. Wurde eine Modifikation festgestellt, wird ein OpenSSL::Cipher::CipherError geworfen. Daher ist es auch bei der Nutzung von Stream Ciphern empfehlenswert, Cipher#final zu verwenden, dem Muster folgend, das auch bei Block Ciphern verwendet wird:

decrypted = cipher.update(encrypted) << cipher.final

Zwar wird Cipher#final bei einem Stream Cipher immer einen leeren String der Länge 0 zurückliefern, allerdings kann man so sicherstellen, dass die MAC-Berechnung bei integrierten Authenticated-Encryption-Modi zuverlässig durchgeführt wird, und man kann bei Bedarf einfach von einem Stream Cipher zu einem Block Cipher wechseln, ohne dass man den Verschlüsselungscode anpassen müsste.

tl;dr

Ich hoffe, Ihnen hat unsere kleine Reise durch die Welt der Stream Cipher Spaß gemacht und Sie können nun besser nachvollziehen, was eine moderne Verschlüsselung ausmacht, wo bei älteren Ansätzen die Probleme lagen, wie man sie im Laufe der Zeit gelöst hat und worin der Mehrwert von Authenticated Encryption liegt. Insbesondere freue ich mich auch, wenn ich Ihnen Ruby und besonders Kryptografie mit Ruby OpenSSL schmackhaft machen konnte, da hier der Komfort und die Eleganz von Ruby voll ausgenutzt werden, ohne auf Features der nativen OpenSSL-Library verzichten zu müssen.

Martin Boßlet ist selbständiger Berater mit den Schwerpunkten Sicherheit, Web- und Unternehmensanwendungen. Zudem vermittelt er regelmäßig sein Wissen in Seminaren als Trainer für Kryptographie, Programmiersprachen und verschiedene Themen aus dem Bereich der Webentwicklung. Seine besondere Leidenschaft gilt der Programmiersprache Ruby, wo er Mitglied im Core-Team ist und die Kryptographiebibliothek betreut.

Links & Literatur

[1] https://blog.cryptographyengineering.com/why-ind-cpa-implies-randomized-encryption/

[2] https://github.com/emboss/stream-ciphers/blob/master/code/stream_cipher_iv.rb

[3] https://en.wikipedia.org/wiki/Ciphertext_indistinguishability#IND-CPA

[4] https://cr.yp.to/chacha.html

[5] https://github.com/emboss/stream-ciphers/blob/master/code/chacha20.rb

[6] https://github.com/emboss/stream-ciphers/blob/master/code/chacha20_malleability.rb

[7] https://en.wikipedia.org/wiki/Authenticated_encryption

[8] https://en.wikipedia.org/wiki/Ciphertext_indistinguishability#Indistinguishability_under_chosen_ciphertext_attack/adaptive_chosen_ciphertext_attack_(IND-CCA1,_IND-CCA2)

[9] https://en.wikipedia.org/wiki/Message_authentication_code

[10] https://www.iacr.org/archive/crypto2001/21390309.pdf

[11] https://github.com/emboss/stream-ciphers/blob/master/code/chacha20_hmac.rb

[12] https://codahale.com/a-lesson-in-timing-attacks/

[13] https://paragonie.com/blog/2015/11/preventing-timing-attacks-on-string-comparison-with-double-hmac-strategy

[14] https://github.com/emboss/stream-ciphers/blob/master/code/string_refinements.rb

[15] https://cr.yp.to/mac.html

[16] https://tools.ietf.org/html/rfc7539

Alle News zum IT Security Summit