diff --git a/Cargo.lock b/Cargo.lock index d352788..b2d222f 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -2248,6 +2248,7 @@ dependencies = [ "serde", "serde-wasm-bindgen", "serde_json", + "ssh-key", "wasm-bindgen", "wasm-bindgen-test", "zeroize", diff --git a/crates/relicario-wasm/Cargo.toml b/crates/relicario-wasm/Cargo.toml index ffd5fae..1c49e95 100644 --- a/crates/relicario-wasm/Cargo.toml +++ b/crates/relicario-wasm/Cargo.toml @@ -21,6 +21,7 @@ base64 = "0.22" hex = "0.4" rand = "0.8" once_cell = "1" +ssh-key = { version = "0.6", features = ["ed25519", "std"] } [dev-dependencies] wasm-bindgen-test = "0.3" diff --git a/crates/relicario-wasm/src/lib.rs b/crates/relicario-wasm/src/lib.rs index f6c6b80..3a114d9 100644 --- a/crates/relicario-wasm/src/lib.rs +++ b/crates/relicario-wasm/src/lib.rs @@ -568,6 +568,106 @@ pub fn wasm_unwrap_recovery_qr( Ok(recovered.to_vec()) } +// ── Org vault WASM bridge ──────────────────────────────────────────────────── + +/// Decode an OpenSSH ed25519 private key string into a Zeroizing 32-byte seed. +/// +/// **Key-form finding (Step 1 of the task brief):** +/// `relicario_core::device::generate_keypair()` returns the private key as an +/// OpenSSH PEM blob — the multiline `-----BEGIN OPENSSH PRIVATE KEY-----...` format, +/// the same form the `ssh_key` crate emits via `PrivateKey::to_openssh()`. It is +/// NOT a raw-bytes base64 string. The plan's `device_private_key_base64` parameter +/// name is therefore misleading; the spec name `device_private_openssh` matches +/// reality. The SW stores this blob in WASM `DEVICE_STATE` and never exposes it +/// to JS. If it were ever serialised for cross-session persistence (future work), +/// it would travel as this same PEM string. +/// +/// This mirrors the first half of `relicario_core::device::sign()`. +fn decode_device_seed(private_key_openssh: &str) -> Result, String> { + use ssh_key::PrivateKey; + let private = PrivateKey::from_openssh(private_key_openssh) + .map_err(|e| format!("parse private key: {e}"))?; + let key_data = private + .key_data() + .ed25519() + .ok_or_else(|| "not an ed25519 key".to_string())?; + let secret_slice: &[u8] = key_data.private.as_ref(); + let secret_bytes: [u8; 32] = secret_slice + .try_into() + .map_err(|_| "invalid ed25519 seed length".to_string())?; + let mut seed = Zeroizing::new([0u8; 32]); + seed.copy_from_slice(&secret_bytes); + Ok(seed) +} + +/// Unwrap a member's ECIES-wrapped org master key into a session handle. +/// +/// `keys_blob` is the raw wrapped-key blob at `keys/.enc` in the org +/// repo — produced by `relicario_core::org::wrap_org_key`. `device_private_openssh` +/// is the device's ed25519 private key in OpenSSH PEM format (held in WASM memory +/// via `crates/relicario-wasm/src/device.rs`; never exposed to JS). +/// +/// The org key is held in the same Zeroizing WASM session registry as the personal +/// master key; org items share the personal `.enc` AEAD format, so the returned +/// handle works with `item_decrypt`/`manifest_decrypt` unchanged. +#[wasm_bindgen] +pub fn org_unwrap_key( + keys_blob: &[u8], + device_private_openssh: &str, +) -> Result { + let seed = decode_device_seed(device_private_openssh) + .map_err(|e| JsError::new(&format!("bad device key: {e}")))?; + let org_key = relicario_core::org::unwrap_org_key(keys_blob, &seed) + .map_err(|e| JsError::new(&format!("org unwrap failed: {e}")))?; + // image_secret slot is unused for org sessions; store a zeroized placeholder. + let handle = session::insert(org_key, Zeroizing::new([0u8; 32])); + Ok(SessionHandle(handle)) +} + +#[cfg(test)] +mod org_tests { + use super::*; + use relicario_core::org::wrap_org_key; + use zeroize::Zeroizing; + + /// Returns (public_openssh, private_openssh) using the real core keypair generator. + /// + /// Device private keys are produced by `relicario_core::device::generate_keypair()` + /// in OpenSSH PEM format ("-----BEGIN OPENSSH PRIVATE KEY-----..."), + /// NOT base64-encoded raw bytes. The plan parameter name "device_private_key_base64" + /// is misleading; the spec name "device_private_openssh" matches the actual format. + /// We call the real generator here (not a hand-rolled helper) so the test exercises + /// the exact production key format and cannot silently drift. + fn test_device_keypair() -> (String, String) { + let (priv_openssh, pub_openssh) = relicario_core::generate_keypair().unwrap(); + (pub_openssh, priv_openssh.to_string()) + } + + #[test] + fn org_unwrap_key_yields_a_session_that_decrypts_org_blobs() { + // Generate a device keypair, wrap a known org key to it, unwrap via the WASM + // path, then encrypt+decrypt an item through the returned handle to assert + // round-trip. + let org_key = Zeroizing::new([7u8; 32]); + let (pub_openssh, priv_openssh) = test_device_keypair(); + let wrapped = wrap_org_key(&org_key, &pub_openssh).unwrap(); + + let handle = org_unwrap_key(&wrapped, &priv_openssh).unwrap(); + // item_encrypt is native-safe: returns Vec; JsError is only reachable on + // the error path, which is not exercised here. + let ct = item_encrypt( + &handle, + r#"{"id":"a1b2c3d4e5f6a7b8","title":"Test","type":"secure_note","created":0,"modified":0,"core":{"type":"secure_note","body":"x"}}"#, + ) + .unwrap(); + // item_decrypt calls serde_wasm_bindgen::Serializer which panics off-wasm. + // Use relicario_core::decrypt_item directly, mirroring the + // manifest_round_trip_via_handle approach in session_tests. + let pt: relicario_core::Item = relicario_core::decrypt_item(&ct, &org_key).unwrap(); + assert!(format!("{:?}", pt.core).contains("SecureNote")); + } +} + #[cfg(test)] mod session_tests { use super::*; diff --git a/extension/src/wasm.d.ts b/extension/src/wasm.d.ts index 2553999..47feb4f 100644 --- a/extension/src/wasm.d.ts +++ b/extension/src/wasm.d.ts @@ -87,6 +87,8 @@ 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; + export function org_unwrap_key(keys_blob: Uint8Array, device_private_openssh: string): SessionHandle; + export default function init(module_or_path?: unknown): Promise; export function initSync(args: { module: WebAssembly.Module }): void; }