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.)

NB:

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 CRYPTO = globalThis.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 = globalThis.crypto.getRandomValues(new Uint8Array(16));
    let encrypted = await CRYPTO.encrypt({ name: ALGO, iv }, key,
            str2bytes(txt));
    return [iv, new Uint8Array(encrypted)].
        map(block => btoa(block.join(","))).
        join("|");
}

async function deriveKey(password) {
    let secret = await CRYPTO.importKey("raw", str2bytes(password),
            "PBKDF2", false, ["deriveBits", "deriveKey"]);
    return CRYPTO.deriveKey({
        name: "PBKDF2",
        salt: SALT,
        iterations: 2 ** 20,
        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 CRYPTO.decrypt({ name: ALGO, iv }, key, encrypted);
    return new 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.