diff --git a/crates/perry-runtime/src/buffer/header.rs b/crates/perry-runtime/src/buffer/header.rs index 20f620e4ac..b23ec1ea93 100644 --- a/crates/perry-runtime/src/buffer/header.rs +++ b/crates/perry-runtime/src/buffer/header.rs @@ -56,6 +56,32 @@ fn external_crypto_keys() -> &'static Mutex> { EXTERNAL_CRYPTO_KEY_META_REGISTRY.get_or_init(|| Mutex::new(HashMap::new())) } +/// Called by the GC's buffer sweep when a CryptoKey-flagged `BufferHeader` +/// dies, so perry-stdlib can drop the matching entry from its own +/// `addr -> CryptoKeyMaterial` map. Registered by +/// `js_set_crypto_key_death_hook` at startup; stays null when stdlib isn't +/// linked. Must not allocate — it runs inside the sweep. +pub type CryptoKeyDeathHookFn = extern "C" fn(usize); +static CRYPTO_KEY_DEATH_HOOK: std::sync::atomic::AtomicPtr<()> = + std::sync::atomic::AtomicPtr::new(std::ptr::null_mut()); + +/// Install the dead-CryptoKey callback (called by perry-stdlib at startup — +/// this crate can't call into perry-stdlib, which depends on it). Same +/// contract as the `js_set_native_*_dispatch` family in `value::handle`. +#[no_mangle] +pub extern "C" fn js_set_crypto_key_death_hook(func: CryptoKeyDeathHookFn) { + CRYPTO_KEY_DEATH_HOOK.store(func as *mut (), std::sync::atomic::Ordering::SeqCst); +} + +fn notify_crypto_key_death(addr: usize) { + let ptr = CRYPTO_KEY_DEATH_HOOK.load(std::sync::atomic::Ordering::SeqCst); + if ptr.is_null() { + return; + } + let hook: CryptoKeyDeathHookFn = unsafe { std::mem::transmute(ptr) }; + hook(addr); +} + pub type CryptoKeyMeta = (u8, u8, u8, bool, u32); thread_local! { @@ -490,6 +516,50 @@ pub(crate) fn finalize_collected_dead_buffer(addr: usize) { BUFFER_AB_ALIAS.with(|r| { r.borrow_mut().remove(&addr); }); + // The WebCrypto/KeyObject side tables were missing from this list. They are + // plain `addr -> metadata` maps that do not root the `BufferHeader`, so a + // collected CryptoKey/secret-key buffer left its entries behind forever. + // Two consequences, both real: + // + // * an unbounded leak — every CryptoKey ever created kept an entry in the + // thread-local map AND in the process-global one (a 60k-key run leaked + // 59,998 of them); + // * the #6080 ABA class this very function exists to prevent: the old + // arena resets a fully-empty block's offset to 0 while keeping its base + // pointer (`arena_reset_empty_blocks` + the block-reuse forward scan in + // `Arena::alloc`), so a recycled address inherits CryptoKey identity. + // `crypto_key_meta`/`is_secret_key` gate `instanceof CryptoKey`, + // `util.types.isCryptoKey`/`isKeyObject`, the `[object CryptoKey]` tag, + // the `.algorithm`/`.type`/`.usages` property surface, `KeyObject.from` + // and `.export()` — an unrelated fresh Buffer landing on a dead key's + // address would answer to all of them. + CRYPTO_KEY_META_REGISTRY.with(|r| { + r.borrow_mut().remove(&addr); + }); + SECRET_KEY_REGISTRY.with(|r| { + r.borrow_mut().remove(&addr); + }); + UINT8ARRAY_FROM_CTOR.with(|r| { + r.borrow_mut().remove(&addr); + }); + // `js_buffer_mark_as_crypto_key_external` writes all three global maps, and + // `is_registered_buffer`/`is_uint8array_buffer` consult them, so a dead + // external key buffer has to be dropped from every one of them. + if let Ok(mut r) = external_buffers().lock() { + r.remove(&addr); + } + if let Ok(mut r) = external_uint8arrays().lock() { + r.remove(&addr); + } + if let Ok(mut r) = external_crypto_keys().lock() { + r.remove(&addr); + } + // perry-stdlib keeps its own `addr -> CryptoKeyMaterial` map (the primary + // one `lookup_crypto_key` consults; the runtime table above is only its + // fallback), and this crate cannot call into perry-stdlib. Notify it + // through the hook it installs at startup. The callback only removes a + // HashMap entry — no allocation, so it is safe to run inside the sweep. + notify_crypto_key_death(addr); super::detach::remove_detached_entry_for_dead_buffer(addr); super::view::remove_entries_for_dead_buffer(addr); } diff --git a/crates/perry-runtime/src/buffer/mod.rs b/crates/perry-runtime/src/buffer/mod.rs index c02255851b..7c7c99464b 100644 --- a/crates/perry-runtime/src/buffer/mod.rs +++ b/crates/perry-runtime/src/buffer/mod.rs @@ -36,9 +36,10 @@ pub use header::{ asymmetric_key_meta, buffer_ab_alias, buffer_alloc, buffer_backing_array_buffer, buffer_byte_offset, buffer_data, buffer_data_mut, crypto_key_meta, ensure_buffer_ab_alias, is_any_array_buffer, is_array_buffer, is_data_view, is_registered_buffer, is_secret_key, - is_shared_array_buffer, is_uint8array_buffer, mark_as_array_buffer, mark_as_asymmetric_key, - mark_as_crypto_key, mark_as_data_view, mark_as_secret_key, mark_as_shared_array_buffer, - mark_as_uint8array, register_buffer, resolve_buffer_ab_alias, set_buffer_ab_alias, + is_shared_array_buffer, is_uint8array_buffer, js_set_crypto_key_death_hook, + mark_as_array_buffer, mark_as_asymmetric_key, mark_as_crypto_key, mark_as_data_view, + mark_as_secret_key, mark_as_shared_array_buffer, mark_as_uint8array, register_buffer, + resolve_buffer_ab_alias, set_buffer_ab_alias, CryptoKeyDeathHookFn, }; pub(crate) use header::{ collect_dead_registered_buffers_post_trace, finalize_collected_dead_buffer, @@ -146,6 +147,51 @@ pub use iter::{ mod tests { use super::*; + /// The GC buffer sweep must drop the CryptoKey/secret-key side tables + /// along with the buffer identity ones. They are plain `addr -> metadata` + /// maps that never rooted the `BufferHeader`, so leaving them behind both + /// leaked an entry per key and let a recycled address inherit CryptoKey + /// identity (`crypto_key_meta`/`is_secret_key` gate `instanceof CryptoKey`, + /// `util.types.isCryptoKey`, `KeyObject.from`, `.export()` …) — the #6080 + /// ABA class this finalizer exists to prevent. + #[test] + fn test_dead_buffer_finalize_prunes_crypto_key_side_tables() { + let buf = buffer_alloc(32); + assert!(!buf.is_null()); + let addr = buf as usize; + + // Shape a WebCrypto secret CryptoKey: HMAC / SHA-256 / secret. + mark_as_uint8array(addr); + mark_as_crypto_key(addr, 1, 2, 1); + mark_as_secret_key(addr); + + assert!(crypto_key_meta(addr).is_some(), "meta registered"); + assert!(is_secret_key(addr), "secret-key flag registered"); + assert!(is_uint8array_buffer(addr), "uint8array flag registered"); + assert!(is_registered_buffer(addr), "buffer registered"); + + // Exactly what the sweep subphase runs once the header is proven dead. + finalize_collected_dead_buffer(addr); + + assert!( + crypto_key_meta(addr).is_none(), + "dead buffer must not keep CryptoKey metadata — a recycled address \ + would answer to instanceof CryptoKey / KeyObject.from()" + ); + assert!( + !is_secret_key(addr), + "dead buffer must not keep the secret-key flag" + ); + assert!( + !is_uint8array_buffer(addr), + "dead buffer must not keep the uint8array flag" + ); + assert!( + !is_registered_buffer(addr), + "dead buffer must not stay registered" + ); + } + #[test] fn test_small_buffer_slab_unique_addresses() { // Every allocation must occupy a distinct address (no overlap). diff --git a/crates/perry-runtime/src/object/native_module_crypto_key_object.rs b/crates/perry-runtime/src/object/native_module_crypto_key_object.rs index 5ff6d42599..7164ea5997 100644 --- a/crates/perry-runtime/src/object/native_module_crypto_key_object.rs +++ b/crates/perry-runtime/src/object/native_module_crypto_key_object.rs @@ -17,28 +17,99 @@ fn invalid_key(value: f64) -> ! { crate::fs::validate::throw_type_error_with_code(&message, "ERR_INVALID_ARG_TYPE") } +/// A CryptoKey Perry recognises but cannot turn into a KeyObject must still +/// fail loudly. Returning `undefined` from a factory like this is what #6302 +/// was filed about. +fn conversion_failed(detail: &str) -> ! { + let message = format!("KeyObject.from() failed: {detail}"); + crate::fs::validate::throw_error_with_code(&message, "ERR_CRYPTO_OPERATION_FAILED") +} + +/// `crypto.KeyObject.from(cryptoKey)` (#2565; asymmetric + unregistered-buffer +/// keys #6302). +/// +/// Node converts *any* CryptoKey — secret, public, or private — into the +/// matching KeyObject, and throws `ERR_INVALID_ARG_TYPE` for everything else. +/// Perry models the two KeyObject shapes differently: +/// +/// * **secret** — the key bytes in a `BufferHeader` flagged by +/// `mark_as_secret_key`, which is what the KeyObject property/method surface +/// keys off (`type`, `symmetricKeySize`, `export`). +/// * **asymmetric** — a PEM (RSA/EC) or internal Ed/X surrogate *string* +/// flagged by `mark_as_asymmetric_key`. +/// +/// The secret conversion is a buffer copy and happens here; the asymmetric one +/// needs the key encoders (RSA PKCS#8/SPKI, P-256/384/521, Ed25519, X25519) +/// that live in perry-stdlib, so it is routed through the WebCrypto dispatch +/// hook — the same bridge `keyObject.toCryptoKey()` uses in the other +/// direction (`native_call_method/string_methods.rs`). +/// +/// Pre-#6302 this function demanded `kind == 1 && is_registered_buffer(addr)` +/// and sent everything else to `invalid_key`, so a public/private CryptoKey — +/// or a secret CryptoKey whose backing buffer was not in *this* thread's buffer +/// registry — never produced a KeyObject at all. pub(super) unsafe fn key_object_from(value: f64) -> f64 { let addr = value_addr(value); let Some((_algo, _hash, kind, _extractable, _usages)) = crate::buffer::crypto_key_meta(addr) else { invalid_key(value); }; - if kind != 1 || !crate::buffer::is_registered_buffer(addr) { - invalid_key(value); + match kind { + 1 => secret_key_object(addr), + 2 | 3 => asymmetric_key_object(addr), + _ => invalid_key(value), } +} +/// Copy the CryptoKey's bytes into a fresh secret-key Buffer. +/// +/// The old `is_registered_buffer(addr)` precondition was wrong: CryptoKey +/// metadata is only ever attached to a `BufferHeader` allocated by the +/// WebCrypto key factories, and that metadata is *also* kept in a +/// process-global registry, so the address is readable even when the current +/// thread's (thread-local) buffer registry has no entry for it. Requiring the +/// registry hit turned such keys into a silent `invalid_key` throw. +unsafe fn secret_key_object(addr: usize) -> f64 { let src = addr as *const crate::buffer::BufferHeader; let len = (*src).length as usize; let out = crate::buffer::buffer_alloc(len as u32); - if !out.is_null() { + if out.is_null() { + conversion_failed("could not allocate the secret key buffer"); + } + if len > 0 { std::ptr::copy_nonoverlapping( crate::buffer::buffer_data(src), crate::buffer::buffer_data_mut(out), len, ); - (*out).length = len as u32; - crate::buffer::mark_as_uint8array(out as usize); - crate::buffer::mark_as_secret_key(out as usize); } + (*out).length = len as u32; + crate::buffer::mark_as_uint8array(out as usize); + crate::buffer::mark_as_secret_key(out as usize); f64::from_bits(JSValue::pointer(out as *const u8).bits()) } + +/// Public/private CryptoKey → PEM/surrogate-string KeyObject, encoded by +/// perry-stdlib's WebCrypto bridge (it owns the RSA/EC/Ed/X key writers and +/// the CryptoKey material registry). +unsafe fn asymmetric_key_object(addr: usize) -> f64 { + let ptr = crate::value::JS_NATIVE_WEBCRYPTO_DISPATCH.load(std::sync::atomic::Ordering::SeqCst); + if ptr.is_null() { + conversion_failed("the WebCrypto runtime is not available"); + } + let dispatch: unsafe extern "C" fn(*const u8, usize, *const f64, usize) -> f64 = + std::mem::transmute(ptr); + // Hand the bridge a properly NaN-boxed pointer: a Buffer reaching + // `KeyObject.from` can arrive as a raw f64-bitcast pointer (module-level + // storage convention), which the stdlib pointer-strip helper would read as + // a tagged primitive and reject. + let args = [f64::from_bits(JSValue::pointer(addr as *const u8).bits())]; + let method = "keyObjectFromCryptoKey"; + let result = dispatch(method.as_ptr(), method.len(), args.as_ptr(), args.len()); + // The bridge throws for key material it cannot model; a bare `undefined` + // return would be the exact silent failure #6302 is about. + if JSValue::from_bits(result.to_bits()).is_undefined() { + conversion_failed("unsupported asymmetric key type"); + } + result +} diff --git a/crates/perry-stdlib/src/common/dispatch/init.rs b/crates/perry-stdlib/src/common/dispatch/init.rs index 613024ea49..ff15a3a1ae 100644 --- a/crates/perry-stdlib/src/common/dispatch/init.rs +++ b/crates/perry-stdlib/src/common/dispatch/init.rs @@ -599,6 +599,11 @@ pub unsafe extern "C" fn js_stdlib_init_dispatch() { perry_runtime::js_set_native_crypto_dispatch(crate::crypto::js_crypto_native_dispatch); #[cfg(feature = "crypto")] perry_runtime::js_set_native_webcrypto_dispatch(crate::webcrypto::js_webcrypto_native_dispatch); + // Prune the stdlib CryptoKey-material map when the GC sweeps a key's + // backing buffer (otherwise it leaks an entry per key and a recycled + // address inherits the dead key's material). + #[cfg(feature = "crypto")] + perry_runtime::buffer::js_set_crypto_key_death_hook(crate::webcrypto::crypto_key_buffer_died); #[cfg(feature = "compression")] perry_runtime::js_set_native_zlib_dispatch(crate::zlib::js_zlib_native_dispatch); perry_runtime::js_set_native_querystring_dispatch( diff --git a/crates/perry-stdlib/src/crypto/util.rs b/crates/perry-stdlib/src/crypto/util.rs index 2ef6bf494f..95d0e71745 100644 --- a/crates/perry-stdlib/src/crypto/util.rs +++ b/crates/perry-stdlib/src/crypto/util.rs @@ -183,14 +183,18 @@ pub(super) const ED25519_PUBLIC_PREFIX: &str = "PERRY-ED25519-PUBLIC:"; pub(super) const X25519_PRIVATE_PREFIX: &str = "PERRY-X25519-PRIVATE:"; pub(super) const X25519_PUBLIC_PREFIX: &str = "PERRY-X25519-PUBLIC:"; -pub(super) fn ed25519_private_surrogate(key: &ed25519_dalek::SigningKey) -> String { +/// `pub(crate)` (not `pub(super)`): the WebCrypto bridge builds the very same +/// surrogate strings when `KeyObject.from()` converts an asymmetric CryptoKey +/// back into a KeyObject (#6302), and both sides must agree byte-for-byte or +/// the `parse_*_surrogate` readers below reject the result. +pub(crate) fn ed25519_private_surrogate(key: &ed25519_dalek::SigningKey) -> String { let secret = base64::engine::general_purpose::URL_SAFE_NO_PAD.encode(key.to_bytes()); let public = base64::engine::general_purpose::URL_SAFE_NO_PAD.encode(key.verifying_key().to_bytes()); format!("{ED25519_PRIVATE_PREFIX}{secret}.{public}") } -pub(super) fn ed25519_public_surrogate(key: &ed25519_dalek::VerifyingKey) -> String { +pub(crate) fn ed25519_public_surrogate(key: &ed25519_dalek::VerifyingKey) -> String { let public = base64::engine::general_purpose::URL_SAFE_NO_PAD.encode(key.to_bytes()); format!("{ED25519_PUBLIC_PREFIX}{public}") } @@ -217,12 +221,12 @@ pub(crate) fn parse_ed25519_public_surrogate(value: &str) -> Option String { +pub(crate) fn x25519_private_surrogate(secret: &[u8; 32]) -> String { let encoded = base64::engine::general_purpose::URL_SAFE_NO_PAD.encode(secret); format!("{X25519_PRIVATE_PREFIX}{encoded}") } -pub(super) fn x25519_public_surrogate(public: &[u8; 32]) -> String { +pub(crate) fn x25519_public_surrogate(public: &[u8; 32]) -> String { let encoded = base64::engine::general_purpose::URL_SAFE_NO_PAD.encode(public); format!("{X25519_PUBLIC_PREFIX}{encoded}") } diff --git a/crates/perry-stdlib/src/webcrypto.rs b/crates/perry-stdlib/src/webcrypto.rs index 909a8dcede..814cbcdb8a 100644 --- a/crates/perry-stdlib/src/webcrypto.rs +++ b/crates/perry-stdlib/src/webcrypto.rs @@ -31,6 +31,10 @@ pub use self::{ aes::*, digest::*, encapsulation::*, hmac::*, jwk::*, kdf::*, keys::*, supports::*, wrap::*, }; +// GC buffer-sweep callback for dead CryptoKey buffers, installed by +// `js_stdlib_init_dispatch`. Re-exported here so `util` stays a private module. +pub(crate) use self::util::crypto_key_buffer_died; + /// Dispatcher for captured/dynamic `crypto.subtle.*` calls. Static /// `crypto.subtle.method(...)` call sites still lower directly in codegen; /// this keeps namespace property reads such as `const subtle = crypto.subtle` @@ -106,6 +110,12 @@ pub unsafe extern "C" fn js_webcrypto_native_dispatch( "keyObjectToCryptoKey" if args_len >= 4 => { js_webcrypto_key_object_to_crypto_key(arg(0), arg(1), arg(2), arg(3)) } + // Internal bridge for `crypto.KeyObject.from()` + // (#6302) — the runtime owns the secret-key shape but the asymmetric + // key encoders live here. Not a `crypto.subtle` method name. + "keyObjectFromCryptoKey" if args_len >= 1 => { + js_webcrypto_key_object_from_crypto_key(arg(0)) + } "supports" if args_len >= 2 => js_webcrypto_supports(arg(0), arg(1), arg(2)), "encapsulateBits" => promise_to_value(js_webcrypto_encapsulate_bits(args_ptr, args_len)), "decapsulateBits" => promise_to_value(js_webcrypto_decapsulate_bits(args_ptr, args_len)), diff --git a/crates/perry-stdlib/src/webcrypto/key_object.rs b/crates/perry-stdlib/src/webcrypto/key_object.rs index 3a3cc9e50d..de1464be5f 100644 --- a/crates/perry-stdlib/src/webcrypto/key_object.rs +++ b/crates/perry-stdlib/src/webcrypto/key_object.rs @@ -1,9 +1,10 @@ use super::*; use crate::crypto::util::{ - parse_ed25519_private_surrogate, parse_ed25519_public_surrogate, parse_p256_signing_key_pem, - parse_p256_verifying_key_pem, parse_rsa_private_key_pem, parse_rsa_public_key_pem, - parse_x25519_private_surrogate, parse_x25519_public_surrogate, + ed25519_private_surrogate, ed25519_public_surrogate, parse_ed25519_private_surrogate, + parse_ed25519_public_surrogate, parse_p256_signing_key_pem, parse_p256_verifying_key_pem, + parse_rsa_private_key_pem, parse_rsa_public_key_pem, parse_x25519_private_surrogate, + parse_x25519_public_surrogate, x25519_private_surrogate, x25519_public_surrogate, }; unsafe fn throw_type_error(message: &str) -> ! { @@ -159,3 +160,120 @@ pub(super) unsafe fn js_webcrypto_key_object_to_crypto_key( ); f64::from_bits(JSValue::pointer(buf as *const u8).bits()) } + +/// Read a registered CryptoKey's raw material straight out of its BufferHeader. +/// +/// SAFETY: callers must have resolved `addr` through `lookup_crypto_key` first, +/// which only succeeds for an address the WebCrypto key factories registered — +/// i.e. a live `BufferHeader` holding the key material. +/// +/// `bytes_from_jsvalue` cannot be used here: it gates on `is_registered_buffer`, +/// which is exactly the thread-local check #6302 is about — a CryptoKey whose +/// metadata resolves through the process-global registry still has readable +/// bytes at `addr`. +unsafe fn crypto_key_bytes(addr: usize) -> Vec { + let buf = addr as *const BufferHeader; + let len = (*buf).length as usize; + if len == 0 { + return Vec::new(); + } + std::slice::from_raw_parts(buffer_payload(buf), len).to_vec() +} + +/// Re-encode an asymmetric CryptoKey's WebCrypto key material (SPKI/PKCS#8 DER +/// for RSA, a SEC1 point / raw scalar for EC, raw 32-byte keys for Ed/X25519) +/// into the PEM / internal-surrogate string form Perry's KeyObject surrogates +/// use, plus the `asymmetric_key_meta` type id (1 rsa, 2 ec, 3 ed25519, +/// 4 x25519). Returns `None` for key types Perry has no KeyObject surrogate for +/// (Ed448 / X448 / ML-KEM) — the caller turns that into a throw, never a silent +/// `undefined`. +fn asymmetric_key_surrogate(mat: CryptoKeyMaterial, bytes: &[u8]) -> Option<(String, u8)> { + let ed_key: Option<[u8; 32]> = bytes.try_into().ok(); + match (mat.algo, mat.kind) { + (KeyAlgo::RsassaPkcs1 | KeyAlgo::RsaPss | KeyAlgo::RsaOaep, KeyKind::Public) => { + let key = RsaPublicKey::from_public_key_der(bytes).ok()?; + Some((key.to_public_key_pem(Default::default()).ok()?, 1)) + } + (KeyAlgo::RsassaPkcs1 | KeyAlgo::RsaPss | KeyAlgo::RsaOaep, KeyKind::Private) => { + let key = RsaPrivateKey::from_pkcs8_der(bytes).ok()?; + Some((key.to_pkcs8_pem(Default::default()).ok()?.to_string(), 1)) + } + (KeyAlgo::EcdsaP256 | KeyAlgo::EcdhP256, KeyKind::Public) => { + let key = P256PublicKey::from_sec1_bytes(bytes).ok()?; + Some((key.to_public_key_pem(Default::default()).ok()?, 2)) + } + (KeyAlgo::EcdsaP256 | KeyAlgo::EcdhP256, KeyKind::Private) => { + let key = P256SecretKey::from_slice(bytes).ok()?; + Some((key.to_pkcs8_pem(Default::default()).ok()?.to_string(), 2)) + } + (KeyAlgo::EcdsaP384 | KeyAlgo::EcdhP384, KeyKind::Public) => { + let key = P384PublicKey::from_sec1_bytes(bytes).ok()?; + Some((key.to_public_key_pem(Default::default()).ok()?, 2)) + } + (KeyAlgo::EcdsaP384 | KeyAlgo::EcdhP384, KeyKind::Private) => { + let key = P384SecretKey::from_slice(bytes).ok()?; + Some((key.to_pkcs8_pem(Default::default()).ok()?.to_string(), 2)) + } + (KeyAlgo::EcdsaP521 | KeyAlgo::EcdhP521, KeyKind::Public) => { + let key = P521PublicKey::from_sec1_bytes(bytes).ok()?; + Some((key.to_public_key_pem(Default::default()).ok()?, 2)) + } + (KeyAlgo::EcdsaP521 | KeyAlgo::EcdhP521, KeyKind::Private) => { + let key = P521SecretKey::from_slice(bytes).ok()?; + Some((key.to_pkcs8_pem(Default::default()).ok()?.to_string(), 2)) + } + (KeyAlgo::Ed25519, KeyKind::Public) => { + let key = ed25519_dalek::VerifyingKey::from_bytes(&ed_key?).ok()?; + Some((ed25519_public_surrogate(&key), 3)) + } + (KeyAlgo::Ed25519, KeyKind::Private) => { + let key = ed25519_dalek::SigningKey::from_bytes(&ed_key?); + Some((ed25519_private_surrogate(&key), 3)) + } + (KeyAlgo::X25519, KeyKind::Public) => Some((x25519_public_surrogate(&ed_key?), 4)), + (KeyAlgo::X25519, KeyKind::Private) => Some((x25519_private_surrogate(&ed_key?), 4)), + _ => None, + } +} + +/// `crypto.KeyObject.from(cryptoKey)` for **asymmetric** CryptoKeys (#6302). +/// +/// The runtime handles the secret-key shape itself (a Buffer flagged as a +/// secret key); public/private keys need the encoders above, so +/// `native_module_crypto_key_object::key_object_from` routes them here through +/// the WebCrypto dispatch hook. The result is a PEM/surrogate string flagged +/// with `mark_as_asymmetric_key`, i.e. exactly what `createPublicKey()` / +/// `createPrivateKey()` / `generateKeyPairSync()` hand back — so `type`, +/// `asymmetricKeyType`, `export()`, `equals()`, `toCryptoKey()`, and the +/// sign/verify paths all work on it. +pub(super) unsafe fn js_webcrypto_key_object_from_crypto_key(key_bits: f64) -> f64 { + let addr = strip_ptr(key_bits.to_bits()); + let mat = lookup_crypto_key(addr) + .unwrap_or_else(|| throw_type_error("KeyObject.from() argument is not a CryptoKey")); + let kind_id = match mat.kind { + KeyKind::Public => 1u8, + KeyKind::Private => 2u8, + // Secret keys never reach the bridge — the runtime converts them. + KeyKind::Secret => { + throw_type_error("KeyObject.from() received a secret key on the asymmetric path") + } + }; + let bytes = crypto_key_bytes(addr); + let (surrogate, asym_type) = asymmetric_key_surrogate(mat, &bytes).unwrap_or_else(|| { + let message = format!( + "KeyObject.from() does not support {:?} {} keys", + mat.algo, + if kind_id == 1 { "public" } else { "private" } + ); + perry_runtime::fs::validate::throw_error_with_code( + &message, + "ERR_CRYPTO_UNSUPPORTED_OPERATION", + ) + }); + let ptr = perry_runtime::js_string_from_bytes(surrogate.as_ptr(), surrogate.len() as u32); + if ptr.is_null() { + throw_dom_exception("OperationError", "The operation failed"); + } + perry_runtime::buffer::mark_as_asymmetric_key(ptr as usize, kind_id, asym_type); + f64::from_bits(JSValue::string_ptr(ptr).bits()) +} diff --git a/crates/perry-stdlib/src/webcrypto/util.rs b/crates/perry-stdlib/src/webcrypto/util.rs index c7221901b1..829a96ac7b 100644 --- a/crates/perry-stdlib/src/webcrypto/util.rs +++ b/crates/perry-stdlib/src/webcrypto/util.rs @@ -345,6 +345,21 @@ fn runtime_key_kind_id(kind: KeyKind) -> u8 { } } +/// GC buffer-sweep callback: the `BufferHeader` backing this CryptoKey died, +/// so drop its material entry. Registered with the runtime by +/// `js_stdlib_init_dispatch` via `js_set_crypto_key_death_hook`. +/// +/// Without this the map grew without bound (one entry per CryptoKey ever +/// created) and — worse — a recycled buffer address inherited the dead key's +/// material, so `lookup_crypto_key` would hand an unrelated fresh Buffer to +/// the subtle sign/verify/encrypt paths. Runs inside the sweep: only removes a +/// HashMap entry, never allocates. +pub(crate) extern "C" fn crypto_key_buffer_died(buf_addr: usize) { + if let Ok(mut r) = CRYPTO_KEY_REGISTRY.lock() { + r.remove(&buf_addr); + } +} + pub(super) fn lookup_crypto_key(buf_addr: usize) -> Option { CRYPTO_KEY_REGISTRY .lock() diff --git a/test-parity/node-suite/crypto/key-object/keyobject-from-cryptokey.ts b/test-parity/node-suite/crypto/key-object/keyobject-from-cryptokey.ts new file mode 100644 index 0000000000..a3b794cd00 --- /dev/null +++ b/test-parity/node-suite/crypto/key-object/keyobject-from-cryptokey.ts @@ -0,0 +1,119 @@ +// #6302: `KeyObject.from()` must accept every CryptoKey shape Node accepts — +// asymmetric (public/private) keys and secret keys whose backing bytes did not +// come from a KeyObject export — and must throw for genuinely invalid input +// instead of silently returning `undefined`. +import * as crypto from "node:crypto"; +import { Buffer } from "node:buffer"; + +const KeyObject = (crypto as any).KeyObject; +const subtle = crypto.webcrypto.subtle; + +function report(label: string, fn: () => unknown) { + try { + console.log(`${label}:`, fn()); + } catch (err: any) { + console.log(`${label}:`, "err", err.name, err.code ?? ""); + } +} + +// ── secret CryptoKey imported from a plain Buffer (not a KeyObject export) ── +const hmacKey = await subtle.importKey( + "raw", + Buffer.from("00112233445566778899aabbccddeeff", "hex"), + { name: "HMAC", hash: "SHA-256" }, + true, + ["sign"], +); +const hmacKo = KeyObject.from(hmacKey); +console.log("hmac type:", hmacKo.type); +console.log("hmac instanceof KeyObject:", hmacKo instanceof KeyObject); +console.log("hmac export hex:", hmacKo.export().toString("hex")); +console.log("hmac symmetricKeySize:", hmacKo.symmetricKeySize); +console.log("hmac asymmetricKeyType:", hmacKo.asymmetricKeyType); + +const aesKey = await subtle.importKey( + "raw", + Buffer.from("000102030405060708090a0b0c0d0e0f", "hex"), + { name: "AES-GCM" }, + true, + ["encrypt", "decrypt"], +); +const aesKo = KeyObject.from(aesKey); +console.log("aes type:", aesKo.type); +console.log("aes export hex:", aesKo.export().toString("hex")); + +// ── asymmetric CryptoKeys ─────────────────────────────────────────────────── +const ed = (await subtle.generateKey({ name: "Ed25519" }, true, [ + "sign", + "verify", +])) as CryptoKeyPair; +const edPublic = KeyObject.from(ed.publicKey); +const edPrivate = KeyObject.from(ed.privateKey); +console.log("ed25519 public type:", edPublic.type); +console.log("ed25519 private type:", edPrivate.type); +console.log("ed25519 public asymmetricKeyType:", edPublic.asymmetricKeyType); +console.log("ed25519 private asymmetricKeyType:", edPrivate.asymmetricKeyType); +console.log("ed25519 public instanceof KeyObject:", edPublic instanceof KeyObject); +console.log("ed25519 private instanceof KeyObject:", edPrivate instanceof KeyObject); +console.log("ed25519 public symmetricKeySize:", edPublic.symmetricKeySize); + +const ec = (await subtle.generateKey({ name: "ECDSA", namedCurve: "P-256" }, true, [ + "sign", + "verify", +])) as CryptoKeyPair; +const ecPublic = KeyObject.from(ec.publicKey); +const ecPrivate = KeyObject.from(ec.privateKey); +console.log("ecdsa public type:", ecPublic.type); +console.log("ecdsa private type:", ecPrivate.type); +console.log("ecdsa public asymmetricKeyType:", ecPublic.asymmetricKeyType); +console.log("ecdsa private asymmetricKeyType:", ecPrivate.asymmetricKeyType); +console.log("ecdsa public instanceof KeyObject:", ecPublic instanceof KeyObject); + +const ecPublicPem = String(ecPublic.export({ format: "pem", type: "spki" })); +const ecPrivatePem = String(ecPrivate.export({ format: "pem", type: "pkcs8" })); +console.log("ecdsa public pem marker:", ecPublicPem.includes("BEGIN PUBLIC KEY")); +console.log("ecdsa private pem marker:", ecPrivatePem.includes("BEGIN PRIVATE KEY")); + +// A KeyObject produced by `from()` must be usable as key material downstream. +const message = Buffer.from("keyobject from cryptokey"); +const ecSignature = crypto.sign("sha256", message, ecPrivate); +console.log("ecdsa sign/verify:", crypto.verify("sha256", message, ecPublic, ecSignature)); +console.log( + "ecdsa verify via pem:", + crypto.verify("sha256", message, ecPublicPem, ecSignature), +); + +const rsa = (await subtle.generateKey( + { + name: "RSASSA-PKCS1-v1_5", + modulusLength: 2048, + publicExponent: new Uint8Array([1, 0, 1]), + hash: "SHA-256", + }, + true, + ["sign", "verify"], +)) as CryptoKeyPair; +const rsaPublic = KeyObject.from(rsa.publicKey); +const rsaPrivate = KeyObject.from(rsa.privateKey); +console.log("rsa public type:", rsaPublic.type); +console.log("rsa private type:", rsaPrivate.type); +console.log("rsa public asymmetricKeyType:", rsaPublic.asymmetricKeyType); +console.log("rsa private asymmetricKeyType:", rsaPrivate.asymmetricKeyType); +console.log( + "rsa public pem marker:", + String(rsaPublic.export({ format: "pem", type: "spki" })).includes("BEGIN PUBLIC KEY"), +); +const rsaSignature = crypto.sign("RSA-SHA256", message, rsaPrivate); +console.log("rsa sign len:", rsaSignature.length); +console.log("rsa sign/verify:", crypto.verify("RSA-SHA256", message, rsaPublic, rsaSignature)); + +// ── invalid input must throw, never resolve to `undefined` ────────────────── +report("from undefined", () => KeyObject.from(undefined)); +report("from null", () => KeyObject.from(null)); +report("from object", () => KeyObject.from({})); +report("from string", () => KeyObject.from("not a key")); +report("from number", () => KeyObject.from(7)); +report("from buffer", () => KeyObject.from(Buffer.from("00", "hex"))); +report("from KeyObject", () => + KeyObject.from(crypto.createSecretKey(Buffer.from("0011", "hex"))), +);