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

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