Verschlüsselung von Web-Push-Nutzlasten

Mat Scales

Vor Chrome 50 konnten Push-Nachrichten keine Nutzlastdaten enthalten. Wenn das Ereignis „push“ in Ihrem Service Worker ausgelöst wurde, wussten Sie nur, dass der Server Ihnen etwas mitteilen wollte, aber nicht, was das sein könnte. Sie mussten dann eine weitere Anfrage an den Server senden und die Details der anzuzeigenden Benachrichtigung abrufen, was bei schlechten Netzwerkbedingungen fehlschlagen kann.

In Chrome 50 (und in der aktuellen Version von Firefox auf dem Computer) können Sie jetzt beliebige Daten zusammen mit dem Push senden, damit der Client die zusätzliche Anfrage vermeiden kann. Allerdings birgt eine große Macht auch eine große Verantwortung. Daher müssen alle Nutzlastdaten verschlüsselt werden.

Die Verschlüsselung von Nutzlasten ist ein wichtiger Bestandteil der Sicherheit von Web-Push-Mitteilungen. HTTPS bietet 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 Übermittlung der Nutzlast verwendet wird. Sie als App-Entwickler haben also keine Kontrolle darüber.

Hier kann HTTPS nur dafür sorgen, dass niemand die Nachricht auf dem Weg zum Push-Dienstanbieter abhören kann. Nach dem Empfang kann er damit machen, was er will, z. B. die Nutzlast an Dritte weiterleiten oder böswillig in etwas anderes ändern. Um dies zu verhindern, verwenden wir die Verschlüsselung, damit Push-Dienste die übertragenen Nutzlasten nicht lesen oder manipulieren können.

Clientseitige Änderungen

Wenn Sie bereits Push-Benachrichtigungen ohne Nutzlasten implementiert haben, müssen Sie nur zwei kleine Änderungen auf der Clientseite vornehmen.

Erstens: Wenn du die Aboinformationen an deinen Backend-Server sendest, musst du einige zusätzliche Informationen erfassen. Wenn Sie JSON.stringify() bereits für das PushSubscription-Objekt verwenden, um es für das Senden an Ihren Server zu serialisieren, müssen Sie nichts ändern. Das Abo enthält jetzt einige 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 du stattdessen direkt auf die Bytes zugreifen möchtest, kannst du die neue Methode getKey() für das Abo verwenden, die einen Parameter als ArrayBuffer zurückgibt. Sie benötigen die beiden Parameter 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 Dateneigenschaft, die beim Auslösen des Ereignisses push auftritt. Es gibt 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 sieht es etwas anders aus. Im Grunde verschlüsselst du die Nutzlast mit den vom Client erhaltenen Informationen zum Verschlüsselungsschlüssel und sendest sie dann als Body einer POST-Anfrage an den Endpunkt im Abo. Dabei werden einige zusätzliche HTTP-Header hinzugefügt.

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

Wir empfehlen die Verwendung einer Bibliothek. Da es sich jedoch um eine neue Funktion handelt, gibt es für viele gängige Sprachen noch keine Bibliotheken. Wenn Sie dies selbst implementieren müssen, finden Sie hier weitere Informationen.

Ich werde die Algorithmen anhand von JavaScript mit Node-Unterstützung veranschaulichen, aber die Grundprinzipien sollten in jeder Sprache gleich sein.

Eingaben

Um eine Nachricht zu verschlüsseln, müssen wir zuerst zwei Dinge aus dem Aboobjekt abrufen, 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 freigegebene Authentifizierungssecret im Feld keys.auth befindet. Beide werden wie oben erwähnt URL-sicher base64-codiert. Das Binärformat des öffentlichen Clientschlüssels ist ein unkomprimierter P-256-Punkt auf einer elliptischen Kurve.

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 der Client jedoch authentifizieren kann, dass die Nachricht von einem vertrauenswürdigen Server gesendet wurde, verwenden wir auch das Authentifizierungskennzeichen. Dieser Schlüssel sollte geheim gehalten, nur mit dem Anwendungsserver geteilt werden, von dem Sie Nachrichten erhalten möchten, und wie ein Passwort behandelt werden.

Außerdem müssen wir neue Daten generieren. Wir benötigen eine kryptografisch sichere, zufällige Salt-Wert mit 16 Byte und ein öffentliches/privates Schlüsselpaar mit elliptischer Kurve. 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. Außerdem sollten Sie ein Salt niemals wiederverwenden.

ECDH

Machen wir einen kleinen Exkurs zu einer praktischen Eigenschaft der elliptischen-Kurven-Kryptografie. Es gibt ein relativ einfaches Verfahren, bei dem Ihr privater 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 genau derselbe Wert abgeleitet.

Dies ist die Grundlage des Diffie-Hellman-Schlüsselvereinbarungsprotokolls (Elliptic Curve Diffie-Hellman, ECDH), mit dem beide Parteien dasselbe gemeinsame Secret haben können, auch wenn sie nur öffentliche Schlüssel ausgetauscht haben. Wir verwenden dieses gemeinsame Secret als Grundlage für unseren tatsächlichen 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

Es ist schon wieder Zeit für eine weitere Bemerkung. Angenommen, Sie haben geheime Daten, die Sie als Verschlüsselungsschlüssel verwenden möchten, die aber nicht kryptografisch sicher genug sind. Mit der HMAC-basierten Key Derivation Function (HKDF) können Sie ein Geheimnis mit niedriger Sicherheit in ein Geheimnis mit hoher Sicherheit umwandeln.

Eine Konsequenz dieser Funktionsweise besteht darin, dass Sie ein Geheimnis mit beliebiger Anzahl von Bits verwenden und ein anderes Geheimnis beliebiger Größe erstellen können, das bis zu 255 Mal so lang ist wie ein Hash, der mit dem von Ihnen verwendeten Hash-Algorithmus generiert wird. Für Push müssen wir gemäß der Spezifikation SHA-256 verwenden, das eine Hash-Länge von 32 Byte (256 Bit) hat.

Wir wissen, 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.

Unten habe ich den Code für eine Node-Version eingefügt. Wie es tatsächlich funktioniert, erfahren Sie in RFC 5869.

Die Eingaben für HKDF sind ein Salt, ein bestimmtes Initialisierungs-Schlüsselmaterial (Initial Keying Material, IKM), ein optionales strukturiertes Datenelement, das für den aktuellen Anwendungsfall spezifisch ist (Info), und die Länge in Byte des gewünschten Ausgabeschlüssels.

// 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 wird das Client-Authentifizierungs-Secret und das gemeinsame Secret mit HKDF zu einem längeren, kryptografisch sichereren Secret vermischt. In der Spezifikation wird er als Pseudozufallsschlüssel (PRK) bezeichnet. So werde ich ihn hier auch nennen, auch wenn Kryptografie-Puristen vielleicht anmerken, dass dies nicht genau ein PRK ist.

Jetzt erstellen wir den endgültigen Inhaltsverschlüsselungsschlüssel und einen Nonce, der an die Chiffre übergeben wird. Dazu wird für jede Nachricht eine einfache Datenstruktur erstellt, die in der Spezifikation als „Info“ bezeichnet wird. Sie enthält Informationen zur elliptischen Kurve, zum Absender und zum Empfänger der Informationen, um die Quelle der Nachricht weiter zu verifizieren. Anschließend verwenden wir HKDF mit dem PRK, unserem Salt und den Informationen, um den Schlüssel und die Nonce der richtigen Größe abzuleiten.

Der Infotyp für die Inhaltsverschlüsselung ist „aesgcm“, der Name der Chiffre, die für die Push-Verschlüsselung verwendet wird.

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 ein kurzer Einschub und Zeit für ein albernes und konstruiertes Beispiel. Angenommen, Ihr Chef hat einen Server, der ihm alle paar Minuten eine Push-Nachricht mit dem Aktienkurs des Unternehmens sendet. Die reine Nachricht dafür ist immer eine 32-Bit-Ganzzahl mit dem Wert in Cent. Außerdem hat sie eine hinterhältige Vereinbarung mit dem Cateringpersonal, das ihr fünf Minuten vor der Lieferung die Nachricht „Donuts in der Kaffeeecke“ senden kann, damit sie „zufällig“ da ist, wenn sie ankommen, und sich den besten schnappen kann.

Die von Web Push verwendete Chiffre generiert verschlüsselte Werte, die genau 16 Byte länger als die unverschlüsselte Eingabe sind. Da „Donuts in der Kaffeeecke“ länger ist als ein 32‑Bit-Aktienkurs, kann jeder schnorrende Mitarbeiter anhand der Länge der Daten erkennen, wann die Donuts ankommen, ohne die Nachrichten zu entschlüsseln.

Aus diesem Grund können Sie mit dem Web-Push-Protokoll dem Anfang der Daten ein Padding hinzufügen. Wie Sie diese Funktion verwenden, hängt von Ihrer Anwendung ab. Im obigen Beispiel könnten Sie alle Nachrichten auf genau 32 Byte auffüllen, sodass sie nicht nur anhand der Länge unterschieden werden können.

Der Padding-Wert ist eine 16-Bit-Big-Endian-Ganzzahl, die die Padding-Länge angibt, gefolgt von dieser Anzahl von NUL Byte Padding. Der Mindestabstand beträgt also zwei Byte – die Zahl 0, codiert in 16 Bit.

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 ankommt, kann der Browser automatisch alle Paddings entfernen, sodass Ihr Clientcode nur die nicht umgebrochene Nachricht empfängt.

Verschlüsselung

Jetzt haben wir endlich alles, was wir für die Verschlüsselung benötigen. Für Web Push ist die Chiffre AES128 mit GCM erforderlich. Wir verwenden unseren Inhaltsverschlüsselungsschlüssel als Schlüssel und die Nonce als Initialisierungsvektor (IV).

In diesem Beispiel sind unsere Daten ein String, es können aber auch beliebige Binärdaten sein. Sie können Nutzlasten mit einer Größe von bis zu 4.078 Byte senden – maximal 4.096 Byte pro Post, wobei 16 Byte für Verschlüsselungsinformationen und mindestens 2 Byte für Padding verwendet werden.

// 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 du eine verschlüsselte Nutzlast hast, musst du nur noch eine relativ einfache HTTP-POST-Anfrage an den Endpunkt senden, der im Abo des Nutzers angegeben ist.

Sie müssen drei Überschriften festlegen.

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

<SALT> und <PUBLICKEY> sind der Salt und der öffentliche Serverschlüssel, die bei der Verschlüsselung verwendet werden. Sie sind als URL-sicheres Base64 codiert.

Bei Verwendung des Web Push-Protokolls besteht der POST-Text dann nur aus den Rohbytes der verschlüsselten Nachricht. Bis Chrome und Firebase Cloud Messaging das Protokoll unterstützen, können Sie die Daten jedoch wie unten beschrieben ganz einfach in Ihre vorhandene JSON-Nutzlast einfügen.

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

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

Debugging / Verifier

Peter Beverloo, einer der Chrome-Entwickler, die die Funktion implementiert haben (und auch an der Spezifikation gearbeitet haben), hat einen Verifier erstellt.

Wenn Sie Ihren Code so anpassen, dass alle Zwischenwerte der Verschlüsselung ausgegeben werden, können Sie sie in den Verifier einfügen und prüfen, ob Sie auf dem richtigen Weg sind.

durch.