Using VAPID with WebPush

This post continues discussion about using the evolving WebPush feature.
Updated Jul, 21st 2016 to fix incorrect usage of “aud” in VAPID header
Updated Oct, 6th 2020 to correct push server URL

One of the problems with offering a service that doesn’t require identification is that it’s hard to know who’s responsible for something. For instance, if a consumer is having problems, or not using the service correctly, it is a challenge to contact them. One option is to require strong identification to use the service, but there are plenty of reasons to not do that, notably privacy.

The answer is to have each publisher optionally identify themselves, but how do we prevent everyone from saying that they’re something popular like “CatFacts”? The Voluntary Application Server Identification for Web Push (VAPID) protocol was drafted to try and answer that question.

Making a claim

VAPID uses JSON Web Tokens (JWT) to carry identifying information. The core of the VAPID transaction is called a “claim”. A claim is a JSON object containing several common fields. It’s best to explain using an example, so let’s create a claim from a fictional CatFacts service.
{
"aud": "https://updates.push.services.mozilla.com",
"exp": 1458679343,
"sub": "mailto:webpush_ops@catfacts.example.com"
}

aud
The “audience” is the destination URL of the push service.
exp
The “expiration” date is the UTC time in seconds when the claim should expire. This should not be longer than 24 hours from the time the request is made. For instance, in Javascript you can use: Math.floor(Date.now() * .001 + 86400).
sub
The “subscriber” is the primary contact email for this subscription. You’ll note that for this, we’ve used a generic email alias rather than a specific person. This approach allows multiple people to be alerted, or assigning a new person without having to change code.

Note: JWT allows for additional fields to be provided, the above are just the “known” set. Feel free to add any additional information that you may want to include when the Push Service needs to contact you. (e.g. AMI-ID of the publishing machine, original customer ID, etc.) Be advised, that you have a limited amount of information you can put in your headers. For instance, Apache allows only 4K for all header information. Keeping items short is to your benefit)

I’ve added spaces and new lines to make things more readable. JWT objects normally strip those out.

Signing and Sealing

A JWT object actually has three parts: a standard header, the claim (which we just built), and a signature.

The header is very simple and is standard to any VAPID JWT object.

{"typ": "JWT","alg":"ES256"}

If you’re curious, typ is the “type” of object (a “JWT”), and alg is the signing algorithm to use. In our case, we’re using Elliptic Curve Cryptography based on the NIST P-256 curve (or “ES256”).

We’ve already discussed what goes in the claim, so now, there’s just the signature. This is where things get complicated.

Here’s code to sign the claim using Python 2.7.


# This Source Code Form is subject to the terms of the Mozilla Public
# License, v. 2.0. If a copy of the MPL was not distributed with this
# file, You can obtain one at http://mozilla.org/MPL/2.0/.

import base64
import time
import json

import ecdsa
from jose import jws


def make_jwt(header, claims, key):
    vk = key.get_verifying_key()
    jwt = jws.sign(
        claims,
        key,
        algorithm=header.get("alg", "ES256")).strip("=")
    # The "0x04" octet indicates that the key is in the
    # uncompressed form. This form is required by the
    # server and DOM API. Other crypto libraries
    # may prepend this prefix automatically.
    raw_public_key = "\x04" + vk.to_string()
    public_key = base64.urlsafe_b64encode(raw_public_key).strip("=")
    return (jwt, public_key)


def main():
    # This is a standard header for all VAPID objects:
    header = {"typ": "JWT", "alg": "ES256"}

    # These are our customized claims.
    claims = {"aud": "https://updates.push.services.mozilla.com",
              "exp": int(time.time()) + 86400,
              "sub": "mailto:webpush_ops@catfacts.example.com"}

    my_key = ecdsa.SigningKey.generate(curve=ecdsa.NIST256p)
    # You can store the private key by writing
    #   my_key.to_pem() to a file.
    # You can reload the private key by reading
    #   my_key.from_pem(file_contents)

    (jwt, public_key) = make_jwt(header, claims, my_key)

    # Return the headers we'll use.
    headers = {
        "Authorization": "Bearer %s" % jwt,
        "Crypto-Key": "p256ecdsa=%s" % public_key,
    }

    print json.dumps(headers, sort_keys=True, indent=4)


main()

There’s a little bit of cheating here in that I’m using the “python ecdsa” library and JOSE‘s jws library, but there are similar libraries for other languages. The important bit is that a key pair is created.

This key pair should be safely retained for the life of the subscription. In most cases, just the private key can be retained since the public key portion can be easily derived from it. You may want to save both private and public keys since we’re working on a dashboard that will use your public key to let you see info about your feeds.

The output of the above script looks like:

{
    "Authorization": "Bearer eyJhbGciOiJFUzI1NiIsInR5cCI6IkpXVCJ9.eyJhdWQiOiJodHRwczovL2NhdGZhY3RzLmV4YW1wbGUuY29tIiwiZXhwIjoxNDU4Njc5MzQzLCJzdWIiOiJtYWlsdG86d2VicHVzaF9vcHNAY2F0ZmFjdHMuZXhhbXBsZS5jb20ifQ.U8MYqcQcwFcK2UkeiISahgZFvaOw56ZQvHYZc4zXC2Ed48-lk3MoYExGagKLwr4lSdbARZEbblAprQfXlap3jw",
    "Crypto-Key": "p256ecdsa=EJwJZq_GN8jJbo1GGpyU70hmP2hbWAUpQFKDByKB81yldJ9GTklBM5xqEwuPM7VuQcyiLDhvovthPIXx-gsQRQ=="
}

These are the HTTP request headers that you should include in the POST request when you send a message. VAPID uses these headers to identify a subscription.

The “Crypto-Key” header may contain many sub-components, separated by a semi-colon (“;”). You can insert the “p256ecdsa” value, which contains the public key, anywhere in that list. This header is also used to relay the encryption key, if you’re sending a push message with data. The JWT is relayed in the “Authorization” header as a “Bearer” token. The server will use the pubic key to check the signature of the JWT and ensure that it’s correct.

Again, VAPID is purely optional. You don’t need it if you want to send messages. Including VAPID information will let us contact you if we see a problem. It will also be used for upcoming features such as restricted subscriptions, which will help minimize issues if the endpoint is ever lost, and the developer dashboard, which will provide you with information about your subscription and some other benefits. We’ll discuss those more when the features becomes available. We’ve also published a few tools that may help you understand and use VAPID. The Web Push Data Test Page (GitHub Repo) can help library authors develop and debug their code by presenting “known good” values. The VAPID verification page (GitHub Repo) is a simpler, “stand alone” version that can test and generate values.

As always, your input is welcome.

Updated to spell out what VAPID stands for.