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 möchte, 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. 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 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 abfangen 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, 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 Sie nur zwei kleine Änderungen auf der Clientseite vornehmen.
Zum einen musst du zusätzliche Informationen erfassen, wenn du die Aboinformationen an deinen Back-End-Server sendest. 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. 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. 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 auf jeden Fall 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 Authentifizierungskennwort 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 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 kryptografisch sichere, zufällige Salt-Wert mit 16 Byte und ein öffentliches/privates Schlüsselpaar der elliptischen 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 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 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 eine Nonce, die an die Chiffre übergeben werden. 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. 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“, 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 hierfür ist immer eine 32-Bit-Ganzzahl mit dem Wert in Cents. 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 erstellt verschlüsselte Werte, die genau 16 Byte länger sind als die unverschlüsselte Eingabe. 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 am Anfang der Daten einen Innenrand hinzufügen. Wie Sie diese Funktion verwenden, liegt in Ihrer Anwendung. 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 Big-Endian-Ganzzahl mit 16 Bit, die die Padding-Länge gefolgt von der Anzahl von NUL
Byte für das Padding angibt. 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önnten sich aber auch beliebige Binärdaten befinden. 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.
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.
Debugging / Verifier
Peter Beverloo, einer der Chrome-Entwickler, der die Funktion implementiert hat (und auch an der Spezifikation mitgearbeitet hat), 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.