diff --git a/build/BUILD.libsecp256k1 b/build/BUILD.libsecp256k1 new file mode 100644 index 00000000000..c6eb3db30ea --- /dev/null +++ b/build/BUILD.libsecp256k1 @@ -0,0 +1,30 @@ +load("@rules_cc//cc:cc_library.bzl", "cc_library") + +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 = [ + "-Wno-unused-function", + "-Wno-nonnull", + ], + local_defines = [ + "SECP256K1_STATIC", + # 5x52 / 4x64 / __int128 require a 64-bit target, which workerd always builds for. + "USE_FIELD_5X52=1", + "USE_SCALAR_4X64=1", + "USE_FORCE_WIDEMUL_INT128=1", + "ECMULT_WINDOW_SIZE=15", + "ECMULT_GEN_PREC_BITS=4", + ], + strip_include_prefix = "include", + textual_hdrs = glob(["src/**/*.h"]), + visibility = ["//visibility:public"], +) diff --git a/build/deps/deps.jsonc b/build/deps/deps.jsonc index e92e34a1422..c5c71a15ccb 100644 --- a/build/deps/deps.jsonc +++ b/build/deps/deps.jsonc @@ -113,6 +113,14 @@ "build_file": "//:build/BUILD.simdutf", "file_regex": "singleheader.zip" }, + { + "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 9b5117c4c8e..9e5f6a6cf83 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 @@ -429,8 +430,8 @@ class EllipticKey final: public AsymmetricKeyCryptoKeyImpl { struct EllipticCurveInfo { kj::StringPtr normalizedName; - int opensslCurveId; - 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) { @@ -446,6 +447,15 @@ EllipticCurveInfo lookupEllipticCurve(kj::StringPtr curveName) { return iter->second; } +// Overload that adds secp256k1 to the table when the compat flag is set. +EllipticCurveInfo lookupEllipticCurve(jsg::Lock& js, kj::StringPtr curveName) { + if (FeatureFlags::get(js).getSecp256k1EcdsaCurve() && + strcasecmp(curveName.cStr(), "secp256k1") == 0) { + return {"secp256k1", 0, 32}; + } + return lookupEllipticCurve(curveName); +} + kj::OneOf, CryptoKeyPair> EllipticKey::generateElliptic(jsg::Lock& js, kj::StringPtr normalizedName, SubtleCrypto::GenerateKeyAlgorithm&& algorithm, @@ -455,7 +465,16 @@ 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); + + // 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, @@ -700,7 +719,13 @@ 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); + + if (strcasecmp(normalizedNamedCurve.cStr(), "secp256k1") == 0) { + return Secp256k1Key::import(js, normalizedName, format, kj::mv(keyData), + CryptoKey::EllipticKeyAlgorithm{normalizedName, normalizedNamedCurve}, extractable, + keyUsages); + } auto importedKey = [&, curveId = curveId] { if (format != "raw") { @@ -758,7 +783,10 @@ 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); + + 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(); diff --git a/src/workerd/api/crypto/secp256k1-key.c++ b/src/workerd/api/crypto/secp256k1-key.c++ new file mode 100644 index 00000000000..5edb41757c3 --- /dev/null +++ b/src/workerd/api/crypto/secp256k1-key.c++ @@ -0,0 +1,359 @@ +// 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 +#include + +#include + +namespace workerd::api { + +namespace { + +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); + return c; + }(); + return ctx; +} + +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; +} + +kj::Array computeDigest(kj::StringPtr hashName, kj::ArrayPtr data) { + const EVP_MD* md = lookupDigestAlgorithm(hashName).second; + auto out = kj::heapArray(EVP_MD_size(md)); + unsigned int outLen = out.size(); + OSSLCALL(EVP_Digest(data.begin(), data.size(), out.begin(), &outLen, md, nullptr)); + KJ_ASSERT(outLen == out.size()); + return out; +} + +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; +} + +kj::Own importRaw(kj::ArrayPtr keyData, + CryptoKey::EllipticKeyAlgorithm keyAlgorithm, + bool extractable, + CryptoKeyUsageSet usages) { + JSG_REQUIRE(keyData.size() == kPubkeyCompressedLen || keyData.size() == kPubkeyUncompressedLen, + DOMDataError, "Invalid secp256k1 public key length (expected ", kPubkeyCompressedLen, " or ", + kPubkeyUncompressedLen, " bytes, got ", keyData.size(), ")."); + + 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) { + JSG_REQUIRE(jwk.kty == "EC", DOMDataError, + "secp256k1 \"jwk\" import requires \"kty\" == \"EC\" (got \"", jwk.kty, "\")."); + + 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\"."); + + // 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 \"alg\" (\"", alg, + "\") does not match expected \"ES256K\"."); + } + + 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; + memcpy(sec1 + 1, xBytes.begin(), kCoordinateLen); + memcpy(sec1 + 1 + kCoordinateLen, yBytes.begin(), kCoordinateLen); + + 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); +} + +} // namespace + +Secp256k1Key::Secp256k1Key(KeyType keyType, + secp256k1_pubkey parsedPubkey, + kj::Maybe> privateKey, + CryptoKey::EllipticKeyAlgorithm keyAlgorithm, + bool extractable, + CryptoKeyUsageSet usages) + : CryptoKey::Impl(extractable, usages), + keyType(keyType), + parsedPubkey(parsedPubkey), + privateKey(kj::mv(privateKey)), + keyAlgorithm(kj::mv(keyAlgorithm)) {} + +kj::StringPtr Secp256k1Key::getAlgorithmName() const { + return keyAlgorithm.name; +} + +CryptoKey::AlgorithmVariant Secp256k1Key::getAlgorithm(jsg::Lock& js) const { + return keyAlgorithm; +} + +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 { + // Malformed signatures → false (per WebCrypto spec), not an exception. + if (signature.size() != kSignatureLen) return false; + + 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); + + secp256k1_ecdsa_signature sig; + if (secp256k1_ecdsa_signature_parse_compact(context(), &sig, signature.begin()) != 1) { + return false; + } + + // `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 { + if (format == "raw") { + 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); + } + + if (format == "jwk") { + auto uncompressed = serializePubkey(parsedPubkey, SECP256K1_EC_UNCOMPRESSED); + SubtleCrypto::JsonWebKey jwk; + jwk.kty = kj::str("EC"); + jwk.crv = kj::str("secp256k1"); + 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): "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 { + 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 new file mode 100644 index 00000000000..91e9ace3172 --- /dev/null +++ b/src/workerd/api/crypto/secp256k1-key.h @@ -0,0 +1,67 @@ +#pragma once + +#include "crypto.h" +#include "impl.h" +#include "keys.h" + +#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: + Secp256k1Key(KeyType keyType, + secp256k1_pubkey parsedPubkey, + kj::Maybe> privateKey, + CryptoKey::EllipticKeyAlgorithm keyAlgorithm, + bool extractable, + CryptoKeyUsageSet usages); + + // 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, + kj::ArrayPtr keyUsages); + + // 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 privateKeyUsages, + CryptoKeyUsageSet publicKeyUsages); + + kj::StringPtr getAlgorithmName() const override; + CryptoKey::AlgorithmVariant getAlgorithm(jsg::Lock& js) const override; + kj::StringPtr getType() const override; + + jsg::JsArrayBuffer sign(jsg::Lock& js, + SubtleCrypto::SignAlgorithm&& algorithm, + kj::ArrayPtr data) const override; + + 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; + size_t jsgGetMemorySelfSize() const override; + + private: + 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; +}; + +} // namespace workerd::api diff --git a/src/workerd/api/tests/BUILD.bazel b/src/workerd/api/tests/BUILD.bazel index 1bee9496b81..807b851b513 100644 --- a/src/workerd/api/tests/BUILD.bazel +++ b/src/workerd/api/tests/BUILD.bazel @@ -308,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 new file mode 100644 index 00000000000..fb23a04eba3 --- /dev/null +++ b/src/workerd/api/tests/crypto-secp256k1-no-flag-test.js @@ -0,0 +1,50 @@ +// 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 +import { rejects } 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(ECDSA, true, ['sign', 'verify']), + expectedError + ); + }, +}; + +export const importKeyRawRejected = { + async test() { + await rejects( + crypto.subtle.importKey('raw', new Uint8Array(33), ECDSA, true, [ + 'verify', + ]), + expectedError + ); + }, +}; + +export const importKeyJwkRejected = { + async test() { + await rejects( + crypto.subtle.importKey( + 'jwk', + { + kty: 'EC', + crv: 'secp256k1', + x: 'AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA', + y: 'AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA', + }, + ECDSA, + true, + ['verify'] + ), + expectedError + ); + }, +}; 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..68a4b5f4817 --- /dev/null +++ b/src/workerd/api/tests/crypto-secp256k1-test.wd-test @@ -0,0 +1,29 @@ +using Workerd = import "/workerd/workerd.capnp"; + +# 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 = [ + ( name = "crypto-secp256k1-no-flag", + worker = ( + modules = [ + (name = "worker", esModule = embed "crypto-secp256k1-no-flag-test.js") + ], + compatibilityFlags = ["nodejs_compat"], + ) + ), + ( 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..b6cb61b608e --- /dev/null +++ b/src/workerd/api/tests/crypto-secp256k1-with-flag-test.js @@ -0,0 +1,405 @@ +// 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 +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, +]); + +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 importGeneratorPublic(); + strictEqual(key.type, 'public'); + strictEqual(key.algorithm.name, 'ECDSA'); + strictEqual(key.algorithm.namedCurve, 'secp256k1'); + ok(key.usages.includes('verify')); + ok(!key.usages.includes('sign')); + }, +}; + +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_UNCOMPRESSED, + ECDSA, + true, + ['verify'] + ); + const exported = new Uint8Array(await crypto.subtle.exportKey('raw', key)); + deepStrictEqual(exported, GENERATOR_PUBKEY_UNCOMPRESSED); + }, +}; + +export const importRejectsWrongLength = { + async test() { + await rejects( + crypto.subtle.importKey('raw', new Uint8Array(32), ECDSA, true, [ + 'verify', + ]), + { name: 'DataError' } + ); + }, +}; + +export const importRejectsInvalidPoint = { + async test() { + // 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, ECDSA, true, ['verify']), + { name: 'DataError' } + ); + }, +}; + +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 signVerifyRoundTrip = { + async test() { + 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 + ); + + const tampered = new Uint8Array(message); + tampered[0] ^= 0x01; + strictEqual( + await crypto.subtle.verify(ECDSA_SHA256, publicKey, signature, tampered), + false + ); + }, +}; + +export const signIsDeterministic = { + // libsecp256k1 uses RFC 6979 deterministic nonces; same key + message must produce the same + // signature bytes. + async test() { + const { privateKey } = await generatePair(); + const message = new TextEncoder().encode('determinism test'); + const sig1 = new Uint8Array( + await crypto.subtle.sign(ECDSA_SHA256, privateKey, message) + ); + const sig2 = new Uint8Array( + await crypto.subtle.sign(ECDSA_SHA256, privateKey, message) + ); + deepStrictEqual(sig1, sig2); + }, +}; + +export const signRejectsPublicKey = { + async test() { + const { publicKey } = await generatePair(); + await rejects( + 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]) + ), + 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 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 importGeneratorPublic(); + return 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(jwk.key_ops.includes('verify')); + // base64url of 32 bytes is 43 chars, alphabet [A-Za-z0-9_-]. + strictEqual(jwk.x.length, 43); + strictEqual(jwk.y.length, 43); + 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, ECDSA, true, [ + 'verify', + ]); + strictEqual(key.type, 'public'); + strictEqual(key.algorithm.namedCurve, 'secp256k1'); + ok(key.usages.includes('verify')); + }, +}; + +export const jwkRoundTrip = { + async test() { + 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) + ); + deepStrictEqual(reExported, GENERATOR_PUBKEY_UNCOMPRESSED); + }, +}; + +export const importJwkRejectsWrongKty = { + async test() { + const jwk = await makeGeneratorJwk(); + jwk.kty = 'OKP'; + await rejects( + crypto.subtle.importKey('jwk', jwk, ECDSA, true, ['verify']), + { name: 'DataError' } + ); + }, +}; + +export const importJwkRejectsWrongCrv = { + async test() { + const jwk = await makeGeneratorJwk(); + jwk.crv = 'P-256'; + await rejects( + crypto.subtle.importKey('jwk', jwk, ECDSA, true, ['verify']), + { name: 'DataError' } + ); + }, +}; + +export const importJwkRejectsWrongAlg = { + // RFC 8812: if `alg` is present on a secp256k1 JWK it must be "ES256K". + async test() { + const jwk = await makeGeneratorJwk(); + jwk.alg = 'ES256'; + await rejects( + 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 = { + async test() { + await rejects( + crypto.subtle.importKey( + 'raw', + GENERATOR_PUBKEY_COMPRESSED, + { name: 'ECDH', namedCurve: 'secp256k1' }, + true, + ['deriveBits'] + ), + { + 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/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", ], diff --git a/src/workerd/io/compatibility-date.capnp b/src/workerd/io/compatibility-date.capnp index 3fba35ade15..9cce989da4a 100644 --- a/src/workerd/io/compatibility-date.capnp +++ b/src/workerd/io/compatibility-date.capnp @@ -1530,4 +1530,11 @@ 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, the WebCrypto ECDSA algorithm accepts "secp256k1" as a + # namedCurve, allowing import, export, sign, verify, and generateKey on + # secp256k1 keys. ECDH over secp256k1 remains unsupported. }