Making our toolchain work on the web using WebAssembly

herman_slatman.jpg

Herman Slatman

Follow Smallstep

Interested in WebAssembly, (Tiny)Go, and step? Join us to talk more about the future of this project in the #WebAssembly channel on the Smallstep Discord server.


Recently I was happily coding on our open source PKI toolchain when an interesting and potentially rewarding side quest popped up. It didn’t take long until I was deep in the rabbit hole full of PKI, JavaScript & WebAssembly. Read along if you’re interested in learning what we had to do to make our toolchain work on the web!

“Universal Certificate Rotation”

I have been interested in all things information security for ages. PKI and (m)TLS were always of special interest to me since they’re so broadly supported by different technologies. Yet both PKI and (m)TLS are still cumbersome to work with, especially when debugging (mis)configurations.

I often lurk on Twitter to keep myself up-to-date with new developments about things that interest me. When Colm MacCárthaigh posted this tweet about Universal certificate rotation running fully inside a browser, it piqued my interest and I started scrolling the thread.

<TwitterTweetEmbed tweetId={'1509898412581285892'} />

It didn’t take long to dawn on me that this was a joke and of course on April 1st - April Fool’s Day. I wasn’t the only one to fall for this - hundreds, if not thousands of people got fooled, including some of my fellow Smallsteppers.

The prank got me thinking: is it possible to perform certificate rotation in a browser? And how would I do that using the Smallstep toolchain?

It didn’t take me long to get to a plan. Let’s dive in!

Certificate Rotation (in a Browser)

At Smallstep we create the (open source) tools to set up and maintain internal Public Key Infrastructure (PKI). We try to make these tools as easy-to-use and interoperable as possible, avoiding the ever-dreaded vendor lock-in. As Colm claimed, certificate expiration is one of the top causes of an internet service disruption. Our CA and CLI support automated certificate renewal, preventing unintentional certificate expiry from happening within your operational environment.

Step CA provides APIs for requesting and renewing certificates. The main difference between these two APIs is the authentication methods supported. Requesting a certificate requires a One-Time Token (OTT) or another authentication method supported by one of our provisioners. Renewing a certificate is (usually) performed using client certificate authentication (mTLS). The CLI automatically selects the authentication method for requesting or renewing a certificate. In addition to the custom authentication methods provided by the provisioners, the CA also supports clients requesting and renewing certificates using the ACME protocol, which authenticates certificate requests by making clients solve a challenge. Another difference between requesting and renewing certificates is that on renewal the existing private key will be reused instead of creating a new one.

We provide CA and CLI binaries for many different architectures, but none of these can be run directly in a browser. We also provide some example clients, including a JavaScript one for use in Node.js that probably could be adapted to work in a browser, but I wanted to try something different instead. I had read many positive articles about WebAssembly and was also aware of this issue in the Tailscale repository tracking support for running a Tailscale node in a browser. At this point, I decided I wanted to try and get our CLI to compile to WebAssembly and executed in a browser.

WebAssembly

WebAssembly (Wasm) is a relatively young W3C standard, introduced in 2015 and released for the first time in 2017, defining a portable binary and text format for executables and the interfaces between these executables and their execution environment. Initially, Wasm was conceived for the web and thus for applications running in browsers. However, nothing in the standard prevents it from being used in alternative environments, resulting in initiatives like the WebAssembly System Interface (WASI) to standardize an interface for Wasm to be executed in environments other than browsers.

One of the main goals of Wasm is to provide high-performance program execution on web pages, which it realizes through its efficient binary instruction format. Thus, when it comes to computation-heavy application logic, it is considered an alternative to JavaScript. Next to providing high-performance application execution, WebAssembly aims to provide a safe execution environment independent of hardware, programming language, or execution environment, making it a very versatile compilation target.

Go is one of the programming languages that supports compiling to Wasm natively and does so quite well. Chances are that you can compile a simple Go program to Wasm and that it can execute as is, but there are several things you need to be aware of when targeting Wasm.

A problem you may come across is the compatibility of your application code with the browser environment. When running a Wasm binary inside a browser, it will be constrained to the APIs that the browser supports and makes accessible to the Wasm binary. The binary will be running in a sandbox, so it can’t easily interact with the rest of the operating system the browser is running in, making some I/O operations hard or even impossible. One example of this is performing HTTPS requests from a Wasm binary. While possible, requests will always have to go through the JavaScript interop layer and transformed into fetch requests. These requests are also susceptible to standard web security measures, like CORS. Listening on a TCP socket is not supported, so you will not be able to run a server directly in the browser. Interaction with the filesystem is possible but requires implementing additional methods in the JavaScript shim to intercept system calls from the Wasm binary.

Hacking on the POC

The goal was clear: find out if it is possible to compile the step CLI to a Wasm binary, serve that on a web page, and request a certificate from step-ca. I started reading up on how to compile Go to WebAssembly, coming down to the following command:

GOOS=js GOARCH=wasm go build -o cli.wasm cmd/step/main.go

When trying this for the first time, I hit a build constraint, blocking the build process. This led me to fork the dependency and, given that this was supposed to be a quick POC, hacking it such that it would compile and adding a replace to go.mod pointing to my local fork. When retrying the build, another dependency broke the process and I repeated the fork and hack process. This took several iterations, resulting in the following replace statements in the go.mod file:

replace github.com/chzyer/readline => ../readline
replace github.com/newrelic/go-agent => ../go-agent
replace github.com/smallstep/certificates => ../certificates
replace github.com/smallstep/zcrypto => ../zcrypto

After replacing the dependencies, there were a couple of things I needed to patch out of the CLI to make it compatible with Wasm. The main issues to solve were as follows (PR here):

  • Handling signals. The js/wasm target doesn’t support all system signals used in the CLI, so we need to find a workaround for those. This comes down to guarding the unsupported signals using Go build target tags.
  • Offline flows. step and step-ca can be used in Offline Mode. In this mode, the CLI does not communicate with the CA API, but instead uses an in-memory instance of the CA to perform certificate operations. The in-memory CA uses the same configuration as an existing step-ca instance, resulting in the same database, keys and templates to be used. For this mode to be fully supported in the js/wasm build, a large part of the step-ca code base would have to be made compatible. I expected this to take a lot more time, so for the POC I removed all of the offline flows.

The cli.wasm module was then built successfully when I ran the build command! I don’t expect it to support all functionalities, but that also wasn’t the point of the POC; thus far it’s a success! 🎉

The next step was to serve the cli.wasm and what better way than to do this using a Go web server. I found a simple example server specifically aimed at serving Wasm binaries in Johan Brandhorst’s Wasm experiments repository when researching the subject before and adapted that to my liking. The repository also contains a Makefile and some examples with a basic index.html and the default wasm_exec.js file provided by the Go team. Together these made serving a fresh Wasm build of the CLI easy.

Serving the cli.wasm for the first time resulted in the following error:

Error obtaining home directory, please define environment variable HOME.

Apparently the CLI needs to know where the user's $HOME is. After a bit of digging through wasm_exec.js, I found that it’s possible to set environment variables for the Wasm binary to use:

const go = new Go();
go.env["HOME"] = ".";      // set the HOME directory to the "current working directory"
go.env["STEPDEBUG"] = "1"; // print debug information to console
go.argv = ["js"];          // set a mock program name; no other arguments 
await go.run(instance);    // run the JavaScript "go" object with the .wasm instance

Refreshing the web page now results in the CLI writing its help message to the browser console. The build info shows that we’re indeed serving a js/wasm binary 🎉

    ...
    VERSION
      Smallstep CLI/0000000-dev (js/wasm)
    ...

Now that we have a working version of the CLI running in the browser, it’s time to request a certificate from step-ca. As described before, the CA has an API that allows a client to authenticate and request a certificate. But before doing that, a client needs to verify that it’s communicating with the expected CA. In the Smallstep ecosystem, this is called bootstrapping. My CA runs on my local machine and reachable at https://ca.localhost:8443. The fingerprint is used by the client to verify that it’s communicating with the expected CA so that it can be trusted for future interactions. Let’s try bootstrapping with my CA in the browser:

...
console.log("bootstrapping ... ");
go.argv = ["js", "ca", "bootstrap", "--ca-url=https://ca.localhost:8443/", "--fingerprint=a343eb46f0effbff60e716b0be59cf8ccf3108af62bcd2e5fea76c8b295b7bc6"];
await go.run(instance);
...       

The command above will perform several HTTPS requests. These HTTPS requests are performed by the Wasm binary code and caught by the JavaScript interop layer before they’re sent to the remote host. This layer transforms the requests to fetch requests so that the browser can execute them. When the response arrives, the response is fed back to the Wasm binary again.

The first time I executed this, I hit a CORS issue:

Cross-Origin Request Blocked: The Same Origin Policy disallows reading the remote resource at https://ca.localhost:8443/root/a343eb46f0effbff60e716b0be59cf8ccf3108af62bcd2e5fea76c8b295b7bc6. (Reason: CORS header ‘Access-Control-Allow-Origin’ missing). Status code: 200.
Get "https://ca.localhost:8443/root/a343eb46f0effbff60e716b0be59cf8ccf3108af62bcd2e5fea76c8b295b7bc6": net/http: fetch() failed: NetworkError when attempting to fetch resource.

The CA API wasn’t developed with the expectation that it would receive requests from browsers, so it didn’t send the required headers in its responses. We use chi as the core web API router, and I found a middleware that adds the required headers to responses. Below are the changes required on the CA side:

import "github.com/go-chi/cors"
// Add CORS middleware 
mux.Use(cors.Handler(cors.Options{
		AllowedOrigins:   []string{"https://*", "http://*"}, // allow any origin (for now)
		AllowedMethods:   []string{"GET", "POST", "PUT", "DELETE", "PATCH", "OPTIONS"},
		AllowedHeaders:   []string{"Accept", "Authorization", "Content-Type", "X-CSRF-Token", "User-Agent"},
		ExposedHeaders:   []string{"Link"},
		AllowCredentials: false,
		MaxAge:           300,
}))

Restarting the CA and refreshing the web page resulted in more errors, indicating an issue with the Go/JavaScript interop and the filesystem.

panic: syscall/js: call of Value.Get on undefined
<empty string> 
goroutine 6 [running]:
syscall/js.Value.Get({{}, 0x0, 0x0}, {0x2c5de3, 0x4}) 
/usr/local/go/src/syscall/js/js.go:299 +0xc 
syscall.mapJSError({{}, 0x0, 0x0}) 
/usr/local/go/src/syscall/fs_js.go:550 +0x2 
syscall.fsCall.func1({{}, 0x0, 0x0}, {0x21f3cd0, 0x1, 0x1}) 
/usr/local/go/src/syscall/fs_js.go:507 +0x6
syscall/js.handleEvent() 
/usr/local/go/src/syscall/js/func.go:96 +0x27

After some digging, I found that the Wasm module wasn’t able to write a file to the filesystem, because the filesystem and the required system calls are not available in a browser environment. The wasm_exec.js checks if it’s running in a Node.js environment and will use its fs implementation to perform the operations, but when running in a browser, the required functions are not available. A quick search on how to solve this led me to a repository created by someone who wanted to run the pdfcpu utility, also written in Go, in a browser. This sounded very similar to what I was trying to do! BrowserFS was suggested as a filesystem implementation that works inside a browser environment. It supports multiple storage backends, including IndexedDB and LocalStorage. I hooked up a LocalStorage backed filesystem to the browser window:

BrowserFS.install(window);
BrowserFS.configure({
		fs: "LocalStorage"
	}, function(e) {
        if (e) {
                console.log(e);
                throw e;
        }
        var fs = BrowserFS.BFSRequire('fs');
        var Buffer = BrowserFS.BFSRequire('buffer').Buffer;
        self.path = BrowserFS.BFSRequire('path');
});

Conveniently, the example repository also contained some additional patches to the filesystem functions for opening and writing files and a handy debugging utility, so I copied those over to my wasm_exec.js. In addition to the functions I copied, I needed to fix the read function, because that operation still failed to complete. This involved quite a bit of debugging! In the end, I found a hacky method to ensure the Wasm binary would receive a filled buffer instead of an empty one. I also added calls to create the directory structure that step uses, so that the structure is ready before the CLI runs and I didn’t need to patch these functions for the POC as well.

Rerunning the CLI now resulted in the following messages:

Console was cleared. 
bootstrapping ...
The root certificate has been saved in .step/certs/root_ca.crt.
The authority configuration has been saved in .step/config/defaults.json.

We successfully bootstrapped the client inside the browser! 🎉

The next step is to create a token with which the CLI can authenticate itself to the CA. The token is signed by a private key, which we can retrieve from the CA. The CA conveniently and securely stores the encrypted private key, so we need a password to decrypt it. For the POC I created a file in the browser filesystem and wrote the password to it. The command to execute to retrieve a token is as follows:

console.log("getting token ...");
go.argv = ["js", "ca", "token", "host.local", "--ca-url=https://ca.localhost:8443/", "--provisioner=Admin JWK", "--password-file=.step/secrets/pass.txt"];
await go.run(instance);

Resulting in the following browser console output:

getting token ...
Admin JWK (JWK) [kid: -bpK0AkvgJpt4Ju9_MaWDrzQL_fnOKOJiI521JABCHU]
eyJhbGciOiJFUzI1NiIsImtpZCI6Ii1icEswQWt2Z0pwdDRKdTlfT....ErbN-aJSxkMRTc_ra9UIdtNn88LsIgX1d7J5Io-PqmTlaETC8K7B-OADh31pbmLsm0aJA

The next step is to request a certificate. The token is currently read from a global variable filled with the stdout output of the previous CLI invocation.

console.log("getting certificate ...");
go.argv = ["js", "ca", "certificate", "host.local", "host.crt", "host.key", "--ca-url=https://ca.localhost:8443/","--token=".concat(token)];
await go.run(instance);

Resulting in the following browser console output:

getting certificate ...
✔ CA: https://ca.localhost:8443 
✔ Certificate: host.crt 
✔ Private Key: host.key

It seems the certificate was written successfully! Let’s see if we can read it to inspect its contents:

console.log("inspecting certificate ...");
go.argv = ["js", "certificate", "inspect", "host.crt"];
await go.run(instance);

Resulting in the following browser console output, generated by the certinfo Go library we maintain:

inspecting certificate ...
Certificate:
    Data:
        Version: 3 (0x2)
        Serial Number: 93322259494976722904314715963687444540 (0x463536a1a3bf72e1ef6e19d59d1c203c)
    Signature Algorithm: ECDSA-SHA256
        Issuer: O=Smallstep,CN=Smallstep Intermediate CA
        Validity
            Not Before: May 20 16:17:52 2022 UTC
            Not After : May 20 16:23:52 2022 UTC
        Subject: CN=host.local
        Subject Public Key Info:
            Public Key Algorithm: ECDSA
                Public-Key: (256 bit)
                X:
                    e1:9e:a1:ca:24:70:ca:16:4a:ee:a4:81:4b:74:d5:
                    15:a3:48:35:b2:9b:cb:dc:70:6a:42:95:f3:0e:c5:
                    d5:c1
                Y:
                    6b:20:01:67:28:18:50:6e:30:b2:de:1a:f4:1f:58:
                    86:0e:8f:b5:97:66:fe:e8:bd:9d:ad:33:23:cf:d0:
                    d5:a9
                Curve: P-256
        X509v3 extensions:
            X509v3 Key Usage: critical
                Digital Signature
            X509v3 Extended Key Usage:
                Server Authentication, Client Authentication
            X509v3 Subject Key Identifier:
                70:40:87:63:14:8F:0B:55:08:34:C7:A2:F2:3D:01:31:9D:F0:0E:20
            X509v3 Authority Key Identifier:
                keyid:C9:DC:F1:D9:23:A2:8D:60:E1:CE:1F:51:D5:FC:20:31:E7:DF:76:34
            X509v3 Subject Alternative Name:
                DNS:host.local
            X509v3 Step Provisioner:
                Type: JWK
                Name: Admin JWK
                CredentialID: -bpK0AkvgJpt4Ju9_MaWDrzQL_fnOKOJiI521JABCHU
    Signature Algorithm: ECDSA-SHA256
         30:45:02:20:5d:26:8d:83:cd:06:c6:3e:b6:f5:c2:95:c3:3f:
         49:80:7a:27:53:da:3c:27:5d:4f:90:ae:de:84:7e:8a:92:3e:
         02:21:00:b9:e1:bc:e0:d2:7b:4a:2a:e4:1c:4c:3e:9f:df:4c:
         03:82:eb:5d:6b:fb:de:47:7d:ab:dc:23:73:03:48:7e:0d

We have completed getting the initial certificate - but we’re not quite there yet! If you recall, this blog started with certificate rotation. When renewing a certificate, step uses the existing private key and certificate to request a new certificate for the same names. When the certificate isn’t expired yet, it will use mTLS to authenticate to the CA on renewal. Unfortunately, this doesn’t work with certificates added to the Go client TLS configuration, because the request gets transformed to a JavaScript fetch request. This transformed JavaScript fetch request doesn’t include the client certificate. This method could still work if the certificate is available in the browser certificate store, but you can't add a certificate automatically to the browser cert store from the web page. Another option is to use the renew-after-expiry configuration, which allows you to renew an expired certificate. This method uses a token signed by the private key to authenticate instead of mTLS, because an expired certificate wouldn’t be accepted in the mTLS connection setup. The downsides of this approach are that it (currently) requires an expired certificate before it can be renewed and that requires the CA to be reconfigured with a fairly risky setting, from a security standpoint. That is why for this proof of concept I settled on getting a completely new keypair and certificate instead. This made me feel like I was cheating a bit, but there is a compromise here - the flow isn’t that much different from the step renew behavior. Renewing the certificate now comes down to getting a fresh token and using that to request a new certificate.

One thing I wasn’t super happy with was the fact I needed to print to the console to know if files were created and/or updated. It is possible to extract metadata and files from the browser’s local storage by hand and inspect those, but I thought it would be nicer to have a directory view showing the contents stored in the BrowserFS instance. I used the lightweight tree.js library and implemented some recursive utility functions for walking the BrowserFS filesystem to create a simple tree view of the directories and files. The screenshot below shows the current state of the tree view.

in-browser-directory-structure.png

At the time of writing, I consider it too early to release my custom CLI build to the public. While the core functionality of the CLI works in the browser, some patches to direct and indirect dependencies were required to get the CLI to compile. These changes currently live in a private workspace and will need some more time and work to be merged upstream or implemented otherwise. I hope to iterate on this project when time allows for it. While continuing to work on the CLI, I’ll suggest or implement changes in a way that unblocks the CLI from being built for Wasm going forward.

Conclusions and Next Steps

Here are the conclusions I came to after spending some time getting step to work in a browser and exploring some of the capabilities of WebAssembly:

  • Retrofitting an application that wasn’t built with the intent to compile to Wasm is a lot of refactoring work. If you want to run your application in Wasm or WASI, be sure to verify that the libraries you want to use are compatible with them and test early and often to see if your application (still) compiles and runs correctly.
  • A Go Wasm build can be huge. It’s a no-brainer to compress a .wasm before serving it to clients. Compressing the CLI Wasm build made it go from around 47MB to about 9MB in size in this case.
  • TinyGo is often suggested as an alternative compiler for compiling Go applications to WebAssembly, because it creates smaller binaries. Some critical cryptographic functionalities and system calls are not yet supported in TinyGo, which blocks us from using it at this time. Development on TinyGo continues, so we’re hopeful we can use it in the future to compile our CLI.
  • Wasm applications are constrained by browser-mandated capabilities and security features. This makes some functionalities harder or impossible to implement without significant changes to the application and its supporting services.
  • Parts of the proof of concept can (and maybe will) be improved, including: doing actual renewals; making it easier to provide and use user input; improved handling of asynchronous execution; a nicer directory tree view; and showing console output in a web terminal instead of (just) in the browser console.
  • Building Wasm applications excites me a lot and makes me start thinking about all the possibilities the technology provides in terms of (Go) applications running in a browser.

Some practical applications of making step compile to Wasm, which we could explore soon:

  • Providing an interactive demo and examples of step and step-ca in the browser, illustrating core PKI concepts and our toolchain.
  • Implement and provide critical cryptographic operations that are hard(er) to implement securely using (just) JavaScript.
  • Diving deeper into how the step toolchain fits into the overall Wasm ecosystem and its close friends, like the WebAssembly System Interface (WASI) and compilation using TinyGo. We could then use step and its SDKs in more places, such as in microcontrollers, robotics, and environments supporting WASI.

Join us on Discord in the #WebAssembly channel to talk more about this and if you are interested in Smallstep + our community taking WebAssembly, (Tiny)Go + step further!

Further Reading and References

WebAssembly Specification: https://webassembly.github.io/spec/core/intro/introduction.html

WebAssembly Experiments by Johan Brandhorst: https://github.com/johanbrandhorst/wasm-experiments

WASI: https://wasi.dev/

TinyGo: https://tinygo.org/

BrowserFS: https://github.com/jvilk/BrowserFS