From 2c0e670ad98dbf847e1ac6e0247c66e5bc50be2b Mon Sep 17 00:00:00 2001 From: Filip Skokan Date: Wed, 11 Sep 2024 13:19:33 +0200 Subject: [PATCH 1/7] chore: ignore package-lock.json --- .gitignore | 1 + 1 file changed, 1 insertion(+) diff --git a/.gitignore b/.gitignore index 88861393..9f5fc90f 100644 --- a/.gitignore +++ b/.gitignore @@ -2,3 +2,4 @@ node_modules .DS_Store .nyc_output coverage +package-lock.json From e54aa6132a8cfddcb8350c5375501827725aa41a Mon Sep 17 00:00:00 2001 From: Filip Skokan Date: Wed, 11 Sep 2024 13:31:43 +0200 Subject: [PATCH 2/7] refactor: use async node:crypto methods when available --- .circleci/config.yml | 34 +++- decode.js | 48 +++++- lib/asymmetricKeyDetailsSupported.js | 3 - lib/base64url.js | 9 + lib/flags.js | 5 + lib/oneShotAlgs.js | 53 ++++++ lib/psSupported.js | 3 - lib/rsaPssKeyDetailsSupported.js | 3 - lib/validateAsymmetricKey.js | 5 +- package.json | 9 +- sign.js | 132 +++++++++----- test/async_sign.tests.js | 19 +- test/decoding.tests.js | 7 +- test/jwt.asymmetric_signing.tests.js | 14 +- test/roundtrip.test.js | 99 +++++++++++ test/rsa-public-key.tests.js | 41 +++-- test/schema.tests.js | 9 +- test/validateAsymmetricKey.tests.js | 15 +- test/wrong_alg.tests.js | 15 +- verify.js | 249 ++++++++++++++++----------- 20 files changed, 532 insertions(+), 240 deletions(-) delete mode 100644 lib/asymmetricKeyDetailsSupported.js create mode 100644 lib/base64url.js create mode 100644 lib/flags.js create mode 100644 lib/oneShotAlgs.js delete mode 100644 lib/psSupported.js delete mode 100644 lib/rsaPssKeyDetailsSupported.js create mode 100644 test/roundtrip.test.js diff --git a/.circleci/config.yml b/.circleci/config.yml index 51f2d617..d9198301 100644 --- a/.circleci/config.yml +++ b/.circleci/config.yml @@ -3,7 +3,7 @@ version: 2.1 # Thanks to https://github.com/teppeis-sandbox/circleci2-multiple-node-versions commands: - test-nodejs: + test-modern: steps: - run: name: Versions @@ -15,28 +15,50 @@ commands: - run: name: Test command: npm test + test-legacy: + steps: + - run: + name: Versions + command: npm version + - checkout + - run: + name: Install dependencies + command: npm install + - run: + name: Test + command: npm run test-only jobs: node-v12: docker: - image: node:12 steps: - - test-nodejs + - test-legacy node-v14: docker: - image: node:14 steps: - - test-nodejs + - test-legacy node-v16: docker: - image: node:16 steps: - - test-nodejs + - test-modern node-v18: docker: - image: node:18 steps: - - test-nodejs + - test-modern + node-v20: + docker: + - image: node:20 + steps: + - test-modern + node-v22: + docker: + - image: node:22 + steps: + - test-modern workflows: node-multi-build: @@ -45,3 +67,5 @@ workflows: - node-v14 - node-v16 - node-v18 + - node-v20 + - node-v22 diff --git a/decode.js b/decode.js index 8fe1adcd..17abb529 100644 --- a/decode.js +++ b/decode.js @@ -1,8 +1,52 @@ -var jws = require('jws'); +function payloadFromJWS(encodedPayload, encoding = "utf8") { + try { + return Buffer.from(encodedPayload, "base64").toString(encoding); + } catch (_) { + return; + } +} + +function headerFromJWS(encodedHeader) { + try { + return JSON.parse(Buffer.from(encodedHeader, "base64").toString()); + } catch (_) { + return; + } +} + +const JWS_REGEX = /^[a-zA-Z0-9\-_]+?\.[a-zA-Z0-9\-_]+?\.([a-zA-Z0-9\-_]+)?$/; + +function isValidJws(string) { + return JWS_REGEX.test(string); +} + +function jwsDecode(token, opts) { + opts = opts || {}; + + if (!isValidJws(token)) return null; + + let [header, payload, signature] = token.split('.'); + + header = headerFromJWS(header); + if (header === undefined) return null; + + payload = payloadFromJWS(payload); + if (payload === undefined) return null; + + if (header.typ === "JWT" || opts.json){ + payload = JSON.parse(payload); + } + + return { + header, + payload, + signature, + }; +} module.exports = function (jwt, options) { options = options || {}; - var decoded = jws.decode(jwt, options); + const decoded = jwsDecode(jwt, options); if (!decoded) { return null; } var payload = decoded.payload; diff --git a/lib/asymmetricKeyDetailsSupported.js b/lib/asymmetricKeyDetailsSupported.js deleted file mode 100644 index a6ede56e..00000000 --- a/lib/asymmetricKeyDetailsSupported.js +++ /dev/null @@ -1,3 +0,0 @@ -const semver = require('semver'); - -module.exports = semver.satisfies(process.version, '>=15.7.0'); diff --git a/lib/base64url.js b/lib/base64url.js new file mode 100644 index 00000000..d3d7b229 --- /dev/null +++ b/lib/base64url.js @@ -0,0 +1,9 @@ +/* istanbul ignore file */ +if (Buffer.isEncoding("base64url")) { + module.exports = (buf) => buf.toString("base64url"); +} else { + const fromBase64 = (base64) => + // eslint-disable-next-line no-div-regex + base64.replace(/=/g, "").replace(/\+/g, "-").replace(/\//g, "_"); + module.exports = (buf) => fromBase64(buf.toString("base64")); +} diff --git a/lib/flags.js b/lib/flags.js new file mode 100644 index 00000000..f9a4c903 --- /dev/null +++ b/lib/flags.js @@ -0,0 +1,5 @@ +/* istanbul ignore file */ +const [major, minor] = process.versions.node.split('.').map((v) => parseInt(v, 10)); + +module.exports.RSA_PSS_KEY_DETAILS_SUPPORTED = major > 16 || (major === 16 && minor >= 9); +module.exports.ASYMMETRIC_KEY_DETAILS_SUPPORTED = major > 15 || (major === 15 && minor >= 7); diff --git a/lib/oneShotAlgs.js b/lib/oneShotAlgs.js new file mode 100644 index 00000000..2be1359e --- /dev/null +++ b/lib/oneShotAlgs.js @@ -0,0 +1,53 @@ +const { constants } = require("crypto"); + +module.exports = function(alg, key) { + switch (alg) { + case 'RS256': + return { + digest: 'sha256', + key: { key, padding: constants.RSA_PKCS1_PADDING }, + }; + case 'RS384': + return { + digest: 'sha384', + key: { key, padding: constants.RSA_PKCS1_PADDING }, + }; + case 'RS512': + return { + digest: 'sha512', + key: { key, padding: constants.RSA_PKCS1_PADDING }, + }; + case 'PS256': + return { + digest: 'sha256', + key: { key, padding: constants.RSA_PKCS1_PSS_PADDING, saltLength: constants.RSA_PSS_SALTLEN_DIGEST }, + }; + case 'PS384': + return { + digest: 'sha384', + key: { key, padding: constants.RSA_PKCS1_PSS_PADDING, saltLength: constants.RSA_PSS_SALTLEN_DIGEST }, + }; + case 'PS512': + return { + digest: 'sha512', + key: { key, padding: constants.RSA_PKCS1_PSS_PADDING, saltLength: constants.RSA_PSS_SALTLEN_DIGEST }, + }; + case 'ES256': + return { + digest: 'sha256', + key: { key, dsaEncoding: 'ieee-p1363' }, + }; + case 'ES384': + return { + digest: 'sha384', + key: { key, dsaEncoding: 'ieee-p1363' }, + }; + case 'ES512': + return { + digest: 'sha512', + key: { key, dsaEncoding: 'ieee-p1363' }, + }; + default: + throw new Error('unreachable'); + } +}; diff --git a/lib/psSupported.js b/lib/psSupported.js deleted file mode 100644 index 8c04144a..00000000 --- a/lib/psSupported.js +++ /dev/null @@ -1,3 +0,0 @@ -var semver = require('semver'); - -module.exports = semver.satisfies(process.version, '^6.12.0 || >=8.0.0'); diff --git a/lib/rsaPssKeyDetailsSupported.js b/lib/rsaPssKeyDetailsSupported.js deleted file mode 100644 index 7fcf3684..00000000 --- a/lib/rsaPssKeyDetailsSupported.js +++ /dev/null @@ -1,3 +0,0 @@ -const semver = require('semver'); - -module.exports = semver.satisfies(process.version, '>=16.9.0'); diff --git a/lib/validateAsymmetricKey.js b/lib/validateAsymmetricKey.js index c10340b0..447bd912 100644 --- a/lib/validateAsymmetricKey.js +++ b/lib/validateAsymmetricKey.js @@ -1,5 +1,4 @@ -const ASYMMETRIC_KEY_DETAILS_SUPPORTED = require('./asymmetricKeyDetailsSupported'); -const RSA_PSS_KEY_DETAILS_SUPPORTED = require('./rsaPssKeyDetailsSupported'); +const { ASYMMETRIC_KEY_DETAILS_SUPPORTED, RSA_PSS_KEY_DETAILS_SUPPORTED } = require('./flags'); const allowedAlgorithmsForKeys = { 'ec': ['ES256', 'ES384', 'ES512'], @@ -52,7 +51,7 @@ module.exports = function(algorithm, key) { const length = parseInt(algorithm.slice(-3), 10); const { hashAlgorithm, mgf1HashAlgorithm, saltLength } = key.asymmetricKeyDetails; - if (hashAlgorithm !== `sha${length}` || mgf1HashAlgorithm !== hashAlgorithm) { + if (hashAlgorithm !== undefined && (hashAlgorithm !== `sha${length}` || mgf1HashAlgorithm !== hashAlgorithm)) { throw new Error(`Invalid key for this operation, its RSA-PSS parameters do not meet the requirements of "alg" ${algorithm}.`); } diff --git a/package.json b/package.json index 81f78da0..4f258b47 100644 --- a/package.json +++ b/package.json @@ -21,7 +21,8 @@ "scripts": { "lint": "eslint .", "coverage": "nyc mocha --use_strict", - "test": "npm run lint && npm run coverage && cost-of-modules" + "test": "npm run lint && npm run coverage && cost-of-modules", + "test-only": "mocha --use_strict" }, "repository": { "type": "git", @@ -36,7 +37,6 @@ "url": "https://github.com/auth0/node-jsonwebtoken/issues" }, "dependencies": { - "jws": "^3.2.2", "lodash.includes": "^4.3.0", "lodash.isboolean": "^3.0.3", "lodash.isinteger": "^4.0.4", @@ -44,8 +44,7 @@ "lodash.isplainobject": "^4.0.6", "lodash.isstring": "^4.0.1", "lodash.once": "^4.0.0", - "ms": "^2.1.1", - "semver": "^7.5.4" + "ms": "^2.1.1" }, "devDependencies": { "atob": "^2.1.2", @@ -53,6 +52,8 @@ "conventional-changelog": "~1.1.0", "cost-of-modules": "^1.0.1", "eslint": "^4.19.1", + "jose": "^5.8.0", + "jws": "^3.2.2", "mocha": "^5.2.0", "nsp": "^2.6.2", "nyc": "^11.9.0", diff --git a/sign.js b/sign.js index 82bf526e..942b2dd2 100644 --- a/sign.js +++ b/sign.js @@ -1,20 +1,22 @@ const timespan = require('./lib/timespan'); -const PS_SUPPORTED = require('./lib/psSupported'); const validateAsymmetricKey = require('./lib/validateAsymmetricKey'); -const jws = require('jws'); const includes = require('lodash.includes'); const isBoolean = require('lodash.isboolean'); const isInteger = require('lodash.isinteger'); const isNumber = require('lodash.isnumber'); const isPlainObject = require('lodash.isplainobject'); const isString = require('lodash.isstring'); -const once = require('lodash.once'); -const { KeyObject, createSecretKey, createPrivateKey } = require('crypto') +const crypto = require('crypto') +const oneShotAlgs = require('./lib/oneShotAlgs'); +const encodeBase64url = require('./lib/base64url'); -const SUPPORTED_ALGS = ['RS256', 'RS384', 'RS512', 'ES256', 'ES384', 'ES512', 'HS256', 'HS384', 'HS512', 'none']; -if (PS_SUPPORTED) { - SUPPORTED_ALGS.splice(3, 0, 'PS256', 'PS384', 'PS512'); -} +const SUPPORTED_ALGS = [ + 'RS256', 'RS384', 'RS512', + 'PS256', 'PS384', 'PS512', + 'ES256', 'ES384', 'ES512', + 'HS256', 'HS384', 'HS512', + 'none', +]; 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' }, @@ -39,6 +41,7 @@ const registered_claims_schema = { nbf: { isValid: isNumber, message: '"nbf" should be a number of seconds' } }; + function validate(schema, allowUnknown, object, parameterName) { if (!isPlainObject(object)) { throw new Error('Expected "' + parameterName + '" to be a plain object.'); @@ -83,7 +86,24 @@ const options_for_objects = [ 'jwtid', ]; -module.exports = function (payload, secretOrPrivateKey, options, callback) { +function encodePayload(payload, encoding = 'utf8') { + let buf; + if (payload instanceof Uint8Array) { + buf = Buffer.from(payload) + } else if (typeof payload === 'string') { + buf = Buffer.from(payload, encoding); + } else { + buf = Buffer.from(JSON.stringify(payload), encoding); + } + + return encodeBase64url(buf); +} + +function encodeHeader(header) { + return encodeBase64url(Buffer.from(JSON.stringify(header))); +} + +module.exports = function(payload, secretOrPrivateKey, options, callback) { if (typeof options === 'function') { callback = options; options = {}; @@ -91,6 +111,16 @@ module.exports = function (payload, secretOrPrivateKey, options, callback) { options = options || {}; } + let done; + if (callback) { + done = callback; + } else { + done = function(err, data) { + if (err) throw err; + return data; + }; + } + const isObjectPayload = typeof payload === 'object' && !Buffer.isBuffer(payload); @@ -101,8 +131,8 @@ module.exports = function (payload, secretOrPrivateKey, options, callback) { }, options.header); function failure(err) { - if (callback) { - return callback(err); + if (done) { + return done(err); } throw err; } @@ -111,12 +141,12 @@ module.exports = function (payload, secretOrPrivateKey, options, callback) { return failure(new Error('secretOrPrivateKey must have a value')); } - if (secretOrPrivateKey != null && !(secretOrPrivateKey instanceof KeyObject)) { + if (secretOrPrivateKey != null && !(secretOrPrivateKey instanceof crypto.KeyObject)) { try { - secretOrPrivateKey = createPrivateKey(secretOrPrivateKey) + secretOrPrivateKey = crypto.createPrivateKey(secretOrPrivateKey) } catch (_) { try { - secretOrPrivateKey = createSecretKey(typeof secretOrPrivateKey === 'string' ? Buffer.from(secretOrPrivateKey) : secretOrPrivateKey) + secretOrPrivateKey = crypto.createSecretKey(typeof secretOrPrivateKey === 'string' ? Buffer.from(secretOrPrivateKey) : secretOrPrivateKey) } catch (_) { return failure(new Error('secretOrPrivateKey is not valid key material')); } @@ -129,12 +159,6 @@ module.exports = function (payload, secretOrPrivateKey, options, callback) { 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') { @@ -224,30 +248,50 @@ module.exports = function (payload, secretOrPrivateKey, options, callback) { } }); - const encoding = options.encoding || 'utf8'; - - if (typeof callback === 'function') { - callback = callback && once(callback); - - jws.createSign({ - header: header, - privateKey: secretOrPrivateKey, - payload: payload, - 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 { - 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}`) + const sync = done !== callback || header.alg === 'none' || header.alg.startsWith('HS') || parseInt(process.versions.node, 10) < 16; + const data = `${encodeHeader(header)}.${encodePayload(payload, options.encoding)}` + + if (header.alg === 'none') { + return done(null, `${data}.`) + } + + if (header.alg.startsWith('HS')) { + const signature = encodeBase64url(crypto.createHmac(`sha${header.alg.substring(2, 5)}`, secretOrPrivateKey).update(Buffer.from(data)).digest()); + return done(null, `${data}.${signature}`); + } + + if (sync) { + const { digest, key } = oneShotAlgs(header.alg, secretOrPrivateKey); + + let signature; + try { + signature = crypto.sign(digest, Buffer.from(data), key); + } catch (err) { + return done(err); } - return signature + + if(!options.allowInsecureKeySizes && (header.alg.startsWith('RS') || header.alg.startsWith('PS')) && signature.byteLength < 256) { + return done(new Error(`secretOrPrivateKey has a minimum key size of 2048 bits for ${header.alg}`)); + } + + const token = `${data}.${encodeBase64url(signature)}`; + + return done(null, token); } + + const { digest, key } = oneShotAlgs(header.alg, secretOrPrivateKey); + + crypto.sign(digest, Buffer.from(data), key, (err, signature) => { + if (err) { + return done(err); + } + + if(!options.allowInsecureKeySizes && (header.alg.startsWith('RS') || header.alg.startsWith('PS')) && signature.byteLength < 256) { + return done(new Error(`secretOrPrivateKey has a minimum key size of 2048 bits for ${header.alg}`)); + } + + const token = `${data}.${encodeBase64url(signature)}`; + + return done(null, token); + }); }; diff --git a/test/async_sign.tests.js b/test/async_sign.tests.js index eb31174e..914f1252 100644 --- a/test/async_sign.tests.js +++ b/test/async_sign.tests.js @@ -1,7 +1,6 @@ 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() { @@ -42,9 +41,7 @@ describe('signing a token asynchronously', function() { }); }); - //Known bug: https://github.com/brianloveswords/node-jws/issues/62 - //If you need this use case, you need to go for the non-callback-ish code style. - it.skip('should work with none algorithm where secret is falsy', function(done) { + it('should work with none algorithm where secret is falsy', function(done) { jwt.sign({ foo: 'bar' }, undefined, { algorithm: 'none' }, function(err, token) { expect(token).to.be.a('string'); expect(token.split('.')).to.have.length(3); @@ -75,15 +72,13 @@ describe('signing a token asynchronously', function() { 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. - jwt.sign({ foo: 'bar' }, secret, { algorithm: 'PS256' }, function (err) { - expect(err).to.be.ok; - done(); - }); + 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. + jwt.sign({ foo: 'bar' }, secret, { algorithm: 'PS256' }, function (err) { + expect(err).to.be.ok; + done(); }); - } + }); it('should return error on wrong arguments', function(done) { //this throw an error because the secret is not a cert and RS256 requires a cert. diff --git a/test/decoding.tests.js b/test/decoding.tests.js index 3bd8c130..c8ee42e8 100644 --- a/test/decoding.tests.js +++ b/test/decoding.tests.js @@ -2,10 +2,15 @@ var jwt = require('../index'); var expect = require('chai').expect; describe('decoding', function() { - it('should not crash when decoding a null token', function () { var decoded = jwt.decode("null"); expect(decoded).to.equal(null); }); + it('should handle invalid tokens', function () { + for (const token of ["not.valid.", {}, null, 1, Number(), String(), false, true, undefined]) { + var decoded = jwt.decode(token); + expect(decoded).to.equal(null); + } + }); }); diff --git a/test/jwt.asymmetric_signing.tests.js b/test/jwt.asymmetric_signing.tests.js index a8472d52..dfd1d385 100644 --- a/test/jwt.asymmetric_signing.tests.js +++ b/test/jwt.asymmetric_signing.tests.js @@ -1,5 +1,4 @@ const jwt = require('../index'); -const PS_SUPPORTED = require('../lib/psSupported'); const fs = require('fs'); const path = require('path'); @@ -18,21 +17,16 @@ const algorithms = { invalid_pub_key: loadKey('invalid_pub.pem') }, ES256: { - // openssl ecparam -name secp256r1 -genkey -param_enc explicit -out ecdsa-private.pem priv_key: loadKey('ecdsa-private.pem'), - // openssl ec -in ecdsa-private.pem -pubout -out ecdsa-public.pem pub_key: loadKey('ecdsa-public.pem'), invalid_pub_key: loadKey('ecdsa-public-invalid.pem') - } -}; - -if (PS_SUPPORTED) { - algorithms.PS256 = { + }, + PS256: { pub_key: loadKey('pub.pem'), priv_key: loadKey('priv.pem'), invalid_pub_key: loadKey('invalid_pub.pem') - }; -} + }, +}; describe('Asymmetric Algorithms', function() { diff --git a/test/roundtrip.test.js b/test/roundtrip.test.js new file mode 100644 index 00000000..0995c5fa --- /dev/null +++ b/test/roundtrip.test.js @@ -0,0 +1,99 @@ +const jwt = require("../index"); +const expect = require("chai").expect; +let jose; + +try { + jose = require("jose"); +} catch (_) {} + +for (const [alg, opts] of [ + ["HS256"], + ["RS256"], + ["PS256"], + ["ES256"], + ["ES384"], + ["ES512"], +]) { + const conditionalDescribe = + parseInt(process.versions.node, 10) >= 18 ? describe : describe.skip; + + conditionalDescribe( + `${alg} roundtrips${opts ? ` with ${JSON.stringify(opts)}` : ""}`, + function () { + if (alg.startsWith("HS")) { + before(function (done) { + jose.generateSecret(alg, opts).then((secretKey) => { + this.publicKey = this.privateKey = secretKey; + done(); + }, done); + }); + } else { + before(function (done) { + jose.generateKeyPair(alg, opts).then((kp) => { + this.publicKey = kp.publicKey; + this.privateKey = kp.privateKey; + done(); + }, done); + }); + } + + describe("round trip jsonwebtoken > jsonwebtoken", function () { + it("without callback", function () { + expect(() => { + const token = jwt.sign({}, this.privateKey, { algorithm: alg }); + jwt.verify(token, this.publicKey); + }).not.to.throw(); + }); + + it("with callback", function (done) { + jwt.sign({}, this.privateKey, { algorithm: alg }, (err, token) => { + if (err) return done(err); + jwt.verify(token, this.publicKey, (err) => { + if (err) return done(err); + done(); + }); + }); + }); + }); + + describe("round trip external > jsonwebtoken", function () { + before(function (done) { + new jose.SignJWT() + .setProtectedHeader({ alg }) + .sign(this.privateKey) + .then((token) => { + this.token = token; + done(); + }, done); + }); + + it("without callback", function () { + expect(() => { + jwt.verify(this.token, this.publicKey); + }).not.to.throw(); + }); + + it("with callback", function (done) { + jwt.verify(this.token, this.publicKey, (err) => { + if (err) return done(err); + done(); + }); + }); + }); + + describe("round trip jsonwebtoken > external", function () { + it("without callback", function (done) { + const token = jwt.sign({}, this.privateKey, { algorithm: alg }); + jose.jwtVerify(token, this.publicKey).then(() => done(), done); + }); + + it("with callback", function (done) { + jwt.sign({}, this.privateKey, { algorithm: alg }, (err, token) => { + if (err) return done(err); + jose.jwtVerify(token, this.publicKey).then(() => done(), done); + }); + }); + }); + } + ); +} diff --git a/test/rsa-public-key.tests.js b/test/rsa-public-key.tests.js index a5fdb769..ed7cb0ad 100644 --- a/test/rsa-public-key.tests.js +++ b/test/rsa-public-key.tests.js @@ -1,5 +1,4 @@ const jwt = require('../'); -const PS_SUPPORTED = require('../lib/psSupported'); const expect = require('chai').expect; const {generateKeyPairSync} = require('crypto') @@ -15,32 +14,32 @@ 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 }); + for (const [keyType, alg] of [['rsa', 'RS256'], ['rsa-pss', 'PS256']]) { + it(`should not work for ${keyType.toUpperCase()} algorithms when modulus length is less than 2048 when allowInsecureKeySizes is false or not set`, function (done) { + const { privateKey } = generateKeyPairSync(keyType, { modulusLength: 1024 }); - expect(function() { - jwt.sign({ foo: 'bar' }, privateKey, { algorithm: 'RS256'}) - }).to.throw(Error, 'minimum key size'); + expect(function() { + jwt.sign({ foo: 'bar' }, privateKey, { algorithm: alg }) + }).to.throw(Error, 'minimum key size'); - done() - }); + 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 }); + it(`should work for ${keyType.toUpperCase()} algorithms when modulus length is less than 2048 when allowInsecureKeySizes is true`, function (done) { + const { privateKey } = generateKeyPairSync(keyType, { modulusLength: 1024 }); - jwt.sign({ foo: 'bar' }, privateKey, { algorithm: 'RS256', allowInsecureKeySizes: true}, done) - }); + jwt.sign({ foo: 'bar' }, privateKey, { algorithm: alg, allowInsecureKeySizes: true }, done) + }); + } - if (PS_SUPPORTED) { - it('should work for PS family of algorithms', function (done) { - var fs = require('fs'); - var cert_pub = fs.readFileSync(__dirname + '/rsa-public-key.pem'); - var cert_priv = fs.readFileSync(__dirname + '/rsa-private.pem'); + it('should work for PS family of algorithms', function (done) { + var fs = require('fs'); + var cert_pub = fs.readFileSync(__dirname + '/rsa-public-key.pem'); + var cert_priv = fs.readFileSync(__dirname + '/rsa-private.pem'); - var token = jwt.sign({ foo: 'bar' }, cert_priv, { algorithm: 'PS256'}); + var token = jwt.sign({ foo: 'bar' }, cert_priv, { algorithm: 'PS256'}); - jwt.verify(token, cert_pub, done); - }); - } + jwt.verify(token, cert_pub, done); + }); }); diff --git a/test/schema.tests.js b/test/schema.tests.js index ebd553f6..0033ab41 100644 --- a/test/schema.tests.js +++ b/test/schema.tests.js @@ -1,7 +1,6 @@ var jwt = require('../index'); var expect = require('chai').expect; var fs = require('fs'); -var PS_SUPPORTED = require('../lib/psSupported'); describe('schema', function() { @@ -23,11 +22,9 @@ describe('schema', function() { sign({algorithm: 'RS256'}, cert_rsa_priv); sign({algorithm: 'RS384'}, cert_rsa_priv); sign({algorithm: 'RS512'}, cert_rsa_priv); - if (PS_SUPPORTED) { - sign({algorithm: 'PS256'}, cert_rsa_priv); - sign({algorithm: 'PS384'}, cert_rsa_priv); - sign({algorithm: 'PS512'}, cert_rsa_priv); - } + sign({algorithm: 'PS256'}, cert_rsa_priv); + sign({algorithm: 'PS384'}, cert_rsa_priv); + sign({algorithm: 'PS512'}, cert_rsa_priv); sign({algorithm: 'ES256'}, cert_ecdsa_priv); sign({algorithm: 'ES384'}, cert_secp384r1_priv); sign({algorithm: 'ES512'}, cert_secp521r1_priv); diff --git a/test/validateAsymmetricKey.tests.js b/test/validateAsymmetricKey.tests.js index e0194b8e..5e6ea791 100644 --- a/test/validateAsymmetricKey.tests.js +++ b/test/validateAsymmetricKey.tests.js @@ -1,7 +1,5 @@ const validateAsymmetricKey = require('../lib/validateAsymmetricKey'); -const PS_SUPPORTED = require('../lib/psSupported'); -const ASYMMETRIC_KEY_DETAILS_SUPPORTED = require('../lib/asymmetricKeyDetailsSupported'); -const RSA_PSS_KEY_DETAILS_SUPPORTED = require('../lib/rsaPssKeyDetailsSupported'); +const { ASYMMETRIC_KEY_DETAILS_SUPPORTED, RSA_PSS_KEY_DETAILS_SUPPORTED } = require('../lib/flags'); const fs = require('fs'); const path = require('path'); const { createPrivateKey } = require('crypto'); @@ -19,14 +17,11 @@ const algorithmParams = { }, ES256: { invalidPrivateKey: loadKey('priv.pem') - } -}; - -if (PS_SUPPORTED) { - algorithmParams.PS256 = { + }, + PS256: { invalidPrivateKey: loadKey('secp384r1-private.pem') - }; -} + }, +}; describe('Asymmetric key validation', function() { Object.keys(algorithmParams).forEach(function(algorithm) { diff --git a/test/wrong_alg.tests.js b/test/wrong_alg.tests.js index 8b6e2459..f7af8366 100644 --- a/test/wrong_alg.tests.js +++ b/test/wrong_alg.tests.js @@ -2,7 +2,6 @@ var fs = require('fs'); var path = require('path'); var jwt = require('../index'); var JsonWebTokenError = require('../lib/JsonWebTokenError'); -var PS_SUPPORTED = require('../lib/psSupported'); var expect = require('chai').expect; @@ -30,15 +29,13 @@ describe('when setting a wrong `header.alg`', function () { }); }); - if (PS_SUPPORTED) { - describe('signing with pub key as HS256 and whitelisting only PS256', function () { - it('should not verify', function () { - expect(function () { - jwt.verify(TOKEN, pub, {algorithms: ['PS256']}); - }).to.throw(JsonWebTokenError, /invalid algorithm/); - }); + describe('signing with pub key as HS256 and whitelisting only PS256', function () { + it('should not verify', function () { + expect(function () { + jwt.verify(TOKEN, pub, {algorithms: ['PS256']}); + }).to.throw(JsonWebTokenError, /invalid algorithm/); }); - } + }); describe('signing with HS256 and checking with HS384', function () { it('should not verify', function () { diff --git a/verify.js b/verify.js index cdbfdc45..2f4dd992 100644 --- a/verify.js +++ b/verify.js @@ -4,21 +4,105 @@ const TokenExpiredError = require('./lib/TokenExpiredError'); const decode = require('./decode'); const timespan = require('./lib/timespan'); const validateAsymmetricKey = require('./lib/validateAsymmetricKey'); -const PS_SUPPORTED = require('./lib/psSupported'); -const jws = require('jws'); -const {KeyObject, createSecretKey, createPublicKey} = require("crypto"); +const crypto = require("crypto"); +const oneShotAlgs = require('./lib/oneShotAlgs'); -const PUB_KEY_ALGS = ['RS256', 'RS384', 'RS512']; const EC_KEY_ALGS = ['ES256', 'ES384', 'ES512']; const RSA_KEY_ALGS = ['RS256', 'RS384', 'RS512']; +const RSA_PSS_KEY_ALGS = ['PS256', 'PS384', 'PS512']; +const PUB_KEY_ALGS = [].concat(RSA_KEY_ALGS, EC_KEY_ALGS); const HS_ALGS = ['HS256', 'HS384', 'HS512']; -if (PS_SUPPORTED) { - PUB_KEY_ALGS.splice(PUB_KEY_ALGS.length, 0, 'PS256', 'PS384', 'PS512'); - RSA_KEY_ALGS.splice(RSA_KEY_ALGS.length, 0, 'PS256', 'PS384', 'PS512'); +function processPayload(header, payload, signature, options, done) { + const clockTimestamp = options.clockTimestamp || Math.floor(Date.now() / 1000); + + if (typeof payload.nbf !== 'undefined' && !options.ignoreNotBefore) { + if (typeof payload.nbf !== 'number') { + return done(new JsonWebTokenError('invalid nbf value')); + } + if (payload.nbf > clockTimestamp + (options.clockTolerance || 0)) { + return done(new NotBeforeError('jwt not active', new Date(payload.nbf * 1000))); + } + } + + if (typeof payload.exp !== 'undefined' && !options.ignoreExpiration) { + if (typeof payload.exp !== 'number') { + return done(new JsonWebTokenError('invalid exp value')); + } + if (clockTimestamp >= payload.exp + (options.clockTolerance || 0)) { + return done(new TokenExpiredError('jwt expired', new Date(payload.exp * 1000))); + } + } + + if (options.audience) { + const audiences = Array.isArray(options.audience) ? options.audience : [options.audience]; + const target = Array.isArray(payload.aud) ? payload.aud : [payload.aud]; + + const match = target.some(function(targetAudience) { + return audiences.some(function(audience) { + return audience instanceof RegExp ? audience.test(targetAudience) : audience === targetAudience; + }); + }); + + if (!match) { + return done(new JsonWebTokenError('jwt audience invalid. expected: ' + audiences.join(' or '))); + } + } + + if (options.issuer) { + const invalid_issuer = + (typeof options.issuer === 'string' && payload.iss !== options.issuer) || + (Array.isArray(options.issuer) && options.issuer.indexOf(payload.iss) === -1); + + if (invalid_issuer) { + return done(new JsonWebTokenError('jwt issuer invalid. expected: ' + options.issuer)); + } + } + + if (options.subject) { + if (payload.sub !== options.subject) { + return done(new JsonWebTokenError('jwt subject invalid. expected: ' + options.subject)); + } + } + + if (options.jwtid) { + if (payload.jti !== options.jwtid) { + return done(new JsonWebTokenError('jwt jwtid invalid. expected: ' + options.jwtid)); + } + } + + if (options.nonce) { + if (payload.nonce !== options.nonce) { + return done(new JsonWebTokenError('jwt nonce invalid. expected: ' + options.nonce)); + } + } + + if (options.maxAge) { + if (typeof payload.iat !== 'number') { + return done(new JsonWebTokenError('iat required when maxAge is specified')); + } + + 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')); + } + if (clockTimestamp >= maxAgeTimestamp + (options.clockTolerance || 0)) { + return done(new TokenExpiredError('maxAge exceeded', new Date(maxAgeTimestamp * 1000))); + } + } + + if (options.complete === true) { + return done(null, { + header, + payload: payload, + signature, + }); + } + + return done(null, payload); } -module.exports = function (jwtString, secretOrPublicKey, options, callback) { +module.exports = function(jwtString, secretOrPublicKey, options, callback) { if ((typeof options === 'function') && !callback) { callback = options; options = {}; @@ -54,8 +138,6 @@ module.exports = function (jwtString, secretOrPublicKey, options, callback) { return done(new JsonWebTokenError('allowInvalidAsymmetricKeyTypes must be a boolean')); } - const clockTimestamp = options.clockTimestamp || Math.floor(Date.now() / 1000); - if (!jwtString){ return done(new JsonWebTokenError('jwt must be provided')); } @@ -117,12 +199,12 @@ module.exports = function (jwtString, secretOrPublicKey, options, callback) { return done(new JsonWebTokenError('please specify "none" in "algorithms" to verify unsigned tokens')); } - if (secretOrPublicKey != null && !(secretOrPublicKey instanceof KeyObject)) { + if (secretOrPublicKey != null && !(secretOrPublicKey instanceof crypto.KeyObject)) { try { - secretOrPublicKey = createPublicKey(secretOrPublicKey); + secretOrPublicKey = crypto.createPublicKey(secretOrPublicKey); } catch (_) { try { - secretOrPublicKey = createSecretKey(typeof secretOrPublicKey === 'string' ? Buffer.from(secretOrPublicKey) : secretOrPublicKey); + secretOrPublicKey = crypto.createSecretKey(typeof secretOrPublicKey === 'string' ? Buffer.from(secretOrPublicKey) : secretOrPublicKey); } catch (_) { return done(new JsonWebTokenError('secretOrPublicKey is not valid key material')) } @@ -132,12 +214,20 @@ module.exports = function (jwtString, secretOrPublicKey, options, callback) { 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 + switch (secretOrPublicKey.asymmetricKeyType) { + case 'rsa': + options.algorithms = [].concat(RSA_KEY_ALGS, RSA_PSS_KEY_ALGS); + break; + case 'rsa-pss': + options.algorithms = RSA_PSS_KEY_ALGS; + break; + case 'ec': + options.algorithms = EC_KEY_ALGS; + break; + default: + options.algorithms = PUB_KEY_ALGS; + } } } @@ -161,103 +251,54 @@ module.exports = function (jwtString, secretOrPublicKey, options, callback) { let valid; - try { - valid = jws.verify(jwtString, decodedToken.header.alg, secretOrPublicKey); - } catch (e) { - return done(e); - } - - if (!valid) { - return done(new JsonWebTokenError('invalid signature')); - } - - const payload = decodedToken.payload; - - if (typeof payload.nbf !== 'undefined' && !options.ignoreNotBefore) { - if (typeof payload.nbf !== 'number') { - return done(new JsonWebTokenError('invalid nbf value')); - } - if (payload.nbf > clockTimestamp + (options.clockTolerance || 0)) { - return done(new NotBeforeError('jwt not active', new Date(payload.nbf * 1000))); - } - } - - if (typeof payload.exp !== 'undefined' && !options.ignoreExpiration) { - if (typeof payload.exp !== 'number') { - return done(new JsonWebTokenError('invalid exp value')); - } - if (clockTimestamp >= payload.exp + (options.clockTolerance || 0)) { - return done(new TokenExpiredError('jwt expired', new Date(payload.exp * 1000))); - } - } - - if (options.audience) { - const audiences = Array.isArray(options.audience) ? options.audience : [options.audience]; - const target = Array.isArray(payload.aud) ? payload.aud : [payload.aud]; - - const match = target.some(function (targetAudience) { - return audiences.some(function (audience) { - return audience instanceof RegExp ? audience.test(targetAudience) : audience === targetAudience; - }); - }); + const data = Buffer.from(`${parts[0]}.${parts[1]}`); + const signature = Buffer.from(parts[2], 'base64'); - if (!match) { - return done(new JsonWebTokenError('jwt audience invalid. expected: ' + audiences.join(' or '))); - } - } + const sync = decodedToken.header.alg === 'none' || decodedToken.header.alg.startsWith('HS') || done !== callback || parseInt(process.versions.node, 10) < 16 - if (options.issuer) { - const invalid_issuer = - (typeof options.issuer === 'string' && payload.iss !== options.issuer) || - (Array.isArray(options.issuer) && options.issuer.indexOf(payload.iss) === -1); - - if (invalid_issuer) { - return done(new JsonWebTokenError('jwt issuer invalid. expected: ' + options.issuer)); + if (decodedToken.header.alg === 'none') { + valid = parts[2] === ''; + if (!valid) { + return done(new JsonWebTokenError('invalid signature')); } + return processPayload(header, decodedToken.payload, parts[2], options, done); } - if (options.subject) { - if (payload.sub !== options.subject) { - return done(new JsonWebTokenError('jwt subject invalid. expected: ' + options.subject)); - } - } - - if (options.jwtid) { - if (payload.jti !== options.jwtid) { - return done(new JsonWebTokenError('jwt jwtid invalid. expected: ' + options.jwtid)); + if (decodedToken.header.alg.startsWith('HS')) { + try { + const expected = signature; + const actual = crypto.createHmac(`sha${decodedToken.header.alg.substring(2, 5)}`, secretOrPublicKey).update(data).digest(); + valid = crypto.timingSafeEqual(expected, actual); + if (!valid) { + return done(new JsonWebTokenError('invalid signature')); + } + return processPayload(header, decodedToken.payload, signature, options, done); + } catch (e) { + return done(e); } } - if (options.nonce) { - if (payload.nonce !== options.nonce) { - return done(new JsonWebTokenError('jwt nonce invalid. expected: ' + options.nonce)); + if (sync) { + try { + const { digest, key } = oneShotAlgs(decodedToken.header.alg, secretOrPublicKey); + const valid = crypto.verify(digest, data, key, signature); + if (!valid) { + return done(new JsonWebTokenError('invalid signature')); + } + return processPayload(header, decodedToken.payload, parts[2], options, done); + } catch (e) { + return done(e); } } - if (options.maxAge) { - if (typeof payload.iat !== 'number') { - return done(new JsonWebTokenError('iat required when maxAge is specified')); + const { digest, key } = oneShotAlgs(decodedToken.header.alg, secretOrPublicKey); + crypto.verify(digest, data, key, signature, (err, valid) => { + if (err) { + return done(err); + } else if (!valid) { + return done(new JsonWebTokenError('invalid signature')); } - - 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')); - } - if (clockTimestamp >= maxAgeTimestamp + (options.clockTolerance || 0)) { - return done(new TokenExpiredError('maxAge exceeded', new Date(maxAgeTimestamp * 1000))); - } - } - - if (options.complete === true) { - const signature = decodedToken.signature; - - return done(null, { - header: header, - payload: payload, - signature: signature - }); - } - - return done(null, payload); + return processPayload(header, decodedToken.payload, parts[2], options, done); + }); }); }; From 75d947c26175775b332a72ea89f30dda4019d255 Mon Sep 17 00:00:00 2001 From: Filip Skokan Date: Wed, 11 Sep 2024 13:33:29 +0200 Subject: [PATCH 3/7] test: remove deprecated use of Buffer() --- test/buffer.tests.js | 2 +- test/claim-aud.test.js | 2 +- test/claim-iss.test.js | 2 +- test/claim-jti.test.js | 2 +- test/claim-nbf.test.js | 2 +- test/claim-sub.tests.js | 2 +- test/header-kid.test.js | 2 +- 7 files changed, 7 insertions(+), 7 deletions(-) diff --git a/test/buffer.tests.js b/test/buffer.tests.js index 612d171b..1b3b2f67 100644 --- a/test/buffer.tests.js +++ b/test/buffer.tests.js @@ -3,7 +3,7 @@ var assert = require('chai').assert; describe('buffer payload', function () { it('should work', function () { - var payload = new Buffer('TkJyotZe8NFpgdfnmgINqg==', 'base64'); + var payload = Buffer.from('TkJyotZe8NFpgdfnmgINqg==', 'base64'); var token = jwt.sign(payload, "signing key"); assert.equal(jwt.decode(token), payload.toString()); }); diff --git a/test/claim-aud.test.js b/test/claim-aud.test.js index 3a27fd89..d48f2129 100644 --- a/test/claim-aud.test.js +++ b/test/claim-aud.test.js @@ -77,7 +77,7 @@ describe('audience', function() { }); it('should error with a Buffer payload', function (done) { - signWithAudience('my_aud', new Buffer('a Buffer payload'), (err) => { + signWithAudience('my_aud', Buffer.from('a Buffer payload'), (err) => { testUtils.asyncCheck(done, () => { expect(err).to.be.instanceOf(Error); expect(err).to.have.property('message', 'invalid audience option for object payload'); diff --git a/test/claim-iss.test.js b/test/claim-iss.test.js index 1b1b72f9..7bee085a 100644 --- a/test/claim-iss.test.js +++ b/test/claim-iss.test.js @@ -77,7 +77,7 @@ describe('issuer', function() { }); it('should error with a Buffer payload', function (done) { - signWithIssuer('foo', new Buffer('a Buffer payload'), (err) => { + signWithIssuer('foo', Buffer.from('a Buffer payload'), (err) => { testUtils.asyncCheck(done, () => { expect(err).to.be.instanceOf(Error); expect(err).to.have.property( diff --git a/test/claim-jti.test.js b/test/claim-jti.test.js index 9721f7c7..f8dd0f4c 100644 --- a/test/claim-jti.test.js +++ b/test/claim-jti.test.js @@ -77,7 +77,7 @@ describe('jwtid', function() { }); it('should error with a Buffer payload', function (done) { - signWithJWTId('foo', new Buffer('a Buffer payload'), (err) => { + signWithJWTId('foo', Buffer.from('a Buffer payload'), (err) => { testUtils.asyncCheck(done, () => { expect(err).to.be.instanceOf(Error); expect(err).to.have.property( diff --git a/test/claim-nbf.test.js b/test/claim-nbf.test.js index 72397de1..6f0bae96 100644 --- a/test/claim-nbf.test.js +++ b/test/claim-nbf.test.js @@ -80,7 +80,7 @@ describe('not before', function() { }); it('should error with a Buffer payload', function (done) { - signWithNotBefore(100, new Buffer('a Buffer payload'), (err) => { + signWithNotBefore(100, Buffer.from('a Buffer payload'), (err) => { testUtils.asyncCheck(done, () => { expect(err).to.be.instanceOf(Error); expect(err).to.have.property('message', 'invalid notBefore option for object payload'); diff --git a/test/claim-sub.tests.js b/test/claim-sub.tests.js index a65b39ec..1809ad0a 100644 --- a/test/claim-sub.tests.js +++ b/test/claim-sub.tests.js @@ -77,7 +77,7 @@ describe('subject', function() { }); it('should error with a Buffer payload', function (done) { - signWithSubject('foo', new Buffer('a Buffer payload'), (err) => { + signWithSubject('foo', Buffer.from('a Buffer payload'), (err) => { testUtils.asyncCheck(done, () => { expect(err).to.be.instanceOf(Error); expect(err).to.have.property( diff --git a/test/header-kid.test.js b/test/header-kid.test.js index e419067a..be2158ad 100644 --- a/test/header-kid.test.js +++ b/test/header-kid.test.js @@ -75,7 +75,7 @@ describe('keyid', function() { }); it('should add "kid" header when "keyid" option is provided and a Buffer payload', function(done) { - signWithKeyId('foo', new Buffer('a Buffer payload'), (err, token) => { + signWithKeyId('foo', Buffer.from('a Buffer payload'), (err, token) => { testUtils.asyncCheck(done, () => { const decoded = jwt.decode(token, {complete: true}); expect(err).to.be.null; From eb18c08daedbb3d1ddbaae919b58649e2d2d07dd Mon Sep 17 00:00:00 2001 From: Filip Skokan Date: Wed, 11 Sep 2024 13:35:01 +0200 Subject: [PATCH 4/7] feat: add support for curve secp256k1 (ES256K) --- lib/oneShotAlgs.js | 5 +++++ lib/validateAsymmetricKey.js | 3 ++- sign.js | 2 +- test/jwt.asymmetric_signing.tests.js | 5 +++++ test/roundtrip.test.js | 1 + test/schema.tests.js | 2 ++ test/secp256k1-private.pem | 5 +++++ test/secp256k1-public-invalid.pem | 4 ++++ test/secp256k1-public.pem | 4 ++++ verify.js | 2 +- 10 files changed, 30 insertions(+), 3 deletions(-) create mode 100644 test/secp256k1-private.pem create mode 100644 test/secp256k1-public-invalid.pem create mode 100644 test/secp256k1-public.pem diff --git a/lib/oneShotAlgs.js b/lib/oneShotAlgs.js index 2be1359e..8e3550cb 100644 --- a/lib/oneShotAlgs.js +++ b/lib/oneShotAlgs.js @@ -37,6 +37,11 @@ module.exports = function(alg, key) { digest: 'sha256', key: { key, dsaEncoding: 'ieee-p1363' }, }; + case 'ES256K': + return { + digest: 'sha256', + key: { key, dsaEncoding: 'ieee-p1363' }, + }; case 'ES384': return { digest: 'sha384', diff --git a/lib/validateAsymmetricKey.js b/lib/validateAsymmetricKey.js index 447bd912..77d5bf3e 100644 --- a/lib/validateAsymmetricKey.js +++ b/lib/validateAsymmetricKey.js @@ -1,13 +1,14 @@ const { ASYMMETRIC_KEY_DETAILS_SUPPORTED, RSA_PSS_KEY_DETAILS_SUPPORTED } = require('./flags'); const allowedAlgorithmsForKeys = { - 'ec': ['ES256', 'ES384', 'ES512'], + 'ec': ['ES256', 'ES256K', 'ES384', 'ES512'], 'rsa': ['RS256', 'PS256', 'RS384', 'PS384', 'RS512', 'PS512'], 'rsa-pss': ['PS256', 'PS384', 'PS512'] }; const allowedCurves = { ES256: 'prime256v1', + ES256K: 'secp256k1', ES384: 'secp384r1', ES512: 'secp521r1', }; diff --git a/sign.js b/sign.js index 942b2dd2..d726f437 100644 --- a/sign.js +++ b/sign.js @@ -13,7 +13,7 @@ const encodeBase64url = require('./lib/base64url'); const SUPPORTED_ALGS = [ 'RS256', 'RS384', 'RS512', 'PS256', 'PS384', 'PS512', - 'ES256', 'ES384', 'ES512', + 'ES256', 'ES256K', 'ES384', 'ES512', 'HS256', 'HS384', 'HS512', 'none', ]; diff --git a/test/jwt.asymmetric_signing.tests.js b/test/jwt.asymmetric_signing.tests.js index dfd1d385..a0f84151 100644 --- a/test/jwt.asymmetric_signing.tests.js +++ b/test/jwt.asymmetric_signing.tests.js @@ -21,6 +21,11 @@ const algorithms = { pub_key: loadKey('ecdsa-public.pem'), invalid_pub_key: loadKey('ecdsa-public-invalid.pem') }, + ES256K: { + priv_key: loadKey('secp256k1-private.pem'), + pub_key: loadKey('secp256k1-public.pem'), + invalid_pub_key: loadKey('secp256k1-public-invalid.pem') + }, PS256: { pub_key: loadKey('pub.pem'), priv_key: loadKey('priv.pem'), diff --git a/test/roundtrip.test.js b/test/roundtrip.test.js index 0995c5fa..2b3c2635 100644 --- a/test/roundtrip.test.js +++ b/test/roundtrip.test.js @@ -11,6 +11,7 @@ for (const [alg, opts] of [ ["RS256"], ["PS256"], ["ES256"], + ["ES256K"], ["ES384"], ["ES512"], ]) { diff --git a/test/schema.tests.js b/test/schema.tests.js index 0033ab41..6def7213 100644 --- a/test/schema.tests.js +++ b/test/schema.tests.js @@ -7,6 +7,7 @@ describe('schema', function() { describe('sign options', function() { var cert_rsa_priv = fs.readFileSync(__dirname + '/rsa-private.pem'); var cert_ecdsa_priv = fs.readFileSync(__dirname + '/ecdsa-private.pem'); + var cert_secp256k1_priv = fs.readFileSync(__dirname + '/secp256k1-private.pem'); var cert_secp384r1_priv = fs.readFileSync(__dirname + '/secp384r1-private.pem'); var cert_secp521r1_priv = fs.readFileSync(__dirname + '/secp521r1-private.pem'); @@ -26,6 +27,7 @@ describe('schema', function() { sign({algorithm: 'PS384'}, cert_rsa_priv); sign({algorithm: 'PS512'}, cert_rsa_priv); sign({algorithm: 'ES256'}, cert_ecdsa_priv); + sign({algorithm: 'ES256K'}, cert_secp256k1_priv); sign({algorithm: 'ES384'}, cert_secp384r1_priv); sign({algorithm: 'ES512'}, cert_secp521r1_priv); sign({algorithm: 'HS256'}, 'superSecret'); diff --git a/test/secp256k1-private.pem b/test/secp256k1-private.pem new file mode 100644 index 00000000..1d05b814 --- /dev/null +++ b/test/secp256k1-private.pem @@ -0,0 +1,5 @@ +-----BEGIN EC PRIVATE KEY----- +MHQCAQEEIFg3x9PMwysC/B5iW1zUFqDUfNbgP77i71jEPhoce0OkoAcGBSuBBAAK +oUQDQgAEUdPp6J0l51augh0A0sB14n2j69er1ZTkhfv+XY3CIU/SFK/BmIt0KfAX +VF2KGowflLSKkySNnfR93uwnf7y1MQ== +-----END EC PRIVATE KEY----- diff --git a/test/secp256k1-public-invalid.pem b/test/secp256k1-public-invalid.pem new file mode 100644 index 00000000..5a84eed6 --- /dev/null +++ b/test/secp256k1-public-invalid.pem @@ -0,0 +1,4 @@ +-----BEGIN PUBLIC KEY----- +MFYwEAYHKoZIzj0CAQYFK4EEAAoDQgAE7cjAbx1KnvP+g5lJQba/42ga/NL5rkIC +rmuRulSLZ+X6oRvnxfhgDkQgkoJkNaqXR6vYE42kfbz5BOfIcNfkig== +-----END PUBLIC KEY----- diff --git a/test/secp256k1-public.pem b/test/secp256k1-public.pem new file mode 100644 index 00000000..aaa8fc98 --- /dev/null +++ b/test/secp256k1-public.pem @@ -0,0 +1,4 @@ +-----BEGIN PUBLIC KEY----- +MFYwEAYHKoZIzj0CAQYFK4EEAAoDQgAEUdPp6J0l51augh0A0sB14n2j69er1ZTk +hfv+XY3CIU/SFK/BmIt0KfAXVF2KGowflLSKkySNnfR93uwnf7y1MQ== +-----END PUBLIC KEY----- diff --git a/verify.js b/verify.js index 2f4dd992..c5dd78fc 100644 --- a/verify.js +++ b/verify.js @@ -7,7 +7,7 @@ const validateAsymmetricKey = require('./lib/validateAsymmetricKey'); const crypto = require("crypto"); const oneShotAlgs = require('./lib/oneShotAlgs'); -const EC_KEY_ALGS = ['ES256', 'ES384', 'ES512']; +const EC_KEY_ALGS = ['ES256', 'ES256K', 'ES384', 'ES512']; const RSA_KEY_ALGS = ['RS256', 'RS384', 'RS512']; const RSA_PSS_KEY_ALGS = ['PS256', 'PS384', 'PS512']; const PUB_KEY_ALGS = [].concat(RSA_KEY_ALGS, EC_KEY_ALGS); From f5081998f20af8e399f623b340d8aa9e26b899ee Mon Sep 17 00:00:00 2001 From: Filip Skokan Date: Wed, 11 Sep 2024 13:38:42 +0200 Subject: [PATCH 5/7] feat: add support for Ed25519 and Ed448 (EdDSA) --- lib/oneShotAlgs.js | 5 +++++ lib/validateAsymmetricKey.js | 2 ++ sign.js | 1 + test/ed25519-private.pem | 3 +++ test/ed25519-public-invalid.pem | 3 +++ test/ed25519-public.pem | 3 +++ test/jwt.asymmetric_signing.tests.js | 5 +++++ test/roundtrip.test.js | 2 ++ test/schema.tests.js | 2 ++ verify.js | 5 +++++ 10 files changed, 31 insertions(+) create mode 100644 test/ed25519-private.pem create mode 100644 test/ed25519-public-invalid.pem create mode 100644 test/ed25519-public.pem diff --git a/lib/oneShotAlgs.js b/lib/oneShotAlgs.js index 8e3550cb..f976f5c0 100644 --- a/lib/oneShotAlgs.js +++ b/lib/oneShotAlgs.js @@ -52,6 +52,11 @@ module.exports = function(alg, key) { digest: 'sha512', key: { key, dsaEncoding: 'ieee-p1363' }, }; + case 'EdDSA': + return { + digest: undefined, + key: { key }, + }; default: throw new Error('unreachable'); } diff --git a/lib/validateAsymmetricKey.js b/lib/validateAsymmetricKey.js index 77d5bf3e..ab0c7a60 100644 --- a/lib/validateAsymmetricKey.js +++ b/lib/validateAsymmetricKey.js @@ -2,6 +2,8 @@ const { ASYMMETRIC_KEY_DETAILS_SUPPORTED, RSA_PSS_KEY_DETAILS_SUPPORTED } = requ const allowedAlgorithmsForKeys = { 'ec': ['ES256', 'ES256K', 'ES384', 'ES512'], + 'ed25519': ['EdDSA'], + 'ed448': ['EdDSA'], 'rsa': ['RS256', 'PS256', 'RS384', 'PS384', 'RS512', 'PS512'], 'rsa-pss': ['PS256', 'PS384', 'PS512'] }; diff --git a/sign.js b/sign.js index d726f437..11974944 100644 --- a/sign.js +++ b/sign.js @@ -15,6 +15,7 @@ const SUPPORTED_ALGS = [ 'PS256', 'PS384', 'PS512', 'ES256', 'ES256K', 'ES384', 'ES512', 'HS256', 'HS384', 'HS512', + 'EdDSA', 'none', ]; diff --git a/test/ed25519-private.pem b/test/ed25519-private.pem new file mode 100644 index 00000000..770ffcce --- /dev/null +++ b/test/ed25519-private.pem @@ -0,0 +1,3 @@ +-----BEGIN PRIVATE KEY----- +MC4CAQAwBQYDK2VwBCIEINm0OEjPHWFVPXX+RWO48diNrzeWvhxLYT0UfBHb6ZBA +-----END PRIVATE KEY----- diff --git a/test/ed25519-public-invalid.pem b/test/ed25519-public-invalid.pem new file mode 100644 index 00000000..54ff4873 --- /dev/null +++ b/test/ed25519-public-invalid.pem @@ -0,0 +1,3 @@ +-----BEGIN PUBLIC KEY----- +MCowBQYDK2VwAyEAnbt7ZRTDvGWNmgiJQ+oOodLqvFS0fl1mlRHTaetHI0Q= +-----END PUBLIC KEY----- diff --git a/test/ed25519-public.pem b/test/ed25519-public.pem new file mode 100644 index 00000000..67669f1e --- /dev/null +++ b/test/ed25519-public.pem @@ -0,0 +1,3 @@ +-----BEGIN PUBLIC KEY----- +MCowBQYDK2VwAyEAbelG8IgnkVHYUdI5CN54QDdYkvgJkeDc7V8EVBN6zVg= +-----END PUBLIC KEY----- diff --git a/test/jwt.asymmetric_signing.tests.js b/test/jwt.asymmetric_signing.tests.js index a0f84151..28eb282a 100644 --- a/test/jwt.asymmetric_signing.tests.js +++ b/test/jwt.asymmetric_signing.tests.js @@ -26,6 +26,11 @@ const algorithms = { pub_key: loadKey('secp256k1-public.pem'), invalid_pub_key: loadKey('secp256k1-public-invalid.pem') }, + EdDSA: { + priv_key: loadKey('ed25519-private.pem'), + pub_key: loadKey('ed25519-public.pem'), + invalid_pub_key: loadKey('ed25519-public-invalid.pem') + }, PS256: { pub_key: loadKey('pub.pem'), priv_key: loadKey('priv.pem'), diff --git a/test/roundtrip.test.js b/test/roundtrip.test.js index 2b3c2635..1748fffc 100644 --- a/test/roundtrip.test.js +++ b/test/roundtrip.test.js @@ -14,6 +14,8 @@ for (const [alg, opts] of [ ["ES256K"], ["ES384"], ["ES512"], + ["EdDSA", { crv: "Ed25519" }], + ["EdDSA", { crv: "Ed448" }], ]) { const conditionalDescribe = parseInt(process.versions.node, 10) >= 18 ? describe : describe.skip; diff --git a/test/schema.tests.js b/test/schema.tests.js index 6def7213..4e55856b 100644 --- a/test/schema.tests.js +++ b/test/schema.tests.js @@ -10,6 +10,7 @@ describe('schema', function() { var cert_secp256k1_priv = fs.readFileSync(__dirname + '/secp256k1-private.pem'); var cert_secp384r1_priv = fs.readFileSync(__dirname + '/secp384r1-private.pem'); var cert_secp521r1_priv = fs.readFileSync(__dirname + '/secp521r1-private.pem'); + var cert_ed25519_priv = fs.readFileSync(__dirname + '/ed25519-private.pem'); function sign(options, secretOrPrivateKey) { jwt.sign({foo: 123}, secretOrPrivateKey, options); @@ -30,6 +31,7 @@ describe('schema', function() { sign({algorithm: 'ES256K'}, cert_secp256k1_priv); sign({algorithm: 'ES384'}, cert_secp384r1_priv); sign({algorithm: 'ES512'}, cert_secp521r1_priv); + sign({algorithm: 'EdDSA'}, cert_ed25519_priv); sign({algorithm: 'HS256'}, 'superSecret'); sign({algorithm: 'HS384'}, 'superSecret'); sign({algorithm: 'HS512'}, 'superSecret'); diff --git a/verify.js b/verify.js index c5dd78fc..be79c93e 100644 --- a/verify.js +++ b/verify.js @@ -12,6 +12,7 @@ const RSA_KEY_ALGS = ['RS256', 'RS384', 'RS512']; const RSA_PSS_KEY_ALGS = ['PS256', 'PS384', 'PS512']; const PUB_KEY_ALGS = [].concat(RSA_KEY_ALGS, EC_KEY_ALGS); const HS_ALGS = ['HS256', 'HS384', 'HS512']; +const EdDSA_ALGS = ['EdDSA']; function processPayload(header, payload, signature, options, done) { const clockTimestamp = options.clockTimestamp || Math.floor(Date.now() / 1000); @@ -225,6 +226,10 @@ module.exports = function(jwtString, secretOrPublicKey, options, callback) { case 'ec': options.algorithms = EC_KEY_ALGS; break; + case 'ed25519': + case 'ed448': + options.algorithms = EdDSA_ALGS; + break; default: options.algorithms = PUB_KEY_ALGS; } From e378449b0f019290c240f954ac313f96be130c40 Mon Sep 17 00:00:00 2001 From: Filip Skokan Date: Thu, 12 Sep 2024 09:29:07 +0200 Subject: [PATCH 6/7] docs: update README.md --- README.md | 32 ++++++++++++++++++++------------ 1 file changed, 20 insertions(+), 12 deletions(-) diff --git a/README.md b/README.md index 4e20dd9c..458e4361 100644 --- a/README.md +++ b/README.md @@ -33,8 +33,8 @@ $ 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 (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. +`secretOrPrivateKey` is a [KeyObject] (preferred), string (utf-8 encoded), buffer, object, or any valid input to [crypto.createPrivateKey] or [crypto.createSecretKey] containing either the secret for HMAC algorithms or the private key for RSA, ECDSA, or EdDSA. + 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`: @@ -52,8 +52,8 @@ When signing with RSA algorithms the minimum modulus length is 2048 except when * `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 -* `allowInvalidAsymmetricKeyTypes`: if true, allows asymmetric keys which do not match the specified algorithm. This option is intended only for backwards compatability and should be avoided. +* `allowInsecureKeySizes`: if true allows private keys with a modulus below 2048 to be used for RSA. This option is intended only for backwards compatibility and should be avoided. +* `allowInvalidAsymmetricKeyTypes`: if true, allows asymmetric keys which do not match the specified algorithm. This option is intended only for backwards compatibility and should be avoided. @@ -133,8 +133,8 @@ jwt.sign({ `token` is the JsonWebToken string -`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. +`secretOrPublicKey` is a [KeyObject] (preferred), string (utf-8 encoded), buffer, object, or any valid input to [crypto.createPublicKey] or [crypto.createSecretKey] containing either the secret for HMAC algorithms or the private key for RSA, ECDSA, or EdDSA. + 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 As mentioned in [this comment](https://github.com/auth0/node-jsonwebtoken/issues/208#issuecomment-231861138), there are other libraries that expect base64 encoded secrets (random bytes encoded using base64), if that is your case you can pass `Buffer.from(secret, 'base64')`, by doing this the secret will be decoded using base64 and the token verification will use the original random bytes. @@ -144,9 +144,10 @@ As mentioned in [this comment](https://github.com/auth0/node-jsonwebtoken/issues * `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'] + > * rsa - ['RS256', 'RS384', 'RS512', 'PS256', 'PS384', 'PS512'] + > * rsa-pss - ['PS256', 'PS384', 'PS512'] + > * ec - ['ES256', 'ES256K', 'ES384', 'ES512'] + > * ed25519, ed448 - ['EdDSA'] * `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. @@ -160,7 +161,7 @@ As mentioned in [this comment](https://github.com/auth0/node-jsonwebtoken/issues > Eg: `1000`, `"2 days"`, `"10h"`, `"7d"`. A numeric value is interpreted as a seconds count. If you use a string be sure you provide the time units (days, hours, etc), otherwise milliseconds unit is used by default (`"120"` is equal to `"120ms"`). * `clockTimestamp`: the time in seconds that should be used as the current time for all necessary comparisons. * `nonce`: if you want to check `nonce` claim, provide a string value here. It is used on Open ID for the ID Tokens. ([Open ID implementation notes](https://openid.net/specs/openid-connect-core-1_0.html#NonceNotes)) -* `allowInvalidAsymmetricKeyTypes`: if true, allows asymmetric keys which do not match the specified algorithm. This option is intended only for backwards compatability and should be avoided. +* `allowInvalidAsymmetricKeyTypes`: if true, allows asymmetric keys which do not match the specified algorithm. This option is intended only for backwards compatibility and should be avoided. ```js // verify a token symmetric - synchronous @@ -352,9 +353,9 @@ jwt.verify(token, 'shhhhh', function(err, decoded) { ``` -## Algorithms supported +## Supported algorithms -Array of supported algorithms. The following algorithms are currently supported. +The following algorithms from the [IANA registry](https://www.iana.org/assignments/jose/jose.xhtml#web-signature-encryption-algorithms) are supported. | alg Parameter Value | Digital Signature or MAC Algorithm | |---------------------|------------------------------------------------------------------------| @@ -368,8 +369,10 @@ Array of supported algorithms. The following algorithms are currently supported. | 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 | +| ES256K | ECDSA using secp256k1 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 | +| EdDSA | EdDSA using Ed25519 or Ed448 | | none | No digital signature or MAC value included | ## Refreshing JWTs @@ -394,3 +397,8 @@ If you have found a bug or if you have a feature request, please report them at ## License This project is licensed under the MIT license. See the [LICENSE](LICENSE) file for more info. + +[crypto.createPublicKey]: https://nodejs.org/api/crypto.html#cryptocreatepublickeykey +[crypto.createPrivateKey]: https://nodejs.org/api/crypto.html#cryptocreateprivatekeykey +[crypto.createSecretKey]: https://nodejs.org/api/crypto.html#cryptocreatesecretkeykey-encoding +[KeyObject]: https://nodejs.org/api/crypto.html#class-keyobject From 852147ac06c49696b1adcb1cec44e1828580da8b Mon Sep 17 00:00:00 2001 From: Filip Skokan Date: Thu, 12 Sep 2024 09:32:46 +0200 Subject: [PATCH 7/7] docs: update README.md --- README.md | 6 ------ 1 file changed, 6 deletions(-) diff --git a/README.md b/README.md index 458e4361..f43c2884 100644 --- a/README.md +++ b/README.md @@ -1,14 +1,8 @@ # jsonwebtoken -| **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) | - An implementation of [JSON Web Tokens](https://tools.ietf.org/html/rfc7519). -This was developed against `draft-ietf-oauth-json-web-token-08`. It makes use of [node-jws](https://github.com/brianloveswords/node-jws) - # Install ```bash