merge: origin/main (Dev-D keyfile, d649203) into org-foundation

Additive conflict in extension/src/wasm.d.ts resolved by keeping BOTH the
org_* declarations (org_unwrap_key/org_manifest_decrypt/org_manifest_encrypt)
and the keyfile_* declarations (unlock_with_secret/keyfile_encode/keyfile_decode).
Rust lib.rs (core + wasm) auto-merged cleanly with both feature sets.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_014s527M917W47LDrfQ4t47g
This commit is contained in:
adlee-was-taken
2026-06-26 21:35:56 -04:00
18 changed files with 858 additions and 55 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::{
@@ -714,6 +753,76 @@ 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"
);
// Negative control: a DIFFERENT secret must derive a DIFFERENT master key
// (guards against a hypothetical constant/secret-insensitive KDF regression).
let h_other = unlock_with_secret("correct horse", &[4u8; 32], &salt, params)
.expect("unlock_with_secret other");
assert_ne!(
extract(h_img.value()),
extract(h_other.value()),
"a different secret must derive a different master key"
);
}
#[test]
fn insert_then_remove_clears_entry() {
session::clear();