Skip to content

Commit d694d02

Browse files
committed
fix: freeze CryptoKey.usages per WebCrypto spec
The WebCrypto spec requires `key.usages` to be a frozen array. Apply `Object.freeze` to the canonicalized usages array in the CryptoKey constructor so external code can't mutate it. Spread into a fresh array when exporting JWK `key_ops` to keep that field mutable. Follow-up cleanups from review of the prior canonicalization commit: - Drop the `length <= 1` early return in `getSortedUsages` so every input flows through the canonical filter (avoids a code path that would silently pass through invalid usages if validation upstream ever regressed). - Document `getUsagesUnion`'s contract — dedup and ordering are the constructor's job, so future contributors don't re-add ad-hoc dedup at call sites. - Type the canonicalization test vectors with `SubtleAlgorithm` / `KeyUsage` / `AnyAlgorithm` instead of `any`, and add regression tests asserting `key.usages` is frozen and that `jwk.key_ops` mutation does not leak back into the source key.
1 parent 465fb5b commit d694d02

4 files changed

Lines changed: 62 additions & 29 deletions

File tree

example/src/tests/subtle/usage_canonicalization.ts

Lines changed: 55 additions & 25 deletions
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,10 @@
11
import { expect } from 'chai';
22
import type {
3+
AnyAlgorithm,
34
CryptoKey,
45
JWK,
6+
KeyUsage,
7+
SubtleAlgorithm,
58
WebCryptoKeyPair,
69
} from 'react-native-quick-crypto';
710
import crypto, { subtle } from 'react-native-quick-crypto';
@@ -21,10 +24,9 @@ const SUITE = 'subtle.usage-canonicalization';
2124

2225
const symmetricVectors: Array<{
2326
name: string;
24-
// SubtleAlgorithm types vary across algorithms; loose typing is fine here.
25-
algorithm: object;
26-
usages: string[];
27-
expected: string[];
27+
algorithm: SubtleAlgorithm;
28+
usages: KeyUsage[];
29+
expected: KeyUsage[];
2830
}> = [
2931
{
3032
name: 'HMAC',
@@ -62,11 +64,9 @@ const symmetricVectors: Array<{
6264
for (const { name, algorithm, usages, expected } of symmetricVectors) {
6365
test(SUITE, `generateKey ${name} usages canonical + deduped`, async () => {
6466
const key = (await subtle.generateKey(
65-
// eslint-disable-next-line @typescript-eslint/no-explicit-any
66-
algorithm as any,
67+
algorithm,
6768
true,
68-
// eslint-disable-next-line @typescript-eslint/no-explicit-any
69-
usages as any,
69+
usages,
7070
)) as CryptoKey;
7171
expect(key.usages).to.deep.equal(expected);
7272
expect(key.usages.length).to.equal(expected.length);
@@ -77,10 +77,10 @@ for (const { name, algorithm, usages, expected } of symmetricVectors) {
7777

7878
type PairVector = {
7979
name: string;
80-
algorithm: object;
81-
usages: string[];
82-
publicExpected: string[];
83-
privateExpected: string[];
80+
algorithm: SubtleAlgorithm;
81+
usages: KeyUsage[];
82+
publicExpected: KeyUsage[];
83+
privateExpected: KeyUsage[];
8484
};
8585

8686
const asymmetricVectors: PairVector[] = [
@@ -171,11 +171,9 @@ const asymmetricVectors: PairVector[] = [
171171
for (const v of asymmetricVectors) {
172172
test(SUITE, `generateKey ${v.name} usages canonical + deduped`, async () => {
173173
const { publicKey, privateKey } = (await subtle.generateKey(
174-
// eslint-disable-next-line @typescript-eslint/no-explicit-any
175-
v.algorithm as any,
174+
v.algorithm,
176175
true,
177-
// eslint-disable-next-line @typescript-eslint/no-explicit-any
178-
v.usages as any,
176+
v.usages,
179177
)) as WebCryptoKeyPair;
180178
expect(publicKey.usages, `${v.name} publicKey`).to.deep.equal(
181179
v.publicExpected,
@@ -211,16 +209,15 @@ test(SUITE, 'importKey raw HMAC dedupes + canonicalizes', async () => {
211209
});
212210

213211
// HKDF / PBKDF2 (importGenericSecretKey path).
214-
for (const name of ['HKDF', 'PBKDF2']) {
212+
const derivationAlgs: AnyAlgorithm[] = ['HKDF', 'PBKDF2'];
213+
for (const name of derivationAlgs) {
215214
test(SUITE, `importKey raw ${name} dedupes + canonicalizes`, async () => {
216-
const key = await subtle.importKey(
217-
'raw',
218-
new Uint8Array(16),
219-
// eslint-disable-next-line @typescript-eslint/no-explicit-any
220-
name as any,
221-
false,
222-
['deriveBits', 'deriveKey', 'deriveBits', 'deriveKey'],
223-
);
215+
const key = await subtle.importKey('raw', new Uint8Array(16), name, false, [
216+
'deriveBits',
217+
'deriveKey',
218+
'deriveBits',
219+
'deriveKey',
220+
]);
224221
expect(key.usages).to.deep.equal(['deriveKey', 'deriveBits']);
225222
});
226223
}
@@ -241,6 +238,39 @@ test(SUITE, 'importKey jwk AES-CBC dedupes', async () => {
241238
expect(key.usages).to.deep.equal(['encrypt', 'decrypt']);
242239
});
243240

241+
// --- key.usages immutability ----------------------------------------------
242+
243+
test(
244+
SUITE,
245+
'key.usages is frozen (push throws, length unchanged)',
246+
async () => {
247+
const key = (await subtle.generateKey(
248+
{ name: 'AES-GCM', length: 128 },
249+
true,
250+
['encrypt', 'decrypt'],
251+
)) as CryptoKey;
252+
expect(Object.isFrozen(key.usages)).to.equal(true);
253+
expect(() => key.usages.push('sign')).to.throw(TypeError);
254+
expect(key.usages).to.deep.equal(['encrypt', 'decrypt']);
255+
},
256+
);
257+
258+
test(
259+
SUITE,
260+
'jwk.key_ops is independent of key.usages (mutable copy)',
261+
async () => {
262+
const key = (await subtle.generateKey(
263+
{ name: 'AES-GCM', length: 128 },
264+
true,
265+
['encrypt', 'decrypt'],
266+
)) as CryptoKey;
267+
const jwk = (await subtle.exportKey('jwk', key)) as JWK;
268+
expect(jwk.key_ops).to.deep.equal(['encrypt', 'decrypt']);
269+
jwk.key_ops!.push('sign');
270+
expect(key.usages).to.deep.equal(['encrypt', 'decrypt']);
271+
},
272+
);
273+
244274
// --- KeyObject.toCryptoKey() ----------------------------------------------
245275

246276
test(SUITE, 'createSecretKey().toCryptoKey() HMAC dedupes', () => {

packages/react-native-quick-crypto/src/keys/classes.ts

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -30,7 +30,8 @@ export class CryptoKey {
3030
) {
3131
this.keyObject = keyObject;
3232
this.keyAlgorithm = keyAlgorithm;
33-
this.keyUsages = getSortedUsages(keyUsages);
33+
// Frozen so external code can't mutate `key.usages` (per WebCrypto spec).
34+
this.keyUsages = Object.freeze(getSortedUsages(keyUsages)) as KeyUsage[];
3435
this.keyExtractable = keyExtractable;
3536
}
3637
// eslint-disable-next-line @typescript-eslint/no-unused-vars

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

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1688,7 +1688,7 @@ const exportKeyRaw = (key: CryptoKey): ArrayBuffer | unknown => {
16881688
const exportKeyJWK = (key: CryptoKey): ArrayBuffer | unknown => {
16891689
const jwk = key.keyObject.handle.exportJwk(
16901690
{
1691-
key_ops: key.usages,
1691+
key_ops: [...key.usages],
16921692
ext: key.extractable,
16931693
},
16941694
true,

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

Lines changed: 4 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -57,6 +57,9 @@ export const validateMaxBufferLength = (
5757
}
5858
};
5959

60+
// Returns the intersection of `usageSet` and the spread `usages`, preserving
61+
// the spread order. Dedup and canonical ordering are not performed here —
62+
// the `CryptoKey` constructor runs `getSortedUsages` on every input.
6063
export const getUsagesUnion = (usageSet: KeyUsage[], ...usages: KeyUsage[]) => {
6164
const newset: KeyUsage[] = [];
6265
for (let n = 0; n < usages.length; n++) {
@@ -83,9 +86,8 @@ const kCanonicalUsageOrder: readonly KeyUsage[] = [
8386
];
8487

8588
export function getSortedUsages(usages: KeyUsage[]): KeyUsage[] {
86-
if (usages.length <= 1) return usages.slice();
8789
const set = new Set<KeyUsage>(usages);
88-
return kCanonicalUsageOrder.filter(u => set.has(u));
90+
return kCanonicalUsageOrder.filter(usage => set.has(usage));
8991
}
9092

9193
const kKeyOps: {

0 commit comments

Comments
 (0)