HUIJZER.XYZ

Encrypting and decrypting a secret with wasm_bindgen

2024-02-23

Doing a round trip of encrypting and decrypting a secret should be pretty easy, right? Well, it turned out to be a bit more involved than I thought. But, in the end it worked here is the code for anyone who wants to do the same.

I'll be going through the functions step by step first. The full example with imports is shown at the end.

First, we need to generate a key. Here, I've set extractable to false. This aims to prevent the key from being read by other scripts.

fn crypto() -> web_sys::Crypto {
    let window = web_sys::window().expect("no global `window` exists");
    window.crypto().expect("no global `crypto` exists")
}

pub fn generate_key() -> Promise {
    let sc = crypto().subtle();
    // Symmetric encryption is used, so the same key is used for both operations.
    // GCM has good performance and security according to Wikipedia.
    let algo = AesKeyGenParams::new("AES-GCM", 256);
    let extractable = false;
    let usages = js_array(&["encrypt", "decrypt"]);
    sc.generate_key_with_object(
        &algo,
        extractable,
        &usages
    ).expect("failed to generate key")
}

Note here that we do not use generate_key_with_str. This is a tip from Renato Athaydes in a GitHub comment. It is possible to use the str version, but using the object version allows for more checking by the Rust compiler. When passing wrong information to the API, the browser will give quite unhelpful errors such as "an invalid or illegal string was specified" or "the operation failed for an operation-specific reason".

Next, this is how a secret can be encrypted:

pub fn encrypt(key: &CryptoKey, data: &[u8]) -> (Uint32Array, Promise) {
    let sc = crypto().subtle();
    // Use different IV for every encryption operation according to AesGcmParams docs.
    // IV doesn't have to be secret, so can be sent with the encrypted data according to docs.
    #[allow(unused_mut)]
    let mut iv = Uint32Array::new_with_length(12);
    // To verify that the IV is truly overwritten.
    // log(&format!("iv: {:?}", iv.to_vec()));
    crypto().get_random_values_with_array_buffer_view(&iv).unwrap();
    // log(&format!("iv: {:?}", iv.to_vec()));
    let algo = AesGcmParams::new(
        "AES-GCM",
        &iv
    );
    let encrypted = sc.encrypt_with_object_and_u8_array(
        &algo,
        key,
        data
    ).expect("failed to encrypt");
    (iv, encrypted)
}

Here, I used get_random_values_with_array_buffer_view instead of get_random_values_with_u8_array. This is because the former is more explicit in the type of the IV, which makes it easier to pass it along with the encrypted data and use it in the decrypt function.

Finally, this is how a secret can be decrypted:

pub fn decrypt(key: &CryptoKey, iv: &Object, data: &[u8]) -> Promise {
    let sc = crypto().subtle();
    let algo = AesGcmParams::new(
        "AES-GCM",
        iv
    );
    sc.decrypt_with_object_and_u8_array(
        &algo,
        key,
        data
    ).expect("failed to decrypt")
}

Putting it all together and adding a test function, we get:

use console_error_panic_hook::hook;
use wasm_bindgen::prelude::*;
use wasm_bindgen_futures::JsFuture;
use web_sys::AesGcmParams;
use web_sys::AesKeyGenParams;
use web_sys::CryptoKey;
use web_sys::js_sys::Object;
use web_sys::js_sys::Promise;
use web_sys::js_sys::Uint32Array;
use web_sys::js_sys;

#[wasm_bindgen]
extern "C" {
    #[wasm_bindgen(js_namespace = console)]
    pub fn log(s: &str);
}

fn crypto() -> web_sys::Crypto {
    let window = web_sys::window().expect("no global `window` exists");
    window.crypto().expect("no global `crypto` exists")
}

fn js_array(values: &[&str]) -> JsValue {
    return JsValue::from(values.iter()
        .map(|x| JsValue::from_str(x))
        .collect::<js_sys::Array>());
}

trait AsByteSlice {
    fn as_u8_slice(&self) -> Result<Vec<u8>, JsValue>;
}

impl AsByteSlice for JsValue {
    fn as_u8_slice(&self) -> Result<Vec<u8>, JsValue> {
        let buffer = self.clone().dyn_into::<js_sys::ArrayBuffer>()
            .map_err(|_| JsValue::from_str("Expected ArrayBuffer"))?;

        let uint8_array = js_sys::Uint8Array::new(&buffer);

        let mut bytes = vec![0; uint8_array.length() as usize];
        uint8_array.copy_to(&mut bytes);
        Ok(bytes)
    }
}

pub fn generate_key() -> Promise {
    let sc = crypto().subtle();
    // Symmetric encryption is used, so the same key is used for both operations.
    // GCM has good performance and security according to Wikipedia.
    let algo = AesKeyGenParams::new("AES-GCM", 256);
    let extractable = false;
    let usages = js_array(&["encrypt", "decrypt"]);
    sc.generate_key_with_object(
        &algo,
        extractable,
        &usages
    ).expect("failed to generate key")
}

pub fn encrypt(key: &CryptoKey, data: &[u8]) -> (Uint32Array, Promise) {
    let sc = crypto().subtle();
    // Use different IV for every encryption operation according to AesGcmParams docs.
    // IV doesn't have to be secret, so can be sent with the encrypted data according to docs.
    #[allow(unused_mut)]
    let mut iv = Uint32Array::new_with_length(12);
    // To verify that the IV is truly overwritten.
    // log(&format!("iv: {:?}", iv.to_vec()));
    crypto().get_random_values_with_array_buffer_view(&iv).unwrap();
    // log(&format!("iv: {:?}", iv.to_vec()));
    let algo = AesGcmParams::new(
        "AES-GCM",
        &iv
    );
    let encrypted = sc.encrypt_with_object_and_u8_array(
        &algo,
        key,
        data
    ).expect("failed to encrypt");
    (iv, encrypted)
}

pub fn decrypt(key: &CryptoKey, iv: &Object, data: &[u8]) -> Promise {
    let sc = crypto().subtle();
    let algo = AesGcmParams::new(
        "AES-GCM",
        iv
    );
    sc.decrypt_with_object_and_u8_array(
        &algo,
        key,
        data
    ).expect("failed to decrypt")
}

async fn test_crypto() -> Result<(), JsValue> {
    let key: Promise = generate_key();
    let key: JsValue = JsFuture::from(key).await?;
    let key: CryptoKey = key.into();
    let text = "some secret text";
    let (iv, encrypted) = encrypt(&key, text.as_bytes());
    let encrypted: JsValue = JsFuture::from(encrypted).await?;

    let data = encrypted.as_u8_slice().unwrap();
    let decrypted: Promise = decrypt(&key, &iv, &data);
    let decrypted: JsValue = JsFuture::from(decrypted).await?;
    let decrypted = decrypted.as_u8_slice().unwrap();
    let decrypted = String::from_utf8(decrypted).unwrap();
    log(&format!("decrypted: {decrypted:?}"));

    Ok(())
}

#[wasm_bindgen(start)]
pub async fn start() -> Result<(), JsValue> {
    panic::set_hook(Box::new(hook));

    test_crypto().await?;
    Ok(())
}

with the following dependencies in Cargo.toml and, in my case, Rust version 1.76:

[dependencies]
console_error_panic_hook = "0.1.7"
url = "2.5.0"
wasm-bindgen = "0.2.91"
wasm-bindgen-futures = "0.4.41"

[dependencies.web-sys]
version = "0.3.68"
features = [
    'AesGcmParams',
    'AesKeyGenParams',
    'Crypto',
    'CryptoKey',
    'SubtleCrypto',
    'Window'
]

In this test_crypto function, the secret text "some secret text" is encrypted and then decrypted. The decrypted text is then logged to the console.

Running this code in a web browser, the logs show the correct result:

decrypted: "some secret text"