app

Local-first trade for farms and co-ops
git clone https://radroots.dev/git/app.git
Log | Files | Refs | README | LICENSE

commit 3c47d34904c41d896c40623f44a3e7ad64e19474
parent b9b7f9d7c46a88a1bf84bcaaf3587fadf412c951
Author: triesap <triesap@radroots.dev>
Date:   Mon, 19 Jan 2026 01:09:02 +0000

app-core: add crypto kdf helpers

- define kdf iteration defaults and salt generation
- add wasm-only PBKDF2 deriveKey implementation
- expose kdf helpers from crypto module
- add unit tests for kdf defaults and salt length

Diffstat:
Acrates/core/src/crypto/kdf.rs | 145+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Mcrates/core/src/crypto/mod.rs | 4++++
2 files changed, 149 insertions(+), 0 deletions(-)

diff --git a/crates/core/src/crypto/kdf.rs b/crates/core/src/crypto/kdf.rs @@ -0,0 +1,145 @@ +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 DEFAULT_KDF_ITERATIONS: u32 = 210_000; +#[cfg(target_arch = "wasm32")] +const KDF_HASH: &str = "SHA-256"; + +pub fn crypto_kdf_iterations_default() -> u32 { + DEFAULT_KDF_ITERATIONS +} + +pub fn crypto_kdf_salt_create(length: usize) -> Result<Vec<u8>, RadrootsClientCryptoError> { + let mut salt = vec![0u8; length]; + fill_random(&mut salt)?; + Ok(salt) +} + +#[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 derive_key_usages() -> Array { + let usages = Array::new(); + usages.push(&"deriveKey".into()); + usages +} + +#[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 pbkdf2_params( + salt: &[u8], + iterations: u32, +) -> Result<Object, RadrootsClientCryptoError> { + let params = Object::new(); + Reflect::set(&params, &"name".into(), &"PBKDF2".into()) + .map_err(|_| RadrootsClientCryptoError::CryptoUndefined)?; + let salt_array = Uint8Array::from(salt); + Reflect::set(&params, &"salt".into(), &salt_array.into()) + .map_err(|_| RadrootsClientCryptoError::CryptoUndefined)?; + Reflect::set( + &params, + &"iterations".into(), + &wasm_bindgen::JsValue::from_f64(iterations as f64), + ) + .map_err(|_| RadrootsClientCryptoError::CryptoUndefined)?; + Reflect::set(&params, &"hash".into(), &KDF_HASH.into()) + .map_err(|_| RadrootsClientCryptoError::CryptoUndefined)?; + Ok(params) +} + +#[cfg(target_arch = "wasm32")] +fn aes_gcm_algorithm() -> 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(256.0), + ) + .map_err(|_| RadrootsClientCryptoError::CryptoUndefined)?; + Ok(algo) +} + +#[cfg(target_arch = "wasm32")] +pub async fn crypto_kdf_derive_kek( + material: &[u8], + salt: &[u8], + iterations: u32, +) -> Result<CryptoKey, RadrootsClientCryptoError> { + let subtle = subtle_crypto()?; + let key_data = Uint8Array::from(material); + let key_data_obj = key_data.unchecked_ref::<Object>(); + let base_promise = subtle + .import_key_with_str( + "raw", + key_data_obj, + "PBKDF2", + false, + &derive_key_usages().into(), + ) + .map_err(|_| RadrootsClientCryptoError::KdfFailure)?; + let base_value = JsFuture::from(base_promise) + .await + .map_err(|_| RadrootsClientCryptoError::KdfFailure)?; + let base_key = base_value + .dyn_into::<CryptoKey>() + .map_err(|_| RadrootsClientCryptoError::KdfFailure)?; + + let pbkdf2 = pbkdf2_params(salt, iterations).map_err(|_| RadrootsClientCryptoError::KdfFailure)?; + let aes_gcm = aes_gcm_algorithm().map_err(|_| RadrootsClientCryptoError::KdfFailure)?; + let promise = subtle + .derive_key_with_object_and_object( + &pbkdf2, + &base_key, + &aes_gcm, + false, + &encrypt_decrypt_usages().into(), + ) + .map_err(|_| RadrootsClientCryptoError::KdfFailure)?; + let value = JsFuture::from(promise) + .await + .map_err(|_| RadrootsClientCryptoError::KdfFailure)?; + value + .dyn_into::<CryptoKey>() + .map_err(|_| RadrootsClientCryptoError::KdfFailure) +} + +#[cfg(test)] +mod tests { + use super::{crypto_kdf_iterations_default, crypto_kdf_salt_create}; + + #[test] + fn kdf_defaults_match_spec() { + assert_eq!(crypto_kdf_iterations_default(), 210_000); + } + + #[test] + fn kdf_salt_length_matches_request() { + let salt = crypto_kdf_salt_create(16).expect("salt"); + assert_eq!(salt.len(), 16); + } +} diff --git a/crates/core/src/crypto/mod.rs b/crates/core/src/crypto/mod.rs @@ -3,6 +3,7 @@ pub mod types; pub mod envelope; pub mod random; pub mod keys; +pub mod kdf; pub use error::{RadrootsClientCryptoError, RadrootsClientCryptoErrorMessage}; pub use types::{ @@ -20,6 +21,7 @@ pub use types::{ }; pub use envelope::{crypto_envelope_decode, crypto_envelope_encode}; pub use keys::crypto_key_id_create; +pub use kdf::{crypto_kdf_iterations_default, crypto_kdf_salt_create}; #[cfg(target_arch = "wasm32")] pub use keys::{ crypto_key_export_raw, @@ -28,3 +30,5 @@ pub use keys::{ crypto_key_unwrap, crypto_key_wrap, }; +#[cfg(target_arch = "wasm32")] +pub use kdf::crypto_kdf_derive_kek;