Web Push Payload Encryption

Mat Scales

Prior to Chrome 50, push messages could not contain any payload data. When the 'push' event fired in your service worker, all you knew was that the server was trying to tell you something, but not what it might be. You then had to make a follow up request to the server and obtain the details of the notification to show, which might fail in poor network conditions.

Now in Chrome 50 (and in the current version of Firefox on desktop) you can send some arbitrary data along with the push so that the client can avoid making the extra request. However, with great power comes great responsibility, so all payload data must be encrypted.

Encryption of payloads is an important part of the security story for web push. HTTPS gives you security when communicating between the browser and your own server, because you trust the server. However, the browser chooses which push provider will be used to actually deliver the payload, so you, as the app developer, have no control over it.

Here, HTTPS can only guarantee that no one can snoop on the message in transit to the push service provider. Once they receive it, they are free to do what they like, including re-transmitting the payload to third-parties or maliciously altering it to something else. To protect against this we use encryption to ensure that push services can't read or tamper with the payloads in transit.

Client-side changes

If you have already implemented push notifications without payloads then there are only two small changes that you need to make on the client-side.

This first is that when you send the subscription information to your backend server you need to gather some extra information. If you already use JSON.stringify() on the PushSubscription object to serialize it for sending to your server then you don't need to change anything. The subscription will now have some extra data in the keys property.

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

The two values p256dh and auth are encoded in a variant of Base64 that I'll call URL-Safe Base64.

If you want to get right at the bytes instead, you can use the new getKey() method on the subscription that returns a parameter as an ArrayBuffer. The two parameters that you need are auth and p256dh.

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

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

The second change is a new data property when the push event fires. It has various synchronous methods for parsing the received data, such as .text(), .json(), .arrayBuffer() and .blob().

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

Server-side changes

On the server side, things change a bit more. The basic process is that you use the encryption key information you got from the client to encrypt the payload and then send that as the body of a POST request to the endpoint in the subscription, adding some extra HTTP headers.

The details are relatively complex, and as with anything related to encryption it's better to use an actively developed library than to roll your own. The Chrome team has published a library for Node.js, with more languages and platforms coming soon. This handles both encryption and the web push protocol, so that sending a push message from a Node.js server is as easy as webpush.sendWebPush(message, subscription).

While we definitely recommend using a library, this is a new feature and there are many popular languages that don't yet have any libraries. If you do need to implement this for yourself, here are the details.

I'll be illustrating the algorithms using Node-flavored JavaScript, but the basic principles should be the same in any language.

Inputs

In order to encrypt a message, we first need to get two things from the subscription object that we received from the client. If you used JSON.stringify() on the client and transmitted that to your server then the client's public key is stored in the keys.p256dh field, while the shared authentication secret is in the keys.auth field. Both of these will be URL-safe Base64 encoded, as mentioned above. The binary format of the client public key is an uncompressed P-256 elliptic curve point.

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

The public key allows us to encrypt the message such that it can only be decrypted using the client's private key.

Public keys are usually considered to be, well, public, so to allow the client to authenticate that the message was sent by a trusted server we also use the authentication secret. Unsurprisingly, this should be kept secret, shared only with the application server that you want to send you messages, and treated like a password.

We also need to generate some new data. We need a 16-byte cryptographically secure random salt and a public/private pair of elliptic curve keys. The particular curve used by the push encryption spec is called P-256, or prime256v1. For the best security the key pair should be generated from scratch every time you encrypt a message, and you should never reuse a salt.

ECDH

Let's take a little aside to talk about a neat property of elliptic curve cryptography. There is relatively simple process which combines your private key with someone else's public key to derive a value. So what? Well, if the other party takes their private key and your public key it will derive the exact same value!

This is the basis of the elliptic curve Diffie-Hellman (ECDH) key agreement protocol, which allows both parties to have the same shared secret even though they only exchanged public keys. We'll use this shared secret as the basis for our actual encryption key.

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

Already time for another aside. Let's say that you have some secret data that you want to use as an encryption key, but it isn't cryptographically secure enough. You can use the HMAC-based Key Derivation Function (HKDF) to turn a secret with low security into one with high security.

One consequence of the way that it works is that it allows you to take a secret of any number of bits and produce another secret of any size up to 255 times as long as a hash produced by whatever hashing algorithm you use. For push, the spec requires us to use SHA-256, which has a hash length of 32 bytes (256 bits).

As it happens, we know that we only need to generate keys up to 32 bytes in size. This means that we can use a simplified version of the algorithm that can't handle larger output sizes.

I've included the code for a Node version below, but you can find out how it actually works in RFC 5869.

The inputs to HKDF are a salt, some initial keying material (ikm), an optional piece of structured data specific to the current use-case (info) and the length in bytes of the desired output key.

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

Deriving the encryption parameters

We now use HKDF to turn the data we have into the parameters for the actual encryption.

The first thing we do is use HKDF to mix the client auth secret and the shared secret into a longer, more cryptographically secure secret. In the spec this is referred to as a Pseudo-Random Key (PRK) so that's what I'll call it here, though cryptography purists may note that this isn't strictly a PRK.

Now we create the final content encryption key and a nonce that will be passed to the cipher. These are created by making a simple data structure for each, referred to in the spec as an info, that contains information specific to the elliptic curve, sender and receiver of the information in order to further verify the message's source. Then we use HKDF with the PRK, our salt and the info to derive the key and nonce of the correct size.

The info type for the content encryption is 'aesgcm' which is the name of the cipher used for push encryption.

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