//! 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. #[derive(Debug)] #[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) } // ── Pluggable second factor: key-file bindings (Task 3) ────────────────────── /// Unlock using a raw 32-byte secret directly, bypassing steganographic /// extraction. Mirrors `unlock` exactly, except the caller supplies the /// secret bytes instead of a carrier image. #[wasm_bindgen] pub fn unlock_with_secret( passphrase: &str, secret: &[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 secret_arr: &[u8; 32] = secret.try_into() .map_err(|_| JsError::new("secret must be exactly 32 bytes"))?; 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(), secret_arr, salt_arr, ¶ms) .map_err(|e| JsError::new(&e.to_string()))?; let handle = session::insert(master_key, Zeroizing::new(*secret_arr)); Ok(SessionHandle(handle)) } /// Encode a 32-byte secret into the `relicario-keyfile-v1` armor format. /// Returns the UTF-8 bytes suitable for writing to a `.relkey` file. #[wasm_bindgen] pub fn keyfile_encode(secret: &[u8]) -> Result, JsError> { let arr: &[u8; 32] = secret.try_into() .map_err(|_| JsError::new("secret must be exactly 32 bytes"))?; Ok(relicario_core::keyfile::keyfile_encode(arr)) } /// Decode a `relicario-keyfile-v1` armored file, returning the raw 32-byte secret. #[wasm_bindgen] pub fn keyfile_decode(bytes: &[u8]) -> Result, JsError> { let s = relicario_core::keyfile::keyfile_decode(bytes) .map_err(|e| JsError::new(&e.to_string()))?; Ok(s.to_vec()) } 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())) } // ── Pure parsers (no session needed) ──────────────────────────────────────── use relicario_core::{base32 as core_base32, mime as core_mime, MonthYear}; /// Parse a card-expiry string (`MM/YYYY` / `MM-YYYY` / `MM/YY`). /// Returns a plain `{ month, year }` object on success. #[wasm_bindgen] pub fn parse_month_year(s: &str) -> Result { let my = MonthYear::parse(s).map_err(|e| JsError::new(&e.to_string()))?; js_value_for(&my) } /// Decode an RFC 4648 base32 string (case-insensitive, optional padding, /// whitespace-stripped). Returned as `Uint8Array` on the JS side. #[wasm_bindgen] pub fn base32_decode_lenient(s: &str) -> Result, JsError> { core_base32::decode_rfc4648_lenient(s).map_err(|e| JsError::new(&e.to_string())) } /// Guess a MIME type from a filename's extension. Returns /// `application/octet-stream` for unknown or missing extensions. #[wasm_bindgen] pub fn guess_mime(filename: &str) -> String { core_mime::guess_for_extension(filename).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()) } // ── Org vault WASM bridge ──────────────────────────────────────────────────── /// Encrypt the registered device key under the current vault master key and /// return CIPHERTEXT for JS to persist (e.g. `chrome.storage.local.device_key_enc`). /// /// The device private key NEVER crosses to JS — only this encrypted blob does. /// On the decrypt side, `restore_device_key` decrypts inside WASM and repopulates /// `DEVICE_STATE` without ever returning plaintext to JS. #[wasm_bindgen] pub fn persist_device_key(handle: &SessionHandle) -> Result, JsError> { need_key(handle)?; let plain = device::export_state_bytes().map_err(|e| JsError::new(&e))?; session::with(handle.0, |k| relicario_core::crypto::encrypt(k, &plain)) .unwrap() .map_err(|e| JsError::new(&e.to_string())) } /// Decrypt a persisted device-key blob under the master key and repopulate /// `DEVICE_STATE` (decryption happens INSIDE WASM; plaintext never reaches JS). /// Call this at unlock time after `restore_device_key` to restore device signing /// capability across service-worker restarts. #[wasm_bindgen] pub fn restore_device_key(handle: &SessionHandle, encrypted: &[u8]) -> Result<(), JsError> { need_key(handle)?; let plain = session::with(handle.0, |k| relicario_core::crypto::decrypt(k, encrypted)) .unwrap() .map_err(|e| JsError::new(&e.to_string()))?; let plain = Zeroizing::new(plain); device::import_state_bytes(&plain).map_err(|e| JsError::new(&e)) } /// Unwrap a member's ECIES-wrapped org master key into a session handle, using /// the registered device key held in `DEVICE_STATE` (restored at unlock via /// `restore_device_key`). The device private key never crosses to JS. /// /// `keys_blob` is the raw wrapped-key blob at `keys/.enc` in the org /// repo — produced by `relicario_core::org::wrap_org_key`. /// /// The org key is held in the same Zeroizing WASM session registry as the personal /// master key; org items share the personal `.enc` AEAD format, so the returned /// handle works with `item_decrypt`/`manifest_decrypt` unchanged. #[wasm_bindgen] pub fn org_unwrap_key(keys_blob: &[u8]) -> Result { let seed = device::signing_seed() .map_err(|e| JsError::new(&format!("device key unavailable: {e}")))?; let org_key = relicario_core::org::unwrap_org_key(keys_blob, &seed) .map_err(|e| JsError::new(&format!("org unwrap failed: {e}")))?; // image_secret slot is unused for org sessions; store a zeroized placeholder. let handle = session::insert(org_key, Zeroizing::new([0u8; 32])); Ok(SessionHandle(handle)) } /// Decrypt an org manifest blob (manifest.enc in the org repo) with an org /// session handle. The org manifest is a dedicated OrgManifest type (NOT the /// personal Manifest); core's decrypt_org_manifest deserializes it. #[wasm_bindgen] pub fn org_manifest_decrypt(handle: &SessionHandle, encrypted: &[u8]) -> Result { need_key(handle)?; let out = session::with(handle.0, |k| relicario_core::decrypt_org_manifest(encrypted, k)) .unwrap() .map_err(|e| JsError::new(&e.to_string()))?; js_value_for(&out) } /// Decrypt an org manifest blob AND grant-filter it to `granted` collection slugs, /// for the READ path. Filtering happens in core (`filter_by_collections`), so /// ungranted entries never cross to JS. (SOFT phase-1 UX filter — see core /// doc-comment; the member holds the org key, this is not crypto isolation.) /// Dev-C writes use the UNFILTERED `org_manifest_decrypt` instead (re-encrypting a /// filtered subset would wipe ungranted entries). #[wasm_bindgen] pub fn org_manifest_decrypt_filtered( handle: &SessionHandle, encrypted: &[u8], granted: Vec, ) -> Result { need_key(handle)?; let manifest = session::with(handle.0, |k| relicario_core::decrypt_org_manifest(encrypted, k)) .unwrap() .map_err(|e| JsError::new(&e.to_string()))?; let filtered = manifest.filter_by_collections(&granted); js_value_for(&filtered) } /// Encrypt an OrgManifest (JSON) with an org session handle, for the write track /// (Dev-C never touches WASM — this is exposed here so they don't have to). #[wasm_bindgen] pub fn org_manifest_encrypt(handle: &SessionHandle, manifest_json: &str) -> Result, JsError> { need_key(handle)?; let m: relicario_core::OrgManifest = serde_json::from_str(manifest_json) .map_err(|e| JsError::new(&format!("org manifest json: {e}")))?; session::with(handle.0, |k| relicario_core::encrypt_org_manifest(&m, k)) .unwrap() .map_err(|e| JsError::new(&e.to_string())) } /// Tests that modify DEVICE_STATE must serialize to prevent races. /// Acquired by any test in org_tests or device_persist_tests that /// calls register_device / clear_device / persist_device_key / restore_device_key. #[cfg(test)] static DEVICE_TEST_LOCK: std::sync::Mutex<()> = std::sync::Mutex::new(()); #[cfg(test)] mod org_tests { use super::*; use relicario_core::org::wrap_org_key; use zeroize::Zeroizing; #[test] fn org_manifest_round_trip_via_handle() { use relicario_core::{OrgManifest, OrgManifestEntry, ItemType, ItemId, decrypt_org_manifest}; session::clear(); let key_bytes = [0xABu8; 32]; let h = session::insert(Zeroizing::new(key_bytes), Zeroizing::new([0u8; 32])); let handle = SessionHandle(h); let key = Zeroizing::new(key_bytes); let mut manifest = OrgManifest::new(); manifest.entries.push(OrgManifestEntry { id: ItemId::new(), r#type: ItemType::SecureNote, title: "Org Test Item".into(), tags: vec!["tag1".into()], modified: 1_234_567_890, trashed_at: None, collection: "prod".into(), }); let manifest_json = serde_json::to_string(&manifest).unwrap(); let encrypted = org_manifest_encrypt(&handle, &manifest_json).unwrap(); assert!(!encrypted.is_empty()); // Decrypt via core directly (avoids js-sys/serde_wasm_bindgen on native). let parsed: OrgManifest = decrypt_org_manifest(&encrypted, &key).unwrap(); assert_eq!(parsed.entries.len(), 1); assert_eq!(parsed.entries[0].collection, "prod"); assert_eq!(parsed.entries[0].title, "Org Test Item"); assert_eq!(parsed.entries[0].tags, vec!["tag1".to_string()]); assert_eq!(parsed.schema_version, 1); // Random nonces: two encryptions of the same plaintext must differ. let encrypted2 = org_manifest_encrypt(&handle, &manifest_json).unwrap(); assert_ne!(encrypted, encrypted2, "nonces must differ"); } #[test] fn org_unwrap_key_yields_a_session_that_decrypts_org_blobs() { // Serialize with all tests that touch DEVICE_STATE. let _guard = super::DEVICE_TEST_LOCK.lock().unwrap_or_else(|e| e.into_inner()); device::clear_device(); // Register a device so DEVICE_STATE is populated; use its signing public key // to wrap a known org key. org_unwrap_key now reads the seed from DEVICE_STATE // — the device_private_openssh param is gone in the 4.5a refactor. let org_key = Zeroizing::new([7u8; 32]); let (signing_pub, _deploy_pub) = device::register_device("test-dev").unwrap(); let wrapped = wrap_org_key(&org_key, &signing_pub).unwrap(); let handle = org_unwrap_key(&wrapped).unwrap(); // item_encrypt is native-safe: returns Vec; JsError is only reachable on // the error path, which is not exercised here. let ct = item_encrypt( &handle, r#"{"id":"a1b2c3d4e5f6a7b8","title":"Test","type":"secure_note","created":0,"modified":0,"core":{"type":"secure_note","body":"x"}}"#, ) .unwrap(); // item_decrypt calls serde_wasm_bindgen::Serializer which panics off-wasm. // Use relicario_core::decrypt_item directly, mirroring the // manifest_round_trip_via_handle approach in session_tests. let pt: relicario_core::Item = relicario_core::decrypt_item(&ct, &org_key).unwrap(); assert!(format!("{:?}", pt.core).contains("SecureNote")); } } #[cfg(test)] mod device_persist_tests { use super::*; /// 1. Persist → restore round-trip. /// /// After persisting the device state and simulating a SW restart (clear_device), /// restoring must bring back the exact same name and signing_public key. #[test] fn persist_restore_round_trip() { let _guard = super::DEVICE_TEST_LOCK.lock().unwrap_or_else(|e| e.into_inner()); session::clear(); device::clear_device(); // A master-key session (no Argon2id needed — we construct the key directly). let master_key = Zeroizing::new([0x42u8; 32]); let h = session::insert(master_key, Zeroizing::new([0u8; 32])); let handle = SessionHandle(h); // Register a device; capture name + signing public key before persist. device::register_device("my-laptop").unwrap(); let (name_before, signing_pub_before, _) = device::get_device_info().unwrap(); // Encrypt the device state under the master key. let enc = persist_device_key(&handle).unwrap(); // Simulate a SW restart: wipe DEVICE_STATE in-memory. device::clear_device(); assert!(device::get_device_info().is_none(), "DEVICE_STATE must be cleared"); // Decrypt and repopulate. restore_device_key(&handle, &enc).unwrap(); // Name and signing_public must survive the full round-trip. let (name_after, signing_pub_after, _) = device::get_device_info().unwrap(); assert_eq!(name_before, name_after, "device name must survive persist/restore"); assert_eq!( signing_pub_before, signing_pub_after, "signing_public must survive persist/restore" ); } /// 2. org_unwrap_key works post-restore. /// /// After a persist→clear→restore cycle, org_unwrap_key must be able to unwrap /// an org key that was wrapped to the device's signing public key. #[test] fn org_unwrap_key_works_after_restore() { let _guard = super::DEVICE_TEST_LOCK.lock().unwrap_or_else(|e| e.into_inner()); session::clear(); device::clear_device(); let master_key = Zeroizing::new([0x43u8; 32]); let h = session::insert(master_key, Zeroizing::new([0u8; 32])); let handle = SessionHandle(h); // Register device; capture signing_pub for key wrapping. let (signing_pub, _deploy_pub) = device::register_device("my-laptop").unwrap(); // Full persist → wipe → restore cycle (simulates SW restart). let enc = persist_device_key(&handle).unwrap(); device::clear_device(); restore_device_key(&handle, &enc).unwrap(); // Wrap a known org key to the device's signing public key. let known_org_key = Zeroizing::new([0x77u8; 32]); let wrapped = relicario_core::org::wrap_org_key(&known_org_key, &signing_pub).unwrap(); // Unwrap using the refactored org_unwrap_key (reads seed from DEVICE_STATE). let org_handle = org_unwrap_key(&wrapped).unwrap(); // Encrypt an item under the org session, then decrypt with the known key // directly (avoids js-sys/serde_wasm_bindgen on native — mirrors existing org test). let ct = item_encrypt( &org_handle, r#"{"id":"a1b2c3d4e5f6a7b8","title":"Test","type":"secure_note","created":0,"modified":0,"core":{"type":"secure_note","body":"x"}}"#, ) .unwrap(); let pt: relicario_core::Item = relicario_core::decrypt_item(&ct, &known_org_key).unwrap(); assert!(format!("{:?}", pt.core).contains("SecureNote")); } /// 3. Ciphertext is NOT plaintext. /// /// The blob returned by persist_device_key must not contain the raw PEM private /// key header — proves the device key is encrypted, never handed to JS in clear. #[test] fn persist_output_is_ciphertext_not_plaintext() { let _guard = super::DEVICE_TEST_LOCK.lock().unwrap_or_else(|e| e.into_inner()); session::clear(); device::clear_device(); let master_key = Zeroizing::new([0x44u8; 32]); let h = session::insert(master_key, Zeroizing::new([0u8; 32])); let handle = SessionHandle(h); device::register_device("my-laptop").unwrap(); let enc = persist_device_key(&handle).unwrap(); // Encrypted bytes must NOT contain the OpenSSH PEM header verbatim. // The XChaCha20-Poly1305 ciphertext is randomized; the ASCII string // "BEGIN OPENSSH PRIVATE KEY" cannot appear in it. let as_lossy = String::from_utf8_lossy(&enc); assert!( !as_lossy.contains("BEGIN OPENSSH PRIVATE KEY"), "persist_device_key must return ciphertext, not the raw PEM private key (device privkey must never reach JS)" ); } /// 4. org_unwrap_key errors when no device is registered. /// /// `org_unwrap_key` wraps its error in `JsError`, which panics on non-wasm /// targets (established constraint in this codebase — see session_tests comment). /// We test `device::signing_seed` directly instead: that is the codepath /// `org_unwrap_key` calls first, and it is what produces the "no device /// registered" error that `org_unwrap_key` surfaces as "device key unavailable". #[test] fn org_unwrap_key_errors_when_no_device_registered() { let _guard = super::DEVICE_TEST_LOCK.lock().unwrap_or_else(|e| e.into_inner()); device::clear_device(); let err = device::signing_seed() .expect_err("signing_seed must fail when no device is registered"); assert_eq!(err, "no device registered", "error message must match"); } } #[cfg(test)] mod session_tests { use super::*; use zeroize::Zeroizing; /// Synthesize a carrier JPEG for embedding tests (mirrors core's private helper). fn make_test_jpeg(width: u32, height: u32) -> Vec { use image::codecs::jpeg::JpegEncoder; use image::{ImageBuffer, ImageEncoder, Rgb}; let img = ImageBuffer::from_fn(width, height, |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 buf = Vec::new(); JpegEncoder::new_with_quality(&mut buf, 92) .write_image(img.as_raw(), width, height, image::ExtendedColorType::Rgb8) .unwrap(); buf } /// SECURITY PROOF: both unlock paths derive the identical master key. /// /// The key-file path (`unlock_with_secret`) must derive the same Argon2id /// master key as the stego-image path (`unlock`) when given the same secret, /// passphrase, salt, and KDF params. This is the core security argument: /// the key file is simply an alternative transport for the same 32-byte /// secret that the stego-image carries. #[test] fn unlock_with_secret_matches_unlock_from_jpeg() { session::clear(); let secret = [3u8; 32]; let salt = [1u8; 32]; let params = r#"{"argon2_m":256,"argon2_t":1,"argon2_p":1}"#; // Image transport: embed the secret into a carrier JPEG, then unlock from it. let carrier = make_test_jpeg(256, 256); let stego = imgsecret::embed(&carrier, &secret).expect("embed secret into carrier"); let h_img = unlock("correct horse", &stego, &salt, params).expect("unlock from jpeg"); // Key-file transport: unlock from the raw secret directly. let h_key = unlock_with_secret("correct horse", &secret, &salt, params) .expect("unlock_with_secret"); // PROOF: both sessions hold a byte-identical master key — the key-file // path derives the SAME master key as the stego-image path for the // same secret. We compare stored master keys directly (avoids js-sys, // which is unavailable natively; stronger than a cross-decrypt anyway). let extract = |h: u32| { session::with(h, |k| { let mut a = [0u8; 32]; a.copy_from_slice(&k[..]); a }) .expect("session present") }; assert_eq!( extract(h_img.value()), extract(h_key.value()), "key-file path must derive the identical master key as the stego-image path" ); // Negative control: a DIFFERENT secret must derive a DIFFERENT master key // (guards against a hypothetical constant/secret-insensitive KDF regression). let h_other = unlock_with_secret("correct horse", &[4u8; 32], &salt, params) .expect("unlock_with_secret other"); assert_ne!( extract(h_img.value()), extract(h_other.value()), "a different secret must derive a different master key" ); } #[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()); } #[test] fn base32_decode_lenient_round_trips_known_vector() { let bytes = super::base32_decode_lenient("MZXW6YTBOI").unwrap(); assert_eq!(bytes, b"foobar"); } #[test] fn guess_mime_known_and_unknown_extensions() { assert_eq!(super::guess_mime("doc.pdf"), "application/pdf"); assert_eq!(super::guess_mime("photo.JPEG"), "image/jpeg"); assert_eq!(super::guess_mime("file.xyz"), "application/octet-stream"); } // Error paths and JsValue serialization can't be exercised natively — // JsError::new and serde_wasm_bindgen::Serializer call wasm-bindgen // imports that panic off-wasm (same constraint as // `parse_lastpass_csv_json_propagates_header_errors` above). Those // paths are covered in core: `time::tests::parse_rejects_malformed` // and `base32::tests::decode_rfc4648_lenient_rejects_non_alphabet_chars`. }