Client-Side Secrets with Web Crypto

Reading about Excalidraw’s end-to-end encryption a while back piqued my interest: Given my TiddlyWiki background, the idea of using a server to exchange sensitive data without exposing details to anyone else seemed compelling.

In fact, at the time I was working on an experiment facing that very issue: Providing a way for users to exchange notes which only they could access. Unlike Excalidraw, I needed secrets that humans can easily exchange and remember (a conscious security trade-off), so I opted for user-generated passphrases instead of auto-generated keys.

Nowadays we can rely on Web Crypto being well supported, so let’s work with that. (Previously one might have used something like SJCL to similar effect.)


Please note that I’m far from being a cryptography expert. Though I had this reviewed by more knowledgeable folks, I’m not entirely confident I got everything right. Plus the inherent security trade-offs here are not suitable in every scenario.

We start out writing a function to encrypt text content using a password:

let CRYTPO = window.crypto.subtle;
let ALGO = "AES-GCM";
let SALT = str2bytes("94211a24-0e7e-4a6a-ae17-6bdb5d25ce4b").buffer;

export async function encrypt(txt, password) {
    let key = await deriveKey(password);
    let iv = window.crypto.getRandomValues(new Uint8Array(16));
    let encrypted = await CRYTPO.encrypt({ name: ALGO, iv }, key,
    return [iv, new Uint8Array(encrypted)].
        map(block => btoa(block.join(","))).

async function deriveKey(password) {
    let secret = await CRYTPO.importKey("raw", str2bytes(password),
            "PBKDF2", false, ["deriveBits", "deriveKey"]);
    return await CRYTPO.deriveKey({
        name: "PBKDF2",
        salt: SALT,
        iterations: 100000,
        hash: "SHA-256"
    }, secret, { name: ALGO, length: 256 }, true, ["encrypt", "decrypt"]);

function str2bytes(txt) {
    return new TextEncoder().encode(txt);

The encryption key is derived from the respective password, which constitutes our shared secret. Note that we’ve settled on a particular algorithm and a hard-coded (and thus public) salt value – see conscious security trade-offs; you really shouldn’t use this without understanding the consequences.

Encrypted content is paired with its unique IV so we can transmit it as a self-contained string. Decrypting that only requires the password, so let’s add the corresponding function:

export async function decrypt(txt, password) {
    let key = await deriveKey(password);
    let [iv, encrypted] = txt.split("|").
        map(block => Uint8Array.from(atob(block).split(","), str2int));
    let decrypted = await CRYTPO.decrypt({ name: ALGO, iv }, key, encrypted);
    return new window.TextDecoder().decode(new Uint8Array(decrypted));

function str2int(txt) {
    return parseInt(txt, 10);

These little wrappers around native functionality are all we need to create a simplistic demo:

That same code might also be used in a simple command-line script.