From 16eccbd6b07fd4a7e799891a90f77e28d20f4de8 Mon Sep 17 00:00:00 2001 From: Isaac Bremseth Date: Thu, 23 Apr 2026 12:57:03 -0400 Subject: [PATCH 1/5] Add initial placeholders for secp256k1 --- src/workerd/api/crypto/ec.c++ | 67 +++++++++-- src/workerd/api/tests/BUILD.bazel | 9 ++ .../tests/crypto-secp256k1-no-flag-test.js | 83 +++++++++++++ .../api/tests/crypto-secp256k1-test.wd-test | 34 ++++++ .../tests/crypto-secp256k1-with-flag-test.js | 113 ++++++++++++++++++ src/workerd/io/compatibility-date.capnp | 14 +++ 6 files changed, 313 insertions(+), 7 deletions(-) create mode 100644 src/workerd/api/tests/crypto-secp256k1-no-flag-test.js create mode 100644 src/workerd/api/tests/crypto-secp256k1-test.wd-test create mode 100644 src/workerd/api/tests/crypto-secp256k1-with-flag-test.js diff --git a/src/workerd/api/crypto/ec.c++ b/src/workerd/api/crypto/ec.c++ index 9b5117c4c8e..df1b1e280c2 100644 --- a/src/workerd/api/crypto/ec.c++ +++ b/src/workerd/api/crypto/ec.c++ @@ -433,19 +433,54 @@ struct EllipticCurveInfo { uint rsSize; // size of "r" and "s" in the signature }; -EllipticCurveInfo lookupEllipticCurve(kj::StringPtr curveName) { +// Sentinel curveId for secp256k1. BoringSSL does not implement secp256k1, so +// there is no NID_* constant we can use. We pick a negative value (real +// BoringSSL NIDs are non-negative) and dispatch to a dedicated backend at +// every call site that would otherwise hand the curveId to BoringSSL. +// +// TODO(secp256k1): replace this sentinel with a proper backend handle once the +// dispatch path to libsecp256k1 / k256 / similar is wired up. +constexpr int kWorkerdSecp256k1CurveId = -1; + +// BoringSSL-backed curves. The table is used by both the WebCrypto entry points +// (which additionally honour the compatibility flag for secp256k1 via the +// `jsg::Lock&` overload below) and by `fromEcKey()`, which receives an already- +// constructed `EVP_PKEY` from the Node.js crypto API and therefore cannot +// produce a secp256k1 key in the first place. +kj::Maybe lookupBoringSslCurve(kj::StringPtr curveName) { static const std::map registeredCurves{ {"P-256", {"P-256", NID_X9_62_prime256v1, 32}}, {"P-384", {"P-384", NID_secp384r1, 48}}, {"P-521", {"P-521", NID_secp521r1, 66}}, }; - auto iter = registeredCurves.find(curveName); - JSG_REQUIRE(iter != registeredCurves.end(), DOMNotSupportedError, - "Unrecognized or unimplemented EC curve \"", curveName, "\" requested."); + if (iter == registeredCurves.end()) return kj::none; return iter->second; } +EllipticCurveInfo lookupEllipticCurve(kj::StringPtr curveName) { + KJ_IF_SOME(info, lookupBoringSslCurve(curveName)) { + return info; + } + JSG_FAIL_REQUIRE(DOMNotSupportedError, "Unrecognized or unimplemented EC curve \"", curveName, + "\" requested."); +} + +EllipticCurveInfo lookupEllipticCurve(jsg::Lock& js, kj::StringPtr curveName) { + KJ_IF_SOME(info, lookupBoringSslCurve(curveName)) { + return info; + } + + // secp256k1 is gated behind a compatibility flag while the backend is landing. + if (FeatureFlags::get(js).getSecp256k1EcdsaCurve() && + strcasecmp(curveName.cStr(), "secp256k1") == 0) { + return EllipticCurveInfo{"secp256k1"_kj, kWorkerdSecp256k1CurveId, 32}; + } + + JSG_FAIL_REQUIRE(DOMNotSupportedError, "Unrecognized or unimplemented EC curve \"", curveName, + "\" requested."); +} + kj::OneOf, CryptoKeyPair> EllipticKey::generateElliptic(jsg::Lock& js, kj::StringPtr normalizedName, SubtleCrypto::GenerateKeyAlgorithm&& algorithm, @@ -455,7 +490,14 @@ kj::OneOf, CryptoKeyPair> EllipticKey::generateElliptic(jsg: kj::StringPtr namedCurve = JSG_REQUIRE_NONNULL( algorithm.namedCurve, TypeError, "Missing field \"namedCurve\" in \"algorithm\"."); - auto [normalizedNamedCurve, curveId, rsSize] = lookupEllipticCurve(namedCurve); + auto [normalizedNamedCurve, curveId, rsSize] = lookupEllipticCurve(js, namedCurve); + + // TODO(secp256k1): once a backend is wired up, dispatch generation here + // instead of falling through to BoringSSL, which does not implement the + // secp256k1 curve. + JSG_REQUIRE(curveId != kWorkerdSecp256k1CurveId, DOMNotSupportedError, + "Key generation for \"secp256k1\" is not yet implemented. The curve is recognized but the " + "backend is not available in this build."); auto keyAlgorithm = CryptoKey::EllipticKeyAlgorithm{ normalizedName, @@ -700,7 +742,12 @@ kj::Own CryptoKey::Impl::importEcdsa(jsg::Lock& js, kj::StringPtr namedCurve = JSG_REQUIRE_NONNULL( algorithm.namedCurve, TypeError, "Missing field \"namedCurve\" in \"algorithm\"."); - auto [normalizedNamedCurve, curveId, rsSize] = lookupEllipticCurve(namedCurve); + auto [normalizedNamedCurve, curveId, rsSize] = lookupEllipticCurve(js, namedCurve); + + // TODO(secp256k1): dispatch import to the dedicated backend. + JSG_REQUIRE(curveId != kWorkerdSecp256k1CurveId, DOMNotSupportedError, + "Key import for \"secp256k1\" is not yet implemented. The curve is recognized but the " + "backend is not available in this build."); auto importedKey = [&, curveId = curveId] { if (format != "raw") { @@ -758,7 +805,13 @@ kj::Own CryptoKey::Impl::importEcdh(jsg::Lock& js, kj::StringPtr namedCurve = JSG_REQUIRE_NONNULL( algorithm.namedCurve, TypeError, "Missing field \"namedCurve\" in \"algorithm\"."); - auto [normalizedNamedCurve, curveId, rsSize] = lookupEllipticCurve(namedCurve); + auto [normalizedNamedCurve, curveId, rsSize] = lookupEllipticCurve(js, namedCurve); + + // The secp256k1 compatibility flag scopes support to ECDSA only; ECDH over + // secp256k1 is not part of this work. Reject explicitly rather than falling + // through to the BoringSSL path with a sentinel curveId. + JSG_REQUIRE(curveId != kWorkerdSecp256k1CurveId, DOMNotSupportedError, + "ECDH is not supported for curve \"secp256k1\"."); auto importedKey = [&, curveId = curveId] { auto strictCrypto = FeatureFlags::get(js).getStrictCrypto(); diff --git a/src/workerd/api/tests/BUILD.bazel b/src/workerd/api/tests/BUILD.bazel index 1bee9496b81..a5b9ea122ca 100644 --- a/src/workerd/api/tests/BUILD.bazel +++ b/src/workerd/api/tests/BUILD.bazel @@ -7,6 +7,15 @@ wd_test( data = ["structuredclone-error-serialize-test.js"], ) +wd_test( + src = "crypto-secp256k1-test.wd-test", + args = ["--experimental"], + data = [ + "crypto-secp256k1-no-flag-test.js", + "crypto-secp256k1-with-flag-test.js", + ], +) + wd_test( src = "deserialize-hardening-test.wd-test", args = ["--experimental"], diff --git a/src/workerd/api/tests/crypto-secp256k1-no-flag-test.js b/src/workerd/api/tests/crypto-secp256k1-no-flag-test.js new file mode 100644 index 00000000000..2ccf3d080a2 --- /dev/null +++ b/src/workerd/api/tests/crypto-secp256k1-no-flag-test.js @@ -0,0 +1,83 @@ +// Copyright (c) 2026 Cloudflare, Inc. +// Licensed under the Apache 2.0 license found in the LICENSE file or at: +// https://opensource.org/licenses/Apache-2.0 +// +// secp256k1 ECDSA — behaviour when the `secp256k1_ecdsa_curve` compatibility +// flag is NOT set (the default today). The curve is unrecognized by +// `lookupEllipticCurve` and every entry point rejects with the pre-existing +// "unrecognized or unimplemented EC curve" message. +// +// This is a regression guard: if someone accidentally unconditionally +// registers secp256k1 (bypassing the flag), or changes the error message, +// these assertions will catch it. + +import { rejects, strictEqual, ok } from 'node:assert'; + +export const generateKeyRejected = { + async test() { + await rejects( + crypto.subtle.generateKey( + { name: 'ECDSA', namedCurve: 'secp256k1' }, + true, + ['sign', 'verify'] + ), + (err) => { + strictEqual(err.name, 'NotSupportedError'); + ok( + /Unrecognized or unimplemented EC curve/.test(err.message), + `unexpected message: ${err.message}` + ); + return true; + } + ); + }, +}; + +export const importKeyRawRejected = { + async test() { + await rejects( + crypto.subtle.importKey( + 'raw', + new Uint8Array(33), + { name: 'ECDSA', namedCurve: 'secp256k1' }, + true, + ['verify'] + ), + (err) => { + strictEqual(err.name, 'NotSupportedError'); + ok( + /Unrecognized or unimplemented EC curve/.test(err.message), + `unexpected message: ${err.message}` + ); + return true; + } + ); + }, +}; + +export const importKeyJwkRejected = { + async test() { + await rejects( + crypto.subtle.importKey( + 'jwk', + { + kty: 'EC', + crv: 'secp256k1', + x: 'AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA', + y: 'AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA', + }, + { name: 'ECDSA', namedCurve: 'secp256k1' }, + true, + ['verify'] + ), + (err) => { + strictEqual(err.name, 'NotSupportedError'); + ok( + /Unrecognized or unimplemented EC curve/.test(err.message), + `unexpected message: ${err.message}` + ); + return true; + } + ); + }, +}; diff --git a/src/workerd/api/tests/crypto-secp256k1-test.wd-test b/src/workerd/api/tests/crypto-secp256k1-test.wd-test new file mode 100644 index 00000000000..bb69e9e68d2 --- /dev/null +++ b/src/workerd/api/tests/crypto-secp256k1-test.wd-test @@ -0,0 +1,34 @@ +using Workerd = import "/workerd/workerd.capnp"; + +# secp256k1 ECDSA support tests. Two services because the compatibility flag +# is per-worker, and each service has its own JS file so assertions only run +# against the scenario they apply to. + +const unitTests :Workerd.Config = ( + services = [ + # Flag disabled: exercises the "unrecognized curve" regression path. + ( name = "crypto-secp256k1-no-flag", + worker = ( + modules = [ + (name = "worker", esModule = embed "crypto-secp256k1-no-flag-test.js") + ], + compatibilityFlags = ["nodejs_compat"], + ) + ), + # Flag enabled but backend not wired: exercises the "recognized curve, + # backend missing" path. Once the backend lands the test assertions flip + # from "asserts failure" to "asserts success". + ( name = "crypto-secp256k1-with-flag", + worker = ( + modules = [ + (name = "worker", esModule = embed "crypto-secp256k1-with-flag-test.js") + ], + compatibilityFlags = [ + "nodejs_compat", + "experimental", + "secp256k1_ecdsa_curve", + ], + ) + ), + ], +); diff --git a/src/workerd/api/tests/crypto-secp256k1-with-flag-test.js b/src/workerd/api/tests/crypto-secp256k1-with-flag-test.js new file mode 100644 index 00000000000..30c1c1972cd --- /dev/null +++ b/src/workerd/api/tests/crypto-secp256k1-with-flag-test.js @@ -0,0 +1,113 @@ +// Copyright (c) 2026 Cloudflare, Inc. +// Licensed under the Apache 2.0 license found in the LICENSE file or at: +// https://opensource.org/licenses/Apache-2.0 +// +// secp256k1 ECDSA — behaviour when the `secp256k1_ecdsa_curve` compatibility +// flag IS set but no backend is wired up yet. The curve is recognized by +// `lookupEllipticCurve` (so a different error message) but every entry point +// rejects with the stub "backend not available" message. +// +// When the backend lands, flip these assertions from "asserts failure" to +// "asserts success" (generate/import/sign/verify round-trips). + +import { rejects, strictEqual, ok } from 'node:assert'; + +export const generateKeyStubs = { + async test() { + await rejects( + crypto.subtle.generateKey( + { name: 'ECDSA', namedCurve: 'secp256k1' }, + true, + ['sign', 'verify'] + ), + (err) => { + strictEqual(err.name, 'NotSupportedError'); + ok( + /Key generation for "secp256k1" is not yet implemented/.test( + err.message + ), + `unexpected message: ${err.message}` + ); + return true; + } + ); + }, +}; + +export const importKeyJwkStubs = { + async test() { + await rejects( + crypto.subtle.importKey( + 'jwk', + { + kty: 'EC', + crv: 'secp256k1', + x: 'AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA', + y: 'AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA', + }, + { name: 'ECDSA', namedCurve: 'secp256k1' }, + true, + ['verify'] + ), + (err) => { + strictEqual(err.name, 'NotSupportedError'); + ok( + /Key import for "secp256k1" is not yet implemented/.test(err.message), + `unexpected message: ${err.message}` + ); + return true; + } + ); + }, +}; + +export const importKeyRawStubs = { + async test() { + await rejects( + crypto.subtle.importKey( + 'raw', + new Uint8Array(33), + { name: 'ECDSA', namedCurve: 'secp256k1' }, + true, + ['verify'] + ), + (err) => { + strictEqual(err.name, 'NotSupportedError'); + ok( + /Key import for "secp256k1" is not yet implemented/.test(err.message), + `unexpected message: ${err.message}` + ); + return true; + } + ); + }, +}; + +export const ecdhRejectedExplicitly = { + // The secp256k1 compat flag is scoped to ECDSA only. ECDH over secp256k1 + // must fail with a specific error even when the flag is on. + async test() { + await rejects( + crypto.subtle.importKey( + 'jwk', + { + kty: 'EC', + crv: 'secp256k1', + x: 'AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA', + y: 'AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA', + }, + { name: 'ECDH', namedCurve: 'secp256k1' }, + true, + ['deriveBits'] + ), + (err) => { + strictEqual(err.name, 'NotSupportedError'); + ok( + /ECDH is not supported for curve "secp256k1"/.test(err.message), + `unexpected message: ${err.message}` + ); + return true; + } + ); + }, +}; diff --git a/src/workerd/io/compatibility-date.capnp b/src/workerd/io/compatibility-date.capnp index 3fba35ade15..945e0be536c 100644 --- a/src/workerd/io/compatibility-date.capnp +++ b/src/workerd/io/compatibility-date.capnp @@ -1530,4 +1530,18 @@ struct CompatibilityFlags @0x8f8c1b68151b6cef { # The rollback function is bundled into the RPC call for the engine to invoke # as a compensation action on failure. Without this flag, the step object is # passed through unwrapped and .rollback() is not available. + + secp256k1EcdsaCurve @176 :Bool + $compatEnableFlag("secp256k1_ecdsa_curve") + $experimental; + # When enabled, adds "secp256k1" as a supported namedCurve for the WebCrypto + # ECDSA algorithm (crypto.subtle.generateKey / importKey / sign / verify / + # exportKey). secp256k1 is the elliptic curve used by Bitcoin, Ethereum, and + # other EVM-compatible chains (see SEC 2 v1.0 / RFC 5480). BoringSSL does not + # implement secp256k1; when this flag is enabled, curve operations are routed + # to a dedicated secp256k1 backend. The curve is usable anywhere the existing + # NIST curves (P-256 / P-384 / P-521) are usable: keys may be generated, + # imported (JWK with `"crv": "secp256k1"`, raw, spki, pkcs8), exported, and + # used to sign and verify. This flag is experimental while the backend is + # being landed; once stable, a $compatEnableDate will be set. } From 009f3782c271e2a1f7c2b83aa8425659f8e7e551 Mon Sep 17 00:00:00 2001 From: Isaac Bremseth Date: Thu, 23 Apr 2026 15:54:19 -0400 Subject: [PATCH 2/5] add secp256k1 dep --- build/BUILD.libsecp256k1 | 82 ++++++++++++++++++++++++++++++ build/deps/deps.jsonc | 13 +++++ build/deps/gen/deps.MODULE.bazel | 11 +++++ src/workerd/api/crypto/ec.c++ | 85 +++++++++++++++++++------------- src/workerd/io/BUILD.bazel | 1 + 5 files changed, 159 insertions(+), 33 deletions(-) create mode 100644 build/BUILD.libsecp256k1 diff --git a/build/BUILD.libsecp256k1 b/build/BUILD.libsecp256k1 new file mode 100644 index 00000000000..43752f5d0ec --- /dev/null +++ b/build/BUILD.libsecp256k1 @@ -0,0 +1,82 @@ +load("@rules_cc//cc:cc_library.bzl", "cc_library") + +# Bazel BUILD file for libsecp256k1 (Bitcoin Core's reference implementation). +# +# Upstream normally uses autotools. Its `./configure` step picks field / scalar +# widths based on compiler features and writes a `libsecp256k1-config.h` that +# `src/secp256k1.c` includes. We skip all of that and set the equivalent +# `-D` flags directly. +# +# Source layout rationale: `src/secp256k1.c` is the only real translation unit — +# every other `.c` under src/ is either test / benchmark scaffolding or a +# precomputed-table generator. The two files that *are* compiled into the +# library in addition to `secp256k1.c` are the prebuilt tables: +# `src/precomputed_ecmult.c` and `src/precomputed_ecmult_gen.c`. Upstream +# checks these in so downstream builds don't need Python / codegen at build +# time. +# +# Modules such as recovery / ECDH / schnorrsig are deliberately NOT enabled +# here. The initial scope for workerd is ECDSA over secp256k1 (sign / verify / +# key handling), which the core library provides. When a caller needs +# `secp256k1_ecdsa_recover` (Ethereum's ecrecover) or similar, flip on the +# matching `ENABLE_MODULE_*` define below and list any extra sources from +# `src/modules//Makefile.am.include`. + +cc_library( + name = "secp256k1", + srcs = [ + "src/precomputed_ecmult.c", + "src/precomputed_ecmult_gen.c", + "src/secp256k1.c", + ], + hdrs = [ + "include/secp256k1.h", + "include/secp256k1_preallocated.h", + ], + copts = [ + # Silence upstream warnings without masking ours. libsecp256k1 is + # extensively reviewed and using it as-is keeps the cert story clean. + "-Wno-unused-function", + "-Wno-nonnull", + ], + # `local_defines` applies these flags only when compiling this target's + # own sources, not when compiling anything that depends on us. This matches + # how `BUILD.zlib` handles its configuration knobs — they are internal to + # the library's translation units and should not leak onto consumers. + local_defines = [ + # Standard flag when building the library as a static archive. + # Equivalent to what autotools' configure would have set. + "SECP256K1_STATIC", + # Field representation. On 64-bit targets (all workerd platforms) + # libsecp256k1's 5x52 representation using native `__int128` is + # dramatically faster than the 10x26 fallback. workerd only supports + # 64-bit targets (see README's supported platforms list). + "USE_FIELD_5X52=1", + # Scalar representation to match the 64-bit field. + "USE_SCALAR_4X64=1", + # Use native 128-bit integer type (__int128) — required for 5x52 / + # 4x64. Clang and GCC both support this on every 64-bit target + # workerd builds for. + "USE_FORCE_WIDEMUL_INT128=1", + # Ecmult window size for signed point multiplication. Upstream default + # when autotools can't probe otherwise; trades a few KB of code size + # for performance. + "ECMULT_WINDOW_SIZE=15", + # Precomputed generator-multiplication table size. 4 is the upstream + # default and produces a ~64KB table. + "ECMULT_GEN_PREC_BITS=4", + ], + # Public include prefix so consumers write `#include `. + strip_include_prefix = "include", + # Private headers (everything under src/) need to be visible to the + # compiler when building src/secp256k1.c, but are NOT part of the public + # API. `textual_hdrs` is the idiomatic way to express "include these from + # .c sources but don't expose them to downstream consumers". + # + # Note: libsecp256k1 splits its logic across `*.h` (declarations) and + # `*_impl.h` (inline implementations that get `#include`d from the + # single `src/secp256k1.c` translation unit). Both are plain headers, so + # one glob covers everything. + textual_hdrs = glob(["src/**/*.h"]), + visibility = ["//visibility:public"], +) diff --git a/build/deps/deps.jsonc b/build/deps/deps.jsonc index e92e34a1422..38f41a7105b 100644 --- a/build/deps/deps.jsonc +++ b/build/deps/deps.jsonc @@ -113,6 +113,19 @@ "build_file": "//:build/BUILD.simdutf", "file_regex": "singleheader.zip" }, + // libsecp256k1 is Bitcoin Core's optimized implementation of ECDSA (and + // more) over the secp256k1 curve. BoringSSL does not implement secp256k1, + // so workerd vendors libsecp256k1 separately and routes WebCrypto ECDSA + // operations for `namedCurve: "secp256k1"` through it. The compat flag + // `secp256k1_ecdsa_curve` gates user-visible exposure. + { + "name": "libsecp256k1", + "type": "github_tarball", + "owner": "bitcoin-core", + "repo": "secp256k1", + "branch": "master", + "build_file": "//:build/BUILD.libsecp256k1" + }, // In theory this should match the version specified in V8 DEPS, but in practice we should get // the latest commit – zlib is very stable and its API has not changed in a long time, but we // want to pick up any bug fixes, security patches and performance improvements more quickly diff --git a/build/deps/gen/deps.MODULE.bazel b/build/deps/gen/deps.MODULE.bazel index 62d0b23dc74..4080763eb1c 100644 --- a/build/deps/gen/deps.MODULE.bazel +++ b/build/deps/gen/deps.MODULE.bazel @@ -83,6 +83,17 @@ archive_override( url = "https://github.com/google/highway/tarball/84379d1c73de9681b54fbe1c035a23c7bd5d272d", ) +# libsecp256k1 +http.archive( + name = "libsecp256k1", + build_file = "//:build/BUILD.libsecp256k1", + sha256 = "8ab7b72826f8c843e3ce25ca0d33f76cf1f96a4418ebd0168a33a2281d23c5de", + strip_prefix = "bitcoin-core-secp256k1-ea174fe", + type = "tgz", + url = "https://github.com/bitcoin-core/secp256k1/tarball/ea174fe045e1832548cd3b7090958afe9573ad2b", +) +use_repo(http, "libsecp256k1") + # nbytes http.archive( name = "nbytes", diff --git a/src/workerd/api/crypto/ec.c++ b/src/workerd/api/crypto/ec.c++ index df1b1e280c2..0615021999e 100644 --- a/src/workerd/api/crypto/ec.c++ +++ b/src/workerd/api/crypto/ec.c++ @@ -427,31 +427,44 @@ class EllipticKey final: public AsymmetricKeyCryptoKeyImpl { uint rsSize; }; +// Identifies which backend owns a given curve. WebCrypto's ECDSA surface is +// parametric over the curve, but not every curve is handled by the same +// implementation: +// +// - `BoringSsl` — the NIST curves (P-256/P-384/P-521). BoringSSL provides +// full curve arithmetic, and `opensslCurveId` holds the NID that its +// `EC_KEY_*` / `EVP_PKEY_*` APIs key on. +// - `Libsecp256k1` — secp256k1 (used by Bitcoin / Ethereum / EVM chains). +// BoringSSL does not implement this curve, so operations are routed to +// libsecp256k1. `opensslCurveId` is not meaningful in this case. +// +// Every call site that dispatches a curve operation should `switch` on the +// backend, not compare `opensslCurveId` against sentinel values. This keeps +// new backends grep-able and makes missing branches a compiler warning. +enum class CurveBackend { + BoringSsl, + Libsecp256k1, +}; + struct EllipticCurveInfo { kj::StringPtr normalizedName; - int opensslCurveId; - uint rsSize; // size of "r" and "s" in the signature + CurveBackend backend; + int opensslCurveId; // Only meaningful when `backend == BoringSsl`. Set to + // zero for non-BoringSSL backends to avoid any accidental + // handoff to an `EC_KEY_new_by_curve_name(0)` call site. + uint rsSize; // Size of "r" and "s" in the signature. }; -// Sentinel curveId for secp256k1. BoringSSL does not implement secp256k1, so -// there is no NID_* constant we can use. We pick a negative value (real -// BoringSSL NIDs are non-negative) and dispatch to a dedicated backend at -// every call site that would otherwise hand the curveId to BoringSSL. -// -// TODO(secp256k1): replace this sentinel with a proper backend handle once the -// dispatch path to libsecp256k1 / k256 / similar is wired up. -constexpr int kWorkerdSecp256k1CurveId = -1; - // BoringSSL-backed curves. The table is used by both the WebCrypto entry points // (which additionally honour the compatibility flag for secp256k1 via the // `jsg::Lock&` overload below) and by `fromEcKey()`, which receives an already- // constructed `EVP_PKEY` from the Node.js crypto API and therefore cannot -// produce a secp256k1 key in the first place. +// produce a non-BoringSSL curve in the first place. kj::Maybe lookupBoringSslCurve(kj::StringPtr curveName) { static const std::map registeredCurves{ - {"P-256", {"P-256", NID_X9_62_prime256v1, 32}}, - {"P-384", {"P-384", NID_secp384r1, 48}}, - {"P-521", {"P-521", NID_secp521r1, 66}}, + {"P-256", {"P-256", CurveBackend::BoringSsl, NID_X9_62_prime256v1, 32}}, + {"P-384", {"P-384", CurveBackend::BoringSsl, NID_secp384r1, 48}}, + {"P-521", {"P-521", CurveBackend::BoringSsl, NID_secp521r1, 66}}, }; auto iter = registeredCurves.find(curveName); if (iter == registeredCurves.end()) return kj::none; @@ -474,7 +487,7 @@ EllipticCurveInfo lookupEllipticCurve(jsg::Lock& js, kj::StringPtr curveName) { // secp256k1 is gated behind a compatibility flag while the backend is landing. if (FeatureFlags::get(js).getSecp256k1EcdsaCurve() && strcasecmp(curveName.cStr(), "secp256k1") == 0) { - return EllipticCurveInfo{"secp256k1"_kj, kWorkerdSecp256k1CurveId, 32}; + return EllipticCurveInfo{"secp256k1"_kj, CurveBackend::Libsecp256k1, 0, 32}; } JSG_FAIL_REQUIRE(DOMNotSupportedError, "Unrecognized or unimplemented EC curve \"", curveName, @@ -490,14 +503,14 @@ kj::OneOf, CryptoKeyPair> EllipticKey::generateElliptic(jsg: kj::StringPtr namedCurve = JSG_REQUIRE_NONNULL( algorithm.namedCurve, TypeError, "Missing field \"namedCurve\" in \"algorithm\"."); - auto [normalizedNamedCurve, curveId, rsSize] = lookupEllipticCurve(js, namedCurve); + auto [normalizedNamedCurve, backend, curveId, rsSize] = lookupEllipticCurve(js, namedCurve); - // TODO(secp256k1): once a backend is wired up, dispatch generation here - // instead of falling through to BoringSSL, which does not implement the - // secp256k1 curve. - JSG_REQUIRE(curveId != kWorkerdSecp256k1CurveId, DOMNotSupportedError, - "Key generation for \"secp256k1\" is not yet implemented. The curve is recognized but the " - "backend is not available in this build."); + // TODO(secp256k1): add a `case CurveBackend::Libsecp256k1` branch that + // generates a key via libsecp256k1 once the backend is wired up. + JSG_REQUIRE(backend == CurveBackend::BoringSsl, DOMNotSupportedError, "Key generation for \"", + normalizedNamedCurve, + "\" is not yet implemented. The curve is recognized but the backend is not available in " + "this build."); auto keyAlgorithm = CryptoKey::EllipticKeyAlgorithm{ normalizedName, @@ -742,12 +755,14 @@ kj::Own CryptoKey::Impl::importEcdsa(jsg::Lock& js, kj::StringPtr namedCurve = JSG_REQUIRE_NONNULL( algorithm.namedCurve, TypeError, "Missing field \"namedCurve\" in \"algorithm\"."); - auto [normalizedNamedCurve, curveId, rsSize] = lookupEllipticCurve(js, namedCurve); + auto [normalizedNamedCurve, backend, curveId, rsSize] = lookupEllipticCurve(js, namedCurve); - // TODO(secp256k1): dispatch import to the dedicated backend. - JSG_REQUIRE(curveId != kWorkerdSecp256k1CurveId, DOMNotSupportedError, - "Key import for \"secp256k1\" is not yet implemented. The curve is recognized but the " - "backend is not available in this build."); + // TODO(secp256k1): add a `case CurveBackend::Libsecp256k1` branch that + // imports a key via libsecp256k1 once the backend is wired up. + JSG_REQUIRE(backend == CurveBackend::BoringSsl, DOMNotSupportedError, "Key import for \"", + normalizedNamedCurve, + "\" is not yet implemented. The curve is recognized but the backend is not available in " + "this build."); auto importedKey = [&, curveId = curveId] { if (format != "raw") { @@ -805,13 +820,13 @@ kj::Own CryptoKey::Impl::importEcdh(jsg::Lock& js, kj::StringPtr namedCurve = JSG_REQUIRE_NONNULL( algorithm.namedCurve, TypeError, "Missing field \"namedCurve\" in \"algorithm\"."); - auto [normalizedNamedCurve, curveId, rsSize] = lookupEllipticCurve(js, namedCurve); + auto [normalizedNamedCurve, backend, curveId, rsSize] = lookupEllipticCurve(js, namedCurve); // The secp256k1 compatibility flag scopes support to ECDSA only; ECDH over // secp256k1 is not part of this work. Reject explicitly rather than falling - // through to the BoringSSL path with a sentinel curveId. - JSG_REQUIRE(curveId != kWorkerdSecp256k1CurveId, DOMNotSupportedError, - "ECDH is not supported for curve \"secp256k1\"."); + // through to the BoringSSL path with a non-BoringSSL curve. + JSG_REQUIRE(backend == CurveBackend::BoringSsl, DOMNotSupportedError, + "ECDH is not supported for curve \"", normalizedNamedCurve, "\"."); auto importedKey = [&, curveId = curveId] { auto strictCrypto = FeatureFlags::get(js).getStrictCrypto(); @@ -1265,7 +1280,11 @@ kj::Own fromEcKey(kj::Own key) { curveName = "unknown"; } - auto [normalizedNamedCurve, curveId, rsSize] = lookupEllipticCurve(curveName); + // `fromEcKey()` receives an already-constructed BoringSSL `EVP_PKEY`, so the + // backend is always `BoringSsl` here and the lock-less lookup is the right + // choice. Only `normalizedNamedCurve` and `rsSize` are consumed below; the + // other bindings exist to match the tuple shape. + auto [normalizedNamedCurve, backend, curveId, rsSize] = lookupEllipticCurve(curveName); return kj::heap( AsymmetricKeyData{ diff --git a/src/workerd/io/BUILD.bazel b/src/workerd/io/BUILD.bazel index 2392eb477b2..ddb0e01600d 100644 --- a/src/workerd/io/BUILD.bazel +++ b/src/workerd/io/BUILD.bazel @@ -116,6 +116,7 @@ wd_cc_library( "@capnp-cpp//src/capnp:capnp-rpc", "@capnp-cpp//src/capnp/compat:http-over-capnp", "@capnp-cpp//src/kj:kj-async", + "@libsecp256k1//:secp256k1", "@ncrypto", "@ssl", ], From 7d437b80100f83aac6b14a74218fcd813d8e2d8e Mon Sep 17 00:00:00 2001 From: Isaac Bremseth Date: Fri, 24 Apr 2026 09:29:20 -0400 Subject: [PATCH 3/5] Wire up key export and verify for secp256k1 --- src/workerd/api/crypto/ec.c++ | 129 +++++------ src/workerd/api/crypto/secp256k1-key.c++ | 176 +++++++++++++++ src/workerd/api/crypto/secp256k1-key.h | 87 +++++++ .../tests/crypto-secp256k1-with-flag-test.js | 212 +++++++++++++++--- 4 files changed, 492 insertions(+), 112 deletions(-) create mode 100644 src/workerd/api/crypto/secp256k1-key.c++ create mode 100644 src/workerd/api/crypto/secp256k1-key.h diff --git a/src/workerd/api/crypto/ec.c++ b/src/workerd/api/crypto/ec.c++ index 0615021999e..f3f838c4278 100644 --- a/src/workerd/api/crypto/ec.c++ +++ b/src/workerd/api/crypto/ec.c++ @@ -6,6 +6,7 @@ #include "impl.h" #include "keys.h" +#include "secp256k1-key.h" #include #include @@ -427,71 +428,40 @@ class EllipticKey final: public AsymmetricKeyCryptoKeyImpl { uint rsSize; }; -// Identifies which backend owns a given curve. WebCrypto's ECDSA surface is -// parametric over the curve, but not every curve is handled by the same -// implementation: -// -// - `BoringSsl` — the NIST curves (P-256/P-384/P-521). BoringSSL provides -// full curve arithmetic, and `opensslCurveId` holds the NID that its -// `EC_KEY_*` / `EVP_PKEY_*` APIs key on. -// - `Libsecp256k1` — secp256k1 (used by Bitcoin / Ethereum / EVM chains). -// BoringSSL does not implement this curve, so operations are routed to -// libsecp256k1. `opensslCurveId` is not meaningful in this case. -// -// Every call site that dispatches a curve operation should `switch` on the -// backend, not compare `opensslCurveId` against sentinel values. This keeps -// new backends grep-able and makes missing branches a compiler warning. -enum class CurveBackend { - BoringSsl, - Libsecp256k1, -}; - struct EllipticCurveInfo { kj::StringPtr normalizedName; - CurveBackend backend; - int opensslCurveId; // Only meaningful when `backend == BoringSsl`. Set to - // zero for non-BoringSSL backends to avoid any accidental - // handoff to an `EC_KEY_new_by_curve_name(0)` call site. + int opensslCurveId; // BoringSSL NID. Meaningless for curves (like secp256k1) that are not + // served by BoringSSL; the dispatch sites branch on `normalizedName` + // before consuming this field. uint rsSize; // Size of "r" and "s" in the signature. }; -// BoringSSL-backed curves. The table is used by both the WebCrypto entry points -// (which additionally honour the compatibility flag for secp256k1 via the -// `jsg::Lock&` overload below) and by `fromEcKey()`, which receives an already- -// constructed `EVP_PKEY` from the Node.js crypto API and therefore cannot -// produce a non-BoringSSL curve in the first place. -kj::Maybe lookupBoringSslCurve(kj::StringPtr curveName) { +EllipticCurveInfo lookupEllipticCurve(kj::StringPtr curveName) { static const std::map registeredCurves{ - {"P-256", {"P-256", CurveBackend::BoringSsl, NID_X9_62_prime256v1, 32}}, - {"P-384", {"P-384", CurveBackend::BoringSsl, NID_secp384r1, 48}}, - {"P-521", {"P-521", CurveBackend::BoringSsl, NID_secp521r1, 66}}, + {"P-256", {"P-256"_kj, NID_X9_62_prime256v1, 32}}, + {"P-384", {"P-384"_kj, NID_secp384r1, 48}}, + {"P-521", {"P-521"_kj, NID_secp521r1, 66}}, }; + auto iter = registeredCurves.find(curveName); - if (iter == registeredCurves.end()) return kj::none; + JSG_REQUIRE(iter != registeredCurves.end(), DOMNotSupportedError, + "Unrecognized or unimplemented EC curve \"", curveName, "\" requested."); return iter->second; } -EllipticCurveInfo lookupEllipticCurve(kj::StringPtr curveName) { - KJ_IF_SOME(info, lookupBoringSslCurve(curveName)) { - return info; - } - JSG_FAIL_REQUIRE(DOMNotSupportedError, "Unrecognized or unimplemented EC curve \"", curveName, - "\" requested."); -} - +// Overload that additionally honours the `secp256k1_ecdsa_curve` compatibility +// flag. secp256k1 is not available via BoringSSL, so its `opensslCurveId` is a +// placeholder; every call site that would hand the id to BoringSSL must first +// short-circuit on the curve name. This overload lives alongside the lock-less +// version because `fromEcKey()` resolves the curve name from an already-built +// BoringSSL `EVP_PKEY`, which trivially cannot be secp256k1. EllipticCurveInfo lookupEllipticCurve(jsg::Lock& js, kj::StringPtr curveName) { - KJ_IF_SOME(info, lookupBoringSslCurve(curveName)) { - return info; - } - - // secp256k1 is gated behind a compatibility flag while the backend is landing. + // secp256k1 is not available via BoringSSL, so we route it to libsecp256k1. if (FeatureFlags::get(js).getSecp256k1EcdsaCurve() && strcasecmp(curveName.cStr(), "secp256k1") == 0) { - return EllipticCurveInfo{"secp256k1"_kj, CurveBackend::Libsecp256k1, 0, 32}; + return {"secp256k1"_kj, 0, 32}; } - - JSG_FAIL_REQUIRE(DOMNotSupportedError, "Unrecognized or unimplemented EC curve \"", curveName, - "\" requested."); + return lookupEllipticCurve(curveName); } kj::OneOf, CryptoKeyPair> EllipticKey::generateElliptic(jsg::Lock& js, @@ -503,14 +473,13 @@ kj::OneOf, CryptoKeyPair> EllipticKey::generateElliptic(jsg: kj::StringPtr namedCurve = JSG_REQUIRE_NONNULL( algorithm.namedCurve, TypeError, "Missing field \"namedCurve\" in \"algorithm\"."); - auto [normalizedNamedCurve, backend, curveId, rsSize] = lookupEllipticCurve(js, namedCurve); + auto [normalizedNamedCurve, curveId, rsSize] = lookupEllipticCurve(js, namedCurve); - // TODO(secp256k1): add a `case CurveBackend::Libsecp256k1` branch that - // generates a key via libsecp256k1 once the backend is wired up. - JSG_REQUIRE(backend == CurveBackend::BoringSsl, DOMNotSupportedError, "Key generation for \"", - normalizedNamedCurve, - "\" is not yet implemented. The curve is recognized but the backend is not available in " - "this build."); + // secp256k1 is not available via BoringSSL. Key generation via libsecp256k1 + // is not yet wired up, so reject with a clear error for now. + JSG_REQUIRE(strcasecmp(normalizedNamedCurve.cStr(), "secp256k1") != 0, DOMNotSupportedError, + "Key generation for \"secp256k1\" is not yet implemented. The curve is recognized but the " + "backend is not available in this build."); auto keyAlgorithm = CryptoKey::EllipticKeyAlgorithm{ normalizedName, @@ -755,14 +724,25 @@ kj::Own CryptoKey::Impl::importEcdsa(jsg::Lock& js, kj::StringPtr namedCurve = JSG_REQUIRE_NONNULL( algorithm.namedCurve, TypeError, "Missing field \"namedCurve\" in \"algorithm\"."); - auto [normalizedNamedCurve, backend, curveId, rsSize] = lookupEllipticCurve(js, namedCurve); + auto [normalizedNamedCurve, curveId, rsSize] = lookupEllipticCurve(js, namedCurve); + + // secp256k1 is not available via BoringSSL, so we route it to libsecp256k1. + // For now only the "raw" public key import format is implemented — that's + // the shape wallets, chain indexers, and signature verifiers actually hand + // us in the EVM world. JWK / SPKI / PKCS8 will land in a follow-up. + if (strcasecmp(normalizedNamedCurve.cStr(), "secp256k1") == 0) { + JSG_REQUIRE(format == "raw", DOMNotSupportedError, "Key import format \"", format, + "\" is not yet implemented for secp256k1 (only \"raw\" public keys are supported)."); - // TODO(secp256k1): add a `case CurveBackend::Libsecp256k1` branch that - // imports a key via libsecp256k1 once the backend is wired up. - JSG_REQUIRE(backend == CurveBackend::BoringSsl, DOMNotSupportedError, "Key import for \"", - normalizedNamedCurve, - "\" is not yet implemented. The curve is recognized but the backend is not available in " - "this build."); + JSG_REQUIRE(keyData.is>(), DOMDataError, + "Expected raw secp256k1 key but got a JSON Web Key."); + + auto usages = CryptoKeyUsageSet::validate(normalizedName, + CryptoKeyUsageSet::Context::importPublic, keyUsages, CryptoKeyUsageSet::verify()); + + return Secp256k1Key::importRawPublic(js, keyData.get>().asPtr(), + CryptoKey::EllipticKeyAlgorithm{normalizedName, normalizedNamedCurve}, extractable, usages); + } auto importedKey = [&, curveId = curveId] { if (format != "raw") { @@ -820,13 +800,13 @@ kj::Own CryptoKey::Impl::importEcdh(jsg::Lock& js, kj::StringPtr namedCurve = JSG_REQUIRE_NONNULL( algorithm.namedCurve, TypeError, "Missing field \"namedCurve\" in \"algorithm\"."); - auto [normalizedNamedCurve, backend, curveId, rsSize] = lookupEllipticCurve(js, namedCurve); + auto [normalizedNamedCurve, curveId, rsSize] = lookupEllipticCurve(js, namedCurve); // The secp256k1 compatibility flag scopes support to ECDSA only; ECDH over - // secp256k1 is not part of this work. Reject explicitly rather than falling - // through to the BoringSSL path with a non-BoringSSL curve. - JSG_REQUIRE(backend == CurveBackend::BoringSsl, DOMNotSupportedError, - "ECDH is not supported for curve \"", normalizedNamedCurve, "\"."); + // secp256k1 is not part of this work and would in any case need to go + // through libsecp256k1 rather than BoringSSL. + JSG_REQUIRE(strcasecmp(normalizedNamedCurve.cStr(), "secp256k1") != 0, DOMNotSupportedError, + "ECDH is not supported for curve \"secp256k1\"."); auto importedKey = [&, curveId = curveId] { auto strictCrypto = FeatureFlags::get(js).getStrictCrypto(); @@ -836,7 +816,7 @@ kj::Own CryptoKey::Impl::importEcdh(jsg::Lock& js, return importAsymmetricForWebCrypto(js, format, kj::mv(keyData), normalizedName, extractable, keyUsages, // Verbose lambda capture needed because: https://bugs.llvm.org/show_bug.cgi?id=35984 - [curveId = curveId, normalizedName = kj::str(normalizedName)]( + [curveId, normalizedName = kj::str(normalizedName)]( SubtleCrypto::JsonWebKey keyDataJwk) -> kj::Own { return ellipticJwkReader(curveId, kj::mv(keyDataJwk), normalizedName); }, @@ -1280,11 +1260,10 @@ kj::Own fromEcKey(kj::Own key) { curveName = "unknown"; } - // `fromEcKey()` receives an already-constructed BoringSSL `EVP_PKEY`, so the - // backend is always `BoringSsl` here and the lock-less lookup is the right - // choice. Only `normalizedNamedCurve` and `rsSize` are consumed below; the - // other bindings exist to match the tuple shape. - auto [normalizedNamedCurve, backend, curveId, rsSize] = lookupEllipticCurve(curveName); + // `fromEcKey()` receives an already-constructed BoringSSL `EVP_PKEY`, so it + // can only produce BoringSSL-supported curves. The lock-less lookup + // overload is the right choice here. + auto [normalizedNamedCurve, curveId, rsSize] = lookupEllipticCurve(curveName); return kj::heap( AsymmetricKeyData{ diff --git a/src/workerd/api/crypto/secp256k1-key.c++ b/src/workerd/api/crypto/secp256k1-key.c++ new file mode 100644 index 00000000000..2a7d5ed0791 --- /dev/null +++ b/src/workerd/api/crypto/secp256k1-key.c++ @@ -0,0 +1,176 @@ +// Copyright (c) 2026 Cloudflare, Inc. +// Licensed under the Apache 2.0 license found in the LICENSE file or at: +// https://opensource.org/licenses/Apache-2.0 + +#include "secp256k1-key.h" + +#include "impl.h" + +#include +#include + +#include + +namespace workerd::api { + +namespace { + +// A singleton libsecp256k1 context used for verify-only operations. +// +// `secp256k1_context_create()` is not cheap (it generates internal lookup +// tables), so the library documentation explicitly recommends creating one +// context per process and reusing it across calls. The context is +// thread-safe for read-only operations like `secp256k1_ecdsa_verify`; +// only randomization and destruction require external synchronization, and +// we do neither after creation. +// +// `SECP256K1_CONTEXT_NONE` is the right flag here — the `_SIGN` / `_VERIFY` +// flags are deprecated no-ops in the current libsecp256k1 API. +const secp256k1_context* getSecp256k1Context() { + static const secp256k1_context* ctx = []() { + secp256k1_context* c = secp256k1_context_create(SECP256K1_CONTEXT_NONE); + KJ_ASSERT(c != nullptr, "failed to create libsecp256k1 context"); + return c; + }(); + return ctx; +} + +// Serialize a `secp256k1_pubkey` to its 33-byte compressed SEC1 form. +// Used for equality comparison and potentially raw export. +kj::Array serializePubkeyCompressed(const secp256k1_pubkey& pubkey) { + auto out = kj::heapArray(33); + size_t outLen = out.size(); + auto ret = secp256k1_ec_pubkey_serialize( + getSecp256k1Context(), out.begin(), &outLen, &pubkey, SECP256K1_EC_COMPRESSED); + KJ_ASSERT(ret == 1, "secp256k1_ec_pubkey_serialize should never fail on a valid parsed pubkey"); + KJ_ASSERT(outLen == 33, "compressed secp256k1 pubkey should always be 33 bytes"); + return out; +} + +// Compute the SHA-256-family digest of `data`, matching WebCrypto's ECDSA +// behaviour where the hash is specified at sign/verify call time. Returns +// the digest bytes (typically 32 bytes for SHA-256, 48 for SHA-384, etc.). +kj::Array computeDigest(kj::StringPtr hashName, kj::ArrayPtr data) { + const EVP_MD* md = lookupDigestAlgorithm(hashName).second; + auto digestCtx = kj::disposeWith(EVP_MD_CTX_new()); + KJ_ASSERT(digestCtx.get() != nullptr); + OSSLCALL(EVP_DigestInit_ex(digestCtx.get(), md, nullptr)); + OSSLCALL(EVP_DigestUpdate(digestCtx.get(), data.begin(), data.size())); + auto out = kj::heapArray(EVP_MD_size(md)); + unsigned int outLen = 0; + OSSLCALL(EVP_DigestFinal_ex(digestCtx.get(), out.begin(), &outLen)); + KJ_ASSERT(outLen == out.size()); + return out; +} + +} // namespace + +Secp256k1Key::Secp256k1Key(secp256k1_pubkey parsedPubkey, + CryptoKey::EllipticKeyAlgorithm keyAlgorithm, + bool extractable, + CryptoKeyUsageSet usages) + : CryptoKey::Impl(extractable, usages), + parsedPubkey(parsedPubkey), + keyAlgorithm(kj::mv(keyAlgorithm)) {} + +kj::Own Secp256k1Key::importRawPublic(jsg::Lock& js, + kj::ArrayPtr keyData, + CryptoKey::EllipticKeyAlgorithm keyAlgorithm, + bool extractable, + CryptoKeyUsageSet usages) { + // SEC1 public key encodings: 33 bytes compressed (0x02/0x03 prefix) or + // 65 bytes uncompressed (0x04 prefix). libsecp256k1's parser accepts + // both formats and validates that the encoded point is actually on the + // secp256k1 curve. + JSG_REQUIRE(keyData.size() == 33 || keyData.size() == 65, DOMDataError, + "Invalid secp256k1 public key length (expected 33 or 65 bytes, got ", keyData.size(), ")."); + + secp256k1_pubkey parsed; + JSG_REQUIRE(secp256k1_ec_pubkey_parse( + getSecp256k1Context(), &parsed, keyData.begin(), keyData.size()) == 1, + DOMDataError, "Invalid secp256k1 public key (failed to parse)."); + + return kj::heap(parsed, kj::mv(keyAlgorithm), extractable, usages); +} + +bool Secp256k1Key::verify(jsg::Lock& js, + SubtleCrypto::SignAlgorithm&& algorithm, + kj::ArrayPtr signature, + kj::ArrayPtr data) const { + // ECDSA verify requires: the hash name (specified at call time, not on the + // key), the signature in "raw" WebCrypto format (r || s, each 32 bytes for + // secp256k1, so 64 bytes total), and the data to verify. + + // 1. Check signature size. WebCrypto ECDSA-secp256k1 signatures are always + // exactly 64 bytes (32-byte r concatenated with 32-byte s). A + // mismatched size is simply a bad signature; per spec we return false + // rather than throwing. + if (signature.size() != 64) { + return false; + } + + // 2. Resolve the hash algorithm. ECDSA is one of the WebCrypto algorithms + // that takes `hash` at verify time, so it MUST be present. + auto hashName = api::getAlgorithmName(JSG_REQUIRE_NONNULL(algorithm.hash, TypeError, + "Missing \"hash\" in algorithm (ECDSA requires the hash algorithm to be specified at call " + "time).")); + + // 3. Hash the message. We delegate the hashing itself to BoringSSL — it + // has SHA-2 / SHA-3 / etc. libsecp256k1 deliberately does not + // implement hashing. + auto digest = computeDigest(hashName, data); + + // 4. Parse the (r || s) concatenated signature into libsecp256k1's + // internal form. `secp256k1_ecdsa_signature_parse_compact` expects + // exactly 64 bytes in big-endian r then s order, which is exactly + // what WebCrypto produces. + secp256k1_ecdsa_signature sig; + if (secp256k1_ecdsa_signature_parse_compact(getSecp256k1Context(), &sig, signature.begin()) != + 1) { + // Signature has invalid r or s (e.g. >= curve order). Per spec, + // malformed signatures produce a `false` return, not an exception. + return false; + } + + // 5. Verify. libsecp256k1 enforces low-s normalization here (signatures + // with s > n/2 are rejected) which matches Bitcoin / Ethereum + // consensus rules and prevents malleability. This is stricter than + // generic ECDSA but matches what essentially every secp256k1 + // consumer wants. + return secp256k1_ecdsa_verify(getSecp256k1Context(), &sig, digest.begin(), &parsedPubkey) == 1; +} + +SubtleCrypto::ExportKeyData Secp256k1Key::exportKey(jsg::Lock& js, kj::StringPtr format) const { + // For now we support only the "raw" format for public keys, emitting the + // 33-byte compressed SEC1 form. This matches what the rest of the + // WebCrypto ECDSA implementation does for public keys (see + // `EllipticKey::exportRaw` for the NIST curve analogue). + // + // TODO(secp256k1): implement "jwk" and "spki" public-key export. + JSG_REQUIRE(format == "raw", DOMNotSupportedError, + "Unsupported key export format for secp256k1: \"", format, + "\" (only \"raw\" is supported " + "for now)."); + + auto serialized = serializePubkeyCompressed(parsedPubkey); + // `ExportKeyData` is a `OneOf, JsonWebKey>`, so + // we need to wrap the freshly-created JsArrayBuffer in a JsRef via + // `.addRef(js)` — a plain JsArrayBuffer won't implicitly convert. + return jsg::JsArrayBuffer::create(js, serialized.asPtr()).addRef(js); +} + +bool Secp256k1Key::equals(const CryptoKey::Impl& other) const { + // Two secp256k1 keys are equal iff they have the same algorithm name, + // same curve, same key type, and same serialized public key bytes. + if (other.getAlgorithmName() != getAlgorithmName()) return false; + if (other.getType() != getType()) return false; + + auto* downcast = dynamic_cast(&other); + if (downcast == nullptr) return false; + + auto thisBytes = serializePubkeyCompressed(parsedPubkey); + auto thatBytes = serializePubkeyCompressed(downcast->parsedPubkey); + return thisBytes.asPtr() == thatBytes.asPtr(); +} + +} // namespace workerd::api diff --git a/src/workerd/api/crypto/secp256k1-key.h b/src/workerd/api/crypto/secp256k1-key.h new file mode 100644 index 00000000000..a6afbe766ec --- /dev/null +++ b/src/workerd/api/crypto/secp256k1-key.h @@ -0,0 +1,87 @@ +// Copyright (c) 2026 Cloudflare, Inc. +// Licensed under the Apache 2.0 license found in the LICENSE file or at: +// https://opensource.org/licenses/Apache-2.0 + +#pragma once + +// Secp256k1Key — WebCrypto ECDSA CryptoKey::Impl for the secp256k1 curve. +// +// BoringSSL does not implement secp256k1, so this key type cannot be stored +// in a BoringSSL `EVP_PKEY` and therefore cannot inherit from the shared +// `AsymmetricKeyCryptoKeyImpl` base (which is parameterized over `EVP_PKEY`). +// Instead, we inherit directly from `CryptoKey::Impl` and hold the raw +// public key bytes in the 64-byte libsecp256k1 parsed form. +// +// Scope: this initial version supports public keys only, and `verify()` only. +// Private keys, signing, generation, and JWK support land in follow-up PRs. + +#include "crypto.h" +#include "impl.h" + +#include + +#include +#include + +namespace workerd::api { + +class Secp256k1Key final: public CryptoKey::Impl { + public: + // Construct a public-key `Secp256k1Key` from an already-parsed + // `secp256k1_pubkey`. Callers own validating / parsing the source bytes + // before constructing. + Secp256k1Key(secp256k1_pubkey parsedPubkey, + CryptoKey::EllipticKeyAlgorithm keyAlgorithm, + bool extractable, + CryptoKeyUsageSet usages); + + // Import a secp256k1 public key in WebCrypto "raw" format. Accepts both + // the 33-byte compressed SEC1 form (leading 0x02 or 0x03) and the + // 65-byte uncompressed form (leading 0x04). Throws `DOMDataError` on + // malformed input. + static kj::Own importRawPublic(jsg::Lock& js, + kj::ArrayPtr keyData, + CryptoKey::EllipticKeyAlgorithm keyAlgorithm, + bool extractable, + CryptoKeyUsageSet usages); + + // --------------------------------------------------------------------- + // CryptoKey::Impl overrides + + kj::StringPtr getAlgorithmName() const override { + return "ECDSA"_kj; + } + + CryptoKey::AlgorithmVariant getAlgorithm(jsg::Lock& js) const override { + return keyAlgorithm; + } + + kj::StringPtr getType() const override { + return "public"_kj; + } + + bool verify(jsg::Lock& js, + SubtleCrypto::SignAlgorithm&& algorithm, + kj::ArrayPtr signature, + kj::ArrayPtr data) const override; + + SubtleCrypto::ExportKeyData exportKey(jsg::Lock& js, kj::StringPtr format) const override; + + bool equals(const CryptoKey::Impl& other) const override; + + kj::StringPtr jsgGetMemoryName() const override { + return "Secp256k1Key"; + } + size_t jsgGetMemorySelfSize() const override { + return sizeof(Secp256k1Key); + } + + private: + // The parsed public key. libsecp256k1's internal representation is 64 + // bytes but should be treated as opaque and only manipulated through + // library functions. + secp256k1_pubkey parsedPubkey; + CryptoKey::EllipticKeyAlgorithm keyAlgorithm; +}; + +} // namespace workerd::api diff --git a/src/workerd/api/tests/crypto-secp256k1-with-flag-test.js b/src/workerd/api/tests/crypto-secp256k1-with-flag-test.js index 30c1c1972cd..2bde3ac5eae 100644 --- a/src/workerd/api/tests/crypto-secp256k1-with-flag-test.js +++ b/src/workerd/api/tests/crypto-secp256k1-with-flag-test.js @@ -3,78 +3,192 @@ // https://opensource.org/licenses/Apache-2.0 // // secp256k1 ECDSA — behaviour when the `secp256k1_ecdsa_curve` compatibility -// flag IS set but no backend is wired up yet. The curve is recognized by -// `lookupEllipticCurve` (so a different error message) but every entry point -// rejects with the stub "backend not available" message. +// flag is enabled. The curve is dispatched through libsecp256k1 (BoringSSL +// does not implement secp256k1). // -// When the backend lands, flip these assertions from "asserts failure" to -// "asserts success" (generate/import/sign/verify round-trips). +// Current scope: `importKey("raw", publicKey)` and `verify(signature, data)`. +// Other operations (generateKey, sign, JWK import/export, SPKI, PKCS8) are +// not yet wired to the libsecp256k1 backend; tests for those assert the +// explicit "not yet implemented" rejections so the stubs can't regress +// silently. +// +// When sign/generate/JWK lands, flip the matching asserts from "must fail" +// to "must succeed" and add end-to-end round-trip tests. import { rejects, strictEqual, ok } from 'node:assert'; -export const generateKeyStubs = { +// ----------------------------------------------------------------------- +// Known-good secp256k1 public key for positive tests. +// +// 33-byte SEC1 compressed encoding of the secp256k1 generator point G +// (i.e. the public key for private key d = 1). This is not secret — +// it's literally the curve's generator. It's a stable, well-documented +// point that lets us exercise the import path without needing to ship +// test-only signing machinery. +// ----------------------------------------------------------------------- +const GENERATOR_PUBKEY_COMPRESSED = new Uint8Array([ + 0x02, 0x79, 0xbe, 0x66, 0x7e, 0xf9, 0xdc, 0xbb, 0xac, 0x55, 0xa0, 0x62, 0x95, + 0xce, 0x87, 0x0b, 0x07, 0x02, 0x9b, 0xfc, 0xdb, 0x2d, 0xce, 0x28, 0xd9, 0x59, + 0xf2, 0x81, 0x5b, 0x16, 0xf8, 0x17, 0x98, +]); + +// ----------------------------------------------------------------------- +// importKey — supported paths +// ----------------------------------------------------------------------- + +export const importRawCompressedPublicKey = { async test() { + const key = await crypto.subtle.importKey( + 'raw', + GENERATOR_PUBKEY_COMPRESSED, + { name: 'ECDSA', namedCurve: 'secp256k1' }, + true, + ['verify'] + ); + strictEqual(key.type, 'public'); + strictEqual(key.algorithm.name, 'ECDSA'); + strictEqual(key.algorithm.namedCurve, 'secp256k1'); + // Usages should be restricted to 'verify' since this is a public key. + ok(key.usages.includes('verify')); + ok(!key.usages.includes('sign')); + }, +}; + +export const exportRawRoundTrip = { + async test() { + const key = await crypto.subtle.importKey( + 'raw', + GENERATOR_PUBKEY_COMPRESSED, + { name: 'ECDSA', namedCurve: 'secp256k1' }, + true, + ['verify'] + ); + const exported = new Uint8Array(await crypto.subtle.exportKey('raw', key)); + strictEqual(exported.byteLength, 33); + for (let i = 0; i < GENERATOR_PUBKEY_COMPRESSED.length; i++) { + strictEqual( + exported[i], + GENERATOR_PUBKEY_COMPRESSED[i], + `exported byte ${i} did not match input` + ); + } + }, +}; + +export const importRejectsWrongLength = { + async test() { + // 32 bytes is not a valid SEC1 public key length. await rejects( - crypto.subtle.generateKey( + crypto.subtle.importKey( + 'raw', + new Uint8Array(32), { name: 'ECDSA', namedCurve: 'secp256k1' }, true, - ['sign', 'verify'] + ['verify'] ), (err) => { - strictEqual(err.name, 'NotSupportedError'); - ok( - /Key generation for "secp256k1" is not yet implemented/.test( - err.message - ), - `unexpected message: ${err.message}` - ); + strictEqual(err.name, 'DataError'); return true; } ); }, }; -export const importKeyJwkStubs = { +export const importRejectsInvalidPoint = { async test() { + // 33 bytes with the right prefix but a garbage x-coordinate. The + // prefix 0x02 says "compressed, y is even" but the payload won't + // correspond to a point on secp256k1. + const bogus = new Uint8Array(33); + bogus[0] = 0x02; + bogus.fill(0xff, 1); await rejects( crypto.subtle.importKey( - 'jwk', - { - kty: 'EC', - crv: 'secp256k1', - x: 'AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA', - y: 'AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA', - }, + 'raw', + bogus, { name: 'ECDSA', namedCurve: 'secp256k1' }, true, ['verify'] ), (err) => { - strictEqual(err.name, 'NotSupportedError'); - ok( - /Key import for "secp256k1" is not yet implemented/.test(err.message), - `unexpected message: ${err.message}` - ); + strictEqual(err.name, 'DataError'); return true; } ); }, }; -export const importKeyRawStubs = { +// ----------------------------------------------------------------------- +// verify — negative cases +// +// Until sign() is wired up for secp256k1 we can't produce a signature +// inside the test, so we can't do a positive verify assertion here. +// What we CAN assert is that malformed / clearly-wrong signatures +// return false (not true, and not a thrown exception). +// ----------------------------------------------------------------------- + +export const verifyRejectsWrongLengthSignature = { + async test() { + const key = await crypto.subtle.importKey( + 'raw', + GENERATOR_PUBKEY_COMPRESSED, + { name: 'ECDSA', namedCurve: 'secp256k1' }, + true, + ['verify'] + ); + // 63 bytes is not a valid ECDSA-raw signature (must be 64). + const result = await crypto.subtle.verify( + { name: 'ECDSA', hash: 'SHA-256' }, + key, + new Uint8Array(63), + new Uint8Array([0x00]) + ); + strictEqual(result, false); + }, +}; + +export const verifyRejectsAllZeroSignature = { + async test() { + const key = await crypto.subtle.importKey( + 'raw', + GENERATOR_PUBKEY_COMPRESSED, + { name: 'ECDSA', namedCurve: 'secp256k1' }, + true, + ['verify'] + ); + // All-zero is not a valid signature (r = 0, s = 0 both fail the + // parser's range check). + const result = await crypto.subtle.verify( + { name: 'ECDSA', hash: 'SHA-256' }, + key, + new Uint8Array(64), + new Uint8Array([0x00]) + ); + strictEqual(result, false); + }, +}; + +// ----------------------------------------------------------------------- +// Paths NOT yet implemented — these MUST still reject until the +// follow-up PRs wire them in. If any of these starts passing, the +// error messages below need to be reviewed for consistency before the +// test is flipped to a positive assertion. +// ----------------------------------------------------------------------- + +export const generateKeyNotYetImplemented = { async test() { await rejects( - crypto.subtle.importKey( - 'raw', - new Uint8Array(33), + crypto.subtle.generateKey( { name: 'ECDSA', namedCurve: 'secp256k1' }, true, - ['verify'] + ['sign', 'verify'] ), (err) => { strictEqual(err.name, 'NotSupportedError'); ok( - /Key import for "secp256k1" is not yet implemented/.test(err.message), + /Key generation for "secp256k1" is not yet implemented/.test( + err.message + ), `unexpected message: ${err.message}` ); return true; @@ -83,9 +197,7 @@ export const importKeyRawStubs = { }, }; -export const ecdhRejectedExplicitly = { - // The secp256k1 compat flag is scoped to ECDSA only. ECDH over secp256k1 - // must fail with a specific error even when the flag is on. +export const importJwkNotYetImplemented = { async test() { await rejects( crypto.subtle.importKey( @@ -96,6 +208,32 @@ export const ecdhRejectedExplicitly = { x: 'AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA', y: 'AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA', }, + { name: 'ECDSA', namedCurve: 'secp256k1' }, + true, + ['verify'] + ), + (err) => { + strictEqual(err.name, 'NotSupportedError'); + ok( + /Key import format "jwk" is not yet implemented for secp256k1/.test( + err.message + ), + `unexpected message: ${err.message}` + ); + return true; + } + ); + }, +}; + +export const ecdhRejectedExplicitly = { + // The secp256k1 compat flag is scoped to ECDSA only. ECDH over + // secp256k1 must fail with a specific error even when the flag is on. + async test() { + await rejects( + crypto.subtle.importKey( + 'raw', + GENERATOR_PUBKEY_COMPRESSED, { name: 'ECDH', namedCurve: 'secp256k1' }, true, ['deriveBits'] From e98f84383d5eedd71c3c94f34c2e968d338e1eb4 Mon Sep 17 00:00:00 2001 From: Isaac Bremseth Date: Fri, 24 Apr 2026 15:20:56 -0400 Subject: [PATCH 4/5] Add JWK --- src/workerd/api/crypto/ec.c++ | 60 ++++--- src/workerd/api/crypto/secp256k1-key.c++ | 124 ++++++++++++-- src/workerd/api/crypto/secp256k1-key.h | 14 +- .../tests/crypto-secp256k1-with-flag-test.js | 152 +++++++++++++++++- 4 files changed, 291 insertions(+), 59 deletions(-) diff --git a/src/workerd/api/crypto/ec.c++ b/src/workerd/api/crypto/ec.c++ index f3f838c4278..900f14b4ea0 100644 --- a/src/workerd/api/crypto/ec.c++ +++ b/src/workerd/api/crypto/ec.c++ @@ -430,17 +430,15 @@ class EllipticKey final: public AsymmetricKeyCryptoKeyImpl { struct EllipticCurveInfo { kj::StringPtr normalizedName; - int opensslCurveId; // BoringSSL NID. Meaningless for curves (like secp256k1) that are not - // served by BoringSSL; the dispatch sites branch on `normalizedName` - // before consuming this field. - uint rsSize; // Size of "r" and "s" in the signature. + int opensslCurveId; // NOTE: Not used by sepc256k1 (set to 0) + uint rsSize; // size of "r" and "s" in the signature. }; EllipticCurveInfo lookupEllipticCurve(kj::StringPtr curveName) { static const std::map registeredCurves{ - {"P-256", {"P-256"_kj, NID_X9_62_prime256v1, 32}}, - {"P-384", {"P-384"_kj, NID_secp384r1, 48}}, - {"P-521", {"P-521"_kj, NID_secp521r1, 66}}, + {"P-256", {"P-256", NID_X9_62_prime256v1, 32}}, + {"P-384", {"P-384", NID_secp384r1, 48}}, + {"P-521", {"P-521", NID_secp521r1, 66}}, }; auto iter = registeredCurves.find(curveName); @@ -449,12 +447,7 @@ EllipticCurveInfo lookupEllipticCurve(kj::StringPtr curveName) { return iter->second; } -// Overload that additionally honours the `secp256k1_ecdsa_curve` compatibility -// flag. secp256k1 is not available via BoringSSL, so its `opensslCurveId` is a -// placeholder; every call site that would hand the id to BoringSSL must first -// short-circuit on the curve name. This overload lives alongside the lock-less -// version because `fromEcKey()` resolves the curve name from an already-built -// BoringSSL `EVP_PKEY`, which trivially cannot be secp256k1. +// Overload that additionally honours the `secp256k1_ecdsa_curve` compatibility flag EllipticCurveInfo lookupEllipticCurve(jsg::Lock& js, kj::StringPtr curveName) { // secp256k1 is not available via BoringSSL, so we route it to libsecp256k1. if (FeatureFlags::get(js).getSecp256k1EcdsaCurve() && @@ -475,8 +468,7 @@ kj::OneOf, CryptoKeyPair> EllipticKey::generateElliptic(jsg: auto [normalizedNamedCurve, curveId, rsSize] = lookupEllipticCurve(js, namedCurve); - // secp256k1 is not available via BoringSSL. Key generation via libsecp256k1 - // is not yet wired up, so reject with a clear error for now. + // TODO: Implement secp256k1 key generation via libsecp256k1 JSG_REQUIRE(strcasecmp(normalizedNamedCurve.cStr(), "secp256k1") != 0, DOMNotSupportedError, "Key generation for \"secp256k1\" is not yet implemented. The curve is recognized but the " "backend is not available in this build."); @@ -727,21 +719,29 @@ kj::Own CryptoKey::Impl::importEcdsa(jsg::Lock& js, auto [normalizedNamedCurve, curveId, rsSize] = lookupEllipticCurve(js, namedCurve); // secp256k1 is not available via BoringSSL, so we route it to libsecp256k1. - // For now only the "raw" public key import format is implemented — that's - // the shape wallets, chain indexers, and signature verifiers actually hand - // us in the EVM world. JWK / SPKI / PKCS8 will land in a follow-up. + // "raw" and "jwk" are the two formats EVM ecosystem tooling uses in + // practice; "spki" and "pkcs8" are not yet implemented for this curve. if (strcasecmp(normalizedNamedCurve.cStr(), "secp256k1") == 0) { - JSG_REQUIRE(format == "raw", DOMNotSupportedError, "Key import format \"", format, - "\" is not yet implemented for secp256k1 (only \"raw\" public keys are supported)."); - - JSG_REQUIRE(keyData.is>(), DOMDataError, - "Expected raw secp256k1 key but got a JSON Web Key."); - auto usages = CryptoKeyUsageSet::validate(normalizedName, CryptoKeyUsageSet::Context::importPublic, keyUsages, CryptoKeyUsageSet::verify()); + auto keyAlg = CryptoKey::EllipticKeyAlgorithm{normalizedName, normalizedNamedCurve}; + + if (format == "raw") { + JSG_REQUIRE(keyData.is>(), DOMDataError, + "Expected raw secp256k1 key but got a JSON Web Key."); + return Secp256k1Key::importRawPublic( + js, keyData.get>().asPtr(), kj::mv(keyAlg), extractable, usages); + } - return Secp256k1Key::importRawPublic(js, keyData.get>().asPtr(), - CryptoKey::EllipticKeyAlgorithm{normalizedName, normalizedNamedCurve}, extractable, usages); + if (format == "jwk") { + JSG_REQUIRE(keyData.is(), DOMDataError, + "Expected JSON Web Key but got raw bytes."); + return Secp256k1Key::importJwk( + js, kj::mv(keyData.get()), kj::mv(keyAlg), extractable, usages); + } + + JSG_FAIL_REQUIRE(DOMNotSupportedError, "Key import format \"", format, + "\" is not yet implemented for secp256k1 (only \"raw\" and \"jwk\" are supported)."); } auto importedKey = [&, curveId = curveId] { @@ -802,9 +802,6 @@ kj::Own CryptoKey::Impl::importEcdh(jsg::Lock& js, auto [normalizedNamedCurve, curveId, rsSize] = lookupEllipticCurve(js, namedCurve); - // The secp256k1 compatibility flag scopes support to ECDSA only; ECDH over - // secp256k1 is not part of this work and would in any case need to go - // through libsecp256k1 rather than BoringSSL. JSG_REQUIRE(strcasecmp(normalizedNamedCurve.cStr(), "secp256k1") != 0, DOMNotSupportedError, "ECDH is not supported for curve \"secp256k1\"."); @@ -816,7 +813,7 @@ kj::Own CryptoKey::Impl::importEcdh(jsg::Lock& js, return importAsymmetricForWebCrypto(js, format, kj::mv(keyData), normalizedName, extractable, keyUsages, // Verbose lambda capture needed because: https://bugs.llvm.org/show_bug.cgi?id=35984 - [curveId, normalizedName = kj::str(normalizedName)]( + [curveId = curveId, normalizedName = kj::str(normalizedName)]( SubtleCrypto::JsonWebKey keyDataJwk) -> kj::Own { return ellipticJwkReader(curveId, kj::mv(keyDataJwk), normalizedName); }, @@ -1260,9 +1257,6 @@ kj::Own fromEcKey(kj::Own key) { curveName = "unknown"; } - // `fromEcKey()` receives an already-constructed BoringSSL `EVP_PKEY`, so it - // can only produce BoringSSL-supported curves. The lock-less lookup - // overload is the right choice here. auto [normalizedNamedCurve, curveId, rsSize] = lookupEllipticCurve(curveName); return kj::heap( diff --git a/src/workerd/api/crypto/secp256k1-key.c++ b/src/workerd/api/crypto/secp256k1-key.c++ index 2a7d5ed0791..6a8bc9cbeb5 100644 --- a/src/workerd/api/crypto/secp256k1-key.c++ +++ b/src/workerd/api/crypto/secp256k1-key.c++ @@ -36,7 +36,7 @@ const secp256k1_context* getSecp256k1Context() { } // Serialize a `secp256k1_pubkey` to its 33-byte compressed SEC1 form. -// Used for equality comparison and potentially raw export. +// Used for equality comparison and raw export. kj::Array serializePubkeyCompressed(const secp256k1_pubkey& pubkey) { auto out = kj::heapArray(33); size_t outLen = out.size(); @@ -47,6 +47,19 @@ kj::Array serializePubkeyCompressed(const secp256k1_pubkey& pubkey) { return out; } +// Serialize a `secp256k1_pubkey` to its 65-byte uncompressed SEC1 form: +// `04 || x (32 bytes) || y (32 bytes)`. Used by JWK export, which needs the +// `x` and `y` coordinates separately. +kj::Array serializePubkeyUncompressed(const secp256k1_pubkey& pubkey) { + auto out = kj::heapArray(65); + size_t outLen = out.size(); + auto ret = secp256k1_ec_pubkey_serialize( + getSecp256k1Context(), out.begin(), &outLen, &pubkey, SECP256K1_EC_UNCOMPRESSED); + KJ_ASSERT(ret == 1, "secp256k1_ec_pubkey_serialize should never fail on a valid parsed pubkey"); + KJ_ASSERT(outLen == 65, "uncompressed secp256k1 pubkey should always be 65 bytes"); + return out; +} + // Compute the SHA-256-family digest of `data`, matching WebCrypto's ECDSA // behaviour where the hash is specified at sign/verify call time. Returns // the digest bytes (typically 32 bytes for SHA-256, 48 for SHA-384, etc.). @@ -73,6 +86,63 @@ Secp256k1Key::Secp256k1Key(secp256k1_pubkey parsedPubkey, parsedPubkey(parsedPubkey), keyAlgorithm(kj::mv(keyAlgorithm)) {} +kj::Own Secp256k1Key::importJwk(jsg::Lock& js, + SubtleCrypto::JsonWebKey&& jwk, + CryptoKey::EllipticKeyAlgorithm keyAlgorithm, + bool extractable, + CryptoKeyUsageSet usages) { + // RFC 7518 §6.2: EC public keys use `kty`=EC and carry `crv`, `x`, `y`. + // secp256k1 is not one of the NIST curves registered in the IANA JOSE + // registry, but RFC 8812 defines `crv`="secp256k1" (and `alg`="ES256K") + // for JOSE / COSE usage. We accept that curve string directly. + + JSG_REQUIRE(jwk.kty == "EC", DOMDataError, + "secp256k1 \"jwk\" import requires a JSON Web Key with Key Type parameter \"kty\" (\"", + jwk.kty, "\") equal to \"EC\"."); + + auto& crv = JSG_REQUIRE_NONNULL( + jwk.crv, DOMDataError, "Missing \"crv\" field in JSON Web Key for secp256k1 import."); + JSG_REQUIRE(crv == "secp256k1", DOMDataError, "\"crv\" field in JSON Web Key (\"", crv, + "\") does not match expected \"secp256k1\"."); + + // If an `alg` field is present, it must be the secp256k1 JOSE identifier + // defined by RFC 8812. We don't require it (many JWKs omit `alg`). + KJ_IF_SOME(alg, jwk.alg) { + JSG_REQUIRE(alg == "ES256K", DOMDataError, "JSON Web Key Algorithm parameter \"alg\" (\"", alg, + "\") does not match the expected value \"ES256K\" for secp256k1."); + } + + // Private-key JWKs carry a `d` component. Private keys are not yet + // supported for secp256k1; reject clearly rather than silently discarding + // the private material. + JSG_REQUIRE(jwk.d == kj::none, DOMNotSupportedError, + "Importing secp256k1 private keys (JWKs with a \"d\" field) is not yet implemented."); + + // Decode the base64url-encoded coordinates. RFC 7518 §6.2.1.2/.3 requires + // both `x` and `y` to be the full 32-byte scalar, left-padded with zeros + // if necessary. We accept shorter encodings and zero-pad, matching what + // the NIST-curve ECDSA path does. + auto xBytes = JSG_REQUIRE_NONNULL(decodeBase64Url(JSG_REQUIRE_NONNULL(kj::mv(jwk.x), DOMDataError, + "Missing \"x\" field in secp256k1 JSON Web Key.")), + DOMDataError, "Invalid base64url encoding in JSON Web Key \"x\" field."); + auto yBytes = JSG_REQUIRE_NONNULL(decodeBase64Url(JSG_REQUIRE_NONNULL(kj::mv(jwk.y), DOMDataError, + "Missing \"y\" field in secp256k1 JSON Web Key.")), + DOMDataError, "Invalid base64url encoding in JSON Web Key \"y\" field."); + + JSG_REQUIRE(xBytes.size() <= 32 && yBytes.size() <= 32, DOMDataError, + "secp256k1 JSON Web Key coordinates must be at most 32 bytes each."); + + // Reconstruct the 65-byte uncompressed SEC1 encoding (0x04 || x || y), + // left-padding x and y if the JWK provided shorter encodings. Hand the + // result to the existing raw-import path for point validation. + kj::byte sec1[65] = {}; + sec1[0] = 0x04; + kj::arrayPtr(sec1).slice(1 + 32 - xBytes.size(), 1 + 32).copyFrom(xBytes); + kj::arrayPtr(sec1).slice(1 + 32 + 32 - yBytes.size(), 65).copyFrom(yBytes); + + return importRawPublic(js, kj::arrayPtr(sec1, 65), kj::mv(keyAlgorithm), extractable, usages); +} + kj::Own Secp256k1Key::importRawPublic(jsg::Lock& js, kj::ArrayPtr keyData, CryptoKey::EllipticKeyAlgorithm keyAlgorithm, @@ -141,22 +211,44 @@ bool Secp256k1Key::verify(jsg::Lock& js, } SubtleCrypto::ExportKeyData Secp256k1Key::exportKey(jsg::Lock& js, kj::StringPtr format) const { - // For now we support only the "raw" format for public keys, emitting the - // 33-byte compressed SEC1 form. This matches what the rest of the - // WebCrypto ECDSA implementation does for public keys (see + // "raw" emits the 33-byte compressed SEC1 form, matching what the rest + // of the WebCrypto ECDSA implementation does for public keys (see // `EllipticKey::exportRaw` for the NIST curve analogue). - // - // TODO(secp256k1): implement "jwk" and "spki" public-key export. - JSG_REQUIRE(format == "raw", DOMNotSupportedError, - "Unsupported key export format for secp256k1: \"", format, - "\" (only \"raw\" is supported " - "for now)."); - - auto serialized = serializePubkeyCompressed(parsedPubkey); - // `ExportKeyData` is a `OneOf, JsonWebKey>`, so - // we need to wrap the freshly-created JsArrayBuffer in a JsRef via - // `.addRef(js)` — a plain JsArrayBuffer won't implicitly convert. - return jsg::JsArrayBuffer::create(js, serialized.asPtr()).addRef(js); + if (format == "raw") { + auto serialized = serializePubkeyCompressed(parsedPubkey); + // `ExportKeyData` is a `OneOf, JsonWebKey>`, + // so we need to wrap the freshly-created JsArrayBuffer in a JsRef via + // `.addRef(js)` — a plain JsArrayBuffer won't implicitly convert. + return jsg::JsArrayBuffer::create(js, serialized.asPtr()).addRef(js); + } + + // "jwk" emits RFC 7517 / RFC 7518 / RFC 8812 JSON Web Key form. + if (format == "jwk") { + auto uncompressed = serializePubkeyUncompressed(parsedPubkey); + // uncompressed[0] is the 0x04 SEC1 tag; the following 32 bytes are x, + // then 32 bytes of y. + auto xBytes = uncompressed.slice(1, 33); + auto yBytes = uncompressed.slice(33, 65); + + SubtleCrypto::JsonWebKey jwk; + jwk.kty = kj::str("EC"); + jwk.crv = kj::str("secp256k1"); + jwk.x = fastEncodeBase64Url(xBytes); + jwk.y = fastEncodeBase64Url(yBytes); + // `ext` and `key_ops` are set by `SubtleCrypto::exportKey`'s caller in + // the AsymmetricKeyCryptoKeyImpl path, but since we inherit directly + // from `CryptoKey::Impl`, we set them ourselves here. See the parallel + // code in AsymmetricKeyCryptoKeyImpl::exportKey. + jwk.ext = true; + jwk.key_ops = getUsages().map([](auto usage) { return kj::str(usage.name()); }); + return jwk; + } + + // TODO(secp256k1): implement "spki" public-key export (requires writing + // a small DER emitter since BoringSSL's EVP_marshal_public_key can't + // handle this curve). + JSG_FAIL_REQUIRE(DOMNotSupportedError, "Unsupported key export format for secp256k1: \"", format, + "\" (only \"raw\" and \"jwk\" are supported)."); } bool Secp256k1Key::equals(const CryptoKey::Impl& other) const { diff --git a/src/workerd/api/crypto/secp256k1-key.h b/src/workerd/api/crypto/secp256k1-key.h index a6afbe766ec..a156f95be7c 100644 --- a/src/workerd/api/crypto/secp256k1-key.h +++ b/src/workerd/api/crypto/secp256k1-key.h @@ -45,11 +45,21 @@ class Secp256k1Key final: public CryptoKey::Impl { bool extractable, CryptoKeyUsageSet usages); + // Import a secp256k1 public key from a JSON Web Key (RFC 7517 / RFC 7518). + // The JWK must have `kty` = "EC" and `crv` = "secp256k1". The `x` and `y` + // coordinates are base64url-encoded 32-byte scalars. Private-key JWKs + // (those with a `d` field) are rejected until the signing path lands. + static kj::Own importJwk(jsg::Lock& js, + SubtleCrypto::JsonWebKey&& jwk, + CryptoKey::EllipticKeyAlgorithm keyAlgorithm, + bool extractable, + CryptoKeyUsageSet usages); + // --------------------------------------------------------------------- // CryptoKey::Impl overrides kj::StringPtr getAlgorithmName() const override { - return "ECDSA"_kj; + return "ECDSA"; } CryptoKey::AlgorithmVariant getAlgorithm(jsg::Lock& js) const override { @@ -57,7 +67,7 @@ class Secp256k1Key final: public CryptoKey::Impl { } kj::StringPtr getType() const override { - return "public"_kj; + return "public"; } bool verify(jsg::Lock& js, diff --git a/src/workerd/api/tests/crypto-secp256k1-with-flag-test.js b/src/workerd/api/tests/crypto-secp256k1-with-flag-test.js index 2bde3ac5eae..e17e8d9eb0b 100644 --- a/src/workerd/api/tests/crypto-secp256k1-with-flag-test.js +++ b/src/workerd/api/tests/crypto-secp256k1-with-flag-test.js @@ -197,17 +197,153 @@ export const generateKeyNotYetImplemented = { }, }; -export const importJwkNotYetImplemented = { +// ----------------------------------------------------------------------- +// JWK import/export — RFC 7517 + RFC 7518 + RFC 8812 +// ----------------------------------------------------------------------- +// +// Instead of hardcoding base64url coordinate values (which is easy to get +// wrong), we derive a known-good JWK via a raw import + JWK export, then +// exercise import/round-trip against that. The raw input is the secp256k1 +// generator point G (SEC 2 §2.4.1) — a well-defined constant we already +// use above. + +async function makeGeneratorJwk() { + const key = await crypto.subtle.importKey( + 'raw', + GENERATOR_PUBKEY_COMPRESSED, + { name: 'ECDSA', namedCurve: 'secp256k1' }, + true, + ['verify'] + ); + return await crypto.subtle.exportKey('jwk', key); +} + +export const exportJwkShape = { + async test() { + const jwk = await makeGeneratorJwk(); + strictEqual(jwk.kty, 'EC'); + strictEqual(jwk.crv, 'secp256k1'); + strictEqual(typeof jwk.x, 'string'); + strictEqual(typeof jwk.y, 'string'); + strictEqual(jwk.d, undefined); + strictEqual(jwk.ext, true); + ok(Array.isArray(jwk.key_ops)); + ok(jwk.key_ops.includes('verify')); + // base64url-decoding a 32-byte value yields a string of length + // ceil(32 * 4 / 3) with '=' padding stripped = 43 characters. + strictEqual(jwk.x.length, 43); + strictEqual(jwk.y.length, 43); + // base64url uses [A-Za-z0-9_-], never '+', '/', or '='. + ok(/^[A-Za-z0-9_-]+$/.test(jwk.x)); + ok(/^[A-Za-z0-9_-]+$/.test(jwk.y)); + }, +}; + +export const importJwkPublic = { + async test() { + const jwk = await makeGeneratorJwk(); + const key = await crypto.subtle.importKey( + 'jwk', + jwk, + { name: 'ECDSA', namedCurve: 'secp256k1' }, + true, + ['verify'] + ); + strictEqual(key.type, 'public'); + strictEqual(key.algorithm.name, 'ECDSA'); + strictEqual(key.algorithm.namedCurve, 'secp256k1'); + ok(key.usages.includes('verify')); + }, +}; + +export const jwkRoundTrip = { + async test() { + // raw -> JWK export -> JWK import -> raw export: should equal original. + const fromRaw = await crypto.subtle.importKey( + 'raw', + GENERATOR_PUBKEY_COMPRESSED, + { name: 'ECDSA', namedCurve: 'secp256k1' }, + true, + ['verify'] + ); + const exportedJwk = await crypto.subtle.exportKey('jwk', fromRaw); + const fromJwk = await crypto.subtle.importKey( + 'jwk', + exportedJwk, + { name: 'ECDSA', namedCurve: 'secp256k1' }, + true, + ['verify'] + ); + const reExportedRaw = new Uint8Array( + await crypto.subtle.exportKey('raw', fromJwk) + ); + strictEqual(reExportedRaw.byteLength, GENERATOR_PUBKEY_COMPRESSED.length); + for (let i = 0; i < reExportedRaw.length; i++) { + strictEqual( + reExportedRaw[i], + GENERATOR_PUBKEY_COMPRESSED[i], + `byte ${i} did not round-trip` + ); + } + }, +}; + +export const importJwkRejectsWrongKty = { + async test() { + const jwk = await makeGeneratorJwk(); + // OKP is for Ed25519/X25519, EC is for secp256k1. + jwk.kty = 'OKP'; + await rejects( + crypto.subtle.importKey( + 'jwk', + jwk, + { name: 'ECDSA', namedCurve: 'secp256k1' }, + true, + ['verify'] + ), + (err) => { + strictEqual(err.name, 'DataError'); + return true; + } + ); + }, +}; + +export const importJwkRejectsWrongCrv = { + async test() { + const jwk = await makeGeneratorJwk(); + jwk.crv = 'P-256'; + await rejects( + crypto.subtle.importKey( + 'jwk', + jwk, + { name: 'ECDSA', namedCurve: 'secp256k1' }, + true, + ['verify'] + ), + (err) => { + strictEqual(err.name, 'DataError'); + return true; + } + ); + }, +}; + +export const importJwkRejectsPrivateKey = { + // Private key support (JWK with `d`) isn't wired up yet. Must reject + // clearly rather than silently dropping the private material. + // + // Use `['verify']` as the requested usage so that WebCrypto's usage + // validation (which rejects `['sign']` on a public key with a + // `SyntaxError` before we ever look at `d`) doesn't mask the error we + // actually want to test. async test() { + const jwk = await makeGeneratorJwk(); + jwk.d = 'AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAE'; await rejects( crypto.subtle.importKey( 'jwk', - { - kty: 'EC', - crv: 'secp256k1', - x: 'AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA', - y: 'AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA', - }, + jwk, { name: 'ECDSA', namedCurve: 'secp256k1' }, true, ['verify'] @@ -215,7 +351,7 @@ export const importJwkNotYetImplemented = { (err) => { strictEqual(err.name, 'NotSupportedError'); ok( - /Key import format "jwk" is not yet implemented for secp256k1/.test( + /Importing secp256k1 private keys \(JWKs with a "d" field\) is not yet implemented/.test( err.message ), `unexpected message: ${err.message}` From 34a2fed701a63ef0a2cbd9ee43d0b6b1c8bff7f6 Mon Sep 17 00:00:00 2001 From: Isaac Bremseth Date: Fri, 24 Apr 2026 16:52:04 -0400 Subject: [PATCH 5/5] Add secp256k1 sign and key gen --- build/BUILD.libsecp256k1 | 54 +- build/deps/deps.jsonc | 5 - src/workerd/api/crypto/ec.c++ | 47 +- src/workerd/api/crypto/secp256k1-key.c++ | 439 +++++++++------ src/workerd/api/crypto/secp256k1-key.h | 86 +-- src/workerd/api/tests/BUILD.bazel | 18 +- .../tests/crypto-secp256k1-no-flag-test.js | 61 +-- .../api/tests/crypto-secp256k1-test.wd-test | 9 +- .../tests/crypto-secp256k1-with-flag-test.js | 504 +++++++++--------- src/workerd/io/compatibility-date.capnp | 13 +- 10 files changed, 598 insertions(+), 638 deletions(-) diff --git a/build/BUILD.libsecp256k1 b/build/BUILD.libsecp256k1 index 43752f5d0ec..c6eb3db30ea 100644 --- a/build/BUILD.libsecp256k1 +++ b/build/BUILD.libsecp256k1 @@ -1,27 +1,5 @@ load("@rules_cc//cc:cc_library.bzl", "cc_library") -# Bazel BUILD file for libsecp256k1 (Bitcoin Core's reference implementation). -# -# Upstream normally uses autotools. Its `./configure` step picks field / scalar -# widths based on compiler features and writes a `libsecp256k1-config.h` that -# `src/secp256k1.c` includes. We skip all of that and set the equivalent -# `-D` flags directly. -# -# Source layout rationale: `src/secp256k1.c` is the only real translation unit — -# every other `.c` under src/ is either test / benchmark scaffolding or a -# precomputed-table generator. The two files that *are* compiled into the -# library in addition to `secp256k1.c` are the prebuilt tables: -# `src/precomputed_ecmult.c` and `src/precomputed_ecmult_gen.c`. Upstream -# checks these in so downstream builds don't need Python / codegen at build -# time. -# -# Modules such as recovery / ECDH / schnorrsig are deliberately NOT enabled -# here. The initial scope for workerd is ECDSA over secp256k1 (sign / verify / -# key handling), which the core library provides. When a caller needs -# `secp256k1_ecdsa_recover` (Ethereum's ecrecover) or similar, flip on the -# matching `ENABLE_MODULE_*` define below and list any extra sources from -# `src/modules//Makefile.am.include`. - cc_library( name = "secp256k1", srcs = [ @@ -34,49 +12,19 @@ cc_library( "include/secp256k1_preallocated.h", ], copts = [ - # Silence upstream warnings without masking ours. libsecp256k1 is - # extensively reviewed and using it as-is keeps the cert story clean. "-Wno-unused-function", "-Wno-nonnull", ], - # `local_defines` applies these flags only when compiling this target's - # own sources, not when compiling anything that depends on us. This matches - # how `BUILD.zlib` handles its configuration knobs — they are internal to - # the library's translation units and should not leak onto consumers. local_defines = [ - # Standard flag when building the library as a static archive. - # Equivalent to what autotools' configure would have set. "SECP256K1_STATIC", - # Field representation. On 64-bit targets (all workerd platforms) - # libsecp256k1's 5x52 representation using native `__int128` is - # dramatically faster than the 10x26 fallback. workerd only supports - # 64-bit targets (see README's supported platforms list). + # 5x52 / 4x64 / __int128 require a 64-bit target, which workerd always builds for. "USE_FIELD_5X52=1", - # Scalar representation to match the 64-bit field. "USE_SCALAR_4X64=1", - # Use native 128-bit integer type (__int128) — required for 5x52 / - # 4x64. Clang and GCC both support this on every 64-bit target - # workerd builds for. "USE_FORCE_WIDEMUL_INT128=1", - # Ecmult window size for signed point multiplication. Upstream default - # when autotools can't probe otherwise; trades a few KB of code size - # for performance. "ECMULT_WINDOW_SIZE=15", - # Precomputed generator-multiplication table size. 4 is the upstream - # default and produces a ~64KB table. "ECMULT_GEN_PREC_BITS=4", ], - # Public include prefix so consumers write `#include `. strip_include_prefix = "include", - # Private headers (everything under src/) need to be visible to the - # compiler when building src/secp256k1.c, but are NOT part of the public - # API. `textual_hdrs` is the idiomatic way to express "include these from - # .c sources but don't expose them to downstream consumers". - # - # Note: libsecp256k1 splits its logic across `*.h` (declarations) and - # `*_impl.h` (inline implementations that get `#include`d from the - # single `src/secp256k1.c` translation unit). Both are plain headers, so - # one glob covers everything. textual_hdrs = glob(["src/**/*.h"]), visibility = ["//visibility:public"], ) diff --git a/build/deps/deps.jsonc b/build/deps/deps.jsonc index 38f41a7105b..c5c71a15ccb 100644 --- a/build/deps/deps.jsonc +++ b/build/deps/deps.jsonc @@ -113,11 +113,6 @@ "build_file": "//:build/BUILD.simdutf", "file_regex": "singleheader.zip" }, - // libsecp256k1 is Bitcoin Core's optimized implementation of ECDSA (and - // more) over the secp256k1 curve. BoringSSL does not implement secp256k1, - // so workerd vendors libsecp256k1 separately and routes WebCrypto ECDSA - // operations for `namedCurve: "secp256k1"` through it. The compat flag - // `secp256k1_ecdsa_curve` gates user-visible exposure. { "name": "libsecp256k1", "type": "github_tarball", diff --git a/src/workerd/api/crypto/ec.c++ b/src/workerd/api/crypto/ec.c++ index 900f14b4ea0..9e5f6a6cf83 100644 --- a/src/workerd/api/crypto/ec.c++ +++ b/src/workerd/api/crypto/ec.c++ @@ -430,8 +430,8 @@ class EllipticKey final: public AsymmetricKeyCryptoKeyImpl { struct EllipticCurveInfo { kj::StringPtr normalizedName; - int opensslCurveId; // NOTE: Not used by sepc256k1 (set to 0) - uint rsSize; // size of "r" and "s" in the signature. + int opensslCurveId; // 0 for curves BoringSSL doesn't support (e.g. secp256k1). + uint rsSize; // Size of "r" and "s" in the signature. }; EllipticCurveInfo lookupEllipticCurve(kj::StringPtr curveName) { @@ -447,12 +447,11 @@ EllipticCurveInfo lookupEllipticCurve(kj::StringPtr curveName) { return iter->second; } -// Overload that additionally honours the `secp256k1_ecdsa_curve` compatibility flag +// Overload that adds secp256k1 to the table when the compat flag is set. EllipticCurveInfo lookupEllipticCurve(jsg::Lock& js, kj::StringPtr curveName) { - // secp256k1 is not available via BoringSSL, so we route it to libsecp256k1. if (FeatureFlags::get(js).getSecp256k1EcdsaCurve() && strcasecmp(curveName.cStr(), "secp256k1") == 0) { - return {"secp256k1"_kj, 0, 32}; + return {"secp256k1", 0, 32}; } return lookupEllipticCurve(curveName); } @@ -468,10 +467,14 @@ kj::OneOf, CryptoKeyPair> EllipticKey::generateElliptic(jsg: auto [normalizedNamedCurve, curveId, rsSize] = lookupEllipticCurve(js, namedCurve); - // TODO: Implement secp256k1 key generation via libsecp256k1 - JSG_REQUIRE(strcasecmp(normalizedNamedCurve.cStr(), "secp256k1") != 0, DOMNotSupportedError, - "Key generation for \"secp256k1\" is not yet implemented. The curve is recognized but the " - "backend is not available in this build."); + // The compat flag scopes secp256k1 support to ECDSA; ECDH is rejected. + if (strcasecmp(normalizedNamedCurve.cStr(), "secp256k1") == 0) { + JSG_REQUIRE(normalizedName == "ECDSA", DOMNotSupportedError, "\"", normalizedName, + "\" is not supported for curve \"secp256k1\"."); + return Secp256k1Key::generatePair(js, + CryptoKey::EllipticKeyAlgorithm{normalizedName, normalizedNamedCurve}, extractable, + privateKeyUsages, publicKeyUsages); + } auto keyAlgorithm = CryptoKey::EllipticKeyAlgorithm{ normalizedName, @@ -718,30 +721,10 @@ kj::Own CryptoKey::Impl::importEcdsa(jsg::Lock& js, auto [normalizedNamedCurve, curveId, rsSize] = lookupEllipticCurve(js, namedCurve); - // secp256k1 is not available via BoringSSL, so we route it to libsecp256k1. - // "raw" and "jwk" are the two formats EVM ecosystem tooling uses in - // practice; "spki" and "pkcs8" are not yet implemented for this curve. if (strcasecmp(normalizedNamedCurve.cStr(), "secp256k1") == 0) { - auto usages = CryptoKeyUsageSet::validate(normalizedName, - CryptoKeyUsageSet::Context::importPublic, keyUsages, CryptoKeyUsageSet::verify()); - auto keyAlg = CryptoKey::EllipticKeyAlgorithm{normalizedName, normalizedNamedCurve}; - - if (format == "raw") { - JSG_REQUIRE(keyData.is>(), DOMDataError, - "Expected raw secp256k1 key but got a JSON Web Key."); - return Secp256k1Key::importRawPublic( - js, keyData.get>().asPtr(), kj::mv(keyAlg), extractable, usages); - } - - if (format == "jwk") { - JSG_REQUIRE(keyData.is(), DOMDataError, - "Expected JSON Web Key but got raw bytes."); - return Secp256k1Key::importJwk( - js, kj::mv(keyData.get()), kj::mv(keyAlg), extractable, usages); - } - - JSG_FAIL_REQUIRE(DOMNotSupportedError, "Key import format \"", format, - "\" is not yet implemented for secp256k1 (only \"raw\" and \"jwk\" are supported)."); + return Secp256k1Key::import(js, normalizedName, format, kj::mv(keyData), + CryptoKey::EllipticKeyAlgorithm{normalizedName, normalizedNamedCurve}, extractable, + keyUsages); } auto importedKey = [&, curveId = curveId] { diff --git a/src/workerd/api/crypto/secp256k1-key.c++ b/src/workerd/api/crypto/secp256k1-key.c++ index 6a8bc9cbeb5..5edb41757c3 100644 --- a/src/workerd/api/crypto/secp256k1-key.c++ +++ b/src/workerd/api/crypto/secp256k1-key.c++ @@ -6,7 +6,10 @@ #include "impl.h" +#include + #include +#include #include #include @@ -15,254 +18,342 @@ namespace workerd::api { namespace { -// A singleton libsecp256k1 context used for verify-only operations. -// -// `secp256k1_context_create()` is not cheap (it generates internal lookup -// tables), so the library documentation explicitly recommends creating one -// context per process and reusing it across calls. The context is -// thread-safe for read-only operations like `secp256k1_ecdsa_verify`; -// only randomization and destruction require external synchronization, and -// we do neither after creation. -// -// `SECP256K1_CONTEXT_NONE` is the right flag here — the `_SIGN` / `_VERIFY` -// flags are deprecated no-ops in the current libsecp256k1 API. -const secp256k1_context* getSecp256k1Context() { +constexpr size_t kSeckeyLen = 32; +constexpr size_t kPubkeyCompressedLen = 33; +constexpr size_t kPubkeyUncompressedLen = 65; +constexpr size_t kCoordinateLen = 32; +constexpr size_t kSignatureLen = 64; + +// Lazily-initialized process-wide context. Safe to share across threads since we only ever +// pass it as a const pointer (the library guarantees that's lock-free). +const secp256k1_context* context() { static const secp256k1_context* ctx = []() { secp256k1_context* c = secp256k1_context_create(SECP256K1_CONTEXT_NONE); - KJ_ASSERT(c != nullptr, "failed to create libsecp256k1 context"); + KJ_ASSERT(c != nullptr); return c; }(); return ctx; } -// Serialize a `secp256k1_pubkey` to its 33-byte compressed SEC1 form. -// Used for equality comparison and raw export. -kj::Array serializePubkeyCompressed(const secp256k1_pubkey& pubkey) { - auto out = kj::heapArray(33); - size_t outLen = out.size(); - auto ret = secp256k1_ec_pubkey_serialize( - getSecp256k1Context(), out.begin(), &outLen, &pubkey, SECP256K1_EC_COMPRESSED); - KJ_ASSERT(ret == 1, "secp256k1_ec_pubkey_serialize should never fail on a valid parsed pubkey"); - KJ_ASSERT(outLen == 33, "compressed secp256k1 pubkey should always be 33 bytes"); - return out; -} - -// Serialize a `secp256k1_pubkey` to its 65-byte uncompressed SEC1 form: -// `04 || x (32 bytes) || y (32 bytes)`. Used by JWK export, which needs the -// `x` and `y` coordinates separately. -kj::Array serializePubkeyUncompressed(const secp256k1_pubkey& pubkey) { - auto out = kj::heapArray(65); - size_t outLen = out.size(); - auto ret = secp256k1_ec_pubkey_serialize( - getSecp256k1Context(), out.begin(), &outLen, &pubkey, SECP256K1_EC_UNCOMPRESSED); - KJ_ASSERT(ret == 1, "secp256k1_ec_pubkey_serialize should never fail on a valid parsed pubkey"); - KJ_ASSERT(outLen == 65, "uncompressed secp256k1 pubkey should always be 65 bytes"); +kj::Array serializePubkey(const secp256k1_pubkey& pubkey, unsigned int flags) { + size_t len = (flags == SECP256K1_EC_COMPRESSED) ? kPubkeyCompressedLen : kPubkeyUncompressedLen; + auto out = kj::heapArray(len); + size_t outLen = len; + auto ret = secp256k1_ec_pubkey_serialize(context(), out.begin(), &outLen, &pubkey, flags); + KJ_ASSERT(ret == 1); + KJ_ASSERT(outLen == len); return out; } -// Compute the SHA-256-family digest of `data`, matching WebCrypto's ECDSA -// behaviour where the hash is specified at sign/verify call time. Returns -// the digest bytes (typically 32 bytes for SHA-256, 48 for SHA-384, etc.). kj::Array computeDigest(kj::StringPtr hashName, kj::ArrayPtr data) { const EVP_MD* md = lookupDigestAlgorithm(hashName).second; - auto digestCtx = kj::disposeWith(EVP_MD_CTX_new()); - KJ_ASSERT(digestCtx.get() != nullptr); - OSSLCALL(EVP_DigestInit_ex(digestCtx.get(), md, nullptr)); - OSSLCALL(EVP_DigestUpdate(digestCtx.get(), data.begin(), data.size())); auto out = kj::heapArray(EVP_MD_size(md)); - unsigned int outLen = 0; - OSSLCALL(EVP_DigestFinal_ex(digestCtx.get(), out.begin(), &outLen)); + unsigned int outLen = out.size(); + OSSLCALL(EVP_Digest(data.begin(), data.size(), out.begin(), &outLen, md, nullptr)); KJ_ASSERT(outLen == out.size()); return out; } -} // namespace +kj::Own wrapSecret(kj::Array&& bytes) { + return kj::heap(kj::mv(bytes)); +} + +// Use on public keys only. Secret material must go through ZeroOnFree-wrapped storage. +kj::Array decodeJwkCoordinate( + kj::String&& encoded, size_t expectedLen, kj::StringPtr fieldName) { + auto bytes = JSG_REQUIRE_NONNULL(decodeBase64Url(kj::mv(encoded)), DOMDataError, + "Invalid base64url encoding in JSON Web Key \"", fieldName, "\" field."); + JSG_REQUIRE(bytes.size() <= expectedLen, DOMDataError, "JSON Web Key \"", fieldName, + "\" must be at most ", expectedLen, " bytes."); + auto out = kj::heapArray(expectedLen); + memset(out.begin(), 0, expectedLen); + out.slice(expectedLen - bytes.size(), expectedLen).copyFrom(bytes); + return out; +} -Secp256k1Key::Secp256k1Key(secp256k1_pubkey parsedPubkey, +kj::Own importRaw(kj::ArrayPtr keyData, CryptoKey::EllipticKeyAlgorithm keyAlgorithm, bool extractable, - CryptoKeyUsageSet usages) - : CryptoKey::Impl(extractable, usages), - parsedPubkey(parsedPubkey), - keyAlgorithm(kj::mv(keyAlgorithm)) {} + CryptoKeyUsageSet usages) { + JSG_REQUIRE(keyData.size() == kPubkeyCompressedLen || keyData.size() == kPubkeyUncompressedLen, + DOMDataError, "Invalid secp256k1 public key length (expected ", kPubkeyCompressedLen, " or ", + kPubkeyUncompressedLen, " bytes, got ", keyData.size(), ")."); -kj::Own Secp256k1Key::importJwk(jsg::Lock& js, - SubtleCrypto::JsonWebKey&& jwk, + secp256k1_pubkey parsed; + JSG_REQUIRE(secp256k1_ec_pubkey_parse(context(), &parsed, keyData.begin(), keyData.size()) == 1, + DOMDataError, "Invalid secp256k1 public key (failed to parse)."); + + return kj::heap( + KeyType::PUBLIC, parsed, kj::none, kj::mv(keyAlgorithm), extractable, usages); +} + +kj::Own importJwk(SubtleCrypto::JsonWebKey&& jwk, CryptoKey::EllipticKeyAlgorithm keyAlgorithm, bool extractable, CryptoKeyUsageSet usages) { - // RFC 7518 §6.2: EC public keys use `kty`=EC and carry `crv`, `x`, `y`. - // secp256k1 is not one of the NIST curves registered in the IANA JOSE - // registry, but RFC 8812 defines `crv`="secp256k1" (and `alg`="ES256K") - // for JOSE / COSE usage. We accept that curve string directly. - JSG_REQUIRE(jwk.kty == "EC", DOMDataError, - "secp256k1 \"jwk\" import requires a JSON Web Key with Key Type parameter \"kty\" (\"", - jwk.kty, "\") equal to \"EC\"."); + "secp256k1 \"jwk\" import requires \"kty\" == \"EC\" (got \"", jwk.kty, "\")."); - auto& crv = JSG_REQUIRE_NONNULL( - jwk.crv, DOMDataError, "Missing \"crv\" field in JSON Web Key for secp256k1 import."); - JSG_REQUIRE(crv == "secp256k1", DOMDataError, "\"crv\" field in JSON Web Key (\"", crv, + auto& crv = JSG_REQUIRE_NONNULL(jwk.crv, DOMDataError, "Missing \"crv\" in JSON Web Key."); + JSG_REQUIRE(crv == "secp256k1", DOMDataError, "JSON Web Key \"crv\" (\"", crv, "\") does not match expected \"secp256k1\"."); - // If an `alg` field is present, it must be the secp256k1 JOSE identifier - // defined by RFC 8812. We don't require it (many JWKs omit `alg`). + // alg is optional; if present it must be ES256K (RFC 8812). KJ_IF_SOME(alg, jwk.alg) { - JSG_REQUIRE(alg == "ES256K", DOMDataError, "JSON Web Key Algorithm parameter \"alg\" (\"", alg, - "\") does not match the expected value \"ES256K\" for secp256k1."); + JSG_REQUIRE(alg == "ES256K", DOMDataError, "JSON Web Key \"alg\" (\"", alg, + "\") does not match expected \"ES256K\"."); } - // Private-key JWKs carry a `d` component. Private keys are not yet - // supported for secp256k1; reject clearly rather than silently discarding - // the private material. - JSG_REQUIRE(jwk.d == kj::none, DOMNotSupportedError, - "Importing secp256k1 private keys (JWKs with a \"d\" field) is not yet implemented."); - - // Decode the base64url-encoded coordinates. RFC 7518 §6.2.1.2/.3 requires - // both `x` and `y` to be the full 32-byte scalar, left-padded with zeros - // if necessary. We accept shorter encodings and zero-pad, matching what - // the NIST-curve ECDSA path does. - auto xBytes = JSG_REQUIRE_NONNULL(decodeBase64Url(JSG_REQUIRE_NONNULL(kj::mv(jwk.x), DOMDataError, - "Missing \"x\" field in secp256k1 JSON Web Key.")), - DOMDataError, "Invalid base64url encoding in JSON Web Key \"x\" field."); - auto yBytes = JSG_REQUIRE_NONNULL(decodeBase64Url(JSG_REQUIRE_NONNULL(kj::mv(jwk.y), DOMDataError, - "Missing \"y\" field in secp256k1 JSON Web Key.")), - DOMDataError, "Invalid base64url encoding in JSON Web Key \"y\" field."); - - JSG_REQUIRE(xBytes.size() <= 32 && yBytes.size() <= 32, DOMDataError, - "secp256k1 JSON Web Key coordinates must be at most 32 bytes each."); - - // Reconstruct the 65-byte uncompressed SEC1 encoding (0x04 || x || y), - // left-padding x and y if the JWK provided shorter encodings. Hand the - // result to the existing raw-import path for point validation. - kj::byte sec1[65] = {}; + auto xBytes = decodeJwkCoordinate( + JSG_REQUIRE_NONNULL(kj::mv(jwk.x), DOMDataError, "Missing \"x\" in JSON Web Key."), + kCoordinateLen, "x"); + auto yBytes = decodeJwkCoordinate( + JSG_REQUIRE_NONNULL(kj::mv(jwk.y), DOMDataError, "Missing \"y\" in JSON Web Key."), + kCoordinateLen, "y"); + + kj::byte sec1[kPubkeyUncompressedLen] = {}; sec1[0] = 0x04; - kj::arrayPtr(sec1).slice(1 + 32 - xBytes.size(), 1 + 32).copyFrom(xBytes); - kj::arrayPtr(sec1).slice(1 + 32 + 32 - yBytes.size(), 65).copyFrom(yBytes); + memcpy(sec1 + 1, xBytes.begin(), kCoordinateLen); + memcpy(sec1 + 1 + kCoordinateLen, yBytes.begin(), kCoordinateLen); - return importRawPublic(js, kj::arrayPtr(sec1, 65), kj::mv(keyAlgorithm), extractable, usages); + secp256k1_pubkey pubkey; + JSG_REQUIRE(secp256k1_ec_pubkey_parse(context(), &pubkey, sec1, sizeof(sec1)) == 1, DOMDataError, + "Invalid secp256k1 public key in JSON Web Key (coordinates are not on curve)."); + + if (jwk.d == kj::none) { + return kj::heap( + KeyType::PUBLIC, pubkey, kj::none, kj::mv(keyAlgorithm), extractable, usages); + } + + // d is decoded into already-wrapped storage. The base64url intermediate is the only + // plaintext copy and gets scrubbed on the way out, including on error paths. RFC 7518 + // permits shorter encodings, so we zero-pad to 32 bytes. + auto scrubbed = wrapSecret(kj::heapArray(kSeckeyLen)); + memset(scrubbed->asPtr().begin(), 0, kSeckeyLen); + + auto dRaw = JSG_REQUIRE_NONNULL(decodeBase64Url(kj::mv(KJ_ASSERT_NONNULL(jwk.d))), DOMDataError, + "Invalid base64url encoding in JSON Web Key \"d\" field."); + KJ_DEFER(OPENSSL_cleanse(dRaw.begin(), dRaw.size())); + JSG_REQUIRE(dRaw.size() <= kSeckeyLen, DOMDataError, + "secp256k1 JSON Web Key private scalar \"d\" must be at most ", kSeckeyLen, " bytes."); + scrubbed->asPtr().slice(kSeckeyLen - dRaw.size(), kSeckeyLen).copyFrom(dRaw); + + // d must be in range and must derive the claimed public point. pubkey_create rejects + // out-of-range scalars internally, so a single check covers both conditions. + secp256k1_pubkey derivedPubkey; + JSG_REQUIRE(secp256k1_ec_pubkey_create(context(), &derivedPubkey, scrubbed->begin()) == 1, + DOMDataError, "Invalid secp256k1 private key in JSON Web Key (\"d\" is out of range)."); + auto derivedSerialized = serializePubkey(derivedPubkey, SECP256K1_EC_UNCOMPRESSED); + JSG_REQUIRE(CRYPTO_memcmp(derivedSerialized.begin(), sec1, kPubkeyUncompressedLen) == 0, + DOMDataError, + "secp256k1 JSON Web Key is inconsistent: public coordinates do not match the point derived " + "from the private scalar."); + + return kj::heap( + KeyType::PRIVATE, pubkey, kj::mv(scrubbed), kj::mv(keyAlgorithm), extractable, usages); } -kj::Own Secp256k1Key::importRawPublic(jsg::Lock& js, - kj::ArrayPtr keyData, +} // namespace + +Secp256k1Key::Secp256k1Key(KeyType keyType, + secp256k1_pubkey parsedPubkey, + kj::Maybe> privateKey, CryptoKey::EllipticKeyAlgorithm keyAlgorithm, bool extractable, - CryptoKeyUsageSet usages) { - // SEC1 public key encodings: 33 bytes compressed (0x02/0x03 prefix) or - // 65 bytes uncompressed (0x04 prefix). libsecp256k1's parser accepts - // both formats and validates that the encoded point is actually on the - // secp256k1 curve. - JSG_REQUIRE(keyData.size() == 33 || keyData.size() == 65, DOMDataError, - "Invalid secp256k1 public key length (expected 33 or 65 bytes, got ", keyData.size(), ")."); + CryptoKeyUsageSet usages) + : CryptoKey::Impl(extractable, usages), + keyType(keyType), + parsedPubkey(parsedPubkey), + privateKey(kj::mv(privateKey)), + keyAlgorithm(kj::mv(keyAlgorithm)) {} - secp256k1_pubkey parsed; - JSG_REQUIRE(secp256k1_ec_pubkey_parse( - getSecp256k1Context(), &parsed, keyData.begin(), keyData.size()) == 1, - DOMDataError, "Invalid secp256k1 public key (failed to parse)."); +kj::StringPtr Secp256k1Key::getAlgorithmName() const { + return keyAlgorithm.name; +} + +CryptoKey::AlgorithmVariant Secp256k1Key::getAlgorithm(jsg::Lock& js) const { + return keyAlgorithm; +} - return kj::heap(parsed, kj::mv(keyAlgorithm), extractable, usages); +kj::StringPtr Secp256k1Key::getType() const { + return keyType == KeyType::PRIVATE ? "private"_kj : "public"_kj; +} + +kj::StringPtr Secp256k1Key::jsgGetMemoryName() const { + return "Secp256k1Key"; +} + +size_t Secp256k1Key::jsgGetMemorySelfSize() const { + return sizeof(Secp256k1Key); +} + +kj::Own Secp256k1Key::import(jsg::Lock& js, + kj::StringPtr normalizedName, + kj::StringPtr format, + SubtleCrypto::ImportKeyData keyData, + CryptoKey::EllipticKeyAlgorithm keyAlgorithm, + bool extractable, + kj::ArrayPtr keyUsages) { + if (format == "raw") { + JSG_REQUIRE(keyData.is>(), DOMDataError, + "Expected raw secp256k1 key but got a JSON Web Key."); + auto usages = CryptoKeyUsageSet::validate(normalizedName, + CryptoKeyUsageSet::Context::importPublic, keyUsages, CryptoKeyUsageSet::verify()); + return importRaw( + keyData.get>().asPtr(), kj::mv(keyAlgorithm), extractable, usages); + } + + if (format == "jwk") { + JSG_REQUIRE(keyData.is(), DOMDataError, + "Expected JSON Web Key but got raw bytes."); + auto& jwk = keyData.get(); + bool isPrivate = jwk.d != kj::none; + auto usages = CryptoKeyUsageSet::validate(normalizedName, + isPrivate ? CryptoKeyUsageSet::Context::importPrivate + : CryptoKeyUsageSet::Context::importPublic, + keyUsages, isPrivate ? CryptoKeyUsageSet::sign() : CryptoKeyUsageSet::verify()); + return importJwk(kj::mv(jwk), kj::mv(keyAlgorithm), extractable, usages); + } + + // TODO(secp256k1): "spki"/"pkcs8" need hand-rolled DER since BoringSSL can't marshal this curve. + JSG_FAIL_REQUIRE(DOMNotSupportedError, "Key import format \"", format, + "\" is not yet implemented for secp256k1 (only \"raw\" and \"jwk\" are supported)."); +} + +CryptoKeyPair Secp256k1Key::generatePair(jsg::Lock& js, + CryptoKey::EllipticKeyAlgorithm keyAlgorithm, + bool extractable, + CryptoKeyUsageSet privateKeyUsages, + CryptoKeyUsageSet publicKeyUsages) { + // A uniformly-random 32-byte string is a valid secp256k1 scalar almost always (~1 in 2^224 + // chance it's invalid). The cap is just defense against a broken RNG. + auto scrubbed = wrapSecret(kj::heapArray(kSeckeyLen)); + constexpr int kMaxAttempts = 256; + bool ok = false; + for (int attempt = 0; attempt < kMaxAttempts; ++attempt) { + IoContext::current().getEntropySource().generate(scrubbed->asPtr()); + if (secp256k1_ec_seckey_verify(context(), scrubbed->begin()) == 1) { + ok = true; + break; + } + } + JSG_REQUIRE(ok, InternalDOMOperationError, + "Failed to sample a valid secp256k1 private scalar after many attempts."); + + secp256k1_pubkey pubkey; + JSG_REQUIRE(secp256k1_ec_pubkey_create(context(), &pubkey, scrubbed->begin()) == 1, + InternalDOMOperationError, "Failed to derive secp256k1 public key from generated scalar."); + + // Public keys are always extractable per WebCrypto; only the private half honours `extractable`. + auto privateKeyAlg = keyAlgorithm; + auto privateKeyRef = js.alloc(kj::heap(KeyType::PRIVATE, pubkey, + kj::mv(scrubbed), kj::mv(privateKeyAlg), extractable, privateKeyUsages)); + auto publicKeyRef = js.alloc(kj::heap( + KeyType::PUBLIC, pubkey, kj::none, kj::mv(keyAlgorithm), true, publicKeyUsages)); + + return CryptoKeyPair{ + .publicKey = kj::mv(publicKeyRef), + .privateKey = kj::mv(privateKeyRef), + }; +} + +jsg::JsArrayBuffer Secp256k1Key::sign(jsg::Lock& js, + SubtleCrypto::SignAlgorithm&& algorithm, + kj::ArrayPtr data) const { + JSG_REQUIRE(keyType == KeyType::PRIVATE, DOMInvalidAccessError, + "ECDSA sign requires a private key, not \"", getType(), "\"."); + auto& seckey = KJ_ASSERT_NONNULL(privateKey); + + auto hashName = api::getAlgorithmName(JSG_REQUIRE_NONNULL(algorithm.hash, TypeError, + "Missing \"hash\" in algorithm (ECDSA requires hash to be specified at call time).")); + auto digest = computeDigest(hashName, data); + + // Passing nullptr for noncefp picks libsecp256k1's default nonce function, which is the + // RFC 6979 deterministic one. The library produces signatures in low-s form by default. + secp256k1_ecdsa_signature sig; + JSG_REQUIRE( + secp256k1_ecdsa_sign(context(), &sig, digest.begin(), seckey->begin(), nullptr, nullptr) == 1, + DOMOperationError, "secp256k1 ECDSA signing failed."); + + auto out = jsg::JsArrayBuffer::create(js, kSignatureLen); + JSG_REQUIRE( + secp256k1_ecdsa_signature_serialize_compact(context(), out.asArrayPtr().begin(), &sig) == 1, + DOMOperationError, "Failed to serialize secp256k1 ECDSA signature."); + return out; } bool Secp256k1Key::verify(jsg::Lock& js, SubtleCrypto::SignAlgorithm&& algorithm, kj::ArrayPtr signature, kj::ArrayPtr data) const { - // ECDSA verify requires: the hash name (specified at call time, not on the - // key), the signature in "raw" WebCrypto format (r || s, each 32 bytes for - // secp256k1, so 64 bytes total), and the data to verify. - - // 1. Check signature size. WebCrypto ECDSA-secp256k1 signatures are always - // exactly 64 bytes (32-byte r concatenated with 32-byte s). A - // mismatched size is simply a bad signature; per spec we return false - // rather than throwing. - if (signature.size() != 64) { - return false; - } + // Malformed signatures → false (per WebCrypto spec), not an exception. + if (signature.size() != kSignatureLen) return false; - // 2. Resolve the hash algorithm. ECDSA is one of the WebCrypto algorithms - // that takes `hash` at verify time, so it MUST be present. auto hashName = api::getAlgorithmName(JSG_REQUIRE_NONNULL(algorithm.hash, TypeError, - "Missing \"hash\" in algorithm (ECDSA requires the hash algorithm to be specified at call " - "time).")); - - // 3. Hash the message. We delegate the hashing itself to BoringSSL — it - // has SHA-2 / SHA-3 / etc. libsecp256k1 deliberately does not - // implement hashing. + "Missing \"hash\" in algorithm (ECDSA requires hash to be specified at call time).")); auto digest = computeDigest(hashName, data); - // 4. Parse the (r || s) concatenated signature into libsecp256k1's - // internal form. `secp256k1_ecdsa_signature_parse_compact` expects - // exactly 64 bytes in big-endian r then s order, which is exactly - // what WebCrypto produces. secp256k1_ecdsa_signature sig; - if (secp256k1_ecdsa_signature_parse_compact(getSecp256k1Context(), &sig, signature.begin()) != - 1) { - // Signature has invalid r or s (e.g. >= curve order). Per spec, - // malformed signatures produce a `false` return, not an exception. + if (secp256k1_ecdsa_signature_parse_compact(context(), &sig, signature.begin()) != 1) { return false; } - // 5. Verify. libsecp256k1 enforces low-s normalization here (signatures - // with s > n/2 are rejected) which matches Bitcoin / Ethereum - // consensus rules and prevents malleability. This is stricter than - // generic ECDSA but matches what essentially every secp256k1 - // consumer wants. - return secp256k1_ecdsa_verify(getSecp256k1Context(), &sig, digest.begin(), &parsedPubkey) == 1; + // `secp256k1_ecdsa_verify` rejects high-s signatures, preventing signature malleability. + return secp256k1_ecdsa_verify(context(), &sig, digest.begin(), &parsedPubkey) == 1; } SubtleCrypto::ExportKeyData Secp256k1Key::exportKey(jsg::Lock& js, kj::StringPtr format) const { - // "raw" emits the 33-byte compressed SEC1 form, matching what the rest - // of the WebCrypto ECDSA implementation does for public keys (see - // `EllipticKey::exportRaw` for the NIST curve analogue). if (format == "raw") { - auto serialized = serializePubkeyCompressed(parsedPubkey); - // `ExportKeyData` is a `OneOf, JsonWebKey>`, - // so we need to wrap the freshly-created JsArrayBuffer in a JsRef via - // `.addRef(js)` — a plain JsArrayBuffer won't implicitly convert. + JSG_REQUIRE(keyType == KeyType::PUBLIC, DOMInvalidAccessError, + "secp256k1 \"raw\" export requires a public key, not \"", getType(), "\"."); + // Match `EllipticKey::getRawPublicKey` and WebCrypto convention: uncompressed SEC1 (65 bytes, + // `04 || x || y`). Accepted on import are either 33-byte compressed or 65-byte uncompressed. + auto serialized = serializePubkey(parsedPubkey, SECP256K1_EC_UNCOMPRESSED); return jsg::JsArrayBuffer::create(js, serialized.asPtr()).addRef(js); } - // "jwk" emits RFC 7517 / RFC 7518 / RFC 8812 JSON Web Key form. if (format == "jwk") { - auto uncompressed = serializePubkeyUncompressed(parsedPubkey); - // uncompressed[0] is the 0x04 SEC1 tag; the following 32 bytes are x, - // then 32 bytes of y. - auto xBytes = uncompressed.slice(1, 33); - auto yBytes = uncompressed.slice(33, 65); - + auto uncompressed = serializePubkey(parsedPubkey, SECP256K1_EC_UNCOMPRESSED); SubtleCrypto::JsonWebKey jwk; jwk.kty = kj::str("EC"); jwk.crv = kj::str("secp256k1"); - jwk.x = fastEncodeBase64Url(xBytes); - jwk.y = fastEncodeBase64Url(yBytes); - // `ext` and `key_ops` are set by `SubtleCrypto::exportKey`'s caller in - // the AsymmetricKeyCryptoKeyImpl path, but since we inherit directly - // from `CryptoKey::Impl`, we set them ourselves here. See the parallel - // code in AsymmetricKeyCryptoKeyImpl::exportKey. + jwk.x = fastEncodeBase64Url(uncompressed.slice(1, 1 + kCoordinateLen)); + jwk.y = fastEncodeBase64Url(uncompressed.slice(1 + kCoordinateLen, kPubkeyUncompressedLen)); + KJ_IF_SOME(seckey, privateKey) { + jwk.d = fastEncodeBase64Url(seckey->asPtr()); + } jwk.ext = true; jwk.key_ops = getUsages().map([](auto usage) { return kj::str(usage.name()); }); return jwk; } - // TODO(secp256k1): implement "spki" public-key export (requires writing - // a small DER emitter since BoringSSL's EVP_marshal_public_key can't - // handle this curve). + // TODO(secp256k1): "spki"/"pkcs8" need hand-rolled DER since BoringSSL can't marshal this curve. JSG_FAIL_REQUIRE(DOMNotSupportedError, "Unsupported key export format for secp256k1: \"", format, "\" (only \"raw\" and \"jwk\" are supported)."); } bool Secp256k1Key::equals(const CryptoKey::Impl& other) const { - // Two secp256k1 keys are equal iff they have the same algorithm name, - // same curve, same key type, and same serialized public key bytes. - if (other.getAlgorithmName() != getAlgorithmName()) return false; - if (other.getType() != getType()) return false; - - auto* downcast = dynamic_cast(&other); - if (downcast == nullptr) return false; - - auto thisBytes = serializePubkeyCompressed(parsedPubkey); - auto thatBytes = serializePubkeyCompressed(downcast->parsedPubkey); - return thisBytes.asPtr() == thatBytes.asPtr(); + if (this == &other) return true; + + KJ_IF_SOME(that, kj::dynamicDowncastIfAvailable(other)) { + if (keyType != that.keyType) return false; + + auto thisPub = serializePubkey(parsedPubkey, SECP256K1_EC_COMPRESSED); + auto thatPub = serializePubkey(that.parsedPubkey, SECP256K1_EC_COMPRESSED); + if (CRYPTO_memcmp(thisPub.begin(), thatPub.begin(), kPubkeyCompressedLen) != 0) { + return false; + } + + // Equal public halves already imply equal private scalars for valid keys, but compare the + // scalars directly and in constant time as defense in depth. + if (keyType == KeyType::PRIVATE) { + auto& a = KJ_ASSERT_NONNULL(privateKey); + auto& b = KJ_ASSERT_NONNULL(that.privateKey); + if (CRYPTO_memcmp(a->begin(), b->begin(), kSeckeyLen) != 0) return false; + } + return true; + } + return false; } } // namespace workerd::api diff --git a/src/workerd/api/crypto/secp256k1-key.h b/src/workerd/api/crypto/secp256k1-key.h index a156f95be7c..91e9ace3172 100644 --- a/src/workerd/api/crypto/secp256k1-key.h +++ b/src/workerd/api/crypto/secp256k1-key.h @@ -1,74 +1,48 @@ -// Copyright (c) 2026 Cloudflare, Inc. -// Licensed under the Apache 2.0 license found in the LICENSE file or at: -// https://opensource.org/licenses/Apache-2.0 - #pragma once -// Secp256k1Key — WebCrypto ECDSA CryptoKey::Impl for the secp256k1 curve. -// -// BoringSSL does not implement secp256k1, so this key type cannot be stored -// in a BoringSSL `EVP_PKEY` and therefore cannot inherit from the shared -// `AsymmetricKeyCryptoKeyImpl` base (which is parameterized over `EVP_PKEY`). -// Instead, we inherit directly from `CryptoKey::Impl` and hold the raw -// public key bytes in the 64-byte libsecp256k1 parsed form. -// -// Scope: this initial version supports public keys only, and `verify()` only. -// Private keys, signing, generation, and JWK support land in follow-up PRs. - #include "crypto.h" #include "impl.h" +#include "keys.h" #include -#include -#include - namespace workerd::api { +// WebCrypto ECDSA over secp256k1, backed by libsecp256k1. Inherits from CryptoKey::Impl +// rather than AsymmetricKeyCryptoKeyImpl because BoringSSL can't represent this curve as +// an EVP_PKEY. A single instance represents either a public or a private key. class Secp256k1Key final: public CryptoKey::Impl { public: - // Construct a public-key `Secp256k1Key` from an already-parsed - // `secp256k1_pubkey`. Callers own validating / parsing the source bytes - // before constructing. - Secp256k1Key(secp256k1_pubkey parsedPubkey, + Secp256k1Key(KeyType keyType, + secp256k1_pubkey parsedPubkey, + kj::Maybe> privateKey, CryptoKey::EllipticKeyAlgorithm keyAlgorithm, bool extractable, CryptoKeyUsageSet usages); - // Import a secp256k1 public key in WebCrypto "raw" format. Accepts both - // the 33-byte compressed SEC1 form (leading 0x02 or 0x03) and the - // 65-byte uncompressed form (leading 0x04). Throws `DOMDataError` on - // malformed input. - static kj::Own importRawPublic(jsg::Lock& js, - kj::ArrayPtr keyData, + // Format is "raw" or "jwk". For "jwk", a `d` field selects the private-key path. + static kj::Own import(jsg::Lock& js, + kj::StringPtr normalizedName, + kj::StringPtr format, + SubtleCrypto::ImportKeyData keyData, CryptoKey::EllipticKeyAlgorithm keyAlgorithm, bool extractable, - CryptoKeyUsageSet usages); + kj::ArrayPtr keyUsages); - // Import a secp256k1 public key from a JSON Web Key (RFC 7517 / RFC 7518). - // The JWK must have `kty` = "EC" and `crv` = "secp256k1". The `x` and `y` - // coordinates are base64url-encoded 32-byte scalars. Private-key JWKs - // (those with a `d` field) are rejected until the signing path lands. - static kj::Own importJwk(jsg::Lock& js, - SubtleCrypto::JsonWebKey&& jwk, + // Must be called from within an IoContext (uses the request's entropy source). + static CryptoKeyPair generatePair(jsg::Lock& js, CryptoKey::EllipticKeyAlgorithm keyAlgorithm, bool extractable, - CryptoKeyUsageSet usages); + CryptoKeyUsageSet privateKeyUsages, + CryptoKeyUsageSet publicKeyUsages); - // --------------------------------------------------------------------- - // CryptoKey::Impl overrides + kj::StringPtr getAlgorithmName() const override; + CryptoKey::AlgorithmVariant getAlgorithm(jsg::Lock& js) const override; + kj::StringPtr getType() const override; - kj::StringPtr getAlgorithmName() const override { - return "ECDSA"; - } - - CryptoKey::AlgorithmVariant getAlgorithm(jsg::Lock& js) const override { - return keyAlgorithm; - } - - kj::StringPtr getType() const override { - return "public"; - } + jsg::JsArrayBuffer sign(jsg::Lock& js, + SubtleCrypto::SignAlgorithm&& algorithm, + kj::ArrayPtr data) const override; bool verify(jsg::Lock& js, SubtleCrypto::SignAlgorithm&& algorithm, @@ -79,18 +53,14 @@ class Secp256k1Key final: public CryptoKey::Impl { bool equals(const CryptoKey::Impl& other) const override; - kj::StringPtr jsgGetMemoryName() const override { - return "Secp256k1Key"; - } - size_t jsgGetMemorySelfSize() const override { - return sizeof(Secp256k1Key); - } + kj::StringPtr jsgGetMemoryName() const override; + size_t jsgGetMemorySelfSize() const override; private: - // The parsed public key. libsecp256k1's internal representation is 64 - // bytes but should be treated as opaque and only manipulated through - // library functions. + KeyType keyType; secp256k1_pubkey parsedPubkey; + // ZeroOnFree's destructor suppresses its implicit moves, so it can't go in kj::Maybe directly. + kj::Maybe> privateKey; CryptoKey::EllipticKeyAlgorithm keyAlgorithm; }; diff --git a/src/workerd/api/tests/BUILD.bazel b/src/workerd/api/tests/BUILD.bazel index a5b9ea122ca..807b851b513 100644 --- a/src/workerd/api/tests/BUILD.bazel +++ b/src/workerd/api/tests/BUILD.bazel @@ -7,15 +7,6 @@ wd_test( data = ["structuredclone-error-serialize-test.js"], ) -wd_test( - src = "crypto-secp256k1-test.wd-test", - args = ["--experimental"], - data = [ - "crypto-secp256k1-no-flag-test.js", - "crypto-secp256k1-with-flag-test.js", - ], -) - wd_test( src = "deserialize-hardening-test.wd-test", args = ["--experimental"], @@ -317,6 +308,15 @@ wd_test( data = ["crypto-impl-asymmetric-test.js"], ) +wd_test( + src = "crypto-secp256k1-test.wd-test", + args = ["--experimental"], + data = [ + "crypto-secp256k1-no-flag-test.js", + "crypto-secp256k1-with-flag-test.js", + ], +) + wd_test( src = "crypto-streams-test.wd-test", args = ["--experimental"], diff --git a/src/workerd/api/tests/crypto-secp256k1-no-flag-test.js b/src/workerd/api/tests/crypto-secp256k1-no-flag-test.js index 2ccf3d080a2..fb23a04eba3 100644 --- a/src/workerd/api/tests/crypto-secp256k1-no-flag-test.js +++ b/src/workerd/api/tests/crypto-secp256k1-no-flag-test.js @@ -1,34 +1,19 @@ // Copyright (c) 2026 Cloudflare, Inc. // Licensed under the Apache 2.0 license found in the LICENSE file or at: // https://opensource.org/licenses/Apache-2.0 -// -// secp256k1 ECDSA — behaviour when the `secp256k1_ecdsa_curve` compatibility -// flag is NOT set (the default today). The curve is unrecognized by -// `lookupEllipticCurve` and every entry point rejects with the pre-existing -// "unrecognized or unimplemented EC curve" message. -// -// This is a regression guard: if someone accidentally unconditionally -// registers secp256k1 (bypassing the flag), or changes the error message, -// these assertions will catch it. +import { rejects } from 'node:assert'; -import { rejects, strictEqual, ok } from 'node:assert'; +const ECDSA = { name: 'ECDSA', namedCurve: 'secp256k1' }; +const expectedError = { + name: 'NotSupportedError', + message: /Unrecognized or unimplemented EC curve/, +}; export const generateKeyRejected = { async test() { await rejects( - crypto.subtle.generateKey( - { name: 'ECDSA', namedCurve: 'secp256k1' }, - true, - ['sign', 'verify'] - ), - (err) => { - strictEqual(err.name, 'NotSupportedError'); - ok( - /Unrecognized or unimplemented EC curve/.test(err.message), - `unexpected message: ${err.message}` - ); - return true; - } + crypto.subtle.generateKey(ECDSA, true, ['sign', 'verify']), + expectedError ); }, }; @@ -36,21 +21,10 @@ export const generateKeyRejected = { export const importKeyRawRejected = { async test() { await rejects( - crypto.subtle.importKey( - 'raw', - new Uint8Array(33), - { name: 'ECDSA', namedCurve: 'secp256k1' }, - true, - ['verify'] - ), - (err) => { - strictEqual(err.name, 'NotSupportedError'); - ok( - /Unrecognized or unimplemented EC curve/.test(err.message), - `unexpected message: ${err.message}` - ); - return true; - } + crypto.subtle.importKey('raw', new Uint8Array(33), ECDSA, true, [ + 'verify', + ]), + expectedError ); }, }; @@ -66,18 +40,11 @@ export const importKeyJwkRejected = { x: 'AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA', y: 'AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA', }, - { name: 'ECDSA', namedCurve: 'secp256k1' }, + ECDSA, true, ['verify'] ), - (err) => { - strictEqual(err.name, 'NotSupportedError'); - ok( - /Unrecognized or unimplemented EC curve/.test(err.message), - `unexpected message: ${err.message}` - ); - return true; - } + expectedError ); }, }; diff --git a/src/workerd/api/tests/crypto-secp256k1-test.wd-test b/src/workerd/api/tests/crypto-secp256k1-test.wd-test index bb69e9e68d2..68a4b5f4817 100644 --- a/src/workerd/api/tests/crypto-secp256k1-test.wd-test +++ b/src/workerd/api/tests/crypto-secp256k1-test.wd-test @@ -1,12 +1,10 @@ using Workerd = import "/workerd/workerd.capnp"; -# secp256k1 ECDSA support tests. Two services because the compatibility flag -# is per-worker, and each service has its own JS file so assertions only run -# against the scenario they apply to. +# Two services so each side can run against a different setting of the secp256k1_ecdsa_curve +# compat flag. The flag is per-worker. const unitTests :Workerd.Config = ( services = [ - # Flag disabled: exercises the "unrecognized curve" regression path. ( name = "crypto-secp256k1-no-flag", worker = ( modules = [ @@ -15,9 +13,6 @@ const unitTests :Workerd.Config = ( compatibilityFlags = ["nodejs_compat"], ) ), - # Flag enabled but backend not wired: exercises the "recognized curve, - # backend missing" path. Once the backend lands the test assertions flip - # from "asserts failure" to "asserts success". ( name = "crypto-secp256k1-with-flag", worker = ( modules = [ diff --git a/src/workerd/api/tests/crypto-secp256k1-with-flag-test.js b/src/workerd/api/tests/crypto-secp256k1-with-flag-test.js index e17e8d9eb0b..b6cb61b608e 100644 --- a/src/workerd/api/tests/crypto-secp256k1-with-flag-test.js +++ b/src/workerd/api/tests/crypto-secp256k1-with-flag-test.js @@ -1,221 +1,197 @@ // Copyright (c) 2026 Cloudflare, Inc. // Licensed under the Apache 2.0 license found in the LICENSE file or at: // https://opensource.org/licenses/Apache-2.0 -// -// secp256k1 ECDSA — behaviour when the `secp256k1_ecdsa_curve` compatibility -// flag is enabled. The curve is dispatched through libsecp256k1 (BoringSSL -// does not implement secp256k1). -// -// Current scope: `importKey("raw", publicKey)` and `verify(signature, data)`. -// Other operations (generateKey, sign, JWK import/export, SPKI, PKCS8) are -// not yet wired to the libsecp256k1 backend; tests for those assert the -// explicit "not yet implemented" rejections so the stubs can't regress -// silently. -// -// When sign/generate/JWK lands, flip the matching asserts from "must fail" -// to "must succeed" and add end-to-end round-trip tests. - -import { rejects, strictEqual, ok } from 'node:assert'; - -// ----------------------------------------------------------------------- -// Known-good secp256k1 public key for positive tests. -// -// 33-byte SEC1 compressed encoding of the secp256k1 generator point G -// (i.e. the public key for private key d = 1). This is not secret — -// it's literally the curve's generator. It's a stable, well-documented -// point that lets us exercise the import path without needing to ship -// test-only signing machinery. -// ----------------------------------------------------------------------- +import { deepStrictEqual, ok, rejects, strictEqual } from 'node:assert'; + +// secp256k1 generator G (the public key for private key d = 1), defined in SEC 2 §2.4.1. const GENERATOR_PUBKEY_COMPRESSED = new Uint8Array([ 0x02, 0x79, 0xbe, 0x66, 0x7e, 0xf9, 0xdc, 0xbb, 0xac, 0x55, 0xa0, 0x62, 0x95, 0xce, 0x87, 0x0b, 0x07, 0x02, 0x9b, 0xfc, 0xdb, 0x2d, 0xce, 0x28, 0xd9, 0x59, 0xf2, 0x81, 0x5b, 0x16, 0xf8, 0x17, 0x98, ]); +const GENERATOR_PUBKEY_UNCOMPRESSED = new Uint8Array([ + 0x04, 0x79, 0xbe, 0x66, 0x7e, 0xf9, 0xdc, 0xbb, 0xac, 0x55, 0xa0, 0x62, 0x95, + 0xce, 0x87, 0x0b, 0x07, 0x02, 0x9b, 0xfc, 0xdb, 0x2d, 0xce, 0x28, 0xd9, 0x59, + 0xf2, 0x81, 0x5b, 0x16, 0xf8, 0x17, 0x98, 0x48, 0x3a, 0xda, 0x77, 0x26, 0xa3, + 0xc4, 0x65, 0x5d, 0xa4, 0xfb, 0xfc, 0x0e, 0x11, 0x08, 0xa8, 0xfd, 0x17, 0xb4, + 0x48, 0xa6, 0x85, 0x54, 0x19, 0x9c, 0x47, 0xd0, 0x8f, 0xfb, 0x10, 0xd4, 0xb8, +]); -// ----------------------------------------------------------------------- -// importKey — supported paths -// ----------------------------------------------------------------------- +const ECDSA = { name: 'ECDSA', namedCurve: 'secp256k1' }; +const ECDSA_SHA256 = { name: 'ECDSA', hash: 'SHA-256' }; + +async function importGeneratorPublic() { + return crypto.subtle.importKey( + 'raw', + GENERATOR_PUBKEY_COMPRESSED, + ECDSA, + true, + ['verify'] + ); +} + +async function generatePair() { + return crypto.subtle.generateKey(ECDSA, true, ['sign', 'verify']); +} export const importRawCompressedPublicKey = { async test() { - const key = await crypto.subtle.importKey( - 'raw', - GENERATOR_PUBKEY_COMPRESSED, - { name: 'ECDSA', namedCurve: 'secp256k1' }, - true, - ['verify'] - ); + const key = await importGeneratorPublic(); strictEqual(key.type, 'public'); strictEqual(key.algorithm.name, 'ECDSA'); strictEqual(key.algorithm.namedCurve, 'secp256k1'); - // Usages should be restricted to 'verify' since this is a public key. ok(key.usages.includes('verify')); ok(!key.usages.includes('sign')); }, }; -export const exportRawRoundTrip = { +export const importCompressedExportUncompressed = { + // WebCrypto raw export is uncompressed SEC1; compressed input is accepted on import. + async test() { + const key = await importGeneratorPublic(); + const exported = new Uint8Array(await crypto.subtle.exportKey('raw', key)); + deepStrictEqual(exported, GENERATOR_PUBKEY_UNCOMPRESSED); + }, +}; + +export const importUncompressedRoundTrip = { async test() { const key = await crypto.subtle.importKey( 'raw', - GENERATOR_PUBKEY_COMPRESSED, - { name: 'ECDSA', namedCurve: 'secp256k1' }, + GENERATOR_PUBKEY_UNCOMPRESSED, + ECDSA, true, ['verify'] ); const exported = new Uint8Array(await crypto.subtle.exportKey('raw', key)); - strictEqual(exported.byteLength, 33); - for (let i = 0; i < GENERATOR_PUBKEY_COMPRESSED.length; i++) { - strictEqual( - exported[i], - GENERATOR_PUBKEY_COMPRESSED[i], - `exported byte ${i} did not match input` - ); - } + deepStrictEqual(exported, GENERATOR_PUBKEY_UNCOMPRESSED); }, }; export const importRejectsWrongLength = { async test() { - // 32 bytes is not a valid SEC1 public key length. await rejects( - crypto.subtle.importKey( - 'raw', - new Uint8Array(32), - { name: 'ECDSA', namedCurve: 'secp256k1' }, - true, - ['verify'] - ), - (err) => { - strictEqual(err.name, 'DataError'); - return true; - } + crypto.subtle.importKey('raw', new Uint8Array(32), ECDSA, true, [ + 'verify', + ]), + { name: 'DataError' } ); }, }; export const importRejectsInvalidPoint = { async test() { - // 33 bytes with the right prefix but a garbage x-coordinate. The - // prefix 0x02 says "compressed, y is even" but the payload won't - // correspond to a point on secp256k1. + // 0x02 prefix claims compressed even-y, but the rest isn't on the curve. const bogus = new Uint8Array(33); bogus[0] = 0x02; bogus.fill(0xff, 1); await rejects( - crypto.subtle.importKey( - 'raw', - bogus, - { name: 'ECDSA', namedCurve: 'secp256k1' }, - true, - ['verify'] - ), - (err) => { - strictEqual(err.name, 'DataError'); - return true; - } + crypto.subtle.importKey('raw', bogus, ECDSA, true, ['verify']), + { name: 'DataError' } ); }, }; -// ----------------------------------------------------------------------- -// verify — negative cases -// -// Until sign() is wired up for secp256k1 we can't produce a signature -// inside the test, so we can't do a positive verify assertion here. -// What we CAN assert is that malformed / clearly-wrong signatures -// return false (not true, and not a thrown exception). -// ----------------------------------------------------------------------- +export const generateKeyProducesPair = { + async test() { + const pair = await generatePair(); + strictEqual(pair.publicKey.type, 'public'); + strictEqual(pair.privateKey.type, 'private'); + strictEqual(pair.publicKey.algorithm.namedCurve, 'secp256k1'); + strictEqual(pair.privateKey.algorithm.namedCurve, 'secp256k1'); + ok(pair.privateKey.usages.includes('sign')); + ok(pair.publicKey.usages.includes('verify')); + ok(!pair.privateKey.usages.includes('verify')); + ok(!pair.publicKey.usages.includes('sign')); + }, +}; -export const verifyRejectsWrongLengthSignature = { +export const signVerifyRoundTrip = { async test() { - const key = await crypto.subtle.importKey( - 'raw', - GENERATOR_PUBKEY_COMPRESSED, - { name: 'ECDSA', namedCurve: 'secp256k1' }, - true, - ['verify'] + const { privateKey, publicKey } = await generatePair(); + const message = new TextEncoder().encode('hello secp256k1'); + const signature = await crypto.subtle.sign( + ECDSA_SHA256, + privateKey, + message + ); + strictEqual(signature.byteLength, 64); + strictEqual( + await crypto.subtle.verify(ECDSA_SHA256, publicKey, signature, message), + true ); - // 63 bytes is not a valid ECDSA-raw signature (must be 64). - const result = await crypto.subtle.verify( - { name: 'ECDSA', hash: 'SHA-256' }, - key, - new Uint8Array(63), - new Uint8Array([0x00]) + + const tampered = new Uint8Array(message); + tampered[0] ^= 0x01; + strictEqual( + await crypto.subtle.verify(ECDSA_SHA256, publicKey, signature, tampered), + false ); - strictEqual(result, false); }, }; -export const verifyRejectsAllZeroSignature = { +export const signIsDeterministic = { + // libsecp256k1 uses RFC 6979 deterministic nonces; same key + message must produce the same + // signature bytes. async test() { - const key = await crypto.subtle.importKey( - 'raw', - GENERATOR_PUBKEY_COMPRESSED, - { name: 'ECDSA', namedCurve: 'secp256k1' }, - true, - ['verify'] + const { privateKey } = await generatePair(); + const message = new TextEncoder().encode('determinism test'); + const sig1 = new Uint8Array( + await crypto.subtle.sign(ECDSA_SHA256, privateKey, message) ); - // All-zero is not a valid signature (r = 0, s = 0 both fail the - // parser's range check). - const result = await crypto.subtle.verify( - { name: 'ECDSA', hash: 'SHA-256' }, - key, - new Uint8Array(64), - new Uint8Array([0x00]) + const sig2 = new Uint8Array( + await crypto.subtle.sign(ECDSA_SHA256, privateKey, message) ); - strictEqual(result, false); + deepStrictEqual(sig1, sig2); }, }; -// ----------------------------------------------------------------------- -// Paths NOT yet implemented — these MUST still reject until the -// follow-up PRs wire them in. If any of these starts passing, the -// error messages below need to be reviewed for consistency before the -// test is flipped to a positive assertion. -// ----------------------------------------------------------------------- - -export const generateKeyNotYetImplemented = { +export const signRejectsPublicKey = { async test() { + const { publicKey } = await generatePair(); await rejects( - crypto.subtle.generateKey( - { name: 'ECDSA', namedCurve: 'secp256k1' }, - true, - ['sign', 'verify'] + crypto.subtle.sign(ECDSA_SHA256, publicKey, new Uint8Array([0])), + { name: 'InvalidAccessError' } + ); + }, +}; + +export const verifyRejectsWrongLengthSignature = { + async test() { + const key = await importGeneratorPublic(); + strictEqual( + await crypto.subtle.verify( + ECDSA_SHA256, + key, + new Uint8Array(63), + new Uint8Array([0]) ), - (err) => { - strictEqual(err.name, 'NotSupportedError'); - ok( - /Key generation for "secp256k1" is not yet implemented/.test( - err.message - ), - `unexpected message: ${err.message}` - ); - return true; - } + false + ); + }, +}; + +export const verifyRejectsAllZeroSignature = { + async test() { + const key = await importGeneratorPublic(); + strictEqual( + await crypto.subtle.verify( + ECDSA_SHA256, + key, + new Uint8Array(64), + new Uint8Array([0]) + ), + false ); }, }; -// ----------------------------------------------------------------------- -// JWK import/export — RFC 7517 + RFC 7518 + RFC 8812 -// ----------------------------------------------------------------------- -// -// Instead of hardcoding base64url coordinate values (which is easy to get -// wrong), we derive a known-good JWK via a raw import + JWK export, then -// exercise import/round-trip against that. The raw input is the secp256k1 -// generator point G (SEC 2 §2.4.1) — a well-defined constant we already -// use above. +// JWK assertions derive known-good JWKs by round-tripping through raw/generate paths and mutate +// from there, rather than hardcoding base64url coordinate strings. async function makeGeneratorJwk() { - const key = await crypto.subtle.importKey( - 'raw', - GENERATOR_PUBKEY_COMPRESSED, - { name: 'ECDSA', namedCurve: 'secp256k1' }, - true, - ['verify'] - ); - return await crypto.subtle.exportKey('jwk', key); + const key = await importGeneratorPublic(); + return crypto.subtle.exportKey('jwk', key); } export const exportJwkShape = { @@ -227,13 +203,10 @@ export const exportJwkShape = { strictEqual(typeof jwk.y, 'string'); strictEqual(jwk.d, undefined); strictEqual(jwk.ext, true); - ok(Array.isArray(jwk.key_ops)); ok(jwk.key_ops.includes('verify')); - // base64url-decoding a 32-byte value yields a string of length - // ceil(32 * 4 / 3) with '=' padding stripped = 43 characters. + // base64url of 32 bytes is 43 chars, alphabet [A-Za-z0-9_-]. strictEqual(jwk.x.length, 43); strictEqual(jwk.y.length, 43); - // base64url uses [A-Za-z0-9_-], never '+', '/', or '='. ok(/^[A-Za-z0-9_-]+$/.test(jwk.x)); ok(/^[A-Za-z0-9_-]+$/.test(jwk.y)); }, @@ -242,15 +215,10 @@ export const exportJwkShape = { export const importJwkPublic = { async test() { const jwk = await makeGeneratorJwk(); - const key = await crypto.subtle.importKey( - 'jwk', - jwk, - { name: 'ECDSA', namedCurve: 'secp256k1' }, - true, - ['verify'] - ); + const key = await crypto.subtle.importKey('jwk', jwk, ECDSA, true, [ + 'verify', + ]); strictEqual(key.type, 'public'); - strictEqual(key.algorithm.name, 'ECDSA'); strictEqual(key.algorithm.namedCurve, 'secp256k1'); ok(key.usages.includes('verify')); }, @@ -258,53 +226,25 @@ export const importJwkPublic = { export const jwkRoundTrip = { async test() { - // raw -> JWK export -> JWK import -> raw export: should equal original. - const fromRaw = await crypto.subtle.importKey( - 'raw', - GENERATOR_PUBKEY_COMPRESSED, - { name: 'ECDSA', namedCurve: 'secp256k1' }, - true, - ['verify'] - ); - const exportedJwk = await crypto.subtle.exportKey('jwk', fromRaw); - const fromJwk = await crypto.subtle.importKey( - 'jwk', - exportedJwk, - { name: 'ECDSA', namedCurve: 'secp256k1' }, - true, - ['verify'] - ); - const reExportedRaw = new Uint8Array( + const fromRaw = await importGeneratorPublic(); + const jwk = await crypto.subtle.exportKey('jwk', fromRaw); + const fromJwk = await crypto.subtle.importKey('jwk', jwk, ECDSA, true, [ + 'verify', + ]); + const reExported = new Uint8Array( await crypto.subtle.exportKey('raw', fromJwk) ); - strictEqual(reExportedRaw.byteLength, GENERATOR_PUBKEY_COMPRESSED.length); - for (let i = 0; i < reExportedRaw.length; i++) { - strictEqual( - reExportedRaw[i], - GENERATOR_PUBKEY_COMPRESSED[i], - `byte ${i} did not round-trip` - ); - } + deepStrictEqual(reExported, GENERATOR_PUBKEY_UNCOMPRESSED); }, }; export const importJwkRejectsWrongKty = { async test() { const jwk = await makeGeneratorJwk(); - // OKP is for Ed25519/X25519, EC is for secp256k1. jwk.kty = 'OKP'; await rejects( - crypto.subtle.importKey( - 'jwk', - jwk, - { name: 'ECDSA', namedCurve: 'secp256k1' }, - true, - ['verify'] - ), - (err) => { - strictEqual(err.name, 'DataError'); - return true; - } + crypto.subtle.importKey('jwk', jwk, ECDSA, true, ['verify']), + { name: 'DataError' } ); }, }; @@ -314,57 +254,125 @@ export const importJwkRejectsWrongCrv = { const jwk = await makeGeneratorJwk(); jwk.crv = 'P-256'; await rejects( - crypto.subtle.importKey( - 'jwk', - jwk, - { name: 'ECDSA', namedCurve: 'secp256k1' }, - true, - ['verify'] - ), - (err) => { - strictEqual(err.name, 'DataError'); - return true; - } + crypto.subtle.importKey('jwk', jwk, ECDSA, true, ['verify']), + { name: 'DataError' } ); }, }; -export const importJwkRejectsPrivateKey = { - // Private key support (JWK with `d`) isn't wired up yet. Must reject - // clearly rather than silently dropping the private material. - // - // Use `['verify']` as the requested usage so that WebCrypto's usage - // validation (which rejects `['sign']` on a public key with a - // `SyntaxError` before we ever look at `d`) doesn't mask the error we - // actually want to test. +export const importJwkRejectsWrongAlg = { + // RFC 8812: if `alg` is present on a secp256k1 JWK it must be "ES256K". async test() { const jwk = await makeGeneratorJwk(); - jwk.d = 'AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAE'; + jwk.alg = 'ES256'; await rejects( - crypto.subtle.importKey( - 'jwk', - jwk, - { name: 'ECDSA', namedCurve: 'secp256k1' }, - true, - ['verify'] - ), - (err) => { - strictEqual(err.name, 'NotSupportedError'); - ok( - /Importing secp256k1 private keys \(JWKs with a "d" field\) is not yet implemented/.test( - err.message - ), - `unexpected message: ${err.message}` - ); - return true; + crypto.subtle.importKey('jwk', jwk, ECDSA, true, ['verify']), + { name: 'DataError' } + ); + }, +}; + +export const importJwkAcceptsShortCoordinate = { + // RFC 7518 permits omitting leading zero bytes in `d`. The generator point's private scalar is + // 1, encoded minimally as a single 0x01 byte ("AQ" in base64url). Our impl zero-pads to 32 + // bytes; the import should succeed and the resulting key should sign in a way the original + // generator public key can verify. + async test() { + const generatorJwk = await makeGeneratorJwk(); + const shortPrivateJwk = { + kty: 'EC', + crv: 'secp256k1', + x: generatorJwk.x, + y: generatorJwk.y, + d: 'AQ', + ext: true, + }; + const privateKey = await crypto.subtle.importKey( + 'jwk', + shortPrivateJwk, + ECDSA, + true, + ['sign'] + ); + const publicKey = await importGeneratorPublic(); + const message = new TextEncoder().encode('short-d test'); + const sig = await crypto.subtle.sign(ECDSA_SHA256, privateKey, message); + strictEqual( + await crypto.subtle.verify(ECDSA_SHA256, publicKey, sig, message), + true + ); + }, +}; + +export const importJwkRejectsOutOfRangePrivateScalar = { + // d = 0xFF...FF is greater than the secp256k1 curve order n, so secp256k1_ec_seckey_verify + // must reject it. + async test() { + const generatorJwk = await makeGeneratorJwk(); + const allOnes = new Uint8Array(32).fill(0xff); + const dB64 = btoa(String.fromCharCode(...allOnes)) + .replace(/\+/g, '-') + .replace(/\//g, '_') + .replace(/=+$/, ''); + const badJwk = { + kty: 'EC', + crv: 'secp256k1', + x: generatorJwk.x, + y: generatorJwk.y, + d: dB64, + ext: true, + }; + await rejects( + crypto.subtle.importKey('jwk', badJwk, ECDSA, true, ['sign']), + { name: 'DataError' } + ); + }, +}; + +export const jwkPrivateRoundTrip = { + async test() { + const { privateKey, publicKey } = await generatePair(); + const jwk = await crypto.subtle.exportKey('jwk', privateKey); + strictEqual(jwk.kty, 'EC'); + strictEqual(jwk.crv, 'secp256k1'); + strictEqual(typeof jwk.d, 'string'); + strictEqual(jwk.d.length, 43); + ok(jwk.key_ops.includes('sign')); + + const reimported = await crypto.subtle.importKey('jwk', jwk, ECDSA, true, [ + 'sign', + ]); + strictEqual(reimported.type, 'private'); + + const message = new TextEncoder().encode('round-trip via JWK'); + const sig = await crypto.subtle.sign(ECDSA_SHA256, reimported, message); + strictEqual( + await crypto.subtle.verify(ECDSA_SHA256, publicKey, sig, message), + true + ); + }, +}; + +export const jwkPrivateRejectsMismatchedHalves = { + // A JWK whose (x, y) doesn't match the point derived from `d` must be rejected, not silently + // produce a broken key. + async test() { + const a = await generatePair(); + const b = await generatePair(); + const jwkA = await crypto.subtle.exportKey('jwk', a.privateKey); + const jwkB = await crypto.subtle.exportKey('jwk', b.privateKey); + const mixed = { ...jwkA, d: jwkB.d }; + await rejects( + crypto.subtle.importKey('jwk', mixed, ECDSA, true, ['sign']), + { + name: 'DataError', + message: /inconsistent|do not match/, } ); }, }; export const ecdhRejectedExplicitly = { - // The secp256k1 compat flag is scoped to ECDSA only. ECDH over - // secp256k1 must fail with a specific error even when the flag is on. async test() { await rejects( crypto.subtle.importKey( @@ -374,14 +382,24 @@ export const ecdhRejectedExplicitly = { true, ['deriveBits'] ), - (err) => { - strictEqual(err.name, 'NotSupportedError'); - ok( - /ECDH is not supported for curve "secp256k1"/.test(err.message), - `unexpected message: ${err.message}` - ); - return true; + { + name: 'NotSupportedError', + message: /ECDH is not supported for curve "secp256k1"/, } ); }, }; + +export const ecdhGenerateRejectedExplicitly = { + // secp256k1 over ECDH must reject at generateKey too, not just at importKey. + async test() { + await rejects( + crypto.subtle.generateKey( + { name: 'ECDH', namedCurve: 'secp256k1' }, + true, + ['deriveBits'] + ), + { name: 'NotSupportedError' } + ); + }, +}; diff --git a/src/workerd/io/compatibility-date.capnp b/src/workerd/io/compatibility-date.capnp index 945e0be536c..9cce989da4a 100644 --- a/src/workerd/io/compatibility-date.capnp +++ b/src/workerd/io/compatibility-date.capnp @@ -1534,14 +1534,7 @@ struct CompatibilityFlags @0x8f8c1b68151b6cef { secp256k1EcdsaCurve @176 :Bool $compatEnableFlag("secp256k1_ecdsa_curve") $experimental; - # When enabled, adds "secp256k1" as a supported namedCurve for the WebCrypto - # ECDSA algorithm (crypto.subtle.generateKey / importKey / sign / verify / - # exportKey). secp256k1 is the elliptic curve used by Bitcoin, Ethereum, and - # other EVM-compatible chains (see SEC 2 v1.0 / RFC 5480). BoringSSL does not - # implement secp256k1; when this flag is enabled, curve operations are routed - # to a dedicated secp256k1 backend. The curve is usable anywhere the existing - # NIST curves (P-256 / P-384 / P-521) are usable: keys may be generated, - # imported (JWK with `"crv": "secp256k1"`, raw, spki, pkcs8), exported, and - # used to sign and verify. This flag is experimental while the backend is - # being landed; once stable, a $compatEnableDate will be set. + # When enabled, the WebCrypto ECDSA algorithm accepts "secp256k1" as a + # namedCurve, allowing import, export, sign, verify, and generateKey on + # secp256k1 keys. ECDH over secp256k1 remains unsupported. }