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<Mutex>), 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 <noreply@anthropic.com>
This commit is contained in:
adlee-was-taken
2026-05-02 12:27:50 -04:00
parent 520f6ec72c
commit fb1f28161c
3 changed files with 116 additions and 14 deletions

View File

@@ -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<Mutex<Option<DeviceState>>> = Lazy::new(|| Mutex::new(None));
struct DeviceState {
name: String,
signing_private: Zeroizing<String>,
signing_public: String,
/// Deploy key stored for future SSH git operations; not yet used for signing.
#[allow(dead_code)]
deploy_private: Zeroizing<String>,
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<String, String> {
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;
}