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 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01McMF5pUJbCMricmFEnf2sG
This commit is contained in:
adlee-was-taken
2026-06-25 21:24:10 -04:00
parent eabc93bd84
commit 6cb12990ec
2 changed files with 105 additions and 1 deletions

View File

@@ -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<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, &params)
.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::{
@@ -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<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"
);
}
#[test]
fn insert_then_remove_clears_entry() {
session::clear();

View File

@@ -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<void>;
export function initSync(args: { module: WebAssembly.Module }): void;
}