A new approach to e-mail encryption in Thunderbird - thanks WebAssembly, hello acus!


Encrypting your electronic communication is a must-have in the 21st century. Since E-mail is still around, messages over SMTP should not be excluded from this rule. In this blog post I demonstrate how WebAssembly and the Go implementation of age encryption can work together to provide alternative encryption in Thunderbird. Please note, that in the current state of development the addon is decrypt-only with one private key.

The Thunderbird addon is published via Mozilla addons.thunderbird.net/thunderbird/addon/acus, the source code is available at codeberg.org/bitkeks/acus.

Das Projekt wurde unter dem Titel Moderne Verschlüsselung in Web und Mail mit WebAssembly bei den Chemnitzer Linux-Tagen 2022 vorgestellt.

WebAssembly? Go in Thunderbird?

WebAssembly, or wasm, is a recent technology which allows running compiled programs inside a web browser engine. The browser loads the binary file and JavaScript glue code enables calling the provided functions, writing to the programs input and reading from output.

Go has a native implementation to compile to WebAssembly. A good starting point is the golangbot.com "Introduction to WebAssembly using Go" article. It covers compilation, glue code and running the program inside the browser.

Now, why do we talk about browsers if the goal of this project is to run it in Thunderbird? That's because Mozilla, the developers of Thunderbird, switched the underlying engine in Thunderbird 78 in 2020. All that you see in the GUI is one big rendered web UI, crafted for mailing. This move also killed most of the previously developed addons, which were based on the XUL interface. Moving to the new platform, a new addon API was enforced: MailExtensions. These are the same specs as WebExtensions use in Firefox.

Current state of encryption in Thunderbird

One of the popular and useful security addons was Enigmail for OpenPGP. Mozilla decided to integrate OpenPGP into Thunderbird. Sadly they did not just port Enigmail to MailExtensions and built from the existing source code - they rebuilt the functionality from the ground up. It's still missing crucial features and does not use the system-wide PGP key store (keys you have managed through gnupg are not re-used inside Thunderbird).

Another well known technology stack is S/MIME. S/MIME uses public key infrastructure to provide users with certificates. Senders of messages then use the public cert of their intended receivers to encrypt mail, receivers decrypt with their secret key.

age encryption

At the end of 2019, Filippo Valsorda started the age encryption project, standing for "actually good encryption" or Italian "aghe". The Go code lives on github.com/FiloSottile/age, the spec at Google docs. The keys are X25519 elliptic curve key pairs wrapped inside the age key format.

The tooling has many features (see man page), but let's focus on what is needed in this scope:

  • Creating keys with age-keygen -o key.txt
  • Encrypting a file with age -o example.jpg.age -r age1ql3z7hjy54... example.jpg, where -o is the output file and -r the receipient's X25519 public key
  • Decrypting a file with age -d -o data.tar.gz -i key.txt data.tar.gz.age, where -d is decryption mode and -i the secret key file

Compiling age to wasm

We need two things to build age for our WebExtension: a Go compiler and a "wrapper" Go program.

Let's have a look at the wrapper. It's shortened to contain only the function which creates a key pair with age, but you can see every needed step to bundle JavaScript with Go.

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
// acus.go
package main

import (
    "filippo.io/age"
    "fmt"
    "syscall/js"
)

// Generate a new age key pair with public and secret part.
// Returns both parts in an object
func acusGenerateKey() js.Func {
    return js.FuncOf(func(this js.Value, args []js.Value) interface{} {
        k, err := age.GenerateX25519Identity()
        if err != nil {
            fmt.Println("Key could not be generated")
            return nil
        }
        newKey := make(map[string]interface{})
        newKey["pubkey"] = k.Recipient().String()
        newKey["secret"] = k.String()
        return newKey
    })
}

// Main entry point function for wasm
func main() {
    // export public functions to JS main "window"
    js.Global().Set("acusGenerateKey", acusGenerateKey())

    // keep program running for JS to call functions
    <-make(chan bool)
}

Compile the program with this script:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
#!/bin/sh

export GOOS=js
export GOARCH=wasm
ROOTDIR=$(dirname "$(readlink -f "$0")")

cd "$ROOTDIR" || exit
echo "Working in $(pwd)"

echo "Building with Go"
go build -ldflags="-s -w" -v -o ./build/acus.wasm acus.go || exit

The resulting wasm binary file is then located in ./build/acus.wasm.

TinyGo could also be used to compile the program. Using it in the addon sadly did not work because it was missing some APIs for age. If you have more success, let me know!

1
2
echo "Building with TinyGo"
tinygo build -target=wasm -ldflags="-s" -no-debug -o build/acus.tiny.wasm acus-wasm.go

Bundling WebAssembly into our MailExtension

The WebAssembly file is loaded by the background.js addon script. In the manifest.json, create an entry for background scripts:

1
2
3
4
5
6
7
// manifest.json
"background": {
    "scripts": [
        "wasm_exec.js",
        "background.js"
    ]
},

The wasm_exec.js file is the "official" wasm Go glue script from the standard installation: wasm_exec.js or via cp "$(go env GOROOT)/misc/wasm/wasm_exec.js" ./. Create a folder called "wasm" beside your manifest and put acus.wasm inside it. The background script then picks it up and loads it with the following code:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
// background.js
window.addEventListener("load", initiate);

function initiate() {
    const go = new Go();
    WebAssembly.instantiateStreaming(fetch("/wasm/acus.wasm"), go.importObject).then((result) => {
        go.run(result.instance);
    }).catch((err) => {
        console.error(err);
    });
}

As soon as "initiate" is finished, the background script can use the window.acusGenerateKey() function. Once it is called, the resulting value is a JavaScript object with the keys "pubkey" and "secret", each containing the corresponding string. Extract from the developer console:

1
2
>>> window.acusGenerateKey()
<<< Object { pubkey: "age1z02dj6dj342...", secret: "AGE-SECRET-KEY-1SRW2JES9..." }

Running the code from inside Thunderbird

You should know how to create a WebExtension (or MailExtension in this case). If you do not, head over to developer.thunderbird.net.

For development, Thunderbird provides a "load temporary addons" function. You can find it on the addon page or via Extras -> Developer Tools -> Debug addons. Loading the addon enables its functionality in Thunderbird and allows to enter the developer console from the addons debug page.

Integrating decryption in the workflow

Now that we know how to build the wasm binary, load it into Thunderbird and use the JavaScript APIs to call the compiled functions, we can finish this proof of concept. Please note, for the next steps you might want to have a look at the full Go source code of acus.go first, to understand how the decryption works.

So finally, as the first fully working feature, automatic decryption of sent email content will be our goal. We already have the background script. To successfully decrypt ciphertext inside our mail, an additional foreground script is needed. It will run inside the mail view window when you open an email and can access the DOM of the message. It cannot run wasm files itself, that's why messaging is used to exchange data with the background script.

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
// displayscript.js

for (child of document.body.children) {
    if (child.classList.contains("moz-text-flowed") || child.classList.contains("moz-text-plain")) {
        let textcontent_elem = child;
        let textcontent = child.textContent;
        break
    }
}

let before_lines = [];
let after_lines = [];
let cipher_lines = [];
let cipher_lines_active = false;
let is_before = true;

for (line of textcontent.split("\n")) {
    [... parsing logic ...]
}

// Use messaging to communicate with the background script to have access to wasm
let decryption_promise = browser.runtime.sendMessage({
    command: "decrypt",
    ciphertext: cipher_lines.join("\n")
});

decryption_promise.then(value => {
    let html = before_lines.join("<br>\n") +
        "<br>\n" + "~~~ decrypted block start ~~~ <br>\n" +
        value.replaceAll("\n", "<br>\n") +
        "<br>\n" + "~~~ decrypted block end ~~~ <br>\n" +
        after_lines.join("<br>\n");
    textcontent_elem.innerHTML = html
}, reason => {
    console.log("Error in decryption_promise: ", reason);
});

The manifest must request permissions for messagesRead and messagesModify to allow loading a messageDisplayScript inside the viewer window. The foreground script should be saved as e.g. "displayscript.js" and must be activated from the background script:

1
2
3
4
5
6
// background.js
messenger.messageDisplayScripts.register({
    js: [{
        file: "displayscript.js"
    }]
});

Also add the message handler for decryption to the background script:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
// background.js
messenger.runtime.onMessage.addListener(async (message, sender, sendResponse) => {
    if (message && message.hasOwnProperty("command")) {
        switch (message.command) {
            case "decrypt":
                let keys = await getLocalKeys();  // returns an array of strings
                let cleartext = window.acusDecryptText({
                    ciphertext: message.ciphertext,
                    privateKey: keys[0]
                });
                return cleartext.cleartext;
        }
    }
});

To set up your own secret key to be able to decrypt messages, you can for example use an input form on the options page. The current implementation does this.

If everything works, try to open a plain text email with an age ciphertext text block. The text will automatically be parsed, decrypted and the ciphertext replaced with clear text 🎉

What about Firefox?

It is also important to acknowledge that this technique also works in Firefox, as Thunderbird more or less inherited the WebExtension API from the Firefox browser. Nevertheless, the funtionality of encryption (and probably decryption) inside the web browser follows another workflow. We will explore this use case later.