DEV Community

vincenzoiozzo
vincenzoiozzo

Posted on

Using Google Tink to sign JWTs with ECDSA

Introduction

In this blog post, we will show how the Tink cryptography library can be used to create, sign, and verify JSON Web Tokens (JWTs), as well as to manage the cryptographic keys for doing so. This is intended as a more practical example to complement Tink’s own documentation on the underlying cryptographic theory and their approach.

At SlashID we are big fan of Tink and use it in a variety of roles, including signing tokens! Read more about it on our blog

Background

We chose Tink because of three core characteristics:

  • The Tink philosophy of an easy to use API that is difficult to mess up and with safe defaults - we’ll see below how picking the wrong library could make your token service entirely insecure
  • Key rotation and management out of the box
  • Tink encryption APIs and integration with KMS makes it easy to protect the signing key

Before we dive into the tutorial, let’s do a brief recap of JWTs.

JSON Web Tokens

JWTs are an industry standard and are widely used in the identity, authentication, and user management space. They are used to transfer information between two parties in a verifiable manner. There are many excellent introductions to JWTs, so for the purposes of this discussion we will focus on the structure.

JWTs are typically transmitted as base-64 encoded strings, and are composed of three parts separated by periods:

  1. A header containing metadata about the token itself
  2. The payload, a JSON-formatted set of claims
  3. A signature that can be used to verify the contents of the payload

For example, this JWT

eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJzdWIiOiIxMjM0NTY3ODkwIiwibmFtZSI6IkpvZSBTbGFzaElEIiwiaWF0IjoxNTE2MjM5MDIyfQ.4cL42NsNCXLPEvmvNGxHN3wLuarpp98wwezHnSt2fqg
Enter fullscreen mode Exit fullscreen mode

has the following parts

Part Encoded value Decoded value Description
Header eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9 { "alg": "HS256", "typ": "JWT"} Indicates that this is a JWT and that it was hashed with the HS256 algorithm (HMAC using SHA-256)
Payload eyJzdWIiOiIxMjM0NTY3ODkwIiwibmFtZSI6IkpvZSBTbGFzaElEIiwiaWF0IjoxNTE2MjM5MDIyfQ {"sub": "1234567890", "name": "SlashID User", "iat": 1516239022} Payload with claims about a user and the token
Signature 4cL42NsNCXLPEvmvNGxHN3wLuarpp98wwezHnSt2fqg N/A The signature generated using the HS256 algorithm that verifies the payload

The important aspect of this is that the JWT is signed, meaning that the claims in the payload can be verified, if one has access to the appropriate cryptographic key.

The signature of a JWT token is calculated as follows:

signAndhash(base64UrlEncode(header) + '.' + base64UrlEncode(payload))
Enter fullscreen mode Exit fullscreen mode

Where signAndhash is the signing and hashing algorithms specified in the alg header. The JOSE IANA page contains the list of supported algorithms.

In the example above HS256 stands for HMAC using SHA-256 and the secret is a 256-bit key.

Signing is not the same as encryption - even without the cryptographic key for verifying, anybody can decode the token payload and inspect the contents.

The Task: Creating and Verifying Signed JWTs

Suppose we have been tasked with building a system to securely sign and verify JWTs for an authenticated user. We will focus on building a small HTTP server that exposes two endpoints: one for generating a signed JWT based on some user information, and one for verifying an existing JWT. We will use asymmetric keys for this - meaning the key has a private part (for signing) and a public part (for verifying). Examples of asymmetric key algorithms include RSA and ECDSA.

Picking the right algorithm

Before we begin, it’s crucial to understand which signing algorithm to use and what are the pros and cons. As discussed earlier the algorithm used to sign the JWT is specified in the header in the alg field, and there are several options for signing and hashing algorithms.

Let’s start with the hashing algorithm. The most common algorithm for hashing is SHA, and an intuitive way to think about hashing security is that the level of security each one gives you is 50% of their output size. So SHA-256 will provide you with 128-bits of security, and SHA-512 will provide you with 256-bits of security. This means that an attacker will have to generate 2^128 hashes before they start finding collisions. Generally anything above 256-bits is considered acceptable.

When it comes to signing, historically, RS256 (RSASSA-PKCS1-v1_5) RS256 has been the default for most JWT implementations. JWTs signed with RSASSA-PKCS1-v1_5 have a deterministic signature, meaning that the same JWT header & payload will always generate the same signature.

Even though there are no known attacks against RSASSA-PKCS1-v1_5 its usage is discouraged in favor of Elliptic Curves/ECDSA. And in certain cases, like for the UK Open Banking standard, RSASSA-PKCS1-v1_5 is forbidden.

When it comes to ECDSA, the most common choice is ES256 which stands for ECDSA using P-256 (an elliptic curve also known as secp256r1) and SHA-256. ECDSA requires much shorter keys than RSA in terms of strength (you need 3072 bits for an RSA key to have the same strength of P-256) and key generation is faster than RSA even though verification is generally slower.

ECDSA, by default, uses a random nonce that is generated per signature, hence ECDSA-generated signatures are non-deterministic.

The random nonce is key, as reusing the random nonce or having easily-guessable bits in the nonce can make the private key easily recoverable. Two high profile cases of such incidents were Sony's Playstation 3 and Bitcoin. In the Playstation 3 case, the private key was recovered due to a static nonce, and in Bitcoin’s case, Android users were affected due to a bug in Java’s SecureRandom class on Android.

Given the risk, it is key to pick a library that safely implements ECDSA either by using a deterministic scheme as described by RFC 6979 or by implementing the algorithm in a way that doesn’t depend on the quality of the random value - for example, see this thread.

For this blogpost we’ll use ES256. If you are implementing your own signing service, please refer to RFC 8725 for current best practices.

Creating, Signing, and Verifying a JWT

For this blogpost we will use Tink’s Go library, but there are published libraries for several other languages, and the principles are the same.

To begin with, let’s take a look at the Tink documentation on JWTs. There are five types that we are interested in to begin with:

  • jwt.RawJWT - an unverified JWT
  • jwt.VerifiedJWT - a JWT that has been verified
  • jwt.Signer - an interface for signing JWTs
  • jwt.Verifier - an interface for verifying signed and encoded JWTs
  • keyset.Handle - the type representing a cryptographic keyset, used to create instances implementing Signer and Verifier

The lifecycle of a JWT is

SlashID Documentation Site

First, we want to build our RawJWT that will be signed. This includes both the registered claims as described in the JWT specification, and some custom claims:

   jwtID := uuid.New().String()

   now := time.Now()
   expiry := now.Add(tokenDuration)

   opts := &jwt.RawJWTOptions{
       Subject:      &userID,
       Issuer:       &tokenIssuer,
       JWTID:        &jwtID,
       IssuedAt:     &now,
       ExpiresAt:    &expiry,
       NotBefore:    &now,
       CustomClaims: claims,
   }

   rawJWT, err := jwt.NewRawJWT(opts)
   if err != nil {
       return "", fmt.Errorf("failed to create new RawJWT: %w", err)
   }


Enter fullscreen mode Exit fullscreen mode

Now we have our RawJWT, we can sign and encode it. First, we need a Signer implementation.

Note that in the example jwt refers to the Tink jwt package.

   signingKeyset, err := tm.keysetsRepo.GetKeyset()
   if err != nil {
       return "", fmt.Errorf("failed to get token signing keyset: %w", err)
   }

   signer, err := jwt.NewSigner(signingKeyset)
   if err != nil {
       return "", fmt.Errorf("failed to create new Signer: %w", err)
   }
Enter fullscreen mode Exit fullscreen mode

To do this, we have introduced an interface, KeysetsRepo:

type KeysetsRepo interface {
   GetKeyset() (*keyset.Handle, error)
}
Enter fullscreen mode Exit fullscreen mode

Which is the interface to whatever we are using to store the keysets. We will come back to the details of this later - for now, we can simply define the interface, which is a single method for getting a keyset. Once we have that keyset, we can create a new JWT signer.

Not all keysets can be used to create a Signer - the keyset must have been created using one of the templates defined in the JWT library, as discussed below.

Finally, we can sign the token and return the signed token string:

   signedToken, err := signer.SignAndEncode(rawJWT)
   if err != nil {
       return "", fmt.Errorf("failed to sign RawJWT: %w", err)
   }

   return signedToken, nil
Enter fullscreen mode Exit fullscreen mode

So now we have the whole method for signing JWTs with Tink.

Now let’s implement the second part of the lifecycle and verify the token. First, we create a Verifier instance:

   verificationKeyset, err := tm.keysetsRepo.GetPublicKeyset()
   if err != nil {
       return nil, fmt.Errorf("failed to get token verification keyset: %w", err)
   }

   verifier, err := jwt.NewVerifier(verificationKeyset)
   if err != nil {
       return nil, fmt.Errorf("failed to create new Verifier: %w", err)
   }
Enter fullscreen mode Exit fullscreen mode

For this, we have added a new method to the KeysetsRepo interface, GetPublicKeyset:

type KeysetsRepo interface {
   GetKeyset() (*keyset.Handle, error)
   GetPublicKeyset() (*keyset.Handle, error)
}
Enter fullscreen mode Exit fullscreen mode

As we are implementing signing with asymmetric keys, we deal with two keysets - the private one for signing tokens, and the public one for verifying. The former must be stored securely and kept secret, otherwise anyone can sign tokens as if they were you, making token verification meaningless. The latter can be published, for example as part of an OIDC discovery document. In this case we make the separation clear by having two methods in the repo, one for getting the full keyset, and one for getting just the public part.

We also need to define a Validator that the Verifier can use to check the token claims:

   opts := &jwt.ValidatorOpts{
       ExpectedIssuer:        &tokenIssuer,
       IgnoreTypeHeader:      true,
       IgnoreAudiences:       true,
       ExpectIssuedInThePast: true,
   }

   validator, err := jwt.NewValidator(opts)
   if err != nil {
       return nil, fmt.Errorf("failed to create new Validator: %w", err)
   }
Enter fullscreen mode Exit fullscreen mode

We are ignoring some fields in this example to keep it shorter.

Finally, we can decode and verify the token:

   verifiedJWT, err := verifier.VerifyAndDecode(signedToken, validator)
   if err != nil {
       return nil, InvalidTokenError
   }

   return verifiedJWT, nil
Enter fullscreen mode Exit fullscreen mode

Now we have the whole method for verifying JWTs as well.

Storing the Signing Keysets

The next step is to implement our KeysetsRepo, which is the interface between our service and however we are persisting our keysets. Safely persisting our token keysets is essential - if we were to lose them, all existing tokens would need to be invalidated, which could have a significant impact on the user experience for anybody using the service to create and verify tokens.

For this example, we will implement a very simple keysets repo that stores the keys in the local file system. While this would typically not be suitable for large-scale distributed systems, it serves to illustrate the essential points of persisting and retrieving keysets. The keyset will be stored in a single file in the working directory. Before we begin to implement the methods needed for our interface, we will write a method for initializing the keyset, InitTokenKeyset.

type FSKeysetsRepo struct {
   keysetsFilePath string
   masterKey       tink.AEAD
}

func NewFSKeysetsRepo(keysetsFilePath string, masterKey tink.AEAD) *FSKeysetsRepo {
   return &FSKeysetsRepo{
       keysetsFilePath: keysetsFilePath,
       masterKey:       masterKey,
   }
}


func (r *FSKeysetsRepo) InitTokenKeyset() error {
   handle, err := keyset.NewHandle(jwt.ES256Template())
   if err != nil {
       return fmt.Errorf("failed to create new keyset handle with ES256 template: %w", err)
   }

   return r.writeKeysetToFile(handle)
}


func (r *FSKeysetsRepo) writeKeysetToFile(handle *keyset.Handle) error {
   f, err := os.Create(r.keysetsFilePath)
   if err != nil {
       return fmt.Errorf("failed to create keysets file %s: %w", r.keysetsFilePath, err)
   }
   defer f.Close() // unhandled error

   jsonWriter := keyset.NewJSONWriter(f)

   err = handle.Write(jsonWriter, r.masterKey)
   if err != nil {
       return fmt.Errorf("failed to write keyset to file %s: %w", r.keysetsFilePath, err)
   }

   return nil
}
Enter fullscreen mode Exit fullscreen mode

First, we create a new keyset using keyset.NewHandle and the ES256Template from the jwt package. This creates a keyset with the ES256 algorithm, which implements elliptic curve signing with the NIST P-256 curve. As mentioned above, this creates a keyset suitable for creating Signer and Verifier instances. Tink provides many other templates for keysets that cannot be used for signing/verifying, and trying to use one as such will result in a runtime error. The jwt subpackage in Tink lists the available templates.

Once we have the new keyset handle, we can serialize it to JSON and write it to a file.

   jsonWriter := keyset.NewJSONWriter(f)

   err = handle.Write(jsonWriter, r.masterKey)
   if err != nil {
       return fmt.Errorf("failed to write keyset to file %s: %w", r.keysetsFilePath, err)
Enter fullscreen mode Exit fullscreen mode

We create a JSONWriter instance for the file, and then call the keyset handle’s Write method. Note that this takes two arguments - an instance of the Writer implementation, and a tink.AEAD instance, which we have called masterKey. This is the encryption key used to encrypt the keyset before writing it to the file.

Tink provides various other implementations of Writer; we have chosen JSON to be more human-readable, so the keyset files can be inspected.

Now we can create a keyset and securely store it in a local file. We can now return to implementing the methods for the KeysetsRepo interface. First, GetKeyset:

func (r *FSKeysetsRepo) GetKeyset() (*keyset.Handle, error) {
   return r.readKeysetFromFile()
}

func (r *FSKeysetsRepo) readKeysetFromFile() (*keyset.Handle, error) {
   f, err := os.Open(r.keysetsFilePath)
   if err != nil {
       return nil, fmt.Errorf("failed to open keysets file %s: %w", r.keysetsFilePath, err)
   }
   defer f.Close() // unhandled error

   jsonReader := keyset.NewJSONReader(f)

   handle, err := keyset.Read(jsonReader, r.masterKey)
   if err != nil {
       return nil, fmt.Errorf("failed to read keyset as JSON: %w", err)
   }

   return handle, err
}
Enter fullscreen mode Exit fullscreen mode

We will need to re-use the readKeysetFromFile logic later, so we have extracted it to a separate method. Here, we open the file holding the keyset, and create a JSONReader (since we stored the keyset serialized as JSON). Then we use the keyset.Read method to read the keyset from the file and create a keyset.Handle. Note that again the master key is needed, this time to decrypt the keyset.

The GetPublicKeyset method is very similar, with one additional step:

func (r *FSKeysetsRepo) GetPublicKeyset() (*keyset.Handle, error) {
   handle, err := r.readKeysetFromFile()
   if err != nil {
       return nil, err
   }

   publicHandle, err := handle.Public()
   if err != nil {
       return nil, fmt.Errorf("failed to get public keyset: %w", err)
   }

   return publicHandle, nil
}
Enter fullscreen mode Exit fullscreen mode

We get the public part of the keyset only using the handle.Public() method, which returns a new keyset containing only the public part, which we then return.

The API

The last remaining piece of our service is an API that can be used to create and verify tokens. For this example, we will implement a very basic HTTP server exposing two endpoints:

func StartServer(h *APIHandler) error {
   mux := http.NewServeMux()
   mux.HandleFunc("/tokens", h.PostToken)
   mux.HandleFunc("/tokens/verify", h.PostVerifyToken)

   return http.ListenAndServe(":8080", mux)
}
Enter fullscreen mode Exit fullscreen mode

Both endpoints will accept only the POST method. POST /tokens will accept some user information and return a signed and encoded JWT; POST /tokens/verify will accept a signed token and return a boolean indicating whether it is valid.

PostToken can leverage GenerateTokenWithClaims to generate a signed token and PostVerifyToken can leverage VerifyToken to verify a token.

The Master Key

Our service is nearly complete - we have our API, a layer of business logic for managing our tokens, and persistence for our keysets. It’s time to put it all together:

func main() {
   conf := getConfigFromEnv()

   keysetsRepo := NewFSKeysetsRepo(
       conf.keysetsFilePath,
       masterKey,
   )

   tokenManager := NewTinkTokenManager(keysetsRepo)

   apiHandler := NewAPIHandler(tokenManager)

   err := keysetsRepo.InitTokenKeyset()
   if err != nil {
       log.Fatalf("Failed to initialize token keysets")
   }

   err = StartServer(apiHandler)
   if err != nil {
       log.Fatalf("Server encountered an error: %s", err.Error())
   }
}
Enter fullscreen mode Exit fullscreen mode

We will not worry too much about getting the configuration from the environment variables for now - however, we are missing the master key, which we need to construct our keysets repo.

As discussed above, the master key is used to encrypt your signing keysets, and so it is essential that it is kept securely and safely. If you were to lose the master key, all of your signing keysets would become inaccessible and unusable. It is therefore recommended that you store the master key in a secure key management service (KMS). KMSs are offered as features by cloud providers (e.g., GCP, AWS) and so can be integrated as part of your cloud deployment.

For local testing, it is possible to create keysets and store them in plaintext in local files. The example repo contains code for doing this to help you try out the service locally. However, this is not safe for non-testing scenarios!

We will use the GCP KMS as an example:

func getMasterKey(conf *config) tink.AEAD {
   gcpClient, err := gcpkms.NewClientWithOptions(context.Background(), conf.keyURIPrefix)
   if err != nil {
       log.Fatal(err)
   }
   registry.RegisterKMSClient(gcpClient)

   masterKey, err := gcpClient.GetAEAD(conf.masterKeyURI)
   if err != nil {
       log.Fatalf("Failed to retrieve master key: %s", err.Error())
   }

   return masterKey
}
Enter fullscreen mode Exit fullscreen mode

The masterKey we return here implements the tink.AEAD interface for encrypting and decrypting. However, we do not have the master key itself locally - that must be kept safely in the KMS. Instead, the data to be encrypted/decrypted is transmitted to and from the KMS behind the scenes.

Conclusion

In this blogpost, we have laid out the essential steps to get started using Tink with JWTs, including:

  • Creating Tink keysets for signing and verifying keys
  • Safely storing these keysets
  • Using the Signer and Verifier interfaces for JWTs
  • Using a KMS to store master keys

Our next blogpost in the series will build on this, looking at key management (in particular, key rotation), and taking a deeper dive into the structure of a Tink keyset.

Top comments (0)