Repository: nodejs/node
Pull request: crypto: ensure expected JWK alg in SubtleCrypto.importKey RSA imports

While working on SHA-3 addition to webcrypto and updating the test suite I noticed that RSA import JWK "alg" values were forced to e.g. PS256 (depending on the digest) even for RSA-OAEP and RSASSA-PKCS1-v1_5 algorithms which should be rejected.

While debugging this I noticed that

1) the expected JWK "alg" prefix (RS, PS, RSA-OAEP) is not checked at all; and after fixing that by using the right `normalizeHashName` context that;
2) the JWK "alg" is not checked as a case-sensitive value

In this PR I made the following changes:

- normalizeHashName is now case sensitive (as the need for its lowercasing went away when I've implemented the proper WebCryptoAPI normalize algorithm routine in #46067)
- RSA JWK alg check uses the right RSA algorithm type context

I've updated tests to

- use the expected "alg" value on valid tests
- added tests checking for a rejection when alg mismatch happens (e.g. alg: PS256 instead of RS256 on RSASSA-PKCS1-v1_5 algorithm)
- added tests checking for a rejection when alg values were lowercased

You can verify the prior bugged behaviour with the following script

<details>
<summary>Details</summary>

```js
const jwk = {
  kty: 'RSA',
  n:
    'zZn4sRGfjQos56yL_Qy1R9NI-THMnFynn94g5RxA6wGrJh4BJT3x6I9x0IbpS3q-d' +
    '4ORA6R2vuDMh8dDFRr9RDH6XY-gUScc9U5Jz3UA2KmVfsCbnUPvcAmMV_ENA7_TF0' +
    'ivVjuIFodyDTx7EKHNVTrHHSlrbt7spbmcivs23Zc',
  e: 'AQAB',
};

const results = await Promise.allSettled([
  crypto.subtle.importKey(
    'jwk',
    {
      ...jwk,
      alg: 'RS256', // RS256 is not a valid alg value for RSA-PSS
    },
    {
      name: 'RSA-PSS',
      hash: 'SHA-256',
    },
    false,
    ['verify']
  ),
  crypto.subtle.importKey(
    'jwk',
    {
      ...jwk,
      alg: 'ps256', // alg values are case-sensitive
    },
    {
      name: 'RSA-PSS',
      hash: 'SHA-256',
    },
    false,
    ['verify']
  ),
]);

results.forEach((result, i) => {
  if (result.status === 'rejected') {
    console.log('vector', i, 'correctly rejected with', result.reason.name);
  } else {
    console.log('vector', i, 'missing expected rejection');
  }
});
```

</details>

Changed files:
filename: lib/internal/crypto/hashnames.js
patch: @@ -2,7 +2,6 @@
 
 const {
   ObjectKeys,
-  StringPrototypeToLowerCase,
 } = primordials;
 
 const kHashContextNode = 1;
@@ -58,22 +57,18 @@ const kHashNames = {
   for (let n = 0; n < keys.length; n++) {
     const contexts = ObjectKeys(kHashNames[keys[n]]);
     for (let i = 0; i < contexts.length; i++) {
-      const alias =
-        StringPrototypeToLowerCase(kHashNames[keys[n]][contexts[i]]);
+      const alias = kHashNames[keys[n]][contexts[i]];
       if (kHashNames[alias] === undefined)
         kHashNames[alias] = kHashNames[keys[n]];
     }
   }
 }
 
 function normalizeHashName(name, context = kHashContextNode) {
-  if (typeof name !== 'string')
-    return name;
-  name = StringPrototypeToLowerCase(name);
-  const alias = kHashNames[name]?.[context];
-  return alias || name;
+  return kHashNames[name]?.[context];
 }
 
+
 normalizeHashName.kContextNode = kHashContextNode;
 normalizeHashName.kContextWebCrypto = kHashContextWebCrypto;
 normalizeHashName.kContextJwkRsa = kHashContextJwkRsa;

filename: lib/internal/crypto/rsa.js
patch: @@ -275,9 +275,13 @@ function rsaImportKey(
       }
 
       if (keyData.alg !== undefined) {
-        const hash =
-          normalizeHashName(keyData.alg, normalizeHashName.kContextWebCrypto);
-        if (hash !== algorithm.hash.name)
+        const expected =
+          normalizeHashName(algorithm.hash.name,
+                            algorithm.name === 'RSASSA-PKCS1-v1_5' ? normalizeHashName.kContextJwkRsa :
+                              algorithm.name === 'RSA-PSS' ? normalizeHashName.kContextJwkRsaPss :
+                                normalizeHashName.kContextJwkRsaOaep);
+
+        if (keyData.alg !== expected)
           throw lazyDOMException(
             'JWK "alg" does not match the requested algorithm',
             'DataError');

filename: test/parallel/test-webcrypto-export-import-rsa.js
patch: @@ -384,6 +384,19 @@ async function testImportJwk(
 
   const jwk = keyData[size].jwk;
 
+  let alg;
+  switch (name) {
+    case 'RSA-PSS':
+      alg = `PS${hash === 'SHA-1' ? 1 : hash.substring(4)}`;
+      break;
+    case 'RSA-OAEP':
+      alg = `RSA-OAEP${hash === 'SHA-1' ? '' : hash.substring(3)}`;
+      break;
+    case 'RSASSA-PKCS1-v1_5':
+      alg = `RS${hash === 'SHA-1' ? 1 : hash.substring(4)}`;
+      break;
+  }
+
   const [
     publicKey,
     privateKey,
@@ -394,14 +407,14 @@ async function testImportJwk(
         kty: jwk.kty,
         n: jwk.n,
         e: jwk.e,
-        alg: `PS${hash.substring(4)}`
+        alg,
       },
       { name, hash },
       extractable,
       publicUsages),
     subtle.importKey(
       'jwk',
-      { ...jwk, alg: `PS${hash.substring(4)}` },
+      { ...jwk, alg },
       { name, hash },
       extractable,
       privateUsages),
@@ -435,6 +448,8 @@ async function testImportJwk(
 
     assert.strictEqual(pubJwk.kty, 'RSA');
     assert.strictEqual(pvtJwk.kty, 'RSA');
+    assert.strictEqual(pubJwk.alg, alg);
+    assert.strictEqual(pvtJwk.alg, alg);
     assert.strictEqual(pubJwk.n, jwk.n);
     assert.strictEqual(pvtJwk.n, jwk.n);
     assert.strictEqual(pubJwk.e, jwk.e);
@@ -483,30 +498,71 @@ async function testImportJwk(
   }
 
   {
-    let invalidAlg = name === 'RSA-OAEP' ? name : name === 'RSA-PSS' ? 'PS' : 'RS';
+    await assert.rejects(
+      subtle.importKey(
+        'jwk',
+        { kty: jwk.kty, n: jwk.n, e: jwk.e, alg: alg.toLowerCase() },
+        { name, hash },
+        extractable,
+        publicUsages),
+      { message: 'JWK "alg" does not match the requested algorithm' });
+    await assert.rejects(
+      subtle.importKey(
+        'jwk',
+        { ...jwk, alg: alg.toLowerCase() },
+        { name, hash },
+        extractable,
+        privateUsages),
+      { message: 'JWK "alg" does not match the requested algorithm' });
+  }
+
+  {
+    let invalidAlgHash = name === 'RSA-OAEP' ? name : name === 'RSA-PSS' ? 'PS' : 'RS';
     switch (name) {
       case 'RSA-OAEP':
         if (hash === 'SHA-1')
-          invalidAlg += '-256';
+          invalidAlgHash += '-256';
         break;
       default:
         if (hash === 'SHA-256')
-          invalidAlg += '384';
+          invalidAlgHash += '384';
         else
-          invalidAlg += '256';
+          invalidAlgHash += '256';
     }
     await assert.rejects(
       subtle.importKey(
         'jwk',
-        { kty: jwk.kty, n: jwk.n, e: jwk.e, alg: invalidAlg },
+        { kty: jwk.kty, n: jwk.n, e: jwk.e, alg: invalidAlgHash },
         { name, hash },
         extractable,
         publicUsages),
       { message: 'JWK "alg" does not match the requested algorithm' });
     await assert.rejects(
       subtle.importKey(
         'jwk',
-        { ...jwk, alg: invalidAlg },
+        { ...jwk, alg: invalidAlgHash },
+        { name, hash },
+        extractable,
+        privateUsages),
+      { message: 'JWK "alg" does not match the requested algorithm' });
+  }
+
+  {
+    const invalidAlgType = name === 'RSA-PSS' ? `RS${hash.substring(4)}` : `PS${hash.substring(4)}`;
+    await assert.rejects(
+      subtle.importKey(
+        'jwk',
+        { kty: jwk.kty, n: jwk.n, e: jwk.e, alg: invalidAlgType },
+        { name, hash },
+        extractable,
+        publicUsages),
+      { message: 'JWK "alg" does not match the requested algorithm' }).catch((e) => {
+      throw e;
+    });
+    await assert.rejects(
+      subtle.importKey(
+        'jwk',
+        { ...jwk, alg: invalidAlgType },
         { name, hash },
         extractable,
         privateUsages),