File
Blob: src/workerd/api/tests/crypto-extras-test.js
| 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 |
| 4 | import { strictEqual, ok } from 'node:assert'; |
| 5 | |
| 6 | export 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 | |
| 46 | export 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 | |
| 60 | export 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 | |
| 96 | export 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 | |
| 161 | export 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 | |
| 219 | export 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. |
| 257 | export 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. |
| 336 | export 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). |
| 390 | export 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 | }; |