Skip to content
File

Blob: src/workerd/api/tests/crypto-extras-test.js

javascript423 lines
1// Copyright (c) 2023 Cloudflare, Inc.
2// Licensed under the Apache 2.0 license found in the LICENSE file or at:
3// https://opensource.org/licenses/Apache-2.0
4import { strictEqual, ok } from 'node:assert';
5 
6export const timingSafeEqual = {
7 test() {
8 // Note that this does not actually test that the equality check is,
9 // in fact, timing safe. It checks only the basic operation of the API
10 
11 const enc = new TextEncoder();
12 [
13 [new ArrayBuffer(0), new ArrayBuffer(0)],
14 [new ArrayBuffer(1), new ArrayBuffer(1)],
15 [enc.encode('hello'), enc.encode('hello')],
16 [enc.encode('hellothere'), enc.encode('hellothere').buffer],
17 ].forEach(([a, b]) => {
18 if (!crypto.subtle.timingSafeEqual(a, b)) {
19 throw new Error('inputs should have been equal', a, b);
20 }
21 });
22 
23 [
24 [enc.encode('hello'), enc.encode('there')],
25 [new Uint8Array([1, 2, 3, 4]), new Uint32Array([1])],
26 ].forEach(([a, b]) => {
27 if (crypto.subtle.timingSafeEqual(a, b)) {
28 throw new Error('inputs should not have been equal', a, b);
29 }
30 });
31 
32 [
33 ['hello', 'there'],
34 [new ArrayBuffer(0), new ArrayBuffer(1)],
35 ].forEach(([a, b]) => {
36 try {
37 crypto.subtle.timingSafeEqual(a, b);
38 throw new Error('inputs should have caused an error', a, b);
39 } catch {
40 // intentionally empty
41 }
42 });
43 },
44};
45 
46export const randomUuid = {
47 test() {
48 const pattern =
49 /[a-f0-9]{8}-[a-f0-9]{4}-4[a-f0-9]{3}-[ab89][a-f0-9]{3}-[a-f0-9]{12}/;
50 // Loop through a bunch of generated UUID's to make sure we're consistently successful.
51 for (let n = 0; n < 100; n++) {
52 const uuid = crypto.randomUUID();
53 if (!pattern.test(uuid)) {
54 throw new Error(`${uuid} is not a valid random UUID`);
55 }
56 }
57 },
58};
59 
60export const cryptoGcmIvZeroLength = {
61 async test() {
62 const key = await crypto.subtle.generateKey(
63 {
64 name: 'AES-GCM',
65 length: 256,
66 },
67 true,
68 ['encrypt', 'decrypt']
69 );
70 
71 for (const op of ['encrypt', 'decrypt']) {
72 await crypto.subtle[op](
73 {
74 name: 'AES-GCM',
75 iv: new ArrayBuffer(0),
76 },
77 key,
78 new ArrayBuffer(100)
79 ).then(
80 () => {
81 throw new Error('should not have resolved');
82 },
83 (err) => {
84 if (
85 err.constructor !== DOMException ||
86 err.message !== 'AES-GCM IV must not be empty.'
87 ) {
88 throw err;
89 }
90 }
91 );
92 }
93 },
94};
95 
96export const cryptoZeroLength = {
97 async test() {
98 function arrayBuffer2hex(arr) {
99 return Array.from(new Uint8Array(arr))
100 .map((i) => ('0' + i.toString(16)).slice(-2))
101 .join('');
102 }
103 
104 // Try using a zero-length input to various crypto functions. This should be valid.
105 // At one point, encrypt() would sometimes fail on an empty input -- but, mysteriously,
106 // it depended on how exactly the ArrayBuffer was constructed! The problem turned out to
107 // be BoringSSL rejecting null pointers even if the size was 0.
108 
109 const empty = new ArrayBuffer();
110 
111 const DIGESTS = {
112 MD5: 'd41d8cd98f00b204e9800998ecf8427e',
113 'SHA-1': 'da39a3ee5e6b4b0d3255bfef95601890afd80709',
114 'SHA-256':
115 'e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855',
116 'SHA-512':
117 'cf83e1357eefb8bdf1542850d66d8007d620e4050b5715dc83f4a921d36ce9ce47d0d13c5d85f2b0ff8318d2877eec2f63b931bd47417a81a538327af927da3e',
118 };
119 
120 for (const name in DIGESTS) {
121 const result = arrayBuffer2hex(await crypto.subtle.digest(name, empty));
122 if (result != DIGESTS[name]) {
123 throw new Error(
124 'for ' + name + ', expected ' + DIGESTS[name] + ' got ' + result
125 );
126 }
127 }
128 
129 const ENCRYPTS = {
130 'AES-CBC': 'dd3eedef984211b98384dc5677bc728e',
131 'AES-GCM': 'fedbd1a722cb7c1a52f529e0469ee449',
132 };
133 
134 for (const name in ENCRYPTS) {
135 const key = await crypto.subtle.importKey(
136 'raw',
137 new Uint8Array([
138 0, 1, 2, 3, 4, 5, 6, 7, 8, 9, 0xa, 0xb, 0xc, 0xd, 0xe, 0xf, 0, 1, 2,
139 3, 4, 5, 6, 7, 8, 9, 0xa, 0xb, 0xc, 0xd, 0xe, 0xf,
140 ]),
141 name,
142 true,
143 ['encrypt']
144 );
145 const result = arrayBuffer2hex(
146 await crypto.subtle.encrypt(
147 { name, iv: new Uint8Array(16) },
148 key,
149 empty
150 )
151 );
152 if (result != ENCRYPTS[name]) {
153 throw new Error(
154 'for ' + name + ', expected ' + ENCRYPTS[name] + ' got ' + result
155 );
156 }
157 }
158 },
159};
160 
161export const deriveBitsNullLength = {
162 async test() {
163 // Tests that deriveBits can take a null or undefined length
164 // argument and still return the correct number of bits if
165 // the algorithm supports it. This is a recent spec change.
166 
167 const pair = await crypto.subtle.generateKey(
168 {
169 name: 'ECDH',
170 namedCurve: 'P-384',
171 },
172 false,
173 ['deriveBits']
174 );
175 
176 {
177 const bits = await crypto.subtle.deriveBits(
178 {
179 name: 'ECDH',
180 namedCurve: 'P-384',
181 public: pair.publicKey,
182 },
183 pair.privateKey,
184 undefined
185 );
186 
187 strictEqual(bits.byteLength, 48);
188 }
189 
190 {
191 const bits = await crypto.subtle.deriveBits(
192 {
193 name: 'ECDH',
194 namedCurve: 'P-384',
195 public: pair.publicKey,
196 },
197 pair.privateKey,
198 null
199 );
200 
201 strictEqual(bits.byteLength, 48);
202 }
203 
204 {
205 const bits = await crypto.subtle.deriveBits(
206 {
207 name: 'ECDH',
208 namedCurve: 'P-384',
209 public: pair.publicKey,
210 },
211 pair.privateKey
212 );
213 
214 strictEqual(bits.byteLength, 48);
215 }
216 },
217};
218 
219export const aesCounterOverflowTest = {
220 async test() {
221 // Regression test: Check that the input counter is not modified when it overflows in the
222 // internal computation.
223 const key = await crypto.subtle.generateKey(
224 {
225 name: 'AES-CTR',
226 length: 128,
227 },
228 false,
229 ['encrypt']
230 );
231 
232 // Maximum counter value, will overflow and require processing in two parts if there is more
233 // than one input data block.
234 const counter = new Uint8Array([
235 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255,
236 255,
237 ]);
238 const counter2 = counter.slice();
239 
240 await crypto.subtle.encrypt(
241 {
242 name: 'AES-CTR',
243 length: 128,
244 counter,
245 },
246 key,
247 new TextEncoder().encode('A'.repeat(2 * 16))
248 );
249 ok(crypto.subtle.timingSafeEqual(counter, counter2));
250 },
251};
252 
253// Test that RSA JWK import with partially invalid fields throws a proper error
254// without leaking memory. The valid n/e/d fields cause BIGNUM allocations; the
255// invalid p field (bad base64) triggers a throw. Under ASAN this verifies the
256// BIGNUMs allocated for n/e/d are properly freed on the error path.
257export const rsaJwkPartialImportFailure = {
258 async test() {
259 // A minimal RSA-2048 JWK with valid n, e, d but invalid CRT parameters.
260 // The n/e/d values are from a real key (public, not sensitive).
261 const jwk = {
262 kty: 'RSA',
263 // Valid base64url-encoded values for n and e (from an RSA-2048 test key)
264 n:
265 'sXchDaQebHnPiGvhGPEUBYNmRREkfWAz4CZV0FxTwtQq6R51mJk8qnnU_6DE_XJr' +
266 'T2JVNPB-bIXGFNnMLPOsTf5Q4r9Ks3h3S3tPzFqSd9Cjv0eRe-ZhWBYFkl-bLE1h' +
267 'ZGnmtQ--KfAiMvAtYNfRJwKL9cSKpGQTmqY6_0IbUqbZ0dXf_5D4rKCiZaQj-lTbm' +
268 'Eifn5JeRKnA2VY4dQvVQKhoQp_dEFwjOLGPOJ3yJhAFRrtFI3tzH7jSLNz2FA9gHk' +
269 'LaPrGxWF-bSNqlegYCr8CATCNfCAt9lDbCCHJiB5TQ5B-R40gM-y_M44zzX9nbZuA' +
270 'rSkBjQ',
271 e: 'AQAB',
272 d:
273 'VFCWOqXr8nvZNyaaJLXEnFBR3W45lj0nSjpUGSH-wOjK4p5_FDRlaL-eRa-VQvwjJ' +
274 '38BRJk9_0dKJPCMcuFVlj-B0FNpZ_gkBGC-jlLfCq3SBjRFBasVUR5vh4GGe_pFD3p' +
275 '0RWjwwl_6yPb_cCeI4XP4kK4JEWndHjvNmBcZI6PU0Lc_8-Fb_Z0-BTN3BA0DBkFS' +
276 'GCQN7G4dCdNQ3Onn3y2JBXB-pYlFkiHyR0j0o_GFoH_GE-WxQb7q0PjkNV-sMFQ8' +
277 '0ql44vEPg0Z8bZ0d1g_j8_Z3PuKCPJxJ6T3IGHPV1D-kBJyBvjJ-rlKr4XQ6XqvAp' +
278 'XWPQ',
279 // Invalid base64 in CRT parameters — should cause import to throw
280 p: '!!!not-valid-base64!!!',
281 q: '!!!not-valid-base64!!!',
282 dp: '!!!not-valid-base64!!!',
283 dq: '!!!not-valid-base64!!!',
284 qi: '!!!not-valid-base64!!!',
285 };
286 
287 let threw = false;
288 try {
289 await crypto.subtle.importKey(
290 'jwk',
291 jwk,
292 { name: 'RSASSA-PKCS1-v1_5', hash: 'SHA-256' },
293 true,
294 ['sign']
295 );
296 } catch (e) {
297 threw = true;
298 // Should get an error about invalid base64, not crash or silently succeed
299 ok(e instanceof Error, 'Expected an Error');
300 }
301 ok(threw, 'Import should have thrown for invalid CRT parameters');
302 
303 // Also test with valid n/e but invalid d (earlier failure point)
304 const jwk2 = {
305 kty: 'RSA',
306 n: jwk.n,
307 e: jwk.e,
308 d: '!!!not-valid-base64!!!',
309 p: '!!!not-valid-base64!!!',
310 q: '!!!not-valid-base64!!!',
311 dp: '!!!not-valid-base64!!!',
312 dq: '!!!not-valid-base64!!!',
313 qi: '!!!not-valid-base64!!!',
314 };
315 
316 threw = false;
317 try {
318 await crypto.subtle.importKey(
319 'jwk',
320 jwk2,
321 { name: 'RSASSA-PKCS1-v1_5', hash: 'SHA-256' },
322 true,
323 ['sign']
324 );
325 } catch (e) {
326 threw = true;
327 ok(e instanceof Error, 'Expected an Error');
328 }
329 ok(threw, 'Import should have thrown for invalid private exponent');
330 },
331};
332 
333// Test that operations on detached ArrayBuffers return empty results instead of
334// crashing. This exercises the WasDetached() checks in JsArrayBuffer, JsUint8Array,
335// JsArrayBufferView, and JsBufferSource.
336export const detachedBufferHandling = {
337 async test() {
338 // Create a buffer, detach it by transferring, then use it with crypto APIs.
339 const buf = new ArrayBuffer(16);
340 const view = new Uint8Array(buf);
341 
342 // Detach by transferring to a new ArrayBuffer via structuredClone
343 structuredClone(buf, { transfer: [buf] });
344 
345 // buf is now detached — byteLength should be 0
346 strictEqual(buf.byteLength, 0);
347 
348 // getRandomValues should handle the detached view gracefully
349 let threw = false;
350 try {
351 crypto.getRandomValues(view);
352 } catch (_e) {
353 threw = true;
354 }
355 // The detached buffer has 0 length, which should be accepted (0 <= 65536)
356 // but the view reports 0 bytes so getRandomValues is effectively a no-op.
357 // Either succeeding with 0 bytes or throwing is acceptable behavior.
358 
359 // timingSafeEqual with detached buffers
360 const detachedBuf2 = new ArrayBuffer(0);
361 ok(
362 crypto.subtle.timingSafeEqual(detachedBuf2, new ArrayBuffer(0)),
363 'Empty buffers should be timing-safe equal'
364 );
365 
366 // Verify that encrypt with a detached IV throws rather than crashing
367 const key = await crypto.subtle.generateKey(
368 { name: 'AES-GCM', length: 128 },
369 false,
370 ['encrypt']
371 );
372 const detachedIv = new ArrayBuffer(12);
373 structuredClone(detachedIv, { transfer: [detachedIv] });
374 threw = false;
375 try {
376 await crypto.subtle.encrypt(
377 { name: 'AES-GCM', iv: detachedIv },
378 key,
379 new ArrayBuffer(0)
380 );
381 } catch (_e) {
382 threw = true;
383 }
384 ok(threw, 'Encrypt with detached IV should throw');
385 },
386};
387 
388// Test that EC JWK import with mismatched public/private key components is rejected.
389// EC_KEY_check_key validates that the private key d corresponds to the public key (x, y).
390export const ecJwkKeyConsistencyCheck = {
391 async test() {
392 // Generate a valid P-256 key pair to get real x, y values
393 const keyPair = await crypto.subtle.generateKey(
394 { name: 'ECDSA', namedCurve: 'P-256' },
395 true,
396 ['sign', 'verify']
397 );
398 const validJwk = await crypto.subtle.exportKey('jwk', keyPair.privateKey);
399 
400 // Corrupt the private key d while keeping x, y valid.
401 // This creates an inconsistency: d does not correspond to (x, y).
402 const corruptedJwk = {
403 ...validJwk,
404 d: validJwk.d.split('').reverse().join(''),
405 };
406 
407 let threw = false;
408 try {
409 await crypto.subtle.importKey(
410 'jwk',
411 corruptedJwk,
412 { name: 'ECDSA', namedCurve: 'P-256' },
413 true,
414 ['sign']
415 );
416 } catch (e) {
417 threw = true;
418 ok(e instanceof Error, 'Expected an Error');
419 }
420 ok(threw, 'Import should have thrown for inconsistent EC JWK private key');
421 },
422};