diff --git a/benchmark/crypto/prepare-asymmetric-key.js b/benchmark/crypto/prepare-asymmetric-key.js new file mode 100644 index 000000000000..dd536c3f451c --- /dev/null +++ b/benchmark/crypto/prepare-asymmetric-key.js @@ -0,0 +1,178 @@ +'use strict'; + +const common = require('../common.js'); +const crypto = require('node:crypto'); +const fs = require('node:fs'); +const path = require('node:path'); +const { pathToFileURL } = require('node:url'); +const { hasOpenSSL, isBoringSSL } = require('../../test/common/crypto.js'); + +const inputs = [ + 'public-keyobject', 'public-keyobject-wrapped', 'public-pem-buffer-wrapped', + 'private-keyobject', 'private-keyobject-wrapped', + 'private-pem-string', 'private-pem-buffer', 'private-pem-arraybuffer', + 'private-pem-string-wrapped', 'private-pem-buffer-wrapped', 'private-pem-arraybuffer-wrapped', + 'private-jwk', + 'public-der-pkcs1-bytes', 'private-der-pkcs1-bytes', 'private-pem-pkcs8-bytes', + 'private-der-pkcs8-bytes', 'private-der-pkcs8-base64', 'private-der-pkcs8-hex', + 'public-der-spki-bytes', 'private-der-sec1-bytes', + 'raw-public-ec-buffer', 'raw-public-ed25519-buffer', + 'raw-private-ec-buffer', 'raw-private-ec-arraybuffer', 'raw-private-ed25519-buffer', + 'pem-passphrase-absent', 'pem-passphrase-empty', 'pem-passphrase-string', + 'pem-passphrase-base64', 'pem-passphrase-buffer', 'pem-passphrase-arraybuffer', + 'file-passphrase-absent', 'file-passphrase-string', 'file-direct', + 'provider-passphrase-absent', 'provider-passphrase-absent-properties', + 'provider-passphrase-empty', 'provider-passphrase-string', 'provider-passphrase-base64', + 'provider-passphrase-buffer', 'provider-passphrase-arraybuffer', 'provider-direct', +]; + +if (hasOpenSSL(3, 5) || isBoringSSL) + inputs.push('raw-seed-ml-dsa-44-buffer'); + +const bench = common.createBenchmark(main, { + input: inputs, + context: ['consume-public', 'consume-private', 'create-public'], + n: [1e6], +}, { + flags: ['--expose-internals'], + combinationFilter({ input, context }) { + if (input === 'private-keyobject' || input === 'private-keyobject-wrapped') + return true; + if (input.startsWith('public-') || input.startsWith('raw-public-')) + return context === 'consume-public'; + return context === 'consume-private'; + }, +}); + +function main({ input, context, n }) { + const { + prepareAsymmetricKey, + kConsumePublic, + kConsumePrivate, + kCreatePublic, + } = require('internal/crypto/keys'); + const ctx = { + 'consume-public': kConsumePublic, + 'consume-private': kConsumePrivate, + 'create-public': kCreatePublic, + }[context]; + const fixtureDir = path.resolve(__dirname, '../../test/fixtures/keys'); + const privateKeyPath = path.join(fixtureDir, 'rsa_private_2048.pem'); + const privateKey = crypto.createPrivateKey(fs.readFileSync(privateKeyPath)); + const publicKey = crypto.createPublicKey(privateKey); + const pem = privateKey.export({ format: 'pem', type: 'pkcs8' }); + const buffer = Buffer.from(pem); + const arraybuffer = Uint8Array.from(buffer).buffer; + const fileURL = pathToFileURL(privateKeyPath); + const providerURL = new URL('pkcs11:object=signing-key;type=private'); + const passphrase = Buffer.from('password'); + + let key; + switch (input) { + case 'public-keyobject': key = publicKey; break; + case 'public-keyobject-wrapped': key = { key: publicKey }; break; + case 'public-pem-buffer-wrapped': + key = { key: Buffer.from(publicKey.export({ format: 'pem', type: 'spki' })) }; + break; + case 'private-keyobject': key = privateKey; break; + case 'private-keyobject-wrapped': key = { key: privateKey }; break; + case 'private-pem-string': key = pem; break; + case 'private-pem-buffer': key = buffer; break; + case 'private-pem-arraybuffer': key = arraybuffer; break; + case 'private-pem-string-wrapped': key = { key: pem }; break; + case 'private-pem-buffer-wrapped': key = { key: buffer }; break; + case 'private-pem-arraybuffer-wrapped': key = { key: arraybuffer }; break; + case 'private-jwk': key = { key: privateKey.export({ format: 'jwk' }), format: 'jwk' }; break; + case 'public-der-pkcs1-bytes': key = { key: publicKey, format: 'der', type: 'pkcs1' }; break; + case 'private-der-pkcs1-bytes': key = { key: privateKey, format: 'der', type: 'pkcs1' }; break; + case 'private-pem-pkcs8-bytes': key = { key: privateKey, format: 'pem', type: 'pkcs8' }; break; + case 'private-der-pkcs8-bytes': key = { key: privateKey, format: 'der', type: 'pkcs8' }; break; + case 'private-der-pkcs8-base64': + key = { key: privateKey, format: 'der', type: 'pkcs8', encoding: 'base64' }; + break; + case 'private-der-pkcs8-hex': + key = { key: privateKey, format: 'der', type: 'pkcs8', encoding: 'hex' }; + break; + case 'public-der-spki-bytes': key = { key: publicKey, format: 'der', type: 'spki' }; break; + case 'private-der-sec1-bytes': + key = { + key: crypto.createPrivateKey(fs.readFileSync(path.join(fixtureDir, 'ec_p256_private.pem'))), + format: 'der', type: 'sec1', + }; + break; + case 'raw-public-ec-buffer': + case 'raw-public-ed25519-buffer': + case 'raw-private-ec-buffer': + case 'raw-private-ec-arraybuffer': + case 'raw-private-ed25519-buffer': { + const asymmetricKeyType = input.includes('-ec-') ? 'ec' : 'ed25519'; + const fixture = asymmetricKeyType === 'ec' ? 'ec_p256_private.pem' : 'ed25519_private.pem'; + let rawKey = crypto.createPrivateKey(fs.readFileSync(path.join(fixtureDir, fixture))); + const format = input.startsWith('raw-public-') ? 'raw-public' : 'raw-private'; + if (format === 'raw-public') rawKey = crypto.createPublicKey(rawKey); + const bytes = rawKey.export({ format }); + key = { + key: input.endsWith('-arraybuffer') ? Uint8Array.from(bytes).buffer : bytes, + format, + asymmetricKeyType, + namedCurve: asymmetricKeyType === 'ec' ? 'prime256v1' : undefined, + }; + break; + } + case 'raw-seed-ml-dsa-44-buffer': { + const seedKey = crypto.createPrivateKey( + fs.readFileSync(path.join(fixtureDir, 'ml_dsa_44_private_seed_only.pem'))); + key = { key: seedKey.export({ format: 'raw-seed' }), format: 'raw-seed', asymmetricKeyType: 'ml-dsa-44' }; + break; + } + case 'pem-passphrase-absent': key = { key: privateKey }; break; + case 'pem-passphrase-empty': key = { key: privateKey, passphrase: '' }; break; + case 'pem-passphrase-string': key = { key: privateKey, passphrase: 'password' }; break; + case 'pem-passphrase-base64': + key = { key: privateKey, passphrase: passphrase.toString('base64'), encoding: 'base64' }; + break; + case 'pem-passphrase-buffer': key = { key: privateKey, passphrase }; break; + case 'pem-passphrase-arraybuffer': + key = { key: privateKey, passphrase: Uint8Array.from(passphrase).buffer }; + break; + case 'file-passphrase-absent': key = { key: fileURL }; break; + case 'file-passphrase-string': key = { key: fileURL, passphrase: 'password' }; break; + case 'file-direct': key = fileURL; break; + case 'provider-passphrase-absent': key = { key: providerURL }; break; + case 'provider-passphrase-absent-properties': key = { key: providerURL, properties: 'provider=default' }; break; + case 'provider-passphrase-empty': key = { key: providerURL, passphrase: '' }; break; + case 'provider-passphrase-string': key = { key: providerURL, passphrase: 'password' }; break; + case 'provider-passphrase-base64': + key = { key: providerURL, passphrase: passphrase.toString('base64'), encoding: 'base64' }; + break; + case 'provider-passphrase-buffer': key = { key: providerURL, passphrase }; break; + case 'provider-passphrase-arraybuffer': + key = { key: providerURL, passphrase: Uint8Array.from(passphrase).buffer }; + break; + case 'provider-direct': key = providerURL; break; + default: throw new Error(`Unsupported input: ${input}`); + } + + if (input.startsWith('pem-passphrase-')) { + const bytes = key.passphrase === undefined ? undefined : + typeof key.passphrase === 'string' ? Buffer.from(key.passphrase, key.encoding) : + Buffer.from(key.passphrase); + key = { + key: Buffer.from(privateKey.export({ + format: 'pem', type: 'pkcs8', + ...(bytes === undefined ? {} : { cipher: 'aes-256-cbc', passphrase: bytes }), + })), + format: 'pem', type: 'pkcs8', passphrase: key.passphrase, encoding: key.encoding, + }; + } else if (key.key instanceof crypto.KeyObject && key.format !== undefined) { + const bytes = Buffer.from(key.key.export({ format: key.format, type: key.type })); + key.key = key.encoding === undefined ? bytes : bytes.toString(key.encoding); + } + + prepareAsymmetricKey(key, ctx); + bench.start(); + for (let index = 0; index < n; index++) { + prepareAsymmetricKey(key, ctx); + } + bench.end(n); +} diff --git a/lib/internal/crypto/keys.js b/lib/internal/crypto/keys.js index 152db0a5f5d1..c935bc1e0d61 100644 --- a/lib/internal/crypto/keys.js +++ b/lib/internal/crypto/keys.js @@ -91,6 +91,7 @@ const { } = require('internal/util'); const { inspect } = require('internal/util/inspect'); +const { Buffer } = require('buffer'); // Key input contexts. const kConsumePublic = 0; @@ -378,7 +379,7 @@ const { return [KeyObject, SecretKeyObject, PublicKeyObject, PrivateKeyObject]; }); -function parseKeyFormat(formatStr, defaultFormat, optionName) { +function parseKeyFormat(formatStr, defaultFormat, objName) { if (formatStr === undefined && defaultFormat !== undefined) return defaultFormat; else if (formatStr === 'pem') @@ -393,10 +394,10 @@ function parseKeyFormat(formatStr, defaultFormat, optionName) { return kKeyFormatRawPrivate; else if (formatStr === 'raw-seed') return kKeyFormatRawSeed; - throw new ERR_INVALID_ARG_VALUE(optionName, formatStr); + throw new ERR_INVALID_ARG_VALUE(option('format', objName), formatStr); } -function parseKeyType(typeStr, required, keyType, isPublic, optionName) { +function parseKeyType(typeStr, required, keyType, isPublic, objName) { if (typeStr === undefined && !required) { return undefined; } else if (typeStr === 'pkcs1') { @@ -417,7 +418,7 @@ function parseKeyType(typeStr, required, keyType, isPublic, optionName) { return kKeyEncodingSEC1; } - throw new ERR_INVALID_ARG_VALUE(optionName, typeStr); + throw new ERR_INVALID_ARG_VALUE(option('type', objName), typeStr); } function option(name, prefix) { @@ -431,7 +432,7 @@ function parseKeyFormatAndType(enc, keyType, isPublic, objName) { const isInput = keyType === undefined; const format = parseKeyFormat(formatStr, isInput ? kKeyFormatPEM : undefined, - option('format', objName)); + objName); if (format === kKeyFormatRawPublic) { if (isPublic === false) { @@ -467,7 +468,7 @@ function parseKeyFormatAndType(enc, keyType, isPublic, objName) { isRequired, keyType, isPublic, - option('type', objName)); + objName); return { format, type }; } @@ -477,6 +478,10 @@ function isStringOrBuffer(val) { isAnyArrayBuffer(val); } +function encodeKeyString(value, encoding) { + return Buffer.from(value, encoding === 'buffer' ? 'utf8' : encoding); +} + function parseKeyEncoding(enc, keyType, isPublic, objName) { validateObject(enc, 'options'); @@ -524,8 +529,8 @@ function parseKeyEncoding(enc, keyType, isPublic, objName) { } } - if (passphrase !== undefined) - passphrase = getArrayBufferOrView(passphrase, 'key.passphrase', encoding); + if (typeof passphrase === 'string') + passphrase = encodeKeyString(passphrase, encoding); return { format, type, cipher, passphrase }; } @@ -553,11 +558,11 @@ function validateAsymmetricKeyType(type, ctx, key) { ); } - if (type !== 'private') { + if (type !== kKeyTypePrivate) { if (ctx === kConsumePrivate || ctx === kCreatePublic) - throw new ERR_CRYPTO_INVALID_KEY_OBJECT_TYPE(type, 'private'); - if (type !== 'public') { - throw new ERR_CRYPTO_INVALID_KEY_OBJECT_TYPE(type, + throw new ERR_CRYPTO_INVALID_KEY_OBJECT_TYPE(keyObjectTypeToString(type), 'private'); + if (type !== kKeyTypePublic) { + throw new ERR_CRYPTO_INVALID_KEY_OBJECT_TYPE(keyObjectTypeToString(type), 'private or public'); } } @@ -599,10 +604,9 @@ function prepareStorePrivateKey(url, name, passphrase, encoding, properties) { if (!isStringOrBuffer(passphrase)) { throw new ERR_INVALID_ARG_VALUE(option('passphrase', name), passphrase); } - passphrase = getArrayBufferOrView( - passphrase, - option('passphrase', name), - encoding); + if (typeof passphrase === 'string') { + passphrase = encodeKeyString(passphrase, encoding); + } } if (properties !== undefined) { @@ -627,96 +631,113 @@ function prepareStorePrivateKey(url, name, passphrase, encoding, properties) { } function prepareAsymmetricKey(key, ctx, name = 'key') { + if (typeof key === 'string') { + return { format: kKeyFormatPEM, data: encodeKeyString(key) }; + } + if (isArrayBufferView(key)) { + return { format: kKeyFormatPEM, data: key }; + } const isPrivateKeyInput = ctx === kConsumePrivate || ctx === kCreatePrivate; if (isKeyObject(key)) { // Best case: A key object, as simple as that. - const type = getKeyObjectType(key); - validateAsymmetricKeyType(type, ctx, key); - return { data: getKeyObjectHandle(key) }; + const slots = getKeyObjectSlots(key); + validateAsymmetricKeyType(slots[kKeyObjectSlotType], ctx, key); + return { data: slots[kKeyObjectSlotHandle] }; } if (isPrivateKeyInput && isURLInstance(key)) { // A private key referenced by a URI for an OpenSSL STORE loader. return prepareStorePrivateKey(key, name); } - if (isStringOrBuffer(key)) { - // Expect PEM by default, mostly for backward compatibility. - return { format: kKeyFormatPEM, data: getArrayBufferOrView(key, name) }; + if (isAnyArrayBuffer(key)) { + return { format: kKeyFormatPEM, data: key }; } if (typeof key === 'object') { - const { key: data, encoding, format, properties } = key; - - // The 'key' property can be a KeyObject as well to allow specifying - // additional options such as padding along with the key. - if (isKeyObject(data)) { - const type = getKeyObjectType(data); - validateAsymmetricKeyType(type, ctx, data); - return { data: getKeyObjectHandle(data) }; - } - if (isPrivateKeyInput && isURLInstance(data)) { - // A private key referenced by a URI for an OpenSSL STORE loader, with - // an optional passphrase/PIN. - return prepareStorePrivateKey( - data, - name, - key.passphrase, - encoding, - properties); - } - if (format === 'jwk') { - validateObject(data, `${name}.key`); - return { data, format: kKeyFormatJWK }; - } else if (format === 'raw-public' || format === 'raw-private' || + return prepareAsymmetricKeyOptions(key, ctx, name, isPrivateKeyInput); + } + + throw new ERR_INVALID_ARG_TYPE( + name, + getKeyTypes({ + allowKeyObject: ctx !== kCreatePrivate, + allowURL: isPrivateKeyInput, + }), + key); +} + +function prepareAsymmetricKeyOptions(key, ctx, name, isPrivateKeyInput) { + const { key: data, encoding, format, properties } = key; + let isData = typeof data === 'string' || isArrayBufferView(data); + + // The 'key' property can be a KeyObject as well to allow specifying + // additional options such as padding along with the key. + if (!isData && isKeyObject(data)) { + const slots = getKeyObjectSlots(data); + validateAsymmetricKeyType(slots[kKeyObjectSlotType], ctx, data); + return { data: slots[kKeyObjectSlotHandle] }; + } + isData ||= isAnyArrayBuffer(data); + if (!isData && isPrivateKeyInput && isURLInstance(data)) { + // A private key referenced by a URI for an OpenSSL STORE loader, with + // an optional passphrase/PIN. + return prepareStorePrivateKey( + data, + name, + key.passphrase, + encoding, + properties); + } + if (format === 'jwk') { + validateObject(data, `${name}.key`); + return { data, format: kKeyFormatJWK }; + } + if (format === 'raw-public' || format === 'raw-private' || format === 'raw-seed') { - if ((ctx === kConsumePrivate || ctx === kCreatePrivate) && - format === 'raw-public') { - throw new ERR_INVALID_ARG_VALUE(`${name}.format`, format); - } - if (!isArrayBufferView(data) && !isAnyArrayBuffer(data)) { - throw new ERR_INVALID_ARG_TYPE( - `${name}.key`, - ['ArrayBuffer', 'Buffer', 'TypedArray', 'DataView'], - data); - } - validateString(key.asymmetricKeyType, `${name}.asymmetricKeyType`); - if (key.asymmetricKeyType === 'ec') { - validateString(key.namedCurve, `${name}.namedCurve`); - } - const rawFormat = parseKeyFormat(format, undefined, `${name}.format`); - return { - data: getArrayBufferOrView(data, `${name}.key`), - format: rawFormat, - type: key.asymmetricKeyType, - namedCurve: key.namedCurve ?? null, - }; + if (isPrivateKeyInput && format === 'raw-public') { + throw new ERR_INVALID_ARG_VALUE(`${name}.format`, format); } - - // Either PEM or DER using PKCS#1 or SPKI. - if (!isStringOrBuffer(data)) { + if (!isData || typeof data === 'string') { throw new ERR_INVALID_ARG_TYPE( `${name}.key`, - getKeyTypes({ - allowKeyObject: ctx !== kCreatePrivate, - allowURL: isPrivateKeyInput, - }), + ['ArrayBuffer', 'Buffer', 'TypedArray', 'DataView'], data); } - - const isPublic = - (ctx === kConsumePrivate || ctx === kCreatePrivate) ? false : undefined; + validateString(key.asymmetricKeyType, `${name}.asymmetricKeyType`); + if (key.asymmetricKeyType === 'ec') { + validateString(key.namedCurve, `${name}.namedCurve`); + } + const rawFormat = format === 'raw-public' ? kKeyFormatRawPublic : + format === 'raw-private' ? kKeyFormatRawPrivate : kKeyFormatRawSeed; return { - data: getArrayBufferOrView(data, `${name}.key`, encoding), - ...parseKeyEncoding(key, undefined, isPublic, name), + data, + format: rawFormat, + type: key.asymmetricKeyType, + namedCurve: key.namedCurve ?? null, }; } - throw new ERR_INVALID_ARG_TYPE( - name, - getKeyTypes({ - allowKeyObject: ctx !== kCreatePrivate, - allowURL: isPrivateKeyInput, - }), - key); + // Either PEM or DER using PKCS#1 or SPKI. + if (!isData) { + throw new ERR_INVALID_ARG_TYPE( + `${name}.key`, + getKeyTypes({ + allowKeyObject: ctx !== kCreatePrivate, + allowURL: isPrivateKeyInput, + }), + data); + } + + const isPublic = isPrivateKeyInput ? false : undefined; + const keyData = typeof data === 'string' ? + encodeKeyString(data, encoding) : data; + const parsed = parseKeyEncoding(key, undefined, isPublic, name); + return { + data: keyData, + format: parsed.format, + type: parsed.type, + cipher: parsed.cipher, + passphrase: parsed.passphrase, + }; } function preparePrivateKey(key, name) { @@ -1414,6 +1435,11 @@ module.exports = { parsePrivateKeyEncoding, parseKeyEncoding, toPublicCryptoKey, + prepareAsymmetricKey, + kConsumePublic, + kConsumePrivate, + kCreatePublic, + kCreatePrivate, preparePrivateKey, preparePublicOrPrivateKey, prepareSecretKey, diff --git a/test/parallel/test-crypto-prepare-asymmetric-key.js b/test/parallel/test-crypto-prepare-asymmetric-key.js new file mode 100644 index 000000000000..de5ea1b483d1 --- /dev/null +++ b/test/parallel/test-crypto-prepare-asymmetric-key.js @@ -0,0 +1,101 @@ +'use strict'; + +// Flags: --expose-internals + +const common = require('../common'); +if (!common.hasCrypto) common.skip('missing crypto'); +const assert = require('node:assert'); +const crypto = require('node:crypto'); +const fixtures = require('../common/fixtures'); +const { + prepareAsymmetricKey, + getKeyObjectHandle, + kConsumePublic, + kConsumePrivate, + kCreatePublic, + kCreatePrivate, +} = require('internal/crypto/keys'); + +const pem = fixtures.readKey('ec_p256_private.pem'); +const keyObject = crypto.createPrivateKey(pem); +const contexts = [kConsumePublic, kConsumePrivate, kCreatePublic, kCreatePrivate]; + +for (const input of [ + pem, + new Uint8Array(pem), + new DataView(Uint8Array.from(pem).buffer), + Uint8Array.from(pem).buffer, + new SharedArrayBuffer(pem.length), +]) { + Object.defineProperty(input, 'key', { get: common.mustNotCall() }); + Object.defineProperty(input, 'format', { get: common.mustNotCall() }); + Object.defineProperty(input, Symbol.toStringTag, { value: 'KeyObject' }); + for (const context of contexts) { + assert.strictEqual(prepareAsymmetricKey(input, context).data, input); + assert.strictEqual(prepareAsymmetricKey({ key: input }, context).data, input); + } +} + +for (const context of [kConsumePublic, kConsumePrivate, kCreatePublic]) { + const options = { key: keyObject, format: 'raw-public', asymmetricKeyType: 'invalid' }; + assert.strictEqual(prepareAsymmetricKey(options, context).data, getKeyObjectHandle(keyObject)); +} + +{ + const reads = []; + const values = { key: pem.toString(), encoding: 'utf8', format: 'pem' }; + const options = {}; + for (const name of ['key', 'encoding', 'format', 'properties', 'type', 'cipher', 'passphrase']) { + Object.defineProperty(options, name, { + get() { + reads.push(name); + return values[name]; + }, + }); + } + assert.deepStrictEqual(prepareAsymmetricKey(options, kConsumePrivate).data, pem); + assert.deepStrictEqual(reads, [ + 'key', 'encoding', 'format', 'properties', 'format', 'type', 'cipher', 'passphrase', 'encoding', + ]); +} + +{ + const input = { + key: pem.toString(), + encoding: 'invalid', + get format() { return 'pem'; }, + get type() { return assert.fail('String conversion must precede encoding option parsing'); }, + }; + assert.throws(() => prepareAsymmetricKey(input, kConsumePrivate), { code: 'ERR_UNKNOWN_ENCODING' }); +} + +{ + const raw = keyObject.export({ format: 'raw-private' }); + const input = { key: raw, format: 'raw-private', asymmetricKeyType: 'ec', namedCurve: 'P-256' }; + assert.strictEqual(prepareAsymmetricKey(input, kConsumePrivate).data, raw); + assert.throws(() => prepareAsymmetricKey({ ...input, key: raw.toString('hex') }, kConsumePrivate), { + code: 'ERR_INVALID_ARG_TYPE', + }); +} + +for (const encoding of [undefined, 'buffer', 'utf8', 'hex', 'base64']) { + const inputEncoding = encoding === 'buffer' ? 'utf8' : encoding; + const passphrase = Buffer.from('password'); + const result = prepareAsymmetricKey({ + key: pem.toString(inputEncoding), + passphrase: passphrase.toString(inputEncoding), + encoding, + }, kConsumePrivate); + assert.deepStrictEqual(result.data, pem); + assert.deepStrictEqual(result.passphrase, passphrase); +} + +for (const [options, optionName] of [ + [{ format: 'invalid' }, 'format'], + [{ format: 'der', type: 'invalid' }, 'type'], +]) { + assert.throws(() => prepareAsymmetricKey({ key: pem, ...options }, kConsumePrivate, 'options.key'), { + code: 'ERR_INVALID_ARG_VALUE', + message: new RegExp(`options\\.key\\.${optionName}`), + }); +}