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

@@ -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"

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;
}

View File

@@ -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<JsValue, JsError> {
}))
}
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<JsValue, JsError> {
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<JsValue, JsError> {
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": "<base64>" }
/// Errors if no device has been registered via register_device().
#[wasm_bindgen]
pub fn sign_for_git(data: &[u8]) -> Result<JsValue, JsError> {
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<JsValue, JsError> {
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,