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 mogelijk mislukt bij slechte netwerkomstandigheden.

Nu kunt u 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 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 web-push-protocol, zodat het verzenden van een push-bericht 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 cliënt 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 wat? 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 grootte 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 inhoudt 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

Pff! 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.

,

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 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 web-push-protocol, zodat het verzenden van een push-bericht 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 wat? 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 grootte 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 inhoudt 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

Pff! 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 (en ook een van de mensen die aan de specificatie heeft gewerkt), heeft een verifier gemaakt .

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

,

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 mogelijk mislukt bij slechte netwerkomstandigheden.

Nu kunt u 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 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 web-push-protocol, zodat het verzenden van een push-bericht 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 wat? 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 grootte 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 inhoudt 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

Pff! 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 implementeerde (evenals een van de mensen die aan de specificatie hebben gewerkt), heeft een verificator gecreëerd .

Door uw code te krijgen om elk van de tussenliggende waarden van de codering uit te voeren, kunt u deze in de verificateur plakken en controleren of u zich op de goede weg bevindt.

,

Mat Scales

Voorafgaand aan Chrome 50 konden push -berichten geen payloadgegevens bevatten. Toen de 'push' -gebeurtenis in uw servicemedewerker werd afgevuurd, wist u alleen dat de server u iets probeerde te vertellen, maar niet wat het zou kunnen zijn. Vervolgens moest u een vervolgverzoek doen aan de server en de details van de melding verkrijgen om te laten zien, wat mogelijk mislukt in slechte netwerkomstandigheden.

Nu in Chrome 50 (en in de huidige versie van Firefox op desktop) kunt u enkele willekeurige gegevens samen met de push verzenden, zodat de client kan voorkomen dat u het extra verzoek doet. Met grote macht komt echter grote verantwoordelijkheid, dus alle ladinggegevens moeten worden gecodeerd.

Codering van payloads is een belangrijk onderdeel van het beveiligingsverhaal voor webpush. HTTPS geeft u beveiliging bij het communiceren tussen de browser en uw eigen server, omdat u de server vertrouwt. De browser kiest echter welke push -provider zal worden gebruikt om de payload daadwerkelijk te leveren, dus u, als de app -ontwikkelaar, heeft er geen controle over.

Hier kunnen HTTPS alleen maar garanderen dat niemand het bericht kan laten snuffelen tijdens het transport naar de push -serviceprovider. Zodra ze het hebben ontvangen, zijn ze vrij om te doen wat ze leuk vinden, inclusief het opnieuw overdragen van de lading naar derden of het kwaadwillig veranderen in iets anders. Om dit te beschermen, gebruiken we codering om ervoor te zorgen dat push -services niet kunnen lezen of knoeien met de payloads tijdens het transport.

Client-side wijzigingen

Als u pushmeldingen zonder payloads al hebt geïmplementeerd, zijn er slechts twee kleine wijzigingen die u aan de client-kant moet aanbrengen.

Dit eerste is dat wanneer u de abonnementsinformatie naar uw backend -server verzendt, u wat extra informatie moet verzamelen. Als u JSON.stringify() op het pushsubscription -object al gebruikt om het te serialiseren voor het verzenden naar uw server, hoeft u niets te wijzigen. Het abonnement heeft nu wat extra gegevens in de eigenschap Keys.

> 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 bij de bytes wilt komen, kunt u de nieuwe getKey() -methode gebruiken op het abonnement dat een parameter als ArrayBuffer retourneert. 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 data -eigenschap wanneer de push -gebeurtenis vuurt. Het heeft 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 op de serverzijde

Aan de serverzijde veranderen dingen wat meer. Het basisproces is dat u de coderingssleutelinformatie gebruikt die u van de client hebt gekregen om de payload te coderen en dat vervolgens als de body van een postverzoek naar het eindpunt in het abonnement te verzenden, wat enkele extra HTTP -headers toevoegt.

De details zijn relatief complex, en zoals met alles wat verband houdt met codering is het beter om een ​​actief ontwikkelde bibliotheek te gebruiken dan om je eigen te rollen. Het Chrome -team heeft een bibliotheek gepubliceerd voor Node.js, met binnenkort meer talen en platforms. Dit behandelt zowel codering als het web push -protocol, zodat het verzenden van een push -bericht van een Node.js -server zo eenvoudig is als webpush.sendWebPush(message, subscription) .

Hoewel we zeker aanraden om een ​​bibliotheek te gebruiken, is dit een nieuwe functie en zijn er veel populaire talen die nog geen bibliotheken hebben. Als u dit zelf moet implementeren, zijn hier de details.

Ik zal de algoritmen illustreren met behulp van het JavaScript met knooppuntmaak, maar de basisprincipes moeten in elke taal hetzelfde zijn.

Ingangen

Om een ​​bericht te coderen, moeten we eerst twee dingen halen uit het abonnementobject dat we van de client hebben ontvangen. Als u JSON.stringify() op de client hebt gebruikt en die naar uw server heeft verzonden, wordt de openbare sleutel van de client opgeslagen in het veld keys.p256dh , terwijl het gedeelde authenticatiegeheim in het veld keys.auth staat. Beide zullen url-safe base64 gecodeerd zijn, zoals hierboven vermeld. Het binaire formaat van de openbare sleutel van de klant is een niet-gecomprimeerd P-256 elliptische curve-punt.

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

De openbare sleutel stelt ons in staat om het bericht zodanig te coderen dat deze alleen kan worden gedecodeerd met behulp van de privésleutel van de klant.

Openbare sleutels worden meestal beschouwd als, nou ja, dus om de klant te laten verifiëren dat het bericht is verzonden door een vertrouwde server, gebruiken we ook het authenticatie -geheim. Het is niet verwonderlijk dat dit geheim moet worden gehouden, alleen gedeeld met de applicatieserver die u u berichten wilt sturen en als een wachtwoord behandeld.

We moeten ook enkele nieuwe gegevens genereren. We hebben een 16-byte cryptografisch beveiligd willekeurig zout en een openbaar/privé-paar elliptische curvesleutels nodig. De specifieke curve die wordt gebruikt door de push-coderingsspecie wordt P-256 of prime256v1 genoemd. Voor de beste beveiliging moet het sleutelpaar helemaal opnieuw worden gegenereerd telkens wanneer u een bericht versleutelt, en u mag nooit een zout hergebruiken.

ECDH

Laten we een beetje opzij nemen om te praten over een nette eigenschap van elliptische curve -cryptografie. Er is een relatief eenvoudig proces dat uw privésleutel combineert met de publieke sleutel van iemand anders om een ​​waarde af te leiden. Dus wat? Nou, als de andere partij hun privésleutel en uw openbare sleutel neemt, zal deze exact dezelfde waarde afleiden!

Dit is de basis van de Elliptic Curve Diffie-Hellman (ECDH) Key Agreement Protocol, waarmee beide partijen hetzelfde gedeeld geheim kunnen hebben, hoewel ze alleen openbare sleutels hebben uitgewisseld. We zullen dit gedeeld geheim gebruiken als basis voor onze werkelijke 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

Al tijd voor een andere opzij. Laten we zeggen dat u enkele geheime gegevens hebt die u als coderingssleutel wilt gebruiken, maar het is niet cryptografisch genoeg veilig genoeg. U kunt de op HMAC gebaseerde Key Derivation-functie (HKDF) gebruiken om een ​​geheim met lage beveiliging om te zetten in één met een hoge beveiliging.

Een gevolg van de manier waarop het werkt, is dat u hiermee een geheim van een willekeurig aantal bits kunt nemen en een ander geheim van elke grootte tot 255 keer zo lang zo lang als een hash geproduceerd door welk hashing -algoritme wordt geproduceerd dat u gebruikt. Voor push vereist de specificatie dat we SHA-256 gebruiken, met een hasj-lengte van 32 bytes (256 bits).

We weten namelijk dat we alleen maar sleutels tot 32 bytes moeten genereren. Dit betekent dat we een vereenvoudigde versie van het algoritme kunnen gebruiken dat niet aan grotere uitvoergroottes kan omgaan.

Ik heb de code voor een knooppuntversie hieronder opgenomen, maar je kunt ontdekken hoe deze eigenlijk werkt in RFC 5869 .

De ingangen voor HKDF zijn een zout, wat initiële keyingmateriaal (IKM), een optioneel stuk gestructureerde gegevens specifiek 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);
}

De coderingsparameters afleiden

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

Het eerste wat we doen, is HKDF gebruiken om het client Auth Secret en het gedeelde geheim te combineren in een langer, meer cryptografisch veilig geheim. In de specificatie wordt dit een pseudo-willekeurige sleutel (PRK) genoemd, dus dat is wat ik het hier zal noemen, hoewel cryptografie-puristen merken dat dit niet strikt een PRK is.

Nu maken we de uiteindelijke inhoudscoderingsleutel en een nonce die aan de cijfer wordt doorgegeven. Deze worden gemaakt door voor elk een eenvoudige gegevensstructuur te maken, waarnaar in de specificatie wordt verwezen als een info, die informatie bevat die specifiek is voor de elliptische curve, afzender en 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 informatietype voor de inhoudscodering is 'AESGCM', wat de naam is van de cijfer die 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);

Vulling

Een ander terzijde en tijd voor een dom en gekunsteld voorbeeld. Laten we zeggen dat uw baas een server heeft die haar om de paar minuten een push -bericht stuurt met de aandelenkoers van het bedrijf. Het eenvoudige bericht hiervoor is altijd een gehele 32-bits geheel getal met de waarde in cent. Ze heeft ook een stiekeme deal met het cateringpersoneel, wat betekent dat ze haar de touw "donuts in de pauzeruimte" kunnen sturen, 5 minuten voordat ze daadwerkelijk worden afgeleverd, zodat ze "toevallig" kan zijn wanneer ze aankomen en de beste pakken.

De cijfer die wordt gebruikt door web push creëert gecodeerde waarden die precies 16 bytes langer zijn dan de niet -gecodeerde invoer. Aangezien "donuts in de breakroom" langer is dan een 32-bits aandelenkoers, kan elke snuffing-medewerker zien wanneer de donuts aankomen zonder de berichten te decoderen, alleen vanuit de lengte van de gegevens.

Om deze reden kunt u met het Web Push -protocol vulling toevoegen aan het begin van de gegevens. Hoe u dit gebruikt, is aan uw toepassing, maar in het bovenstaande voorbeeld kunt u alle berichten opvullen om precies 32 bytes te zijn, waardoor het onmogelijk is om de berichten alleen op lengte te onderscheiden.

De vullingwaarde is een 16-bits big-endiaans geheel getal dat de vullingslengte aangeeft, gevolgd door dat aantal NUL bytes vulling. Dus de minimale vulling is twee bytes - het nummer 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 push -bericht aankomt bij de client, kan de browser elke vulling automatisch strippen, zodat uw clientcode alleen het niet -gepailleerde bericht ontvangt.

Codering

Nu hebben we eindelijk alle dingen om de codering te doen. De cijfer die nodig is voor webpush is AES128 met behulp van GCM . We gebruiken onze inhoudscoderingsleutel als de sleutel en de nonce als de initialisatievector (IV).

In dit voorbeeld zijn onze gegevens een tekenreeks, maar het kunnen eventuele binaire gegevens zijn. U kunt payloads verzenden naar een grootte van 4078 bytes - 4096 bytes maximum per post, met 16 -bytes voor codering -informatie en ten minste 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

Pff! Nu u een gecodeerde payload hebt, hoeft u alleen een relatief eenvoudig HTTP -postverzoek te doen voor het eindpunt dat is opgegeven door het abonnement van de gebruiker.

Je moet drie headers instellen.

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

<SALT> en <PUBLICKEY> zijn de openbare sleutel van zout en server die wordt gebruikt in de codering, gecodeerd als URL-veilige basis64.

Bij gebruik van het Web Push -protocol is het lichaam van de post dan alleen de ruwe bytes van de gecodeerde boodschap. Totdat Chrome en Firebase Cloud Messaging het protocol echter ondersteunen, kunt u de gegevens in uw bestaande JSON -payload echter eenvoudig als volgt opnemen.

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

De waarde van de eigenschap rawData moet de Base64 -gecodeerde weergave van het gecodeerde bericht zijn.

Debuggen / verifier

Peter Beverloo, een van de Chrome -ingenieurs die de functie implementeerde (evenals een van de mensen die aan de specificatie hebben gewerkt), heeft een verificator gecreëerd .

Door uw code te krijgen om elk van de tussenliggende waarden van de codering uit te voeren, kunt u deze in de verificateur plakken en controleren of u zich op de goede weg bevindt.