diff --git a/example/src/tests/random/random_tests.ts b/example/src/tests/random/random_tests.ts index 2bb3c8a7..9c6e1d1c 100644 --- a/example/src/tests/random/random_tests.ts +++ b/example/src/tests/random/random_tests.ts @@ -650,6 +650,43 @@ test(SUITE, 'crypto.getRandomValues', () => { expect(r.length).to.equal(10); }); +// WebCrypto §getRandomValues: byteLength > 65536 must throw a +// QuotaExceededError DOMException carrying `quota` and `requested`. +test(SUITE, 'getRandomValues - QuotaExceededError on > 65536 bytes', () => { + let caught: unknown; + try { + crypto.getRandomValues(new Uint8Array(65537)); + } catch (e) { + caught = e; + } + const err = caught as Error & { quota?: number; requested?: number }; + expect(err).to.be.instanceOf(Error); + expect(err.name).to.equal('QuotaExceededError'); + expect(err.quota).to.equal(65536); + expect(err.requested).to.equal(65537); +}); + +// WebCrypto §getRandomValues: non-integer-typed views must throw +// TypeMismatchError. Float and DataView are explicitly excluded. +[ + ['Float32Array', () => new Float32Array(4)], + ['Float64Array', () => new Float64Array(4)], + ['DataView', () => new DataView(new ArrayBuffer(8))], +].forEach(([name, make]) => { + test(SUITE, `getRandomValues - TypeMismatchError on ${name}`, () => { + let caught: unknown; + try { + // @ts-expect-error - intentionally passing disallowed view type + crypto.getRandomValues((make as () => ArrayBufferView)()); + } catch (e) { + caught = e; + } + const err = caught as Error; + expect(err).to.be.instanceOf(Error); + expect(err.name).to.equal('TypeMismatchError'); + }); +}); + // Issue #953: TypedArray views over larger ArrayBuffers // getRandomValues / randomFillSync should only fill the view, not the entire // underlying ArrayBuffer. diff --git a/example/src/tests/subtle/import_export.ts b/example/src/tests/subtle/import_export.ts index 4ec61af3..dc648bd1 100644 --- a/example/src/tests/subtle/import_export.ts +++ b/example/src/tests/subtle/import_export.ts @@ -3376,3 +3376,28 @@ for (const algorithm of ['KMAC128', 'KMAC256'] as const) { expect(ab2str(sig1, 'hex')).to.equal(ab2str(sig2, 'hex')); }); } + +// WebCrypto §SubtleCrypto.exportKey step 4: a non-extractable key must reject +// with an InvalidAccessError DOMException, not a generic Error. +test( + SUITE, + 'exportKey - non-extractable key throws InvalidAccessError', + async () => { + const key = await subtle.generateKey( + { name: 'AES-GCM', length: 256 }, + false, + ['encrypt', 'decrypt'], + ); + + let caught: unknown; + try { + await subtle.exportKey('raw', key as CryptoKey); + } catch (e) { + caught = e; + } + const err = caught as Error; + expect(err).to.be.instanceOf(Error); + expect(err.name).to.equal('InvalidAccessError'); + expect(err.message).to.contain('not extractable'); + }, +); diff --git a/example/src/tests/util.ts b/example/src/tests/util.ts index f88a1689..d5250f4c 100644 --- a/example/src/tests/util.ts +++ b/example/src/tests/util.ts @@ -23,10 +23,17 @@ export const assertThrowsAsync = async ( } catch (error) { const err = error as Error; if (expectedMessage) { - assert.include( - err.message, - expectedMessage, - `Function failed as expected, but could not find message snippet '${expectedMessage}'. Saw '${err.message}' instead.`, + // Match the snippet against either the message OR the error name. Spec- + // correct DOMException errors carry the type ('DataError', + // 'NotSupportedError', etc.) on `err.name`, not in the human-readable + // message — so existing tests that check for the type-name snippet + // continue to pass. + const found = + err.message.includes(expectedMessage) || + err.name.includes(expectedMessage); + assert.isTrue( + found, + `Function failed as expected, but could not find snippet '${expectedMessage}' in message or name. Saw message='${err.message}' name='${err.name}' instead.`, ); } return; diff --git a/packages/react-native-quick-crypto/src/random.ts b/packages/react-native-quick-crypto/src/random.ts index 5ca2b7d8..412023ab 100644 --- a/packages/react-native-quick-crypto/src/random.ts +++ b/packages/react-native-quick-crypto/src/random.ts @@ -1,6 +1,10 @@ import { Buffer } from '@craftzdog/react-native-buffer'; import type { ABV, RandomCallback } from './utils'; -import { abvToArrayBuffer } from './utils'; +import { + abvToArrayBuffer, + lazyDOMException, + QuotaExceededError, +} from './utils'; import { NitroModules } from 'react-native-nitro-modules'; import type { Random } from './specs/random.nitro'; @@ -288,6 +292,27 @@ export type RandomTypedArrays = | Uint16Array | Uint32Array; +// WebCrypto §getRandomValues only accepts integer-typed views. Float and +// non-TypedArray ABVs (DataView) must be rejected with a TypeMismatchError +// DOMException — see https://w3c.github.io/webcrypto/#Crypto-method-getRandomValues +const INTEGER_TYPED_ARRAY_TAGS = new Set([ + 'Int8Array', + 'Int16Array', + 'Int32Array', + 'Uint8Array', + 'Uint8ClampedArray', + 'Uint16Array', + 'Uint32Array', + 'BigInt64Array', + 'BigUint64Array', +]); + +function isIntegerTypedArray(value: unknown): boolean { + if (!ArrayBuffer.isView(value)) return false; + const tag = (value as { [Symbol.toStringTag]?: string })[Symbol.toStringTag]; + return tag !== undefined && INTEGER_TYPED_ARRAY_TAGS.has(tag); +} + /** * Fills the provided typed array with cryptographically strong random values. * @@ -295,8 +320,17 @@ export type RandomTypedArrays = * @returns The filled data */ export function getRandomValues(data: RandomTypedArrays) { + if (!isIntegerTypedArray(data)) { + throw lazyDOMException( + 'The data argument must be an integer-type TypedArray', + 'TypeMismatchError', + ); + } if (data.byteLength > 65536) { - throw new Error('The requested length exceeds 65,536 bytes'); + throw new QuotaExceededError('The requested length exceeds 65,536 bytes', { + quota: 65536, + requested: data.byteLength, + }); } randomFillSync(data, 0); return data; diff --git a/packages/react-native-quick-crypto/src/subtle.ts b/packages/react-native-quick-crypto/src/subtle.ts index aeb5888b..6c882c08 100644 --- a/packages/react-native-quick-crypto/src/subtle.ts +++ b/packages/react-native-quick-crypto/src/subtle.ts @@ -2514,7 +2514,8 @@ export class Subtle { format: ImportFormat, key: CryptoKey, ): Promise { - if (!key.extractable) throw new Error('key is not extractable'); + if (!key.extractable) + throw lazyDOMException('key is not extractable', 'InvalidAccessError'); if (format === 'raw-seed') { const pqcAlgos = [ diff --git a/packages/react-native-quick-crypto/src/utils/errors.ts b/packages/react-native-quick-crypto/src/utils/errors.ts index 4d19a62a..03ba80df 100644 --- a/packages/react-native-quick-crypto/src/utils/errors.ts +++ b/packages/react-native-quick-crypto/src/utils/errors.ts @@ -5,9 +5,79 @@ type DOMName = cause: unknown; }; +// Hermes (React Native) does not implement DOMException natively. Use it when +// the host provides one; otherwise fall back to an Error subclass that exposes +// the WebCrypto-relevant surface (.name, .message, .code) so consumers that +// branch on `err.name === 'InvalidAccessError'` see the spec-correct value. +const DOM_EXCEPTION_CODES: Record = { + IndexSizeError: 1, + HierarchyRequestError: 3, + WrongDocumentError: 4, + InvalidCharacterError: 5, + NoModificationAllowedError: 7, + NotFoundError: 8, + NotSupportedError: 9, + InUseAttributeError: 10, + InvalidStateError: 11, + SyntaxError: 12, + InvalidModificationError: 13, + NamespaceError: 14, + InvalidAccessError: 15, + TypeMismatchError: 17, + SecurityError: 18, + NetworkError: 19, + AbortError: 20, + URLMismatchError: 21, + QuotaExceededError: 22, + TimeoutError: 23, + InvalidNodeTypeError: 24, + DataCloneError: 25, +}; + +const HostDOMException: typeof globalThis.DOMException | undefined = ( + globalThis as { DOMException?: typeof globalThis.DOMException } +).DOMException; + +class FallbackDOMException extends Error { + readonly code: number; + constructor(message: string, name: string) { + super(message); + this.name = name; + this.code = DOM_EXCEPTION_CODES[name] ?? 0; + } +} + export function lazyDOMException(message: string, domName: DOMName): Error { const name = typeof domName === 'string' ? domName : domName.name; - const cause = - typeof domName === 'string' ? '' : `\nCaused by: ${domName.cause}`; - return new Error(`[${name}]: ${message}${cause}`); + const cause = typeof domName === 'string' ? undefined : domName.cause; + + let err: Error; + if (HostDOMException) { + err = + cause !== undefined + ? new HostDOMException(message, { name, cause } as never) + : new HostDOMException(message, name); + } else { + err = new FallbackDOMException(message, name); + if (cause !== undefined) { + (err as Error & { cause?: unknown }).cause = cause; + } + } + return err; +} + +// QuotaExceededError carries `quota` and `requested` numeric fields per the +// WebIDL spec (https://webidl.spec.whatwg.org/#quotaexceedederror). DOMException +// in legacy hosts does not expose these, so always use our subclass. +export class QuotaExceededError extends FallbackDOMException { + readonly quota: number | null; + readonly requested: number | null; + constructor( + message: string, + options: { quota?: number; requested?: number } = {}, + ) { + super(message, 'QuotaExceededError'); + this.quota = options.quota ?? null; + this.requested = options.requested ?? null; + } }