Repository: denoland/deno
Pull request: feat(ext/node): implement KeyObject.toCryptoKey() and KeyObject.from(CryptoKey)

## Summary

- Implements `KeyObject.prototype.toCryptoKey(algorithm, extractable, usages)` for converting Node.js `KeyObject` to Web Crypto `CryptoKey`
- Implements `KeyObject.from(cryptoKey)` static method for the reverse conversion
- Adds `ChaCha20-Poly1305` key import/export support to the Web Crypto layer (closes #28411 for key management)
- Adds `test-crypto-key-objects-to-crypto-key.js` and `test-crypto-key-objects-messageport.js` as expected failures (blocked by missing Ed448 keygen and MessagePort KeyObject serialization)

Supports all key types (secret, public, private) and algorithms: AES-CTR/CBC/GCM/KW, HMAC, RSA (PKCS1/PSS/OAEP), ECDSA/ECDH, Ed25519, X25519, PBKDF2, HKDF, and ChaCha20-Poly1305.

## Test plan

- [x] `cargo test --test node_compat test-crypto-key-objects-` passes (2/2 as expected failures)
- [x] Manual verification of roundtrip `KeyObject → CryptoKey → KeyObject` for all supported algorithms
- [x] Validation errors match Node.js behavior (empty usages, zero-length keys, PBKDF2 extractability, key type mismatch, EC named curve mismatch)
- [x] `tools/format.js` and `tools/lint.js --js` pass

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Changed files:
filename: ext/crypto/00_crypto.js
patch: @@ -177,6 +177,7 @@ const supportedAlgorithms = {
     "AES-GCM": null,
     "AES-OCB": null,
     "AES-KW": null,
+    "ChaCha20-Poly1305": null,
     "Ed25519": null,
     "X25519": null,
     "X448": null,
@@ -194,13 +195,15 @@ const supportedAlgorithms = {
     "AES-GCM": "AesGcmParams",
     "AES-OCB": "AesGcmParams",
     "AES-CTR": "AesCtrParams",
+    "ChaCha20-Poly1305": null,
   },
   "decrypt": {
     "RSA-OAEP": "RsaOaepParams",
     "AES-CBC": "AesCbcParams",
     "AES-GCM": "AesGcmParams",
     "AES-OCB": "AesGcmParams",
     "AES-CTR": "AesCtrParams",
+    "ChaCha20-Poly1305": null,
   },
   "get key length": {
     "AES-CBC": "AesDerivedKeyParams",
@@ -1078,6 +1081,10 @@ class SubtleCrypto {
         result = exportKeyAES(format, key, innerKey);
         break;
       }
+      case "ChaCha20-Poly1305": {
+        result = exportKeyChaCha20Poly1305(format, key, innerKey);
+        break;
+      }
       default:
         throw new DOMException("Not implemented", "NotSupportedError");
     }
@@ -2864,6 +2871,65 @@ function exportKeyAES(
   }
 }
 
+function exportKeyChaCha20Poly1305(format, _key, innerKey) {
+  switch (format) {
+    case "raw": {
+      const data = innerKey.data;
+      return TypedArrayPrototypeGetBuffer(data);
+    }
+    default:
+      throw new DOMException("Not implemented", "NotSupportedError");
+  }
+}
+
+function importKeyChaCha20Poly1305(
+  format,
+  keyData,
+  extractable,
+  keyUsages,
+) {
+  const supportedKeyUsages = ["encrypt", "decrypt", "wrapKey", "unwrapKey"];
+  if (
+    ArrayPrototypeFind(
+      keyUsages,
+      (u) => !ArrayPrototypeIncludes(supportedKeyUsages, u),
+    ) !== undefined
+  ) {
+    throw new DOMException("Invalid key usage", "SyntaxError");
+  }
+
+  switch (format) {
+    case "raw": {
+      if (TypedArrayPrototypeGetByteLength(keyData) !== 32) {
+        throw new DOMException(
+          "Invalid key length: ChaCha20-Poly1305 requires 256-bit key",
+          "DataError",
+        );
+      }
+
+      const handle = {};
+      WeakMapPrototypeSet(KEY_STORE, handle, {
+        type: "secret",
+        data: keyData,
+      });
+
+      const algorithm = {
+        name: "ChaCha20-Poly1305",
+      };
+
+      return constructKey(
+        "secret",
+        extractable,
+        usageIntersection(keyUsages, recognisedUsages),
+        algorithm,
+        handle,
+      );
+    }
+    default:
+      throw new DOMException("Not implemented", "NotSupportedError");
+  }
+}
+
 function importKeyAES(
   format,
   normalizedAlgorithm,
@@ -3653,6 +3719,14 @@ async function importKeyInner(
         ["wrapKey", "unwrapKey"],
       );
     }
+    case "ChaCha20-Poly1305": {
+      return importKeyChaCha20Poly1305(
+        format,
+        keyData,
+        extractable,
+        keyUsages,
+      );
+    }
     case "X448": {
       return importKeyX448(
         format,
@@ -5840,4 +5914,191 @@ const dictEcdhKeyDeriveParams = [
 webidl.converters.EcdhKeyDeriveParams = webidl
   .createDictionaryConverter("EcdhKeyDeriveParams", dictEcdhKeyDeriveParams);
 
-export { Crypto, crypto, CryptoKey, SubtleCrypto };
+// Bridge functions for Node.js KeyObject interop
+
+/**
+ * Export CryptoKey data in a format suitable for creating Node.js KeyObject.
+ * Returns { type, data } where data is DER bytes (spki/pkcs8) or raw bytes (secret).
+ */
+function cryptoKeyExportNodeKeyMaterial(cryptoKey) {
+  const handle = cryptoKey[_handle];
+  const innerKey = WeakMapPrototypeGet(KEY_STORE, handle);
+  const type = cryptoKey[_type];
+  const algorithmName = cryptoKey[_algorithm].name;
+
+  if (type === "secret") {
+    return { type: "secret", data: innerKey.data };
+  }
+
+  if (type === "public") {
+    let data;
+    switch (algorithmName) {
+      case "RSASSA-PKCS1-v1_5":
+      case "RSA-PSS":
+      case "RSA-OAEP":
+        data = op_crypto_export_key(
+          { algorithm: algorithmName, format: "spki" },
+          innerKey,
+        );
+        break;
+      case "ECDH":
+      case "ECDSA":
+        data = op_crypto_export_key({
+          algorithm: algorithmName,
+          namedCurve: cryptoKey[_algorithm].namedCurve,
+          format: "spki",
+        }, innerKey);
+        break;
+      case "Ed25519":
+        data = op_crypto_export_spki_ed25519(innerKey);
+        break;
+      case "X25519":
+        data = op_crypto_export_spki_x25519(innerKey);
+        break;
+      case "X448":
+        data = op_crypto_export_spki_x448(innerKey);
+        break;
+      default:
+        throw new TypeError(`Unsupported algorithm: ${algorithmName}`);
+    }
+    return { type: "public", data };
+  }
+
+  // private
+  let data;
+  switch (algorithmName) {
+    case "RSASSA-PKCS1-v1_5":
+    case "RSA-PSS":
+    case "RSA-OAEP":
+      data = op_crypto_export_key(
+        { algorithm: algorithmName, format: "pkcs8" },
+        innerKey,
+      );
+      break;
+    case "ECDH":
+    case "ECDSA":
+      data = op_crypto_export_key({
+        algorithm: algorithmName,
+        namedCurve: cryptoKey[_algorithm].namedCurve,
+        format: "pkcs8",
+      }, innerKey);
+      break;
+    case "Ed25519": {
+      data = op_crypto_export_pkcs8_ed25519(
+        new Uint8Array([0x04, 0x22, ...new SafeArrayIterator(innerKey)]),
+      );
+      data[15] = 0x20;
+      break;
+    }
+    case "X25519": {
+      data = op_crypto_export_pkcs8_x25519(
+        new Uint8Array([0x04, 0x22, ...new SafeArrayIterator(innerKey)]),
+      );
+      data[15] = 0x20;
+      break;
+    }
+    case "X448": {
+      data = op_crypto_export_pkcs8_x448(
+        new Uint8Array([0x04, 0x22, ...new SafeArrayIterator(innerKey)]),
+      );
+      data[15] = 0x20;
+      break;
+    }
+    default:
+      throw new TypeError(`Unsupported algorithm: ${algorithmName}`);
+  }
+  return { type: "private", data };
+}
+
+/**
+ * Create a CryptoKey from key material (sync). Used by Node.js keyObject.toCryptoKey().
+ * @param {string} format - "raw", "spki", or "pkcs8"
+ * @param {Uint8Array} keyData - key material
+ * @param {object} algorithm - algorithm identifier (string or object)
+ * @param {boolean} extractable
+ * @param {string[]} usages
+ * @returns {CryptoKey}
+ */
+function importCryptoKeySync(format, keyData, algorithm, extractable, usages) {
+  const normalizedAlgorithm = normalizeAlgorithm(algorithm, "importKey");
+  const algorithmName = normalizedAlgorithm.name;
+
+  switch (algorithmName) {
+    case "HMAC":
+      return importKeyHMAC(
+        format,
+        normalizedAlgorithm,
+        keyData,
+        extractable,
+        usages,
+      );
+    case "ECDH":
+    case "ECDSA":
+      return importKeyEC(
+        format,
+        normalizedAlgorithm,
+        keyData,
+        extractable,
+        usages,
+      );
+    case "RSASSA-PKCS1-v1_5":
+    case "RSA-PSS":
+    case "RSA-OAEP":
+      return importKeyRSA(
+        format,
+        normalizedAlgorithm,
+        keyData,
+        extractable,
+        usages,
+      );
+    case "HKDF":
+      return importKeyHKDF(format, keyData, extractable, usages);
+    case "PBKDF2":
+      return importKeyPBKDF2(format, keyData, extractable, usages);
+    case "AES-CTR":
+    case "AES-CBC":
+    case "AES-GCM":
+    case "AES-OCB":
+      return importKeyAES(
+        format,
+        normalizedAlgorithm,
+        keyData,
+        extractable,
+        usages,
+        ["encrypt", "decrypt", "wrapKey", "unwrapKey"],
+      );
+    case "AES-KW":
+      return importKeyAES(
+        format,
+        normalizedAlgorithm,
+        keyData,
+        extractable,
+        usages,
+        ["wrapKey", "unwrapKey"],
+      );
+    case "ChaCha20-Poly1305":
+      return importKeyChaCha20Poly1305(
+        format,
+        keyData,
+        extractable,
+        usages,
+      );
+    case "X448":
+      return importKeyX448(format, keyData, extractable, usages);
+    case "X25519":
+      return importKeyX25519(format, keyData, extractable, usages);
+    case "Ed25519":
+      return importKeyEd25519(format, keyData, extractable, usages);
+    default:
+      throw new DOMException("Not implemented", "NotSupportedError");
+  }
+}
+
+export {
+  Crypto,
+  crypto,
+  CryptoKey,
+  cryptoKeyExportNodeKeyMaterial,
+  importCryptoKeySync,
+  SubtleCrypto,
+};

filename: ext/node/polyfills/internal/crypto/keys.ts
patch: @@ -7,6 +7,7 @@
 import { primordials } from "ext:core/mod.js";
 
 const {
+  ArrayPrototypeIncludes,
   ObjectDefineProperties,
   SymbolToStringTag,
 } = primordials;
@@ -34,6 +35,11 @@ import {
   op_node_key_type,
 } from "ext:core/ops";
 
+import {
+  cryptoKeyExportNodeKeyMaterial,
+  importCryptoKeySync,
+} from "ext:deno_crypto/00_crypto.js";
+
 import { kHandle } from "ext:deno_node/internal/crypto/constants.ts";
 import { isStringOrBuffer } from "ext:deno_node/internal/crypto/cipher.ts";
 import {
@@ -209,7 +215,22 @@ export class KeyObject {
     if (!isCryptoKey(key)) {
       throw new ERR_INVALID_ARG_TYPE("key", "CryptoKey", key);
     }
-    notImplemented("crypto.KeyObject.prototype.from");
+    const { type, data } = cryptoKeyExportNodeKeyMaterial(key);
+    if (type === "secret") {
+      const handle = op_node_create_secret_key(data);
+      return new SecretKeyObject(handle);
+    } else if (type === "public") {
+      const handle = op_node_create_public_key(data, "der", "spki");
+      return new PublicKeyObject(handle);
+    } else {
+      const handle = op_node_create_private_key(
+        data,
+        "der",
+        "pkcs8",
+        undefined,
+      );
+      return new PrivateKeyObject(handle);
+    }
   }
 
   equals(otherKeyObject: KeyObject): boolean {
@@ -755,6 +776,102 @@ export class SecretKeyObject extends KeyObject {
     return undefined;
   }
 
+  toCryptoKey(
+    algorithm: string | object,
+    extractable: boolean,
+    usages: string[],
+  ): CryptoKey {
+    const algName = typeof algorithm === "string"
+      ? algorithm
+      : (algorithm as { name: string }).name;
+
+    // Node.js-specific validation
+    const rawData = new Uint8Array(op_node_export_secret_key(this[kHandle]));
+
+    if (rawData.byteLength === 0) {
+      throw new DOMException(
+        "Zero-length key is not supported",
+        "DataError",
+      );
+    }
+
+    if (algName === "PBKDF2") {
+      if (extractable) {
+        throw new DOMException(
+          "PBKDF2 keys are not extractable",
+          "SyntaxError",
+        );
+      }
+      if (
+        usages.length > 0 &&
+        usages.some((u: string) =>
+          !ArrayPrototypeIncludes(["deriveKey", "deriveBits"], u)
+        )
+      ) {
+        throw new DOMException(
+          "Unsupported key usage for a PBKDF2 key",
+          "SyntaxError",
+        );
+      }
+    } else if (algName === "HKDF") {
+      if (extractable) {
+        throw new DOMException(
+          "HKDF keys are not extractable",
+          "SyntaxError",
+        );
+      }
+      if (
+        usages.length > 0 &&
+        usages.some((u: string) =>
+          !ArrayPrototypeIncludes(["deriveKey", "deriveBits"], u)
+        )
+      ) {
+        throw new DOMException(
+          "Unsupported key usage for an HKDF key",
+          "SyntaxError",
+        );
+      }
+    } else if (algName === "HMAC") {
+      if (usages.length === 0) {
+        throw new DOMException(
+          "Usages cannot be empty when importing a secret key.",
+          "SyntaxError",
+        );
+      }
+      const alg = algorithm as { length?: number };
+      if (alg.length !== undefined && alg.length === 0) {
+        throw new DOMException(
+          "HmacImportParams.length cannot be 0",
+          "DataError",
+        );
+      }
+    } else if (algName === "KMAC128" || algName === "KMAC256") {
+      if (usages.length === 0) {
+        throw new DOMException(
+          "Usages cannot be empty when importing a secret key.",
+          "SyntaxError",
+        );
+      }
+      const alg = algorithm as { length?: number };
+      if (alg.length !== undefined && alg.length === 0) {
+        throw new DOMException(
+          "KmacImportParams.length cannot be 0",
+          "DataError",
+        );
+      }
+    } else {
+      // AES variants, ChaCha20-Poly1305
+      if (usages.length === 0) {
+        throw new DOMException(
+          "Usages cannot be empty when importing a secret key.",
+          "SyntaxError",
+        );
+      }
+    }
+
+    return importCryptoKeySync("raw", rawData, algorithm, extractable, usages);
+  }
+
   export(options?: { format?: "buffer" | "jwk" }): Buffer | JsonWebKey {
     let format: "buffer" | "jwk" = "buffer";
     if (options !== undefined) {
@@ -797,6 +914,39 @@ export class PrivateKeyObject extends AsymmetricKeyObject {
     super("private", handle);
   }
 
+  toCryptoKey(
+    algorithm: string | object,
+    extractable: boolean,
+    usages: string[],
+  ): CryptoKey {
+    const algName = typeof algorithm === "string"
+      ? algorithm
+      : (algorithm as { name: string }).name;
+
+    _validateAsymmetricKeyAlgorithm(this, algName);
+    if (typeof algorithm === "object") {
+      _validateEcNamedCurve(this, algorithm);
+    }
+
+    if (usages.length === 0) {
+      throw new DOMException(
+        "Usages cannot be empty when importing a private key.",
+        "SyntaxError",
+      );
+    }
+
+    const pkcs8Data = Buffer.from(
+      op_node_export_private_key_der(this[kHandle], "pkcs8"),
+    );
+    return importCryptoKeySync(
+      "pkcs8",
+      pkcs8Data,
+      algorithm,
+      extractable,
+      usages,
+    );
+  }
+
   export(options: JwkKeyExportOptions | KeyExportOptions<KeyFormat>) {
     if (options && options.format === "jwk") {
       return op_node_export_private_key_jwk(this[kHandle]);
@@ -819,6 +969,32 @@ export class PublicKeyObject extends AsymmetricKeyObject {
     super("public", handle);
   }
 
+  toCryptoKey(
+    algorithm: string | object,
+    extractable: boolean,
+    usages: string[],
+  ): CryptoKey {
+    const algName = typeof algorithm === "string"
+      ? algorithm
+      : (algorithm as { name: string }).name;
+
+    _validateAsymmetricKeyAlgorithm(this, algName);
+    if (typeof algorithm === "object") {
+      _validateEcNamedCurve(this, algorithm);
+    }
+
+    const spkiData = Buffer.from(
+      op_node_export_public_key_der(this[kHandle], "spki"),
+    );
+    return importCryptoKeySync(
+      "spki",
+      spkiData,
+      algorithm,
+      extractable,
+      usages,
+    );
+  }
+
   export(options: JwkKeyExportOptions | KeyExportOptions<KeyFormat>) {
     if (options && options.format === "jwk") {
       return op_node_export_public_key_jwk(this[kHandle]);
@@ -837,6 +1013,49 @@ export class PublicKeyObject extends AsymmetricKeyObject {
   }
 }
 
+function _validateAsymmetricKeyAlgorithm(
+  keyObject: AsymmetricKeyObject,
+  algName: string,
+) {
+  const keyType = keyObject.asymmetricKeyType;
+
+  // Check algorithm compatibility with the key type
+  if (keyType === "ed25519" || keyType === "x25519") {
+    const expectedAlg = keyType === "ed25519" ? "Ed25519" : "X25519";
+    if (algName !== expectedAlg) {
+      throw new DOMException("Invalid key type", "DataError");
+    }
+  } else if (keyType === "ed448" || keyType === "x448") {
+    const expectedAlg = keyType === "ed448" ? "Ed448" : "X448";
+    if (algName !== expectedAlg) {
+      throw new DOMException("Invalid key type", "DataError");
+    }
+  }
+}
+
+function _validateEcNamedCurve(
+  keyObject: AsymmetricKeyObject,
+  algorithm: object,
+) {
+  const details = keyObject.asymmetricKeyDetails;
+  const alg = algorithm as { namedCurve?: string };
+  if (alg.namedCurve && details?.namedCurve) {
+    // Map Node.js curve names to Web Crypto names
+    const curveMap: Record<string, string> = {
+      "prime256v1": "P-256",
+      "secp384r1": "P-384",
+      "secp521r1": "P-521",
+      "P-256": "P-256",
+      "P-384": "P-384",
+      "P-521": "P-521",
+    };
+    const keyCurve = curveMap[details.namedCurve] || details.namedCurve;
+    if (keyCurve !== alg.namedCurve) {
+      throw new DOMException("Named curve mismatch", "DataError");
+    }
+  }
+}
+
 export function createSecretKey(
   key: string | ArrayBufferView | ArrayBuffer | KeyObject | CryptoKey,
   encoding?: string,

filename: tests/node_compat/config.jsonc
patch: @@ -276,6 +276,15 @@
     "parallel/test-crypto-keygen-non-standard-public-exponent.js": {},
     "parallel/test-crypto-keygen-rfc8017-9-1.js": {},
     "parallel/test-crypto-keygen-rfc8017-a-2-3.js": {},
+    "parallel/test-crypto-key-objects-messageport.js": {
+      "exitCode": 1,
+      "reason": "KeyObject structured clone (MessagePort serialization) not yet implemented"
+    },
+    "parallel/test-crypto-key-objects-to-crypto-key.js": {
+      "exitCode": 1,
+      "output": "[WILDCARD]Not implemented: ed448[WILDCARD]",
+      "reason": "Ed448 key generation not yet implemented"
+    },
     "parallel/test-crypto-lazy-transform-writable.js": {},
     "parallel/test-crypto-oneshot-hash.js": {},
     "parallel/test-crypto-op-during-process-exit.js": {},