//! WASM bindings for relicario. //! //! 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::{derive_master_key, imgsecret, KdfParams}; /// Handle type returned from `unlock`. Backed by a `u32`; opaque to JS. #[wasm_bindgen] 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_bytes: &[u8], salt: &[u8], params_json: &str, ) -> 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)) } #[wasm_bindgen] pub fn lock(handle: &SessionHandle) -> bool { session::remove(handle.0) } // Subsequent wasm_bindgen fns added in Tasks 19-21. use serde_wasm_bindgen::Serializer; use relicario_core::{ decrypt_item, decrypt_manifest, decrypt_settings, encrypt_item, encrypt_manifest, encrypt_settings, Item, Manifest, VaultSettings, }; fn need_key(handle: &SessionHandle) -> Result<(), JsError> { if session::with(handle.0, |_| ()).is_some() { Ok(()) } else { Err(JsError::new("invalid or locked session handle")) } } fn js_value_for(v: &T) -> Result { let ser = Serializer::new().serialize_maps_as_objects(true); v.serialize(&ser).map_err(|e| JsError::new(&e.to_string())) } #[wasm_bindgen] pub fn manifest_decrypt(handle: &SessionHandle, encrypted: &[u8]) -> Result { need_key(handle)?; let out = session::with(handle.0, |k| decrypt_manifest(encrypted, k)) .unwrap() .map_err(|e| JsError::new(&e.to_string()))?; js_value_for(&out) } #[wasm_bindgen] pub fn manifest_encrypt(handle: &SessionHandle, manifest_json: &str) -> Result, JsError> { need_key(handle)?; let m: Manifest = serde_json::from_str(manifest_json) .map_err(|e| JsError::new(&format!("manifest json: {e}")))?; session::with(handle.0, |k| encrypt_manifest(&m, k)) .unwrap() .map_err(|e| JsError::new(&e.to_string())) } #[wasm_bindgen] pub fn item_decrypt(handle: &SessionHandle, encrypted: &[u8]) -> Result { need_key(handle)?; let out = session::with(handle.0, |k| decrypt_item(encrypted, k)) .unwrap() .map_err(|e| JsError::new(&e.to_string()))?; js_value_for(&out) } #[wasm_bindgen] pub fn item_encrypt(handle: &SessionHandle, item_json: &str) -> Result, JsError> { need_key(handle)?; let item: Item = serde_json::from_str(item_json) .map_err(|e| JsError::new(&format!("item json: {e}")))?; session::with(handle.0, |k| encrypt_item(&item, k)) .unwrap() .map_err(|e| JsError::new(&e.to_string())) } #[wasm_bindgen] pub fn settings_decrypt(handle: &SessionHandle, encrypted: &[u8]) -> Result { need_key(handle)?; let out = session::with(handle.0, |k| decrypt_settings(encrypted, k)) .unwrap() .map_err(|e| JsError::new(&e.to_string()))?; js_value_for(&out) } #[wasm_bindgen] pub fn settings_encrypt(handle: &SessionHandle, settings_json: &str) -> Result, JsError> { need_key(handle)?; let s: VaultSettings = serde_json::from_str(settings_json) .map_err(|e| JsError::new(&format!("settings json: {e}")))?; session::with(handle.0, |k| encrypt_settings(&s, k)) .unwrap() .map_err(|e| JsError::new(&e.to_string())) } // ── Task 20: attachment / generator / imgsecret / ID / TOTP bridges ───────── use relicario_core::{decrypt_attachment, encrypt_attachment, FieldId, ItemId}; #[wasm_bindgen] pub struct EncryptedAttachment { aid: String, bytes: Vec, } #[wasm_bindgen] impl EncryptedAttachment { #[wasm_bindgen(getter)] pub fn aid(&self) -> String { self.aid.clone() } #[wasm_bindgen(getter)] pub fn bytes(&self) -> Vec { self.bytes.clone() } } #[wasm_bindgen] pub fn attachment_encrypt( handle: &SessionHandle, plaintext: &[u8], max_bytes: u64, ) -> Result { need_key(handle)?; let enc = session::with(handle.0, |k| encrypt_attachment(plaintext, k, max_bytes)) .unwrap() .map_err(|e| JsError::new(&e.to_string()))?; Ok(EncryptedAttachment { aid: enc.id.as_str().to_owned(), bytes: enc.bytes }) } #[wasm_bindgen] pub fn attachment_decrypt( handle: &SessionHandle, encrypted: &[u8], ) -> Result, JsError> { need_key(handle)?; let plain = session::with(handle.0, |k| decrypt_attachment(encrypted, k)) .unwrap() .map_err(|e| JsError::new(&e.to_string()))?; Ok(plain.to_vec()) } #[wasm_bindgen] pub fn new_item_id() -> String { ItemId::new().as_str().to_owned() } #[wasm_bindgen] pub fn new_field_id() -> String { FieldId::new().as_str().to_owned() } use relicario_core::{ generate_passphrase as core_generate_passphrase, generate_password as core_generate_password, rate_passphrase as core_rate_passphrase, GeneratorRequest, }; #[wasm_bindgen] pub fn generate_password(request_json: &str) -> Result { let req: GeneratorRequest = serde_json::from_str(request_json) .map_err(|e| JsError::new(&format!("generator request: {e}")))?; let out = core_generate_password(&req).map_err(|e| JsError::new(&e.to_string()))?; Ok(out.as_str().to_owned()) } #[wasm_bindgen] pub fn generate_passphrase(request_json: &str) -> Result { let req: GeneratorRequest = serde_json::from_str(request_json) .map_err(|e| JsError::new(&format!("generator request: {e}")))?; let out = core_generate_passphrase(&req).map_err(|e| JsError::new(&e.to_string()))?; Ok(out.as_str().to_owned()) } #[wasm_bindgen] pub fn rate_passphrase(p: &str) -> Result { let est = core_rate_passphrase(p); js_value_for(&serde_json::json!({ "score": est.score, "guesses_log10": est.guesses_log10, })) } #[wasm_bindgen] pub fn extract_image_secret(image_bytes: &[u8]) -> Result, JsError> { let s = imgsecret::extract(image_bytes).map_err(|e| JsError::new(&e.to_string()))?; Ok(s.to_vec()) } #[wasm_bindgen] pub fn embed_image_secret(carrier: &[u8], secret: &[u8]) -> Result, JsError> { let s: &[u8; 32] = secret.try_into() .map_err(|_| JsError::new("secret must be exactly 32 bytes"))?; imgsecret::embed(carrier, s).map_err(|e| JsError::new(&e.to_string())) } use relicario_core::item_types::{TotpConfig, compute_totp_code}; #[wasm_bindgen] pub struct TotpCode { code: String, expires_at: u64, } #[wasm_bindgen] impl TotpCode { #[wasm_bindgen(getter)] pub fn code(&self) -> String { self.code.clone() } #[wasm_bindgen(getter)] pub fn expires_at(&self) -> u64 { self.expires_at } } #[wasm_bindgen] pub fn totp_compute( config_json: &str, now_unix_seconds: u64, ) -> Result { let cfg: TotpConfig = serde_json::from_str(config_json) .map_err(|e| JsError::new(&format!("totp config: {e}")))?; let code = compute_totp_code(&cfg, now_unix_seconds) .map_err(|e| JsError::new(&e.to_string()))?; let period = cfg.period_seconds as u64; let expires_at = ((now_unix_seconds / period) + 1) * period; Ok(TotpCode { code, expires_at }) } #[cfg(test)] mod session_tests { use super::*; use zeroize::Zeroizing; #[test] 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 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); } #[test] fn manifest_round_trip_via_handle() { use relicario_core::{Manifest, decrypt_manifest}; session::clear(); let h = session::insert(Zeroizing::new([0x55u8; 32])); let handle = SessionHandle(h); let key = Zeroizing::new([0x55u8; 32]); let empty = Manifest::new(); let bytes = manifest_encrypt(&handle, &serde_json::to_string(&empty).unwrap()).unwrap(); assert!(!bytes.is_empty()); // Decrypt via core directly (avoids js-sys on native). let parsed: Manifest = decrypt_manifest(&bytes, &key).unwrap(); assert_eq!(parsed.items.len(), 0); // Random nonces mean two encryptions of the same plaintext differ. let bytes2 = manifest_encrypt(&handle, &serde_json::to_string(&empty).unwrap()).unwrap(); assert_ne!(bytes, bytes2, "nonces must differ"); } }