//! WASM bindings for the idfoto password manager. //! //! 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). use wasm_bindgen::prelude::*; use relicario_core::crypto::{self, KdfParams}; use relicario_core::entry::Entry; use relicario_core::vault; use relicario_core::imgsecret; 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. #[wasm_bindgen] pub fn derive_master_key( passphrase: &str, image_secret: &[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()) } /// 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())) } /// 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() } #[cfg(test)] mod tests { use super::*; #[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"); } #[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"); } }