Skip to content

fix(crypto): KeyObject.from() must handle asymmetric and unregistered-buffer CryptoKeys (#6302)#6310

Merged
proggeramlug merged 2 commits into
mainfrom
fix/6302-keyobject-from
Jul 13, 2026
Merged

fix(crypto): KeyObject.from() must handle asymmetric and unregistered-buffer CryptoKeys (#6302)#6310
proggeramlug merged 2 commits into
mainfrom
fix/6302-keyobject-from

Conversation

@proggeramlug

@proggeramlug proggeramlug commented Jul 12, 2026

Copy link
Copy Markdown
Contributor

Fixes #6302 (split out of #2565, whose three headline deliverables shipped).

Root cause

native_module_crypto_key_object::key_object_from accepted exactly one shape:

if kind != 1 || !crate::buffer::is_registered_buffer(addr) {
    invalid_key(value);   // -> throws ERR_INVALID_ARG_TYPE
}

Anything that was not a secret CryptoKey backed by a buffer present in the
calling thread's buffer registry fell through to invalid_key(). A public
or private CryptoKey straight out of subtle.generateKey() / importKey() is
a perfectly valid input for Node's KeyObject.from(), but Perry rejected it —
on main the asymmetric cases die with:

TypeError: The "key" argument must be an instance of CryptoKey. Received an instance of Object

The is_registered_buffer half of the guard was wrong too: CryptoKey metadata
is only ever attached to a BufferHeader allocated by the WebCrypto key
factories, and that metadata also lives in a process-global registry, so the
key bytes are readable even when the thread-local buffer registry has no entry.

The checked-in fixture keyobject-class-from.ts only passes because it imports
from secret.export() — an already-registered secret buffer — so the gap was
uncovered.

Fix

Perry models the two KeyObject shapes differently:

  • secret — key bytes in a BufferHeader flagged mark_as_secret_key
  • asymmetric — a PEM (RSA/EC) or internal Ed/X surrogate string flagged
    mark_as_asymmetric_key

so key_object_from now dispatches on the CryptoKey's kind:

  • kind == secret → copy into a secret-key Buffer (buffer-registry
    precondition dropped).
  • kind == public | private → route through the WebCrypto dispatch hook to a
    new keyObjectFromCryptoKey bridge in perry-stdlib (the same bridge
    keyObject.toCryptoKey() uses in the opposite direction). It re-encodes the
    WebCrypto key material — RSA SPKI/PKCS#8 DER, EC SEC1 point / raw scalar
    (P-256/384/521), raw 32-byte Ed25519/X25519 — into exactly the
    PEM/surrogate strings createPublicKey() / createPrivateKey() /
    generateKeyPairSync() produce, and flags them with
    mark_as_asymmetric_key. type, asymmetricKeyType, export(),
    equals(), toCryptoKey() and the crypto.sign / crypto.verify paths
    therefore all work on the result.
  • key material Perry has no KeyObject surrogate for (Ed448, X448, ML-KEM) now
    throws Error [ERR_CRYPTO_UNSUPPORTED_OPERATION] instead of yielding
    something unusable. Node returns a KeyObject there — a remaining gap, but a
    loud one.

invalid_key() has no other callers, so there is no other silent-undefined
site in this module to fix.

Before / after vs node --experimental-strip-types (node 26.3)

input node perry main this PR
KeyObject.from(<Ed25519 public CryptoKey>) public throws ERR_INVALID_ARG_TYPE public
KeyObject.from(<Ed25519 private CryptoKey>) private throws ERR_INVALID_ARG_TYPE private
KeyObject.from(<ECDSA P-256 pair>) public / private, asymmetricKeyType: ec, sign+verify round-trips throws matches
KeyObject.from(<RSASSA-PKCS1-v1_5 pair>) public / private, asymmetricKeyType: rsa throws matches
KeyObject.from(<X25519 pair>) / <ECDH P-384> public / private throws matches
KeyObject.from(<HMAC CryptoKey from Buffer.from(hex)>) secret secret secret
KeyObject.from(undefined | null | {} | "x" | 7 | Buffer | KeyObject) TypeError ERR_INVALID_ARG_TYPE same same

Tests

New fixture test-parity/node-suite/crypto/key-object/keyobject-from-cryptokey.ts
covers the unregistered-buffer secret keys (HMAC + AES imported from
Buffer.from(hex)), the asymmetric Ed25519 / ECDSA P-256 / RSA cases including
a crypto.sign + crypto.verify round-trip through the produced KeyObjects,
and the seven invalid-input throws. It fails on main (the asymmetric block
throws) and is byte-identical to Node with this change.

Verified locally against node --experimental-strip-types:

  • all 13 test-parity/node-suite/crypto/key-object/ fixtures byte-identical
  • 98/98 fixtures under crypto/{subtle,webcrypto,asymmetric,secret-key,keygen} byte-identical
  • cargo fmt --all -- --check, scripts/check_file_size.sh,
    scripts/gc_store_site_inventory.py and scripts/addr_class_inventory.py
    clean for the touched files (the addr_class ratchet failure on
    crates/perry-runtime/src/map.rs is pre-existing on main — PR fix(runtime): Map/BigInt bare-address branches must reject the handle bands (unblocks main) #6297).

Summary by CodeRabbit

  • New Features
    • Added support for crypto.KeyObject.from(cryptoKey) for asymmetric CryptoKeys (in addition to secret keys), covering common algorithms such as HMAC, AES-GCM, Ed25519, ECDSA (P-256), X25519, and RSA.
  • Bug Fixes
    • Improved conversion validation and error reporting so invalid/unsupported inputs fail consistently with structured crypto errors.
    • Added GC-driven cleanup for CryptoKey-related buffer side data to prevent stale key material and unchecked registry growth.
  • Tests
    • Expanded Node test coverage for conversion, signing/verification, PEM export expectations, and invalid-input failure cases.

@coderabbitai

coderabbitai Bot commented Jul 12, 2026

Copy link
Copy Markdown

Review Change Stack

📝 Walkthrough

Walkthrough

KeyObject.from() now converts secret and asymmetric WebCrypto CryptoKey values, routes asymmetric conversion through WebCrypto surrogate encoding, reports unsupported inputs as errors, and cleans CryptoKey metadata when backing buffers are collected.

Changes

CryptoKey Object Conversion

Layer / File(s) Summary
Runtime conversion dispatch
crates/perry-runtime/src/object/native_module_crypto_key_object.rs, crates/perry-stdlib/src/webcrypto.rs
Metadata-based dispatch handles secret and asymmetric keys, allocates secret buffers, invokes the asymmetric bridge, and reports conversion failures.
WebCrypto surrogate bridge
crates/perry-stdlib/src/webcrypto/key_object.rs, crates/perry-stdlib/src/crypto/util.rs
Raw asymmetric key bytes are encoded as supported surrogates, returned through the dispatcher, and registered as asymmetric keys.
CryptoKey registry cleanup
crates/perry-runtime/src/buffer/header.rs, crates/perry-runtime/src/buffer/mod.rs, crates/perry-stdlib/src/common/dispatch/init.rs, crates/perry-stdlib/src/webcrypto/util.rs
Dead-buffer finalization removes CryptoKey side-table entries and invokes a registered callback to prune stored key material.
Conversion behavior coverage
test-parity/node-suite/crypto/key-object/keyobject-from-cryptokey.ts
Tests cover secret, Ed25519, ECDSA, and RSA keys, signing, verification, exports, and invalid inputs.

Estimated code review effort: 4 (Complex) | ~45 minutes

Sequence Diagram(s)

sequenceDiagram
  participant NodeCrypto
  participant RuntimeKeyObject
  participant WebCrypto
  participant KeyObject
  participant GC
  NodeCrypto->>RuntimeKeyObject: call KeyObject.from(CryptoKey)
  RuntimeKeyObject->>WebCrypto: dispatch keyObjectFromCryptoKey
  WebCrypto->>WebCrypto: read bytes and encode surrogate
  WebCrypto-->>RuntimeKeyObject: return converted key
  RuntimeKeyObject-->>KeyObject: return converted KeyObject
  GC->>RuntimeKeyObject: finalize CryptoKey backing buffer
  RuntimeKeyObject->>WebCrypto: notify crypto key death
  WebCrypto-->>RuntimeKeyObject: remove stored key material
Loading

Possibly related PRs

  • PerryTS/perry#6273: Both changes modify buffer finalization to prune stale buffer-associated state.
🚥 Pre-merge checks | ✅ 5
✅ Passed checks (5 passed)
Check name Status Explanation
Title check ✅ Passed The title is concise and accurately summarizes the main crypto.KeyObject.from() fix for asymmetric and unregistered-buffer CryptoKeys.
Description check ✅ Passed The description covers the root cause, fix, and tests, though it is not formatted exactly to the template headings.
Linked Issues check ✅ Passed The changes satisfy #6302 by handling asymmetric CryptoKeys and secret keys from unregistered buffers, with matching regression tests.
Out of Scope Changes check ✅ Passed The additional GC cleanup and registry plumbing are supporting crypto-key fixes, not obviously unrelated to the linked issue.
Docstring Coverage ✅ Passed Docstring coverage is 100.00% which is sufficient. The required threshold is 80.00%.
✨ Finishing Touches
📝 Generate docstrings
  • Create stacked PR
  • Commit on current branch
🧪 Generate unit tests (beta)
  • Create PR with unit tests
  • Commit unit tests in branch fix/6302-keyobject-from

Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out.

❤️ Share

Comment @coderabbitai help to get the list of available commands.

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🧹 Nitpick comments (1)
test-parity/node-suite/crypto/key-object/keyobject-from-cryptokey.ts (1)

1-119: 📐 Maintainability & Code Quality | 🔵 Trivial | ⚡ Quick win

Add an unsupported CryptoKey case. The fixture covers the supported shapes and invalid inputs, but not the ERR_CRYPTO_UNSUPPORTED_OPERATION path for Ed448/X448/ML-KEM. Add one report(...) case for an unsupported CryptoKey shape to lock that behavior in.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@test-parity/node-suite/crypto/key-object/keyobject-from-cryptokey.ts` around
lines 1 - 119, Add a report(...) case near the other invalid-input checks using
a CryptoKey generated with an unsupported algorithm such as Ed448, X448, or
ML-KEM, and pass it to KeyObject.from. Ensure the fixture observes the expected
ERR_CRYPTO_UNSUPPORTED_OPERATION error path without changing the existing
supported-key coverage.
🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

Nitpick comments:
In `@test-parity/node-suite/crypto/key-object/keyobject-from-cryptokey.ts`:
- Around line 1-119: Add a report(...) case near the other invalid-input checks
using a CryptoKey generated with an unsupported algorithm such as Ed448, X448,
or ML-KEM, and pass it to KeyObject.from. Ensure the fixture observes the
expected ERR_CRYPTO_UNSUPPORTED_OPERATION error path without changing the
existing supported-key coverage.

ℹ️ Review info
⚙️ Run configuration

Configuration used: defaults

Review profile: CHILL

Plan: Pro Plus

Run ID: aea605e0-7645-4cfa-b1bd-75f856181bbe

📥 Commits

Reviewing files that changed from the base of the PR and between 0960415 and d94d7cb.

📒 Files selected for processing (5)
  • crates/perry-runtime/src/object/native_module_crypto_key_object.rs
  • crates/perry-stdlib/src/crypto/util.rs
  • crates/perry-stdlib/src/webcrypto.rs
  • crates/perry-stdlib/src/webcrypto/key_object.rs
  • test-parity/node-suite/crypto/key-object/keyobject-from-cryptokey.ts

@proggeramlug proggeramlug force-pushed the fix/6302-keyobject-from branch from d94d7cb to e21fef7 Compare July 12, 2026 18:46

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 1

🧹 Nitpick comments (1)
crates/perry-runtime/src/object/native_module_crypto_key_object.rs (1)

92-115: 📐 Maintainability & Code Quality | 🔵 Trivial | ⚡ Quick win

Factor out the shared WebCrypto dispatch boilerplate. keyObject.from() and keyObject.toCryptoKey() both load/transmute JS_NATIVE_WEBCRYPTO_DISPATCH; a small shared helper would keep the unsafe signature and argument packing in one place.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@crates/perry-runtime/src/object/native_module_crypto_key_object.rs` around
lines 92 - 115, Factor the repeated WebCrypto dispatch setup from
asymmetric_key_object and keyObject.toCryptoKey into a shared helper that loads
and validates JS_NATIVE_WEBCRYPTO_DISPATCH, performs the unsafe function-pointer
conversion, and packs the NaN-boxed arguments before invoking the requested
method. Update both callers to reuse this helper while preserving their existing
method names and result-specific error handling.
🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

Inline comments:
In `@crates/perry-runtime/src/object/native_module_crypto_key_object.rs`:
- Around line 64-90: Update the CryptoKey metadata lifecycle used by
secret_key_object and crypto_key_bytes so registry entries cannot outlive their
BufferHeader. Either root each associated buffer for the lifetime of its
CryptoKey metadata, or update finalize_collected_dead_buffer to remove metadata
when the buffer is swept and handle address reuse consistently. Ensure stale
CryptoKey entries cannot be dereferenced after collection.

---

Nitpick comments:
In `@crates/perry-runtime/src/object/native_module_crypto_key_object.rs`:
- Around line 92-115: Factor the repeated WebCrypto dispatch setup from
asymmetric_key_object and keyObject.toCryptoKey into a shared helper that loads
and validates JS_NATIVE_WEBCRYPTO_DISPATCH, performs the unsafe function-pointer
conversion, and packs the NaN-boxed arguments before invoking the requested
method. Update both callers to reuse this helper while preserving their existing
method names and result-specific error handling.
🪄 Autofix (Beta)

Fix all unresolved CodeRabbit comments on this PR:

  • Push a commit to this branch (recommended)
  • Create a new PR with the fixes

ℹ️ Review info
⚙️ Run configuration

Configuration used: defaults

Review profile: CHILL

Plan: Pro Plus

Run ID: d0f36114-3d70-4b7d-8fb2-369381558d2e

📥 Commits

Reviewing files that changed from the base of the PR and between d94d7cb and e21fef7.

📒 Files selected for processing (5)
  • crates/perry-runtime/src/object/native_module_crypto_key_object.rs
  • crates/perry-stdlib/src/crypto/util.rs
  • crates/perry-stdlib/src/webcrypto.rs
  • crates/perry-stdlib/src/webcrypto/key_object.rs
  • test-parity/node-suite/crypto/key-object/keyobject-from-cryptokey.ts
🚧 Files skipped from review as they are similar to previous changes (2)
  • crates/perry-stdlib/src/webcrypto.rs
  • test-parity/node-suite/crypto/key-object/keyobject-from-cryptokey.ts

Comment thread crates/perry-runtime/src/object/native_module_crypto_key_object.rs
Ralph Küpper added 2 commits July 13, 2026 06:42
…-buffer CryptoKeys (#6302)

crypto.KeyObject.from() only accepted a secret CryptoKey whose backing
BufferHeader was in the calling thread's buffer registry (kind == 1 &&
is_registered_buffer). Every other CryptoKey — including a perfectly
valid public/private key from subtle.generateKey()/importKey() — fell
through to invalid_key() and never produced a KeyObject.

- runtime: dispatch on the CryptoKey kind. Secret keys copy into a
  secret-key Buffer (no buffer-registry precondition: CryptoKey metadata
  is only ever attached to a BufferHeader, and it also lives in a
  process-global registry, so the bytes are readable regardless).
  Public/private keys route to the WebCrypto bridge.
- stdlib: new keyObjectFromCryptoKey bridge re-encodes asymmetric key
  material (RSA SPKI/PKCS#8 DER, EC SEC1/scalar, Ed25519/X25519 raw)
  into the PEM/surrogate strings Perry's KeyObject surrogates use, and
  flags them with mark_as_asymmetric_key, so type / asymmetricKeyType /
  export / equals / toCryptoKey / sign / verify all work.
- key material Perry cannot model (Ed448, X448, ML-KEM) now throws
  instead of silently yielding a non-KeyObject.
- fixture: test-parity/node-suite/crypto/key-object/keyobject-from-cryptokey.ts
…swept

`finalize_collected_dead_buffer` exists to "drop every registry/side-table
entry keyed by a dead buffer's address" so a recycled address cannot inherit
the dead buffer's identity (the #6080 ABA class). The WebCrypto/KeyObject
tables were missing from that list:

  * CRYPTO_KEY_META_REGISTRY   (thread-local addr -> CryptoKeyMeta)
  * SECRET_KEY_REGISTRY        (thread-local)
  * UINT8ARRAY_FROM_CTOR       (thread-local)
  * external_buffers/uint8arrays/crypto_keys (process-global; all three are
    written together by js_buffer_mark_as_crypto_key_external)
  * perry-stdlib's CRYPTO_KEY_REGISTRY (addr -> CryptoKeyMaterial), the map
    lookup_crypto_key consults first

None of them root the BufferHeader, so both an unbounded leak and a stale-
identity hazard followed. Measured with a temporary probe in the sweep: a run
creating 60k HMAC CryptoKeys and dropping every JS reference swept 59,998 of
their buffers while retaining 59,998 CryptoKey metadata entries.

The identity hazard is not hypothetical: the old arena resets a fully-empty
block's offset to 0 while keeping its base pointer (arena_reset_empty_blocks
plus the block-reuse forward scan in Arena::alloc), so a reset block hands out
the same addresses again. 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, and lookup_crypto_key would hand it to the subtle sign/verify paths.

perry-runtime cannot call into perry-stdlib, so the stdlib map is pruned via a
death hook installed at startup, mirroring the existing js_set_native_*_dispatch
pattern. The callback only removes a HashMap entry — no allocation, so it is
safe to run inside the sweep.

Pruning is driven by the existing post-full-trace dead-buffer collection, so a
live CryptoKey (which is reachable precisely because the JS value IS the
BufferHeader pointer) is never affected.
@proggeramlug proggeramlug force-pushed the fix/6302-keyobject-from branch from e21fef7 to 573ad38 Compare July 13, 2026 05:06
@proggeramlug

Copy link
Copy Markdown
Contributor Author

CodeRabbit "Root CryptoKey metadata or prune it with buffer GC" — verified REAL, fixed in 573ad38

Rebased onto main first (10 PRs had landed; clean rebase).

What I verified

The factual core of the finding is correct. finalize_collected_dead_buffer — whose own doc comment says it exists to "drop every registry/side-table entry keyed by a dead buffer's address" so that "the recycled address inherits buffer identity … the #6080 ABA class" cannot happen — pruned only BUFFER_REGISTRY, ARRAY_BUFFER_REGISTRY, BUFFER_AB_ALIAS, detach and view entries. The WebCrypto/KeyObject tables were simply left off the list:

table scope pruned before?
CRYPTO_KEY_META_REGISTRY thread-local addr -> CryptoKeyMeta no
SECRET_KEY_REGISTRY thread-local no
UINT8ARRAY_FROM_CTOR thread-local no
external_buffers / external_uint8arrays / external_crypto_keys process-global (all three written together by js_buffer_mark_as_crypto_key_external) no
perry-stdlib CRYPTO_KEY_REGISTRY (addr -> CryptoKeyMaterial) process-global; the map lookup_crypto_key consults first no

None of them root the BufferHeader.

Reproduced (not just reasoned about)

Temporary probe in the sweep, 60k HMAC CryptoKeys created via subtle.importKey with every JS reference dropped:

[FIN] dead buffer 0x41b895a0ed8 swept, but CryptoKey meta Some((1, 2, 1, true, 12)) STAYS in registry
...
FIN lines: 59998

59,998 of 60,000 key buffers were collected while their CryptoKey metadata stayed in the registry — an unbounded leak (one entry per CryptoKey ever created, in three maps, for the life of the process). After the fix the same probe shows the registry draining 59999 → 2 (only the still-live keys remain).

One correction to the finding's framing

CodeRabbit describes it as secret_key_object/crypto_key_bytes "dereferencing stale addresses after the backing buffer is collected". That specific sequence isn't reachable: a Perry CryptoKey is the BufferHeader pointer, so while you can still name the key in JS it's a live GC root and registered_buffer_is_dead_post_trace never reports it dead. You cannot hand KeyObject.from() a key whose buffer was already freed.

The real hazard is address reuse (ABA), which is exactly what this finalizer was written to prevent — and it is not hypothetical: 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 reset block hands out the same addresses again. 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, and lookup_crypto_key would hand it to the subtle sign/verify paths. I was not able to force a block to go fully empty in a synthetic repro (so no observed ABA hit), but the leak is proven and the reuse mechanism is plainly there.

Pre-existing vs. new

The leak and the ABA are pre-existing on main, not introduced here — but this PR widens the exposure, which is why I fixed it rather than deferring. On main key_object_from required kind == 1 && is_registered_buffer(addr); this PR drops the is_registered_buffer precondition and adds the kind == 2|3 asymmetric route, so a stale metadata entry now suffices on its own and can steer into the asymmetric encoder.

Fix (573ad38, code-only)

Added the missing tables to finalize_collected_dead_buffer. perry-runtime can't call into perry-stdlib, so stdlib's material map is pruned through a js_set_crypto_key_death_hook callback installed at startup, mirroring the existing js_set_native_*_dispatch pattern; the callback only removes a HashMap entry, so it never allocates inside the sweep. Pruning rides the existing post-full-trace dead-buffer collection, so live keys are untouched.

I deliberately did not reach for GC_FLAG_PINNED to root the buffers: pinned objects are not traced (gc/cycle.rs only enqueues when flags & (MARKED|PINNED) == 0), so pinning would have traded a stale entry for an untraced-reference walk — and it would have made the leak permanent by design.

Not touched (same ABA class, but outside this finding and this PR's code path): DATA_VIEW_REGISTRY and SHARED_ARRAY_BUFFER_REGISTRY. Worth a follow-up issue; the SAB backing is a process-global never-freed allocation so it should never be collected anyway.

Validation

  • new unit test test_dead_buffer_finalize_prunes_crypto_key_side_tables (fails without the fix)
  • all 13 test-parity/node-suite/crypto/key-object/ fixtures byte-identical to node --experimental-strip-types
  • 88/88 fixtures across crypto/{subtle,webcrypto,asymmetric,secret-key,keygen} byte-identical
  • cargo test -p perry-runtime --lib: 1278 passed. The one failure, native_module_stream::…::stream_constructors_expose_static_method_values, is the known parallel-suite flake — it passes in isolation and is a streams test with no overlap with buffer/crypto
  • cargo fmt --all -- --check and scripts/check_file_size.sh clean

Re the nitpick on keyobject-from-cryptokey.ts (add an ERR_CRYPTO_UNSUPPORTED_OPERATION case for Ed448/X448/ML-KEM): skipping. That path can't be exercised as a parity fixture — the fixture's contract is byte-identical output against Node, and Node succeeds there (it returns a KeyObject) whereas Perry throws. Asserting Perry's throw would bake in a permanent, intentional divergence from Node and make the fixture fail the moment the gap is closed. The divergence is already called out in the PR description as a known remaining gap.

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 2

🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

Inline comments:
In `@crates/perry-runtime/src/buffer/header.rs`:
- Around line 545-562: Optimize finalize_collected_dead_buffer by gating each
external_* mutex lock with its corresponding nonempty atomic latch, following
the pattern used by is_registered_buffer. Capture the removal result from
external_crypto_keys() and call notify_crypto_key_death(addr) only when an entry
was actually removed, while continuing to clear all relevant tables for real
CryptoKey buffers.
- Around line 519-544: Update finalize_collected_dead_buffer to remove the
address from ASYMMETRIC_KEY_REGISTRY alongside CRYPTO_KEY_META_REGISTRY and
SECRET_KEY_REGISTRY. Ensure mark_as_asymmetric_key entries are cleared when the
buffer is finalized, preventing stale KeyObject metadata on recycled addresses.
🪄 Autofix (Beta)

Fix all unresolved CodeRabbit comments on this PR:

  • Push a commit to this branch (recommended)
  • Create a new PR with the fixes

ℹ️ Review info
⚙️ Run configuration

Configuration used: defaults

Review profile: CHILL

Plan: Pro Plus

Run ID: 33a1daa2-96c2-4829-a998-8179d71967e0

📥 Commits

Reviewing files that changed from the base of the PR and between e21fef7 and 573ad38.

📒 Files selected for processing (9)
  • crates/perry-runtime/src/buffer/header.rs
  • crates/perry-runtime/src/buffer/mod.rs
  • crates/perry-runtime/src/object/native_module_crypto_key_object.rs
  • crates/perry-stdlib/src/common/dispatch/init.rs
  • crates/perry-stdlib/src/crypto/util.rs
  • crates/perry-stdlib/src/webcrypto.rs
  • crates/perry-stdlib/src/webcrypto/key_object.rs
  • crates/perry-stdlib/src/webcrypto/util.rs
  • test-parity/node-suite/crypto/key-object/keyobject-from-cryptokey.ts
🚧 Files skipped from review as they are similar to previous changes (4)
  • crates/perry-stdlib/src/crypto/util.rs
  • crates/perry-runtime/src/object/native_module_crypto_key_object.rs
  • test-parity/node-suite/crypto/key-object/keyobject-from-cryptokey.ts
  • crates/perry-stdlib/src/webcrypto/key_object.rs

Comment on lines +519 to +544
// 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);
});

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🩺 Stability & Availability | 🟠 Major | ⚡ Quick win

🧩 Analysis chain

🏁 Script executed:

#!/bin/bash
rg -n "ASYMMETRIC_KEY_REGISTRY" -C4 --type=rust
rg -n "fn mark_as_asymmetric_key|fn is_asymmetric_key|asymmetric_key_meta" -C6 --type=rust

Repository: PerryTS/perry

Length of output: 151


🏁 Script executed:

#!/bin/bash
set -euo pipefail

# Locate relevant files and references to key registries / asymmetric key helpers.
git ls-files | rg 'crates/perry-runtime/src/(buffer|crypto|webcrypto|key|mod).*\.rs$|crates/perry-runtime/src/.+\.rs$'
rg -n "ASYMMETRIC_KEY_REGISTRY|CRYPTO_KEY_META_REGISTRY|SECRET_KEY_REGISTRY|UINT8ARRAY_FROM_CTOR|mark_as_asymmetric_key|is_asymmetric_key|asymmetric" crates/perry-runtime/src -C 3

# Inspect the buffer header cleanup area and the asymmetric-key related definitions/usages if any.
ast-grep outline crates/perry-runtime/src/buffer/header.rs --view expanded
ast-grep outline crates/perry-runtime/src/buffer/mod.rs --view expanded

Repository: PerryTS/perry

Length of output: 50369


🏁 Script executed:

#!/bin/bash
set -euo pipefail

# If the prior search finds no asymmetric registry symbol, inspect the key-related API surface to see
# whether any asymmetric identity/type checks are implemented under different names.
rg -n "KeyObject|CryptoKey|secret key|asymmetric key|public key|private key|export\\(|instanceof CryptoKey|isCryptoKey|isKeyObject|util.types.isCryptoKey|util.types.isKeyObject" crates/perry-runtime/src -C 2

# Show the cleanup function around the cited lines.
sed -n '500,560p' crates/perry-runtime/src/buffer/header.rs

Repository: PerryTS/perry

Length of output: 50369


🏁 Script executed:

#!/bin/bash
set -euo pipefail

# Search for any other address-keyed registries in buffer/header.rs and adjacent modules.
rg -n "thread_local!|addr ->|remove\\(&addr\\)|insert\\(addr|HashMap<usize|HashMap<u64|BTreeMap<usize|BTreeMap<u64" crates/perry-runtime/src/buffer -C 2

# Enumerate nearby files in the buffer module.
find crates/perry-runtime/src/buffer -maxdepth 2 -type f | sort

Repository: PerryTS/perry

Length of output: 15415


Remove ASYMMETRIC_KEY_REGISTRY in finalize_collected_dead_buffer
mark_as_asymmetric_key populates the same addr-keyed side table, and it gates KeyObject brand/type/property dispatch (instanceof, util.types.isKeyObject, toCryptoKey, .export(), type/asymmetricKeyType/asymmetricKeyDetails). Leaving it behind leaks entries and lets a recycled address inherit stale KeyObject identity.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@crates/perry-runtime/src/buffer/header.rs` around lines 519 - 544, Update
finalize_collected_dead_buffer to remove the address from
ASYMMETRIC_KEY_REGISTRY alongside CRYPTO_KEY_META_REGISTRY and
SECRET_KEY_REGISTRY. Ensure mark_as_asymmetric_key entries are cleared when the
buffer is finalized, preventing stale KeyObject metadata on recycled addresses.

Comment on lines +545 to +562
// `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);

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🚀 Performance & Scalability | 🟠 Major | ⚡ Quick win

New unconditional global-mutex locks + cross-crate FFI hook run on every dead buffer, not just CryptoKeys.

Lines 548-556 add three brand-new external_buffers()/external_uint8arrays()/external_crypto_keys() .lock() calls to finalize_collected_dead_buffer, and notify_crypto_key_death(addr) unconditionally invokes the perry-stdlib hook (which itself locks CRYPTO_KEY_REGISTRY) — for every dead buffer, including the overwhelming majority that were never CryptoKeys. This runs on the GC sweep's per-dead-object hot path.

is_registered_buffer already shows the cheaper pattern for exactly this: it gates the external_buffers().lock() behind the EXTERNAL_BUFFERS_NONEMPTY atomic latch to skip the lock entirely when the map has never been populated. None of that reuse happens here, and the removals' own return values (HashMap::removeOption, HashSet::removebool) are discarded even though they'd tell you for free whether this address was ever a crypto key — which is exactly the condition needed to skip the stdlib hook call.

⚡ Proposed fix — skip the expensive paths for non-crypto buffers
-    CRYPTO_KEY_META_REGISTRY.with(|r| {
-        r.borrow_mut().remove(&addr);
-    });
-    SECRET_KEY_REGISTRY.with(|r| {
-        r.borrow_mut().remove(&addr);
-    });
+    let was_crypto_key_meta =
+        CRYPTO_KEY_META_REGISTRY.with(|r| r.borrow_mut().remove(&addr).is_some());
+    let was_secret_key = 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 EXTERNAL_BUFFERS_NONEMPTY.load(std::sync::atomic::Ordering::Acquire) {
+        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 ...
-    notify_crypto_key_death(addr);
+    let was_external_crypto_key = external_crypto_keys()
+        .lock()
+        .map(|mut r| r.remove(&addr).is_some())
+        .unwrap_or(false);
+    // perry-stdlib keeps its own `addr -> CryptoKeyMaterial` map ...
+    if was_crypto_key_meta || was_secret_key || was_external_crypto_key {
+        notify_crypto_key_death(addr);
+    }

This still fully clears every table for actual CryptoKey buffers; it just avoids the mutex/FFI overhead for the common case of ordinary Buffers.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@crates/perry-runtime/src/buffer/header.rs` around lines 545 - 562, Optimize
finalize_collected_dead_buffer by gating each external_* mutex lock with its
corresponding nonempty atomic latch, following the pattern used by
is_registered_buffer. Capture the removal result from external_crypto_keys() and
call notify_crypto_key_death(addr) only when an entry was actually removed,
while continuing to clear all relevant tables for real CryptoKey buffers.

@proggeramlug proggeramlug merged commit 14c801b into main Jul 13, 2026
25 checks passed
@proggeramlug proggeramlug deleted the fix/6302-keyobject-from branch July 13, 2026 06:18
proggeramlug added a commit that referenced this pull request Jul 13, 2026
… buffer death (#6337) (#6341)

* fix(gc): prune DATA_VIEW_REGISTRY and SHARED_ARRAY_BUFFER_REGISTRY on buffer death (#6337)

`finalize_collected_dead_buffer` exists to drop "every registry/side-table
entry keyed by a dead buffer's address" — its doc comment cites preventing the
#6080 ABA class — but it only pruned BUFFER_REGISTRY, ARRAY_BUFFER_REGISTRY and
BUFFER_AB_ALIAS. `DATA_VIEW_REGISTRY` and `SHARED_ARRAY_BUFFER_REGISTRY` had
zero `.remove`/`.retain` sites anywhere in the tree.

Two consequences, the same pair proven for the WebCrypto tables in #6302/#6310:

  * unbounded leak — one permanent entry per DataView / SAB-flagged buffer ever
    created, for the life of the process;
  * ABA / address reuse — `arena_reset_empty_blocks` resets a fully-empty
    block's offset to 0 while keeping its base pointer, so a reset block
    re-issues the same addresses and a stale entry answers for an unrelated
    object. `is_data_view`/`is_shared_array_buffer` gate `util.types.isDataView`
    / `isSharedArrayBuffer`, `ArrayBuffer.isView`, the `[object DataView]` tag,
    and the structuredClone / `.slice()` re-marking paths.

Both are now pruned in the post-full-trace sweep, alongside the existing three.

SharedArrayBuffer needed a second, separate fix. A process-global SAB backing
(`shared_sab::alloc_shared_sab`) is NOT a GC allocation: it comes straight from
`alloc_zeroed`, carries no GcHeader, and is never freed — that is what lets the
bytes alias across `perry/thread` agents (#4913). But `js_shared_array_buffer_new`
does `register_buffer` it, so it reaches `registered_buffer_is_dead_post_trace`
on every full trace, where `try_read_gc_header` reads the 8 bytes BEFORE the
malloc block and interprets the allocator's own metadata as a GcHeader: one
arbitrary byte compared against GC_TYPE_BUFFER (10), the next against the
mark/pin/forward bits. A chance match declares a LIVE, never-freed SAB dead —
and `finalize_collected_dead_buffer` then runs `view::remove_entries_for_dead_buffer`,
which retains on `info.backing != addr` and so unregisters EVERY live
typed-array view over that SAB: exactly the views cross-agent `Atomics`
wait/notify resolve their absolute slot addresses through.

So the backing is now vetoed as a dead candidate before any header is sniffed,
rather than pruned. The entries that DO get pruned from
SHARED_ARRAY_BUFFER_REGISTRY are the arena-allocated SAB-flagged copies
(`SharedArrayBuffer.prototype.slice`, structuredClone), which are ordinary
GC-heap buffers that genuinely die and whose addresses genuinely get recycled.
Cross-thread aliasing, the process-global registry and the Atomics futex table
are untouched.

The veto sits behind a `SHARED_SAB_NONEMPTY` latch mirroring
`EXTERNAL_BUFFERS_NONEMPTY`, so processes that never allocate a SAB — nearly all
of them — answer from one relaxed atomic load and never take the registry lock.
That also removes an unconditional mutex acquisition from `is_registered_buffer`'s
fallback, which JSON.stringify runs per serialized pointer (#6009).

Tests (`gc/tests/buffer_side_tables.rs`): dead DataView / SAB-flagged buffer
pruned on full GC; live (rooted) ones survive; a 4096-entry leak regression that
asserts both registries DRAIN to baseline after their owners are swept; and two
SAB-liveness tests, one seeding the shared-SAB registry to prove the veto
deterministically (the malloc-metadata coincidence cannot be forced without
writing outside the allocation), one end-to-end over a real
`new SharedArrayBuffer(64)`.

* test(gc): root the live-survival buffers in a real shadow frame (#6337)

GcTestIsolationGuard does not push a shadow frame, so js_shadow_slot_set
under it roots nothing — the buffers stayed unreachable and the safety
inverse would have passed for the wrong reason. Use CopyingNurseryTestGuard,
which pushes the frame (same as the dead_owner_side_tables precedent).

* perf(gc): snapshot the SAB registry once per dead-buffer scan (#6337)

The veto in registered_buffer_is_dead_post_trace called is_shared_sab per
registered buffer, taking the process-global registry mutex once per buffer
per full trace in any program that allocates a SharedArrayBuffer. The set is
tiny and the GC is stop-the-world here, so lock it once per scan and hand the
filter a plain set. Returns None — and skips the check entirely, allocating
nothing — for the processes that never allocate a SAB.

---------

Co-authored-by: Ralph Küpper <ralph@skelpo.com>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

runtime: crypto.KeyObject.from() returns undefined for asymmetric CryptoKeys and unregistered-buffer secret keys

1 participant