From ecdf6cc6073ea13a7e71df5fad043550f08d0fa6 Mon Sep 17 00:00:00 2001 From: david-renaud-okta <97122670+david-renaud-okta@users.noreply.github.com> Date: Tue, 29 Nov 2022 11:58:59 -0500 Subject: [PATCH] fix!: Prevent accidental use of insecure key sizes & misconfiguration of secrets (#852) * fix!: Disable use of weak RSA key sizes for asymmetric algorithms Added checks to prevent invalid secrets from being used with the HS*** algorithms when signing and verifying Added checks to prevent the use of insecure asymmetric key sizes except when explicitly overriden via options Prevented Buffers containing malicious objects from being used as key material. BREAKING CHANGE: Requires node 12.x or later to allow use of `KeyObject` --- .circleci/config.yml | 48 +++++-------------- README.md | 47 +++++++++++-------- package.json | 14 ++---- sign.js | 81 ++++++++++++++++++++++---------- test/async_sign.tests.js | 16 +++++++ test/jwt.hs.tests.js | 25 +++++++++- test/jwt.malicious.tests.js | 39 ++++++++++++++++ test/rsa-public-key.tests.js | 22 ++++++++- test/schema.tests.js | 22 ++++----- verify.js | 91 +++++++++++++++++++++++------------- 10 files changed, 267 insertions(+), 138 deletions(-) create mode 100644 test/jwt.malicious.tests.js diff --git a/.circleci/config.yml b/.circleci/config.yml index 04670d7..51f2d61 100644 --- a/.circleci/config.yml +++ b/.circleci/config.yml @@ -17,55 +17,31 @@ commands: command: npm test jobs: - node-v4: + node-v12: docker: - - image: node:4 + - image: node:12 steps: - test-nodejs - node-v5: + node-v14: docker: - - image: node:5 + - image: node:14 steps: - test-nodejs - node-v6: + node-v16: docker: - - image: node:6 + - image: node:16 steps: - test-nodejs - node-v7: + node-v18: docker: - - image: node:7 - steps: - - test-nodejs - node-v8: - docker: - - image: node:8 - steps: - - test-nodejs - node-v9: - docker: - - image: node:9 - steps: - - test-nodejs - node-v10: - docker: - - image: node:10 - steps: - - test-nodejs - node-v11: - docker: - - image: node:11 + - image: node:18 steps: - test-nodejs workflows: node-multi-build: jobs: - - node-v4 - - node-v5 - - node-v6 - - node-v7 - - node-v8 - - node-v9 - - node-v10 - - node-v11 \ No newline at end of file + - node-v12 + - node-v14 + - node-v16 + - node-v18 diff --git a/README.md b/README.md index 834777e..0510907 100644 --- a/README.md +++ b/README.md @@ -1,7 +1,7 @@ # jsonwebtoken -| **Build** | **Dependency** | -|-----------|---------------| +| **Build** | **Dependency** | +|-----------------------------------------------------------------------------------------------------------------------------------------|------------------------------------------------------------------------------------------------------------------------| | [![Build Status](https://secure.travis-ci.org/auth0/node-jsonwebtoken.svg?branch=master)](http://travis-ci.org/auth0/node-jsonwebtoken) | [![Dependency Status](https://david-dm.org/auth0/node-jsonwebtoken.svg)](https://david-dm.org/auth0/node-jsonwebtoken) | @@ -32,8 +32,9 @@ $ npm install jsonwebtoken > If `payload` is not a buffer or a string, it will be coerced into a string using `JSON.stringify`. -`secretOrPrivateKey` is a string, buffer, or object containing either the secret for HMAC algorithms or the PEM +`secretOrPrivateKey` is a string (utf-8 encoded), buffer, object, or KeyObject containing either the secret for HMAC algorithms or the PEM encoded private key for RSA and ECDSA. In case of a private key with passphrase an object `{ key, passphrase }` can be used (based on [crypto documentation](https://nodejs.org/api/crypto.html#crypto_sign_sign_private_key_output_format)), in this case be sure you pass the `algorithm` option. +When signing with RSA algorithms the minimum modulus length is 2048 except when the allowInsecureKeySizes option is set to true. Private keys below this size will be rejected with an error. `options`: @@ -50,6 +51,7 @@ encoded private key for RSA and ECDSA. In case of a private key with passphrase * `header` * `keyid` * `mutatePayload`: if true, the sign function will modify the payload object directly. This is useful if you need a raw reference to the payload after claims have been applied to it but before it has been encoded into a token. +* `allowInsecureKeySizes`: if true allows private keys with a modulus below 2048 to be used for RSA @@ -129,7 +131,7 @@ jwt.sign({ `token` is the JsonWebToken string -`secretOrPublicKey` is a string or buffer containing either the secret for HMAC algorithms, or the PEM +`secretOrPublicKey` is a string (utf-8 encoded), buffer, or KeyObject containing either the secret for HMAC algorithms, or the PEM encoded public key for RSA and ECDSA. If `jwt.verify` is called asynchronous, `secretOrPublicKey` can be a function that should fetch the secret or public key. See below for a detailed example @@ -137,7 +139,12 @@ As mentioned in [this comment](https://github.com/auth0/node-jsonwebtoken/issues `options` -* `algorithms`: List of strings with the names of the allowed algorithms. For instance, `["HS256", "HS384"]`. +* `algorithms`: List of strings with the names of the allowed algorithms. For instance, `["HS256", "HS384"]`. + > If not specified a defaults will be used based on the type of key provided + > * secret - ['HS256', 'HS384', 'HS512'] + > * rsa - ['RS256', 'RS384', 'RS512'] + > * ec - ['ES256', 'ES384', 'ES512'] + > * default - ['RS256', 'RS384', 'RS512'] * `audience`: if you want to check audience (`aud`), provide a value here. The audience can be checked against a string, a regular expression or a list of strings and/or regular expressions. > Eg: `"urn:foo"`, `/urn:f[o]{2}/`, `[/urn:f[o]{2}/, "urn:bar"]` * `complete`: return an object with the decoded `{ payload, header, signature }` instead of only the usual content of the payload. @@ -347,21 +354,21 @@ jwt.verify(token, 'shhhhh', function(err, decoded) { Array of supported algorithms. The following algorithms are currently supported. -alg Parameter Value | Digital Signature or MAC Algorithm -----------------|---------------------------- -HS256 | HMAC using SHA-256 hash algorithm -HS384 | HMAC using SHA-384 hash algorithm -HS512 | HMAC using SHA-512 hash algorithm -RS256 | RSASSA-PKCS1-v1_5 using SHA-256 hash algorithm -RS384 | RSASSA-PKCS1-v1_5 using SHA-384 hash algorithm -RS512 | RSASSA-PKCS1-v1_5 using SHA-512 hash algorithm -PS256 | RSASSA-PSS using SHA-256 hash algorithm (only node ^6.12.0 OR >=8.0.0) -PS384 | RSASSA-PSS using SHA-384 hash algorithm (only node ^6.12.0 OR >=8.0.0) -PS512 | RSASSA-PSS using SHA-512 hash algorithm (only node ^6.12.0 OR >=8.0.0) -ES256 | ECDSA using P-256 curve and SHA-256 hash algorithm -ES384 | ECDSA using P-384 curve and SHA-384 hash algorithm -ES512 | ECDSA using P-521 curve and SHA-512 hash algorithm -none | No digital signature or MAC value included +| alg Parameter Value | Digital Signature or MAC Algorithm | +|---------------------|------------------------------------------------------------------------| +| HS256 | HMAC using SHA-256 hash algorithm | +| HS384 | HMAC using SHA-384 hash algorithm | +| HS512 | HMAC using SHA-512 hash algorithm | +| RS256 | RSASSA-PKCS1-v1_5 using SHA-256 hash algorithm | +| RS384 | RSASSA-PKCS1-v1_5 using SHA-384 hash algorithm | +| RS512 | RSASSA-PKCS1-v1_5 using SHA-512 hash algorithm | +| PS256 | RSASSA-PSS using SHA-256 hash algorithm (only node ^6.12.0 OR >=8.0.0) | +| PS384 | RSASSA-PSS using SHA-384 hash algorithm (only node ^6.12.0 OR >=8.0.0) | +| PS512 | RSASSA-PSS using SHA-512 hash algorithm (only node ^6.12.0 OR >=8.0.0) | +| ES256 | ECDSA using P-256 curve and SHA-256 hash algorithm | +| ES384 | ECDSA using P-384 curve and SHA-384 hash algorithm | +| ES512 | ECDSA using P-521 curve and SHA-512 hash algorithm | +| none | No digital signature or MAC value included | ## Refreshing JWTs diff --git a/package.json b/package.json index 81d63da..8e4345c 100644 --- a/package.json +++ b/package.json @@ -37,15 +37,9 @@ }, "dependencies": { "jws": "^3.2.2", - "lodash.includes": "^4.3.0", - "lodash.isboolean": "^3.0.3", - "lodash.isinteger": "^4.0.4", - "lodash.isnumber": "^3.0.3", - "lodash.isplainobject": "^4.0.6", - "lodash.isstring": "^4.0.1", - "lodash.once": "^4.0.0", + "lodash": "^4.17.21", "ms": "^2.1.1", - "semver": "^5.6.0" + "semver": "^7.3.8" }, "devDependencies": { "atob": "^2.1.2", @@ -59,8 +53,8 @@ "sinon": "^6.0.0" }, "engines": { - "npm": ">=1.4.28", - "node": ">=4" + "npm": ">=6", + "node": ">=12" }, "files": [ "lib", diff --git a/sign.js b/sign.js index f8a2877..3da5119 100644 --- a/sign.js +++ b/sign.js @@ -1,20 +1,15 @@ -var timespan = require('./lib/timespan'); -var PS_SUPPORTED = require('./lib/psSupported'); -var jws = require('jws'); -var includes = require('lodash.includes'); -var isBoolean = require('lodash.isboolean'); -var isInteger = require('lodash.isinteger'); -var isNumber = require('lodash.isnumber'); -var isPlainObject = require('lodash.isplainobject'); -var isString = require('lodash.isstring'); -var once = require('lodash.once'); - -var SUPPORTED_ALGS = ['RS256', 'RS384', 'RS512', 'ES256', 'ES384', 'ES512', 'HS256', 'HS384', 'HS512', 'none']; +const timespan = require('./lib/timespan'); +const PS_SUPPORTED = require('./lib/psSupported'); +const jws = require('jws'); +const {includes, isBoolean, isInteger, isNumber, isPlainObject, isString, once} = require('lodash') +const { KeyObject, createSecretKey, createPrivateKey } = require('crypto') + +const SUPPORTED_ALGS = ['RS256', 'RS384', 'RS512', 'ES256', 'ES384', 'ES512', 'HS256', 'HS384', 'HS512', 'none']; if (PS_SUPPORTED) { SUPPORTED_ALGS.splice(3, 0, 'PS256', 'PS384', 'PS512'); } -var sign_options_schema = { +const sign_options_schema = { expiresIn: { isValid: function(value) { return isInteger(value) || (isString(value) && value); }, message: '"expiresIn" should be a number of seconds or string representing a timespan' }, notBefore: { isValid: function(value) { return isInteger(value) || (isString(value) && value); }, message: '"notBefore" should be a number of seconds or string representing a timespan' }, audience: { isValid: function(value) { return isString(value) || Array.isArray(value); }, message: '"audience" must be a string or array' }, @@ -26,10 +21,11 @@ var sign_options_schema = { jwtid: { isValid: isString, message: '"jwtid" must be a string' }, noTimestamp: { isValid: isBoolean, message: '"noTimestamp" must be a boolean' }, keyid: { isValid: isString, message: '"keyid" must be a string' }, - mutatePayload: { isValid: isBoolean, message: '"mutatePayload" must be a boolean' } + mutatePayload: { isValid: isBoolean, message: '"mutatePayload" must be a boolean' }, + allowInsecureKeySizes: { isValid: isBoolean, message: '"allowInsecureKeySizes" must be a boolean'} }; -var registered_claims_schema = { +const registered_claims_schema = { iat: { isValid: isNumber, message: '"iat" should be a number of seconds' }, exp: { isValid: isNumber, message: '"exp" should be a number of seconds' }, nbf: { isValid: isNumber, message: '"nbf" should be a number of seconds' } @@ -41,7 +37,7 @@ function validate(schema, allowUnknown, object, parameterName) { } Object.keys(object) .forEach(function(key) { - var validator = schema[key]; + const validator = schema[key]; if (!validator) { if (!allowUnknown) { throw new Error('"' + key + '" is not allowed in "' + parameterName + '"'); @@ -62,14 +58,14 @@ function validatePayload(payload) { return validate(registered_claims_schema, true, payload, 'payload'); } -var options_to_payload = { +const options_to_payload = { 'audience': 'aud', 'issuer': 'iss', 'subject': 'sub', 'jwtid': 'jti' }; -var options_for_objects = [ +const options_for_objects = [ 'expiresIn', 'notBefore', 'noTimestamp', @@ -87,10 +83,10 @@ module.exports = function (payload, secretOrPrivateKey, options, callback) { options = options || {}; } - var isObjectPayload = typeof payload === 'object' && + const isObjectPayload = typeof payload === 'object' && !Buffer.isBuffer(payload); - var header = Object.assign({ + const header = Object.assign({ alg: options.algorithm || 'HS256', typ: isObjectPayload ? 'JWT' : undefined, kid: options.keyid @@ -107,6 +103,32 @@ module.exports = function (payload, secretOrPrivateKey, options, callback) { return failure(new Error('secretOrPrivateKey must have a value')); } + if (secretOrPrivateKey != null && !(secretOrPrivateKey instanceof KeyObject)) { + try { + secretOrPrivateKey = createPrivateKey(secretOrPrivateKey) + } catch (_) { + try { + secretOrPrivateKey = createSecretKey(typeof secretOrPrivateKey === 'string' ? Buffer.from(secretOrPrivateKey) : secretOrPrivateKey) + } catch (_) { + return failure(new Error('secretOrPrivateKey is not valid key material')); + } + } + } + + if (header.alg.startsWith('HS') && secretOrPrivateKey.type !== 'secret') { + return failure(new Error((`secretOrPrivateKey must be a symmetric key when using ${header.alg}`))) + } else if (/^(?:RS|PS|ES)/.test(header.alg)) { + if (secretOrPrivateKey.type !== 'private') { + return failure(new Error((`secretOrPrivateKey must be an asymmetric key when using ${header.alg}`))) + } + if (!options.allowInsecureKeySizes && + !header.alg.startsWith('ES') && + secretOrPrivateKey.asymmetricKeyDetails !== undefined && //KeyObject.asymmetricKeyDetails is supported in Node 15+ + secretOrPrivateKey.asymmetricKeyDetails.modulusLength < 2048) { + return failure(new Error(`secretOrPrivateKey has a minimum key size of 2048 bits for ${header.alg}`)); + } + } + if (typeof payload === 'undefined') { return failure(new Error('payload is required')); } else if (isObjectPayload) { @@ -120,7 +142,7 @@ module.exports = function (payload, secretOrPrivateKey, options, callback) { payload = Object.assign({},payload); } } else { - var invalid_options = options_for_objects.filter(function (opt) { + const invalid_options = options_for_objects.filter(function (opt) { return typeof options[opt] !== 'undefined'; }); @@ -144,7 +166,7 @@ module.exports = function (payload, secretOrPrivateKey, options, callback) { return failure(error); } - var timestamp = payload.iat || Math.floor(Date.now() / 1000); + const timestamp = payload.iat || Math.floor(Date.now() / 1000); if (options.noTimestamp) { delete payload.iat; @@ -177,7 +199,7 @@ module.exports = function (payload, secretOrPrivateKey, options, callback) { } Object.keys(options_to_payload).forEach(function (key) { - var claim = options_to_payload[key]; + const claim = options_to_payload[key]; if (typeof options[key] !== 'undefined') { if (typeof payload[claim] !== 'undefined') { return failure(new Error('Bad "options.' + key + '" option. The payload already has an "' + claim + '" property.')); @@ -186,7 +208,7 @@ module.exports = function (payload, secretOrPrivateKey, options, callback) { } }); - var encoding = options.encoding || 'utf8'; + const encoding = options.encoding || 'utf8'; if (typeof callback === 'function') { callback = callback && once(callback); @@ -198,9 +220,18 @@ module.exports = function (payload, secretOrPrivateKey, options, callback) { encoding: encoding }).once('error', callback) .once('done', function (signature) { + // TODO: Remove in favor of the modulus length check before signing once node 15+ is the minimum supported version + if(!options.allowInsecureKeySizes && /^(?:RS|PS)/.test(header.alg) && signature.length < 256) { + return callback(new Error(`secretOrPrivateKey has a minimum key size of 2048 bits for ${header.alg}`)) + } callback(null, signature); }); } else { - return jws.sign({header: header, payload: payload, secret: secretOrPrivateKey, encoding: encoding}); + let signature = jws.sign({header: header, payload: payload, secret: secretOrPrivateKey, encoding: encoding}); + // TODO: Remove in favor of the modulus length check before signing once node 15+ is the minimum supported version + if(!options.allowInsecureKeySizes && /^(?:RS|PS)/.test(header.alg) && signature.length < 256) { + throw new Error(`secretOrPrivateKey has a minimum key size of 2048 bits for ${header.alg}`) + } + return signature } }; diff --git a/test/async_sign.tests.js b/test/async_sign.tests.js index 6eb7dc7..eb31174 100644 --- a/test/async_sign.tests.js +++ b/test/async_sign.tests.js @@ -2,6 +2,7 @@ var jwt = require('../index'); var expect = require('chai').expect; var jws = require('jws'); var PS_SUPPORTED = require('../lib/psSupported'); +const {generateKeyPairSync} = require("crypto"); describe('signing a token asynchronously', function() { @@ -59,6 +60,21 @@ describe('signing a token asynchronously', function() { }); }); + it('should not work for RS algorithms when modulus length is less than 2048 when allowInsecureKeySizes is false or not set', function(done) { + const { privateKey } = generateKeyPairSync('rsa', { modulusLength: 1024 }); + + jwt.sign({ foo: 'bar' }, privateKey, { algorithm: 'RS256' }, function (err) { + expect(err).to.be.ok; + done(); + }); + }); + + it('should work for RS algorithms when modulus length is less than 2048 when allowInsecureKeySizes is true', function(done) { + const { privateKey } = generateKeyPairSync('rsa', { modulusLength: 1024 }); + + jwt.sign({ foo: 'bar' }, privateKey, { algorithm: 'RS256', allowInsecureKeySizes: true }, done); + }); + if (PS_SUPPORTED) { it('should return error when secret is not a cert for PS256', function(done) { //this throw an error because the secret is not a cert and PS256 requires a cert. diff --git a/test/jwt.hs.tests.js b/test/jwt.hs.tests.js index a7741ff..1f5ec2f 100644 --- a/test/jwt.hs.tests.js +++ b/test/jwt.hs.tests.js @@ -3,10 +3,33 @@ const jwt = require('../index'); const jws = require('jws'); const expect = require('chai').expect; const assert = require('chai').assert; +const { generateKeyPairSync } = require('crypto') describe('HS256', function() { - describe('when signing a token', function() { + describe("when signing using HS256", function () { + it('should throw if the secret is an asymmetric key', function () { + const { privateKey } = generateKeyPairSync('rsa', { modulusLength: 2048 }); + + expect(function () { + jwt.sign({ foo: 'bar' }, privateKey, { algorithm: 'HS256' }) + }).to.throw(Error, 'must be a symmetric key') + }) + + it('should throw if the payload is undefined', function () { + expect(function () { + jwt.sign(undefined, "secret", { algorithm: 'HS256' }) + }).to.throw(Error, 'payload is required') + }) + + it('should throw if options is not a plain object', function () { + expect(function () { + jwt.sign({ foo: 'bar' }, "secret", ['HS256']) + }).to.throw(Error, 'Expected "options" to be a plain object') + }) + }) + + describe('with a token signed using HS256', function() { var secret = 'shhhhhh'; var token = jwt.sign({ foo: 'bar' }, secret, { algorithm: 'HS256' }); diff --git a/test/jwt.malicious.tests.js b/test/jwt.malicious.tests.js new file mode 100644 index 0000000..d26ef41 --- /dev/null +++ b/test/jwt.malicious.tests.js @@ -0,0 +1,39 @@ +const jwt = require('../index'); +const crypto = require("crypto"); +const {expect} = require('chai'); +const JsonWebTokenError = require("../lib/JsonWebTokenError"); + +describe('when verifying a malicious token', function () { + // attacker has access to the public rsa key, but crafts the token as HS256 + // with kid set to the id of the rsa key, instead of the id of the hmac secret. + // const maliciousToken = jwt.sign( + // {foo: 'bar'}, + // pubRsaKey, + // {algorithm: 'HS256', keyid: 'rsaKeyId'} + // ); + // consumer accepts self signed tokens (HS256) and third party tokens (RS256) + const options = {algorithms: ['RS256', 'HS256']}; + + const { + publicKey: pubRsaKey + } = crypto.generateKeyPairSync('rsa', {modulusLength: 2048}); + + it('should not allow HMAC verification with an RSA key in KeyObject format', function () { + const maliciousToken = 'eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCIsImtpZCI6InJzYUtleUlkIn0.eyJmb28iOiJiYXIiLCJpYXQiOjE2NTk1MTA2MDh9.cOcHI1TXPbxTMlyVTfjArSWskrmezbrG8iR7uJHwtrQ'; + + expect(() => jwt.verify(maliciousToken, pubRsaKey, options)).to.throw(JsonWebTokenError, 'must be a symmetric key'); + }) + + it('should not allow HMAC verification with an RSA key in PEM format', function () { + const maliciousToken = 'eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCIsImtpZCI6InJzYUtleUlkIn0.eyJmb28iOiJiYXIiLCJpYXQiOjE2NTk1MTA2MDh9.cOcHI1TXPbxTMlyVTfjArSWskrmezbrG8iR7uJHwtrQ'; + + expect(() => jwt.verify(maliciousToken, pubRsaKey.export({type: 'spki', format: 'pem'}), options)).to.throw(JsonWebTokenError, 'must be a symmetric key'); + }) + + it('should not allow arbitrary execution from malicious Buffers containing objects with overridden toString functions', function () { + const token = jwt.sign({"foo": "bar"}, 'secret') + const maliciousBuffer = {toString: () => {throw new Error("Arbitrary Code Execution")}} + + expect(() => jwt.verify(token, maliciousBuffer)).to.throw(Error, 'not valid key material'); + }) +}) diff --git a/test/rsa-public-key.tests.js b/test/rsa-public-key.tests.js index 6abafb8..a5fdb76 100644 --- a/test/rsa-public-key.tests.js +++ b/test/rsa-public-key.tests.js @@ -1,5 +1,7 @@ -var jwt = require('../'); -var PS_SUPPORTED = require('../lib/psSupported'); +const jwt = require('../'); +const PS_SUPPORTED = require('../lib/psSupported'); +const expect = require('chai').expect; +const {generateKeyPairSync} = require('crypto') describe('public key start with BEGIN RSA PUBLIC KEY', function () { @@ -13,6 +15,22 @@ describe('public key start with BEGIN RSA PUBLIC KEY', function () { jwt.verify(token, cert_pub, done); }); + it('should not work for RS algorithms when modulus length is less than 2048 when allowInsecureKeySizes is false or not set', function (done) { + const { privateKey } = generateKeyPairSync('rsa', { modulusLength: 1024 }); + + expect(function() { + jwt.sign({ foo: 'bar' }, privateKey, { algorithm: 'RS256'}) + }).to.throw(Error, 'minimum key size'); + + done() + }); + + it('should work for RS algorithms when modulus length is less than 2048 when allowInsecureKeySizes is true', function (done) { + const { privateKey } = generateKeyPairSync('rsa', { modulusLength: 1024 }); + + jwt.sign({ foo: 'bar' }, privateKey, { algorithm: 'RS256', allowInsecureKeySizes: true}, done) + }); + if (PS_SUPPORTED) { it('should work for PS family of algorithms', function (done) { var fs = require('fs'); diff --git a/test/schema.tests.js b/test/schema.tests.js index 75a3b0f..0a648f1 100644 --- a/test/schema.tests.js +++ b/test/schema.tests.js @@ -10,9 +10,9 @@ describe('schema', function() { var cert_rsa_priv = fs.readFileSync(__dirname + '/rsa-private.pem'); var cert_ecdsa_priv = fs.readFileSync(__dirname + '/ecdsa-private.pem'); - function sign(options) { + function sign(options, secret) { var isEcdsa = options.algorithm && options.algorithm.indexOf('ES') === 0; - jwt.sign({foo: 123}, isEcdsa ? cert_ecdsa_priv : cert_rsa_priv, options); + jwt.sign({foo: 123}, secret || (isEcdsa ? cert_ecdsa_priv : cert_rsa_priv), options); } it('should validate algorithm', function () { @@ -31,30 +31,30 @@ describe('schema', function() { sign({algorithm: 'ES256'}); sign({algorithm: 'ES384'}); sign({algorithm: 'ES512'}); - sign({algorithm: 'HS256'}); - sign({algorithm: 'HS384'}); - sign({algorithm: 'HS512'}); + sign({algorithm: 'HS256'}, 'superSecret'); + sign({algorithm: 'HS384'}, 'superSecret'); + sign({algorithm: 'HS512'}, 'superSecret'); }); it('should validate header', function () { expect(function () { - sign({ header: 'foo' }); + sign({ header: 'foo' }, 'superSecret'); }).to.throw(/"header" must be an object/); - sign({header: {}}); + sign({header: {}}, 'superSecret'); }); it('should validate encoding', function () { expect(function () { - sign({ encoding: 10 }); + sign({ encoding: 10 }, 'superSecret'); }).to.throw(/"encoding" must be a string/); - sign({encoding: 'utf8'}); + sign({encoding: 'utf8'},'superSecret'); }); it('should validate noTimestamp', function () { expect(function () { - sign({ noTimestamp: 10 }); + sign({ noTimestamp: 10 }, 'superSecret'); }).to.throw(/"noTimestamp" must be a boolean/); - sign({noTimestamp: true}); + sign({noTimestamp: true}, 'superSecret'); }); }); diff --git a/verify.js b/verify.js index 95fa365..0b649db 100644 --- a/verify.js +++ b/verify.js @@ -1,18 +1,20 @@ -var JsonWebTokenError = require('./lib/JsonWebTokenError'); -var NotBeforeError = require('./lib/NotBeforeError'); -var TokenExpiredError = require('./lib/TokenExpiredError'); -var decode = require('./decode'); -var timespan = require('./lib/timespan'); -var PS_SUPPORTED = require('./lib/psSupported'); -var jws = require('jws'); - -var PUB_KEY_ALGS = ['RS256', 'RS384', 'RS512', 'ES256', 'ES384', 'ES512']; -var RSA_KEY_ALGS = ['RS256', 'RS384', 'RS512']; -var HS_ALGS = ['HS256', 'HS384', 'HS512']; +const JsonWebTokenError = require('./lib/JsonWebTokenError'); +const NotBeforeError = require('./lib/NotBeforeError'); +const TokenExpiredError = require('./lib/TokenExpiredError'); +const decode = require('./decode'); +const timespan = require('./lib/timespan'); +const PS_SUPPORTED = require('./lib/psSupported'); +const jws = require('jws'); +const {KeyObject, createSecretKey, createPublicKey} = require("crypto"); + +const PUB_KEY_ALGS = ['RS256', 'RS384', 'RS512']; +const EC_KEY_ALGS = ['ES256', 'ES384', 'ES512']; +const RSA_KEY_ALGS = ['RS256', 'RS384', 'RS512']; +const HS_ALGS = ['HS256', 'HS384', 'HS512']; if (PS_SUPPORTED) { - PUB_KEY_ALGS.splice(3, 0, 'PS256', 'PS384', 'PS512'); - RSA_KEY_ALGS.splice(3, 0, 'PS256', 'PS384', 'PS512'); + PUB_KEY_ALGS.splice(PUB_KEY_ALGS.length, 0, 'PS256', 'PS384', 'PS512'); + RSA_KEY_ALGS.splice(RSA_KEY_ALGS.length, 0, 'PS256', 'PS384', 'PS512'); } module.exports = function (jwtString, secretOrPublicKey, options, callback) { @@ -28,7 +30,7 @@ module.exports = function (jwtString, secretOrPublicKey, options, callback) { //clone this object since we are going to mutate it. options = Object.assign({}, options); - var done; + let done; if (callback) { done = callback; @@ -47,7 +49,7 @@ module.exports = function (jwtString, secretOrPublicKey, options, callback) { return done(new JsonWebTokenError('nonce must be a non-empty string')); } - var clockTimestamp = options.clockTimestamp || Math.floor(Date.now() / 1000); + const clockTimestamp = options.clockTimestamp || Math.floor(Date.now() / 1000); if (!jwtString){ return done(new JsonWebTokenError('jwt must be provided')); @@ -57,13 +59,13 @@ module.exports = function (jwtString, secretOrPublicKey, options, callback) { return done(new JsonWebTokenError('jwt must be a string')); } - var parts = jwtString.split('.'); + const parts = jwtString.split('.'); if (parts.length !== 3){ return done(new JsonWebTokenError('jwt malformed')); } - var decodedToken; + let decodedToken; try { decodedToken = decode(jwtString, { complete: true }); @@ -75,8 +77,8 @@ module.exports = function (jwtString, secretOrPublicKey, options, callback) { return done(new JsonWebTokenError('invalid token')); } - var header = decodedToken.header; - var getSecret; + const header = decodedToken.header; + let getSecret; if(typeof secretOrPublicKey === 'function') { if(!callback) { @@ -96,7 +98,7 @@ module.exports = function (jwtString, secretOrPublicKey, options, callback) { return done(new JsonWebTokenError('error in secret or public key callback: ' + err.message)); } - var hasSignature = parts[2].trim() !== ''; + const hasSignature = parts[2].trim() !== ''; if (!hasSignature && secretOrPublicKey){ return done(new JsonWebTokenError('jwt signature is required')); @@ -110,18 +112,41 @@ module.exports = function (jwtString, secretOrPublicKey, options, callback) { return done(new JsonWebTokenError('please specify "none" in "algorithms" to verify unsigned tokens')); } - if (!options.algorithms) { - options.algorithms = secretOrPublicKey.toString().includes('BEGIN CERTIFICATE') || - secretOrPublicKey.toString().includes('BEGIN PUBLIC KEY') ? PUB_KEY_ALGS : - secretOrPublicKey.toString().includes('BEGIN RSA PUBLIC KEY') ? RSA_KEY_ALGS : HS_ALGS; + if (secretOrPublicKey != null && !(secretOrPublicKey instanceof KeyObject)) { + try { + secretOrPublicKey = createPublicKey(secretOrPublicKey); + } catch (_) { + try { + secretOrPublicKey = createSecretKey(typeof secretOrPublicKey === 'string' ? Buffer.from(secretOrPublicKey) : secretOrPublicKey); + } catch (_) { + return done(new JsonWebTokenError('secretOrPublicKey is not valid key material')) + } + } + } + if (!options.algorithms) { + if (secretOrPublicKey.type === 'secret') { + options.algorithms = HS_ALGS; + } else if (['rsa', 'rsa-pss'].includes(secretOrPublicKey.asymmetricKeyType)) { + options.algorithms = RSA_KEY_ALGS + } else if (secretOrPublicKey.asymmetricKeyType === 'ec') { + options.algorithms = EC_KEY_ALGS + } else { + options.algorithms = PUB_KEY_ALGS + } } - if (!~options.algorithms.indexOf(decodedToken.header.alg)) { + if (options.algorithms.indexOf(decodedToken.header.alg) === -1) { return done(new JsonWebTokenError('invalid algorithm')); } - var valid; + if (header.alg.startsWith('HS') && secretOrPublicKey.type !== 'secret') { + return done(new JsonWebTokenError((`secretOrPublicKey must be a symmetric key when using ${header.alg}`))) + } else if (/^(?:RS|PS|ES)/.test(header.alg) && secretOrPublicKey.type !== 'public') { + return done(new JsonWebTokenError((`secretOrPublicKey must be an asymmetric key when using ${header.alg}`))) + } + + let valid; try { valid = jws.verify(jwtString, decodedToken.header.alg, secretOrPublicKey); @@ -133,7 +158,7 @@ module.exports = function (jwtString, secretOrPublicKey, options, callback) { return done(new JsonWebTokenError('invalid signature')); } - var payload = decodedToken.payload; + const payload = decodedToken.payload; if (typeof payload.nbf !== 'undefined' && !options.ignoreNotBefore) { if (typeof payload.nbf !== 'number') { @@ -154,10 +179,10 @@ module.exports = function (jwtString, secretOrPublicKey, options, callback) { } if (options.audience) { - var audiences = Array.isArray(options.audience) ? options.audience : [options.audience]; - var target = Array.isArray(payload.aud) ? payload.aud : [payload.aud]; + const audiences = Array.isArray(options.audience) ? options.audience : [options.audience]; + const target = Array.isArray(payload.aud) ? payload.aud : [payload.aud]; - var match = target.some(function (targetAudience) { + const match = target.some(function (targetAudience) { return audiences.some(function (audience) { return audience instanceof RegExp ? audience.test(targetAudience) : audience === targetAudience; }); @@ -169,7 +194,7 @@ module.exports = function (jwtString, secretOrPublicKey, options, callback) { } if (options.issuer) { - var invalid_issuer = + const invalid_issuer = (typeof options.issuer === 'string' && payload.iss !== options.issuer) || (Array.isArray(options.issuer) && options.issuer.indexOf(payload.iss) === -1); @@ -201,7 +226,7 @@ module.exports = function (jwtString, secretOrPublicKey, options, callback) { return done(new JsonWebTokenError('iat required when maxAge is specified')); } - var maxAgeTimestamp = timespan(options.maxAge, payload.iat); + const maxAgeTimestamp = timespan(options.maxAge, payload.iat); if (typeof maxAgeTimestamp === 'undefined') { return done(new JsonWebTokenError('"maxAge" should be a number of seconds or string representing a timespan eg: "1d", "20h", 60')); } @@ -211,7 +236,7 @@ module.exports = function (jwtString, secretOrPublicKey, options, callback) { } if (options.complete === true) { - var signature = decodedToken.signature; + const signature = decodedToken.signature; return done(null, { header: header,