Web Push Payload-codering

Mat Scales

Vóór Chrome 50 konden pushberichten geen payloadgegevens bevatten. Toen de 'push'-gebeurtenis bij uw servicemedewerker werd geactiveerd, wist u alleen dat de server u iets probeerde te vertellen, maar niet wat het zou kunnen zijn. Vervolgens moest u een vervolgverzoek indienen bij de server en de details van de melding verkrijgen om weer te geven, wat bij slechte netwerkomstandigheden mogelijk mislukt.

Nu kun je in Chrome 50 (en in de huidige versie van Firefox op desktop) enkele willekeurige gegevens meesturen met de push, zodat de client kan voorkomen dat hij het extra verzoek doet. Grote macht brengt echter een grote verantwoordelijkheid met zich mee, dus alle payload-gegevens moeten worden gecodeerd.

Versleuteling van payloads is een belangrijk onderdeel van het beveiligingsverhaal voor webpush. HTTPS geeft u veiligheid bij de communicatie tussen de browser en uw eigen server, omdat u de server vertrouwt. De browser kiest echter welke pushprovider wordt gebruikt om de payload daadwerkelijk te leveren, dus jij als app-ontwikkelaar hebt daar geen controle over.

Hier kan HTTPS alleen garanderen dat niemand kan meekijken in het bericht dat onderweg is naar de push-serviceprovider. Zodra ze het hebben ontvangen, zijn ze vrij om te doen wat ze willen, inclusief het opnieuw verzenden van de payload naar derden of het kwaadwillig veranderen in iets anders. Om ons hiertegen te beschermen, gebruiken we encryptie om ervoor te zorgen dat push-services de payloads tijdens de overdracht niet kunnen lezen of ermee kunnen knoeien.

Veranderingen aan de cliëntzijde

Als u al pushmeldingen zonder payloads heeft geïmplementeerd , zijn er slechts twee kleine wijzigingen die u aan de clientzijde hoeft aan te brengen.

De eerste is dat wanneer u de abonnementsinformatie naar uw backend-server verzendt, u wat extra informatie moet verzamelen. Als u JSON.stringify() al gebruikt op het PushSubscription- object om het te serialiseren voor verzending naar uw server, hoeft u niets te wijzigen. Het abonnement bevat nu wat extra gegevens in de sleuteleigenschap.

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

De twee waarden p256dh en auth zijn gecodeerd in een variant van Base64 die ik URL-Safe Base64 zal noemen.

Als u in plaats daarvan direct aan de slag wilt met de bytes, kunt u de nieuwe getKey() -methode voor het abonnement gebruiken, die een parameter retourneert als ArrayBuffer . De twee parameters die u nodig hebt zijn auth en p256dh .

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

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

De tweede wijziging is een nieuwe gegevenseigenschap wanneer de push gebeurtenis wordt geactiveerd. Het beschikt over verschillende synchrone methoden voor het parseren van de ontvangen gegevens, zoals .text() , .json() , .arrayBuffer() en .blob() .

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

Wijzigingen aan de serverzijde

Aan de serverkant veranderen de zaken iets meer. Het basisproces is dat u de coderingssleutelinformatie die u van de client hebt gekregen, gebruikt om de payload te coderen en die vervolgens als hoofdtekst van een POST-verzoek naar het eindpunt in het abonnement te verzenden, waarbij u enkele extra HTTP-headers toevoegt.

De details zijn relatief complex, en zoals bij alles wat met encryptie te maken heeft, is het beter om een ​​actief ontwikkelde bibliotheek te gebruiken dan je eigen bibliotheek te gebruiken. Het Chrome-team heeft een bibliotheek voor Node.js gepubliceerd, en binnenkort komen er meer talen en platforms. Dit regelt zowel de versleuteling als het webpushprotocol, zodat het verzenden van een pushbericht vanaf een Node.js-server net zo eenvoudig is als webpush.sendWebPush(message, subscription) .

Hoewel we zeker het gebruik van een bibliotheek aanbevelen, is dit een nieuwe functie en zijn er veel populaire talen die nog geen bibliotheken hebben. Als u dit voor uzelf moet implementeren, vindt u hier de details.

Ik zal de algoritmen illustreren met behulp van JavaScript met Node-smaak, maar de basisprincipes moeten in elke taal hetzelfde zijn.

Ingangen

Om een ​​bericht te versleutelen, moeten we eerst twee dingen ophalen uit het abonnementsobject dat we van de klant hebben ontvangen. Als u JSON.stringify() op de client hebt gebruikt en dat naar uw server hebt verzonden, wordt de openbare sleutel van de client opgeslagen in het veld keys.p256dh , terwijl het gedeelde authenticatiegeheim zich in het veld keys.auth bevindt. Beide zullen URL-veilige Base64-gecodeerd zijn, zoals hierboven vermeld. Het binaire formaat van de openbare sleutel van de client is een ongecomprimeerd P-256 elliptisch curvepunt.

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

Met de publieke sleutel kunnen we het bericht zodanig versleutelen dat het alleen kan worden ontsleuteld met behulp van de privésleutel van de klant.

Openbare sleutels worden doorgaans als openbaar beschouwd, dus om de client in staat te stellen te verifiëren dat het bericht door een vertrouwde server is verzonden, gebruiken we ook het authenticatiegeheim. Het is niet verwonderlijk dat dit geheim moet worden gehouden, alleen moet worden gedeeld met de applicatieserver waarnaar u berichten wilt sturen, en moet worden behandeld als een wachtwoord.

We moeten ook nieuwe gegevens genereren. We hebben een cryptografisch beveiligd willekeurig zout van 16 bytes nodig en een openbaar/privaat paar elliptische curvesleutels . De specifieke curve die wordt gebruikt door de push-encryptiespecificatie wordt P-256 of prime256v1 genoemd. Voor de beste beveiliging moet het sleutelpaar elke keer dat u een bericht codeert, helemaal opnieuw worden gegenereerd, en u mag een salt nooit opnieuw gebruiken.

ECDH

Laten we even terzijde staan ​​om te praten over een mooie eigenschap van elliptische curve-cryptografie. Er is een relatief eenvoudig proces waarbij uw privésleutel wordt gecombineerd met de publieke sleutel van iemand anders om een ​​waarde af te leiden. Dus? Welnu, als de andere partij zijn privésleutel en uw publieke sleutel neemt, zal deze exact dezelfde waarde krijgen!

Dit is de basis van het elliptische curve Diffie-Hellman (ECDH) sleutelovereenkomstprotocol, waarmee beide partijen hetzelfde gedeelde geheim kunnen hebben, ook al hebben ze alleen openbare sleutels uitgewisseld. We gebruiken dit gedeelde geheim als basis voor onze daadwerkelijke coderingssleutel.

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

Alweer tijd voor een andere terzijde. Stel dat u geheime gegevens heeft die u als coderingssleutel wilt gebruiken, maar dat deze cryptografisch niet veilig genoeg zijn. U kunt de op HMAC gebaseerde Key Derivation Function (HKDF) gebruiken om een ​​geheim met lage beveiliging om te zetten in een geheim met hoge beveiliging.

Een gevolg van de manier waarop het werkt, is dat je met een geheim van een willekeurig aantal bits een ander geheim van elke omvang kunt produceren, tot wel 255 keer zo lang als een hash die wordt geproduceerd door welk hash-algoritme je ook gebruikt. Voor push vereisen de specificaties dat we SHA-256 gebruiken, die een hashlengte heeft van 32 bytes (256 bits).

We weten namelijk dat we alleen sleutels van maximaal 32 bytes hoeven te genereren. Dit betekent dat we een vereenvoudigde versie van het algoritme kunnen gebruiken die grotere uitvoergroottes niet aankan.

Ik heb hieronder de code voor een Node-versie opgenomen, maar je kunt ontdekken hoe het daadwerkelijk werkt in RFC 5869 .

De invoer voor HKDF is een salt, wat initieel sleutelmateriaal (ikm), een optioneel stuk gestructureerde gegevens dat specifiek is voor de huidige use-case (info) en de lengte in bytes van de gewenste uitvoersleutel.

// 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);
}

Het afleiden van de encryptieparameters

We gebruiken nu HKDF om de gegevens die we hebben om te zetten in de parameters voor de daadwerkelijke codering.

Het eerste dat we doen is HKDF gebruiken om het authenticatiegeheim van de client en het gedeelde geheim te combineren tot een langer, cryptografisch veiliger geheim. In de specificatie wordt dit een Pseudo-Random Key (PRK) genoemd, dus zo zal ik het hier noemen, hoewel cryptografiepuristen misschien opmerken dat dit niet strikt een PRK is.

Nu maken we de definitieve inhoudscoderingssleutel en een nonce die aan het cijfer wordt doorgegeven. Deze worden gemaakt door voor elk een eenvoudige datastructuur te maken, in de specificatie info genoemd , die informatie bevat die specifiek is voor de elliptische curve, de afzender en de ontvanger van de informatie, om de bron van het bericht verder te verifiëren. Vervolgens gebruiken we HKDF met de PRK, ons zout en de info om de sleutel en nonce van de juiste maat af te leiden.

Het infotype voor de inhoudscodering is 'aesgcm', wat de naam is van het cijfer dat wordt gebruikt voor push-codering.

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);

Opvulling

Nog even terzijde, en tijd voor een dwaas en gekunsteld voorbeeld. Laten we zeggen dat uw baas een server heeft die haar elke paar minuten een pushbericht stuurt met de aandelenkoers van het bedrijf. De duidelijke boodschap hiervoor is altijd een 32-bits geheel getal met de waarde in centen. Ze heeft ook een stiekeme deal met het cateringpersoneel, wat betekent dat ze haar 5 minuten voordat ze daadwerkelijk worden afgeleverd het touwtje "donuts in de pauzeruimte" kunnen sturen, zodat ze er “toevallig” bij kan zijn als ze aankomen en de beste kan pakken. .

Het cijfer dat door Web Push wordt gebruikt, creëert gecodeerde waarden die precies 16 bytes langer zijn dan de niet-gecodeerde invoer. Omdat "donuts in de kantine" langer is dan een 32-bits aandelenkoers, kan elke rondsnuffelende medewerker zien wanneer de donuts arriveren zonder de berichten te decoderen, alleen al aan de hand van de lengte van de gegevens.

Om deze reden kunt u met het webpushprotocol opvulling aan het begin van de gegevens toevoegen. Hoe u dit gebruikt, hangt af van uw toepassing, maar in het bovenstaande voorbeeld kunt u alle berichten opvullen tot precies 32 bytes, waardoor het onmogelijk wordt om de berichten alleen op basis van lengte te onderscheiden.

De opvulwaarde is een 16-bits big-endian geheel getal dat de opvullengte specificeert, gevolgd door dat aantal NUL bytes aan opvulling. De minimale opvulling is dus twee bytes: het getal nul gecodeerd in 16 bits.

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

Wanneer uw pushbericht bij de client arriveert, kan de browser automatisch eventuele opvulling verwijderen, zodat uw clientcode alleen het niet-opgevulde bericht ontvangt.

Encryptie

Nu hebben we eindelijk alle dingen om de codering uit te voeren. Het vereiste cijfer voor Web Push is AES128 met behulp van GCM . We gebruiken onze inhoudsencryptiesleutel als sleutel en de nonce als initialisatievector (IV).

In dit voorbeeld zijn onze gegevens een tekenreeks, maar dit kunnen alle binaire gegevens zijn. Je kunt payloads verzenden met een grootte van maximaal 4078 bytes - maximaal 4096 bytes per bericht, met 16 bytes voor coderingsinformatie en minimaal 2 bytes voor opvulling.

// 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

Opluchting! Nu u over een gecodeerde payload beschikt, hoeft u alleen maar een relatief eenvoudig HTTP POST-verzoek in te dienen bij het eindpunt dat is opgegeven in het abonnement van de gebruiker.

U moet drie headers instellen.

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

<SALT> en <PUBLICKEY> zijn de openbare salt- en serversleutel die worden gebruikt bij de codering, gecodeerd als URL-veilige Base64.

Bij gebruik van het Web Push-protocol bestaat de hoofdtekst van de POST dan alleen uit de onbewerkte bytes van het gecodeerde bericht. Totdat Chrome en Firebase Cloud Messaging het protocol ondersteunen, kunt u de gegevens echter eenvoudig als volgt in uw bestaande JSON-payload opnemen.

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

De waarde van de eigenschap rawData moet de base64-gecodeerde representatie van het gecodeerde bericht zijn.

Foutopsporing / verificatie

Peter Beverloo, een van de Chrome-ingenieurs die de functie heeft geïmplementeerd (maar ook een van de mensen die aan de specificatie heeft gewerkt), heeft een verifier gemaakt .

Door ervoor te zorgen dat uw code elk van de tussenliggende waarden van de codering uitvoert, kunt u deze in de verifier plakken en controleren of u op de goede weg bent.