Skip to content

Commit 51bb170

Browse files
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 <[email protected]>
1 parent a44e101 commit 51bb170

File tree

5 files changed

+261
-43
lines changed

5 files changed

+261
-43
lines changed

.vscode/c_cpp_properties.json

Lines changed: 3 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -7,7 +7,8 @@
77
"${workspaceFolder}/runtime/spidermonkey/release/include",
88
"/opt/wasi-sdk/share/wasi-sysroot/include/",
99
"${workspaceFolder}/runtime/js-compute-runtime",
10-
"${workspaceFolder}/runtime/js-compute-runtime/build/openssl-3.0.7/include"
10+
"${workspaceFolder}/runtime/js-compute-runtime/build/openssl-3.0.7/include",
11+
"${workspaceFolder}/runtime/js-compute-runtime/third_party/fmt/include"
1112
],
1213
"defines": [
1314
"__wasi__"
@@ -26,4 +27,4 @@
2627
}
2728
],
2829
"version": 4
29-
}
30+
}

integration-tests/js-compute/fixtures/crypto/bin/index.js

Lines changed: 16 additions & 33 deletions
Original file line numberDiff line numberDiff line change
@@ -1,30 +1,8 @@
11
/// <reference path="../../../../../types/index.d.ts" />
22
/* eslint-env serviceworker, shared-node-browser, browser */
33

4-
import { env } from 'fastly:env';
5-
import { pass, fail, assert, assertThrows, assertRejects, assertResolves } from "../../../assertions.js";
6-
7-
addEventListener("fetch", event => {
8-
event.respondWith(app(event));
9-
});
10-
/**
11-
* @param {FetchEvent} event
12-
* @returns {Response}
13-
*/
14-
async function app(event) {
15-
try {
16-
const path = (new URL(event.request.url)).pathname;
17-
console.log(`path: ${path}`)
18-
console.log(`FASTLY_SERVICE_VERSION: ${env('FASTLY_SERVICE_VERSION')}`)
19-
if (routes.has(path)) {
20-
const routeHandler = routes.get(path);
21-
return await routeHandler();
22-
}
23-
return fail(`${path} endpoint does not exist`)
24-
} catch (error) {
25-
return fail(`The routeHandler threw an error: ${error.message}` + '\n' + error.stack)
26-
}
27-
}
4+
import { pass, assert, assertThrows, assertRejects, assertResolves } from "../../../assertions.js";
5+
import { routes } from "../../../test-harness.js";
286

297
// From https://www.rfc-editor.org/rfc/rfc7517#appendix-A.1
308
const publicRsaJsonWebKeyData = {
@@ -92,14 +70,8 @@ const rsaJsonWebKeyAlgorithm = {
9270
const ecdsaJsonWebKeyAlgorithm = {
9371
name: "ECDSA",
9472
namedCurve: "P-256",
95-
}
96-
97-
const routes = new Map();
98-
routes.set('/', () => {
99-
routes.delete('/');
100-
let test_routes = Array.from(routes.keys())
101-
return new Response(JSON.stringify(test_routes), { 'headers': { 'content-type': 'application/json' } });
102-
});
73+
hash: {name: "SHA-256"},
74+
};
10375

10476
let error;
10577
routes.set("/crypto", async () => {
@@ -1207,7 +1179,7 @@ routes.set("/crypto.subtle", async () => {
12071179
}
12081180
// correct-signature
12091181
{
1210-
routes.set("/crypto.subtle.verify/correct-signature-jwk", async () => {
1182+
routes.set("/crypto.subtle.verify/correct-signature-jwk-rsa", async () => {
12111183
const pkey = await crypto.subtle.importKey('jwk', privateRsaJsonWebKeyData, rsaJsonWebKeyAlgorithm, privateRsaJsonWebKeyData.ext, privateRsaJsonWebKeyData.key_ops);
12121184
const key = await crypto.subtle.importKey('jwk', publicRsaJsonWebKeyData, rsaJsonWebKeyAlgorithm, publicRsaJsonWebKeyData.ext, publicRsaJsonWebKeyData.key_ops);
12131185
const enc = new TextEncoder();
@@ -1218,6 +1190,17 @@ routes.set("/crypto.subtle", async () => {
12181190
if (error) { return error; }
12191191
return pass('ok');
12201192
});
1193+
routes.set("/crypto.subtle.verify/correct-signature-jwk-ecdsa", async () => {
1194+
const pkey = await crypto.subtle.importKey('jwk', privateEcdsaJsonWebKeyData, ecdsaJsonWebKeyAlgorithm, privateEcdsaJsonWebKeyData.ext, privateEcdsaJsonWebKeyData.key_ops);
1195+
const key = await crypto.subtle.importKey('jwk', publicEcdsaJsonWebKeyData, ecdsaJsonWebKeyAlgorithm, publicEcdsaJsonWebKeyData.ext, publicEcdsaJsonWebKeyData.key_ops);
1196+
const enc = new TextEncoder();
1197+
const data = enc.encode('hello world');
1198+
const signature = await crypto.subtle.sign(ecdsaJsonWebKeyAlgorithm, pkey, data);
1199+
const result = await crypto.subtle.verify(ecdsaJsonWebKeyAlgorithm, key, signature, data);
1200+
error = assert(result, true, "result === true");
1201+
if (error) { return error; }
1202+
return pass('ok');
1203+
});
12211204
routes.set("/crypto.subtle.verify/correct-signature-hmac", async () => {
12221205
const results = {
12231206
'SHA-1': new Uint8Array([

integration-tests/js-compute/fixtures/crypto/tests.json

Lines changed: 16 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1195,13 +1195,26 @@
11951195
"body": "ok"
11961196
}
11971197
},
1198-
"GET /crypto.subtle.verify/correct-signature-jwk": {
1198+
"GET /crypto.subtle.verify/correct-signature-jwk-rsa": {
11991199
"environments": [
12001200
"viceroy", "c@e"
12011201
],
12021202
"downstream_request": {
12031203
"method": "GET",
1204-
"pathname": "/crypto.subtle.verify/correct-signature-jwk"
1204+
"pathname": "/crypto.subtle.verify/correct-signature-jwk-rsa"
1205+
},
1206+
"downstream_response": {
1207+
"status": 200,
1208+
"body": "ok"
1209+
}
1210+
},
1211+
"GET /crypto.subtle.verify/correct-signature-jwk-ecdsa": {
1212+
"environments": [
1213+
"viceroy", "c@e"
1214+
],
1215+
"downstream_request": {
1216+
"method": "GET",
1217+
"pathname": "/crypto.subtle.verify/correct-signature-jwk-ecdsa"
12051218
},
12061219
"downstream_response": {
12071220
"status": 200,
@@ -1221,4 +1234,4 @@
12211234
"body": "ok"
12221235
}
12231236
}
1224-
}
1237+
}

runtime/js-compute-runtime/builtins/crypto-algorithm.cpp

Lines changed: 222 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,7 @@
22
#include "openssl/sha.h"
33
#include <fmt/format.h>
44
#include <iostream>
5+
#include <openssl/ecdsa.h>
56
#include <optional>
67
#include <span>
78
#include <vector>
@@ -16,6 +17,49 @@
1617
namespace builtins {
1718

1819
namespace {
20+
int numBitsToBytes(int x) { return (x / 8) + (7 + (x % 8)) / 8; }
21+
22+
std::pair<mozilla::UniquePtr<uint8_t[], JS::FreePolicy>, size_t>
23+
convertToBytesExpand(JSContext *cx, const BIGNUM *bignum, size_t minimumBufferSize) {
24+
int length = BN_num_bytes(bignum);
25+
26+
size_t bufferSize = std::max<size_t>(length, minimumBufferSize);
27+
mozilla::UniquePtr<uint8_t[], JS::FreePolicy> bytes{
28+
static_cast<uint8_t *>(JS_malloc(cx, bufferSize))};
29+
30+
size_t paddingLength = bufferSize - length;
31+
if (paddingLength > 0) {
32+
uint8_t padding = BN_is_negative(bignum) ? 0xFF : 0x00;
33+
std::fill_n(bytes.get(), paddingLength, padding);
34+
}
35+
BN_bn2bin(bignum, bytes.get() + paddingLength);
36+
return std::pair<mozilla::UniquePtr<uint8_t[], JS::FreePolicy>, size_t>(std::move(bytes),
37+
bufferSize);
38+
}
39+
40+
const EVP_MD *createDigestAlgorithm(JSContext *cx, CryptoAlgorithmIdentifier hashIdentifier) {
41+
switch (hashIdentifier) {
42+
case CryptoAlgorithmIdentifier::MD5: {
43+
return EVP_md5();
44+
}
45+
case CryptoAlgorithmIdentifier::SHA_1: {
46+
return EVP_sha1();
47+
}
48+
case CryptoAlgorithmIdentifier::SHA_256: {
49+
return EVP_sha256();
50+
}
51+
case CryptoAlgorithmIdentifier::SHA_384: {
52+
return EVP_sha384();
53+
}
54+
case CryptoAlgorithmIdentifier::SHA_512: {
55+
return EVP_sha512();
56+
}
57+
default: {
58+
DOMException::raise(cx, "NotSupportedError", "NotSupportedError");
59+
return nullptr;
60+
}
61+
}
62+
}
1963

2064
const EVP_MD *createDigestAlgorithm(JSContext *cx, JS::HandleObject key) {
2165

@@ -366,6 +410,30 @@ JS::Result<builtins::NamedCurve> toNamedCurve(JSContext *cx, JS::HandleValue val
366410
}
367411
}
368412

413+
JS::Result<size_t> curveSize(JSContext *cx, JS::HandleObject key) {
414+
415+
JS::RootedObject alg(cx, CryptoKey::get_algorithm(key));
416+
417+
JS::RootedValue namedCurve_val(cx);
418+
JS_GetProperty(cx, alg, "namedCurve", &namedCurve_val);
419+
auto namedCurve_chars = core::encode(cx, namedCurve_val);
420+
if (!namedCurve_chars) {
421+
return JS::Result<size_t>(JS::Error());
422+
}
423+
424+
std::string_view namedCurve = namedCurve_chars;
425+
if (namedCurve == "P-256") {
426+
return 256;
427+
} else if (namedCurve == "P-384") {
428+
return 384;
429+
} else if (namedCurve == "P-521") {
430+
return 521;
431+
}
432+
433+
MOZ_ASSERT_UNREACHABLE();
434+
return 0;
435+
}
436+
369437
// This implements the first section of
370438
// https://w3c.github.io/webcrypto/#algorithm-normalization-normalize-an-algorithm which is shared
371439
// 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) {
755823
JSObject *CryptoAlgorithmECDSA_Sign_Verify::sign(JSContext *cx, JS::HandleObject key, std::span<uint8_t> data) {
756824
MOZ_ASSERT(CryptoKey::is_instance(key));
757825

758-
return nullptr;
826+
// 1. If the [[type]] internal slot of key is not "private", then throw an InvalidAccessError.
827+
if (CryptoKey::type(key) != CryptoKeyType::Private) {
828+
DOMException::raise(cx, "InvalidAccessError", "InvalidAccessError");
829+
return nullptr;
830+
}
831+
832+
// 2. Let hashAlgorithm be the hash member of normalizedAlgorithm.
833+
const EVP_MD* algorithm = createDigestAlgorithm(cx, this->hashIdentifier);
834+
if (!algorithm) {
835+
DOMException::raise(cx, "SubtleCrypto.sign: failed to sign", "OperationError");
836+
return nullptr;
837+
}
838+
839+
// 3. Let M be the result of performing the digest operation specified by hashAlgorithm using message.
840+
auto digestOption = ::builtins::rawDigest(cx, data, algorithm, EVP_MD_size(algorithm));
841+
if (!digestOption.has_value()) {
842+
DOMException::raise(cx, "OperationError", "OperationError");
843+
return nullptr;
844+
}
845+
846+
auto digest = digestOption.value();
847+
848+
// 4. Let d be the ECDSA private key associated with key.
849+
#pragma clang diagnostic push
850+
#pragma clang diagnostic ignored "-Wdeprecated-declarations"
851+
const EC_KEY * ecKey = EVP_PKEY_get0_EC_KEY(CryptoKey::key(key));
852+
#pragma clang diagnostic pop
853+
if (!ecKey) {
854+
DOMException::raise(cx, "SubtleCrypto.verify: failed to verify", "OperationError");
855+
return nullptr;
856+
}
857+
858+
// 5. Let params be the EC domain parameters associated with key.
859+
#pragma clang diagnostic push
860+
#pragma clang diagnostic ignored "-Wdeprecated-declarations"
861+
auto sig = ECDSA_do_sign(digest.data(), digest.size(), const_cast<EC_KEY*>(ecKey));
862+
#pragma clang diagnostic pop
863+
if (!sig) {
864+
DOMException::raise(cx, "SubtleCrypto.verify: failed to verify", "OperationError");
865+
return nullptr;
866+
}
867+
868+
// 6. If the namedCurve attribute of the [[algorithm]] internal slot of key is "P-256", "P-384" or "P-521":
869+
// 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.
870+
// Let r and s be the pair of integers resulting from performing the ECDSA signing process.
871+
// Let result be an empty byte sequence.
872+
// 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.
873+
// Convert r to an octet string of length n and append this sequence of bytes to result.
874+
// Convert s to an octet string of length n and append this sequence of bytes to result.
875+
// Otherwise, the namedCurve attribute of the [[algorithm]] internal slot of key is a value specified in an applicable specification:
876+
// Perform the ECDSA signature steps specified in that specification, passing in M, params and d and resulting in result.
877+
const BIGNUM* r;
878+
const BIGNUM* s;
879+
ECDSA_SIG_get0(sig, &r, &s);
880+
auto keySize = curveSize(cx, key);
881+
if (keySize.isErr()) {
882+
return nullptr;
883+
}
884+
885+
size_t keySizeInBytes = numBitsToBytes(keySize.unwrap());
886+
887+
auto rBytesAndSize = convertToBytesExpand(cx, r, keySizeInBytes);
888+
auto *rBytes = rBytesAndSize.first.get();
889+
auto rBytesSize = rBytesAndSize.second;
890+
891+
auto sBytesAndSize = convertToBytesExpand(cx, s, keySizeInBytes);
892+
auto *sBytes = sBytesAndSize.first.get();
893+
auto sBytesSize = sBytesAndSize.second;
894+
895+
auto resultSize = rBytesSize + sBytesSize;
896+
mozilla::UniquePtr<uint8_t[], JS::FreePolicy> result{
897+
static_cast<uint8_t *>(JS_malloc(cx, resultSize))};
898+
899+
std::memcpy(result.get(), rBytes, rBytesSize);
900+
std::memcpy(result.get() + rBytesSize, sBytes, sBytesSize);
901+
902+
// 7. Return the result of creating an ArrayBuffer containing result.
903+
JS::RootedObject buffer(cx, JS::NewArrayBufferWithContents(cx, resultSize, result.get()));
904+
if (!buffer) {
905+
// We can be here is the array buffer was too large -- if that was the case then a
906+
// JSMSG_BAD_ARRAY_LENGTH will have been created. No other failure scenarios in this path will
907+
// create a JS exception and so we need to create one.
908+
if (!JS_IsExceptionPending(cx)) {
909+
// TODO Rename error to InternalError
910+
JS_ReportErrorLatin1(cx, "InternalError");
911+
}
912+
return nullptr;
913+
}
914+
915+
// `signature` is now owned by `buffer`
916+
static_cast<void>(result.release());
917+
918+
return buffer;
759919
};
760920
JS::Result<bool> CryptoAlgorithmECDSA_Sign_Verify::verify(JSContext *cx, JS::HandleObject key, std::span<uint8_t> signature, std::span<uint8_t> data) {
761921
MOZ_ASSERT(CryptoKey::is_instance(key));
762-
DOMException::raise(cx, "SubtleCrypto.verify: failed to verify", "OperationError");
763-
return JS::Result<bool>(JS::Error());
922+
// 1. If the [[type]] internal slot of key is not "public", then throw an InvalidAccessError.
923+
if (CryptoKey::type(key) != CryptoKeyType::Public) {
924+
DOMException::raise(cx, "InvalidAccessError", "InvalidAccessError");
925+
return JS::Result<bool>(JS::Error());
926+
}
927+
928+
// 2. Let hashAlgorithm be the hash member of normalizedAlgorithm.
929+
const EVP_MD* algorithm = createDigestAlgorithm(cx, this->hashIdentifier);
930+
if (!algorithm) {
931+
DOMException::raise(cx, "SubtleCrypto.verify: failed to verify", "OperationError");
932+
return JS::Result<bool>(JS::Error());
933+
}
934+
935+
// 3. Let M be the result of performing the digest operation specified by hashAlgorithm using message.
936+
auto digestOption = ::builtins::rawDigest(cx, data, algorithm, EVP_MD_size(algorithm));
937+
if (!digestOption.has_value()) {
938+
DOMException::raise(cx, "OperationError", "OperationError");
939+
return JS::Result<bool>(JS::Error());
940+
}
941+
942+
auto digest = digestOption.value();
943+
944+
// 4. Let Q be the ECDSA public key associated with key.
945+
#pragma clang diagnostic push
946+
#pragma clang diagnostic ignored "-Wdeprecated-declarations"
947+
const EC_KEY * ecKey = EVP_PKEY_get0_EC_KEY(CryptoKey::key(key));
948+
#pragma clang diagnostic pop
949+
if (!ecKey) {
950+
DOMException::raise(cx, "SubtleCrypto.verify: failed to verify", "OperationError");
951+
return JS::Result<bool>(JS::Error());
952+
}
953+
954+
// 5. Let params be the EC domain parameters associated with key.
955+
// 6. If the namedCurve attribute of the [[algorithm]] internal slot of key is "P-256", "P-384" or "P-521":
956+
// 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.
957+
// Otherwise, the namedCurve attribute of the [[algorithm]] internal slot of key is a value specified in an applicable specification:
958+
// 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.
959+
auto keySize = curveSize(cx, key);
960+
if (keySize.isErr()) {
961+
return JS::Result<bool>(JS::Error());
962+
}
963+
964+
size_t keySizeInBytes = numBitsToBytes(keySize.unwrap());
965+
966+
auto sig = ECDSA_SIG_new();
967+
auto r = BN_bin2bn(signature.data(), keySizeInBytes, nullptr);
968+
auto s = BN_bin2bn(signature.data() + keySizeInBytes, keySizeInBytes, nullptr);
969+
970+
if (!ECDSA_SIG_set0(sig, r, s)) {
971+
DOMException::raise(cx, "SubtleCrypto.verify: failed to verify", "OperationError");
972+
return JS::Result<bool>(JS::Error());
973+
}
974+
975+
// 7. Let result be a boolean with the value true if the signature is valid and the value false otherwise.
976+
#pragma clang diagnostic push
977+
#pragma clang diagnostic ignored "-Wdeprecated-declarations"
978+
bool result = ECDSA_do_verify(digest.data(), digest.size(), sig, const_cast<EC_KEY*>(ecKey)) == 1;
979+
#pragma clang diagnostic pop
980+
981+
// 8. Return result.
982+
return result;
764983
};
765984
JSObject *CryptoAlgorithmECDSA_Sign_Verify::toObject(JSContext *cx) {
766985
return nullptr;

runtime/js-compute-runtime/host_interface/component/fastly_world_adapter.cpp

Lines changed: 4 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -593,8 +593,10 @@ bool fastly_compute_at_edge_http_resp_header_names_get(
593593
cursor = (uint32_t)next_cursor;
594594
}
595595
cabi_free(buf);
596-
strs = static_cast<fastly_world_string_t *>(cabi_realloc(
597-
strs, str_max * sizeof(fastly_world_string_t), 1, str_cnt * sizeof(fastly_world_string_t)));
596+
if (str_cnt != 0) {
597+
strs = static_cast<fastly_world_string_t *>(cabi_realloc(
598+
strs, str_max * sizeof(fastly_world_string_t), 1, str_cnt * sizeof(fastly_world_string_t)));
599+
}
598600
ret->ptr = strs;
599601
ret->len = str_cnt;
600602
return true;

0 commit comments

Comments
 (0)