From a998d6c49393c5ff9ed4cc0bd79cbe997a221b18 Mon Sep 17 00:00:00 2001 From: James George <25279263+jamesgeorge007@users.noreply.github.com> Date: Thu, 22 Jan 2026 17:30:06 +0530 Subject: [PATCH] feat(js-sandbox): add extensive Web Crypto API support (#5791) --- .../collections/scripting-revamp-coll.json | 332 +++--- .../crypto/encrypt-decrypt.spec.ts | 261 +++++ .../crypto/key-operations.spec.ts | 829 +++++++++++++ .../cage-modules/crypto/random-values.spec.ts | 243 ++++ .../cage-modules/crypto/sign-verify.spec.ts | 207 ++++ .../cage-modules/crypto/subtle-digest.spec.ts | 207 ++++ .../serialization-edge-cases.spec.ts | 1 - .../pw-namespace/expect/toBeLevelxxx.spec.ts | 3 - .../src/cage-modules/crypto.ts | 1031 +++++++++++++++++ .../src/cage-modules/default.ts | 4 +- .../src/cage-modules/fetch.ts | 61 +- .../src/cage-modules/index.ts | 2 + .../src/cage-modules/utils/vm-marshal.ts | 241 ++++ 13 files changed, 3223 insertions(+), 199 deletions(-) create mode 100644 packages/hoppscotch-js-sandbox/src/__tests__/cage-modules/crypto/encrypt-decrypt.spec.ts create mode 100644 packages/hoppscotch-js-sandbox/src/__tests__/cage-modules/crypto/key-operations.spec.ts create mode 100644 packages/hoppscotch-js-sandbox/src/__tests__/cage-modules/crypto/random-values.spec.ts create mode 100644 packages/hoppscotch-js-sandbox/src/__tests__/cage-modules/crypto/sign-verify.spec.ts create mode 100644 packages/hoppscotch-js-sandbox/src/__tests__/cage-modules/crypto/subtle-digest.spec.ts create mode 100644 packages/hoppscotch-js-sandbox/src/cage-modules/crypto.ts create mode 100644 packages/hoppscotch-js-sandbox/src/cage-modules/utils/vm-marshal.ts diff --git a/packages/hoppscotch-cli/src/__tests__/e2e/fixtures/collections/scripting-revamp-coll.json b/packages/hoppscotch-cli/src/__tests__/e2e/fixtures/collections/scripting-revamp-coll.json index 10548005..a7d1b45c 100644 --- a/packages/hoppscotch-cli/src/__tests__/e2e/fixtures/collections/scripting-revamp-coll.json +++ b/packages/hoppscotch-cli/src/__tests__/e2e/fixtures/collections/scripting-revamp-coll.json @@ -1,11 +1,10 @@ { - "v": 10, - "id": "cmi8s7e0b000alj0isau8jt3x", + "v": 11, "name": "scripting-revamp-coll", "folders": [], "requests": [ { - "v": "16", + "v": "17", "id": "cmfhzf0oo0092qt0if5rvd2g4", "name": "json-response-test", "method": "POST", @@ -31,10 +30,10 @@ }, "requestVariables": [], "responses": {}, - "_ref_id": "req_mi8s7dz1_0e51a53b-8e08-4390-a2c8-bf4034623f78" + "description": null }, { - "v": "16", + "v": "17", "id": "cmfhzf0op0093qt0ictgoxymy", "name": "html-response-test", "method": "GET", @@ -60,10 +59,10 @@ }, "requestVariables": [], "responses": {}, - "_ref_id": "req_mi8s7dz1_87685b90-47bb-4272-b9e3-78efc86ce298" + "description": null }, { - "v": "16", + "v": "17", "id": "cmfhzf0op0094qt0ixbo9rqnw", "name": "environment-variables-test", "method": "GET", @@ -82,10 +81,10 @@ }, "requestVariables": [], "responses": {}, - "_ref_id": "req_mi8s7dz1_7e619d82-0e16-4a24-bc03-d070cd5f0621" + "description": null }, { - "v": "16", + "v": "17", "id": "cmfhzf0op0095qt0ieogkxx1w", "name": "request-modification-test", "method": "GET", @@ -118,10 +117,10 @@ }, "requestVariables": [], "responses": {}, - "_ref_id": "req_mi8s7dz2_5d3ccef8-8ed9-45b4-b8da-a83127730147" + "description": null }, { - "v": "16", + "v": "17", "id": "cmfhzf0op0096qt0i6wellfus", "name": "response-parsing-test", "method": "POST", @@ -147,10 +146,10 @@ }, "requestVariables": [], "responses": {}, - "_ref_id": "req_mi8s7dz2_d04535d4-ea26-40bf-be2b-e5fef0051b03" + "description": null }, { - "v": "16", + "v": "17", "id": "cmfhzf0op0097qt0ia4wf0lej", "name": "request-variables-test", "method": "GET", @@ -185,10 +184,10 @@ } ], "responses": {}, - "_ref_id": "req_mi8s7dz2_29079e08-dc98-4332-87e6-12f86ca273a5" + "description": null }, { - "v": "16", + "v": "17", "id": "cmfhzf0op0098qt0ii9fguj6e", "name": "info-context-test", "method": "GET", @@ -207,10 +206,10 @@ }, "requestVariables": [], "responses": {}, - "_ref_id": "req_mi8s7dz2_f95a19a0-fcaf-4aac-a0cc-80d103e0a500" + "description": null }, { - "v": "16", + "v": "17", "id": "cmfhzf0op0099qt0iamthw97r", "name": "pm-namespace-additional-test", "method": "GET", @@ -229,10 +228,10 @@ }, "requestVariables": [], "responses": {}, - "_ref_id": "req_mi8s7dz2_61a82bd3-0884-4b29-bb6e-0807c694e6dd" + "description": null }, { - "v": "16", + "v": "17", "id": "cmfhzf0op009aqt0inw3j6dq9", "name": "expectation-methods-test", "method": "POST", @@ -251,10 +250,10 @@ }, "requestVariables": [], "responses": {}, - "_ref_id": "req_mi8s7dz2_c7de9aae-5936-4fe7-9205-2823b560f8ad" + "description": null }, { - "v": "16", + "v": "17", "id": "cmfhzf0op00chai1qt0inext01", "name": "chai-assertions-hopp-extended", "method": "POST", @@ -273,10 +272,10 @@ }, "requestVariables": [], "responses": {}, - "_ref_id": "req_mi8s7dz2_d4acd239-fd73-43e7-a96b-27f293b4f8ce" + "description": null }, { - "v": "16", + "v": "17", "id": "cmfhzf0op00chai2qt0inext02", "name": "chai-assertions-pm-parity", "method": "POST", @@ -295,10 +294,10 @@ }, "requestVariables": [], "responses": {}, - "_ref_id": "req_mi8s7dz3_e33cf09a-d284-46ca-a394-c8033d5dde84" + "description": null }, { - "v": "16", + "v": "17", "id": "cmfhzf0op00cookies01", "name": "cookie-assertions-test", "method": "GET", @@ -324,10 +323,10 @@ }, "requestVariables": [], "responses": {}, - "_ref_id": "req_mi8s7dz3_0bff5a56-b147-45f8-a8da-e5175eb940d9" + "description": null }, { - "v": "16", + "v": "17", "id": "cmfhzf0op00schema01", "name": "json-schema-validation-test", "method": "POST", @@ -346,10 +345,10 @@ }, "requestVariables": [], "responses": {}, - "_ref_id": "req_mi8s7dz3_05e595b7-ff00-4ae8-b695-8957c1381387" + "description": null }, { - "v": "16", + "v": "17", "id": "cmfhzf0op00charset01", "name": "charset-validation-test", "method": "GET", @@ -368,10 +367,10 @@ }, "requestVariables": [], "responses": {}, - "_ref_id": "req_mi8s7dz3_0e536fee-92a3-4131-8a67-a7fd69cd189f" + "description": null }, { - "v": "16", + "v": "17", "id": "cmfhzf0op00jsonpath01", "name": "jsonpath-query-test", "method": "POST", @@ -390,10 +389,10 @@ }, "requestVariables": [], "responses": {}, - "_ref_id": "req_mi8s7dz3_db1401ea-7ef8-4838-a570-dc3782610050" + "description": null }, { - "v": "16", + "v": "17", "id": "cmfhzf0op00envext01", "name": "environment-extensions-test", "method": "GET", @@ -412,10 +411,10 @@ }, "requestVariables": [], "responses": {}, - "_ref_id": "req_mi8s7dz3_b749ccf4-0efb-4543-b8c5-94a142d53876" + "description": null }, { - "v": "16", + "v": "17", "id": "cmfhzf0op00respext01", "name": "response-extensions-test", "method": "POST", @@ -434,10 +433,10 @@ }, "requestVariables": [], "responses": {}, - "_ref_id": "req_mi8s7dz3_27dfe163-c152-46b4-b3ce-a90377b640f7" + "description": null }, { - "v": "16", + "v": "17", "id": "cmfhzf0op00execext01", "name": "execution-context-test", "method": "GET", @@ -456,10 +455,10 @@ }, "requestVariables": [], "responses": {}, - "_ref_id": "req_mi8s7dz3_57859840-7e61-4114-b514-199ab51ba57e" + "description": null }, { - "v": "16", + "v": "17", "id": "cmfhzf0op00bddassert01", "name": "bdd-response-assertions-test", "method": "POST", @@ -485,10 +484,10 @@ }, "requestVariables": [], "responses": {}, - "_ref_id": "req_mi8s7dz3_ec06bb6c-1857-4352-bdb5-24b349a51a09" + "description": null }, { - "v": "16", + "v": "17", "id": "cmfhzf0op00includecontain01", "name": "include-contain-assertions-test", "method": "POST", @@ -507,10 +506,10 @@ }, "requestVariables": [], "responses": {}, - "_ref_id": "req_mi8s7dz4_11cc69ef-f13a-4d02-9c42-607bcd84054b" + "description": null }, { - "v": "16", + "v": "17", "id": "cmfhzf0op00envunsetclear01", "name": "environment-unset-clear-test", "method": "GET", @@ -529,10 +528,10 @@ }, "requestVariables": [], "responses": {}, - "_ref_id": "req_mi8s7dz4_ab8af065-c323-4830-86b8-be5f8b8570a7" + "description": null }, { - "v": "16", + "v": "17", "id": "cmfhzf0op00pmmutate01", "name": "pm-request-mutation-test", "method": "GET", @@ -565,10 +564,10 @@ }, "requestVariables": [], "responses": {}, - "_ref_id": "req_mi8s7dz4_f73dfa1a-8539-425b-a6af-0a4622aec733" + "description": null }, { - "v": "16", + "v": "17", "id": "cmfhzf0op00urlmutate01", "name": "pm-url-property-mutations-test", "method": "GET", @@ -587,10 +586,10 @@ }, "requestVariables": [], "responses": {}, - "_ref_id": "req_mi8s7dz4_4ecd2c5d-acbe-4f77-987a-a6c257b7f825" + "description": null }, { - "v": "16", + "v": "17", "id": "cmfhzf0op00unsupported01", "name": "unsupported-features-test", "method": "GET", @@ -609,10 +608,10 @@ }, "requestVariables": [], "responses": {}, - "_ref_id": "req_mi8s7dz4_d3265ddb-982b-4da6-9506-125b0657fa13" + "description": null }, { - "v": "16", + "v": "17", "id": "cmfhzf0op00urlpropertylist01", "name": "url-propertylist-helpers-test", "method": "GET", @@ -669,10 +668,10 @@ }, "requestVariables": [], "responses": {}, - "_ref_id": "req_mi8s7dz4_d8fa5d58-1a76-420d-946e-5cb063fc65e3" + "description": null }, { - "v": "16", + "v": "17", "name": "propertylist-advanced-methods-test", "method": "GET", "endpoint": "https://echo.hoppscotch.io/propertylist", @@ -722,10 +721,10 @@ }, "requestVariables": [], "responses": {}, - "_ref_id": "req_mi8s7dz4_8e31db5d-90ed-4bad-b92a-67976c476c35" + "description": null }, { - "v": "16", + "v": "17", "id": "advanced-response-methods-test", "name": "advanced-response-methods-test", "method": "POST", @@ -751,10 +750,10 @@ }, "requestVariables": [], "responses": {}, - "_ref_id": "req_mi8s7dz4_80b32834-2683-4a4a-a866-90a3a97c7471" + "description": null }, { - "v": "16", + "v": "17", "id": "advanced-chai-map-set-test", "name": "advanced-chai-map-set-test", "method": "GET", @@ -773,10 +772,10 @@ }, "requestVariables": [], "responses": {}, - "_ref_id": "req_mi8s7dz4_c10eecab-a890-4a1b-97bb-99ddaa9bca9c" + "description": null }, { - "v": "16", + "v": "17", "id": "cmfhzf0op00typecoer01", "name": "type-preservation-test", "method": "GET", @@ -794,10 +793,11 @@ "body": null }, "requestVariables": [], - "responses": {} + "responses": {}, + "description": null }, { - "v": "16", + "v": "17", "id": "type_preservation_ui_compat", "name": "type-preservation-ui-compatibility-test", "method": "POST", @@ -816,10 +816,10 @@ }, "requestVariables": [], "responses": {}, - "_ref_id": "req_mi8s7dz5_94e03aa3-8d21-4bad-8d3f-e4a276e1667e" + "description": null }, { - "v": "16", + "v": "17", "id": "fetch-get-basic", "name": "hopp.fetch() - GET request basic", "method": "GET", @@ -837,10 +837,11 @@ "body": null }, "requestVariables": [], - "responses": {} + "responses": {}, + "description": null }, { - "v": "16", + "v": "17", "id": "fetch-post-json", "name": "hopp.fetch() - POST with JSON body", "method": "GET", @@ -858,10 +859,11 @@ "body": null }, "requestVariables": [], - "responses": {} + "responses": {}, + "description": null }, { - "v": "16", + "v": "17", "id": "fetch-404-error", "name": "hopp.fetch() - 404 error handling", "method": "GET", @@ -879,10 +881,11 @@ "body": null }, "requestVariables": [], - "responses": {} + "responses": {}, + "description": null }, { - "v": "16", + "v": "17", "id": "fetch-custom-headers", "name": "hopp.fetch() - Custom headers", "method": "GET", @@ -900,10 +903,11 @@ "body": null }, "requestVariables": [], - "responses": {} + "responses": {}, + "description": null }, { - "v": "16", + "v": "17", "id": "fetch-env-url", "name": "hopp.fetch() - Environment variable URL", "method": "GET", @@ -921,10 +925,11 @@ "body": null }, "requestVariables": [], - "responses": {} + "responses": {}, + "description": null }, { - "v": "16", + "v": "17", "id": "fetch-response-text", "name": "hopp.fetch() - Response text parsing", "method": "GET", @@ -942,10 +947,11 @@ "body": null }, "requestVariables": [], - "responses": {} + "responses": {}, + "description": null }, { - "v": "16", + "v": "17", "id": "fetch-http-methods", "name": "hopp.fetch() - HTTP methods (PUT, DELETE, PATCH)", "method": "GET", @@ -963,10 +969,11 @@ "body": null }, "requestVariables": [], - "responses": {} + "responses": {}, + "description": null }, { - "v": "16", + "v": "17", "id": "pm-sendrequest-string-url", "name": "pm.sendRequest() - String URL format", "method": "GET", @@ -984,10 +991,11 @@ "body": null }, "requestVariables": [], - "responses": {} + "responses": {}, + "description": null }, { - "v": "16", + "v": "17", "id": "pm-sendrequest-request-object", "name": "pm.sendRequest() - Request object format", "method": "GET", @@ -1005,10 +1013,11 @@ "body": null }, "requestVariables": [], - "responses": {} + "responses": {}, + "description": null }, { - "v": "16", + "v": "17", "id": "pm-sendrequest-urlencoded", "name": "pm.sendRequest() - URL-encoded body", "method": "GET", @@ -1026,10 +1035,11 @@ "body": null }, "requestVariables": [], - "responses": {} + "responses": {}, + "description": null }, { - "v": "16", + "v": "17", "id": "pm-sendrequest-response-format", "name": "pm.sendRequest() - Response format validation", "method": "GET", @@ -1047,10 +1057,11 @@ "body": null }, "requestVariables": [], - "responses": {} + "responses": {}, + "description": null }, { - "v": "16", + "v": "17", "id": "pm-sendrequest-error-codes", "name": "pm.sendRequest() - HTTP error status codes", "method": "GET", @@ -1069,10 +1080,10 @@ }, "requestVariables": [], "responses": {}, - "_ref_id": "req_mi8s89cl_01becae7-dca6-47ab-87e5-fb2df28fc393" + "description": null }, { - "v": "16", + "v": "17", "id": "pm-sendrequest-env-integration", "name": "pm.sendRequest() - Environment variable integration", "method": "GET", @@ -1090,10 +1101,11 @@ "body": null }, "requestVariables": [], - "responses": {} + "responses": {}, + "description": null }, { - "v": "16", + "v": "17", "id": "pm-sendrequest-store-response", "name": "pm.sendRequest() - Store response in environment", "method": "GET", @@ -1111,10 +1123,11 @@ "body": null }, "requestVariables": [], - "responses": {} + "responses": {}, + "description": null }, { - "v": "16", + "v": "17", "id": "pm-sendrequest-rfc-object-headers", "name": "pm.sendRequest() - RFC pattern with object headers", "method": "GET", @@ -1132,10 +1145,11 @@ "body": null }, "requestVariables": [], - "responses": {} + "responses": {}, + "description": null }, { - "v": "16", + "v": "17", "id": "fetch-pm-interop", "name": "hopp.fetch() and pm.sendRequest() - Interoperability", "method": "GET", @@ -1153,10 +1167,11 @@ "body": null }, "requestVariables": [], - "responses": {} + "responses": {}, + "description": null }, { - "v": "16", + "v": "17", "id": "fetch-json-parsing", "name": "hopp.fetch() - JSON response parsing", "method": "GET", @@ -1181,10 +1196,11 @@ "body": null }, "requestVariables": [], - "responses": {} + "responses": {}, + "description": null }, { - "v": "16", + "v": "17", "id": "fetch-headers-access", "name": "hopp.fetch() - Response headers access", "method": "GET", @@ -1209,10 +1225,11 @@ "body": null }, "requestVariables": [], - "responses": {} + "responses": {}, + "description": null }, { - "v": "16", + "v": "17", "id": "pm-sendrequest-formdata", "name": "pm.sendRequest() - FormData body mode", "method": "POST", @@ -1230,10 +1247,11 @@ "body": null }, "requestVariables": [], - "responses": {} + "responses": {}, + "description": null }, { - "v": "16", + "v": "17", "id": "pm-sendrequest-json-parsing", "name": "pm.sendRequest() - JSON parsing method", "method": "POST", @@ -1251,10 +1269,11 @@ "body": null }, "requestVariables": [], - "responses": {} + "responses": {}, + "description": null }, { - "v": "16", + "v": "17", "id": "pm-sendrequest-headers-extraction", "name": "pm.sendRequest() - Response headers extraction", "method": "GET", @@ -1279,10 +1298,11 @@ "body": null }, "requestVariables": [], - "responses": {} + "responses": {}, + "description": null }, { - "v": "16", + "v": "17", "id": "fetch-network-error", "name": "hopp.fetch() - Network error handling", "method": "GET", @@ -1300,10 +1320,11 @@ "body": null }, "requestVariables": [], - "responses": {} + "responses": {}, + "description": null }, { - "v": "16", + "v": "17", "id": "pm-sendrequest-network-error", "name": "pm.sendRequest() - Network error callback", "method": "GET", @@ -1321,10 +1342,11 @@ "body": null }, "requestVariables": [], - "responses": {} + "responses": {}, + "description": null }, { - "v": "16", + "v": "17", "id": "fetch-sequential-requests", "name": "hopp.fetch() - Sequential requests chain", "method": "GET", @@ -1342,10 +1364,11 @@ "body": null }, "requestVariables": [], - "responses": {} + "responses": {}, + "description": null }, { - "v": "16", + "v": "17", "id": "pm-sendrequest-nested", "name": "pm.sendRequest() - Nested requests", "method": "GET", @@ -1363,10 +1386,11 @@ "body": null }, "requestVariables": [], - "responses": {} + "responses": {}, + "description": null }, { - "v": "16", + "v": "17", "id": "fetch-binary-response", "name": "hopp.fetch() - Binary response (arrayBuffer)", "method": "GET", @@ -1384,10 +1408,11 @@ "body": null }, "requestVariables": [], - "responses": {} + "responses": {}, + "description": null }, { - "v": "16", + "v": "17", "id": "pm-sendrequest-empty-response", "name": "pm.sendRequest() - Empty response body (204)", "method": "DELETE", @@ -1405,10 +1430,11 @@ "body": null }, "requestVariables": [], - "responses": {} + "responses": {}, + "description": null }, { - "v": "16", + "v": "17", "id": "async_patterns_prereq", "name": "Async Patterns - Pre-Request", "method": "GET", @@ -1426,10 +1452,11 @@ "body": null }, "requestVariables": [], - "responses": {} + "responses": {}, + "description": null }, { - "v": "16", + "v": "17", "id": "async_patterns_test", "name": "Async Patterns - Test Script", "method": "GET", @@ -1447,10 +1474,11 @@ "body": null }, "requestVariables": [], - "responses": {} + "responses": {}, + "description": null }, { - "v": "16", + "v": "17", "id": "workflow_patterns", "name": "Workflow Patterns (Sequential, Parallel, Auth)", "method": "GET", @@ -1468,10 +1496,11 @@ "body": null }, "requestVariables": [], - "responses": {} + "responses": {}, + "description": null }, { - "v": "16", + "v": "17", "id": "error_handling_combined", "name": "Error Handling & Edge Cases", "method": "GET", @@ -1489,10 +1518,11 @@ "body": null }, "requestVariables": [], - "responses": {} + "responses": {}, + "description": null }, { - "v": "16", + "v": "17", "id": "large_payload_formdata", "name": "Large Payload & FormData", "method": "GET", @@ -1510,10 +1540,11 @@ "body": null }, "requestVariables": [], - "responses": {} + "responses": {}, + "description": null }, { - "v": "16", + "v": "17", "id": "get_methods_combined", "name": "GET Methods (Query, Headers, URL)", "method": "GET", @@ -1531,10 +1562,11 @@ "body": null }, "requestVariables": [], - "responses": {} + "responses": {}, + "description": null }, { - "v": "16", + "v": "17", "id": "post_methods_combined", "name": "POST Methods (JSON, URLEncoded, Binary)", "method": "GET", @@ -1552,10 +1584,11 @@ "body": null }, "requestVariables": [], - "responses": {} + "responses": {}, + "description": null }, { - "v": "16", + "v": "17", "id": "http_methods_combined", "name": "HTTP Methods (PUT, PATCH, DELETE)", "method": "GET", @@ -1573,10 +1606,11 @@ "body": null }, "requestVariables": [], - "responses": {} + "responses": {}, + "description": null }, { - "v": "16", + "v": "17", "id": "response_parsing_combined", "name": "Response Parsing (Headers, Status, Body)", "method": "GET", @@ -1594,10 +1628,11 @@ "body": null }, "requestVariables": [], - "responses": {} + "responses": {}, + "description": null }, { - "v": "16", + "v": "17", "id": "dynamic_url_construction", "name": "Dynamic URL Construction", "method": "GET", @@ -1615,7 +1650,30 @@ "body": null }, "requestVariables": [], - "responses": {} + "responses": {}, + "description": null + }, + { + "v": "17", + "id": "crypto_module_test", + "name": "crypto-module-test", + "method": "GET", + "endpoint": "https://echo.hoppscotch.io", + "params": [], + "headers": [], + "preRequestScript": "// Hoppscotch Sandbox Crypto Module Tests\n// NOTE: The sandbox crypto API accepts plain arrays instead of TypedArrays.\n// Results include both .length and .byteLength for compatibility.\n// @ts-expect-error comments suppress Monaco's Web Crypto type checking for inputs.\n\n// Test crypto.randomUUID()\nconst uuid = crypto.randomUUID()\npm.environment.set('test_uuid', uuid)\n\n// Test crypto.getRandomValues()\nconst randomBytes = new Array(16).fill(0)\n// @ts-expect-error - Sandbox accepts plain arrays, not just TypedArrays\ncrypto.getRandomValues(randomBytes)\npm.environment.set('random_bytes_length', randomBytes.length)\npm.environment.set('has_random_values', randomBytes.some(v => v !== 0))\n\n// Test crypto.subtle.digest() with SHA-256\nconst testData = [72, 101, 108, 108, 111] // 'Hello' as bytes\n// @ts-expect-error - Sandbox accepts plain arrays as data input\nconst hash = await crypto.subtle.digest('SHA-256', testData)\npm.environment.set('hash_length', hash.byteLength)\n\n// Test crypto.subtle.generateKey() and sign/verify with HMAC\nconst hmacKey = await crypto.subtle.generateKey(\n { name: 'HMAC', hash: 'SHA-256' },\n true,\n ['sign', 'verify']\n)\npm.environment.set('hmac_key_type', hmacKey.type)\n\n// @ts-expect-error - Sandbox accepts plain arrays as data input\nconst signature = await crypto.subtle.sign('HMAC', hmacKey, testData)\npm.environment.set('signature_length', signature.byteLength)\n\n// @ts-expect-error - Sandbox accepts plain arrays for signature and data\nconst isValid = await crypto.subtle.verify('HMAC', hmacKey, signature, testData)\npm.environment.set('signature_valid', isValid)\n\n// Test crypto.subtle.generateKey() with AES-GCM\nconst aesKey = await crypto.subtle.generateKey(\n { name: 'AES-GCM', length: 256 },\n true,\n ['encrypt', 'decrypt']\n)\npm.environment.set('aes_key_type', aesKey.type)\n\n// Test encrypt/decrypt with AES-GCM\nconst iv = new Array(12).fill(0).map((_, i) => i)\nconst plaintext = [83, 101, 99, 114, 101, 116] // 'Secret' as bytes\n\n// @ts-expect-error - Sandbox accepts plain arrays for iv and plaintext\nconst ciphertext = await crypto.subtle.encrypt(\n { name: 'AES-GCM', iv },\n aesKey,\n plaintext\n)\npm.environment.set('ciphertext_length', ciphertext.byteLength)\n\n// @ts-expect-error - Sandbox accepts plain arrays for iv and ciphertext\nconst decrypted = await crypto.subtle.decrypt(\n { name: 'AES-GCM', iv: iv },\n aesKey,\n ciphertext\n)\n// Compare decrypted bytes to original plaintext\nlet decryptMatch = decrypted.length === plaintext.length\nif (decryptMatch) {\n for (let i = 0; i < plaintext.length; i++) {\n if (decrypted[i] !== plaintext[i]) { decryptMatch = false; break }\n }\n}\npm.environment.set('decrypted_matches', decryptMatch)\n\n// Test RSA-OAEP encryption/decryption\nconst rsaKeyPair = await crypto.subtle.generateKey(\n {\n name: 'RSA-OAEP',\n modulusLength: 2048,\n // @ts-expect-error - Sandbox accepts plain array for publicExponent\n publicExponent: [1, 0, 1],\n hash: 'SHA-256'\n },\n true,\n ['encrypt', 'decrypt']\n)\n\n// @ts-expect-error - Sandbox accepts plain arrays as data input\nconst rsaCiphertext = await crypto.subtle.encrypt(\n { name: 'RSA-OAEP' },\n rsaKeyPair.publicKey,\n testData\n)\npm.environment.set('rsa_ciphertext_length', rsaCiphertext.byteLength)\n\nconst rsaDecrypted = await crypto.subtle.decrypt(\n { name: 'RSA-OAEP' },\n rsaKeyPair.privateKey,\n rsaCiphertext\n)\n// Compare RSA decrypted bytes to original testData\nlet rsaMatch = rsaDecrypted.length === testData.length\nif (rsaMatch) {\n for (let i = 0; i < testData.length; i++) {\n if (rsaDecrypted[i] !== testData[i]) { rsaMatch = false; break }\n }\n}\npm.environment.set('rsa_decrypted_matches', rsaMatch)\n\n// Test PBKDF2 key derivation\n// @ts-expect-error - Sandbox accepts plain array as key data\nconst passwordKey = await crypto.subtle.importKey(\n 'raw',\n [112, 97, 115, 115, 119, 111, 114, 100], // 'password'\n { name: 'PBKDF2' },\n false,\n ['deriveKey']\n)\n\nconst derivedKey = await crypto.subtle.deriveKey(\n {\n name: 'PBKDF2',\n hash: 'SHA-256',\n // @ts-expect-error - Sandbox accepts plain array for salt\n salt: [1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1],\n iterations: 1000\n },\n passwordKey,\n { name: 'AES-GCM', length: 256 },\n true,\n ['encrypt', 'decrypt']\n)\npm.environment.set('pbkdf2_key_type', derivedKey.type)\n", + "testScript": "const getNum = (key) => {\n const v = pm.environment.get(key)\n return typeof v === 'number' ? v : parseInt(String(v), 10)\n}\n\nconst getBool = (key) => {\n const v = pm.environment.get(key)\n return String(v) === 'true'\n}\n\n// crypto.randomUUID() tests\npm.test('crypto.randomUUID() generates valid UUID v4 format', () => {\n const uuid = pm.environment.get('test_uuid')\n pm.expect(uuid).to.be.a('string')\n pm.expect(uuid.length).to.equal(36)\n \n // UUID v4 format: xxxxxxxx-xxxx-4xxx-yxxx-xxxxxxxxxxxx\n const uuidPattern = /^[0-9a-f]{8}-[0-9a-f]{4}-4[0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/i\n pm.expect(uuidPattern.test(uuid)).to.be.true\n})\n\n// crypto.getRandomValues() tests\npm.test('crypto.getRandomValues() fills array with random bytes', () => {\n pm.expect(getNum('random_bytes_length')).to.equal(16)\n pm.expect(getBool('has_random_values')).to.be.true\n})\n\n// crypto.subtle.digest() tests\npm.test('crypto.subtle.digest() produces SHA-256 hash', () => {\n pm.expect(getNum('hash_length')).to.equal(32) // SHA-256 = 32 bytes\n})\n\n// HMAC sign/verify tests\npm.test('crypto.subtle.generateKey() creates HMAC key', () => {\n pm.expect(pm.environment.get('hmac_key_type')).to.equal('secret')\n})\n\npm.test('crypto.subtle.sign() produces HMAC signature', () => {\n pm.expect(getNum('signature_length')).to.equal(32) // SHA-256 HMAC = 32 bytes\n})\n\npm.test('crypto.subtle.verify() validates HMAC signature', () => {\n pm.expect(getBool('signature_valid')).to.be.true\n})\n\n// AES-GCM encrypt/decrypt tests\npm.test('crypto.subtle.generateKey() creates AES-GCM key', () => {\n pm.expect(pm.environment.get('aes_key_type')).to.equal('secret')\n})\n\npm.test('crypto.subtle.encrypt() produces ciphertext', () => {\n pm.expect(getNum('ciphertext_length')).to.be.above(0)\n})\n\npm.test('crypto.subtle.decrypt() recovers original plaintext', () => {\n pm.expect(getBool('decrypted_matches')).to.be.true\n})\n\n// RSA-OAEP encrypt/decrypt tests\npm.test('crypto.subtle.encrypt() produces RSA ciphertext', () => {\n pm.expect(getNum('rsa_ciphertext_length')).to.be.above(0)\n})\n\npm.test('crypto.subtle.decrypt() recovers RSA plaintext', () => {\n pm.expect(getBool('rsa_decrypted_matches')).to.be.true\n})\n\n// PBKDF2 key derivation tests\npm.test('crypto.subtle.deriveKey() creates AES key from password', () => {\n pm.expect(pm.environment.get('pbkdf2_key_type')).to.equal('secret')\n})\n\n// Additional in-script crypto tests\npm.test('crypto.randomUUID() generates unique UUIDs', () => {\n const uuid1 = crypto.randomUUID()\n const uuid2 = crypto.randomUUID()\n pm.expect(uuid1).to.not.equal(uuid2)\n})\n\npm.test('crypto.getRandomValues() mutates array in place', () => {\n const arr = new Array(10).fill(0)\n // @ts-expect-error - Sandbox accepts plain arrays, not just TypedArrays\n const result = crypto.getRandomValues(arr)\n pm.expect(result).to.equal(arr)\n pm.expect(arr.some(v => v !== 0)).to.be.true\n})\n", + "auth": { + "authType": "inherit", + "authActive": true + }, + "body": { + "contentType": null, + "body": null + }, + "requestVariables": [], + "responses": {}, + "description": null } ], "auth": { @@ -1624,5 +1682,5 @@ }, "headers": [], "variables": [], - "_ref_id": "coll_mi8sfgx8_4523effa-e775-4550-afb8-4ab5a4ef45ae" + "description": null } \ No newline at end of file diff --git a/packages/hoppscotch-js-sandbox/src/__tests__/cage-modules/crypto/encrypt-decrypt.spec.ts b/packages/hoppscotch-js-sandbox/src/__tests__/cage-modules/crypto/encrypt-decrypt.spec.ts new file mode 100644 index 00000000..345798ce --- /dev/null +++ b/packages/hoppscotch-js-sandbox/src/__tests__/cage-modules/crypto/encrypt-decrypt.spec.ts @@ -0,0 +1,261 @@ +import { describe, it, expect } from "vitest" +import { FaradayCage } from "faraday-cage" +import { customCryptoModule } from "../../../cage-modules" + +const runCage = async (script: string) => { + const cage = await FaradayCage.create() + return cage.runCode(script, [ + customCryptoModule({ + cryptoImpl: globalThis.crypto, + }), + ]) +} + +describe("crypto.subtle.encrypt/decrypt", () => { + it("encrypts and decrypts with AES-GCM", async () => { + const script = ` + (async () => { + const key = await crypto.subtle.generateKey( + { name: 'AES-GCM', length: 256 }, + true, + ['encrypt', 'decrypt'] + ) + + const data = [116, 101, 115, 116, 32, 100, 97, 116, 97] // "test data" + const iv = [1,2,3,4,5,6,7,8,9,10,11,12] + + const encrypted = await crypto.subtle.encrypt( + { name: 'AES-GCM', iv }, + key, + data + ) + + if (!Array.isArray(encrypted) || encrypted.length === 0) { + throw new Error('encrypted did not return a byte array') + } + + const decrypted = await crypto.subtle.decrypt( + { name: 'AES-GCM', iv }, + key, + encrypted + ) + + if (JSON.stringify(decrypted) !== JSON.stringify(data)) { + throw new Error('decrypted bytes mismatch') + } + })() + ` + + const result = await runCage(script) + expect(result.type).toBe("ok") + }) + + it("encrypts and decrypts with AES-CBC", async () => { + const script = ` + (async () => { + const key = await crypto.subtle.generateKey( + { name: 'AES-CBC', length: 256 }, + true, + ['encrypt', 'decrypt'] + ) + + const data = [104, 101, 108, 108, 111, 32, 119, 111, 114, 108, 100] // "hello world" + const iv = [1,2,3,4,5,6,7,8,9,10,11,12,13,14,15,16] + + const encrypted = await crypto.subtle.encrypt( + { name: 'AES-CBC', iv }, + key, + data + ) + + const decrypted = await crypto.subtle.decrypt( + { name: 'AES-CBC', iv }, + key, + encrypted + ) + + if (JSON.stringify(decrypted) !== JSON.stringify(data)) { + throw new Error('decrypted bytes mismatch') + } + })() + ` + + const result = await runCage(script) + expect(result.type).toBe("ok") + }) + + it("handles empty data", async () => { + const script = ` + (async () => { + const key = await crypto.subtle.generateKey( + { name: 'AES-GCM', length: 128 }, + true, + ['encrypt', 'decrypt'] + ) + + const data = [] + const iv = [1,2,3,4,5,6,7,8,9,10,11,12] + + const encrypted = await crypto.subtle.encrypt( + { name: 'AES-GCM', iv }, + key, + data + ) + + const decrypted = await crypto.subtle.decrypt( + { name: 'AES-GCM', iv }, + key, + encrypted + ) + + if (!Array.isArray(decrypted) || decrypted.length !== 0) { + throw new Error('expected empty decrypted array') + } + })() + ` + + const result = await runCage(script) + expect(result.type).toBe("ok") + }) + + it("rejects decrypt with invalid key usage", async () => { + const script = ` + (async () => { + const key = await crypto.subtle.generateKey( + { name: 'AES-GCM', length: 256 }, + true, + ['encrypt'] // No decrypt usage + ) + + const data = [116, 101, 115, 116] + const iv = [1,2,3,4,5,6,7,8,9,10,11,12] + + const encrypted = await crypto.subtle.encrypt( + { name: 'AES-GCM', iv }, + key, + data + ) + + let rejected = false + try { + await crypto.subtle.decrypt({ name: 'AES-GCM', iv }, key, encrypted) + } catch (_) { + rejected = true + } + if (!rejected) throw new Error('expected decrypt to reject') + })() + ` + + const result = await runCage(script) + expect(result.type).toBe("ok") + }) + + it("rejects decrypt with wrong IV (AES-GCM)", async () => { + const script = ` + (async () => { + const key = await crypto.subtle.generateKey( + { name: 'AES-GCM', length: 256 }, + true, + ['encrypt', 'decrypt'] + ) + + const data = [116, 101, 115, 116] + const iv1 = [1,2,3,4,5,6,7,8,9,10,11,12] + const iv2 = [12,11,10,9,8,7,6,5,4,3,2,1] + + const encrypted = await crypto.subtle.encrypt( + { name: 'AES-GCM', iv: iv1 }, + key, + data + ) + + let rejected = false + try { + await crypto.subtle.decrypt({ name: 'AES-GCM', iv: iv2 }, key, encrypted) + } catch (_) { + rejected = true + } + if (!rejected) throw new Error('expected decrypt to reject with wrong IV') + })() + ` + + const result = await runCage(script) + expect(result.type).toBe("ok") + }) + + it("encrypts and decrypts with RSA-OAEP", async () => { + const script = ` + (async () => { + const keyPair = await crypto.subtle.generateKey( + { + name: 'RSA-OAEP', + modulusLength: 2048, + publicExponent: [1, 0, 1], + hash: 'SHA-256' + }, + true, + ['encrypt', 'decrypt'] + ) + + const data = [116, 101, 115, 116, 32, 100, 97, 116, 97] // "test data" + + const encrypted = await crypto.subtle.encrypt( + { name: 'RSA-OAEP' }, + keyPair.publicKey, + data + ) + + if (!Array.isArray(encrypted) || encrypted.length === 0) { + throw new Error('encrypted did not return a byte array') + } + + const decrypted = await crypto.subtle.decrypt( + { name: 'RSA-OAEP' }, + keyPair.privateKey, + encrypted + ) + + if (JSON.stringify(decrypted) !== JSON.stringify(data)) { + throw new Error('decrypted bytes mismatch') + } + })() + ` + + const result = await runCage(script) + expect(result.type).toBe("ok") + }) + + it("encrypts and decrypts with AES-CTR", async () => { + const script = ` + (async () => { + const key = await crypto.subtle.generateKey( + { name: 'AES-CTR', length: 256 }, + true, + ['encrypt', 'decrypt'] + ) + + const data = [104, 101, 108, 108, 111, 32, 119, 111, 114, 108, 100] // "hello world" + const counter = [1,2,3,4,5,6,7,8,9,10,11,12,13,14,15,16] + + const encrypted = await crypto.subtle.encrypt( + { name: 'AES-CTR', counter: counter, length: 64 }, + key, + data + ) + + const decrypted = await crypto.subtle.decrypt( + { name: 'AES-CTR', counter: counter, length: 64 }, + key, + encrypted + ) + + if (JSON.stringify(decrypted) !== JSON.stringify(data)) { + throw new Error('decrypted bytes mismatch') + } + })() + ` + + const result = await runCage(script) + expect(result.type).toBe("ok") + }) +}) diff --git a/packages/hoppscotch-js-sandbox/src/__tests__/cage-modules/crypto/key-operations.spec.ts b/packages/hoppscotch-js-sandbox/src/__tests__/cage-modules/crypto/key-operations.spec.ts new file mode 100644 index 00000000..dbeeb3d4 --- /dev/null +++ b/packages/hoppscotch-js-sandbox/src/__tests__/cage-modules/crypto/key-operations.spec.ts @@ -0,0 +1,829 @@ +import { describe, expect, test } from "vitest" +import { runTestWithEmptyEnv } from "~/utils/test-helpers" + +/** + * Tests for crypto.subtle key-based operations: + * - generateKey + * - importKey + * - exportKey + * - encrypt/decrypt + * - sign/verify + */ + +describe("crypto.subtle.generateKey()", () => { + test("should generate HMAC key for signing", () => { + const script = ` + const key = await crypto.subtle.generateKey( + { name: "HMAC", hash: "SHA-256" }, + true, + ["sign", "verify"] + ) + + hopp.test("HMAC key generated successfully", () => { + hopp.expect(key).toBeType("object") + hopp.expect(key.type).toBe("secret") + hopp.expect(key.extractable).toBe(true) + }) + ` + + return expect(runTestWithEmptyEnv(script)()).resolves.toEqualRight([ + expect.objectContaining({ + children: expect.arrayContaining([ + expect.objectContaining({ + descriptor: "HMAC key generated successfully", + expectResults: expect.arrayContaining([ + expect.objectContaining({ status: "pass" }), + ]), + }), + ]), + }), + ]) + }) + + test("should generate AES-GCM key for encryption", () => { + const script = ` + const key = await crypto.subtle.generateKey( + { name: "AES-GCM", length: 256 }, + true, + ["encrypt", "decrypt"] + ) + + hopp.test("AES-GCM key generated successfully", () => { + hopp.expect(key).toBeType("object") + hopp.expect(key.type).toBe("secret") + hopp.expect(key.extractable).toBe(true) + }) + ` + + return expect(runTestWithEmptyEnv(script)()).resolves.toEqualRight([ + expect.objectContaining({ + children: expect.arrayContaining([ + expect.objectContaining({ + descriptor: "AES-GCM key generated successfully", + expectResults: expect.arrayContaining([ + expect.objectContaining({ status: "pass" }), + ]), + }), + ]), + }), + ]) + }) +}) + +describe("crypto.subtle.importKey() and exportKey()", () => { + test("should import raw HMAC key", () => { + // Create a test key (32 bytes of data) + const rawKeyBytes = Array.from({ length: 32 }, (_, i) => i) + + const script = ` + const rawKeyData = ${JSON.stringify(rawKeyBytes)} + + const key = await crypto.subtle.importKey( + "raw", + rawKeyData, + { name: "HMAC", hash: "SHA-256" }, + true, + ["sign", "verify"] + ) + + hopp.test("Raw HMAC key imported successfully", () => { + hopp.expect(key).toBeType("object") + hopp.expect(key.type).toBe("secret") + hopp.expect(key.extractable).toBe(true) + }) + ` + + return expect(runTestWithEmptyEnv(script)()).resolves.toEqualRight([ + expect.objectContaining({ + children: expect.arrayContaining([ + expect.objectContaining({ + descriptor: "Raw HMAC key imported successfully", + expectResults: expect.arrayContaining([ + expect.objectContaining({ status: "pass" }), + ]), + }), + ]), + }), + ]) + }) + + test("should export key in raw format", () => { + const rawKeyBytes = Array.from({ length: 32 }, (_, i) => i) + + const script = ` + const rawKeyData = ${JSON.stringify(rawKeyBytes)} + + const key = await crypto.subtle.importKey( + "raw", + rawKeyData, + { name: "HMAC", hash: "SHA-256" }, + true, + ["sign", "verify"] + ) + + const exportedKey = await crypto.subtle.exportKey("raw", key) + const exportedArray = Array.from(exportedKey) + + hopp.test("Key exported successfully in raw format", () => { + hopp.expect(exportedArray.length).toBe(32) + hopp.expect(exportedArray[0]).toBe(0) + hopp.expect(exportedArray[31]).toBe(31) + }) + ` + + return expect(runTestWithEmptyEnv(script)()).resolves.toEqualRight([ + expect.objectContaining({ + children: expect.arrayContaining([ + expect.objectContaining({ + descriptor: "Key exported successfully in raw format", + expectResults: expect.arrayContaining([ + expect.objectContaining({ status: "pass" }), + ]), + }), + ]), + }), + ]) + }) + + test("should import and export key in JWK format", () => { + const script = ` + // Generate a key first + const generatedKey = await crypto.subtle.generateKey( + { name: "AES-GCM", length: 256 }, + true, + ["encrypt", "decrypt"] + ) + + // Export as JWK + const jwk = await crypto.subtle.exportKey("jwk", generatedKey) + + hopp.test("Key exported as JWK successfully", () => { + hopp.expect(jwk).toBeType("object") + hopp.expect(jwk.kty).toBe("oct") + hopp.expect(jwk.k).toBeType("string") + hopp.expect(jwk.alg).toBe("A256GCM") + }) + + // Re-import the JWK + const reimportedKey = await crypto.subtle.importKey( + "jwk", + jwk, + { name: "AES-GCM" }, + true, + ["encrypt", "decrypt"] + ) + + hopp.test("JWK reimported successfully", () => { + hopp.expect(reimportedKey).toBeType("object") + hopp.expect(reimportedKey.type).toBe("secret") + }) + ` + + return expect(runTestWithEmptyEnv(script)()).resolves.toEqualRight([ + expect.objectContaining({ + children: expect.arrayContaining([ + expect.objectContaining({ + descriptor: "Key exported as JWK successfully", + expectResults: expect.arrayContaining([ + expect.objectContaining({ status: "pass" }), + ]), + }), + expect.objectContaining({ + descriptor: "JWK reimported successfully", + expectResults: expect.arrayContaining([ + expect.objectContaining({ status: "pass" }), + ]), + }), + ]), + }), + ]) + }) +}) + +describe("crypto.subtle.encrypt() and decrypt()", () => { + test("should encrypt and decrypt with AES-GCM", () => { + const plainTextBytes = Array.from(new TextEncoder().encode("Hello, World!")) + const ivBytes = Array.from({ length: 12 }, (_, i) => i) // 12-byte IV for AES-GCM + + const script = ` + const plainText = ${JSON.stringify(plainTextBytes)} + const iv = ${JSON.stringify(ivBytes)} + + // Generate an AES-GCM key + const key = await crypto.subtle.generateKey( + { name: "AES-GCM", length: 256 }, + true, + ["encrypt", "decrypt"] + ) + + // Encrypt the plaintext + const ciphertext = await crypto.subtle.encrypt( + { name: "AES-GCM", iv: iv }, + key, + plainText + ) + + hopp.test("Encryption produces ciphertext", () => { + hopp.expect(ciphertext.length).toBeType("number") + hopp.expect(ciphertext.length > 0).toBe(true) + }) + + // Decrypt the ciphertext + const decrypted = await crypto.subtle.decrypt( + { name: "AES-GCM", iv: iv }, + key, + ciphertext + ) + + const decryptedText = new TextDecoder().decode(new Uint8Array(decrypted)) + + hopp.test("Decryption recovers original plaintext", () => { + hopp.expect(decryptedText).toBe("Hello, World!") + }) + ` + + return expect(runTestWithEmptyEnv(script)()).resolves.toEqualRight([ + expect.objectContaining({ + children: expect.arrayContaining([ + expect.objectContaining({ + descriptor: "Encryption produces ciphertext", + expectResults: expect.arrayContaining([ + expect.objectContaining({ status: "pass" }), + ]), + }), + expect.objectContaining({ + descriptor: "Decryption recovers original plaintext", + expectResults: expect.arrayContaining([ + expect.objectContaining({ status: "pass" }), + ]), + }), + ]), + }), + ]) + }) + + test("encrypted data differs from plaintext", () => { + const plainTextBytes = Array.from( + new TextEncoder().encode("Secret message") + ) + const ivBytes = Array.from({ length: 12 }, (_, i) => i + 100) + + const script = ` + const plainText = ${JSON.stringify(plainTextBytes)} + const iv = ${JSON.stringify(ivBytes)} + + const key = await crypto.subtle.generateKey( + { name: "AES-GCM", length: 256 }, + true, + ["encrypt", "decrypt"] + ) + + const ciphertext = await crypto.subtle.encrypt( + { name: "AES-GCM", iv: iv }, + key, + plainText + ) + + // Compare ciphertext to plaintext - they should be different + const ciphertextArray = Array.from(ciphertext) + const isDifferent = plainText.some((byte, i) => ciphertextArray[i] !== byte) + + hopp.test("Ciphertext differs from plaintext", () => { + hopp.expect(isDifferent).toBe(true) + }) + ` + + return expect(runTestWithEmptyEnv(script)()).resolves.toEqualRight([ + expect.objectContaining({ + children: expect.arrayContaining([ + expect.objectContaining({ + descriptor: "Ciphertext differs from plaintext", + expectResults: expect.arrayContaining([ + expect.objectContaining({ status: "pass" }), + ]), + }), + ]), + }), + ]) + }) +}) + +describe("crypto.subtle.sign() and verify()", () => { + test("should sign and verify with HMAC", () => { + const dataBytes = Array.from(new TextEncoder().encode("Data to sign")) + + const script = ` + const data = ${JSON.stringify(dataBytes)} + + // Generate HMAC key + const key = await crypto.subtle.generateKey( + { name: "HMAC", hash: "SHA-256" }, + true, + ["sign", "verify"] + ) + + // Sign the data + const signature = await crypto.subtle.sign( + "HMAC", + key, + data + ) + + hopp.test("Signature is produced", () => { + hopp.expect(signature.length).toBe(32) // SHA-256 produces 32-byte signatures + hopp.expect(signature.byteLength).toBe(32) + }) + + // Verify the signature + const isValid = await crypto.subtle.verify( + "HMAC", + key, + signature, + data + ) + + hopp.test("Signature verification succeeds", () => { + hopp.expect(isValid).toBe(true) + }) + ` + + return expect(runTestWithEmptyEnv(script)()).resolves.toEqualRight([ + expect.objectContaining({ + children: expect.arrayContaining([ + expect.objectContaining({ + descriptor: "Signature is produced", + expectResults: expect.arrayContaining([ + expect.objectContaining({ status: "pass" }), + ]), + }), + expect.objectContaining({ + descriptor: "Signature verification succeeds", + expectResults: expect.arrayContaining([ + expect.objectContaining({ status: "pass" }), + ]), + }), + ]), + }), + ]) + }) + + test("verification fails with tampered data", () => { + const dataBytes = Array.from(new TextEncoder().encode("Original data")) + const tamperedDataBytes = Array.from( + new TextEncoder().encode("Tampered data") + ) + + const script = ` + const originalData = ${JSON.stringify(dataBytes)} + const tamperedData = ${JSON.stringify(tamperedDataBytes)} + + const key = await crypto.subtle.generateKey( + { name: "HMAC", hash: "SHA-256" }, + true, + ["sign", "verify"] + ) + + // Sign the original data + const signature = await crypto.subtle.sign( + "HMAC", + key, + originalData + ) + + // Try to verify with tampered data + const isValid = await crypto.subtle.verify( + "HMAC", + key, + signature, + tamperedData + ) + + hopp.test("Verification fails with tampered data", () => { + hopp.expect(isValid).toBe(false) + }) + ` + + return expect(runTestWithEmptyEnv(script)()).resolves.toEqualRight([ + expect.objectContaining({ + children: expect.arrayContaining([ + expect.objectContaining({ + descriptor: "Verification fails with tampered data", + expectResults: expect.arrayContaining([ + expect.objectContaining({ status: "pass" }), + ]), + }), + ]), + }), + ]) + }) + + test("verification fails with wrong key", () => { + const dataBytes = Array.from(new TextEncoder().encode("Protected data")) + + const script = ` + const data = ${JSON.stringify(dataBytes)} + + // Generate two different keys + const key1 = await crypto.subtle.generateKey( + { name: "HMAC", hash: "SHA-256" }, + true, + ["sign", "verify"] + ) + + const key2 = await crypto.subtle.generateKey( + { name: "HMAC", hash: "SHA-256" }, + true, + ["sign", "verify"] + ) + + // Sign with key1 + const signature = await crypto.subtle.sign( + "HMAC", + key1, + data + ) + + // Try to verify with key2 + const isValid = await crypto.subtle.verify( + "HMAC", + key2, + signature, + data + ) + + hopp.test("Verification fails with wrong key", () => { + hopp.expect(isValid).toBe(false) + }) + ` + + return expect(runTestWithEmptyEnv(script)()).resolves.toEqualRight([ + expect.objectContaining({ + children: expect.arrayContaining([ + expect.objectContaining({ + descriptor: "Verification fails with wrong key", + expectResults: expect.arrayContaining([ + expect.objectContaining({ status: "pass" }), + ]), + }), + ]), + }), + ]) + }) +}) + +describe("crypto.getRandomValues() validation", () => { + test("should reject arrays exceeding 65536 bytes", () => { + const script = ` + let errorThrown = false + let errorMessage = "" + + try { + const largeArray = new Array(65537).fill(0) + crypto.getRandomValues(largeArray) + } catch (e) { + errorThrown = true + errorMessage = e.message + } + + hopp.test("Throws error for oversized array", () => { + hopp.expect(errorThrown).toBe(true) + hopp.expect(errorMessage).toInclude("65536") + }) + ` + + return expect(runTestWithEmptyEnv(script)()).resolves.toEqualRight([ + expect.objectContaining({ + children: expect.arrayContaining([ + expect.objectContaining({ + descriptor: "Throws error for oversized array", + expectResults: expect.arrayContaining([ + expect.objectContaining({ status: "pass" }), + ]), + }), + ]), + }), + ]) + }) + + test("should accept array at exactly 65536 bytes", () => { + const script = ` + const maxArray = new Array(65536).fill(0) + const result = crypto.getRandomValues(maxArray) + + hopp.test("Accepts 65536-byte array", () => { + hopp.expect(result.length).toBe(65536) + // Check that at least some values were filled + const hasNonZero = result.some(v => v !== 0) + hopp.expect(hasNonZero).toBe(true) + }) + ` + + return expect(runTestWithEmptyEnv(script)()).resolves.toEqualRight([ + expect.objectContaining({ + children: expect.arrayContaining([ + expect.objectContaining({ + descriptor: "Accepts 65536-byte array", + expectResults: expect.arrayContaining([ + expect.objectContaining({ status: "pass" }), + ]), + }), + ]), + }), + ]) + }) +}) + +describe("crypto.subtle.deriveBits() and deriveKey()", () => { + test("should derive bits using PBKDF2", () => { + const script = ` + // Import a password as key material + const passwordKey = await crypto.subtle.importKey( + "raw", + [112, 97, 115, 115, 119, 111, 114, 100], // "password" + { name: "PBKDF2" }, + false, + ["deriveBits"] + ) + + // Derive 256 bits + const derivedBits = await crypto.subtle.deriveBits( + { + name: "PBKDF2", + hash: "SHA-256", + salt: new Array(16).fill(1), // 16-byte salt + iterations: 1000 + }, + passwordKey, + 256 + ) + + hopp.test("PBKDF2 deriveBits produces correct length", () => { + hopp.expect(derivedBits.length).toBe(32) // 256 bits = 32 bytes + // Check that we got non-zero data + hopp.expect(derivedBits.some(b => b !== 0)).toBe(true) + }) + ` + + return expect(runTestWithEmptyEnv(script)()).resolves.toEqualRight([ + expect.objectContaining({ + children: expect.arrayContaining([ + expect.objectContaining({ + descriptor: "PBKDF2 deriveBits produces correct length", + expectResults: expect.arrayContaining([ + expect.objectContaining({ status: "pass" }), + ]), + }), + ]), + }), + ]) + }) + + test("should derive key using PBKDF2", () => { + const script = ` + // Import password as key material + const passwordKey = await crypto.subtle.importKey( + "raw", + [109, 121, 112, 97, 115, 115], // "mypass" + { name: "PBKDF2" }, + false, + ["deriveKey"] + ) + + // Derive an AES-GCM key + const derivedKey = await crypto.subtle.deriveKey( + { + name: "PBKDF2", + hash: "SHA-256", + salt: new Array(16).fill(2), + iterations: 1000 + }, + passwordKey, + { name: "AES-GCM", length: 256 }, + true, + ["encrypt", "decrypt"] + ) + + hopp.test("PBKDF2 deriveKey produces usable AES key", () => { + hopp.expect(derivedKey).toBeType("object") + hopp.expect(derivedKey.type).toBe("secret") + hopp.expect(derivedKey.extractable).toBe(true) + }) + + // Test that the derived key actually works + const testData = [116, 101, 115, 116] // "test" + const iv = new Array(12).fill(0) + + const encrypted = await crypto.subtle.encrypt( + { name: "AES-GCM", iv: iv }, + derivedKey, + testData + ) + + const decrypted = await crypto.subtle.decrypt( + { name: "AES-GCM", iv: iv }, + derivedKey, + encrypted + ) + + hopp.test("Derived key can encrypt and decrypt", () => { + hopp.expect(JSON.stringify(decrypted)).toBe(JSON.stringify(testData)) + }) + ` + + return expect(runTestWithEmptyEnv(script)()).resolves.toEqualRight([ + expect.objectContaining({ + children: expect.arrayContaining([ + expect.objectContaining({ + descriptor: "PBKDF2 deriveKey produces usable AES key", + expectResults: expect.arrayContaining([ + expect.objectContaining({ status: "pass" }), + ]), + }), + expect.objectContaining({ + descriptor: "Derived key can encrypt and decrypt", + expectResults: expect.arrayContaining([ + expect.objectContaining({ status: "pass" }), + ]), + }), + ]), + }), + ]) + }) +}) + +describe("crypto.subtle.wrapKey() and unwrapKey()", () => { + test("should wrap and unwrap AES key with AES-KW", () => { + const script = ` + // Generate keys + const aesKey = await crypto.subtle.generateKey( + { name: "AES-GCM", length: 256 }, + true, + ["encrypt", "decrypt"] + ) + + const wrappingKey = await crypto.subtle.generateKey( + { name: "AES-KW", length: 256 }, + true, + ["wrapKey", "unwrapKey"] + ) + + // Wrap the AES key + const wrappedKey = await crypto.subtle.wrapKey( + "raw", + aesKey, + wrappingKey, + "AES-KW" + ) + + hopp.test("Key wrapping produces wrapped data", () => { + hopp.expect(wrappedKey).toBeType("object") + hopp.expect(wrappedKey.length).toBe(40) // AES-256 key (32) + padding (8) + }) + + // Unwrap the key + const unwrappedKey = await crypto.subtle.unwrapKey( + "raw", + wrappedKey, + wrappingKey, + "AES-KW", + { name: "AES-GCM", length: 256 }, + true, + ["encrypt", "decrypt"] + ) + + hopp.test("Unwrapped key is valid", () => { + hopp.expect(unwrappedKey).toBeType("object") + hopp.expect(unwrappedKey.type).toBe("secret") + hopp.expect(unwrappedKey.extractable).toBe(true) + }) + + // Test that unwrapped key works + const testData = [117, 110, 119, 114, 97, 112] // "unwrap" + const iv = new Array(12).fill(3) + + const encrypted = await crypto.subtle.encrypt( + { name: "AES-GCM", iv: iv }, + unwrappedKey, + testData + ) + + const decrypted = await crypto.subtle.decrypt( + { name: "AES-GCM", iv: iv }, + unwrappedKey, + encrypted + ) + + hopp.test("Unwrapped key functions correctly", () => { + hopp.expect(JSON.stringify(decrypted)).toBe(JSON.stringify(testData)) + }) + ` + + return expect(runTestWithEmptyEnv(script)()).resolves.toEqualRight([ + expect.objectContaining({ + children: expect.arrayContaining([ + expect.objectContaining({ + descriptor: "Key wrapping produces wrapped data", + expectResults: expect.arrayContaining([ + expect.objectContaining({ status: "pass" }), + ]), + }), + expect.objectContaining({ + descriptor: "Unwrapped key is valid", + expectResults: expect.arrayContaining([ + expect.objectContaining({ status: "pass" }), + ]), + }), + expect.objectContaining({ + descriptor: "Unwrapped key functions correctly", + expectResults: expect.arrayContaining([ + expect.objectContaining({ status: "pass" }), + ]), + }), + ]), + }), + ]) + }) + + test("should wrap and unwrap key with JWK format", () => { + const script = ` + // Generate an AES key + const aesKey = await crypto.subtle.generateKey( + { name: "AES-GCM", length: 128 }, + true, + ["encrypt", "decrypt"] + ) + + // Export to JWK for comparison + const originalJwk = await crypto.subtle.exportKey("jwk", aesKey) + + // Generate wrapping key + const wrappingKey = await crypto.subtle.generateKey( + { name: "AES-KW", length: 128 }, + true, + ["wrapKey", "unwrapKey"] + ) + + // Wrap the key as JWK + const wrappedKey = await crypto.subtle.wrapKey( + "jwk", + aesKey, + wrappingKey, + "AES-KW" + ) + + hopp.test("JWK wrapping produces data", () => { + hopp.expect(wrappedKey).toBeType("object") + hopp.expect(wrappedKey.length > 0).toBe(true) + }) + + // Unwrap the JWK + const unwrappedKey = await crypto.subtle.unwrapKey( + "jwk", + wrappedKey, + wrappingKey, + "AES-KW", + { name: "AES-GCM" }, + true, + ["encrypt", "decrypt"] + ) + + hopp.test("Unwrapped JWK key is valid", () => { + hopp.expect(unwrappedKey).toBeType("object") + hopp.expect(unwrappedKey.type).toBe("secret") + }) + + // Export unwrapped key and compare + const unwrappedJwk = await crypto.subtle.exportKey("jwk", unwrappedKey) + + hopp.test("Unwrapped key matches original", () => { + hopp.expect(unwrappedJwk.kty).toBe(originalJwk.kty) + hopp.expect(unwrappedJwk.alg).toBe(originalJwk.alg) + }) + ` + + return expect(runTestWithEmptyEnv(script)()).resolves.toEqualRight([ + expect.objectContaining({ + children: expect.arrayContaining([ + expect.objectContaining({ + descriptor: "JWK wrapping produces data", + expectResults: expect.arrayContaining([ + expect.objectContaining({ status: "pass" }), + ]), + }), + expect.objectContaining({ + descriptor: "Unwrapped JWK key is valid", + expectResults: expect.arrayContaining([ + expect.objectContaining({ status: "pass" }), + ]), + }), + expect.objectContaining({ + descriptor: "Unwrapped key matches original", + expectResults: expect.arrayContaining([ + expect.objectContaining({ status: "pass" }), + ]), + }), + ]), + }), + ]) + }) +}) diff --git a/packages/hoppscotch-js-sandbox/src/__tests__/cage-modules/crypto/random-values.spec.ts b/packages/hoppscotch-js-sandbox/src/__tests__/cage-modules/crypto/random-values.spec.ts new file mode 100644 index 00000000..84028b48 --- /dev/null +++ b/packages/hoppscotch-js-sandbox/src/__tests__/cage-modules/crypto/random-values.spec.ts @@ -0,0 +1,243 @@ +import { describe, expect, test } from "vitest" +import { FaradayCage } from "faraday-cage" +import { customCryptoModule } from "~/cage-modules" +import { runTestWithEmptyEnv } from "~/utils/test-helpers" + +describe("crypto.getRandomValues()", () => { + test("should generate random values for array", () => { + const script = ` + const array = new Array(10).fill(0) + const result = crypto.getRandomValues(array) + + // Check that values were modified + const hasNonZero = result.some(v => v !== 0) + + hopp.test("Random values generated", () => { + hopp.expect(result.length).toBe(10) + hopp.expect(hasNonZero).toBe(true) + hopp.expect(result).toBe(array) // Should mutate in place + }) + ` + + return expect(runTestWithEmptyEnv(script)()).resolves.toEqualRight([ + expect.objectContaining({ + children: expect.arrayContaining([ + expect.objectContaining({ + descriptor: "Random values generated", + expectResults: expect.arrayContaining([ + expect.objectContaining({ status: "pass" }), + ]), + }), + ]), + }), + ]) + }) + + test("should generate different values on multiple calls", () => { + const script = ` + const array1 = new Array(32).fill(0) + const array2 = new Array(32).fill(0) + + crypto.getRandomValues(array1) + crypto.getRandomValues(array2) + + // Arrays should be different + const isDifferent = array1.some((v, i) => v !== array2[i]) + + hopp.test("Random values are different", () => { + hopp.expect(isDifferent).toBe(true) + }) + ` + + return expect(runTestWithEmptyEnv(script)()).resolves.toEqualRight([ + expect.objectContaining({ + children: expect.arrayContaining([ + expect.objectContaining({ + descriptor: "Random values are different", + expectResults: [expect.objectContaining({ status: "pass" })], + }), + ]), + }), + ]) + }) + + test("should handle different array sizes", () => { + const script = ` + const sizes = [1, 16, 256] + const results = [] + + for (const size of sizes) { + const array = new Array(size).fill(0) + crypto.getRandomValues(array) + results.push({ + size, + hasRandomValues: array.some(v => v !== 0), + length: array.length + }) + } + + hopp.test("Handles various array sizes", () => { + for (const result of results) { + hopp.expect(result.length).toBe(result.size) + hopp.expect(result.hasRandomValues).toBe(true) + } + }) + ` + + return expect(runTestWithEmptyEnv(script)()).resolves.toEqualRight([ + expect.objectContaining({ + children: expect.arrayContaining([ + expect.objectContaining({ + descriptor: "Handles various array sizes", + expectResults: expect.arrayContaining([ + expect.objectContaining({ status: "pass" }), + ]), + }), + ]), + }), + ]) + }) + + test("should return values in valid byte range (0-255)", () => { + const script = ` + const array = new Array(100).fill(0) + crypto.getRandomValues(array) + + const allInRange = array.every(v => v >= 0 && v <= 255) + const hasVariety = new Set(array).size > 1 + + hopp.test("Values are valid bytes", () => { + hopp.expect(allInRange).toBe(true) + hopp.expect(hasVariety).toBe(true) + }) + ` + + return expect(runTestWithEmptyEnv(script)()).resolves.toEqualRight([ + expect.objectContaining({ + children: expect.arrayContaining([ + expect.objectContaining({ + descriptor: "Values are valid bytes", + expectResults: expect.arrayContaining([ + expect.objectContaining({ status: "pass" }), + ]), + }), + ]), + }), + ]) + }) +}) + +describe("crypto.randomUUID()", () => { + test("should generate valid UUID v4 format", () => { + const script = ` + const uuid = crypto.randomUUID() + + // UUID v4 format: xxxxxxxx-xxxx-4xxx-yxxx-xxxxxxxxxxxx + const uuidPattern = /^[0-9a-f]{8}-[0-9a-f]{4}-4[0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/i + + hopp.test("UUID format is valid", () => { + hopp.expect(typeof uuid).toBe("string") + hopp.expect(uuid.length).toBe(36) + hopp.expect(uuidPattern.test(uuid)).toBe(true) + }) + ` + + return expect(runTestWithEmptyEnv(script)()).resolves.toEqualRight([ + expect.objectContaining({ + children: expect.arrayContaining([ + expect.objectContaining({ + descriptor: "UUID format is valid", + expectResults: expect.arrayContaining([ + expect.objectContaining({ status: "pass" }), + ]), + }), + ]), + }), + ]) + }) + + test("should generate unique UUIDs", () => { + const script = ` + const uuids = [] + for (let i = 0; i < 100; i++) { + uuids.push(crypto.randomUUID()) + } + + const uniqueUuids = new Set(uuids) + + hopp.test("UUIDs are unique", () => { + hopp.expect(uniqueUuids.size).toBe(100) + }) + ` + + return expect(runTestWithEmptyEnv(script)()).resolves.toEqualRight([ + expect.objectContaining({ + children: expect.arrayContaining([ + expect.objectContaining({ + descriptor: "UUIDs are unique", + expectResults: [expect.objectContaining({ status: "pass" })], + }), + ]), + }), + ]) + }) + + test("should generate UUIDs with correct version and variant", () => { + const script = ` + const uuid = crypto.randomUUID() + const parts = uuid.split('-') + + // Version should be 4 (random) + const version = parts[2][0] + + // Variant should be 8, 9, a, or b (RFC 4122) + const variant = parts[3][0].toLowerCase() + + hopp.test("UUID version and variant are correct", () => { + hopp.expect(version).toBe('4') + hopp.expect(['8', '9', 'a', 'b'].includes(variant)).toBe(true) + }) + ` + + return expect(runTestWithEmptyEnv(script)()).resolves.toEqualRight([ + expect.objectContaining({ + children: expect.arrayContaining([ + expect.objectContaining({ + descriptor: "UUID version and variant are correct", + expectResults: expect.arrayContaining([ + expect.objectContaining({ status: "pass" }), + ]), + }), + ]), + }), + ]) + }) + + test("should still work when cryptoImpl.randomUUID is missing (polyfill)", async () => { + const cage = await FaradayCage.create() + + const cryptoImplWithoutRandomUUID = { + getRandomValues: globalThis.crypto.getRandomValues.bind( + globalThis.crypto + ), + subtle: globalThis.crypto.subtle, + } as unknown as Crypto + + const script = ` + (async () => { + const uuid = crypto.randomUUID() + const uuidPattern = /^[0-9a-f]{8}-[0-9a-f]{4}-4[0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/i + if (typeof uuid !== 'string' || uuid.length !== 36) throw new Error('uuid shape invalid') + if (!uuidPattern.test(uuid)) throw new Error('uuid format invalid') + })() + ` + + const result = await cage.runCode(script, [ + customCryptoModule({ + cryptoImpl: cryptoImplWithoutRandomUUID, + }), + ]) + + expect(result.type).toBe("ok") + }) +}) diff --git a/packages/hoppscotch-js-sandbox/src/__tests__/cage-modules/crypto/sign-verify.spec.ts b/packages/hoppscotch-js-sandbox/src/__tests__/cage-modules/crypto/sign-verify.spec.ts new file mode 100644 index 00000000..594132d5 --- /dev/null +++ b/packages/hoppscotch-js-sandbox/src/__tests__/cage-modules/crypto/sign-verify.spec.ts @@ -0,0 +1,207 @@ +import { describe, it, expect } from "vitest" +import { FaradayCage } from "faraday-cage" +import { customCryptoModule } from "../../../cage-modules" + +const runCage = async (script: string) => { + const cage = await FaradayCage.create() + return cage.runCode(script, [ + customCryptoModule({ + cryptoImpl: globalThis.crypto, + }), + ]) +} + +describe("crypto.subtle.sign/verify", () => { + it("signs and verifies with HMAC", async () => { + const script = ` + (async () => { + const key = await crypto.subtle.generateKey( + { name: 'HMAC', hash: 'SHA-256' }, + true, + ['sign', 'verify'] + ) + + const data = [109, 101, 115, 115, 97, 103, 101] // "message" + + const signature = await crypto.subtle.sign('HMAC', key, data) + if (!Array.isArray(signature) || signature.length === 0) throw new Error('signature shape mismatch') + + const isValid = await crypto.subtle.verify('HMAC', key, signature, data) + if (isValid !== true) throw new Error('expected signature to verify') + })() + ` + + const result = await runCage(script) + expect(result.type).toBe("ok") + }) + + it("signs and verifies with ECDSA", async () => { + const script = ` + (async () => { + const keyPair = await crypto.subtle.generateKey( + { name: 'ECDSA', namedCurve: 'P-256' }, + true, + ['sign', 'verify'] + ) + + const data = [116, 101, 115, 116, 32, 109, 101, 115, 115, 97, 103, 101] // "test message" + + const signature = await crypto.subtle.sign( + { name: 'ECDSA', hash: 'SHA-256' }, + keyPair.privateKey, + data + ) + + const isValid = await crypto.subtle.verify( + { name: 'ECDSA', hash: 'SHA-256' }, + keyPair.publicKey, + signature, + data + ) + + if (isValid !== true) throw new Error('expected ECDSA signature to verify') + })() + ` + + const result = await runCage(script) + expect(result.type).toBe("ok") + }) + + it("signs and verifies with RSA-PSS", async () => { + const script = ` + (async () => { + const keyPair = await crypto.subtle.generateKey( + { name: 'RSA-PSS', modulusLength: 2048, publicExponent: [1, 0, 1], hash: 'SHA-256' }, + true, + ['sign', 'verify'] + ) + + const data = [114, 115, 97, 32, 116, 101, 115, 116] // "rsa test" + + const signature = await crypto.subtle.sign( + { name: 'RSA-PSS', saltLength: 32 }, + keyPair.privateKey, + data + ) + + const isValid = await crypto.subtle.verify( + { name: 'RSA-PSS', saltLength: 32 }, + keyPair.publicKey, + signature, + data + ) + + if (isValid !== true) throw new Error('expected RSA-PSS signature to verify') + })() + ` + + const result = await runCage(script) + expect(result.type).toBe("ok") + }) + + it("returns false for verification with different data", async () => { + const script = ` + (async () => { + const key = await crypto.subtle.generateKey( + { name: 'HMAC', hash: 'SHA-256' }, + true, + ['sign', 'verify'] + ) + + const data1 = [100, 97, 116, 97, 49] // "data1" + const data2 = [100, 97, 116, 97, 50] // "data2" + + const signature = await crypto.subtle.sign('HMAC', key, data1) + const isValid = await crypto.subtle.verify('HMAC', key, signature, data2) + if (isValid !== false) throw new Error('expected verification to be false with different data') + })() + ` + + const result = await runCage(script) + expect(result.type).toBe("ok") + }) + + it("returns false for verification with wrong key", async () => { + const script = ` + (async () => { + const key1 = await crypto.subtle.generateKey( + { name: 'HMAC', hash: 'SHA-256' }, + true, + ['sign', 'verify'] + ) + + const key2 = await crypto.subtle.generateKey( + { name: 'HMAC', hash: 'SHA-256' }, + true, + ['sign', 'verify'] + ) + + const data = [116, 101, 115, 116] + const signature = await crypto.subtle.sign('HMAC', key1, data) + const isValid = await crypto.subtle.verify('HMAC', key2, signature, data) + + if (isValid !== false) throw new Error('expected verification to be false with wrong key') + })() + ` + + const result = await runCage(script) + expect(result.type).toBe("ok") + }) + + it("handles empty data", async () => { + const script = ` + (async () => { + const key = await crypto.subtle.generateKey( + { name: 'HMAC', hash: 'SHA-256' }, + true, + ['sign', 'verify'] + ) + + const data = [] + const signature = await crypto.subtle.sign('HMAC', key, data) + const isValid = await crypto.subtle.verify('HMAC', key, signature, data) + if (isValid !== true) throw new Error('expected verification to succeed for empty data') + })() + ` + + const result = await runCage(script) + expect(result.type).toBe("ok") + }) + + it("signs and verifies with RSASSA-PKCS1-v1_5", async () => { + const script = ` + (async () => { + const keyPair = await crypto.subtle.generateKey( + { + name: 'RSASSA-PKCS1-v1_5', + modulusLength: 2048, + publicExponent: [1, 0, 1], + hash: 'SHA-256' + }, + true, + ['sign', 'verify'] + ) + + const data = [114, 115, 97, 32, 116, 101, 115, 116] // "rsa test" + + const signature = await crypto.subtle.sign( + { name: 'RSASSA-PKCS1-v1_5' }, + keyPair.privateKey, + data + ) + + const isValid = await crypto.subtle.verify( + { name: 'RSASSA-PKCS1-v1_5' }, + keyPair.publicKey, + signature, + data + ) + + if (isValid !== true) throw new Error('expected RSASSA-PKCS1-v1_5 signature to verify') + })() + ` + + const result = await runCage(script) + expect(result.type).toBe("ok") + }) +}) diff --git a/packages/hoppscotch-js-sandbox/src/__tests__/cage-modules/crypto/subtle-digest.spec.ts b/packages/hoppscotch-js-sandbox/src/__tests__/cage-modules/crypto/subtle-digest.spec.ts new file mode 100644 index 00000000..adaa94ef --- /dev/null +++ b/packages/hoppscotch-js-sandbox/src/__tests__/cage-modules/crypto/subtle-digest.spec.ts @@ -0,0 +1,207 @@ +import { describe, expect, test } from "vitest" +import { runTestWithEmptyEnv } from "~/utils/test-helpers" + +// Pre-compute test data outside the sandbox +const helloWorldBytes = Array.from(new TextEncoder().encode("Hello, World!")) +const testBytes = Array.from(new TextEncoder().encode("test")) +const emptyBytes: number[] = [] +const consistentDataBytes = Array.from( + new TextEncoder().encode("consistent data") +) +const data1Bytes = Array.from(new TextEncoder().encode("data1")) +const data2Bytes = Array.from(new TextEncoder().encode("data2")) + +describe("crypto.subtle.digest()", () => { + test("should compute SHA-256 hash of string data", () => { + const script = ` + const dataArray = ${JSON.stringify(helloWorldBytes)} + + const hashBuffer = await crypto.subtle.digest("SHA-256", dataArray) + + // Convert to hex string for verification + const hashArray = Array.from(new Uint8Array(hashBuffer)) + const hashHex = hashArray.map(b => b.toString(16).padStart(2, '0')).join('') + + // Expected SHA-256 hash of "Hello, World!" + const expectedHash = "dffd6021bb2bd5b0af676290809ec3a53191dd81c7f70a4b28688a362182986f" + + hopp.test("SHA-256 digest is correct", () => { + hopp.expect(hashHex).toBe(expectedHash) + hopp.expect(hashBuffer.byteLength).toBe(32) // SHA-256 produces 32 bytes + }) + ` + + return expect(runTestWithEmptyEnv(script)()).resolves.toEqualRight([ + expect.objectContaining({ + children: expect.arrayContaining([ + expect.objectContaining({ + descriptor: "SHA-256 digest is correct", + expectResults: expect.arrayContaining([ + expect.objectContaining({ status: "pass" }), + ]), + }), + ]), + }), + ]) + }) + + test("should compute SHA-1 hash", () => { + const script = ` + const dataArray = ${JSON.stringify(testBytes)} + + const hashBuffer = await crypto.subtle.digest("SHA-1", dataArray) + + // Expected SHA-1 hash of "test" + const hashArray = Array.from(new Uint8Array(hashBuffer)) + const hashHex = hashArray.map(b => b.toString(16).padStart(2, '0')).join('') + const expectedHash = "a94a8fe5ccb19ba61c4c0873d391e987982fbbd3" + + hopp.test("SHA-1 digest is correct", () => { + hopp.expect(hashHex).toBe(expectedHash) + hopp.expect(hashBuffer.byteLength).toBe(20) // SHA-1 produces 20 bytes + }) + ` + + return expect(runTestWithEmptyEnv(script)()).resolves.toEqualRight([ + expect.objectContaining({ + children: expect.arrayContaining([ + expect.objectContaining({ + descriptor: "SHA-1 digest is correct", + expectResults: expect.arrayContaining([ + expect.objectContaining({ status: "pass" }), + ]), + }), + ]), + }), + ]) + }) + + test("should compute SHA-384 hash", () => { + const script = ` + const dataArray = ${JSON.stringify(testBytes)} + + const hashBuffer = await crypto.subtle.digest("SHA-384", dataArray) + + hopp.test("SHA-384 produces correct byte length", () => { + hopp.expect(hashBuffer.byteLength).toBe(48) // SHA-384 produces 48 bytes + }) + ` + + return expect(runTestWithEmptyEnv(script)()).resolves.toEqualRight([ + expect.objectContaining({ + children: expect.arrayContaining([ + expect.objectContaining({ + descriptor: "SHA-384 produces correct byte length", + expectResults: [expect.objectContaining({ status: "pass" })], + }), + ]), + }), + ]) + }) + + test("should compute SHA-512 hash", () => { + const script = ` + const dataArray = ${JSON.stringify(testBytes)} + + const hashBuffer = await crypto.subtle.digest("SHA-512", dataArray) + + hopp.test("SHA-512 produces correct byte length", () => { + hopp.expect(hashBuffer.byteLength).toBe(64) // SHA-512 produces 64 bytes + }) + ` + + return expect(runTestWithEmptyEnv(script)()).resolves.toEqualRight([ + expect.objectContaining({ + children: expect.arrayContaining([ + expect.objectContaining({ + descriptor: "SHA-512 produces correct byte length", + expectResults: [expect.objectContaining({ status: "pass" })], + }), + ]), + }), + ]) + }) + + test("should handle empty string", () => { + const script = ` + const dataArray = ${JSON.stringify(emptyBytes)} + + const hashBuffer = await crypto.subtle.digest("SHA-256", dataArray) + + // SHA-256 of empty string + const hashArray = Array.from(new Uint8Array(hashBuffer)) + const hashHex = hashArray.map(b => b.toString(16).padStart(2, '0')).join('') + const expectedHash = "e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855" + + hopp.test("Empty string digest is correct", () => { + hopp.expect(hashHex).toBe(expectedHash) + }) + ` + + return expect(runTestWithEmptyEnv(script)()).resolves.toEqualRight([ + expect.objectContaining({ + children: expect.arrayContaining([ + expect.objectContaining({ + descriptor: "Empty string digest is correct", + expectResults: [expect.objectContaining({ status: "pass" })], + }), + ]), + }), + ]) + }) + + test("should produce consistent hashes for same input", () => { + const script = ` + const dataArray = ${JSON.stringify(consistentDataBytes)} + + const hash1 = await crypto.subtle.digest("SHA-256", dataArray) + const hash2 = await crypto.subtle.digest("SHA-256", dataArray) + + const hash1Hex = Array.from(new Uint8Array(hash1)).map(b => b.toString(16).padStart(2, '0')).join('') + const hash2Hex = Array.from(new Uint8Array(hash2)).map(b => b.toString(16).padStart(2, '0')).join('') + + hopp.test("Same input produces same hash", () => { + hopp.expect(hash1Hex).toBe(hash2Hex) + }) + ` + + return expect(runTestWithEmptyEnv(script)()).resolves.toEqualRight([ + expect.objectContaining({ + children: expect.arrayContaining([ + expect.objectContaining({ + descriptor: "Same input produces same hash", + expectResults: [expect.objectContaining({ status: "pass" })], + }), + ]), + }), + ]) + }) + + test("should produce different hashes for different inputs", () => { + const script = ` + const data1Array = ${JSON.stringify(data1Bytes)} + const data2Array = ${JSON.stringify(data2Bytes)} + + const hash1 = await crypto.subtle.digest("SHA-256", data1Array) + const hash2 = await crypto.subtle.digest("SHA-256", data2Array) + + const hash1Hex = Array.from(new Uint8Array(hash1)).map(b => b.toString(16).padStart(2, '0')).join('') + const hash2Hex = Array.from(new Uint8Array(hash2)).map(b => b.toString(16).padStart(2, '0')).join('') + + hopp.test("Different inputs produce different hashes", () => { + hopp.expect(hash1Hex).not.toBe(hash2Hex) + }) + ` + + return expect(runTestWithEmptyEnv(script)()).resolves.toEqualRight([ + expect.objectContaining({ + children: expect.arrayContaining([ + expect.objectContaining({ + descriptor: "Different inputs produce different hashes", + expectResults: [expect.objectContaining({ status: "pass" })], + }), + ]), + }), + ]) + }) +}) diff --git a/packages/hoppscotch-js-sandbox/src/__tests__/pm-namespace/serialization-edge-cases.spec.ts b/packages/hoppscotch-js-sandbox/src/__tests__/pm-namespace/serialization-edge-cases.spec.ts index c1c1ccc2..b1a6af85 100644 --- a/packages/hoppscotch-js-sandbox/src/__tests__/pm-namespace/serialization-edge-cases.spec.ts +++ b/packages/hoppscotch-js-sandbox/src/__tests__/pm-namespace/serialization-edge-cases.spec.ts @@ -947,7 +947,6 @@ describe("Serialization Edge Cases - Assertion Chaining", () => { pm.test("Circular array limitation", function() { const arr = [1, 2, 3] arr.push(arr) // Creates circular reference - pm.expect(arr).to.have.lengthOf(4) }) `)() diff --git a/packages/hoppscotch-js-sandbox/src/__tests__/pw-namespace/expect/toBeLevelxxx.spec.ts b/packages/hoppscotch-js-sandbox/src/__tests__/pw-namespace/expect/toBeLevelxxx.spec.ts index f9ef3c3a..bedbf963 100644 --- a/packages/hoppscotch-js-sandbox/src/__tests__/pw-namespace/expect/toBeLevelxxx.spec.ts +++ b/packages/hoppscotch-js-sandbox/src/__tests__/pw-namespace/expect/toBeLevelxxx.spec.ts @@ -1,9 +1,6 @@ import { describe, expect, test } from "vitest" import { runTest, fakeResponse } from "~/utils/test-helpers" -// Skipped: These tests are comprehensive but cause timeout issues in CI/CD environments -// due to the large number of iterations (100+ per test). The toBeLevelxxx matchers are -// adequately covered by other test suites and E2E tests. Re-enable if timeout issues are resolved. describe("toBeLevelxxx", { timeout: 100000 }, () => { describe("toBeLevel2xx", () => { test("assertion passes for 200 series with no negation", async () => { diff --git a/packages/hoppscotch-js-sandbox/src/cage-modules/crypto.ts b/packages/hoppscotch-js-sandbox/src/cage-modules/crypto.ts new file mode 100644 index 00000000..86bacd99 --- /dev/null +++ b/packages/hoppscotch-js-sandbox/src/cage-modules/crypto.ts @@ -0,0 +1,1031 @@ +import { + defineCageModule, + defineSandboxFunctionRaw, +} from "faraday-cage/modules" +import { + CryptoKeyRegistry, + marshalValue, + MAX_GET_RANDOM_VALUES_SIZE, + uint8ArrayToVmArray, + vmArrayToUint8Array, +} from "./utils/vm-marshal" + +export type CustomCryptoModuleConfig = { + cryptoImpl?: Crypto +} + +// Normalize algorithm objects by converting certain array props to Uint8Array. +const normalizeAlgorithm = (algorithm: any): any => { + if (typeof algorithm === "string") { + return algorithm + } + if (typeof algorithm !== "object" || algorithm === null) { + return algorithm + } + + const normalized = { ...algorithm } + + // Convert known array properties to Uint8Array + const arrayProps = [ + "iv", + "counter", + "salt", + "additionalData", + "label", + "info", + "publicExponent", + ] + for (const prop of arrayProps) { + if (normalized[prop] && Array.isArray(normalized[prop])) { + normalized[prop] = new Uint8Array(normalized[prop]) + } + } + + return normalized +} + +export const customCryptoModule = (config: CustomCryptoModuleConfig = {}) => + defineCageModule((ctx) => { + const cryptoImpl = config.cryptoImpl ?? globalThis.crypto + const subtleImpl = cryptoImpl?.subtle + const getRandomValuesImpl = cryptoImpl?.getRandomValues + + const vmCryptoError = (message: string) => + ctx.scope.manage( + ctx.vm.newError({ + name: "CryptoError", + message, + }) + ) + + const rejectVmPromise = (message: string) => + ctx.scope.manage( + ctx.vm.newPromise((_resolve, reject) => { + reject(vmCryptoError(message)) + }) + ).handle + + const randomUUID = + typeof cryptoImpl?.randomUUID === "function" + ? cryptoImpl.randomUUID.bind(cryptoImpl) + : () => { + if (typeof getRandomValuesImpl !== "function") { + throw new Error( + "crypto.randomUUID is not available (requires WebCrypto)" + ) + } + + const bytes = new Uint8Array(16) + getRandomValuesImpl.call(cryptoImpl, bytes) + bytes[6] = (bytes[6] & 0x0f) | 0x40 + bytes[8] = (bytes[8] & 0x3f) | 0x80 + const hex = Array.from(bytes, (b) => + b.toString(16).padStart(2, "0") + ).join("") + return `${hex.slice(0, 8)}-${hex.slice(8, 12)}-${hex.slice(12, 16)}-${hex.slice(16, 20)}-${hex.slice(20)}` + } + + // Keys cannot be serialized across the VM boundary; store host keys in a registry. + let keyRegistry: CryptoKeyRegistry | null = null + const getKeyRegistry = () => (keyRegistry ??= new CryptoKeyRegistry()) + + // Track pending async operations so the runtime isn't disposed early. + // Without this, WebCrypto promises can resolve/reject after the VM is torn down, + // causing QuickJSUseAfterFree errors. + const pendingOperations: Promise[] = [] + let keepAliveRegistered = false + let resolveKeepAlive: (() => void) | null = null + + const registerKeepAlive = () => { + if (keepAliveRegistered) return + keepAliveRegistered = true + + const keepAlivePromise = new Promise((resolve) => { + resolveKeepAlive = resolve + }) + + ctx.keepAlivePromises.push(keepAlivePromise) + + const asyncHook = async () => { + // Poll until all operations are complete with a grace period + let emptyRounds = 0 + const maxEmptyRounds = 5 + + while (emptyRounds < maxEmptyRounds) { + if (pendingOperations.length > 0) { + emptyRounds = 0 + await Promise.allSettled(pendingOperations) + await new Promise((r) => setTimeout(r, 10)) + } else { + emptyRounds++ + await new Promise((r) => setTimeout(r, 10)) + } + } + + // Ensure registry timers don't outlive the cage run. + keyRegistry?.dispose() + resolveKeepAlive?.() + } + + // NOTE: faraday-cage's afterScriptExecutionHooks types are (() => void) but runtime supports async + ctx.afterScriptExecutionHooks.push(asyncHook as () => void) + } + + const trackAsyncOperation = (promise: Promise): Promise => { + registerKeepAlive() + pendingOperations.push(promise) + return promise.finally(() => { + const index = pendingOperations.indexOf(promise) + if (index > -1) { + pendingOperations.splice(index, 1) + } + }) + } + + // ======================================================================== + // crypto.getRandomValues() implementation + // ======================================================================== + const getRandomValuesFn = defineSandboxFunctionRaw( + ctx, + "getRandomValues", + (...args) => { + if (typeof getRandomValuesImpl !== "function") { + throw new Error( + "crypto.getRandomValues is not available (requires WebCrypto)" + ) + } + + const arrayHandle = args[0] + if (!arrayHandle) { + throw new Error("getRandomValues requires an array-like argument") + } + + // Get the length of the array + const lengthHandle = ctx.vm.getProp(arrayHandle, "length") + const length = ctx.vm.getNumber(lengthHandle) + lengthHandle.dispose() + + // Validate size per Web Crypto spec (max 65536 bytes) + // Prefer byteLength if available (TypedArray-like), otherwise fall back to length. + const byteLengthHandle = ctx.vm.getProp(arrayHandle, "byteLength") + const byteLength = + ctx.vm.typeof(byteLengthHandle) === "number" + ? ctx.vm.getNumber(byteLengthHandle) + : length + byteLengthHandle.dispose() + + if (byteLength > MAX_GET_RANDOM_VALUES_SIZE) { + throw new Error( + `Failed to execute 'getRandomValues': The ArrayBuffer/ArrayBufferView's byte length (${byteLength}) exceeds the maximum allowed (${MAX_GET_RANDOM_VALUES_SIZE} bytes).` + ) + } + + if (length < 0) { + throw new Error( + "Failed to execute 'getRandomValues': Invalid array length" + ) + } + + // Create a native Uint8Array and fill it with random values + const nativeArray = new Uint8Array(length) + getRandomValuesImpl.call(cryptoImpl, nativeArray) + + // Update the VM array with the random values + for (let i = 0; i < length; i++) { + const valueHandle = ctx.scope.manage(ctx.vm.newNumber(nativeArray[i])) + ctx.vm.setProp(arrayHandle, i, valueHandle) + } + + // Return the same array (mutated in place) + return arrayHandle + } + ) + + // ======================================================================== + // crypto.randomUUID() implementation + // ======================================================================== + const randomUUIDFn = defineSandboxFunctionRaw(ctx, "randomUUID", () => { + try { + const uuid = randomUUID() + return ctx.scope.manage(ctx.vm.newString(uuid)) + } catch (e) { + throw e instanceof Error ? e : new Error(String(e)) + } + }) + + // ======================================================================== + // crypto.subtle namespace implementation + // ======================================================================== + const subtleObj = ctx.scope.manage(ctx.vm.newObject()) + + /** + * Helper to retrieve a CryptoKey from a VM key handle + * Keys are stored in the registry and referenced by their __keyId property + */ + const getKeyFromHandle = (keyHandle: any): CryptoKey => { + if (!keyRegistry) { + throw new Error("Invalid key: key registry not initialized") + } + + const keyIdHandle = ctx.vm.getProp(keyHandle, "__keyId") + const keyId = ctx.vm.getString(keyIdHandle) + keyIdHandle.dispose() + + const key = keyRegistry.get(keyId) + if (!key) { + throw new Error("Invalid key: key not found in registry") + } + + // If it's a CryptoKeyPair, we need to determine which key to use + // This shouldn't happen in normal use since we store individual keys + if ("privateKey" in key || "publicKey" in key) { + throw new Error("Invalid key: expected CryptoKey, got CryptoKeyPair") + } + + return key as CryptoKey + } + + /** + * Helper to create a VM key handle from a CryptoKey + */ + const createKeyHandle = (key: CryptoKey): any => { + const keyId = getKeyRegistry().store(key) + const keyObj = ctx.scope.manage(ctx.vm.newObject()) + + // Store the key ID for later retrieval + ctx.vm.setProp( + keyObj, + "__keyId", + ctx.scope.manage(ctx.vm.newString(keyId)) + ) + + // Expose basic key properties + ctx.vm.setProp( + keyObj, + "type", + ctx.scope.manage(ctx.vm.newString(key.type)) + ) + ctx.vm.setProp( + keyObj, + "extractable", + key.extractable ? ctx.vm.true : ctx.vm.false + ) + ctx.vm.setProp(keyObj, "algorithm", marshalValue(ctx, key.algorithm)) + + // Marshal usages array + const usagesArray = ctx.scope.manage(ctx.vm.newArray()) + key.usages.forEach((usage, i) => { + ctx.vm.setProp( + usagesArray, + i, + ctx.scope.manage(ctx.vm.newString(usage)) + ) + }) + ctx.vm.setProp(keyObj, "usages", usagesArray) + + return keyObj + } + + // crypto.subtle.digest() implementation + const digestFn = defineSandboxFunctionRaw(ctx, "digest", (...args) => { + if (!subtleImpl) { + return rejectVmPromise( + "crypto.subtle.digest is not available (requires WebCrypto)" + ) + } + + const algorithm = ctx.vm.dump(args[0]) as string | AlgorithmIdentifier + const dataHandle = args[1] + + if (!dataHandle) { + throw new Error("digest requires data argument") + } + + // Convert VM data to Uint8Array + const data = vmArrayToUint8Array(ctx, dataHandle) + + // Create a promise that resolves with the digest + const promiseHandle = ctx.scope.manage( + ctx.vm.newPromise((resolve, reject) => { + trackAsyncOperation( + subtleImpl.digest(algorithm, data as BufferSource) + ) + .then((hashBuffer) => { + // Convert ArrayBuffer to VM array + const hashArray = new Uint8Array(hashBuffer) + resolve(uint8ArrayToVmArray(ctx, hashArray)) + }) + .catch((error) => { + reject( + ctx.scope.manage( + ctx.vm.newError({ + name: "CryptoError", + message: + error instanceof Error + ? error.message + : "Digest operation failed", + }) + ) + ) + }) + }) + ) + + return promiseHandle.handle + }) + + // crypto.subtle.encrypt() implementation + const encryptFn = defineSandboxFunctionRaw(ctx, "encrypt", (...args) => { + if (!subtleImpl) { + return rejectVmPromise( + "crypto.subtle.encrypt is not available (requires WebCrypto)" + ) + } + + const algorithmRaw = ctx.vm.dump(args[0]) + const algorithm = normalizeAlgorithm(algorithmRaw) + const keyHandle = args[1] + const dataHandle = args[2] + + if (!keyHandle || !dataHandle) { + throw new Error("encrypt requires algorithm, key, and data arguments") + } + + // Get the key from registry + const key = getKeyFromHandle(keyHandle) + const data = vmArrayToUint8Array(ctx, dataHandle) + + const promiseHandle = ctx.scope.manage( + ctx.vm.newPromise((resolve, reject) => { + trackAsyncOperation( + subtleImpl.encrypt( + algorithm as AlgorithmIdentifier, + key, + data as BufferSource + ) + ) + .then((encryptedBuffer) => { + const encryptedArray = new Uint8Array(encryptedBuffer) + resolve(uint8ArrayToVmArray(ctx, encryptedArray)) + }) + .catch((error) => { + reject( + ctx.scope.manage( + ctx.vm.newError({ + name: "CryptoError", + message: + error instanceof Error + ? error.message + : "Encryption failed", + }) + ) + ) + }) + }) + ) + + return promiseHandle.handle + }) + + // crypto.subtle.decrypt() implementation + const decryptFn = defineSandboxFunctionRaw(ctx, "decrypt", (...args) => { + if (!subtleImpl) { + return rejectVmPromise( + "crypto.subtle.decrypt is not available (requires WebCrypto)" + ) + } + + const algorithmRaw = ctx.vm.dump(args[0]) + const algorithm = normalizeAlgorithm(algorithmRaw) + const keyHandle = args[1] + const dataHandle = args[2] + + if (!keyHandle || !dataHandle) { + throw new Error("decrypt requires algorithm, key, and data arguments") + } + + const key = getKeyFromHandle(keyHandle) + const data = vmArrayToUint8Array(ctx, dataHandle) + + const promiseHandle = ctx.scope.manage( + ctx.vm.newPromise((resolve, reject) => { + trackAsyncOperation( + subtleImpl.decrypt( + algorithm as AlgorithmIdentifier, + key, + data as BufferSource + ) + ) + .then((decryptedBuffer) => { + const decryptedArray = new Uint8Array(decryptedBuffer) + resolve(uint8ArrayToVmArray(ctx, decryptedArray)) + }) + .catch((error) => { + reject( + ctx.scope.manage( + ctx.vm.newError({ + name: "CryptoError", + message: + error instanceof Error + ? error.message + : "Decryption failed", + }) + ) + ) + }) + }) + ) + + return promiseHandle.handle + }) + + // crypto.subtle.sign() implementation + const signFn = defineSandboxFunctionRaw(ctx, "sign", (...args) => { + if (!subtleImpl) { + return rejectVmPromise( + "crypto.subtle.sign is not available (requires WebCrypto)" + ) + } + + const algorithmRaw = ctx.vm.dump(args[0]) + const algorithm = normalizeAlgorithm(algorithmRaw) + const keyHandle = args[1] + const dataHandle = args[2] + + if (!keyHandle || !dataHandle) { + throw new Error("sign requires algorithm, key, and data arguments") + } + + const key = getKeyFromHandle(keyHandle) + const data = vmArrayToUint8Array(ctx, dataHandle) + + const promiseHandle = ctx.scope.manage( + ctx.vm.newPromise((resolve, reject) => { + trackAsyncOperation( + subtleImpl.sign( + algorithm as AlgorithmIdentifier, + key, + data as BufferSource + ) + ) + .then((signatureBuffer) => { + const signatureArray = new Uint8Array(signatureBuffer) + resolve(uint8ArrayToVmArray(ctx, signatureArray)) + }) + .catch((error) => { + reject( + ctx.scope.manage( + ctx.vm.newError({ + name: "CryptoError", + message: + error instanceof Error ? error.message : "Sign failed", + }) + ) + ) + }) + }) + ) + + return promiseHandle.handle + }) + + // crypto.subtle.verify() implementation + const verifyFn = defineSandboxFunctionRaw(ctx, "verify", (...args) => { + if (!subtleImpl) { + return rejectVmPromise( + "crypto.subtle.verify is not available (requires WebCrypto)" + ) + } + + const algorithmRaw = ctx.vm.dump(args[0]) + const algorithm = normalizeAlgorithm(algorithmRaw) + const keyHandle = args[1] + const signatureHandle = args[2] + const dataHandle = args[3] + + if (!keyHandle || !signatureHandle || !dataHandle) { + throw new Error( + "verify requires algorithm, key, signature, and data arguments" + ) + } + + const key = getKeyFromHandle(keyHandle) + const signature = vmArrayToUint8Array(ctx, signatureHandle) + const data = vmArrayToUint8Array(ctx, dataHandle) + + const promiseHandle = ctx.scope.manage( + ctx.vm.newPromise((resolve, reject) => { + trackAsyncOperation( + subtleImpl.verify( + algorithm as AlgorithmIdentifier, + key, + signature as BufferSource, + data as BufferSource + ) + ) + .then((verified) => { + resolve(verified ? ctx.vm.true : ctx.vm.false) + }) + .catch((error) => { + reject( + ctx.scope.manage( + ctx.vm.newError({ + name: "CryptoError", + message: + error instanceof Error ? error.message : "Verify failed", + }) + ) + ) + }) + }) + ) + + return promiseHandle.handle + }) + + // crypto.subtle.generateKey() implementation + const generateKeyFn = defineSandboxFunctionRaw( + ctx, + "generateKey", + (...args) => { + if (!subtleImpl) { + return rejectVmPromise( + "crypto.subtle.generateKey is not available (requires WebCrypto)" + ) + } + + const algorithmRaw = ctx.vm.dump(args[0]) + const algorithm = normalizeAlgorithm(algorithmRaw) + const extractable = ctx.vm.dump(args[1]) as boolean + const keyUsages = ctx.vm.dump(args[2]) as KeyUsage[] + + if (!algorithm || extractable === undefined || !keyUsages) { + throw new Error( + "generateKey requires algorithm, extractable, and keyUsages arguments" + ) + } + + const promiseHandle = ctx.scope.manage( + ctx.vm.newPromise((resolve, reject) => { + trackAsyncOperation( + subtleImpl.generateKey( + algorithm as AlgorithmIdentifier, + extractable, + keyUsages + ) + ) + .then((key) => { + // Handle both CryptoKey and CryptoKeyPair + if ("privateKey" in key && "publicKey" in key) { + // It's a key pair - create handles for both + const keyPairObj = ctx.scope.manage(ctx.vm.newObject()) + ctx.vm.setProp( + keyPairObj, + "privateKey", + createKeyHandle(key.privateKey) + ) + ctx.vm.setProp( + keyPairObj, + "publicKey", + createKeyHandle(key.publicKey) + ) + resolve(keyPairObj) + } else { + // It's a single key + resolve(createKeyHandle(key as CryptoKey)) + } + }) + .catch((error) => { + reject( + ctx.scope.manage( + ctx.vm.newError({ + name: "CryptoError", + message: + error instanceof Error + ? error.message + : "Key generation failed", + }) + ) + ) + }) + }) + ) + + return promiseHandle.handle + } + ) + + // crypto.subtle.importKey() implementation + const importKeyFn = defineSandboxFunctionRaw( + ctx, + "importKey", + (...args) => { + if (!subtleImpl) { + return rejectVmPromise( + "crypto.subtle.importKey is not available (requires WebCrypto)" + ) + } + + const format = ctx.vm.dump(args[0]) as KeyFormat + const keyDataHandle = args[1] + const algorithmRaw = ctx.vm.dump(args[2]) + const algorithm = normalizeAlgorithm(algorithmRaw) + const extractable = ctx.vm.dump(args[3]) as boolean + const keyUsages = ctx.vm.dump(args[4]) as KeyUsage[] + + if ( + !format || + !keyDataHandle || + !algorithm || + extractable === undefined || + !keyUsages + ) { + throw new Error( + "importKey requires format, keyData, algorithm, extractable, and keyUsages arguments" + ) + } + + const promiseHandle = ctx.scope.manage( + ctx.vm.newPromise((resolve, reject) => { + let importPromise: Promise + + // Handle format-specific overloads + if (format === "jwk") { + const jwkData = ctx.vm.dump(keyDataHandle) as JsonWebKey + importPromise = trackAsyncOperation( + subtleImpl.importKey( + "jwk", + jwkData, + algorithm as AlgorithmIdentifier, + extractable, + keyUsages + ) + ) + } else { + const bufferData = vmArrayToUint8Array(ctx, keyDataHandle) + importPromise = trackAsyncOperation( + subtleImpl.importKey( + format as "pkcs8" | "raw" | "spki", + bufferData as BufferSource, + algorithm as AlgorithmIdentifier, + extractable, + keyUsages + ) + ) + } + + importPromise + .then((key) => { + resolve(createKeyHandle(key)) + }) + .catch((error) => { + reject( + ctx.scope.manage( + ctx.vm.newError({ + name: "CryptoError", + message: + error instanceof Error + ? error.message + : "Key import failed", + }) + ) + ) + }) + }) + ) + + return promiseHandle.handle + } + ) + + // crypto.subtle.exportKey() implementation + const exportKeyFn = defineSandboxFunctionRaw( + ctx, + "exportKey", + (...args) => { + if (!subtleImpl) { + return rejectVmPromise( + "crypto.subtle.exportKey is not available (requires WebCrypto)" + ) + } + + const format = ctx.vm.dump(args[0]) as KeyFormat + const keyHandle = args[1] + + if (!format || !keyHandle) { + throw new Error("exportKey requires format and key arguments") + } + + // Get the key from registry + const key = getKeyFromHandle(keyHandle) + + const promiseHandle = ctx.scope.manage( + ctx.vm.newPromise((resolve, reject) => { + trackAsyncOperation(subtleImpl.exportKey(format, key)) + .then((exportedKey) => { + if (format === "jwk") { + resolve(marshalValue(ctx, exportedKey)) + } else { + const keyArray = new Uint8Array(exportedKey as ArrayBuffer) + resolve(uint8ArrayToVmArray(ctx, keyArray)) + } + }) + .catch((error) => { + reject( + ctx.scope.manage( + ctx.vm.newError({ + name: "CryptoError", + message: + error instanceof Error + ? error.message + : "Key export failed", + }) + ) + ) + }) + }) + ) + + return promiseHandle.handle + } + ) + + // crypto.subtle.deriveBits() implementation + const deriveBitsFn = defineSandboxFunctionRaw( + ctx, + "deriveBits", + (...args) => { + if (!subtleImpl) { + return rejectVmPromise( + "crypto.subtle.deriveBits is not available (requires WebCrypto)" + ) + } + + const algorithmRaw = ctx.vm.dump(args[0]) + const algorithm = normalizeAlgorithm(algorithmRaw) + const keyHandle = args[1] + const length = ctx.vm.dump(args[2]) as number + + if (!algorithm || !keyHandle || length === undefined) { + throw new Error( + "deriveBits requires algorithm, key, and length arguments" + ) + } + + const key = getKeyFromHandle(keyHandle) + + const promiseHandle = ctx.scope.manage( + ctx.vm.newPromise((resolve, reject) => { + trackAsyncOperation( + subtleImpl.deriveBits( + algorithm as AlgorithmIdentifier, + key, + length + ) + ) + .then((bits) => { + const bitsArray = new Uint8Array(bits) + resolve(uint8ArrayToVmArray(ctx, bitsArray)) + }) + .catch((error) => { + reject( + ctx.scope.manage( + ctx.vm.newError({ + name: "CryptoError", + message: + error instanceof Error + ? error.message + : "deriveBits failed", + }) + ) + ) + }) + }) + ) + + return promiseHandle.handle + } + ) + + // crypto.subtle.deriveKey() implementation + const deriveKeyFn = defineSandboxFunctionRaw( + ctx, + "deriveKey", + (...args) => { + if (!subtleImpl) { + return rejectVmPromise( + "crypto.subtle.deriveKey is not available (requires WebCrypto)" + ) + } + + const algorithmRaw = ctx.vm.dump(args[0]) + const algorithm = normalizeAlgorithm(algorithmRaw) + const baseKeyHandle = args[1] + const derivedKeyTypeRaw = ctx.vm.dump(args[2]) + const derivedKeyType = normalizeAlgorithm(derivedKeyTypeRaw) + const extractable = ctx.vm.dump(args[3]) as boolean + const keyUsages = ctx.vm.dump(args[4]) as KeyUsage[] + + if ( + !algorithm || + !baseKeyHandle || + !derivedKeyType || + extractable === undefined || + !keyUsages + ) { + throw new Error( + "deriveKey requires algorithm, baseKey, derivedKeyType, extractable, and keyUsages arguments" + ) + } + + const baseKey = getKeyFromHandle(baseKeyHandle) + + const promiseHandle = ctx.scope.manage( + ctx.vm.newPromise((resolve, reject) => { + trackAsyncOperation( + subtleImpl.deriveKey( + algorithm as AlgorithmIdentifier, + baseKey, + derivedKeyType as AlgorithmIdentifier, + extractable, + keyUsages + ) + ) + .then((derivedKey) => { + resolve(createKeyHandle(derivedKey)) + }) + .catch((error) => { + reject( + ctx.scope.manage( + ctx.vm.newError({ + name: "CryptoError", + message: + error instanceof Error + ? error.message + : "deriveKey failed", + }) + ) + ) + }) + }) + ) + + return promiseHandle.handle + } + ) + + // crypto.subtle.wrapKey() implementation + const wrapKeyFn = defineSandboxFunctionRaw(ctx, "wrapKey", (...args) => { + if (!subtleImpl) { + return rejectVmPromise( + "crypto.subtle.wrapKey is not available (requires WebCrypto)" + ) + } + + const format = ctx.vm.dump(args[0]) as KeyFormat + const keyHandle = args[1] + const wrappingKeyHandle = args[2] + const wrapAlgorithmRaw = ctx.vm.dump(args[3]) + const wrapAlgorithm = normalizeAlgorithm(wrapAlgorithmRaw) + + if (!format || !keyHandle || !wrappingKeyHandle || !wrapAlgorithm) { + throw new Error( + "wrapKey requires format, key, wrappingKey, and wrapAlgorithm arguments" + ) + } + + const key = getKeyFromHandle(keyHandle) + const wrappingKey = getKeyFromHandle(wrappingKeyHandle) + + const promiseHandle = ctx.scope.manage( + ctx.vm.newPromise((resolve, reject) => { + trackAsyncOperation( + subtleImpl.wrapKey( + format, + key, + wrappingKey, + wrapAlgorithm as AlgorithmIdentifier + ) + ) + .then((wrappedKey) => { + const wrappedArray = new Uint8Array(wrappedKey) + resolve(uint8ArrayToVmArray(ctx, wrappedArray)) + }) + .catch((error) => { + reject( + ctx.scope.manage( + ctx.vm.newError({ + name: "CryptoError", + message: + error instanceof Error ? error.message : "wrapKey failed", + }) + ) + ) + }) + }) + ) + + return promiseHandle.handle + }) + + // crypto.subtle.unwrapKey() implementation + const unwrapKeyFn = defineSandboxFunctionRaw( + ctx, + "unwrapKey", + (...args) => { + if (!subtleImpl) { + return rejectVmPromise( + "crypto.subtle.unwrapKey is not available (requires WebCrypto)" + ) + } + + const format = ctx.vm.dump(args[0]) as KeyFormat + const wrappedKeyHandle = args[1] + const unwrappingKeyHandle = args[2] + const unwrapAlgorithmRaw = ctx.vm.dump(args[3]) + const unwrapAlgorithm = normalizeAlgorithm(unwrapAlgorithmRaw) + const unwrappedKeyAlgorithmRaw = ctx.vm.dump(args[4]) + const unwrappedKeyAlgorithm = normalizeAlgorithm( + unwrappedKeyAlgorithmRaw + ) + const extractable = ctx.vm.dump(args[5]) as boolean + const keyUsages = ctx.vm.dump(args[6]) as KeyUsage[] + + if ( + !format || + !wrappedKeyHandle || + !unwrappingKeyHandle || + !unwrapAlgorithm || + !unwrappedKeyAlgorithm || + extractable === undefined || + !keyUsages + ) { + throw new Error( + "unwrapKey requires all arguments: format, wrappedKey, unwrappingKey, unwrapAlgorithm, unwrappedKeyAlgorithm, extractable, keyUsages" + ) + } + + const wrappedKey = vmArrayToUint8Array(ctx, wrappedKeyHandle) + const unwrappingKey = getKeyFromHandle(unwrappingKeyHandle) + + const promiseHandle = ctx.scope.manage( + ctx.vm.newPromise((resolve, reject) => { + trackAsyncOperation( + subtleImpl.unwrapKey( + format, + wrappedKey as BufferSource, + unwrappingKey, + unwrapAlgorithm as AlgorithmIdentifier, + unwrappedKeyAlgorithm as AlgorithmIdentifier, + extractable, + keyUsages + ) + ) + .then((unwrappedKey) => { + resolve(createKeyHandle(unwrappedKey)) + }) + .catch((error) => { + reject( + ctx.scope.manage( + ctx.vm.newError({ + name: "CryptoError", + message: + error instanceof Error + ? error.message + : "unwrapKey failed", + }) + ) + ) + }) + }) + ) + + return promiseHandle.handle + } + ) + + // Add all methods to subtle object + ctx.vm.setProp(subtleObj, "digest", digestFn) + ctx.vm.setProp(subtleObj, "encrypt", encryptFn) + ctx.vm.setProp(subtleObj, "decrypt", decryptFn) + ctx.vm.setProp(subtleObj, "sign", signFn) + ctx.vm.setProp(subtleObj, "verify", verifyFn) + ctx.vm.setProp(subtleObj, "generateKey", generateKeyFn) + ctx.vm.setProp(subtleObj, "importKey", importKeyFn) + ctx.vm.setProp(subtleObj, "exportKey", exportKeyFn) + ctx.vm.setProp(subtleObj, "deriveBits", deriveBitsFn) + ctx.vm.setProp(subtleObj, "deriveKey", deriveKeyFn) + ctx.vm.setProp(subtleObj, "wrapKey", wrapKeyFn) + ctx.vm.setProp(subtleObj, "unwrapKey", unwrapKeyFn) + + // ======================================================================== + // Main crypto object + // ======================================================================== + const cryptoObj = ctx.scope.manage(ctx.vm.newObject()) + ctx.vm.setProp(cryptoObj, "getRandomValues", getRandomValuesFn) + ctx.vm.setProp(cryptoObj, "randomUUID", randomUUIDFn) + ctx.vm.setProp(cryptoObj, "subtle", subtleObj) + + // Set crypto on global scope + ctx.vm.setProp(ctx.vm.global, "crypto", cryptoObj) + }) diff --git a/packages/hoppscotch-js-sandbox/src/cage-modules/default.ts b/packages/hoppscotch-js-sandbox/src/cage-modules/default.ts index 0a83b5e7..eb9437b2 100644 --- a/packages/hoppscotch-js-sandbox/src/cage-modules/default.ts +++ b/packages/hoppscotch-js-sandbox/src/cage-modules/default.ts @@ -2,13 +2,13 @@ import { blobPolyfill, ConsoleEntry, console as ConsoleModule, - crypto, encoding, esmModuleLoader, timers, urlPolyfill, } from "faraday-cage/modules" import type { HoppFetchHook } from "~/types" +import { customCryptoModule } from "./crypto" import { customFetchModule } from "./fetch" type DefaultModulesConfig = { @@ -60,7 +60,7 @@ export const defaultModules = (config?: DefaultModulesConfig) => { console.table(...args) }, }), - crypto({ + customCryptoModule({ cryptoImpl: globalThis.crypto, }), diff --git a/packages/hoppscotch-js-sandbox/src/cage-modules/fetch.ts b/packages/hoppscotch-js-sandbox/src/cage-modules/fetch.ts index 4b170bec..828537d7 100644 --- a/packages/hoppscotch-js-sandbox/src/cage-modules/fetch.ts +++ b/packages/hoppscotch-js-sandbox/src/cage-modules/fetch.ts @@ -3,53 +3,27 @@ import { defineSandboxFunctionRaw, } from "faraday-cage/modules" import type { HoppFetchHook } from "~/types" +import { marshalValue as sharedMarshalValue } from "./utils/vm-marshal" -/** - * Type augmentation for Headers to include iterator methods - * These methods exist in modern Headers implementations but may not be in all type definitions - */ +// Type augmentation for some Headers iterator methods. interface HeadersWithIterators extends Headers { entries(): IterableIterator<[string, string]> keys(): IterableIterator values(): IterableIterator } -/** - * Extended Response type with internal properties for serialization - * These properties are added by HoppFetchHook implementations - */ +// Response shape used for VM serialization. type SerializableResponse = Response & { - /** - * Raw body bytes for efficient transfer across VM boundary - */ _bodyBytes: number[] - /** - * Plain object containing header key-value pairs (no methods) - * Used for efficient iteration in the VM without native Headers methods - */ _headersData?: Record } -/** - * Type for async script execution hooks - * Although typed as (() => void) in faraday-cage, the runtime supports async functions - */ type AsyncScriptExecutionHook = () => Promise -/** - * Interface for configuring the custom fetch module - */ export type CustomFetchModuleConfig = { - /** - * Custom fetch implementation to use (HoppFetchHook) - */ fetchImpl?: HoppFetchHook } -/** - * Creates a custom fetch module that uses HoppFetchHook - * This module wraps the HoppFetchHook and provides proper async handling - */ export const customFetchModule = (config: CustomFetchModuleConfig = {}) => defineCageModule((ctx) => { const fetchImpl = config.fetchImpl || globalThis.fetch @@ -98,33 +72,8 @@ export const customFetchModule = (config: CustomFetchModuleConfig = {}) => }) } - // Helper to marshal values to VM - const marshalValue = (value: any): any => { - if (value === null) return ctx.vm.null - if (value === undefined) return ctx.vm.undefined - if (value === true) return ctx.vm.true - if (value === false) return ctx.vm.false - if (typeof value === "string") - return ctx.scope.manage(ctx.vm.newString(value)) - if (typeof value === "number") - return ctx.scope.manage(ctx.vm.newNumber(value)) - if (typeof value === "object") { - if (Array.isArray(value)) { - const arr = ctx.scope.manage(ctx.vm.newArray()) - value.forEach((item, i) => { - ctx.vm.setProp(arr, i, marshalValue(item)) - }) - return arr - } else { - const obj = ctx.scope.manage(ctx.vm.newObject()) - for (const [k, v] of Object.entries(value)) { - ctx.vm.setProp(obj, k, marshalValue(v)) - } - return obj - } - } - return ctx.vm.undefined - } + // Helper to marshal values to VM (using shared utility) + const marshalValue = (value: any): any => sharedMarshalValue(ctx, value) // Define fetch function in the sandbox const fetchFn = defineSandboxFunctionRaw(ctx, "fetch", (...args) => { diff --git a/packages/hoppscotch-js-sandbox/src/cage-modules/index.ts b/packages/hoppscotch-js-sandbox/src/cage-modules/index.ts index 7afb4fb6..4d06112a 100644 --- a/packages/hoppscotch-js-sandbox/src/cage-modules/index.ts +++ b/packages/hoppscotch-js-sandbox/src/cage-modules/index.ts @@ -1,2 +1,4 @@ export { defaultModules } from "./default" export { postRequestModule, preRequestModule } from "./scripting-modules" +export { customCryptoModule } from "./crypto" +export type { CustomCryptoModuleConfig } from "./crypto" diff --git a/packages/hoppscotch-js-sandbox/src/cage-modules/utils/vm-marshal.ts b/packages/hoppscotch-js-sandbox/src/cage-modules/utils/vm-marshal.ts new file mode 100644 index 00000000..5bc196db --- /dev/null +++ b/packages/hoppscotch-js-sandbox/src/cage-modules/utils/vm-marshal.ts @@ -0,0 +1,241 @@ +// Internal faraday-cage ctx type (kept as any). +// eslint-disable-next-line @typescript-eslint/no-explicit-any +type CageModuleContext = any + +export const marshalValue = (ctx: CageModuleContext, value: any): any => { + if (value === null) return ctx.vm.null + if (value === undefined) return ctx.vm.undefined + if (value === true) return ctx.vm.true + if (value === false) return ctx.vm.false + if (typeof value === "string") + return ctx.scope.manage(ctx.vm.newString(value)) + if (typeof value === "number") + return ctx.scope.manage(ctx.vm.newNumber(value)) + if (value instanceof Uint8Array || value instanceof ArrayBuffer) { + // Convert typed arrays to plain arrays for QuickJS + // QuickJS doesn't support native TypedArrays/ArrayBuffer + const bytes = value instanceof Uint8Array ? value : new Uint8Array(value) + const arr = ctx.scope.manage(ctx.vm.newArray()) + for (let i = 0; i < bytes.length; i++) { + ctx.vm.setProp(arr, i, ctx.scope.manage(ctx.vm.newNumber(bytes[i]))) + } + // Add byteLength property for ArrayBuffer compatibility + ctx.vm.setProp( + arr, + "byteLength", + ctx.scope.manage(ctx.vm.newNumber(bytes.length)) + ) + return arr + } + if (typeof value === "object") { + if (Array.isArray(value)) { + const arr = ctx.scope.manage(ctx.vm.newArray()) + value.forEach((item, i) => { + ctx.vm.setProp(arr, i, marshalValue(ctx, item)) + }) + return arr + } else { + const obj = ctx.scope.manage(ctx.vm.newObject()) + for (const [k, v] of Object.entries(value)) { + ctx.vm.setProp(obj, k, marshalValue(ctx, v)) + } + return obj + } + } + return ctx.vm.undefined +} + +export const vmArrayToUint8Array = ( + ctx: CageModuleContext, + vmArray: any +): Uint8Array => { + const length = ctx.vm.getProp(vmArray, "length") + const lengthNum = ctx.vm.getNumber(length) + length.dispose() + + const bytes = new Uint8Array(lengthNum) + for (let i = 0; i < lengthNum; i++) { + const item = ctx.vm.getProp(vmArray, i) + bytes[i] = ctx.vm.getNumber(item) + item.dispose() + } + return bytes +} + +export const uint8ArrayToVmArray = ( + ctx: CageModuleContext, + bytes: Uint8Array +): any => { + const vmArray = ctx.scope.manage(ctx.vm.newArray()) + for (let i = 0; i < bytes.length; i++) { + ctx.vm.setProp(vmArray, i, ctx.scope.manage(ctx.vm.newNumber(bytes[i]))) + } + // Add byteLength property for ArrayBuffer compatibility + ctx.vm.setProp( + vmArray, + "byteLength", + ctx.scope.manage(ctx.vm.newNumber(bytes.length)) + ) + return vmArray +} + +interface KeyEntry { + ref: WeakRef | CryptoKey | CryptoKeyPair + strongRef: CryptoKey | CryptoKeyPair + expiresAt: number +} + +const KEY_EXPIRY_MS = 5 * 60 * 1000 // 5 minutes + +export class CryptoKeyRegistry { + private readonly supportsWeakRef = typeof WeakRef === "function" + private readonly supportsFinalizer = + typeof FinalizationRegistry === "function" + private keys = new Map() + private finalizer?: FinalizationRegistry + private cleanupTimer: ReturnType | null = null + + constructor() { + if (this.supportsFinalizer) { + this.finalizer = new FinalizationRegistry((id: string) => { + this.keys.delete(id) + }) + } + this.scheduleCleanup() + } + + store(key: CryptoKey | CryptoKeyPair, ttl: number = KEY_EXPIRY_MS): string { + const id = + typeof globalThis.crypto?.randomUUID === "function" + ? globalThis.crypto.randomUUID() + : (() => { + // Fallback if randomUUID is unavailable - identifier does not need to be cryptographically secure + if (typeof globalThis.crypto?.getRandomValues === "function") { + const bytes = new Uint8Array(16) + globalThis.crypto.getRandomValues(bytes) + // RFC 4122 v4 bits + bytes[6] = (bytes[6] & 0x0f) | 0x40 + bytes[8] = (bytes[8] & 0x3f) | 0x80 + const hex = Array.from(bytes, (b) => + b.toString(16).padStart(2, "0") + ).join("") + return `${hex.slice(0, 8)}-${hex.slice(8, 12)}-${hex.slice(12, 16)}-${hex.slice(16, 20)}-${hex.slice(20)}` + } + + return `${Date.now()}-${Math.random()}-${Math.random()}` + })() + const expiresAt = Date.now() + ttl + + this.keys.set(id, { + ref: this.supportsWeakRef ? new WeakRef(key) : key, + strongRef: key, + expiresAt, + }) + + if (this.finalizer) { + this.finalizer.register(key, id, key) + } + return id + } + + get(id: string): CryptoKey | CryptoKeyPair | undefined { + const entry = this.keys.get(id) + if (!entry) return undefined + + const now = Date.now() + if (entry.expiresAt <= now) { + this.keys.delete(id) + return undefined + } + + const key = + this.supportsWeakRef && "deref" in entry.ref + ? ((entry.ref as WeakRef).deref() ?? + entry.strongRef) + : entry.strongRef + + if (!key) { + this.keys.delete(id) + return undefined + } + + // Reset TTL on access + entry.expiresAt = Date.now() + KEY_EXPIRY_MS + return key + } + + has(id: string): boolean { + return this.get(id) !== undefined + } + + delete(id: string): boolean { + const entry = this.keys.get(id) + if (!entry) return false + + if (this.finalizer) { + const key = entry.strongRef + + if (key) { + this.finalizer.unregister(key) + } + } + + return this.keys.delete(id) + } + + clear(): void { + for (const [_id, entry] of this.keys.entries()) { + if (this.finalizer) { + const key = entry.strongRef + + this.finalizer.unregister(key) + } + } + this.keys.clear() + } + + get size(): number { + this.cleanup() + return this.keys.size + } + + dispose(): void { + if (this.cleanupTimer) { + clearTimeout(this.cleanupTimer) + this.cleanupTimer = null + } + this.clear() + } + + private cleanup(): void { + const now = Date.now() + for (const [id, entry] of this.keys.entries()) { + const key = + this.supportsWeakRef && "deref" in entry.ref + ? ((entry.ref as WeakRef).deref() ?? + entry.strongRef) + : entry.strongRef + + if (!key || entry.expiresAt <= now) { + this.keys.delete(id) + } + } + } + + private scheduleCleanup(): void { + if (this.cleanupTimer) { + clearTimeout(this.cleanupTimer) + } + + this.cleanupTimer = setTimeout(() => { + this.cleanup() + this.scheduleCleanup() + }, KEY_EXPIRY_MS) // Cleanup interval based on KEY_EXPIRY_MS + } +} + +/** + * Maximum byte size for getRandomValues() per Web Crypto spec + * https://www.w3.org/TR/WebCryptoAPI/#Crypto-method-getRandomValues + */ +export const MAX_GET_RANDOM_VALUES_SIZE = 65536