Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
37 changes: 37 additions & 0 deletions example/src/tests/random/random_tests.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down
25 changes: 25 additions & 0 deletions example/src/tests/subtle/import_export.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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');
},
);
15 changes: 11 additions & 4 deletions example/src/tests/util.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down
38 changes: 36 additions & 2 deletions packages/react-native-quick-crypto/src/random.ts
Original file line number Diff line number Diff line change
@@ -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';

Expand Down Expand Up @@ -288,15 +292,45 @@ 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.
*
* @param data The data to fill with random values
* @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;
Expand Down
3 changes: 2 additions & 1 deletion packages/react-native-quick-crypto/src/subtle.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2514,7 +2514,8 @@ export class Subtle {
format: ImportFormat,
key: CryptoKey,
): Promise<ArrayBuffer | JWK> {
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 = [
Expand Down
76 changes: 73 additions & 3 deletions packages/react-native-quick-crypto/src/utils/errors.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<string, number> = {
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;
}
}
Loading