What we want is something fairy simple, like this:

JWT.sign(options).then(function (jwt) {
    console.log(jwt)
});

And to get back our lovely JWT, which is in the format

<header>.<claims>.<signature>

What the W3C gave us instead is a bunch of incongruous garbage that, thankfully, just barely happens to work.

Why?

Traditionally JWT signing has been mistaken as role for servers. However, treating a browser like the device (and user) it represents opens up exciting new opportunities for peer-to-peer and device-based authentication.

Furthermore, using passphrase-protected client-side private keys makes for frictionless 2-Factor Authentication 2FA by combining the "something you have" (device) with "something you know" (passphrase) without putting the server at risk of knowing things that it shouldn't - meaning you also get Secure-Remote Passwords SRP for free.

This means a better, faster, and more secure user experience, with less server risk (and less server load).

This opens up exactly the kind of possibilities that are necessary for advances in the Peer Web / Indie Web and devices like Hub.

Win-win-win-win-win!

TL;DR

Short of using a library, there's really not a TL;DR for this one, unfortunately.

keypairs.js is the great fit if you need to handle both EC and RSA, and especially if you need both JOSE (JWT / JWS / JWK) and X509 (PEM / DER), and it's probably your most light-weight cross-browser option for doing so.

That's what I used on Greenlock Domains for implementing ACME (Let's Encrypt) in the browser.

Cheat Sheet

The next best that I can do for a brief is give you a cheatsheet-style summary of what's going to be useful throughout this process:

  • btoa()
  • "x".charCodeAt(0)
  • String.fromCharCode()
  • encodeURIComponent()
  • Uint8Array.from(...)
  • (new TextEncoder()).encode(...)
  • window.crypto.subtle.generateKey
  • window.crypto.subtle.importKey
  • window.crypto.subtle.sign
  • window.crypto.subtle.verify
  • window.crypto.subtle.digest

Simplest JWT signing, but no Simpler

JSON Web Tokens are super easy to generate - it's just some JSON converted to base64 and signed.

However, since JavaScript has such a sordid past and none of the TS39, W3C, WHATWG, Node Foundation, or any of the other JavaScript-related bureaucracies can stand to work together on... anything, the Crypto APIs are incongruous, incomplete, and really crappy to work with.

You need about a dozen helper functions to do really basic stuff - like convert between utf-8 and base64

In other words:

It's like death by a thousand papercuts

To aleviate some of the pain and keep this example super simple, I've eliminated all variation and only focus on EC public/private keypairs - which are the simplest, most efficient, and (equally) most secure algorithm for JWTs.

First: Some Data

I'm going to assume that you're at least somewhat familiar with JWTs, so I won't go deep into high-level explanation, but just to make sure we're on the same page:

You typically want to use a JWT as a replacement for a Cookie session, particularly for APIs - such as your front end talking to your back end, or allowing 3rd party applications to act on behalf of the user.

As such, you generally want to store more-or-less session data about the user (and application), for authentication, and perhaps for app (not user) authorization.

In JWT parlance you call the "data" that you want to sign "claims".

At the very least you should specify

  • an issuer iss (https url)
  • an expiration exp (in seconds)
  • a token id jti (random string)
  • a user id sub (preferrably a hash of true-user-id + app-id)

You may also want to specify

  • the authorized party azp (typically 3rd party app) allowed to act on behalf of the user
  • the audience aud (typically peer service) allowed to accept the token

Here's what that looks like:

var claims = {
    // The secure url at which .well-known/openid-configuration
    // and .well-known/jwks.json can be found - so that the token's
    // public key can be found to validate the token
    iss: 'https://example.com/',

    // The Unix time in seconds that the token expires
    // (now + 15 minutes is a good rule of thumb)
    exp: '1562392724',

    // An arbitrary (probably random) id for the token
    jti: 'YNiXjHAmoehN8TsIgkvr1g',

    // A user id (preferrably pairwise with the app id / azp)
    sub: 'bJaHgdhob7ioPZ3ODdcp2w'
};

If you fail to include an expiration (for short-lived tokens) or token id (for long-lived tokens), you may have no other way of invalidating the token than invaliding the private key which signed it.

In the case of device-based authentication, that's not terrible, but it's not preferred, and it could be a real headache for traditional JWT backends.

Second: Simplest JWK

This is an example of the simplest JWK that WebCrypto will accept.

Note that ext is not required generally, but is required for JWT.

var jwk = {
    // Defines the JWK type
    kty: 'EC',
    crv: 'P-256',

    // The EC private part
    d: 'GYAwlBHc2mPsj1lp315HbYOmKNJ7esmO3JAkZVn9nJs',

    // The EC public parts
    x: 'ToL2HppsTESXQKvp7ED6NMgV4YnwbMeONexNry3KDNQ',
    y: 'Tt6Q3rxU37KAinUV9PLMlwosNy1t3Bf2VDg5q955AGc',

    // WebCrypto requires an "extractable" field to import keys
    ext: true
};

As for how to generate an EC private key with WebCrypto, the code for that is a few sections further down.

Third: JWK Header

Generally speaking you don't need to specify any details for the header as they're set by the signing function.

For example, it would be a bad idea to manually set the JWS type and signature:

  • typ must be JWT
  • alg must be ES256, since we're using EC P-256.

You must also have some information to associate the public key with the token:

  • kid should be the JWK Thumbprint (or use jwk instead)
  • jwk should be used in place of kid when registering a new key

For some strange reason there are a lot of implementations that choose and store a random public identifier for kid. To me this seems unnecessarily complicated since the key already has a deterministic identifier, which is already a public value.

That said, it's common to have to set kid and/or jwk manually in libraries that don't automatically use the standardized thumbprint as the kid.

"header" vs "protected header"

For some reason the JWS (JOSE) spec allows for "unsigned headers".

Thankfully JWT, which is also part of the spec, doesn't allow this.

When people say "header" in reference to JWT, what they mean (whether they know it or not) is a JWS "protected header".

Finally: Constructing the JWT

Unfortunately, WebCrypto was purposefully designed to be complicated and incomplete (more on that below).

Although the actual process would be very simple and straight-forward to demonstrate in most languages, it's a very tedius process in the browser.

To keep the example simple, I've moved the dozen or so necessary helper functions into their own section below.

Hopefully this is small enough that the comments suffice without breaking it out step-by-tedius-step.

var JWT = {};
JWT.sign = function(jwk, headers, claims) {
    // Make a shallow copy of the key
    // (to set ext if it wasn't already set)
    jwk = Object.assign({}, jwk);

    // The headers should probably be empty
    headers.typ = 'JWT';
    headers.alg = 'ES256';
    if (!headers.kid) {
        // alternate: see thumbprint function below
        headers.jwk = { kty: jwk.kty, crv: jwk.crv, x: jwk.x, y: jwk.y };
    }

    var jws = {
        // JWT "headers" really means JWS "protected headers"
        protected: strToUrlBase64(JSON.stringify(headers)),

        // JWT "claims" are really a JSON-defined JWS "payload"
        payload: strToUrlBase64(JSON.stringify(claims))
    };

    // To import as EC (ECDSA, P-256, SHA-256, ES256)
    var keyType = {
        name: 'ECDSA',
        namedCurve: 'P-256',
        hash: { name: 'SHA-256' }
    };

    // To make re-exportable as JSON (or DER/PEM)
    var exportable = true;

    // Import as a private key that isn't black-listed from signing
    var privileges = ['sign'];

    // Actually do the import, which comes out as an abstract key type
    return window.crypto.subtle
        .importKey('jwk', jwk, keyType, exportable, privileges)
        .then(function(privkey) {
            // Convert UTF-8 to Uint8Array ArrayBuffer
            var data = strToUint8(jws.protected + '.' + jws.payload);

            // The signature and hash should match the bit-entropy of the key
            // https://tools.ietf.org/html/rfc7518#section-3
            var sigType = { name: 'ECDSA', hash: { name: 'SHA-256' } };

            return window.crypto.subtle.sign(sigType, privkey, data).then(function(signature) {
                // returns an ArrayBuffer containing a JOSE (not X509) signature,
                // which must be converted to Uint8 to be useful
                jws.signature = uint8ToUrlBase64(new Uint8Array(signature));

                // JWT is just a "compressed", "protected" JWS
                return jws.protected + '.' + jws.payload + '.' + jws.signature;
            });
        });
};

Generating a key

Unless Bruce Schneier begins to say otherwise, you should use EC P-256 keys.

RSA 2048 is fine too... but they're about 10x larger bit size for the same bit entropy.

var EC = {};
EC.generate = function() {
    var keyType = {
        name: 'ECDSA',
        namedCurve: 'P-256'
    };
    var exportable = true;
    var privileges = ['sign', 'verify'];
    return window.crypto.subtle.generateKey(keyType, exportable, privileges).then(function(key) {
        // returns an abstract and opaque WebCrypto object,
        // which in most cases you'll want to export as JSON to be able to save
        return window.crypto.subtle.exportKey('jwk', key.privateKey);
    });
};
// Create a Public Key from a Private Key
//
// chops off the private parts
EC.neuter = function(jwk) {
    var copy = Object.assign({}, jwk);
    delete copy.d;
    copy.key_ops = ['verify'];
    return copy;
};

Thumbprints

JWK thumbprints (the Key ID kid) are very easy to generate - it's just a SHA hash of the key. You sort the public key properties for the appropriate key type, and match the bit length of the SHA to the bit entropy of the key (i.e. 256 for P-256 or RSA 2048).

var JWK = {};
JWK.thumbprint = function(jwk) {
    // lexigraphically sorted, no spaces
    var sortedPub = '{"crv":"CRV","kty":"EC","x":"X","y":"Y"}'
        .replace('CRV', jwk.crv)
        .replace('X', jwk.x)
        .replace('Y', jwk.y);

    // The hash should match the size of the key,
    // but we're only dealing with P-256
    return window.crypto.subtle
        .digest({ name: 'SHA-256' }, strToUint8(sortedPub))
        .then(function(hash) {
            return uint8ToUrlBase64(new Uint8Array(hash));
        });
};

Testing it all out

After publishing the article for the first time I actually copied and pasted each block into the console and then ran this and verified it against jwt.io to make sure that it all works.

If you'd like, you can do the same (be sure to grab the helper functions below first).

var claims = {
    iss: 'https://example.com/',
    sub: 'xxx',
    azp: 'https://cool.io/',
    aud: 'https://example.com/',
    exp: Math.round(Date.now() / 1000) + 15 * 60
};

EC.generate().then(function(jwk) {
    console.info('Private Key:', JSON.stringify(jwk));
    console.info('Public Key:', JSON.stringify(EC.neuter(jwk)));

    return JWK.thumbprint(jwk).then(function(kid) {
        return JWT.sign(jwk, { kid: kid }, claims).then(function(jwt) {
            console.info('JWT:', jwt);
        });
    });
});

Supporting "Helper" Functions

I created some helper functions to try to keep the the above example easier to read.

// String (UCS-2) to Uint8Array
//
// because... JavaScript, Strings, and Buffers
function strToUint8(str) {
    return new TextEncoder().encode(str);
}

// Same thing, without TextEncoder:
function strToUint8(str) {
    return new Uint8Array.from(
        utf8ToBinaryString(str)
            .split('')
            .forEach(function(ch) {
                return ch.charCodeAt(0);
            })
    );
}
// UCS-2 String to URL-Safe Base64
//
// btoa doesn't work on UTF-8 strings
function strToUrlBase64(str) {
    return binToUrlBase64(utf8ToBinaryString(str));
}
// Binary String to URL-Safe Base64
//
// btoa (Binary-to-Ascii) means "binary string" to base64
function binToUrlBase64(bin) {
    return btoa(bin)
        .replace(/\+/g, '-')
        .replace(/\//g, '_')
        .replace(/=+/g, '');
}
// UTF-8 to Binary String
//
// Because JavaScript has a strange relationship with strings
// https://coolaj86.com/articles/base64-unicode-utf-8-javascript-and-you/
function utf8ToBinaryString(str) {
    var escstr = encodeURIComponent(str);
    // replaces any uri escape sequence, such as %0A,
    // with binary escape, such as 0x0A
    var binstr = escstr.replace(/%([0-9A-F]{2})/g, function(match, p1) {
        return String.fromCharCode(parseInt(p1, 16));
    });

    return binstr;
}
// Uint8Array to URL Safe Base64
//
// the shortest distant between two encodings... binary string
function uint8ToUrlBase64(uint8) {
    var bin = '';
    uint8.forEach(function(code) {
        bin += String.fromCharCode(code);
    });
    return binToUrlBase64(bin);
}

ED25519, RSA, and HMAC

To keep things simple I only showed the use of EC P-256 public/private keypairs rather than the older RSA standard (which is still every bit as secure, but just not very storage-efficient), or the even older HMAC (which relies on shared secrets, of which I am not a fan*).

If you're interested in using RSA (or HMAC, despite my distaste for it), I'd recommend you check out this excellent collection of WebCrypto Examples. Sadly, the more modern ed25519 standard hasn't made it to web crypto.

* I don't like HMAC and advise against supporting it because it uses shared secrets and therefore has many of the same drawbacks as passwords (which are also shared secrets), particularly in regards to how limited it's function is in comparison to public/private keys. Additionally, if you roll your own JWT implementation with HMAC support, you could accidentally open yourself up to a public key attack.

Why so complicated?

The question remains: Why add new APIs to the browser and then make them so unusable and complicated?

The problem is called "Security Theatre".

It's when you do things that are unnecessarily complicated in order to make things appear "secure" (i.e. for marketing to the layman) while, more often, actually decreasing security.

In the WebCrypto API you see a lot of senseless Security Theatre:

  • You can't get a list of supported algorithms, encodings, etc - you have to try the one you want and try another if it fails (supposedly to prevent browser "fingerprinting", but realistically just makes using it annoying.
  • There are different names for the same thing (or reasonably implied thing) across APIs and standards - EC/ECDSA, P-256/ES256, and much worse with RSA.
  • You have to explicitly specify mutually inclusive options - such as sign for private keys and verify for public keys.
  • Super-opinionated in unimportant ways - private keys can be used to verify, since they contain the public key, but that's disallowed.

I was rage complaining to one of the security experts who was part of the committee about how frustrating I find these APIs... but he was one of the ones fighting for pragmatic security practices and simply shared in my lamentations. Picard Facepalm.


By AJ ONeal

If you loved this and want more like it, sign up!


Did I make your day?
Buy me a coffeeBuy me a coffee  

(you can learn about the bigger picture I'm working towards on my patreon page )



Published

2019-7-5



Buy me a coffeeBuy me a coffee


  73% of Goal Reached


Want more like this?