From 69ae6f424f01cf38c6f5a7b5eccc8016678bba0c Mon Sep 17 00:00:00 2001 From: Filip Skokan Date: Sat, 28 Mar 2026 21:17:39 +0100 Subject: [PATCH] crypto: recognize raw formats in keygen Signed-off-by: Filip Skokan --- deps/ncrypto/ncrypto.h | 4 + lib/internal/crypto/keys.js | 44 ++++ src/crypto/crypto_keys.cc | 156 ++++++++++++- test/parallel/test-crypto-keygen-raw.js | 285 ++++++++++++++++++++++++ 4 files changed, 488 insertions(+), 1 deletion(-) create mode 100644 test/parallel/test-crypto-keygen-raw.js diff --git a/deps/ncrypto/ncrypto.h b/deps/ncrypto/ncrypto.h index c781d7e2e0288f..4f86702da88267 100644 --- a/deps/ncrypto/ncrypto.h +++ b/deps/ncrypto/ncrypto.h @@ -858,6 +858,9 @@ class EVPKeyPointer final { DER, PEM, JWK, + RAW_PUBLIC, + RAW_PRIVATE, + RAW_SEED, }; enum class PKParseError { NOT_RECOGNIZED, NEED_PASSPHRASE, FAILED }; @@ -867,6 +870,7 @@ class EVPKeyPointer final { bool output_key_object = false; PKFormatType format = PKFormatType::DER; PKEncodingType type = PKEncodingType::PKCS8; + int ec_point_form = POINT_CONVERSION_UNCOMPRESSED; AsymmetricKeyEncodingConfig() = default; AsymmetricKeyEncodingConfig(bool output_key_object, PKFormatType format, diff --git a/lib/internal/crypto/keys.js b/lib/internal/crypto/keys.js index 80c073a1dbfac1..6a10107da4cd02 100644 --- a/lib/internal/crypto/keys.js +++ b/lib/internal/crypto/keys.js @@ -21,6 +21,9 @@ const { kKeyFormatPEM, kKeyFormatDER, kKeyFormatJWK, + kKeyFormatRawPublic, + kKeyFormatRawPrivate, + kKeyFormatRawSeed, kKeyEncodingPKCS1, kKeyEncodingPKCS8, kKeyEncodingSPKI, @@ -419,6 +422,12 @@ function parseKeyFormat(formatStr, defaultFormat, optionName) { return kKeyFormatDER; else if (formatStr === 'jwk') return kKeyFormatJWK; + else if (formatStr === 'raw-public') + return kKeyFormatRawPublic; + else if (formatStr === 'raw-private') + return kKeyFormatRawPrivate; + else if (formatStr === 'raw-seed') + return kKeyFormatRawSeed; throw new ERR_INVALID_ARG_VALUE(optionName, formatStr); } @@ -459,6 +468,33 @@ function parseKeyFormatAndType(enc, keyType, isPublic, objName) { isInput ? kKeyFormatPEM : undefined, option('format', objName)); + if (format === kKeyFormatRawPublic) { + if (isPublic === false) { + throw new ERR_INVALID_ARG_VALUE(option('format', objName), 'raw-public'); + } + let type; + if (typeStr === undefined || typeStr === 'uncompressed') { + type = POINT_CONVERSION_UNCOMPRESSED; + } else if (typeStr === 'compressed') { + type = POINT_CONVERSION_COMPRESSED; + } else { + throw new ERR_INVALID_ARG_VALUE(option('type', objName), typeStr); + } + return { format, type }; + } + + if (format === kKeyFormatRawPrivate || format === kKeyFormatRawSeed) { + if (isPublic === true) { + throw new ERR_INVALID_ARG_VALUE( + option('format', objName), + format === kKeyFormatRawPrivate ? 'raw-private' : 'raw-seed'); + } + if (typeStr !== undefined) { + throw new ERR_INVALID_ARG_VALUE(option('type', objName), typeStr); + } + return { format }; + } + const isRequired = (!isInput || format === kKeyFormatDER) && format !== kKeyFormatJWK; @@ -490,6 +526,14 @@ function parseKeyEncoding(enc, keyType, isPublic, objName) { if (isPublic !== true) { ({ cipher, passphrase, encoding } = enc); + if (format === kKeyFormatRawPrivate || format === kKeyFormatRawSeed) { + if (cipher != null || passphrase !== undefined) { + throw new ERR_CRYPTO_INCOMPATIBLE_KEY_OPTIONS( + 'raw format', 'does not support encryption'); + } + return { format, type }; + } + if (!isInput) { if (cipher != null) { if (typeof cipher !== 'string') diff --git a/src/crypto/crypto_keys.cc b/src/crypto/crypto_keys.cc index 3fdbe9eacdf176..bcea72facc7ca9 100644 --- a/src/crypto/crypto_keys.cc +++ b/src/crypto/crypto_keys.cc @@ -66,7 +66,16 @@ Maybe GetKeyFormatAndTypeFromJs( config.format = static_cast( args[*offset].As()->Value()); - if (args[*offset + 1]->IsInt32()) { + if (config.format == EVPKeyPointer::PKFormatType::RAW_PUBLIC || + config.format == EVPKeyPointer::PKFormatType::RAW_PRIVATE || + config.format == EVPKeyPointer::PKFormatType::RAW_SEED) { + // Raw formats use the type slot for ec_point_form (int) or null. + if (args[*offset + 1]->IsInt32()) { + config.ec_point_form = args[*offset + 1].As()->Value(); + } else { + CHECK(args[*offset + 1]->IsNullOrUndefined()); + } + } else if (args[*offset + 1]->IsInt32()) { config.type = static_cast( args[*offset + 1].As()->Value()); } else { @@ -329,6 +338,54 @@ bool KeyObjectData::ToEncodedPublicKey( *out = Object::New(env->isolate()); return ExportJWKInner( env, addRefWithType(KeyType::kKeyTypePublic), *out, false); + } else if (config.format == EVPKeyPointer::PKFormatType::RAW_PUBLIC) { + Mutex::ScopedLock lock(mutex()); + const auto& pkey = GetAsymmetricKey(); + if (pkey.id() == EVP_PKEY_EC) { + const EC_KEY* ec_key = pkey; + CHECK_NOT_NULL(ec_key); + auto form = static_cast(config.ec_point_form); + const auto group = ECKeyPointer::GetGroup(ec_key); + const auto point = ECKeyPointer::GetPublicKey(ec_key); + return ECPointToBuffer(env, group, point, form).ToLocal(out); + } + switch (pkey.id()) { + case EVP_PKEY_ED25519: + case EVP_PKEY_ED448: + case EVP_PKEY_X25519: + case EVP_PKEY_X448: +#if OPENSSL_WITH_PQC + case EVP_PKEY_ML_DSA_44: + case EVP_PKEY_ML_DSA_65: + case EVP_PKEY_ML_DSA_87: + case EVP_PKEY_ML_KEM_512: + case EVP_PKEY_ML_KEM_768: + case EVP_PKEY_ML_KEM_1024: + case EVP_PKEY_SLH_DSA_SHA2_128F: + case EVP_PKEY_SLH_DSA_SHA2_128S: + case EVP_PKEY_SLH_DSA_SHA2_192F: + case EVP_PKEY_SLH_DSA_SHA2_192S: + case EVP_PKEY_SLH_DSA_SHA2_256F: + case EVP_PKEY_SLH_DSA_SHA2_256S: + case EVP_PKEY_SLH_DSA_SHAKE_128F: + case EVP_PKEY_SLH_DSA_SHAKE_128S: + case EVP_PKEY_SLH_DSA_SHAKE_192F: + case EVP_PKEY_SLH_DSA_SHAKE_192S: + case EVP_PKEY_SLH_DSA_SHAKE_256F: + case EVP_PKEY_SLH_DSA_SHAKE_256S: +#endif + break; + default: + THROW_ERR_CRYPTO_INCOMPATIBLE_KEY_OPTIONS(env); + return false; + } + auto raw_data = pkey.rawPublicKey(); + if (!raw_data) { + THROW_ERR_CRYPTO_OPERATION_FAILED(env, "Failed to get raw public key"); + return false; + } + return Buffer::Copy(env, raw_data.get(), raw_data.size()) + .ToLocal(out); } return WritePublicKey(env, GetAsymmetricKey(), config).ToLocal(out); @@ -347,6 +404,86 @@ bool KeyObjectData::ToEncodedPrivateKey( *out = Object::New(env->isolate()); return ExportJWKInner( env, addRefWithType(KeyType::kKeyTypePrivate), *out, false); + } else if (config.format == EVPKeyPointer::PKFormatType::RAW_PRIVATE) { + Mutex::ScopedLock lock(mutex()); + const auto& pkey = GetAsymmetricKey(); + if (pkey.id() == EVP_PKEY_EC) { + const EC_KEY* ec_key = pkey; + CHECK_NOT_NULL(ec_key); + const BIGNUM* private_key = ECKeyPointer::GetPrivateKey(ec_key); + CHECK_NOT_NULL(private_key); + const auto group = ECKeyPointer::GetGroup(ec_key); + auto order = BignumPointer::New(); + CHECK(order); + CHECK(EC_GROUP_get_order(group, order.get(), nullptr)); + auto buf = BignumPointer::EncodePadded(private_key, order.byteLength()); + if (!buf) { + THROW_ERR_CRYPTO_OPERATION_FAILED(env, + "Failed to export EC private key"); + return false; + } + return Buffer::Copy(env, buf.get(), buf.size()).ToLocal(out); + } + switch (pkey.id()) { + case EVP_PKEY_ED25519: + case EVP_PKEY_ED448: + case EVP_PKEY_X25519: + case EVP_PKEY_X448: +#if OPENSSL_WITH_PQC + case EVP_PKEY_SLH_DSA_SHA2_128F: + case EVP_PKEY_SLH_DSA_SHA2_128S: + case EVP_PKEY_SLH_DSA_SHA2_192F: + case EVP_PKEY_SLH_DSA_SHA2_192S: + case EVP_PKEY_SLH_DSA_SHA2_256F: + case EVP_PKEY_SLH_DSA_SHA2_256S: + case EVP_PKEY_SLH_DSA_SHAKE_128F: + case EVP_PKEY_SLH_DSA_SHAKE_128S: + case EVP_PKEY_SLH_DSA_SHAKE_192F: + case EVP_PKEY_SLH_DSA_SHAKE_192S: + case EVP_PKEY_SLH_DSA_SHAKE_256F: + case EVP_PKEY_SLH_DSA_SHAKE_256S: +#endif + break; + default: + THROW_ERR_CRYPTO_INCOMPATIBLE_KEY_OPTIONS(env); + return false; + } + auto raw_data = pkey.rawPrivateKey(); + if (!raw_data) { + THROW_ERR_CRYPTO_OPERATION_FAILED(env, "Failed to get raw private key"); + return false; + } + return Buffer::Copy(env, raw_data.get(), raw_data.size()) + .ToLocal(out); + } else if (config.format == EVPKeyPointer::PKFormatType::RAW_SEED) { + Mutex::ScopedLock lock(mutex()); + const auto& pkey = GetAsymmetricKey(); + switch (pkey.id()) { +#if OPENSSL_WITH_PQC + case EVP_PKEY_ML_DSA_44: + case EVP_PKEY_ML_DSA_65: + case EVP_PKEY_ML_DSA_87: + case EVP_PKEY_ML_KEM_512: + case EVP_PKEY_ML_KEM_768: + case EVP_PKEY_ML_KEM_1024: + break; +#endif + default: + THROW_ERR_CRYPTO_INCOMPATIBLE_KEY_OPTIONS(env); + return false; + } +#if OPENSSL_WITH_PQC + auto raw_data = pkey.rawSeed(); + if (!raw_data) { + THROW_ERR_CRYPTO_OPERATION_FAILED(env, "Failed to get raw seed"); + return false; + } + return Buffer::Copy(env, raw_data.get(), raw_data.size()) + .ToLocal(out); +#else + THROW_ERR_CRYPTO_INCOMPATIBLE_KEY_OPTIONS(env); + return false; +#endif } return WritePrivateKey(env, GetAsymmetricKey(), config).ToLocal(out); @@ -367,6 +504,14 @@ KeyObjectData::GetPrivateKeyEncodingFromJs( if (config.output_key_object) { if (context != kKeyContextInput) (*offset)++; + } else if (config.format == EVPKeyPointer::PKFormatType::RAW_PRIVATE || + config.format == EVPKeyPointer::PKFormatType::RAW_SEED) { + // Raw formats don't support encryption. Still consume the arg offsets. + if (context != kKeyContextInput) { + CHECK(args[*offset]->IsNullOrUndefined()); + (*offset)++; + } + CHECK(args[*offset]->IsNullOrUndefined()); } else { bool needs_passphrase = false; if (context != kKeyContextInput) { @@ -1557,6 +1702,12 @@ void Initialize(Environment* env, Local target) { static_cast(EVPKeyPointer::PKFormatType::PEM); constexpr int kKeyFormatJWK = static_cast(EVPKeyPointer::PKFormatType::JWK); + constexpr int kKeyFormatRawPublic = + static_cast(EVPKeyPointer::PKFormatType::RAW_PUBLIC); + constexpr int kKeyFormatRawPrivate = + static_cast(EVPKeyPointer::PKFormatType::RAW_PRIVATE); + constexpr int kKeyFormatRawSeed = + static_cast(EVPKeyPointer::PKFormatType::RAW_SEED); constexpr auto kSigEncDER = DSASigEnc::DER; constexpr auto kSigEncP1363 = DSASigEnc::P1363; @@ -1596,6 +1747,9 @@ void Initialize(Environment* env, Local target) { NODE_DEFINE_CONSTANT(target, kKeyFormatDER); NODE_DEFINE_CONSTANT(target, kKeyFormatPEM); NODE_DEFINE_CONSTANT(target, kKeyFormatJWK); + NODE_DEFINE_CONSTANT(target, kKeyFormatRawPublic); + NODE_DEFINE_CONSTANT(target, kKeyFormatRawPrivate); + NODE_DEFINE_CONSTANT(target, kKeyFormatRawSeed); NODE_DEFINE_CONSTANT(target, kKeyTypeSecret); NODE_DEFINE_CONSTANT(target, kKeyTypePublic); NODE_DEFINE_CONSTANT(target, kKeyTypePrivate); diff --git a/test/parallel/test-crypto-keygen-raw.js b/test/parallel/test-crypto-keygen-raw.js new file mode 100644 index 00000000000000..fd2971dc2c86cd --- /dev/null +++ b/test/parallel/test-crypto-keygen-raw.js @@ -0,0 +1,285 @@ +'use strict'; + +const common = require('../common'); +if (!common.hasCrypto) + common.skip('missing crypto'); + +const assert = require('assert'); +const { + generateKeyPair, + generateKeyPairSync, + createPublicKey, + createPrivateKey, +} = require('crypto'); +const { hasOpenSSL } = require('../common/crypto'); + +// Test generateKeyPairSync with raw encoding for EdDSA/ECDH key types. +{ + const types = ['ed25519', 'x25519']; + if (!process.features.openssl_is_boringssl) { + types.push('ed448', 'x448'); + } + for (const type of types) { + const { publicKey, privateKey } = generateKeyPairSync(type, { + publicKeyEncoding: { format: 'raw-public' }, + privateKeyEncoding: { format: 'raw-private' }, + }); + + assert(Buffer.isBuffer(publicKey)); + assert(Buffer.isBuffer(privateKey)); + + // Roundtrip: import from raw, re-export, and compare. + const importedPub = createPublicKey({ + key: publicKey, + format: 'raw-public', + asymmetricKeyType: type, + }); + const importedPriv = createPrivateKey({ + key: privateKey, + format: 'raw-private', + asymmetricKeyType: type, + }); + + assert.deepStrictEqual(importedPub.export({ format: 'raw-public' }), + publicKey); + assert.deepStrictEqual(importedPriv.export({ format: 'raw-private' }), + privateKey); + } +} + +// Test async generateKeyPair with raw encoding for EdDSA/ECDH key types. +{ + const types = ['ed25519', 'x25519']; + if (!process.features.openssl_is_boringssl) { + types.push('ed448', 'x448'); + } + for (const type of types) { + generateKeyPair(type, { + publicKeyEncoding: { format: 'raw-public' }, + privateKeyEncoding: { format: 'raw-private' }, + }, common.mustSucceed((publicKey, privateKey) => { + assert(Buffer.isBuffer(publicKey)); + assert(Buffer.isBuffer(privateKey)); + })); + } +} + +// Test generateKeyPairSync with raw encoding for EC keys. +{ + for (const namedCurve of ['P-256', 'P-384', 'P-521']) { + const { publicKey, privateKey } = generateKeyPairSync('ec', { + namedCurve, + publicKeyEncoding: { format: 'raw-public' }, + privateKeyEncoding: { format: 'raw-private' }, + }); + + assert(Buffer.isBuffer(publicKey)); + assert(Buffer.isBuffer(privateKey)); + + // Roundtrip with EC. + const importedPub = createPublicKey({ + key: publicKey, + format: 'raw-public', + asymmetricKeyType: 'ec', + namedCurve, + }); + const importedPriv = createPrivateKey({ + key: privateKey, + format: 'raw-private', + asymmetricKeyType: 'ec', + namedCurve, + }); + + assert.deepStrictEqual(importedPub.export({ format: 'raw-public' }), + publicKey); + assert.deepStrictEqual(importedPriv.export({ format: 'raw-private' }), + privateKey); + } +} + +// Test EC raw-public with compressed and uncompressed point formats. +{ + const { publicKey: uncompressed } = generateKeyPairSync('ec', { + namedCurve: 'P-256', + publicKeyEncoding: { format: 'raw-public', type: 'uncompressed' }, + privateKeyEncoding: { format: 'raw-private' }, + }); + const { publicKey: compressed } = generateKeyPairSync('ec', { + namedCurve: 'P-256', + publicKeyEncoding: { format: 'raw-public', type: 'compressed' }, + privateKeyEncoding: { format: 'raw-private' }, + }); + + // Uncompressed P-256 public key is 65 bytes, compressed is 33 bytes. + assert.strictEqual(uncompressed.length, 65); + assert.strictEqual(compressed.length, 33); +} + +// Test mixed: one side raw, other side pem. +{ + const { publicKey, privateKey } = generateKeyPairSync('ed25519', { + publicKeyEncoding: { format: 'raw-public' }, + privateKeyEncoding: { format: 'pem', type: 'pkcs8' }, + }); + + assert(Buffer.isBuffer(publicKey)); + assert.strictEqual(typeof privateKey, 'string'); + assert(privateKey.startsWith('-----BEGIN PRIVATE KEY-----')); +} + +{ + const { publicKey, privateKey } = generateKeyPairSync('ed25519', { + publicKeyEncoding: { format: 'pem', type: 'spki' }, + privateKeyEncoding: { format: 'raw-private' }, + }); + + assert.strictEqual(typeof publicKey, 'string'); + assert(publicKey.startsWith('-----BEGIN PUBLIC KEY-----')); + assert(Buffer.isBuffer(privateKey)); +} + +// Test mixed: one side raw, other side KeyObject (no encoding). +{ + const { publicKey, privateKey } = generateKeyPairSync('ed25519', { + publicKeyEncoding: { format: 'raw-public' }, + }); + + assert(Buffer.isBuffer(publicKey)); + assert.strictEqual(privateKey.type, 'private'); +} + +// Test error: raw with RSA. +{ + assert.throws(() => generateKeyPairSync('rsa', { + modulusLength: 2048, + publicKeyEncoding: { format: 'raw-public' }, + privateKeyEncoding: { format: 'raw-private' }, + }), { code: 'ERR_CRYPTO_INCOMPATIBLE_KEY_OPTIONS' }); +} + +// Test error: raw with DSA. +{ + assert.throws(() => generateKeyPairSync('dsa', { + modulusLength: 2048, + publicKeyEncoding: { format: 'raw-public' }, + privateKeyEncoding: { format: 'raw-private' }, + }), { code: 'ERR_CRYPTO_INCOMPATIBLE_KEY_OPTIONS' }); +} + +// Test error: raw-private in publicKeyEncoding. +{ + assert.throws(() => generateKeyPairSync('ed25519', { + publicKeyEncoding: { format: 'raw-private' }, + privateKeyEncoding: { format: 'raw-private' }, + }), { code: 'ERR_INVALID_ARG_VALUE' }); +} + +// Test error: raw-seed in publicKeyEncoding. +{ + assert.throws(() => generateKeyPairSync('ed25519', { + publicKeyEncoding: { format: 'raw-seed' }, + privateKeyEncoding: { format: 'raw-private' }, + }), { code: 'ERR_INVALID_ARG_VALUE' }); +} + +// Test error: raw-public in privateKeyEncoding. +{ + assert.throws(() => generateKeyPairSync('ed25519', { + publicKeyEncoding: { format: 'raw-public' }, + privateKeyEncoding: { format: 'raw-public' }, + }), { code: 'ERR_INVALID_ARG_VALUE' }); +} + +// Test error: passphrase with raw private key encoding. +{ + assert.throws(() => generateKeyPairSync('ed25519', { + publicKeyEncoding: { format: 'raw-public' }, + privateKeyEncoding: { + format: 'raw-private', + cipher: 'aes-256-cbc', + passphrase: 'secret', + }, + }), { code: 'ERR_CRYPTO_INCOMPATIBLE_KEY_OPTIONS' }); +} + +// PQC key types +if (hasOpenSSL(3, 5)) { + // Test raw encoding for ML-DSA key types (raw-public + raw-seed only). + { + for (const type of ['ml-dsa-44', 'ml-dsa-65', 'ml-dsa-87']) { + const { publicKey, privateKey } = generateKeyPairSync(type, { + publicKeyEncoding: { format: 'raw-public' }, + privateKeyEncoding: { format: 'raw-seed' }, + }); + + assert(Buffer.isBuffer(publicKey)); + assert(Buffer.isBuffer(privateKey)); + // raw-seed output should be 32 bytes for ML-DSA. + assert.strictEqual(privateKey.length, 32); + } + } + + // Test error: raw-private with ML-DSA (not supported). + { + assert.throws(() => generateKeyPairSync('ml-dsa-44', { + publicKeyEncoding: { format: 'raw-public' }, + privateKeyEncoding: { format: 'raw-private' }, + }), { code: 'ERR_CRYPTO_INCOMPATIBLE_KEY_OPTIONS' }); + } + + // Test raw encoding for ML-KEM key types (raw-public + raw-seed only). + { + for (const type of ['ml-kem-512', 'ml-kem-768', 'ml-kem-1024']) { + const { publicKey, privateKey } = generateKeyPairSync(type, { + publicKeyEncoding: { format: 'raw-public' }, + privateKeyEncoding: { format: 'raw-seed' }, + }); + + assert(Buffer.isBuffer(publicKey)); + assert(Buffer.isBuffer(privateKey)); + // raw-seed output should be 64 bytes for ML-KEM. + assert.strictEqual(privateKey.length, 64); + } + } + + // Test error: raw-private with ML-KEM (not supported). + { + assert.throws(() => generateKeyPairSync('ml-kem-512', { + publicKeyEncoding: { format: 'raw-public' }, + privateKeyEncoding: { format: 'raw-private' }, + }), { code: 'ERR_CRYPTO_INCOMPATIBLE_KEY_OPTIONS' }); + } + + // Test raw encoding for SLH-DSA key types. + { + for (const type of ['slh-dsa-sha2-128f', 'slh-dsa-shake-128f']) { + const { publicKey, privateKey } = generateKeyPairSync(type, { + publicKeyEncoding: { format: 'raw-public' }, + privateKeyEncoding: { format: 'raw-private' }, + }); + + assert(Buffer.isBuffer(publicKey)); + assert(Buffer.isBuffer(privateKey)); + } + } + + // Test error: raw-seed with SLH-DSA (not supported). + { + assert.throws(() => generateKeyPairSync('slh-dsa-sha2-128f', { + publicKeyEncoding: { format: 'raw-public' }, + privateKeyEncoding: { format: 'raw-seed' }, + }), { code: 'ERR_CRYPTO_INCOMPATIBLE_KEY_OPTIONS' }); + } + + // Test async generateKeyPair with raw encoding for PQC types. + { + generateKeyPair('ml-dsa-44', { + publicKeyEncoding: { format: 'raw-public' }, + privateKeyEncoding: { format: 'raw-seed' }, + }, common.mustSucceed((publicKey, privateKey) => { + assert(Buffer.isBuffer(publicKey)); + assert(Buffer.isBuffer(privateKey)); + assert.strictEqual(privateKey.length, 32); + })); + } +}