Add OrgManifest::filter_by_collections(&[String]) as the single source of grant-filter logic. Refactor filter_for_member to delegate to it (no duplicated loop). Add org_manifest_decrypt_filtered WASM binding that decrypts + filters in core so ungranted entries never cross to JS (phase-1 SOFT/UX filter; per-collection crypto isolation is phase-2). Declare the binding in extension/src/wasm.d.ts. Vec<String> compiled cleanly for wasm32-unknown-unknown. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_014s527M917W47LDrfQ4t47g
1086 lines
44 KiB
Rust
1086 lines
44 KiB
Rust
//! 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<SessionHandle, JsError> {
|
|
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<SessionHandle, JsError> {
|
|
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<Vec<u8>, 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<Vec<u8>, 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<T: serde::Serialize>(v: &T) -> Result<JsValue, JsError> {
|
|
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<JsValue, JsError> {
|
|
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<Vec<u8>, 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<JsValue, JsError> {
|
|
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<Vec<u8>, 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<JsValue, JsError> {
|
|
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<Vec<u8>, 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<String, JsError> {
|
|
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<u8>,
|
|
}
|
|
|
|
#[wasm_bindgen]
|
|
impl EncryptedAttachment {
|
|
#[wasm_bindgen(getter)] pub fn aid(&self) -> String { self.aid.clone() }
|
|
#[wasm_bindgen(getter)] pub fn bytes(&self) -> Vec<u8> { self.bytes.clone() }
|
|
}
|
|
|
|
#[wasm_bindgen]
|
|
pub fn attachment_encrypt(
|
|
handle: &SessionHandle,
|
|
plaintext: &[u8],
|
|
max_bytes: u64,
|
|
) -> Result<EncryptedAttachment, JsError> {
|
|
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<Vec<u8>, 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<String, JsError> {
|
|
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<String, JsError> {
|
|
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<JsValue, JsError> {
|
|
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<JsValue, JsError> {
|
|
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": "<base64>" }
|
|
/// Errors if no device has been registered via register_device().
|
|
#[wasm_bindgen]
|
|
pub fn sign_for_git(data: &[u8]) -> Result<JsValue, JsError> {
|
|
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<JsValue, JsError> {
|
|
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<JsValue, JsError> {
|
|
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::<Vec<_>>(),
|
|
}));
|
|
}
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
js_value_for(&results)
|
|
}
|
|
|
|
#[wasm_bindgen]
|
|
pub fn extract_image_secret(image_bytes: &[u8]) -> Result<Vec<u8>, 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<Vec<u8>, 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<JsValue, JsError> {
|
|
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<Vec<u8>, 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<TotpCode, JsError> {
|
|
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": "<base64>",
|
|
/// "params_json": "...",
|
|
/// "devices_json": "...",
|
|
/// "manifest_enc": "<base64>",
|
|
/// "settings_enc": "<base64>",
|
|
/// "items": [{"id": "<hex>", "ciphertext": "<base64>"}, ...],
|
|
/// "attachments": [{"item_id": "<hex>", "attachment_id": "<hex>", "ciphertext": "<base64>"}, ...],
|
|
/// "reference_jpg": "<base64>" | null,
|
|
/// "git_archive": "<base64>" | null
|
|
/// }
|
|
/// ```
|
|
#[wasm_bindgen]
|
|
pub fn pack_backup_json(input_json: &str, passphrase: &str) -> Result<Vec<u8>, JsError> {
|
|
#[derive(serde::Deserialize)]
|
|
struct InJson {
|
|
salt: String,
|
|
params_json: String,
|
|
devices_json: String,
|
|
manifest_enc: String,
|
|
settings_enc: String,
|
|
items: Vec<InItem>,
|
|
attachments: Vec<InAttachment>,
|
|
reference_jpg: Option<String>,
|
|
git_archive: Option<String>,
|
|
}
|
|
#[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<u8>)> = 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::<Result<Vec<_>, JsError>>()?;
|
|
let attach_bytes: Vec<(String, String, Vec<u8>)> = 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::<Result<Vec<_>, 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<BackupItem> = items_bytes.iter()
|
|
.map(|(id, ct)| BackupItem { id: id.clone(), ciphertext: ct })
|
|
.collect();
|
|
let attach_refs: Vec<BackupAttachment> = 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<String, JsError> {
|
|
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::<Vec<_>>(),
|
|
"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::<Vec<_>>(),
|
|
"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<String, JsError> {
|
|
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<String, JsError> {
|
|
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<Vec<u8>, 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<Vec<u8>, 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/<member_id>.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<SessionHandle, JsError> {
|
|
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<JsValue, JsError> {
|
|
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<String>,
|
|
) -> Result<JsValue, JsError> {
|
|
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<Vec<u8>, 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<u8>; 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<u8> {
|
|
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`.
|
|
}
|