Replacing JWTs with Branca and PASETO in .NET Core

Scott Brady
Scott Brady
C#

In my previous article I discussed the criticisms surrounding JSON Web Tokens (JWTs) and some of their alternatives. Some of these alternatives had merits, however, many of the implementations that I found neglected to include the payload validation that we are used to in JWT libraries.

I’ve implemented some of these JWT alternatives as a side project, with a focus on including JWT payload validation. Thankfully, the Microsoft.IdentityModel libraries were extensible enough for me to build on top of the existing JWT validators. This means that protecting your APIs with PASETO can look as simple as:

services.AddAuthentication()
    .AddJwtBearer("paseto", options =>
    {
        options.SecurityTokenValidators.Clear();
        options.SecurityTokenValidators.Add(new PasetoTokenHandler());
    
        options.TokenValidationParameters.ValidIssuer = "you";
        options.TokenValidationParameters.ValidAudience = "me";

        options.TokenValidationParameters.IssuerSigningKey = 
            new EdDsaSecurityKey(new Ed25519PublicKeyParameters(somePublicKey, 0));
    });

I’ve packaged up these token handlers and a few other helper classes in a nuget package called ScottBrady.IdentityModel. I am great at naming things.

Design

As I mentioned before, any JWT alternative can still take advantage of the JWT standards. That includes your typical validation of standard claims such as the token issuer (iss), audience (aud), and lifetime (exp, iat, and nbf). In my opinion, it would not make sense to try and replace these when they are already widely used and understood.

By taking advantage of the work already done in Microsoft.IdentityModel.Tokens.JsonWebTokens, I was able to extract some reusable classes that Branca, PASETO, and any other future token handler can take advantage of. So, by creating your own implementation of JwtPayloadSecurityToken and JwtPayloadTokenHandler, you can get JWT style payload validation out of the box. All you need to do is implement the token format specifics, such as signature generation and validation.

This design means that all JWT payload validation is enabled by default. So, if you don’t want features such as issuer or token lifetime validation, you must manually disable them in the TokenValidationParameters. I think this is a safer approach than having to remember to turn them on.

Branca Tokens

Branca tokens are suitable for internal systems that can get away with using symmetric key encryption. This simple construct uses XChaCha20-Poly1305 for authenticated encryption and produces relatively small tokens. Branca tokens have one difference from the usual JWT payload format: their “issued at” (iat) claim is not present in the payload, but rather in the token construct itself (the timestamp found in the token header).

The BrancaTokenHandler has the usual methods found on ISecurityTokenValidator, but it also contains some spec-level encryption and decryption methods:

// JWT-style Branca tokens
string CreateToken(SecurityTokenDescriptor tokenDescriptor);
TokenValidationResult ValidateToken(string token, TokenValidationParameters validationParameters);
ClaimsPrincipal ValidateToken(string token, TokenValidationParameters validationParameters, out SecurityToken validatedToken);

// spec-level Branca tokens
string CreateToken(string payload, byte[] key);
BrancaToken DecryptToken(string token, byte[] key);

Creating Branca Tokens in .NET

Branca tokens require a 32-byte symmetric key, with an algorithm of XC20P.

var handler = new BrancaTokenHandler();
var key = Encoding.UTF8.GetBytes("supersecretkeyyoushouldnotcommit");

string token = handler.CreateToken(new SecurityTokenDescriptor
{
    Issuer = "me",
    Audience = "you",
    Claims = new Dictionary<string, object> {{"sub", "123"}},
    EncryptingCredentials = new EncryptingCredentials(
        new SymmetricSecurityKey(key), ExtendedSecurityAlgorithms.XChaCha20Poly1305)
});

This will produce a token that looks something like the following:

3dDrny9lLLnB8eu3yblkFZHJnUNvr4dnsWQDtG9uXHsact1CuVfIhORxa2Fl6BcCMTQTjJBnEyYqhKck5M9qFdM39VEBlVeuByAz6eyDgMylcoKHDcfQeWfYSyWiJFDpueTjzPGOJBF

With a payload of:

{"iss":"me","aud":"you","exp":1589145135,"nbf":1589141535}

Validating Branca Tokens in .NET

var handler = new BrancaTokenHandler();
var key = Encoding.UTF8.GetBytes("supersecretkeyyoushouldnotcommit");

ClaimsPrincipal principal = handler.ValidateToken(
    token,
    new TokenValidationParameters
    {
        ValidIssuer = "me",
        ValidAudience = "you",
        TokenDecryptionKey = new SymmetricSecurityKey(key)
    }, out SecurityToken parsedToken);

PASETO

PASETO is a competing standard to JOSE and JWT that offers a versioned ciphersuite. Personally, I don’t think PASETO should be a replacement for JWTs. Still, if you want a versioned ciphersuite over JOSE’s ciphersuite agility, then this is a good stop-gap until JOSE addresses this concern.

This implementation only implements public tokens, suitable zero-trust systems such as an OAuth authorization server. These tokens are unencrypted and signed, much like your average JWT. Version 1 uses RSASSA-PSS using SHA-384 (PS384 in JOSE), and version 2 uses EdDSA over Curve25519 (EdDSA in JOSE). PASETO local tokens only implement symmetric encryption; if you need this, I recommend that you consider using Branca instead.

One difference from JWTs to keep in mind is that dates are represented using ISO 8601 strings (“2020-05-10T06:29:38+00:00”), rather than UNIX Epoch time (1589135378).

The PasetoTokenHandler only allows tokens to be generated using the usual JWT methods:

string CreateToken(PasetoSecurityTokenDescriptor tokenDescriptor);
TokenValidationResult ValidateToken(string token, TokenValidationParameters validationParameters);
ClaimsPrincipal ValidateToken(string token, TokenValidationParameters validationParameters, out SecurityToken validatedToken);

Supported versions can be defined in the token handler’s constructor.

Creating PASETO Tokens in .NET

Here’s an example of a v2, public PASETO being created. v1 tokens require signing credentials containing an RsaSecurityKey with an algorithm of PS384, while v2 tokens require signing credentials containing an EdDsaSecurityKey with an algorithm of EdDSA.

var handler = new PasetoTokenHandler();
var privateKey = Convert.FromBase64String("TYXei5+8Qd2ZqKIlEuJJ3S50WYuocFTrqK+3/gHVH9B2hpLtAgscF2c9QuWCzV9fQxal3XBqTXivXJPpp79vgw==");

string token = handler.CreateToken(new PasetoSecurityTokenDescriptor(
    PasetoConstants.Versions.V2, PasetoConstants.Purposes.Public)
{
    Issuer = "me",
    Audience = "you",
    Claims = new Dictionary<string, object> {{"sub", "123"}},
    SigningCredentials = new SigningCredentials(
        new EdDsaSecurityKey(new Ed25519PrivateKeyParameters(privateKey, 0)), ExtendedSecurityAlgorithms.EdDsa)
});

This will produce a token that looks something like the following:

v2.public.eyJpc3MiOiJtZSIsImF1ZCI6InlvdSIsImV4cCI6IjIwMjAtMDUtMTBUMjE6NTc6MjcrMDA6MDAiLCJpYXQiOiIyMDIwLTA1LTEwVDIwOjU3OjI3KzAwOjAwIiwibmJmIjoiMjAyMC0wNS0xMFQyMDo1NzoyNyswMDowMCJ9o-WvvLjeWkwPejz5mdCbvbohQ0lV9M6F27EDwQw-w0OOd1Eh7OoI2gTk51j9OgivPwKGOusBEcfOvI1jm1zHCA

With a payload of:

{"iss":"me","aud":"you","exp":"2020-05-10T21:57:08+00:00","iat":"2020-05-10T20:57:08+00:00","nbf":"2020-05-10T20:57:08+00:00"}

While a v1 public token looks the same but it has a significantly longer signature:

v1.public.eyJpc3MiOiJtZSIsImF1ZCI6InlvdSIsImV4cCI6IjIwMjAtMDUtMTBUMjE6NTc6MDgrMDA6MDAiLCJpYXQiOiIyMDIwLTA1LTEwVDIwOjU3OjA4KzAwOjAwIiwibmJmIjoiMjAyMC0wNS0xMFQyMDo1NzowOCswMDowMCJ9Cm19ivSOko4z49Uq0N0rKMrEAjgxZQNrDniqKIU4jZ3DvwLtfVxoW-82VwzqYm5Q7mSPQ47Z-ihaOgGtO4a9jUBHQxcdfD4h5mJAek2cWTSEB9CaRmDEUvLFrkkP8vz8m64nLl_gPF5_SQA0-rPjq5Ege_7RJkgVgIIzCjkXoysRdGxgHJD4RGRALewaAapnr31la0BgRPgCrbz9OFCYg974NGcBRbIV9ectK6YI6DUpw5_xJcYeKGsufFbr2MxVaPVDoPe0wMZe5Gjm5grPCXAayCmEx9otrJbvxSYESl499eFbznQ42EGmK0FFgblsiVBgyVCf6ogbXCJUx2dpYg

Validating PASETO Tokens in .NET

var handler = new PasetoTokenHandler();
var publicKey = Convert.FromBase64String("doaS7QILHBdnPULlgs1fX0MWpd1wak14r1yT6ae/b4M=");

ClaimsPrincipal principal = handler.ValidateToken(
    token,
    new TokenValidationParameters
    {
        ValidIssuer = "me",
        ValidAudience = "you",
        IssuerSigningKey = new EdDsaSecurityKey(new Ed25519PublicKeyParameters(publicKey, 0))
    }, out SecurityToken parsedToken);

Token Helpers

While building this, I ended up creating functionality around Base62 encoding and XChaCha20-Poly1305 that I have packaged up for re-use.

Base62 Encoding

Base62 encoding uses the 0123456789ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz character set. Before using Branca, this is not something I had seen before, but it seems to be used as an alternative for base64url encoding, albeit a slower version. This implementation builds upon the work found in an older library.

var plaintext = "hello world"; // encoded = AAwf93rvy4aWQVw
var encoded = Base62.Encode(Encoding.UTF8.GetBytes(plaintext));

More SecurityAlgorithms

I’ve added some constants for newer entries in the JSON Web Signature and Encryption Algorithms registry. This includes values found in draft-amringer-jose-chacha and RFC 8037. These can be found in ScottBrady.IdentityModel.Crypto.ExtendedSecurityAlgorithms.

Nuget & Source Code

Give this library a go on nuget or check out the source code on GitHub. Feature requests are welcome 🙂.