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

Padding

Another aside, and time for a silly and contrived example. Let's say that your boss has a server that sends her a push message every few minutes with the company stock price. The plain message for this will always be a 32-bit integer with the value in cents. She also has a sneaky deal with the catering staff which means that they can send her the string "doughnuts in the break room" 5 minutes before they are actually delivered so that she can “coincidentally” be there when they arrive and grab the best one.

The cipher used by Web Push creates encrypted values that are exactly 16 bytes longer than the unencrypted input. Since "doughnuts in the break room" is longer that a 32-bit stock price, any snooping employee will be able to tell when the doughnuts are arriving without decrypting the messages, just from the length of the data.

For this reason, the web push protocol allows you to add padding to the beginning of the data. How you use this is up to your application, but in the above example you could pad all messages to be exactly 32 bytes, making it impossible to distinguish the messages based only on length.

The padding value is a 16-bit big-endian integer specifying the padding length followed by that number of NUL bytes of padding. So the minimum padding is two bytes - the number zero encoded into 16 bits.

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

When your push message arrives at the client, the browser will be able to automatically strip out any padding, so your client code only receives the unpadded message.

Encryption

Now we finally have all of the things to do the encryption. The cipher required for Web Push is AES128 using GCM. We use our content encryption key as the key and the nonce as the initialization vector (IV).

In this example our data is a string, but it could be any binary data. You can send payloads up to a size of 4078 bytes - 4096 bytes maximum per post, with 16-bytes for encryption information and at least 2 bytes for padding.

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

Phew! Now that you have an encrypted payload, you just need to make a relatively simple HTTP POST request to the endpoint specified by the user's subscription.

You need to set three headers.

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

<SALT> and <PUBLICKEY> are the salt and server public key used in the encryption, encoded as URL-safe Base64.

When using the Web Push protocol, the body of the POST is then just the raw bytes of the encrypted message. However, until Chrome and Firebase Cloud Messaging support the protocol, you can easily include the data in your existing JSON payload as follows.

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

The value of the rawData property must be the base64 encoded representation of the encrypted message.

Debugging / verifier

Peter Beverloo, one of the Chrome engineers who implemented the feature (as well as being one of the people who worked on the spec), has created a verifier.

By getting your code to output each of the intermediate values of the encryption you can paste them into the verifier and check that you are on the right track.