Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Refactor RSA verification: Replace jsrsasign with crypto-js #268

Merged
merged 11 commits into from
Jan 30, 2020
3 changes: 2 additions & 1 deletion package.json
Original file line number Diff line number Diff line change
Expand Up @@ -48,7 +48,8 @@
},
"dependencies": {
"base-64": "^0.1.0",
"jsrsasign": "8.0.12",
"crypto-js": "^3.1.9-1",
lbalmaceda marked this conversation as resolved.
Show resolved Hide resolved
"jsbn": "^1.1.0",
"jwt-decode": "^2.2.0",
"url": "^0.11.0"
},
Expand Down
31 changes: 31 additions & 0 deletions src/jwt/__tests__/base64.spec.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,31 @@
import * as base64 from '../base64';

describe('helpers base64 url', function() {
describe('padding', function() {
it('does not add to multiple of 4', function() {
expect(base64.padding('')).toBe('');
expect(base64.padding('abcd')).toBe('abcd');
});
it('adds to non multiple of 4', function() {
expect(base64.padding('a')).toBe('a===');
expect(base64.padding('ab')).toBe('ab==');
expect(base64.padding('abc')).toBe('abc=');
expect(base64.padding('abced')).toBe('abced===');
});
it('does not change already padded value', function() {
const padded = base64.padding('abc');
expect(padded).toBe('abc=');
const again = base64.padding(padded);
expect(again).toBe('abc=');
});
});

describe('decoding to hex', function() {
it('should convert base64 input into hex output', function() {
expect(base64.decodeToHEX('AQAB')).toBe('010001');
expect(base64.decodeToHEX('uGbXWiK3dQTyCbX5')).toBe(
'b866d75a22b77504f209b5f9',
);
});
});
});
12 changes: 12 additions & 0 deletions src/jwt/__tests__/jwks.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,12 @@
{
"keys": [
{
"use": "sig",
"alg": "RS256",
"kty": "RSA",
"e": "AQAB",
"kid": "1234",
"n": "uGbXWiK3dQTyCbX5xdE4yCuYp0AF2d15Qq1JSXT_lx8CEcXb9RbDddl8jGDv-spi5qPa8qEHiK7FwV2KpRE983wGPnYsAm9BxLFb4YrLYcDFOIGULuk2FtrPS512Qea1bXASuvYXEpQNpGbnTGVsWXI9C-yjHztqyL2h8P6mlThPY9E9ue2fCqdgixfTFIF9Dm4SLHbphUS2iw7w1JgT69s7of9-I9l5lsJ9cozf1rxrXX4V1u_SotUuNB3Fp8oB4C1fLBEhSlMcUJirz1E8AziMCxS-VrRPDM-zfvpIJg3JljAh3PJHDiLu902v9w-Iplu1WyoB2aPfitxEhRN0Yw"
}
]
}
97 changes: 39 additions & 58 deletions src/jwt/__tests__/jwt.spec.js
Original file line number Diff line number Diff line change
@@ -1,7 +1,6 @@
import verifyToken from '../index';
import * as signatureVerifier from '../signatureVerifier';
const jwtDecoder = require('jwt-decode');
import {KEYUTIL} from 'jsrsasign';
import * as fs from 'fs';
import * as path from 'path';
import fetchMock from 'fetch-mock';
Expand All @@ -12,16 +11,6 @@ describe('id token verification tests', () => {
fetchMock.restore();
});

it('uses fixed version of jsrsasign', () => {
lbalmaceda marked this conversation as resolved.
Show resolved Hide resolved
// jsrsasign has not been updated recently; we want to verify that the dependency is pinned to 8.0.12
const packageData = fs.readFileSync(
path.resolve(__dirname, '../../../package.json'),
);
const packageJson = JSON.parse(packageData);
const jsrsasignDepVersion = packageJson.dependencies.jsrsasign;
expect(jsrsasignDepVersion).toBe('8.0.12');
});

it('resolves when no idToken present', async () => {
await expect(verify(undefined)).resolves.toBeUndefined();
});
Expand Down Expand Up @@ -70,7 +59,7 @@ describe('id token verification tests', () => {
const testJwt =
'eyJhbGciOiJSUzI1NiIsImtpZCI6IjEyMzQifQ.eyJpc3MiOiJodHRwczovL3Rva2Vucy10ZXN0LmF1dGgwLmNvbS8iLCJzdWIiOiJhdXRoMHwxMjM0NTY3ODkiLCJhdWQiOlsidG9rZW5zLXRlc3QtMTIzIiwiZXh0ZXJuYWwtdGVzdC0xMjMiXSwiZXhwIjoxNTcwMjAyOTMxLCJpYXQiOjE1NzAwMzAxMzEsIm5vbmNlIjoiYTU5dms1OTIiLCJhenAiOiJ0b2tlbnMtdGVzdC0xMjMiLCJhdXRoX3RpbWUiOjE1NzAxMTY1MzAuNzk2fQ.Xad-J3PtImY3z--Gvj-H61tH18mCGQUUBkcug-CB5ehkjd56PXrA-AJHZK7OLryB_uj6sFKVn-V8Wr6t3KW7_Fd2n-__Ca2h6PtgIrjceZlHAQY4SgAk9tPmeeTOhs6KyXDeW0Ot0j3CP9p7nWxgCGMu_H5J5ZgJSVUVlffVpaIMEGiFZ_r71PLPtuTL3GsDwtICG_5xuqoR2YBLSpNuuc46t15i94E3JC1UXGryRfxVbeHg3x5DF9nf6eVkMHRdi-CdNQn2iD0G9OmxxELh-40pecbyUxLv4NfTHmbxOdvWRK00N8sgkElnPnoWXb5pacxLShFsBTJdXIsyqF_onA';

const jwks = getJwks();
const jwks = getExpectedJwks();
jwks.keys[0].kid = '4321';

setupFetchMock({jwks});
Expand All @@ -86,14 +75,31 @@ describe('id token verification tests', () => {
);
});

it('fails when signature is not verified', async () => {
it('fails when public key is invalid and cannot be reconstructed', async () => {
const testJwt =
'eyJhbGciOiJSUzI1NiIsImtpZCI6IjEyMzQifQ.eyJpc3MiOiJodHRwczovL3Rva2Vucy10ZXN0LmF1dGgwLmNvbS8iLCJzdWIiOiJhdXRoMHwxMjM0NTY3ODkiLCJhdWQiOlsidG9rZW5zLXRlc3QtMTIzIiwiZXh0ZXJuYWwtdGVzdC0xMjMiXSwiZXhwIjoxNTcwMjAzMjgxLCJpYXQiOjE1NzAwMzA0ODEsIm5vbmNlIjoiYTU5dms1OTIiLCJhenAiOiJ0b2tlbnMtdGVzdC0xMjMiLCJhdXRoX3RpbWUiOjE1NzAxMTY4ODAuNjk0fQ.ZNPsQq_U8NGyi5WFNgvuT0QlxfGFS9w6YIHWiF4dnwz_Zf3mv3gh4wybDR8vaLCE8ONTXvT9V_rW6oqNHSvEwa0nvPy2Vi3gVAvSfusoiYhkuQG_6SuqbeOrNJ1cejGzqw_iv2s6yEyN3B9wp0TCuIKL5jLPttaRi6ouGCbYeReANecaLOVZstrO4GhlY0NwtT4j5Dn1tDYavWxi1DZBisxBvMEFA6N0aQa51gJm6RYtUjBTo50j1xG5b7TIF4edjjT85FYQgrwEzA7Ss3HpnrYXEEvHn4nCsc585T3GKQuF21Nli-qGgQ3MywPOOqqiCSvL254Cp88Gt3xDS1hnqg';
const jwks = getJwks();
jwks.keys[0].n += 'bad';
const jwks = getExpectedJwks();
jwks.keys[0].n = 'bad-modulus';

setupFetchMock({jwks});

const result = verify(testJwt);
expect(result).rejects.toHaveProperty(
'name',
'a0.idtoken.invalid_signature',
);
expect(result).rejects.toHaveProperty(
'message',
'Invalid ID token signature',
);
});

it('fails when signature is invalid', async () => {
const testJwt =
'eyJhbGciOiJSUzI1NiIsImtpZCI6IjEyMzQifQ.eyJpc3MiOiJodHRwczovL3Rva2Vucy10ZXN0LmF1dGgwLmNvbS8iLCJzdWIiOiJhdXRoMHwxMjM0NTY3ODkiLCJhdWQiOlsidG9rZW5zLXRlc3QtMTIzIiwiZXh0ZXJuYWwtdGVzdC0xMjMiXSwiZXhwIjoxNTcwMjAzMjgxLCJpYXQiOjE1NzAwMzA0ODEsIm5vbmNlIjoiYTU5dms1OTIiLCJhenAiOiJ0b2tlbnMtdGVzdC0xMjMiLCJhdXRoX3RpbWUiOjE1NzAxMTY4ODAuNjk0fQ.invalid-signature';

setupFetchMock();

await expect(verify(testJwt)).rejects.toHaveProperty(
'name',
'a0.idtoken.invalid_signature',
Expand All @@ -114,53 +120,11 @@ describe('id token verification tests', () => {
it('passes verification with valid token signed with RS256', async () => {
const testJwt =
'eyJhbGciOiJSUzI1NiIsImtpZCI6IjEyMzQifQ.eyJpc3MiOiJodHRwczovL3Rva2Vucy10ZXN0LmF1dGgwLmNvbS8iLCJzdWIiOiJhdXRoMHwxMjM0NTY3ODkiLCJhdWQiOlsidG9rZW5zLXRlc3QtMTIzIiwiZXh0ZXJuYWwtdGVzdC0xMjMiXSwiZXhwIjoxNTY3NDg2ODAwLCJpYXQiOjE1NjczMTQwMDAsIm5vbmNlIjoiYTU5dms1OTIiLCJhenAiOiJ0b2tlbnMtdGVzdC0xMjMiLCJhdXRoX3RpbWUiOjE1NjczMTQwMDB9.ObH7oG3NsGaxWnB8rzbLOgAD2I0fr9dyZC81YUrbju3RwC3lRAxqJkbesiSdGKry9OamIhKYwUGpPK0wrBaRJo8UjDjICkhM6lGP23plysemxhDnFK1qjj-NaUaW1yKg14v2lVpQl7glW9LIhFDhpqIf4bILA2wt9-z8Uvi31ETZvGb8PDY2bEvjXR-69-yLuoTNT2skP9loKfz6hHDMQCTWrGA61BMMjkZBLo9UotD9BzN8V7bLrFFT25v6q9N83mWaGLsHntzPIl3EYPOwX0NbE0lXKar59TUqtaTB3uNFHbGjIYi8wuuIp4PV9arpE3YrjWOOmrMurD1KpIyQrQ';
const contents = fs.readFileSync(
path.resolve(__dirname, './pubkey.pem'),
'utf8',
);

const pubKey = KEYUTIL.getKey(contents);
const jwkFromKey = KEYUTIL.getJWKFromKey(pubKey);

jwkFromKey.kid = '1234';
jwkFromKey.alg = 'RS256';
jwkFromKey.use = 'sig';

const jwks = {
keys: [jwkFromKey],
};

setupFetchMock({jwks});
setupFetchMock();

await expect(verify(testJwt)).resolves.toBeUndefined();
});

const setupFetchMock = ({
domain = BASE_EXPECTATIONS.domain,
jwks = getJwks(),
} = {}) => {
const expectedDiscoveryUri = `https://${domain}/.well-known/openid-configuration`;
const expectedJwksUri = `https://${domain}/.well-known/jwks.json`;

fetchMock.get(expectedDiscoveryUri, {jwks_uri: expectedJwksUri});
fetchMock.get(expectedJwksUri, jwks);
};

const getJwks = () => {
return {
keys: [
{
kty: 'RSA',
n:
'st69ml_DI8MhepFSV9o8zjzRFiEst1_1-XJe0ib-g_aMauGTFOqeITdVqWTJMzZsjtwsPFD1CXbmEtI282GBbniJ7XkrZwpjzXangbvJpFE-aBmKeogTq6B94a19H9umCtV7eC55xDmOylXYPFdcVFvolWajdYGywqH8d4Cu_pIB25ELoA78goP4MqweJhnOt4r5jORea2paLXa04ojvglbOGnFec65Y4Hyw2mWGu06f0sxW-LMGzwP_SgbpRDKKnn-W8grguPq63sLexDTBFLyPNCcFQ8wnEQzLCaNNJItu-OFwgwgJhiB3d0et5m3lF2_lEJ2Pwndp0ORlOWcJbQbad',
e: 'AQAB',
kid: '1234',
alg: 'RS256',
use: 'sig',
},
],
};
};
});

describe('token claims verification', () => {
Expand Down Expand Up @@ -639,4 +603,21 @@ describe('id token verification tests', () => {
const options = Object.assign({}, optionsDefaults, optionsOverrides);
return verifyToken(idToken, options);
};

const setupFetchMock = ({
domain = BASE_EXPECTATIONS.domain,
jwks = getExpectedJwks(),
} = {}) => {
const expectedDiscoveryUri = `https://${domain}/.well-known/openid-configuration`;
const expectedJwksUri = `https://${domain}/.well-known/jwks.json`;

fetchMock.get(expectedDiscoveryUri, {jwks_uri: expectedJwksUri});
fetchMock.get(expectedJwksUri, jwks);
};

const getExpectedJwks = () => {
return JSON.parse(
fs.readFileSync(path.resolve(__dirname, './jwks.json'), 'utf8'),
);
};
});
32 changes: 32 additions & 0 deletions src/jwt/base64.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,32 @@
/**
* Borrowed from IDToken-verifier package
* https://github.com/auth0/idtoken-verifier/blob/master/src/helpers/base64.js
*/
import base64 from 'base64-js';

export function padding(str) {
const paddingLength = 4;
const mod = str.length % paddingLength;
const pad = paddingLength - mod;

lbalmaceda marked this conversation as resolved.
Show resolved Hide resolved
if (mod === 0) {
return str;
}

return str + new Array(1 + pad).join('=');
}

function byteArrayToHex(raw) {
let HEX = '';

for (let i = 0; i < raw.length; i++) {
const _hex = raw[i].toString(16);
HEX += _hex.length === 2 ? _hex : '0' + _hex;
}

return HEX;
}

export function decodeToHEX(str) {
return byteArrayToHex(base64.toByteArray(padding(str)));
}
72 changes: 72 additions & 0 deletions src/jwt/rsa-verifier.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,72 @@
/*
Based on the work of Tom Wu
http://www-cs-students.stanford.edu/~tjw/jsbn/
http://www-cs-students.stanford.edu/~tjw/jsbn/LICENSE
*/

import {BigInteger} from 'jsbn';
import SHA256 from 'crypto-js/sha256';

const digestInfoHead = {
sha256: '3031300d060960864801650304020105000420',
};

const digestAlgs = {
sha256: SHA256,
lbalmaceda marked this conversation as resolved.
Show resolved Hide resolved
};

function RSAVerifier(modulus, exp) {
this.n = null;
this.e = 0;

if (modulus && modulus.length > 0 && exp && exp.length > 0) {
this.n = new BigInteger(modulus, 16);
this.e = parseInt(exp, 16);
} else {
throw new Error('Invalid key data');
Widcket marked this conversation as resolved.
Show resolved Hide resolved
}
}

function getAlgorithmFromDigest(hDigestInfo) {
for (let algName in digestInfoHead) {
const head = digestInfoHead[algName];
const len = head.length;

if (hDigestInfo.substring(0, len) === head) {
return {
alg: algName,
hash: hDigestInfo.substring(len),
};
}
}
return [];
}

RSAVerifier.prototype.verify = function(msg, encodedSignature) {
const decodedSignature = encodedSignature.replace(/[^0-9a-f]|[\s\n]]/gi, '');

const signature = new BigInteger(decodedSignature, 16);
if (signature.bitLength() > this.n.bitLength()) {
//Signature does not match with the key modulus.
return false;
}

const decryptedSignature = signature.modPowInt(this.e, this.n);
const digest = decryptedSignature.toString(16).replace(/^1f+00/, '');

const digestInfo = getAlgorithmFromDigest(digest);
if (digestInfo.length === 0) {
//Hashing algorithm is not found
return false;
}

if (!digestAlgs.hasOwnProperty(digestInfo.alg)) {
//Hashing algorithm is not supported
return false;
}

const msgHash = digestAlgs[digestInfo.alg](msg).toString();
return digestInfo.hash === msgHash;
};

export default RSAVerifier;
21 changes: 14 additions & 7 deletions src/jwt/signatureVerifier.js
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
import AuthError from '../auth/authError';
import {KEYUTIL, KJUR} from 'jsrsasign';
import RSAVerifier from './rsa-verifier';
import * as base64 from './base64';
const jwtDecoder = require('jwt-decode');

const ALLOWED_ALGORITHMS = ['RS256', 'HS256'];
Expand Down Expand Up @@ -45,22 +46,28 @@ export const verifySignature = (idToken, options) => {
}

return getJwk(options.domain, header.kid).then(jwk => {
const pubKey = KEYUTIL.getKey(jwk);
const signatureValid = KJUR.jws.JWS.verify(idToken, pubKey, ['RS256']);

if (signatureValid) {
const rsaVerifier = rsaVerifierForKey(jwk);
const encodedParts = idToken.split('.');
const headerAndPayload = encodedParts[0] + '.' + encodedParts[1];
const signature = base64.decodeToHEX(encodedParts[2]);
if (rsaVerifier.verify(headerAndPayload, signature)) {
return Promise.resolve(payload);
}

return Promise.reject(
idTokenError({
error: 'invalid_signature',
desc: 'Invalid token signature',
desc: 'Invalid ID token signature',
}),
);
});
};

const rsaVerifierForKey = jwk => {
const modulus = base64.decodeToHEX(jwk.n);
const exponent = base64.decodeToHEX(jwk.e);
return new RSAVerifier(modulus, exponent);
};

const getJwk = (domain, kid) => {
return getJwksUri(domain)
.then(uri => fetchJson(uri))
Expand Down
15 changes: 10 additions & 5 deletions yarn.lock
Original file line number Diff line number Diff line change
Expand Up @@ -1952,6 +1952,11 @@ cross-spawn@^6.0.0:
shebang-command "^1.2.0"
which "^1.2.9"

crypto-js@^3.1.9-1:
version "3.1.9-1"
resolved "https://registry.yarnpkg.com/crypto-js/-/crypto-js-3.1.9-1.tgz#fda19e761fc077e01ffbfdc6e9fdfc59e8806cd8"
integrity sha1-/aGedh/Ad+Af+/3G6f38WeiAbNg=

cssom@0.3.x, "cssom@>= 0.3.2 < 0.4.0":
version "0.3.8"
resolved "https://registry.yarnpkg.com/cssom/-/cssom-0.3.8.tgz#9f1276f5b2b463f2114d3f2c75250af8c1a36f4a"
Expand Down Expand Up @@ -3621,6 +3626,11 @@ js2xmlparser@^4.0.0:
dependencies:
xmlcreate "^2.0.0"

jsbn@^1.1.0:
version "1.1.0"
resolved "https://registry.yarnpkg.com/jsbn/-/jsbn-1.1.0.tgz#b01307cb29b618a1ed26ec79e911f803c4da0040"
integrity sha1-sBMHyym2GKHtJux56RH4A8TaAEA=

jsbn@~0.1.0:
version "0.1.1"
resolved "https://registry.yarnpkg.com/jsbn/-/jsbn-0.1.1.tgz#a5e654c2e5a2deb5f201d96cefbca80c0ef2f513"
Expand Down Expand Up @@ -3796,11 +3806,6 @@ jsprim@^1.2.2:
json-schema "0.2.3"
verror "1.10.0"

jsrsasign@8.0.12:
version "8.0.12"
resolved "https://registry.yarnpkg.com/jsrsasign/-/jsrsasign-8.0.12.tgz#22abb9656d34a30b9530436720835e89c2e5c316"
integrity sha1-Iqu5ZW00owuVMENnIINeicLlwxY=

jwt-decode@^2.2.0:
version "2.2.0"
resolved "https://registry.yarnpkg.com/jwt-decode/-/jwt-decode-2.2.0.tgz#7d86bd56679f58ce6a84704a657dd392bba81a79"
Expand Down