From fb1f28161cfa6c226518d71965fae9faf46d2fd0 Mon Sep 17 00:00:00 2001 From: adlee-was-taken Date: Sat, 2 May 2026 12:27:50 -0400 Subject: [PATCH] feat(wasm): secure device API (private keys never cross to JS) - register_device() generates signing + deploy keypairs via core device module, stores them in DEVICE_STATE (once_cell Lazy), and returns only public keys to JS - sign_for_git() signs data using the internal signing key - get_device_info() returns name and public keys; returns null if not registered - clear_device() zeroes and drops device state (logout / re-registration) - Removed generate_device_keypair() which exposed raw private key bytes Fixes audit I5: private key material no longer crosses the WASM boundary. Co-Authored-By: Claude Sonnet 4.6 --- crates/relicario-wasm/Cargo.toml | 1 + crates/relicario-wasm/src/device.rs | 71 +++++++++++++++++++++++++++++ crates/relicario-wasm/src/lib.rs | 58 +++++++++++++++++------ 3 files changed, 116 insertions(+), 14 deletions(-) create mode 100644 crates/relicario-wasm/src/device.rs diff --git a/crates/relicario-wasm/Cargo.toml b/crates/relicario-wasm/Cargo.toml index dada56b..d0d8234 100644 --- a/crates/relicario-wasm/Cargo.toml +++ b/crates/relicario-wasm/Cargo.toml @@ -19,6 +19,7 @@ ed25519-dalek = { version = "2", features = ["rand_core"] } base64 = "0.22" hex = "0.4" rand = "0.8" +once_cell = "1" [dev-dependencies] wasm-bindgen-test = "0.3" diff --git a/crates/relicario-wasm/src/device.rs b/crates/relicario-wasm/src/device.rs new file mode 100644 index 0000000..68fafc4 --- /dev/null +++ b/crates/relicario-wasm/src/device.rs @@ -0,0 +1,71 @@ +//! WASM device key management -- private keys never cross to JS. + +use std::sync::Mutex; + +use once_cell::sync::Lazy; +use zeroize::Zeroizing; + +use relicario_core::device as core_device; + +/// In-memory device key storage (private keys held in WASM linear memory). +static DEVICE_STATE: Lazy>> = Lazy::new(|| Mutex::new(None)); + +struct DeviceState { + name: String, + signing_private: Zeroizing, + signing_public: String, + /// Deploy key stored for future SSH git operations; not yet used for signing. + #[allow(dead_code)] + deploy_private: Zeroizing, + deploy_public: String, +} + +/// Register a new device, storing the keypairs internally and returning +/// only the public keys. Private keys never leave WASM memory. +pub fn register_device(name: &str) -> Result<(String, String), String> { + let (signing_priv, signing_pub) = + core_device::generate_keypair().map_err(|e| e.to_string())?; + let (deploy_priv, deploy_pub) = + core_device::generate_keypair().map_err(|e| e.to_string())?; + + let state = DeviceState { + name: name.to_string(), + signing_private: signing_priv, + signing_public: signing_pub.clone(), + deploy_private: deploy_priv, + deploy_public: deploy_pub.clone(), + }; + + *DEVICE_STATE.lock().unwrap() = Some(state); + + Ok((signing_pub, deploy_pub)) +} + +/// Sign `data` using the registered device's signing key. +/// Returns a base64-encoded signature. +pub fn sign_for_git(data: &[u8]) -> Result { + let guard = DEVICE_STATE.lock().unwrap(); + let state = guard + .as_ref() + .ok_or_else(|| "no device registered".to_string())?; + + core_device::sign(&state.signing_private, data).map_err(|e| e.to_string()) +} + +/// Return current device info: (name, signing_public_key, deploy_public_key). +/// Returns None if no device has been registered in this session. +pub fn get_device_info() -> Option<(String, String, String)> { + let guard = DEVICE_STATE.lock().unwrap(); + guard.as_ref().map(|s| { + ( + s.name.clone(), + s.signing_public.clone(), + s.deploy_public.clone(), + ) + }) +} + +/// Clear device state (call on logout or before re-registration). +pub fn clear_device() { + *DEVICE_STATE.lock().unwrap() = None; +} diff --git a/crates/relicario-wasm/src/lib.rs b/crates/relicario-wasm/src/lib.rs index c57c375..ff1bed6 100644 --- a/crates/relicario-wasm/src/lib.rs +++ b/crates/relicario-wasm/src/lib.rs @@ -5,6 +5,7 @@ //! looked up per call via a u32 handle. JS cannot read key bytes. mod session; +mod device; use wasm_bindgen::prelude::*; @@ -206,26 +207,53 @@ pub fn rate_passphrase(p: &str) -> Result { })) } -use ed25519_dalek::SigningKey; -use base64::Engine; - -/// Generate an ed25519 keypair for device registration. -/// Returns JSON: { "public_key_hex": "...", "private_key_base64": "..." } +/// Register a new device, generating ed25519 keypairs for signing and deploy. +/// Returns JSON: { "signing_public_key": "ssh-ed25519 ...", "deploy_public_key": "ssh-ed25519 ..." } +/// Private keys are kept internal to WASM and never cross to JS. #[wasm_bindgen] -pub fn generate_device_keypair() -> Result { - let mut rng = rand::thread_rng(); - let signing_key = SigningKey::generate(&mut rng); - let verifying_key = signing_key.verifying_key(); - - let public_hex = hex::encode(verifying_key.as_bytes()); - let private_b64 = base64::engine::general_purpose::STANDARD.encode(signing_key.as_bytes()); +pub fn register_device(name: &str) -> Result { + let (signing_pub, deploy_pub) = + device::register_device(name).map_err(|e| JsError::new(&e))?; js_value_for(&serde_json::json!({ - "public_key_hex": public_hex, - "private_key_base64": private_b64, + "signing_public_key": signing_pub, + "deploy_public_key": deploy_pub, })) } +/// Sign `data` using the registered device's signing key. +/// Returns JSON: { "signature": "" } +/// Errors if no device has been registered via register_device(). +#[wasm_bindgen] +pub fn sign_for_git(data: &[u8]) -> Result { + let signature = device::sign_for_git(data).map_err(|e| JsError::new(&e))?; + + js_value_for(&serde_json::json!({ + "signature": signature, + })) +} + +/// Get the current device's name and public keys. +/// Returns JSON: { "name": "...", "signing_public_key": "...", "deploy_public_key": "..." } +/// Returns null if no device is registered in this session. +#[wasm_bindgen] +pub fn get_device_info() -> Result { + match device::get_device_info() { + Some((name, signing_pub, deploy_pub)) => js_value_for(&serde_json::json!({ + "name": name, + "signing_public_key": signing_pub, + "deploy_public_key": deploy_pub, + })), + None => Ok(JsValue::NULL), + } +} + +/// Clear the in-memory device state (call on logout or before re-registration). +#[wasm_bindgen] +pub fn clear_device() { + device::clear_device(); +} + /// Extract field history from a decrypted item JSON. /// Returns JSON array of { field_id, field_name, current_value, entries: [{ value, changed_at }] } #[wasm_bindgen] @@ -307,6 +335,8 @@ pub fn totp_compute( // ── Backup container bridge ───────────────────────────────────────────────── +use base64::Engine; + use relicario_core::backup::{ pack_backup as core_pack_backup, unpack_backup as core_unpack_backup,