diff --git a/crates/relicario-wasm/Cargo.toml b/crates/relicario-wasm/Cargo.toml index 0cf6eb9..2f04cdc 100644 --- a/crates/relicario-wasm/Cargo.toml +++ b/crates/relicario-wasm/Cargo.toml @@ -10,11 +10,10 @@ crate-type = ["cdylib", "rlib"] [dependencies] relicario-core = { path = "../relicario-core" } wasm-bindgen = "0.2" -js-sys = "0.3" +serde-wasm-bindgen = "0.6" serde_json = "1" -hmac = "0.12" -sha1 = "0.10" -data-encoding = "2" +serde = { version = "1", features = ["derive"] } +zeroize = "1" getrandom = { version = "0.2", features = ["js"] } [dev-dependencies] diff --git a/crates/relicario-wasm/src/lib.rs b/crates/relicario-wasm/src/lib.rs index 8a6b767..51539b9 100644 --- a/crates/relicario-wasm/src/lib.rs +++ b/crates/relicario-wasm/src/lib.rs @@ -1,364 +1,73 @@ -//! WASM bindings for the relicario password manager. +//! WASM bindings for relicario. //! -//! This crate wraps [`relicario_core`] for use in a Chrome MV3 browser extension via -//! `wasm-bindgen`. Every function marked `#[wasm_bindgen]` is callable from -//! JavaScript after loading the compiled `.wasm` module. -//! -//! All crypto operations run entirely in the browser -- the extension never sends -//! secrets to any server. The TOTP function lets the extension generate live 6-digit -//! authenticator codes without a separate authenticator app. -//! -//! ## Design notes -//! -//! - Functions accept and return `Vec`, `&[u8]`, and `String` -- wasm-bindgen -//! handles the JS ↔ Rust marshalling automatically (typed arrays for bytes, strings -//! for JSON). -//! - Errors are mapped to `JsValue` strings so they surface as thrown exceptions in JS. -//! - `generate_password` and `generate_entry_id` use `js_sys::Math::random()` because -//! `OsRng`/`getrandom` requires special WASM configuration. `Math.random()` is -//! sufficient for these non-security-critical operations (password character selection -//! and identifier generation). +//! The bridge exposes an opaque `SessionHandle` API: the master key is held +//! entirely in WASM linear memory, wrapped in `Zeroizing<[u8; 32]>`, and +//! looked up per call via a u32 handle. JS cannot read key bytes. + +mod session; use wasm_bindgen::prelude::*; -use relicario_core::crypto::{self, KdfParams}; -use relicario_core::entry::Entry; -use relicario_core::vault; -use relicario_core::imgsecret; +use relicario_core::{derive_master_key, imgsecret, KdfParams}; -use hmac::{Hmac, Mac}; -use sha1::Sha1; - -/// Derive a 256-bit master key from a passphrase, image secret, salt, and KDF parameters. -/// -/// The `params_json` argument is a JSON object with fields `argon2_m`, `argon2_t`, -/// and `argon2_p` (matching [`KdfParams`]). Example: -/// -/// ```json -/// {"argon2_m": 65536, "argon2_t": 3, "argon2_p": 4} -/// ``` -/// -/// Returns a 32-byte `Uint8Array` in JavaScript. +/// Handle type returned from `unlock`. Backed by a `u32`; opaque to JS. #[wasm_bindgen] -pub fn derive_master_key( +pub struct SessionHandle(u32); + +#[wasm_bindgen] +impl SessionHandle { + #[wasm_bindgen(getter)] + pub fn value(&self) -> u32 { self.0 } +} + +#[wasm_bindgen] +pub fn unlock( passphrase: &str, - image_secret: &[u8], + image_bytes: &[u8], salt: &[u8], params_json: &str, -) -> Result, JsValue> { - let params: KdfParams = - serde_json::from_str(params_json).map_err(|e| JsValue::from_str(&e.to_string()))?; - - let image_secret: &[u8; 32] = image_secret - .try_into() - .map_err(|_| JsValue::from_str("image_secret must be exactly 32 bytes"))?; - let salt: &[u8; 32] = salt - .try_into() - .map_err(|_| JsValue::from_str("salt must be exactly 32 bytes"))?; - - let key = crypto::derive_master_key(passphrase.as_bytes(), image_secret, salt, ¶ms) - .map_err(|e| JsValue::from_str(&e.to_string()))?; - - Ok(key.to_vec()) +) -> Result { + let params: KdfParams = serde_json::from_str(params_json) + .map_err(|e| JsError::new(&format!("params: {e}")))?; + let image_secret = imgsecret::extract(image_bytes) + .map_err(|e| JsError::new(&e.to_string()))?; + let salt_arr: &[u8; 32] = salt.try_into() + .map_err(|_| JsError::new("salt must be exactly 32 bytes"))?; + let master_key = derive_master_key(passphrase.as_bytes(), &image_secret, salt_arr, ¶ms) + .map_err(|e| JsError::new(&e.to_string()))?; + let handle = session::insert(master_key); + Ok(SessionHandle(handle)) } -/// Encrypt arbitrary plaintext bytes under a 256-bit key using XChaCha20-Poly1305. -/// -/// Returns the ciphertext as a `Uint8Array` in the format: -/// `version(1) || nonce(24) || ciphertext+tag`. #[wasm_bindgen] -pub fn encrypt(plaintext: &[u8], key: &[u8]) -> Result, JsValue> { - let key: &[u8; 32] = key - .try_into() - .map_err(|_| JsValue::from_str("key must be exactly 32 bytes"))?; - crypto::encrypt(key, plaintext).map_err(|e| JsValue::from_str(&e.to_string())) +pub fn lock(handle: &SessionHandle) -> bool { + session::remove(handle.0) } -/// Decrypt a ciphertext blob produced by [`encrypt`], returning the original plaintext. -/// -/// Returns the plaintext as a `Uint8Array`. Throws if the key is wrong or the data -/// has been tampered with. -#[wasm_bindgen] -pub fn decrypt(ciphertext: &[u8], key: &[u8]) -> Result, JsValue> { - let key: &[u8; 32] = key - .try_into() - .map_err(|_| JsValue::from_str("key must be exactly 32 bytes"))?; - crypto::decrypt(key, ciphertext).map_err(|e| JsValue::from_str(&e.to_string())) -} - -/// Extract the 32-byte steganographic secret from a JPEG image. -/// -/// Returns a 32-byte `Uint8Array` containing the embedded secret. -/// Throws if the image is not a valid JPEG or the secret cannot be recovered. -#[wasm_bindgen] -pub fn extract_image_secret(jpeg_bytes: &[u8]) -> Result, JsValue> { - let secret = - imgsecret::extract(jpeg_bytes).map_err(|e| JsValue::from_str(&e.to_string()))?; - Ok(secret.to_vec()) -} - -/// Embed a 256-bit secret into a carrier JPEG image. -#[wasm_bindgen] -pub fn embed_image_secret(carrier_jpeg: &[u8], secret: &[u8]) -> Result, JsValue> { - let secret: [u8; 32] = secret - .try_into() - .map_err(|_| JsValue::from_str("secret must be exactly 32 bytes"))?; - relicario_core::imgsecret::embed(carrier_jpeg, &secret) - .map_err(|e| JsValue::from_str(&e.to_string())) -} - -/// Encrypt an [`Entry`] (given as a JSON string) under the master key. -/// -/// The `entry_json` must deserialize into an [`Entry`] struct. Returns the -/// ciphertext as a `Uint8Array`. -#[wasm_bindgen] -pub fn encrypt_entry(entry_json: &str, key: &[u8]) -> Result, JsValue> { - let key: &[u8; 32] = key - .try_into() - .map_err(|_| JsValue::from_str("key must be exactly 32 bytes"))?; - let entry: Entry = - serde_json::from_str(entry_json).map_err(|e| JsValue::from_str(&e.to_string()))?; - vault::encrypt_entry(key, &entry).map_err(|e| JsValue::from_str(&e.to_string())) -} - -/// Decrypt an entry ciphertext blob and return the entry as a JSON string. -/// -/// Throws if the key is wrong, the data is tampered, or the decrypted JSON is malformed. -#[wasm_bindgen] -pub fn decrypt_entry(ciphertext: &[u8], key: &[u8]) -> Result { - let key: &[u8; 32] = key - .try_into() - .map_err(|_| JsValue::from_str("key must be exactly 32 bytes"))?; - let entry = - vault::decrypt_entry(key, ciphertext).map_err(|e| JsValue::from_str(&e.to_string()))?; - serde_json::to_string(&entry).map_err(|e| JsValue::from_str(&e.to_string())) -} - -/// Encrypt a [`Manifest`] (given as a JSON string) under the master key. -/// -/// Returns the ciphertext as a `Uint8Array`. -#[wasm_bindgen] -pub fn encrypt_manifest(manifest_json: &str, key: &[u8]) -> Result, JsValue> { - let key: &[u8; 32] = key - .try_into() - .map_err(|_| JsValue::from_str("key must be exactly 32 bytes"))?; - let manifest: relicario_core::entry::Manifest = - serde_json::from_str(manifest_json).map_err(|e| JsValue::from_str(&e.to_string()))?; - vault::encrypt_manifest(key, &manifest).map_err(|e| JsValue::from_str(&e.to_string())) -} - -/// Decrypt a manifest ciphertext blob and return the manifest as a JSON string. -/// -/// Throws if the key is wrong, the data is tampered, or the decrypted JSON is malformed. -#[wasm_bindgen] -pub fn decrypt_manifest(ciphertext: &[u8], key: &[u8]) -> Result { - let key: &[u8; 32] = key - .try_into() - .map_err(|_| JsValue::from_str("key must be exactly 32 bytes"))?; - let manifest = vault::decrypt_manifest(key, ciphertext) - .map_err(|e| JsValue::from_str(&e.to_string()))?; - serde_json::to_string(&manifest).map_err(|e| JsValue::from_str(&e.to_string())) -} - -/// Generate a 6-digit TOTP code per RFC 6238. -/// -/// # Arguments -/// -/// - `secret_base32`: the shared secret encoded in base32 (with or without padding). -/// - `timestamp_secs`: the current Unix timestamp in seconds. -/// -/// # Algorithm -/// -/// 1. Decode the base32 secret. -/// 2. Compute the time step: `T = timestamp_secs / 30`. -/// 3. Compute `HMAC-SHA1(secret, T as big-endian u64)`. -/// 4. Dynamic truncation: extract a 4-byte segment from the HMAC output at an -/// offset determined by the last nibble. -/// 5. Mask the high bit, take modulo 10^6, and zero-pad to 6 digits. -/// -/// Returns a 6-character string like `"287082"`. -#[wasm_bindgen] -pub fn generate_totp(secret_base32: &str, timestamp_secs: u64) -> Result { - generate_totp_inner(secret_base32, timestamp_secs) - .map_err(|e| JsValue::from_str(&e.to_string())) -} - -/// Inner TOTP implementation that returns a standard Result for testability -/// (avoids depending on JsValue in native tests). -fn generate_totp_inner( - secret_base32: &str, - timestamp_secs: u64, -) -> std::result::Result { - // Normalize: strip whitespace, uppercase, remove padding for lenient decode, - // then re-pad to a multiple of 8 for strict base32. - let cleaned: String = secret_base32 - .chars() - .filter(|c| !c.is_whitespace()) - .collect::() - .to_uppercase() - .trim_end_matches('=') - .to_string(); - - // Re-pad to a multiple of 8 characters (base32 requirement). - let padded = { - let remainder = cleaned.len() % 8; - if remainder == 0 { - cleaned - } else { - let pad_count = 8 - remainder; - format!("{}{}", cleaned, "=".repeat(pad_count)) - } - }; - - let secret = data_encoding::BASE32 - .decode(padded.as_bytes()) - .map_err(|e| format!("invalid base32 secret: {}", e))?; - - // Time step: T = floor(timestamp / 30) - let time_step = timestamp_secs / 30; - - // HMAC-SHA1(secret, time_step as big-endian u64) - type HmacSha1 = Hmac; - let mut mac = - HmacSha1::new_from_slice(&secret).map_err(|e| format!("HMAC init failed: {}", e))?; - mac.update(&time_step.to_be_bytes()); - let result = mac.finalize().into_bytes(); - - // Dynamic truncation per RFC 4226 section 5.4 - let offset = (result[19] & 0x0F) as usize; - let code = ((result[offset] as u32 & 0x7F) << 24) - | ((result[offset + 1] as u32) << 16) - | ((result[offset + 2] as u32) << 8) - | (result[offset + 3] as u32); - - // 6-digit code, zero-padded - Ok(format!("{:06}", code % 1_000_000)) -} - -/// Generate a random password of the given length. -/// -/// Uses `js_sys::Math::random()` for randomness (not cryptographically secure, -/// but sufficient for password character selection). The character set includes -/// uppercase, lowercase, digits, and common symbols. -/// -/// This function is only available in WASM -- it will panic in native builds -/// because `js_sys::Math::random()` requires a JS runtime. -#[wasm_bindgen] -pub fn generate_password(length: u32) -> String { - const CHARSET: &[u8] = b"ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789!@#$%^&*()-_=+[]{}|;:,.<>?"; - - (0..length) - .map(|_| { - let idx = (js_sys::Math::random() * CHARSET.len() as f64) as usize; - CHARSET[idx % CHARSET.len()] as char - }) - .collect() -} - -/// Generate a random 8-character hex string for use as an entry ID. -/// -/// Uses `js_sys::Math::random()` for randomness. Entry IDs are not -/// security-sensitive -- they are just opaque identifiers. -/// -/// This function is only available in WASM -- it will panic in native builds -/// because `js_sys::Math::random()` requires a JS runtime. -#[wasm_bindgen] -pub fn generate_entry_id() -> String { - (0..4) - .map(|_| { - let byte = (js_sys::Math::random() * 256.0) as u8; - format!("{:02x}", byte) - }) - .collect() -} +// Subsequent wasm_bindgen fns added in Tasks 19-21. #[cfg(test)] -mod tests { +mod session_tests { use super::*; + use zeroize::Zeroizing; #[test] - fn totp_rfc6238_test_vector() { - // secret = "12345678901234567890" ASCII, time = 59, expected = "287082" - let secret_b32 = data_encoding::BASE32.encode(b"12345678901234567890"); - let result = generate_totp_inner(&secret_b32, 59).unwrap(); - assert_eq!(result, "287082"); + fn insert_then_remove_clears_entry() { + session::clear(); + let h = session::insert(Zeroizing::new([0x11u8; 32])); + assert_ne!(h, 0); + assert!(session::remove(h)); + assert!(!session::remove(h)); // second remove false } #[test] - fn totp_rfc6238_test_vector_2() { - // time = 1111111109, expected = "081804" - let secret_b32 = data_encoding::BASE32.encode(b"12345678901234567890"); - let result = generate_totp_inner(&secret_b32, 1111111109).unwrap(); - assert_eq!(result, "081804"); - } - - #[test] - fn totp_rfc6238_test_vector_3() { - // time = 1234567890, expected = "005924" - let secret_b32 = data_encoding::BASE32.encode(b"12345678901234567890"); - let result = generate_totp_inner(&secret_b32, 1234567890).unwrap(); - assert_eq!(result, "005924"); - } - - #[test] - fn totp_invalid_base32_fails() { - let result = generate_totp_inner("not-valid-base32!!!", 1000); - assert!(result.is_err()); - } - - #[test] - fn derive_key_via_wasm_wrapper() { - let params = r#"{"argon2_m":256,"argon2_t":1,"argon2_p":1}"#; - let key = - derive_master_key("test-passphrase", &[0x42u8; 32], &[0x01u8; 32], params).unwrap(); - assert_eq!(key.len(), 32); - let key2 = - derive_master_key("test-passphrase", &[0x42u8; 32], &[0x01u8; 32], params).unwrap(); - assert_eq!(key, key2); - } - - #[test] - fn encrypt_decrypt_via_wasm_wrapper() { - let key = [0xABu8; 32]; - let ciphertext = encrypt(b"hello wasm", &key).unwrap(); - let decrypted = decrypt(&ciphertext, &key).unwrap(); - assert_eq!(decrypted, b"hello wasm"); - } - - #[test] - fn embed_then_extract_round_trip() { - use image::codecs::jpeg::JpegEncoder; - use image::{ImageBuffer, ImageEncoder, Rgb}; - - let img = ImageBuffer::from_fn(400, 300, |x, y| { - Rgb([ - ((x * 7 + y * 13) % 256) as u8, - ((x * 11 + y * 3) % 256) as u8, - ((x * 5 + y * 17) % 256) as u8, - ]) - }); - let mut jpeg_buf = Vec::new(); - let encoder = JpegEncoder::new_with_quality(&mut jpeg_buf, 92); - encoder.write_image(img.as_raw(), 400, 300, image::ExtendedColorType::Rgb8).unwrap(); - - let secret = [0xDE, 0xAD, 0xBE, 0xEF, 0x01, 0x02, 0x03, 0x04, - 0x05, 0x06, 0x07, 0x08, 0x09, 0x0A, 0x0B, 0x0C, - 0x0D, 0x0E, 0x0F, 0x10, 0x11, 0x12, 0x13, 0x14, - 0x15, 0x16, 0x17, 0x18, 0x19, 0x1A, 0x1B, 0x1Cu8]; - - let stego = embed_image_secret(&jpeg_buf, &secret).unwrap(); - let extracted = extract_image_secret(&stego).unwrap(); - assert_eq!(extracted, secret); - } - - #[test] - fn encrypt_entry_decrypt_entry_round_trip() { - let key = [0xABu8; 32]; - let entry_json = r#"{"name":"Test","password":"secret","created_at":"2024-01-01T00:00:00Z","updated_at":"2024-01-01T00:00:00Z"}"#; - let ciphertext = encrypt_entry(entry_json, &key).unwrap(); - let result = decrypt_entry(&ciphertext, &key).unwrap(); - let parsed: serde_json::Value = serde_json::from_str(&result).unwrap(); - assert_eq!(parsed["name"], "Test"); - assert_eq!(parsed["password"], "secret"); + fn with_yields_key_only_while_session_lives() { + session::clear(); + let h = session::insert(Zeroizing::new([0x22u8; 32])); + let byte = session::with(h, |k| k[0]); + assert_eq!(byte, Some(0x22)); + session::remove(h); + let byte = session::with(h, |k| k[0]); + assert_eq!(byte, None); } } diff --git a/crates/relicario-wasm/src/session.rs b/crates/relicario-wasm/src/session.rs new file mode 100644 index 0000000..6b553ad --- /dev/null +++ b/crates/relicario-wasm/src/session.rs @@ -0,0 +1,41 @@ +//! Opaque session-handle bridge. The master key never leaves WASM linear +//! memory; JS receives only a u32 handle that it passes back on every +//! subsequent call. + +use std::cell::RefCell; +use std::collections::HashMap; +use zeroize::Zeroizing; + +thread_local! { + static SESSIONS: RefCell>> = RefCell::new(HashMap::new()); + static NEXT_HANDLE: RefCell = const { RefCell::new(1) }; +} + +pub fn insert(key: Zeroizing<[u8; 32]>) -> u32 { + let handle = NEXT_HANDLE.with(|n| { + let mut n = n.borrow_mut(); + let h = *n; + *n = n.wrapping_add(1); + if *n == 0 { *n = 1; } // avoid reserving 0 as a valid handle + h + }); + SESSIONS.with(|s| { s.borrow_mut().insert(handle, key); }); + handle +} + +pub fn with(handle: u32, f: F) -> Option +where + F: FnOnce(&Zeroizing<[u8; 32]>) -> R, +{ + SESSIONS.with(|s| s.borrow().get(&handle).map(f)) +} + +pub fn remove(handle: u32) -> bool { + SESSIONS.with(|s| s.borrow_mut().remove(&handle).is_some()) +} + +/// For tests only — empty the table and wipe all sessions. +#[cfg(test)] +pub fn clear() { + SESSIONS.with(|s| s.borrow_mut().clear()); +}