From 51bb1703fb81fddac24b152fc7b1e0f32f976de5 Mon Sep 17 00:00:00 2001 From: Jake Champion Date: Fri, 22 Sep 2023 00:58:15 +0100 Subject: [PATCH] feat: add support for ECDSA keys to be used with SubtleCrypto.prototype.sign and SubtleCrypto.prototype.verify (#667) In the last release we added support for importing an ECDSA key with SubtleCrypto.prototype.importKey. This patch adds the ability to use these keys to sign data and to verify data was signed with the same underlying key. With this patch, Fastly Compute customers are now able to validate signatures provided by Fastly Fanout requests Co-authored-by: Trevor Elliott --- .vscode/c_cpp_properties.json | 5 +- .../js-compute/fixtures/crypto/bin/index.js | 49 ++-- .../js-compute/fixtures/crypto/tests.json | 19 +- .../builtins/crypto-algorithm.cpp | 225 +++++++++++++++++- .../component/fastly_world_adapter.cpp | 6 +- 5 files changed, 261 insertions(+), 43 deletions(-) diff --git a/.vscode/c_cpp_properties.json b/.vscode/c_cpp_properties.json index 97cf0b570e..bf972beeea 100644 --- a/.vscode/c_cpp_properties.json +++ b/.vscode/c_cpp_properties.json @@ -7,7 +7,8 @@ "${workspaceFolder}/runtime/spidermonkey/release/include", "/opt/wasi-sdk/share/wasi-sysroot/include/", "${workspaceFolder}/runtime/js-compute-runtime", - "${workspaceFolder}/runtime/js-compute-runtime/build/openssl-3.0.7/include" + "${workspaceFolder}/runtime/js-compute-runtime/build/openssl-3.0.7/include", + "${workspaceFolder}/runtime/js-compute-runtime/third_party/fmt/include" ], "defines": [ "__wasi__" @@ -26,4 +27,4 @@ } ], "version": 4 -} +} \ No newline at end of file diff --git a/integration-tests/js-compute/fixtures/crypto/bin/index.js b/integration-tests/js-compute/fixtures/crypto/bin/index.js index e31d68415a..9a663080b7 100644 --- a/integration-tests/js-compute/fixtures/crypto/bin/index.js +++ b/integration-tests/js-compute/fixtures/crypto/bin/index.js @@ -1,30 +1,8 @@ /// /* eslint-env serviceworker, shared-node-browser, browser */ -import { env } from 'fastly:env'; -import { pass, fail, assert, assertThrows, assertRejects, assertResolves } from "../../../assertions.js"; - -addEventListener("fetch", event => { - event.respondWith(app(event)); -}); -/** - * @param {FetchEvent} event - * @returns {Response} - */ -async function app(event) { - try { - const path = (new URL(event.request.url)).pathname; - console.log(`path: ${path}`) - console.log(`FASTLY_SERVICE_VERSION: ${env('FASTLY_SERVICE_VERSION')}`) - if (routes.has(path)) { - const routeHandler = routes.get(path); - return await routeHandler(); - } - return fail(`${path} endpoint does not exist`) - } catch (error) { - return fail(`The routeHandler threw an error: ${error.message}` + '\n' + error.stack) - } -} +import { pass, assert, assertThrows, assertRejects, assertResolves } from "../../../assertions.js"; +import { routes } from "../../../test-harness.js"; // From https://www.rfc-editor.org/rfc/rfc7517#appendix-A.1 const publicRsaJsonWebKeyData = { @@ -92,14 +70,8 @@ const rsaJsonWebKeyAlgorithm = { const ecdsaJsonWebKeyAlgorithm = { name: "ECDSA", namedCurve: "P-256", -} - -const routes = new Map(); -routes.set('/', () => { - routes.delete('/'); - let test_routes = Array.from(routes.keys()) - return new Response(JSON.stringify(test_routes), { 'headers': { 'content-type': 'application/json' } }); -}); + hash: {name: "SHA-256"}, +}; let error; routes.set("/crypto", async () => { @@ -1207,7 +1179,7 @@ routes.set("/crypto.subtle", async () => { } // correct-signature { - routes.set("/crypto.subtle.verify/correct-signature-jwk", async () => { + routes.set("/crypto.subtle.verify/correct-signature-jwk-rsa", async () => { const pkey = await crypto.subtle.importKey('jwk', privateRsaJsonWebKeyData, rsaJsonWebKeyAlgorithm, privateRsaJsonWebKeyData.ext, privateRsaJsonWebKeyData.key_ops); const key = await crypto.subtle.importKey('jwk', publicRsaJsonWebKeyData, rsaJsonWebKeyAlgorithm, publicRsaJsonWebKeyData.ext, publicRsaJsonWebKeyData.key_ops); const enc = new TextEncoder(); @@ -1218,6 +1190,17 @@ routes.set("/crypto.subtle", async () => { if (error) { return error; } return pass('ok'); }); + routes.set("/crypto.subtle.verify/correct-signature-jwk-ecdsa", async () => { + const pkey = await crypto.subtle.importKey('jwk', privateEcdsaJsonWebKeyData, ecdsaJsonWebKeyAlgorithm, privateEcdsaJsonWebKeyData.ext, privateEcdsaJsonWebKeyData.key_ops); + const key = await crypto.subtle.importKey('jwk', publicEcdsaJsonWebKeyData, ecdsaJsonWebKeyAlgorithm, publicEcdsaJsonWebKeyData.ext, publicEcdsaJsonWebKeyData.key_ops); + const enc = new TextEncoder(); + const data = enc.encode('hello world'); + const signature = await crypto.subtle.sign(ecdsaJsonWebKeyAlgorithm, pkey, data); + const result = await crypto.subtle.verify(ecdsaJsonWebKeyAlgorithm, key, signature, data); + error = assert(result, true, "result === true"); + if (error) { return error; } + return pass('ok'); + }); routes.set("/crypto.subtle.verify/correct-signature-hmac", async () => { const results = { 'SHA-1': new Uint8Array([ diff --git a/integration-tests/js-compute/fixtures/crypto/tests.json b/integration-tests/js-compute/fixtures/crypto/tests.json index a0447ac543..5f71009b9f 100644 --- a/integration-tests/js-compute/fixtures/crypto/tests.json +++ b/integration-tests/js-compute/fixtures/crypto/tests.json @@ -1195,13 +1195,26 @@ "body": "ok" } }, - "GET /crypto.subtle.verify/correct-signature-jwk": { + "GET /crypto.subtle.verify/correct-signature-jwk-rsa": { "environments": [ "viceroy", "c@e" ], "downstream_request": { "method": "GET", - "pathname": "/crypto.subtle.verify/correct-signature-jwk" + "pathname": "/crypto.subtle.verify/correct-signature-jwk-rsa" + }, + "downstream_response": { + "status": 200, + "body": "ok" + } + }, + "GET /crypto.subtle.verify/correct-signature-jwk-ecdsa": { + "environments": [ + "viceroy", "c@e" + ], + "downstream_request": { + "method": "GET", + "pathname": "/crypto.subtle.verify/correct-signature-jwk-ecdsa" }, "downstream_response": { "status": 200, @@ -1221,4 +1234,4 @@ "body": "ok" } } -} \ No newline at end of file +} diff --git a/runtime/js-compute-runtime/builtins/crypto-algorithm.cpp b/runtime/js-compute-runtime/builtins/crypto-algorithm.cpp index d2dde444fb..47f7a3c57d 100644 --- a/runtime/js-compute-runtime/builtins/crypto-algorithm.cpp +++ b/runtime/js-compute-runtime/builtins/crypto-algorithm.cpp @@ -2,6 +2,7 @@ #include "openssl/sha.h" #include #include +#include #include #include #include @@ -16,6 +17,49 @@ namespace builtins { namespace { +int numBitsToBytes(int x) { return (x / 8) + (7 + (x % 8)) / 8; } + +std::pair, size_t> +convertToBytesExpand(JSContext *cx, const BIGNUM *bignum, size_t minimumBufferSize) { + int length = BN_num_bytes(bignum); + + size_t bufferSize = std::max(length, minimumBufferSize); + mozilla::UniquePtr bytes{ + static_cast(JS_malloc(cx, bufferSize))}; + + size_t paddingLength = bufferSize - length; + if (paddingLength > 0) { + uint8_t padding = BN_is_negative(bignum) ? 0xFF : 0x00; + std::fill_n(bytes.get(), paddingLength, padding); + } + BN_bn2bin(bignum, bytes.get() + paddingLength); + return std::pair, size_t>(std::move(bytes), + bufferSize); +} + +const EVP_MD *createDigestAlgorithm(JSContext *cx, CryptoAlgorithmIdentifier hashIdentifier) { + switch (hashIdentifier) { + case CryptoAlgorithmIdentifier::MD5: { + return EVP_md5(); + } + case CryptoAlgorithmIdentifier::SHA_1: { + return EVP_sha1(); + } + case CryptoAlgorithmIdentifier::SHA_256: { + return EVP_sha256(); + } + case CryptoAlgorithmIdentifier::SHA_384: { + return EVP_sha384(); + } + case CryptoAlgorithmIdentifier::SHA_512: { + return EVP_sha512(); + } + default: { + DOMException::raise(cx, "NotSupportedError", "NotSupportedError"); + return nullptr; + } + } +} const EVP_MD *createDigestAlgorithm(JSContext *cx, JS::HandleObject key) { @@ -366,6 +410,30 @@ JS::Result toNamedCurve(JSContext *cx, JS::HandleValue val } } +JS::Result curveSize(JSContext *cx, JS::HandleObject key) { + + JS::RootedObject alg(cx, CryptoKey::get_algorithm(key)); + + JS::RootedValue namedCurve_val(cx); + JS_GetProperty(cx, alg, "namedCurve", &namedCurve_val); + auto namedCurve_chars = core::encode(cx, namedCurve_val); + if (!namedCurve_chars) { + return JS::Result(JS::Error()); + } + + std::string_view namedCurve = namedCurve_chars; + if (namedCurve == "P-256") { + return 256; + } else if (namedCurve == "P-384") { + return 384; + } else if (namedCurve == "P-521") { + return 521; + } + + MOZ_ASSERT_UNREACHABLE(); + return 0; +} + // This implements the first section of // https://w3c.github.io/webcrypto/#algorithm-normalization-normalize-an-algorithm which is shared // across all the diffent algorithms, but importantly does not implement the parts to do with the @@ -755,12 +823,163 @@ JSObject *CryptoAlgorithmHMAC_Sign_Verify::toObject(JSContext *cx) { JSObject *CryptoAlgorithmECDSA_Sign_Verify::sign(JSContext *cx, JS::HandleObject key, std::span data) { MOZ_ASSERT(CryptoKey::is_instance(key)); - return nullptr; + // 1. If the [[type]] internal slot of key is not "private", then throw an InvalidAccessError. + if (CryptoKey::type(key) != CryptoKeyType::Private) { + DOMException::raise(cx, "InvalidAccessError", "InvalidAccessError"); + return nullptr; + } + + // 2. Let hashAlgorithm be the hash member of normalizedAlgorithm. + const EVP_MD* algorithm = createDigestAlgorithm(cx, this->hashIdentifier); + if (!algorithm) { + DOMException::raise(cx, "SubtleCrypto.sign: failed to sign", "OperationError"); + return nullptr; + } + + // 3. Let M be the result of performing the digest operation specified by hashAlgorithm using message. + auto digestOption = ::builtins::rawDigest(cx, data, algorithm, EVP_MD_size(algorithm)); + if (!digestOption.has_value()) { + DOMException::raise(cx, "OperationError", "OperationError"); + return nullptr; + } + + auto digest = digestOption.value(); + + // 4. Let d be the ECDSA private key associated with key. + #pragma clang diagnostic push + #pragma clang diagnostic ignored "-Wdeprecated-declarations" + const EC_KEY * ecKey = EVP_PKEY_get0_EC_KEY(CryptoKey::key(key)); + #pragma clang diagnostic pop + if (!ecKey) { + DOMException::raise(cx, "SubtleCrypto.verify: failed to verify", "OperationError"); + return nullptr; + } + + // 5. Let params be the EC domain parameters associated with key. + #pragma clang diagnostic push + #pragma clang diagnostic ignored "-Wdeprecated-declarations" + auto sig = ECDSA_do_sign(digest.data(), digest.size(), const_cast(ecKey)); + #pragma clang diagnostic pop + if (!sig) { + DOMException::raise(cx, "SubtleCrypto.verify: failed to verify", "OperationError"); + return nullptr; + } + + // 6. If the namedCurve attribute of the [[algorithm]] internal slot of key is "P-256", "P-384" or "P-521": + // Perform the ECDSA signing process, as specified in [RFC6090], Section 5.4, with M as the message, using params as the EC domain parameters, and with d as the private key. + // Let r and s be the pair of integers resulting from performing the ECDSA signing process. + // Let result be an empty byte sequence. + // Let n be the smallest integer such that n * 8 is greater than the logarithm to base 2 of the order of the base point of the elliptic curve identified by params. + // Convert r to an octet string of length n and append this sequence of bytes to result. + // Convert s to an octet string of length n and append this sequence of bytes to result. + // Otherwise, the namedCurve attribute of the [[algorithm]] internal slot of key is a value specified in an applicable specification: + // Perform the ECDSA signature steps specified in that specification, passing in M, params and d and resulting in result. + const BIGNUM* r; + const BIGNUM* s; + ECDSA_SIG_get0(sig, &r, &s); + auto keySize = curveSize(cx, key); + if (keySize.isErr()) { + return nullptr; + } + + size_t keySizeInBytes = numBitsToBytes(keySize.unwrap()); + + auto rBytesAndSize = convertToBytesExpand(cx, r, keySizeInBytes); + auto *rBytes = rBytesAndSize.first.get(); + auto rBytesSize = rBytesAndSize.second; + + auto sBytesAndSize = convertToBytesExpand(cx, s, keySizeInBytes); + auto *sBytes = sBytesAndSize.first.get(); + auto sBytesSize = sBytesAndSize.second; + + auto resultSize = rBytesSize + sBytesSize; + mozilla::UniquePtr result{ + static_cast(JS_malloc(cx, resultSize))}; + + std::memcpy(result.get(), rBytes, rBytesSize); + std::memcpy(result.get() + rBytesSize, sBytes, sBytesSize); + + // 7. Return the result of creating an ArrayBuffer containing result. + JS::RootedObject buffer(cx, JS::NewArrayBufferWithContents(cx, resultSize, result.get())); + if (!buffer) { + // We can be here is the array buffer was too large -- if that was the case then a + // JSMSG_BAD_ARRAY_LENGTH will have been created. No other failure scenarios in this path will + // create a JS exception and so we need to create one. + if (!JS_IsExceptionPending(cx)) { + // TODO Rename error to InternalError + JS_ReportErrorLatin1(cx, "InternalError"); + } + return nullptr; + } + + // `signature` is now owned by `buffer` + static_cast(result.release()); + + return buffer; }; JS::Result CryptoAlgorithmECDSA_Sign_Verify::verify(JSContext *cx, JS::HandleObject key, std::span signature, std::span data) { MOZ_ASSERT(CryptoKey::is_instance(key)); - DOMException::raise(cx, "SubtleCrypto.verify: failed to verify", "OperationError"); - return JS::Result(JS::Error()); + // 1. If the [[type]] internal slot of key is not "public", then throw an InvalidAccessError. + if (CryptoKey::type(key) != CryptoKeyType::Public) { + DOMException::raise(cx, "InvalidAccessError", "InvalidAccessError"); + return JS::Result(JS::Error()); + } + + // 2. Let hashAlgorithm be the hash member of normalizedAlgorithm. + const EVP_MD* algorithm = createDigestAlgorithm(cx, this->hashIdentifier); + if (!algorithm) { + DOMException::raise(cx, "SubtleCrypto.verify: failed to verify", "OperationError"); + return JS::Result(JS::Error()); + } + + // 3. Let M be the result of performing the digest operation specified by hashAlgorithm using message. + auto digestOption = ::builtins::rawDigest(cx, data, algorithm, EVP_MD_size(algorithm)); + if (!digestOption.has_value()) { + DOMException::raise(cx, "OperationError", "OperationError"); + return JS::Result(JS::Error()); + } + + auto digest = digestOption.value(); + + // 4. Let Q be the ECDSA public key associated with key. + #pragma clang diagnostic push + #pragma clang diagnostic ignored "-Wdeprecated-declarations" + const EC_KEY * ecKey = EVP_PKEY_get0_EC_KEY(CryptoKey::key(key)); + #pragma clang diagnostic pop + if (!ecKey) { + DOMException::raise(cx, "SubtleCrypto.verify: failed to verify", "OperationError"); + return JS::Result(JS::Error()); + } + + // 5. Let params be the EC domain parameters associated with key. + // 6. If the namedCurve attribute of the [[algorithm]] internal slot of key is "P-256", "P-384" or "P-521": + // Perform the ECDSA verifying process, as specified in RFC6090, Section 5.3, with M as the received message, signature as the received signature and using params as the EC domain parameters, and Q as the public key. + // Otherwise, the namedCurve attribute of the [[algorithm]] internal slot of key is a value specified in an applicable specification: + // Perform the ECDSA verification steps specified in that specification passing in M, signature, params and Q and resulting in an indication of whether or not the purported signature is valid. + auto keySize = curveSize(cx, key); + if (keySize.isErr()) { + return JS::Result(JS::Error()); + } + + size_t keySizeInBytes = numBitsToBytes(keySize.unwrap()); + + auto sig = ECDSA_SIG_new(); + auto r = BN_bin2bn(signature.data(), keySizeInBytes, nullptr); + auto s = BN_bin2bn(signature.data() + keySizeInBytes, keySizeInBytes, nullptr); + + if (!ECDSA_SIG_set0(sig, r, s)) { + DOMException::raise(cx, "SubtleCrypto.verify: failed to verify", "OperationError"); + return JS::Result(JS::Error()); + } + + // 7. Let result be a boolean with the value true if the signature is valid and the value false otherwise. + #pragma clang diagnostic push + #pragma clang diagnostic ignored "-Wdeprecated-declarations" + bool result = ECDSA_do_verify(digest.data(), digest.size(), sig, const_cast(ecKey)) == 1; + #pragma clang diagnostic pop + + // 8. Return result. + return result; }; JSObject *CryptoAlgorithmECDSA_Sign_Verify::toObject(JSContext *cx) { return nullptr; diff --git a/runtime/js-compute-runtime/host_interface/component/fastly_world_adapter.cpp b/runtime/js-compute-runtime/host_interface/component/fastly_world_adapter.cpp index 5b17751893..68041e6425 100644 --- a/runtime/js-compute-runtime/host_interface/component/fastly_world_adapter.cpp +++ b/runtime/js-compute-runtime/host_interface/component/fastly_world_adapter.cpp @@ -593,8 +593,10 @@ bool fastly_compute_at_edge_http_resp_header_names_get( cursor = (uint32_t)next_cursor; } cabi_free(buf); - strs = static_cast(cabi_realloc( - strs, str_max * sizeof(fastly_world_string_t), 1, str_cnt * sizeof(fastly_world_string_t))); + if (str_cnt != 0) { + strs = static_cast(cabi_realloc( + strs, str_max * sizeof(fastly_world_string_t), 1, str_cnt * sizeof(fastly_world_string_t))); + } ret->ptr = strs; ret->len = str_cnt; return true;