feat(wasm): opaque SessionHandle bridge with unlock/lock
Master key never leaves WASM linear memory. Held in Zeroizing<[u8;32]> inside a thread_local HashMap keyed by u32. lock() removes + zeroizes. Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
@@ -10,11 +10,10 @@ crate-type = ["cdylib", "rlib"]
|
|||||||
[dependencies]
|
[dependencies]
|
||||||
relicario-core = { path = "../relicario-core" }
|
relicario-core = { path = "../relicario-core" }
|
||||||
wasm-bindgen = "0.2"
|
wasm-bindgen = "0.2"
|
||||||
js-sys = "0.3"
|
serde-wasm-bindgen = "0.6"
|
||||||
serde_json = "1"
|
serde_json = "1"
|
||||||
hmac = "0.12"
|
serde = { version = "1", features = ["derive"] }
|
||||||
sha1 = "0.10"
|
zeroize = "1"
|
||||||
data-encoding = "2"
|
|
||||||
getrandom = { version = "0.2", features = ["js"] }
|
getrandom = { version = "0.2", features = ["js"] }
|
||||||
|
|
||||||
[dev-dependencies]
|
[dev-dependencies]
|
||||||
|
|||||||
@@ -1,364 +1,73 @@
|
|||||||
//! WASM bindings for the relicario password manager.
|
//! WASM bindings for relicario.
|
||||||
//!
|
//!
|
||||||
//! This crate wraps [`relicario_core`] for use in a Chrome MV3 browser extension via
|
//! The bridge exposes an opaque `SessionHandle` API: the master key is held
|
||||||
//! `wasm-bindgen`. Every function marked `#[wasm_bindgen]` is callable from
|
//! entirely in WASM linear memory, wrapped in `Zeroizing<[u8; 32]>`, and
|
||||||
//! JavaScript after loading the compiled `.wasm` module.
|
//! looked up per call via a u32 handle. JS cannot read key bytes.
|
||||||
//!
|
|
||||||
//! All crypto operations run entirely in the browser -- the extension never sends
|
mod session;
|
||||||
//! secrets to any server. The TOTP function lets the extension generate live 6-digit
|
|
||||||
//! authenticator codes without a separate authenticator app.
|
|
||||||
//!
|
|
||||||
//! ## Design notes
|
|
||||||
//!
|
|
||||||
//! - Functions accept and return `Vec<u8>`, `&[u8]`, and `String` -- wasm-bindgen
|
|
||||||
//! handles the JS ↔ Rust marshalling automatically (typed arrays for bytes, strings
|
|
||||||
//! for JSON).
|
|
||||||
//! - Errors are mapped to `JsValue` strings so they surface as thrown exceptions in JS.
|
|
||||||
//! - `generate_password` and `generate_entry_id` use `js_sys::Math::random()` because
|
|
||||||
//! `OsRng`/`getrandom` requires special WASM configuration. `Math.random()` is
|
|
||||||
//! sufficient for these non-security-critical operations (password character selection
|
|
||||||
//! and identifier generation).
|
|
||||||
|
|
||||||
use wasm_bindgen::prelude::*;
|
use wasm_bindgen::prelude::*;
|
||||||
|
|
||||||
use relicario_core::crypto::{self, KdfParams};
|
use relicario_core::{derive_master_key, imgsecret, KdfParams};
|
||||||
use relicario_core::entry::Entry;
|
|
||||||
use relicario_core::vault;
|
|
||||||
use relicario_core::imgsecret;
|
|
||||||
|
|
||||||
use hmac::{Hmac, Mac};
|
/// Handle type returned from `unlock`. Backed by a `u32`; opaque to JS.
|
||||||
use sha1::Sha1;
|
|
||||||
|
|
||||||
/// Derive a 256-bit master key from a passphrase, image secret, salt, and KDF parameters.
|
|
||||||
///
|
|
||||||
/// The `params_json` argument is a JSON object with fields `argon2_m`, `argon2_t`,
|
|
||||||
/// and `argon2_p` (matching [`KdfParams`]). Example:
|
|
||||||
///
|
|
||||||
/// ```json
|
|
||||||
/// {"argon2_m": 65536, "argon2_t": 3, "argon2_p": 4}
|
|
||||||
/// ```
|
|
||||||
///
|
|
||||||
/// Returns a 32-byte `Uint8Array` in JavaScript.
|
|
||||||
#[wasm_bindgen]
|
#[wasm_bindgen]
|
||||||
pub fn derive_master_key(
|
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,
|
passphrase: &str,
|
||||||
image_secret: &[u8],
|
image_bytes: &[u8],
|
||||||
salt: &[u8],
|
salt: &[u8],
|
||||||
params_json: &str,
|
params_json: &str,
|
||||||
) -> Result<Vec<u8>, JsValue> {
|
) -> Result<SessionHandle, JsError> {
|
||||||
let params: KdfParams =
|
let params: KdfParams = serde_json::from_str(params_json)
|
||||||
serde_json::from_str(params_json).map_err(|e| JsValue::from_str(&e.to_string()))?;
|
.map_err(|e| JsError::new(&format!("params: {e}")))?;
|
||||||
|
let image_secret = imgsecret::extract(image_bytes)
|
||||||
let image_secret: &[u8; 32] = image_secret
|
.map_err(|e| JsError::new(&e.to_string()))?;
|
||||||
.try_into()
|
let salt_arr: &[u8; 32] = salt.try_into()
|
||||||
.map_err(|_| JsValue::from_str("image_secret must be exactly 32 bytes"))?;
|
.map_err(|_| JsError::new("salt must be exactly 32 bytes"))?;
|
||||||
let salt: &[u8; 32] = salt
|
let master_key = derive_master_key(passphrase.as_bytes(), &image_secret, salt_arr, ¶ms)
|
||||||
.try_into()
|
.map_err(|e| JsError::new(&e.to_string()))?;
|
||||||
.map_err(|_| JsValue::from_str("salt must be exactly 32 bytes"))?;
|
let handle = session::insert(master_key);
|
||||||
|
Ok(SessionHandle(handle))
|
||||||
let key = crypto::derive_master_key(passphrase.as_bytes(), image_secret, salt, ¶ms)
|
|
||||||
.map_err(|e| JsValue::from_str(&e.to_string()))?;
|
|
||||||
|
|
||||||
Ok(key.to_vec())
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Encrypt arbitrary plaintext bytes under a 256-bit key using XChaCha20-Poly1305.
|
|
||||||
///
|
|
||||||
/// Returns the ciphertext as a `Uint8Array` in the format:
|
|
||||||
/// `version(1) || nonce(24) || ciphertext+tag`.
|
|
||||||
#[wasm_bindgen]
|
#[wasm_bindgen]
|
||||||
pub fn encrypt(plaintext: &[u8], key: &[u8]) -> Result<Vec<u8>, JsValue> {
|
pub fn lock(handle: &SessionHandle) -> bool {
|
||||||
let key: &[u8; 32] = key
|
session::remove(handle.0)
|
||||||
.try_into()
|
|
||||||
.map_err(|_| JsValue::from_str("key must be exactly 32 bytes"))?;
|
|
||||||
crypto::encrypt(key, plaintext).map_err(|e| JsValue::from_str(&e.to_string()))
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Decrypt a ciphertext blob produced by [`encrypt`], returning the original plaintext.
|
// Subsequent wasm_bindgen fns added in Tasks 19-21.
|
||||||
///
|
|
||||||
/// Returns the plaintext as a `Uint8Array`. Throws if the key is wrong or the data
|
|
||||||
/// has been tampered with.
|
|
||||||
#[wasm_bindgen]
|
|
||||||
pub fn decrypt(ciphertext: &[u8], key: &[u8]) -> Result<Vec<u8>, JsValue> {
|
|
||||||
let key: &[u8; 32] = key
|
|
||||||
.try_into()
|
|
||||||
.map_err(|_| JsValue::from_str("key must be exactly 32 bytes"))?;
|
|
||||||
crypto::decrypt(key, ciphertext).map_err(|e| JsValue::from_str(&e.to_string()))
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Extract the 32-byte steganographic secret from a JPEG image.
|
|
||||||
///
|
|
||||||
/// Returns a 32-byte `Uint8Array` containing the embedded secret.
|
|
||||||
/// Throws if the image is not a valid JPEG or the secret cannot be recovered.
|
|
||||||
#[wasm_bindgen]
|
|
||||||
pub fn extract_image_secret(jpeg_bytes: &[u8]) -> Result<Vec<u8>, JsValue> {
|
|
||||||
let secret =
|
|
||||||
imgsecret::extract(jpeg_bytes).map_err(|e| JsValue::from_str(&e.to_string()))?;
|
|
||||||
Ok(secret.to_vec())
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Embed a 256-bit secret into a carrier JPEG image.
|
|
||||||
#[wasm_bindgen]
|
|
||||||
pub fn embed_image_secret(carrier_jpeg: &[u8], secret: &[u8]) -> Result<Vec<u8>, JsValue> {
|
|
||||||
let secret: [u8; 32] = secret
|
|
||||||
.try_into()
|
|
||||||
.map_err(|_| JsValue::from_str("secret must be exactly 32 bytes"))?;
|
|
||||||
relicario_core::imgsecret::embed(carrier_jpeg, &secret)
|
|
||||||
.map_err(|e| JsValue::from_str(&e.to_string()))
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Encrypt an [`Entry`] (given as a JSON string) under the master key.
|
|
||||||
///
|
|
||||||
/// The `entry_json` must deserialize into an [`Entry`] struct. Returns the
|
|
||||||
/// ciphertext as a `Uint8Array`.
|
|
||||||
#[wasm_bindgen]
|
|
||||||
pub fn encrypt_entry(entry_json: &str, key: &[u8]) -> Result<Vec<u8>, JsValue> {
|
|
||||||
let key: &[u8; 32] = key
|
|
||||||
.try_into()
|
|
||||||
.map_err(|_| JsValue::from_str("key must be exactly 32 bytes"))?;
|
|
||||||
let entry: Entry =
|
|
||||||
serde_json::from_str(entry_json).map_err(|e| JsValue::from_str(&e.to_string()))?;
|
|
||||||
vault::encrypt_entry(key, &entry).map_err(|e| JsValue::from_str(&e.to_string()))
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Decrypt an entry ciphertext blob and return the entry as a JSON string.
|
|
||||||
///
|
|
||||||
/// Throws if the key is wrong, the data is tampered, or the decrypted JSON is malformed.
|
|
||||||
#[wasm_bindgen]
|
|
||||||
pub fn decrypt_entry(ciphertext: &[u8], key: &[u8]) -> Result<String, JsValue> {
|
|
||||||
let key: &[u8; 32] = key
|
|
||||||
.try_into()
|
|
||||||
.map_err(|_| JsValue::from_str("key must be exactly 32 bytes"))?;
|
|
||||||
let entry =
|
|
||||||
vault::decrypt_entry(key, ciphertext).map_err(|e| JsValue::from_str(&e.to_string()))?;
|
|
||||||
serde_json::to_string(&entry).map_err(|e| JsValue::from_str(&e.to_string()))
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Encrypt a [`Manifest`] (given as a JSON string) under the master key.
|
|
||||||
///
|
|
||||||
/// Returns the ciphertext as a `Uint8Array`.
|
|
||||||
#[wasm_bindgen]
|
|
||||||
pub fn encrypt_manifest(manifest_json: &str, key: &[u8]) -> Result<Vec<u8>, JsValue> {
|
|
||||||
let key: &[u8; 32] = key
|
|
||||||
.try_into()
|
|
||||||
.map_err(|_| JsValue::from_str("key must be exactly 32 bytes"))?;
|
|
||||||
let manifest: relicario_core::entry::Manifest =
|
|
||||||
serde_json::from_str(manifest_json).map_err(|e| JsValue::from_str(&e.to_string()))?;
|
|
||||||
vault::encrypt_manifest(key, &manifest).map_err(|e| JsValue::from_str(&e.to_string()))
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Decrypt a manifest ciphertext blob and return the manifest as a JSON string.
|
|
||||||
///
|
|
||||||
/// Throws if the key is wrong, the data is tampered, or the decrypted JSON is malformed.
|
|
||||||
#[wasm_bindgen]
|
|
||||||
pub fn decrypt_manifest(ciphertext: &[u8], key: &[u8]) -> Result<String, JsValue> {
|
|
||||||
let key: &[u8; 32] = key
|
|
||||||
.try_into()
|
|
||||||
.map_err(|_| JsValue::from_str("key must be exactly 32 bytes"))?;
|
|
||||||
let manifest = vault::decrypt_manifest(key, ciphertext)
|
|
||||||
.map_err(|e| JsValue::from_str(&e.to_string()))?;
|
|
||||||
serde_json::to_string(&manifest).map_err(|e| JsValue::from_str(&e.to_string()))
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Generate a 6-digit TOTP code per RFC 6238.
|
|
||||||
///
|
|
||||||
/// # Arguments
|
|
||||||
///
|
|
||||||
/// - `secret_base32`: the shared secret encoded in base32 (with or without padding).
|
|
||||||
/// - `timestamp_secs`: the current Unix timestamp in seconds.
|
|
||||||
///
|
|
||||||
/// # Algorithm
|
|
||||||
///
|
|
||||||
/// 1. Decode the base32 secret.
|
|
||||||
/// 2. Compute the time step: `T = timestamp_secs / 30`.
|
|
||||||
/// 3. Compute `HMAC-SHA1(secret, T as big-endian u64)`.
|
|
||||||
/// 4. Dynamic truncation: extract a 4-byte segment from the HMAC output at an
|
|
||||||
/// offset determined by the last nibble.
|
|
||||||
/// 5. Mask the high bit, take modulo 10^6, and zero-pad to 6 digits.
|
|
||||||
///
|
|
||||||
/// Returns a 6-character string like `"287082"`.
|
|
||||||
#[wasm_bindgen]
|
|
||||||
pub fn generate_totp(secret_base32: &str, timestamp_secs: u64) -> Result<String, JsValue> {
|
|
||||||
generate_totp_inner(secret_base32, timestamp_secs)
|
|
||||||
.map_err(|e| JsValue::from_str(&e.to_string()))
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Inner TOTP implementation that returns a standard Result for testability
|
|
||||||
/// (avoids depending on JsValue in native tests).
|
|
||||||
fn generate_totp_inner(
|
|
||||||
secret_base32: &str,
|
|
||||||
timestamp_secs: u64,
|
|
||||||
) -> std::result::Result<String, String> {
|
|
||||||
// Normalize: strip whitespace, uppercase, remove padding for lenient decode,
|
|
||||||
// then re-pad to a multiple of 8 for strict base32.
|
|
||||||
let cleaned: String = secret_base32
|
|
||||||
.chars()
|
|
||||||
.filter(|c| !c.is_whitespace())
|
|
||||||
.collect::<String>()
|
|
||||||
.to_uppercase()
|
|
||||||
.trim_end_matches('=')
|
|
||||||
.to_string();
|
|
||||||
|
|
||||||
// Re-pad to a multiple of 8 characters (base32 requirement).
|
|
||||||
let padded = {
|
|
||||||
let remainder = cleaned.len() % 8;
|
|
||||||
if remainder == 0 {
|
|
||||||
cleaned
|
|
||||||
} else {
|
|
||||||
let pad_count = 8 - remainder;
|
|
||||||
format!("{}{}", cleaned, "=".repeat(pad_count))
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
let secret = data_encoding::BASE32
|
|
||||||
.decode(padded.as_bytes())
|
|
||||||
.map_err(|e| format!("invalid base32 secret: {}", e))?;
|
|
||||||
|
|
||||||
// Time step: T = floor(timestamp / 30)
|
|
||||||
let time_step = timestamp_secs / 30;
|
|
||||||
|
|
||||||
// HMAC-SHA1(secret, time_step as big-endian u64)
|
|
||||||
type HmacSha1 = Hmac<Sha1>;
|
|
||||||
let mut mac =
|
|
||||||
HmacSha1::new_from_slice(&secret).map_err(|e| format!("HMAC init failed: {}", e))?;
|
|
||||||
mac.update(&time_step.to_be_bytes());
|
|
||||||
let result = mac.finalize().into_bytes();
|
|
||||||
|
|
||||||
// Dynamic truncation per RFC 4226 section 5.4
|
|
||||||
let offset = (result[19] & 0x0F) as usize;
|
|
||||||
let code = ((result[offset] as u32 & 0x7F) << 24)
|
|
||||||
| ((result[offset + 1] as u32) << 16)
|
|
||||||
| ((result[offset + 2] as u32) << 8)
|
|
||||||
| (result[offset + 3] as u32);
|
|
||||||
|
|
||||||
// 6-digit code, zero-padded
|
|
||||||
Ok(format!("{:06}", code % 1_000_000))
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Generate a random password of the given length.
|
|
||||||
///
|
|
||||||
/// Uses `js_sys::Math::random()` for randomness (not cryptographically secure,
|
|
||||||
/// but sufficient for password character selection). The character set includes
|
|
||||||
/// uppercase, lowercase, digits, and common symbols.
|
|
||||||
///
|
|
||||||
/// This function is only available in WASM -- it will panic in native builds
|
|
||||||
/// because `js_sys::Math::random()` requires a JS runtime.
|
|
||||||
#[wasm_bindgen]
|
|
||||||
pub fn generate_password(length: u32) -> String {
|
|
||||||
const CHARSET: &[u8] = b"ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789!@#$%^&*()-_=+[]{}|;:,.<>?";
|
|
||||||
|
|
||||||
(0..length)
|
|
||||||
.map(|_| {
|
|
||||||
let idx = (js_sys::Math::random() * CHARSET.len() as f64) as usize;
|
|
||||||
CHARSET[idx % CHARSET.len()] as char
|
|
||||||
})
|
|
||||||
.collect()
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Generate a random 8-character hex string for use as an entry ID.
|
|
||||||
///
|
|
||||||
/// Uses `js_sys::Math::random()` for randomness. Entry IDs are not
|
|
||||||
/// security-sensitive -- they are just opaque identifiers.
|
|
||||||
///
|
|
||||||
/// This function is only available in WASM -- it will panic in native builds
|
|
||||||
/// because `js_sys::Math::random()` requires a JS runtime.
|
|
||||||
#[wasm_bindgen]
|
|
||||||
pub fn generate_entry_id() -> String {
|
|
||||||
(0..4)
|
|
||||||
.map(|_| {
|
|
||||||
let byte = (js_sys::Math::random() * 256.0) as u8;
|
|
||||||
format!("{:02x}", byte)
|
|
||||||
})
|
|
||||||
.collect()
|
|
||||||
}
|
|
||||||
|
|
||||||
#[cfg(test)]
|
#[cfg(test)]
|
||||||
mod tests {
|
mod session_tests {
|
||||||
use super::*;
|
use super::*;
|
||||||
|
use zeroize::Zeroizing;
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn totp_rfc6238_test_vector() {
|
fn insert_then_remove_clears_entry() {
|
||||||
// secret = "12345678901234567890" ASCII, time = 59, expected = "287082"
|
session::clear();
|
||||||
let secret_b32 = data_encoding::BASE32.encode(b"12345678901234567890");
|
let h = session::insert(Zeroizing::new([0x11u8; 32]));
|
||||||
let result = generate_totp_inner(&secret_b32, 59).unwrap();
|
assert_ne!(h, 0);
|
||||||
assert_eq!(result, "287082");
|
assert!(session::remove(h));
|
||||||
|
assert!(!session::remove(h)); // second remove false
|
||||||
}
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn totp_rfc6238_test_vector_2() {
|
fn with_yields_key_only_while_session_lives() {
|
||||||
// time = 1111111109, expected = "081804"
|
session::clear();
|
||||||
let secret_b32 = data_encoding::BASE32.encode(b"12345678901234567890");
|
let h = session::insert(Zeroizing::new([0x22u8; 32]));
|
||||||
let result = generate_totp_inner(&secret_b32, 1111111109).unwrap();
|
let byte = session::with(h, |k| k[0]);
|
||||||
assert_eq!(result, "081804");
|
assert_eq!(byte, Some(0x22));
|
||||||
}
|
session::remove(h);
|
||||||
|
let byte = session::with(h, |k| k[0]);
|
||||||
#[test]
|
assert_eq!(byte, None);
|
||||||
fn totp_rfc6238_test_vector_3() {
|
|
||||||
// time = 1234567890, expected = "005924"
|
|
||||||
let secret_b32 = data_encoding::BASE32.encode(b"12345678901234567890");
|
|
||||||
let result = generate_totp_inner(&secret_b32, 1234567890).unwrap();
|
|
||||||
assert_eq!(result, "005924");
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn totp_invalid_base32_fails() {
|
|
||||||
let result = generate_totp_inner("not-valid-base32!!!", 1000);
|
|
||||||
assert!(result.is_err());
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn derive_key_via_wasm_wrapper() {
|
|
||||||
let params = r#"{"argon2_m":256,"argon2_t":1,"argon2_p":1}"#;
|
|
||||||
let key =
|
|
||||||
derive_master_key("test-passphrase", &[0x42u8; 32], &[0x01u8; 32], params).unwrap();
|
|
||||||
assert_eq!(key.len(), 32);
|
|
||||||
let key2 =
|
|
||||||
derive_master_key("test-passphrase", &[0x42u8; 32], &[0x01u8; 32], params).unwrap();
|
|
||||||
assert_eq!(key, key2);
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn encrypt_decrypt_via_wasm_wrapper() {
|
|
||||||
let key = [0xABu8; 32];
|
|
||||||
let ciphertext = encrypt(b"hello wasm", &key).unwrap();
|
|
||||||
let decrypted = decrypt(&ciphertext, &key).unwrap();
|
|
||||||
assert_eq!(decrypted, b"hello wasm");
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn embed_then_extract_round_trip() {
|
|
||||||
use image::codecs::jpeg::JpegEncoder;
|
|
||||||
use image::{ImageBuffer, ImageEncoder, Rgb};
|
|
||||||
|
|
||||||
let img = ImageBuffer::from_fn(400, 300, |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 jpeg_buf = Vec::new();
|
|
||||||
let encoder = JpegEncoder::new_with_quality(&mut jpeg_buf, 92);
|
|
||||||
encoder.write_image(img.as_raw(), 400, 300, image::ExtendedColorType::Rgb8).unwrap();
|
|
||||||
|
|
||||||
let secret = [0xDE, 0xAD, 0xBE, 0xEF, 0x01, 0x02, 0x03, 0x04,
|
|
||||||
0x05, 0x06, 0x07, 0x08, 0x09, 0x0A, 0x0B, 0x0C,
|
|
||||||
0x0D, 0x0E, 0x0F, 0x10, 0x11, 0x12, 0x13, 0x14,
|
|
||||||
0x15, 0x16, 0x17, 0x18, 0x19, 0x1A, 0x1B, 0x1Cu8];
|
|
||||||
|
|
||||||
let stego = embed_image_secret(&jpeg_buf, &secret).unwrap();
|
|
||||||
let extracted = extract_image_secret(&stego).unwrap();
|
|
||||||
assert_eq!(extracted, secret);
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn encrypt_entry_decrypt_entry_round_trip() {
|
|
||||||
let key = [0xABu8; 32];
|
|
||||||
let entry_json = r#"{"name":"Test","password":"secret","created_at":"2024-01-01T00:00:00Z","updated_at":"2024-01-01T00:00:00Z"}"#;
|
|
||||||
let ciphertext = encrypt_entry(entry_json, &key).unwrap();
|
|
||||||
let result = decrypt_entry(&ciphertext, &key).unwrap();
|
|
||||||
let parsed: serde_json::Value = serde_json::from_str(&result).unwrap();
|
|
||||||
assert_eq!(parsed["name"], "Test");
|
|
||||||
assert_eq!(parsed["password"], "secret");
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
41
crates/relicario-wasm/src/session.rs
Normal file
41
crates/relicario-wasm/src/session.rs
Normal file
@@ -0,0 +1,41 @@
|
|||||||
|
//! Opaque session-handle bridge. The master key never leaves WASM linear
|
||||||
|
//! memory; JS receives only a u32 handle that it passes back on every
|
||||||
|
//! subsequent call.
|
||||||
|
|
||||||
|
use std::cell::RefCell;
|
||||||
|
use std::collections::HashMap;
|
||||||
|
use zeroize::Zeroizing;
|
||||||
|
|
||||||
|
thread_local! {
|
||||||
|
static SESSIONS: RefCell<HashMap<u32, Zeroizing<[u8; 32]>>> = RefCell::new(HashMap::new());
|
||||||
|
static NEXT_HANDLE: RefCell<u32> = const { RefCell::new(1) };
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn insert(key: Zeroizing<[u8; 32]>) -> u32 {
|
||||||
|
let handle = NEXT_HANDLE.with(|n| {
|
||||||
|
let mut n = n.borrow_mut();
|
||||||
|
let h = *n;
|
||||||
|
*n = n.wrapping_add(1);
|
||||||
|
if *n == 0 { *n = 1; } // avoid reserving 0 as a valid handle
|
||||||
|
h
|
||||||
|
});
|
||||||
|
SESSIONS.with(|s| { s.borrow_mut().insert(handle, key); });
|
||||||
|
handle
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn with<F, R>(handle: u32, f: F) -> Option<R>
|
||||||
|
where
|
||||||
|
F: FnOnce(&Zeroizing<[u8; 32]>) -> R,
|
||||||
|
{
|
||||||
|
SESSIONS.with(|s| s.borrow().get(&handle).map(f))
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn remove(handle: u32) -> bool {
|
||||||
|
SESSIONS.with(|s| s.borrow_mut().remove(&handle).is_some())
|
||||||
|
}
|
||||||
|
|
||||||
|
/// For tests only — empty the table and wipe all sessions.
|
||||||
|
#[cfg(test)]
|
||||||
|
pub fn clear() {
|
||||||
|
SESSIONS.with(|s| s.borrow_mut().clear());
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user