Verschlüsselung von Web-Push-Nutzlasten

Mat Scales

Vor Chrome 50 konnten Push-Nachrichten keine Nutzlastdaten enthalten. Als das Push-Ereignis in Ihrem Service Worker ausgelöst wurde, wussten Sie nur, dass der Server versuchte, Ihnen etwas mitzuteilen, aber nicht, was es sein könnte. Anschließend mussten Sie eine Folgeanfrage an den Server senden und die Details der anzuzeigenden Benachrichtigung abrufen. Dies konnte bei schlechten Netzwerkbedingungen fehlschlagen.

In Chrome 50 (und in der aktuellen Version von Firefox auf Desktop-Computern) können Sie nun einige beliebige Daten zusammen mit dem Push-Vorgang senden, sodass der Client die zusätzliche Anfrage vermeiden kann. Eine hohe Leistung bringt jedoch auch eine große Verantwortung mit sich, sodass alle Nutzlastdaten verschlüsselt werden müssen.

Die Verschlüsselung von Nutzlasten ist ein wichtiger Bestandteil der Sicherheitsstrategie für Web-Push. HTTPS bietet Ihnen Sicherheit bei der Kommunikation zwischen dem Browser und Ihrem eigenen Server, da Sie dem Server vertrauen. Der Browser wählt jedoch aus, welcher Push-Anbieter für die tatsächliche Bereitstellung der Nutzlast verwendet wird. Sie als App-Entwickler haben also keine Kontrolle darüber.

Hier kann HTTPS nur garantieren, dass die Nachricht bei der Übertragung an den Push-Dienstanbieter nicht ausspioniert werden kann. Sobald sie es erhalten haben, können sie tun, was sie möchten, einschließlich der erneuten Übertragung der Nutzlast an Dritte oder der böswilligen Änderung an etwas anderem. Um dies zu verhindern, setzen wir die Verschlüsselung ein, um sicherzustellen, dass Push-Dienste die Nutzlasten während der Übertragung nicht lesen oder manipulieren können.

Clientseitige Änderungen

Wenn Sie bereits Push-Benachrichtigungen ohne Nutzlasten implementiert haben, müssen clientseitig nur zwei kleine Änderungen vorgenommen werden.

Zum einen musst du zusätzliche Informationen erfassen, wenn du die Aboinformationen an deinen Back-End-Server sendest. Wenn du bereits JSON.stringify() für das PushSubscription-Objekt verwendest, um es zum Senden an den Server zu serialisieren, musst du nichts ändern. Das Abo enthält jetzt zusätzliche Daten in der Schlüsseleigenschaft.

> JSON.stringify(subscription)
{"endpoint":"https://android.googleapis.com/gcm/send/f1LsxkKphfQ:APA91bFUx7ja4BK4JVrNgVjpg1cs9lGSGI6IMNL4mQ3Xe6mDGxvt_C_gItKYJI9CAx5i_Ss6cmDxdWZoLyhS2RJhkcv7LeE6hkiOsK6oBzbyifvKCdUYU7ADIRBiYNxIVpLIYeZ8kq_A",
"keys":{"p256dh":"BLc4xRzKlKORKWlbdgFaBrrPK3ydWAHo4M0gs0i1oEKgPpWC5cW8OCzVrOQRv-1npXRWk8udnW3oYhIO4475rds=",
"auth":"5I2Bu2oKdyy9CwL8QVF0NQ=="}}

Die beiden Werte p256dh und auth sind in einer Variante von Base64 codiert, die ich URL-Safe Base64 nenne.

Wenn Sie stattdessen direkt bei den Byte ankommen möchten, können Sie die neue Methode getKey() für das Abo verwenden, das einen Parameter als ArrayBuffer zurückgibt. Die beiden Parameter, die Sie benötigen, sind auth und p256dh.

> new Uint8Array(subscription.getKey('auth'));
[228, 141, 129, ...] (16 bytes)

> new Uint8Array(subscription.getKey('p256dh'));
[4, 183, 56, ...] (65 bytes)

Die zweite Änderung ist eine neue data-Property, wenn das push-Ereignis ausgelöst wird. Es bietet verschiedene synchrone Methoden zum Parsen der empfangenen Daten, z. B. .text(), .json(), .arrayBuffer() und .blob().

self.addEventListener('push', function(event) {
  if (event.data) {
    console.log(event.data.json());
  }
});

Serverseitige Änderungen

Auf der Serverseite ändert sich etwas mehr. Der grundlegende Prozess besteht darin, dass Sie die Verschlüsselungsschlüsselinformationen, die Sie vom Client erhalten haben, zum Verschlüsseln der Nutzlast verwenden und diese dann als Text einer POST-Anfrage an den Endpunkt im Abo senden und einige zusätzliche HTTP-Header hinzufügen.

Die Details sind relativ komplex. Wie bei allem, was mit der Verschlüsselung zu tun hat, ist es besser, eine aktiv entwickelte Bibliothek zu verwenden, als Ihre eigene zu erstellen. Das Chrome-Team hat eine Bibliothek für Node.js veröffentlicht. Weitere Sprachen und Plattformen werden bald folgen. Dieser verarbeitet sowohl die Verschlüsselung als auch das Web-Push-Protokoll, sodass das Senden einer Push-Nachricht von einem Node.js-Server genauso einfach ist wie webpush.sendWebPush(message, subscription).

Wir empfehlen die Verwendung einer Bibliothek auf jeden Fall. Allerdings handelt es sich hierbei um eine neue Funktion. Für viele beliebte Sprachen gibt es noch keine Bibliotheken. Wenn Sie dies selbst implementieren müssen, finden Sie hier die Details.

Ich werde die Algorithmen mit Node.js-JavaScript veranschaulichen, aber die Grundprinzipien sollten in jeder Sprache gleich sein.

Eingaben

Zum Verschlüsseln einer Nachricht müssen zuerst zwei Dinge aus dem Aboobjekt abgerufen werden, das wir vom Client erhalten haben. Wenn Sie JSON.stringify() auf dem Client verwendet und an Ihren Server übertragen haben, wird der öffentliche Schlüssel des Clients im Feld keys.p256dh gespeichert, während sich das gemeinsame Authentifizierungs-Secret im Feld keys.auth befindet. Beide sind wie oben erwähnt URL-sicher mit Base64 codiert. Das Binärformat des öffentlichen Schlüssels des Clients ist ein nicht komprimierter Punkt mit elliptischen P-256-Kurven.

const clientPublicKey = new Buffer(subscription.keys.p256dh, 'base64');
const clientAuthSecret = new Buffer(subscription.keys.auth, 'base64');

Mit dem öffentlichen Schlüssel können wir die Nachricht so verschlüsseln, dass sie nur mit dem privaten Schlüssel des Clients entschlüsselt werden kann.

Öffentliche Schlüssel gelten in der Regel als öffentlich. Damit sich der Client authentifizieren kann, dass die Nachricht von einem vertrauenswürdigen Server gesendet wurde, verwenden wir also auch das Authentifizierungs-Secret. Wie zu erwarten, sollte dies geheim gehalten, nur dem Anwendungsserver, von dem Sie Nachrichten senden möchten, freigegeben und wie ein Passwort behandelt werden.

Außerdem müssen wir neue Daten generieren. Wir benötigen eine 16-Byte-kryptografisch sichere zufällige Salt- und ein öffentliches/privates Paar von Elliptische-Kurven-Schlüsseln. Die von der Push-Verschlüsselungsspezifikation verwendete Kurve heißt P-256 oder prime256v1. Für eine optimale Sicherheit sollte das Schlüsselpaar jedes Mal neu generiert werden, wenn Sie eine Nachricht verschlüsseln. Salts sollten Sie niemals wiederverwenden.

ECDH

Sprechen wir nun über eine tolle Eigenschaft der Elliptische-Kurven-Kryptografie. Es ist ein relativ einfaches Verfahren, bei dem Ihren privaten Schlüssel mit dem öffentlichen Schlüssel einer anderen Person kombiniert wird, um einen Wert abzuleiten. Was ist mein Vorteil? Wenn die andere Partei ihren privaten Schlüssel und Ihren öffentlichen Schlüssel verwendet, wird derselbe Wert abgeleitet.

Dies ist die Grundlage des ECDH-Protokolls (Elliptic Curve Diffie-Hellman) für Schlüsselvereinbarungen, mit dem beide Parteien das gleiche gemeinsame Secret nutzen können, obwohl sie nur öffentliche Schlüssel ausgetauscht haben. Wir verwenden dieses gemeinsame Secret als Grundlage für unseren eigentlichen Verschlüsselungsschlüssel.

const crypto = require('crypto');

const salt = crypto.randomBytes(16);

// Node has ECDH built-in to the standard crypto library. For some languages
// you may need to use a third-party library.
const serverECDH = crypto.createECDH('prime256v1');
const serverPublicKey = serverECDH.generateKeys();
const sharedSecret = serverECDH.computeSecret(clientPublicKey);

HKDF

Jetzt ist schon Zeit für eine andere Seite. Angenommen, Sie haben einige geheime Daten, die Sie als Verschlüsselungsschlüssel verwenden möchten, aber diese sind kryptografisch nicht ausreichend sicher. Sie können die HMAC-basierte Key Derivation Function (HKDF) verwenden, um ein Secret mit geringer Sicherheit in ein Secret mit hoher Sicherheit umzuwandeln.

Eine Konsequenz der Funktionsweise besteht darin, dass Sie ein Secret mit einer beliebigen Anzahl von Bits in die Erstellung eines weiteren Secrets beliebiger Größe bis zu 255-mal so lange wie ein Hash produzieren können, der von dem von Ihnen verwendeten Hash-Algorithmus erzeugt wurde. Für die Push-Übertragung muss gemäß Spezifikation SHA-256 mit einer Hash-Länge von 32 Byte (256 Bit) verwendet werden.

Dabei wissen wir, dass wir nur Schlüssel mit einer Größe von bis zu 32 Byte generieren müssen. Das bedeutet, dass wir eine vereinfachte Version des Algorithmus verwenden können, die größere Ausgabegrößen nicht verarbeiten kann.

Den Code für eine Knotenversion finden Sie unten. Wie er tatsächlich funktioniert, können Sie unter RFC 5869 herausfinden.

Die Eingaben in HKDF bestehen aus einem Salt, einem anfänglichen Schlüsselmaterial (ikm), einer optionalen strukturierten Datenkomponente, die für den aktuellen Anwendungsfall spezifisch ist (Informationen), und der Länge des gewünschten Ausgabeschlüssels in Byte.

// Simplified HKDF, returning keys up to 32 bytes long
function hkdf(salt, ikm, info, length) {
  if (length > 32) {
    throw new Error('Cannot return keys of more than 32 bytes, ${length} requested');
  }

  // Extract
  const keyHmac = crypto.createHmac('sha256', salt);
  keyHmac.update(ikm);
  const key = keyHmac.digest();

  // Expand
  const infoHmac = crypto.createHmac('sha256', key);
  infoHmac.update(info);
  // A one byte long buffer containing only 0x01
  const ONE_BUFFER = new Buffer(1).fill(1);
  infoHmac.update(ONE_BUFFER);
  return infoHmac.digest().slice(0, length);
}

Verschlüsselungsparameter ableiten

Wir verwenden jetzt HKDF, um die vorhandenen Daten in die Parameter für die eigentliche Verschlüsselung umzuwandeln.

Zuerst verwenden wir HKDF, um das geheime Client-Authentifizierungs-Secret und das gemeinsame Secret zu einem längeren, kryptografisch sichereren Secret zu mischen. In der Spezifikation wird dies als Pseudozufallsschlüssel (Pseudo-Random Key, PRK) bezeichnet und ich nenne es hier, auch wenn Kryptografie-Puristen anmerken, dass es sich nicht ausschließlich um einen PRK handelt.

Jetzt erstellen wir den endgültigen Inhaltsverschlüsselungsschlüssel und eine Nonce, die an die Chiffre übergeben werden. Sie werden erstellt, indem für jede eine einfache Datenstruktur erstellt wird, die in der Spezifikation als Information bezeichnet wird. Sie enthält Informationen zur elliptischen Kurve, zum Absender und zum Empfänger der Informationen, mit denen die Quelle der Nachricht weiter überprüft werden kann. Dann verwenden wir HKDF mit dem PRK, unserem Salt und den Informationen, um den Key und die Nonce der richtigen Größe abzuleiten.

Der Infotyp für die Inhaltsverschlüsselung ist "aesgcm". Dies ist der Name der für die Push-Verschlüsselung verwendeten Chiffre.

const authInfo = new Buffer('Content-Encoding: auth\0', 'utf8');
const prk = hkdf(clientAuthSecret, sharedSecret, authInfo, 32);

function createInfo(type, clientPublicKey, serverPublicKey) {
  const len = type.length;

  // The start index for each element within the buffer is:
  // value               | length | start    |
  // -----------------------------------------
  // 'Content-Encoding: '| 18     | 0        |
  // type                | len    | 18       |
  // nul byte            | 1      | 18 + len |
  // 'P-256'             | 5      | 19 + len |
  // nul byte            | 1      | 24 + len |
  // client key length   | 2      | 25 + len |
  // client key          | 65     | 27 + len |
  // server key length   | 2      | 92 + len |
  // server key          | 65     | 94 + len |
  // For the purposes of push encryption the length of the keys will
  // always be 65 bytes.
  const info = new Buffer(18 + len + 1 + 5 + 1 + 2 + 65 + 2 + 65);

  // The string 'Content-Encoding: ', as utf-8
  info.write('Content-Encoding: ');
  // The 'type' of the record, a utf-8 string
  info.write(type, 18);
  // A single null-byte
  info.write('\0', 18 + len);
  // The string 'P-256', declaring the elliptic curve being used
  info.write('P-256', 19 + len);
  // A single null-byte
  info.write('\0', 24 + len);
  // The length of the client's public key as a 16-bit integer
  info.writeUInt16BE(clientPublicKey.length, 25 + len);
  // Now the actual client public key
  clientPublicKey.copy(info, 27 + len);
  // Length of our public key
  info.writeUInt16BE(serverPublicKey.length, 92 + len);
  // The key itself
  serverPublicKey.copy(info, 94 + len);

  return info;
}

// Derive the Content Encryption Key
const contentEncryptionKeyInfo = createInfo('aesgcm', clientPublicKey, serverPublicKey);
const contentEncryptionKey = hkdf(salt, prk, contentEncryptionKeyInfo, 16);

// Derive the Nonce
const nonceInfo = createInfo('nonce', clientPublicKey, serverPublicKey);
const nonce = hkdf(salt, prk, nonceInfo, 12);

Abstand

Noch eins beiseite und Zeit für ein albernes und konstruktives Beispiel. Nehmen wir an, Ihre Chefin hat einen Server, der ihr alle paar Minuten eine Push-Nachricht mit dem Aktienkurs des Unternehmens sendet. Die reine Nachricht hierfür ist immer eine 32-Bit-Ganzzahl mit dem Wert in Cents. Sie hat auch eine Geheimhaltung mit den Catering-Mitarbeitern, was bedeutet, dass sie ihr fünf Minuten vor der tatsächlichen Lieferung die Schnur "Donuts im Pausenraum" schicken können, damit sie "zufällig" da sein kann, wenn sie angekommen sind und das beste holen.

Die von Web Push verwendete Chiffre erstellt verschlüsselte Werte, die genau 16 Byte länger sind als die unverschlüsselte Eingabe. Da "Donuts im Pausenraum" länger ist als der 32-Bit-Aktienkurs, kann jeder Mitarbeiter, der die Daten ausspioniert, eintreffen, ohne die Nachrichten zu entschlüsseln. Das geht allein anhand der Länge der Daten.

Aus diesem Grund können Sie mit dem Web-Push-Protokoll am Anfang der Daten einen Innenrand hinzufügen. Wie Sie dies verwenden, hängt von Ihrer Anwendung ab. Im obigen Beispiel könnten Sie jedoch alle Nachrichten mit genau 32 Byte auffüllen, wodurch es unmöglich wird, die Nachrichten nur nach der Länge zu unterscheiden.

Der Padding-Wert ist eine Big-Endian-Ganzzahl mit 16 Bit, die die Padding-Länge gefolgt von der Anzahl von NUL Byte für das Padding angibt. Das Mindestabstand beträgt also 2 Byte – die Zahl 0 ist in 16 Bit codiert.

const padding = new Buffer(2 + paddingLength);
// The buffer must be only zeroes, except the length
padding.fill(0);
padding.writeUInt16BE(paddingLength, 0);

Wenn Ihre Push-Nachricht beim Client eingeht, kann der Browser automatisch alle Abstände entfernen, sodass Ihr Clientcode nur die Nachricht ohne Padding empfängt.

Verschlüsselung

Jetzt haben wir alles, was wir für die Verschlüsselung tun müssen. Die für Web Push erforderliche Chiffre ist AES128 mit GCM. Wir verwenden unseren Inhaltsverschlüsselungsschlüssel als Schlüssel und die Nonce als Initialisierungsvektor (IV).

In diesem Beispiel sind unsere Daten eine Zeichenfolge, es könnten sich aber auch beliebige Binärdaten befinden. Sie können Nutzlasten mit einer Größe von bis zu 4.078 bis maximal 4.096 Byte pro Post senden, wobei 16 Byte für Verschlüsselungsinformationen und mindestens 2 Byte für das Padding enthalten sind.

// Create a buffer from our data, in this case a UTF-8 encoded string
const plaintext = new Buffer('Push notification payload!', 'utf8');
const cipher = crypto.createCipheriv('id-aes128-GCM', contentEncryptionKey,
nonce);

const result = cipher.update(Buffer.concat(padding, plaintext));
cipher.final();

// Append the auth tag to the result - https://nodejs.org/api/crypto.html#crypto_cipher_getauthtag
return Buffer.concat([result, cipher.getAuthTag()]);

Web-Push

Geschafft! Nachdem Sie nun eine verschlüsselte Nutzlast haben, müssen Sie nur noch eine relativ einfache HTTP-POST-Anfrage an den vom Abo des Nutzers angegebenen Endpunkt senden.

Sie müssen drei Header festlegen.

Encryption: salt=<SALT>
Crypto-Key: dh=<PUBLICKEY>
Content-Encoding: aesgcm

<SALT> und <PUBLICKEY> sind der bei der Verschlüsselung verwendete Salt- und Serverschlüssel, codiert als URL-sicherer Base64-Schlüssel.

Wenn Sie das Web Push-Protokoll verwenden, besteht der Text der POST-Anfrage nur aus den Rohbyte der verschlüsselten Nachricht. Solange Chrome und Firebase Cloud Messaging das Protokoll jedoch nicht unterstützen, können Sie die Daten ganz einfach in die vorhandene JSON-Nutzlast aufnehmen. Gehen Sie dazu so vor:

{
    "registration_ids": [ "…" ],
    "raw_data": "BIXzEKOFquzVlr/1tS1bhmobZ…"
}

Der Wert des Attributs rawData muss die base64-codierte Darstellung der verschlüsselten Nachricht sein.

Fehlerbehebung / Prüfung

Peter Beverloo, einer der Chrome-Entwickler, der die Funktion implementiert hat (und zu den Personen, die an der Spezifikation mitgearbeitet haben), hat einen Verifier entwickelt.

Wenn Sie Ihren Code für die Ausgabe der Zwischenwerte der Verschlüsselung abrufen, können Sie diese in die Prüfung einfügen und so prüfen, ob Sie auf dem richtigen Weg sind.