@@ -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
312341if ( 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 , / D e r i v i n g b i t s f a i l e d / ) ;
356+ assert . match ( err . message , / p r o v i d e r s i g n a t u r e f a i l u r e / ) ;
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 : / P r e h a s h e d s i g n i n g i s n o t s u p p o r t e d / } ) ;
369+ } , / p r o v i d e r s i g n a t u r e f a i l u r e / ) ;
333370
334371 assert . throws ( ( ) => {
335- crypto . verifyDigest ( null , Buffer . alloc ( 32 ) , pubKey , Buffer . alloc ( 64 ) ) ;
336- } , { code : 'ERR_CRYPTO_OPERATION_FAILED' , message : / P r e h a s h e d s i g n i n g i s n o t s u p p o r t e d / } ) ;
372+ crypto . signDigest ( null , Buffer . alloc ( 128 ) , privKey ) ;
373+ } , / p r o v i d e r s i g n a t u r e f a i l u r e / ) ;
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 : / C o n t e x t p a r a m e t e r i s u n s u p p o r t e d / } ) ;
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 : / C o n t e x t p a r a m e t e r i s u n s u p p o r t e d / } ) ;
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