feat(wasm): persist/restore device key under master key; org_unwrap_key reads DEVICE_STATE

- device.rs: add PersistedDeviceState (serde mirror), export_state_bytes
  (plaintext JSON in Zeroizing<Vec<u8>>, NOT a wasm_bindgen export),
  import_state_bytes (repopulates DEVICE_STATE with Zeroizing<String> keys),
  signing_seed (extracts ed25519 seed via core::device::extract_ed25519_seed)
- lib.rs: add persist_device_key (#[wasm_bindgen], encrypts state bytes under
  master key via relicario_core::crypto::encrypt; ciphertext only reaches JS),
  restore_device_key (#[wasm_bindgen], decrypts inside WASM then calls
  import_state_bytes; plaintext never leaves WASM)
- lib.rs: refactor org_unwrap_key to drop device_private_openssh param; reads
  seed from DEVICE_STATE via device::signing_seed()
- lib.rs: update org_unwrap_key_yields_a_session_that_decrypts_org_blobs to
  new signature (register_device first, use signing_pub for wrap_org_key)
- lib.rs: add device_persist_tests module (4 tests: round-trip, org-unwrap
  post-restore, ciphertext-not-plaintext, no-device-error)
- lib.rs: add DEVICE_TEST_LOCK static to serialize DEVICE_STATE-touching tests
- lib.rs: add #[derive(Debug)] to SessionHandle
- wasm.d.ts: update org_unwrap_key sig; declare persist_device_key +
  restore_device_key

All gates: cargo test -p relicario-wasm (15/15), cargo test -p relicario-core
(no regression), cargo build --target wasm32-unknown-unknown (clean), clippy
-D warnings (clean).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_014s527M917W47LDrfQ4t47g
This commit is contained in:
adlee-was-taken
2026-06-26 21:53:24 -04:00
parent ce141480b9
commit be619f8ed0
3 changed files with 281 additions and 41 deletions

View File

@@ -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<Vec<u8>>`; `import_state_bytes` re-wraps the private key fields
/// back into `Zeroizing<String>` 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<Zeroizing<Vec<u8>>, 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<String>`.
///
/// # 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<Zeroizing<[u8; 32]>, 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())
}

View File

@@ -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<Vec<u8>, 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/<member_id>.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<SessionHandle, JsError> {
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<SessionHandle, JsError> {
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<String>` wrapper — never copied into a plain `String`.
fn test_device_keypair() -> (String, Zeroizing<String>) {
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<String> → &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<u8>; 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::*;

View File

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