From 6cb12990eccf747c666e7c1bd0b64244927b4826 Mon Sep 17 00:00:00 2001 From: adlee-was-taken Date: Thu, 25 Jun 2026 21:24:10 -0400 Subject: [PATCH] feat(wasm): unlock_with_secret + keyfile encode/decode bindings + equivalence proof Adds three WASM bindings for the pluggable second-factor work: - unlock_with_secret: mirrors unlock() but accepts a raw 32-byte secret instead of a carrier image, skipping imgsecret::extract - keyfile_encode: armors a 32-byte secret into relicario-keyfile-v1 format - keyfile_decode: decodes keyfile armor back to the raw 32-byte secret Adds native equivalence test proving that unlock_with_secret and unlock derive an identical Argon2id master key when given the same secret, passphrase, salt, and KDF params. Comparison is direct (master-key bytes), not cross-decrypt, so the test is both native-safe and unambiguous. Co-Authored-By: Claude Sonnet 4.6 Claude-Session: https://claude.ai/code/session_01McMF5pUJbCMricmFEnf2sG --- crates/relicario-wasm/src/lib.rs | 101 ++++++++++++++++++++++++++++++- extension/src/wasm.d.ts | 5 ++ 2 files changed, 105 insertions(+), 1 deletion(-) diff --git a/crates/relicario-wasm/src/lib.rs b/crates/relicario-wasm/src/lib.rs index f6c6b80..21eb255 100644 --- a/crates/relicario-wasm/src/lib.rs +++ b/crates/relicario-wasm/src/lib.rs @@ -70,7 +70,46 @@ pub fn lock(handle: &SessionHandle) -> bool { session::remove(handle.0) } -// Subsequent wasm_bindgen fns added in Tasks 19-21. +// ── 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::{ @@ -573,6 +612,66 @@ 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" + ); + } + #[test] fn insert_then_remove_clears_entry() { session::clear(); diff --git a/extension/src/wasm.d.ts b/extension/src/wasm.d.ts index 2553999..d8512b1 100644 --- a/extension/src/wasm.d.ts +++ b/extension/src/wasm.d.ts @@ -87,6 +87,11 @@ declare module 'relicario-wasm' { export function wasm_generate_recovery_qr(handle: SessionHandle, passphrase: string): string; export function wasm_unwrap_recovery_qr(payload_b64: string, passphrase: string): Uint8Array; + // Pluggable second factor: key-file bindings (Task 3) + export function unlock_with_secret(passphrase: string, secret: Uint8Array, salt: Uint8Array, params_json: string): SessionHandle; + export function keyfile_encode(secret: Uint8Array): Uint8Array; + export function keyfile_decode(bytes: Uint8Array): Uint8Array; + export default function init(module_or_path?: unknown): Promise; export function initSync(args: { module: WebAssembly.Module }): void; }