Skip to content

Commit 6400fb9

Browse files
duonglaiquangrbri
authored andcommitted
SubtleCrypto: implement encrypt() and decrypt()
1 parent 9548628 commit 6400fb9

File tree

2 files changed

+313
-10
lines changed

2 files changed

+313
-10
lines changed

src/main/java/org/htmlunit/javascript/host/crypto/SubtleCrypto.java

Lines changed: 196 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -23,6 +23,7 @@
2323
import java.security.PrivateKey;
2424
import java.security.PublicKey;
2525
import java.security.Signature;
26+
import java.security.spec.AlgorithmParameterSpec;
2627
import java.security.spec.ECGenParameterSpec;
2728
import java.security.spec.MGF1ParameterSpec;
2829
import java.security.spec.PSSParameterSpec;
@@ -35,9 +36,14 @@
3536
import java.util.Map;
3637
import java.util.Set;
3738

39+
import javax.crypto.Cipher;
3840
import javax.crypto.KeyGenerator;
3941
import javax.crypto.Mac;
4042
import javax.crypto.SecretKey;
43+
import javax.crypto.spec.GCMParameterSpec;
44+
import javax.crypto.spec.IvParameterSpec;
45+
import javax.crypto.spec.OAEPParameterSpec;
46+
import javax.crypto.spec.PSource;
4147
import javax.crypto.spec.SecretKeySpec;
4248

4349
import org.htmlunit.corejs.javascript.EcmaError;
@@ -94,6 +100,11 @@ public class SubtleCrypto extends HtmlUnitScriptable {
94100
new LinkedHashSet<>(List.of("encrypt", "decrypt", "sign", "verify",
95101
"deriveKey", "deriveBits", "wrapKey", "unwrapKey")));
96102

103+
/**
104+
* @see <a href="https://w3c.github.io/webcrypto/#aes-gcm-operations">AES-GCM encrypt, step 6</a>
105+
*/
106+
private static final Set<Integer> VALID_AES_GCM_TAG_LENGTHS = Set.of(32, 64, 96, 104, 112, 120, 128);
107+
97108
private static class InvalidAccessException extends RuntimeException {
98109
InvalidAccessException(final String message) {
99110
super(message);
@@ -114,23 +125,183 @@ private NativePromise notImplemented() {
114125
}
115126

116127
/**
117-
* Not yet implemented.
118-
*
119-
* @return a Promise which will be fulfilled with the encrypted data (also known as "ciphertext")
128+
* Encrypts data using the given key and algorithm.
129+
* @see <a href="https://w3c.github.io/webcrypto/#SubtleCrypto-method-encrypt">SubtleCrypto.encrypt()</a>
130+
* @param algorithm the algorithm identifier with parameters
131+
* @param key the CryptoKey to encrypt with
132+
* @param data the data to encrypt
133+
* @return a Promise that fulfills with an ArrayBuffer containing the ciphertext
120134
*/
121135
@JsxFunction
122-
public NativePromise encrypt() {
123-
return notImplemented();
136+
public NativePromise encrypt(final Object algorithm, final CryptoKey key, final Object data) {
137+
return doCipher(algorithm, key, data, Cipher.ENCRYPT_MODE);
124138
}
125139

126140
/**
127-
* Not yet implemented.
128-
*
129-
* @return a Promise which will be fulfilled with the decrypted data (also known as "plaintext")
141+
* Decrypts data using the given key and algorithm.
142+
* @see <a href="https://w3c.github.io/webcrypto/#SubtleCrypto-method-decrypt">SubtleCrypto.decrypt()</a>
143+
* @param algorithm the algorithm identifier with parameters
144+
* @param key the CryptoKey to decrypt with
145+
* @param data the data to decrypt
146+
* @return a Promise that fulfills with an ArrayBuffer containing the plaintext
130147
*/
131148
@JsxFunction
132-
public NativePromise decrypt() {
133-
return notImplemented();
149+
public NativePromise decrypt(final Object algorithm, final CryptoKey key, final Object data) {
150+
return doCipher(algorithm, key, data, Cipher.DECRYPT_MODE);
151+
}
152+
153+
/**
154+
* Shared encrypt/decrypt implementation.
155+
*/
156+
private NativePromise doCipher(final Object algorithm, final CryptoKey key,
157+
final Object data, final int cipherMode) {
158+
final String operation = switch (cipherMode) {
159+
case Cipher.ENCRYPT_MODE -> "encrypt";
160+
case Cipher.DECRYPT_MODE -> "decrypt";
161+
default -> throw new IllegalArgumentException("Invalid cipher mode: " + cipherMode);
162+
};
163+
164+
final byte[] result;
165+
try {
166+
final String algorithmName = resolveAlgorithmName(algorithm);
167+
ensureAlgorithmIsSupported(operation, algorithmName);
168+
ensureKeyAlgorithmMatches(algorithmName, key);
169+
ensureKeyUsage(key, operation);
170+
171+
final ByteBuffer inputData = asByteBuffer(data);
172+
173+
// encrypt/decrypt requires algorithm parameters as an object (iv, counter, etc.)
174+
if (!(algorithm instanceof Scriptable algorithmObj)) {
175+
throw new IllegalArgumentException("An invalid or illegal string was specified");
176+
}
177+
178+
switch (algorithmName) {
179+
case "AES-CBC": {
180+
// https://w3c.github.io/webcrypto/#aes-cbc-operations
181+
final byte[] iv = extractBuffer(algorithmObj, "iv");
182+
if (iv == null || iv.length != 16) {
183+
throw new IllegalArgumentException(
184+
"Data provided to an operation does not meet requirements");
185+
}
186+
final SecretKey secretKey = getInternalKey(key, SecretKey.class);
187+
final Cipher cipher = Cipher.getInstance("AES/CBC/PKCS5Padding");
188+
cipher.init(cipherMode, secretKey, new IvParameterSpec(iv));
189+
result = cipher.doFinal(toByteArray(inputData));
190+
break;
191+
}
192+
case "AES-GCM": {
193+
// https://w3c.github.io/webcrypto/#aes-gcm-operations
194+
final byte[] iv = extractBuffer(algorithmObj, "iv");
195+
if (iv == null || iv.length == 0) {
196+
throw new IllegalArgumentException(
197+
"Data provided to an operation does not meet requirements");
198+
}
199+
200+
final int tagLength;
201+
final Object tagLengthProp = ScriptableObject.getProperty(algorithmObj, "tagLength");
202+
if (tagLengthProp instanceof Number num) {
203+
tagLength = num.intValue();
204+
if (!VALID_AES_GCM_TAG_LENGTHS.contains(tagLength)) {
205+
throw new IllegalArgumentException(
206+
"Data provided to an operation does not meet requirements");
207+
}
208+
}
209+
else {
210+
tagLength = 128;
211+
}
212+
213+
final SecretKey secretKey = getInternalKey(key, SecretKey.class);
214+
final Cipher cipher = Cipher.getInstance("AES/GCM/NoPadding");
215+
cipher.init(cipherMode, secretKey, new GCMParameterSpec(tagLength, iv));
216+
217+
final Object aadProp = ScriptableObject.getProperty(algorithmObj, "additionalData");
218+
if (aadProp instanceof Scriptable) {
219+
final ByteBuffer aad = asByteBuffer(aadProp);
220+
cipher.updateAAD(toByteArray(aad));
221+
}
222+
223+
result = cipher.doFinal(toByteArray(inputData));
224+
break;
225+
}
226+
case "AES-CTR": {
227+
// https://w3c.github.io/webcrypto/#aes-ctr-operations
228+
final byte[] counter = extractBuffer(algorithmObj, "counter");
229+
if (counter == null || counter.length != 16) {
230+
throw new IllegalArgumentException(
231+
"Data provided to an operation does not meet requirements");
232+
}
233+
234+
final Object lengthProp = ScriptableObject.getProperty(algorithmObj, "length");
235+
if (!(lengthProp instanceof Number numLength)) {
236+
throw new IllegalArgumentException(
237+
"Data provided to an operation does not meet requirements");
238+
}
239+
final int counterLength = numLength.intValue();
240+
if (counterLength < 1 || counterLength > 128) {
241+
throw new IllegalArgumentException(
242+
"Data provided to an operation does not meet requirements");
243+
}
244+
245+
final SecretKey secretKey = getInternalKey(key, SecretKey.class);
246+
// Java always increments the full 128-bit counter, ignoring the 'length' partitioning.
247+
// This only becomes an issue when data exceeds 2^length AES blocks (16 bytes each),
248+
// but in real-world usage (length >= 64) it's pretty much unreachable.
249+
final Cipher cipher = Cipher.getInstance("AES/CTR/NoPadding");
250+
cipher.init(cipherMode, secretKey, new IvParameterSpec(counter));
251+
result = cipher.doFinal(toByteArray(inputData));
252+
break;
253+
}
254+
case "RSA-OAEP": {
255+
// https://w3c.github.io/webcrypto/#rsa-oaep-operations
256+
final Scriptable keyAlgorithm = key.getAlgorithm();
257+
final Object hashObj = ScriptableObject.getProperty(keyAlgorithm, "hash");
258+
final String hash = resolveAlgorithmName(hashObj);
259+
260+
final byte[] label;
261+
final Object labelProp = ScriptableObject.getProperty(algorithmObj, "label");
262+
if (labelProp instanceof Scriptable) {
263+
final ByteBuffer labelBuf = asByteBuffer(labelProp);
264+
label = toByteArray(labelBuf);
265+
}
266+
else {
267+
label = new byte[0];
268+
}
269+
270+
final MGF1ParameterSpec mgf1Spec = new MGF1ParameterSpec(hash);
271+
final AlgorithmParameterSpec oaepSpec = new OAEPParameterSpec(
272+
hash, "MGF1", mgf1Spec, new PSource.PSpecified(label));
273+
274+
final Key internalKey;
275+
if (cipherMode == Cipher.ENCRYPT_MODE) {
276+
internalKey = getInternalKey(key, PublicKey.class);
277+
}
278+
else {
279+
internalKey = getInternalKey(key, PrivateKey.class);
280+
}
281+
282+
final Cipher cipher = Cipher.getInstance("RSA/ECB/OAEPPadding");
283+
cipher.init(cipherMode, internalKey, oaepSpec);
284+
result = cipher.doFinal(toByteArray(inputData));
285+
break;
286+
}
287+
default:
288+
throw new UnsupportedOperationException(operation + " " + algorithmName);
289+
}
290+
}
291+
catch (final EcmaError e) {
292+
return setupRejectedPromise(() -> e);
293+
}
294+
catch (final InvalidAccessException e) {
295+
return setupRejectedPromise(() -> new DOMException(e.getMessage(), DOMException.INVALID_ACCESS_ERR));
296+
}
297+
catch (final IllegalArgumentException e) {
298+
return setupRejectedPromise(() -> new DOMException(e.getMessage(), DOMException.SYNTAX_ERR));
299+
}
300+
catch (final GeneralSecurityException | UnsupportedOperationException e) {
301+
return setupRejectedPromise(() -> new DOMException("Operation is not supported: " + e.getMessage(),
302+
DOMException.NOT_SUPPORTED_ERR));
303+
}
304+
return setupPromise(() -> createArrayBuffer(result));
134305
}
135306

136307
/**
@@ -709,6 +880,21 @@ else if (data instanceof NativeArrayBufferView arrayBufferView) {
709880
}
710881
}
711882

883+
/**
884+
* Reads a property from a JS object and converts it to a byte array.
885+
* @param obj the JS object containing the property
886+
* @param property the property name (e.g. "iv", "counter", "label")
887+
* @return the byte array, or {@code null} if the property is absent or not convertible
888+
*/
889+
private static byte[] extractBuffer(final Scriptable obj, final String property) {
890+
final Object prop = ScriptableObject.getProperty(obj, property);
891+
if (prop instanceof Scriptable) {
892+
final ByteBuffer buf = asByteBuffer(prop);
893+
return toByteArray(buf);
894+
}
895+
return null;
896+
}
897+
712898
/**
713899
* Creates a NativeArrayBuffer with proper scope and prototype from the given bytes.
714900
* @param data the byte array to wrap

src/test/java/org/htmlunit/javascript/host/crypto/SubtleCryptoTest.java

Lines changed: 117 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -529,4 +529,121 @@ public void generateSignVerifyHmac() throws Exception {
529529
loadPage2(html);
530530
verifyTitle2(DEFAULT_WAIT_TIME, getWebDriver(), getExpectedAlerts());
531531
}
532+
533+
/**
534+
* @throws Exception if the test fails
535+
*/
536+
@Test
537+
@Alerts({"AES-CBC ok", "AES-GCM ok", "AES-CTR ok"})
538+
public void encryptDecryptAes() throws Exception {
539+
final String html = DOCTYPE_HTML
540+
+ "<html><head><script>\n"
541+
+ LOG_TITLE_FUNCTION
542+
+ " function encryptDecryptRoundTrip(genParams, encParams) {\n"
543+
+ " var data = new TextEncoder().encode('hello world');\n"
544+
+ " return window.crypto.subtle.generateKey(\n"
545+
+ " genParams, false, ['encrypt', 'decrypt']\n"
546+
+ " ).then(function(key) {\n"
547+
+ " return window.crypto.subtle.encrypt(encParams, key, data)"
548+
+ " .then(function(encrypted) {\n"
549+
+ " return window.crypto.subtle.decrypt(encParams, key, encrypted)"
550+
+ " .then(function(decrypted) {\n"
551+
+ " var result = new TextDecoder().decode(decrypted);\n"
552+
+ " log(genParams.name + (result === 'hello world' ? ' ok' : ' FAIL'));\n"
553+
+ " });\n"
554+
+ " });\n"
555+
+ " });\n"
556+
+ " }\n"
557+
+ " function test() {\n"
558+
+ " var iv16 = crypto.getRandomValues(new Uint8Array(16));\n"
559+
+ " var iv12 = crypto.getRandomValues(new Uint8Array(12));\n"
560+
+ " encryptDecryptRoundTrip(\n"
561+
+ " { name: 'AES-CBC', length: 256 },\n"
562+
+ " { name: 'AES-CBC', iv: iv16 }\n"
563+
+ " ).then(function() {\n"
564+
+ " return encryptDecryptRoundTrip(\n"
565+
+ " { name: 'AES-GCM', length: 256 },\n"
566+
+ " { name: 'AES-GCM', iv: iv12 }\n"
567+
+ " );\n"
568+
+ " }).then(function() {\n"
569+
+ " return encryptDecryptRoundTrip(\n"
570+
+ " { name: 'AES-CTR', length: 256 },\n"
571+
+ " { name: 'AES-CTR', counter: iv16, length: 64 }\n"
572+
+ " );\n"
573+
+ " });\n"
574+
+ " }\n"
575+
+ "</script></head><body onload='test()'>\n"
576+
+ "</body></html>";
577+
578+
loadPage2(html);
579+
verifyTitle2(DEFAULT_WAIT_TIME, getWebDriver(), getExpectedAlerts());
580+
}
581+
582+
/**
583+
* @throws Exception if the test fails
584+
*/
585+
@Test
586+
@Alerts("hello world")
587+
public void encryptDecryptRsaOaep() throws Exception {
588+
final String html = DOCTYPE_HTML
589+
+ "<html><head><script>\n"
590+
+ LOG_TITLE_FUNCTION
591+
+ " function test() {\n"
592+
+ " var data = new TextEncoder().encode('hello world');\n"
593+
+ " window.crypto.subtle.generateKey(\n"
594+
+ " { name: 'RSA-OAEP', modulusLength: 2048,\n"
595+
+ " publicExponent: new Uint8Array([1, 0, 1]), hash: 'SHA-256' },\n"
596+
+ " false, ['encrypt', 'decrypt']\n"
597+
+ " ).then(function(keyPair) {\n"
598+
+ " return window.crypto.subtle.encrypt(\n"
599+
+ " { name: 'RSA-OAEP' }, keyPair.publicKey, data\n"
600+
+ " ).then(function(encrypted) {\n"
601+
+ " return window.crypto.subtle.decrypt(\n"
602+
+ " { name: 'RSA-OAEP' }, keyPair.privateKey, encrypted\n"
603+
+ " ).then(function(decrypted) {\n"
604+
+ " log(new TextDecoder().decode(decrypted));\n"
605+
+ " });\n"
606+
+ " });\n"
607+
+ " });\n"
608+
+ " }\n"
609+
+ "</script></head><body onload='test()'>\n"
610+
+ "</body></html>";
611+
612+
loadPage2(html);
613+
verifyTitle2(DEFAULT_WAIT_TIME, getWebDriver(), getExpectedAlerts());
614+
}
615+
616+
/**
617+
* @throws Exception if the test fails
618+
*/
619+
@Test
620+
@Alerts("hello world")
621+
public void encryptDecryptAesGcmAad() throws Exception {
622+
final String html = DOCTYPE_HTML
623+
+ "<html><head><script>\n"
624+
+ LOG_TITLE_FUNCTION
625+
+ " function test() {\n"
626+
+ " var data = new TextEncoder().encode('hello world');\n"
627+
+ " var aad = new TextEncoder().encode('additional data');\n"
628+
+ " var iv = crypto.getRandomValues(new Uint8Array(12));\n"
629+
+ " crypto.subtle.generateKey(\n"
630+
+ " {name: 'AES-GCM', length: 256}, false, ['encrypt', 'decrypt']\n"
631+
+ " ).then(function(key) {\n"
632+
+ " return crypto.subtle.encrypt(\n"
633+
+ " {name: 'AES-GCM', iv: iv, additionalData: aad}, key, data\n"
634+
+ " ).then(function(encrypted) {\n"
635+
+ " return crypto.subtle.decrypt(\n"
636+
+ " {name: 'AES-GCM', iv: iv, additionalData: aad}, key, encrypted\n"
637+
+ " ).then(function(decrypted) {\n"
638+
+ " log(new TextDecoder().decode(decrypted));\n"
639+
+ " });\n"
640+
+ " });\n"
641+
+ " });\n"
642+
+ " }\n"
643+
+ "</script></head><body onload='test()'>\n"
644+
+ "</body></html>";
645+
646+
loadPage2(html);
647+
verifyTitle2(DEFAULT_WAIT_TIME, getWebDriver(), getExpectedAlerts());
648+
}
532649
}

0 commit comments

Comments
 (0)