Skip to content

Commit 5a10d66

Browse files
authored
fix: throw spec-correct DOMException types in WebCrypto error paths (#1018)
1 parent 849029a commit 5a10d66

6 files changed

Lines changed: 184 additions & 10 deletions

File tree

example/src/tests/random/random_tests.ts

Lines changed: 37 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -650,6 +650,43 @@ test(SUITE, 'crypto.getRandomValues', () => {
650650
expect(r.length).to.equal(10);
651651
});
652652

653+
// WebCrypto §getRandomValues: byteLength > 65536 must throw a
654+
// QuotaExceededError DOMException carrying `quota` and `requested`.
655+
test(SUITE, 'getRandomValues - QuotaExceededError on > 65536 bytes', () => {
656+
let caught: unknown;
657+
try {
658+
crypto.getRandomValues(new Uint8Array(65537));
659+
} catch (e) {
660+
caught = e;
661+
}
662+
const err = caught as Error & { quota?: number; requested?: number };
663+
expect(err).to.be.instanceOf(Error);
664+
expect(err.name).to.equal('QuotaExceededError');
665+
expect(err.quota).to.equal(65536);
666+
expect(err.requested).to.equal(65537);
667+
});
668+
669+
// WebCrypto §getRandomValues: non-integer-typed views must throw
670+
// TypeMismatchError. Float and DataView are explicitly excluded.
671+
[
672+
['Float32Array', () => new Float32Array(4)],
673+
['Float64Array', () => new Float64Array(4)],
674+
['DataView', () => new DataView(new ArrayBuffer(8))],
675+
].forEach(([name, make]) => {
676+
test(SUITE, `getRandomValues - TypeMismatchError on ${name}`, () => {
677+
let caught: unknown;
678+
try {
679+
// @ts-expect-error - intentionally passing disallowed view type
680+
crypto.getRandomValues((make as () => ArrayBufferView)());
681+
} catch (e) {
682+
caught = e;
683+
}
684+
const err = caught as Error;
685+
expect(err).to.be.instanceOf(Error);
686+
expect(err.name).to.equal('TypeMismatchError');
687+
});
688+
});
689+
653690
// Issue #953: TypedArray views over larger ArrayBuffers
654691
// getRandomValues / randomFillSync should only fill the view, not the entire
655692
// underlying ArrayBuffer.

example/src/tests/subtle/import_export.ts

Lines changed: 25 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -3376,3 +3376,28 @@ for (const algorithm of ['KMAC128', 'KMAC256'] as const) {
33763376
expect(ab2str(sig1, 'hex')).to.equal(ab2str(sig2, 'hex'));
33773377
});
33783378
}
3379+
3380+
// WebCrypto §SubtleCrypto.exportKey step 4: a non-extractable key must reject
3381+
// with an InvalidAccessError DOMException, not a generic Error.
3382+
test(
3383+
SUITE,
3384+
'exportKey - non-extractable key throws InvalidAccessError',
3385+
async () => {
3386+
const key = await subtle.generateKey(
3387+
{ name: 'AES-GCM', length: 256 },
3388+
false,
3389+
['encrypt', 'decrypt'],
3390+
);
3391+
3392+
let caught: unknown;
3393+
try {
3394+
await subtle.exportKey('raw', key as CryptoKey);
3395+
} catch (e) {
3396+
caught = e;
3397+
}
3398+
const err = caught as Error;
3399+
expect(err).to.be.instanceOf(Error);
3400+
expect(err.name).to.equal('InvalidAccessError');
3401+
expect(err.message).to.contain('not extractable');
3402+
},
3403+
);

example/src/tests/util.ts

Lines changed: 11 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -23,10 +23,17 @@ export const assertThrowsAsync = async (
2323
} catch (error) {
2424
const err = error as Error;
2525
if (expectedMessage) {
26-
assert.include(
27-
err.message,
28-
expectedMessage,
29-
`Function failed as expected, but could not find message snippet '${expectedMessage}'. Saw '${err.message}' instead.`,
26+
// Match the snippet against either the message OR the error name. Spec-
27+
// correct DOMException errors carry the type ('DataError',
28+
// 'NotSupportedError', etc.) on `err.name`, not in the human-readable
29+
// message — so existing tests that check for the type-name snippet
30+
// continue to pass.
31+
const found =
32+
err.message.includes(expectedMessage) ||
33+
err.name.includes(expectedMessage);
34+
assert.isTrue(
35+
found,
36+
`Function failed as expected, but could not find snippet '${expectedMessage}' in message or name. Saw message='${err.message}' name='${err.name}' instead.`,
3037
);
3138
}
3239
return;

packages/react-native-quick-crypto/src/random.ts

Lines changed: 36 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,10 @@
11
import { Buffer } from '@craftzdog/react-native-buffer';
22
import type { ABV, RandomCallback } from './utils';
3-
import { abvToArrayBuffer } from './utils';
3+
import {
4+
abvToArrayBuffer,
5+
lazyDOMException,
6+
QuotaExceededError,
7+
} from './utils';
48
import { NitroModules } from 'react-native-nitro-modules';
59
import type { Random } from './specs/random.nitro';
610

@@ -288,15 +292,45 @@ export type RandomTypedArrays =
288292
| Uint16Array
289293
| Uint32Array;
290294

295+
// WebCrypto §getRandomValues only accepts integer-typed views. Float and
296+
// non-TypedArray ABVs (DataView) must be rejected with a TypeMismatchError
297+
// DOMException — see https://w3c.github.io/webcrypto/#Crypto-method-getRandomValues
298+
const INTEGER_TYPED_ARRAY_TAGS = new Set([
299+
'Int8Array',
300+
'Int16Array',
301+
'Int32Array',
302+
'Uint8Array',
303+
'Uint8ClampedArray',
304+
'Uint16Array',
305+
'Uint32Array',
306+
'BigInt64Array',
307+
'BigUint64Array',
308+
]);
309+
310+
function isIntegerTypedArray(value: unknown): boolean {
311+
if (!ArrayBuffer.isView(value)) return false;
312+
const tag = (value as { [Symbol.toStringTag]?: string })[Symbol.toStringTag];
313+
return tag !== undefined && INTEGER_TYPED_ARRAY_TAGS.has(tag);
314+
}
315+
291316
/**
292317
* Fills the provided typed array with cryptographically strong random values.
293318
*
294319
* @param data The data to fill with random values
295320
* @returns The filled data
296321
*/
297322
export function getRandomValues(data: RandomTypedArrays) {
323+
if (!isIntegerTypedArray(data)) {
324+
throw lazyDOMException(
325+
'The data argument must be an integer-type TypedArray',
326+
'TypeMismatchError',
327+
);
328+
}
298329
if (data.byteLength > 65536) {
299-
throw new Error('The requested length exceeds 65,536 bytes');
330+
throw new QuotaExceededError('The requested length exceeds 65,536 bytes', {
331+
quota: 65536,
332+
requested: data.byteLength,
333+
});
300334
}
301335
randomFillSync(data, 0);
302336
return data;

packages/react-native-quick-crypto/src/subtle.ts

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -2514,7 +2514,8 @@ export class Subtle {
25142514
format: ImportFormat,
25152515
key: CryptoKey,
25162516
): Promise<ArrayBuffer | JWK> {
2517-
if (!key.extractable) throw new Error('key is not extractable');
2517+
if (!key.extractable)
2518+
throw lazyDOMException('key is not extractable', 'InvalidAccessError');
25182519

25192520
if (format === 'raw-seed') {
25202521
const pqcAlgos = [

packages/react-native-quick-crypto/src/utils/errors.ts

Lines changed: 73 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -5,9 +5,79 @@ type DOMName =
55
cause: unknown;
66
};
77

8+
// Hermes (React Native) does not implement DOMException natively. Use it when
9+
// the host provides one; otherwise fall back to an Error subclass that exposes
10+
// the WebCrypto-relevant surface (.name, .message, .code) so consumers that
11+
// branch on `err.name === 'InvalidAccessError'` see the spec-correct value.
12+
const DOM_EXCEPTION_CODES: Record<string, number> = {
13+
IndexSizeError: 1,
14+
HierarchyRequestError: 3,
15+
WrongDocumentError: 4,
16+
InvalidCharacterError: 5,
17+
NoModificationAllowedError: 7,
18+
NotFoundError: 8,
19+
NotSupportedError: 9,
20+
InUseAttributeError: 10,
21+
InvalidStateError: 11,
22+
SyntaxError: 12,
23+
InvalidModificationError: 13,
24+
NamespaceError: 14,
25+
InvalidAccessError: 15,
26+
TypeMismatchError: 17,
27+
SecurityError: 18,
28+
NetworkError: 19,
29+
AbortError: 20,
30+
URLMismatchError: 21,
31+
QuotaExceededError: 22,
32+
TimeoutError: 23,
33+
InvalidNodeTypeError: 24,
34+
DataCloneError: 25,
35+
};
36+
37+
const HostDOMException: typeof globalThis.DOMException | undefined = (
38+
globalThis as { DOMException?: typeof globalThis.DOMException }
39+
).DOMException;
40+
41+
class FallbackDOMException extends Error {
42+
readonly code: number;
43+
constructor(message: string, name: string) {
44+
super(message);
45+
this.name = name;
46+
this.code = DOM_EXCEPTION_CODES[name] ?? 0;
47+
}
48+
}
49+
850
export function lazyDOMException(message: string, domName: DOMName): Error {
951
const name = typeof domName === 'string' ? domName : domName.name;
10-
const cause =
11-
typeof domName === 'string' ? '' : `\nCaused by: ${domName.cause}`;
12-
return new Error(`[${name}]: ${message}${cause}`);
52+
const cause = typeof domName === 'string' ? undefined : domName.cause;
53+
54+
let err: Error;
55+
if (HostDOMException) {
56+
err =
57+
cause !== undefined
58+
? new HostDOMException(message, { name, cause } as never)
59+
: new HostDOMException(message, name);
60+
} else {
61+
err = new FallbackDOMException(message, name);
62+
if (cause !== undefined) {
63+
(err as Error & { cause?: unknown }).cause = cause;
64+
}
65+
}
66+
return err;
67+
}
68+
69+
// QuotaExceededError carries `quota` and `requested` numeric fields per the
70+
// WebIDL spec (https://webidl.spec.whatwg.org/#quotaexceedederror). DOMException
71+
// in legacy hosts does not expose these, so always use our subclass.
72+
export class QuotaExceededError extends FallbackDOMException {
73+
readonly quota: number | null;
74+
readonly requested: number | null;
75+
constructor(
76+
message: string,
77+
options: { quota?: number; requested?: number } = {},
78+
) {
79+
super(message, 'QuotaExceededError');
80+
this.quota = options.quota ?? null;
81+
this.requested = options.requested ?? null;
82+
}
1383
}

0 commit comments

Comments
 (0)