capybara-simulated 0.7.0 → 0.9.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -41,7 +41,7 @@ module Capybara
41
41
  # JS exception that crashes the whole script chain. Bodies take
42
42
  # `(browser, *js_args)` and return whatever the JS caller expects.
43
43
  BROWSER_HOST_FNS = {
44
- '__rackFetch' => ->(b, *a) { b.rack_fetch(a[0], a[1], a[2], a[3], a[4], a[5], credentials: a[6] || 'same-origin', referrer_policy: a[7], referrer: a[8], cache_mode: a[9] || 'default') },
44
+ '__rackFetch' => ->(b, *a) { b.rack_fetch(a[0], a[1], a[2], a[3], a[4], a[5], credentials: a[6] || 'same-origin', referrer_policy: a[7], referrer: a[8], cache_mode: a[9] || 'default', initiator: a[10], site_seed: a[11], origin_null: a[12]) },
45
45
  '__csimExternalAsset' => ->(b, *a) { b.external_asset_source(a[0]) },
46
46
  '__locationAssign' => ->(b, *a) { b.location_assign(a[0]); nil },
47
47
  '__locationReload' => ->(b, *_) { b.location_reload; nil },
@@ -62,9 +62,15 @@ module Capybara
62
62
  # like __csimFrameNavigate — see Browser#frame_submit_self.
63
63
  '__csimFrameSubmit' => ->(b, *a) { b.frame_submit_self(a[0].to_i); nil },
64
64
  '__csimFrameHistoryGo' => ->(b, *a) { b.frame_history_go(a[0].to_i, a[1].to_i); nil },
65
+ # A frame `src=` re-navigation records a session-history entry (snapshotting the OUTGOING
66
+ # document) so history.go(-1) can traverse back — and a controlling SW sees
67
+ # isHistoryNavigation. Must run while the outgoing realm (a[0]) is still alive.
68
+ '__csim_recordFrameNav' => ->(b, *a) { b.record_frame_nav(a[0].to_i, a[1]); nil },
65
69
  '__setTimersActive' => ->(b, *a) { b.timers_active = !!a[0]; nil },
66
- '__setCurrentUrl' => ->(b, *a) { b.history_state(a[0], a[1]); nil },
67
- '__pushHistoryEntry' => ->(b, *a) { b.history_push(a[0], a[1]); nil },
70
+ # a[2] is the realm that navigated: a nested browsing context keeps its OWN session
71
+ # history, so a frame's pushState must not be mirrored onto the top document's.
72
+ '__setCurrentUrl' => ->(b, *a) { b.history_state(a[0], a[1], a[2]); nil },
73
+ '__pushHistoryEntry' => ->(b, *a) { b.history_push(a[0], a[1], a[2]); nil },
68
74
  '__historyGo' => ->(b, *a) { b.history_go(a[0]); nil },
69
75
  '__historyLength' => ->(b, *_) { b.history_length },
70
76
  '__csimReadFilePick' => ->(b, *a) { b.read_file_pick(a[0], a[1], a[2], a[3]) },
@@ -72,11 +78,24 @@ module Capybara
72
78
  '__setDocumentCookie' => ->(b, *a) { b.write_document_cookie(a[0].to_s); nil },
73
79
  '__getDocumentReferrer' => ->(b, *_) { b.current_referer },
74
80
  '__csim_storageGet' => ->(b, *a) { b.storage_get(a[0], a[1]) },
75
- '__csim_storageSet' => ->(b, *a) { b.storage_set(a[0], a[1], a[2]); nil },
81
+ '__csim_storageSet' => ->(b, *a) { b.storage_set(a[0], a[1], a[2]) },
76
82
  '__csim_storageRemove' => ->(b, *a) { b.storage_remove(a[0], a[1]); nil },
77
83
  '__csim_storageClear' => ->(b, *a) { b.storage_clear(a[0]); nil },
78
84
  '__csim_storageKey' => ->(b, *a) { b.storage_key(a[0], a[1]) },
79
85
  '__csim_storageLength' => ->(b, *a) { b.storage_length(a[0]) },
86
+ '__csimStorageChanged' => ->(b, *a) { b.storage_changed(a[0], a[1], a[2], a[3], a[4], a[5]); nil },
87
+ # Cache Storage — origin-partitioned (a[0] = origin key), Ruby-backed so it
88
+ # survives the per-visit VM rebuild and is shared between a service worker and
89
+ # the client it controls. The JS side (cache-storage.js) owns the spec matching;
90
+ # Ruby is a dumb ordered store keyed by (origin, cache name).
91
+ '__csim_cacheStorageOpen' => ->(b, *a) { b.cache_storage_open(a[0], a[1]) },
92
+ '__csim_cacheStorageHas' => ->(b, *a) { b.cache_storage_has(a[0], a[1]) },
93
+ '__csim_cacheStorageDelete' => ->(b, *a) { b.cache_storage_delete(a[0], a[1]) },
94
+ '__csim_cacheStorageKeys' => ->(b, *a) { b.cache_storage_keys(a[0]) },
95
+ '__csim_cacheEntries' => ->(b, *a) { b.cache_entries(a[0], a[1]) },
96
+ '__csim_cacheEntryResponse' => ->(b, *a) { b.cache_entry_response(a[0], a[1], a[2]) },
97
+ '__csim_cachePut' => ->(b, *a) { b.cache_put(a[0], a[1], a[2], a[3], a[4]); nil },
98
+ '__csim_cacheDeleteEntries' => ->(b, *a) { b.cache_delete_entries(a[0], a[1], a[2]) },
80
99
  '__csimGeolocationState' => ->(b, *_) { b.geolocation_state_json },
81
100
  '__modalDialog' => ->(b, *a) { b.handle_modal(a[0], a[1], a[2]) },
82
101
  '__csim_pushImportmap' => ->(b, *a) { b.set_importmap(a[0]); nil },
@@ -90,15 +109,24 @@ module Capybara
90
109
  '__csim_rackFetchAsyncAbort' => ->(b, *a) { b.rack_fetch_async_abort(a[0]); nil },
91
110
  # Cross-window references (window.open / opener / postMessage). A separate-VM
92
111
  # aux window forwards to the Driver; a same-origin window realm lives in this
93
- # isolate. a[2] is the opener's realm id (for wiring window.opener).
94
- '__csimWindowOpen' => ->(b, *a) { b.open_child_window(a[0], a[1], a[2]) },
112
+ # isolate. a[2] is the opener's realm id (for wiring window.opener); a[3]/a[4] the opener
113
+ # document's base URL and origin, which an about:blank popup inherits.
114
+ '__csimWindowOpen' => ->(b, *a) { b.open_child_window(a[0], a[1], a[2], a[3], a[4]) },
95
115
  # A `target=_blank`/named link/area activation from a frame or window realm:
96
116
  # open a new auxiliary window (the realm's VM isn't rebuilt — a fresh window
97
117
  # is). `opener` = rel=opener (target=_blank defaults to noopener); the Driver
98
118
  # forces noopener for a cross-partition blob: target.
99
119
  '__csimOpenAuxFromRealm' => ->(b, *a) { b.open_aux_from_realm(a[0], a[1], a[2]); nil },
100
120
  '__csimWindowPostMessage' => ->(b, *a) { b.post_message_to_window(a[0], a[1], a[2]); nil },
101
- '__csimBroadcast' => ->(b, *a) { b.broadcast_to_windows(a[0], a[1], a[2].to_i); nil },
121
+ '__csimBroadcast' => ->(b, *a) { b.broadcast_to_windows(a[0], a[1], a[2].to_i, a[3]); nil },
122
+ # BroadcastChannel isolate-wide, creation-ordered registry (the multi-realm delivery path). A
123
+ # channel registers on construction / unregisters on close; `bc_post` snapshots the eligible
124
+ # targets at post time and queues one ordered delivery per target. Only consulted when a sibling
125
+ # same-isolate realm exists (`bc_siblings_exist?`); single-window pages never touch it.
126
+ '__csimBcRegister' => ->(b, *a) { b.bc_register(a[0], a[1], a[2], a[3]); nil },
127
+ '__csimBcUnregister' => ->(b, *a) { b.bc_unregister(a[0], a[1]); nil },
128
+ '__csimBcPost' => ->(b, *a) { b.bc_post(a[0], a[1], a[2], a[3], a[4], a[5]); nil },
129
+ '__csimBcSiblingsExist' => ->(b, *_a) { b.bc_siblings_exist? },
102
130
  '__csimWindowGet' => ->(b, *a) { b.window_get(a[0], a[1]) },
103
131
  '__csimWindowDocGet' => ->(b, *a) { b.window_doc_get(a[0], a[1]) },
104
132
  # Cross-window remote-ref RPC: route an opener's node/object proxy op to
@@ -115,13 +143,57 @@ module Capybara
115
143
  # Fire an aux window's OWN `load` event (in its VM) — deferred by the
116
144
  # opener so a child `window.onload` runs after the opener's current task.
117
145
  '__csimFireAuxWindowLoad' => ->(b, *a) { b.fire_aux_window_load(a[0]); nil },
118
- '__csim_workerSpawn' => ->(b, *a) { b.worker_spawn(a[0], shared: !!a[1]) },
146
+ # a[3] is the CREATING realm (the worker dies when it is discarded); a[4] that context's
147
+ # CURRENT controller handle, which a DEDICATED worker inherits rather than scope-matching
148
+ # its own — often opaque (blob:/data:) — script URL. 0 when the creator is uncontrolled.
149
+ '__csim_workerSpawn' => ->(b, *a) { b.worker_spawn(a[0], shared: !!a[1], creator_key: a[2], realm_id: a[3].to_i, controller_handle: a[4].to_i) },
119
150
  # navigator.serviceWorker.register (universal-server only) — spawn a worker
120
151
  # running the SW script as an executor context. Returns its handle.
121
- '__csim_serviceWorkerRegister' => ->(b, *a) { b.worker_spawn(a[0], service: true) },
152
+ '__csim_serviceWorkerRegister' => ->(b, *a) { b.worker_spawn(a[0], service: true, creator_key: a[1]) },
122
153
  '__csim_workerPostToWorker' => ->(b, *a) { b.worker_post_to_worker(a[0], a[1]); nil },
154
+ # ServiceWorker.postMessage from a client window → the SW's `message` event (source = client).
155
+ '__csim_serviceWorkerPostMessage' => ->(b, *a) { b.service_worker_post_message(a[0], a[1], a[2], a[3]); nil },
156
+ # Cross-isolate MessagePort channel (client-realm side): register this realm's endpoint, and
157
+ # relay a client-realm port's postMessage to its remote (worker/SW) peer.
158
+ '__csimClientPortEndpoint' => ->(b, *a) { b.port_channel_endpoint_realm(a[0], a[1]); nil },
159
+ '__csimClientPortPost' => ->(b, *a) { b.client_port_post(a[0], a[1]); nil },
160
+ # The focus chain moved into this realm's browsing context (a focus() commit, or an
161
+ # <iframe> focused in its container, which hands focus to the nested context).
162
+ '__csimNoteFocusedRealm' => ->(b, *a) { b.note_focused_realm(a[0]); nil },
163
+ # This realm is a service-worker client: reported at document load and again whenever
164
+ # control is installed, by the realm, because only it knows its own URL, frame type and
165
+ # controller. `a[3]` is the controlling worker's handle, 0 when uncontrolled.
166
+ '__csimNoteClient' => ->(b, *a) { b.sw_note_client(a[0], a[1], a[2], a[3]); nil },
167
+ # A controlled client's fetch → the controlling SW's `fetch` event. Returns false if the SW
168
+ # is gone (client falls back to the network).
169
+ '__csim_serviceWorkerControllerFetch' => ->(b, *a) { b.service_worker_controller_fetch(a[0], a[1], a[2], a[3]) },
170
+ # A controlled client cancelled a streaming respondWith body → cancel the SW's source stream
171
+ # (routed to the worker that owns this [realm, fetch] stream). See sw_stream_cancel.
172
+ '__csim_swStreamCancel' => ->(b, *a) { b.sw_stream_cancel(a[0], a[1]); nil },
173
+ # Client lifecycle mirrors scope→active-worker into Ruby so a navigation (fetched
174
+ # Ruby-side before the destination realm's JS exists) can find its controlling SW.
175
+ '__csim_swRegisterScope' => ->(b, *a) { b.sw_register_scope(a[0], a[1]); nil },
176
+ '__csim_swUnregisterScope' => ->(b, *a) { b.sw_unregister_scope(a[0]); nil },
177
+ # The active worker handle at an EXACT scope (0 if none), so a register() from a realm with no
178
+ # local registration (a different iframe registering an already-active scope) can synthesize a
179
+ # registration reflecting the shared active worker instead of installing a duplicate.
180
+ '__csim_swActiveHandleForScope' => ->(b, *a) { b.sw_active_handle_for_scope(a[0]) },
181
+ # Does this worker still control any client? An incoming worker may only activate once the
182
+ # outgoing one controls nothing (or skipWaiting is called) — see _scheduleLifecycle.
183
+ '__csim_swControlsClients' => ->(b, *a) { b.sw_worker_controls_clients?(a[0]) },
184
+ '__csim_swNoteActivationParked' => ->(b, *_) { b.sw_note_activation_parked; nil },
185
+ # Navigation Preload state (NavigationPreloadManager), keyed by the registration's active
186
+ # worker handle — reached identically from the client (registration.active._handle) and the
187
+ # worker (__csimWorkerHandle). Get returns {enabled, headerValue}; set leaves a nil field as-is.
188
+ '__csim_swNavPreloadState' => ->(b, *a) { b.nav_preload_state(a[0]) },
189
+ '__csim_swNavPreloadSet' => ->(b, *a) { b.nav_preload_set(a[0], a[1], a[2]); nil },
190
+ # A navigation (iframe/document load) → its controlling SW's `fetch` event, awaited
191
+ # synchronously. Returns the response wire hash, or nil to load from the network.
192
+ '__csim_swNavigationFetch' => ->(b, *a) { b.service_worker_navigation_fetch(a[0], is_reload: !!a[1], is_history: !!a[2], referrer_source: a[3], method: a[4] || 'GET', body_b64: a[5] || '', content_type: a[6]) },
123
193
  '__csim_workerTerminate' => ->(b, *a) { b.worker_terminate(a[0]); nil },
124
194
  '__csim_decodeImage' => ->(b, *a) { b.decode_image(a[0], a[1], a[2]) },
195
+ '__csim_renderText' => ->(b, *a) { b.render_text(a[0], a[1], a[2], a[3], a[4]) },
196
+ '__csim_loadImage' => ->(b, *a) { b.load_image(a[0], !!a[1], a[2] || 'same-origin') },
125
197
  '__csim_blobRegister' => ->(b, *a) { b.blob_register(a[0], a[1], a[2]); nil },
126
198
  # WHATWG/UTS46 IDNA for the URL parser's host processing (the JS tr46 stub
127
199
  # delegates non-ASCII / xn-- hosts here; ASCII stays in-VM).
@@ -144,6 +216,7 @@ module Capybara
144
216
  # eager-@app.calls a foreign URL (side effects: extra visit / log row).
145
217
  '__csim_allHostsLocal' => ->(b, *a) { b.send(:all_hosts_local?) },
146
218
  '__csim_decodeVideoFrame' => ->(b, *a) { b.decode_video_frame(a[0]) },
219
+ '__csim_videoBytesB64' => ->(b, *a) { b.video_bytes_b64(a[0]) },
147
220
  '__csim_encodeImage' => ->(b, *a) { b.encode_image(a[0], a[1], a[2], a[3], a[4]) },
148
221
  # WebAuthn create / get raise `WebauthnState::Error` carrying
149
222
  # the DOMException name (`InvalidStateError`, …); rescue here
@@ -171,34 +244,18 @@ module Capybara
171
244
  # Host fns that route to pure stdlib — no Browser surface,
172
245
  # nothing to safe_call, no allocation needed for the wrap. Skip
173
246
  # the rescue overhead on every per-find / per-event invocation.
174
- # Process-wide cascade-rule cache (mirrors the script bytecode cache). The
175
- # built {hide, layout} rules are deterministic per (stylesheet-set,
176
- # viewport), so the JS side caches the serialized rules keyed by a digest of
177
- # the sheet sources and skips the ~12-15 ms css-tree parse + per-rule
178
- # specificity + terminalKey rebuild on every per-visit VM rebuild. Lives in
179
- # Ruby (not the VM) so it survives `rebuild_ctx`. Key space is tiny (one app
180
- # ships one stylesheet set), so the map stays small; no eviction needed.
181
- CASCADE_RULE_CACHE = {}
182
- CASCADE_RULE_CACHE_MUTEX = Mutex.new
183
-
184
- # Process-wide PER-SHEET parse cache (companion to CASCADE_RULE_CACHE). The
185
- # built whole-cascade is cached above, but it misses whenever a page's inline
186
- # `<style>` changes (Avo injects per-page styles), forcing a rebuild that
187
- # re-parses every sheet — including unchanged linked bundles (avo.base.css).
188
- # `parseSheet` is pure, so the JS side caches its serialized `{hide,layout}`
189
- # keyed by (cssText hash, viewport) here, surviving the per-visit VM rebuild
190
- # that wipes the in-VM `__sheetCache` — the CSS analogue of the JS bytecode
191
- # cache. Keyed by content, so a content change yields a new key. Capped.
247
+ # Process-wide PER-SHEET parse cache (the CSS analogue of the JS bytecode
248
+ # cache). `parseSheet` is pure, so the JS side caches its serialized
249
+ # `{hide,layout}` here keyed by (cssText hash, viewport), surviving the
250
+ # per-visit VM rebuild that wipes the in-VM `__sheetCache`. A cascade
251
+ # rebuild then re-parses only sheets it has never seen (content change =
252
+ # new key). Content-keyed ONLY never url-keyed so freshness stays the
253
+ # asset cache's call. Capped.
192
254
  SHEET_PARSE_CACHE = {}
193
255
  SHEET_PARSE_CACHE_MUTEX = Mutex.new
194
256
  SHEET_PARSE_CACHE_MAX = 2048
195
257
 
196
258
  STDLIB_HOST_FNS = {
197
- '__csimCascadeCacheGet' => ->(*a) { CASCADE_RULE_CACHE_MUTEX.synchronize { CASCADE_RULE_CACHE[a[0].to_s] } },
198
- '__csimCascadeCachePut' => lambda {|*a|
199
- CASCADE_RULE_CACHE_MUTEX.synchronize { CASCADE_RULE_CACHE[a[0].to_s] = a[1].to_s }
200
- nil
201
- },
202
259
  '__csimSheetCacheGet' => ->(*a) { SHEET_PARSE_CACHE_MUTEX.synchronize { SHEET_PARSE_CACHE[a[0].to_s] } },
203
260
  '__csimSheetCachePut' => lambda {|*a|
204
261
  SHEET_PARSE_CACHE_MUTEX.synchronize {
@@ -215,15 +272,377 @@ module Capybara
215
272
  '__csim_utf8Decode' => ->(*a) { a[0].pack('C*').force_encoding('UTF-8') },
216
273
  # `__csim_parseUrl` is defined in JS now (js/src/url-parse.js, backed by
217
274
  # the vendored whatwg-url) — spec-correct + no V8↔Ruby boundary per parse.
218
- # Web Crypto SubtleCrypto.digest algo is "SHA-1"/"SHA-256"/etc.
219
- # JS hands us the byte array; we return the digest as bytes.
275
+ # Web Crypto raw primitives, backed by OpenSSL. The JS side (js/src/webcrypto.js)
276
+ # owns the whole SubtleCrypto contract algorithm normalization, CryptoKey
277
+ # objects, usage validation, the DOMException grammar — and calls these only for
278
+ # the actual number-crunching. Byte arguments cross as plain Arrays (JS packs a
279
+ # BufferSource into one); results return as byte Arrays.
280
+ #
281
+ # digest — algo is "SHA1"/"SHA256"/… (JS strips the dash and upcases).
220
282
  '__csim_subtleDigest' => lambda {|*a|
221
283
  algo = a[0].to_s.upcase.tr('-', '')
222
284
  bytes = a[1].is_a?(Array) ? a[1].pack('C*') : a[1].to_s
223
285
  OpenSSL::Digest.new(algo).digest(bytes).bytes
286
+ },
287
+ # HMAC sign — args: (hash "SHA256", keyBytes, dataBytes) → mac bytes. verify is
288
+ # done JS-side by re-signing and comparing, so no separate host fn is needed.
289
+ '__csim_hmacSign' => lambda {|*a|
290
+ hash = a[0].to_s.upcase.tr('-', '')
291
+ key = a[1].is_a?(Array) ? a[1].pack('C*') : a[1].to_s
292
+ data = a[2].is_a?(Array) ? a[2].pack('C*') : a[2].to_s
293
+ OpenSSL::HMAC.digest(OpenSSL::Digest.new(hash), key, data).bytes
294
+ },
295
+ # AES encrypt/decrypt — args: (cipher "aes-256-gcm", key, iv, data, aad, tagBytes).
296
+ # For GCM the JS side follows the WebCrypto layout (ciphertext ‖ truncated tag), so
297
+ # encrypt appends the tagBytes-long tag and decrypt splits it back off before
298
+ # verifying; a tag mismatch / bad padding raises CipherError, which the JS layer
299
+ # maps to OperationError. CBC/CTR pass tagBytes 0 and an empty aad.
300
+ '__csim_aesEncrypt' => lambda {|*a|
301
+ name = a[0].to_s
302
+ key = a[1].is_a?(Array) ? a[1].pack('C*') : a[1].to_s
303
+ iv = a[2].is_a?(Array) ? a[2].pack('C*') : a[2].to_s
304
+ data = a[3].is_a?(Array) ? a[3].pack('C*') : a[3].to_s
305
+ aad = a[4].is_a?(Array) ? a[4].pack('C*') : a[4].to_s
306
+ tagbytes = a[5].to_i
307
+ c = OpenSSL::Cipher.new(name)
308
+ c.encrypt
309
+ c.key = key
310
+ c.iv_len = iv.bytesize if name.end_with?('-gcm') # GCM defaults to 12; allow any length
311
+ c.iv = iv
312
+ if name.end_with?('-gcm')
313
+ c.auth_data = aad
314
+ ct = c.update(data) + c.final
315
+ (ct + c.auth_tag(tagbytes)).bytes
316
+ else
317
+ (c.update(data) + c.final).bytes
318
+ end
319
+ },
320
+ '__csim_aesDecrypt' => lambda {|*a|
321
+ name = a[0].to_s
322
+ key = a[1].is_a?(Array) ? a[1].pack('C*') : a[1].to_s
323
+ iv = a[2].is_a?(Array) ? a[2].pack('C*') : a[2].to_s
324
+ data = a[3].is_a?(Array) ? a[3].pack('C*') : a[3].to_s
325
+ aad = a[4].is_a?(Array) ? a[4].pack('C*') : a[4].to_s
326
+ tagbytes = a[5].to_i
327
+ c = OpenSSL::Cipher.new(name)
328
+ c.decrypt
329
+ c.key = key
330
+ c.iv_len = iv.bytesize if name.end_with?('-gcm')
331
+ c.iv = iv
332
+ if name.end_with?('-gcm')
333
+ ct = data[0, data.bytesize - tagbytes]
334
+ tag = data[data.bytesize - tagbytes, tagbytes]
335
+ c.auth_data = aad
336
+ c.auth_tag = tag
337
+ (c.update(ct) + c.final).bytes
338
+ else
339
+ (c.update(data) + c.final).bytes
340
+ end
341
+ },
342
+ # RSA — keys cross as their DER encoding (SPKI for public, PKCS#8 for private)
343
+ # in a byte Array; the host re-parses per call (OpenSSL::PKey.read auto-detects).
344
+ # generateKey returns the private PKCS#8 DER, from which the JS side derives the
345
+ # public key and parameters via the export / key-info fns below.
346
+ '__csim_rsaGenerate' => lambda {|*a|
347
+ bits = a[0].to_i
348
+ exp = OpenSSL::BN.new(crypto_bytes(a[1]), 2).to_i
349
+ OpenSSL::PKey::RSA.generate(bits, exp).private_to_der.bytes
350
+ },
351
+ # Re-encode a key to 'spki' (public) or 'pkcs8' (private). Asking a private key
352
+ # for 'spki' yields its public half — how generateKey obtains the public key.
353
+ '__csim_rsaExport' => lambda {|*a|
354
+ key = RuntimeShared.pkey_read(a[0])
355
+ a[1].to_s == 'spki' ? key.public_to_der.bytes : key.private_to_der.bytes
356
+ },
357
+ # Modulus length (bits) + public exponent (big-endian bytes) for key.algorithm.
358
+ '__csim_rsaKeyInfo' => lambda {|*a|
359
+ key = RuntimeShared.pkey_read(a[0])
360
+ { 'modulusLength' => key.n.num_bits, 'publicExponent' => key.e.to_s(2).bytes }
361
+ },
362
+ # Build a key DER from JWK components (big-endian byte arrays). Public keys pass
363
+ # only n/e; private keys pass the full CRT set. Returns SPKI / PKCS#8 DER bytes.
364
+ '__csim_rsaImportJwk' => lambda {|*a|
365
+ RuntimeShared.rsa_jwk_to_der(*a).bytes
366
+ },
367
+ # Explode a key DER into JWK components (big-endian byte arrays). Private keys
368
+ # include the CRT parameters; public keys carry only n/e.
369
+ '__csim_rsaExportJwk' => lambda {|*a|
370
+ RuntimeShared.rsa_der_to_jwk(a[0])
371
+ },
372
+ # sign/verify — scheme 'pkcs1' (RSASSA-PKCS1-v1_5) or 'pss' (RSA-PSS, MGF1 with
373
+ # the same hash, saltLength in bytes). verify swallows OpenSSL errors to a false.
374
+ '__csim_rsaSign' => lambda {|*a|
375
+ key = RuntimeShared.pkey_read(a[0])
376
+ hash = a[1].to_s
377
+ data = crypto_bytes(a[2])
378
+ if a[3].to_s == 'pss'
379
+ key.sign_pss(hash, data, salt_length: a[4].to_i, mgf1_hash: hash).bytes
380
+ else
381
+ key.sign(hash, data).bytes
382
+ end
383
+ },
384
+ '__csim_rsaVerify' => lambda {|*a|
385
+ key = RuntimeShared.pkey_read(a[0])
386
+ hash = a[1].to_s
387
+ data = crypto_bytes(a[2])
388
+ sig = crypto_bytes(a[3])
389
+ begin
390
+ if a[4].to_s == 'pss'
391
+ key.verify_pss(hash, sig, data, salt_length: a[5].to_i, mgf1_hash: hash)
392
+ else
393
+ key.verify(hash, sig, data)
394
+ end
395
+ rescue OpenSSL::PKey::PKeyError
396
+ false
397
+ end
398
+ },
399
+ # RSA-OAEP encrypt/decrypt — MGF1 + OAEP with the key's hash and an optional label.
400
+ # `rsa_oaep_label` takes a HEX string, not raw bytes (OpenSSL parses it via
401
+ # prepare_from_text), so the label crosses hex-encoded.
402
+ '__csim_rsaEncrypt' => lambda {|*a|
403
+ key = RuntimeShared.pkey_read(a[0])
404
+ opts = { rsa_padding_mode: 'oaep', rsa_oaep_md: a[1].to_s }
405
+ label = crypto_bytes(a[3])
406
+ opts[:rsa_oaep_label] = label.unpack1('H*') unless label.empty?
407
+ key.encrypt(crypto_bytes(a[2]), opts).bytes
408
+ },
409
+ '__csim_rsaDecrypt' => lambda {|*a|
410
+ key = RuntimeShared.pkey_read(a[0])
411
+ opts = { rsa_padding_mode: 'oaep', rsa_oaep_md: a[1].to_s }
412
+ label = crypto_bytes(a[3])
413
+ opts[:rsa_oaep_label] = label.unpack1('H*') unless label.empty?
414
+ key.decrypt(crypto_bytes(a[2]), opts).bytes
415
+ },
416
+ # Elliptic curve (ECDSA / ECDH). The JS side passes the OpenSSL curve name
417
+ # ('prime256v1' / 'secp384r1' / 'secp521r1') and the field byte length so the
418
+ # host stays curve-agnostic. Keys cross as SPKI / PKCS#8 DER.
419
+ '__csim_ecGenerate' => lambda {|*a|
420
+ OpenSSL::PKey::EC.generate(a[0].to_s).private_to_der.bytes
421
+ },
422
+ '__csim_ecExport' => lambda {|*a|
423
+ key = RuntimeShared.pkey_read(a[0])
424
+ case a[1].to_s
425
+ when 'spki' then key.public_to_der.bytes
426
+ when 'pkcs8' then key.private_to_der.bytes
427
+ else key.public_key.to_octet_string(:uncompressed).bytes # 'raw' → uncompressed point
428
+ end
429
+ },
430
+ '__csim_ecKeyInfo' => lambda {|*a|
431
+ { 'curve' => RuntimeShared.pkey_read(a[0]).group.curve_name }
432
+ },
433
+ '__csim_ecImportRaw' => lambda {|*a|
434
+ RuntimeShared.ec_point_spki(a[0].to_s, crypto_bytes(a[1])).bytes
435
+ },
436
+ # args: (curve, isPrivate, x, y, [d, n]). The uncompressed public point is
437
+ # 0x04 ‖ x ‖ y; a private key additionally carries the scalar d (padded to n).
438
+ '__csim_ecImportJwk' => lambda {|*a|
439
+ curve = a[0].to_s
440
+ point = "\x04".b + crypto_bytes(a[2]) + crypto_bytes(a[3])
441
+ if a[1]
442
+ RuntimeShared.ec_priv_pkcs8(curve, crypto_bytes(a[4]), point, a[5].to_i).bytes
443
+ else
444
+ RuntimeShared.ec_point_spki(curve, point).bytes
445
+ end
446
+ },
447
+ '__csim_ecExportJwk' => lambda {|*a|
448
+ RuntimeShared.ec_der_to_jwk(a[0], a[1].to_i)
449
+ },
450
+ '__csim_ecdsaSign' => lambda {|*a|
451
+ key = RuntimeShared.pkey_read(a[0])
452
+ der = key.sign(OpenSSL::Digest.new(a[1].to_s), crypto_bytes(a[2]))
453
+ RuntimeShared.ecdsa_der_to_raw(der, a[3].to_i).bytes
454
+ },
455
+ '__csim_ecdsaVerify' => lambda {|*a|
456
+ key = RuntimeShared.pkey_read(a[0])
457
+ der = RuntimeShared.ecdsa_raw_to_der(crypto_bytes(a[3]), a[4].to_i)
458
+ begin
459
+ der ? key.verify(OpenSSL::Digest.new(a[1].to_s), der, crypto_bytes(a[2])) : false
460
+ rescue OpenSSL::PKey::PKeyError
461
+ false
462
+ end
463
+ },
464
+ # Key derivation — HKDF / PBKDF2 produce `nbytes` bytes; ECDH computes the raw
465
+ # shared secret (field size). The JS side truncates to the requested bit length.
466
+ '__csim_hkdf' => lambda {|*a|
467
+ OpenSSL::KDF.hkdf(crypto_bytes(a[1]), salt: crypto_bytes(a[2]), info: crypto_bytes(a[3]), length: a[4].to_i, hash: a[0].to_s).bytes
468
+ },
469
+ '__csim_pbkdf2' => lambda {|*a|
470
+ OpenSSL::KDF.pbkdf2_hmac(crypto_bytes(a[1]), salt: crypto_bytes(a[2]), iterations: a[3].to_i, length: a[4].to_i, hash: a[0].to_s).bytes
471
+ },
472
+ '__csim_ecdhDerive' => lambda {|*a|
473
+ priv = RuntimeShared.pkey_read(a[0])
474
+ pub = RuntimeShared.pkey_read(a[1])
475
+ priv.dh_compute_key(pub.public_key).bytes
476
+ },
477
+ # AES-KW (RFC 3394 key wrap). The cipher width follows the wrapping key; OpenSSL's
478
+ # wrap mode supplies the default A6A6… IV. A tampered wrap raises on unwrap, which
479
+ # the JS layer maps to OperationError.
480
+ '__csim_aesKwWrap' => lambda {|*a|
481
+ key = crypto_bytes(a[0])
482
+ c = OpenSSL::Cipher.new("aes-#{key.bytesize * 8}-wrap")
483
+ c.encrypt
484
+ c.key = key
485
+ (c.update(crypto_bytes(a[1])) + c.final).bytes
486
+ },
487
+ '__csim_aesKwUnwrap' => lambda {|*a|
488
+ key = crypto_bytes(a[0])
489
+ c = OpenSSL::Cipher.new("aes-#{key.bytesize * 8}-wrap")
490
+ c.decrypt
491
+ c.key = key
492
+ (c.update(crypto_bytes(a[1])) + c.final).bytes
493
+ },
494
+ # OKP curves (Ed25519 signatures / X25519 key agreement). The JS side passes the
495
+ # OpenSSL curve name ('ED25519' / 'X25519'); keys cross as SPKI / PKCS#8 DER, and
496
+ # the raw form is the 32-byte public (or private, via JWK `d`) key.
497
+ '__csim_okpGenerate' => lambda {|*a|
498
+ OpenSSL::PKey.generate_key(a[0].to_s).private_to_der.bytes
499
+ },
500
+ '__csim_okpExport' => lambda {|*a|
501
+ key = RuntimeShared.pkey_read(a[0])
502
+ case a[1].to_s
503
+ when 'spki' then key.public_to_der.bytes
504
+ when 'pkcs8' then key.private_to_der.bytes
505
+ else key.raw_public_key.bytes # 'raw' → 32-byte public key
506
+ end
507
+ },
508
+ '__csim_okpImportRaw' => lambda {|*a|
509
+ OpenSSL::PKey.new_raw_public_key(a[0].to_s, crypto_bytes(a[1])).public_to_der.bytes
510
+ },
511
+ # Parse + re-emit a canonical OKP DER — validates the SPKI/PKCS#8 structure at
512
+ # import time (a truncated / malformed key raises, which JS maps to DataError)
513
+ # instead of storing the bytes blindly and only failing on first use.
514
+ '__csim_okpImportDer' => lambda {|*a|
515
+ key = RuntimeShared.pkey_read(a[1])
516
+ a[0].to_s == 'private' ? key.private_to_der.bytes : key.public_to_der.bytes
517
+ },
518
+ '__csim_okpImportJwk' => lambda {|*a|
519
+ type = a[0].to_s
520
+ if a[1]
521
+ key = OpenSSL::PKey.new_raw_private_key(type, crypto_bytes(a[3]))
522
+ # The JWK "x" (public) must equal the public key derived from "d" — a mismatch is
523
+ # an invalid key pair (DataError on the JS side).
524
+ raise OpenSSL::PKey::PKeyError, 'OKP JWK x does not match d' unless key.raw_public_key == crypto_bytes(a[2])
525
+ key.private_to_der.bytes
526
+ else
527
+ OpenSSL::PKey.new_raw_public_key(type, crypto_bytes(a[2])).public_to_der.bytes
528
+ end
529
+ },
530
+ '__csim_okpExportJwk' => lambda {|*a|
531
+ key = RuntimeShared.pkey_read(a[0])
532
+ jwk = { 'x' => key.raw_public_key.bytes }
533
+ jwk['d'] = key.raw_private_key.bytes if a[1] # a[1] = the key is private
534
+ jwk
535
+ },
536
+ '__csim_ed25519Sign' => lambda {|*a|
537
+ RuntimeShared.pkey_read(a[0]).sign(nil, crypto_bytes(a[1])).bytes
538
+ },
539
+ '__csim_ed25519Verify' => lambda {|*a|
540
+ key = RuntimeShared.pkey_read(a[0])
541
+ begin
542
+ key.verify(nil, crypto_bytes(a[2]), crypto_bytes(a[1]))
543
+ rescue OpenSSL::PKey::PKeyError
544
+ false
545
+ end
546
+ },
547
+ '__csim_x25519Derive' => lambda {|*a|
548
+ RuntimeShared.pkey_read(a[0]).derive(RuntimeShared.pkey_read(a[1])).bytes
224
549
  }
225
550
  }.freeze
226
551
 
552
+ # Pack a host-fn byte argument (a JS BufferSource crosses as an Array) into a
553
+ # binary String for OpenSSL.
554
+ def self.crypto_bytes(a)
555
+ a.is_a?(Array) ? a.pack('C*') : a.to_s
556
+ end
557
+
558
+ # Parse an RSA / EC key from its DER bytes (SPKI public or PKCS#8 private —
559
+ # OpenSSL::PKey.read auto-detects both).
560
+ def self.pkey_read(der_bytes)
561
+ OpenSSL::PKey.read(crypto_bytes(der_bytes))
562
+ end
563
+
564
+ # ECDSA signatures cross as the WebCrypto IEEE-P1363 form — r ‖ s, each padded to
565
+ # the curve's field byte length — while OpenSSL speaks DER. Convert both ways.
566
+ def self.ecdsa_der_to_raw(der, n)
567
+ asn = OpenSSL::ASN1.decode(der)
568
+ r = asn.value[0].value.to_s(2).rjust(n, "\x00".b)
569
+ s = asn.value[1].value.to_s(2).rjust(n, "\x00".b)
570
+ r + s
571
+ end
572
+
573
+ def self.ecdsa_raw_to_der(raw, n)
574
+ return nil unless raw.bytesize == 2 * n # wrong length → verify fails, not raises
575
+ r = OpenSSL::BN.new(raw[0, n], 2)
576
+ s = OpenSSL::BN.new(raw[n, n], 2)
577
+ OpenSSL::ASN1::Sequence([OpenSSL::ASN1::Integer(r), OpenSSL::ASN1::Integer(s)]).to_der
578
+ end
579
+
580
+ def self.ec_algid(curve)
581
+ OpenSSL::ASN1::Sequence([OpenSSL::ASN1::ObjectId('id-ecPublicKey'), OpenSSL::ASN1::ObjectId(curve)])
582
+ end
583
+
584
+ # SPKI DER for an EC public key from its uncompressed point (0x04 ‖ x ‖ y).
585
+ def self.ec_point_spki(curve, point)
586
+ OpenSSL::ASN1::Sequence([ec_algid(curve), OpenSSL::ASN1::BitString(point)]).to_der
587
+ end
588
+
589
+ # PKCS#8 DER for an EC private key from the private scalar `d` and public point,
590
+ # via the RFC 5915 ECPrivateKey structure (OpenSSL 3 dropped component setters).
591
+ def self.ec_priv_pkcs8(curve, d, point, n)
592
+ ec_priv = OpenSSL::ASN1::Sequence([
593
+ OpenSSL::ASN1::Integer(1),
594
+ OpenSSL::ASN1::OctetString(d.rjust(n, "\x00".b)),
595
+ OpenSSL::ASN1::ASN1Data.new([OpenSSL::ASN1::ObjectId(curve)], 0, :CONTEXT_SPECIFIC),
596
+ OpenSSL::ASN1::ASN1Data.new([OpenSSL::ASN1::BitString(point)], 1, :CONTEXT_SPECIFIC)
597
+ ]).to_der
598
+ OpenSSL::ASN1::Sequence([OpenSSL::ASN1::Integer(0), ec_algid(curve), OpenSSL::ASN1::OctetString(ec_priv)]).to_der
599
+ end
600
+
601
+ # Explode an EC key DER into JWK coordinates (x, y, and d for a private key), each a
602
+ # big-endian byte Array padded to the field length; plus the OpenSSL curve name.
603
+ def self.ec_der_to_jwk(der_bytes, n)
604
+ key = pkey_read(der_bytes)
605
+ point = key.public_key.to_octet_string(:uncompressed) # 0x04 ‖ x ‖ y
606
+ jwk = { 'curve' => key.group.curve_name, 'x' => point[1, n].bytes, 'y' => point[1 + n, n].bytes }
607
+ jwk['d'] = key.private_key.to_s(2).rjust(n, "\x00".b).bytes if key.private_key?
608
+ jwk
609
+ end
610
+
611
+ # Assemble an RSA key DER from JWK components. `is_private` selects between a
612
+ # public SPKI (n, e) and a private PKCS#8 with the full CRT set. Each component is
613
+ # a big-endian byte Array. Building the ASN.1 by hand is the OpenSSL-3-supported
614
+ # path (the deprecated `rsa.n = …` setters are gone).
615
+ def self.rsa_jwk_to_der(is_private, n, e, d = nil, p = nil, q = nil, dp = nil, dq = nil, qi = nil)
616
+ bn = ->(b) { OpenSSL::BN.new(crypto_bytes(b), 2) }
617
+ alg_id = OpenSSL::ASN1::Sequence([OpenSSL::ASN1::ObjectId('rsaEncryption'), OpenSSL::ASN1::Null(nil)])
618
+ if is_private
619
+ pkcs1 = OpenSSL::ASN1::Sequence([
620
+ OpenSSL::ASN1::Integer(0),
621
+ OpenSSL::ASN1::Integer(bn.(n)), OpenSSL::ASN1::Integer(bn.(e)), OpenSSL::ASN1::Integer(bn.(d)),
622
+ OpenSSL::ASN1::Integer(bn.(p)), OpenSSL::ASN1::Integer(bn.(q)),
623
+ OpenSSL::ASN1::Integer(bn.(dp)), OpenSSL::ASN1::Integer(bn.(dq)), OpenSSL::ASN1::Integer(bn.(qi))
624
+ ]).to_der
625
+ OpenSSL::ASN1::Sequence([OpenSSL::ASN1::Integer(0), alg_id, OpenSSL::ASN1::OctetString(pkcs1)]).to_der
626
+ else
627
+ pkcs1 = OpenSSL::ASN1::Sequence([OpenSSL::ASN1::Integer(bn.(n)), OpenSSL::ASN1::Integer(bn.(e))]).to_der
628
+ OpenSSL::ASN1::Sequence([alg_id, OpenSSL::ASN1::BitString(pkcs1)]).to_der
629
+ end
630
+ end
631
+
632
+ # Explode an RSA key DER into JWK components (big-endian byte Arrays). Private keys
633
+ # carry the full CRT set; public keys only n/e.
634
+ def self.rsa_der_to_jwk(der_bytes)
635
+ key = pkey_read(der_bytes)
636
+ jwk = { 'n' => key.n.to_s(2).bytes, 'e' => key.e.to_s(2).bytes }
637
+ if key.private?
638
+ jwk.merge!(
639
+ 'd' => key.d.to_s(2).bytes, 'p' => key.p.to_s(2).bytes, 'q' => key.q.to_s(2).bytes,
640
+ 'dp' => key.dmp1.to_s(2).bytes, 'dq' => key.dmq1.to_s(2).bytes, 'qi' => key.iqmp.to_s(2).bytes
641
+ )
642
+ end
643
+ jwk
644
+ end
645
+
227
646
  def self.safe_call
228
647
  yield
229
648
  rescue StandardError => e