feat(wasm): org_unwrap_key — ECIES unwrap into a session handle
Expose org_unwrap_key(keys_blob, device_private_openssh) to let the extension SW unwrap a member's ECIES-wrapped org master key into an ordinary Zeroizing session handle. The returned handle is key-agnostic and works with the existing item_decrypt/manifest_decrypt AEAD. Key-form finding: device private keys are OpenSSH PEM blobs produced by relicario_core::device::generate_keypair(), not base64 raw bytes. The plan's "device_private_key_base64" parameter name was misleading; all code and the .d.ts declaration use "device_private_openssh" to match the spec and reality. Adds ssh-key dep to relicario-wasm for PEM parsing in decode_device_seed. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_014s527M917W47LDrfQ4t47g
This commit is contained in:
1
Cargo.lock
generated
1
Cargo.lock
generated
@@ -2248,6 +2248,7 @@ dependencies = [
|
|||||||
"serde",
|
"serde",
|
||||||
"serde-wasm-bindgen",
|
"serde-wasm-bindgen",
|
||||||
"serde_json",
|
"serde_json",
|
||||||
|
"ssh-key",
|
||||||
"wasm-bindgen",
|
"wasm-bindgen",
|
||||||
"wasm-bindgen-test",
|
"wasm-bindgen-test",
|
||||||
"zeroize",
|
"zeroize",
|
||||||
|
|||||||
@@ -21,6 +21,7 @@ base64 = "0.22"
|
|||||||
hex = "0.4"
|
hex = "0.4"
|
||||||
rand = "0.8"
|
rand = "0.8"
|
||||||
once_cell = "1"
|
once_cell = "1"
|
||||||
|
ssh-key = { version = "0.6", features = ["ed25519", "std"] }
|
||||||
|
|
||||||
[dev-dependencies]
|
[dev-dependencies]
|
||||||
wasm-bindgen-test = "0.3"
|
wasm-bindgen-test = "0.3"
|
||||||
|
|||||||
@@ -568,6 +568,106 @@ pub fn wasm_unwrap_recovery_qr(
|
|||||||
Ok(recovered.to_vec())
|
Ok(recovered.to_vec())
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ── Org vault WASM bridge ────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
/// Decode an OpenSSH ed25519 private key string into a Zeroizing 32-byte seed.
|
||||||
|
///
|
||||||
|
/// **Key-form finding (Step 1 of the task brief):**
|
||||||
|
/// `relicario_core::device::generate_keypair()` returns the private key as an
|
||||||
|
/// OpenSSH PEM blob — the multiline `-----BEGIN OPENSSH PRIVATE KEY-----...` format,
|
||||||
|
/// the same form the `ssh_key` crate emits via `PrivateKey::to_openssh()`. It is
|
||||||
|
/// NOT a raw-bytes base64 string. The plan's `device_private_key_base64` parameter
|
||||||
|
/// name is therefore misleading; the spec name `device_private_openssh` matches
|
||||||
|
/// reality. The SW stores this blob in WASM `DEVICE_STATE` and never exposes it
|
||||||
|
/// to JS. If it were ever serialised for cross-session persistence (future work),
|
||||||
|
/// it would travel as this same PEM string.
|
||||||
|
///
|
||||||
|
/// This mirrors the first half of `relicario_core::device::sign()`.
|
||||||
|
fn decode_device_seed(private_key_openssh: &str) -> Result<Zeroizing<[u8; 32]>, String> {
|
||||||
|
use ssh_key::PrivateKey;
|
||||||
|
let private = PrivateKey::from_openssh(private_key_openssh)
|
||||||
|
.map_err(|e| format!("parse private key: {e}"))?;
|
||||||
|
let key_data = private
|
||||||
|
.key_data()
|
||||||
|
.ed25519()
|
||||||
|
.ok_or_else(|| "not an ed25519 key".to_string())?;
|
||||||
|
let secret_slice: &[u8] = key_data.private.as_ref();
|
||||||
|
let secret_bytes: [u8; 32] = secret_slice
|
||||||
|
.try_into()
|
||||||
|
.map_err(|_| "invalid ed25519 seed length".to_string())?;
|
||||||
|
let mut seed = Zeroizing::new([0u8; 32]);
|
||||||
|
seed.copy_from_slice(&secret_bytes);
|
||||||
|
Ok(seed)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Unwrap a member's ECIES-wrapped org master key into a session handle.
|
||||||
|
///
|
||||||
|
/// `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 in OpenSSH PEM format (held in WASM memory
|
||||||
|
/// via `crates/relicario-wasm/src/device.rs`; never exposed to JS).
|
||||||
|
///
|
||||||
|
/// 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 = decode_device_seed(device_private_openssh)
|
||||||
|
.map_err(|e| JsError::new(&format!("bad device key: {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.
|
||||||
|
let handle = session::insert(org_key, Zeroizing::new([0u8; 32]));
|
||||||
|
Ok(SessionHandle(handle))
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
mod org_tests {
|
||||||
|
use super::*;
|
||||||
|
use relicario_core::org::wrap_org_key;
|
||||||
|
use zeroize::Zeroizing;
|
||||||
|
|
||||||
|
/// 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.
|
||||||
|
fn test_device_keypair() -> (String, String) {
|
||||||
|
let (priv_openssh, pub_openssh) = relicario_core::generate_keypair().unwrap();
|
||||||
|
(pub_openssh, priv_openssh.to_string())
|
||||||
|
}
|
||||||
|
|
||||||
|
#[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();
|
||||||
|
|
||||||
|
let handle = org_unwrap_key(&wrapped, &priv_openssh).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(
|
||||||
|
&handle,
|
||||||
|
r#"{"id":"a1b2c3d4e5f6a7b8","title":"Test","type":"secure_note","created":0,"modified":0,"core":{"type":"secure_note","body":"x"}}"#,
|
||||||
|
)
|
||||||
|
.unwrap();
|
||||||
|
// item_decrypt calls serde_wasm_bindgen::Serializer which panics off-wasm.
|
||||||
|
// Use relicario_core::decrypt_item directly, mirroring the
|
||||||
|
// manifest_round_trip_via_handle approach in session_tests.
|
||||||
|
let pt: relicario_core::Item = relicario_core::decrypt_item(&ct, &org_key).unwrap();
|
||||||
|
assert!(format!("{:?}", pt.core).contains("SecureNote"));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
#[cfg(test)]
|
#[cfg(test)]
|
||||||
mod session_tests {
|
mod session_tests {
|
||||||
use super::*;
|
use super::*;
|
||||||
|
|||||||
2
extension/src/wasm.d.ts
vendored
2
extension/src/wasm.d.ts
vendored
@@ -87,6 +87,8 @@ declare module 'relicario-wasm' {
|
|||||||
export function wasm_generate_recovery_qr(handle: SessionHandle, passphrase: string): string;
|
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 wasm_unwrap_recovery_qr(payload_b64: string, passphrase: string): Uint8Array;
|
||||||
|
|
||||||
|
export function org_unwrap_key(keys_blob: Uint8Array, device_private_openssh: string): SessionHandle;
|
||||||
|
|
||||||
export default function init(module_or_path?: unknown): Promise<void>;
|
export default function init(module_or_path?: unknown): Promise<void>;
|
||||||
export function initSync(args: { module: WebAssembly.Module }): void;
|
export function initSync(args: { module: WebAssembly.Module }): void;
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user