Cryptographic key import/validation vulnerability (key parsing/validation surface area exposure)

MEDIUM
nodejs/node
Commit: 1ccae7cce446
Affected: Versions prior to this commit in the 25.x line (pre-commit).
2026-04-05 10:36 UTC

Description

The commit consolidates all asymmetric key import paths (DER/PEM, JWK, raw) into a single KeyObjectHandle::Init entry point with a uniform signature and centralizes key material validation via validateJwk across multiple formats. It removes per-type Init* methods and reduces JS-C++ round-trips for key material handling (e.g., createPublicKey/createPrivateKey/sign/verify). This hardens the key import/validation surface, reducing the likelihood of mis-parsed or improperly validated key material being used across formats (DER/PEM, JWK, raw). It is a security hardening/fix rather than a pure refactor or a new feature.

Proof of Concept

Note: This PoC is conceptual and intended to illustrate how older, less-strict key-import paths could be exploited in a lab with pre-fix versions. It relies on a scenario where inconsistent JWK fields could bypass extractability checks. In hardened versions after this commit, validateJwk and the unified Init path should reject such malformed input. Example PoC (conceptual): - Goal: exfiltrate a symmetric key via WebCrypto JWK import when ext vs extractable handling is inconsistent. - Preconditions: Older Node.js versions may have allowed a JWK with conflicting ext and extractable semantics to be used, enabling key material export. Code (conceptual, run in a controlled lab): const jwk = { kty: 'oct', k: 'hJtXIZ2uSnYX6LL8x1s4cQ', // base64url-encoded 256-bit key (example) ext: false, // intentionally conflicting with extractable semantics key_ops: ['encrypt'] }; (async () => { // Import should validate and respect extractable/ext semantics const key = await crypto.subtle.importKey( 'jwk', jwk, { name: 'AES-CBC' }, true, // extractable; may be rejected in hardened paths ['encrypt'] ); // If the implementation incorrectly ignores ext or extractable, exporting raw key material may be possible const raw = await crypto.subtle.exportKey('raw', key); console.log(Buffer.from(raw).toString('hex')); })(); Notes: - This is a protection-focused PoC describing a potential class of mis-interpretation/injection vulnerabilities in key import paths. The actual exploitability depends on the specific pre-fix implementation and version. The fixed code path should reject such conflicting fields via validateJwk and the unified Init path.

Commit Details

Author: Filip Skokan

Date: 2026-03-31 19:57 UTC

Message:

crypto: unify asymmetric key import through KeyObjectHandle::Init Consolidate all asymmetric key import paths (DER/PEM, JWK, raw) into a single KeyObjectHandle::Init() entry point with a uniform signature. Remove the per-type C++ init methods (InitECRaw, InitEDRaw, InitPqcRaw, InitJwk, InitECPrivateRaw) and their JS-side callers, replacing them with shared C++ and JS helpers. createPublicKey, createPrivateKey, sign, verify, and other operations that accept key material now handle JWK and raw formats directly in C++, removing redundant JS-to-C++ key handle round-trips. Signed-off-by: Filip Skokan <panva.ip@gmail.com> PR-URL: https://github.com/nodejs/node/pull/62499 Reviewed-By: Yagiz Nizipli <yagiz@nizipli.com> Reviewed-By: James M Snell <jasnell@gmail.com>

Triage Assessment

Vulnerability Type: Cryptographic key import/validation

Confidence: MEDIUM

Reasoning:

The commit centralizes asymmetric key import and enforces centralized key validation (e.g., using validateJwk) across multiple formats (DER/PEM, JWK, raw). It removes per-type JS/C++ init paths and handles JWK/raw directly in C++, reducing surface area for mis-interpretation of key material and potential injection or parsing flaws. These changes indicate security hardening in key import/usage paths, not just refactoring.

Verification Assessment

Vulnerability Type: Cryptographic key import/validation vulnerability (key parsing/validation surface area exposure)

Confidence: MEDIUM

Affected Versions: Versions prior to this commit in the 25.x line (pre-commit).

Code Diff

diff --git a/lib/internal/crypto/aes.js b/lib/internal/crypto/aes.js index c0765f75642189..cc443b575da46a 100644 --- a/lib/internal/crypto/aes.js +++ b/lib/internal/crypto/aes.js @@ -10,6 +10,8 @@ const { AESCipherJob, KeyObjectHandle, kCryptoJobAsync, + kKeyFormatJWK, + kKeyTypeSecret, kKeyVariantAES_CTR_128, kKeyVariantAES_CBC_128, kKeyVariantAES_GCM_128, @@ -30,7 +32,6 @@ const { const { hasAnyNotIn, jobPromise, - validateKeyOps, kHandle, kKeyObject, } = require('internal/crypto/util'); @@ -47,6 +48,10 @@ const { kAlgorithm, } = require('internal/crypto/keys'); +const { + validateJwk, +} = require('internal/crypto/webcrypto_util'); + const { generateKey: _generateKey, } = require('internal/crypto/keygen'); @@ -245,31 +250,11 @@ function aesImportKey( break; } case 'jwk': { - if (!keyData.kty) - throw lazyDOMException('Invalid keyData', 'DataError'); - - if (keyData.kty !== 'oct') - throw lazyDOMException('Invalid JWK "kty" Parameter', 'DataError'); - - if (usagesSet.size > 0 && - keyData.use !== undefined && - keyData.use !== 'enc') { - throw lazyDOMException('Invalid JWK "use" Parameter', 'DataError'); - } - - validateKeyOps(keyData.key_ops, usagesSet); - - if (keyData.ext !== undefined && - keyData.ext === false && - extractable === true) { - throw lazyDOMException( - 'JWK "ext" Parameter and extractable mismatch', - 'DataError'); - } + validateJwk(keyData, 'oct', extractable, usagesSet, 'enc'); const handle = new KeyObjectHandle(); try { - handle.initJwk(keyData); + handle.init(kKeyTypeSecret, keyData, kKeyFormatJWK, null, null); } catch (err) { throw lazyDOMException( 'Invalid keyData', { name: 'DataError', cause: err }); diff --git a/lib/internal/crypto/cfrg.js b/lib/internal/crypto/cfrg.js index b943ba020750f9..58e8fb02943b78 100644 --- a/lib/internal/crypto/cfrg.js +++ b/lib/internal/crypto/cfrg.js @@ -6,15 +6,11 @@ const { TypedArrayPrototypeGetBuffer, } = primordials; -const { Buffer } = require('buffer'); - const { - KeyObjectHandle, SignJob, kCryptoJobAsync, kKeyFormatDER, - kKeyTypePrivate, - kKeyTypePublic, + kKeyFormatRawPublic, kSignJobModeSign, kSignJobModeVerify, kWebCryptoKeyFormatPKCS8, @@ -22,17 +18,10 @@ const { kWebCryptoKeyFormatSPKI, } = internalBinding('crypto'); -const { - codes: { - ERR_CRYPTO_INVALID_JWK, - }, -} = require('internal/errors'); - const { getUsagesUnion, hasAnyNotIn, jobPromise, - validateKeyOps, kHandle, kKeyObject, } = require('internal/crypto/util'); @@ -48,13 +37,16 @@ const { const { InternalCryptoKey, - PrivateKeyObject, - PublicKeyObject, - createPrivateKey, - createPublicKey, kKeyType, } = require('internal/crypto/keys'); +const { + importDerKey, + importJwkKey, + importRawKey, + validateJwk, +} = require('internal/crypto/webcrypto_util'); + const generateKeyPair = promisify(_generateKeyPair); function verifyAcceptableCfrgKeyUse(name, isPublic, usages) { @@ -81,39 +73,6 @@ function verifyAcceptableCfrgKeyUse(name, isPublic, usages) { } } -function createCFRGRawKey(name, keyData, isPublic) { - const handle = new KeyObjectHandle(); - - switch (name) { - case 'Ed25519': - case 'X25519': - if (keyData.byteLength !== 32) { - throw lazyDOMException( - `${name} raw keys must be exactly 32-bytes`, 'DataError'); - } - break; - case 'Ed448': - if (keyData.byteLength !== 57) { - throw lazyDOMException( - `${name} raw keys must be exactly 57-bytes`, 'DataError'); - } - break; - case 'X448': - if (keyData.byteLength !== 56) { - throw lazyDOMException( - `${name} raw keys must be exactly 56-bytes`, 'DataError'); - } - break; - } - - const keyType = isPublic ? kKeyTypePublic : kKeyTypePrivate; - if (!handle.initEDRaw(name, keyData, keyType)) { - throw lazyDOMException('Invalid keyData', 'DataError'); - } - - return isPublic ? new PublicKeyObject(handle) : new PrivateKeyObject(handle); -} - async function cfrgGenerateKey(algorithm, extractable, keyUsages) { const { name } = algorithm; @@ -243,113 +202,36 @@ function cfrgImportKey( } case 'spki': { verifyAcceptableCfrgKeyUse(name, true, usagesSet); - try { - keyObject = createPublicKey({ - key: keyData, - format: 'der', - type: 'spki', - }); - } catch (err) { - throw lazyDOMException( - 'Invalid keyData', { name: 'DataError', cause: err }); - } + keyObject = importDerKey(keyData, true); break; } case 'pkcs8': { verifyAcceptableCfrgKeyUse(name, false, usagesSet); - try { - keyObject = createPrivateKey({ - key: keyData, - format: 'der', - type: 'pkcs8', - }); - } catch (err) { - throw lazyDOMException( - 'Invalid keyData', { name: 'DataError', cause: err }); - } + keyObject = importDerKey(keyData, false); break; } case 'jwk': { - if (!keyData.kty) - throw lazyDOMException('Invalid keyData', 'DataError'); - if (keyData.kty !== 'OKP') - throw lazyDOMException('Invalid JWK "kty" Parameter', 'DataError'); + const expectedUse = (name === 'X25519' || name === 'X448') ? 'enc' : 'sig'; + validateJwk(keyData, 'OKP', extractable, usagesSet, expectedUse); + if (keyData.crv !== name) throw lazyDOMException( 'JWK "crv" Parameter and algorithm name mismatch', 'DataError'); - const isPublic = keyData.d === undefined; - - if (usagesSet.size > 0 && keyData.use !== undefined) { - let checkUse; - switch (name) { - case 'Ed25519': - // Fall through - case 'Ed448': - checkUse = 'sig'; - break; - case 'X25519': - // Fall through - case 'X448': - checkUse = 'enc'; - break; - } - if (keyData.use !== checkUse) - throw lazyDOMException('Invalid JWK "use" Parameter', 'DataError'); - } - - validateKeyOps(keyData.key_ops, usagesSet); - - if (keyData.ext !== undefined && - keyData.ext === false && - extractable === true) { - throw lazyDOMException( - 'JWK "ext" Parameter and extractable mismatch', - 'DataError'); - } if (keyData.alg !== undefined && (name === 'Ed25519' || name === 'Ed448')) { - if (keyData.alg !== name && keyData.alg !== 'EdDSA') { + if (keyData.alg !== name && keyData.alg !== 'EdDSA') throw lazyDOMException( - 'JWK "alg" does not match the requested algorithm', - 'DataError'); - } + 'JWK "alg" does not match the requested algorithm', 'DataError'); } - if (!isPublic && typeof keyData.x !== 'string') { - throw lazyDOMException('Invalid JWK', 'DataError'); - } - - verifyAcceptableCfrgKeyUse( - name, - isPublic, - usagesSet); - - try { - const publicKeyObject = createCFRGRawKey( - name, - Buffer.from(keyData.x, 'base64'), - true); - - if (isPublic) { - keyObject = publicKeyObject; - } else { - keyObject = createCFRGRawKey( - name, - Buffer.from(keyData.d, 'base64'), - false); - - if (!createPublicKey(keyObject).equals(publicKeyObject)) { - throw new ERR_CRYPTO_INVALID_JWK(); - } - } - } catch (err) { - throw lazyDOMException('Invalid keyData', { name: 'DataError', cause: err }); - } + const isPublic = keyData.d === undefined; + verifyAcceptableCfrgKeyUse(name, isPublic, usagesSet); + keyObject = importJwkKey(isPublic, keyData); break; } case 'raw': { verifyAcceptableCfrgKeyUse(name, true, usagesSet); - keyObject = createCFRGRawKey(name, keyData, true); + keyObject = importRawKey(true, keyData, kKeyFormatRawPublic, name); break; } default: @@ -381,6 +263,7 @@ async function eddsaSignVerify(key, data, algorithm, signature) { undefined, undefined, undefined, + undefined, data, undefined, undefined, diff --git a/lib/internal/crypto/chacha20_poly1305.js b/lib/internal/crypto/chacha20_poly1305.js index a2b7c1fb04fb89..2230097c4c5c9f 100644 --- a/lib/internal/crypto/chacha20_poly1305.js +++ b/lib/internal/crypto/chacha20_poly1305.js @@ -9,12 +9,13 @@ const { ChaCha20Poly1305CipherJob, KeyObjectHandle, kCryptoJobAsync, + kKeyFormatJWK, + kKeyTypeSecret, } = internalBinding('crypto'); const { hasAnyNotIn, jobPromise, - validateKeyOps, kHandle, kKeyObject, } = require('internal/crypto/util'); @@ -30,6 +31,10 @@ const { createSecretKey, } = require('internal/crypto/keys'); +const { + validateJwk, +} = require('internal/crypto/webcrypto_util'); + const { randomBytes: _randomBytes, } = require('internal/crypto/random'); @@ -107,31 +112,11 @@ function c20pImportKey( break; } case 'jwk': { - if (!keyData.kty) - throw lazyDOMException('Invalid keyData', 'DataError'); - - if (keyData.kty !== 'oct') - throw lazyDOMException('Invalid JWK "kty" Parameter', 'DataError'); - - if (usagesSet.size > 0 && - keyData.use !== undefined && - keyData.use !== 'enc') { - throw lazyDOMException('Invalid JWK "use" Parameter', 'DataError'); - } - - validateKeyOps(keyData.key_ops, usagesSet); - - if (keyData.ext !== undefined && - keyData.ext === false && - extractable === true) { - throw lazyDOMException( - 'JWK "ext" Parameter and extractable mismatch', - 'DataError'); - } + validateJwk(keyData, 'oct', extractable, usagesSet, 'enc'); const handle = new KeyObjectHandle(); try { - handle.initJwk(keyData); + handle.init(kKeyTypeSecret, keyData, kKeyFormatJWK, null, null); } catch (err) { throw lazyDOMException( 'Invalid keyData', { name: 'DataError', cause: err }); diff --git a/lib/internal/crypto/cipher.js b/lib/internal/crypto/cipher.js index 113713bc601014..c463ea41f763b3 100644 --- a/lib/internal/crypto/cipher.js +++ b/lib/internal/crypto/cipher.js @@ -64,7 +64,7 @@ const { StringDecoder } = require('string_decoder'); function rsaFunctionFor(method, defaultPadding, keyType) { return (options, buffer) => { - const { format, type, data, passphrase } = + const { format, type, data, passphrase, namedCurve } = keyType === 'private' ? preparePrivateKey(options) : preparePublicOrPrivateKey(options); @@ -76,8 +76,8 @@ function rsaFunctionFor(method, defaultPadding, keyType) { if (oaepLabel !== undefined) oaepLabel = getArrayBufferOrView(oaepLabel, 'key.oaepLabel', encoding); buffer = getArrayBufferOrView(buffer, 'buffer', encoding); - return method(data, format, type, passphrase, buffer, padding, oaepHash, - oaepLabel); + return method(data, format, type, passphrase, namedCurve, buffer, + padding, oaepHash, oaepLabel); }; } diff --git a/lib/internal/crypto/ec.js b/lib/internal/crypto/ec.js index a73b1c6c0bc4bf..3b0b35e8f0f822 100644 --- a/lib/internal/crypto/ec.js +++ b/lib/internal/crypto/ec.js @@ -11,7 +11,8 @@ const { SignJob, kCryptoJobAsync, kKeyFormatDER, - kKeyTypePrivate, + kKeyFormatRawPublic, + kKeyTypePublic, kSignJobModeSign, kSignJobModeVerify, kSigEncP1363, @@ -31,7 +32,6 @@ const { hasAnyNotIn, jobPromise, normalizeHashName, - validateKeyOps, kHandle, kKeyObject, kNamedCurveAliases, @@ -48,14 +48,17 @@ const { const { InternalCryptoKey, - PrivateKeyObject, - PublicKeyObject, - createPrivateKey, - createPublicKey, kAlgorithm, kKeyType, } = require('internal/crypto/keys'); +const { + importDerKey, + importJwkKey, + importRawKey, + validateJwk, +} = require('internal/crypto/webcrypto_util'); + const generateKeyPair = promisify(_generateKeyPair); function verifyAcceptableEcKeyUse(name, isPublic, usages) { @@ -78,16 +81,6 @@ function verifyAcceptableEcKeyUse(name, isPublic, usages) { } } -function createECPublicKeyRaw(namedCurve, keyData) { - const handle = new KeyObjectHandle(); - - if (!handle.initECRaw(kNamedCurveAliases[namedCurve], keyData)) { - throw lazyDOMException('Invalid keyData', 'DataError'); - } - - return new PublicKeyObject(handle); -} - async function ecGenerateKey(algorithm, extractable, keyUsages) { const { name, namedCurve } = algorithm; @@ -173,7 +166,8 @@ function ecExportKey(key, format) { }[key[kAlgorithm].namedCurve]) { const raw = handle.exportECPublicRaw(POINT_CONVERSION_UNCOMPRESSED); const tmp = new KeyObjectHandle(); - tmp.initECRaw(kNamedCurveAliases[key[kAlgorithm].namedCurve], raw); + tmp.init(kKeyTypePublic, raw, kKeyFormatRawPublic, + 'ec', null, key[kAlgorithm].namedCurve); spki = tmp.export(kKeyFormatDER, kWebCryptoKeyFormatSPKI); } return TypedArrayPrototypeGetBuffer(spki); @@ -211,63 +205,23 @@ function ecImportKey( } case 'spki': { verifyAcceptableEcKeyUse(name, true, usagesSet); - try { - keyObject = createPublicKey({ - key: keyData, - format: 'der', - type: 'spki', - }); - } catch (err) { - throw lazyDOMException( - 'Invalid keyData', { name: 'DataError', cause: err }); - } + keyObject = importDerKey(keyData, true); break; } case 'pkcs8': { verifyAcceptableEcKeyUse(name, false, usagesSet); - try { - keyObject = createPrivateKey({ - key: keyData, - format: 'der', - type: 'pkcs8', - }); - } catch (err) { - throw lazyDOMException( - 'Invalid keyData', { name: 'DataError', cause: err }); - } + keyObject = importDerKey(keyData, false); break; } case 'jwk': { - if (!keyData.kty) - throw lazyDOMException('Invalid keyData', 'DataError'); - if (keyData.kty !== 'EC') - throw lazyDOMException('Invalid JWK "kty" Parameter', 'DataError'); + const expectedUse = name === 'ECDH' ? 'enc' : 'sig'; + validateJwk(keyData, 'EC', extractable, usagesSet, expectedUse); + if (keyData.crv !== namedCurve) throw lazyDOMException( 'JWK "crv" does not match the requested algorithm', 'DataError'); - verifyAcceptableEcKeyUse( - name, - keyData.d === undefined, ... [truncated]
← Back to Alerts View on GitHub →