commit b9b7f9d7c46a88a1bf84bcaaf3587fadf412c951
parent 5b00d310759668f3477467a5b438c316300db9e2
Author: triesap <triesap@radroots.dev>
Date: Mon, 19 Jan 2026 01:07:13 +0000
app-core: add wasm crypto key helpers
- add wasm-only helpers for generating and importing keys
- implement AES-GCM wrap/unwrap via WebCrypto APIs
- export wasm crypto key helpers from crypto module
- add wasm dependencies needed for subtle crypto access
Diffstat:
5 files changed, 163 insertions(+), 2 deletions(-)
diff --git a/Cargo.lock b/Cargo.lock
@@ -1165,6 +1165,7 @@ dependencies = [
"js-sys",
"serde",
"serde_json",
+ "wasm-bindgen-futures",
"web-sys",
]
diff --git a/Cargo.toml b/Cargo.toml
@@ -19,7 +19,8 @@ serde = { version = "1", features = ["derive"] }
serde_json = "1"
getrandom = "0.2"
js-sys = "0.3.77"
-web-sys = { version = "0.3.77", features = ["Window", "Crypto"] }
+web-sys = { version = "0.3.77", features = ["Crypto", "CryptoKey", "SubtleCrypto", "Window"] }
+wasm-bindgen-futures = "0.4"
[profile.release]
codegen-units = 1
diff --git a/crates/core/Cargo.toml b/crates/core/Cargo.toml
@@ -18,3 +18,4 @@ getrandom = { workspace = true }
[target.'cfg(target_arch = "wasm32")'.dependencies]
js-sys = { workspace = true }
web-sys = { workspace = true }
+wasm-bindgen-futures = { workspace = true }
diff --git a/crates/core/src/crypto/keys.rs b/crates/core/src/crypto/keys.rs
@@ -1,7 +1,18 @@
-use super::{RadrootsClientCryptoError};
+use super::RadrootsClientCryptoError;
use crate::crypto::random::fill_random;
+#[cfg(target_arch = "wasm32")]
+use js_sys::{Array, Object, Reflect, Uint8Array};
+#[cfg(target_arch = "wasm32")]
+use wasm_bindgen::JsCast;
+#[cfg(target_arch = "wasm32")]
+use wasm_bindgen_futures::JsFuture;
+#[cfg(target_arch = "wasm32")]
+use web_sys::{CryptoKey, SubtleCrypto};
+
const KEY_ID_BYTES_LENGTH: usize = 16;
+#[cfg(target_arch = "wasm32")]
+const WRAP_IV_LENGTH: usize = 12;
fn bytes_to_hex(bytes: &[u8]) -> String {
let mut out = String::with_capacity(bytes.len() * 2);
@@ -18,6 +29,145 @@ pub fn crypto_key_id_create() -> Result<String, RadrootsClientCryptoError> {
Ok(bytes_to_hex(&bytes))
}
+#[cfg(target_arch = "wasm32")]
+fn subtle_crypto() -> Result<SubtleCrypto, RadrootsClientCryptoError> {
+ let window = web_sys::window().ok_or(RadrootsClientCryptoError::CryptoUndefined)?;
+ let crypto = window
+ .crypto()
+ .map_err(|_| RadrootsClientCryptoError::CryptoUndefined)?;
+ Ok(crypto.subtle())
+}
+
+#[cfg(target_arch = "wasm32")]
+fn encrypt_decrypt_usages() -> Array {
+ let usages = Array::new();
+ usages.push(&"encrypt".into());
+ usages.push(&"decrypt".into());
+ usages
+}
+
+#[cfg(target_arch = "wasm32")]
+fn aes_gcm_algorithm(length: u32) -> Result<Object, RadrootsClientCryptoError> {
+ let algo = Object::new();
+ Reflect::set(&algo, &"name".into(), &"AES-GCM".into())
+ .map_err(|_| RadrootsClientCryptoError::CryptoUndefined)?;
+ Reflect::set(
+ &algo,
+ &"length".into(),
+ &wasm_bindgen::JsValue::from_f64(length as f64),
+ )
+ .map_err(|_| RadrootsClientCryptoError::CryptoUndefined)?;
+ Ok(algo)
+}
+
+#[cfg(target_arch = "wasm32")]
+fn aes_gcm_params(iv: &[u8]) -> Result<Object, RadrootsClientCryptoError> {
+ let algo = Object::new();
+ Reflect::set(&algo, &"name".into(), &"AES-GCM".into())
+ .map_err(|_| RadrootsClientCryptoError::CryptoUndefined)?;
+ let iv_array = Uint8Array::from(iv);
+ Reflect::set(&algo, &"iv".into(), &iv_array.into())
+ .map_err(|_| RadrootsClientCryptoError::CryptoUndefined)?;
+ Ok(algo)
+}
+
+#[cfg(target_arch = "wasm32")]
+pub async fn crypto_key_generate() -> Result<CryptoKey, RadrootsClientCryptoError> {
+ let subtle = subtle_crypto()?;
+ let algo = aes_gcm_algorithm(256)?;
+ let usages = encrypt_decrypt_usages();
+ let promise = subtle
+ .generate_key_with_object(&algo, true, &usages.into())
+ .map_err(|_| RadrootsClientCryptoError::CryptoUndefined)?;
+ let value = JsFuture::from(promise)
+ .await
+ .map_err(|_| RadrootsClientCryptoError::CryptoUndefined)?;
+ value
+ .dyn_into::<CryptoKey>()
+ .map_err(|_| RadrootsClientCryptoError::CryptoUndefined)
+}
+
+#[cfg(target_arch = "wasm32")]
+pub async fn crypto_key_export_raw(
+ key: &CryptoKey,
+) -> Result<Vec<u8>, RadrootsClientCryptoError> {
+ let subtle = subtle_crypto()?;
+ let promise = subtle
+ .export_key("raw", key)
+ .map_err(|_| RadrootsClientCryptoError::CryptoUndefined)?;
+ let value = JsFuture::from(promise)
+ .await
+ .map_err(|_| RadrootsClientCryptoError::CryptoUndefined)?;
+ let array = Uint8Array::new(&value);
+ let mut out = vec![0u8; array.length() as usize];
+ array.copy_to(&mut out);
+ Ok(out)
+}
+
+#[cfg(target_arch = "wasm32")]
+pub async fn crypto_key_import_raw(
+ raw: &[u8],
+) -> Result<CryptoKey, RadrootsClientCryptoError> {
+ let subtle = subtle_crypto()?;
+ let algo = aes_gcm_algorithm(256)?;
+ let usages = encrypt_decrypt_usages();
+ let data = Uint8Array::from(raw);
+ let data_obj = data.unchecked_ref::<Object>();
+ let promise = subtle
+ .import_key_with_object("raw", data_obj, &algo, false, &usages.into())
+ .map_err(|_| RadrootsClientCryptoError::CryptoUndefined)?;
+ let value = JsFuture::from(promise)
+ .await
+ .map_err(|_| RadrootsClientCryptoError::CryptoUndefined)?;
+ value
+ .dyn_into::<CryptoKey>()
+ .map_err(|_| RadrootsClientCryptoError::CryptoUndefined)
+}
+
+#[cfg(target_arch = "wasm32")]
+pub async fn crypto_key_wrap(
+ kek: &CryptoKey,
+ raw_key: &mut [u8],
+) -> Result<(Vec<u8>, Vec<u8>), RadrootsClientCryptoError> {
+ let subtle = subtle_crypto().map_err(|_| RadrootsClientCryptoError::WrapFailure)?;
+ let mut wrap_iv = vec![0u8; WRAP_IV_LENGTH];
+ fill_random(&mut wrap_iv).map_err(|_| RadrootsClientCryptoError::WrapFailure)?;
+ let algo = aes_gcm_params(&wrap_iv).map_err(|_| RadrootsClientCryptoError::WrapFailure)?;
+ let promise = subtle
+ .encrypt_with_object_and_u8_array(&algo, kek, raw_key)
+ .map_err(|_| RadrootsClientCryptoError::WrapFailure)?;
+ let value = JsFuture::from(promise)
+ .await
+ .map_err(|_| RadrootsClientCryptoError::WrapFailure)?;
+ let array = Uint8Array::new(&value);
+ let mut wrapped = vec![0u8; array.length() as usize];
+ array.copy_to(&mut wrapped);
+ raw_key.fill(0);
+ Ok((wrapped, wrap_iv))
+}
+
+#[cfg(target_arch = "wasm32")]
+pub async fn crypto_key_unwrap(
+ kek: &CryptoKey,
+ wrapped_key: &[u8],
+ wrap_iv: &[u8],
+) -> Result<CryptoKey, RadrootsClientCryptoError> {
+ let subtle = subtle_crypto().map_err(|_| RadrootsClientCryptoError::UnwrapFailure)?;
+ let algo = aes_gcm_params(wrap_iv).map_err(|_| RadrootsClientCryptoError::UnwrapFailure)?;
+ let promise = subtle
+ .decrypt_with_object_and_u8_array(&algo, kek, wrapped_key)
+ .map_err(|_| RadrootsClientCryptoError::UnwrapFailure)?;
+ let value = JsFuture::from(promise)
+ .await
+ .map_err(|_| RadrootsClientCryptoError::UnwrapFailure)?;
+ let array = Uint8Array::new(&value);
+ let mut raw = vec![0u8; array.length() as usize];
+ array.copy_to(&mut raw);
+ crypto_key_import_raw(&raw)
+ .await
+ .map_err(|_| RadrootsClientCryptoError::UnwrapFailure)
+}
+
#[cfg(test)]
mod tests {
use super::crypto_key_id_create;
diff --git a/crates/core/src/crypto/mod.rs b/crates/core/src/crypto/mod.rs
@@ -20,3 +20,11 @@ pub use types::{
};
pub use envelope::{crypto_envelope_decode, crypto_envelope_encode};
pub use keys::crypto_key_id_create;
+#[cfg(target_arch = "wasm32")]
+pub use keys::{
+ crypto_key_export_raw,
+ crypto_key_generate,
+ crypto_key_import_raw,
+ crypto_key_unwrap,
+ crypto_key_wrap,
+};