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>
61 lines
1.9 KiB
Rust
61 lines
1.9 KiB
Rust
//! Opaque session-handle bridge. The master key never leaves WASM linear
|
|
//! memory; JS receives only a u32 handle that it passes back on every
|
|
//! subsequent call.
|
|
|
|
use std::cell::RefCell;
|
|
use std::collections::HashMap;
|
|
use zeroize::Zeroizing;
|
|
|
|
pub struct SessionData {
|
|
pub master_key: Zeroizing<[u8; 32]>,
|
|
pub image_secret: Zeroizing<[u8; 32]>,
|
|
}
|
|
|
|
thread_local! {
|
|
static SESSIONS: RefCell<HashMap<u32, SessionData>> = RefCell::new(HashMap::new());
|
|
static NEXT_HANDLE: RefCell<u32> = const { RefCell::new(1) };
|
|
}
|
|
|
|
pub fn insert(master_key: Zeroizing<[u8; 32]>, image_secret: Zeroizing<[u8; 32]>) -> u32 {
|
|
let handle = NEXT_HANDLE.with(|n| {
|
|
let mut n = n.borrow_mut();
|
|
let h = *n;
|
|
*n = n.wrapping_add(1);
|
|
if *n == 0 { *n = 1; } // avoid reserving 0 as a valid handle
|
|
h
|
|
});
|
|
SESSIONS.with(|s| {
|
|
s.borrow_mut().insert(handle, SessionData { master_key, image_secret });
|
|
});
|
|
handle
|
|
}
|
|
|
|
/// Access the master key for a handle. Preserves original `with` signature for all existing callers.
|
|
pub fn with<F, R>(handle: u32, f: F) -> Option<R>
|
|
where
|
|
F: FnOnce(&Zeroizing<[u8; 32]>) -> R,
|
|
{
|
|
SESSIONS.with(|s| s.borrow().get(&handle).map(|d| f(&d.master_key)))
|
|
}
|
|
|
|
/// Access the image_secret for a handle (used by recovery QR).
|
|
pub fn with_image_secret<F, R>(handle: u32, f: F) -> Option<R>
|
|
where
|
|
F: FnOnce(&Zeroizing<[u8; 32]>) -> R,
|
|
{
|
|
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 {
|
|
SESSIONS.with(|s| s.borrow_mut().remove(&handle).is_some())
|
|
}
|
|
|
|
/// For tests only — empty the table and wipe all sessions.
|
|
#[cfg(test)]
|
|
pub fn clear() {
|
|
SESSIONS.with(|s| s.borrow_mut().clear());
|
|
}
|