Cloud-basierter Datenspeicher mit E2E Web Crypto
Julian
Ende-zu-Ende Verschlüsselung ermöglicht es, Kommunikationen zwischen Nutzern gegen Abhörung oder Verfälschung abzusichern. Dabei können Informationen über unsichere Kanäle wie das Internet übertragen werden, da die Ver- und Entschlüsselung direkt auf dem Endgerät des Senders bzw. des Empfängers erfolgt. Auch die langfristige Speicherung von Daten auf externen Hostern kann auf diese Weise gesichert werden, denn der Hoster hat selbst zu keinem Zeitpunkt Zugriff auf die unverschlüsselten Daten. Das wollen wir in diesem Artikel beispielhaft demonstrieren: wir bauen einen Cloud-basierten Datenspeicher, der, ähnlich wie der Passwort-Manager Bitwarden, mit einer Ende-zu-Ende Verschlüsselung gesichert ist.
Der Dienst soll aus zwei Komponenten bestehen – einer Web-App, in der die Daten verarbeitet werden, und einem Backend, das die Daten in verschlüsselter Form persistiert. Dazu nutzen wir die Web Crypto API, die uns von modernen Browsern als standardisierter „Baukasten“ mit geprüften kryptographischen Operationen bereitgestellt wird, und die Umsetzung verschiedener Verschlüsselungsverfahren in Web-Anwendungen ermöglicht.
Grundlagen der digitalen Verschlüsselung
Was bedeutet es überhaupt, wenn wir von Verschlüsselung sprechen? Im Wesentlichen werden digitale Informationen verschlüsselt, indem die Bits der unverschlüsselten Daten (dem Klartext) mit den Bits aus einer zufälligen Anordnung (dem Schlüssel) kombiniert werden. Diesem Prozess liegt das Exklusiv-Oder-Gatter (XOR) zugrunde.
+---+---+---------+
| A | B | A XOR B |
+---+---+---------+
| 0 | 0 | 0 |
| 0 | 1 | 1 |
| 1 | 0 | 1 |
| 1 | 1 | 0 |
+---+---+---------+
Das Prinzip dahinter ist, dass die Bits der verschlüsselten Daten nicht ohne den entsprechenden Schlüssel in ihren Ursprungszustand versetzt werden können; beispielsweise könnte ein verschlüsseltes Bit 1 das Ergebnis der Operation 1 XOR 0, oder auch 0 XOR 1 sein. Für ein verschlüsseltes Bit 0 gibt es ebenfalls zwei Optionen. Weil die Bits des Schlüssels gleichverteilt zufällig gewählt werden, ist jede dieser Möglichkeiten genauso wahrscheinlich wie die andere. Wenn wir den Schlüssel kennen, ist es jedoch ein Leichtes, den Klartext wiederherzustellen: eine zweite XOR-Verknüpfung macht die erste rückgängig.
Symmetrische Verschlüsselung mit AES
Bei der symmetrischen Verschlüsselung wird derselbe Schlüssel zum Verschlüsseln und Entschlüsseln der Daten verwendet. Einer der am weitesten verbreiteten Algorithmen dieser Art ist AES (Advanced Encryption Standard). Entwickelt wurde er von den Kryptologen Daemen und Rijmen unter dem Namen Rijndael, bevor er als Sieger einer Ausschreibung des National Institute of Standards and Technology (NIST) ausgewählt wurde. Der Sicherheitsgrad von AES ist so hoch, dass er von Banken und Militärs eingesetzt wird.
Als Blockchiffre (block cipher) kann AES normalerweise nur einen Klartextblock mit einer festen Länge von 128 Bits verschlüsseln. Der Betriebsmodus AES-GCM (Galois/Counter Mode) wandelt dies in eine Art Stromchiffre (stream cipher) um, sodass Daten beliebiger Länge verschlüsselt werden können. Durch den Zusatz von Prüfsummen, den sogenannten Authentication Tags, wird zudem die Integrität der verschlüsselten Daten gewährleistet.
GCM hat allerdings eine Besonderheit die wir beachten müssen: jede Verschlüsselung erfordert einen Initialisierungsvektor (initialization vector) der mit dem ersten Block verknüpft wird, um zu vermeiden, dass mehrere gleiche Klartextblöcke den gleichen Geheimtextblock ergeben, was wiederum Rückschlüsse auf den Schlüssel ermöglichen würde. Daher müssen wir jedes Mal einen IV wählen, der noch nicht in Kombination mit diesem Schlüssel verwendet wurde; dafür reicht z.B. ein Zähler aus, den wir nach Verwendung des Schlüssels um eins erhöhen.
Planung der Datenstruktur
Als Ausgangspunkt für unseren Datenspeicher nehmen wir einen einfachen Key-Value Store. Die darin gespeicherten Werte sollen verschlüsselt, und nur vom Endnutzer mithilfe eines Master-Schlüssels gelesen und geschrieben werden können. Das können einfache Strings wie Passwörter, oder auch strukturierte Informationen wie z.B. JSON-Dokumente sein. Wir fangen an mit folgendem Datensatz:
+----+---------+-----------------+----+
| id | user_id | encrypted_value | iv |
+----+---------+-----------------+----+
Anhand einer eindeutigen id
und user_id
identifizieren wir den Datensatz und seinen Besitzer; das könnten zum
Beispiel UUIDs sein. encrypted_value
enthält die zugehörigen Daten in AES-GCM-verschlüsselter Form. Für die
Entschlüsselung benötigen wir außerdem den verwendeten Initialisierungsvektor iv
, den wir gleichzeitig verändern, wenn
ein neuer encrypted_value
geschrieben wird. Je nach Anwendungsdomäne können wir weitere Metadaten hinzufügen, wie z.B.
eine Bezeichnung des Datensatzes, Erstelldatum, Änderungsdatum usw.
AES mit Web Crypto
In der JavaScript-Umgebung eines modernen Browsers kommen wir über crypto.subtle
an das SubtleCrypto
-Interface, das
uns die Methoden der Web Crypto API liefert. Der etwas merkwürdig klingende Name „subtle“ („subtil“) weist darauf hin,
dass die darin enthaltenen Algorithmen bestimmte Anforderungen an den Entwickler stellen, die erfüllt werden müssen,
bevor die Verschlüsselung wirklich als sicher gelten kann
(Diese Notiz aus der W3C-Spezifikation erwähnt als Beispiel
einer Sicherheitslücke die Verwendung von AES-CTR ohne Message Authentication Codes, was Angreifern erlauben würde, die
Daten zu manipulieren).
In folgendem Code-Beispiel generieren wir einen 256-Bit AES-Schlüssel und nutzen ihn zur Ver- und Entschlüsselung mit
AES-GCM. Zu beachten ist, dass die Web Crypto Methoden encrypt
und decrypt
nur mit rohen Bytes (Uint8Arrays
)
arbeiten; wenn unsere Daten aus Strings bestehen, können wir diese mit TextEncoder
und TextDecoder
konvertieren.
Wir statten den Klartext mit Padding aus, um zu verhindern, dass man über die exakte Länge des Geheimtextes eventuelle Rückschlüsse auf den Klartext ziehen kann, was insbesondere für einen Passwort-Manager problematisch wäre.
const encryptAesGcm = (key: CryptoKey, value: string, iv: Uint8Array) => {
const encodedValue = new TextEncoder().encode(value);
const paddedEncodedValue = new Uint8Array(Math.ceil((encodedValue.length + 1) / 32) * 32);
paddedEncodedValue.set(encodedValue);
const res = crypto.subtle.encrypt(
{
name: 'AES-GCM',
iv,
},
key,
encodedValue,
);
return res;
};
const decryptAesGcm = async (key: CryptoKey, ciphertext: ArrayBuffer, iv: Uint8Array) => {
const decrypted = await crypto.subtle.decrypt(
{
name: 'AES-GCM',
iv,
},
key,
ciphertext,
);
return new TextDecoder().decode(decrypted);
};
const main = async () => {
const key = await crypto.subtle.generateKey(
{
name: 'AES-GCM',
length: 256,
},
true,
['encrypt', 'decrypt'],
);
const iv = crypto.getRandomValues(new Uint8Array(16));
const encrypted = await encryptAesGcm(key, 'Hello world', iv);
console.log(await decryptAesGcm(key, new Uint8Array(encrypted), iv));
};
main();
Asymmetrische und hybride Verschlüsselung mit RSA
Nun stellen wir uns vor, dass Nutzer in der Lage sein wollen, Daten miteinander zu teilen. Dabei sollen Zugriffsrechte individuell pro Datensatz vergeben werden können. Das ist schwierig, weil die symmetrische Verschlüsselung voraussetzt, dass alle Nutzer, die Zugriff auf einen Datensatz haben, denselben Schlüssel besitzen — und sonst niemand. Es muss also ein Schlüsselaustausch zwischen den Parteien stattfinden, der ebenfalls abgesichert (verschlüsselt) werden muss; ein Henne-Ei-Problem.
Die Antwort auf dieses Problem ist die asymmetrische Verschlüsselung. Im Gegensatz zur symmetrischen Verschlüsselung kommen hier zwei Schlüssel zum Einsatz, ein „Public/private keypair“. Der public key wird zur Verschlüsselung verwendet; oft sind public keys öffentlich einsehbar, z.B. in Form eines GPG keys oder eines TLS-Zertifikats, sodass jede beliebige Person etwas damit verschlüsseln kann. Nur der dazu passende private key kann die Daten wieder entschlüsseln und muss daher geheim gehalten werden.
> RSA, zusammengesetzt aus den Initialen der Erfinder Rivest, Shamir und Adleman, ist eines der ältesten und am häufigsten benutzen Verfahren, um ein Schlüsselpaar mit dieser Eigenschaft herzustellen.
Asymmetrische Verschlüsselung erlaubt es uns, einen Schlüssel (public key) zu übermitteln, ohne ihn wortwörtlich ins Ohr flüstern zu müssen. Sie hat jedoch auch einen großen Nachteil, denn aufgrund ihres grundsätzlich höheren Rechenaufwands ist asymmetrische Verschlüsselung für größere Datenmengen ungeeignet. Dafür haben sich Kryptologen einen Trick ausgedacht: die hybride Verschlüsselung. Anstelle der Daten verschlüsseln wir einen zufällig generierten symmetrischen Schlüssel (z.B. AES) um diesen sicher mit anderen Parteien zu kommunizieren. Dieser Schlüssel ist verantwortlich für die eigentliche Verschlüsselung der Daten.
RSA mit Web Crypto
In diesem Beispiel generieren wir ein RSA public/private keypair und ver- und entschlüsseln damit einen AES-Schlüssel.
const generateKeyPair = (): Promise<CryptoKeyPair> => {
return crypto.subtle.generateKey(
{
name: 'RSA-OAEP',
modulusLength: 4096,
publicExponent: new Uint8Array([1, 0, 1]),
hash: 'SHA-512',
},
true,
['wrapKey', 'unwrapKey'],
);
};
const main = async () => {
const keypair = await generateKeyPair();
const aesKey = await crypto.subtle.generateKey(
{
name: 'AES-GCM',
length: 256,
},
true,
['encrypt', 'decrypt'],
);
const wrapped = await crypto.subtle.wrapKey('raw', aesKey, keypair.publicKey, {
name: 'RSA-OAEP',
});
const unwrapped = await crypto.subtle.unwrapKey('raw', wrapped, keypair.privateKey, {name: 'RSA-OAEP'}, {name: 'AES-GCM'}, true, ['encrypt', 'decrypt']);
};
Erweiterung der Datenstruktur
Wenn ein Datensatz geteilt wird, generieren wir dafür einen geteilten AES-Schlüssel. Zugriff auf diesen Schlüssel muss
beschränkt sein auf ausgewählte Nutzer. Um das umzusetzen, legen wir serverseitig eine zweite Tabelle an, in der wir die
Zugriffsrechte der Datensätze festhalten. Die Felder user_id
und record_id
stellen eine Zuordnung zum jeweiligen
Nutzer und dem geteilten Datensatz her. Aber was hat es mit wrapped_encryption_key
auf sich? Wir sind kurz auf die
hybride Verschlüsselung eingegangen; das Ergebnis daraus ist ein verschlüsselter symmetrischer Schlüssel, und einen
solchen legen wir hier ab (im Crypto-Jargon wird die Verschlüsselung von Schlüsseln auch als „key wrapping“ bezeichnet).
Für jeden autorisierten Nutzer legen wir eine entsprechende Zeile an, rufen seinen (öffentlichen) Public Key ab,
verschlüsseln damit den geteilten Schlüssel und schreiben das Ergebnis in wrapped_encryption_key
.
Wenn ein Nutzer auf den Datensatz zugreifen möchte, gibt der Server diesen wrapped_encryption_key
zurück. Der Nutzer
kann dann seinen Private Key verwenden, um den geteilten Schlüssel aufzudecken und damit den Datensatz zu entschlüsseln.
+---------+-----------+------------------------+
| user_id | record_id | wrapped_encryption_key |
+---------+-----------+------------------------+
Falls die Teilung mit einem Nutzer widerrufen wird, müssen wir die entsprechende Zeile aus der Tabelle löschen, einen
neuen geteilten Schlüssel generieren, den Datensatz mit diesem geteilten Schlüssel erneut verschlüsseln und anschließend
für jeden Nutzer den wrapped_encryption_key
aktualisieren.
Zur Erinnerung: unsere Ende-zu-Ende Verschlüsselung hat den Anspruch, dass die Daten das Endgerät nicht in unverschlüsselter Form verlassen. Daher laufen die meisten der oben aufgeführten Prozesse direkt auf dem Endgerät des ausführenden Nutzers ab.
Passwort statt Private Key
Als nächstes müssen wir uns überlegen, wie bzw. wo wir die asymmetrischen Schlüssel aufbewahren, von denen unsere
Informationssicherheit abhängt. Sollten wir den Private key verlieren, hätten wir keinen Zugriff mehr auf die
verschlüsselten Daten; außerdem darf er auch nicht in die Hände Dritter gelangen. Die einfachste Lösung ist, den
Schlüssel als Datei auf dem Endgerät zu lagern, wo er letztendlich auch verwendet wird. Ähnlich machen es auch SSH
(Secure shell) Clients, indem sie das .ssh-Verzeichnis des Nutzers durchsuchen, wo das Schlüsselpaar, z.B. mit den
Bezeichnungen id_rsa
(private) und id_rsa.pub
(public) abgelegt ist. Für Web-Anwendungen ohne direkten Zugriff auf
das Dateisystem bedeutet das aber, dass die Schlüssel zum Herstellen einer sicheren Kommunikation zuerst in den Browser
geladen werden müssten.
Es gibt aber noch ein anderes Problem: Viele Nutzer sind es gewohnt, sich mit einem Passwort einzuloggen und würden das gerne auch weiterhin so machen. Wir können dem Nutzer die Verwaltung des Schlüssels abnehmen, indem wir den Schlüssel stattdessen auf dem Server ablegen. Wir achten darauf, dass wir den Schlüssel nicht in ungeschützter, sondern in verschlüsselter Form aufbewahren, da dieser sonst von einem potentiellen Angreifer oder gar dem Betreiber des Dienstes selbst (ja, uns) missbraucht werden könnte. Grundlage für diese Verschlüsselung ist ein symmetrischer Schlüssel, der aus dem Passwort abgeleitet wird. Eigens für diesen Zweck gibt es eine Familie von Funktionen, zu denen unter anderem PBKDF2 (Password-Based Key Derivation Function 2) und modernere Varianten wie Scrypt und Argon2 gehören. Diese Funktionen zielen darauf ab, Brute-Force-Angriffe zum Erraten des Schlüssels so schwer wie möglich machen, was z.B. durch einen relativ hohen Aufwand an Rechenleistung oder Speichernutzung gewährleistet wird. Üblicherweise nimmt eine Ableitungsfunktion als Parameter auch ein „Salt“: eine Folge von Bits die für jedes Passwort individuell und zufällig generiert wird.
Der Salt-Wert wird mit dem Passwort kombiniert, und verhindert so den Einsatz von Rainbow Tables, sodass gespeicherte Passwort-Hashes – auch jene aus identischen Passwörtern – von Angreifern einzeln „geknackt“ werden müssten.
Wenn der Nutzer sich einloggt, tut er das also wie gewohnt mit E-Mail und Passwort. Weil die Verschlüsselung des Private
Keys aus dem Passwort abgeleitet wird, müssen wir allerdings unbedingt verhindern, dass das Passwort im Klartext an die
Außenwelt gelangt. Daher generieren wir mit der gleichen Ableitungsfunktion einen weiteren Hash-Wert (password_hash
),
der anstelle des Passworts beim Login versendet und auf dem Server verglichen wird.
Die Datenstruktur, die einen Nutzer beschreibt, könnte etwa so aussehen (die E-Mail Adresse kann auch durch eine andere eindeutige Benutzerkennung ersetzt werden):
+----+-------+---------------+------------+---------------------+
| id | email | password_hash | public_key | wrapped_private_key |
+----+-------+---------------+------------+---------------------+
Das Feld wrapped_private_key
hält den Private Key in verschlüsselter Form und wird so nach erfolgreichem Login vom
Server ausgehändigt. Um den Private Key im Klartext zu erhalten muss der Key Wrap rückgängig gemacht werden – dazu wird
die Ableitung des symmetrischen Schlüssels aus dem Passwort wiederholt und der wrapped_private_key
damit
entschlüsselt.
Ableitungsfunktionen mit Web Crypto
In diesem Code-Beispiel leiten wir mit PBKDF2 aus einem Passwort einen AES-Schlüssel ab. Mit deriveKey
kombinieren wir
die Zwischenschritte deriveBits
und importKey
. Für das Schlüsselmaterial brauchen wir trotzdem einen separaten
Aufruf von importKey
, weil die deriveKey
-Methode einen Basisschlüssel vom Typ CryptoKey
, und nicht etwa ein
Uint8Array erwartet.
Die Web Crypto API fordert von uns, dass wir für jeden Schlüssel eine genaue Angabe machen, wie er verwendet
werden darf. In diesem Fall erlauben wir es, aus dem Schlüsselmaterial keyMaterial
Ableitungen herzustellen. Der
abgeleitete AES-Schlüssel ist hingegen nur für Verschlüsselungen und Entschlüsselungen zugelassen.
const deriveAesGcm256Key = async (password: string, salt: string) => {
const enc = new TextEncoder();
const keyMaterial = await crypto.subtle.importKey('raw', enc.encode(password), 'PBKDF2', false, ['deriveBits', 'deriveKey']);
return crypto.subtle.deriveKey(
{
name: 'PBKDF2',
iterations: 100_000,
hash: 'SHA-256',
salt: enc.encode(salt),
},
keyMaterial,
{name: 'AES-GCM', length: 256},
true,
['encrypt', 'decrypt'],
);
};
Anwendungsbeispiel: E2E-Verschlüsselung in MEBI
MEBI, das Meldeportal zur einrichtungsbezogenen Impfpflicht, wird von ATMINA in Kooperation mit dem Ministerium für Soziales, Gesundheit und Gleichstellung entwickelt. Das Portal verarbeitet Gesundheitsdaten mit Personenbezug, also Daten die nach DSGVO als höchste Schutzkategorie gestuft sind. Um die Vertraulichkeit dieser hochsensiblen Informationen zu gewährleisten, entschieden wir uns zu Beginn des Projektes für eine nahtlose Ende-zu-Ende Verschlüsselung zwischen den meldenden Einrichtungen und den 44 Gesundheitsämtern in Niedersachsen. Die Verfügbarkeit auf fast allen Geräten und der große Funktionsumfang der Web Crypto API halfen uns dabei, in kürzester Zeit ein vollständiges Krypto-System aufzusetzen, das von mehr als 4000 Einrichtungen verwendet wird. Mehr Informationen zum Portal haben wir auf der MEBI-Projektseite bereitgestellt.
Themen:
- Allgemein
- Programmiersprachen
- Softwareentwicklung