capybara-simulated 0.7.0 → 0.8.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.
- checksums.yaml +4 -4
- data/README.md +4 -3
- data/lib/capybara/simulated/browser.rb +2562 -234
- data/lib/capybara/simulated/driver.rb +43 -2
- data/lib/capybara/simulated/js/bridge.bundle.js +30437 -18595
- data/lib/capybara/simulated/node.rb +25 -7
- data/lib/capybara/simulated/quickjs_runtime.rb +52 -9
- data/lib/capybara/simulated/runtime_shared.rb +425 -7
- data/lib/capybara/simulated/v8_runtime.rb +81 -6
- data/lib/capybara/simulated/version.rb +1 -1
- metadata +2 -2
|
@@ -122,16 +122,31 @@ module Capybara
|
|
|
122
122
|
self
|
|
123
123
|
end
|
|
124
124
|
|
|
125
|
-
|
|
125
|
+
# Capybara's driver `scroll_to` contract (from Element#scroll_to): a target element + align
|
|
126
|
+
# symbol, a position symbol (`:top`/`:bottom`/`:center`), or an `[x, y]` coordinate pair.
|
|
127
|
+
# `self` is the element it was called on — the document root for a session-level scroll (routed
|
|
128
|
+
# via `find('/html')`), else an element. Drives a real scroll offset in the layout engine.
|
|
129
|
+
def scroll_to(target = nil, arg = nil, coords = nil)
|
|
130
|
+
check_stale
|
|
131
|
+
if target
|
|
132
|
+
browser.scroll_to(handle_id, target.handle_id, arg)
|
|
133
|
+
elsif coords
|
|
134
|
+
browser.scroll_to(handle_id, nil, nil, coords[0], coords[1])
|
|
135
|
+
elsif arg
|
|
136
|
+
browser.scroll_to(handle_id, nil, arg)
|
|
137
|
+
end
|
|
138
|
+
self
|
|
139
|
+
end
|
|
126
140
|
|
|
127
141
|
# Capybara's standard rect API. No layout engine — but
|
|
128
|
-
#
|
|
129
|
-
#
|
|
130
|
-
#
|
|
131
|
-
#
|
|
142
|
+
# The element's coarse border-box (viewport-relative), from the layout engine — backs the
|
|
143
|
+
# spatial selectors (`:above`/`:below`/`:near`) and coordinate drag. Deterministic per layout
|
|
144
|
+
# generation (memoised; only a DOM/style mutation changes it), so Discourse's
|
|
145
|
+
# `wait_for_animation` — which polls `element.rect[:x]` twice and waits for it to stabilise —
|
|
146
|
+
# still settles immediately when nothing is animating.
|
|
132
147
|
def rect
|
|
133
148
|
check_stale
|
|
134
|
-
|
|
149
|
+
browser.rect(handle_id)
|
|
135
150
|
end
|
|
136
151
|
|
|
137
152
|
def send_keys(*keys)
|
|
@@ -195,7 +210,10 @@ module Capybara
|
|
|
195
210
|
def selected? = browser.option_selected?(handle_id)
|
|
196
211
|
def checked? = !!self['checked']
|
|
197
212
|
def readonly? = !!self['readonly']
|
|
198
|
-
def obscured?(*)
|
|
213
|
+
def obscured?(*)
|
|
214
|
+
check_stale
|
|
215
|
+
browser.obscured?(handle_id)
|
|
216
|
+
end
|
|
199
217
|
def synchronize(*) = yield
|
|
200
218
|
def style(names = [])
|
|
201
219
|
check_stale
|
|
@@ -13,6 +13,23 @@
|
|
|
13
13
|
|
|
14
14
|
require 'digest'
|
|
15
15
|
require 'quickjs'
|
|
16
|
+
begin
|
|
17
|
+
# Intl moved out of the quickjs gem in 0.19. bridge.js patches Intl.DateTimeFormat, so the
|
|
18
|
+
# companion gem is required for the QuickJS engine — say so plainly rather than letting a bare
|
|
19
|
+
# LoadError name a file the user never asked for.
|
|
20
|
+
require 'quickjs-polyfill-intl/datetimeformat'
|
|
21
|
+
rescue LoadError
|
|
22
|
+
raise LoadError, <<~MSG
|
|
23
|
+
capybara-simulated's QuickJS engine needs the `quickjs-polyfill-intl` gem
|
|
24
|
+
(quickjs 0.19 moved Intl out of the core gem, and the DOM bridge uses
|
|
25
|
+
Intl.DateTimeFormat). Add it next to quickjs:
|
|
26
|
+
|
|
27
|
+
gem 'quickjs', '>= 0.19'
|
|
28
|
+
gem 'quickjs-polyfill-intl'
|
|
29
|
+
|
|
30
|
+
Or use the V8 engine (`gem 'rusty_racer'`), which has ICU built in.
|
|
31
|
+
MSG
|
|
32
|
+
end
|
|
16
33
|
|
|
17
34
|
require_relative 'runtime_shared'
|
|
18
35
|
require_relative 'worker_runtime'
|
|
@@ -150,6 +167,19 @@ module Capybara
|
|
|
150
167
|
normalize(result)
|
|
151
168
|
end
|
|
152
169
|
|
|
170
|
+
# QuickJS has no per-frame realms (iframes share the one VM): `within_frame` falls back to
|
|
171
|
+
# the same realm, and any realm id routes to the single global context.
|
|
172
|
+
def supports_frames? = false
|
|
173
|
+
|
|
174
|
+
def realm_call(_realm_id, name, *args) = call(name, *args)
|
|
175
|
+
|
|
176
|
+
# No per-frame realms, so no realm is ever "alive" as a distinct browsing context —
|
|
177
|
+
# every client id resolves to 'client-window' (the single global). Defined so a
|
|
178
|
+
# caller that gates realm routing on it (deliver_worker_messages) works on both engines.
|
|
179
|
+
def frame_realm_alive?(_realm_id) = false
|
|
180
|
+
|
|
181
|
+
def frame_realm_ids = []
|
|
182
|
+
|
|
153
183
|
# bridge.js owns the virtual clock; we drive it from Ruby because
|
|
154
184
|
# Capybara's polling cadence is wall-clock-anchored.
|
|
155
185
|
def drain_timers(max_ms = nil)
|
|
@@ -230,14 +260,17 @@ module Capybara
|
|
|
230
260
|
# URL / TextEncoder / atob/btoa / crypto — already route through Ruby host fns,
|
|
231
261
|
# so POLYFILL_INTL is the only one we strictly need).
|
|
232
262
|
#
|
|
233
|
-
#
|
|
234
|
-
#
|
|
235
|
-
#
|
|
236
|
-
#
|
|
237
|
-
#
|
|
238
|
-
#
|
|
239
|
-
#
|
|
240
|
-
|
|
263
|
+
# Since 0.19 the Intl polyfills live in the separate `quickjs-polyfill-intl`
|
|
264
|
+
# gem, registered by name instead of quickjs's old bundled `POLYFILL_INTL`
|
|
265
|
+
# flag. `:polyfill_intl_datetimeformat_all` is the whole DateTimeFormat chain
|
|
266
|
+
# (getcanonicallocales → locale → pluralrules → numberformat → datetimeformat)
|
|
267
|
+
# as ONE registration — the only chain bridge.js needs, and cheaper than
|
|
268
|
+
# requiring the five links separately.
|
|
269
|
+
#
|
|
270
|
+
# PERF (rule 3): the polyfill source is eval'd per VM, and the VM-pool pre-warm
|
|
271
|
+
# used to be GVL-serial, which put every feature on the critical path. quickjs
|
|
272
|
+
# now releases the GVL while loading polyfills, so the pre-warm overlaps.
|
|
273
|
+
INTL_FEATURES = [:polyfill_intl_datetimeformat_all].freeze
|
|
241
274
|
#
|
|
242
275
|
# `max_stack_size: 0` — `JS_SetMaxStackSize` measures C stack
|
|
243
276
|
# delta from runtime construction; Ruby callers reach QuickJS
|
|
@@ -352,11 +385,21 @@ module Capybara
|
|
|
352
385
|
# host fns attached *after* the replay (so snapshot_stubs.js's
|
|
353
386
|
# no-ops don't overwrite real ones), `__csim_isWorker` set, +
|
|
354
387
|
# the per-worker postMessage routed through `post_back`.
|
|
355
|
-
def self.build_worker(browser, post_back)
|
|
388
|
+
def self.build_worker(browser, post_back, broadcast_out = nil, sw_hooks = {})
|
|
356
389
|
vm = Quickjs::VM.new(**VM_OPTIONS)
|
|
357
390
|
bridge_runnable.run(on: vm)
|
|
358
391
|
attach_host_fns(vm, browser)
|
|
359
392
|
vm.define_function('__csim_workerPostMessage') {|data| post_back.call(data); nil }
|
|
393
|
+
# A worker's BroadcastChannel fan-out routes through the thread-safe outbox, not
|
|
394
|
+
# `browser.broadcast_to_windows` (cross-thread inbox mutation). See v8_runtime#build_worker.
|
|
395
|
+
vm.define_function('__csimBroadcast') {|name, data, _rid, origin| broadcast_out&.call(name, data, origin); nil } if broadcast_out
|
|
396
|
+
# Service-worker → main-thread signals via the outbox (see v8_runtime#build_worker).
|
|
397
|
+
vm.define_function('__csim_swPostToClient') {|client_id, data| sw_hooks[:post_to_client]&.call(client_id, data); nil } if sw_hooks[:post_to_client]
|
|
398
|
+
vm.define_function('__csim_swClaim') { sw_hooks[:claim]&.call; nil } if sw_hooks[:claim]
|
|
399
|
+
vm.define_function('__csim_swFetchRespond') {|fetch_id, resp, realm_id| sw_hooks[:fetch_respond]&.call(fetch_id, resp, realm_id); nil } if sw_hooks[:fetch_respond]
|
|
400
|
+
vm.define_function('__csim_swFetchStream') {|fetch_id, kind, payload, realm_id| sw_hooks[:fetch_stream]&.call(fetch_id, kind, payload, realm_id); nil } if sw_hooks[:fetch_stream]
|
|
401
|
+
vm.define_function('__csim_workerPortPost') {|channel, data| sw_hooks[:port_post]&.call(channel, data); nil } if sw_hooks[:port_post]
|
|
402
|
+
vm.define_function('__csim_workerPortEndpoint') {|channel| sw_hooks[:port_endpoint]&.call(channel); nil } if sw_hooks[:port_endpoint]
|
|
360
403
|
# Override main's __setTimersActive so worker's empty-timer-map
|
|
361
404
|
# flip doesn't race main's `polling?` gate. See v8_runtime's
|
|
362
405
|
# build_worker for the long-form rationale.
|
|
@@ -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,6 +62,10 @@ 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
70
|
'__setCurrentUrl' => ->(b, *a) { b.history_state(a[0], a[1]); nil },
|
|
67
71
|
'__pushHistoryEntry' => ->(b, *a) { b.history_push(a[0], a[1]); nil },
|
|
@@ -72,11 +76,24 @@ module Capybara
|
|
|
72
76
|
'__setDocumentCookie' => ->(b, *a) { b.write_document_cookie(a[0].to_s); nil },
|
|
73
77
|
'__getDocumentReferrer' => ->(b, *_) { b.current_referer },
|
|
74
78
|
'__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])
|
|
79
|
+
'__csim_storageSet' => ->(b, *a) { b.storage_set(a[0], a[1], a[2]) },
|
|
76
80
|
'__csim_storageRemove' => ->(b, *a) { b.storage_remove(a[0], a[1]); nil },
|
|
77
81
|
'__csim_storageClear' => ->(b, *a) { b.storage_clear(a[0]); nil },
|
|
78
82
|
'__csim_storageKey' => ->(b, *a) { b.storage_key(a[0], a[1]) },
|
|
79
83
|
'__csim_storageLength' => ->(b, *a) { b.storage_length(a[0]) },
|
|
84
|
+
'__csimStorageChanged' => ->(b, *a) { b.storage_changed(a[0], a[1], a[2], a[3], a[4], a[5]); nil },
|
|
85
|
+
# Cache Storage — origin-partitioned (a[0] = origin key), Ruby-backed so it
|
|
86
|
+
# survives the per-visit VM rebuild and is shared between a service worker and
|
|
87
|
+
# the client it controls. The JS side (cache-storage.js) owns the spec matching;
|
|
88
|
+
# Ruby is a dumb ordered store keyed by (origin, cache name).
|
|
89
|
+
'__csim_cacheStorageOpen' => ->(b, *a) { b.cache_storage_open(a[0], a[1]) },
|
|
90
|
+
'__csim_cacheStorageHas' => ->(b, *a) { b.cache_storage_has(a[0], a[1]) },
|
|
91
|
+
'__csim_cacheStorageDelete' => ->(b, *a) { b.cache_storage_delete(a[0], a[1]) },
|
|
92
|
+
'__csim_cacheStorageKeys' => ->(b, *a) { b.cache_storage_keys(a[0]) },
|
|
93
|
+
'__csim_cacheEntries' => ->(b, *a) { b.cache_entries(a[0], a[1]) },
|
|
94
|
+
'__csim_cacheEntryResponse' => ->(b, *a) { b.cache_entry_response(a[0], a[1], a[2]) },
|
|
95
|
+
'__csim_cachePut' => ->(b, *a) { b.cache_put(a[0], a[1], a[2], a[3], a[4]); nil },
|
|
96
|
+
'__csim_cacheDeleteEntries' => ->(b, *a) { b.cache_delete_entries(a[0], a[1], a[2]) },
|
|
80
97
|
'__csimGeolocationState' => ->(b, *_) { b.geolocation_state_json },
|
|
81
98
|
'__modalDialog' => ->(b, *a) { b.handle_modal(a[0], a[1], a[2]) },
|
|
82
99
|
'__csim_pushImportmap' => ->(b, *a) { b.set_importmap(a[0]); nil },
|
|
@@ -98,7 +115,15 @@ module Capybara
|
|
|
98
115
|
# forces noopener for a cross-partition blob: target.
|
|
99
116
|
'__csimOpenAuxFromRealm' => ->(b, *a) { b.open_aux_from_realm(a[0], a[1], a[2]); nil },
|
|
100
117
|
'__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 },
|
|
118
|
+
'__csimBroadcast' => ->(b, *a) { b.broadcast_to_windows(a[0], a[1], a[2].to_i, a[3]); nil },
|
|
119
|
+
# BroadcastChannel isolate-wide, creation-ordered registry (the multi-realm delivery path). A
|
|
120
|
+
# channel registers on construction / unregisters on close; `bc_post` snapshots the eligible
|
|
121
|
+
# targets at post time and queues one ordered delivery per target. Only consulted when a sibling
|
|
122
|
+
# same-isolate realm exists (`bc_siblings_exist?`); single-window pages never touch it.
|
|
123
|
+
'__csimBcRegister' => ->(b, *a) { b.bc_register(a[0], a[1], a[2], a[3]); nil },
|
|
124
|
+
'__csimBcUnregister' => ->(b, *a) { b.bc_unregister(a[0], a[1]); nil },
|
|
125
|
+
'__csimBcPost' => ->(b, *a) { b.bc_post(a[0], a[1], a[2], a[3], a[4], a[5]); nil },
|
|
126
|
+
'__csimBcSiblingsExist' => ->(b, *_a) { b.bc_siblings_exist? },
|
|
102
127
|
'__csimWindowGet' => ->(b, *a) { b.window_get(a[0], a[1]) },
|
|
103
128
|
'__csimWindowDocGet' => ->(b, *a) { b.window_doc_get(a[0], a[1]) },
|
|
104
129
|
# Cross-window remote-ref RPC: route an opener's node/object proxy op to
|
|
@@ -115,13 +140,43 @@ module Capybara
|
|
|
115
140
|
# Fire an aux window's OWN `load` event (in its VM) — deferred by the
|
|
116
141
|
# opener so a child `window.onload` runs after the opener's current task.
|
|
117
142
|
'__csimFireAuxWindowLoad' => ->(b, *a) { b.fire_aux_window_load(a[0]); nil },
|
|
118
|
-
'__csim_workerSpawn' => ->(b, *a) { b.worker_spawn(a[0], shared: !!a[1]) },
|
|
143
|
+
'__csim_workerSpawn' => ->(b, *a) { b.worker_spawn(a[0], shared: !!a[1], creator_key: a[2]) },
|
|
119
144
|
# navigator.serviceWorker.register (universal-server only) — spawn a worker
|
|
120
145
|
# running the SW script as an executor context. Returns its handle.
|
|
121
|
-
'__csim_serviceWorkerRegister' => ->(b, *a) { b.worker_spawn(a[0], service: true) },
|
|
146
|
+
'__csim_serviceWorkerRegister' => ->(b, *a) { b.worker_spawn(a[0], service: true, creator_key: a[1]) },
|
|
122
147
|
'__csim_workerPostToWorker' => ->(b, *a) { b.worker_post_to_worker(a[0], a[1]); nil },
|
|
148
|
+
# ServiceWorker.postMessage from a client window → the SW's `message` event (source = client).
|
|
149
|
+
'__csim_serviceWorkerPostMessage' => ->(b, *a) { b.service_worker_post_message(a[0], a[1], a[2], a[3]); nil },
|
|
150
|
+
# Cross-isolate MessagePort channel (client-realm side): register this realm's endpoint, and
|
|
151
|
+
# relay a client-realm port's postMessage to its remote (worker/SW) peer.
|
|
152
|
+
'__csimClientPortEndpoint' => ->(b, *a) { b.port_channel_endpoint_realm(a[0], a[1]); nil },
|
|
153
|
+
'__csimClientPortPost' => ->(b, *a) { b.client_port_post(a[0], a[1]); nil },
|
|
154
|
+
# A controlled client's fetch → the controlling SW's `fetch` event. Returns false if the SW
|
|
155
|
+
# is gone (client falls back to the network).
|
|
156
|
+
'__csim_serviceWorkerControllerFetch' => ->(b, *a) { b.service_worker_controller_fetch(a[0], a[1], a[2], a[3]) },
|
|
157
|
+
# A controlled client cancelled a streaming respondWith body → cancel the SW's source stream
|
|
158
|
+
# (routed to the worker that owns this [realm, fetch] stream). See sw_stream_cancel.
|
|
159
|
+
'__csim_swStreamCancel' => ->(b, *a) { b.sw_stream_cancel(a[0], a[1]); nil },
|
|
160
|
+
# Client lifecycle mirrors scope→active-worker into Ruby so a navigation (fetched
|
|
161
|
+
# Ruby-side before the destination realm's JS exists) can find its controlling SW.
|
|
162
|
+
'__csim_swRegisterScope' => ->(b, *a) { b.sw_register_scope(a[0], a[1]); nil },
|
|
163
|
+
'__csim_swUnregisterScope' => ->(b, *a) { b.sw_unregister_scope(a[0]); nil },
|
|
164
|
+
# The active worker handle at an EXACT scope (0 if none), so a register() from a realm with no
|
|
165
|
+
# local registration (a different iframe registering an already-active scope) can synthesize a
|
|
166
|
+
# registration reflecting the shared active worker instead of installing a duplicate.
|
|
167
|
+
'__csim_swActiveHandleForScope' => ->(b, *a) { b.sw_active_handle_for_scope(a[0]) },
|
|
168
|
+
# Navigation Preload state (NavigationPreloadManager), keyed by the registration's active
|
|
169
|
+
# worker handle — reached identically from the client (registration.active._handle) and the
|
|
170
|
+
# worker (__csimWorkerHandle). Get returns {enabled, headerValue}; set leaves a nil field as-is.
|
|
171
|
+
'__csim_swNavPreloadState' => ->(b, *a) { b.nav_preload_state(a[0]) },
|
|
172
|
+
'__csim_swNavPreloadSet' => ->(b, *a) { b.nav_preload_set(a[0], a[1], a[2]); nil },
|
|
173
|
+
# A navigation (iframe/document load) → its controlling SW's `fetch` event, awaited
|
|
174
|
+
# synchronously. Returns the response wire hash, or nil to load from the network.
|
|
175
|
+
'__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
176
|
'__csim_workerTerminate' => ->(b, *a) { b.worker_terminate(a[0]); nil },
|
|
124
177
|
'__csim_decodeImage' => ->(b, *a) { b.decode_image(a[0], a[1], a[2]) },
|
|
178
|
+
'__csim_renderText' => ->(b, *a) { b.render_text(a[0], a[1], a[2], a[3], a[4]) },
|
|
179
|
+
'__csim_loadImage' => ->(b, *a) { b.load_image(a[0], !!a[1], a[2] || 'same-origin') },
|
|
125
180
|
'__csim_blobRegister' => ->(b, *a) { b.blob_register(a[0], a[1], a[2]); nil },
|
|
126
181
|
# WHATWG/UTS46 IDNA for the URL parser's host processing (the JS tr46 stub
|
|
127
182
|
# delegates non-ASCII / xn-- hosts here; ASCII stays in-VM).
|
|
@@ -144,6 +199,7 @@ module Capybara
|
|
|
144
199
|
# eager-@app.calls a foreign URL (side effects: extra visit / log row).
|
|
145
200
|
'__csim_allHostsLocal' => ->(b, *a) { b.send(:all_hosts_local?) },
|
|
146
201
|
'__csim_decodeVideoFrame' => ->(b, *a) { b.decode_video_frame(a[0]) },
|
|
202
|
+
'__csim_videoBytesB64' => ->(b, *a) { b.video_bytes_b64(a[0]) },
|
|
147
203
|
'__csim_encodeImage' => ->(b, *a) { b.encode_image(a[0], a[1], a[2], a[3], a[4]) },
|
|
148
204
|
# WebAuthn create / get raise `WebauthnState::Error` carrying
|
|
149
205
|
# the DOMException name (`InvalidStateError`, …); rescue here
|
|
@@ -215,15 +271,377 @@ module Capybara
|
|
|
215
271
|
'__csim_utf8Decode' => ->(*a) { a[0].pack('C*').force_encoding('UTF-8') },
|
|
216
272
|
# `__csim_parseUrl` is defined in JS now (js/src/url-parse.js, backed by
|
|
217
273
|
# the vendored whatwg-url) — spec-correct + no V8↔Ruby boundary per parse.
|
|
218
|
-
# Web Crypto
|
|
219
|
-
#
|
|
274
|
+
# Web Crypto raw primitives, backed by OpenSSL. The JS side (js/src/webcrypto.js)
|
|
275
|
+
# owns the whole SubtleCrypto contract — algorithm normalization, CryptoKey
|
|
276
|
+
# objects, usage validation, the DOMException grammar — and calls these only for
|
|
277
|
+
# the actual number-crunching. Byte arguments cross as plain Arrays (JS packs a
|
|
278
|
+
# BufferSource into one); results return as byte Arrays.
|
|
279
|
+
#
|
|
280
|
+
# digest — algo is "SHA1"/"SHA256"/… (JS strips the dash and upcases).
|
|
220
281
|
'__csim_subtleDigest' => lambda {|*a|
|
|
221
282
|
algo = a[0].to_s.upcase.tr('-', '')
|
|
222
283
|
bytes = a[1].is_a?(Array) ? a[1].pack('C*') : a[1].to_s
|
|
223
284
|
OpenSSL::Digest.new(algo).digest(bytes).bytes
|
|
285
|
+
},
|
|
286
|
+
# HMAC sign — args: (hash "SHA256", keyBytes, dataBytes) → mac bytes. verify is
|
|
287
|
+
# done JS-side by re-signing and comparing, so no separate host fn is needed.
|
|
288
|
+
'__csim_hmacSign' => lambda {|*a|
|
|
289
|
+
hash = a[0].to_s.upcase.tr('-', '')
|
|
290
|
+
key = a[1].is_a?(Array) ? a[1].pack('C*') : a[1].to_s
|
|
291
|
+
data = a[2].is_a?(Array) ? a[2].pack('C*') : a[2].to_s
|
|
292
|
+
OpenSSL::HMAC.digest(OpenSSL::Digest.new(hash), key, data).bytes
|
|
293
|
+
},
|
|
294
|
+
# AES encrypt/decrypt — args: (cipher "aes-256-gcm", key, iv, data, aad, tagBytes).
|
|
295
|
+
# For GCM the JS side follows the WebCrypto layout (ciphertext ‖ truncated tag), so
|
|
296
|
+
# encrypt appends the tagBytes-long tag and decrypt splits it back off before
|
|
297
|
+
# verifying; a tag mismatch / bad padding raises CipherError, which the JS layer
|
|
298
|
+
# maps to OperationError. CBC/CTR pass tagBytes 0 and an empty aad.
|
|
299
|
+
'__csim_aesEncrypt' => lambda {|*a|
|
|
300
|
+
name = a[0].to_s
|
|
301
|
+
key = a[1].is_a?(Array) ? a[1].pack('C*') : a[1].to_s
|
|
302
|
+
iv = a[2].is_a?(Array) ? a[2].pack('C*') : a[2].to_s
|
|
303
|
+
data = a[3].is_a?(Array) ? a[3].pack('C*') : a[3].to_s
|
|
304
|
+
aad = a[4].is_a?(Array) ? a[4].pack('C*') : a[4].to_s
|
|
305
|
+
tagbytes = a[5].to_i
|
|
306
|
+
c = OpenSSL::Cipher.new(name)
|
|
307
|
+
c.encrypt
|
|
308
|
+
c.key = key
|
|
309
|
+
c.iv_len = iv.bytesize if name.end_with?('-gcm') # GCM defaults to 12; allow any length
|
|
310
|
+
c.iv = iv
|
|
311
|
+
if name.end_with?('-gcm')
|
|
312
|
+
c.auth_data = aad
|
|
313
|
+
ct = c.update(data) + c.final
|
|
314
|
+
(ct + c.auth_tag(tagbytes)).bytes
|
|
315
|
+
else
|
|
316
|
+
(c.update(data) + c.final).bytes
|
|
317
|
+
end
|
|
318
|
+
},
|
|
319
|
+
'__csim_aesDecrypt' => lambda {|*a|
|
|
320
|
+
name = a[0].to_s
|
|
321
|
+
key = a[1].is_a?(Array) ? a[1].pack('C*') : a[1].to_s
|
|
322
|
+
iv = a[2].is_a?(Array) ? a[2].pack('C*') : a[2].to_s
|
|
323
|
+
data = a[3].is_a?(Array) ? a[3].pack('C*') : a[3].to_s
|
|
324
|
+
aad = a[4].is_a?(Array) ? a[4].pack('C*') : a[4].to_s
|
|
325
|
+
tagbytes = a[5].to_i
|
|
326
|
+
c = OpenSSL::Cipher.new(name)
|
|
327
|
+
c.decrypt
|
|
328
|
+
c.key = key
|
|
329
|
+
c.iv_len = iv.bytesize if name.end_with?('-gcm')
|
|
330
|
+
c.iv = iv
|
|
331
|
+
if name.end_with?('-gcm')
|
|
332
|
+
ct = data[0, data.bytesize - tagbytes]
|
|
333
|
+
tag = data[data.bytesize - tagbytes, tagbytes]
|
|
334
|
+
c.auth_data = aad
|
|
335
|
+
c.auth_tag = tag
|
|
336
|
+
(c.update(ct) + c.final).bytes
|
|
337
|
+
else
|
|
338
|
+
(c.update(data) + c.final).bytes
|
|
339
|
+
end
|
|
340
|
+
},
|
|
341
|
+
# RSA — keys cross as their DER encoding (SPKI for public, PKCS#8 for private)
|
|
342
|
+
# in a byte Array; the host re-parses per call (OpenSSL::PKey.read auto-detects).
|
|
343
|
+
# generateKey returns the private PKCS#8 DER, from which the JS side derives the
|
|
344
|
+
# public key and parameters via the export / key-info fns below.
|
|
345
|
+
'__csim_rsaGenerate' => lambda {|*a|
|
|
346
|
+
bits = a[0].to_i
|
|
347
|
+
exp = OpenSSL::BN.new(crypto_bytes(a[1]), 2).to_i
|
|
348
|
+
OpenSSL::PKey::RSA.generate(bits, exp).private_to_der.bytes
|
|
349
|
+
},
|
|
350
|
+
# Re-encode a key to 'spki' (public) or 'pkcs8' (private). Asking a private key
|
|
351
|
+
# for 'spki' yields its public half — how generateKey obtains the public key.
|
|
352
|
+
'__csim_rsaExport' => lambda {|*a|
|
|
353
|
+
key = RuntimeShared.pkey_read(a[0])
|
|
354
|
+
a[1].to_s == 'spki' ? key.public_to_der.bytes : key.private_to_der.bytes
|
|
355
|
+
},
|
|
356
|
+
# Modulus length (bits) + public exponent (big-endian bytes) for key.algorithm.
|
|
357
|
+
'__csim_rsaKeyInfo' => lambda {|*a|
|
|
358
|
+
key = RuntimeShared.pkey_read(a[0])
|
|
359
|
+
{ 'modulusLength' => key.n.num_bits, 'publicExponent' => key.e.to_s(2).bytes }
|
|
360
|
+
},
|
|
361
|
+
# Build a key DER from JWK components (big-endian byte arrays). Public keys pass
|
|
362
|
+
# only n/e; private keys pass the full CRT set. Returns SPKI / PKCS#8 DER bytes.
|
|
363
|
+
'__csim_rsaImportJwk' => lambda {|*a|
|
|
364
|
+
RuntimeShared.rsa_jwk_to_der(*a).bytes
|
|
365
|
+
},
|
|
366
|
+
# Explode a key DER into JWK components (big-endian byte arrays). Private keys
|
|
367
|
+
# include the CRT parameters; public keys carry only n/e.
|
|
368
|
+
'__csim_rsaExportJwk' => lambda {|*a|
|
|
369
|
+
RuntimeShared.rsa_der_to_jwk(a[0])
|
|
370
|
+
},
|
|
371
|
+
# sign/verify — scheme 'pkcs1' (RSASSA-PKCS1-v1_5) or 'pss' (RSA-PSS, MGF1 with
|
|
372
|
+
# the same hash, saltLength in bytes). verify swallows OpenSSL errors to a false.
|
|
373
|
+
'__csim_rsaSign' => lambda {|*a|
|
|
374
|
+
key = RuntimeShared.pkey_read(a[0])
|
|
375
|
+
hash = a[1].to_s
|
|
376
|
+
data = crypto_bytes(a[2])
|
|
377
|
+
if a[3].to_s == 'pss'
|
|
378
|
+
key.sign_pss(hash, data, salt_length: a[4].to_i, mgf1_hash: hash).bytes
|
|
379
|
+
else
|
|
380
|
+
key.sign(hash, data).bytes
|
|
381
|
+
end
|
|
382
|
+
},
|
|
383
|
+
'__csim_rsaVerify' => lambda {|*a|
|
|
384
|
+
key = RuntimeShared.pkey_read(a[0])
|
|
385
|
+
hash = a[1].to_s
|
|
386
|
+
data = crypto_bytes(a[2])
|
|
387
|
+
sig = crypto_bytes(a[3])
|
|
388
|
+
begin
|
|
389
|
+
if a[4].to_s == 'pss'
|
|
390
|
+
key.verify_pss(hash, sig, data, salt_length: a[5].to_i, mgf1_hash: hash)
|
|
391
|
+
else
|
|
392
|
+
key.verify(hash, sig, data)
|
|
393
|
+
end
|
|
394
|
+
rescue OpenSSL::PKey::PKeyError
|
|
395
|
+
false
|
|
396
|
+
end
|
|
397
|
+
},
|
|
398
|
+
# RSA-OAEP encrypt/decrypt — MGF1 + OAEP with the key's hash and an optional label.
|
|
399
|
+
# `rsa_oaep_label` takes a HEX string, not raw bytes (OpenSSL parses it via
|
|
400
|
+
# prepare_from_text), so the label crosses hex-encoded.
|
|
401
|
+
'__csim_rsaEncrypt' => lambda {|*a|
|
|
402
|
+
key = RuntimeShared.pkey_read(a[0])
|
|
403
|
+
opts = { rsa_padding_mode: 'oaep', rsa_oaep_md: a[1].to_s }
|
|
404
|
+
label = crypto_bytes(a[3])
|
|
405
|
+
opts[:rsa_oaep_label] = label.unpack1('H*') unless label.empty?
|
|
406
|
+
key.encrypt(crypto_bytes(a[2]), opts).bytes
|
|
407
|
+
},
|
|
408
|
+
'__csim_rsaDecrypt' => lambda {|*a|
|
|
409
|
+
key = RuntimeShared.pkey_read(a[0])
|
|
410
|
+
opts = { rsa_padding_mode: 'oaep', rsa_oaep_md: a[1].to_s }
|
|
411
|
+
label = crypto_bytes(a[3])
|
|
412
|
+
opts[:rsa_oaep_label] = label.unpack1('H*') unless label.empty?
|
|
413
|
+
key.decrypt(crypto_bytes(a[2]), opts).bytes
|
|
414
|
+
},
|
|
415
|
+
# Elliptic curve (ECDSA / ECDH). The JS side passes the OpenSSL curve name
|
|
416
|
+
# ('prime256v1' / 'secp384r1' / 'secp521r1') and the field byte length so the
|
|
417
|
+
# host stays curve-agnostic. Keys cross as SPKI / PKCS#8 DER.
|
|
418
|
+
'__csim_ecGenerate' => lambda {|*a|
|
|
419
|
+
OpenSSL::PKey::EC.generate(a[0].to_s).private_to_der.bytes
|
|
420
|
+
},
|
|
421
|
+
'__csim_ecExport' => lambda {|*a|
|
|
422
|
+
key = RuntimeShared.pkey_read(a[0])
|
|
423
|
+
case a[1].to_s
|
|
424
|
+
when 'spki' then key.public_to_der.bytes
|
|
425
|
+
when 'pkcs8' then key.private_to_der.bytes
|
|
426
|
+
else key.public_key.to_octet_string(:uncompressed).bytes # 'raw' → uncompressed point
|
|
427
|
+
end
|
|
428
|
+
},
|
|
429
|
+
'__csim_ecKeyInfo' => lambda {|*a|
|
|
430
|
+
{ 'curve' => RuntimeShared.pkey_read(a[0]).group.curve_name }
|
|
431
|
+
},
|
|
432
|
+
'__csim_ecImportRaw' => lambda {|*a|
|
|
433
|
+
RuntimeShared.ec_point_spki(a[0].to_s, crypto_bytes(a[1])).bytes
|
|
434
|
+
},
|
|
435
|
+
# args: (curve, isPrivate, x, y, [d, n]). The uncompressed public point is
|
|
436
|
+
# 0x04 ‖ x ‖ y; a private key additionally carries the scalar d (padded to n).
|
|
437
|
+
'__csim_ecImportJwk' => lambda {|*a|
|
|
438
|
+
curve = a[0].to_s
|
|
439
|
+
point = "\x04".b + crypto_bytes(a[2]) + crypto_bytes(a[3])
|
|
440
|
+
if a[1]
|
|
441
|
+
RuntimeShared.ec_priv_pkcs8(curve, crypto_bytes(a[4]), point, a[5].to_i).bytes
|
|
442
|
+
else
|
|
443
|
+
RuntimeShared.ec_point_spki(curve, point).bytes
|
|
444
|
+
end
|
|
445
|
+
},
|
|
446
|
+
'__csim_ecExportJwk' => lambda {|*a|
|
|
447
|
+
RuntimeShared.ec_der_to_jwk(a[0], a[1].to_i)
|
|
448
|
+
},
|
|
449
|
+
'__csim_ecdsaSign' => lambda {|*a|
|
|
450
|
+
key = RuntimeShared.pkey_read(a[0])
|
|
451
|
+
der = key.sign(OpenSSL::Digest.new(a[1].to_s), crypto_bytes(a[2]))
|
|
452
|
+
RuntimeShared.ecdsa_der_to_raw(der, a[3].to_i).bytes
|
|
453
|
+
},
|
|
454
|
+
'__csim_ecdsaVerify' => lambda {|*a|
|
|
455
|
+
key = RuntimeShared.pkey_read(a[0])
|
|
456
|
+
der = RuntimeShared.ecdsa_raw_to_der(crypto_bytes(a[3]), a[4].to_i)
|
|
457
|
+
begin
|
|
458
|
+
der ? key.verify(OpenSSL::Digest.new(a[1].to_s), der, crypto_bytes(a[2])) : false
|
|
459
|
+
rescue OpenSSL::PKey::PKeyError
|
|
460
|
+
false
|
|
461
|
+
end
|
|
462
|
+
},
|
|
463
|
+
# Key derivation — HKDF / PBKDF2 produce `nbytes` bytes; ECDH computes the raw
|
|
464
|
+
# shared secret (field size). The JS side truncates to the requested bit length.
|
|
465
|
+
'__csim_hkdf' => lambda {|*a|
|
|
466
|
+
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
|
|
467
|
+
},
|
|
468
|
+
'__csim_pbkdf2' => lambda {|*a|
|
|
469
|
+
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
|
|
470
|
+
},
|
|
471
|
+
'__csim_ecdhDerive' => lambda {|*a|
|
|
472
|
+
priv = RuntimeShared.pkey_read(a[0])
|
|
473
|
+
pub = RuntimeShared.pkey_read(a[1])
|
|
474
|
+
priv.dh_compute_key(pub.public_key).bytes
|
|
475
|
+
},
|
|
476
|
+
# AES-KW (RFC 3394 key wrap). The cipher width follows the wrapping key; OpenSSL's
|
|
477
|
+
# wrap mode supplies the default A6A6… IV. A tampered wrap raises on unwrap, which
|
|
478
|
+
# the JS layer maps to OperationError.
|
|
479
|
+
'__csim_aesKwWrap' => lambda {|*a|
|
|
480
|
+
key = crypto_bytes(a[0])
|
|
481
|
+
c = OpenSSL::Cipher.new("aes-#{key.bytesize * 8}-wrap")
|
|
482
|
+
c.encrypt
|
|
483
|
+
c.key = key
|
|
484
|
+
(c.update(crypto_bytes(a[1])) + c.final).bytes
|
|
485
|
+
},
|
|
486
|
+
'__csim_aesKwUnwrap' => lambda {|*a|
|
|
487
|
+
key = crypto_bytes(a[0])
|
|
488
|
+
c = OpenSSL::Cipher.new("aes-#{key.bytesize * 8}-wrap")
|
|
489
|
+
c.decrypt
|
|
490
|
+
c.key = key
|
|
491
|
+
(c.update(crypto_bytes(a[1])) + c.final).bytes
|
|
492
|
+
},
|
|
493
|
+
# OKP curves (Ed25519 signatures / X25519 key agreement). The JS side passes the
|
|
494
|
+
# OpenSSL curve name ('ED25519' / 'X25519'); keys cross as SPKI / PKCS#8 DER, and
|
|
495
|
+
# the raw form is the 32-byte public (or private, via JWK `d`) key.
|
|
496
|
+
'__csim_okpGenerate' => lambda {|*a|
|
|
497
|
+
OpenSSL::PKey.generate_key(a[0].to_s).private_to_der.bytes
|
|
498
|
+
},
|
|
499
|
+
'__csim_okpExport' => lambda {|*a|
|
|
500
|
+
key = RuntimeShared.pkey_read(a[0])
|
|
501
|
+
case a[1].to_s
|
|
502
|
+
when 'spki' then key.public_to_der.bytes
|
|
503
|
+
when 'pkcs8' then key.private_to_der.bytes
|
|
504
|
+
else key.raw_public_key.bytes # 'raw' → 32-byte public key
|
|
505
|
+
end
|
|
506
|
+
},
|
|
507
|
+
'__csim_okpImportRaw' => lambda {|*a|
|
|
508
|
+
OpenSSL::PKey.new_raw_public_key(a[0].to_s, crypto_bytes(a[1])).public_to_der.bytes
|
|
509
|
+
},
|
|
510
|
+
# Parse + re-emit a canonical OKP DER — validates the SPKI/PKCS#8 structure at
|
|
511
|
+
# import time (a truncated / malformed key raises, which JS maps to DataError)
|
|
512
|
+
# instead of storing the bytes blindly and only failing on first use.
|
|
513
|
+
'__csim_okpImportDer' => lambda {|*a|
|
|
514
|
+
key = RuntimeShared.pkey_read(a[1])
|
|
515
|
+
a[0].to_s == 'private' ? key.private_to_der.bytes : key.public_to_der.bytes
|
|
516
|
+
},
|
|
517
|
+
'__csim_okpImportJwk' => lambda {|*a|
|
|
518
|
+
type = a[0].to_s
|
|
519
|
+
if a[1]
|
|
520
|
+
key = OpenSSL::PKey.new_raw_private_key(type, crypto_bytes(a[3]))
|
|
521
|
+
# The JWK "x" (public) must equal the public key derived from "d" — a mismatch is
|
|
522
|
+
# an invalid key pair (DataError on the JS side).
|
|
523
|
+
raise OpenSSL::PKey::PKeyError, 'OKP JWK x does not match d' unless key.raw_public_key == crypto_bytes(a[2])
|
|
524
|
+
key.private_to_der.bytes
|
|
525
|
+
else
|
|
526
|
+
OpenSSL::PKey.new_raw_public_key(type, crypto_bytes(a[2])).public_to_der.bytes
|
|
527
|
+
end
|
|
528
|
+
},
|
|
529
|
+
'__csim_okpExportJwk' => lambda {|*a|
|
|
530
|
+
key = RuntimeShared.pkey_read(a[0])
|
|
531
|
+
jwk = { 'x' => key.raw_public_key.bytes }
|
|
532
|
+
jwk['d'] = key.raw_private_key.bytes if a[1] # a[1] = the key is private
|
|
533
|
+
jwk
|
|
534
|
+
},
|
|
535
|
+
'__csim_ed25519Sign' => lambda {|*a|
|
|
536
|
+
RuntimeShared.pkey_read(a[0]).sign(nil, crypto_bytes(a[1])).bytes
|
|
537
|
+
},
|
|
538
|
+
'__csim_ed25519Verify' => lambda {|*a|
|
|
539
|
+
key = RuntimeShared.pkey_read(a[0])
|
|
540
|
+
begin
|
|
541
|
+
key.verify(nil, crypto_bytes(a[2]), crypto_bytes(a[1]))
|
|
542
|
+
rescue OpenSSL::PKey::PKeyError
|
|
543
|
+
false
|
|
544
|
+
end
|
|
545
|
+
},
|
|
546
|
+
'__csim_x25519Derive' => lambda {|*a|
|
|
547
|
+
RuntimeShared.pkey_read(a[0]).derive(RuntimeShared.pkey_read(a[1])).bytes
|
|
224
548
|
}
|
|
225
549
|
}.freeze
|
|
226
550
|
|
|
551
|
+
# Pack a host-fn byte argument (a JS BufferSource crosses as an Array) into a
|
|
552
|
+
# binary String for OpenSSL.
|
|
553
|
+
def self.crypto_bytes(a)
|
|
554
|
+
a.is_a?(Array) ? a.pack('C*') : a.to_s
|
|
555
|
+
end
|
|
556
|
+
|
|
557
|
+
# Parse an RSA / EC key from its DER bytes (SPKI public or PKCS#8 private —
|
|
558
|
+
# OpenSSL::PKey.read auto-detects both).
|
|
559
|
+
def self.pkey_read(der_bytes)
|
|
560
|
+
OpenSSL::PKey.read(crypto_bytes(der_bytes))
|
|
561
|
+
end
|
|
562
|
+
|
|
563
|
+
# ECDSA signatures cross as the WebCrypto IEEE-P1363 form — r ‖ s, each padded to
|
|
564
|
+
# the curve's field byte length — while OpenSSL speaks DER. Convert both ways.
|
|
565
|
+
def self.ecdsa_der_to_raw(der, n)
|
|
566
|
+
asn = OpenSSL::ASN1.decode(der)
|
|
567
|
+
r = asn.value[0].value.to_s(2).rjust(n, "\x00".b)
|
|
568
|
+
s = asn.value[1].value.to_s(2).rjust(n, "\x00".b)
|
|
569
|
+
r + s
|
|
570
|
+
end
|
|
571
|
+
|
|
572
|
+
def self.ecdsa_raw_to_der(raw, n)
|
|
573
|
+
return nil unless raw.bytesize == 2 * n # wrong length → verify fails, not raises
|
|
574
|
+
r = OpenSSL::BN.new(raw[0, n], 2)
|
|
575
|
+
s = OpenSSL::BN.new(raw[n, n], 2)
|
|
576
|
+
OpenSSL::ASN1::Sequence([OpenSSL::ASN1::Integer(r), OpenSSL::ASN1::Integer(s)]).to_der
|
|
577
|
+
end
|
|
578
|
+
|
|
579
|
+
def self.ec_algid(curve)
|
|
580
|
+
OpenSSL::ASN1::Sequence([OpenSSL::ASN1::ObjectId('id-ecPublicKey'), OpenSSL::ASN1::ObjectId(curve)])
|
|
581
|
+
end
|
|
582
|
+
|
|
583
|
+
# SPKI DER for an EC public key from its uncompressed point (0x04 ‖ x ‖ y).
|
|
584
|
+
def self.ec_point_spki(curve, point)
|
|
585
|
+
OpenSSL::ASN1::Sequence([ec_algid(curve), OpenSSL::ASN1::BitString(point)]).to_der
|
|
586
|
+
end
|
|
587
|
+
|
|
588
|
+
# PKCS#8 DER for an EC private key from the private scalar `d` and public point,
|
|
589
|
+
# via the RFC 5915 ECPrivateKey structure (OpenSSL 3 dropped component setters).
|
|
590
|
+
def self.ec_priv_pkcs8(curve, d, point, n)
|
|
591
|
+
ec_priv = OpenSSL::ASN1::Sequence([
|
|
592
|
+
OpenSSL::ASN1::Integer(1),
|
|
593
|
+
OpenSSL::ASN1::OctetString(d.rjust(n, "\x00".b)),
|
|
594
|
+
OpenSSL::ASN1::ASN1Data.new([OpenSSL::ASN1::ObjectId(curve)], 0, :CONTEXT_SPECIFIC),
|
|
595
|
+
OpenSSL::ASN1::ASN1Data.new([OpenSSL::ASN1::BitString(point)], 1, :CONTEXT_SPECIFIC)
|
|
596
|
+
]).to_der
|
|
597
|
+
OpenSSL::ASN1::Sequence([OpenSSL::ASN1::Integer(0), ec_algid(curve), OpenSSL::ASN1::OctetString(ec_priv)]).to_der
|
|
598
|
+
end
|
|
599
|
+
|
|
600
|
+
# Explode an EC key DER into JWK coordinates (x, y, and d for a private key), each a
|
|
601
|
+
# big-endian byte Array padded to the field length; plus the OpenSSL curve name.
|
|
602
|
+
def self.ec_der_to_jwk(der_bytes, n)
|
|
603
|
+
key = pkey_read(der_bytes)
|
|
604
|
+
point = key.public_key.to_octet_string(:uncompressed) # 0x04 ‖ x ‖ y
|
|
605
|
+
jwk = { 'curve' => key.group.curve_name, 'x' => point[1, n].bytes, 'y' => point[1 + n, n].bytes }
|
|
606
|
+
jwk['d'] = key.private_key.to_s(2).rjust(n, "\x00".b).bytes if key.private_key?
|
|
607
|
+
jwk
|
|
608
|
+
end
|
|
609
|
+
|
|
610
|
+
# Assemble an RSA key DER from JWK components. `is_private` selects between a
|
|
611
|
+
# public SPKI (n, e) and a private PKCS#8 with the full CRT set. Each component is
|
|
612
|
+
# a big-endian byte Array. Building the ASN.1 by hand is the OpenSSL-3-supported
|
|
613
|
+
# path (the deprecated `rsa.n = …` setters are gone).
|
|
614
|
+
def self.rsa_jwk_to_der(is_private, n, e, d = nil, p = nil, q = nil, dp = nil, dq = nil, qi = nil)
|
|
615
|
+
bn = ->(b) { OpenSSL::BN.new(crypto_bytes(b), 2) }
|
|
616
|
+
alg_id = OpenSSL::ASN1::Sequence([OpenSSL::ASN1::ObjectId('rsaEncryption'), OpenSSL::ASN1::Null(nil)])
|
|
617
|
+
if is_private
|
|
618
|
+
pkcs1 = OpenSSL::ASN1::Sequence([
|
|
619
|
+
OpenSSL::ASN1::Integer(0),
|
|
620
|
+
OpenSSL::ASN1::Integer(bn.(n)), OpenSSL::ASN1::Integer(bn.(e)), OpenSSL::ASN1::Integer(bn.(d)),
|
|
621
|
+
OpenSSL::ASN1::Integer(bn.(p)), OpenSSL::ASN1::Integer(bn.(q)),
|
|
622
|
+
OpenSSL::ASN1::Integer(bn.(dp)), OpenSSL::ASN1::Integer(bn.(dq)), OpenSSL::ASN1::Integer(bn.(qi))
|
|
623
|
+
]).to_der
|
|
624
|
+
OpenSSL::ASN1::Sequence([OpenSSL::ASN1::Integer(0), alg_id, OpenSSL::ASN1::OctetString(pkcs1)]).to_der
|
|
625
|
+
else
|
|
626
|
+
pkcs1 = OpenSSL::ASN1::Sequence([OpenSSL::ASN1::Integer(bn.(n)), OpenSSL::ASN1::Integer(bn.(e))]).to_der
|
|
627
|
+
OpenSSL::ASN1::Sequence([alg_id, OpenSSL::ASN1::BitString(pkcs1)]).to_der
|
|
628
|
+
end
|
|
629
|
+
end
|
|
630
|
+
|
|
631
|
+
# Explode an RSA key DER into JWK components (big-endian byte Arrays). Private keys
|
|
632
|
+
# carry the full CRT set; public keys only n/e.
|
|
633
|
+
def self.rsa_der_to_jwk(der_bytes)
|
|
634
|
+
key = pkey_read(der_bytes)
|
|
635
|
+
jwk = { 'n' => key.n.to_s(2).bytes, 'e' => key.e.to_s(2).bytes }
|
|
636
|
+
if key.private?
|
|
637
|
+
jwk.merge!(
|
|
638
|
+
'd' => key.d.to_s(2).bytes, 'p' => key.p.to_s(2).bytes, 'q' => key.q.to_s(2).bytes,
|
|
639
|
+
'dp' => key.dmp1.to_s(2).bytes, 'dq' => key.dmq1.to_s(2).bytes, 'qi' => key.iqmp.to_s(2).bytes
|
|
640
|
+
)
|
|
641
|
+
end
|
|
642
|
+
jwk
|
|
643
|
+
end
|
|
644
|
+
|
|
227
645
|
def self.safe_call
|
|
228
646
|
yield
|
|
229
647
|
rescue StandardError => e
|