feat(js-sandbox): add extensive Web Crypto API support (#5791)

This commit is contained in:
James George 2026-01-22 17:30:06 +05:30 committed by GitHub
parent 4e717d79a5
commit a998d6c493
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
13 changed files with 3223 additions and 199 deletions

View file

@ -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
}

View file

@ -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")
})
})

View file

@ -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" }),
]),
}),
]),
}),
])
})
})

View file

@ -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")
})
})

View file

@ -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")
})
})

View file

@ -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" })],
}),
]),
}),
])
})
})

View file

@ -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)
})
`)()

View file

@ -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 () => {

File diff suppressed because it is too large Load diff

View file

@ -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,
}),

View file

@ -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<string>
values(): IterableIterator<string>
}
/**
* 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<string, string>
}
/**
* Type for async script execution hooks
* Although typed as (() => void) in faraday-cage, the runtime supports async functions
*/
type AsyncScriptExecutionHook = () => Promise<void>
/**
* 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) => {

View file

@ -1,2 +1,4 @@
export { defaultModules } from "./default"
export { postRequestModule, preRequestModule } from "./scripting-modules"
export { customCryptoModule } from "./crypto"
export type { CustomCryptoModuleConfig } from "./crypto"

View file

@ -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> | 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<string, KeyEntry>()
private finalizer?: FinalizationRegistry<string>
private cleanupTimer: ReturnType<typeof setTimeout> | 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<CryptoKey | CryptoKeyPair>).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<CryptoKey | CryptoKeyPair>).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