Cloud-basierter Datenspeicher mit E2E Web Crypto

Julian

cloud-basierter-datenspeicher-mit-e2e-web-crypto

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 Exlusiv-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 decryptnur mit rohen Bytes (Uint8Arrays) arbeiten; wenn unsere Daten aus Strings bestehen, können wir diese mit TextEncoder und TextDecoder konvertieren.

Info

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.

Info

RSA, zusammengesetzt aus den Initialien 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.

Grafik über Verschlüsselungen

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.

Info

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.

Warnung

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 Kryptosystem aufzusetzen, das von mehr als 4000 Einrichtungen verwendet wird. Mehr Informationen zum Portal haben wir auf der MEBI-Projektseite bereitgestellt.

Themen:

  • Allgemein
  • Programmiersprachen
  • Softwareentwicklung