diff --git a/crates/relicario-wasm/src/device.rs b/crates/relicario-wasm/src/device.rs index 68fafc4..3ecef05 100644 --- a/crates/relicario-wasm/src/device.rs +++ b/crates/relicario-wasm/src/device.rs @@ -3,6 +3,7 @@ use std::sync::Mutex; use once_cell::sync::Lazy; +use serde::{Deserialize, Serialize}; use zeroize::Zeroizing; use relicario_core::device as core_device; @@ -20,6 +21,27 @@ struct DeviceState { deploy_public: String, } +/// Serialization-safe mirror of `DeviceState`. +/// +/// Fields are plain `String` (not `Zeroizing`) because serde requires `Sized` +/// owned types. The byte vector produced by `export_state_bytes` is wrapped in +/// `Zeroizing>`; `import_state_bytes` re-wraps the private key fields +/// back into `Zeroizing` on decode. +/// +/// # Security note +/// +/// This struct itself is NOT zeroed on drop. The invariant that matters is: +/// (a) `persist_device_key` in lib.rs encrypts the bytes before returning to JS, +/// (b) `export_state_bytes` is NOT a `#[wasm_bindgen]` export. +#[derive(Serialize, Deserialize)] +struct PersistedDeviceState { + name: String, + signing_private: String, + signing_public: String, + deploy_private: 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> { @@ -69,3 +91,66 @@ pub fn get_device_info() -> Option<(String, String, String)> { pub fn clear_device() { *DEVICE_STATE.lock().unwrap() = None; } + +/// Serialize the current `DEVICE_STATE` to JSON bytes, wrapped in `Zeroizing`. +/// +/// # Security +/// +/// The returned bytes are **plaintext secret material** — they contain the +/// ed25519 private key in OpenSSH PEM format. The caller in `lib.rs` encrypts +/// them immediately under the vault master key before returning anything to JS. +/// This function **must never** be exposed as a `#[wasm_bindgen]` export. +/// +/// # Errors +/// +/// Returns `Err("no device registered")` if `DEVICE_STATE` is empty. +pub fn export_state_bytes() -> Result>, String> { + let guard = DEVICE_STATE.lock().unwrap(); + let state = guard.as_ref().ok_or_else(|| "no device registered".to_string())?; + let persisted = PersistedDeviceState { + name: state.name.clone(), + signing_private: state.signing_private.as_str().to_owned(), + signing_public: state.signing_public.clone(), + deploy_private: state.deploy_private.as_str().to_owned(), + deploy_public: state.deploy_public.clone(), + }; + let bytes = serde_json::to_vec(&persisted).map_err(|e| e.to_string())?; + Ok(Zeroizing::new(bytes)) +} + +/// Deserialize a `PersistedDeviceState` from raw bytes and repopulate +/// `DEVICE_STATE`. Private key fields are re-wrapped in `Zeroizing`. +/// +/// # Errors +/// +/// Returns `Err(...)` if `bytes` is not valid JSON matching `PersistedDeviceState`. +pub fn import_state_bytes(bytes: &[u8]) -> Result<(), String> { + let persisted: PersistedDeviceState = + serde_json::from_slice(bytes).map_err(|e| e.to_string())?; + let state = DeviceState { + name: persisted.name, + signing_private: Zeroizing::new(persisted.signing_private), + signing_public: persisted.signing_public, + deploy_private: Zeroizing::new(persisted.deploy_private), + deploy_public: persisted.deploy_public, + }; + *DEVICE_STATE.lock().unwrap() = Some(state); + Ok(()) +} + +/// Extract the raw 32-byte ed25519 seed from the registered device's signing key. +/// +/// Used by `org_unwrap_key` (which calls `relicario_core::org::unwrap_org_key`) +/// so the private key never has to cross to JS. The seed is wrapped in +/// `Zeroizing<[u8; 32]>` and wiped on drop. +/// +/// # Errors +/// +/// Returns `Err("no device registered")` if `DEVICE_STATE` is empty, or an +/// error from `extract_ed25519_seed` if the stored key can't be parsed. +pub fn signing_seed() -> Result, String> { + let guard = DEVICE_STATE.lock().unwrap(); + let state = guard.as_ref().ok_or_else(|| "no device registered".to_string())?; + core_device::extract_ed25519_seed(state.signing_private.as_str()) + .map_err(|e| e.to_string()) +} diff --git a/crates/relicario-wasm/src/lib.rs b/crates/relicario-wasm/src/lib.rs index e16be9f..ebb7215 100644 --- a/crates/relicario-wasm/src/lib.rs +++ b/crates/relicario-wasm/src/lib.rs @@ -19,6 +19,7 @@ use relicario_core::{derive_master_key, imgsecret, KdfParams}; /// `lock(handle)` remains available as the explicit early-cleanup path; the /// `Drop` impl is the safety net that catches code paths which forget to call /// `lock` before letting the handle go out of scope. +#[derive(Debug)] #[wasm_bindgen] pub struct SessionHandle(u32); @@ -609,31 +610,49 @@ pub fn wasm_unwrap_recovery_qr( // ── Org vault WASM bridge ──────────────────────────────────────────────────── -/// Unwrap a member's ECIES-wrapped org master key into a session handle. +/// Encrypt the registered device key under the current vault master key and +/// return CIPHERTEXT for JS to persist (e.g. `chrome.storage.local.device_key_enc`). +/// +/// The device private key NEVER crosses to JS — only this encrypted blob does. +/// On the decrypt side, `restore_device_key` decrypts inside WASM and repopulates +/// `DEVICE_STATE` without ever returning plaintext to JS. +#[wasm_bindgen] +pub fn persist_device_key(handle: &SessionHandle) -> Result, JsError> { + need_key(handle)?; + let plain = device::export_state_bytes().map_err(|e| JsError::new(&e))?; + session::with(handle.0, |k| relicario_core::crypto::encrypt(k, &plain)) + .unwrap() + .map_err(|e| JsError::new(&e.to_string())) +} + +/// Decrypt a persisted device-key blob under the master key and repopulate +/// `DEVICE_STATE` (decryption happens INSIDE WASM; plaintext never reaches JS). +/// Call this at unlock time after `restore_device_key` to restore device signing +/// capability across service-worker restarts. +#[wasm_bindgen] +pub fn restore_device_key(handle: &SessionHandle, encrypted: &[u8]) -> Result<(), JsError> { + need_key(handle)?; + let plain = session::with(handle.0, |k| relicario_core::crypto::decrypt(k, encrypted)) + .unwrap() + .map_err(|e| JsError::new(&e.to_string()))?; + let plain = Zeroizing::new(plain); + device::import_state_bytes(&plain).map_err(|e| JsError::new(&e)) +} + +/// Unwrap a member's ECIES-wrapped org master key into a session handle, using +/// the registered device key held in `DEVICE_STATE` (restored at unlock via +/// `restore_device_key`). The device private key never crosses to JS. /// /// `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. -/// -/// **Device-key form (Step 1 finding):** the key arrives as an OpenSSH PEM blob — -/// the multiline `-----BEGIN OPENSSH PRIVATE KEY-----...` form emitted by -/// `relicario_core::device::generate_keypair()`, NOT a base64-encoded raw-bytes -/// string. The plan's `device_private_key_base64` name was misleading; the spec -/// name `device_private_openssh` matches reality. The SW holds this blob in WASM -/// `DEVICE_STATE` and never exposes it to JS. Decoding to the zeroized 32-byte -/// seed is delegated to `relicario_core::device::extract_ed25519_seed`, the same -/// path `device::sign()` uses, so the secret never lands in a plain `[u8; 32]`. +/// repo — produced by `relicario_core::org::wrap_org_key`. /// /// 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 = relicario_core::device::extract_ed25519_seed(device_private_openssh) - .map_err(|e| JsError::new(&format!("bad device key: {e}")))?; +pub fn org_unwrap_key(keys_blob: &[u8]) -> Result { + let seed = device::signing_seed() + .map_err(|e| JsError::new(&format!("device key unavailable: {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. @@ -665,6 +684,12 @@ pub fn org_manifest_encrypt(handle: &SessionHandle, manifest_json: &str) -> Resu .map_err(|e| JsError::new(&e.to_string())) } +/// Tests that modify DEVICE_STATE must serialize to prevent races. +/// Acquired by any test in org_tests or device_persist_tests that +/// calls register_device / clear_device / persist_device_key / restore_device_key. +#[cfg(test)] +static DEVICE_TEST_LOCK: std::sync::Mutex<()> = std::sync::Mutex::new(()); + #[cfg(test)] mod org_tests { use super::*; @@ -708,31 +733,20 @@ mod org_tests { assert_ne!(encrypted, encrypted2, "nonces must differ"); } - /// 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. The private key is - /// kept in its `Zeroizing` wrapper — never copied into a plain `String`. - fn test_device_keypair() -> (String, Zeroizing) { - let (priv_openssh, pub_openssh) = relicario_core::generate_keypair().unwrap(); - (pub_openssh, priv_openssh) - } - #[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(); + // Serialize with all tests that touch DEVICE_STATE. + let _guard = super::DEVICE_TEST_LOCK.lock().unwrap_or_else(|e| e.into_inner()); + device::clear_device(); - // &priv_openssh derefs Zeroizing → &str; no plain-String copy. - let handle = org_unwrap_key(&wrapped, &priv_openssh).unwrap(); + // Register a device so DEVICE_STATE is populated; use its signing public key + // to wrap a known org key. org_unwrap_key now reads the seed from DEVICE_STATE + // — the device_private_openssh param is gone in the 4.5a refactor. + let org_key = Zeroizing::new([7u8; 32]); + let (signing_pub, _deploy_pub) = device::register_device("test-dev").unwrap(); + let wrapped = wrap_org_key(&org_key, &signing_pub).unwrap(); + + let handle = org_unwrap_key(&wrapped).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( @@ -748,6 +762,134 @@ mod org_tests { } } +#[cfg(test)] +mod device_persist_tests { + use super::*; + + /// 1. Persist → restore round-trip. + /// + /// After persisting the device state and simulating a SW restart (clear_device), + /// restoring must bring back the exact same name and signing_public key. + #[test] + fn persist_restore_round_trip() { + let _guard = super::DEVICE_TEST_LOCK.lock().unwrap_or_else(|e| e.into_inner()); + session::clear(); + device::clear_device(); + + // A master-key session (no Argon2id needed — we construct the key directly). + let master_key = Zeroizing::new([0x42u8; 32]); + let h = session::insert(master_key, Zeroizing::new([0u8; 32])); + let handle = SessionHandle(h); + + // Register a device; capture name + signing public key before persist. + device::register_device("my-laptop").unwrap(); + let (name_before, signing_pub_before, _) = device::get_device_info().unwrap(); + + // Encrypt the device state under the master key. + let enc = persist_device_key(&handle).unwrap(); + + // Simulate a SW restart: wipe DEVICE_STATE in-memory. + device::clear_device(); + assert!(device::get_device_info().is_none(), "DEVICE_STATE must be cleared"); + + // Decrypt and repopulate. + restore_device_key(&handle, &enc).unwrap(); + + // Name and signing_public must survive the full round-trip. + let (name_after, signing_pub_after, _) = device::get_device_info().unwrap(); + assert_eq!(name_before, name_after, "device name must survive persist/restore"); + assert_eq!( + signing_pub_before, signing_pub_after, + "signing_public must survive persist/restore" + ); + } + + /// 2. org_unwrap_key works post-restore. + /// + /// After a persist→clear→restore cycle, org_unwrap_key must be able to unwrap + /// an org key that was wrapped to the device's signing public key. + #[test] + fn org_unwrap_key_works_after_restore() { + let _guard = super::DEVICE_TEST_LOCK.lock().unwrap_or_else(|e| e.into_inner()); + session::clear(); + device::clear_device(); + + let master_key = Zeroizing::new([0x43u8; 32]); + let h = session::insert(master_key, Zeroizing::new([0u8; 32])); + let handle = SessionHandle(h); + + // Register device; capture signing_pub for key wrapping. + let (signing_pub, _deploy_pub) = device::register_device("my-laptop").unwrap(); + + // Full persist → wipe → restore cycle (simulates SW restart). + let enc = persist_device_key(&handle).unwrap(); + device::clear_device(); + restore_device_key(&handle, &enc).unwrap(); + + // Wrap a known org key to the device's signing public key. + let known_org_key = Zeroizing::new([0x77u8; 32]); + let wrapped = relicario_core::org::wrap_org_key(&known_org_key, &signing_pub).unwrap(); + + // Unwrap using the refactored org_unwrap_key (reads seed from DEVICE_STATE). + let org_handle = org_unwrap_key(&wrapped).unwrap(); + + // Encrypt an item under the org session, then decrypt with the known key + // directly (avoids js-sys/serde_wasm_bindgen on native — mirrors existing org test). + let ct = item_encrypt( + &org_handle, + r#"{"id":"a1b2c3d4e5f6a7b8","title":"Test","type":"secure_note","created":0,"modified":0,"core":{"type":"secure_note","body":"x"}}"#, + ) + .unwrap(); + let pt: relicario_core::Item = + relicario_core::decrypt_item(&ct, &known_org_key).unwrap(); + assert!(format!("{:?}", pt.core).contains("SecureNote")); + } + + /// 3. Ciphertext is NOT plaintext. + /// + /// The blob returned by persist_device_key must not contain the raw PEM private + /// key header — proves the device key is encrypted, never handed to JS in clear. + #[test] + fn persist_output_is_ciphertext_not_plaintext() { + let _guard = super::DEVICE_TEST_LOCK.lock().unwrap_or_else(|e| e.into_inner()); + session::clear(); + device::clear_device(); + + let master_key = Zeroizing::new([0x44u8; 32]); + let h = session::insert(master_key, Zeroizing::new([0u8; 32])); + let handle = SessionHandle(h); + + device::register_device("my-laptop").unwrap(); + let enc = persist_device_key(&handle).unwrap(); + + // Encrypted bytes must NOT contain the OpenSSH PEM header verbatim. + // The XChaCha20-Poly1305 ciphertext is randomized; the ASCII string + // "BEGIN OPENSSH PRIVATE KEY" cannot appear in it. + let as_lossy = String::from_utf8_lossy(&enc); + assert!( + !as_lossy.contains("BEGIN OPENSSH PRIVATE KEY"), + "persist_device_key must return ciphertext, not the raw PEM private key (device privkey must never reach JS)" + ); + } + + /// 4. org_unwrap_key errors when no device is registered. + /// + /// `org_unwrap_key` wraps its error in `JsError`, which panics on non-wasm + /// targets (established constraint in this codebase — see session_tests comment). + /// We test `device::signing_seed` directly instead: that is the codepath + /// `org_unwrap_key` calls first, and it is what produces the "no device + /// registered" error that `org_unwrap_key` surfaces as "device key unavailable". + #[test] + fn org_unwrap_key_errors_when_no_device_registered() { + let _guard = super::DEVICE_TEST_LOCK.lock().unwrap_or_else(|e| e.into_inner()); + device::clear_device(); + + let err = device::signing_seed() + .expect_err("signing_seed must fail when no device is registered"); + assert_eq!(err, "no device registered", "error message must match"); + } +} + #[cfg(test)] mod session_tests { use super::*; diff --git a/extension/src/wasm.d.ts b/extension/src/wasm.d.ts index 2e90ad3..cad0669 100644 --- a/extension/src/wasm.d.ts +++ b/extension/src/wasm.d.ts @@ -87,9 +87,22 @@ 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; + /** Unwrap an ECIES-wrapped org master key using the registered device key in DEVICE_STATE. */ + export function org_unwrap_key(keys_blob: Uint8Array): SessionHandle; export function org_manifest_decrypt(handle: SessionHandle, encrypted: Uint8Array): unknown; export function org_manifest_encrypt(handle: SessionHandle, manifest_json: string): Uint8Array; + /** + * Encrypt the registered device key under the vault master key. + * Returns CIPHERTEXT — the device private key never crosses to JS. + * Store the returned bytes in chrome.storage.local as `device_key_enc`. + */ + export function persist_device_key(handle: SessionHandle): Uint8Array; + /** + * Decrypt a persisted device-key blob under the master key and repopulate + * DEVICE_STATE. Decryption happens inside WASM; plaintext never reaches JS. + * Call at unlock time with the `device_key_enc` blob from chrome.storage.local. + */ + export function restore_device_key(handle: SessionHandle, encrypted: Uint8Array): void; // Pluggable second factor: key-file bindings (Task 3) export function unlock_with_secret(passphrase: string, secret: Uint8Array, salt: Uint8Array, params_json: string): SessionHandle; export function keyfile_encode(secret: Uint8Array): Uint8Array;