diff --git a/example/src/hooks/useTestsList.ts b/example/src/hooks/useTestsList.ts index ea1e71a3..7df17793 100644 --- a/example/src/hooks/useTestsList.ts +++ b/example/src/hooks/useTestsList.ts @@ -41,6 +41,7 @@ import '../tests/subtle/encrypt_decrypt'; import '../tests/subtle/generateKey'; import '../tests/subtle/import_export'; import '../tests/subtle/jwk_rfc7517_tests'; +import '../tests/subtle/sharedarraybuffer_rejection'; import '../tests/subtle/sign_verify'; import '../tests/subtle/supports'; import '../tests/subtle/getPublicKey'; diff --git a/example/src/tests/subtle/sharedarraybuffer_rejection.ts b/example/src/tests/subtle/sharedarraybuffer_rejection.ts new file mode 100644 index 00000000..26a78ca9 --- /dev/null +++ b/example/src/tests/subtle/sharedarraybuffer_rejection.ts @@ -0,0 +1,321 @@ +import { expect } from 'chai'; +import crypto, { subtle, getRandomValues } from 'react-native-quick-crypto'; +import type { CryptoKey, HkdfAlgorithm } from 'react-native-quick-crypto'; +import { test } from '../util'; + +// WebCrypto / Web IDL §BufferSource: SharedArrayBuffer-backed inputs must +// be rejected from all subtle.* methods and getRandomValues. Concurrent +// writes during async crypto operations can race with the algorithm, +// corrupting output or leaking intermediate state. +// +// Reference: Node.js commit bee10872588 ("lib: reject SharedArrayBuffer in +// web APIs per spec") — Node throws TypeError, matching the WebIDL +// BufferSource converter and the W3C WebCrypto spec. + +const SUITE = 'subtle.sharedarraybuffer-rejection'; + +// Some hosts (older Hermes builds) don't expose SharedArrayBuffer at all. +// Skip the suite cleanly in that case rather than failing. +const sabAvailable = typeof SharedArrayBuffer !== 'undefined'; + +function makeSab(byteLength = 16): SharedArrayBuffer { + return new SharedArrayBuffer(byteLength); +} + +function makeSabView(byteLength = 16): Uint8Array { + return new Uint8Array(makeSab(byteLength)); +} + +function expectRejected(err: unknown, label: string) { + expect(err, `${label}: expected an error`).to.be.instanceOf(Error); + // WebIDL BufferSource conversion failure is TypeError per spec / Node. + expect((err as Error).name, `${label}: error name`).to.equal('TypeError'); + expect( + (err as Error).message.toLowerCase(), + `${label}: error message mentions SharedArrayBuffer`, + ).to.include('sharedarraybuffer'); +} + +if (sabAvailable) { + // ---- getRandomValues ---------------------------------------------------- + + test(SUITE, 'getRandomValues rejects SAB-backed Uint8Array', () => { + let caught: unknown; + try { + getRandomValues(makeSabView(8)); + } catch (e) { + caught = e; + } + expectRejected(caught, 'getRandomValues'); + }); + + // ---- randomFill / randomFillSync --------------------------------------- + + test(SUITE, 'randomFillSync rejects SAB-backed Uint8Array', () => { + let caught: unknown; + try { + crypto.randomFillSync(makeSabView(8)); + } catch (e) { + caught = e; + } + expectRejected(caught, 'randomFillSync'); + }); + + test(SUITE, 'randomFillSync rejects raw SharedArrayBuffer', () => { + let caught: unknown; + try { + crypto.randomFillSync(makeSab(8) as unknown as ArrayBuffer); + } catch (e) { + caught = e; + } + expectRejected(caught, 'randomFillSync (raw SAB)'); + }); + + test(SUITE, 'randomFill rejects SAB-backed Uint8Array', () => { + let caught: unknown; + try { + crypto.randomFill(makeSabView(8), () => { + // not reached + }); + } catch (e) { + caught = e; + } + expectRejected(caught, 'randomFill'); + }); + + // ---- subtle.digest ----------------------------------------------------- + + test(SUITE, 'subtle.digest rejects SAB-backed view', async () => { + let caught: unknown; + try { + await subtle.digest('SHA-256', makeSabView(8)); + } catch (e) { + caught = e; + } + expectRejected(caught, 'subtle.digest'); + }); + + test(SUITE, 'subtle.digest rejects raw SharedArrayBuffer', async () => { + let caught: unknown; + try { + await subtle.digest('SHA-256', makeSab(8) as unknown as ArrayBuffer); + } catch (e) { + caught = e; + } + expectRejected(caught, 'subtle.digest (raw SAB)'); + }); + + // ---- subtle.encrypt / decrypt ------------------------------------------ + + test(SUITE, 'subtle.encrypt rejects SAB-backed plaintext', async () => { + const key = await subtle.generateKey( + { name: 'AES-GCM', length: 256 }, + false, + ['encrypt', 'decrypt'], + ); + const iv = new Uint8Array(12); + let caught: unknown; + try { + await subtle.encrypt( + { name: 'AES-GCM', iv }, + key as CryptoKey, + makeSabView(16), + ); + } catch (e) { + caught = e; + } + expectRejected(caught, 'subtle.encrypt'); + }); + + test(SUITE, 'subtle.encrypt rejects SAB-backed iv', async () => { + const key = await subtle.generateKey( + { name: 'AES-GCM', length: 256 }, + false, + ['encrypt', 'decrypt'], + ); + let caught: unknown; + try { + await subtle.encrypt( + { name: 'AES-GCM', iv: makeSabView(12) }, + key as CryptoKey, + new Uint8Array(16), + ); + } catch (e) { + caught = e; + } + expectRejected(caught, 'subtle.encrypt (SAB iv)'); + }); + + test(SUITE, 'subtle.decrypt rejects SAB-backed ciphertext', async () => { + const key = await subtle.generateKey( + { name: 'AES-GCM', length: 256 }, + false, + ['encrypt', 'decrypt'], + ); + let caught: unknown; + try { + await subtle.decrypt( + { name: 'AES-GCM', iv: new Uint8Array(12) }, + key as CryptoKey, + makeSabView(32), + ); + } catch (e) { + caught = e; + } + expectRejected(caught, 'subtle.decrypt'); + }); + + // ---- subtle.sign / verify --------------------------------------------- + + test(SUITE, 'subtle.sign rejects SAB-backed data', async () => { + const key = await subtle.generateKey( + { name: 'HMAC', hash: 'SHA-256' }, + false, + ['sign', 'verify'], + ); + let caught: unknown; + try { + await subtle.sign({ name: 'HMAC' }, key as CryptoKey, makeSabView(16)); + } catch (e) { + caught = e; + } + expectRejected(caught, 'subtle.sign'); + }); + + test(SUITE, 'subtle.verify rejects SAB-backed signature', async () => { + const key = await subtle.generateKey( + { name: 'HMAC', hash: 'SHA-256' }, + false, + ['sign', 'verify'], + ); + let caught: unknown; + try { + await subtle.verify( + { name: 'HMAC' }, + key as CryptoKey, + makeSabView(32), + new Uint8Array(16), + ); + } catch (e) { + caught = e; + } + expectRejected(caught, 'subtle.verify (SAB signature)'); + }); + + test(SUITE, 'subtle.verify rejects SAB-backed data', async () => { + const key = await subtle.generateKey( + { name: 'HMAC', hash: 'SHA-256' }, + false, + ['sign', 'verify'], + ); + let caught: unknown; + try { + await subtle.verify( + { name: 'HMAC' }, + key as CryptoKey, + new Uint8Array(32), + makeSabView(16), + ); + } catch (e) { + caught = e; + } + expectRejected(caught, 'subtle.verify (SAB data)'); + }); + + // ---- subtle.importKey -------------------------------------------------- + + test(SUITE, 'subtle.importKey rejects SAB-backed raw key', async () => { + let caught: unknown; + try { + await subtle.importKey( + 'raw', + makeSabView(32), + { name: 'HMAC', hash: 'SHA-256' }, + false, + ['sign'], + ); + } catch (e) { + caught = e; + } + expectRejected(caught, 'subtle.importKey'); + }); + + // ---- subtle.encrypt AES-GCM additionalData ---------------------------- + + test( + SUITE, + 'subtle.encrypt AES-GCM rejects SAB-backed additionalData', + async () => { + const key = await subtle.generateKey( + { name: 'AES-GCM', length: 256 }, + false, + ['encrypt', 'decrypt'], + ); + let caught: unknown; + try { + await subtle.encrypt( + { + name: 'AES-GCM', + iv: new Uint8Array(12), + additionalData: makeSabView(16), + }, + key as CryptoKey, + new Uint8Array(16), + ); + } catch (e) { + caught = e; + } + expectRejected(caught, 'subtle.encrypt (SAB additionalData)'); + }, + ); + + // ---- subtle.encrypt AES-CTR counter ----------------------------------- + + test(SUITE, 'subtle.encrypt AES-CTR rejects SAB-backed counter', async () => { + const key = await subtle.generateKey( + { name: 'AES-CTR', length: 256 }, + false, + ['encrypt', 'decrypt'], + ); + let caught: unknown; + try { + await subtle.encrypt( + { name: 'AES-CTR', counter: makeSabView(16), length: 64 }, + key as CryptoKey, + new Uint8Array(16), + ); + } catch (e) { + caught = e; + } + expectRejected(caught, 'subtle.encrypt (SAB counter)'); + }); + + // ---- subtle.deriveBits (HKDF salt/info) -------------------------------- + + test(SUITE, 'subtle.deriveBits rejects SAB-backed HKDF salt', async () => { + const baseKey = await subtle.importKey( + 'raw', + new Uint8Array(32), + 'HKDF', + false, + ['deriveBits'], + ); + let caught: unknown; + try { + const algorithm = { + name: 'HKDF', + hash: 'SHA-256', + salt: makeSabView(16), + info: new Uint8Array(0), + } satisfies HkdfAlgorithm; + await subtle.deriveBits( + algorithm as Parameters[0], + baseKey, + 128, + ); + } catch (e) { + caught = e; + } + expectRejected(caught, 'subtle.deriveBits (SAB salt)'); + }); +} diff --git a/packages/react-native-quick-crypto/src/random.ts b/packages/react-native-quick-crypto/src/random.ts index 412023ab..579b4354 100644 --- a/packages/react-native-quick-crypto/src/random.ts +++ b/packages/react-native-quick-crypto/src/random.ts @@ -4,6 +4,7 @@ import { abvToArrayBuffer, lazyDOMException, QuotaExceededError, + rejectSharedArrayBuffer, } from './utils'; import { NitroModules } from 'react-native-nitro-modules'; import type { Random } from './specs/random.nitro'; @@ -320,6 +321,13 @@ function isIntegerTypedArray(value: unknown): boolean { * @returns The filled data */ export function getRandomValues(data: RandomTypedArrays) { + // WebIDL BufferSource conversion (TypeError) must run before the + // WebCrypto-specific integer-type / size checks (TypeMismatchError / + // QuotaExceededError). `randomFillSync` below also rejects SAB via + // `abvToArrayBuffer`, but by then we'd already have thrown the wrong + // error type for a non-integer SAB-view, so the explicit early call is + // load-bearing for spec compliance — not redundant. + rejectSharedArrayBuffer(data); if (!isIntegerTypedArray(data)) { throw lazyDOMException( 'The data argument must be an integer-type TypedArray', diff --git a/packages/react-native-quick-crypto/src/utils/conversion.ts b/packages/react-native-quick-crypto/src/utils/conversion.ts index 04199f27..77f9675c 100644 --- a/packages/react-native-quick-crypto/src/utils/conversion.ts +++ b/packages/react-native-quick-crypto/src/utils/conversion.ts @@ -52,6 +52,34 @@ if (isHermes) { } } +// WebCrypto / Web IDL §BufferSource: SharedArrayBuffer-backed inputs must +// be rejected. Concurrent writes from another worker during async crypto +// can corrupt computations or leak intermediate state, so even copying +// the source isn't safe (the copy itself races). Reject at conversion. +// See Node's `lib/internal/webidl.js` BufferSource converter (commit +// bee10872588) — it throws TypeError, matching the WebIDL spec. +// +// We apply this guard to *every* conversion helper, not just the ones +// reached from `subtle.*`. That's deliberately stricter than Node, whose +// classic APIs (`createHash().update`, `createHmac().update`, +// `createCipheriv().update`, etc.) accept SAB-backed views. The TOCTOU +// concern is the same on either side of the WebCrypto / classic line, so +// we prefer the safer default everywhere. +export function rejectSharedArrayBuffer(buf: unknown): void { + if (typeof SharedArrayBuffer === 'undefined') return; + if (buf instanceof SharedArrayBuffer) { + throw new TypeError('SharedArrayBuffer is not a supported BufferSource'); + } + if ( + ArrayBuffer.isView(buf) && + (buf as ArrayBufferView).buffer instanceof SharedArrayBuffer + ) { + throw new TypeError( + 'View on a SharedArrayBuffer is not a supported BufferSource', + ); + } +} + /** * Returns the underlying ArrayBuffer of a Buffer / TypedArray view **without * copying**, ignoring `byteOffset`/`byteLength`. The full backing storage is @@ -64,6 +92,7 @@ if (isHermes) { * view's region and won't leak unrelated bytes from the backing buffer. */ export const abvToArrayBuffer = (buf: ABV) => { + rejectSharedArrayBuffer(buf); if (CraftzdogBuffer.isBuffer(buf)) { return buf.buffer as ArrayBuffer; } @@ -101,6 +130,8 @@ export function toArrayBuffer( } export function bufferLikeToArrayBuffer(buf: BufferLike): ArrayBuffer { + rejectSharedArrayBuffer(buf); + // Buffer if (CraftzdogBuffer.isBuffer(buf) || SafeBuffer.isBuffer(buf)) { return toArrayBuffer(buf); @@ -115,22 +146,8 @@ export function bufferLikeToArrayBuffer(buf: BufferLike): ArrayBuffer { return buf; } - // If buf is a SharedArrayBuffer, convert it to ArrayBuffer. - // This typically involves a copy of the data. - if ( - typeof SharedArrayBuffer !== 'undefined' && - buf instanceof SharedArrayBuffer - ) { - const arrayBuffer = new ArrayBuffer(buf.byteLength); - new Uint8Array(arrayBuffer).set(new Uint8Array(buf)); - return arrayBuffer; - } - - // If we reach here, 'buf' is of a type within BufferLike that has not been handled by the above checks. - // This indicates either an incomplete BufferLike definition or an unexpected input type. - // Throw an error to signal this, ensuring the function's contract (return ArrayBuffer or throw) is met. throw new TypeError( - 'Input must be a Buffer, ArrayBufferView, ArrayBuffer, or SharedArrayBuffer.', + 'Input must be a Buffer, ArrayBufferView, or ArrayBuffer.', ); } @@ -138,6 +155,8 @@ export function binaryLikeToArrayBuffer( input: BinaryLikeNode, // CipherKey adds compat with node types encoding: string = 'utf-8', ): ArrayBuffer { + rejectSharedArrayBuffer(input); + // string if (typeof input === 'string') { if (encoding === 'buffer') {