diff --git a/lib/internal/crypto/aes.js b/lib/internal/crypto/aes.js index 7ce1dabbf7c..3280ddff845 100644 --- a/lib/internal/crypto/aes.js +++ b/lib/internal/crypto/aes.js @@ -24,6 +24,7 @@ const { const { getUsagesMask, jobPromise, + getBufferSourceByteLength, } = require('internal/crypto/util'); const { @@ -218,7 +219,7 @@ function aesImportKey( if (format === 'raw' && name === 'AES-OCB') { return undefined; } - length = keyData.byteLength * 8; + length = getBufferSourceByteLength(keyData) * 8; validateKeyLength(length); handle = importSecretKey(keyData); break; diff --git a/lib/internal/crypto/cfrg.js b/lib/internal/crypto/cfrg.js index 994b8f510c4..9eea26aa36a 100644 --- a/lib/internal/crypto/cfrg.js +++ b/lib/internal/crypto/cfrg.js @@ -1,7 +1,6 @@ 'use strict'; const { - SafeSet, StringPrototypeToLowerCase, TypedArrayPrototypeGetBuffer, } = primordials; @@ -27,6 +26,7 @@ const { const { getUsagesMask, jobPromise, + toUsagesSet, } = require('internal/crypto/util'); const { @@ -124,7 +124,7 @@ function cfrgImportKey( const { name } = algorithm; let handle; const allowedUsages = kUsages[name]; - const usagesSet = new SafeSet(usages); + const usagesSet = toUsagesSet(usages); switch (format) { case 'KeyObjectHandle': verifyAcceptableKeyUse( diff --git a/lib/internal/crypto/diffiehellman.js b/lib/internal/crypto/diffiehellman.js index edf429fc028..5bf9d511542 100644 --- a/lib/internal/crypto/diffiehellman.js +++ b/lib/internal/crypto/diffiehellman.js @@ -1,6 +1,7 @@ 'use strict'; const { + ArrayBufferPrototypeGetByteLength, ArrayBufferPrototypeSlice, FunctionPrototypeCall, ObjectDefineProperty, @@ -373,7 +374,7 @@ function ecdhDeriveBits(algorithm, baseKey, length) { return jobPromiseThen(bits, (bits) => { const sliceLength = numBitsToBytes(length); - const { byteLength } = bits; + const byteLength = ArrayBufferPrototypeGetByteLength(bits); // If the length is larger than the derived secret, throw. if (byteLength < sliceLength) throw lazyDOMException('derived bit length is too small', 'OperationError'); diff --git a/lib/internal/crypto/ec.js b/lib/internal/crypto/ec.js index fc19e7cce6a..cbd89dd11c7 100644 --- a/lib/internal/crypto/ec.js +++ b/lib/internal/crypto/ec.js @@ -1,7 +1,6 @@ 'use strict'; const { - SafeSet, TypedArrayPrototypeGetBuffer, TypedArrayPrototypeGetByteLength, } = primordials; @@ -33,6 +32,7 @@ const { jobPromise, normalizeHashName, kNamedCurveAliases, + toUsagesSet, } = require('internal/crypto/util'); const { @@ -142,7 +142,7 @@ function ecImportKey( let handle; const allowedUsages = kUsages[name]; - const usagesSet = new SafeSet(usages); + const usagesSet = toUsagesSet(usages); switch (format) { case 'KeyObjectHandle': verifyAcceptableKeyUse( @@ -215,7 +215,8 @@ function ecImportKey( throw lazyDOMException('Invalid keyData', 'DataError'); } - if (kNamedCurveAliases[namedCurve] !== handle.keyDetail({}).namedCurve) + if (kNamedCurveAliases[namedCurve] !== + handle.keyDetail({ __proto__: null }).namedCurve) throw lazyDOMException('Named curve mismatch', 'DataError'); return new InternalCryptoKey( diff --git a/lib/internal/crypto/hash.js b/lib/internal/crypto/hash.js index 16834f169a5..2445a3f7488 100644 --- a/lib/internal/crypto/hash.js +++ b/lib/internal/crypto/hash.js @@ -30,6 +30,7 @@ const { kHandle, getCachedHashId, getHashCache, + getOptionalByteLength, } = require('internal/crypto/util'); const { @@ -217,8 +218,8 @@ function asyncDigest(algorithm, data) { // Fall through case 'cSHAKE256': { const outputLength = algorithm.outputLength; - if (algorithm.functionName?.byteLength || - algorithm.customization?.byteLength) { + if (getOptionalByteLength(algorithm.functionName) || + getOptionalByteLength(algorithm.customization)) { if (CShakeJob === undefined) { throw lazyDOMException( 'Non-empty CShakeParams functionName or customization is not supported', diff --git a/lib/internal/crypto/hashnames.js b/lib/internal/crypto/hashnames.js index 7a625c47e2f..c39d696a801 100644 --- a/lib/internal/crypto/hashnames.js +++ b/lib/internal/crypto/hashnames.js @@ -2,6 +2,7 @@ const { ObjectKeys, + ObjectSetPrototypeOf, } = primordials; const kHashContextNode = 1; @@ -71,15 +72,22 @@ const kHashNames = { }, }; +// Both tables are indexed with computed keys, so a polluted %Object.prototype% +// key must not answer a miss. Detached here rather than declared +// `__proto__: null`: V8 puts that literal form in dictionary mode. +ObjectSetPrototypeOf(kHashNames, null); + { // Index the aliases const keys = ObjectKeys(kHashNames); for (let n = 0; n < keys.length; n++) { - const contexts = ObjectKeys(kHashNames[keys[n]]); + const entry = kHashNames[keys[n]]; + ObjectSetPrototypeOf(entry, null); + const contexts = ObjectKeys(entry); for (let i = 0; i < contexts.length; i++) { - const alias = kHashNames[keys[n]][contexts[i]]; + const alias = entry[contexts[i]]; if (kHashNames[alias] === undefined) - kHashNames[alias] = kHashNames[keys[n]]; + kHashNames[alias] = entry; } } } diff --git a/lib/internal/crypto/keys.js b/lib/internal/crypto/keys.js index 45d7cd53501..327a6352d5c 100644 --- a/lib/internal/crypto/keys.js +++ b/lib/internal/crypto/keys.js @@ -5,7 +5,6 @@ const { ObjectDefineProperties, ObjectPrototypeHasOwnProperty, ObjectSetPrototypeOf, - SafeSet, StringPrototypeIncludes, StringPrototypeStartsWith, SymbolToStringTag, @@ -68,6 +67,7 @@ const { getUsagesMask, getUsagesFromMask, hasUsage, + toUsagesSet, } = require('internal/crypto/util'); const { @@ -1306,7 +1306,7 @@ function importGenericSecretKey( extractable, keyUsages, ) { - const usagesSet = new SafeSet(keyUsages); + const usagesSet = toUsagesSet(keyUsages); const { name } = algorithm; if (extractable) throw lazyDOMException(`${name} keys are not extractable`, 'SyntaxError'); diff --git a/lib/internal/crypto/ml_dsa.js b/lib/internal/crypto/ml_dsa.js index 857961ff6ef..0756198312c 100644 --- a/lib/internal/crypto/ml_dsa.js +++ b/lib/internal/crypto/ml_dsa.js @@ -1,7 +1,6 @@ 'use strict'; const { - SafeSet, StringPrototypeToLowerCase, TypedArrayPrototypeGetBuffer, TypedArrayPrototypeGetByteLength, @@ -28,6 +27,8 @@ const { const { getUsagesMask, jobPromise, + toUsagesSet, + getBufferSourceByteLength, } = require('internal/crypto/util'); const { @@ -122,7 +123,7 @@ function mlDsaImportKey( const { name } = algorithm; let handle; - const usagesSet = new SafeSet(usages); + const usagesSet = toUsagesSet(usages); switch (format) { case 'KeyObjectHandle': verifyAcceptableKeyUse( @@ -147,7 +148,7 @@ function mlDsaImportKey( 'ML-DSA-65': 4060, 'ML-DSA-87': 4924, }; - if (keyData.byteLength === privOnlyLengths[name]) { + if (getBufferSourceByteLength(keyData) === privOnlyLengths[name]) { throw lazyDOMException( 'Importing an ML-DSA PKCS#8 key without a seed is not supported', 'NotSupportedError'); diff --git a/lib/internal/crypto/ml_kem.js b/lib/internal/crypto/ml_kem.js index f18dcd13db7..c917c88c0f2 100644 --- a/lib/internal/crypto/ml_kem.js +++ b/lib/internal/crypto/ml_kem.js @@ -1,7 +1,6 @@ 'use strict'; const { - SafeSet, StringPrototypeToLowerCase, TypedArrayPrototypeGetBuffer, TypedArrayPrototypeGetByteLength, @@ -27,6 +26,8 @@ const { const { getUsagesMask, jobPromise, + toUsagesSet, + getBufferSourceByteLength, } = require('internal/crypto/util'); const { @@ -123,7 +124,7 @@ function mlKemImportKey( const { name } = algorithm; let handle; - const usagesSet = new SafeSet(usages); + const usagesSet = toUsagesSet(usages); switch (format) { case 'KeyObjectHandle': verifyAcceptableKeyUse( @@ -148,7 +149,7 @@ function mlKemImportKey( 'ML-KEM-768': 2428, 'ML-KEM-1024': 3196, }; - if (keyData.byteLength === privOnlyLengths[name]) { + if (getBufferSourceByteLength(keyData) === privOnlyLengths[name]) { throw lazyDOMException( 'Importing an ML-KEM PKCS#8 key without a seed is not supported', 'NotSupportedError'); diff --git a/lib/internal/crypto/rsa.js b/lib/internal/crypto/rsa.js index a2757384f4f..d153ab664bd 100644 --- a/lib/internal/crypto/rsa.js +++ b/lib/internal/crypto/rsa.js @@ -2,7 +2,6 @@ const { MathCeil, - SafeSet, TypedArrayPrototypeGetBuffer, Uint8Array, } = primordials; @@ -35,6 +34,7 @@ const { jobPromise, normalizeHashName, validateMaxBufferLength, + toUsagesSet, } = require('internal/crypto/util'); const { @@ -174,7 +174,7 @@ function rsaImportKey( extractable, usages) { const allowedUsages = kUsages[algorithm.name]; - const usagesSet = new SafeSet(usages); + const usagesSet = toUsagesSet(usages); let handle; switch (format) { case 'KeyObjectHandle': @@ -234,7 +234,7 @@ function rsaImportKey( const { modulusLength, publicExponent, - } = handle.keyDetail({}); + } = handle.keyDetail({ __proto__: null }); return new InternalCryptoKey(handle, { name: algorithm.name, diff --git a/lib/internal/crypto/util.js b/lib/internal/crypto/util.js index 68981c6dfe0..e33960e898a 100644 --- a/lib/internal/crypto/util.js +++ b/lib/internal/crypto/util.js @@ -15,15 +15,18 @@ const { ObjectEntries, ObjectKeys, ObjectPrototypeHasOwnProperty, + ObjectSetPrototypeOf, PromisePrototypeThen, PromiseReject, PromiseWithResolvers, SafeMap, + SafeSet, StringPrototypeToUpperCase, Symbol, TypedArrayPrototypeGetBuffer, TypedArrayPrototypeGetByteLength, TypedArrayPrototypeGetByteOffset, + TypedArrayPrototypeGetLength, TypedArrayPrototypeSlice, Uint8Array, } = primordials; @@ -467,17 +470,23 @@ const experimentalAlgorithms = [ // Also builds a parallel Map per operation // for O(1) case-insensitive algorithm name lookup in normalizeAlgorithm. function createSupportedAlgorithms(algorithmDefs) { + // Detached below rather than declared `__proto__: null`: V8 puts that + // literal form in dictionary mode, slowing every registry lookup. const result = {}; const nameMap = {}; - for (const { 0: algorithmName, 1: operations } of ObjectEntries(algorithmDefs)) { + const algorithmEntries = ObjectEntries(algorithmDefs); + for (let i = 0; i < algorithmEntries.length; i++) { + const { 0: algorithmName, 1: operations } = algorithmEntries[i]; // Skip algorithms that are conditionally not supported if (ObjectPrototypeHasOwnProperty(conditionalAlgorithms, algorithmName) && !conditionalAlgorithms[algorithmName]) { continue; } - for (const { 0: operation, 1: dict } of ObjectEntries(operations)) { + const operationEntries = ObjectEntries(operations); + for (let j = 0; j < operationEntries.length; j++) { + const { 0: operation, 1: dict } = operationEntries[j]; result[operation] ||= {}; nameMap[operation] ||= new SafeMap(); nameMap[operation].set(StringPrototypeToUpperCase(algorithmName), algorithmName); @@ -498,6 +507,13 @@ function createSupportedAlgorithms(algorithmDefs) { } } + const operations = ObjectKeys(result); + for (let i = 0; i < operations.length; i++) { + ObjectSetPrototypeOf(result[operations[i]], null); + } + ObjectSetPrototypeOf(result, null); + ObjectSetPrototypeOf(nameMap, null); + return { algorithms: result, nameMap }; } @@ -546,12 +562,17 @@ const simpleAlgorithmDictionaries = { // Pre-compute ObjectKeys() for each dictionary entry at module init // to avoid allocating a new keys array on every normalizeAlgorithm call. -for (const { 0: name, 1: types } of ObjectEntries(simpleAlgorithmDictionaries)) { +const simpleAlgorithmDictionaryEntries = + ObjectEntries(simpleAlgorithmDictionaries); +for (let i = 0; i < simpleAlgorithmDictionaryEntries.length; i++) { + const { 0: name, 1: types } = simpleAlgorithmDictionaryEntries[i]; simpleAlgorithmDictionaries[name] = { keys: ObjectKeys(types), types }; } +// See createSupportedAlgorithms() for why this is detached here. +ObjectSetPrototypeOf(simpleAlgorithmDictionaries, null); function validateMaxBufferLength(data, name, max = kMaxBufferLength) { - if (data.byteLength > max) { + if (getBufferSourceByteLength(data) > max) { throw lazyDOMException( `${name} must be at most ${max} bytes`, 'OperationError'); @@ -656,20 +677,8 @@ function normalizeAlgorithm(algorithm, op) { const idlValue = normalizedAlgorithm[member]; // 3. if (idlType === 'BufferSource' && idlValue) { - const isView = ArrayBufferIsView(idlValue); - const idlValueBytes = isView ? - new Uint8Array( - getDataViewOrTypedArrayBuffer(idlValue), - getDataViewOrTypedArrayByteOffset(idlValue), - getDataViewOrTypedArrayByteLength(idlValue), - ) : - new Uint8Array( - idlValue, - 0, - ArrayBufferPrototypeGetByteLength(idlValue), - ); normalizedAlgorithm[member] = TypedArrayPrototypeSlice( - idlValueBytes, + getBufferSourceBytes(idlValue), ); } else if (idlType === 'HashAlgorithmIdentifier') { normalizedAlgorithm[member] = normalizeAlgorithm(idlValue, 'digest'); @@ -698,6 +707,26 @@ function getDataViewOrTypedArrayByteLength(V) { DataViewPrototypeGetByteLength(V) : TypedArrayPrototypeGetByteLength(V); } +function getBufferSourceByteLength(V) { + return ArrayBufferIsView(V) ? + getDataViewOrTypedArrayByteLength(V) : + ArrayBufferPrototypeGetByteLength(V); +} + +function getBufferSourceBytes(V) { + return ArrayBufferIsView(V) ? + new Uint8Array( + getDataViewOrTypedArrayBuffer(V), + getDataViewOrTypedArrayByteOffset(V), + getDataViewOrTypedArrayByteLength(V), + ) : + new Uint8Array(V, 0, ArrayBufferPrototypeGetByteLength(V)); +} + +function getOptionalByteLength(V) { + return V === undefined ? 0 : TypedArrayPrototypeGetByteLength(V); +} + function hasAnyNotIn(set, checks) { for (const s of set) if (!ArrayPrototypeIncludes(checks, s)) @@ -856,9 +885,10 @@ function jobPromiseThen(promise, onFulfilled, onRejected) { // Returns undefined if the conversion was unsuccessful. function bigIntArrayToUnsignedInt(input) { let result = 0; + const length = TypedArrayPrototypeGetLength(input); - for (let n = 0; n < input.length; ++n) { - const n_reversed = input.length - n - 1; + for (let n = 0; n < length; ++n) { + const n_reversed = length - n - 1; if (n_reversed >= 4 && input[n]) return; // Too large result |= input[n] << 8 * n_reversed; @@ -869,9 +899,10 @@ function bigIntArrayToUnsignedInt(input) { function bigIntArrayToUnsignedBigInt(input) { let result = 0n; + const length = TypedArrayPrototypeGetLength(input); - for (let n = 0; n < input.length; ++n) { - const n_reversed = input.length - n - 1; + for (let n = 0; n < length; ++n) { + const n_reversed = length - n - 1; result |= BigInt(input[n]) << 8n * BigInt(n_reversed); } @@ -911,6 +942,19 @@ for (let n = 0; n < kCanonicalUsageOrder.length; n++) { kUsageByMask[mask] = usage; } +/** + * Collects a key usage list into a set. + * @param {string[]} usages + * @returns {SafeSet} + */ +function toUsagesSet(usages) { + const usagesSet = new SafeSet(); + for (let n = 0; n < usages.length; n++) { + usagesSet.add(usages[n]); + } + return usagesSet; +} + /** * Returns a bit mask representing the usages from `usageSet`. * @param {SafeSet} usageSet @@ -1039,10 +1083,13 @@ function secureHeapUsed() { module.exports = { getArrayBufferOrView, + getBufferSourceByteLength, + getBufferSourceBytes, getCiphers, getCurves, getDataViewOrTypedArrayBuffer, getHashes, + getOptionalByteLength, emitOpenSSLEngineDeprecation, kHandle, setEngine, @@ -1069,6 +1116,7 @@ module.exports = { getStringOption, getUsagesMask, getUsagesFromMask, + toUsagesSet, hasUsage, secureHeapUsed, getCachedHashId, diff --git a/lib/internal/crypto/webcrypto.js b/lib/internal/crypto/webcrypto.js index 4326ae3a68d..277a0d7a1fb 100644 --- a/lib/internal/crypto/webcrypto.js +++ b/lib/internal/crypto/webcrypto.js @@ -69,6 +69,7 @@ const { numBitsToBytes, prepareWebCryptoResult, validateMaxBufferLength, + getOptionalByteLength, } = require('internal/crypto/util'); const { @@ -130,9 +131,17 @@ function prepareSubtleMethod(receiver, method, argLength, required) { } function convertSubtleArgument(prefix, converter, value, index) { + // Mirrors makeOptions() in internal/webidl, including why it stays an + // ordinary literal: the converters read every member below, and an absent + // one would resolve through %Object.prototype%. return webidl.converters[converter](value, { prefix, context: kArgumentContexts[index], + code: undefined, + enforceRange: undefined, + clamp: undefined, + allowShared: undefined, + allowResizable: undefined, }); } @@ -1798,8 +1807,8 @@ function check(op, alg, length) { case 'digest': { if ((normalizedAlgorithm.name === 'cSHAKE128' || normalizedAlgorithm.name === 'cSHAKE256') && - (normalizedAlgorithm.functionName?.byteLength || - normalizedAlgorithm.customization?.byteLength)) { + (getOptionalByteLength(normalizedAlgorithm.functionName) || + getOptionalByteLength(normalizedAlgorithm.customization))) { return CShakeJob !== undefined; } return true; diff --git a/lib/internal/crypto/webcrypto_util.js b/lib/internal/crypto/webcrypto_util.js index 1b802a6dc46..065520c59cb 100644 --- a/lib/internal/crypto/webcrypto_util.js +++ b/lib/internal/crypto/webcrypto_util.js @@ -1,7 +1,7 @@ 'use strict'; const { - ArrayPrototypePush, + ArrayPrototypePushApply, SafeSet, } = primordials; @@ -19,6 +19,7 @@ const { const { hasAnyNotIn, validateKeyOps, + toUsagesSet, } = require('internal/crypto/util'); const { @@ -60,7 +61,7 @@ function verifyAcceptableKeyUse(subject, usagesSet, allowed) { * @returns {SafeSet} */ function validateKeyUsages(usages, allowed, subject) { - const usagesSet = new SafeSet(usages); + const usagesSet = toUsagesSet(usages); verifyAcceptableKeyUse(subject, usagesSet, allowed); return usagesSet; } @@ -115,12 +116,8 @@ function getKeyPairUsages(usagesSet, allowed) { */ function createKeyUsages(publicUsages, privateUsages) { const keygen = []; - for (let n = 0; n < publicUsages.length; n++) { - ArrayPrototypePush(keygen, publicUsages[n]); - } - for (let n = 0; n < privateUsages.length; n++) { - ArrayPrototypePush(keygen, privateUsages[n]); - } + ArrayPrototypePushApply(keygen, publicUsages); + ArrayPrototypePushApply(keygen, privateUsages); return { __proto__: null, public: publicUsages, diff --git a/lib/internal/crypto/webidl.js b/lib/internal/crypto/webidl.js index d0d6036a1ab..cab4a4c7631 100644 --- a/lib/internal/crypto/webidl.js +++ b/lib/internal/crypto/webidl.js @@ -1,15 +1,15 @@ 'use strict'; const { - ArrayBufferIsView, ArrayPrototypeIncludes, MathPow, NumberParseInt, ObjectPrototypeHasOwnProperty, StringPrototypeCharCodeAt, + StringPrototypeSplit, StringPrototypeStartsWith, StringPrototypeToLowerCase, - Uint8Array, + TypedArrayPrototypeGetLength, } = primordials; const { @@ -25,6 +25,8 @@ const { } = require('internal/crypto/keys'); const { validateMaxBufferLength, + getBufferSourceByteLength, + getBufferSourceBytes, kNamedCurveAliases, numBitsToBytes, } = require('internal/crypto/util'); @@ -39,7 +41,7 @@ const { } = require('internal/webidl'); function validateByteLength(buf, name, target) { - if (buf.byteLength !== target) { + if (getBufferSourceByteLength(buf) !== target) { throw lazyDOMException( `${name} must contain exactly ${target} bytes`, 'OperationError'); @@ -125,6 +127,9 @@ function enforceRangeOptions(opts) { context: opts.context, code: opts.code, enforceRange: true, + clamp: undefined, + allowShared: undefined, + allowResizable: undefined, }; } @@ -232,7 +237,7 @@ converters.AesKeyGenParams = createDictionaryConverter( function validateZeroLength(parameterName) { return (V, dict) => { - if (V.byteLength) { + if (getBufferSourceByteLength(V)) { throw lazyDOMException( `Non zero-length ${parameterName} is not supported.`, 'NotSupportedError'); } @@ -248,19 +253,18 @@ function validateCShakeOutputLength(V) { } function bufferSourceEqualsAscii(V, string) { - if (V.byteLength !== string.length) return false; + if (getBufferSourceByteLength(V) !== string.length) return false; - const bytes = ArrayBufferIsView(V) ? - new Uint8Array(V.buffer, V.byteOffset, V.byteLength) : - new Uint8Array(V); - for (let i = 0; i < bytes.length; i++) { + const bytes = getBufferSourceBytes(V); + const length = TypedArrayPrototypeGetLength(bytes); + for (let i = 0; i < length; i++) { if (bytes[i] !== StringPrototypeCharCodeAt(string, i)) return false; } return true; } function validateCShakeFunctionName(V) { - if (V.byteLength === 0 || + if (getBufferSourceByteLength(V) === 0 || bufferSourceEqualsAscii(V, 'KMAC') || bufferSourceEqualsAscii(V, 'TupleHash') || bufferSourceEqualsAscii(V, 'ParallelHash')) { @@ -316,7 +320,12 @@ function validateHmacKeyLength(parameterName, zeroError) { }; } -for (const { 0: name, 1: zeroError } of [['HmacKeyGenParams', 'OperationError'], ['HmacImportParams', 'DataError']]) { +const kHmacDictionaries = [ + ['HmacKeyGenParams', 'OperationError'], + ['HmacImportParams', 'DataError'], +]; +for (let i = 0; i < kHmacDictionaries.length; i++) { + const { 0: name, 1: zeroError } = kHmacDictionaries[i]; converters[name] = createDictionaryConverter( name, [ dictAlgorithm, @@ -500,7 +509,7 @@ converters.AeadParams = createDictionaryConverter( validateMaxBufferLength(V, 'algorithm.iv'); break; case 'aes-ocb': - if (V.byteLength > 15) { + if (getBufferSourceByteLength(V) > 15) { throw lazyDOMException( 'AES-OCB algorithm.iv must be no more than 15 bytes', 'OperationError'); @@ -609,7 +618,8 @@ converters.ContextParams = createDictionaryConverter( if (process.features.openssl_is_boringssl) { this.validator = undefined; } else { - let { 0: major, 1: minor } = process.versions.openssl.split('.'); + let { 0: major, 1: minor } = + StringPrototypeSplit(process.versions.openssl, '.'); major = NumberParseInt(major, 10); minor = NumberParseInt(minor, 10); if (major > 3 || (major === 3 && minor >= 2)) { @@ -632,7 +642,7 @@ converters.Argon2Params = createDictionaryConverter( key: 'nonce', converter: converters.BufferSource, validator: (V) => { - if (V.byteLength < 8) { + if (getBufferSourceByteLength(V) < 8) { throw lazyDOMException('nonce must be at least 8 bytes', 'OperationError'); } }, @@ -698,7 +708,9 @@ converters.Argon2Params = createDictionaryConverter( ], ]); -for (const name of ['KmacKeyGenParams', 'KmacImportParams']) { +const kKmacDictionaries = ['KmacKeyGenParams', 'KmacImportParams']; +for (let i = 0; i < kKmacDictionaries.length; i++) { + const name = kKmacDictionaries[i]; converters[name] = createDictionaryConverter( name, [ dictAlgorithm, diff --git a/lib/internal/webidl.js b/lib/internal/webidl.js index 13575d4f730..7f61bae8c21 100644 --- a/lib/internal/webidl.js +++ b/lib/internal/webidl.js @@ -20,6 +20,7 @@ const { NumberIsNaN, NumberMAX_SAFE_INTEGER, NumberMIN_SAFE_INTEGER, + ObjectPrototypeHasOwnProperty, ObjectPrototypeIsPrototypeOf, SafeArrayIterator, SafeSet, @@ -699,16 +700,47 @@ function createDictionaryConverter( const dictionaries = ArrayIsArray(members[0]) ? members : [members]; const sortedDictionaries = []; + function ownMember(member, key) { + return ObjectPrototypeHasOwnProperty(member, key) ? member[key] : undefined; + } + + // Dictionaries with no defaults and no required members skip steps + // 4.1.5/4.1.6 entirely, keeping the absent-member path free. + let anyMissingMemberHandling = false; + // Web IDL dictionary conversion steps 3-4 process inherited dictionaries // from least-derived to most-derived and sort only within each dictionary. // Callers with inheritance pass one member array per dictionary level. for (let i = 0; i < dictionaries.length; i++) { - ArrayPrototypePush( - sortedDictionaries, - ArrayPrototypeToSorted(dictionaries[i], compareMembers), + const sortedMembers = ArrayPrototypeToSorted( + dictionaries[i], + compareMembers, ); + // Definition sites spell out only the members they need, so reading the + // optional ones below would resolve through %Object.prototype%. + // Re-materialize each descriptor once with every key present, copied from + // own properties only. The ordinary prototype is deliberate: nothing + // consults it now, and detaching it measurably slows these reads down. + for (let j = 0; j < sortedMembers.length; j++) { + const member = sortedMembers[j]; + const defaultValue = ownMember(member, 'defaultValue'); + const required = ownMember(member, 'required'); + if (typeof defaultValue === 'function' || required) { + anyMissingMemberHandling = true; + } + sortedMembers[j] = { + key: ownMember(member, 'key'), + converter: ownMember(member, 'converter'), + defaultValue, + required, + validator: ownMember(member, 'validator'), + }; + } + ArrayPrototypePush(sortedDictionaries, sortedMembers); } + const hasMissingMemberHandling = anyMissingMemberHandling; + return function(jsDict, options = kEmptyObject) { // Step 1: reject non-object, non-null, non-undefined values. if (jsDict != null && type(jsDict) !== 'Object') { @@ -747,14 +779,16 @@ function createDictionaryConverter( member.validator?.(idlMemberValue, jsDict); // Step 4.1.4.2: set idlDict[key] to the IDL value. idlDict[key] = idlMemberValue; - } else if (typeof member.defaultValue === 'function') { - // Step 4.1.5: store the member default value. - idlDict[key] = member.defaultValue(); - } else if (member.required) { - // Step 4.1.6: required missing members throw. - throw makeException( - missingDictionaryMemberMessage(dictionaryName, key), - makeOptions(options, options.context, 'ERR_MISSING_OPTION')); + } else if (hasMissingMemberHandling) { + if (typeof member.defaultValue === 'function') { + // Step 4.1.5: store the member default value. + idlDict[key] = member.defaultValue(); + } else if (member.required) { + // Step 4.1.6: required missing members throw. + throw makeException( + missingDictionaryMemberMessage(dictionaryName, key), + makeOptions(options, options.context, 'ERR_MISSING_OPTION')); + } } } } diff --git a/test/parallel/test-webcrypto-prototype-pollution.mjs b/test/parallel/test-webcrypto-prototype-pollution.mjs new file mode 100644 index 00000000000..8bb93cff0ae --- /dev/null +++ b/test/parallel/test-webcrypto-prototype-pollution.mjs @@ -0,0 +1,470 @@ +// Flags: --expose-internals + +import * as common from '../common/index.mjs'; +import assert from 'node:assert'; +import { createRequire } from 'node:module'; + +if (!common.hasCrypto) common.skip('missing crypto'); + +// Regression tests for prototype pollution reaching WebCrypto input validation +// and normalization, via BufferSource prototype accessors, inherited +// %Object.prototype% keys, or %Array.prototype%[%Symbol.iterator%]. See +// test-webcrypto-promise-prototype-pollution.mjs for the promise side. + +const require = createRequire(import.meta.url); +const { kSupportedAlgorithms } = require('internal/crypto/util'); +const { getFips } = require('node:crypto'); +const { hasOpenSSL } = require('../common/crypto'); +const { subtle } = globalThis.crypto; + +const TypedArrayPrototype = Object.getPrototypeOf(Uint8Array.prototype); +const data = new TextEncoder().encode('prototype pollution'); +const modulusLength = getFips() === 1 ? 2048 : 1024; + +// Avoids SubtleCrypto.supports(), which warns and invokes the registry's +// experimental-algorithm getters. +function supports(operation, name) { + return Object.hasOwn(kSupportedAlgorithms[operation] ?? {}, name); +} + +// Each poison is { target, key, ...descriptor }. +async function withPoisoned(poisons, fn) { + const saved = []; + for (const { target, key, ...descriptor } of poisons) { + saved.push([target, key, Object.getOwnPropertyDescriptor(target, key)]); + Object.defineProperty(target, key, { + __proto__: null, + configurable: true, + ...descriptor, + }); + } + try { + return await fn(); + } finally { + for (let i = saved.length - 1; i >= 0; i--) { + const { 0: target, 1: key, 2: descriptor } = saved[i]; + if (descriptor === undefined) { + delete target[key]; + } else { + Object.defineProperty(target, key, descriptor); + } + } + } +} + +function poisonTypedArrayByteLength(value) { + return [{ target: TypedArrayPrototype, key: 'byteLength', get: () => value }]; +} + +function inherited(key, value) { + return [{ target: Object.prototype, key, value, writable: true }]; +} + +const poisonArrayIterator = [{ + target: Array.prototype, + key: Symbol.iterator, + value: () => ({ next: () => ({ done: true, value: undefined }) }), + writable: true, +}]; + +// A poisoned array iterator breaks assert too, so settle under the poison and +// assert once it has been restored. +async function settleUnderPoison(poisons, fn) { + const outcome = { __proto__: null, value: undefined, error: undefined }; + await withPoisoned(poisons, async () => { + try { + outcome.value = await fn(); + } catch (err) { + outcome.error = err; + } + }); + return outcome; +} + +// validateByteLength(). Unguarded, the empty iv reaches OpenSSL, which also +// fails with OperationError, hence the message assertion. +{ + const key = await subtle.importKey( + 'raw-secret', new Uint8Array(16), 'AES-CBC', false, ['encrypt']); + await withPoisoned(poisonTypedArrayByteLength(16), common.mustCall(() => + assert.rejects( + subtle.encrypt({ name: 'AES-CBC', iv: new Uint8Array(0) }, key, data), + { + name: 'OperationError', + message: /algorithm\.iv must contain exactly 16 bytes/, + }))); +} + +// validateMaxBufferLength(). +{ + const key = await subtle.importKey( + 'raw-secret', new Uint8Array(32), 'HKDF', false, ['deriveBits']); + await withPoisoned(poisonTypedArrayByteLength(0), common.mustCall(() => + assert.rejects( + subtle.deriveBits({ + name: 'HKDF', + hash: 'SHA-256', + salt: new Uint8Array(0), + info: new Uint8Array(4096), + }, key, 8), + { + name: 'OperationError', + message: /algorithm\.info must be at most 1024 bytes/, + }))); +} + +// aesImportKey(). +await withPoisoned(poisonTypedArrayByteLength(16), common.mustCall(async () => { + const key = await subtle.importKey( + 'raw-secret', new Uint8Array(32), 'AES-GCM', true, ['encrypt']); + assert.strictEqual(key.algorithm.length, 256); +})); + +// validateCShakeFunctionName(). +if (supports('digest', 'cSHAKE128')) { + await withPoisoned(poisonTypedArrayByteLength(0), common.mustCall(() => + assert.rejects( + subtle.digest({ + name: 'cSHAKE128', + outputLength: 256, + functionName: new Uint8Array([0x41, 0x42, 0x43, 0x44]), + }, data), + { + name: 'NotSupportedError', + message: /Unsupported CShakeParams functionName/, + }))); + + // asyncDigest() picks the cSHAKE job over plain SHAKE on a non-empty + // customization. + if (hasOpenSSL(3)) { + const algorithm = { + name: 'cSHAKE128', + outputLength: 256, + customization: new Uint8Array([1, 2, 3]), + }; + const expected = new Uint8Array(await subtle.digest(algorithm, data)); + const plain = new Uint8Array( + await subtle.digest({ name: 'cSHAKE128', outputLength: 256 }, data)); + assert.notDeepStrictEqual(expected, plain); + await withPoisoned(poisonTypedArrayByteLength(0), + common.mustCall(async () => { + assert.deepStrictEqual( + new Uint8Array(await subtle.digest(algorithm, data)), + expected); + })); + } +} + +// AeadParams: AES-OCB caps the iv at 15 bytes. +if (supports('encrypt', 'AES-OCB')) { + const key = await subtle.importKey( + 'raw-secret', new Uint8Array(16), 'AES-OCB', false, ['encrypt']); + await withPoisoned(poisonTypedArrayByteLength(12), common.mustCall(() => + assert.rejects( + subtle.encrypt({ name: 'AES-OCB', iv: new Uint8Array(20) }, key, data), + { + name: 'OperationError', + message: /algorithm\.iv must be no more than 15 bytes/, + }))); +} + +// Argon2Params: the nonce has an 8 byte minimum. +if (supports('deriveBits', 'Argon2id')) { + const key = await subtle.importKey( + 'raw-secret', new Uint8Array(32), 'Argon2id', false, ['deriveBits']); + await withPoisoned(poisonTypedArrayByteLength(16), common.mustCall(() => + assert.rejects( + subtle.deriveBits({ + name: 'Argon2id', + nonce: new Uint8Array(4), + memory: 32, + passes: 1, + parallelism: 1, + }, key, 256), + { + name: 'OperationError', + message: /nonce must be at least 8 bytes/, + }))); +} + +// bigIntArrayToUnsignedInt(): TypedArray `length` is a prototype accessor. +await withPoisoned( + [{ target: TypedArrayPrototype, key: 'length', get: () => 0 }], + common.mustCall(async () => { + const { publicKey } = await subtle.generateKey({ + name: 'RSA-OAEP', + modulusLength, + publicExponent: new Uint8Array([1, 0, 1]), + hash: 'SHA-256', + }, true, ['encrypt', 'decrypt']); + assert.strictEqual(publicKey.algorithm.modulusLength, modulusLength); + assert.deepStrictEqual( + publicKey.algorithm.publicExponent, new Uint8Array([1, 0, 1])); + })); + +// ecdhDeriveBits() bounds the request by the native job's ArrayBuffer. +{ + const { privateKey, publicKey } = await subtle.generateKey( + { name: 'ECDH', namedCurve: 'P-256' }, false, ['deriveBits']); + await withPoisoned( + [{ target: ArrayBuffer.prototype, key: 'byteLength', get: () => 1e9 }], + common.mustCall(() => assert.rejects( + subtle.deriveBits({ name: 'ECDH', public: publicKey }, privateKey, 8192), + { name: 'OperationError' }))); +} + +// simpleAlgorithmDictionaries relies on a miss returning undefined. +{ + const { privateKey, publicKey } = await subtle.generateKey( + { name: 'ECDH', namedCurve: 'P-256' }, false, ['deriveBits']); + await withPoisoned( + inherited('EcdhKeyDeriveParams', + { keys: ['public'], types: { public: 'BufferSource' } }), + common.mustCall(async () => { + const bits = await subtle.deriveBits( + { name: 'ECDH', public: publicKey }, privateKey, 128); + assert.strictEqual(bits.byteLength, 16); + })); + + await withPoisoned( + inherited('AesKeyGenParams', + { keys: ['name'], types: { name: 'AlgorithmIdentifier' } }), + common.mustCall(async () => { + const key = await subtle.generateKey( + { name: 'AES-GCM', length: 128 }, false, ['encrypt']); + assert.strictEqual(key.algorithm.length, 128); + })); +} + +// createDictionaryConverter() reads optional member descriptor keys. +{ + const key = await subtle.generateKey( + { name: 'AES-GCM', length: 128 }, false, ['encrypt']); + const encrypt = () => subtle.encrypt( + { name: 'AES-GCM', iv: new Uint8Array(12) }, key, data); + + for (const poison of [ + inherited('required', true), + inherited('defaultValue', () => 9999), + inherited('validator', common.mustNotCall('Object.prototype.validator')), + ]) { + await withPoisoned(poison, common.mustCall(async () => { + assert.strictEqual((await encrypt()).byteLength, data.byteLength + 16); + })); + } +} + +// Conversion options are read by key by the Web IDL converters. +{ + const key = await subtle.importKey( + 'raw-secret', new Uint8Array(32), 'HKDF', false, ['deriveBits']); + const hkdf = (length) => subtle.deriveBits({ + name: 'HKDF', + hash: 'SHA-256', + salt: new Uint8Array(0), + info: new Uint8Array(0), + }, key, length); + + // [EnforceRange] and [Clamp] are not set for a plain `unsigned long`, so + // 2 ** 32 wraps to 0 rather than throwing or clamping. + for (const attribute of ['enforceRange', 'clamp']) { + await withPoisoned( + inherited(attribute, true), + common.mustCall(async () => { + assert.strictEqual((await hkdf(2 ** 32)).byteLength, 0); + })); + } + + // [AllowResizable] is not set for BufferSource. + await withPoisoned(inherited('allowResizable', true), common.mustCall(() => + assert.rejects( + subtle.digest('SHA-256', new ArrayBuffer(8, { maxByteLength: 1024 })), + { name: 'TypeError' }))); + + // makeException() falls back to ERR_INVALID_ARG_TYPE. + await withPoisoned(inherited('code', 'ERR_POLLUTED'), common.mustCall(() => + assert.rejects(subtle.digest('SHA-256', 'not a BufferSource'), + { code: 'ERR_INVALID_ARG_TYPE' }))); +} + +// enforceRangeOptions(): [EnforceRange] uses IntegerPart, not round-half-even. +{ + const key = await subtle.importKey( + 'raw-secret', new Uint8Array(4), 'PBKDF2', false, ['deriveBits']); + const pbkdf2 = (iterations) => subtle.deriveBits({ + name: 'PBKDF2', + hash: 'SHA-256', + salt: new Uint8Array(16), + iterations, + }, key, 8); + + const expected = new Uint8Array(await pbkdf2(1)); + await withPoisoned(inherited('clamp', true), common.mustCall(async () => { + assert.deepStrictEqual(new Uint8Array(await pbkdf2(1.5)), expected); + })); +} + +// keyDetail() is filled in by C++ with an ordinary [[Set]]. +{ + const { publicKey } = await subtle.generateKey({ + name: 'RSA-PSS', + modulusLength, + publicExponent: new Uint8Array([1, 0, 1]), + hash: 'SHA-256', + }, true, ['sign', 'verify']); + const spki = await subtle.exportKey('spki', publicKey); + + await withPoisoned([ + { + target: Object.prototype, key: 'modulusLength', + get: () => 8192, set() {}, + }, + { + target: Object.prototype, key: 'publicExponent', + get: () => new Uint8Array([9, 9, 9]), set() {}, + }, + ], common.mustCall(async () => { + const imported = await subtle.importKey( + 'spki', spki, { name: 'RSA-PSS', hash: 'SHA-256' }, true, ['verify']); + assert.strictEqual(imported.algorithm.modulusLength, modulusLength); + assert.deepStrictEqual( + imported.algorithm.publicExponent, new Uint8Array([1, 0, 1])); + })); +} + +{ + const { publicKey } = await subtle.generateKey( + { name: 'ECDSA', namedCurve: 'P-384' }, true, ['sign', 'verify']); + const spki = await subtle.exportKey('spki', publicKey); + + await withPoisoned( + [{ + target: Object.prototype, key: 'namedCurve', + get: () => 'prime256v1', set() {}, + }], + common.mustCall(() => assert.rejects( + subtle.importKey( + 'spki', spki, { name: 'ECDSA', namedCurve: 'P-256' }, true, ['verify']), + { name: 'DataError', message: /Named curve mismatch/ }))); +} + +// Key usages under a poisoned array iterator. Callers pass a Set so the +// spec-mandated sequence conversion still yields the requested usage; only +// WebCrypto's own re-iteration of that array sees the poison. +{ + // Every Set has to be built before the poison is installed, otherwise the + // Set constructor itself iterates its array argument and comes out empty. + const signOnly = new Set(['sign']); + const encryptOnly = new Set(['encrypt']); + const decryptOnly = new Set(['decrypt']); + const decapsulateKeyOnly = new Set(['decapsulateKey']); + + // Secret keys reject empty usages anyway, so match the message: the usage + // has to be rejected as unsupported, not as missing. + const cases = [ + { + name: 'AES-GCM', + message: /Unsupported key usage for AES-GCM key/, + importKey: () => subtle.importKey( + 'raw-secret', new Uint8Array(32), 'AES-GCM', false, signOnly), + }, + { + name: 'HKDF', + message: /Unsupported key usage for a HKDF key/, + importKey: () => subtle.importKey( + 'raw-secret', new Uint8Array(32), 'HKDF', false, encryptOnly), + }, + ]; + + const addPublicKeyCase = async (name, algorithm, usages, disallowed) => { + if (!supports('importKey', name)) return; + const { publicKey } = await subtle.generateKey(algorithm, true, usages); + const spki = await subtle.exportKey('spki', publicKey); + cases.push({ + name, + importKey: () => subtle.importKey( + 'spki', spki, algorithm, true, disallowed), + }); + }; + + await addPublicKeyCase('ECDSA', { name: 'ECDSA', namedCurve: 'P-256' }, + ['sign', 'verify'], signOnly); + await addPublicKeyCase('Ed25519', { name: 'Ed25519' }, + ['sign', 'verify'], signOnly); + await addPublicKeyCase('RSA-OAEP', { + name: 'RSA-OAEP', + modulusLength, + publicExponent: new Uint8Array([1, 0, 1]), + hash: 'SHA-256', + }, ['encrypt', 'decrypt'], decryptOnly); + await addPublicKeyCase('ML-DSA-44', { name: 'ML-DSA-44' }, + ['sign', 'verify'], signOnly); + await addPublicKeyCase('ML-KEM-512', { name: 'ML-KEM-512' }, + ['encapsulateKey', 'decapsulateKey'], + decapsulateKeyOnly); + + for (const { name, message, importKey } of cases) { + const outcome = await settleUnderPoison(poisonArrayIterator, importKey); + assert.strictEqual(outcome.value, undefined, name); + assert.strictEqual(outcome.error?.name, 'SyntaxError', name); + if (message !== undefined) assert.match(outcome.error.message, message); + } +} + +// The registry, the Web IDL converters and the hash name aliases are built at +// module load, so poisoning those needs a fresh process. The child bodies are +// written as real functions and stringified into -e so that they stay linted. +async function runInFreshProcess(fn, args, expected) { + const { code, stdout, stderr } = await common.spawnPromisified( + process.execPath, ['-e', `(${fn})(${args})`]); + assert.strictEqual(code, 0, stderr); + assert.strictEqual(stdout.trim(), expected, stderr); +} + +// Only the load happens under the poison: a sequence argument would +// legitimately come out empty while the caller's iterator is broken. +async function pollutedArrayIteratorChild(kmac) { + const real = Array.prototype[Symbol.iterator]; + Array.prototype[Symbol.iterator] = () => ({ next: () => ({ done: true }) }); + const { subtle } = globalThis.crypto; + const out = []; + try { + out.push((await subtle.digest('SHA-256', new Uint8Array(4))).byteLength); + } finally { + Array.prototype[Symbol.iterator] = real; + } + const hmac = await subtle.generateKey( + { name: 'HMAC', hash: 'SHA-256' }, false, ['sign']); + out.push((await subtle.sign('HMAC', hmac, new Uint8Array(4))).byteLength); + const aes = await subtle.generateKey( + { name: 'AES-GCM', length: 128 }, false, ['encrypt']); + out.push((await subtle.encrypt( + { name: 'AES-GCM', iv: new Uint8Array(12) }, aes, new Uint8Array(4), + )).byteLength); + if (kmac) { + const key = await subtle.generateKey( + { name: 'KMAC128', length: 128 }, false, ['sign']); + out.push((await subtle.sign( + { name: 'KMAC128', outputLength: 256 }, key, new Uint8Array(4), + )).byteLength); + } + console.log(out.join(',')); +} + +// kHashNames indexes its aliases at load time. +async function pollutedHashNameChild() { + Object.prototype['SHA-256'] = { 1: 'md5', 2: 'POLLUTED' }; + const { subtle } = globalThis.crypto; + const key = await subtle.generateKey( + { name: 'HMAC', hash: 'SHA-256' }, true, ['sign']); + const signature = await subtle.sign('HMAC', key, new Uint8Array(4)); + const { alg } = await subtle.exportKey('jwk', key); + console.log(`${signature.byteLength},${alg}`); +} + +{ + const kmac = supports('generateKey', 'KMAC128'); + await runInFreshProcess(pollutedArrayIteratorChild, kmac, + kmac ? '32,32,20,32' : '32,32,20'); + await runInFreshProcess(pollutedHashNameChild, '', '32,HS256'); +}