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:
@@ -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, ¶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::{
|
||||
@@ -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();
|
||||
|
||||
5
extension/src/wasm.d.ts
vendored
5
extension/src/wasm.d.ts
vendored
@@ -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;
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user