fix(wasm): impl Drop for SessionHandle clears registry entry

Closes the P1.1 defense-in-depth gap: wasm-bindgen's auto-generated
.free() previously dropped the SessionHandle wrapper (a u32) without
removing the SESSIONS HashMap entry, leaving the master key and
image_secret in WASM linear memory until JS explicitly called
lock(handle). Drop now wires .free() to session::remove, and the
new native test pins the contract.

Refs: docs/superpowers/specs/2026-05-04-security-polish-design.md (Phase 1)
Refs: docs/superpowers/reviews/2026-05-04-architecture-review.md (P1.1)

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
This commit is contained in:
adlee-was-taken
2026-05-06 01:52:24 -04:00
parent bd3d53fddb
commit 1e858e1d1f
3 changed files with 56 additions and 1 deletions

View File

@@ -12,7 +12,13 @@ use zeroize::Zeroizing;
use relicario_core::{derive_master_key, imgsecret, KdfParams}; use relicario_core::{derive_master_key, imgsecret, KdfParams};
/// Handle type returned from `unlock`. Backed by a `u32`; opaque to JS. /// Handle returned from `unlock`. Backed by a `u32`; opaque to JS.
///
/// Dropping the handle (or invoking `.free()` from JS) removes the entry from
/// the session registry, zeroizing the wrapped master key and image_secret.
/// `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.
#[wasm_bindgen] #[wasm_bindgen]
pub struct SessionHandle(u32); pub struct SessionHandle(u32);
@@ -22,6 +28,23 @@ impl SessionHandle {
pub fn value(&self) -> u32 { self.0 } pub fn value(&self) -> u32 { self.0 }
} }
impl Drop for SessionHandle {
fn drop(&mut self) { let _ = session::remove(self.0); }
}
#[doc(hidden)]
pub fn __test_make_handle() -> SessionHandle {
SessionHandle(session::insert(
Zeroizing::new([0x77u8; 32]),
Zeroizing::new([0u8; 32]),
))
}
#[doc(hidden)]
pub fn __test_session_exists(handle: u32) -> bool {
session::with(handle, |_| ()).is_some()
}
#[wasm_bindgen] #[wasm_bindgen]
pub fn unlock( pub fn unlock(
passphrase: &str, passphrase: &str,
@@ -533,6 +556,19 @@ mod session_tests {
assert!(!session::remove(h)); // second remove false assert!(!session::remove(h)); // second remove false
} }
#[test]
fn dropping_session_handle_clears_registry_entry() {
session::clear();
let handle = SessionHandle(session::insert(
Zeroizing::new([0x33u8; 32]),
Zeroizing::new([0u8; 32]),
));
let id = handle.value();
assert!(session::with(id, |_| ()).is_some());
drop(handle);
assert!(session::with(id, |_| ()).is_none());
}
#[test] #[test]
fn with_yields_key_only_while_session_lives() { fn with_yields_key_only_while_session_lives() {
session::clear(); session::clear();

View File

@@ -46,6 +46,9 @@ where
SESSIONS.with(|s| s.borrow().get(&handle).map(|d| f(&d.image_secret))) SESSIONS.with(|s| s.borrow().get(&handle).map(|d| f(&d.image_secret)))
} }
/// Remove a session entry. Called by both `lock(handle)` (the explicit
/// path) and `impl Drop for SessionHandle` (the safety net). Returns
/// `true` if an entry was removed, `false` if the handle was already gone.
pub fn remove(handle: u32) -> bool { pub fn remove(handle: u32) -> bool {
SESSIONS.with(|s| s.borrow_mut().remove(&handle).is_some()) SESSIONS.with(|s| s.borrow_mut().remove(&handle).is_some())
} }

View File

@@ -0,0 +1,16 @@
//! Belt-and-suspenders companion to the native `dropping_session_handle_clears_registry_entry`
//! test in `lib.rs`. This file exists for `wasm-pack test --node` symmetry; the
//! native test in the same crate is what gates CI.
use wasm_bindgen_test::wasm_bindgen_test;
use relicario_wasm::{__test_make_handle, __test_session_exists};
#[wasm_bindgen_test]
fn dropping_session_handle_clears_registry_entry() {
let handle = __test_make_handle();
let id = handle.value();
assert!(__test_session_exists(id));
drop(handle);
assert!(!__test_session_exists(id));
}