From 09c4f1ecfc532246ae39ac40e8b3e576ae719d92 Mon Sep 17 00:00:00 2001 From: Nikos Polykandriotis Date: Tue, 26 Sep 2023 20:22:24 +0300 Subject: [PATCH] refactor(backend): Refactor hasValidSignature to also work with PEM keys Also extracted some functionality to scr/tokens/jwt/algorithms so it can be reused in the future and added some spec. --- packages/backend/README.md | 2 +- packages/backend/src/tokens/fixtures.ts | 33 ++++++++++ packages/backend/src/tokens/jwt/algorithms.ts | 65 +++++++++++++++++++ .../src/tokens/jwt/hasValidSignature.test.ts | 23 +++++++ .../src/tokens/jwt/hasValidSignature.ts | 38 ++--------- packages/backend/tests/suites.ts | 2 + 6 files changed, 129 insertions(+), 34 deletions(-) create mode 100644 packages/backend/src/tokens/jwt/algorithms.ts create mode 100644 packages/backend/src/tokens/jwt/hasValidSignature.test.ts diff --git a/packages/backend/README.md b/packages/backend/README.md index abcac112389..1a005726325 100644 --- a/packages/backend/README.md +++ b/packages/backend/README.md @@ -142,7 +142,7 @@ import { decodeJwt } from '@clerk/backend'; decodeJwt(token); ``` -#### hasValidSignature(jwt: Jwt, jwk: JsonWebKey) +#### hasValidSignature(jwt: Jwt, key: JsonWebKey | string) Verifies that the JWT has a valid signature. The key needs to be provided. diff --git a/packages/backend/src/tokens/fixtures.ts b/packages/backend/src/tokens/fixtures.ts index 37119e1c105..88443d0e584 100644 --- a/packages/backend/src/tokens/fixtures.ts +++ b/packages/backend/src/tokens/fixtures.ts @@ -59,3 +59,36 @@ export const mockPEMJwtKey = 'qpj5Cb+sxfFI+Vhf1GB1bNeOLPR10nkSMJ74HB0heHi/SsM83JiGekv0CpZPCC8j\n' + 'cQIDAQAB\n' + '-----END PUBLIC KEY-----'; + +export const pemEncodedPublicKey = `-----BEGIN PUBLIC KEY----- +MIIBIjANBgkqhkiG9w0BAQEFAAOCAQ8AMIIBCgKCAQEAqYy3MY8fOneEyzNDu9lp +6iGXKkNUF+u/dUnrlkadZYyB35efzKFJEr9fftmWv5PUj1uRHTQ3bh6X1cceOYsI +jy008dHWZJsKhGOxgdTjeK91rjaklxt7tyFXEiKHIOr1LSgKzopClOfCIjxK/oPU +Of38pVh7WnekcSBQmU5fqA+EzKMi6k9VwvbzqKlZM4XQsiFyn28d9VubJWjTU8nN +ot0n1NE+9k6TxM8nglM4RwkBH4Ni4B0LhKKOOV+AG8tBNiZVil415dpBldmJ/j0w +k7Ad4VFi9en3Z17oCKr+K+zuT7vKMKSb1548dk0vnmi0vj2QGXSo+61wM5yQWpk6 +sQIDAQAB +-----END PUBLIC KEY-----`; + +export const someOtherPublicKey = `-----BEGIN PUBLIC KEY----- +MIIBIjANBgkqhkiG9w0BAQEFAAOCAQ8AMIIBCgKCAQEA5wdNEDm/HUAO6xarLs6e +cS0/J8GencMs5I6rYS825knb8jsNbfoukYfBiK81vQy1/eK5gdWrEprpXQrmIcwG +akdeUhybYlK68UhHNA5+TAmZ+ReLTJ2QDk5YU4I1NlRRq/bqtEhWsBDOCCkpVsC4 +OLnUpsZKGUwpCrE/8stMSJ6Xx+TzBlDe21cV1j0gn5CWswrrXo7m8OIZ9xkRnNn4 +fTNypMSCbx6BS7fgmer6Efx9HOu9UIKgXD/29q3pEpFXiHRdQRbVoAc9vEZl0QIw +PSNjILVJLKvb6MhKoQMyaP5k0c1rEkVJr9jQk5Z/6WPklCNK3oT5+gh2lgi7ZxBd +oQIDAQAB +-----END PUBLIC KEY-----`; + +export const publicJwks = { + key_ops: ['verify'], + ext: true, + kty: 'RSA', + n: 'qYy3MY8fOneEyzNDu9lp6iGXKkNUF-u_dUnrlkadZYyB35efzKFJEr9fftmWv5PUj1uRHTQ3bh6X1cceOYsIjy008dHWZJsKhGOxgdTjeK91rjaklxt7tyFXEiKHIOr1LSgKzopClOfCIjxK_oPUOf38pVh7WnekcSBQmU5fqA-EzKMi6k9VwvbzqKlZM4XQsiFyn28d9VubJWjTU8nNot0n1NE-9k6TxM8nglM4RwkBH4Ni4B0LhKKOOV-AG8tBNiZVil415dpBldmJ_j0wk7Ad4VFi9en3Z17oCKr-K-zuT7vKMKSb1548dk0vnmi0vj2QGXSo-61wM5yQWpk6sQ', + e: 'AQAB', + alg: 'RS256', +}; + +// this jwt has be signe with the keys above. The payload is mockJwtPayload and the header is mockJwtHeader +export const signedJwt = + 'eyJhbGciOiJSUzI1NiIsImtpZCI6Imluc18yR0lvUWhiVXB5MGhYN0IyY1ZrdVRNaW5Yb0QiLCJ0eXAiOiJKV1QifQ.eyJhenAiOiJodHRwczovL2FjY291bnRzLmluc3BpcmVkLnB1bWEtNzQubGNsLmRldiIsImV4cCI6MTY2NjY0ODMxMCwiaWF0IjoxNjY2NjQ4MjUwLCJpc3MiOiJodHRwczovL2NsZXJrLmluc3BpcmVkLnB1bWEtNzQubGNsLmRldiIsIm5iZiI6MTY2NjY0ODI0MCwic2lkIjoic2Vzc18yR2JEQjRlbk5kQ2E1dlMxenBDM1h6Zzl0SzkiLCJzdWIiOiJ1c2VyXzJHSXBYT0VwVnlKdzUxcmtabjlLbW5jNlN4ciJ9.j3rB92k32WqbQDkFB093H4GoQsBVLH4HLGF6ObcwUaVGiHC8SEu6T31FuPf257SL8A5sSGtWWM1fqhQpdLohgZb_hbJswGBuYI-Clxl9BtpIRHbWFZkLBIj8yS9W9aVtD3fWBbF6PHx7BY1udio-rbGWg1YAOZNtVcxF02p-MvX-8XIK92Vwu3Un5zyfCoVIg__qo3Xntzw3tznsZ4XDe212c6kVz1R_L1d5DKjeWXpjUPAS_zFeZSIJEQLf4JNr4JCY38tfdnc3ajfDA3p36saf1XwmTdWXQKCXi75c2TJAXROs3Pgqr5Kw_5clygoFuxN5OEMhFWFSnvIBdi3M6w'; diff --git a/packages/backend/src/tokens/jwt/algorithms.ts b/packages/backend/src/tokens/jwt/algorithms.ts new file mode 100644 index 00000000000..b49bebbc581 --- /dev/null +++ b/packages/backend/src/tokens/jwt/algorithms.ts @@ -0,0 +1,65 @@ +import runtime from '../../runtime'; +import isomorphicAtob from '../../shared/isomorphicAtob'; + +const algToHash: Record = { + RS256: 'SHA-256', + RS384: 'SHA-384', + RS512: 'SHA-512', +}; +const RSA_ALGORITHM_NAME = 'RSASSA-PKCS1-v1_5'; + +const jwksAlgToCryptoAlg: Record = { + RS256: RSA_ALGORITHM_NAME, + RS384: RSA_ALGORITHM_NAME, + RS512: RSA_ALGORITHM_NAME, +}; + +const algs = Object.keys(algToHash); + +// https://developer.mozilla.org/en-US/docs/Web/API/SubtleCrypto/importKey#pkcs_8_import +function pemToBuffer(secret: string): ArrayBuffer { + const trimmed = secret + .replace(/-----BEGIN.*?-----/g, '') + .replace(/-----END.*?-----/g, '') + .replace(/\s/g, ''); + + const decoded = isomorphicAtob(trimmed); + + const buffer = new ArrayBuffer(decoded.length); + const bufView = new Uint8Array(buffer); + + for (let i = 0, strLen = decoded.length; i < strLen; i++) { + bufView[i] = decoded.charCodeAt(i); + } + + return bufView; +} + +export function getCryptoAlgorithm(algorithmName: string): RsaHashedImportParams { + const hash = algToHash[algorithmName]; + const name = jwksAlgToCryptoAlg[algorithmName]; + + if (!hash || !name) { + throw new Error(`Unsupported algorithm ${algorithmName}, expected one of ${algs.join(', ')}`); + } + + return { + hash: { name: algToHash[algorithmName] }, + name: jwksAlgToCryptoAlg[algorithmName], + }; +} + +export function importKey( + secret: JsonWebKey | string, + algorithm: RsaHashedImportParams, + keyUsage: 'verify' | 'sign', +): Promise { + if (typeof secret === 'object') { + return runtime.crypto.subtle.importKey('jwk', secret, algorithm, false, [keyUsage]); + } + + const keyData = pemToBuffer(secret); + const format = keyUsage === 'sign' ? 'pkcs8' : 'spki'; + + return runtime.crypto.subtle.importKey(format, keyData, algorithm, false, [keyUsage]); +} diff --git a/packages/backend/src/tokens/jwt/hasValidSignature.test.ts b/packages/backend/src/tokens/jwt/hasValidSignature.test.ts new file mode 100644 index 00000000000..b6ce2fa7e03 --- /dev/null +++ b/packages/backend/src/tokens/jwt/hasValidSignature.test.ts @@ -0,0 +1,23 @@ +import type QUnit from 'qunit'; + +import { pemEncodedPublicKey, publicJwks, signedJwt, someOtherPublicKey } from '../fixtures'; +import { hasValidSignature } from '../jwt/hasValidSignature'; +import { decodeJwt } from './verifyJwt'; + +export default (QUnit: QUnit) => { + const { module, test } = QUnit; + + module('hasValidSignature(jwt, key)', () => { + test('verifies the signature with a JWK formatted key', async assert => { + assert.true(await hasValidSignature(decodeJwt(signedJwt), publicJwks)); + }); + + test('verifies the signature with a PEM formatted key', async assert => { + assert.true(await hasValidSignature(decodeJwt(signedJwt), pemEncodedPublicKey)); + }); + + test('it returns false if the key is not correct', async assert => { + assert.false(await hasValidSignature(decodeJwt(signedJwt), someOtherPublicKey)); + }); + }); +}; diff --git a/packages/backend/src/tokens/jwt/hasValidSignature.ts b/packages/backend/src/tokens/jwt/hasValidSignature.ts index 1daac535656..7fb17e65fea 100644 --- a/packages/backend/src/tokens/jwt/hasValidSignature.ts +++ b/packages/backend/src/tokens/jwt/hasValidSignature.ts @@ -3,43 +3,15 @@ import type { Jwt } from '@clerk/types'; // DO NOT CHANGE: Runtime needs to be imported as a default export so that we can stub its dependencies with Sinon.js // For more information refer to https://sinonjs.org/how-to/stub-dependency/ import runtime from '../../runtime'; +import { getCryptoAlgorithm, importKey } from './algorithms'; -const algToHash: Record = { - RS256: 'SHA-256', - RS384: 'SHA-384', - RS512: 'SHA-512', - ES256: 'SHA-256', - ES384: 'SHA-384', - ES512: 'SHA-512', -}; - -const RSA_ALGORITHM_NAME = 'RSASSA-PKCS1-v1_5'; -const EC_ALGORITHM_NAME = 'ECDSA'; - -const jwksAlgToCryptoAlg: Record = { - RS256: RSA_ALGORITHM_NAME, - RS384: RSA_ALGORITHM_NAME, - RS512: RSA_ALGORITHM_NAME, - ES256: EC_ALGORITHM_NAME, - ES384: EC_ALGORITHM_NAME, - ES512: EC_ALGORITHM_NAME, -}; - -export async function hasValidSignature(jwt: Jwt, jwk: JsonWebKey) { +export async function hasValidSignature(jwt: Jwt, key: JsonWebKey | string) { const { header, signature, raw } = jwt; const encoder = new TextEncoder(); const data = encoder.encode([raw.header, raw.payload].join('.')); + const algorithm = getCryptoAlgorithm(header.alg); - const cryptoKey = await runtime.crypto.subtle.importKey( - 'jwk', - jwk, - { - name: jwksAlgToCryptoAlg[header.alg], - hash: algToHash[header.alg], - }, - false, - ['verify'], - ); + const cryptoKey = await importKey(key, algorithm, 'verify'); - return runtime.crypto.subtle.verify('RSASSA-PKCS1-v1_5', cryptoKey, signature, data); + return runtime.crypto.subtle.verify(algorithm.name, cryptoKey, signature, data); } diff --git a/packages/backend/tests/suites.ts b/packages/backend/tests/suites.ts index 776d490a34f..6d9091e8627 100644 --- a/packages/backend/tests/suites.ts +++ b/packages/backend/tests/suites.ts @@ -6,6 +6,7 @@ import keysTest from './dist/tokens/keys.test.js'; import pathTest from './dist/util/path.test.js'; import verifyTest from './dist/tokens/verify.test.js'; import verifyJwtTest from './dist/tokens/jwt/verifyJwt.test.js'; +import hasValidSignatureTest from './dist/tokens/jwt/hasValidSignature.test.js'; import interstitialRequestTest from './dist/tokens/interstitial.test.js'; import jwtAssertionsTest from './dist/tokens/jwt/assertions.test.js'; @@ -29,6 +30,7 @@ const suites = [ verifyTest, pathTest, verifyJwtTest, + hasValidSignatureTest, factoryTest, redirectTest, utilsTest,