//! 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; mod device; use wasm_bindgen::prelude::*; use zeroize::Zeroizing; use relicario_core::{derive_master_key, imgsecret, KdfParams}; /// Handle returned from `unlock`. Backed by a `u32`; opaque to JS. /// /// Dropping the handle (or invoking `.free()` from JS) removes the entry from /// the session registry, zeroizing the wrapped master key and image_secret. /// `lock(handle)` remains available as the explicit early-cleanup path; the /// `Drop` impl is the safety net that catches code paths which forget to call /// `lock` before letting the handle go out of scope. #[wasm_bindgen] pub struct SessionHandle(u32); #[wasm_bindgen] impl SessionHandle { #[wasm_bindgen(getter)] pub fn value(&self) -> u32 { self.0 } } impl Drop for SessionHandle { fn drop(&mut self) { let _ = session::remove(self.0); } } #[doc(hidden)] pub fn __test_make_handle() -> SessionHandle { SessionHandle(session::insert( Zeroizing::new([0x77u8; 32]), Zeroizing::new([0u8; 32]), )) } #[doc(hidden)] pub fn __test_session_exists(handle: u32) -> bool { session::with(handle, |_| ()).is_some() } #[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 stored_secret = Zeroizing::new(image_secret); let handle = session::insert(master_key, stored_secret); 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())) } /// Returns the JSON for `VaultSettings::default()`. Used by the setup /// wizard to encrypt and write a default settings.enc on new-vault setup. /// Keeping this in WASM (instead of hand-encoding in TS) prevents drift /// when the default VaultSettings shape changes in Rust. #[wasm_bindgen] pub fn default_vault_settings_json() -> Result { let s = VaultSettings::default(); serde_json::to_string(&s).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, })) } /// Register a new device, generating ed25519 keypairs for signing and deploy. /// Returns JSON: { "signing_public_key": "ssh-ed25519 ...", "deploy_public_key": "ssh-ed25519 ..." } /// Private keys are kept internal to WASM and never cross to JS. #[wasm_bindgen] pub fn register_device(name: &str) -> Result { let (signing_pub, deploy_pub) = device::register_device(name).map_err(|e| JsError::new(&e))?; js_value_for(&serde_json::json!({ "signing_public_key": signing_pub, "deploy_public_key": deploy_pub, })) } /// Sign `data` using the registered device's signing key. /// Returns JSON: { "signature": "" } /// Errors if no device has been registered via register_device(). #[wasm_bindgen] pub fn sign_for_git(data: &[u8]) -> Result { let signature = device::sign_for_git(data).map_err(|e| JsError::new(&e))?; js_value_for(&serde_json::json!({ "signature": signature, })) } /// Get the current device's name and public keys. /// Returns JSON: { "name": "...", "signing_public_key": "...", "deploy_public_key": "..." } /// Returns null if no device is registered in this session. #[wasm_bindgen] pub fn get_device_info() -> Result { match device::get_device_info() { Some((name, signing_pub, deploy_pub)) => js_value_for(&serde_json::json!({ "name": name, "signing_public_key": signing_pub, "deploy_public_key": deploy_pub, })), None => Ok(JsValue::NULL), } } /// Clear the in-memory device state (call on logout or before re-registration). #[wasm_bindgen] pub fn clear_device() { device::clear_device(); } /// Extract field history from a decrypted item JSON. /// Returns JSON array of { field_id, field_name, current_value, entries: [{ value, changed_at }] } #[wasm_bindgen] pub fn get_field_history(item_json: &str) -> Result { let item: Item = serde_json::from_str(item_json) .map_err(|e| JsError::new(&format!("item json: {e}")))?; let mut results = Vec::new(); // Only section fields are tracked in field_history (set_field_value operates on sections). for section in &item.sections { for field in §ion.fields { if field.value.is_history_tracked() { if let Some(entries) = item.field_history.get(&field.id) { if !entries.is_empty() { let current = match &field.value { relicario_core::FieldValue::Password(v) => v.as_str().to_owned(), relicario_core::FieldValue::Concealed(v) => v.as_str().to_owned(), _ => String::new(), }; results.push(serde_json::json!({ "field_id": field.id.as_str(), "field_name": &field.label, "current_value": current, "entries": entries.iter().map(|e| serde_json::json!({ "value": e.value.as_str(), "changed_at": e.replaced_at, })).collect::>(), })); } } } } } js_value_for(&results) } #[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 }) } // ── Backup container bridge ───────────────────────────────────────────────── use base64::Engine; use relicario_core::backup::{ pack_backup as core_pack_backup, unpack_backup as core_unpack_backup, BackupInput, BackupItem, BackupAttachment, }; /// Pack a vault into a `.relbak` byte vector. /// /// `input_json` shape: /// ```json /// { /// "salt": "", /// "params_json": "...", /// "devices_json": "...", /// "manifest_enc": "", /// "settings_enc": "", /// "items": [{"id": "", "ciphertext": ""}, ...], /// "attachments": [{"item_id": "", "attachment_id": "", "ciphertext": ""}, ...], /// "reference_jpg": "" | null, /// "git_archive": "" | null /// } /// ``` #[wasm_bindgen] pub fn pack_backup_json(input_json: &str, passphrase: &str) -> Result, JsError> { #[derive(serde::Deserialize)] struct InJson { salt: String, params_json: String, devices_json: String, manifest_enc: String, settings_enc: String, items: Vec, attachments: Vec, reference_jpg: Option, git_archive: Option, } #[derive(serde::Deserialize)] struct InItem { id: String, ciphertext: String } #[derive(serde::Deserialize)] struct InAttachment { item_id: String, attachment_id: String, ciphertext: String } let parsed: InJson = serde_json::from_str(input_json) .map_err(|e| JsError::new(&format!("backup input: {e}")))?; let b64 = base64::engine::general_purpose::STANDARD; let salt = b64.decode(&parsed.salt).map_err(|e| JsError::new(&e.to_string()))?; let manifest = b64.decode(&parsed.manifest_enc).map_err(|e| JsError::new(&e.to_string()))?; let settings = b64.decode(&parsed.settings_enc).map_err(|e| JsError::new(&e.to_string()))?; let items_bytes: Vec<(String, Vec)> = parsed.items.iter() .map(|i| { let ct = b64.decode(&i.ciphertext).map_err(|e| JsError::new(&e.to_string()))?; Ok((i.id.clone(), ct)) }) .collect::, JsError>>()?; let attach_bytes: Vec<(String, String, Vec)> = parsed.attachments.iter() .map(|a| { let ct = b64.decode(&a.ciphertext).map_err(|e| JsError::new(&e.to_string()))?; Ok((a.item_id.clone(), a.attachment_id.clone(), ct)) }) .collect::, JsError>>()?; let ref_bytes = parsed.reference_jpg.as_deref() .map(|s| b64.decode(s)) .transpose() .map_err(|e| JsError::new(&e.to_string()))?; let git_bytes = parsed.git_archive.as_deref() .map(|s| b64.decode(s)) .transpose() .map_err(|e| JsError::new(&e.to_string()))?; let items_refs: Vec = items_bytes.iter() .map(|(id, ct)| BackupItem { id: id.clone(), ciphertext: ct }) .collect(); let attach_refs: Vec = attach_bytes.iter() .map(|(iid, aid, ct)| BackupAttachment { item_id: iid.clone(), attachment_id: aid.clone(), ciphertext: ct, }) .collect(); let input = BackupInput { salt: &salt, params_json: &parsed.params_json, devices_json: &parsed.devices_json, manifest_enc: &manifest, settings_enc: &settings, items: items_refs, attachments: attach_refs, reference_jpg: ref_bytes.as_deref(), git_archive: git_bytes.as_deref(), }; core_pack_backup(input, passphrase).map_err(|e| JsError::new(&e.to_string())) } /// Unpack `.relbak` bytes; returns the JSON shape that mirrors `BackupOutput`, /// with binary fields base64-encoded. #[wasm_bindgen] pub fn unpack_backup_json(bytes: &[u8], passphrase: &str) -> Result { let out = core_unpack_backup(bytes, passphrase) .map_err(|e| JsError::new(&e.to_string()))?; let b64 = base64::engine::general_purpose::STANDARD; let json = serde_json::json!({ "salt": b64.encode(out.salt), "params_json": out.params_json, "devices_json": out.devices_json, "manifest_enc": b64.encode(&out.manifest_enc), "settings_enc": b64.encode(&out.settings_enc), "items": out.items.iter().map(|i| serde_json::json!({ "id": i.id, "ciphertext": b64.encode(&i.ciphertext), })).collect::>(), "attachments": out.attachments.iter().map(|a| serde_json::json!({ "item_id": a.item_id, "attachment_id": a.attachment_id, "ciphertext": b64.encode(&a.ciphertext), })).collect::>(), "reference_jpg": out.reference_jpg.as_ref().map(|b| b64.encode(b)), "git_archive": out.git_archive.as_ref().map(|b| b64.encode(b)), "created_at": out.created_at, }); Ok(json.to_string()) } // ── LastPass CSV importer bridge ──────────────────────────────────────────── use relicario_core::import_lastpass::parse_lastpass_csv as core_parse_lastpass_csv; /// Parse a LastPass CSV into `{ items: [Item], warnings: [ImportWarning] }`. /// /// Items are returned as full `Item` JSON objects with freshly-minted IDs /// and timestamps already populated. The SW caller is responsible for /// encrypting + writing them; this bridge stays pure so the preview UI /// can render counts without committing anything. #[wasm_bindgen] pub fn parse_lastpass_csv_json(csv_bytes: &[u8]) -> Result { let (items, warnings) = core_parse_lastpass_csv(csv_bytes) .map_err(|e| JsError::new(&e.to_string()))?; let json = serde_json::json!({ "items": items, "warnings": warnings, }); Ok(json.to_string()) } // ── Recovery QR bindings ───────────────────────────────────────────────────── use relicario_core::{generate_recovery_qr, recovery_qr_to_svg, unwrap_recovery_qr}; /// Generate a recovery QR SVG for the current session. /// Returns the SVG string. The passphrase wraps the image_secret under a /// separate key (domain-separated from the master key derivation). #[wasm_bindgen] pub fn wasm_generate_recovery_qr( handle: &SessionHandle, passphrase: &str, ) -> Result { let payload = session::with_image_secret(handle.0, |s| generate_recovery_qr(passphrase, s)) .ok_or_else(|| JsError::new("invalid or locked session handle"))? .map_err(|e| JsError::new(&e.to_string()))?; Ok(recovery_qr_to_svg(&payload)) } /// Unwrap a recovery QR payload (base64-encoded 109-byte blob) using the passphrase. /// Returns the raw image_secret bytes (32 bytes). #[wasm_bindgen] pub fn wasm_unwrap_recovery_qr( payload_b64: &str, passphrase: &str, ) -> Result, JsError> { use base64::{engine::general_purpose::STANDARD, Engine}; let bytes = STANDARD.decode(payload_b64) .map_err(|e| JsError::new(&format!("base64: {e}")))?; let recovered = unwrap_recovery_qr(&bytes, passphrase) .map_err(|e| JsError::new(&e.to_string()))?; Ok(recovered.to_vec()) } #[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]), Zeroizing::new([0u8; 32])); assert_ne!(h, 0); assert!(session::remove(h)); assert!(!session::remove(h)); // second remove false } #[test] fn dropping_session_handle_clears_registry_entry() { session::clear(); let handle = SessionHandle(session::insert( Zeroizing::new([0x33u8; 32]), Zeroizing::new([0u8; 32]), )); let id = handle.value(); assert!(session::with(id, |_| ()).is_some()); drop(handle); assert!(session::with(id, |_| ()).is_none()); } #[test] fn with_yields_key_only_while_session_lives() { session::clear(); let h = session::insert(Zeroizing::new([0x22u8; 32]), Zeroizing::new([0u8; 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]), Zeroizing::new([0u8; 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"); } #[test] fn parse_lastpass_csv_json_returns_items_and_warnings() { // Row 1 imports cleanly; row 2 has an empty `name` and is skipped // with a warning. let csv = "url,username,password,totp,extra,name,grouping,fav\n\ https://x,alice,hunter2,,,GitHub,Work,1\n\ https://y,bob,hunter2,,,,,"; let json = super::parse_lastpass_csv_json(csv.as_bytes()).unwrap(); let v: serde_json::Value = serde_json::from_str(&json).unwrap(); assert_eq!(v["items"].as_array().unwrap().len(), 1); assert_eq!(v["warnings"].as_array().unwrap().len(), 1); assert!(v["warnings"][0]["message"].as_str().unwrap().contains("name")); // The item's title round-trips as a plain JSON string. assert_eq!(v["items"][0]["title"].as_str().unwrap(), "GitHub"); } #[test] fn parse_lastpass_csv_json_propagates_header_errors() { // Test the underlying core function directly since native tests // can't call wasm_bindgen functions. use relicario_core::import_lastpass::parse_lastpass_csv; let bad = "name,user,pass\nA,u,p\n"; let err = parse_lastpass_csv(bad.as_bytes()); // Should fail with a header validation error. assert!(err.is_err()); } }