diff --git a/crates/relicario-wasm/Cargo.toml b/crates/relicario-wasm/Cargo.toml index ffd5fae..08065cc 100644 --- a/crates/relicario-wasm/Cargo.toml +++ b/crates/relicario-wasm/Cargo.toml @@ -14,7 +14,7 @@ wasm-bindgen = "0.2" serde-wasm-bindgen = "0.6" serde_json = "1" serde = { version = "1", features = ["derive"] } -zeroize = "1" +zeroize = { version = "1", features = ["derive"] } getrandom = { version = "0.2", features = ["js"] } ed25519-dalek = { version = "2", features = ["rand_core"] } base64 = "0.22" diff --git a/crates/relicario-wasm/src/device.rs b/crates/relicario-wasm/src/device.rs index 3ecef05..347b2a7 100644 --- a/crates/relicario-wasm/src/device.rs +++ b/crates/relicario-wasm/src/device.rs @@ -4,7 +4,7 @@ use std::sync::Mutex; use once_cell::sync::Lazy; use serde::{Deserialize, Serialize}; -use zeroize::Zeroizing; +use zeroize::{Zeroize, ZeroizeOnDrop, Zeroizing}; use relicario_core::device as core_device; @@ -30,10 +30,17 @@ struct DeviceState { /// /// # Security note /// -/// This struct itself is NOT zeroed on drop. The invariant that matters is: +/// `ZeroizeOnDrop` wipes every `String` field (including the `signing_private` / +/// `deploy_private` device PRIVATE keys) when the struct drops, on EVERY path — +/// success, the `?` error path of `serde_json::to_vec`, and panics — so the +/// transient plaintext copy created in `export_state_bytes` leaves no residue in +/// heap memory. Because the derived `ZeroizeOnDrop` adds a `Drop` impl, fields +/// cannot be moved out (Rust E0509); `import_state_bytes` uses `mem::take` to +/// transfer the private-key Strings into `Zeroizing` without copying, +/// leaving empty Strings that the drop-zeroize no-ops. The remaining invariants: /// (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)] +#[derive(Serialize, Deserialize, Zeroize, ZeroizeOnDrop)] struct PersistedDeviceState { name: String, signing_private: String, @@ -97,9 +104,15 @@ pub fn clear_device() { /// # 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. +/// ed25519 private keys (signing + deploy) 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. +/// +/// The transient `persisted` clone holds plaintext private-key copies, but +/// `PersistedDeviceState` derives `ZeroizeOnDrop`, so those copies are wiped when +/// `persisted` drops — on the success path, the `?` error path of +/// `serde_json::to_vec`, and on any panic — leaving no heap residue. /// /// # Errors /// @@ -116,23 +129,32 @@ pub fn export_state_bytes() -> Result>, String> { }; let bytes = serde_json::to_vec(&persisted).map_err(|e| e.to_string())?; Ok(Zeroizing::new(bytes)) + // `persisted` (and its plaintext private-key copies) is zeroized here on drop. } /// Deserialize a `PersistedDeviceState` from raw bytes and repopulate /// `DEVICE_STATE`. Private key fields are re-wrapped in `Zeroizing`. /// +/// # Security +/// +/// `PersistedDeviceState` derives `ZeroizeOnDrop`, which adds a `Drop` impl, so +/// its fields cannot be moved out (Rust E0509). We use `mem::take` to transfer +/// each `String` into the new `DeviceState` (wrapping the private keys in +/// `Zeroizing`) — this moves the heap buffer without copying, leaving an +/// empty `String` behind whose drop-zeroize is a no-op. No secret copy survives. +/// /// # Errors /// /// Returns `Err(...)` if `bytes` is not valid JSON matching `PersistedDeviceState`. pub fn import_state_bytes(bytes: &[u8]) -> Result<(), String> { - let persisted: PersistedDeviceState = + let mut 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, + name: std::mem::take(&mut persisted.name), + signing_private: Zeroizing::new(std::mem::take(&mut persisted.signing_private)), + signing_public: std::mem::take(&mut persisted.signing_public), + deploy_private: Zeroizing::new(std::mem::take(&mut persisted.deploy_private)), + deploy_public: std::mem::take(&mut persisted.deploy_public), }; *DEVICE_STATE.lock().unwrap() = Some(state); Ok(())