From c734eaf18966f4d3b8d8ae78c9d77a45f0324827 Mon Sep 17 00:00:00 2001 From: Filip Skokan Date: Wed, 30 Sep 2026 17:49:20 +0200 Subject: [PATCH 1/3] feat(crypto): add ChaCha20-Poly1305 support Implement encryption and decryption through BoringSSL's AEAD API, with raw-secret and JWK key import/export and derived key support. Validate AEAD parameters with algorithm-specific tag length conversion. Gate the algorithm behind webcrypto_modern_algorithms to preserve the existing API surface. Enable its WPT coverage and add regression tests. Reject disabled derived-key targets before running the KDF. Support Node.js KeyObject raw exports and report secret key sizes from native material lengths, including for non-extractable keys. Correct existing AES/HMAC bit counts and KDF zero sizes without a compatibility flag, accepting the risk of correcting these metadata values to match Node.js. Unsupported raw-key equality comparisons return false. Accept the unflagged AES-GCM tagLength error differences: out-of-range values have a different TypeError message, and workers without capture_async_api_throws receive a rejected promise instead of a synchronous throw. Valid AES-GCM results remain unchanged. --- src/workerd/api/crypto/AGENTS.md | 1 + src/workerd/api/crypto/aes.c++ | 8 +- src/workerd/api/crypto/chacha20-poly1305.c++ | 204 ++++++++++++++ src/workerd/api/crypto/crypto.c++ | 34 ++- src/workerd/api/crypto/crypto.h | 7 +- src/workerd/api/crypto/digest.c++ | 6 +- src/workerd/api/crypto/hkdf.c++ | 4 + src/workerd/api/crypto/impl-test.c++ | 24 ++ src/workerd/api/crypto/impl.c++ | 4 +- src/workerd/api/crypto/impl.h | 26 ++ src/workerd/api/crypto/pbkdf2.c++ | 4 + src/workerd/api/node/crypto-keys.c++ | 38 +-- src/workerd/api/node/crypto.h | 4 +- .../api/node/tests/crypto_keys-test.js | 61 +++++ src/workerd/api/tests/crypto-extras-test.js | 31 +++ .../tests/crypto-modern-algorithms-test.js | 250 ++++++++++++++++++ src/workerd/io/compatibility-date.capnp | 9 +- src/wpt/WebCryptoAPI-test.ts | 26 +- 18 files changed, 672 insertions(+), 69 deletions(-) create mode 100644 src/workerd/api/crypto/chacha20-poly1305.c++ diff --git a/src/workerd/api/crypto/AGENTS.md b/src/workerd/api/crypto/AGENTS.md index 6012f64d4c2..8705f895087 100644 --- a/src/workerd/api/crypto/AGENTS.md +++ b/src/workerd/api/crypto/AGENTS.md @@ -12,6 +12,7 @@ WebCrypto API + Node.js crypto C++ implementations over BoringSSL. `crypto.h` de | `impl.h/c++` | Internal base: `CryptoKey::Impl`, `interpretAlgorithmParam()`, OpenSSL error helpers, `OSSLCALL` | | `keys.h/c++` | `AsymmetricKeyCryptoKeyImpl` base; `KeyEncoding`/`KeyFormat`/`KeyType` enums; generic import/export | | `aes.c++` | AES-CBC, AES-CTR, AES-GCM, AES-KW | +| `chacha20-poly1305.c++` | ChaCha20-Poly1305 AEAD | | `rsa.h/c++` | RSA-OAEP, RSA-PSS, RSASSA-PKCS1-v1_5 | | `ec.h/c++` | ECDSA, ECDH, EdDSA (Ed25519/X25519) | | `dh.h/c++` | Diffie-Hellman key exchange (Node.js compat) | diff --git a/src/workerd/api/crypto/aes.c++ b/src/workerd/api/crypto/aes.c++ index 2d48332bcb1..a0018e1b297 100644 --- a/src/workerd/api/crypto/aes.c++ +++ b/src/workerd/api/crypto/aes.c++ @@ -134,6 +134,10 @@ class AesKeyBase: public CryptoKey::Impl { return keyAlgorithm; } + size_t getSymmetricKeySize() const override final { + return keyData.size(); + } + SubtleCrypto::ExportKeyData exportKey(jsg::Lock& js, kj::StringPtr format) const override final { JSG_REQUIRE(format == "raw" || format == "raw-secret" || format == "jwk", DOMNotSupportedError, getAlgorithmName(), @@ -211,7 +215,7 @@ class AesGcmKey final: public AesKeyBase { "AES-GCM can only encrypt up to 2^39 - 256 bytes of plaintext at a time, but requested ", plainText.size(), " bytes."); - int tagLength = algorithm.tagLength.orDefault(128); + int tagLength = normalizeAesGcmTagLength(algorithm.tagLength.orDefault(128)); validateAesGcmTagLength(tagLength); auto cipherCtx = kj::disposeWith(EVP_CIPHER_CTX_new()); @@ -270,7 +274,7 @@ class AesGcmKey final: public AesKeyBase { .getHandle(js); JSG_REQUIRE(iv.size() != 0, DOMOperationError, "AES-GCM IV must not be empty."); - int tagLength = algorithm.tagLength.orDefault(128); + int tagLength = normalizeAesGcmTagLength(algorithm.tagLength.orDefault(128)); validateAesGcmTagLength(tagLength); JSG_REQUIRE(cipherText.size() >= tagLength / 8, DOMOperationError, "Ciphertext length of ", diff --git a/src/workerd/api/crypto/chacha20-poly1305.c++ b/src/workerd/api/crypto/chacha20-poly1305.c++ new file mode 100644 index 00000000000..dedfa69943f --- /dev/null +++ b/src/workerd/api/crypto/chacha20-poly1305.c++ @@ -0,0 +1,204 @@ +// 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 "impl.h" + +#include + +#include +#include + +#include + +namespace workerd::api { +namespace { + +constexpr size_t KEY_BYTES = 32; +constexpr size_t IV_BYTES = 12; +constexpr size_t TAG_BYTES = 16; + +const auto USAGES = CryptoKeyUsageSet::encrypt() | CryptoKeyUsageSet::decrypt() | + CryptoKeyUsageSet::wrapKey() | CryptoKeyUsageSet::unwrapKey(); + +class Chacha20Poly1305Key final: public CryptoKey::Impl { + public: + Chacha20Poly1305Key(kj::Array keyData, bool extractable, CryptoKeyUsageSet usages) + : CryptoKey::Impl(extractable, usages), + keyData(kj::mv(keyData)) {} + + ~Chacha20Poly1305Key() noexcept(false) { + OPENSSL_cleanse(keyData.begin(), keyData.size()); + } + + jsg::JsArrayBuffer encrypt(jsg::Lock& js, + SubtleCrypto::EncryptAlgorithm&& algorithm, + kj::ArrayPtr plaintext) const override { + auto iv = JSG_REQUIRE_NONNULL(algorithm.iv, TypeError, "Missing field \"iv\" in \"algorithm\".") + .getHandle(js); + JSG_REQUIRE( + iv.size() == IV_BYTES, DOMOperationError, "ChaCha20-Poly1305 IV must be 12 bytes long."); + validateChacha20Poly1305TagLength(algorithm.tagLength.orDefault(128)); + + auto ctx = kj::disposeWith( + EVP_AEAD_CTX_new(EVP_aead_chacha20_poly1305(), keyData.begin(), keyData.size(), TAG_BYTES)); + JSG_REQUIRE(ctx.get() != nullptr, DOMOperationError, "Failed to initialize ChaCha20-Poly1305."); + kj::ArrayPtr aad; + KJ_IF_SOME(additionalData, algorithm.additionalData) { + aad = additionalData.getHandle(js).asArrayPtr(); + } + auto result = jsg::JsArrayBuffer::create(js, plaintext.size() + TAG_BYTES); + size_t written = 0; + JSG_REQUIRE(EVP_AEAD_CTX_seal(ctx.get(), result.asArrayPtr().begin(), &written, result.size(), + iv.asArrayPtr().begin(), iv.size(), plaintext.begin(), plaintext.size(), + aad.begin(), aad.size()) == 1, + DOMOperationError, "ChaCha20-Poly1305 encryption failed."); + KJ_ASSERT(written == result.size()); + return result; + } + + jsg::JsArrayBuffer decrypt(jsg::Lock& js, + SubtleCrypto::EncryptAlgorithm&& algorithm, + kj::ArrayPtr ciphertext) const override { + auto iv = JSG_REQUIRE_NONNULL(algorithm.iv, TypeError, "Missing field \"iv\" in \"algorithm\".") + .getHandle(js); + JSG_REQUIRE( + iv.size() == IV_BYTES, DOMOperationError, "ChaCha20-Poly1305 IV must be 12 bytes long."); + validateChacha20Poly1305TagLength(algorithm.tagLength.orDefault(128)); + JSG_REQUIRE(ciphertext.size() >= TAG_BYTES, DOMOperationError, + "ChaCha20-Poly1305 ciphertext is shorter than its authentication tag."); + + auto ctx = kj::disposeWith( + EVP_AEAD_CTX_new(EVP_aead_chacha20_poly1305(), keyData.begin(), keyData.size(), TAG_BYTES)); + JSG_REQUIRE(ctx.get() != nullptr, DOMOperationError, "Failed to initialize ChaCha20-Poly1305."); + kj::ArrayPtr aad; + KJ_IF_SOME(additionalData, algorithm.additionalData) { + aad = additionalData.getHandle(js).asArrayPtr(); + } + auto result = jsg::JsArrayBuffer::create(js, ciphertext.size() - TAG_BYTES); + size_t written = 0; + JSG_REQUIRE(EVP_AEAD_CTX_open(ctx.get(), result.asArrayPtr().begin(), &written, result.size(), + iv.asArrayPtr().begin(), iv.size(), ciphertext.begin(), ciphertext.size(), + aad.begin(), aad.size()) == 1, + DOMOperationError, "ChaCha20-Poly1305 authentication failed."); + KJ_ASSERT(written == result.size()); + return result; + } + + SubtleCrypto::ExportKeyData exportKey(jsg::Lock& js, kj::StringPtr format) const override { + if (format == "raw-secret") { + return jsg::JsArrayBuffer::create(js, keyData).addRef(js); + } + if (format == "jwk") { + SubtleCrypto::JsonWebKey jwk; + jwk.kty = kj::str("oct"); + jwk.k = fastEncodeBase64Url(keyData); + jwk.alg = kj::str("C20P"); + jwk.key_ops = getUsages().map([](auto usage) { return kj::str(usage.name()); }); + jwk.ext = isExtractable(); + return jwk; + } + JSG_FAIL_REQUIRE(DOMNotSupportedError, + "ChaCha20-Poly1305 key only supports exporting \"raw-secret\" and \"jwk\", not \"", format, + "\"."); + } + + kj::StringPtr getAlgorithmName() const override { + return "ChaCha20-Poly1305"; + } + CryptoKey::AlgorithmVariant getAlgorithm(jsg::Lock&) const override { + return CryptoKey::KeyAlgorithm{"ChaCha20-Poly1305"}; + } + size_t getSymmetricKeySize() const override { + return keyData.size(); + } + bool equals(const Impl& other) const override { + return this == &other || (other.getType() == "secret"_kj && other.equals(keyData)); + } + bool equals(const kj::Array& other) const override { + return other.size() == KEY_BYTES && + CRYPTO_memcmp(keyData.begin(), other.begin(), KEY_BYTES) == 0; + } + kj::StringPtr jsgGetMemoryName() const override { + return "Chacha20Poly1305Key"; + } + size_t jsgGetMemorySelfSize() const override { + return sizeof(Chacha20Poly1305Key); + } + void jsgGetMemoryInfo(jsg::MemoryTracker& tracker) const override { + tracker.trackFieldWithSize("keyData", keyData.size()); + } + + private: + kj::Array keyData; +}; + +void validateJwkUsages(SubtleCrypto::JsonWebKey& jwk, kj::ArrayPtr keyUsages) { + KJ_IF_SOME(use, jwk.use) { + JSG_REQUIRE(keyUsages.size() == 0 || use == "enc", DOMDataError, + "ChaCha20-Poly1305 JWK must have a use of \"enc\"."); + } + KJ_IF_SOME(ops, jwk.key_ops) { + std::sort(ops.begin(), ops.end()); + JSG_REQUIRE(std::adjacent_find(ops.begin(), ops.end()) == ops.end(), DOMDataError, + "ChaCha20-Poly1305 JWK key_ops contains duplicates."); + for (const auto& usage: keyUsages) { + JSG_REQUIRE(std::binary_search(ops.begin(), ops.end(), usage), DOMDataError, + "ChaCha20-Poly1305 JWK key_ops does not contain ", usage, "."); + } + } +} + +} // namespace + +kj::OneOf, CryptoKeyPair> CryptoKey::Impl::generateChacha20Poly1305( + jsg::Lock& js, + kj::StringPtr normalizedName, + SubtleCrypto::GenerateKeyAlgorithm&&, + bool extractable, + kj::ArrayPtr keyUsages) { + auto usages = CryptoKeyUsageSet::validate( + normalizedName, CryptoKeyUsageSet::Context::generate, keyUsages, USAGES); + auto keyData = kj::heapArray(KEY_BYTES); + IoContext::current().getEntropySource().generate(keyData); + return js.alloc(kj::heap(kj::mv(keyData), extractable, usages)); +} + +kj::Own CryptoKey::Impl::importChacha20Poly1305(jsg::Lock& js, + kj::StringPtr normalizedName, + kj::StringPtr format, + SubtleCrypto::ImportKeyData keyData, + SubtleCrypto::ImportKeyAlgorithm&&, + bool extractable, + kj::ArrayPtr keyUsages) { + auto usages = CryptoKeyUsageSet::validate( + normalizedName, CryptoKeyUsageSet::Context::importSecret, keyUsages, USAGES); + kj::Array bytes; + if (format == "raw-secret") { + auto& source = keyData.get>(); + auto handle = source.getHandle(js); + JSG_REQUIRE( + handle.size() == KEY_BYTES, DOMDataError, "ChaCha20-Poly1305 key must be 256 bits."); + bytes = handle.copy(); + } else if (format == "jwk") { + auto& jwk = keyData.get(); + JSG_REQUIRE(jwk.kty == "oct", DOMDataError, "ChaCha20-Poly1305 JWK kty must be oct."); + KJ_IF_SOME(alg, jwk.alg) { + JSG_REQUIRE(alg == "C20P", DOMDataError, "ChaCha20-Poly1305 JWK alg must be C20P."); + } + validateJwkUsages(jwk, keyUsages); + KJ_IF_SOME(ext, jwk.ext) { + JSG_REQUIRE(ext || !extractable, DOMDataError, + "ChaCha20-Poly1305 JWK ext is incompatible with extractability."); + } + bytes = UNWRAP_JWK_BIGNUM( + kj::mv(jwk.k), DOMDataError, "ChaCha20-Poly1305 JWK requires a base64url key."); + JSG_REQUIRE( + bytes.size() == KEY_BYTES, DOMDataError, "ChaCha20-Poly1305 JWK key must be 256 bits."); + } else { + JSG_FAIL_REQUIRE(DOMNotSupportedError, "Unrecognized key import format \"", format, "\"."); + } + return kj::heap(kj::mv(bytes), extractable, usages); +} + +} // namespace workerd::api diff --git a/src/workerd/api/crypto/crypto.c++ b/src/workerd/api/crypto/crypto.c++ index 4b9df4aaad2..ebf44e41b8a 100644 --- a/src/workerd/api/crypto/crypto.c++ +++ b/src/workerd/api/crypto/crypto.c++ @@ -127,6 +127,8 @@ static kj::Maybe lookupAlgorithm(kj::StringPtr name) { {"AES-CBC"_kj, &CryptoKey::Impl::importAes, &CryptoKey::Impl::generateAes}, {"AES-GCM"_kj, &CryptoKey::Impl::importAes, &CryptoKey::Impl::generateAes}, {"AES-KW"_kj, &CryptoKey::Impl::importAes, &CryptoKey::Impl::generateAes}, + {"ChaCha20-Poly1305"_kj, &CryptoKey::Impl::importChacha20Poly1305, + &CryptoKey::Impl::generateChacha20Poly1305}, {"HMAC"_kj, &CryptoKey::Impl::importHmac, &CryptoKey::Impl::generateHmac}, {"PBKDF2"_kj, &CryptoKey::Impl::importPbkdf2}, {"HKDF"_kj, &CryptoKey::Impl::importHkdf}, @@ -152,7 +154,8 @@ static kj::Maybe lookupAlgorithm(kj::StringPtr name) { // algorithm registered. return Worker::Api::current().getCryptoAlgorithm(name); } else { - if ((iter->name.startsWith("ML-DSA-"_kj) || iter->name.startsWith("ML-KEM-"_kj)) && + if ((iter->name == "ChaCha20-Poly1305"_kj || iter->name.startsWith("ML-DSA-"_kj) || + iter->name.startsWith("ML-KEM-"_kj)) && !Worker::Api::current().getFeatureFlags().getWebCryptoModernAlgorithms()) { return kj::none; } @@ -201,19 +204,24 @@ kj::Maybe getKeyLength(const SubtleCrypto::ImportKeyAlgorithm& derived {"AES-CBC"}, {"AES-GCM"}, {"AES-KW"}, + {"ChaCha20-Poly1305"}, {"HMAC"}, {"HKDF"}, {"PBKDF2"}, }; auto algIter = registeredAlgorithms.find(algName); - JSG_REQUIRE(algIter != registeredAlgorithms.end(), DOMNotSupportedError, - "Unrecognized derived key type \"", algName, "\" requested."); + JSG_REQUIRE(algIter != registeredAlgorithms.end() && + (*algIter != "ChaCha20-Poly1305" || + Worker::Api::current().getFeatureFlags().getWebCryptoModernAlgorithms()), + DOMNotSupportedError, "Unrecognized derived key type \"", algName, "\" requested."); // We could implement getKeyLength() with the same map-of-strings-to-implementation-functions // strategy as the rest of the crypto operations, but this function is so simple that it hardly - // seems worth the bother. The spec only identifies three cases: the AES family, HMAC, and the KDF - // algorithms. - if (algIter->startsWith("AES-")) { + // seems worth the bother. AES and HMAC have variable key lengths, ChaCha20-Poly1305 uses 256 + // bits, and KDF keys have no fixed length. + if (*algIter == "ChaCha20-Poly1305") { + return 256; + } else if (algIter->startsWith("AES-")) { int length = JSG_REQUIRE_NONNULL( derivedKeyAlgorithm.length, TypeError, "Missing field \"length\" in \"derivedKeyParams\"."); switch (length) { @@ -413,7 +421,8 @@ void validateImportKeyAlgorithm( } bool supportsEncryptDecrypt(kj::StringPtr normalizedName) { - return isOneOf(normalizedName, {"AES-CTR"_kj, "AES-CBC"_kj, "AES-GCM"_kj, "RSA-OAEP"_kj}); + return isOneOf(normalizedName, + {"AES-CTR"_kj, "AES-CBC"_kj, "AES-GCM"_kj, "RSA-OAEP"_kj, "ChaCha20-Poly1305"_kj}); } bool supportsSign(kj::StringPtr normalizedName) { @@ -437,7 +446,7 @@ bool supportsExportKey(kj::StringPtr normalizedName) { {"AES-CTR"_kj, "AES-CBC"_kj, "AES-GCM"_kj, "AES-KW"_kj, "HMAC"_kj, "RSASSA-PKCS1-v1_5"_kj, "RSA-PSS"_kj, "RSA-OAEP"_kj, "RSA-RAW"_kj, "ECDSA"_kj, "ECDH"_kj, "NODE-ED25519"_kj, "Ed25519"_kj, "X25519"_kj, "ML-DSA-44"_kj, "ML-DSA-65"_kj, "ML-DSA-87"_kj, "ML-KEM-768"_kj, - "ML-KEM-1024"_kj}); + "ML-KEM-1024"_kj, "ChaCha20-Poly1305"_kj}); } bool supportsEncapsulate(kj::StringPtr normalizedName) { @@ -457,7 +466,7 @@ void validateEncryptAlgorithm( auto iv = JSG_REQUIRE_NONNULL(algorithm.iv, TypeError, "Missing field \"iv\" in \"algorithm\".") .getHandle(js); JSG_REQUIRE(iv.size() != 0, DOMOperationError, "AES-GCM IV must not be empty."); - validateAesGcmTagLength(algorithm.tagLength.orDefault(128)); + validateAesGcmTagLength(normalizeAesGcmTagLength(algorithm.tagLength.orDefault(128))); } else if (normalizedName == "AES-CBC") { auto iv = JSG_REQUIRE_NONNULL(algorithm.iv, TypeError, "Missing field \"iv\" in \"algorithm\".") .getHandle(js); @@ -471,6 +480,11 @@ void validateEncryptAlgorithm( algorithm.length, TypeError, "Missing \"length\" member in \"algorithm\"."); JSG_REQUIRE(counterLength > 0 && counterLength <= 128, DOMOperationError, "Invalid counter of ", counterLength, " bits length provided."); + } else if (normalizedName == "ChaCha20-Poly1305") { + auto iv = JSG_REQUIRE_NONNULL(algorithm.iv, TypeError, "Missing field \"iv\" in \"algorithm\".") + .getHandle(js); + JSG_REQUIRE(iv.size() == 12, DOMOperationError, "ChaCha20-Poly1305 IV must be 12 bytes long."); + validateChacha20Poly1305TagLength(algorithm.tagLength.orDefault(128)); } } @@ -1313,7 +1327,7 @@ bool SubtleCrypto::supports(jsg::Lock& js, auto name = lookupAlgorithm(additionalAlgorithm.name).orDefault({}).name; if (!isOneOf(name, {"AES-CTR"_kj, "AES-CBC"_kj, "AES-GCM"_kj, "AES-KW"_kj, "HMAC"_kj, - "HKDF"_kj, "PBKDF2"_kj})) { + "HKDF"_kj, "PBKDF2"_kj, "ChaCha20-Poly1305"_kj})) { return false; } if (name == "HMAC") { diff --git a/src/workerd/api/crypto/crypto.h b/src/workerd/api/crypto/crypto.h index b026c566ea7..225e3dd15cb 100644 --- a/src/workerd/api/crypto/crypto.h +++ b/src/workerd/api/crypto/crypto.h @@ -387,17 +387,14 @@ class SubtleCrypto: public jsg::Object { // The additional authentication data to include. jsg::Optional> additionalData; - // The desired length of the authentication tag. May be 0 - 128. - // Note: the spec specifies this as a Web IDL byte (== signed char in C++), not an int, but JS - // has no such 8-bit integer animal. - jsg::Optional tagLength; + // The desired length of the authentication tag, validated by the selected algorithm. + jsg::Optional tagLength; // The initial value of the counter block for AES-CTR. // https://www.w3.org/TR/WebCryptoAPI/#aes-ctr-params jsg::Optional> counter; // The length, in bits, of the rightmost part of the counter block that is incremented. - // See above why we use int instead of int8_t. // https://www.w3.org/TR/WebCryptoAPI/#aes-ctr-params jsg::Optional length; diff --git a/src/workerd/api/crypto/digest.c++ b/src/workerd/api/crypto/digest.c++ index 07de6f67916..efffd47a87c 100644 --- a/src/workerd/api/crypto/digest.c++ +++ b/src/workerd/api/crypto/digest.c++ @@ -108,6 +108,10 @@ class HmacKey final: public CryptoKey::Impl { return keyAlgorithm; } + size_t getSymmetricKeySize() const override { + return keyData.size(); + } + bool equals(const CryptoKey::Impl& other) const override final { return this == &other || (other.getType() == "secret"_kj && other.equals(keyData)); } @@ -154,7 +158,7 @@ kj::Own initHmacContext( } KJ_CASE_ONEOF(key2, CryptoKey::Impl*) { // We already checked that the key is a secret key, so the following should succeed. - SubtleCrypto::ExportKeyData keyData = key2->exportKey(js, "raw"_kj); + SubtleCrypto::ExportKeyData keyData = key2->exportKey(js, "raw-secret"_kj); KJ_SWITCH_ONEOF(keyData) { KJ_CASE_ONEOF(key_data, jsg::JsRef) { diff --git a/src/workerd/api/crypto/hkdf.c++ b/src/workerd/api/crypto/hkdf.c++ index d31c582e41d..1190d820831 100644 --- a/src/workerd/api/crypto/hkdf.c++ +++ b/src/workerd/api/crypto/hkdf.c++ @@ -71,6 +71,10 @@ class HkdfKey final: public CryptoKey::Impl { return keyAlgorithm; } + size_t getSymmetricKeySize() const override { + return keyData.size(); + } + bool equals(const CryptoKey::Impl& other) const override final { return this == &other || (other.getType() == "secret"_kj && other.equals(keyData)); } diff --git a/src/workerd/api/crypto/impl-test.c++ b/src/workerd/api/crypto/impl-test.c++ index 244962a4424..295b6512d97 100644 --- a/src/workerd/api/crypto/impl-test.c++ +++ b/src/workerd/api/crypto/impl-test.c++ @@ -13,6 +13,30 @@ namespace workerd::api { namespace { +KJ_TEST("Raw key equality returns false when an implementation does not support it") { + class SecretKey final: public CryptoKey::Impl { + public: + SecretKey(): Impl(true, CryptoKeyUsageSet::encrypt()) {} + using Impl::equals; + + kj::StringPtr getAlgorithmName() const override { + return "test-secret"; + } + CryptoKey::AlgorithmVariant getAlgorithm(jsg::Lock&) const override { + return CryptoKey::KeyAlgorithm{"test-secret"}; + } + bool equals(const Impl& other) const override { + return this == &other; + } + }; + + SecretKey key; + const CryptoKey::Impl& impl = key; + auto bytes = kj::heapArray(32); + KJ_EXPECT(impl.getType() == "secret"); + KJ_EXPECT(!impl.equals(bytes)); +} + KJ_TEST("Crypto error conversion") { ClearErrorOnReturn clearErrorOnReturn; diff --git a/src/workerd/api/crypto/impl.c++ b/src/workerd/api/crypto/impl.c++ index 77f9b3fe2a0..f7710a8f2d1 100644 --- a/src/workerd/api/crypto/impl.c++ +++ b/src/workerd/api/crypto/impl.c++ @@ -231,8 +231,8 @@ kj::EncodingResult> decodeBase64Url(kj::String text) { return kj::decodeBase64(text); } -bool CryptoKey::Impl::equals(const kj::Array& other) const { - KJ_FAIL_REQUIRE("Unable to compare raw key material for this key"); +bool CryptoKey::Impl::equals(const kj::Array&) const { + return false; } kj::Own CryptoKey::Impl::from(jsg::Lock& js, kj::Own key) { diff --git a/src/workerd/api/crypto/impl.h b/src/workerd/api/crypto/impl.h index 66544539043..8b983b81f6e 100644 --- a/src/workerd/api/crypto/impl.h +++ b/src/workerd/api/crypto/impl.h @@ -19,6 +19,8 @@ #include +#include + using BIGNUM = struct bignum_st; // Wrap calls to OpenSSL's EVP_* interface (and similar APIs) in this macro to @@ -78,6 +80,14 @@ std::pair lookupDigestAlgorithm(kj::StringPtr algo // kj::decodeBase64 can do this in-situ for both cases. kj::EncodingResult> decodeBase64Url(kj::String text); +inline int normalizeAesGcmTagLength(double tagLength) { + if (!jsg::isFinite(tagLength)) return 0; + JSG_REQUIRE( + tagLength <= static_cast(kj::maxValue) && tagLength >= static_cast(kj::minValue), + TypeError, "AES-GCM tagLength is outside the integer range."); + return static_cast(tagLength); +} + inline void validateAesGcmTagLength(int tagLength) { switch (tagLength) { case 32: @@ -93,6 +103,15 @@ inline void validateAesGcmTagLength(int tagLength) { } } +inline void validateChacha20Poly1305TagLength(double tagLength) { + JSG_REQUIRE(jsg::isFinite(tagLength), TypeError, "ChaCha20-Poly1305 tagLength must be finite."); + tagLength = std::trunc(tagLength); + JSG_REQUIRE(tagLength >= 0 && tagLength <= 255, TypeError, + "ChaCha20-Poly1305 tagLength must be an octet."); + JSG_REQUIRE( + tagLength == 128, DOMOperationError, "ChaCha20-Poly1305 tag length must be 128 bits."); +} + // WebCrypto likes to allow algorithms to be specified as a simple string name, or as a struct // containing a `name` field and possibly other fields. This helper collapses that. template @@ -129,6 +148,7 @@ class CryptoKey::Impl { kj::ArrayPtr keyUsages); static ImportFunc importAes; + static ImportFunc importChacha20Poly1305; static ImportFunc importHmac; static ImportFunc importPbkdf2; static ImportFunc importHkdf; @@ -147,6 +167,7 @@ class CryptoKey::Impl { kj::ArrayPtr keyUsages); static GenerateFunc generateAes; + static GenerateFunc generateChacha20Poly1305; static GenerateFunc generateHmac; static GenerateFunc generateRsa; static GenerateFunc generateEcdsa; @@ -258,6 +279,11 @@ class CryptoKey::Impl { virtual kj::StringPtr getAlgorithmName() const = 0; + // The size in bytes of the key material for Node.js secret keys. + virtual size_t getSymmetricKeySize() const { + return 0; + } + virtual CryptoKey::AsymmetricKeyDetails getAsymmetricKeyDetail(jsg::Lock& js) const { JSG_FAIL_REQUIRE(DOMNotSupportedError, "The getAsymmetricKeyDetail operation is not implemented for \"", getAlgorithmName(), diff --git a/src/workerd/api/crypto/pbkdf2.c++ b/src/workerd/api/crypto/pbkdf2.c++ index 3f9af4e2292..8563828d5ee 100644 --- a/src/workerd/api/crypto/pbkdf2.c++ +++ b/src/workerd/api/crypto/pbkdf2.c++ @@ -91,6 +91,10 @@ class Pbkdf2Key final: public CryptoKey::Impl { return keyAlgorithm; } + size_t getSymmetricKeySize() const override { + return keyData.size(); + } + bool equals(const CryptoKey::Impl& other) const override final { return this == &other || (other.getType() == "secret"_kj && other.equals(keyData)); } diff --git a/src/workerd/api/node/crypto-keys.c++ b/src/workerd/api/node/crypto-keys.c++ index 3b9d9f630c5..0a01be3cfa9 100644 --- a/src/workerd/api/node/crypto-keys.c++ +++ b/src/workerd/api/node/crypto-keys.c++ @@ -53,13 +53,12 @@ class SecretKey final: public CryptoKey::Impl { }; } + size_t getSymmetricKeySize() const override { + return keyData.size(); + } + bool equals(const CryptoKey::Impl& other) const override final { - if (this == &other) return true; - if (other.getType() != "secret"_kj) return false; - KJ_IF_SOME(o, kj::dynamicDowncastIfAvailable(other)) { - return equalsImpl(o.rawKeyData()); - } - return false; + return this == &other || (other.getType() == "secret"_kj && other.equals(keyData)); } bool equalsImpl(kj::ArrayPtr other) const { @@ -72,8 +71,9 @@ class SecretKey final: public CryptoKey::Impl { } SubtleCrypto::ExportKeyData exportKey(jsg::Lock& js, kj::StringPtr format) const override final { - JSG_REQUIRE(format == "raw" || format == "jwk", DOMNotSupportedError, getAlgorithmName(), - " key only supports exporting \"raw\" & \"jwk\", not \"", format, "\"."); + JSG_REQUIRE(format == "raw" || format == "raw-secret" || format == "jwk", DOMNotSupportedError, + getAlgorithmName(), + " key only supports exporting \"raw\", \"raw-secret\" & \"jwk\", not \"", format, "\"."); if (format == "jwk") { SubtleCrypto::JsonWebKey jwk; @@ -399,10 +399,10 @@ kj::OneOf CryptoImpl:: if (key->getType() == "secret"_kj) { // For secret keys, we only pay attention to the format option, which will be - // one of either "buffer" or "jwk". The "buffer" option correlates to the "raw" + // one of either "buffer" or "jwk". The "buffer" option correlates to the "raw-secret" // format in Web Crypto. The "jwk" option is handled above. JSG_REQUIRE(format == "buffer"_kj, TypeError, "Invalid format for secret key export: ", format); - return convertExportKeyData(key->impl->exportKey(js, "raw"_kj)); + return convertExportKeyData(key->impl->exportKey(js, "raw-secret"_kj)); } kj::StringPtr type = JSG_REQUIRE_NONNULL(opts.type, TypeError, "Missing type option"); @@ -445,24 +445,10 @@ kj::StringPtr CryptoImpl::getAsymmetricKeyType(jsg::Lock& js, jsg::Refsecond : name; } -uint32_t CryptoImpl::getSymmetricKeySize(jsg::Lock& js, jsg::Ref key) { +double CryptoImpl::getSymmetricKeySize(jsg::Ref key) { JSG_REQUIRE( key->getType() == "secret"_kj, TypeError, "Only secret keys have a symmetric key size"); - KJ_SWITCH_ONEOF(key->getAlgorithm(js)) { - KJ_CASE_ONEOF(algorithm, CryptoKey::AesKeyAlgorithm) { - return algorithm.length; - } - KJ_CASE_ONEOF(algorithm, CryptoKey::HmacKeyAlgorithm) { - return algorithm.length; - } - KJ_CASE_ONEOF(algorithm, CryptoKey::ArbitraryKeyAlgorithm) { - return algorithm.length.orDefault(0); - } - KJ_CASE_ONEOF_DEFAULT { - return 0; - } - } - KJ_UNREACHABLE; + return static_cast(key->impl->getSymmetricKeySize()); } jsg::Ref CryptoImpl::createSecretKey(jsg::Lock& js, jsg::JsBufferSource keyData) { diff --git a/src/workerd/api/node/crypto.h b/src/workerd/api/node/crypto.h index bf109319c2f..0dbe76aacfb 100644 --- a/src/workerd/api/node/crypto.h +++ b/src/workerd/api/node/crypto.h @@ -209,9 +209,7 @@ class CryptoImpl final: public jsg::Object { CryptoKey::AsymmetricKeyDetails getAsymmetricKeyDetail(jsg::Lock& js, jsg::Ref key); kj::StringPtr getAsymmetricKeyType(jsg::Lock& js, jsg::Ref key); - // The `length` of a secret key's algorithm, or 0 if it has none. Read from the key itself rather - // than from the `algorithm` property, which scripts can modify. - uint32_t getSymmetricKeySize(jsg::Lock& js, jsg::Ref key); + double getSymmetricKeySize(jsg::Ref key); jsg::Ref createSecretKey(jsg::Lock& js, jsg::JsBufferSource keyData); jsg::Ref createPrivateKey(jsg::Lock& js, CreateAsymmetricKeyOptions options); diff --git a/src/workerd/api/node/tests/crypto_keys-test.js b/src/workerd/api/node/tests/crypto_keys-test.js index c98a637cc8d..1c097e06b91 100644 --- a/src/workerd/api/node/tests/crypto_keys-test.js +++ b/src/workerd/api/node/tests/crypto_keys-test.js @@ -89,6 +89,67 @@ export const secret_key_equals_test = { // Unable to determine equality if either key is not extractable. ok(!hmac_ko.equals(hmac2_ko)); ok(!hmac2_ko.equals(hmac_ko)); + + const raw_ko = createSecretKey(secretKeyData1); + for (const key of [aes1_ko, aes2_ko, hmac_ko]) { + ok(raw_ko.equals(key)); + ok(key.equals(raw_ko)); + } + for (const key of [aes3_ko, hmac2_ko]) { + ok(!raw_ko.equals(key)); + ok(!key.equals(raw_ko)); + } + }, +}; + +export const webcrypto_secret_key_size_and_export = { + async test() { + for (const [algorithm, size, usages] of [ + ['AES-GCM', 16, ['encrypt']], + ['AES-GCM', 24, ['encrypt']], + ['AES-GCM', 32, ['encrypt']], + [{ name: 'HMAC', hash: 'SHA-256' }, 33, ['sign']], + [{ name: 'HMAC', hash: 'SHA-256', length: 249 }, 32, ['sign']], + ]) { + const data = Buffer.alloc(size, 0x80); + for (const extractable of [false, true]) { + const key = KeyObject.from( + await crypto.subtle.importKey( + 'raw', + data, + algorithm, + extractable, + usages + ) + ); + strictEqual(key.symmetricKeySize, size); + if (extractable) { + deepStrictEqual(key.export(), data); + deepStrictEqual(key.export({ format: 'buffer' }), data); + deepStrictEqual( + Buffer.from(key.export({ format: 'jwk' }).k, 'base64url'), + data + ); + } else { + throws(() => key.export(), TypeError); + } + } + } + + const data = Buffer.alloc(31, 7); + for (const algorithm of ['HKDF', 'PBKDF2']) { + const key = KeyObject.from( + await crypto.subtle.importKey('raw', data, algorithm, false, [ + 'deriveBits', + ]) + ); + strictEqual(key.symmetricKeySize, data.byteLength); + throws(() => key.export(), TypeError); + } + const key = createSecretKey(data); + strictEqual(key.symmetricKeySize, data.byteLength); + deepStrictEqual(key.export(), data); + deepStrictEqual(key.export({ format: 'buffer' }), data); }, }; diff --git a/src/workerd/api/tests/crypto-extras-test.js b/src/workerd/api/tests/crypto-extras-test.js index 575fad1185a..aa71354b9b5 100644 --- a/src/workerd/api/tests/crypto-extras-test.js +++ b/src/workerd/api/tests/crypto-extras-test.js @@ -485,6 +485,37 @@ export const webCryptoModernAlgorithmsRequireCompatFlag = { }, }; +export const chachaDerivedKeyRequiresCompatFlag = { + async test() { + const baseKey = await crypto.subtle.importKey( + 'raw', + new Uint8Array(32), + 'HKDF', + false, + ['deriveKey'] + ); + const algorithm = { + name: 'HKDF', + hash: 'SHA-256', + salt: new Uint8Array(16), + }; + for (const target of ['ChaCha20-Poly1305', { name: 'chacha20-poly1305' }]) { + for (const params of [ + algorithm, + { ...algorithm, info: new Uint8Array() }, + ]) { + await rejects( + crypto.subtle.deriveKey(params, baseKey, target, true, ['encrypt']), + { + name: 'NotSupportedError', + message: /Unrecognized derived key type/, + } + ); + } + } + }, +}; + export const webCryptoSignAlgorithmIgnoresUnrelatedContext = { async test() { const keyPair = await crypto.subtle.generateKey( diff --git a/src/workerd/api/tests/crypto-modern-algorithms-test.js b/src/workerd/api/tests/crypto-modern-algorithms-test.js index 804dddcd003..824bdf9e0c3 100644 --- a/src/workerd/api/tests/crypto-modern-algorithms-test.js +++ b/src/workerd/api/tests/crypto-modern-algorithms-test.js @@ -2,6 +2,7 @@ // Licensed under the Apache 2.0 license found in the LICENSE file or at: // https://opensource.org/licenses/Apache-2.0 import { rejects, strictEqual, throws } from 'node:assert'; +import { createHmac, createSecretKey, KeyObject } from 'node:crypto'; export const supportsEcdhP521ByteRoundedDeriveBits = { async test() { @@ -40,6 +41,72 @@ export const supportsEcdhP521ByteRoundedDeriveBits = { }, }; +export const chacha20Poly1305 = { + async test() { + const key = await crypto.subtle.generateKey( + { name: 'ChaCha20-Poly1305', length: 256 }, + true, + ['encrypt', 'decrypt'] + ); + const iv = new Uint8Array(12); + const plaintext = new TextEncoder().encode('hello'); + const ciphertext = await crypto.subtle.encrypt( + { name: 'ChaCha20-Poly1305', iv }, + key, + plaintext + ); + const decrypted = await crypto.subtle.decrypt( + { name: 'ChaCha20-Poly1305', iv }, + key, + ciphertext + ); + strictEqual(new TextDecoder().decode(decrypted), 'hello'); + + const jwk = await crypto.subtle.exportKey('jwk', key); + await crypto.subtle.importKey('jwk', jwk, 'ChaCha20-Poly1305', true, [ + 'encrypt', + 'decrypt', + ]); + await rejects( + crypto.subtle.importKey( + 'raw', + new Uint8Array(32), + 'ChaCha20-Poly1305', + true, + ['encrypt'] + ), + { name: 'NotSupportedError' } + ); + await rejects(crypto.subtle.exportKey('raw', key), { + name: 'NotSupportedError', + }); + + const baseKey = await crypto.subtle.importKey( + 'raw', + new Uint8Array(32), + 'HKDF', + false, + ['deriveKey'] + ); + const derivedKey = await crypto.subtle.deriveKey( + { + name: 'HKDF', + hash: 'SHA-256', + salt: new Uint8Array(16), + info: new Uint8Array(), + }, + baseKey, + 'ChaCha20-Poly1305', + true, + ['encrypt'] + ); + strictEqual( + (await crypto.subtle.exportKey('raw-secret', derivedKey)).byteLength, + 32 + ); + }, +}; + export const mlDsaJwkKeyOpsValidation = { async test() { const keyPair = await crypto.subtle.generateKey('ML-DSA-44', true, [ @@ -135,3 +202,186 @@ export const mlDsaContextLength = { } }, }; + +export const chacha20Poly1305Parameters = { + async test() { + const name = 'ChaCha20-Poly1305'; + const key = await crypto.subtle.generateKey({ name, length: 128 }, true, [ + 'encrypt', + 'decrypt', + ]); + strictEqual( + (await crypto.subtle.exportKey('raw-secret', key)).byteLength, + 32 + ); + strictEqual( + crypto.subtle.constructor.supports('generateKey', { name, length: 128 }), + true + ); + const iv = new Uint8Array(12); + const additionalData = new Uint8Array([1, 2, 3]); + const algorithm = { name, iv, additionalData }; + const encrypted = await crypto.subtle.encrypt( + algorithm, + key, + new Uint8Array() + ); + strictEqual(encrypted.byteLength, 16); + strictEqual( + (await crypto.subtle.decrypt(algorithm, key, encrypted)).byteLength, + 0 + ); + for (const invalid of [ + { ...algorithm, iv: new Uint8Array(11) }, + { ...algorithm, tagLength: 96 }, + ]) { + await rejects(crypto.subtle.encrypt(invalid, key, new Uint8Array()), { + name: 'OperationError', + }); + strictEqual( + crypto.subtle.constructor.supports('encrypt', invalid), + false + ); + } + strictEqual( + ( + await crypto.subtle.encrypt( + { ...algorithm, tagLength: 128.5 }, + key, + new Uint8Array() + ) + ).byteLength, + 16 + ); + for (const tagLength of [-1, 256, NaN, Infinity, -Infinity]) { + await rejects( + crypto.subtle.encrypt( + { ...algorithm, tagLength }, + key, + new Uint8Array() + ), + { name: 'TypeError' } + ); + } + await rejects( + crypto.subtle.decrypt( + { ...algorithm, additionalData: new Uint8Array([4]) }, + key, + encrypted + ), + { name: 'OperationError' } + ); + await rejects(crypto.subtle.decrypt(algorithm, key, new Uint8Array(15)), { + name: 'OperationError', + }); + const aesKey = await crypto.subtle.generateKey( + { name: 'AES-GCM', length: 256 }, + false, + ['encrypt'] + ); + for (const tagLength of [NaN, Infinity]) { + await rejects( + crypto.subtle.encrypt( + { name: 'AES-GCM', iv, tagLength }, + aesKey, + new Uint8Array() + ), + { + name: 'OperationError', + } + ); + } + strictEqual( + ( + await crypto.subtle.encrypt( + { name: 'AES-GCM', iv, tagLength: 96.5 }, + aesKey, + new Uint8Array() + ) + ).byteLength, + 12 + ); + }, +}; + +export const chacha20Poly1305KeyEquality = { + async test() { + const data = new Uint8Array(32).fill(7); + const chacha = KeyObject.from( + await crypto.subtle.importKey( + 'raw-secret', + data, + 'ChaCha20-Poly1305', + true, + ['encrypt'] + ) + ); + strictEqual(chacha.equals(chacha), true); + for (const [algorithm, keyData, usages, expected] of [ + ['AES-GCM', data, ['encrypt'], true], + ['AES-GCM', data.slice(0, 16), ['encrypt'], false], + ['AES-GCM', new Uint8Array(32).fill(8), ['encrypt'], false], + ['ChaCha20-Poly1305', data, ['encrypt'], true], + ['ChaCha20-Poly1305', new Uint8Array(32).fill(8), ['encrypt'], false], + [{ name: 'HMAC', hash: 'SHA-256' }, data, ['sign'], true], + ]) { + const format = algorithm === 'ChaCha20-Poly1305' ? 'raw-secret' : 'raw'; + const other = KeyObject.from( + await crypto.subtle.importKey(format, keyData, algorithm, true, usages) + ); + strictEqual(other.equals(chacha), expected); + strictEqual(chacha.equals(other), expected); + } + const raw = createSecretKey(data); + strictEqual(raw.equals(chacha), true); + strictEqual(chacha.equals(raw), true); + }, +}; + +export const chacha20Poly1305KeyObject = { + async test() { + const data = new Uint8Array(32).fill(7); + for (const extractable of [true, false]) { + const key = KeyObject.from( + await crypto.subtle.importKey( + 'raw-secret', + data, + 'ChaCha20-Poly1305', + extractable, + ['encrypt'] + ) + ); + strictEqual(key.symmetricKeySize, 32); + if (extractable) { + strictEqual(key.export().toString('hex'), '07'.repeat(32)); + strictEqual(key.export({ format: 'buffer' }).byteLength, 32); + strictEqual(key.export({ format: 'jwk' }).alg, 'C20P'); + } else { + throws(() => key.export(), { name: 'TypeError' }); + } + } + }, +}; + +export const chacha20Poly1305Hmac = { + async test() { + const data = new Uint8Array(32).fill(7); + const message = 'hello'; + const expected = createHmac('sha256', data).update(message).digest('hex'); + for (const extractable of [true, false]) { + const key = await crypto.subtle.importKey( + 'raw-secret', + data, + 'ChaCha20-Poly1305', + extractable, + ['encrypt'] + ); + for (const hmacKey of [key, KeyObject.from(key)]) { + strictEqual( + createHmac('sha256', hmacKey).update(message).digest('hex'), + expected + ); + } + } + }, +}; diff --git a/src/workerd/io/compatibility-date.capnp b/src/workerd/io/compatibility-date.capnp index b1cb45a5334..ca7e13b22b4 100644 --- a/src/workerd/io/compatibility-date.capnp +++ b/src/workerd/io/compatibility-date.capnp @@ -1682,10 +1682,11 @@ struct CompatibilityFlags @0x8f8c1b68151b6cef { webCryptoModernAlgorithms @191 :Bool $compatEnableFlag("webcrypto_modern_algorithms"); # Enables opt-in WebCrypto modern algorithm support. This currently exposes the subset of - # the evolving WICG Modern Algorithms draft implemented by workerd: ML-KEM, ML-DSA, related - # key encapsulation helpers, getPublicKey(), SubtleCrypto.supports(), and AKP JWK support. It is - # explicitly gated because the draft is still changing and workerd does not implement the full - # proposal. The API may change as the draft evolves. + # the evolving WICG Modern Algorithms draft implemented by workerd: ML-KEM, ML-DSA, + # ChaCha20-Poly1305, related key encapsulation helpers, + # getPublicKey(), SubtleCrypto.supports(), and AKP JWK support. It is explicitly gated because + # the draft is still changing and workerd does not implement the full proposal. The API may + # change as the draft evolves. wasmEsmIntegrationBuiltins @192 :Bool $compatEnableFlag("wasm_esm_integration_builtins"); diff --git a/src/wpt/WebCryptoAPI-test.ts b/src/wpt/WebCryptoAPI-test.ts index 358d0f0ba91..139c5d309e1 100644 --- a/src/wpt/WebCryptoAPI-test.ts +++ b/src/wpt/WebCryptoAPI-test.ts @@ -116,8 +116,7 @@ export default { 'encrypt_decrypt/aes_ocb.tentative.https.any.js': unsupported('AES-OCB'), 'encrypt_decrypt/aes_ocb_fixtures.js': supportFile, 'encrypt_decrypt/aes_ocb_vectors.js': supportFile, - 'encrypt_decrypt/chacha20_poly1305.tentative.https.any.js': - unsupported('ChaCha20-Poly1305'), + 'encrypt_decrypt/chacha20_poly1305.tentative.https.any.js': {}, 'encrypt_decrypt/rsa.js': supportFile, 'encrypt_decrypt/rsa_oaep.https.any.js': {}, 'encrypt_decrypt/rsa_vectors.js': {}, @@ -160,8 +159,7 @@ export default { comment: 'Wrong type of error returned', expectedFailures: [/^(Empty|Bad) algorithm:/], }, - 'generateKey/failures_chacha20_poly1305.tentative.https.any.js': - unsupported('ChaCha20-Poly1305'), + 'generateKey/failures_chacha20_poly1305.tentative.https.any.js': {}, 'generateKey/failures_kmac.tentative.https.any.js': unsupported('KMAC'), 'generateKey/successes.js': supportFile, 'generateKey/successes_AES-CBC.https.any.js': {}, @@ -187,8 +185,7 @@ export default { 'generateKey/successes_RSASSA-PKCS1-v1_5.https.any.js': {}, 'generateKey/successes_X25519.https.any.js': {}, 'generateKey/successes_X448.tentative.https.any.js': unsupported('X448'), - 'generateKey/successes_chacha20_poly1305.tentative.https.any.js': - unsupported('ChaCha20-Poly1305'), + 'generateKey/successes_chacha20_poly1305.tentative.https.any.js': {}, 'generateKey/successes_kmac.tentative.https.any.js': unsupported('KMAC'), 'getPublicKey.tentative.https.any.js': { comment: 'Ed448, X448, ML-KEM-512, and hybrid ML-KEM are not supported', @@ -258,8 +255,7 @@ export default { unsupported('AES-OCB'), 'import_export/Argon2_importKey.tentative.https.any.js': unsupported('Argon2'), - 'import_export/ChaCha20-Poly1305_importKey.tentative.https.any.js': - unsupported('ChaCha20-Poly1305'), + 'import_export/ChaCha20-Poly1305_importKey.tentative.https.any.js': {}, 'import_export/Hybrid-KEM_importKey.tentative.https.any.js': unsupported('Hybrid ML-KEM'), 'import_export/Hybrid-KEM_importKey_fixtures.js': { @@ -395,8 +391,10 @@ export default { expectedFailures: true, }, 'serialization/aes-ocb.tentative.https.any.js': unsupported('AES-OCB'), - 'serialization/chacha20-poly1305.tentative.https.any.js': - unsupported('ChaCha20-Poly1305'), + 'serialization/chacha20-poly1305.tentative.https.any.js': { + comment: 'CryptoKey does not support structured cloning', + expectedFailures: true, + }, 'serialization/ecdh.https.any.js': { comment: 'CryptoKey does not support structured cloning', expectedFailures: true, @@ -502,12 +500,8 @@ export default { 'sign_verify/rsa_pss_vectors.js': {}, 'sign_verify/signature.js': {}, 'supports-modern.tentative.https.any.js': { - comment: - 'ML-KEM-512, hybrid ML-KEM, and ChaCha20-Poly1305 are not supported', - expectedFailures: [ - /ML-KEM-512|MLKEM(768|1024)-|ChaCha20-Poly1305/, - 'supports returns true for algorithm objects with valid parameters', - ], + comment: 'ML-KEM-512 and hybrid ML-KEM are not supported', + expectedFailures: [/ML-KEM-512|MLKEM(768|1024)-/], }, 'supports.tentative.https.any.js': { comment: From 57cfd5156624762bc4614f1fcf45d11d662b8b09 Mon Sep 17 00:00:00 2001 From: Filip Skokan Date: Wed, 30 Sep 2026 17:43:37 +0200 Subject: [PATCH 2/3] feat(crypto): add MLKEM768-X25519 support Use BoringSSL's X-Wing API for key generation, seed expansion, and encapsulation/decapsulation. Support raw-public, raw-seed, and JWK key formats, validate JWK metadata, and derive public keys from private keys. Gate the algorithm behind webcrypto_modern_algorithms to preserve the existing API surface. Enable its WPT coverage and add regression tests. --- src/workerd/api/crypto/AGENTS.md | 1 + src/workerd/api/crypto/crypto.c++ | 12 +- src/workerd/api/crypto/hybridkem.c++ | 267 ++++++++++++++++++ src/workerd/api/crypto/impl.h | 2 + .../tests/crypto-modern-algorithms-test.js | 178 ++++++++++++ src/workerd/io/compatibility-date.capnp | 2 +- src/wpt/WebCryptoAPI-test.ts | 49 ++-- 7 files changed, 482 insertions(+), 29 deletions(-) create mode 100644 src/workerd/api/crypto/hybridkem.c++ diff --git a/src/workerd/api/crypto/AGENTS.md b/src/workerd/api/crypto/AGENTS.md index 8705f895087..f2c9205b33d 100644 --- a/src/workerd/api/crypto/AGENTS.md +++ b/src/workerd/api/crypto/AGENTS.md @@ -16,6 +16,7 @@ WebCrypto API + Node.js crypto C++ implementations over BoringSSL. `crypto.h` de | `rsa.h/c++` | RSA-OAEP, RSA-PSS, RSASSA-PKCS1-v1_5 | | `ec.h/c++` | ECDSA, ECDH, EdDSA (Ed25519/X25519) | | `dh.h/c++` | Diffie-Hellman key exchange (Node.js compat) | +| `hybridkem.c++` | MLKEM768-X25519 (X-Wing) key encapsulation | | `digest.h/c++` | Hash/digest: SHA-\*, MD5, Blake2b512/2s256 | | `kdf.h` + `hkdf.c++`, `pbkdf2.c++`, `scrypt.c++` | KDF declarations in header; each algo in own .c++ | | `jwk.h/c++` | JWK import/export helpers | diff --git a/src/workerd/api/crypto/crypto.c++ b/src/workerd/api/crypto/crypto.c++ index ebf44e41b8a..d27dd897098 100644 --- a/src/workerd/api/crypto/crypto.c++ +++ b/src/workerd/api/crypto/crypto.c++ @@ -146,6 +146,7 @@ static kj::Maybe lookupAlgorithm(kj::StringPtr name) { {"ML-DSA-87"_kj, &CryptoKey::Impl::importMlDsa, &CryptoKey::Impl::generateMlDsa}, {"ML-KEM-768"_kj, &CryptoKey::Impl::importMlKem, &CryptoKey::Impl::generateMlKem}, {"ML-KEM-1024"_kj, &CryptoKey::Impl::importMlKem, &CryptoKey::Impl::generateMlKem}, + {"MLKEM768-X25519"_kj, &CryptoKey::Impl::importHybridKem, &CryptoKey::Impl::generateHybridKem}, }; auto iter = ALGORITHMS.find(CryptoAlgorithm{name}); @@ -155,7 +156,7 @@ static kj::Maybe lookupAlgorithm(kj::StringPtr name) { return Worker::Api::current().getCryptoAlgorithm(name); } else { if ((iter->name == "ChaCha20-Poly1305"_kj || iter->name.startsWith("ML-DSA-"_kj) || - iter->name.startsWith("ML-KEM-"_kj)) && + iter->name.startsWith("ML-KEM-"_kj) || iter->name.startsWith("MLKEM"_kj)) && !Worker::Api::current().getFeatureFlags().getWebCryptoModernAlgorithms()) { return kj::none; } @@ -446,18 +447,18 @@ bool supportsExportKey(kj::StringPtr normalizedName) { {"AES-CTR"_kj, "AES-CBC"_kj, "AES-GCM"_kj, "AES-KW"_kj, "HMAC"_kj, "RSASSA-PKCS1-v1_5"_kj, "RSA-PSS"_kj, "RSA-OAEP"_kj, "RSA-RAW"_kj, "ECDSA"_kj, "ECDH"_kj, "NODE-ED25519"_kj, "Ed25519"_kj, "X25519"_kj, "ML-DSA-44"_kj, "ML-DSA-65"_kj, "ML-DSA-87"_kj, "ML-KEM-768"_kj, - "ML-KEM-1024"_kj, "ChaCha20-Poly1305"_kj}); + "ML-KEM-1024"_kj, "MLKEM768-X25519"_kj, "ChaCha20-Poly1305"_kj}); } bool supportsEncapsulate(kj::StringPtr normalizedName) { - return isOneOf(normalizedName, {"ML-KEM-768"_kj, "ML-KEM-1024"_kj}); + return isOneOf(normalizedName, {"ML-KEM-768"_kj, "ML-KEM-1024"_kj, "MLKEM768-X25519"_kj}); } bool supportsGetPublicKey(kj::StringPtr normalizedName) { return isOneOf(normalizedName, {"RSA-OAEP"_kj, "ECDH"_kj, "X25519"_kj, "ML-KEM-768"_kj, "ML-KEM-1024"_kj, "ECDSA"_kj, "Ed25519"_kj, "RSA-PSS"_kj, "RSASSA-PKCS1-v1_5"_kj, "ML-DSA-44"_kj, "ML-DSA-65"_kj, - "ML-DSA-87"_kj}); + "ML-DSA-87"_kj, "MLKEM768-X25519"_kj}); } void validateEncryptAlgorithm( @@ -1122,7 +1123,8 @@ jsg::Promise> SubtleCrypto::getPublicKey( allowedUsages = CryptoKeyUsageSet::encrypt() | CryptoKeyUsageSet::wrapKey(); } else if (algorithmName == "ECDH" || algorithmName == "X25519") { allowedUsages = CryptoKeyUsageSet(); - } else if (algorithmName == "ML-KEM-768" || algorithmName == "ML-KEM-1024") { + } else if (algorithmName == "ML-KEM-768" || algorithmName == "ML-KEM-1024" || + algorithmName == "MLKEM768-X25519") { allowedUsages = CryptoKeyUsageSet::encapsulateKey() | CryptoKeyUsageSet::encapsulateBits(); } else if (algorithmName == "ECDSA" || algorithmName == "Ed25519" || algorithmName == "RSA-PSS" || algorithmName == "RSASSA-PKCS1-v1_5" || diff --git a/src/workerd/api/crypto/hybridkem.c++ b/src/workerd/api/crypto/hybridkem.c++ new file mode 100644 index 00000000000..fed4440dbb3 --- /dev/null +++ b/src/workerd/api/crypto/hybridkem.c++ @@ -0,0 +1,267 @@ +// 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 "impl.h" + +#include +#include +#include + +#include + +namespace workerd::api { +namespace { + +const auto PUBLIC_USAGES = + CryptoKeyUsageSet::encapsulateKey() | CryptoKeyUsageSet::encapsulateBits(); +const auto PRIVATE_USAGES = + CryptoKeyUsageSet::decapsulateKey() | CryptoKeyUsageSet::decapsulateBits(); + +class HybridKemKey final: public CryptoKey::Impl { + public: + HybridKemKey(kj::Array seed, + XWING_private_key privateKey, + kj::Array publicKey, + bool extractable, + CryptoKeyUsageSet usages) + : CryptoKey::Impl(extractable, usages), + keyType(KeyType::PRIVATE), + seed(kj::mv(seed)), + privateKey(kj::mv(privateKey)), + publicKey(kj::mv(publicKey)) {} + + HybridKemKey(kj::Array publicKey, bool extractable, CryptoKeyUsageSet usages) + : CryptoKey::Impl(extractable, usages), + keyType(KeyType::PUBLIC), + publicKey(kj::mv(publicKey)) {} + + ~HybridKemKey() noexcept(false) { + KJ_IF_SOME(s, seed) OPENSSL_cleanse(s.begin(), s.size()); + KJ_IF_SOME(k, privateKey) OPENSSL_cleanse(&k, sizeof(k)); + } + + std::pair encapsulate(jsg::Lock& js) const override { + JSG_REQUIRE( + keyType == KeyType::PUBLIC, DOMInvalidAccessError, "Encapsulation requires a public key."); + auto sharedSecret = jsg::JsArrayBuffer::create(js, XWING_SHARED_SECRET_BYTES); + auto ciphertext = jsg::JsArrayBuffer::create(js, XWING_CIPHERTEXT_BYTES); + JSG_REQUIRE(XWING_encap(ciphertext.asArrayPtr().begin(), sharedSecret.asArrayPtr().begin(), + publicKey.begin()) == 1, + DOMOperationError, "MLKEM768-X25519 encapsulation failed."); + return {kj::mv(sharedSecret), kj::mv(ciphertext)}; + } + + jsg::JsArrayBuffer decapsulate( + jsg::Lock& js, kj::ArrayPtr ciphertext) const override { + JSG_REQUIRE(keyType == KeyType::PRIVATE, DOMInvalidAccessError, + "Decapsulation requires a private key."); + JSG_REQUIRE(ciphertext.size() == XWING_CIPHERTEXT_BYTES, DOMOperationError, + "Invalid ciphertext length for MLKEM768-X25519."); + auto sharedSecret = jsg::JsArrayBuffer::create(js, XWING_SHARED_SECRET_BYTES); + JSG_REQUIRE(XWING_decap(sharedSecret.asArrayPtr().begin(), ciphertext.begin(), + &KJ_ASSERT_NONNULL(privateKey)) == 1, + DOMOperationError, "MLKEM768-X25519 decapsulation failed."); + return sharedSecret; + } + + kj::Own getPublicKey(jsg::Lock&, CryptoKeyUsageSet usages) const override { + JSG_REQUIRE( + keyType == KeyType::PRIVATE, DOMInvalidAccessError, "getPublicKey requires a private key."); + return kj::heap(kj::heapArray(publicKey), true, usages); + } + + SubtleCrypto::ExportKeyData exportKey(jsg::Lock& js, kj::StringPtr format) const override { + if (format == "raw-public") { + JSG_REQUIRE(keyType == KeyType::PUBLIC, DOMInvalidAccessError, + "raw-public export requires a public key."); + return jsg::JsArrayBuffer::create(js, publicKey).addRef(js); + } + if (format == "raw-seed") { + JSG_REQUIRE(keyType == KeyType::PRIVATE, DOMInvalidAccessError, + "raw-seed export requires a private key."); + return jsg::JsArrayBuffer::create(js, KJ_ASSERT_NONNULL(seed)).addRef(js); + } + if (format == "jwk") { + SubtleCrypto::JsonWebKey jwk; + jwk.kty = kj::str("AKP"); + jwk.alg = kj::str("MLKEM768-X25519"); + jwk.pub = kj::encodeBase64Url(publicKey); + jwk.ext = isExtractable(); + jwk.key_ops = getUsages().map([](auto usage) { return kj::str(usage.name()); }); + if (keyType == KeyType::PRIVATE) jwk.priv = kj::encodeBase64Url(KJ_ASSERT_NONNULL(seed)); + return jwk; + } + JSG_FAIL_REQUIRE( + DOMNotSupportedError, "Unrecognized export format \"", format, "\" for MLKEM768-X25519."); + } + + kj::StringPtr getAlgorithmName() const override { + return "MLKEM768-X25519"; + } + bool publicKeyEquals(kj::ArrayPtr value) const { + return value.size() == publicKey.size() && + CRYPTO_memcmp(value.begin(), publicKey.begin(), value.size()) == 0; + } + CryptoKey::AlgorithmVariant getAlgorithm(jsg::Lock&) const override { + return CryptoKey::KeyAlgorithm{"MLKEM768-X25519"}; + } + kj::StringPtr getType() const override { + return keyType == KeyType::PRIVATE ? "private" : "public"; + } + bool equals(const Impl& other) const override { + auto* key = dynamic_cast(&other); + return key != nullptr && keyType == key->keyType && publicKey.size() == key->publicKey.size() && + CRYPTO_memcmp(publicKey.begin(), key->publicKey.begin(), publicKey.size()) == 0; + } + kj::StringPtr jsgGetMemoryName() const override { + return "HybridKemKey"; + } + size_t jsgGetMemorySelfSize() const override { + return sizeof(HybridKemKey); + } + void jsgGetMemoryInfo(jsg::MemoryTracker& tracker) const override { + tracker.trackFieldWithSize("publicKey", publicKey.size()); + KJ_IF_SOME(s, seed) tracker.trackFieldWithSize("seed", s.size()); + } + + static kj::Maybe> fromSeed( + kj::Array seed, bool extractable, CryptoKeyUsageSet usages) { + XWING_private_key privateKey; + KJ_DEFER(OPENSSL_cleanse(&privateKey, sizeof(privateKey))); + CBS cbs; + CBS_init(&cbs, seed.begin(), seed.size()); + if (XWING_parse_private_key(&privateKey, &cbs) != 1) return kj::none; + auto publicKey = kj::heapArray(XWING_PUBLIC_KEY_BYTES); + if (XWING_public_from_private(publicKey.begin(), &privateKey) != 1) return kj::none; + return kj::heap( + kj::mv(seed), kj::mv(privateKey), kj::mv(publicKey), extractable, usages); + } + + static CryptoKeyPair generateKeyPair(jsg::Lock& js, + bool extractable, + CryptoKeyUsageSet privateUsages, + CryptoKeyUsageSet publicUsages) { + XWING_private_key privateKey; + auto publicKey = kj::heapArray(XWING_PUBLIC_KEY_BYTES); + JSG_REQUIRE(XWING_generate_key(publicKey.begin(), &privateKey) == 1, DOMOperationError, + "MLKEM768-X25519 key generation failed."); + KJ_DEFER(OPENSSL_cleanse(&privateKey, sizeof(privateKey))); + bssl::ScopedCBB cbb; + JSG_REQUIRE(CBB_init(cbb.get(), XWING_PRIVATE_KEY_BYTES) && + XWING_marshal_private_key(cbb.get(), &privateKey) && CBB_flush(cbb.get()), + InternalDOMOperationError, "Failed to marshal MLKEM768-X25519 seed."); + auto seed = kj::heapArray(CBB_data(cbb.get()), CBB_len(cbb.get())); + auto privateImpl = kj::heap( + kj::mv(seed), kj::mv(privateKey), kj::mv(publicKey), extractable, privateUsages); + auto publicImpl = privateImpl->getPublicKey(js, publicUsages); + return CryptoKeyPair{.publicKey = js.alloc(kj::mv(publicImpl)), + .privateKey = js.alloc(kj::mv(privateImpl))}; + } + + private: + enum class KeyType { PUBLIC, PRIVATE }; + KeyType keyType; + kj::Maybe> seed; + kj::Maybe privateKey; + kj::Array publicKey; +}; + +} // namespace + +kj::OneOf, CryptoKeyPair> CryptoKey::Impl::generateHybridKem(jsg::Lock& js, + kj::StringPtr normalizedName, + SubtleCrypto::GenerateKeyAlgorithm&&, + bool extractable, + kj::ArrayPtr keyUsages) { + auto usages = CryptoKeyUsageSet::validate(normalizedName, CryptoKeyUsageSet::Context::generate, + keyUsages, PUBLIC_USAGES | PRIVATE_USAGES); + return HybridKemKey::generateKeyPair(js, extractable, + usages & CryptoKeyUsageSet::privateKeyMask(), usages & CryptoKeyUsageSet::publicKeyMask()); +} + +kj::Own CryptoKey::Impl::importHybridKem(jsg::Lock& js, + kj::StringPtr normalizedName, + kj::StringPtr format, + SubtleCrypto::ImportKeyData keyData, + SubtleCrypto::ImportKeyAlgorithm&&, + bool extractable, + kj::ArrayPtr keyUsages) { + if (format == "raw-seed") { + auto usages = CryptoKeyUsageSet::validate( + normalizedName, CryptoKeyUsageSet::Context::importPrivate, keyUsages, PRIVATE_USAGES); + auto& source = JSG_REQUIRE_NONNULL(keyData.tryGet>(), + DOMDataError, "Import data for raw-seed must be a buffer."); + auto data = source.getHandle(js).asArrayPtr(); + JSG_REQUIRE(data.size() == XWING_PRIVATE_KEY_BYTES, DOMDataError, + "MLKEM768-X25519 seed must be 32 bytes."); + return JSG_REQUIRE_NONNULL( + HybridKemKey::fromSeed(kj::heapArray(data), extractable, usages), + DOMOperationError, "Failed to derive MLKEM768-X25519 key pair from seed."); + } + if (format == "jwk") { + auto& jwk = JSG_REQUIRE_NONNULL(keyData.tryGet(), DOMDataError, + "Import data for jwk must be a JsonWebKey."); + auto usages = jwk.priv != kj::none + ? CryptoKeyUsageSet::validate( + normalizedName, CryptoKeyUsageSet::Context::importPrivate, keyUsages, PRIVATE_USAGES) + : CryptoKeyUsageSet::validate( + normalizedName, CryptoKeyUsageSet::Context::importPublic, keyUsages, PUBLIC_USAGES); + JSG_REQUIRE(jwk.kty == "AKP", DOMDataError, "JWK kty must be AKP."); + KJ_IF_SOME(alg, jwk.alg) { + JSG_REQUIRE( + alg == "MLKEM768-X25519", DOMDataError, "JWK alg does not match MLKEM768-X25519."); + } else { + JSG_FAIL_REQUIRE(DOMDataError, "JWK must have alg."); + } + KJ_IF_SOME(use, jwk.use) { + JSG_REQUIRE(keyUsages.size() == 0 || use == "enc", DOMDataError, + "MLKEM768-X25519 JWK use must be enc."); + } + KJ_IF_SOME(ops, jwk.key_ops) { + std::sort(ops.begin(), ops.end()); + JSG_REQUIRE(std::adjacent_find(ops.begin(), ops.end()) == ops.end(), DOMDataError, + "MLKEM768-X25519 JWK key_ops contains duplicates."); + for (const auto& usage: keyUsages) { + JSG_REQUIRE(std::binary_search(ops.begin(), ops.end(), usage), DOMDataError, + "MLKEM768-X25519 JWK key_ops does not contain ", usage, "."); + } + } + KJ_IF_SOME(ext, jwk.ext) { + JSG_REQUIRE(ext || !extractable, DOMDataError, + "MLKEM768-X25519 JWK ext is incompatible with extractability."); + } + KJ_IF_SOME(priv, jwk.priv) { + auto decoded = decodeBase64Url(kj::mv(priv)); + JSG_REQUIRE(!decoded.hadErrors && decoded.size() == XWING_PRIVATE_KEY_BYTES, DOMDataError, + "Invalid MLKEM768-X25519 JWK priv."); + auto key = JSG_REQUIRE_NONNULL( + HybridKemKey::fromSeed(kj::heapArray(decoded), extractable, usages), + DOMDataError, "Invalid MLKEM768-X25519 JWK priv."); + auto& pub = JSG_REQUIRE_NONNULL(jwk.pub, DOMDataError, "JWK private keys must have pub."); + auto expected = decodeBase64Url(kj::mv(pub)); + JSG_REQUIRE(!expected.hadErrors && key->publicKeyEquals(expected), DOMDataError, + "JWK pub does not match priv."); + return kj::mv(key); + } + auto& pub = JSG_REQUIRE_NONNULL(jwk.pub, DOMDataError, "JWK must have pub."); + auto decoded = decodeBase64Url(kj::mv(pub)); + JSG_REQUIRE(!decoded.hadErrors && decoded.size() == XWING_PUBLIC_KEY_BYTES, DOMDataError, + "Invalid MLKEM768-X25519 JWK pub."); + return kj::heap(kj::heapArray(decoded), extractable, usages); + } + if (format == "raw-public") { + auto usages = CryptoKeyUsageSet::validate( + normalizedName, CryptoKeyUsageSet::Context::importPublic, keyUsages, PUBLIC_USAGES); + auto& source = JSG_REQUIRE_NONNULL(keyData.tryGet>(), + DOMDataError, "Import data for raw-public must be a buffer."); + auto data = source.getHandle(js).asArrayPtr(); + JSG_REQUIRE(data.size() == XWING_PUBLIC_KEY_BYTES, DOMDataError, + "MLKEM768-X25519 public key must be 1216 bytes."); + return kj::heap(kj::heapArray(data), extractable, usages); + } + JSG_FAIL_REQUIRE( + DOMNotSupportedError, "Unsupported import format \"", format, "\" for MLKEM768-X25519."); +} + +} // namespace workerd::api diff --git a/src/workerd/api/crypto/impl.h b/src/workerd/api/crypto/impl.h index 8b983b81f6e..107f7b6ea8c 100644 --- a/src/workerd/api/crypto/impl.h +++ b/src/workerd/api/crypto/impl.h @@ -159,6 +159,7 @@ class CryptoKey::Impl { static ImportFunc importRsaRaw; static ImportFunc importMlDsa; static ImportFunc importMlKem; + static ImportFunc importHybridKem; using GenerateFunc = kj::OneOf, CryptoKeyPair>(jsg::Lock& js, kj::StringPtr normalizedName, @@ -175,6 +176,7 @@ class CryptoKey::Impl { static GenerateFunc generateEddsa; static GenerateFunc generateMlDsa; static GenerateFunc generateMlKem; + static GenerateFunc generateHybridKem; Impl(bool extractable, CryptoKeyUsageSet usages): extractable(extractable), usages(usages) {} diff --git a/src/workerd/api/tests/crypto-modern-algorithms-test.js b/src/workerd/api/tests/crypto-modern-algorithms-test.js index 824bdf9e0c3..bd22125a7e1 100644 --- a/src/workerd/api/tests/crypto-modern-algorithms-test.js +++ b/src/workerd/api/tests/crypto-modern-algorithms-test.js @@ -107,6 +107,59 @@ export const chacha20Poly1305 = { }, }; +export const hybridKemMlkem768X25519 = { + async test() { + const keyPair = await crypto.subtle.generateKey('MLKEM768-X25519', true, [ + 'encapsulateBits', + 'decapsulateBits', + ]); + const encapsulated = await crypto.subtle.encapsulateBits( + 'MLKEM768-X25519', + keyPair.publicKey + ); + const decapsulated = await crypto.subtle.decapsulateBits( + 'MLKEM768-X25519', + keyPair.privateKey, + encapsulated.ciphertext + ); + strictEqual( + new Uint8Array(encapsulated.sharedKey).toString(), + new Uint8Array(decapsulated).toString() + ); + + const derivedPublicKey = await crypto.subtle.getPublicKey( + keyPair.privateKey, + ['encapsulateBits'] + ); + const publicJwk = await crypto.subtle.exportKey('jwk', keyPair.publicKey); + const derivedPublicJwk = await crypto.subtle.exportKey( + 'jwk', + derivedPublicKey + ); + const privateJwk = await crypto.subtle.exportKey('jwk', keyPair.privateKey); + strictEqual(derivedPublicJwk.pub, publicJwk.pub); + await crypto.subtle.importKey('jwk', publicJwk, 'MLKEM768-X25519', true, [ + 'encapsulateBits', + ]); + await crypto.subtle.importKey('jwk', privateJwk, 'MLKEM768-X25519', true, [ + 'decapsulateBits', + ]); + await rejects( + crypto.subtle.importKey( + 'jwk', + { + ...privateJwk, + pub: `${privateJwk.pub[0] === 'A' ? 'B' : 'A'}${privateJwk.pub.slice(1)}`, + }, + 'MLKEM768-X25519', + true, + ['decapsulateBits'] + ), + { name: 'DataError' } + ); + }, +}; + export const mlDsaJwkKeyOpsValidation = { async test() { const keyPair = await crypto.subtle.generateKey('ML-DSA-44', true, [ @@ -203,6 +256,46 @@ export const mlDsaContextLength = { }, }; +export const hybridKemJwkMetadata = { + async test() { + const pair = await crypto.subtle.generateKey('MLKEM768-X25519', true, [ + 'encapsulateBits', + 'decapsulateBits', + ]); + for (const [key, usage] of [ + [pair.publicKey, 'encapsulateBits'], + [pair.privateKey, 'decapsulateBits'], + ]) { + const jwk = await crypto.subtle.exportKey('jwk', key); + for (const metadata of [ + { use: 'sig' }, + { key_ops: [] }, + { key_ops: [usage, usage] }, + { ext: false }, + ]) { + await rejects( + crypto.subtle.importKey( + 'jwk', + { ...jwk, ...metadata }, + 'MLKEM768-X25519', + true, + [usage] + ), + { name: 'DataError' } + ); + } + const imported = await crypto.subtle.importKey( + 'jwk', + { ...jwk, ext: false }, + 'MLKEM768-X25519', + false, + [usage] + ); + strictEqual(imported.extractable, false); + } + }, +}; + export const chacha20Poly1305Parameters = { async test() { const name = 'ChaCha20-Poly1305'; @@ -304,6 +397,91 @@ export const chacha20Poly1305Parameters = { }, }; +export const hybridKemRawFormatsAndSharedKeys = { + async test() { + const name = 'MLKEM768-X25519'; + const pair = await crypto.subtle.generateKey(name, true, [ + 'encapsulateKey', + 'decapsulateKey', + 'encapsulateBits', + 'decapsulateBits', + ]); + const seed = await crypto.subtle.exportKey('raw-seed', pair.privateKey); + const pub = await crypto.subtle.exportKey('raw-public', pair.publicKey); + strictEqual(seed.byteLength, 32); + strictEqual(pub.byteLength, 1216); + const privateKey = await crypto.subtle.importKey( + 'raw-seed', + seed, + name, + false, + ['decapsulateBits', 'decapsulateKey'] + ); + const publicKey = await crypto.subtle.importKey( + 'raw-public', + pub, + name, + true, + ['encapsulateBits', 'encapsulateKey'] + ); + for (const algorithm of [ + 'ChaCha20-Poly1305', + { name: 'AES-GCM', length: 256 }, + ]) { + strictEqual( + crypto.subtle.constructor.supports('encapsulateKey', name, algorithm), + true + ); + strictEqual( + crypto.subtle.constructor.supports('decapsulateKey', name, algorithm), + true + ); + const result = await crypto.subtle.encapsulateKey( + name, + publicKey, + algorithm, + true, + ['encrypt'] + ); + strictEqual(result.ciphertext.byteLength, 1120); + const shared = await crypto.subtle.decapsulateKey( + name, + privateKey, + result.ciphertext, + algorithm, + true, + ['encrypt'] + ); + strictEqual( + new Uint8Array( + await crypto.subtle.exportKey('raw-secret', shared) + ).toString(), + new Uint8Array( + await crypto.subtle.exportKey('raw-secret', result.sharedKey) + ).toString() + ); + } + for (const size of [0, 1119, 1121]) { + await rejects( + crypto.subtle.decapsulateBits(name, privateKey, new Uint8Array(size)), + { name: 'OperationError' } + ); + } + await rejects( + crypto.subtle.decapsulateBits(name, privateKey, new Uint8Array(1120)), + { name: 'OperationError' } + ); + for (const [format, data, usages] of [ + ['raw-public', new Uint8Array(1215), ['encapsulateBits']], + ['raw-seed', new Uint8Array(31), ['decapsulateBits']], + ]) { + await rejects(crypto.subtle.importKey(format, data, name, true, usages), { + name: 'DataError', + }); + } + }, +}; + export const chacha20Poly1305KeyEquality = { async test() { const data = new Uint8Array(32).fill(7); diff --git a/src/workerd/io/compatibility-date.capnp b/src/workerd/io/compatibility-date.capnp index ca7e13b22b4..038da104713 100644 --- a/src/workerd/io/compatibility-date.capnp +++ b/src/workerd/io/compatibility-date.capnp @@ -1683,7 +1683,7 @@ struct CompatibilityFlags @0x8f8c1b68151b6cef { $compatEnableFlag("webcrypto_modern_algorithms"); # Enables opt-in WebCrypto modern algorithm support. This currently exposes the subset of # the evolving WICG Modern Algorithms draft implemented by workerd: ML-KEM, ML-DSA, - # ChaCha20-Poly1305, related key encapsulation helpers, + # ChaCha20-Poly1305, MLKEM768-X25519, related key encapsulation helpers, # getPublicKey(), SubtleCrypto.supports(), and AKP JWK support. It is explicitly gated because # the draft is still changing and workerd does not implement the full proposal. The API may # change as the draft evolves. diff --git a/src/wpt/WebCryptoAPI-test.ts b/src/wpt/WebCryptoAPI-test.ts index 139c5d309e1..7309a23c04c 100644 --- a/src/wpt/WebCryptoAPI-test.ts +++ b/src/wpt/WebCryptoAPI-test.ts @@ -90,17 +90,14 @@ export default { 'digest/turboshake.tentative.https.any.js': unsupported('TurboSHAKE'), 'digest/xof_digest.js': {}, 'encap_decap/encap_decap_bits.tentative.https.any.js': { - comment: 'ML-KEM-512 and hybrid ML-KEM are not supported', - expectedFailures: [/ML-KEM-512/i, /^MLKEM(768|1024)-/], + comment: 'ML-KEM-512 and the P-256/P-384 hybrid KEMs are not supported', + expectedFailures: [/ML-KEM-512/i, /^MLKEM(768-P256|1024-P384) /], }, 'encap_decap/encap_decap_keys.tentative.https.any.js': { - comment: 'ML-KEM-512 and hybrid ML-KEM are not supported', - expectedFailures: [/ML-KEM-512/i, /^MLKEM(768|1024)-/], - }, - 'encap_decap/hybrid_kem_vectors.js': { - comment: 'Hybrid ML-KEM (post-quantum key encapsulation) is not supported', - omittedTests: true, + comment: 'ML-KEM-512 and the P-256/P-384 hybrid KEMs are not supported', + expectedFailures: [/ML-KEM-512/i, /^MLKEM(768-P256|1024-P384) /], }, + 'encap_decap/hybrid_kem_vectors.js': supportFile, 'encap_decap/ml_kem_vectors.js': supportFile, 'encrypt_decrypt/aes.js': supportFile, 'encrypt_decrypt/aes_cbc.https.any.js': {}, @@ -143,8 +140,10 @@ export default { 'generateKey/failures_Ed25519.https.any.js': {}, 'generateKey/failures_Ed448.tentative.https.any.js': unsupported('Ed448'), 'generateKey/failures_HMAC.https.any.js': {}, - 'generateKey/failures_Hybrid-KEM.tentative.https.any.js': - unsupported('Hybrid ML-KEM'), + 'generateKey/failures_Hybrid-KEM.tentative.https.any.js': { + comment: 'MLKEM768-P256 and MLKEM1024-P384 are not supported', + expectedFailures: [/MLKEM(768-P256|1024-P384)/i], + }, 'generateKey/failures_ML-DSA.tentative.https.any.js': {}, 'generateKey/failures_ML-KEM.tentative.https.any.js': { comment: 'ML-KEM-512 is not supported', @@ -173,8 +172,10 @@ export default { 'generateKey/successes_Ed25519.https.any.js': {}, 'generateKey/successes_Ed448.tentative.https.any.js': unsupported('Ed448'), 'generateKey/successes_HMAC.https.any.js': {}, - 'generateKey/successes_Hybrid-KEM.tentative.https.any.js': - unsupported('Hybrid ML-KEM'), + 'generateKey/successes_Hybrid-KEM.tentative.https.any.js': { + comment: 'MLKEM768-P256 and MLKEM1024-P384 are not supported', + expectedFailures: [/MLKEM(768-P256|1024-P384)/i], + }, 'generateKey/successes_ML-DSA.tentative.https.any.js': {}, 'generateKey/successes_ML-KEM.tentative.https.any.js': { comment: 'ML-KEM-512 is not supported', @@ -188,8 +189,9 @@ export default { 'generateKey/successes_chacha20_poly1305.tentative.https.any.js': {}, 'generateKey/successes_kmac.tentative.https.any.js': unsupported('KMAC'), 'getPublicKey.tentative.https.any.js': { - comment: 'Ed448, X448, ML-KEM-512, and hybrid ML-KEM are not supported', - expectedFailures: [/Ed448|X448|ML-KEM-512|MLKEM(768|1024)-/], + comment: + 'Ed448, X448, ML-KEM-512, and the P-256/P-384 hybrid KEMs are not supported', + expectedFailures: [/Ed448|X448|ML-KEM-512|MLKEM(768-P256|1024-P384)/], }, 'getRandomValues.any.js': {}, 'historical.any.js': { @@ -256,12 +258,11 @@ export default { 'import_export/Argon2_importKey.tentative.https.any.js': unsupported('Argon2'), 'import_export/ChaCha20-Poly1305_importKey.tentative.https.any.js': {}, - 'import_export/Hybrid-KEM_importKey.tentative.https.any.js': - unsupported('Hybrid ML-KEM'), - 'import_export/Hybrid-KEM_importKey_fixtures.js': { - comment: 'Hybrid ML-KEM (post-quantum key encapsulation) is not supported', - omittedTests: true, + 'import_export/Hybrid-KEM_importKey.tentative.https.any.js': { + comment: 'MLKEM768-P256 and MLKEM1024-P384 are not supported', + expectedFailures: [/MLKEM(768-P256|1024-P384)/i], }, + 'import_export/Hybrid-KEM_importKey_fixtures.js': supportFile, 'import_export/KMAC_importKey.tentative.https.any.js': unsupported('KMAC'), 'import_export/ML-DSA_importKey.tentative.https.any.js': {}, 'import_export/ML-DSA_importKey_fixtures.js': supportFile, @@ -412,8 +413,10 @@ export default { comment: 'CryptoKey does not support structured cloning', expectedFailures: true, }, - 'serialization/hybridkem.tentative.https.window.js': - unsupported('Hybrid ML-KEM'), + 'serialization/hybridkem.tentative.https.window.js': { + comment: 'CryptoKey does not support structured cloning', + expectedFailures: true, + }, 'serialization/kmac.tentative.https.any.js': unsupported('KMAC'), 'serialization/mldsa.tentative.https.any.js': { comment: 'CryptoKey does not support structured cloning', @@ -500,8 +503,8 @@ export default { 'sign_verify/rsa_pss_vectors.js': {}, 'sign_verify/signature.js': {}, 'supports-modern.tentative.https.any.js': { - comment: 'ML-KEM-512 and hybrid ML-KEM are not supported', - expectedFailures: [/ML-KEM-512|MLKEM(768|1024)-/], + comment: 'ML-KEM-512 and the P-256/P-384 hybrid KEMs are not supported', + expectedFailures: [/ML-KEM-512|MLKEM(768-P256|1024-P384)/], }, 'supports.tentative.https.any.js': { comment: From 860100632f7166714b469d01467362b2e72cfa99 Mon Sep 17 00:00:00 2001 From: Filip Skokan Date: Wed, 21 Oct 2026 16:43:47 +0200 Subject: [PATCH 3/3] feat(crypto): add SHA-3 digest support Expose SHA3-256, SHA3-384, and SHA3-512 through SubtleCrypto.digest() and SubtleCrypto.supports() using BoringSSL's public EVP APIs. Keep SHA-3 out of the shared hash lookup so keyed operations reject it. Gate the algorithms behind webcrypto_modern_algorithms to preserve the existing API surface. Enable SHA-3 WPT coverage and test both flag states and rejection by keyed operations. --- src/workerd/api/crypto/crypto.c++ | 16 ++- src/workerd/api/tests/BUILD.bazel | 5 + src/workerd/api/tests/crypto-sha3-test.js | 118 ++++++++++++++++++ .../api/tests/crypto-sha3-test.wd-test | 20 +++ src/workerd/io/compatibility-date.capnp | 2 +- src/wpt/WebCryptoAPI-test.ts | 2 +- 6 files changed, 157 insertions(+), 6 deletions(-) create mode 100644 src/workerd/api/tests/crypto-sha3-test.js create mode 100644 src/workerd/api/tests/crypto-sha3-test.wd-test diff --git a/src/workerd/api/crypto/crypto.c++ b/src/workerd/api/crypto/crypto.c++ index d27dd897098..061673510f8 100644 --- a/src/workerd/api/crypto/crypto.c++ +++ b/src/workerd/api/crypto/crypto.c++ @@ -104,8 +104,7 @@ namespace { // somewhere in CryptoKey, perhaps implicitly in the default implementations of the // encrypt/decrypt/sign/verify/etc. functions. // -// Note that SubtleCrypto.digest() is special. It is not a key-based operation and we only support -// one hash family, SHA, so its implementation is non-virtual. +// SubtleCrypto.digest() is not a key-based operation, so its implementation is non-virtual. // // NOTE(perf): The SubtleCrypto interface is asynchronous, but all of our implementations perform // the crypto synchronously before returning. In theory, we could be performing bulk crypto in a @@ -121,6 +120,15 @@ namespace { // ======================================================================================= // Registered algorithms +const EVP_MD* lookupSubtleDigestAlgorithm(kj::StringPtr name) { + if (Worker::Api::current().getFeatureFlags().getWebCryptoModernAlgorithms()) { + if (strcasecmp(name.cStr(), "SHA3-256") == 0) return EVP_sha3_256(); + if (strcasecmp(name.cStr(), "SHA3-384") == 0) return EVP_sha3_384(); + if (strcasecmp(name.cStr(), "SHA3-512") == 0) return EVP_sha3_512(); + } + return lookupDigestAlgorithm(name).second; +} + static kj::Maybe lookupAlgorithm(kj::StringPtr name) { static const std::set ALGORITHMS = { {"AES-CTR"_kj, &CryptoKey::Impl::importAes, &CryptoKey::Impl::generateAes}, @@ -759,7 +767,7 @@ jsg::Promise> SubtleCrypto::digest( auto checkErrorsOnFinish = webCryptoOperationBegin(__func__, algorithm); return js.evalNow([&] { - auto type = lookupDigestAlgorithm(algorithm.name).second; + auto type = lookupSubtleDigestAlgorithm(algorithm.name); auto digestCtx = kj::disposeWith(EVP_MD_CTX_new()); KJ_ASSERT(digestCtx.get() != nullptr); @@ -1207,7 +1215,7 @@ bool SubtleCrypto::supports(jsg::Lock& js, auto normalizedAlgorithm = normalizeSupportAlgorithm(js, algorithmValue, digestAlgorithmHandler); KJ_IF_SOME(exception, kj::runCatchingExceptions([&] { - lookupDigestAlgorithm(normalizedAlgorithm.name); + lookupSubtleDigestAlgorithm(normalizedAlgorithm.name); })) { (void)exception; return false; diff --git a/src/workerd/api/tests/BUILD.bazel b/src/workerd/api/tests/BUILD.bazel index 0c24145008d..73599106dec 100644 --- a/src/workerd/api/tests/BUILD.bazel +++ b/src/workerd/api/tests/BUILD.bazel @@ -701,6 +701,11 @@ wd_test( data = ["crypto-modern-algorithms-test.js"], ) +wd_test( + src = "crypto-sha3-test.wd-test", + data = ["crypto-sha3-test.js"], +) + wd_test( src = "resizable-arraybuffer-toctou-test.wd-test", args = ["--experimental"], diff --git a/src/workerd/api/tests/crypto-sha3-test.js b/src/workerd/api/tests/crypto-sha3-test.js new file mode 100644 index 00000000000..d670076a5f6 --- /dev/null +++ b/src/workerd/api/tests/crypto-sha3-test.js @@ -0,0 +1,118 @@ +// 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, strictEqual } from 'node:assert'; +import { Buffer } from 'node:buffer'; + +const vectors = [ + [ + 'SHA3-256', + 'a7ffc6f8bf1ed76651c14756a061d662f580ff4de43b49fa82d80a4b80f8434a', + '3a985da74fe225b2045c172d6bd390bd855f086e3e9d525b46bfe24511431532', + ], + [ + 'SHA3-384', + '0c63a75b845e4f7d01107d852e4c2485c51a50aaaa94fc61995e71bbee983a2ac3713831264adb47fb6bd1e058d5f004', + 'ec01498288516fc926459f58e2c6ad8df9b473cb0fc08c2596da7cf0e49be4b298d88cea927ac7f539f1edf228376d25', + ], + [ + 'SHA3-512', + 'a69f73cca23a9ac5c8b567dc185a756e97c982164fe25859e0d1dcc1475c80a615b2123af1f5f94c11e3e9402c3ac558f500199d95b6d3e301758586281dcd26', + 'b751850b1a57168a5693cd924b6b096e08f621827444f70d884f5d0240d2712e10e116e9192af3c91a7ec57647e3934057340b4cf408d5a56592f8274eec53f0', + ], +]; + +export const sha3Digest = { + async test(_controller, env) { + for (const [name, empty, abc] of vectors) { + for (const [input, expected] of [ + ['', empty], + ['abc', abc], + ]) { + for (const algorithm of [name, { name: name.toLowerCase() }]) { + const digest = crypto.subtle.digest( + algorithm, + new TextEncoder().encode(input) + ); + if (env.ENABLED) { + strictEqual(Buffer.from(await digest).toString('hex'), expected); + } else { + await rejects(digest, { name: 'NotSupportedError' }); + } + } + } + if (env.ENABLED) { + strictEqual(crypto.subtle.constructor.supports('digest', name), true); + strictEqual( + crypto.subtle.constructor.supports('digest', { + name: name.toLowerCase(), + }), + true + ); + strictEqual( + crypto.subtle.constructor.supports('generateKey', name), + false + ); + strictEqual( + crypto.subtle.constructor.supports('importKey', name), + false + ); + } + } + }, +}; + +export const sha3DigestOnly = { + async test(_controller, env) { + const data = new Uint8Array([1, 2, 3]); + const ec = await crypto.subtle.generateKey( + { name: 'ECDSA', namedCurve: 'P-256' }, + false, + ['sign'] + ); + for (const [hash] of vectors) { + const hmac = { name: 'HMAC', hash }; + const rsa = { + name: 'RSA-PSS', + hash, + modulusLength: 2048, + publicExponent: new Uint8Array([1, 0, 1]), + }; + for (const algorithm of [hmac, rsa]) { + await rejects(crypto.subtle.generateKey(algorithm, false, ['sign']), { + name: 'NotSupportedError', + }); + if (env.ENABLED) + strictEqual( + crypto.subtle.constructor.supports('generateKey', algorithm), + false + ); + } + await rejects( + crypto.subtle.importKey('raw', data, hmac, false, ['sign']), + { name: 'NotSupportedError' } + ); + const sign = { name: 'ECDSA', hash }; + await rejects(crypto.subtle.sign(sign, ec.privateKey, data), { + name: 'NotSupportedError', + }); + if (env.ENABLED) + strictEqual(crypto.subtle.constructor.supports('sign', sign), false); + for (const name of ['HKDF', 'PBKDF2']) { + const key = await crypto.subtle.importKey('raw', data, name, false, [ + 'deriveBits', + ]); + const algorithm = { name, hash, salt: data, info: data, iterations: 1 }; + await rejects(crypto.subtle.deriveBits(algorithm, key, 256), { + name: 'NotSupportedError', + }); + if (env.ENABLED) + strictEqual( + crypto.subtle.constructor.supports('deriveBits', algorithm, 256), + false + ); + } + } + }, +}; diff --git a/src/workerd/api/tests/crypto-sha3-test.wd-test b/src/workerd/api/tests/crypto-sha3-test.wd-test new file mode 100644 index 00000000000..1e6f4d52f35 --- /dev/null +++ b/src/workerd/api/tests/crypto-sha3-test.wd-test @@ -0,0 +1,20 @@ +using Workerd = import "/workerd/workerd.capnp"; + +const unitTests :Workerd.Config = ( + services = [ + ( name = "crypto-sha3-enabled-test", + worker = ( + modules = [(name = "worker", esModule = embed "crypto-sha3-test.js")], + compatibilityFlags = ["nodejs_compat", "webcrypto_modern_algorithms"], + bindings = [(name = "ENABLED", json = "true")], + ) + ), + ( name = "crypto-sha3-disabled-test", + worker = ( + modules = [(name = "worker", esModule = embed "crypto-sha3-test.js")], + compatibilityFlags = ["nodejs_compat"], + bindings = [(name = "ENABLED", json = "false")], + ) + ), + ], +); diff --git a/src/workerd/io/compatibility-date.capnp b/src/workerd/io/compatibility-date.capnp index 038da104713..bd2f3acb73a 100644 --- a/src/workerd/io/compatibility-date.capnp +++ b/src/workerd/io/compatibility-date.capnp @@ -1683,7 +1683,7 @@ struct CompatibilityFlags @0x8f8c1b68151b6cef { $compatEnableFlag("webcrypto_modern_algorithms"); # Enables opt-in WebCrypto modern algorithm support. This currently exposes the subset of # the evolving WICG Modern Algorithms draft implemented by workerd: ML-KEM, ML-DSA, - # ChaCha20-Poly1305, MLKEM768-X25519, related key encapsulation helpers, + # ChaCha20-Poly1305, MLKEM768-X25519, SHA-3 digests, related key encapsulation helpers, # getPublicKey(), SubtleCrypto.supports(), and AKP JWK support. It is explicitly gated because # the draft is still changing and workerd does not implement the full proposal. The API may # change as the draft evolves. diff --git a/src/wpt/WebCryptoAPI-test.ts b/src/wpt/WebCryptoAPI-test.ts index 7309a23c04c..e8b9b2dcf58 100644 --- a/src/wpt/WebCryptoAPI-test.ts +++ b/src/wpt/WebCryptoAPI-test.ts @@ -86,7 +86,7 @@ export default { 'digest/digest.js': {}, 'digest/digest_test_data.js': {}, 'digest/kangarootwelve.tentative.https.any.js': unsupported('KangarooTwelve'), - 'digest/sha3.tentative.https.any.js': unsupported('SHA3'), + 'digest/sha3.tentative.https.any.js': {}, 'digest/turboshake.tentative.https.any.js': unsupported('TurboSHAKE'), 'digest/xof_digest.js': {}, 'encap_decap/encap_decap_bits.tentative.https.any.js': {