View Javadoc
1   /*
2    * Copyright (c) 2002-2026 Gargoyle Software Inc.
3    *
4    * Licensed under the Apache License, Version 2.0 (the "License");
5    * you may not use this file except in compliance with the License.
6    * You may obtain a copy of the License at
7    * https://www.apache.org/licenses/LICENSE-2.0
8    *
9    * Unless required by applicable law or agreed to in writing, software
10   * distributed under the License is distributed on an "AS IS" BASIS,
11   * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
12   * See the License for the specific language governing permissions and
13   * limitations under the License.
14   */
15  package org.htmlunit.javascript.host.crypto;
16  
17  import java.nio.ByteBuffer;
18  import java.security.GeneralSecurityException;
19  import java.security.Key;
20  import java.security.KeyPair;
21  import java.security.KeyPairGenerator;
22  import java.security.MessageDigest;
23  import java.security.PrivateKey;
24  import java.security.PublicKey;
25  import java.security.Signature;
26  import java.security.spec.AlgorithmParameterSpec;
27  import java.security.spec.ECGenParameterSpec;
28  import java.security.spec.MGF1ParameterSpec;
29  import java.security.spec.PSSParameterSpec;
30  import java.security.spec.RSAKeyGenParameterSpec;
31  import java.util.ArrayList;
32  import java.util.Collections;
33  import java.util.HashSet;
34  import java.util.LinkedHashSet;
35  import java.util.List;
36  import java.util.Map;
37  import java.util.Set;
38  
39  import javax.crypto.Cipher;
40  import javax.crypto.KeyGenerator;
41  import javax.crypto.Mac;
42  import javax.crypto.SecretKey;
43  import javax.crypto.spec.GCMParameterSpec;
44  import javax.crypto.spec.IvParameterSpec;
45  import javax.crypto.spec.OAEPParameterSpec;
46  import javax.crypto.spec.PSource;
47  import javax.crypto.spec.SecretKeySpec;
48  
49  import org.htmlunit.corejs.javascript.EcmaError;
50  import org.htmlunit.corejs.javascript.NativePromise;
51  import org.htmlunit.corejs.javascript.Scriptable;
52  import org.htmlunit.corejs.javascript.ScriptableObject;
53  import org.htmlunit.corejs.javascript.VarScope;
54  import org.htmlunit.corejs.javascript.typedarrays.NativeArrayBuffer;
55  import org.htmlunit.corejs.javascript.typedarrays.NativeArrayBufferView;
56  import org.htmlunit.javascript.HtmlUnitScriptable;
57  import org.htmlunit.javascript.JavaScriptEngine;
58  import org.htmlunit.javascript.configuration.JsxClass;
59  import org.htmlunit.javascript.configuration.JsxConstructor;
60  import org.htmlunit.javascript.configuration.JsxFunction;
61  import org.htmlunit.javascript.host.dom.DOMException;
62  
63  /**
64   * A JavaScript object for {@code SubtleCrypto}.
65   *
66   * @author Ahmed Ashour
67   * @author Ronald Brill
68   * @author Atsushi Nakagawa
69   * @author Lai Quang Duong
70   *
71   * @see <a href="https://developer.mozilla.org/en-US/docs/Web/API/SubtleCrypto">MDN Documentation</a>
72   */
73  @JsxClass
74  public class SubtleCrypto extends HtmlUnitScriptable {
75  
76      /**
77       * Maps each crypto operation to its supported algorithm names.
78       * @see <a href="https://w3c.github.io/webcrypto/#algorithm-overview">Algorithm Overview</a>
79       */
80      private static final Map<String, Set<String>> OPERATION_TO_SUPPORTED_ALGORITHMS = Map.ofEntries(
81              Map.entry("encrypt", Set.of("RSA-OAEP", "AES-CTR", "AES-CBC", "AES-GCM")),
82              Map.entry("decrypt", Set.of("RSA-OAEP", "AES-CTR", "AES-CBC", "AES-GCM")),
83              Map.entry("sign", Set.of("RSASSA-PKCS1-v1_5", "RSA-PSS", "ECDSA", "HMAC")),
84              Map.entry("verify", Set.of("RSASSA-PKCS1-v1_5", "RSA-PSS", "ECDSA", "HMAC")),
85              Map.entry("digest", Set.of("SHA-1", "SHA-256", "SHA-384", "SHA-512")),
86              Map.entry("generateKey", Set.of("RSASSA-PKCS1-v1_5", "RSA-PSS", "RSA-OAEP",
87                      "ECDSA", "ECDH", "AES-CTR", "AES-CBC", "AES-GCM", "AES-KW", "HMAC")),
88              Map.entry("importKey", Set.of("RSASSA-PKCS1-v1_5", "RSA-PSS", "RSA-OAEP", "ECDSA", "ECDH",
89                      "AES-CTR", "AES-CBC", "AES-GCM", "AES-KW", "HMAC", "HKDF", "PBKDF2")),
90              Map.entry("wrapKey", Set.of("RSA-OAEP", "AES-CTR", "AES-CBC", "AES-GCM", "AES-KW")),
91              Map.entry("unwrapKey", Set.of("RSA-OAEP", "AES-CTR", "AES-CBC", "AES-GCM", "AES-KW")),
92              Map.entry("deriveBits", Set.of("ECDH", "HKDF", "PBKDF2")),
93              Map.entry("deriveKey", Set.of("ECDH", "HKDF", "PBKDF2"))
94      );
95  
96      /**
97       * The recognized cryptographic key usages.
98       *
99       * @see <a href="https://w3c.github.io/webcrypto/#dfn-RecognizedKeyUsage">RecognizedKeyUsage</a>
100      */
101     private static final Set<String> RECOGNIZED_KEY_USAGES = Collections.unmodifiableSet(
102             new LinkedHashSet<>(List.of("encrypt", "decrypt", "sign", "verify",
103                     "deriveKey", "deriveBits", "wrapKey", "unwrapKey")));
104 
105     /**
106      * The valid authentication tag lengths for AES-GCM, in bits.
107      *
108      * @see <a href="https://w3c.github.io/webcrypto/#aes-gcm-operations">AES-GCM encrypt, step 6</a>
109      */
110     private static final Set<Integer> VALID_AES_GCM_TAG_LENGTHS = Set.of(32, 64, 96, 104, 112, 120, 128);
111 
112     private static class InvalidAccessException extends RuntimeException {
113         InvalidAccessException(final String message) {
114             super(message);
115         }
116     }
117 
118     /**
119      * Creates an instance.
120      */
121     @JsxConstructor
122     public void jsConstructor() {
123         throw JavaScriptEngine.typeErrorIllegalConstructor();
124     }
125 
126     private NativePromise notImplemented() {
127         return setupRejectedPromise(() ->
128                 new DOMException("Operation is not supported", DOMException.NOT_SUPPORTED_ERR));
129     }
130 
131     /**
132      * Encrypts data using the given key and algorithm.
133      * @see <a href="https://w3c.github.io/webcrypto/#SubtleCrypto-method-encrypt">SubtleCrypto.encrypt()</a>
134      * @param algorithm the algorithm identifier with parameters
135      * @param key the CryptoKey to encrypt with
136      * @param data the data to encrypt
137      * @return a Promise that fulfills with an ArrayBuffer containing the ciphertext
138      */
139     @JsxFunction
140     public NativePromise encrypt(final Object algorithm, final CryptoKey key, final Object data) {
141         return doCipher(algorithm, key, data, Cipher.ENCRYPT_MODE);
142     }
143 
144     /**
145      * Decrypts data using the given key and algorithm.
146      * @see <a href="https://w3c.github.io/webcrypto/#SubtleCrypto-method-decrypt">SubtleCrypto.decrypt()</a>
147      * @param algorithm the algorithm identifier with parameters
148      * @param key the CryptoKey to decrypt with
149      * @param data the data to decrypt
150      * @return a Promise that fulfills with an ArrayBuffer containing the plaintext
151      */
152     @JsxFunction
153     public NativePromise decrypt(final Object algorithm, final CryptoKey key, final Object data) {
154         return doCipher(algorithm, key, data, Cipher.DECRYPT_MODE);
155     }
156 
157     /**
158      * Shared encrypt/decrypt implementation.
159      */
160     private NativePromise doCipher(final Object algorithm, final CryptoKey key,
161             final Object data, final int cipherMode) {
162         final String operation = switch (cipherMode) {
163             case Cipher.ENCRYPT_MODE -> "encrypt";
164             case Cipher.DECRYPT_MODE -> "decrypt";
165             default -> throw new IllegalArgumentException("Invalid cipher mode: " + cipherMode);
166         };
167 
168         final byte[] result;
169         try {
170             final String algorithmName = resolveAlgorithmName(algorithm);
171             ensureAlgorithmIsSupported(operation, algorithmName);
172             ensureKeyAlgorithmMatches(algorithmName, key);
173             ensureKeyUsage(key, operation);
174 
175             final ByteBuffer inputData = asByteBuffer(data);
176 
177             // encrypt/decrypt requires algorithm parameters as an object (iv, counter, etc.)
178             if (!(algorithm instanceof Scriptable algorithmObj)) {
179                 throw new IllegalArgumentException("An invalid or illegal string was specified");
180             }
181 
182             switch (algorithmName) {
183                 case "AES-CBC": {
184                     // https://w3c.github.io/webcrypto/#aes-cbc-operations
185                     final byte[] iv = extractBuffer(algorithmObj, "iv");
186                     if (iv == null || iv.length != 16) {
187                         throw new IllegalArgumentException(
188                                 "Data provided to an operation does not meet requirements");
189                     }
190                     final SecretKey secretKey = getInternalKey(key, SecretKey.class);
191                     final Cipher cipher = Cipher.getInstance("AES/CBC/PKCS5Padding");
192                     cipher.init(cipherMode, secretKey, new IvParameterSpec(iv));
193                     result = cipher.doFinal(toByteArray(inputData));
194                     break;
195                 }
196                 case "AES-GCM": {
197                     // https://w3c.github.io/webcrypto/#aes-gcm-operations
198                     final byte[] iv = extractBuffer(algorithmObj, "iv");
199                     if (iv == null || iv.length == 0) {
200                         throw new IllegalArgumentException(
201                                 "Data provided to an operation does not meet requirements");
202                     }
203 
204                     final int tagLength;
205                     final Object tagLengthProp = ScriptableObject.getProperty(algorithmObj, "tagLength");
206                     if (tagLengthProp instanceof Number num) {
207                         tagLength = num.intValue();
208                         if (!VALID_AES_GCM_TAG_LENGTHS.contains(tagLength)) {
209                             throw new IllegalArgumentException(
210                                     "Data provided to an operation does not meet requirements");
211                         }
212                     }
213                     else {
214                         tagLength = 128;
215                     }
216 
217                     final SecretKey secretKey = getInternalKey(key, SecretKey.class);
218                     final Cipher cipher = Cipher.getInstance("AES/GCM/NoPadding");
219                     cipher.init(cipherMode, secretKey, new GCMParameterSpec(tagLength, iv));
220 
221                     final Object aadProp = ScriptableObject.getProperty(algorithmObj, "additionalData");
222                     if (aadProp instanceof Scriptable) {
223                         final ByteBuffer aad = asByteBuffer(aadProp);
224                         cipher.updateAAD(toByteArray(aad));
225                     }
226 
227                     result = cipher.doFinal(toByteArray(inputData));
228                     break;
229                 }
230                 case "AES-CTR": {
231                     // https://w3c.github.io/webcrypto/#aes-ctr-operations
232                     final byte[] counter = extractBuffer(algorithmObj, "counter");
233                     if (counter == null || counter.length != 16) {
234                         throw new IllegalArgumentException(
235                                 "Data provided to an operation does not meet requirements");
236                     }
237 
238                     final Object lengthProp = ScriptableObject.getProperty(algorithmObj, "length");
239                     if (!(lengthProp instanceof Number numLength)) {
240                         throw new IllegalArgumentException(
241                                 "Data provided to an operation does not meet requirements");
242                     }
243                     final int counterLength = numLength.intValue();
244                     if (counterLength < 1 || counterLength > 128) {
245                         throw new IllegalArgumentException(
246                                 "Data provided to an operation does not meet requirements");
247                     }
248 
249                     final SecretKey secretKey = getInternalKey(key, SecretKey.class);
250                     // Java always increments the full 128-bit counter, ignoring the 'length' partitioning.
251                     // This only becomes an issue when data exceeds 2^length AES blocks (16 bytes each),
252                     // but in real-world usage (length >= 64) it's pretty much unreachable.
253                     final Cipher cipher = Cipher.getInstance("AES/CTR/NoPadding");
254                     cipher.init(cipherMode, secretKey, new IvParameterSpec(counter));
255                     result = cipher.doFinal(toByteArray(inputData));
256                     break;
257                 }
258                 case "RSA-OAEP": {
259                     // https://w3c.github.io/webcrypto/#rsa-oaep-operations
260                     final Scriptable keyAlgorithm = key.getAlgorithm();
261                     final Object hashObj = ScriptableObject.getProperty(keyAlgorithm, "hash");
262                     final String hash = resolveAlgorithmName(hashObj);
263 
264                     final byte[] label;
265                     final Object labelProp = ScriptableObject.getProperty(algorithmObj, "label");
266                     if (labelProp instanceof Scriptable) {
267                         final ByteBuffer labelBuf = asByteBuffer(labelProp);
268                         label = toByteArray(labelBuf);
269                     }
270                     else {
271                         label = new byte[0];
272                     }
273 
274                     final MGF1ParameterSpec mgf1Spec = new MGF1ParameterSpec(hash);
275                     final AlgorithmParameterSpec oaepSpec = new OAEPParameterSpec(
276                             hash, "MGF1", mgf1Spec, new PSource.PSpecified(label));
277 
278                     final Key internalKey;
279                     if (cipherMode == Cipher.ENCRYPT_MODE) {
280                         internalKey = getInternalKey(key, PublicKey.class);
281                     }
282                     else {
283                         internalKey = getInternalKey(key, PrivateKey.class);
284                     }
285 
286                     final Cipher cipher = Cipher.getInstance("RSA/ECB/OAEPPadding");
287                     cipher.init(cipherMode, internalKey, oaepSpec);
288                     result = cipher.doFinal(toByteArray(inputData));
289                     break;
290                 }
291                 default:
292                     throw new UnsupportedOperationException(operation + " " + algorithmName);
293             }
294         }
295         catch (final EcmaError e) {
296             return setupRejectedPromise(() -> e);
297         }
298         catch (final InvalidAccessException e) {
299             return setupRejectedPromise(() -> new DOMException(e.getMessage(), DOMException.INVALID_ACCESS_ERR));
300         }
301         catch (final IllegalArgumentException e) {
302             return setupRejectedPromise(() -> new DOMException(e.getMessage(), DOMException.SYNTAX_ERR));
303         }
304         catch (final GeneralSecurityException | UnsupportedOperationException e) {
305             return setupRejectedPromise(() -> new DOMException("Operation is not supported: " + e.getMessage(),
306                     DOMException.NOT_SUPPORTED_ERR));
307         }
308         return setupPromise(() -> createArrayBuffer(result));
309     }
310 
311     /**
312      * Signs data using the given key.
313      * @see <a href="https://w3c.github.io/webcrypto/#SubtleCrypto-method-sign">SubtleCrypto.sign()</a>
314      * @param algorithm the algorithm identifier (String or object with name property)
315      * @param key the CryptoKey to sign with
316      * @param data the data to sign
317      * @return a Promise that fulfills with an ArrayBuffer containing the signature
318      */
319     @JsxFunction
320     public NativePromise sign(final Object algorithm, final CryptoKey key, final Object data) {
321         return doSignOrVerify(algorithm, key, null, data, true);
322     }
323 
324     /**
325      * Verifies a signature using the given key.
326      * @see <a href="https://w3c.github.io/webcrypto/#SubtleCrypto-method-verify">SubtleCrypto.verify()</a>
327      * @param algorithm the algorithm identifier (String or object with name property)
328      * @param key the CryptoKey to verify with
329      * @param signature the signature to verify
330      * @param data the data that was signed
331      * @return a Promise that fulfills with a boolean indicating whether the signature is valid
332      */
333     @JsxFunction
334     public NativePromise verify(final Object algorithm, final CryptoKey key,
335             final Object signature, final Object data) {
336         return doSignOrVerify(algorithm, key, signature, data, false);
337     }
338 
339     /**
340      * Shared sign/verify implementation.
341      */
342     private NativePromise doSignOrVerify(final Object algorithm, final CryptoKey key,
343             final Object existingSignature, final Object data, final boolean isSigning) {
344         final Object result;
345         try {
346             final String algorithmName = resolveAlgorithmName(algorithm);
347             final String operation = isSigning ? "sign" : "verify";
348             ensureAlgorithmIsSupported(operation, algorithmName);
349             ensureKeyAlgorithmMatches(algorithmName, key);
350             ensureKeyUsage(key, operation);
351 
352             final ByteBuffer inputData = asByteBuffer(data);
353 
354             switch (algorithmName) {
355                 case "HMAC": {
356                     // https://w3c.github.io/webcrypto/#hmac-operations
357                     final SecretKey secretKey = getInternalKey(key, SecretKey.class);
358                     final Mac mac = Mac.getInstance(secretKey.getAlgorithm());
359                     mac.init(secretKey);
360                     mac.update(inputData);
361                     final byte[] macBytes = mac.doFinal();
362                     if (isSigning) {
363                         result = macBytes;
364                     }
365                     else {
366                         result = MessageDigest.isEqual(macBytes,
367                                 toByteArray(asByteBuffer(existingSignature)));
368                     }
369                     break;
370                 }
371                 case "RSASSA-PKCS1-v1_5":
372                     // https://w3c.github.io/webcrypto/#rsassa-pkcs1
373                 case "RSA-PSS":
374                     // https://w3c.github.io/webcrypto/#rsa-pss
375                 case "ECDSA": {
376                     // https://w3c.github.io/webcrypto/#ecdsa-operations
377                     final Signature sig = "ECDSA".equals(algorithmName)
378                             ? resolveEcdsaSignature(algorithm)
379                             : resolveRsaSignature(algorithmName, algorithm, key);
380                     if (isSigning) {
381                         sig.initSign(getInternalKey(key, PrivateKey.class));
382                         sig.update(inputData);
383                         result = sig.sign();
384                     }
385                     else {
386                         sig.initVerify(getInternalKey(key, PublicKey.class));
387                         sig.update(inputData);
388                         result = sig.verify(toByteArray(asByteBuffer(existingSignature)));
389                     }
390                     break;
391                 }
392                 default:
393                     throw new UnsupportedOperationException(operation + " " + algorithmName);
394             }
395         }
396         catch (final EcmaError e) {
397             return setupRejectedPromise(() -> e);
398         }
399         catch (final InvalidAccessException e) {
400             return setupRejectedPromise(() -> new DOMException(e.getMessage(), DOMException.INVALID_ACCESS_ERR));
401         }
402         catch (final IllegalArgumentException e) {
403             return setupRejectedPromise(() -> new DOMException(e.getMessage(), DOMException.SYNTAX_ERR));
404         }
405         catch (final GeneralSecurityException | UnsupportedOperationException e) {
406             return setupRejectedPromise(() -> new DOMException("Operation is not supported: " + e.getMessage(),
407                     DOMException.NOT_SUPPORTED_ERR));
408         }
409 
410         if (isSigning) {
411             return setupPromise(() -> createArrayBuffer((byte[]) result));
412         }
413         return setupPromise(() -> result);
414     }
415 
416     /**
417      * Resolves the RSA {@link Signature} instance for the given algorithm.
418      */
419     private static Signature resolveRsaSignature(final String algorithmName, final Object algorithmParams,
420             final CryptoKey key) throws GeneralSecurityException {
421         final Object hashObj = ScriptableObject.getProperty(key.getAlgorithm(), "hash");
422         final String hash = resolveAlgorithmName(hashObj);
423         final String javaHash = hash.replace("-", "");
424 
425         if ("RSASSA-PKCS1-v1_5".equals(algorithmName)) {
426             return Signature.getInstance(javaHash + "withRSA");
427         }
428 
429         if (!(algorithmParams instanceof Scriptable obj)) {
430             throw new IllegalArgumentException("Data provided to an operation does not meet requirements");
431         }
432         final Object saltLengthProp = ScriptableObject.getProperty(obj, "saltLength");
433         if (!(saltLengthProp instanceof Number num)) {
434             throw new IllegalArgumentException("Data provided to an operation does not meet requirements");
435         }
436         final int saltLength = num.intValue();
437 
438         final MGF1ParameterSpec mgf1Spec = new MGF1ParameterSpec(hash);
439         final PSSParameterSpec pssSpec = new PSSParameterSpec(hash, "MGF1", mgf1Spec, saltLength, 1);
440         final Signature sig = Signature.getInstance("RSASSA-PSS");
441         sig.setParameter(pssSpec);
442         return sig;
443     }
444 
445     /**
446      * Resolves the ECDSA {@link Signature} instance for the given algorithm params.
447      */
448     private static Signature resolveEcdsaSignature(final Object algorithmParams)
449             throws GeneralSecurityException {
450         if (!(algorithmParams instanceof Scriptable obj)) {
451             throw new IllegalArgumentException("Data provided to an operation does not meet requirements");
452         }
453         final Object hashProp = ScriptableObject.getProperty(obj, "hash");
454         final String hash = resolveAlgorithmName(hashProp);
455         final String javaHash = hash.replace("-", "");
456         return Signature.getInstance(javaHash + "withECDSAinP1363Format");
457     }
458 
459     private static byte[] toByteArray(final ByteBuffer buffer) {
460         final byte[] result = new byte[buffer.remaining()];
461         buffer.get(result);
462         return result;
463     }
464 
465     /**
466      * Generates a digest of the given data.
467      * @see <a href="https://w3c.github.io/webcrypto/#SubtleCrypto-method-digest">SubtleCrypto.digest()</a>
468      * @param hashAlgorithm a string or an object with a single property name containing the hash algorithm to use
469      * @param data an object containing the data to be digested
470      * @return a Promise that fulfills with an ArrayBuffer containing the digest
471      */
472     @JsxFunction
473     public NativePromise digest(final Object hashAlgorithm, final Object data) {
474         final byte[] digest;
475         try {
476             final ByteBuffer inputData = asByteBuffer(data);
477             final String algorithm = resolveAlgorithmName(hashAlgorithm);
478             ensureAlgorithmIsSupported("digest", algorithm);
479 
480             final MessageDigest messageDigest = MessageDigest.getInstance(algorithm);
481             messageDigest.update(inputData);
482             digest = messageDigest.digest();
483         }
484         catch (final EcmaError e) {
485             return setupRejectedPromise(() -> e);
486         }
487         catch (final IllegalArgumentException e) {
488             return setupRejectedPromise(() -> new DOMException(e.getMessage(), DOMException.SYNTAX_ERR));
489         }
490         catch (final GeneralSecurityException | UnsupportedOperationException e) {
491             return setupRejectedPromise(() -> new DOMException("Operation is not supported: " + e.getMessage(),
492                     DOMException.NOT_SUPPORTED_ERR));
493         }
494         return setupPromise(() -> createArrayBuffer(digest));
495     }
496 
497     /**
498      * Generates a new key (for symmetric algorithms) or key pair (for public-key algorithms).
499      * @see <a href="https://w3c.github.io/webcrypto/#SubtleCrypto-method-generateKey">SubtleCrypto.generateKey()</a>
500      * @param keyGenParams algorithm-specific key generation parameters
501      * @param isExtractable whether the key(s) can be exported
502      * @param keyUsages permitted operations for the key(s)
503      * @return a Promise that fulfills with a CryptoKey or CryptoKeyPair
504      */
505     @JsxFunction
506     public NativePromise generateKey(final Scriptable keyGenParams, final boolean isExtractable,
507             final Scriptable keyUsages) {
508         final Object result;
509         try {
510             final String algorithm = resolveAlgorithmName(keyGenParams);
511             ensureAlgorithmIsSupported("generateKey", algorithm);
512 
513             final VarScope scope = keyGenParams.getParentScope();
514 
515             switch (algorithm) {
516                 case "RSASSA-PKCS1-v1_5":
517                 case "RSA-PSS":
518                 case "RSA-OAEP": {
519                     final RsaHashedKeyAlgorithm rsaParams = RsaHashedKeyAlgorithm.from(keyGenParams);
520                     final List<String> usages = resolveKeyUsages(algorithm, keyUsages);
521 
522                     final KeyPairGenerator keyPairGen = KeyPairGenerator.getInstance("RSA");
523                     keyPairGen.initialize(new RSAKeyGenParameterSpec(
524                             rsaParams.getModulusLength(), rsaParams.getPublicExponentAsBigInteger()));
525                     final KeyPair keyPair = keyPairGen.generateKeyPair();
526 
527                     final Scriptable algoObj = rsaParams.toScriptableObject(scope);
528                     result = createKeyPair(keyPair, algoObj, isExtractable, usages, scope);
529                     break;
530                 }
531                 case "ECDSA":
532                 case "ECDH": {
533                     final EcKeyAlgorithm ecParams = EcKeyAlgorithm.from(keyGenParams);
534                     final List<String> usages = resolveKeyUsages(algorithm, keyUsages);
535 
536                     final KeyPairGenerator keyPairGen = KeyPairGenerator.getInstance("EC");
537                     keyPairGen.initialize(new ECGenParameterSpec(ecParams.getJavaCurveName()));
538                     final KeyPair keyPair = keyPairGen.generateKeyPair();
539 
540                     final Scriptable algoObj = ecParams.toScriptableObject(scope);
541                     result = createKeyPair(keyPair, algoObj, isExtractable, usages, scope);
542                     break;
543                 }
544                 case "AES-CBC":
545                 case "AES-CTR":
546                 case "AES-GCM":
547                 case "AES-KW": {
548                     final AesKeyAlgorithm aesParams = AesKeyAlgorithm.from(keyGenParams);
549                     final List<String> usages = resolveKeyUsages(algorithm, keyUsages);
550                     if (usages.isEmpty()) {
551                         throw new IllegalArgumentException("An invalid or illegal string was specified");
552                     }
553 
554                     final KeyGenerator keyGen = KeyGenerator.getInstance("AES");
555                     keyGen.init(aesParams.getLength());
556                     final SecretKey secretKey = keyGen.generateKey();
557 
558                     final Scriptable algoObj = aesParams.toScriptableObject(scope);
559                     result = CryptoKey.create(getParentScope(), secretKey, isExtractable, algoObj, usages);
560                     break;
561                 }
562                 case "HMAC": {
563                     final HmacKeyAlgorithm hmacParams = HmacKeyAlgorithm.from(keyGenParams);
564                     final List<String> usages = resolveKeyUsages(algorithm, keyUsages);
565                     if (usages.isEmpty()) {
566                         throw new IllegalArgumentException("An invalid or illegal string was specified");
567                     }
568 
569                     final KeyGenerator keyGen = KeyGenerator.getInstance(hmacParams.getJavaName());
570                     keyGen.init(hmacParams.getLength());
571                     final SecretKey secretKey = keyGen.generateKey();
572 
573                     final Scriptable algoObj = hmacParams.toScriptableObject(scope);
574                     result = CryptoKey.create(getParentScope(), secretKey, isExtractable, algoObj, usages);
575                     break;
576                 }
577                 default:
578                     throw new UnsupportedOperationException("generateKey " + algorithm);
579             }
580         }
581         catch (final EcmaError e) {
582             return setupRejectedPromise(() -> e);
583         }
584         catch (final IllegalArgumentException e) {
585             return setupRejectedPromise(() -> new DOMException(e.getMessage(), DOMException.SYNTAX_ERR));
586         }
587         catch (final GeneralSecurityException | UnsupportedOperationException e) {
588             return setupRejectedPromise(() -> new DOMException("Operation is not supported: " + e.getMessage(),
589                     DOMException.NOT_SUPPORTED_ERR));
590         }
591         return setupPromise(() -> result);
592     }
593 
594     /**
595      * Creates a CryptoKeyPair (plain JS object with publicKey/privateKey) from a Java KeyPair.
596      * The public key is always extractable regardless of the extractable parameter.
597      * Usages are split: public gets {encrypt,verify,wrapKey},
598      * private gets {decrypt,sign,unwrapKey,deriveBits,deriveKey}.
599      */
600     private Scriptable createKeyPair(final KeyPair keyPair, final Scriptable algoObj,
601             final boolean isExtractable, final List<String> allUsages, final VarScope scope) {
602         final Set<String> publicUsageSet = Set.of("encrypt", "verify", "wrapKey");
603         final Set<String> privateUsageSet = Set.of("decrypt", "sign", "unwrapKey", "deriveBits", "deriveKey");
604 
605         final List<String> publicUsages = new ArrayList<>();
606         final List<String> privateUsages = new ArrayList<>();
607         for (final String usage : allUsages) {
608             if (publicUsageSet.contains(usage)) {
609                 publicUsages.add(usage);
610             }
611             if (privateUsageSet.contains(usage)) {
612                 privateUsages.add(usage);
613             }
614         }
615 
616         // if privateKey usages would be empty, throw SyntaxError
617         if (privateUsages.isEmpty()) {
618             throw new IllegalArgumentException("An invalid or illegal string was specified");
619         }
620 
621         // public key is always extractable
622         final CryptoKey publicKey = CryptoKey.create(
623                 getParentScope(), keyPair.getPublic(), true, algoObj, publicUsages);
624         final CryptoKey privateKey = CryptoKey.create(
625                 getParentScope(), keyPair.getPrivate(), isExtractable, algoObj, privateUsages);
626 
627         final Scriptable keyPairObj = JavaScriptEngine.newObject(scope);
628         ScriptableObject.putProperty(keyPairObj, "publicKey", publicKey);
629         ScriptableObject.putProperty(keyPairObj, "privateKey", privateKey);
630         return keyPairObj;
631     }
632 
633     /**
634      * Not yet implemented.
635      *
636      * @return a Promise which will be fulfilled with a CryptoKey object representing the new key
637      */
638     @JsxFunction
639     public NativePromise deriveKey() {
640         return notImplemented();
641     }
642 
643     /**
644      * Not yet implemented.
645      *
646      * @return a Promise which will be fulfilled with an ArrayBuffer containing the derived bits
647      */
648     @JsxFunction
649     public NativePromise deriveBits() {
650         return notImplemented();
651     }
652 
653     /**
654      * Imports a key from external, portable key material.
655      * @see <a href="https://w3c.github.io/webcrypto/#SubtleCrypto-method-importKey">SubtleCrypto.importKey()</a>
656      * @param format the data format ("raw", "pkcs8", "spki", "jwk")
657      * @param keyData the key material (BufferSource for raw/pkcs8/spki, JsonWebKey for jwk)
658      * @param keyImportParams algorithm-specific import parameters
659      * @param isExtractable whether the key can be exported
660      * @param keyUsages permitted operations for this key
661      * @return a Promise that fulfills with the imported CryptoKey
662      */
663     @JsxFunction
664     public NativePromise importKey(final String format, final Scriptable keyData,
665             final Scriptable keyImportParams, final boolean isExtractable, final Scriptable keyUsages) {
666         final CryptoKey key;
667         try {
668             final String algorithm = resolveAlgorithmName(keyImportParams);
669             ensureAlgorithmIsSupported("importKey", algorithm);
670 
671             switch (format) {
672                 case "raw":
673                     key = importRawKey(algorithm, keyData, keyImportParams, isExtractable, keyUsages);
674                     break;
675                 case "pkcs8":
676                 case "spki":
677                 case "jwk":
678                     return notImplemented();
679                 default:
680                     throw new IllegalArgumentException("An invalid or illegal string was specified");
681             }
682         }
683         catch (final EcmaError e) {
684             return setupRejectedPromise(() -> e);
685         }
686         catch (final IllegalArgumentException e) {
687             return setupRejectedPromise(() -> new DOMException(e.getMessage(), DOMException.SYNTAX_ERR));
688         }
689         catch (final UnsupportedOperationException e) {
690             return setupRejectedPromise(() -> new DOMException("Operation is not supported: " + e.getMessage(),
691                     DOMException.NOT_SUPPORTED_ERR));
692         }
693         return setupPromise(() -> key);
694     }
695 
696     private CryptoKey importRawKey(final String algorithm, final Scriptable keyData,
697             final Scriptable keyImportParams, final boolean isExtractable, final Scriptable keyUsages) {
698         final ByteBuffer byteBuffer = asByteBuffer(keyData);
699         final byte[] rawBytes = new byte[byteBuffer.remaining()];
700         byteBuffer.get(rawBytes);
701         final int bitLength = rawBytes.length * 8;
702         if (bitLength == 0) {
703             throw new IllegalArgumentException("Data provided to an operation does not meet requirements");
704         }
705 
706         final List<String> usages = resolveKeyUsages(algorithm, keyUsages);
707         if (usages.isEmpty()) {
708             throw new IllegalArgumentException("An invalid or illegal string was specified");
709         }
710 
711         if ("HMAC".equals(algorithm)) {
712             final HmacKeyAlgorithm params = HmacKeyAlgorithm.from(keyImportParams, bitLength);
713             final int length = params.getLength();
714             if (length > bitLength || length <= bitLength - 8) {
715                 throw new IllegalArgumentException("Data provided to an operation does not meet requirements");
716             }
717 
718             final Scriptable scriptableAlgorithm = params.toScriptableObject(keyImportParams.getParentScope());
719             final SecretKey internalKey = new SecretKeySpec(rawBytes, params.getJavaName());
720             return CryptoKey.create(getParentScope(), internalKey, isExtractable, scriptableAlgorithm, usages);
721         }
722 
723         if (AesKeyAlgorithm.isSupported(algorithm)) {
724             final AesKeyAlgorithm aesAlgo = new AesKeyAlgorithm(algorithm, bitLength);
725             final Scriptable scriptableAlgorithm = aesAlgo.toScriptableObject(keyImportParams.getParentScope());
726             final SecretKey internalKey = new SecretKeySpec(rawBytes, "AES");
727             return CryptoKey.create(getParentScope(), internalKey, isExtractable, scriptableAlgorithm, usages);
728         }
729 
730         throw new UnsupportedOperationException("importKey raw " + algorithm);
731     }
732 
733     /**
734      * Exports a key in the specified format.
735      * @see <a href="https://w3c.github.io/webcrypto/#SubtleCrypto-method-exportKey">SubtleCrypto.exportKey()</a>
736      * @param format the data format ("raw", "pkcs8", "spki", "jwk")
737      * @param key the CryptoKey to export
738      * @return a Promise that fulfills with the key data
739      */
740     @JsxFunction
741     public NativePromise exportKey(final String format, final CryptoKey key) {
742         final byte[] result;
743         try {
744             if (!key.getExtractable()) {
745                 return setupRejectedPromise(() -> new DOMException(
746                         "A parameter or an operation is not supported by the underlying object",
747                         DOMException.INVALID_ACCESS_ERR));
748             }
749 
750             switch (format) {
751                 case "raw": {
752                     if (!(key.getInternalKey() instanceof SecretKey secretKey)) {
753                         throw new IllegalArgumentException(
754                                 "Data provided to an operation does not meet requirements");
755                     }
756                     result = secretKey.getEncoded();
757                     break;
758                 }
759                 case "pkcs8":
760                 case "spki":
761                 case "jwk":
762                     return notImplemented();
763                 default:
764                     throw new IllegalArgumentException("An invalid or illegal string was specified");
765             }
766         }
767         catch (final IllegalArgumentException e) {
768             return setupRejectedPromise(() -> new DOMException(e.getMessage(), DOMException.SYNTAX_ERR));
769         }
770         catch (final UnsupportedOperationException e) {
771             return setupRejectedPromise(() -> new DOMException("Operation is not supported: " + e.getMessage(),
772                     DOMException.NOT_SUPPORTED_ERR));
773         }
774         return setupPromise(() -> createArrayBuffer(result));
775     }
776 
777     /**
778      * Not yet implemented.
779      *
780      * @return a Promise that fulfills with an ArrayBuffer containing the encrypted exported key
781      */
782     @JsxFunction
783     public NativePromise wrapKey() {
784         return notImplemented();
785     }
786 
787     /**
788      * Not yet implemented.
789      *
790      * @return a Promise that fulfills with the unwrapped key as a CryptoKey object
791      */
792     @JsxFunction
793     public NativePromise unwrapKey() {
794         return notImplemented();
795     }
796 
797     /**
798      * Checks if the specified crypto operation supports the given algorithm.
799      * @see <a href="https://w3c.github.io/webcrypto/#algorithm-overview">Algorithm Overview</a>
800      * @param operation the crypto operation (e.g. "digest", "sign")
801      * @param algorithm the algorithm name (e.g. "SHA-256", "HMAC")
802      * @throws UnsupportedOperationException if the operation does not support the algorithm
803      */
804     private static void ensureAlgorithmIsSupported(final String operation, final String algorithm) {
805         final Set<String> supportedAlgorithms = OPERATION_TO_SUPPORTED_ALGORITHMS.get(operation);
806         if (supportedAlgorithms == null || !supportedAlgorithms.contains(algorithm)) {
807             throw new UnsupportedOperationException(operation + " " + algorithm);
808         }
809     }
810 
811     /**
812      * Verifies that the operation's algorithm name matches the key's algorithm name.
813      * @param algorithmName the algorithm name from the operation parameters
814      * @param key the CryptoKey being used
815      * @throws InvalidAccessException if the algorithm names don't match
816      */
817     private static void ensureKeyAlgorithmMatches(final String algorithmName, final CryptoKey key) {
818         final String keyAlgoName = resolveAlgorithmName(key.getAlgorithm());
819         if (!algorithmName.equals(keyAlgoName)) {
820             throw new InvalidAccessException(
821                     "A parameter or an operation is not supported by the underlying object");
822         }
823     }
824 
825     /**
826      * Verifies that the key's usages include the specified usage.
827      * @param key the CryptoKey being used
828      * @param usage the required usage (e.g. "encrypt", "sign")
829      * @throws InvalidAccessException if the key doesn't have the required usage
830      */
831     private static void ensureKeyUsage(final CryptoKey key, final String usage) {
832         if (!key.getUsagesInternal().contains(usage)) {
833             throw new InvalidAccessException(
834                     "A parameter or an operation is not supported by the underlying object");
835         }
836     }
837 
838     /**
839      * Resolves the algorithm name from the given {@code AlgorithmIdentifier}.
840      * @see <a href="https://w3c.github.io/webcrypto/#dfn-AlgorithmIdentifier">
841      *     AlgorithmIdentifier</a>
842      * @param algorithm the algorithm identifier (String or Scriptable with name property)
843      * @return the resolved algorithm name
844      * @throws IllegalArgumentException if the identifier cannot be resolved
845      */
846     static String resolveAlgorithmName(final Object algorithm) {
847         if (algorithm instanceof String str) {
848             return str;
849         }
850         if (algorithm instanceof Scriptable obj) {
851             final Object name = ScriptableObject.getProperty(obj, "name");
852             if (name instanceof String nameStr) {
853                 return nameStr;
854             }
855         }
856         throw new IllegalArgumentException("An invalid or illegal string was specified");
857     }
858 
859     /**
860      * Converts ArrayBuffer or ArrayBufferView to a ByteBuffer.
861      * @param data the buffer source object
862      * @return the ByteBuffer wrapping the data
863      * @throws IllegalArgumentException if data is not a Scriptable or is NOT_FOUND
864      * @throws EcmaError if data is not an ArrayBuffer or ArrayBufferView
865      */
866     static ByteBuffer asByteBuffer(final Object data) {
867         if (!(data instanceof Scriptable)) {
868             throw new IllegalArgumentException("An invalid or illegal string was specified");
869         }
870         if (data == Scriptable.NOT_FOUND) {
871             throw new IllegalArgumentException("An invalid or illegal string was specified");
872         }
873         if (data instanceof NativeArrayBuffer nativeBuffer) {
874             return ByteBuffer.wrap(nativeBuffer.getBuffer());
875         }
876         else if (data instanceof NativeArrayBufferView arrayBufferView) {
877             final NativeArrayBuffer arrayBuffer = arrayBufferView.getBuffer();
878             return ByteBuffer.wrap(
879                     arrayBuffer.getBuffer(), arrayBufferView.getByteOffset(), arrayBufferView.getByteLength());
880         }
881         else {
882             throw JavaScriptEngine.typeError(
883                     "Argument could not be converted to any of: ArrayBufferView, ArrayBuffer.");
884         }
885     }
886 
887     /**
888      * Reads a property from a JS object and converts it to a byte array.
889      * @param obj the JS object containing the property
890      * @param property the property name (e.g. "iv", "counter", "label")
891      * @return the byte array, or {@code null} if the property is absent or not convertible
892      */
893     private static byte[] extractBuffer(final Scriptable obj, final String property) {
894         final Object prop = ScriptableObject.getProperty(obj, property);
895         if (prop instanceof Scriptable) {
896             final ByteBuffer buf = asByteBuffer(prop);
897             return toByteArray(buf);
898         }
899         return null;
900     }
901 
902     /**
903      * Creates a NativeArrayBuffer with proper scope and prototype from the given bytes.
904      * @param data the byte array to wrap
905      * @return the new NativeArrayBuffer
906      */
907     NativeArrayBuffer createArrayBuffer(final byte[] data) {
908         final NativeArrayBuffer buffer = new NativeArrayBuffer(data.length);
909         System.arraycopy(data, 0, buffer.getBuffer(), 0, data.length);
910         buffer.setParentScope(getParentScope());
911         buffer.setPrototype(ScriptableObject.getClassPrototype(getParentScope(), buffer.getClassName()));
912         return buffer;
913     }
914 
915     /**
916      * Resolves and validates key usages from the JS array against the algorithm's supported operations.
917      * @param algorithm the algorithm name
918      * @param keyUsages the JS usages array
919      * @return the validated, ordered list of usages
920      * @throws IllegalArgumentException if usages array is invalid or contains unrecognized values
921      */
922     static List<String> resolveKeyUsages(final String algorithm, final Scriptable keyUsages) {
923         if (!JavaScriptEngine.isArrayLike(keyUsages)) {
924             throw new IllegalArgumentException("An invalid or illegal string was specified");
925         }
926 
927         final Set<String> supportedKeyUsages = new HashSet<>();
928         JavaScriptEngine.iterateArrayLike(null, keyUsages, usage -> {
929             if (!(usage instanceof String usageStr)) {
930                 throw new IllegalArgumentException("An invalid or illegal string was specified");
931             }
932             if (!RECOGNIZED_KEY_USAGES.contains(usageStr)) {
933                 throw new IllegalArgumentException("An invalid or illegal string was specified");
934             }
935 
936             final Set<String> supportedAlgorithms = OPERATION_TO_SUPPORTED_ALGORITHMS.get(usageStr);
937             if (supportedAlgorithms != null && supportedAlgorithms.contains(algorithm)) {
938                 supportedKeyUsages.add(usageStr);
939             }
940         });
941 
942         // maintain canonical ordering per RECOGNIZED_KEY_USAGES
943         final List<String> sortedKeyUsages = new ArrayList<>();
944         for (final String keyUsage : RECOGNIZED_KEY_USAGES) {
945             if (supportedKeyUsages.contains(keyUsage)) {
946                 sortedKeyUsages.add(keyUsage);
947             }
948         }
949 
950         return sortedKeyUsages;
951     }
952 
953     /**
954      * Extracts the internal Java key from a CryptoKey, validating it is the expected type.
955      * @param <T> the expected key type
956      * @param cryptoKey the CryptoKey
957      * @param expectedKeyType the expected class (e.g. SecretKey.class)
958      * @return the internal key cast to the expected type
959      * @throws InvalidAccessException if the key is not the expected type
960      */
961     static <T extends Key> T getInternalKey(final CryptoKey cryptoKey, final Class<T> expectedKeyType) {
962         final Key internalKey = cryptoKey.getInternalKey();
963         if (!expectedKeyType.isInstance(internalKey)) {
964             throw new InvalidAccessException("A parameter or an operation is not supported by the underlying object");
965         }
966         return expectedKeyType.cast(internalKey);
967     }
968 }