diff --git a/bench/snippets/webcrypto-keys.mjs b/bench/snippets/webcrypto-keys.mjs new file mode 100644 index 000000000000..351aeedfc946 --- /dev/null +++ b/bench/snippets/webcrypto-keys.mjs @@ -0,0 +1,84 @@ +import { bench, group, run } from "../runner.mjs"; + +const { subtle } = crypto; + +const raw = new Uint8Array(32).fill(1); +const k = Buffer.from(raw).toString("base64url"); +const usages = ["encrypt", "decrypt"]; +const everyUsage = [ + ...usages, + "sign", + "verify", + "deriveKey", + "deriveBits", + "wrapKey", + "unwrapKey", + "encapsulateKey", + "encapsulateBits", + "decapsulateKey", + "decapsulateBits", +]; +const aesJwk = key_ops => ({ kty: "oct", k, key_ops }); +const aesJwks = [ + ["no key_ops", aesJwk(undefined)], + ["1 key_ops", aesJwk(["encrypt"])], + ["2 key_ops", aesJwk(usages)], + ["12 key_ops", aesJwk(everyUsage)], + ["2 key_ops and one that is not a KeyUsage", aesJwk([...usages, "urn:example:op"])], +]; +const hmac = { name: "HMAC", hash: "SHA-256" }; +const hmacJwk = { kty: "oct", k, key_ops: ["sign", "verify"] }; +const ecdsa = { name: "ECDSA", namedCurve: "P-256" }; +const ecdsaJwk = { + kty: "EC", + crv: "P-256", + x: "3DyR3pdz7BDZtoAVvm4Mn55Ashdl84LCJB9kcUI3IrQ", + y: "r6xDaTpWl7d958Y4HMq6OR4F8PLe1wl7m41I2ZX1yoU", + key_ops: ["verify"], +}; + +const aesKey = await subtle.importKey("raw", raw, "AES-GCM", true, usages); +const ecdsaKey = await subtle.importKey("jwk", ecdsaJwk, ecdsa, true, ["verify"]); +const wrapAlgorithm = { name: "AES-GCM", iv: new Uint8Array(12).fill(2) }; +const wrappingKey = await subtle.importKey("raw", raw, "AES-GCM", false, ["wrapKey", "unwrapKey"]); +const wrapped = await subtle.wrapKey("jwk", aesKey, wrappingKey, wrapAlgorithm); + +group("importKey", () => { + bench("importKey raw AES-GCM", async () => { + await subtle.importKey("raw", raw, "AES-GCM", true, usages); + }); + for (const [name, jwk] of aesJwks) { + bench(`importKey jwk AES-GCM, ${name}`, async () => { + await subtle.importKey("jwk", jwk, "AES-GCM", true, ["encrypt"]); + }); + } + bench("importKey jwk HMAC, 2 key_ops", async () => { + await subtle.importKey("jwk", hmacJwk, hmac, true, ["sign"]); + }); + bench("importKey jwk ECDSA public, 1 key_ops", async () => { + await subtle.importKey("jwk", ecdsaJwk, ecdsa, true, ["verify"]); + }); +}); + +group("unwrapKey", () => { + bench("unwrapKey jwk AES-GCM, 2 key_ops", async () => { + await subtle.unwrapKey("jwk", wrapped, wrappingKey, wrapAlgorithm, "AES-GCM", true, usages); + }); +}); + +group("exportKey", () => { + bench("exportKey jwk AES-GCM", async () => { + await subtle.exportKey("jwk", aesKey); + }); + bench("exportKey jwk ECDSA public", async () => { + await subtle.exportKey("jwk", ecdsaKey); + }); +}); + +group("generateKey", () => { + bench("generateKey AES-GCM 256", async () => { + await subtle.generateKey({ name: "AES-GCM", length: 256 }, true, usages); + }); +}); + +await run(); diff --git a/src/jsc/bindings/webcrypto/CryptoAlgorithmAKPShared.h b/src/jsc/bindings/webcrypto/CryptoAlgorithmAKPShared.h index b96e8af08708..de3f09b667a3 100644 --- a/src/jsc/bindings/webcrypto/CryptoAlgorithmAKPShared.h +++ b/src/jsc/bindings/webcrypto/CryptoAlgorithmAKPShared.h @@ -113,7 +113,7 @@ inline void importAkpKey(CryptoAlgorithmIdentifier identifier, const AkpAlgorith exceptionCallback(DataError, "Invalid JWK \"use\" Parameter"_s); return; } - if (hasDuplicateJwkKeyOps(jwk.key_ops)) { + if (hasDuplicateJwkKeyOps(jwk)) { exceptionCallback(DataError, "Duplicate key operation"_s); return; } diff --git a/src/jsc/bindings/webcrypto/CryptoAlgorithmChaCha20Poly1305.cpp b/src/jsc/bindings/webcrypto/CryptoAlgorithmChaCha20Poly1305.cpp index db71adc8cd5b..b7c77586dba1 100644 --- a/src/jsc/bindings/webcrypto/CryptoAlgorithmChaCha20Poly1305.cpp +++ b/src/jsc/bindings/webcrypto/CryptoAlgorithmChaCha20Poly1305.cpp @@ -158,7 +158,7 @@ void CryptoAlgorithmChaCha20Poly1305::importKey(CryptoKeyFormat format, KeyData& exceptionCallback(DataError, "Invalid JWK \"use\" Parameter"_s); return; } - if (hasDuplicateJwkKeyOps(jwk.key_ops)) { + if (hasDuplicateJwkKeyOps(jwk)) { exceptionCallback(DataError, "Duplicate key operation"_s); return; } @@ -215,7 +215,7 @@ void CryptoAlgorithmChaCha20Poly1305::exportKey(CryptoKeyFormat format, Ref CryptoKeyAKP::exportJwk() const JsonWebKey result; result.kty = "AKP"_s; result.alg = CryptoAlgorithmRegistry::singleton().name(algorithmIdentifier()); - result.key_ops = usages(); + result.key_ops = toJwkKeyOps(usagesBitmap()); result.ext = extractable(); if (type() == CryptoKeyType::Private) { diff --git a/src/jsc/bindings/webcrypto/CryptoKeyEC.cpp b/src/jsc/bindings/webcrypto/CryptoKeyEC.cpp index a4caf85a0459..c2da54170898 100644 --- a/src/jsc/bindings/webcrypto/CryptoKeyEC.cpp +++ b/src/jsc/bindings/webcrypto/CryptoKeyEC.cpp @@ -160,7 +160,7 @@ ExceptionOr CryptoKeyEC::exportJwk() const result.crv = P521; break; } - result.key_ops = usages(); + result.key_ops = toJwkKeyOps(usagesBitmap()); result.ext = extractable(); if (!platformAddFieldElements(result)) return Exception { OperationError }; diff --git a/src/jsc/bindings/webcrypto/CryptoKeyHMAC.cpp b/src/jsc/bindings/webcrypto/CryptoKeyHMAC.cpp index c45864e3ebfe..b5ccd0b529df 100644 --- a/src/jsc/bindings/webcrypto/CryptoKeyHMAC.cpp +++ b/src/jsc/bindings/webcrypto/CryptoKeyHMAC.cpp @@ -103,7 +103,7 @@ JsonWebKey CryptoKeyHMAC::exportJwk() const JsonWebKey result {}; result.kty = "oct"_s; result.k = Bun::base64URLEncodeToString(m_key); - result.key_ops = usages(); + result.key_ops = toJwkKeyOps(usagesBitmap()); result.ext = extractable(); return result; } diff --git a/src/jsc/bindings/webcrypto/CryptoKeyOKP.cpp b/src/jsc/bindings/webcrypto/CryptoKeyOKP.cpp index 054f77eb0fa6..653e4ab9ff27 100644 --- a/src/jsc/bindings/webcrypto/CryptoKeyOKP.cpp +++ b/src/jsc/bindings/webcrypto/CryptoKeyOKP.cpp @@ -219,7 +219,7 @@ ExceptionOr CryptoKeyOKP::exportJwk() const break; } - result.key_ops = usages(); + result.key_ops = toJwkKeyOps(usagesBitmap()); result.ext = extractable(); switch (type()) { diff --git a/src/jsc/bindings/webcrypto/CryptoKeyRSA.cpp b/src/jsc/bindings/webcrypto/CryptoKeyRSA.cpp index a97805343dbf..33f8afb41093 100644 --- a/src/jsc/bindings/webcrypto/CryptoKeyRSA.cpp +++ b/src/jsc/bindings/webcrypto/CryptoKeyRSA.cpp @@ -112,7 +112,7 @@ JsonWebKey CryptoKeyRSA::exportJwk() const { JsonWebKey result {}; result.kty = "RSA"_s; - result.key_ops = usages(); + result.key_ops = toJwkKeyOps(usagesBitmap()); result.ext = extractable(); auto rsaComponents = exportData(); diff --git a/src/jsc/bindings/webcrypto/JSCryptoKeyUsage.cpp b/src/jsc/bindings/webcrypto/JSCryptoKeyUsage.cpp index d6bb6dfb1ab5..5635d04403ba 100644 --- a/src/jsc/bindings/webcrypto/JSCryptoKeyUsage.cpp +++ b/src/jsc/bindings/webcrypto/JSCryptoKeyUsage.cpp @@ -71,7 +71,12 @@ template<> JSString* convertEnumerationToJS(JSGlobalObject& lexicalGlobalObject, template<> std::optional parseEnumeration(JSGlobalObject& lexicalGlobalObject, JSValue value) { - auto stringValue = value.toWTFString(&lexicalGlobalObject); + return parseEnumerationFromString(value.toWTFString(&lexicalGlobalObject)); +} + +// NEVER_INLINE: inlined into its two callers, this left the table scan as an out-of-line std::__find_if. +template<> NEVER_INLINE std::optional parseEnumerationFromString(const String& stringValue) +{ static constexpr SortedArrayMap enumerationMapping { std::to_array>({ { "decapsulateBits"_s, CryptoKeyUsage::DecapsulateBits }, { "decapsulateKey"_s, CryptoKeyUsage::DecapsulateKey }, diff --git a/src/jsc/bindings/webcrypto/JSCryptoKeyUsage.h b/src/jsc/bindings/webcrypto/JSCryptoKeyUsage.h index 84d87e30d40e..0c1131fc7d91 100644 --- a/src/jsc/bindings/webcrypto/JSCryptoKeyUsage.h +++ b/src/jsc/bindings/webcrypto/JSCryptoKeyUsage.h @@ -31,6 +31,7 @@ String convertEnumerationToString(CryptoKeyUsage); template<> JSC::JSString* convertEnumerationToJS(JSC::JSGlobalObject&, CryptoKeyUsage); template<> std::optional parseEnumeration(JSC::JSGlobalObject&, JSC::JSValue); +template<> std::optional parseEnumerationFromString(const String&); } // namespace WebCore diff --git a/src/jsc/bindings/webcrypto/JSJsonWebKey.cpp b/src/jsc/bindings/webcrypto/JSJsonWebKey.cpp index f5e9c8e187a2..7c9c0861e76e 100644 --- a/src/jsc/bindings/webcrypto/JSJsonWebKey.cpp +++ b/src/jsc/bindings/webcrypto/JSJsonWebKey.cpp @@ -24,9 +24,7 @@ #include "JSJsonWebKey.h" -#include "JSCryptoKeyUsage.h" #include "JSDOMConvertBoolean.h" -#include "JSDOMConvertEnumeration.h" #include "JSDOMConvertSequences.h" #include "JSDOMConvertStrings.h" #include "JSDOMGlobalObject.h" @@ -147,7 +145,7 @@ template<> JsonWebKey convertDictionary(JSGlobalObject& lexicalGloba RETURN_IF_EXCEPTION(throwScope, {}); } if (!key_opsValue.isUndefined()) { - result.key_ops = convert>>(lexicalGlobalObject, key_opsValue); + result.key_ops = convert>(lexicalGlobalObject, key_opsValue); RETURN_IF_EXCEPTION(throwScope, {}); } JSValue ktyValue; @@ -321,8 +319,8 @@ JSC::JSObject* convertDictionaryToJS(JSC::JSGlobalObject& lexicalGlobalObject, J RETURN_IF_EXCEPTION(throwScope, {}); Bun::putDirectNamed(vm, result, "k"_s, kValue); } - if (!IDLSequence>::isNullValue(dictionary.key_ops)) { - auto key_opsValue = toJS>>(lexicalGlobalObject, globalObject, throwScope, IDLSequence>::extractValueFromNullable(dictionary.key_ops)); + if (!IDLSequence::isNullValue(dictionary.key_ops)) { + auto key_opsValue = toJS>(lexicalGlobalObject, globalObject, throwScope, IDLSequence::extractValueFromNullable(dictionary.key_ops)); RETURN_IF_EXCEPTION(throwScope, {}); Bun::putDirectNamed(vm, result, "key_ops"_s, key_opsValue); } diff --git a/src/jsc/bindings/webcrypto/JSRsaOtherPrimesInfo.cpp b/src/jsc/bindings/webcrypto/JSRsaOtherPrimesInfo.cpp index 93aae82e67d7..0f7293e25e2a 100644 --- a/src/jsc/bindings/webcrypto/JSRsaOtherPrimesInfo.cpp +++ b/src/jsc/bindings/webcrypto/JSRsaOtherPrimesInfo.cpp @@ -55,9 +55,6 @@ template<> RsaOtherPrimesInfo convertDictionary(JSGlobalObje if (!dValue.isUndefined()) { result.d = convert(lexicalGlobalObject, dValue); RETURN_IF_EXCEPTION(throwScope, {}); - } else { - throwRequiredMemberTypeError(lexicalGlobalObject, throwScope, "d"_s, "RsaOtherPrimesInfo"_s, "DOMString"_s); - return {}; } JSValue rValue; if (isNullOrUndefined) @@ -69,9 +66,6 @@ template<> RsaOtherPrimesInfo convertDictionary(JSGlobalObje if (!rValue.isUndefined()) { result.r = convert(lexicalGlobalObject, rValue); RETURN_IF_EXCEPTION(throwScope, {}); - } else { - throwRequiredMemberTypeError(lexicalGlobalObject, throwScope, "r"_s, "RsaOtherPrimesInfo"_s, "DOMString"_s); - return {}; } JSValue tValue; if (isNullOrUndefined) @@ -83,9 +77,6 @@ template<> RsaOtherPrimesInfo convertDictionary(JSGlobalObje if (!tValue.isUndefined()) { result.t = convert(lexicalGlobalObject, tValue); RETURN_IF_EXCEPTION(throwScope, {}); - } else { - throwRequiredMemberTypeError(lexicalGlobalObject, throwScope, "t"_s, "RsaOtherPrimesInfo"_s, "DOMString"_s); - return {}; } return result; } diff --git a/src/jsc/bindings/webcrypto/JsonWebKey.h b/src/jsc/bindings/webcrypto/JsonWebKey.h index 342dbe85b88f..820e0eea6aef 100644 --- a/src/jsc/bindings/webcrypto/JsonWebKey.h +++ b/src/jsc/bindings/webcrypto/JsonWebKey.h @@ -27,6 +27,7 @@ #include "CryptoKeyUsage.h" #include "RsaOtherPrimesInfo.h" +#include #include #if ENABLE(WEB_CRYPTO) @@ -36,8 +37,9 @@ namespace WebCore { struct JsonWebKey { String kty; String use; - // FIXME: Consider merging key_ops and usages. - std::optional> key_ops; + // Any string is a valid entry (RFC 7517 section 4.3), so not WebKit's Vector. + std::optional> key_ops; + // The key_ops entries that name a KeyUsage, set by normalizeJsonWebKey(). CryptoKeyUsageBitmap usages; String alg; @@ -61,27 +63,26 @@ struct JsonWebKey { String priv; }; +// The key_ops of an exported key: its usages, in KeyUsage order. +Vector toJwkKeyOps(CryptoKeyUsageBitmap); + +bool hasRepeatedJwkKeyOp(const Vector&); + // Node's validateKeyOps rejects a repeated key_ops entry for every algorithm // before the operations/usages mismatch check. -inline bool hasDuplicateJwkKeyOps(const std::optional>& keyOps) +inline bool hasDuplicateJwkKeyOps(const JsonWebKey& jwk) { - if (!keyOps) + if (!jwk.key_ops) return false; - CryptoKeyUsageBitmap seenOps = 0; - for (auto op : *keyOps) { - // The binding enum order matches the bitmap bit order. - CryptoKeyUsageBitmap bit = 1 << static_cast(op); - if (seenOps & bit) - return true; - seenOps |= bit; - } - return false; + size_t distinctKeyUsageCount = std::popcount(static_cast(jwk.usages)); + bool everyEntryIsADistinctKeyUsage = jwk.key_ops->size() == distinctKeyUsageCount; + return !everyEntryIsADistinctKeyUsage && hasRepeatedJwkKeyOp(*jwk.key_ops); } // Whether `key_ops`, if the key has any, is well formed and covers `usages`. inline bool jwkKeyOpsAllow(const JsonWebKey& jwk, CryptoKeyUsageBitmap usages) { - return !jwk.key_ops || (!hasDuplicateJwkKeyOps(jwk.key_ops) && (jwk.usages & usages) == usages); + return !jwk.key_ops || (!hasDuplicateJwkKeyOps(jwk) && (jwk.usages & usages) == usages); } } // namespace WebCore diff --git a/src/jsc/bindings/webcrypto/JsonWebKey.idl b/src/jsc/bindings/webcrypto/JsonWebKey.idl index 525e9aaa507a..f267809a0a08 100644 --- a/src/jsc/bindings/webcrypto/JsonWebKey.idl +++ b/src/jsc/bindings/webcrypto/JsonWebKey.idl @@ -30,7 +30,7 @@ // The following fields are defined in Section 3.1 of JSON Web Key DOMString kty; DOMString use; - sequence key_ops; + sequence key_ops; DOMString alg; // The following fields are defined in JSON Web Key Parameters Registration diff --git a/src/jsc/bindings/webcrypto/RsaOtherPrimesInfo.idl b/src/jsc/bindings/webcrypto/RsaOtherPrimesInfo.idl index 9b1f30e2b8c9..953424b9757a 100644 --- a/src/jsc/bindings/webcrypto/RsaOtherPrimesInfo.idl +++ b/src/jsc/bindings/webcrypto/RsaOtherPrimesInfo.idl @@ -28,7 +28,7 @@ JSGenerateToJSObject, ] dictionary RsaOtherPrimesInfo { // The following fields are defined in Section 6.3.2.7 of JSON Web Algorithms - required DOMString r; - required DOMString d; - required DOMString t; + DOMString r; + DOMString d; + DOMString t; }; diff --git a/src/jsc/bindings/webcrypto/SubtleCrypto.cpp b/src/jsc/bindings/webcrypto/SubtleCrypto.cpp index 318aec31784c..4091162d174e 100644 --- a/src/jsc/bindings/webcrypto/SubtleCrypto.cpp +++ b/src/jsc/bindings/webcrypto/SubtleCrypto.cpp @@ -40,6 +40,7 @@ #include "JSCryptoAlgorithmParameters.h" #include "JSCryptoKey.h" #include "JSCryptoKeyPair.h" +#include "JSCryptoKeyUsage.h" #include "JSDOMPromiseDeferred.h" #include "JSDOMWrapper.h" #include "JSEcKeyParams.h" @@ -589,8 +590,35 @@ static void rejectWithException(Ref&& passedPromise, ExceptionC static void normalizeJsonWebKey(JsonWebKey& webKey) { - // Maybe we shouldn't silently bypass duplicated usages? - webKey.usages = webKey.key_ops ? SubtleCrypto::toCryptoKeyUsageBitmap(webKey.key_ops.value()) : 0; + webKey.usages = 0; + if (!webKey.key_ops) + return; + for (auto& op : *webKey.key_ops) { + if (auto usage = parseEnumerationFromString(op)) + webKey.usages |= WebCore::toCryptoKeyUsageBitmap(*usage); + } +} + +bool hasRepeatedJwkKeyOp(const Vector& keyOps) +{ + HashSet seenOps; + for (auto& op : keyOps) { + if (!seenOps.add(op).isNewEntry) + return true; + } + return false; +} + +Vector toJwkKeyOps(CryptoKeyUsageBitmap usages) +{ + Vector keyOps; + keyOps.reserveInitialCapacity(std::popcount(static_cast(usages))); + // The binding enum order matches the bitmap bit order. + for (int bit = 0; usages >> bit; ++bit) { + if (usages & (1 << bit)) + keyOps.append(convertEnumerationToString(static_cast(bit))); + } + return keyOps; } // FIXME: This returns an std::optional and takes a promise, rather than returning an @@ -629,7 +657,7 @@ static std::optional toKeyData(SubtleCrypto::KeyFormat format, SubtleCr WTF::makeVisitor( [](JsonWebKey& webKey) -> std::optional { normalizeJsonWebKey(webKey); - return KeyData { webKey }; + return KeyData { WTF::move(webKey) }; }, [&promise](auto&) -> std::optional { promise->reject(Exception { TypeError }); @@ -1264,7 +1292,7 @@ void SubtleCrypto::importKey(JSC::JSGlobalObject& state, KeyFormat format, KeyDa return; } - auto keyData = *keyDataOrNull; + auto keyData = WTF::move(*keyDataOrNull); auto keyUsagesBitmap = toCryptoKeyUsageBitmap(keyUsages); if (!aliasImportKeyFormat(params->identifier, format, promise.get())) @@ -1590,7 +1618,7 @@ void SubtleCrypto::unwrapKey(JSC::JSGlobalObject& state, KeyFormat format, Buffe } normalizeJsonWebKey(jwk); - keyData = jwk; + keyData = WTF::move(jwk); break; } } diff --git a/test/js/node/test/parallel/test-webcrypto-key-ops-duplicates.js b/test/js/node/test/parallel/test-webcrypto-key-ops-duplicates.js new file mode 100644 index 000000000000..4a666a1caa47 --- /dev/null +++ b/test/js/node/test/parallel/test-webcrypto-key-ops-duplicates.js @@ -0,0 +1,20 @@ +'use strict'; + +const common = require('../common'); +if (!common.hasCrypto) + common.skip('missing crypto'); + +const assert = require('assert'); +const { subtle } = globalThis.crypto; + +(async () => { + const algorithm = { name: 'HMAC', hash: 'SHA-256' }; + const jwk = { kty: 'oct', k: 'AAAAAAAAAAAAAAAAAAAAAA' }; + await subtle.importKey('jwk', { ...jwk, key_ops: ['sign', 'future'] }, + algorithm, false, ['sign']); + for (const key_ops of [['sign', 'sign'], ['sign', 'future', 'future']]) { + await assert.rejects(subtle.importKey('jwk', { ...jwk, key_ops }, + algorithm, false, ['sign']), + { name: 'DataError' }); + } +})().then(common.mustCall()); diff --git a/test/js/web/crypto/web-crypto.test.ts b/test/js/web/crypto/web-crypto.test.ts index c469528f324f..2fc58571d20a 100644 --- a/test/js/web/crypto/web-crypto.test.ts +++ b/test/js/web/crypto/web-crypto.test.ts @@ -160,12 +160,12 @@ describe("Web Crypto", () => { }); // Previously this promise never settled: the error thrown during - // JsonWebKey dictionary conversion (here an invalid key_ops enum entry) - // escaped as an uncaught exception and the DeferredPromise was left in - // m_pendingPromises forever. kty is optional now, so an invalid enum is - // what still reaches the conversion-exception branch. + // JsonWebKey dictionary conversion escaped as an uncaught exception and + // the DeferredPromise was left in m_pendingPromises forever. kty is + // optional and a key_ops entry can be any string, so a key_ops that is + // not a sequence is what still reaches the conversion-exception branch. it("settles when JsonWebKey dictionary conversion itself throws", async () => { - const { key, iv, wrapped } = await setup(new TextEncoder().encode(JSON.stringify({ key_ops: ["bogus"] }))); + const { key, iv, wrapped } = await setup(new TextEncoder().encode(JSON.stringify({ key_ops: "sign" }))); const err = await crypto.subtle .unwrapKey("jwk", wrapped, key, { name: "AES-GCM", iv }, { name: "AES-GCM" }, true, ["encrypt", "decrypt"]) .then( @@ -173,7 +173,7 @@ describe("Web Crypto", () => { e => e, ); expect(err).toBeInstanceOf(TypeError); - expect(err.message).toBe("value must be enumeration (string)"); + expect(err.message).toBe("Value is not a sequence"); }); it("does not leak DeferredPromise in m_pendingPromises on JWK parse errors", async () => { @@ -189,13 +189,13 @@ describe("Web Crypto", () => { const key = await crypto.subtle.importKey("raw", keyData, { name: "AES-GCM" }, false, [ "encrypt", "decrypt", "wrapKey", "unwrapKey", ]); - // key_ops with an invalid enum entry throws during dictionary + // A key_ops that is not a sequence throws during dictionary // conversion, which is the leaky branch this fixture guards (a - // merely-unusable JWK now converts fine and rejects downstream). + // merely-unusable JWK converts fine and rejects downstream). const wrapped = await crypto.subtle.encrypt( { name: "AES-GCM", iv }, key, - new TextEncoder().encode(JSON.stringify({ key_ops: ["bogus"] })), + new TextEncoder().encode(JSON.stringify({ key_ops: "sign" })), ); async function once() { await crypto.subtle @@ -1654,6 +1654,11 @@ describe("JWK import validation", () => { () => "resolved", e => e.name, ); + const rsaPublic = { + kty: "RSA", + n: "ylJkPUUI1WdwPhfxioPyUeFOVdmpIAMNID4uCvhtnqdH3P-yDl4V9Qvcjvb4SnB1eHOWKpXhpkA1uD3gmmebqvmRJB242EbTaf89mLr6BxFvSDUBwHxMzYgewnXFUJiA85rOUrVL9mDZcwwVvpCT3836iq4_RfDIuQEKOi5OntU", + e: "AQAB", + }; it.each([ ["Ed25519", "sign", "K1wIouqnuiA04b3WrMa-xKIKIpfHetNZRv3h9fBf768", "wVK6M3SMhQh3NK-7GRrSV-BVWQx1FO5pW8hhQeu_NdA"], @@ -1749,6 +1754,128 @@ describe("JWK import validation", () => { } }); + // JsonWebKey.key_ops is sequence, and RFC 7517 section 4.3 lets a JWK name operations that are not a + // KeyUsage. One row for each place that checks key_ops: CryptoKey{AES,RSA,EC}::importJwk, the two branches of + // CryptoKeyOKP::importJwk, and the HMAC, ChaCha20-Poly1305 and ML-DSA importers. Each row also exports its key, + // because every key type builds the key_ops that it exports from the usages. + it("takes a key_ops entry that is not a KeyUsage, and rejects a repeated one, for every key type", async () => { + const oct = { kty: "oct", k: Buffer.alloc(32, 1).toString("base64url") }; + const mlDsa = (await crypto.subtle.generateKey("ML-DSA-44", true, ["sign", "verify"])) as CryptoKeyPair; + const cases: [any, JsonWebKey, KeyUsage[]][] = [ + ["AES-GCM", oct, ["encrypt"]], + [{ name: "RSA-OAEP", hash: "SHA-256" }, rsaPublic, ["encrypt"]], + [ + { name: "ECDSA", namedCurve: "P-256" }, + { + kty: "EC", + crv: "P-256", + x: "3DyR3pdz7BDZtoAVvm4Mn55Ashdl84LCJB9kcUI3IrQ", + y: "r6xDaTpWl7d958Y4HMq6OR4F8PLe1wl7m41I2ZX1yoU", + }, + ["verify"], + ], + ["Ed25519", { kty: "OKP", crv: "Ed25519", x: "S89QWE7a6JwjIPX04rf9qxlJ9vQxs5bSW1NxjR03mls" }, ["verify"]], + ["X25519", { kty: "OKP", crv: "X25519", x: "hSDwCYkwp1R0i33ctD73Wg2_Og0mOBr06uFD1q1y5Go" }, []], + [{ name: "HMAC", hash: "SHA-256" }, oct, ["sign"]], + ["ChaCha20-Poly1305", oct, ["encrypt", "decrypt", "wrapKey", "unwrapKey"]], + ["ML-DSA-44", await crypto.subtle.exportKey("jwk", mlDsa.publicKey), ["verify"]], + ]; + const results: unknown[] = []; + for (const [algorithm, jwk, usages] of cases) { + const importWith = (key_ops: string[]) => + crypto.subtle.importKey("jwk", { ...jwk, key_ops }, algorithm, true, usages); + const usagesWith = (key_ops: string[]) => + importWith(key_ops).then( + key => key.usages, + e => e.name, + ); + results.push({ + algorithm: algorithm.name ?? algorithm, + foreign: await usagesWith([...usages, "urn:example:op"]), + exported: await importWith([...usages, "urn:example:op"]).then( + async key => (await crypto.subtle.exportKey("jwk", key)).key_ops, + e => e.name, + ), + foreignTwice: await usagesWith([...usages, "urn:example:op", "urn:example:op"]), + foreignAlone: await usagesWith(["urn:example:op"]), + }); + } + expect(results).toEqual( + cases.map(([algorithm, , usages]) => ({ + algorithm: algorithm.name ?? algorithm, + foreign: usages, + exported: usages, + foreignTwice: "DataError", + // key_ops must hold each requested usage. X25519 asks for none. + foreignAlone: usages.length ? "DataError" : [], + })), + ); + }); + + it("converts the key_ops entries to strings", async () => { + const importWith = (key_ops: unknown) => + crypto.subtle + .importKey( + "jwk", + { kty: "oct", k: Buffer.alloc(16).toString("base64url"), key_ops } as JsonWebKey, + "AES-GCM", + true, + ["encrypt"], + ) + .then( + key => key.usages, + e => e.name, + ); + expect({ + number: await importWith([1, "encrypt"]), + null: await importWith(["encrypt", null]), + // 1 and "1" are the same string. + repeatedAfterConversion: await importWith(["encrypt", 1, "1"]), + notASequence: await importWith("encrypt"), + }).toEqual({ + number: ["encrypt"], + null: ["encrypt"], + repeatedAfterConversion: "DataError", + notASequence: "TypeError", + }); + }); + + // RsaOtherPrimesInfo has no required member, so an entry with no r, d or t converts and the key type decides. + it("converts an oth entry that has no r, d or t", async () => { + const importWith = (algorithm: any, jwk: JsonWebKey, usage: KeyUsage) => + rejection(crypto.subtle.importKey("jwk", { ...jwk, oth: [{}] }, algorithm, true, [usage])); + expect({ + aes: await importWith("AES-GCM", { kty: "oct", k: Buffer.alloc(16).toString("base64url") }, "encrypt"), + rsa: await importWith({ name: "RSA-OAEP", hash: "SHA-256" }, rsaPublic, "encrypt"), + }).toEqual({ aes: "resolved", rsa: "DataError" }); + }); + + it("unwrapKey takes such key_ops too, and exportKey gives the usages alone", async () => { + const iv = new Uint8Array(12).fill(2); + const wrappingKey = await crypto.subtle.importKey("raw", new Uint8Array(32).fill(1), "AES-GCM", false, [ + "encrypt", + "unwrapKey", + ]); + const jwk = { kty: "oct", k: Buffer.alloc(32, 1).toString("base64url") }; + const unwrap = async (key_ops: unknown) => { + const wrapped = await crypto.subtle.encrypt( + { name: "AES-GCM", iv }, + wrappingKey, + new TextEncoder().encode(JSON.stringify({ ...jwk, key_ops })), + ); + return crypto.subtle.unwrapKey("jwk", wrapped, wrappingKey, { name: "AES-GCM", iv }, "AES-GCM", true, [ + "decrypt", + "encrypt", + ]); + }; + const key = await unwrap(["decrypt", "JOSE-only-op", 7, "encrypt"]); + expect({ + usages: key.usages, + exported: (await crypto.subtle.exportKey("jwk", key)).key_ops, + repeated: await rejection(unwrap(["decrypt", "encrypt", "JOSE-only-op", "JOSE-only-op"])), + }).toEqual({ usages: ["encrypt", "decrypt"], exported: ["encrypt", "decrypt"], repeated: "DataError" }); + }); + it("reports the modulus length in bits", async () => { const n = Buffer.alloc(256); n[0] = 0x40;