From 599fce5b02bb515bdab0c981749c6d48c7bdc350 Mon Sep 17 00:00:00 2001 From: Brad Anderson Date: Sun, 3 May 2026 22:29:37 -0400 Subject: [PATCH 1/2] fix: validate JWK alg/crv per algorithm in subtle.importKey Node validates JWK alg and crv fields against the algorithm name and hash at import time. RNQC silently accepted mismatched values, which allowed malformed keys to flow into crypto operations and broke interop with Node-produced JWKs. - RSA: validate jwk.alg against the per-context expected name derived from algorithm.hash (RS256, PS256, RSA-OAEP-256, ...) - Ed25519/Ed448: require crv === algorithm.name; if alg is present, accept only algorithm.name or 'EdDSA' - X25519/X448: require crv === algorithm.name (no alg check, per Node) Closes #1001 --- example/src/tests/subtle/import_export.ts | 146 ++++++++++++++++++ .../react-native-quick-crypto/src/subtle.ts | 33 ++++ 2 files changed, 179 insertions(+) diff --git a/example/src/tests/subtle/import_export.ts b/example/src/tests/subtle/import_export.ts index dc648bd1..09c1e0c7 100644 --- a/example/src/tests/subtle/import_export.ts +++ b/example/src/tests/subtle/import_export.ts @@ -2898,6 +2898,152 @@ for (const { name: curveName, rawSize } of edCurves) { }); } +// --- JWK validation: per-algorithm alg/crv/use mismatch (gh#1001) --- + +const RSA_JWK_ALG_CASES: { + name: RSAKeyPairAlgorithm; + hash: HashAlgorithm; + expected: string; + wrong: string; + usages: KeyUsage[]; +}[] = [ + { + name: 'RSASSA-PKCS1-v1_5', + hash: 'SHA-256', + expected: 'RS256', + wrong: 'RS384', + usages: ['verify'], + }, + { + name: 'RSA-PSS', + hash: 'SHA-256', + expected: 'PS256', + wrong: 'PS384', + usages: ['verify'], + }, + { + name: 'RSA-OAEP', + hash: 'SHA-256', + expected: 'RSA-OAEP-256', + wrong: 'RSA-OAEP-384', + usages: ['encrypt'], + }, +]; + +for (const { name, hash, wrong, usages } of RSA_JWK_ALG_CASES) { + test(SUITE, `${name}/${hash} importKey rejects wrong jwk alg`, async () => { + const keyPair = (await subtle.generateKey( + { + name, + modulusLength: 2048, + publicExponent: new Uint8Array([1, 0, 1]), + hash, + }, + true, + name === 'RSA-OAEP' ? ['encrypt', 'decrypt'] : ['sign', 'verify'], + )) as CryptoKeyPair; + const jwk = (await subtle.exportKey( + 'jwk', + keyPair.publicKey as CryptoKey, + )) as JWK; + await assertThrowsAsync( + async () => + await subtle.importKey( + 'jwk', + { ...jwk, alg: wrong }, + { name, hash }, + true, + usages, + ), + 'JWK "alg" does not match the requested algorithm', + ); + }); +} + +for (const { name } of edCurves) { + test(SUITE, `${name} importKey rejects wrong jwk crv`, async () => { + const keyPair = (await subtle.generateKey({ name }, true, [ + 'sign', + 'verify', + ])) as CryptoKeyPair; + const jwk = (await subtle.exportKey( + 'jwk', + keyPair.publicKey as CryptoKey, + )) as JWK; + const wrongCrv = name === 'Ed25519' ? 'Ed448' : 'Ed25519'; + await assertThrowsAsync( + async () => + await subtle.importKey( + 'jwk', + { ...jwk, crv: wrongCrv }, + { name }, + true, + ['verify'], + ), + 'JWK "crv" Parameter and algorithm name mismatch', + ); + }); + + test(SUITE, `${name} importKey rejects wrong jwk alg`, async () => { + const keyPair = (await subtle.generateKey({ name }, true, [ + 'sign', + 'verify', + ])) as CryptoKeyPair; + const jwk = (await subtle.exportKey( + 'jwk', + keyPair.publicKey as CryptoKey, + )) as JWK; + await assertThrowsAsync( + async () => + await subtle.importKey( + 'jwk', + { ...jwk, alg: 'RS256' }, + { name }, + true, + ['verify'], + ), + 'JWK "alg" does not match the requested algorithm', + ); + }); + + test(SUITE, `${name} importKey accepts jwk alg "EdDSA"`, async () => { + const keyPair = (await subtle.generateKey({ name }, true, [ + 'sign', + 'verify', + ])) as CryptoKeyPair; + const jwk = (await subtle.exportKey( + 'jwk', + keyPair.publicKey as CryptoKey, + )) as JWK; + const imported = await subtle.importKey( + 'jwk', + { ...jwk, alg: 'EdDSA' }, + { name }, + true, + ['verify'], + ); + expect(imported.algorithm.name).to.equal(name); + }); + + test(SUITE, `${name} importKey rejects jwk use="enc"`, async () => { + const keyPair = (await subtle.generateKey({ name }, true, [ + 'sign', + 'verify', + ])) as CryptoKeyPair; + const jwk = (await subtle.exportKey( + 'jwk', + keyPair.publicKey as CryptoKey, + )) as JWK; + await assertThrowsAsync( + async () => + await subtle.importKey('jwk', { ...jwk, use: 'enc' }, { name }, true, [ + 'verify', + ]), + 'Invalid JWK "use" Parameter', + ); + }); +} + // AES-OCB JWK export/import roundtrip test(SUITE, 'AES-OCB export/import jwk', async () => { const key = await subtle.generateKey({ name: 'AES-OCB', length: 256 }, true, [ diff --git a/packages/react-native-quick-crypto/src/subtle.ts b/packages/react-native-quick-crypto/src/subtle.ts index 7e0eade3..1381c9e1 100644 --- a/packages/react-native-quick-crypto/src/subtle.ts +++ b/packages/react-native-quick-crypto/src/subtle.ts @@ -957,6 +957,22 @@ function rsaImportKey( validateJwkStructure(jwk, extractable, keyUsages, expectedUse); checkUsages(); + if (jwk.alg !== undefined) { + const jwkContext = + name === 'RSASSA-PKCS1-v1_5' + ? HashContext.JwkRsa + : name === 'RSA-PSS' + ? HashContext.JwkRsaPss + : HashContext.JwkRsaOaep; + const expectedAlg = normalizeHashName(algorithm.hash, jwkContext); + if (jwk.alg !== expectedAlg) { + throw lazyDOMException( + 'JWK "alg" does not match the requested algorithm', + 'DataError', + ); + } + } + const handle = NitroModules.createHybridObject('KeyObjectHandle'); let keyType: KeyType | undefined; @@ -1248,6 +1264,23 @@ function edImportKey( } const expectedUse = isX ? 'enc' : 'sig'; validateJwkStructure(jwkData, extractable, keyUsages, expectedUse); + + if (jwkData.crv !== name) { + throw lazyDOMException( + 'JWK "crv" Parameter and algorithm name mismatch', + 'DataError', + ); + } + + if (!isX && jwkData.alg !== undefined) { + if (jwkData.alg !== name && jwkData.alg !== 'EdDSA') { + throw lazyDOMException( + 'JWK "alg" does not match the requested algorithm', + 'DataError', + ); + } + } + checkUsages(); const handle = NitroModules.createHybridObject('KeyObjectHandle'); From f808b1ecedb694a134d7c89dcccb5dd6e572df8c Mon Sep 17 00:00:00 2001 From: Brad Anderson Date: Sun, 3 May 2026 22:38:24 -0400 Subject: [PATCH 2/2] refactor: tighten JWK validation in subtle.importKey Add `kty === 'OKP'` check to edImportKey to reject non-OKP keyData inline, matching the pattern already used by rsaImportKey/ecImportKey and Node's `Invalid JWK "kty" Parameter` message. Refactor the nested ternary in rsaImportKey's alg check into a switch for readability. Add X25519/X448 `crv` mismatch tests to cover the now-ungated crv check, and replace the Ed `use="enc"` test (which only exercised pre-existing validateJwkStructure behavior) with a `kty: 'EC'` test that actually exercises the new check. --- example/src/tests/subtle/import_export.ts | 32 +++++++++++++++++-- .../react-native-quick-crypto/src/subtle.ts | 20 ++++++++---- 2 files changed, 43 insertions(+), 9 deletions(-) diff --git a/example/src/tests/subtle/import_export.ts b/example/src/tests/subtle/import_export.ts index 09c1e0c7..12c8fc4f 100644 --- a/example/src/tests/subtle/import_export.ts +++ b/example/src/tests/subtle/import_export.ts @@ -3025,7 +3025,7 @@ for (const { name } of edCurves) { expect(imported.algorithm.name).to.equal(name); }); - test(SUITE, `${name} importKey rejects jwk use="enc"`, async () => { + test(SUITE, `${name} importKey rejects wrong jwk kty`, async () => { const keyPair = (await subtle.generateKey({ name }, true, [ 'sign', 'verify', @@ -3036,10 +3036,36 @@ for (const { name } of edCurves) { )) as JWK; await assertThrowsAsync( async () => - await subtle.importKey('jwk', { ...jwk, use: 'enc' }, { name }, true, [ + await subtle.importKey('jwk', { ...jwk, kty: 'EC' }, { name }, true, [ 'verify', ]), - 'Invalid JWK "use" Parameter', + 'Invalid JWK "kty" Parameter', + ); + }); +} + +const xCurves = ['X25519', 'X448'] as const; +for (const name of xCurves) { + test(SUITE, `${name} importKey rejects wrong jwk crv`, async () => { + const keyPair = (await subtle.generateKey({ name }, true, [ + 'deriveKey', + 'deriveBits', + ])) as CryptoKeyPair; + const jwk = (await subtle.exportKey( + 'jwk', + keyPair.publicKey as CryptoKey, + )) as JWK; + const wrongCrv = name === 'X25519' ? 'X448' : 'X25519'; + await assertThrowsAsync( + async () => + await subtle.importKey( + 'jwk', + { ...jwk, crv: wrongCrv }, + { name }, + true, + [], + ), + 'JWK "crv" Parameter and algorithm name mismatch', ); }); } diff --git a/packages/react-native-quick-crypto/src/subtle.ts b/packages/react-native-quick-crypto/src/subtle.ts index 1381c9e1..2a4c08b7 100644 --- a/packages/react-native-quick-crypto/src/subtle.ts +++ b/packages/react-native-quick-crypto/src/subtle.ts @@ -958,12 +958,17 @@ function rsaImportKey( checkUsages(); if (jwk.alg !== undefined) { - const jwkContext = - name === 'RSASSA-PKCS1-v1_5' - ? HashContext.JwkRsa - : name === 'RSA-PSS' - ? HashContext.JwkRsaPss - : HashContext.JwkRsaOaep; + let jwkContext: HashContext; + switch (name) { + case 'RSASSA-PKCS1-v1_5': + jwkContext = HashContext.JwkRsa; + break; + case 'RSA-PSS': + jwkContext = HashContext.JwkRsaPss; + break; + default: + jwkContext = HashContext.JwkRsaOaep; + } const expectedAlg = normalizeHashName(algorithm.hash, jwkContext); if (jwk.alg !== expectedAlg) { throw lazyDOMException( @@ -1262,6 +1267,9 @@ function edImportKey( if (!jwkData || typeof jwkData !== 'object') { throw lazyDOMException('Invalid keyData', 'DataError'); } + if (jwkData.kty !== 'OKP') { + throw lazyDOMException('Invalid JWK "kty" Parameter', 'DataError'); + } const expectedUse = isX ? 'enc' : 'sig'; validateJwkStructure(jwkData, extractable, keyUsages, expectedUse);