diff --git a/lib/internal/crypto/cfrg.js b/lib/internal/crypto/cfrg.js index 9eea26aa36a0..1f7b3202fb10 100644 --- a/lib/internal/crypto/cfrg.js +++ b/lib/internal/crypto/cfrg.js @@ -146,6 +146,12 @@ function cfrgImportKey( break; } case 'jwk': { + const isPublic = keyData.d === undefined; + verifyAcceptableKeyUse( + name, + usagesSet, + isPublic ? allowedUsages.public : allowedUsages.private); + const expectedUse = (name === 'X25519' || name === 'X448') ? 'enc' : 'sig'; validateJwk(keyData, 'OKP', extractable, usagesSet, expectedUse); @@ -159,11 +165,6 @@ function cfrgImportKey( 'JWK "alg" does not match the requested algorithm', 'DataError'); } - const isPublic = keyData.d === undefined; - verifyAcceptableKeyUse( - name, - usagesSet, - isPublic ? allowedUsages.public : allowedUsages.private); handle = importJwkKey(isPublic, keyData); break; } diff --git a/lib/internal/crypto/ec.js b/lib/internal/crypto/ec.js index cbd89dd11c7e..0f1e0555202d 100644 --- a/lib/internal/crypto/ec.js +++ b/lib/internal/crypto/ec.js @@ -164,6 +164,12 @@ function ecImportKey( break; } case 'jwk': { + const isPublic = keyData.d === undefined; + verifyAcceptableKeyUse( + name, + usagesSet, + isPublic ? allowedUsages.public : allowedUsages.private); + const expectedUse = name === 'ECDH' ? 'enc' : 'sig'; validateJwk(keyData, 'EC', extractable, usagesSet, expectedUse); @@ -185,11 +191,6 @@ function ecImportKey( 'DataError'); } - const isPublic = keyData.d === undefined; - verifyAcceptableKeyUse( - name, - usagesSet, - isPublic ? allowedUsages.public : allowedUsages.private); handle = importJwkKey(isPublic, keyData); break; } diff --git a/lib/internal/crypto/hkdf.js b/lib/internal/crypto/hkdf.js index d55968907541..fcff7070164f 100644 --- a/lib/internal/crypto/hkdf.js +++ b/lib/internal/crypto/hkdf.js @@ -22,7 +22,6 @@ const { const { kMaxLength } = require('buffer'); const { - getDigestSizeInBytes, jobPromise, normalizeHashName, toBuf, @@ -142,6 +141,22 @@ function hkdfSync(hash, key, salt, info, length) { return bits; } +function getDigestSizeInBytes(name) { + switch (name) { + case 'SHA-1': + return 20; + case 'SHA-256': // Fall through + case 'SHA3-256': + return 32; + case 'SHA-384': // Fall through + case 'SHA3-384': + return 48; + case 'SHA-512': // Fall through + case 'SHA3-512': + return 64; + } +} + function validateHkdfDeriveBitsLength(length, hash) { if (length === null) throw lazyDOMException('length cannot be null', 'OperationError'); diff --git a/lib/internal/crypto/ml_dsa.js b/lib/internal/crypto/ml_dsa.js index 0756198312c0..108ec22fc791 100644 --- a/lib/internal/crypto/ml_dsa.js +++ b/lib/internal/crypto/ml_dsa.js @@ -158,17 +158,18 @@ function mlDsaImportKey( break; } case 'jwk': { + const isPublic = keyData.priv === undefined; + verifyAcceptableKeyUse( + name, + usagesSet, + isPublic ? kUsages.public : kUsages.private); + validateJwk(keyData, 'AKP', extractable, usagesSet, 'sig'); if (keyData.alg !== name) throw lazyDOMException( 'JWK "alg" Parameter and algorithm name mismatch', 'DataError'); - const isPublic = keyData.priv === undefined; - verifyAcceptableKeyUse( - name, - usagesSet, - isPublic ? kUsages.public : kUsages.private); handle = importJwkKey(isPublic, keyData); break; } diff --git a/lib/internal/crypto/ml_kem.js b/lib/internal/crypto/ml_kem.js index c917c88c0f29..da077ac8344d 100644 --- a/lib/internal/crypto/ml_kem.js +++ b/lib/internal/crypto/ml_kem.js @@ -169,17 +169,18 @@ function mlKemImportKey( break; } case 'jwk': { + const isPublic = keyData.priv === undefined; + verifyAcceptableKeyUse( + name, + usagesSet, + isPublic ? kUsages.public : kUsages.private); + validateJwk(keyData, 'AKP', extractable, usagesSet, 'enc'); if (keyData.alg !== name) throw lazyDOMException( 'JWK "alg" Parameter and algorithm name mismatch', 'DataError'); - const isPublic = keyData.priv === undefined; - verifyAcceptableKeyUse( - name, - usagesSet, - isPublic ? kUsages.public : kUsages.private); handle = importJwkKey(isPublic, keyData); break; } diff --git a/lib/internal/crypto/rsa.js b/lib/internal/crypto/rsa.js index 846b6b7bf748..27b23087d563 100644 --- a/lib/internal/crypto/rsa.js +++ b/lib/internal/crypto/rsa.js @@ -1,7 +1,6 @@ 'use strict'; const { - MathCeil, TypedArrayPrototypeGetBuffer, Uint8Array, } = primordials; @@ -24,12 +23,11 @@ const { } = internalBinding('crypto'); const { - validateInt32, + isInt32, } = require('internal/validators'); const { bigIntArrayToUnsignedInt, - getDigestSizeInBytes, getUsagesMask, jobPromise, normalizeHashName, @@ -188,6 +186,12 @@ function rsaImportKey( break; } case 'jwk': { + const isPublic = keyData.d === undefined; + verifyAcceptableKeyUse( + algorithm.name, + usagesSet, + isPublic ? allowedUsages.public : allowedUsages.private); + const expectedUse = algorithm.name === 'RSA-OAEP' ? 'enc' : 'sig'; validateJwk(keyData, 'RSA', extractable, usagesSet, expectedUse); @@ -204,11 +208,6 @@ function rsaImportKey( 'DataError'); } - const isPublic = keyData.d === undefined; - verifyAcceptableKeyUse( - algorithm.name, - usagesSet, - isPublic ? allowedUsages.public : allowedUsages.private); handle = importJwkKey(isPublic, keyData); break; } @@ -241,19 +240,16 @@ function rsaSignVerify(key, data, { saltLength }, signature) { throw lazyDOMException(`Key must be a ${type} key`, 'InvalidAccessError'); const algorithm = getCryptoKeyAlgorithm(key); - if (algorithm.name === 'RSA-PSS') { - try { - validateInt32( - saltLength, - 'algorithm.saltLength', - 0, - MathCeil((algorithm.modulusLength - 1) / 8) - - getDigestSizeInBytes(algorithm.hash.name) - 2); - } catch (err) { - throw lazyDOMException( - 'The operation failed for an operation-specific reason', - { name: 'OperationError', cause: err }); - } + // RsaPssParams converts saltLength to an unsigned long, but SignJob only + // accepts int32 values. + if (algorithm.name === 'RSA-PSS' && !isInt32(saltLength)) { + // EMSA-PSS-VERIFY treats an impossible salt length as inconsistent. + if (mode === kSignJobModeVerify) + return false; + + throw lazyDOMException( + 'The operation failed for an operation-specific reason', + 'OperationError'); } return jobPromise(() => new SignJob( diff --git a/lib/internal/crypto/util.js b/lib/internal/crypto/util.js index 3c52feebea68..5c0fd334eabd 100644 --- a/lib/internal/crypto/util.js +++ b/lib/internal/crypto/util.js @@ -1042,22 +1042,6 @@ function getBlockSize(name) { } } -function getDigestSizeInBytes(name) { - switch (name) { - case 'SHA-1': - return 20; - case 'SHA-256': // Fall through - case 'SHA3-256': - return 32; - case 'SHA-384': // Fall through - case 'SHA3-384': - return 48; - case 'SHA-512': // Fall through - case 'SHA3-512': - return 64; - } -} - function validateKeyOps(keyOps, usagesSet) { if (keyOps === undefined) return; validateArray(keyOps, 'keyData.key_ops'); @@ -1132,7 +1116,6 @@ module.exports = { bigIntArrayToUnsignedBigInt, bigIntArrayToUnsignedInt, getBlockSize, - getDigestSizeInBytes, getStringOption, getUsagesMask, getUsagesFromMask, diff --git a/lib/internal/crypto/webcrypto.js b/lib/internal/crypto/webcrypto.js index 55953d8e4745..342b5ee05fe0 100644 --- a/lib/internal/crypto/webcrypto.js +++ b/lib/internal/crypto/webcrypto.js @@ -482,19 +482,20 @@ function deriveKeyImpl( } function exportKeySpki(key) { + let exporter; switch (getCryptoKeyAlgorithm(key).name) { case 'RSASSA-PKCS1-v1_5': // Fall through case 'RSA-PSS': // Fall through case 'RSA-OAEP': - return require('internal/crypto/rsa') - .rsaExportKey(key, kWebCryptoKeyFormatSPKI); + exporter = require('internal/crypto/rsa').rsaExportKey; + break; case 'ECDSA': // Fall through case 'ECDH': - return require('internal/crypto/ec') - .ecExportKey(key, kWebCryptoKeyFormatSPKI); + exporter = require('internal/crypto/ec').ecExportKey; + break; case 'Ed25519': // Fall through case 'Ed448': @@ -502,25 +503,30 @@ function exportKeySpki(key) { case 'X25519': // Fall through case 'X448': - return require('internal/crypto/cfrg') - .cfrgExportKey(key, kWebCryptoKeyFormatSPKI); + exporter = require('internal/crypto/cfrg').cfrgExportKey; + break; case 'ML-DSA-44': // Fall through case 'ML-DSA-65': // Fall through case 'ML-DSA-87': - return require('internal/crypto/ml_dsa') - .mlDsaExportKey(key, kWebCryptoKeyFormatSPKI); + exporter = require('internal/crypto/ml_dsa').mlDsaExportKey; + break; case 'ML-KEM-512': // Fall through case 'ML-KEM-768': // Fall through case 'ML-KEM-1024': - return require('internal/crypto/ml_kem') - .mlKemExportKey(key, kWebCryptoKeyFormatSPKI); + exporter = require('internal/crypto/ml_kem').mlKemExportKey; + break; default: return undefined; } + + if (getCryptoKeyType(key) !== 'public') + throw lazyDOMException('Key must be a public key', 'InvalidAccessError'); + + return exporter(key, kWebCryptoKeyFormatSPKI); } function exportKeyPkcs8(key) { @@ -767,9 +773,7 @@ function exportKeySync(format, key) { let result; switch (format) { case 'spki': { - if (type === 'public') { - result = exportKeySpki(key); - } + result = exportKeySpki(key); break; } case 'pkcs8': { diff --git a/test/parallel/test-webcrypto-export-import-cfrg.js b/test/parallel/test-webcrypto-export-import-cfrg.js index 0ff7c61bdc68..fb8014840f34 100644 --- a/test/parallel/test-webcrypto-export-import-cfrg.js +++ b/test/parallel/test-webcrypto-export-import-cfrg.js @@ -432,6 +432,41 @@ async function testImportRaw({ name, publicUsages }) { await Promise.all(tests); })().then(common.mustCall()); +// JWK key usage validation precedes `key_ops` validation. +(async function() { + for (const { name, publicUsages, privateUsages } of testVectors) { + const jwk = keyData[name].jwk; + const publicJwk = { + kty: jwk.kty, + crv: jwk.crv, + x: jwk.x, + }; + const isKeyAgreement = name.startsWith('X'); + const invalidUsage = isKeyAgreement ? + privateUsages[0] : publicUsages[0]; + const invalidJwk = isKeyAgreement ? publicJwk : jwk; + + await assert.rejects( + subtle.importKey( + 'jwk', + { ...invalidJwk, key_ops: [invalidUsage, invalidUsage] }, + { name }, + true, + [invalidUsage]), + { name: 'SyntaxError', message: /Unsupported key usage/ }); + + const validUsage = privateUsages[0]; + await assert.rejects( + subtle.importKey( + 'jwk', + { ...jwk, key_ops: [validUsage, validUsage] }, + { name }, + true, + [validUsage]), + { name: 'DataError', message: 'Duplicate key operation' }); + } +})().then(common.mustCall()); + { const rsaPublic = crypto.createPublicKey( fixtures.readKey('rsa_public_2048.pem')); diff --git a/test/parallel/test-webcrypto-export-import-ec.js b/test/parallel/test-webcrypto-export-import-ec.js index f6157bff2b69..978aad14ebd9 100644 --- a/test/parallel/test-webcrypto-export-import-ec.js +++ b/test/parallel/test-webcrypto-export-import-ec.js @@ -163,6 +163,12 @@ async function testImportPkcs8( assert.strictEqual( Buffer.from(pkcs8).toString('hex'), keyData[namedCurve].pkcs8.toString('hex')); + + await assert.rejects( + subtle.exportKey('spki', key), { + message: 'Key must be a public key', + name: 'InvalidAccessError', + }); } else { await assert.rejects( subtle.exportKey('pkcs8', key), { @@ -406,6 +412,42 @@ async function testImportRaw({ name, publicUsages }, namedCurve) { await Promise.all(tests); })().then(common.mustCall()); +// JWK key usage validation precedes `key_ops` validation. +(async function() { + const jwk = keyData['P-256'].jwk; + const publicJwk = { + kty: jwk.kty, + crv: jwk.crv, + x: jwk.x, + y: jwk.y, + }; + + for (const { name, publicUsages, privateUsages } of testVectors) { + const invalidUsage = name === 'ECDH' ? + privateUsages[0] : publicUsages[0]; + const invalidJwk = name === 'ECDH' ? publicJwk : jwk; + + await assert.rejects( + subtle.importKey( + 'jwk', + { ...invalidJwk, key_ops: [invalidUsage, invalidUsage] }, + { name, namedCurve: 'P-256' }, + true, + [invalidUsage]), + { name: 'SyntaxError', message: /Unsupported key usage/ }); + + const validUsage = privateUsages[0]; + await assert.rejects( + subtle.importKey( + 'jwk', + { ...jwk, key_ops: [validUsage, validUsage] }, + { name, namedCurve: 'P-256' }, + true, + [validUsage]), + { name: 'DataError', message: 'Duplicate key operation' }); + } +})().then(common.mustCall()); + // https://github.com/nodejs/node/issues/45859 (async function() { diff --git a/test/parallel/test-webcrypto-export-import-ml-dsa.js b/test/parallel/test-webcrypto-export-import-ml-dsa.js index 20d46870e430..9c6e04da053c 100644 --- a/test/parallel/test-webcrypto-export-import-ml-dsa.js +++ b/test/parallel/test-webcrypto-export-import-ml-dsa.js @@ -491,6 +491,40 @@ async function testImportRawSeed({ name, privateUsages }, extractable) { }); })().then(common.mustCall()); +// JWK key usage validation precedes `key_ops` validation. +(async function() { + const privateJwk = keyData['ML-DSA-65'].jwk; + const publicJwk = { ...privateJwk, priv: undefined }; + + for (const [jwk, usage] of [ + [privateJwk, 'verify'], + [publicJwk, 'sign'], + ]) { + await assert.rejects( + subtle.importKey( + 'jwk', + { ...jwk, key_ops: [usage, usage] }, + 'ML-DSA-65', + true, + [usage]), + { name: 'SyntaxError', message: /Unsupported key usage/ }); + } + + for (const [jwk, usage] of [ + [privateJwk, 'sign'], + [publicJwk, 'verify'], + ]) { + await assert.rejects( + subtle.importKey( + 'jwk', + { ...jwk, key_ops: [usage, usage] }, + 'ML-DSA-65', + true, + [usage]), + { name: 'DataError', message: /Duplicate key operation/ }); + } +})().then(common.mustCall()); + if (!process.features.openssl_is_boringssl) { (async function() { for (const { name, privateUsages } of testVectors) { diff --git a/test/parallel/test-webcrypto-export-import-ml-kem.js b/test/parallel/test-webcrypto-export-import-ml-kem.js index cd05224969c8..862eea96726d 100644 --- a/test/parallel/test-webcrypto-export-import-ml-kem.js +++ b/test/parallel/test-webcrypto-export-import-ml-kem.js @@ -496,13 +496,29 @@ async function testImportJwk({ name, publicUsages, privateUsages }, extractable) }); })().then(common.mustCall()); -// Regression test: JWK `key_ops` validation must recognize ML-KEM operations -// (encapsulateKey, encapsulateBits, decapsulateKey, decapsulateBits) so that -// duplicate entries are rejected +// JWK key usage validation precedes `key_ops` validation. (async function() { - for (const op of ['encapsulateKey', 'encapsulateBits', - 'decapsulateKey', 'decapsulateBits']) { - const jwk = { ...keyData['ML-KEM-768'].jwk, key_ops: [op, op] }; + const privateJwk = keyData['ML-KEM-768'].jwk; + const encapsulationOps = ['encapsulateKey', 'encapsulateBits']; + const decapsulationOps = ['decapsulateKey', 'decapsulateBits']; + + for (const op of encapsulationOps) { + const jwk = { ...privateJwk, key_ops: [op, op] }; + await assert.rejects( + subtle.importKey('jwk', jwk, { name: 'ML-KEM-768' }, true, [op]), + { name: 'SyntaxError', message: /Unsupported key usage/ }); + } + + // Duplicate entries are still rejected when the requested usages are valid. + for (const op of encapsulationOps) { + const jwk = { ...privateJwk, priv: undefined, key_ops: [op, op] }; + await assert.rejects( + subtle.importKey('jwk', jwk, { name: 'ML-KEM-768' }, true, [op]), + { name: 'DataError', message: /Duplicate key operation/ }); + } + + for (const op of decapsulationOps) { + const jwk = { ...privateJwk, key_ops: [op, op] }; await assert.rejects( subtle.importKey('jwk', jwk, { name: 'ML-KEM-768' }, true, [op]), { name: 'DataError', message: /Duplicate key operation/ }); diff --git a/test/parallel/test-webcrypto-export-import-rsa.js b/test/parallel/test-webcrypto-export-import-rsa.js index 8808fcbe6b1f..294baff544a6 100644 --- a/test/parallel/test-webcrypto-export-import-rsa.js +++ b/test/parallel/test-webcrypto-export-import-rsa.js @@ -651,6 +651,35 @@ const testVectors = [ await Promise.all(variations); })().then(common.mustCall()); +// Type-specific JWK usage validation precedes `key_ops` validation. +(async function() { + const privateJwk = keyData[1024].jwk; + + for (const { name, publicUsages, privateUsages } of testVectors) { + const algorithm = { name, hash: 'SHA-256' }; + const invalidUsage = publicUsages[0]; + const validUsage = privateUsages[0]; + + await assert.rejects( + subtle.importKey( + 'jwk', + { ...privateJwk, key_ops: [invalidUsage, invalidUsage] }, + algorithm, + true, + [invalidUsage]), + { name: 'SyntaxError', message: /Unsupported key usage/ }); + + await assert.rejects( + subtle.importKey( + 'jwk', + { ...privateJwk, key_ops: [validUsage, validUsage] }, + algorithm, + true, + [validUsage]), + { name: 'DataError', message: 'Duplicate key operation' }); + } +})().then(common.mustCall()); + { const ecPublic = crypto.createPublicKey( fixtures.readKey('ec_p256_public.pem')); diff --git a/test/parallel/test-webcrypto-sign-verify-rsa.js b/test/parallel/test-webcrypto-sign-verify-rsa.js index 62e9cbe7826a..12633cd50de2 100644 --- a/test/parallel/test-webcrypto-sign-verify-rsa.js +++ b/test/parallel/test-webcrypto-sign-verify-rsa.js @@ -227,22 +227,26 @@ async function testSaltLength(keyLength, hash, hLen) { const signature = await subtle.sign( { name: 'RSA-PSS', saltLength: max }, privateKey, data); - await assert.rejects( - subtle.sign({ name: 'RSA-PSS', saltLength: max + 1 }, privateKey, data), (err) => { - assert.strictEqual(err.name, 'OperationError'); - assert.strictEqual(err.cause?.code, 'ERR_OUT_OF_RANGE'); - assert.strictEqual(err.cause?.message, `The value of "algorithm.saltLength" is out of range. It must be >= 0 && <= ${max}. Received ${max + 1}`); - return true; - }); - await subtle.verify( - { name: 'RSA-PSS', saltLength: max }, publicKey, signature, data); - await assert.rejects( - subtle.verify({ name: 'RSA-PSS', saltLength: max + 1 }, publicKey, signature, data), (err) => { - assert.strictEqual(err.name, 'OperationError'); - assert.strictEqual(err.cause?.code, 'ERR_OUT_OF_RANGE'); - assert.strictEqual(err.cause?.message, `The value of "algorithm.saltLength" is out of range. It must be >= 0 && <= ${max}. Received ${max + 1}`); - return true; - }); + assert.strictEqual(await subtle.verify( + { name: 'RSA-PSS', saltLength: max }, publicKey, signature, data), true); + + for (const saltLength of [max + 1, 0x7fffffff]) { + await assert.rejects( + subtle.sign({ name: 'RSA-PSS', saltLength }, privateKey, data), { + name: 'OperationError', + }); + assert.strictEqual(await subtle.verify( + { name: 'RSA-PSS', saltLength }, publicKey, signature, data), false); + } + + for (const saltLength of [0x80000000, 0xffffffff]) { + await assert.rejects( + subtle.sign({ name: 'RSA-PSS', saltLength }, privateKey, data), { + name: 'OperationError', + }); + assert.strictEqual(await subtle.verify( + { name: 'RSA-PSS', saltLength }, publicKey, signature, data), false); + } } (async function() { diff --git a/test/parallel/test-webcrypto-wrap-unwrap.js b/test/parallel/test-webcrypto-wrap-unwrap.js index c2c089ed0ffa..64ff4c5f5342 100644 --- a/test/parallel/test-webcrypto-wrap-unwrap.js +++ b/test/parallel/test-webcrypto-wrap-unwrap.js @@ -506,7 +506,7 @@ async function testNonByteLengthWrapUnwrap({ // Test that wrapKey/unwrapKey validate the wrapping/unwrapping key's // algorithm and usage before proceeding. // Spec: https://w3c.github.io/webcrypto/#SubtleCrypto-method-wrapKey -// Steps 9-10 (wrapping key checks) must precede step 12 (exportKey). +// Steps 9-10 (wrapping key checks) must precede step 13 (export operation). (async function() { const hmacKey = await subtle.generateKey( { name: 'HMAC', hash: 'SHA-256' }, @@ -551,7 +551,7 @@ async function testNonByteLengthWrapUnwrap({ }); // Correct wrapping key algorithm and usage results in the expected - // exportKey error (not the wrapping key validation error). + // export operation error (not the wrapping key validation error). const wrapKey = await subtle.generateKey( { name: 'AES-GCM', length: 128 }, true, @@ -563,8 +563,8 @@ async function testNonByteLengthWrapUnwrap({ name: 'AES-GCM', iv: new Uint8Array(12), }), { - // exportKey('spki', privateKey) throws NotSupportedError - name: 'NotSupportedError', + message: 'Key must be a public key', + name: 'InvalidAccessError', }); // --- unwrapKey validation tests ---