generate_device_keypair returns an ed25519 keypair as JSON with hex pubkey and base64 private key. get_field_history extracts tracked field history from a decrypted item for the popup's history view. Co-Authored-By: Claude <noreply@anthropic.com>
341 lines
12 KiB
Rust
341 lines
12 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;
|
|
|
|
use wasm_bindgen::prelude::*;
|
|
|
|
use relicario_core::{derive_master_key, imgsecret, KdfParams};
|
|
|
|
/// Handle type returned from `unlock`. Backed by a `u32`; opaque to JS.
|
|
#[wasm_bindgen]
|
|
pub struct SessionHandle(u32);
|
|
|
|
#[wasm_bindgen]
|
|
impl SessionHandle {
|
|
#[wasm_bindgen(getter)]
|
|
pub fn value(&self) -> u32 { self.0 }
|
|
}
|
|
|
|
#[wasm_bindgen]
|
|
pub fn unlock(
|
|
passphrase: &str,
|
|
image_bytes: &[u8],
|
|
salt: &[u8],
|
|
params_json: &str,
|
|
) -> Result<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 handle = session::insert(master_key);
|
|
Ok(SessionHandle(handle))
|
|
}
|
|
|
|
#[wasm_bindgen]
|
|
pub fn lock(handle: &SessionHandle) -> bool {
|
|
session::remove(handle.0)
|
|
}
|
|
|
|
// Subsequent wasm_bindgen fns added in Tasks 19-21.
|
|
|
|
use serde_wasm_bindgen::Serializer;
|
|
use relicario_core::{
|
|
decrypt_item, decrypt_manifest, decrypt_settings,
|
|
encrypt_item, encrypt_manifest, encrypt_settings,
|
|
Item, Manifest, VaultSettings,
|
|
};
|
|
|
|
fn need_key(handle: &SessionHandle) -> Result<(), JsError> {
|
|
if session::with(handle.0, |_| ()).is_some() { Ok(()) }
|
|
else { Err(JsError::new("invalid or locked session handle")) }
|
|
}
|
|
|
|
fn js_value_for<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()))
|
|
}
|
|
|
|
// ── 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,
|
|
}))
|
|
}
|
|
|
|
use ed25519_dalek::SigningKey;
|
|
use base64::Engine;
|
|
|
|
/// Generate an ed25519 keypair for device registration.
|
|
/// Returns JSON: { "public_key_hex": "...", "private_key_base64": "..." }
|
|
#[wasm_bindgen]
|
|
pub fn generate_device_keypair() -> Result<JsValue, JsError> {
|
|
let mut rng = rand::thread_rng();
|
|
let signing_key = SigningKey::generate(&mut rng);
|
|
let verifying_key = signing_key.verifying_key();
|
|
|
|
let public_hex = hex::encode(verifying_key.as_bytes());
|
|
let private_b64 = base64::engine::general_purpose::STANDARD.encode(signing_key.as_bytes());
|
|
|
|
js_value_for(&serde_json::json!({
|
|
"public_key_hex": public_hex,
|
|
"private_key_base64": private_b64,
|
|
}))
|
|
}
|
|
|
|
/// 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()))
|
|
}
|
|
|
|
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 })
|
|
}
|
|
|
|
#[cfg(test)]
|
|
mod session_tests {
|
|
use super::*;
|
|
use zeroize::Zeroizing;
|
|
|
|
#[test]
|
|
fn insert_then_remove_clears_entry() {
|
|
session::clear();
|
|
let h = session::insert(Zeroizing::new([0x11u8; 32]));
|
|
assert_ne!(h, 0);
|
|
assert!(session::remove(h));
|
|
assert!(!session::remove(h)); // second remove false
|
|
}
|
|
|
|
#[test]
|
|
fn with_yields_key_only_while_session_lives() {
|
|
session::clear();
|
|
let h = session::insert(Zeroizing::new([0x22u8; 32]));
|
|
let byte = session::with(h, |k| k[0]);
|
|
assert_eq!(byte, Some(0x22));
|
|
session::remove(h);
|
|
let byte = session::with(h, |k| k[0]);
|
|
assert_eq!(byte, None);
|
|
}
|
|
|
|
#[test]
|
|
fn manifest_round_trip_via_handle() {
|
|
use relicario_core::{Manifest, decrypt_manifest};
|
|
session::clear();
|
|
let h = session::insert(Zeroizing::new([0x55u8; 32]));
|
|
let handle = SessionHandle(h);
|
|
let key = Zeroizing::new([0x55u8; 32]);
|
|
let empty = Manifest::new();
|
|
let bytes = manifest_encrypt(&handle, &serde_json::to_string(&empty).unwrap()).unwrap();
|
|
assert!(!bytes.is_empty());
|
|
// Decrypt via core directly (avoids js-sys on native).
|
|
let parsed: Manifest = decrypt_manifest(&bytes, &key).unwrap();
|
|
assert_eq!(parsed.items.len(), 0);
|
|
// Random nonces mean two encryptions of the same plaintext differ.
|
|
let bytes2 = manifest_encrypt(&handle, &serde_json::to_string(&empty).unwrap()).unwrap();
|
|
assert_ne!(bytes, bytes2, "nonces must differ");
|
|
}
|
|
}
|