Verschlüsselung von Web-Push-Nutzlasten

Mattenwaagen

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 versucht hat, 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 erhalten, was bei schlechten Netzwerkbedingungen möglicherweise fehlschlägt.

Jetzt können Sie in Chrome 50 (und in der aktuellen Version von Firefox auf dem Computer) mit dem Push beliebige Daten senden, sodass der Client die zusätzliche Anfrage vermeiden kann. Hohe Leistung birgt jedoch auch eine große Verantwortung, daher müssen alle Nutzlastdaten verschlüsselt werden.

Die Verschlüsselung von Nutzlasten ist ein wichtiger Bestandteil des Sicherheitskonzepts von 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 zum Bereitstellen der Nutzlast verwendet wird, sodass Sie als App-Entwickler keine Kontrolle darüber haben.

Hier kann HTTPS nur garantieren, dass niemand die Nachricht bei der Übertragung an den Push-Dienstanbieter ausspionieren kann. Sobald sie diese erhalten, können sie tun, was sie möchten, z. B. die Nutzlast noch einmal an Dritte senden oder sie böswillig in etwas anderes ändern. Um uns davor zu schützen, verwenden wir Verschlüsselung, damit Push-Dienste die Nutzlasten während der Übertragung weder lesen noch 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.

Wenn Sie die Aboinformationen an Ihren Back-End-Server senden, müssen Sie einige zusätzliche Informationen erfassen. Wenn Sie JSON.stringify() bereits für das PushSubscription-Objekt verwenden, um es zum Senden an Ihren Server zu serialisieren, müssen Sie nichts ändern. Das Abo enthält jetzt zusätzliche Daten im Attribut „Schlüssel“.

> 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 Base64-Variante codiert, die ich URL-sicheres Base64 nenne.

Wenn Sie stattdessen die Byte-Anzahl direkt berechnen möchten, können Sie für das Abo die neue Methode getKey() verwenden, die einen Parameter als ArrayBuffer zurückgibt. Die beiden erforderlichen Parameter 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 ein neues Attribut data, wenn das Ereignis push 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 Serverseite ist das etwas anders. Im Grunde verwenden Sie die Informationen zum Verschlüsselungsschlüssel, die Sie vom Client erhalten haben, um die Nutzlast zu verschlüsseln. Senden Sie diese dann als Text einer POST-Anfrage an den Endpunkt des Abos und fügen Sie zusätzliche HTTP-Header hinzu.

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 eine eigene Bibliothek zu verwenden. Das Chrome-Team hat eine Bibliothek für Node.js veröffentlicht. Weitere Sprachen und Plattformen folgen demnächst. Damit 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 auf jeden Fall, eine Bibliothek zu verwenden. Dies ist jedoch 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 knotenbasiertem JavaScript 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 auf dem Client JSON.stringify() verwendet und an Ihren Server übertragen haben, wird der öffentliche Schlüssel des Clients im Feld keys.p256dh gespeichert. Das gemeinsame Authentifizierungs-Secret ist im Feld keys.auth. Beide werden, wie oben erwähnt, URL-sicher Base64-codiert. Das Binärformat des öffentlichen Clientschlüssels ist ein unkomprimierter P-256-Punkt mit elliptischer 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 werden normalerweise als öffentlich betrachtet. Damit der Client also authentifizieren kann, dass die Nachricht von einem vertrauenswürdigen Server gesendet wurde, verwenden wir auch das Authentifizierungs-Secret. Wenig überraschend sollte dies geheim gehalten, nur für den Anwendungsserver freigegeben, von dem Sie Nachrichten an Sie senden möchten, und wie ein Passwort behandelt werden.

Außerdem müssen wir neue Daten generieren. Wir benötigen ein kryptografisch sicheres 16-Byte-Zufalls-Salt-Paar 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 optimale Sicherheit sollte das Schlüsselpaar bei jeder Verschlüsselung einer Nachricht neu generiert werden und niemals einen Salt wiederverwenden.

ECDH

Nehmen wir uns etwas beiseite, um über eine tolle Eigenschaft der Elliptische-Kurven-Kryptografie zu sprechen. Es gibt ein relativ einfaches Verfahren, bei dem Ihren privaten Schlüssel mit dem öffentlichen Schlüssel eines anderen Nutzers kombiniert wird, um einen Wert abzuleiten. Was ist mein Vorteil? Wenn die andere Partei ihren privaten Schlüssel und Ihren öffentlichen Schlüssel annimmt, wird daraus genau derselbe Wert abgeleitet.

Dies ist die Grundlage des ECDH-Schlüsselvereinbarungsprotokolls (Elliptic Curve Diffie-Hellman), das es beiden Parteien ermöglicht, dasselbe gemeinsame Secret zu haben, obwohl nur öffentliche Schlüssel ausgetauscht werden. 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

Sie können jetzt schon wieder etwas beiseiten. 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 Schlüsselableitungsfunktion (Key Derivation Function, HKDF) verwenden, um ein Secret mit geringer Sicherheit in ein Secret mit hoher Sicherheit umzuwandeln.

Eine Konsequenz aus seiner Funktionsweise besteht darin, dass Sie ein Secret mit einer beliebigen Anzahl von Bits verwenden und ein weiteres Secret beliebiger Größe erstellen können, das bis zu 255-mal größer ist als ein Hash, der von dem von Ihnen verwendeten Hash-Algorithmus erzeugt 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. Dies bedeutet, dass wir eine vereinfachte Version des Algorithmus verwenden können, die keine größeren Ausgabegrößen verarbeiten kann.

Im Folgenden finden Sie den Code für eine Node-Version. Informationen zur Funktionsweise finden Sie in RFC 5869.

Die Eingaben in HKDF sind ein Salt, ein anfängliches Schlüsselmaterial (ikm), ein optionales Stück strukturierter Daten speziell für den aktuellen Anwendungsfall (Informationen) und die 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 uns vorliegenden Daten in Parameter für die eigentliche Verschlüsselung umzuwandeln.

Als Erstes verwenden wir HKDF, um das Clientauthentifizierungs-Secret und das gemeinsame Secret zu einem längeren, kryptografisch sichereren Secret zu mischen. In der Spezifikation wird dies als Pseudozufallsschlüssel (PRK) bezeichnet, also nenne ich ihn hier, auch wenn Kryptografie-Puriisten anmerken, dass dies kein reiner PRK ist.

Nun erstellen wir den endgültigen Inhaltsverschlüsselungsschlüssel und eine Nominierung, die an die Chiffre übergeben wird. Diese werden durch Erstellen einer einfachen Datenstruktur für jede Komponente erstellt, die in der Spezifikation als Information bezeichnet wird und spezifische Informationen zur elliptischen Kurve sowie zum Absender und Empfänger der Informationen enthält, damit die Quelle der Nachricht weiter geprüft werden kann. Dann 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 lautet „aesgcm“. Dies ist 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 bisschen nebenbei und Zeit für ein albernes und erfundenes Beispiel. Angenommen, Ihr Chef hat einen Server, der ihr alle paar Minuten eine Push-Nachricht mit dem Aktienkurs des Unternehmens sendet. Die einfache Nachricht hierfür ist immer eine 32-Bit-Ganzzahl mit dem Wert in Cents. Sie hat auch einen versteckten Vertrag mit dem Catering-Personal abgeschlossen, sodass sie ihr 5 Minuten vor der tatsächlichen Auslieferung die Zeichenfolge „Donuts im Pausenraum“ senden kann, damit sie „zufällig“ dort sein kann, wenn sie eintreffen und sich das beste auswählen lassen.

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

Aus diesem Grund ermöglicht Ihnen das Web-Push-Protokoll, am Anfang der Daten ein Padding hinzuzufügen. Wie Sie dies verwenden, bleibt Ihrer Anwendung überlassen. Im obigen Beispiel könnten Sie jedoch alle Nachrichten auf genau 32 Byte auffüllen, sodass es nicht möglich ist, die Nachrichten nur nach der Länge zu unterscheiden.

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

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

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

Verschlüsselung

Jetzt haben wir endlich alles für die Verschlüsselung. 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 ein String, aber es können auch beliebige Binärdaten sein. Sie können Nutzlasten von bis zu einer Größe von 4.078 bis maximal 4.096 Byte pro Post senden, mit 16 Byte für Verschlüsselungsinformationen und mindestens 2 Byte für das Auffüllen.

// 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! Jetzt, da Sie eine verschlüsselte Nutzlast haben, müssen Sie nur eine relativ einfache HTTP-POST-Anfrage an den im Abo des Nutzers angegebenen Endpunkt senden.

Sie müssen drei Überschriften festlegen.

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

<SALT> und <PUBLICKEY> sind der für die Verschlüsselung verwendete öffentliche Salt- und Serverschlüssel, codiert als URL-sicheres Base64-Zertifikat.

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

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

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

Fehlerbehebung / Verifizierung

Peter Beverloo, einer der Chrome-Entwickler, der die Funktion implementiert hat und an der Spezifikation mitgearbeitet hat, hat einen Prüfer erstellt.

Wenn Sie Ihren Code so abrufen, dass alle Zwischenwerte der Verschlüsselung ausgegeben werden, können Sie diese in den Prüfer einfügen und prüfen, ob Sie sich auf dem richtigen Weg befinden.