diff --git a/src/main/java/org/htmlunit/javascript/host/crypto/AesKeyAlgorithm.java b/src/main/java/org/htmlunit/javascript/host/crypto/AesKeyAlgorithm.java new file mode 100644 index 0000000000..b0338f237f --- /dev/null +++ b/src/main/java/org/htmlunit/javascript/host/crypto/AesKeyAlgorithm.java @@ -0,0 +1,97 @@ +/* + * Copyright (c) 2002-2026 Gargoyle Software Inc. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * https://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package org.htmlunit.javascript.host.crypto; + +import java.util.Set; + +import org.htmlunit.corejs.javascript.NativeObject; +import org.htmlunit.corejs.javascript.ScriptRuntime; +import org.htmlunit.corejs.javascript.Scriptable; +import org.htmlunit.corejs.javascript.ScriptableObject; +import org.htmlunit.corejs.javascript.TopLevel; + +/** + * Internal helper representing AES key algorithm parameters. + * Used by {@link SubtleCrypto} for AES key operations. + * + * @author Lai Quang Duong + */ +final class AesKeyAlgorithm { + + static final Set SUPPORTED_NAMES = Set.of("AES-CBC", "AES-CTR", "AES-GCM", "AES-KW"); + static final Set SUPPORTED_LENGTHS = Set.of(128, 192, 256); + + private final String name_; + private final int length_; + + AesKeyAlgorithm(final String name, final int length) { + if (!SUPPORTED_NAMES.contains(name)) { + throw new UnsupportedOperationException("AES " + name); + } + name_ = name; + + if (!SUPPORTED_LENGTHS.contains(length)) { + throw new IllegalArgumentException("Data provided to an operation does not meet requirements"); + } + length_ = length; + } + + /** + * Parse AES key algorithm parameters from a JS object. + * + * @param keyGenParams the JS algorithm parameters object + * @return the parsed AesKeyAlgorithm + */ + static AesKeyAlgorithm from(final Scriptable keyGenParams) { + final Object nameProp = ScriptableObject.getProperty(keyGenParams, "name"); + if (!(nameProp instanceof String name)) { + throw new IllegalArgumentException("An invalid or illegal string was specified"); + } + + final Object lengthProp = ScriptableObject.getProperty(keyGenParams, "length"); + if (!(lengthProp instanceof Number numLength)) { + throw new IllegalArgumentException("An invalid or illegal string was specified"); + } + + return new AesKeyAlgorithm(name, numLength.intValue()); + } + + static boolean isSupported(final String name) { + return SUPPORTED_NAMES.contains(name); + } + + String getName() { + return name_; + } + + int getLength() { + return length_; + } + + /** + * Converts to a JS object matching the {@code AesKeyAlgorithm} dictionary: + * {@code {name: "AES-GCM", length: 256}} + * + * @param scope the JS scope for prototype/parent setup + * @return the JS algorithm object + */ + Scriptable toScriptableObject(final Scriptable scope) { + final NativeObject algorithm = new NativeObject(); + ScriptRuntime.setBuiltinProtoAndParent(algorithm, scope, TopLevel.Builtins.Object); + ScriptableObject.putProperty(algorithm, "name", getName()); + ScriptableObject.putProperty(algorithm, "length", getLength()); + return algorithm; + } +} diff --git a/src/main/java/org/htmlunit/javascript/host/crypto/CryptoKey.java b/src/main/java/org/htmlunit/javascript/host/crypto/CryptoKey.java index cd465988be..0a8df769b2 100644 --- a/src/main/java/org/htmlunit/javascript/host/crypto/CryptoKey.java +++ b/src/main/java/org/htmlunit/javascript/host/crypto/CryptoKey.java @@ -14,24 +14,131 @@ */ package org.htmlunit.javascript.host.crypto; +import java.security.Key; +import java.security.PrivateKey; +import java.security.PublicKey; +import java.util.Collection; +import java.util.LinkedHashSet; +import java.util.Objects; +import java.util.Set; + +import javax.crypto.SecretKey; + +import org.htmlunit.corejs.javascript.Scriptable; import org.htmlunit.javascript.HtmlUnitScriptable; +import org.htmlunit.javascript.JavaScriptEngine; import org.htmlunit.javascript.configuration.JsxClass; import org.htmlunit.javascript.configuration.JsxConstructor; +import org.htmlunit.javascript.configuration.JsxGetter; +import org.htmlunit.javascript.host.Window; /** * A JavaScript object for {@code CryptoKey}. * + * @see CryptoKey + * * @author Ahmed Ashour * @author Ronald Brill + * @author Lai Quang Duong */ @JsxClass public class CryptoKey extends HtmlUnitScriptable { + private Key internalKey_; + private String type_; + private boolean isExtractable_; + private Scriptable algorithm_; + private Set usages_; + /** * JavaScript constructor. */ @JsxConstructor public void jsConstructor() { - // nothing to do + throw JavaScriptEngine.typeErrorIllegalConstructor(); + } + + /** + * Creates a properly scoped CryptoKey from the given parameters. + * + * @param scope the JS scope + * @param internalKey the Java key (SecretKey, PublicKey, or PrivateKey) + * @param isExtractable whether the key can be exported + * @param algorithm the JS algorithm descriptor object + * @param usages the permitted key usages + * @return the new CryptoKey + */ + static CryptoKey create(final Scriptable scope, final Key internalKey, final boolean isExtractable, + final Scriptable algorithm, final Collection usages) { + final CryptoKey key = new CryptoKey(); + key.internalKey_ = Objects.requireNonNull(internalKey); + + if (internalKey instanceof PublicKey) { + key.type_ = "public"; + } + else if (internalKey instanceof PrivateKey) { + key.type_ = "private"; + } + else if (internalKey instanceof SecretKey) { + key.type_ = "secret"; + } + else { + throw new IllegalStateException("Unsupported key type: " + internalKey.getClass()); + } + + key.isExtractable_ = isExtractable; + key.algorithm_ = algorithm; + key.usages_ = new LinkedHashSet<>(usages); + + final Window window = getWindow(Objects.requireNonNull(scope)); + key.setParentScope(window); + key.setPrototype(window.getPrototype(CryptoKey.class)); + return key; + } + + /** + * @return the Java key (opaque {@code [[handle]]} internal slot) + */ + public Key getInternalKey() { + return internalKey_; + } + + /** + * @return the key type: "public", "private", or "secret" + */ + @JsxGetter + public String getType() { + return type_; + } + + /** + * @return whether the key material may be exported + */ + @JsxGetter + public boolean getExtractable() { + return isExtractable_; + } + + /** + * @return the algorithm descriptor object + */ + @JsxGetter + public Scriptable getAlgorithm() { + return algorithm_; + } + + /** + * @return the permitted key usages as a JS array + */ + @JsxGetter + public Scriptable getUsages() { + return JavaScriptEngine.newArray(this, usages_.toArray()); + } + + /** + * @return the permitted key usages as a Java set (for internal use) + */ + public Set getUsagesInternal() { + return usages_; } } diff --git a/src/main/java/org/htmlunit/javascript/host/crypto/EcKeyAlgorithm.java b/src/main/java/org/htmlunit/javascript/host/crypto/EcKeyAlgorithm.java new file mode 100644 index 0000000000..42db00146f --- /dev/null +++ b/src/main/java/org/htmlunit/javascript/host/crypto/EcKeyAlgorithm.java @@ -0,0 +1,110 @@ +/* + * Copyright (c) 2002-2026 Gargoyle Software Inc. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * https://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package org.htmlunit.javascript.host.crypto; + +import java.util.Map; +import java.util.Set; + +import org.htmlunit.corejs.javascript.NativeObject; +import org.htmlunit.corejs.javascript.ScriptRuntime; +import org.htmlunit.corejs.javascript.Scriptable; +import org.htmlunit.corejs.javascript.ScriptableObject; +import org.htmlunit.corejs.javascript.TopLevel; + +/** + * Internal helper representing EC key algorithm parameters. + * Used by {@link SubtleCrypto} for ECDSA and ECDH key operations. + * + * @author Lai Quang Duong + */ +final class EcKeyAlgorithm { + + static final Set SUPPORTED_NAMES = Set.of("ECDSA", "ECDH"); + + private static final Map CURVE_TO_JCA = Map.of( + "P-256", "secp256r1", + "P-384", "secp384r1", + "P-521", "secp521r1" + ); + + private final String name_; + private final String namedCurve_; + + EcKeyAlgorithm(final String name, final String namedCurve) { + if (!SUPPORTED_NAMES.contains(name)) { + throw new UnsupportedOperationException("EC " + name); + } + name_ = name; + + if (!CURVE_TO_JCA.containsKey(namedCurve)) { + throw new UnsupportedOperationException("EC curve " + namedCurve); + } + namedCurve_ = namedCurve; + } + + /** + * Parse EC key algorithm parameters from a JS object. + * + * @param keyGenParams the JS algorithm parameters object + * @return the parsed EcKeyAlgorithm + */ + static EcKeyAlgorithm from(final Scriptable keyGenParams) { + final Object nameProp = ScriptableObject.getProperty(keyGenParams, "name"); + if (!(nameProp instanceof String name)) { + throw new IllegalArgumentException("An invalid or illegal string was specified"); + } + + final Object curveProp = ScriptableObject.getProperty(keyGenParams, "namedCurve"); + if (!(curveProp instanceof String namedCurve)) { + throw new IllegalArgumentException("An invalid or illegal string was specified"); + } + + return new EcKeyAlgorithm(name, namedCurve); + } + + static boolean isSupported(final String name) { + return SUPPORTED_NAMES.contains(name); + } + + String getName() { + return name_; + } + + String getNamedCurve() { + return namedCurve_; + } + + /** + * @return the JCA curve name (e.g. "secp256r1" for "P-256") + */ + String getJavaCurveName() { + return CURVE_TO_JCA.get(namedCurve_); + } + + /** + * Converts to a JS object matching the {@code EcKeyAlgorithm} dictionary: + * {@code {name: "ECDSA", namedCurve: "P-256"}} + * + * @param scope the JS scope for prototype/parent setup + * @return the JS algorithm object + */ + Scriptable toScriptableObject(final Scriptable scope) { + final NativeObject algorithm = new NativeObject(); + ScriptRuntime.setBuiltinProtoAndParent(algorithm, scope, TopLevel.Builtins.Object); + ScriptableObject.putProperty(algorithm, "name", getName()); + ScriptableObject.putProperty(algorithm, "namedCurve", getNamedCurve()); + return algorithm; + } +} diff --git a/src/main/java/org/htmlunit/javascript/host/crypto/HmacKeyAlgorithm.java b/src/main/java/org/htmlunit/javascript/host/crypto/HmacKeyAlgorithm.java new file mode 100644 index 0000000000..c0ee006b48 --- /dev/null +++ b/src/main/java/org/htmlunit/javascript/host/crypto/HmacKeyAlgorithm.java @@ -0,0 +1,131 @@ +/* + * Copyright (c) 2002-2026 Gargoyle Software Inc. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * https://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package org.htmlunit.javascript.host.crypto; + +import java.util.Set; + +import org.htmlunit.corejs.javascript.NativeObject; +import org.htmlunit.corejs.javascript.ScriptRuntime; +import org.htmlunit.corejs.javascript.Scriptable; +import org.htmlunit.corejs.javascript.ScriptableObject; +import org.htmlunit.corejs.javascript.TopLevel; + +/** + * Internal helper representing HMAC key algorithm parameters. + * Used by {@link SubtleCrypto} for HMAC key operations. + * + * @author Lai Quang Duong + */ +final class HmacKeyAlgorithm { + + static final Set SUPPORTED_HASH_ALGORITHMS = Set.of("SHA-1", "SHA-256", "SHA-384", "SHA-512"); + + private final String hash_; + private final int length_; + + HmacKeyAlgorithm(final String hash, final int length) { + if (!SUPPORTED_HASH_ALGORITHMS.contains(hash)) { + throw new UnsupportedOperationException("HMAC " + hash); + } + hash_ = hash; + + if (length <= 0) { + throw new IllegalArgumentException("Data provided to an operation does not meet requirements"); + } + length_ = length; + } + + /** + * Parse HMAC key algorithm parameters from a JS object. + * + * @param keyGenParams the JS algorithm parameters object + * @return the parsed HmacKeyAlgorithm + */ + static HmacKeyAlgorithm from(final Scriptable keyGenParams) { + return from(keyGenParams, null); + } + + /** + * Parse HMAC key algorithm parameters from a JS object, with an optional fallback length. + * + * @param keyGenParams the JS algorithm parameters object + * @param fallbackLength optional length to use when not specified in params; + * if null, defaults to the hash block size + * @return the parsed HmacKeyAlgorithm + */ + static HmacKeyAlgorithm from(final Scriptable keyGenParams, final Integer fallbackLength) { + final Object hashProp = ScriptableObject.getProperty(keyGenParams, "hash"); + final String hash = SubtleCrypto.resolveAlgorithmName(hashProp); + + final int length; + final Object lengthProp = ScriptableObject.getProperty(keyGenParams, "length"); + if (lengthProp == Scriptable.NOT_FOUND) { + if (fallbackLength != null) { + length = fallbackLength; + } + else { + // default to the block size of the hash algorithm + length = switch (hash) { + case "SHA-1", "SHA-256" -> 512; + case "SHA-384", "SHA-512" -> 1024; + default -> throw new UnsupportedOperationException("HMAC " + hash); + }; + } + } + else { + if (!(lengthProp instanceof Number numLength)) { + throw new IllegalArgumentException("An invalid or illegal string was specified"); + } + length = numLength.intValue(); + } + + return new HmacKeyAlgorithm(hash, length); + } + + String getHash() { + return hash_; + } + + int getLength() { + return length_; + } + + /** + * @return the Java algorithm name for {@link javax.crypto.Mac} (e.g. "HmacSHA256") + */ + String getJavaName() { + return "Hmac" + hash_.replace("-", ""); + } + + /** + * Converts to a JS object matching the {@code HmacKeyAlgorithm} dictionary: + * {@code {name: "HMAC", hash: {name: "SHA-256"}, length: N}} + * + * @param scope the JS scope for prototype/parent setup + * @return the JS algorithm object + */ + Scriptable toScriptableObject(final Scriptable scope) { + final NativeObject hashObj = new NativeObject(); + ScriptRuntime.setBuiltinProtoAndParent(hashObj, scope, TopLevel.Builtins.Object); + ScriptableObject.putProperty(hashObj, "name", getHash()); + + final NativeObject algorithm = new NativeObject(); + ScriptRuntime.setBuiltinProtoAndParent(algorithm, scope, TopLevel.Builtins.Object); + ScriptableObject.putProperty(algorithm, "name", "HMAC"); + ScriptableObject.putProperty(algorithm, "hash", hashObj); + ScriptableObject.putProperty(algorithm, "length", getLength()); + return algorithm; + } +} diff --git a/src/main/java/org/htmlunit/javascript/host/crypto/RsaHashedKeyAlgorithm.java b/src/main/java/org/htmlunit/javascript/host/crypto/RsaHashedKeyAlgorithm.java new file mode 100644 index 0000000000..803e085673 --- /dev/null +++ b/src/main/java/org/htmlunit/javascript/host/crypto/RsaHashedKeyAlgorithm.java @@ -0,0 +1,170 @@ +/* + * Copyright (c) 2002-2026 Gargoyle Software Inc. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * https://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package org.htmlunit.javascript.host.crypto; + +import java.math.BigInteger; +import java.util.Set; + +import org.htmlunit.corejs.javascript.NativeObject; +import org.htmlunit.corejs.javascript.ScriptRuntime; +import org.htmlunit.corejs.javascript.Scriptable; +import org.htmlunit.corejs.javascript.ScriptableObject; +import org.htmlunit.corejs.javascript.TopLevel; +import org.htmlunit.corejs.javascript.typedarrays.NativeArrayBuffer; +import org.htmlunit.corejs.javascript.typedarrays.NativeArrayBufferView; +import org.htmlunit.corejs.javascript.typedarrays.NativeUint8Array; + +/** + * Internal helper representing RSA hashed key algorithm parameters. + * Used by {@link SubtleCrypto} for RSA key operations. + * + * @author Lai Quang Duong + */ +final class RsaHashedKeyAlgorithm { + + static final Set SUPPORTED_NAMES = Set.of("RSASSA-PKCS1-v1_5", "RSA-PSS", "RSA-OAEP"); + static final Set SUPPORTED_HASH_ALGORITHMS = Set.of("SHA-1", "SHA-256", "SHA-384", "SHA-512"); + + private final String name_; + private final int modulusLength_; + private final byte[] publicExponent_; + private final String hash_; + + RsaHashedKeyAlgorithm(final String name, final int modulusLength, + final byte[] publicExponent, final String hash) { + if (!SUPPORTED_NAMES.contains(name)) { + throw new UnsupportedOperationException("RSA " + name); + } + name_ = name; + + if (modulusLength <= 0) { + throw new IllegalArgumentException("Data provided to an operation does not meet requirements"); + } + modulusLength_ = modulusLength; + + if (publicExponent == null || publicExponent.length == 0) { + throw new IllegalArgumentException("Data provided to an operation does not meet requirements"); + } + publicExponent_ = publicExponent.clone(); + + if (!SUPPORTED_HASH_ALGORITHMS.contains(hash)) { + throw new UnsupportedOperationException("RSA hash " + hash); + } + hash_ = hash; + } + + /** + * Parse RSA hashed key algorithm parameters from a JS object. + * + * @param keyGenParams the JS algorithm parameters object + * @return the parsed RsaHashedKeyAlgorithm + */ + static RsaHashedKeyAlgorithm from(final Scriptable keyGenParams) { + final Object nameProp = ScriptableObject.getProperty(keyGenParams, "name"); + if (!(nameProp instanceof String name)) { + throw new IllegalArgumentException("An invalid or illegal string was specified"); + } + + final Object modulusLengthProp = ScriptableObject.getProperty(keyGenParams, "modulusLength"); + if (!(modulusLengthProp instanceof Number numModulusLength)) { + throw new IllegalArgumentException("An invalid or illegal string was specified"); + } + + final Object publicExponentProp = ScriptableObject.getProperty(keyGenParams, "publicExponent"); + final byte[] publicExponent = extractBytes(publicExponentProp); + if (publicExponent == null) { + throw new IllegalArgumentException("An invalid or illegal string was specified"); + } + + final Object hashProp = ScriptableObject.getProperty(keyGenParams, "hash"); + final String hash = SubtleCrypto.resolveAlgorithmName(hashProp); + + return new RsaHashedKeyAlgorithm(name, numModulusLength.intValue(), publicExponent, hash); + } + + private static byte[] extractBytes(final Object value) { + if (value instanceof NativeArrayBufferView view) { + final NativeArrayBuffer buf = view.getBuffer(); + final byte[] result = new byte[view.getByteLength()]; + System.arraycopy(buf.getBuffer(), view.getByteOffset(), result, 0, result.length); + return result; + } + if (value instanceof NativeArrayBuffer buf) { + return buf.getBuffer().clone(); + } + return null; + } + + static boolean isSupported(final String name) { + return SUPPORTED_NAMES.contains(name); + } + + String getName() { + return name_; + } + + int getModulusLength() { + return modulusLength_; + } + + byte[] getPublicExponent() { + return publicExponent_.clone(); + } + + /** + * @return the public exponent as a BigInteger + */ + BigInteger getPublicExponentAsBigInteger() { + return new BigInteger(1, publicExponent_); + } + + String getHash() { + return hash_; + } + + /** + * @return the Java hash algorithm name (e.g. "SHA256" from "SHA-256") + */ + String getJavaHash() { + return hash_.replace("-", ""); + } + + /** + * Converts to a JS object matching the {@code RsaHashedKeyAlgorithm} dictionary: + * {@code {name: "RSASSA-PKCS1-v1_5", modulusLength: 2048, publicExponent: Uint8Array, hash: {name: "SHA-256"}}} + * + * @param scope the JS scope for prototype/parent setup + * @return the JS algorithm object + */ + Scriptable toScriptableObject(final Scriptable scope) { + final NativeObject hashObj = new NativeObject(); + ScriptRuntime.setBuiltinProtoAndParent(hashObj, scope, TopLevel.Builtins.Object); + ScriptableObject.putProperty(hashObj, "name", getHash()); + + final NativeArrayBuffer arrayBuffer = new NativeArrayBuffer(publicExponent_.length); + System.arraycopy(publicExponent_, 0, arrayBuffer.getBuffer(), 0, publicExponent_.length); + ScriptRuntime.setBuiltinProtoAndParent(arrayBuffer, scope, TopLevel.Builtins.ArrayBuffer); + final NativeUint8Array uint8Array = new NativeUint8Array(arrayBuffer, 0, publicExponent_.length); + ScriptRuntime.setBuiltinProtoAndParent(uint8Array, scope, TopLevel.Builtins.Uint8Array); + + final NativeObject algorithm = new NativeObject(); + ScriptRuntime.setBuiltinProtoAndParent(algorithm, scope, TopLevel.Builtins.Object); + ScriptableObject.putProperty(algorithm, "name", getName()); + ScriptableObject.putProperty(algorithm, "hash", hashObj); + ScriptableObject.putProperty(algorithm, "modulusLength", getModulusLength()); + ScriptableObject.putProperty(algorithm, "publicExponent", uint8Array); + return algorithm; + } +} diff --git a/src/main/java/org/htmlunit/javascript/host/crypto/SubtleCrypto.java b/src/main/java/org/htmlunit/javascript/host/crypto/SubtleCrypto.java index a1a4139221..b1953d076e 100644 --- a/src/main/java/org/htmlunit/javascript/host/crypto/SubtleCrypto.java +++ b/src/main/java/org/htmlunit/javascript/host/crypto/SubtleCrypto.java @@ -14,7 +14,47 @@ */ package org.htmlunit.javascript.host.crypto; +import java.nio.ByteBuffer; +import java.security.GeneralSecurityException; +import java.security.Key; +import java.security.KeyPair; +import java.security.KeyPairGenerator; +import java.security.MessageDigest; +import java.security.PrivateKey; +import java.security.PublicKey; +import java.security.Signature; +import java.security.spec.AlgorithmParameterSpec; +import java.security.spec.ECGenParameterSpec; +import java.security.spec.MGF1ParameterSpec; +import java.security.spec.PSSParameterSpec; +import java.security.spec.RSAKeyGenParameterSpec; +import java.util.ArrayList; +import java.util.Collections; +import java.util.HashSet; +import java.util.LinkedHashSet; +import java.util.List; +import java.util.Map; +import java.util.Set; + +import javax.crypto.Cipher; +import javax.crypto.KeyGenerator; +import javax.crypto.Mac; +import javax.crypto.SecretKey; +import javax.crypto.spec.GCMParameterSpec; +import javax.crypto.spec.IvParameterSpec; +import javax.crypto.spec.OAEPParameterSpec; +import javax.crypto.spec.PSource; +import javax.crypto.spec.SecretKeySpec; + +import org.htmlunit.corejs.javascript.EcmaError; +import org.htmlunit.corejs.javascript.NativeObject; import org.htmlunit.corejs.javascript.NativePromise; +import org.htmlunit.corejs.javascript.ScriptRuntime; +import org.htmlunit.corejs.javascript.Scriptable; +import org.htmlunit.corejs.javascript.ScriptableObject; +import org.htmlunit.corejs.javascript.TopLevel; +import org.htmlunit.corejs.javascript.typedarrays.NativeArrayBuffer; +import org.htmlunit.corejs.javascript.typedarrays.NativeArrayBufferView; import org.htmlunit.javascript.HtmlUnitScriptable; import org.htmlunit.javascript.JavaScriptEngine; import org.htmlunit.javascript.configuration.JsxClass; @@ -28,10 +68,49 @@ * @author Ahmed Ashour * @author Ronald Brill * @author Atsushi Nakagawa + * @author Lai Quang Duong */ @JsxClass public class SubtleCrypto extends HtmlUnitScriptable { + /** + * Maps each crypto operation to its supported algorithm names. + * @see Algorithm Overview + */ + private static final Map> OPERATION_TO_SUPPORTED_ALGORITHMS = Map.ofEntries( + Map.entry("encrypt", Set.of("RSA-OAEP", "AES-CTR", "AES-CBC", "AES-GCM")), + Map.entry("decrypt", Set.of("RSA-OAEP", "AES-CTR", "AES-CBC", "AES-GCM")), + Map.entry("sign", Set.of("RSASSA-PKCS1-v1_5", "RSA-PSS", "ECDSA", "HMAC")), + Map.entry("verify", Set.of("RSASSA-PKCS1-v1_5", "RSA-PSS", "ECDSA", "HMAC")), + Map.entry("digest", Set.of("SHA-1", "SHA-256", "SHA-384", "SHA-512")), + Map.entry("generateKey", Set.of("RSASSA-PKCS1-v1_5", "RSA-PSS", "RSA-OAEP", + "ECDSA", "ECDH", "AES-CTR", "AES-CBC", "AES-GCM", "AES-KW", "HMAC")), + Map.entry("importKey", Set.of("RSASSA-PKCS1-v1_5", "RSA-PSS", "RSA-OAEP", "ECDSA", "ECDH", + "AES-CTR", "AES-CBC", "AES-GCM", "AES-KW", "HMAC", "HKDF", "PBKDF2")), + Map.entry("wrapKey", Set.of("RSA-OAEP", "AES-CTR", "AES-CBC", "AES-GCM", "AES-KW")), + Map.entry("unwrapKey", Set.of("RSA-OAEP", "AES-CTR", "AES-CBC", "AES-GCM", "AES-KW")), + Map.entry("deriveBits", Set.of("ECDH", "HKDF", "PBKDF2")), + Map.entry("deriveKey", Set.of("ECDH", "HKDF", "PBKDF2")) + ); + + /** + * @see RecognizedKeyUsage + */ + private static final Set RECOGNIZED_KEY_USAGES = Collections.unmodifiableSet( + new LinkedHashSet<>(List.of("encrypt", "decrypt", "sign", "verify", + "deriveKey", "deriveBits", "wrapKey", "unwrapKey"))); + + /** + * @see AES-GCM encrypt, step 6 + */ + private static final Set VALID_AES_GCM_TAG_LENGTHS = Set.of(32, 64, 96, 104, 112, 120, 128); + + private static class InvalidAccessException extends RuntimeException { + InvalidAccessException(final String message) { + super(message); + } + } + /** * Creates an instance. */ @@ -46,63 +125,506 @@ private NativePromise notImplemented() { } /** - * Not yet implemented. - * - * @return a Promise which will be fulfilled with the encrypted data (also known as "ciphertext") + * Encrypts data using the given key and algorithm. + * @see SubtleCrypto.encrypt() + * @param algorithm the algorithm identifier with parameters + * @param key the CryptoKey to encrypt with + * @param data the data to encrypt + * @return a Promise that fulfills with an ArrayBuffer containing the ciphertext */ @JsxFunction - public NativePromise encrypt() { - return notImplemented(); + public NativePromise encrypt(final Object algorithm, final CryptoKey key, final Object data) { + return doCipher(algorithm, key, data, Cipher.ENCRYPT_MODE); } /** - * Not yet implemented. - * - * @return a Promise which will be fulfilled with the decrypted data (also known as "plaintext") + * Decrypts data using the given key and algorithm. + * @see SubtleCrypto.decrypt() + * @param algorithm the algorithm identifier with parameters + * @param key the CryptoKey to decrypt with + * @param data the data to decrypt + * @return a Promise that fulfills with an ArrayBuffer containing the plaintext */ @JsxFunction - public NativePromise decrypt() { - return notImplemented(); + public NativePromise decrypt(final Object algorithm, final CryptoKey key, final Object data) { + return doCipher(algorithm, key, data, Cipher.DECRYPT_MODE); } /** - * Not yet implemented. - * - * @return a Promise which will be fulfilled with the signature + * Shared encrypt/decrypt implementation. + */ + private NativePromise doCipher(final Object algorithm, final CryptoKey key, + final Object data, final int cipherMode) { + final String operation = switch (cipherMode) { + case Cipher.ENCRYPT_MODE -> "encrypt"; + case Cipher.DECRYPT_MODE -> "decrypt"; + default -> throw new IllegalArgumentException("Invalid cipher mode: " + cipherMode); + }; + + final byte[] result; + try { + final String algorithmName = resolveAlgorithmName(algorithm); + ensureAlgorithmIsSupported(operation, algorithmName); + ensureKeyAlgorithmMatches(algorithmName, key); + ensureKeyUsage(key, operation); + + final ByteBuffer inputData = asByteBuffer(data); + + // encrypt/decrypt requires algorithm parameters as an object (iv, counter, etc.) + if (!(algorithm instanceof Scriptable algorithmObj)) { + throw new IllegalArgumentException("An invalid or illegal string was specified"); + } + + switch (algorithmName) { + case "AES-CBC": { + // https://w3c.github.io/webcrypto/#aes-cbc-operations + final byte[] iv = extractBuffer(algorithmObj, "iv"); + if (iv == null || iv.length != 16) { + throw new IllegalArgumentException( + "Data provided to an operation does not meet requirements"); + } + final SecretKey secretKey = getInternalKey(key, SecretKey.class); + final Cipher cipher = Cipher.getInstance("AES/CBC/PKCS5Padding"); + cipher.init(cipherMode, secretKey, new IvParameterSpec(iv)); + result = cipher.doFinal(toByteArray(inputData)); + break; + } + case "AES-GCM": { + // https://w3c.github.io/webcrypto/#aes-gcm-operations + final byte[] iv = extractBuffer(algorithmObj, "iv"); + if (iv == null || iv.length == 0) { + throw new IllegalArgumentException( + "Data provided to an operation does not meet requirements"); + } + + final int tagLength; + final Object tagLengthProp = ScriptableObject.getProperty(algorithmObj, "tagLength"); + if (tagLengthProp instanceof Number num) { + tagLength = num.intValue(); + if (!VALID_AES_GCM_TAG_LENGTHS.contains(tagLength)) { + throw new IllegalArgumentException( + "Data provided to an operation does not meet requirements"); + } + } + else { + tagLength = 128; + } + + final SecretKey secretKey = getInternalKey(key, SecretKey.class); + final Cipher cipher = Cipher.getInstance("AES/GCM/NoPadding"); + cipher.init(cipherMode, secretKey, new GCMParameterSpec(tagLength, iv)); + + final Object aadProp = ScriptableObject.getProperty(algorithmObj, "additionalData"); + if (aadProp instanceof Scriptable) { + final ByteBuffer aad = asByteBuffer(aadProp); + cipher.updateAAD(toByteArray(aad)); + } + + result = cipher.doFinal(toByteArray(inputData)); + break; + } + case "AES-CTR": { + // https://w3c.github.io/webcrypto/#aes-ctr-operations + final byte[] counter = extractBuffer(algorithmObj, "counter"); + if (counter == null || counter.length != 16) { + throw new IllegalArgumentException( + "Data provided to an operation does not meet requirements"); + } + + final Object lengthProp = ScriptableObject.getProperty(algorithmObj, "length"); + if (!(lengthProp instanceof Number numLength)) { + throw new IllegalArgumentException( + "Data provided to an operation does not meet requirements"); + } + final int counterLength = numLength.intValue(); + if (counterLength < 1 || counterLength > 128) { + throw new IllegalArgumentException( + "Data provided to an operation does not meet requirements"); + } + + final SecretKey secretKey = getInternalKey(key, SecretKey.class); + // Java always increments the full 128-bit counter, ignoring the 'length' partitioning. + // This only becomes an issue when data exceeds 2^length AES blocks (16 bytes each), + // but in real-world usage (length >= 64) it's pretty much unreachable. + final Cipher cipher = Cipher.getInstance("AES/CTR/NoPadding"); + cipher.init(cipherMode, secretKey, new IvParameterSpec(counter)); + result = cipher.doFinal(toByteArray(inputData)); + break; + } + case "RSA-OAEP": { + // https://w3c.github.io/webcrypto/#rsa-oaep-operations + final Scriptable keyAlgorithm = key.getAlgorithm(); + final Object hashObj = ScriptableObject.getProperty(keyAlgorithm, "hash"); + final String hash = resolveAlgorithmName(hashObj); + + final byte[] label; + final Object labelProp = ScriptableObject.getProperty(algorithmObj, "label"); + if (labelProp instanceof Scriptable) { + final ByteBuffer labelBuf = asByteBuffer(labelProp); + label = toByteArray(labelBuf); + } + else { + label = new byte[0]; + } + + final MGF1ParameterSpec mgf1Spec = new MGF1ParameterSpec(hash); + final AlgorithmParameterSpec oaepSpec = new OAEPParameterSpec( + hash, "MGF1", mgf1Spec, new PSource.PSpecified(label)); + + final Key internalKey; + if (cipherMode == Cipher.ENCRYPT_MODE) { + internalKey = getInternalKey(key, PublicKey.class); + } + else { + internalKey = getInternalKey(key, PrivateKey.class); + } + + final Cipher cipher = Cipher.getInstance("RSA/ECB/OAEPPadding"); + cipher.init(cipherMode, internalKey, oaepSpec); + result = cipher.doFinal(toByteArray(inputData)); + break; + } + default: + throw new UnsupportedOperationException(operation + " " + algorithmName); + } + } + catch (final EcmaError e) { + return setupRejectedPromise(() -> e); + } + catch (final InvalidAccessException e) { + return setupRejectedPromise(() -> new DOMException(e.getMessage(), DOMException.INVALID_ACCESS_ERR)); + } + catch (final IllegalArgumentException e) { + return setupRejectedPromise(() -> new DOMException(e.getMessage(), DOMException.SYNTAX_ERR)); + } + catch (final GeneralSecurityException | UnsupportedOperationException e) { + return setupRejectedPromise(() -> new DOMException("Operation is not supported: " + e.getMessage(), + DOMException.NOT_SUPPORTED_ERR)); + } + return setupPromise(() -> createArrayBuffer(result)); + } + + /** + * Signs data using the given key. + * @see SubtleCrypto.sign() + * @param algorithm the algorithm identifier (String or object with name property) + * @param key the CryptoKey to sign with + * @param data the data to sign + * @return a Promise that fulfills with an ArrayBuffer containing the signature */ @JsxFunction - public NativePromise sign() { - return notImplemented(); + public NativePromise sign(final Object algorithm, final CryptoKey key, final Object data) { + return doSignOrVerify(algorithm, key, null, data, true); } /** - * Not yet implemented. - * - * @return a Promise which will be fulfilled with a boolean value indicating whether the signature is valid + * Verifies a signature using the given key. + * @see SubtleCrypto.verify() + * @param algorithm the algorithm identifier (String or object with name property) + * @param key the CryptoKey to verify with + * @param signature the signature to verify + * @param data the data that was signed + * @return a Promise that fulfills with a boolean indicating whether the signature is valid */ @JsxFunction - public NativePromise verify() { - return notImplemented(); + public NativePromise verify(final Object algorithm, final CryptoKey key, + final Object signature, final Object data) { + return doSignOrVerify(algorithm, key, signature, data, false); } /** - * Not yet implemented. - * - * @return a Promise which will be fulfilled with the digest + * Shared sign/verify implementation. + */ + private NativePromise doSignOrVerify(final Object algorithm, final CryptoKey key, + final Object existingSignature, final Object data, final boolean isSigning) { + final Object result; + try { + final String algorithmName = resolveAlgorithmName(algorithm); + final String operation = isSigning ? "sign" : "verify"; + ensureAlgorithmIsSupported(operation, algorithmName); + ensureKeyAlgorithmMatches(algorithmName, key); + ensureKeyUsage(key, operation); + + final ByteBuffer inputData = asByteBuffer(data); + + switch (algorithmName) { + case "HMAC": { + // https://w3c.github.io/webcrypto/#hmac-operations + final SecretKey secretKey = getInternalKey(key, SecretKey.class); + final Mac mac = Mac.getInstance(secretKey.getAlgorithm()); + mac.init(secretKey); + mac.update(inputData); + final byte[] macBytes = mac.doFinal(); + if (isSigning) { + result = macBytes; + } + else { + result = MessageDigest.isEqual(macBytes, + toByteArray(asByteBuffer(existingSignature))); + } + break; + } + case "RSASSA-PKCS1-v1_5": + // https://w3c.github.io/webcrypto/#rsassa-pkcs1 + case "RSA-PSS": + // https://w3c.github.io/webcrypto/#rsa-pss + case "ECDSA": { + // https://w3c.github.io/webcrypto/#ecdsa-operations + final Signature sig = "ECDSA".equals(algorithmName) + ? resolveEcdsaSignature(algorithm) + : resolveRsaSignature(algorithmName, algorithm, key); + if (isSigning) { + sig.initSign(getInternalKey(key, PrivateKey.class)); + sig.update(inputData); + result = sig.sign(); + } + else { + sig.initVerify(getInternalKey(key, PublicKey.class)); + sig.update(inputData); + result = sig.verify(toByteArray(asByteBuffer(existingSignature))); + } + break; + } + default: + throw new UnsupportedOperationException(operation + " " + algorithmName); + } + } + catch (final EcmaError e) { + return setupRejectedPromise(() -> e); + } + catch (final InvalidAccessException e) { + return setupRejectedPromise(() -> new DOMException(e.getMessage(), DOMException.INVALID_ACCESS_ERR)); + } + catch (final IllegalArgumentException e) { + return setupRejectedPromise(() -> new DOMException(e.getMessage(), DOMException.SYNTAX_ERR)); + } + catch (final GeneralSecurityException | UnsupportedOperationException e) { + return setupRejectedPromise(() -> new DOMException("Operation is not supported: " + e.getMessage(), + DOMException.NOT_SUPPORTED_ERR)); + } + + if (isSigning) { + return setupPromise(() -> createArrayBuffer((byte[]) result)); + } + return setupPromise(() -> result); + } + + /** + * Resolves the RSA {@link Signature} instance for the given algorithm. + */ + private static Signature resolveRsaSignature(final String algorithmName, final Object algorithmParams, + final CryptoKey key) throws GeneralSecurityException { + final Object hashObj = ScriptableObject.getProperty(key.getAlgorithm(), "hash"); + final String hash = resolveAlgorithmName(hashObj); + final String javaHash = hash.replace("-", ""); + + if ("RSASSA-PKCS1-v1_5".equals(algorithmName)) { + return Signature.getInstance(javaHash + "withRSA"); + } + + if (!(algorithmParams instanceof Scriptable obj)) { + throw new IllegalArgumentException("Data provided to an operation does not meet requirements"); + } + final Object saltLengthProp = ScriptableObject.getProperty(obj, "saltLength"); + if (!(saltLengthProp instanceof Number num)) { + throw new IllegalArgumentException("Data provided to an operation does not meet requirements"); + } + final int saltLength = num.intValue(); + + final MGF1ParameterSpec mgf1Spec = new MGF1ParameterSpec(hash); + final PSSParameterSpec pssSpec = new PSSParameterSpec(hash, "MGF1", mgf1Spec, saltLength, 1); + final Signature sig = Signature.getInstance("RSASSA-PSS"); + sig.setParameter(pssSpec); + return sig; + } + + /** + * Resolves the ECDSA {@link Signature} instance for the given algorithm params. + */ + private static Signature resolveEcdsaSignature(final Object algorithmParams) + throws GeneralSecurityException { + if (!(algorithmParams instanceof Scriptable obj)) { + throw new IllegalArgumentException("Data provided to an operation does not meet requirements"); + } + final Object hashProp = ScriptableObject.getProperty(obj, "hash"); + final String hash = resolveAlgorithmName(hashProp); + final String javaHash = hash.replace("-", ""); + return Signature.getInstance(javaHash + "withECDSAinP1363Format"); + } + + private static byte[] toByteArray(final ByteBuffer buffer) { + final byte[] result = new byte[buffer.remaining()]; + buffer.get(result); + return result; + } + + /** + * Generates a digest of the given data. + * @see SubtleCrypto.digest() + * @param hashAlgorithm a string or an object with a single property name containing the hash algorithm to use + * @param data an object containing the data to be digested + * @return a Promise that fulfills with an ArrayBuffer containing the digest */ @JsxFunction - public NativePromise digest() { - return notImplemented(); + public NativePromise digest(final Object hashAlgorithm, final Object data) { + final byte[] digest; + try { + final ByteBuffer inputData = asByteBuffer(data); + final String algorithm = resolveAlgorithmName(hashAlgorithm); + ensureAlgorithmIsSupported("digest", algorithm); + + final MessageDigest messageDigest = MessageDigest.getInstance(algorithm); + messageDigest.update(inputData); + digest = messageDigest.digest(); + } + catch (final EcmaError e) { + return setupRejectedPromise(() -> e); + } + catch (final IllegalArgumentException e) { + return setupRejectedPromise(() -> new DOMException(e.getMessage(), DOMException.SYNTAX_ERR)); + } + catch (final GeneralSecurityException | UnsupportedOperationException e) { + return setupRejectedPromise(() -> new DOMException("Operation is not supported: " + e.getMessage(), + DOMException.NOT_SUPPORTED_ERR)); + } + return setupPromise(() -> createArrayBuffer(digest)); } /** - * Not yet implemented. - * - * @return a new key (for symmetric algorithms) or key pair (for public-key algorithms) + * Generates a new key (for symmetric algorithms) or key pair (for public-key algorithms). + * @see SubtleCrypto.generateKey() + * @param keyGenParams algorithm-specific key generation parameters + * @param isExtractable whether the key(s) can be exported + * @param keyUsages permitted operations for the key(s) + * @return a Promise that fulfills with a CryptoKey or CryptoKeyPair */ @JsxFunction - public NativePromise generateKey() { - return notImplemented(); + public NativePromise generateKey(final Scriptable keyGenParams, final boolean isExtractable, + final Scriptable keyUsages) { + final Object result; + try { + final String algorithm = resolveAlgorithmName(keyGenParams); + ensureAlgorithmIsSupported("generateKey", algorithm); + + final Scriptable scope = keyGenParams.getParentScope(); + + switch (algorithm) { + case "RSASSA-PKCS1-v1_5": + case "RSA-PSS": + case "RSA-OAEP": { + final RsaHashedKeyAlgorithm rsaParams = RsaHashedKeyAlgorithm.from(keyGenParams); + final List usages = resolveKeyUsages(algorithm, keyUsages); + + final KeyPairGenerator keyPairGen = KeyPairGenerator.getInstance("RSA"); + keyPairGen.initialize(new RSAKeyGenParameterSpec( + rsaParams.getModulusLength(), rsaParams.getPublicExponentAsBigInteger())); + final KeyPair keyPair = keyPairGen.generateKeyPair(); + + final Scriptable algoObj = rsaParams.toScriptableObject(scope); + result = createKeyPair(keyPair, algoObj, isExtractable, usages, scope); + break; + } + case "ECDSA": + case "ECDH": { + final EcKeyAlgorithm ecParams = EcKeyAlgorithm.from(keyGenParams); + final List usages = resolveKeyUsages(algorithm, keyUsages); + + final KeyPairGenerator keyPairGen = KeyPairGenerator.getInstance("EC"); + keyPairGen.initialize(new ECGenParameterSpec(ecParams.getJavaCurveName())); + final KeyPair keyPair = keyPairGen.generateKeyPair(); + + final Scriptable algoObj = ecParams.toScriptableObject(scope); + result = createKeyPair(keyPair, algoObj, isExtractable, usages, scope); + break; + } + case "AES-CBC": + case "AES-CTR": + case "AES-GCM": + case "AES-KW": { + final AesKeyAlgorithm aesParams = AesKeyAlgorithm.from(keyGenParams); + final List usages = resolveKeyUsages(algorithm, keyUsages); + if (usages.isEmpty()) { + throw new IllegalArgumentException("An invalid or illegal string was specified"); + } + + final KeyGenerator keyGen = KeyGenerator.getInstance("AES"); + keyGen.init(aesParams.getLength()); + final SecretKey secretKey = keyGen.generateKey(); + + final Scriptable algoObj = aesParams.toScriptableObject(scope); + result = CryptoKey.create(getParentScope(), secretKey, isExtractable, algoObj, usages); + break; + } + case "HMAC": { + final HmacKeyAlgorithm hmacParams = HmacKeyAlgorithm.from(keyGenParams); + final List usages = resolveKeyUsages("HMAC", keyUsages); + if (usages.isEmpty()) { + throw new IllegalArgumentException("An invalid or illegal string was specified"); + } + + final KeyGenerator keyGen = KeyGenerator.getInstance(hmacParams.getJavaName()); + keyGen.init(hmacParams.getLength()); + final SecretKey secretKey = keyGen.generateKey(); + + final Scriptable algoObj = hmacParams.toScriptableObject(scope); + result = CryptoKey.create(getParentScope(), secretKey, isExtractable, algoObj, usages); + break; + } + default: + throw new UnsupportedOperationException("generateKey " + algorithm); + } + } + catch (final EcmaError e) { + return setupRejectedPromise(() -> e); + } + catch (final IllegalArgumentException e) { + return setupRejectedPromise(() -> new DOMException(e.getMessage(), DOMException.SYNTAX_ERR)); + } + catch (final GeneralSecurityException | UnsupportedOperationException e) { + return setupRejectedPromise(() -> new DOMException("Operation is not supported: " + e.getMessage(), + DOMException.NOT_SUPPORTED_ERR)); + } + return setupPromise(() -> result); + } + + /** + * Creates a CryptoKeyPair (plain JS object with publicKey/privateKey) from a Java KeyPair. + * The public key is always extractable regardless of the extractable parameter. + * Usages are split: public gets {encrypt,verify,wrapKey}, + * private gets {decrypt,sign,unwrapKey,deriveBits,deriveKey}. + */ + private Scriptable createKeyPair(final KeyPair keyPair, final Scriptable algoObj, + final boolean isExtractable, final List allUsages, final Scriptable scope) { + final Set publicUsageSet = Set.of("encrypt", "verify", "wrapKey"); + final Set privateUsageSet = Set.of("decrypt", "sign", "unwrapKey", "deriveBits", "deriveKey"); + + final List publicUsages = new ArrayList<>(); + final List privateUsages = new ArrayList<>(); + for (final String usage : allUsages) { + if (publicUsageSet.contains(usage)) { + publicUsages.add(usage); + } + if (privateUsageSet.contains(usage)) { + privateUsages.add(usage); + } + } + + // if privateKey usages would be empty, throw SyntaxError + if (privateUsages.isEmpty()) { + throw new IllegalArgumentException("An invalid or illegal string was specified"); + } + + // public key is always extractable + final CryptoKey publicKey = CryptoKey.create( + getParentScope(), keyPair.getPublic(), true, algoObj, publicUsages); + final CryptoKey privateKey = CryptoKey.create( + getParentScope(), keyPair.getPrivate(), isExtractable, algoObj, privateUsages); + + final NativeObject keyPairObj = new NativeObject(); + ScriptRuntime.setBuiltinProtoAndParent(keyPairObj, scope, TopLevel.Builtins.Object); + ScriptableObject.putProperty(keyPairObj, "publicKey", publicKey); + ScriptableObject.putProperty(keyPairObj, "privateKey", privateKey); + return keyPairObj; } /** @@ -126,23 +648,127 @@ public NativePromise deriveBits() { } /** - * Not yet implemented. - * - * @return a CryptoKey object that you can use in the Web Crypto API + * Imports a key from external, portable key material. + * @see SubtleCrypto.importKey() + * @param format the data format ("raw", "pkcs8", "spki", "jwk") + * @param keyData the key material (BufferSource for raw/pkcs8/spki, JsonWebKey for jwk) + * @param keyImportParams algorithm-specific import parameters + * @param isExtractable whether the key can be exported + * @param keyUsages permitted operations for this key + * @return a Promise that fulfills with the imported CryptoKey */ @JsxFunction - public NativePromise importKey() { - return notImplemented(); + public NativePromise importKey(final String format, final Scriptable keyData, + final Scriptable keyImportParams, final boolean isExtractable, final Scriptable keyUsages) { + final CryptoKey key; + try { + final String algorithm = resolveAlgorithmName(keyImportParams); + ensureAlgorithmIsSupported("importKey", algorithm); + + switch (format) { + case "raw": + key = importRawKey(algorithm, keyData, keyImportParams, isExtractable, keyUsages); + break; + case "pkcs8": + case "spki": + case "jwk": + return notImplemented(); + default: + throw new IllegalArgumentException("An invalid or illegal string was specified"); + } + } + catch (final EcmaError e) { + return setupRejectedPromise(() -> e); + } + catch (final IllegalArgumentException e) { + return setupRejectedPromise(() -> new DOMException(e.getMessage(), DOMException.SYNTAX_ERR)); + } + catch (final UnsupportedOperationException e) { + return setupRejectedPromise(() -> new DOMException("Operation is not supported: " + e.getMessage(), + DOMException.NOT_SUPPORTED_ERR)); + } + return setupPromise(() -> key); + } + + private CryptoKey importRawKey(final String algorithm, final Scriptable keyData, + final Scriptable keyImportParams, final boolean isExtractable, final Scriptable keyUsages) { + final ByteBuffer byteBuffer = asByteBuffer(keyData); + final byte[] rawBytes = new byte[byteBuffer.remaining()]; + byteBuffer.get(rawBytes); + final int bitLength = rawBytes.length * 8; + if (bitLength == 0) { + throw new IllegalArgumentException("Data provided to an operation does not meet requirements"); + } + + final List usages = resolveKeyUsages(algorithm, keyUsages); + if (usages.isEmpty()) { + throw new IllegalArgumentException("An invalid or illegal string was specified"); + } + + if ("HMAC".equals(algorithm)) { + final HmacKeyAlgorithm params = HmacKeyAlgorithm.from(keyImportParams, bitLength); + final int length = params.getLength(); + if (length > bitLength || length <= bitLength - 8) { + throw new IllegalArgumentException("Data provided to an operation does not meet requirements"); + } + + final Scriptable scriptableAlgorithm = params.toScriptableObject(keyImportParams.getParentScope()); + final SecretKey internalKey = new SecretKeySpec(rawBytes, params.getJavaName()); + return CryptoKey.create(getParentScope(), internalKey, isExtractable, scriptableAlgorithm, usages); + } + + if (AesKeyAlgorithm.isSupported(algorithm)) { + final AesKeyAlgorithm aesAlgo = new AesKeyAlgorithm(algorithm, bitLength); + final Scriptable scriptableAlgorithm = aesAlgo.toScriptableObject(keyImportParams.getParentScope()); + final SecretKey internalKey = new SecretKeySpec(rawBytes, "AES"); + return CryptoKey.create(getParentScope(), internalKey, isExtractable, scriptableAlgorithm, usages); + } + + throw new UnsupportedOperationException("importKey raw " + algorithm); } /** - * Not yet implemented. - * - * @return the key in an external, portable format + * Exports a key in the specified format. + * @see SubtleCrypto.exportKey() + * @param format the data format ("raw", "pkcs8", "spki", "jwk") + * @param key the CryptoKey to export + * @return a Promise that fulfills with the key data */ @JsxFunction - public NativePromise exportKey() { - return notImplemented(); + public NativePromise exportKey(final String format, final CryptoKey key) { + final byte[] result; + try { + if (!key.getExtractable()) { + return setupRejectedPromise(() -> new DOMException( + "A parameter or an operation is not supported by the underlying object", + DOMException.INVALID_ACCESS_ERR)); + } + + switch (format) { + case "raw": { + if (!(key.getInternalKey() instanceof SecretKey secretKey)) { + throw new IllegalArgumentException( + "Data provided to an operation does not meet requirements"); + } + result = secretKey.getEncoded(); + break; + } + case "pkcs8": + case "spki": + case "jwk": + return notImplemented(); + default: + throw new IllegalArgumentException("An invalid or illegal string was specified"); + } + } + catch (final IllegalArgumentException e) { + return setupRejectedPromise(() -> new DOMException(e.getMessage(), DOMException.SYNTAX_ERR)); + } + catch (final UnsupportedOperationException e) { + return setupRejectedPromise(() -> new DOMException("Operation is not supported: " + e.getMessage(), + DOMException.NOT_SUPPORTED_ERR)); + } + return setupPromise(() -> createArrayBuffer(result)); } /** @@ -164,4 +790,175 @@ public NativePromise wrapKey() { public NativePromise unwrapKey() { return notImplemented(); } + + /** + * Checks if the specified crypto operation supports the given algorithm. + * @see Algorithm Overview + * @param operation the crypto operation (e.g. "digest", "sign") + * @param algorithm the algorithm name (e.g. "SHA-256", "HMAC") + * @throws UnsupportedOperationException if the operation does not support the algorithm + */ + private static void ensureAlgorithmIsSupported(final String operation, final String algorithm) { + final Set supportedAlgorithms = OPERATION_TO_SUPPORTED_ALGORITHMS.get(operation); + if (supportedAlgorithms == null || !supportedAlgorithms.contains(algorithm)) { + throw new UnsupportedOperationException(operation + " " + algorithm); + } + } + + /** + * Verifies that the operation's algorithm name matches the key's algorithm name. + * @param algorithmName the algorithm name from the operation parameters + * @param key the CryptoKey being used + * @throws InvalidAccessException if the algorithm names don't match + */ + private static void ensureKeyAlgorithmMatches(final String algorithmName, final CryptoKey key) { + final String keyAlgoName = resolveAlgorithmName(key.getAlgorithm()); + if (!algorithmName.equals(keyAlgoName)) { + throw new InvalidAccessException( + "A parameter or an operation is not supported by the underlying object"); + } + } + + /** + * Verifies that the key's usages include the specified usage. + * @param key the CryptoKey being used + * @param usage the required usage (e.g. "encrypt", "sign") + * @throws InvalidAccessException if the key doesn't have the required usage + */ + private static void ensureKeyUsage(final CryptoKey key, final String usage) { + if (!key.getUsagesInternal().contains(usage)) { + throw new InvalidAccessException( + "A parameter or an operation is not supported by the underlying object"); + } + } + + /** + * Resolves the algorithm name from the given {@code AlgorithmIdentifier}. + * @see + * AlgorithmIdentifier + * @param algorithm the algorithm identifier (String or Scriptable with name property) + * @return the resolved algorithm name + * @throws IllegalArgumentException if the identifier cannot be resolved + */ + static String resolveAlgorithmName(final Object algorithm) { + if (algorithm instanceof String str) { + return str; + } + if (algorithm instanceof Scriptable obj) { + final Object name = ScriptableObject.getProperty(obj, "name"); + if (name instanceof String nameStr) { + return nameStr; + } + } + throw new IllegalArgumentException("An invalid or illegal string was specified"); + } + + /** + * Converts ArrayBuffer or ArrayBufferView to a ByteBuffer. + * @param data the buffer source object + * @return the ByteBuffer wrapping the data + * @throws IllegalArgumentException if data is not a Scriptable or is NOT_FOUND + * @throws EcmaError if data is not an ArrayBuffer or ArrayBufferView + */ + static ByteBuffer asByteBuffer(final Object data) { + if (!(data instanceof Scriptable)) { + throw new IllegalArgumentException("An invalid or illegal string was specified"); + } + if (data == Scriptable.NOT_FOUND) { + throw new IllegalArgumentException("An invalid or illegal string was specified"); + } + if (data instanceof NativeArrayBuffer nativeBuffer) { + return ByteBuffer.wrap(nativeBuffer.getBuffer()); + } + else if (data instanceof NativeArrayBufferView arrayBufferView) { + final NativeArrayBuffer arrayBuffer = arrayBufferView.getBuffer(); + return ByteBuffer.wrap( + arrayBuffer.getBuffer(), arrayBufferView.getByteOffset(), arrayBufferView.getByteLength()); + } + else { + throw JavaScriptEngine.typeError("Argument could not be converted to any of: ArrayBufferView, ArrayBuffer."); + } + } + + /** + * Reads a property from a JS object and converts it to a byte array. + * @param obj the JS object containing the property + * @param property the property name (e.g. "iv", "counter", "label") + * @return the byte array, or {@code null} if the property is absent or not convertible + */ + private static byte[] extractBuffer(final Scriptable obj, final String property) { + final Object prop = ScriptableObject.getProperty(obj, property); + if (prop instanceof Scriptable) { + final ByteBuffer buf = asByteBuffer(prop); + return toByteArray(buf); + } + return null; + } + + /** + * Creates a NativeArrayBuffer with proper scope and prototype from the given bytes. + * @param data the byte array to wrap + * @return the new NativeArrayBuffer + */ + NativeArrayBuffer createArrayBuffer(final byte[] data) { + final NativeArrayBuffer buffer = new NativeArrayBuffer(data.length); + System.arraycopy(data, 0, buffer.getBuffer(), 0, data.length); + buffer.setParentScope(getParentScope()); + buffer.setPrototype(ScriptableObject.getClassPrototype(getWindow(), buffer.getClassName())); + return buffer; + } + + /** + * Resolves and validates key usages from the JS array against the algorithm's supported operations. + * @param algorithm the algorithm name + * @param keyUsages the JS usages array + * @return the validated, ordered list of usages + * @throws IllegalArgumentException if usages array is invalid or contains unrecognized values + */ + static List resolveKeyUsages(final String algorithm, final Scriptable keyUsages) { + if (!ScriptRuntime.isArrayLike(keyUsages)) { + throw new IllegalArgumentException("An invalid or illegal string was specified"); + } + + final Set supportedKeyUsages = new HashSet<>(); + for (final Object usage : ScriptRuntime.getArrayElements(keyUsages)) { + if (!(usage instanceof String usageStr)) { + throw new IllegalArgumentException("An invalid or illegal string was specified"); + } + if (!RECOGNIZED_KEY_USAGES.contains(usageStr)) { + throw new IllegalArgumentException("An invalid or illegal string was specified"); + } + + final Set supportedAlgorithms = OPERATION_TO_SUPPORTED_ALGORITHMS.get(usageStr); + if (supportedAlgorithms != null && supportedAlgorithms.contains(algorithm)) { + supportedKeyUsages.add(usageStr); + } + } + + // maintain canonical ordering per RECOGNIZED_KEY_USAGES + final List sortedKeyUsages = new ArrayList<>(); + for (final String keyUsage : RECOGNIZED_KEY_USAGES) { + if (supportedKeyUsages.contains(keyUsage)) { + sortedKeyUsages.add(keyUsage); + } + } + + return sortedKeyUsages; + } + + /** + * Extracts the internal Java key from a CryptoKey, validating it is the expected type. + * @param the expected key type + * @param cryptoKey the CryptoKey + * @param expectedKeyType the expected class (e.g. SecretKey.class) + * @return the internal key cast to the expected type + * @throws InvalidAccessException if the key is not the expected type + */ + static T getInternalKey(final CryptoKey cryptoKey, final Class expectedKeyType) { + final Key internalKey = cryptoKey.getInternalKey(); + if (!expectedKeyType.isInstance(internalKey)) { + throw new InvalidAccessException("A parameter or an operation is not supported by the underlying object"); + } + return expectedKeyType.cast(internalKey); + } } diff --git a/src/test/java/org/htmlunit/javascript/host/crypto/SubtleCryptoTest.java b/src/test/java/org/htmlunit/javascript/host/crypto/SubtleCryptoTest.java index 34eaba34d7..d89b6a5aeb 100644 --- a/src/test/java/org/htmlunit/javascript/host/crypto/SubtleCryptoTest.java +++ b/src/test/java/org/htmlunit/javascript/host/crypto/SubtleCryptoTest.java @@ -25,6 +25,7 @@ * @author Ahmed Ashour * @author Ronald Brill * @author Atsushi Nakagawa + * @author Lai Quang Duong */ public class SubtleCryptoTest extends WebDriverTestCase { @@ -97,10 +98,6 @@ public void unsupportedCall() throws Exception { "private", "false", "sign", "name RSASSA-PKCS1-v1_5", "hash [object Object]", "modulusLength 2048", "publicExponent 1,0,1", "done"}) - @HtmlUnitNYI(CHROME = {"[object Crypto]", "[object DOMException]"}, - EDGE = {"[object Crypto]", "[object DOMException]"}, - FF = {"[object Crypto]", "[object DOMException]"}, - FF_ESR = {"[object Crypto]", "[object DOMException]"}) public void rsassa() throws Exception { final String html = DOCTYPE_HTML + "\n" + + ""; + + loadPage2(html); + verifyTitle2(DEFAULT_WAIT_TIME, getWebDriver(), getExpectedAlerts()); + } + + /** + * @throws Exception if the test fails + */ + @Test + @Alerts({"secret", "true", "HMAC", "SHA-1", "512", "sign,verify"}) + public void importKeyHmac() throws Exception { + final String html = DOCTYPE_HTML + + "\n" + + ""; + + loadPage2(html); + verifyTitle2(DEFAULT_WAIT_TIME, getWebDriver(), getExpectedAlerts()); + } + + /** + * @throws Exception if the test fails + */ + @Test + @Alerts({"secret", "false", "AES-GCM", "256", "encrypt,decrypt"}) + public void importKeyAes() throws Exception { + final String html = DOCTYPE_HTML + + "\n" + + ""; + + loadPage2(html); + verifyTitle2(DEFAULT_WAIT_TIME, getWebDriver(), getExpectedAlerts()); + } + + /** + * @throws Exception if the test fails + */ + @Test + @Alerts({"secret", "true", "AES-GCM", "256", "encrypt,decrypt"}) + public void generateKeyAesGcm() throws Exception { + final String html = DOCTYPE_HTML + + "\n" + + ""; + + loadPage2(html); + verifyTitle2(DEFAULT_WAIT_TIME, getWebDriver(), getExpectedAlerts()); + } + + /** + * @throws Exception if the test fails + */ + @Test + @Alerts({"secret", "false", "HMAC", "SHA-256", "512", "sign,verify"}) + public void generateKeyHmac() throws Exception { + final String html = DOCTYPE_HTML + + "\n" + + ""; + + loadPage2(html); + verifyTitle2(DEFAULT_WAIT_TIME, getWebDriver(), getExpectedAlerts()); + } + + /** + * @throws Exception if the test fails + */ + @Test + @Alerts({"public", "true", "ECDSA", "P-256", "verify", + "private", "false", "ECDSA", "P-256", "sign"}) + public void generateKeyEc() throws Exception { + final String html = DOCTYPE_HTML + + "\n" + + ""; + + loadPage2(html); + verifyTitle2(DEFAULT_WAIT_TIME, getWebDriver(), getExpectedAlerts()); + } + + /** + * @throws Exception if the test fails + */ + @Test + @Alerts({"1,2,3,4,5,6,7,8,9,10,11,12,13,14,15,16", "rejected"}) + public void exportKeyRaw() throws Exception { + final String html = DOCTYPE_HTML + + "\n" + + ""; + + loadPage2(html); + verifyTitle2(DEFAULT_WAIT_TIME, getWebDriver(), getExpectedAlerts()); + } + + /** + * @throws Exception if the test fails + */ + @Test + @Alerts({"20", "true", "false"}) + public void signVerifyHmac() throws Exception { + final String html = DOCTYPE_HTML + + "\n" + + ""; + + loadPage2(html); + verifyTitle2(DEFAULT_WAIT_TIME, getWebDriver(), getExpectedAlerts()); + } + + /** + * @throws Exception if the test fails + */ + @Test + @Alerts({"PKCS1 true", "PKCS1 false", + "PSS true", "PSS false"}) + public void signVerifyRsa() throws Exception { + final String html = DOCTYPE_HTML + + "\n" + + ""; + + loadPage2(html); + verifyTitle2(DEFAULT_WAIT_TIME, getWebDriver(), getExpectedAlerts()); + } + + /** + * @throws Exception if the test fails + */ + @Test + @Alerts({"true", "false"}) + public void signVerifyEcdsa() throws Exception { + final String html = DOCTYPE_HTML + + "\n" + + ""; + + loadPage2(html); + verifyTitle2(DEFAULT_WAIT_TIME, getWebDriver(), getExpectedAlerts()); + } + + /** + * @throws Exception if the test fails + */ + @Test + @Alerts({"true", "false"}) + public void generateSignVerifyHmac() throws Exception { + final String html = DOCTYPE_HTML + + "\n" + + ""; + + loadPage2(html); + verifyTitle2(DEFAULT_WAIT_TIME, getWebDriver(), getExpectedAlerts()); + } + + /** + * @throws Exception if the test fails + */ + @Test + @Alerts({"AES-CBC ok", "AES-GCM ok", "AES-CTR ok"}) + public void encryptDecryptAes() throws Exception { + final String html = DOCTYPE_HTML + + "\n" + + ""; + + loadPage2(html); + verifyTitle2(DEFAULT_WAIT_TIME, getWebDriver(), getExpectedAlerts()); + } + + /** + * @throws Exception if the test fails + */ + @Test + @Alerts("hello world") + public void encryptDecryptRsaOaep() throws Exception { + final String html = DOCTYPE_HTML + + "\n" + + ""; + + loadPage2(html); + verifyTitle2(DEFAULT_WAIT_TIME, getWebDriver(), getExpectedAlerts()); + } + + /** + * @throws Exception if the test fails + */ + @Test + @Alerts("hello world") + public void encryptDecryptAesGcmAad() throws Exception { + final String html = DOCTYPE_HTML + + "\n" + + ""; + + loadPage2(html); + verifyTitle2(DEFAULT_WAIT_TIME, getWebDriver(), getExpectedAlerts()); + } }