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