Experiments/18 Dec, 2022

In-browser HSM-backed Encryption with Tink and Wasm

This post explores how to use Wasm to lift Tink to JavaScript and how you can leverage it to perform client-side encryption directly from the browser, backed with a master key stored in a HSM.


Introduction

We already wrote about our app-layer encryption model and how we use it to prevent mass-exfiltration and keep user data safe. However, with our model the end user still has to trust our ability to keep data safe and secure.

The natural evolution of this is to allow the user to perform client-side encryption so that SlashID is unable to decrypt any data stored with us. Client-side encryption involves several building blocks and has a number of tradeoffs that make it unsuitable for some use cases.

This blogpost is an experiment to show what is possible with Wasm and Tink, rather than a design recommendation. In particular we’ll show a proof of concept of how to encrypt data directly from the browser using a master key stored in a Hardware Security Module (HSM) using Tink (Google’s open-source cryptography library) and WebAssembly (Wasm), but it is not meant as a recommendation on how to implement secure client-side encryption.

It is also worth mentioning that client-side encryption is significantly easier and safer in native environments such as iOS apps, and we expect that some of those capabilities (for example, the ability to encrypt/decrypt data through the Secure Enclave) will eventually be exposed to the browser through Web Crypto.

Web Crypto vs Tink

While Web Crypto APIs have now reached over 97% coverage in browsers, Mozilla is the first one to warn that the use of these primitives is very challenging and with a potentially disastrous impact on security:

Warning: The Web Crypto API provides a number of low-level cryptographic primitives. It’s very easy to misuse them, and the pitfalls involved can be very subtle.

Even assuming you use the basic cryptographic functions correctly, secure key management and overall security system design are extremely hard to get right, and are generally the domain of specialist security experts.

Errors in security system design and implementation can make the security of the system completely ineffective.

We fully agree with the Mozilla folks! In fact, while it is certainly possible to use native Web Crypto to implement end-to-end encryption (here’s an example), we are big proponents of using a safer abstraction layer on top of low-level cryptographic primitives.

Furthermore, Web Crypto has limited support for cryptographic primitives. For instance, neither deterministic encryption nor streaming encryption are supported.

Tink provides a safer layer of abstraction above underlying cryptographic primitives and has support for several cryptographic primitives including Envelope Encryption and Deterministic Encryption, that’s why we chose it for our app-layer encryption model.

Given our positive experience with Tink, we wanted to experiment with something comparable in the browser.

Tink through Wasm

The Tink maintainers started to work on a Typescript version of Tink but as of today it is still experimental and there’s no timeline for a stable release. We therefore turned to Wasm to lift the stable Golang implementation of Tink to JavaScript.

In general, there are two requirements to lift a program to Wasm:

  1. Compiler support to translate the original application into Wasm bytecode
  2. A system interface to allow the Wasm bytecode to interact with the rest of the system (e.g., the filesystem or the network)

Wasm was introduced in 2017, and since then it has gained a lot of popularity and widespread support. When it comes to compiling from Go to Wasm, we have two options:

  1. The Go compiler supports the generation of Wasm bytecode out of the box, although Go still doesn’t have native support for The WebAssembly System Interface (WASI)
  2. TinyGo, which offers support for WASI

Unfortunately, TinyGo has limited support for the standard Go libraries and is not able to run Tink out of the box, so for this blogpost we’ll stick to the native Go compiler.

Lifting Tink to Wasm

To compile a Go program in Wasm with the native compiler, we simply need to set GOOS and GOARCH to GOOS=js GOARCH=wasm. Let’s try that out with Tink and see if it still passes the unit tests:

git clone https://github.com/google/tink
Cloning into 'tink'...
$ cd tink/go
$ export PATH="$PATH:$(go env GOROOT)/misc/wasm"
$ GOOS=js GOARCH=wasm  go test ./...

FAIL    github.com/google/tink/go/integration/hcvault [build failed]

The Hashicorp Vault tests fail and in general the tests are slower than native, but overall Tink seems to be usable. In particular, the test suites for signature, aed and core seem to pass.

A simple Tink interface

To use Tink from JavaScript we need to expose an interface usable from the browser. Let’s write a simple encrypt/decrypt pair that uses AES GCM to encrypt/decrypt data and returns it base64-encoded to the caller.

The code uses syscall/js to interact with JavaScript.


func encryptString(this js.Value, args []js.Value) interface{} {

   var msg string
   if len(args) < 1 {
       fmt.Println("Not enough arguments")
       return nil
   }

   if args[0].Type() != js.TypeString {
       fmt.Println("the argument is not a string")
       return nil
   }

   msg = args[0].String()
   fmt.Println("Encrypting:", msg)
   //For this example we use AES_128_GCM. Let's create a keyset
   kh, err := keyset.NewHandle(aead.AES128GCMKeyTemplate())
   if err != nil {
       fmt.Printf("Error creating the keyset %v\n", err)
       return nil
   }

   aeadPrimitive, err := aead.New(kh)
   if err != nil {
       fmt.Printf("Error creating the aead primitive %v\n", err)
       return nil
   }

   //Let's encrypt the message
   ct, err := aeadPrimitive.Encrypt([]byte(msg), nil)
   if err != nil {
       fmt.Printf("Error encrypting data %v\n", err)
       return nil
   }

   b64EncryptedData := base64.URLEncoding.EncodeToString(ct)

   //we are returning the keyset to the caller which is not recommended behavior
   // so we need to export it through the insecurecleartextkeyset interface
   buffer := new(bytes.Buffer)
   exportedPriv := keyset.NewJSONWriter(buffer)
   err = insecurecleartextkeyset.Write(kh, exportedPriv)
   if err != nil {
       fmt.Printf("Error exporting keyset %v\n", err)
       return nil
   }

   b64Keyset := base64.URLEncoding.EncodeToString(buffer.Bytes())

   return []interface{}{b64EncryptedData, b64Keyset}
}

The function to decrypt a string given a key looks as follows:

func decryptString(this js.Value, args []js.Value) interface{} {

   if len(args) < 2 {
       fmt.Println("Not enough arguments")
       return nil
   }

   if args[0].Type() != js.TypeString {
       fmt.Println("the value to decrypt is not a string")
       return nil
   }

   if args[1].Type() != js.TypeString {
       fmt.Println("the keyset is not a string")
       return nil
   }

   encryptedString := args[0].String()
   b64EncKeyset := args[1].String()

   encryptedStringBinary, err := base64.URLEncoding.DecodeString(encryptedString)
   if err != nil {
       fmt.Println("Error decoding Base64 encoded data")
       return nil
   }

   decodedKeyset, err := base64.URLEncoding.DecodeString(b64EncKeyset)
   if err != nil {
       fmt.Println("Error decoding Base64 encoded data")
       return nil
   }

   //let's load the keyset to decrypt encryptedString
   reader := keyset.NewJSONReader(bytes.NewBuffer([]byte(decodedKeyset)))
   decryptionKeyset, err := insecurecleartextkeyset.Read(reader)
   if err != nil {
       fmt.Printf("Error reading the keyset %v\n", err)
       return nil
   }

   aeadPrimitive, err := aead.New(decryptionKeyset)
   if err != nil {
       fmt.Printf("Error creating the aead primitive %v\n", err)
       return nil
   }

   //now we can decrypt the string
   decryptedString, err := aeadPrimitive.Decrypt([]byte(encryptedStringBinary), nil)
   if err != nil {
       fmt.Printf("Error decrypting data %v\n", err)
       return nil
   }

   b64DecryptedData := base64.URLEncoding.EncodeToString([]byte(decryptedString))
   return b64DecryptedData
}

Now that we have the primitives to encrypt/decrypt data we can expose them to the browser

func main() {

   global := js.Global()
   global.Set("wasmEncryptString", js.FuncOf(encryptString))
   global.Set("wasmDecryptString", js.FuncOf(decryptString))
   fmt.Println("wrappers ready")

   //never exit
   done := make(chan struct{}, 0)
   <-done
}

Using the functions from the browser is straightforward:

WebAssembly.instantiateStreaming(fetch('main.wasm'), go.importObject).then(
  async (result) => {
    mod = result.module
    inst = result.instance

    go.run(inst)
    inst = await WebAssembly.instantiate(mod, go.importObject)

    ret = wasmEncryptString('aaa')
    console.log(ret)
    console.log(wasmDecryptString(ret[0], ret[1]))
  }
)

Benchmarking and optimization

This all seems great except that when we compile the Wasm module, we get:

% GOOS=js GOARCH=wasm go build -o main.wasm .
% du -h main.wasm
7.8M	main.wasm

Almost 8 MB! That really wouldn’t make for a snappy user experience. Can we do better? Stripping debugging symbols only reduces the file size by about 300 KB. However, we can compress the file with brotli and get a more manageable 2.1 MB file:

% GOOS=js GOARCH=wasm go build -o main.wasm main.go
% du -h main.wasm
7.8M    main.wasm
% brotli main.wasm -o compressed.wasm.br
% du -h compressed.wasm.br
2.1M    compressed.wasm.br

Depending on the use case, we could further prune Tink to remove all the modules that are not needed by our Wasm module.

To benchmark Tink’s runtime performance against native Web Crypto, we implemented a comparable encrypt/decrypt pair as follows:

async function encryptString(msg) {
  const key = await window.crypto.subtle.generateKey(
    { name: 'AES-GCM', length: 128 },
    true, // extractable
    ['encrypt', 'decrypt']
  )
  let iv = new Uint8Array(12)
  window.crypto.getRandomValues(iv)
  const encrypted = await window.crypto.subtle.encrypt(
    { name: 'AES-GCM', iv: iv },
    key,
    new TextEncoder().encode(msg)
  )
  const exportedKey = await window.crypto.subtle.exportKey('jwk', key)

  return [encrypted, exportedKey, iv]
}

async function decryptString(msg, keyExport, iv) {
  const key = await window.crypto.subtle.importKey(
    'jwk',
    keyExport,
    { name: 'AES-GCM', length: 128 },
    true, // extractable
    ['encrypt', 'decrypt']
  )

  const decrypted = await window.crypto.subtle.decrypt(
    { name: 'AES-GCM', iv: iv },
    key,
    msg
  )

  const decoded = new window.TextDecoder().decode(new Uint8Array(decrypted))

  return decoded
}

And we ran both function pairs with 100 times as follows:

for (var i = 0; i < 100; i++) {
  let val = makeid(i + 1)
  let t0 = performance.now()
  ret = wasmEncryptString(val)
  decrypted = wasmDecryptString(ret[0], ret[1])
  let t1 = performance.now()
  if (val != decrypted) {
    console.log(`invalid result: encrypted ${val} - received ${decrypted}`)
    break
  }
  console.log(
    `Call to wasmEncryptString/wasmDecryptString took ${t1 - t0} milliseconds.`
  )
}

for (var i = 0; i < 100; i++) {
  let val = makeid(i + 1)
  let t0 = performance.now()
  ret = await encryptString(val)
  decrypted = await decryptString(ret[0], ret[1], ret[2])
  let t1 = performance.now()
  if (val != decrypted) {
    console.log(`invalid result: encrypted ${val} - received ${decrypted}`)
    break
  }
  console.log(
    `Call to encryptString/decryptString took ${t1 - t0} milliseconds.`
  )
}

Excluding the first call to wasmEncryptString/wasmDecryptString which each took approximately 1.2 ms across 10 runs, the average for the Tink pair was 0.2-0.3 ms compared to WebCrypto’s 0.1-0.2 ms. While Tink is measurably slower than native, we believe it is negligible.

Accessing a Hardware Security Module directly from the browser

Lifting Tink to Wasm allows us to do some pretty exciting things, and one of them is to encrypt data using Envelope Encryption with a master key stored in a secure HSM. In envelope encryption, the HSM key acts as a key encryption key (KEK). That is, it’s used to encrypt data encryption keys (DEK) which in turn are used to encrypt actual data.

For this blogpost we use a Cloud KMS, but the same principles are applicable to any HSM that has an adapter for Tink. While both GCP and AWS offer KMS solutions, Google doesn’t expose ‘access-control-allow-origin’ on GCP APIs, so calls to GCP KMS from the browser are blocked by Cross-origin resource sharing (CORS) policies; therefore, we can only use the AWS KMS from the browser.

After creating a KEK in KMS, to encrypt each message you must:

  1. Generate a data encryption key (DEK) locally.
  2. Use the DEK locally to encrypt the message.
  3. Use Cloud KMS to encrypt (wrap) the DEK with the KEK.
  4. Store the encrypted data and the wrapped DEK.

You don’t need to implement this envelope encryption process from scratch when you use Tink.

To use Tink for envelope encryption, you provide Tink with a key URI and credentials. The key URI points to your KEK in KMS, and the credentials let Tink use the KEK. Tink generates the DEK, encrypts the data, wraps the DEK, and then returns a single ciphertext with the encrypted data and wrapped DEK.

For the sake of brevity we’ll skip the details on key creation in KMS and authentication to the user AWS account. We’ll also skip the details on how to parse arguments from JavaScript - for details, please see the section below on how to use fetch from Go in Wasm.

The first step is to create a KMS instance:

func constructAWSKMS(keyURI string, data map[string]string) (*kms.KMS, error) {
   crVal := credentials.Value{
       AccessKeyID:     data["access_key"],
       SecretAccessKey: data["secret"],
   }
   creds := credentials.NewStaticCredentialsFromCreds(crVal)

   region, err := getRegion(keyURI)
   if err != nil {
       return nil, err
   }

   session := session.Must(session.NewSession(&aws.Config{
       Credentials: creds,
       Region:      aws.String(region),
   }))

   return kms.New(session), nil
}

Now that we have a KMS instance we can create a registry.KMSClient

kms, err := constructAWSKMS(keyURI, creds)

if err != nil {
    fmt.Printf("Error constructing kms %v\n", err)
    return js.Value{}, errors.New("Error constructing kms")
}
KMSClient, err = awskms.NewClientWithKMS(keyURI, kms)

if err != nil {
    fmt.Printf("Error connecting to aws kms %v\n", err)
    return js.Value{}, errors.New("Error connecting to aws kms")
}

The KMSClient allows us to get an AEAD primitive from KMS and perform envelope encryption/decryption with it.

   registry.RegisterKMSClient(KMSClient)
   masterKey, err := KMSClient.GetAEAD(keyURI)
   if err != nil {
       fmt.Printf("Unable to get the master key from KMS %v\n", err)
       return nil
   }

   //create a keyset encrypted with the master key from KMS
   dek := aead.AES128CTRHMACSHA256KeyTemplate()
   kh, err := keyset.NewHandle(aead.KMSEnvelopeAEADKeyTemplate(keyURI, dek))
   if err != nil {
       fmt.Printf("Unable to create a keyset %v\n", err)
       return nil
    }
   aeadPrimitive, err := aead.New(kh)
   if err != nil {
       fmt.Printf("Unable to create the aead primitive %v\n", err)
       return nil
   }

   //Let's encrypt the message
   ct, err := aeadPrimitive.Encrypt([]byte(msg), nil)
   if err != nil {
       fmt.Printf("Unable to encrypt the message %v\n", err)
       return nil
    }

To decrypt a ciphertext we first load the key and decrypt it with the master key from KMS and then we can decrypt the message as we would normally do


func decryptStringWithKMS(data, encryptedKeyset, keyURI string, kmsClient registry.KMSClient) (string, error) {

   fmt.Println("Decrypting:", data)

   registry.RegisterKMSClient(kmsClient)
   masterKey, err := kmsClient.GetAEAD(keyURI)
   if err != nil {
       return "", err
   }

   encryptedStringBinary, err := base64.URLEncoding.DecodeString(data)
   if err != nil {
       return "", errors.New("Error decoding Base64 encoded data")
   }

   decodedKeyset, err := base64.URLEncoding.DecodeString(encryptedKeyset)
   if err != nil {
       return "", errors.New("Error decoding Base64 encoded data")
   }

   reader := keyset.NewJSONReader(strings.NewReader(string(decodedKeyset)))

   // Read reads the encrypted keyset handle back from the io.Reader
   // implementation and decrypts it using the master key.
   kh, err := keyset.Read(reader, masterKey)
   if err != nil {
       return "", err
   }

   a, err := aead.New(kh)
   if err != nil {
       return "", err
   }

   //now we can decrypt the string
   decryptedString, err := a.Decrypt([]byte(encryptedStringBinary), nil)
   if err != nil {
       return "", err
   }
   return string(decryptedString), nil
}

Putting this all together from JavaScript:

WebAssembly.instantiateStreaming(fetch("main.wasm"), go.importObject).then(async (result) => {
    mod = result.module;
    inst = result.instance;

    go.run(inst);
    inst = await WebAssembly.instantiate(mod, go.importObject);

    credsAWS = {
        'access_key': "YOUR_ACCESS_KEY",
        'secret': "YOUR_SECRET",
    }

    ret = await wasmEncryptStringKMS("test hsm", "aws-kms://arn:aws:kms:us-east-1:489321446924:key/cdb44898-ce62-4799-b743-bf39631c9b51
    ", "aws", credsAWS)
    console.log(ret)
    ret = await wasmDecryptStringKMS(ret[0], ret[1], "aws-kms://arn:aws:kms:us-east-1:489321446924:key/cdb44898-ce62-4799-b743-bf39631c9b51", "aws", credsAWS)
    console.log(ret)
...

This allows us to store the encryption key securely without having to worry about leaking key material.

Notes on making network requests from Wasm

Quoting from the FuncOf reference:

Invoking the wrapped Go function from JavaScript will pause the event loop and spawn a new goroutine. Other wrapped functions which are triggered during a call from Go to JavaScript get executed on the same goroutine.

As a consequence, if one wrapped function blocks, JavaScript’s event loop is blocked until that function returns. Hence, calling any async JavaScript API, which requires the event loop, like fetch (http.Client), will cause an immediate deadlock. Therefore a blocking function should explicitly start a new goroutine.

Go HTTP calls use fetch in Javascript. This results in a blocking operation (network call), which needs to be handled properly otherwise it will result in a deadlock. In our specific example, fetch is used to encrypt the message with the key stored in KMS.

To solve the issue, blocking functions should explicitly start a new goroutine. However, this makes returning data to Javascript trickier because we can’t use channels (we’d be back in the deadlock). The easiest solution is to create a Promise through syscall/js and call resolve() through the goroutine:

func resolvePromise(cb func() (js.Value, error)) js.Value {
   promiseHandler := js.FuncOf(func(this js.Value, args []js.Value) interface{} {
       resolve := args[0]
       reject := args[1]

       go func() {
           ret, err := cb() //execute the blocking function in a goroutine
           if err != nil {
               reject.Invoke(js.Global().Get("Error").Error(err))
               return
           }

           resolve.Invoke(ret)
       }()

       return nil
   })
   defer promiseHandler.Release()

   return js.Global().Get("Promise").New(promiseHandler)
}

Conclusion

In this blogpost we’ve demonstrated an example where Tink makes it very easy to perform envelope encryption and to interface with a Cloud KMS, which would be significantly harder to implement with WebCrypto. While we used AWS HSMs for this blogpost, it is equally easy to implement the same mechanism with any HSM (including portable ones such as YubiHSM) by creating a Tink integration for it.

The Tink team is working on a TypeScript port of the library and we are eagerly waiting for its release. While lifting Tink to Wasm is definitely possible, the tradeoffs of using Tink via Wasm vs native WebCrypto are not obvious and are highly dependent on your project requirement.

In a future blogpost we’ll expand further on client-side encryption to show how we can allow the user to store encrypted data without the significant burden of managing keys or credentials.