Repository: nodejs/node
Pull request: crypto: reject ML-KEM/ML-DSA PKCS#8 import without seed in SubtleCrypto

Reject importing ML-KEM and ML-DSA PKCS#8 private keys that do not include a seed, throwing NotSupportedError.

Also add tests for importing PKCS#8 keys with a mismatched expanded key.

Refs: https://redirect.github.com/WICG/webcrypto-modern-algos/pull/34

Note: both of these algorithms are marked as https://github.com/nodejs/node/labels/experimental

Changed files:
filename: doc/api/webcrypto.md
patch: @@ -1260,6 +1260,10 @@ The {CryptoKey} (secret key) generating algorithms supported include:
 <!-- YAML
 added: v15.0.0
 changes:
+  - version: REPLACEME
+    pr-url: https://github.com/nodejs/node/pull/62218
+    description: Importing ML-DSA and ML-KEM PKCS#8 keys
+      without a seed is no longer supported.
   - version: v24.8.0
     pr-url: https://github.com/nodejs/node/pull/59647
     description: KMAC algorithms are now supported.

filename: lib/internal/crypto/ml_dsa.js
patch: @@ -191,6 +191,19 @@ function mlDsaImportKey(
     }
     case 'pkcs8': {
       verifyAcceptableMlDsaKeyUse(name, false, usagesSet);
+
+      const privOnlyLengths = {
+        '__proto__': null,
+        'ML-DSA-44': 2588,
+        'ML-DSA-65': 4060,
+        'ML-DSA-87': 4924,
+      };
+      if (keyData.byteLength === privOnlyLengths[name]) {
+        throw lazyDOMException(
+          'Importing an ML-DSA PKCS#8 key without a seed is not supported',
+          'NotSupportedError');
+      }
+
       try {
         keyObject = createPrivateKey({
           key: keyData,

filename: lib/internal/crypto/ml_kem.js
patch: @@ -181,6 +181,19 @@ function mlKemImportKey(
     }
     case 'pkcs8': {
       verifyAcceptableMlKemKeyUse(name, false, usagesSet);
+
+      const privOnlyLengths = {
+        '__proto__': null,
+        'ML-KEM-512': 1660,
+        'ML-KEM-768': 2428,
+        'ML-KEM-1024': 3196,
+      };
+      if (keyData.byteLength === privOnlyLengths[name]) {
+        throw lazyDOMException(
+          'Importing an ML-KEM PKCS#8 key without a seed is not supported',
+          'NotSupportedError');
+      }
+
       try {
         keyObject = createPrivateKey({
           key: keyData,

filename: test/parallel/test-webcrypto-export-import-ml-dsa.js
patch: @@ -12,6 +12,7 @@ if (!hasOpenSSL(3, 5))
 
 const assert = require('assert');
 const { subtle } = globalThis.crypto;
+const { createPrivateKey } = require('crypto');
 
 const fixtures = require('../common/fixtures');
 
@@ -199,42 +200,32 @@ async function testImportPkcs8SeedOnly({ name, privateUsages }, extractable) {
 }
 
 async function testImportPkcs8PrivOnly({ name, privateUsages }, extractable) {
-  const key = await subtle.importKey(
-    'pkcs8',
-    keyData[name].pkcs8_priv_only,
-    { name },
-    extractable,
-    privateUsages);
-  assert.strictEqual(key.type, 'private');
-  assert.strictEqual(key.extractable, extractable);
-  assert.deepStrictEqual(key.usages, privateUsages);
-  assert.deepStrictEqual(key.algorithm.name, name);
-  assert.strictEqual(key.algorithm, key.algorithm);
-  assert.strictEqual(key.usages, key.usages);
-
-  if (extractable) {
-    await assert.rejects(subtle.exportKey('pkcs8', key), (err) => {
-      assert.strictEqual(err.name, 'OperationError');
-      assert.strictEqual(err.cause.code, 'ERR_CRYPTO_OPERATION_FAILED');
-      assert.strictEqual(err.cause.message, 'Failed to get raw seed');
-      return true;
+  await assert.rejects(
+    subtle.importKey(
+      'pkcs8',
+      keyData[name].pkcs8_priv_only,
+      { name },
+      extractable,
+      privateUsages),
+    {
+      name: 'NotSupportedError',
+      message: 'Importing an ML-DSA PKCS#8 key without a seed is not supported',
     });
-  } else {
-    await assert.rejects(
-      subtle.exportKey('pkcs8', key), {
-        message: /key is not extractable/,
-        name: 'InvalidAccessError',
-      });
-  }
+}
 
+async function testImportPkcs8MismatchedSeed({ name, privateUsages }, extractable) {
+  const modified = Buffer.from(keyData[name].pkcs8);
+  modified[30] ^= 0xff;
   await assert.rejects(
     subtle.importKey(
       'pkcs8',
-      keyData[name].pkcs8_seed_only,
+      modified,
       { name },
       extractable,
-      [/* empty usages */]),
-    { name: 'SyntaxError', message: 'Usages cannot be empty when importing a private key.' });
+      privateUsages),
+    {
+      name: 'DataError',
+    });
 }
 
 async function testImportJwk({ name, publicUsages, privateUsages }, extractable) {
@@ -499,6 +490,7 @@ async function testImportRawSeed({ name, privateUsages }, extractable) {
       tests.push(testImportPkcs8(vector, extractable));
       tests.push(testImportPkcs8SeedOnly(vector, extractable));
       tests.push(testImportPkcs8PrivOnly(vector, extractable));
+      tests.push(testImportPkcs8MismatchedSeed(vector, extractable));
       tests.push(testImportJwk(vector, extractable));
       tests.push(testImportRawSeed(vector, extractable));
       tests.push(testImportRawPublic(vector, extractable));
@@ -515,3 +507,17 @@ async function testImportRawSeed({ name, privateUsages }, extractable) {
     message: 'Unable to import ML-DSA-44 using raw format',
   });
 })().then(common.mustCall());
+
+(async function() {
+  for (const { name, privateUsages } of testVectors) {
+    const pem = fixtures.readKey(getKeyFileName(name.toLowerCase(), 'private_priv_only'), 'ascii');
+    const keyObject = createPrivateKey(pem);
+    const key = keyObject.toCryptoKey({ name }, true, privateUsages);
+    await assert.rejects(subtle.exportKey('pkcs8', key), (err) => {
+      assert.strictEqual(err.name, 'OperationError');
+      assert.strictEqual(err.cause.code, 'ERR_CRYPTO_OPERATION_FAILED');
+      assert.strictEqual(err.cause.message, 'Failed to get raw seed');
+      return true;
+    });
+  }
+})().then(common.mustCall());

filename: test/parallel/test-webcrypto-export-import-ml-kem.js
patch: @@ -12,6 +12,7 @@ if (!hasOpenSSL(3, 5))
 
 const assert = require('assert');
 const { subtle } = globalThis.crypto;
+const { createPrivateKey } = require('crypto');
 
 const fixtures = require('../common/fixtures');
 
@@ -182,42 +183,32 @@ async function testImportPkcs8SeedOnly({ name, privateUsages }, extractable) {
 }
 
 async function testImportPkcs8PrivOnly({ name, privateUsages }, extractable) {
-  const key = await subtle.importKey(
-    'pkcs8',
-    keyData[name].pkcs8_priv_only,
-    { name },
-    extractable,
-    privateUsages);
-  assert.strictEqual(key.type, 'private');
-  assert.strictEqual(key.extractable, extractable);
-  assert.deepStrictEqual(key.usages, privateUsages);
-  assert.deepStrictEqual(key.algorithm.name, name);
-  assert.strictEqual(key.algorithm, key.algorithm);
-  assert.strictEqual(key.usages, key.usages);
-
-  if (extractable) {
-    await assert.rejects(subtle.exportKey('pkcs8', key), (err) => {
-      assert.strictEqual(err.name, 'OperationError');
-      assert.strictEqual(err.cause.code, 'ERR_CRYPTO_OPERATION_FAILED');
-      assert.strictEqual(err.cause.message, 'Failed to get raw seed');
-      return true;
+  await assert.rejects(
+    subtle.importKey(
+      'pkcs8',
+      keyData[name].pkcs8_priv_only,
+      { name },
+      extractable,
+      privateUsages),
+    {
+      name: 'NotSupportedError',
+      message: 'Importing an ML-KEM PKCS#8 key without a seed is not supported',
     });
-  } else {
-    await assert.rejects(
-      subtle.exportKey('pkcs8', key), {
-        message: /key is not extractable/,
-        name: 'InvalidAccessError',
-      });
-  }
+}
 
+async function testImportPkcs8MismatchedSeed({ name, privateUsages }, extractable) {
+  const modified = Buffer.from(keyData[name].pkcs8);
+  modified[30] ^= 0xff;
   await assert.rejects(
     subtle.importKey(
       'pkcs8',
-      keyData[name].pkcs8_seed_only,
+      modified,
       { name },
       extractable,
-      [/* empty usages */]),
-    { name: 'SyntaxError', message: 'Usages cannot be empty when importing a private key.' });
+      privateUsages),
+    {
+      name: 'DataError',
+    });
 }
 
 async function testImportRawPublic({ name, publicUsages }, extractable) {
@@ -302,6 +293,7 @@ async function testImportRawSeed({ name, privateUsages }, extractable) {
       tests.push(testImportPkcs8(vector, extractable));
       tests.push(testImportPkcs8SeedOnly(vector, extractable));
       tests.push(testImportPkcs8PrivOnly(vector, extractable));
+      tests.push(testImportPkcs8MismatchedSeed(vector, extractable));
       tests.push(testImportRawSeed(vector, extractable));
       tests.push(testImportRawPublic(vector, extractable));
     }
@@ -317,3 +309,17 @@ async function testImportRawSeed({ name, privateUsages }, extractable) {
     message: 'Unable to import ML-KEM-512 using raw format',
   });
 })().then(common.mustCall());
+
+(async function() {
+  for (const { name, privateUsages } of testVectors) {
+    const pem = fixtures.readKey(getKeyFileName(name.toLowerCase(), 'private_priv_only'), 'ascii');
+    const keyObject = createPrivateKey(pem);
+    const key = keyObject.toCryptoKey({ name }, true, privateUsages);
+    await assert.rejects(subtle.exportKey('pkcs8', key), (err) => {
+      assert.strictEqual(err.name, 'OperationError');
+      assert.strictEqual(err.cause.code, 'ERR_CRYPTO_OPERATION_FAILED');
+      assert.strictEqual(err.cause.message, 'Failed to get raw seed');
+      return true;
+    });
+  }
+})().then(common.mustCall());