Skip to content

Commit 92b9654

Browse files
committed
fixup! crypto: add signDigest/verifyDigest and Ed25519ctx support
1 parent 7d63459 commit 92b9654

5 files changed

Lines changed: 201 additions & 28 deletions

File tree

deps/ncrypto/ncrypto.cc

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -3800,6 +3800,18 @@ int EVPKeyCtxPointer::initForVerifyEx(const OSSL_PARAM params[]) {
38003800
}
38013801
#endif
38023802

3803+
#ifdef OSSL_SIGNATURE_PARAM_MU
3804+
int EVPKeyCtxPointer::initForSignMessage(const OSSL_PARAM params[]) {
3805+
if (!ctx_) return 0;
3806+
return EVP_PKEY_sign_message_init(ctx_.get(), nullptr, params);
3807+
}
3808+
3809+
int EVPKeyCtxPointer::initForVerifyMessage(const OSSL_PARAM params[]) {
3810+
if (!ctx_) return 0;
3811+
return EVP_PKEY_verify_message_init(ctx_.get(), nullptr, params);
3812+
}
3813+
#endif
3814+
38033815
bool EVPKeyCtxPointer::initForEncrypt() {
38043816
if (!ctx_) return false;
38053817
return EVP_PKEY_encrypt_init(ctx_.get()) == 1;

deps/ncrypto/ncrypto.h

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -826,6 +826,10 @@ class EVPKeyCtxPointer final {
826826
int initForVerifyEx(const OSSL_PARAM params[]);
827827
int initForSignEx(const OSSL_PARAM params[]);
828828
#endif
829+
#ifdef OSSL_SIGNATURE_PARAM_MU
830+
int initForSignMessage(const OSSL_PARAM params[]);
831+
int initForVerifyMessage(const OSSL_PARAM params[]);
832+
#endif
829833

830834
static EVPKeyCtxPointer New(const EVPKeyPointer& key);
831835
static EVPKeyCtxPointer NewFromID(int id);

doc/api/crypto.md

Lines changed: 22 additions & 16 deletions
Original file line numberDiff line numberDiff line change
@@ -5839,10 +5839,15 @@ Calculates and returns the signature for `digest` using the given private key
58395839
and algorithm. Unlike [`crypto.sign()`][], this function does not hash the data
58405840
internally — `digest` is expected to be a pre-computed hash digest.
58415841

5842-
For RSA, ECDSA, and DSA keys, `algorithm` identifies the hash function that was
5843-
used to create `digest`. For Ed25519 and Ed448 keys, `algorithm` must be `null`
5844-
or `undefined`, and `digest` must be the output of the appropriate prehash
5845-
function (SHA-512 for Ed25519ph, SHAKE256 with 64-byte output for Ed448ph).
5842+
The interpretation of `algorithm` and `digest` depends on the key type:
5843+
5844+
* RSA, ECDSA, DSA: `algorithm` identifies the hash function used to create
5845+
`digest`.
5846+
* Ed25519, Ed448: `algorithm` must be `null` or `undefined`. `digest` must
5847+
be the output of the appropriate prehash function (SHA-512 for Ed25519ph,
5848+
SHAKE256 with 64-byte output for Ed448ph).
5849+
* ML-DSA: `algorithm` must be `null` or `undefined`. `digest` must be the
5850+
64-byte external mu value per FIPS 204.
58465851

58475852
If `key` is not a [`KeyObject`][], this function behaves as if `key` had been
58485853
passed to [`crypto.createPrivateKey()`][]. If it is an object, the following
@@ -5866,10 +5871,8 @@ additional properties can be passed:
58665871
maximum permissible value.
58675872
* `context` {ArrayBuffer|Buffer|TypedArray|DataView} For Ed25519ph and Ed448ph,
58685873
this option specifies the optional context to differentiate signatures
5869-
generated for different purposes with the same key.
5870-
5871-
This function does not support key types that require one-shot signing without
5872-
prehash variants, such as ML-DSA and SLH-DSA.
5874+
generated for different purposes with the same key. Not supported for ML-DSA
5875+
keys because the context is already encoded into the mu value.
58735876

58745877
If the `callback` function is provided this function uses libuv's threadpool.
58755878

@@ -6037,10 +6040,15 @@ Verifies the given signature for `digest` using the given key and algorithm.
60376040
Unlike [`crypto.verify()`][], this function does not hash the data
60386041
internally — `digest` is expected to be a pre-computed hash digest.
60396042

6040-
For RSA, ECDSA, and DSA keys, `algorithm` identifies the hash function that was
6041-
used to create `digest`. For Ed25519 and Ed448 keys, `algorithm` must be `null`
6042-
or `undefined`, and `digest` must be the output of the appropriate prehash
6043-
function (SHA-512 for Ed25519ph, SHAKE256 with 64-byte output for Ed448ph).
6043+
The interpretation of `algorithm` and `digest` depends on the key type:
6044+
6045+
* RSA, ECDSA, DSA: `algorithm` identifies the hash function used to create
6046+
`digest`.
6047+
* Ed25519, Ed448: `algorithm` must be `null` or `undefined`. `digest` must
6048+
be the output of the appropriate prehash function (SHA-512 for Ed25519ph,
6049+
SHAKE256 with 64-byte output for Ed448ph).
6050+
* ML-DSA: `algorithm` must be `null` or `undefined`. `digest` must be the
6051+
64-byte external mu value per FIPS 204.
60446052

60456053
If `key` is not a [`KeyObject`][], this function behaves as if `key` had been
60466054
passed to [`crypto.createPublicKey()`][]. If it is an object, the following
@@ -6064,16 +6072,14 @@ additional properties can be passed:
60646072
maximum permissible value.
60656073
* `context` {ArrayBuffer|Buffer|TypedArray|DataView} For Ed25519ph and Ed448ph,
60666074
this option specifies the optional context to differentiate signatures
6067-
generated for different purposes with the same key.
6075+
generated for different purposes with the same key. Not supported for ML-DSA
6076+
keys because the context is already encoded into the mu value.
60686077

60696078
The `signature` argument is the previously calculated signature for the `digest`.
60706079

60716080
Because public keys can be derived from private keys, a private key or a public
60726081
key may be passed for `key`.
60736082

6074-
This function does not support key types that require one-shot verification
6075-
without prehash variants, such as ML-DSA and SLH-DSA.
6076-
60776083
If the `callback` function is provided this function uses libuv's threadpool.
60786084

60796085
### `crypto.webcrypto`

src/crypto/crypto_sig.cc

Lines changed: 44 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -713,23 +713,38 @@ bool SignTraits::DeriveBits(Environment* env,
713713
// Prehashed signing: the caller already hashed the data and is providing
714714
// the digest directly. Use the low-level EVP_PKEY_sign/EVP_PKEY_verify path.
715715
if (is_prehashed) {
716+
bool is_eddsa = key.id() == EVP_PKEY_ED25519 || key.id() == EVP_PKEY_ED448;
717+
#if OPENSSL_WITH_PQC
718+
bool is_mldsa = key.id() == EVP_PKEY_ML_DSA_44 ||
719+
key.id() == EVP_PKEY_ML_DSA_65 ||
720+
key.id() == EVP_PKEY_ML_DSA_87;
721+
#else
722+
bool is_mldsa = false;
723+
#endif
724+
716725
// One-shot-only algorithms that don't have prehash variants
717-
// (ML-DSA, SLH-DSA) are not supported.
718-
if (key.isOneShotVariant() && key.id() != EVP_PKEY_ED25519 &&
719-
key.id() != EVP_PKEY_ED448) {
726+
// (SLH-DSA) are not supported.
727+
if (key.isOneShotVariant() && !is_eddsa && !is_mldsa) {
720728
if (can_throw)
721729
crypto::CheckThrow(env, SignBase::Error::PrehashUnsupported);
722730
return false;
723731
}
724732

733+
// For ML-DSA, context must already be part of the externally computed
734+
// mu value. Passing a context string separately is not supported.
735+
if (has_context && is_mldsa) {
736+
if (can_throw)
737+
crypto::CheckThrow(env, SignBase::Error::ContextUnsupported);
738+
return false;
739+
}
740+
725741
EVPKeyCtxPointer pkctx = key.newCtx();
726742
if (!pkctx) [[unlikely]] {
727743
if (can_throw) crypto::CheckThrow(env, SignBase::Error::Init);
728744
return false;
729745
}
730746

731747
int init_ret;
732-
bool is_eddsa = key.id() == EVP_PKEY_ED25519 || key.id() == EVP_PKEY_ED448;
733748

734749
if (is_eddsa) {
735750
#ifdef OSSL_SIGNATURE_PARAM_INSTANCE
@@ -764,6 +779,31 @@ bool SignTraits::DeriveBits(Environment* env,
764779
crypto::CheckThrow(env, SignBase::Error::PrehashUnsupported);
765780
return false;
766781
#endif // OSSL_SIGNATURE_PARAM_INSTANCE
782+
} else if (is_mldsa) {
783+
#ifdef OSSL_SIGNATURE_PARAM_MU
784+
// For ML-DSA, use EVP_PKEY_sign_message_init with mu param.
785+
// The caller provides the 64-byte mu value directly.
786+
// Context string is not passed here — it's already incorporated
787+
// into mu by the caller.
788+
int mu_flag = 1;
789+
std::vector<OSSL_PARAM> ossl_params;
790+
ossl_params.push_back(
791+
OSSL_PARAM_construct_int(OSSL_SIGNATURE_PARAM_MU, &mu_flag));
792+
ossl_params.push_back(OSSL_PARAM_END);
793+
794+
switch (params.mode) {
795+
case SignConfiguration::Mode::Sign:
796+
init_ret = pkctx.initForSignMessage(ossl_params.data());
797+
break;
798+
case SignConfiguration::Mode::Verify:
799+
init_ret = pkctx.initForVerifyMessage(ossl_params.data());
800+
break;
801+
}
802+
#else
803+
if (can_throw)
804+
crypto::CheckThrow(env, SignBase::Error::PrehashUnsupported);
805+
return false;
806+
#endif // OSSL_SIGNATURE_PARAM_MU
767807
} else {
768808
// RSA, ECDSA, DSA: use standard EVP_PKEY_sign_init path.
769809
switch (params.mode) {

test/parallel/test-crypto-sign-verify-digest.js

Lines changed: 119 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -274,6 +274,35 @@ if (hasOpenSSL(3, 2)) {
274274
}
275275
}
276276

277+
// --- ML-DSA external mu (prehashed) ---
278+
if (hasOpenSSL(3, 5)) {
279+
// ML-DSA signDigest/verifyDigest treats input as external mu.
280+
// mu is the 64-byte SHAKE-256(tr || M') value that the caller computes.
281+
const variants = [
282+
{ alg: 'ml_dsa_44' },
283+
{ alg: 'ml_dsa_65' },
284+
{ alg: 'ml_dsa_87' },
285+
];
286+
287+
for (const { alg } of variants) {
288+
const privKey = fixtures.readKey(`${alg}_private.pem`, 'ascii');
289+
const pubKey = fixtures.readKey(`${alg}_public.pem`, 'ascii');
290+
291+
const mu = crypto.randomBytes(64);
292+
293+
const sig = crypto.signDigest(null, mu, privKey);
294+
assert(Buffer.isBuffer(sig));
295+
assert(sig.length > 0);
296+
297+
// Verify with same mu succeeds
298+
assert.strictEqual(crypto.verifyDigest(null, mu, pubKey, sig), true);
299+
300+
// Verify with wrong mu fails
301+
const wrongMu = crypto.randomBytes(64);
302+
assert.strictEqual(crypto.verifyDigest(null, wrongMu, pubKey, sig), false);
303+
}
304+
}
305+
277306
// --- Async (callback) mode ---
278307
{
279308
const privKey = fixtures.readKey('rsa_private_2048.pem', 'ascii');
@@ -310,30 +339,112 @@ if (hasOpenSSL(3, 2)) {
310339
}
311340

312341
if (hasOpenSSL(3, 5)) {
313-
// PrehashUnsupported error is delivered via callback
342+
// ML-DSA async sign+verify with external mu (64-byte pre-computed value)
314343
const mldsaPrivKey = fixtures.readKey('ml_dsa_44_private.pem', 'ascii');
344+
const mldsaPubKey = fixtures.readKey('ml_dsa_44_public.pem', 'ascii');
345+
const mu = crypto.randomBytes(64);
346+
crypto.signDigest(null, mu, mldsaPrivKey, common.mustSucceed((sig) => {
347+
assert(sig.length > 0);
348+
crypto.verifyDigest(null, mu, mldsaPubKey, sig, common.mustSucceed((ok) => {
349+
assert.strictEqual(ok, true);
350+
}));
351+
}));
352+
353+
// Wrong mu length (32 bytes) is rejected asynchronously
315354
crypto.signDigest(null, Buffer.alloc(32), mldsaPrivKey, common.mustCall((err) => {
316355
assert(err);
317-
// TODO(@panva): revisit how to make CryptoJob async failures retain
318-
// and decorate OpenSSL errors.
319-
assert.match(err.message, /Deriving bits failed/);
356+
assert.match(err.message, /provider signature failure/);
320357
}));
321358
}
322359

323360
// --- Error: unsupported key type for prehashed signing ---
324361
{
325-
// ML-DSA keys are one-shot-only and don't support prehashed signing.
362+
// ML-DSA rejects wrong mu length (must be exactly 64 bytes).
326363
if (hasOpenSSL(3, 5)) {
327364
const privKey = fixtures.readKey('ml_dsa_44_private.pem', 'ascii');
328365
const pubKey = fixtures.readKey('ml_dsa_44_public.pem', 'ascii');
329366

330367
assert.throws(() => {
331368
crypto.signDigest(null, Buffer.alloc(32), privKey);
332-
}, { code: 'ERR_CRYPTO_OPERATION_FAILED', message: /Prehashed signing is not supported/ });
369+
}, /provider signature failure/);
333370

334371
assert.throws(() => {
335-
crypto.verifyDigest(null, Buffer.alloc(32), pubKey, Buffer.alloc(64));
336-
}, { code: 'ERR_CRYPTO_OPERATION_FAILED', message: /Prehashed signing is not supported/ });
372+
crypto.signDigest(null, Buffer.alloc(128), privKey);
373+
}, /provider signature failure/);
374+
375+
// verifyDigest returns false for wrong mu length (not a throw)
376+
assert.strictEqual(
377+
crypto.verifyDigest(null, Buffer.alloc(32), pubKey, Buffer.alloc(2420)),
378+
false,
379+
);
380+
381+
// Context string is not supported with signDigest/verifyDigest for ML-DSA
382+
// since context must already be incorporated into the externally computed mu.
383+
assert.throws(() => {
384+
crypto.signDigest(null, Buffer.alloc(64), { key: privKey, context: Buffer.from('ctx') });
385+
}, { code: 'ERR_CRYPTO_OPERATION_FAILED', message: /Context parameter is unsupported/ });
386+
assert.throws(() => {
387+
crypto.verifyDigest(null, Buffer.alloc(64), { key: pubKey, context: Buffer.from('ctx') },
388+
Buffer.alloc(2420));
389+
}, { code: 'ERR_CRYPTO_OPERATION_FAILED', message: /Context parameter is unsupported/ });
390+
}
391+
392+
// ML-DSA external mu cross-verification with crypto.sign/crypto.verify.
393+
// Computes mu = SHAKE-256(tr || M', 64) per FIPS 204, where
394+
// tr = SHAKE-256(pk, 64) and M' encodes the context.
395+
if (hasOpenSSL(3, 5)) {
396+
const variants = [
397+
{ alg: 'ml_dsa_44', sigLen: 2420 },
398+
{ alg: 'ml_dsa_65', sigLen: 3309 },
399+
{ alg: 'ml_dsa_87', sigLen: 4627 },
400+
];
401+
402+
for (const { alg } of variants) {
403+
const privKey = fixtures.readKey(`${alg}_private.pem`, 'ascii');
404+
const pubKey = fixtures.readKey(`${alg}_public.pem`, 'ascii');
405+
406+
// Get raw public key bytes for tr computation via JWK export.
407+
const pubKeyObj = crypto.createPublicKey(pubKey);
408+
const pkBytes = Buffer.from(pubKeyObj.export({ format: 'jwk' }).pub, 'base64url');
409+
const tr = crypto.createHash('shake256', { outputLength: 64 }).update(pkBytes).digest();
410+
411+
const msg = Buffer.from('ML-DSA cross-verify test message');
412+
413+
// Without context: M' = 0x00 || 0x00 || M
414+
{
415+
const mPrime = Buffer.concat([Buffer.from([0x00, 0x00]), msg]);
416+
const mu = crypto.createHash('shake256', { outputLength: 64 })
417+
.update(tr).update(mPrime).digest();
418+
419+
const sig = crypto.signDigest(null, mu, privKey);
420+
assert.strictEqual(crypto.verify(null, msg, pubKey, sig), true);
421+
422+
const sig2 = crypto.sign(null, msg, privKey);
423+
assert.strictEqual(crypto.verifyDigest(null, mu, pubKey, sig2), true);
424+
}
425+
426+
// With context: M' = 0x00 || len(ctx) || ctx || M
427+
{
428+
const ctx = Buffer.from('test context string');
429+
const mPrime = Buffer.concat([Buffer.from([0x00, ctx.length]), ctx, msg]);
430+
const mu = crypto.createHash('shake256', { outputLength: 64 })
431+
.update(tr).update(mPrime).digest();
432+
433+
const sig = crypto.signDigest(null, mu, privKey);
434+
assert.strictEqual(
435+
crypto.verify(null, msg, { key: pubKey, context: ctx }, sig), true);
436+
437+
const sig2 = crypto.sign(null, msg, { key: privKey, context: ctx });
438+
assert.strictEqual(crypto.verifyDigest(null, mu, pubKey, sig2), true);
439+
440+
// Mismatched context: signDigest with context mu, verify without context
441+
assert.strictEqual(crypto.verify(null, msg, pubKey, sig), false);
442+
443+
// Mismatched context: sign without context, verifyDigest with context mu
444+
const sig3 = crypto.sign(null, msg, privKey);
445+
assert.strictEqual(crypto.verifyDigest(null, mu, pubKey, sig3), false);
446+
}
447+
}
337448
}
338449

339450
// Ed25519ph/Ed448ph require OpenSSL >= 3.2. On older versions, they

0 commit comments

Comments
 (0)