From 1c4fe6d795ca8e318503fe0d78585d9ba99a31df Mon Sep 17 00:00:00 2001 From: Aditi <62544124+Aditi-1400@users.noreply.github.com> Date: Tue, 8 Jul 2025 19:09:00 +0530 Subject: [PATCH] crypto: support outputLength option in crypto.hash for XOF functions Support `outputLength` option in crypto.hash() for XOF hash functions to align with the behaviour of crypto.createHash() API closes: https://github.com/nodejs/node/issues/57312 Co-authored-by: Filip Skokan PR-URL: https://github.com/nodejs/node/pull/58121 Fixes: https://github.com/nodejs/node/issues/57312 Reviewed-By: Joyee Cheung Reviewed-By: Filip Skokan Reviewed-By: Rafael Gonzaga --- deps/ncrypto/ncrypto.cc | 16 +++ deps/ncrypto/ncrypto.h | 5 + doc/api/crypto.md | 15 ++- lib/internal/crypto/hash.js | 33 +++-- src/crypto/crypto_hash.cc | 70 ++++++++-- ...st-crypto-default-shake-lengths-oneshot.js | 18 +++ test/parallel/test-crypto-oneshot-hash-xof.js | 122 ++++++++++++++++++ test/parallel/test-crypto-oneshot-hash.js | 2 +- 8 files changed, 258 insertions(+), 23 deletions(-) create mode 100644 test/parallel/test-crypto-default-shake-lengths-oneshot.js create mode 100644 test/parallel/test-crypto-oneshot-hash-xof.js diff --git a/deps/ncrypto/ncrypto.cc b/deps/ncrypto/ncrypto.cc index 7aa91497663..cb6b6ab4a61 100644 --- a/deps/ncrypto/ncrypto.cc +++ b/deps/ncrypto/ncrypto.cc @@ -4191,6 +4191,22 @@ DataPointer hashDigest(const Buffer& buf, return data.resize(result_size); } +DataPointer xofHashDigest(const Buffer& buf, + const EVP_MD* md, + size_t output_length) { + if (md == nullptr) return {}; + + EVPMDCtxPointer ctx = EVPMDCtxPointer::New(); + if (!ctx) return {}; + if (ctx.digestInit(md) != 1) { + return {}; + } + if (ctx.digestUpdate(reinterpret_cast&>(buf)) != 1) { + return {}; + } + return ctx.digestFinal(output_length); +} + // ============================================================================ X509Name::X509Name() : name_(nullptr), total_(0) {} diff --git a/deps/ncrypto/ncrypto.h b/deps/ncrypto/ncrypto.h index db13a1b95d4..28e836f0bdb 100644 --- a/deps/ncrypto/ncrypto.h +++ b/deps/ncrypto/ncrypto.h @@ -278,8 +278,13 @@ class Digest final { const EVP_MD* md_ = nullptr; }; +// Computes a fixed-length digest. DataPointer hashDigest(const Buffer& data, const EVP_MD* md); +// Computes a variable-length digest for XOF algorithms (e.g. SHAKE128). +DataPointer xofHashDigest(const Buffer& data, + const EVP_MD* md, + size_t length); class Cipher final { public: diff --git a/doc/api/crypto.md b/doc/api/crypto.md index c73cb1894d1..442785d1e13 100644 --- a/doc/api/crypto.md +++ b/doc/api/crypto.md @@ -4203,12 +4203,16 @@ A convenient alias for [`crypto.webcrypto.getRandomValues()`][]. This implementation is not compliant with the Web Crypto spec, to write web-compatible code use [`crypto.webcrypto.getRandomValues()`][] instead. -### `crypto.hash(algorithm, data[, outputEncoding])` +### `crypto.hash(algorithm, data[, options])` > Stability: 1.2 - Release candidate @@ -4219,8 +4223,11 @@ added: input encoding is desired for a string input, user could encode the string into a `TypedArray` using either `TextEncoder` or `Buffer.from()` and passing the encoded `TypedArray` into this API instead. -* `outputEncoding` {string|undefined} [Encoding][encoding] used to encode the - returned digest. **Default:** `'hex'`. +* `options` {Object|string} + * `outputEncoding` {string} [Encoding][encoding] used to encode the + returned digest. **Default:** `'hex'`. + * `outputLength` {number} For XOF hash functions such as 'shake256', + the outputLength option can be used to specify the desired output length in bytes. * Returns: {string|Buffer} A utility for creating one-shot hash digests of data. It can be faster than @@ -4233,6 +4240,8 @@ version of OpenSSL on the platform. Examples are `'sha256'`, `'sha512'`, etc. On recent releases of OpenSSL, `openssl list -digest-algorithms` will display the available digest algorithms. +If `options` is a string, then it specifies the `outputEncoding`. + Example: ```cjs diff --git a/lib/internal/crypto/hash.js b/lib/internal/crypto/hash.js index f5b89cd9ac3..8dff1ea2cbf 100644 --- a/lib/internal/crypto/hash.js +++ b/lib/internal/crypto/hash.js @@ -54,6 +54,7 @@ const { const { validateEncoding, validateString, + validateObject, validateUint32, } = require('internal/validators'); @@ -218,14 +219,27 @@ async function asyncDigest(algorithm, data) { throw lazyDOMException('Unrecognized algorithm name', 'NotSupportedError'); } -function hash(algorithm, input, outputEncoding = 'hex') { +function hash(algorithm, input, options) { validateString(algorithm, 'algorithm'); if (typeof input !== 'string' && !isArrayBufferView(input)) { throw new ERR_INVALID_ARG_TYPE('input', ['Buffer', 'TypedArray', 'DataView', 'string'], input); } + let outputEncoding; + let outputLength; + + if (typeof options === 'string') { + outputEncoding = options; + } else if (options !== undefined) { + validateObject(options, 'options'); + outputLength = options.outputLength; + outputEncoding = options.outputEncoding; + } + + outputEncoding ??= 'hex'; + let normalized = outputEncoding; // Fast case: if it's 'hex', we don't need to validate it further. - if (outputEncoding !== 'hex') { + if (normalized !== 'hex') { validateString(outputEncoding, 'outputEncoding'); normalized = normalizeEncoding(outputEncoding); // If the encoding is invalid, normalizeEncoding() returns undefined. @@ -238,14 +252,17 @@ function hash(algorithm, input, outputEncoding = 'hex') { } } } - // TODO: ideally we have to ship https://github.com/nodejs/node/pull/58121 so - // that a proper DEP0198 deprecation can be done here as well. - const normalizedAlgorithm = normalizeAlgorithm(algorithm); - if (normalizedAlgorithm === 'shake128' || normalizedAlgorithm === 'shake256') { - return new Hash(algorithm).update(input).digest(normalized); + + if (outputLength !== undefined) { + validateUint32(outputLength, 'outputLength'); } + + if (outputLength === undefined) { + maybeEmitDeprecationWarning(algorithm); + } + return oneShotDigest(algorithm, getCachedHashId(algorithm), getHashCache(), - input, normalized, encodingsMap[normalized]); + input, normalized, encodingsMap[normalized], outputLength); } module.exports = { diff --git a/src/crypto/crypto_hash.cc b/src/crypto/crypto_hash.cc index 38b0996c96e..ee8633d3119 100644 --- a/src/crypto/crypto_hash.cc +++ b/src/crypto/crypto_hash.cc @@ -208,17 +208,18 @@ const EVP_MD* GetDigestImplementation(Environment* env, } // crypto.digest(algorithm, algorithmId, algorithmCache, -// input, outputEncoding, outputEncodingId) +// input, outputEncoding, outputEncodingId, outputLength) void Hash::OneShotDigest(const FunctionCallbackInfo& args) { Environment* env = Environment::GetCurrent(args); Isolate* isolate = env->isolate(); - CHECK_EQ(args.Length(), 6); + CHECK_EQ(args.Length(), 7); CHECK(args[0]->IsString()); // algorithm CHECK(args[1]->IsInt32()); // algorithmId CHECK(args[2]->IsObject()); // algorithmCache CHECK(args[3]->IsString() || args[3]->IsArrayBufferView()); // input CHECK(args[4]->IsString()); // outputEncoding CHECK(args[5]->IsUint32() || args[5]->IsUndefined()); // outputEncodingId + CHECK(args[6]->IsUint32() || args[6]->IsUndefined()); // outputLength const EVP_MD* md = GetDigestImplementation(env, args[0], args[1], args[2]); if (md == nullptr) [[unlikely]] { @@ -230,21 +231,68 @@ void Hash::OneShotDigest(const FunctionCallbackInfo& args) { enum encoding output_enc = ParseEncoding(isolate, args[4], args[5], HEX); - DataPointer output = ([&] { + bool is_xof = (EVP_MD_flags(md) & EVP_MD_FLAG_XOF) != 0; + int output_length = EVP_MD_size(md); + + // This is to cause hash() to fail when an incorrect + // outputLength option was passed for a non-XOF hash function. + if (!is_xof && !args[6]->IsUndefined()) { + output_length = args[6].As()->Value(); + if (output_length != EVP_MD_size(md)) { + Utf8Value method(isolate, args[0]); + std::string message = + "Output length " + std::to_string(output_length) + " is invalid for "; + message += method.ToString() + ", which does not support XOF"; + return ThrowCryptoError(env, ERR_get_error(), message.c_str()); + } + } else if (is_xof) { + if (!args[6]->IsUndefined()) { + output_length = args[6].As()->Value(); + } else if (output_length == 0) { + // This is to handle OpenSSL 3.4's breaking change in SHAKE128/256 + // default lengths + const char* name = OBJ_nid2sn(EVP_MD_type(md)); + if (name != nullptr) { + if (strcmp(name, "SHAKE128") == 0) { + output_length = 16; + } else if (strcmp(name, "SHAKE256") == 0) { + output_length = 32; + } + } + } + } + + if (output_length == 0) { + if (output_enc == BUFFER) { + Local ab = v8::ArrayBuffer::New(isolate, 0); + args.GetReturnValue().Set( + Buffer::New(isolate, ab, 0, 0).ToLocalChecked()); + } else { + args.GetReturnValue().Set(v8::String::Empty(isolate)); + } + return; + } + + DataPointer output = ([&]() -> DataPointer { + Utf8Value utf8(isolate, args[3]); + ncrypto::Buffer buf; if (args[3]->IsString()) { - Utf8Value utf8(isolate, args[3]); - ncrypto::Buffer buf{ + buf = { .data = reinterpret_cast(utf8.out()), .len = utf8.length(), }; - return ncrypto::hashDigest(buf, md); + } else { + ArrayBufferViewContents input(args[3]); + buf = { + .data = reinterpret_cast(input.data()), + .len = input.length(), + }; + } + + if (is_xof) { + return ncrypto::xofHashDigest(buf, md, output_length); } - ArrayBufferViewContents input(args[3]); - ncrypto::Buffer buf{ - .data = reinterpret_cast(input.data()), - .len = input.length(), - }; return ncrypto::hashDigest(buf, md); })(); diff --git a/test/parallel/test-crypto-default-shake-lengths-oneshot.js b/test/parallel/test-crypto-default-shake-lengths-oneshot.js new file mode 100644 index 00000000000..247e58d93c4 --- /dev/null +++ b/test/parallel/test-crypto-default-shake-lengths-oneshot.js @@ -0,0 +1,18 @@ +// Flags: --pending-deprecation +'use strict'; + +const common = require('../common'); +if (!common.hasCrypto) + common.skip('missing crypto'); + +const { hash } = require('crypto'); + +common.expectWarning({ + DeprecationWarning: { + DEP0198: 'Creating SHAKE128/256 digests without an explicit options.outputLength is deprecated.', + } +}); + +{ + hash('shake128', 'test'); +} diff --git a/test/parallel/test-crypto-oneshot-hash-xof.js b/test/parallel/test-crypto-oneshot-hash-xof.js new file mode 100644 index 00000000000..75cb4800ff1 --- /dev/null +++ b/test/parallel/test-crypto-oneshot-hash-xof.js @@ -0,0 +1,122 @@ +'use strict'; +// This tests crypto.hash() works. +const common = require('../common'); + +if (!common.hasCrypto) common.skip('missing crypto'); + +const assert = require('assert'); +const crypto = require('crypto'); + +// Test XOF hash functions and the outputLength option. +{ + // Default outputLengths. + assert.strictEqual( + crypto.hash('shake128', ''), + '7f9c2ba4e88f827d616045507605853e' + ); + + assert.strictEqual( + crypto.hash('shake256', ''), + '46b9dd2b0ba88d13233b3feb743eeb243fcd52ea62b81b82b50c27646ed5762f' + ); + + // outputEncoding as an option. + assert.strictEqual( + crypto.hash('shake128', '', { outputEncoding: 'base64url' }), + 'f5wrpOiPgn1hYEVQdgWFPg' + ); + + assert.strictEqual( + crypto.hash('shake256', '', { outputEncoding: 'base64url' }), + 'RrndKwuojRMjOz_rdD7rJD_NUupiuBuCtQwnZG7Vdi8' + ); + + assert.deepStrictEqual( + crypto.hash('shake128', '', { outputEncoding: 'buffer' }), + Buffer.from('f5wrpOiPgn1hYEVQdgWFPg', 'base64url') + ); + + assert.deepStrictEqual( + crypto.hash('shake256', '', { outputEncoding: 'buffer' }), + Buffer.from('RrndKwuojRMjOz_rdD7rJD_NUupiuBuCtQwnZG7Vdi8', 'base64url') + ); + + // Short outputLengths. + assert.strictEqual(crypto.hash('shake128', '', { outputLength: 0 }), ''); + assert.deepStrictEqual(crypto.hash('shake128', '', { outputEncoding: 'buffer', outputLength: 0 }), + Buffer.alloc(0)); + + assert.strictEqual( + crypto.hash('shake128', '', { outputLength: 5 }), + crypto.createHash('shake128', { outputLength: 5 }).update('').digest('hex') + ); + // Check length + assert.strictEqual( + crypto.hash('shake128', '', { outputLength: 5 }).length, + crypto.createHash('shake128', { outputLength: 5 }).update('').digest('hex') + .length + ); + + assert.strictEqual( + crypto.hash('shake128', '', { outputLength: 15 }), + crypto.createHash('shake128', { outputLength: 15 }).update('').digest('hex') + ); + // Check length + assert.strictEqual( + crypto.hash('shake128', '', { outputLength: 15 }).length, + crypto.createHash('shake128', { outputLength: 15 }).update('').digest('hex') + .length + ); + + assert.strictEqual( + crypto.hash('shake256', '', { outputLength: 16 }), + crypto.createHash('shake256', { outputLength: 16 }).update('').digest('hex') + ); + // Check length + assert.strictEqual( + crypto.hash('shake256', '', { outputLength: 16 }).length, + crypto.createHash('shake256', { outputLength: 16 }).update('').digest('hex') + .length + ); + + // Large outputLengths. + assert.strictEqual( + crypto.hash('shake128', '', { outputLength: 128 }), + crypto + .createHash('shake128', { outputLength: 128 }).update('') + .digest('hex') + ); + // Check length without encoding + assert.strictEqual( + crypto.hash('shake128', '', { outputLength: 128 }).length, + crypto + .createHash('shake128', { outputLength: 128 }).update('') + .digest('hex').length + ); + assert.strictEqual( + crypto.hash('shake256', '', { outputLength: 128 }), + crypto + .createHash('shake256', { outputLength: 128 }).update('') + .digest('hex') + ); + + const actual = crypto.hash('shake256', 'The message is shorter than the hash!', { outputLength: 1024 * 1024 }); + const expected = crypto + .createHash('shake256', { + outputLength: 1024 * 1024, + }) + .update('The message is shorter than the hash!') + .digest('hex'); + assert.strictEqual(actual, expected); + + // Non-XOF hash functions should accept valid outputLength options as well. + assert.strictEqual(crypto.hash('sha224', '', { outputLength: 28 }), + 'd14a028c2a3a2bc9476102bb288234c4' + + '15a2b01f828ea62ac5b3e42f'); + + // Non-XOF hash functions should fail when outputLength isn't their actual outputLength + assert.throws(() => crypto.hash('sha224', '', { outputLength: 32 }), + { message: 'Output length 32 is invalid for sha224, which does not support XOF' }); + assert.throws(() => crypto.hash('sha224', '', { outputLength: 0 }), + { message: 'Output length 0 is invalid for sha224, which does not support XOF' }); +} diff --git a/test/parallel/test-crypto-oneshot-hash.js b/test/parallel/test-crypto-oneshot-hash.js index 56b4c04a65a..f6ac8656c94 100644 --- a/test/parallel/test-crypto-oneshot-hash.js +++ b/test/parallel/test-crypto-oneshot-hash.js @@ -19,7 +19,7 @@ const fs = require('fs'); assert.throws(() => { crypto.hash('sha1', invalid); }, { code: 'ERR_INVALID_ARG_TYPE' }); }); -[null, true, 1, () => {}, {}].forEach((invalid) => { +[0, 1, NaN, true, Symbol(0)].forEach((invalid) => { assert.throws(() => { crypto.hash('sha1', 'test', invalid); }, { code: 'ERR_INVALID_ARG_TYPE' }); });