From e57384cbf84cc30d8cc0be2b1f881107c4c74577 Mon Sep 17 00:00:00 2001 From: "Matthew A. Miller" Date: Thu, 12 Nov 2015 13:35:31 -0700 Subject: [PATCH] Update: implement JWK thumbprint support [RFC 7638] --- README.md | 20 +++++++++++ lib/jwk/basekey.js | 54 +++++++++++++++++++++++++++--- lib/jwk/eckey.js | 28 ++++++++++++++-- lib/jwk/helpers.js | 33 +++++++++++++++++- lib/jwk/octkey.js | 26 +++++++++++++-- lib/jwk/rsakey.js | 30 ++++++++++++++--- test/jwk/basekey-test.js | 70 +++++++++++++++++++++++++++++++++++++++ test/jwk/eckey-test.js | 28 ++++++++++++++++ test/jwk/keystore-test.js | 16 +++++---- test/jwk/octkey-test.js | 24 ++++++++++++++ test/jwk/rsakey-test.js | 26 +++++++++++++++ 11 files changed, 334 insertions(+), 21 deletions(-) diff --git a/README.md b/README.md index bbbc055..b8b3bf5 100644 --- a/README.md +++ b/README.md @@ -15,6 +15,7 @@ A JavaScript implementation of the JSON Object Signing and Encryption (JOSE) for - [Searching for Keys](#searching-for-keys) - [Managing Keys](#managing-keys) - [Importing and Exporting a Single Key](#importing-and-exporting-a-single-key) + - [Obtaining a Key's Thumbprint](#obtaining-a-keys-thumbprint) - [Signatures](#signatures) - [Signing Content](#signing-content) - [Verifying a JWS](#verifying-a-jws) @@ -269,6 +270,25 @@ To export the public **and** private portions of a Key: var output = key.toJSON(true); ``` +### Obtaining a Key's Thumbprint ### + +To get or calculate a [RFC 7638](https://tools.ietf.org/html/rfc7638) thumbprint for a key: + +``` +// where hash is a supported algorithm, currently one of: +// * SHA-1 +// * SHA-256 +// * SHA-384 +// * SHA-512 +key.thumbprint(hash). + then(function(print) { + // {print} is a Buffer containing the thumbprint binary value + }); +``` + +When importing or generating a key that does not have a "kid" defined, a +"SHA-256" thumbprint is calculated and used as the "kid". + ## Signatures ## ### Signing Content ### diff --git a/lib/jwk/basekey.js b/lib/jwk/basekey.js index 9dd406b..709c19e 100644 --- a/lib/jwk/basekey.js +++ b/lib/jwk/basekey.js @@ -5,7 +5,8 @@ */ "use strict"; -var clone = require("lodash.clone"), +var assign = require("lodash.assign"), + clone = require("lodash.clone"), flatten = require("lodash.flatten"), intersection = require("lodash.intersection"), merge = require("../util/merge"), @@ -16,7 +17,8 @@ var clone = require("lodash.clone"), var ALGORITHMS = require("../algorithms"), CONSTANTS = require("./constants.js"), - HELPERS = require("./helpers.js"); + HELPERS = require("./helpers.js"), + UTIL = require("../util"); /** * @class JWK.Key @@ -50,12 +52,31 @@ var JWKBaseKeyObject = function(kty, ks, props, cfg) { var excluded = []; var keys = {}, - json = {}; + json = {}, + prints, + kid; - // force certain values props = clone(props); + // strip thumbprints if present + prints = assign({}, props[HELPERS.INTERNALS.THUMBPRINT_KEY] || {}); + delete props[HELPERS.INTERNALS.THUMBPRINT_KEY]; + Object.keys(prints).forEach(function(a) { + var h = prints[a]; + if (!kid) { + kid = h; + if (Buffer.isBuffer(kid)) { + kid = UTIL.base64url.encode(kid); + } + } + if (!Buffer.isBuffer(h)) { + h = UTIL.base64url.decode(h); + prints[a] = h; + } + }); + + // force certain values props.kty = kty; - props.kid = props.kid || uuid(); + props.kid = props.kid || kid || uuid(); // setup base info var included = Object.keys(HELPERS.COMMON_PROPS).map(function(p) { @@ -153,6 +174,29 @@ var JWKBaseKeyObject = function(kty, ks, props, cfg) { }); // ### Public Methods ### + /** + * Generates the thumbprint of this Key. + * + * @param {String} [] The hash algorithm to use + * @returns {Promise} The promise for the thumbprint generation. + */ + Object.defineProperty(this, "thumbprint", { + value: function(hash) { + hash = (hash || HELPERS.INTERNALS.THUMBPRINT_HASH).toUpperCase(); + if (prints[hash]) { + // return cached value + return Promise.resolve(prints[hash]); + } + var p = HELPERS.thumbprint(cfg, json, hash); + p = p.then(function(result) { + if (result) { + prints[hash] = result; + } + return result; + }); + return p; + } + }); /** * @method JWK.Key#algorithms * @description diff --git a/lib/jwk/eckey.js b/lib/jwk/eckey.js index e885f4c..bda85e4 100644 --- a/lib/jwk/eckey.js +++ b/lib/jwk/eckey.js @@ -86,6 +86,18 @@ var JWKEcCfg = { return pk; }, + thumbprint: function(json) { + if (json.public) { + json = json.public; + } + var fields = { + crv: json.crv, + kty: "EC", + x: json.x, + y: json.y + }; + return fields; + }, algorithms: function(keys, mode) { var len = (keys.public && keys.public.length) || (keys.private && keys.private.length) || @@ -294,8 +306,20 @@ var validators = { var JWKEcFactory = { kty: "EC", validators: validators, - prepare: function() { - return Promise.resolve(JWKEcCfg); + prepare: function(props) { + // TODO: validate key properties + var cfg = JWKEcCfg; + var p = Promise.resolve(props); + p = p.then(function(json) { + return JWK.helpers.thumbprint(cfg, json); + }); + p = p.then(function(hash) { + var prints = {}; + prints[JWK.helpers.INTERNALS.THUMBPRINT_HASH] = hash; + props[JWK.helpers.INTERNALS.THUMBPRINT_KEY] = prints; + return cfg; + }); + return p; }, generate: function(size) { var keypair = depsecc.generateKeyPair(size); diff --git a/lib/jwk/helpers.js b/lib/jwk/helpers.js index 24a0229..67d9a00 100644 --- a/lib/jwk/helpers.js +++ b/lib/jwk/helpers.js @@ -9,6 +9,8 @@ var clone = require("lodash.clone"), util = require("../util"), forge = require("../deps/forge"); +var ALGORITHMS = require("../algorithms"); + // ### ASN.1 Validators // Adapted from digitalbazaar/node-forge/js/asn1.js // PrivateKeyInfo @@ -289,6 +291,11 @@ var X509CertificateValidator = { ] }; +var INTERNALS = { + THUMBPRINT_KEY: "internal\u0000thumbprint", + THUMBPRINT_HASH: "SHA-256" +}; + module.exports = { validators: { privateKey: privateKeyValidator, @@ -296,6 +303,29 @@ module.exports = { certificate: X509CertificateValidator }, + thumbprint: function(cfg, json, hash) { + if ("function" !== typeof cfg.thumbprint) { + return Promise.reject(new Error("thumbprint not supported")); + } + + hash = (hash || INTERNALS.THUMBPRINT_HASH).toUpperCase(); + var fields = cfg.thumbprint(json); + var input = Object.keys(fields). + sort(). + map(function(k) { + var v = fields[k]; + if (Buffer.isBuffer(v)) { + v = util.base64url.encode(v); + } + return JSON.stringify(k) + ":" + JSON.stringify(v); + }); + input = "{" + input.join(",") + "}"; + try { + return ALGORITHMS.digest(hash, new Buffer(input, "utf8")); + } catch (err) { + return Promise.reject(err); + } + }, unpackProps: function(props, allowed) { var output; @@ -353,5 +383,6 @@ module.exports = { {name: "x5t", type: "binary"}, {name: "x5u", type: "string"}, {name: "key_ops", type: "array"} - ] + ], + INTERNALS: INTERNALS }; diff --git a/lib/jwk/octkey.js b/lib/jwk/octkey.js index ca4ac6c..40d9064 100644 --- a/lib/jwk/octkey.js +++ b/lib/jwk/octkey.js @@ -106,6 +106,17 @@ var JWKOctetCfg = { return pk; }, + thumbprint: function(json) { + if (json.private) { + json = json.private; + } + var fields; + fields = { + k: json.k || "", + kty: "oct" + }; + return fields; + }, algorithms: function(keys, mode) { var len = keys.private && (keys.private.k.length * 8); var mins = [256, 384, 512]; @@ -175,9 +186,20 @@ var JWKOctetCfg = { // Factory var JWKOctetFactory = { kty: "oct", - prepare: function() { + prepare: function(props) { // TODO: validate key properties - return Promise.resolve(JWKOctetCfg); + var cfg = JWKOctetCfg; + var p = Promise.resolve(props); + p = p.then(function(json) { + return JWK.helpers.thumbprint(cfg, json); + }); + p = p.then(function(hash) { + var prints = {}; + prints[JWK.helpers.INTERNALS.THUMBPRINT_HASH] = hash; + props[JWK.helpers.INTERNALS.THUMBPRINT_KEY] = prints; + return cfg; + }); + return p; }, generate: function(size) { // TODO: validate key sizes diff --git a/lib/jwk/rsakey.js b/lib/jwk/rsakey.js index 544b4f1..54387be 100644 --- a/lib/jwk/rsakey.js +++ b/lib/jwk/rsakey.js @@ -5,8 +5,8 @@ */ "use strict"; -var forge = require("../deps/forge.js"); -var rsau = require("../algorithms/rsa-util"); +var forge = require("../deps/forge.js"), + rsau = require("../algorithms/rsa-util"); var JWK = { BaseKey: require("./basekey.js"), @@ -66,6 +66,17 @@ var JWKRsaCfg = { return pk; }, + thumbprint: function(json) { + if (json.public) { + json = json.public; + } + var fields = { + e: json.e, + kty: "RSA", + n: json.n + }; + return fields; + }, algorithms: function(keys, mode) { switch (mode) { case "encrypt": @@ -229,9 +240,20 @@ var validators = { var JWKRsaFactory = { kty: "RSA", validators: validators, - prepare: function() { + prepare: function(props) { // TODO: validate key properties - return Promise.resolve(JWKRsaCfg); + var cfg = JWKRsaCfg; + var p = Promise.resolve(props); + p = p.then(function(json) { + return JWK.helpers.thumbprint(cfg, json); + }); + p = p.then(function(hash) { + var prints = {}; + prints[JWK.helpers.INTERNALS.THUMBPRINT_HASH] = hash; + props[JWK.helpers.INTERNALS.THUMBPRINT_KEY] = prints; + return cfg; + }); + return p; }, generate: function(size) { // TODO: validate key sizes diff --git a/test/jwk/basekey-test.js b/test/jwk/basekey-test.js index 0d18d4c..cef9a23 100644 --- a/test/jwk/basekey-test.js +++ b/test/jwk/basekey-test.js @@ -40,6 +40,12 @@ describe("jwk/basekey", function() { return pk; }, + thumbprint: function(json) { + var fields = {}; + fields.pub = json.public.pub; + fields.kty = "DUMMY"; + return fields; + }, algorithms: function(keys, mode) { var supported; switch (mode) { @@ -625,6 +631,70 @@ describe("jwk/basekey", function() { }); }); + describe("thumbprints", function() { + it("returns a promise for a 'default' thumbprint", function() { + var inst; + + inst = createInstance({ + pub: "Lc3EY3_96tfej0F7Afa0TQ", + prv: "SBh6LBt1DBTeyHTvwDgSjg" + }); + var p = inst.thumbprint(); + p = p.then(function(print) { + assert.equal(print.toString("hex"), + "37e91104e7e5b0b923926844c710a100aa48fa14554e85fc901a5ebc99cd13e6"); + }); + return p; + }); + it("returns a promise for a specified thumbprint", function() { + var inst; + + inst = createInstance({ + pub: "Lc3EY3_96tfej0F7Afa0TQ", + prv: "SBh6LBt1DBTeyHTvwDgSjg" + }); + var p = inst.thumbprint("SHA-1"); + p = p.then(function(print) { + assert.equal(print.toString("hex"), + "d14514ab53c383798343e3577ad12947e84fad40"); + }); + return p; + }); + it("fails on invalid hash algoritm", function() { + var inst; + + inst = createInstance({ + pub: "Lc3EY3_96tfej0F7Afa0TQ", + prv: "SBh6LBt1DBTeyHTvwDgSjg" + }); + var p = inst.thumbprint("SHA3"); + p = p.then(function(print) { + assert.ok(false, "unexpected success"); + }, function(err) { + assert.ok(err); + }); + return p; + }); + it("returns the same thumbprint as before", function() { + var inst; + + inst = createInstance({ + pub: "Lc3EY3_96tfej0F7Afa0TQ", + prv: "SBh6LBt1DBTeyHTvwDgSjg" + }); + var p = inst.thumbprint(); + p = p.then(function(print) { + assert.equal(print.toString("hex"), + "37e91104e7e5b0b923926844c710a100aa48fa14554e85fc901a5ebc99cd13e6"); + return inst.thumbprint(); + }); + p = p.then(function(print) { + assert.equal(print.toString("hex"), + "37e91104e7e5b0b923926844c710a100aa48fa14554e85fc901a5ebc99cd13e6"); + }); + return p; + }); + }); describe("algorithms and supports", function() { it("returns all supported algorithms", function() { var inst; diff --git a/test/jwk/eckey-test.js b/test/jwk/eckey-test.js index 91e4297..524c598 100644 --- a/test/jwk/eckey-test.js +++ b/test/jwk/eckey-test.js @@ -133,6 +133,26 @@ describe("jwk/EC", function() { assert.deepEqual(actual, undefined); }); }); + describe("#thumbprint", function() { + var json = { + public: { + "kty": "EC", + "crv": "P-256", + "x": "uiOfViX69jYwnygrkPkuM0XqUlvW65WEs_7rgT3eaak", + "y": "v8S-ifVFkNLoe1TSUrNFQVj6jRbK1L8V-eZa-ngsZLM" + } + }; + it("returns required fields (minus kty)", function() { + var expected = { + "crv": "P-256", + "kty": "EC", + "x": "uiOfViX69jYwnygrkPkuM0XqUlvW65WEs_7rgT3eaak", + "y": "v8S-ifVFkNLoe1TSUrNFQVj6jRbK1L8V-eZa-ngsZLM" + }; + var actual = JWK.EC.config.thumbprint(json); + assert.deepEqual(actual, expected); + }); + }); describe("#wrapKey", function() { it("returns key value", function() { @@ -323,6 +343,10 @@ describe("jwk/EC", function() { kid: "someid" }); assert.deepEqual(key.toJSON(true), json); + return key.thumbprint(); + }); + promise = promise.then(function(print) { + assert.equal(print.toString("hex"), "c840ce5ed3b9c62facb05e82ac8e70b4fa4c47c456a5f98ae0cbe5a3e2ebcea5"); }); return promise; @@ -342,6 +366,10 @@ describe("jwk/EC", function() { kid: "someid" }); assert.deepEqual(key.toJSON(true), json); + return key.thumbprint(); + }); + promise = promise.then(function(print) { + assert.equal(print.toString("hex"), "c840ce5ed3b9c62facb05e82ac8e70b4fa4c47c456a5f98ae0cbe5a3e2ebcea5"); }); }); }); diff --git a/test/jwk/keystore-test.js b/test/jwk/keystore-test.js index 86dc51a..5c2a49d 100644 --- a/test/jwk/keystore-test.js +++ b/test/jwk/keystore-test.js @@ -395,6 +395,13 @@ describe("jwk/keystore", function() { var inst = JWK.store.KeyStore.createKeyStore(), keys = [ + { + kty: "oct", + kid: "somevalue", + k: "SBh6LBt1DBTeyHTvwDgSjg", + use: "enc", + alg: "A128GCM" + }, { "kty": "RSA", "kid": "somekey", @@ -407,13 +414,6 @@ describe("jwk/keystore", function() { "dp": "lbOdOJNFrWTKldMjXRfu7Jag8lTeETyhvH7Dx1p5zqPUCN1ETMhYUK3DuxEqjan8qmZrmbN8yAO4lTG6BHKsdCdd1R23kyI15hmZ7Lsih7uTt8Z0XBZMVYT3ZtsIW0XCgAwkvPD3j75Ha7oeToSfMbmiD94RpKq0jBQZEosadEk", "dq": "OcG2RrJMyNoRH5ukA96ebUbvJNSZ0RSk_vCuN19y6GsG5k65TChrX9Cp_SHDBWwjPldM0CZmuSB76Yv0GVJS84GdgmeW0r94KdDA2hmy-vRHUi-VLzIBwKNbJbJd6_b_hJVjnwGobw1j2FtjWjXbq-lIFVTe18rPtmTdLqVNOgE", "qi": "YYCsHYc8qLJ1aIWnVJ9srXBC3VPWhB98tjOdK-xafhi19TeDL3OxazFV0f0FuxEGOmYeHyF4nh72wK3kRBrcosNQkAlK8oMH3Cg_AnMYehFRmDSKUFjDjXH5bVBfFk72FkmEywEaQgOiYs34P4RAEBdZohh6UTZm0-bajOkVEOE" - }, - { - kty: "oct", - kid: "somevalue", - k: "SBh6LBt1DBTeyHTvwDgSjg", - use: "enc", - alg: "A128GCM" } ]; @@ -428,6 +428,7 @@ describe("jwk/keystore", function() { }); it("toJSON() exports the keys (public fields only)", function() { + // TODO: make this test less sensitive to ordering var actual = inst.toJSON(); var expected = { keys: keys.map(function(k) { @@ -437,6 +438,7 @@ describe("jwk/keystore", function() { assert.deepEqual(actual, expected); }); it("toJSON() exports the keys (with private fields)", function() { + // TODO: make this test less sensitive to ordering var actual = inst.toJSON(true); var expected = { keys: keys.map(function(k) { diff --git a/test/jwk/octkey-test.js b/test/jwk/octkey-test.js index d33361c..72c5470 100644 --- a/test/jwk/octkey-test.js +++ b/test/jwk/octkey-test.js @@ -99,6 +99,22 @@ describe("jwk/oct", function() { }); }); + describe("#thumbprint", function() { + var json = { + private: { + "k": "Obdi7uR-5mc3Zbo0HtI-CQ" + } + }; + it("returns required fields (minus kty)", function() { + var expected = { + k: "Obdi7uR-5mc3Zbo0HtI-CQ", + kty: "oct" + }; + var actual = JWK.OCTET.config.thumbprint(json); + assert.deepEqual(actual, expected); + }); + }); + describe("#encryptKey", function() { it("returns key value", function() { var keys = { @@ -683,6 +699,10 @@ describe("jwk/oct", function() { use: "sig", alg: "HS256" }); + return key.thumbprint(); + }); + promise = promise.then(function(print) { + assert.equal(print.toString("hex"), "fdce868cfc9f1d400ac42190a55f2ee6f12ca04363c3b207f3c4c3c01e343e48"); }); return promise; @@ -704,6 +724,10 @@ describe("jwk/oct", function() { use: "enc", alg: "A128GCMKW" }); + return key.thumbprint(); + }); + promise = promise.then(function(print) { + assert.equal(print.toString("hex"), "9006be7d413efbcdeb16d180fa7f84de2cbc7a0463f1a2d50c09a221b5b34cd9"); }); return promise; diff --git a/test/jwk/rsakey-test.js b/test/jwk/rsakey-test.js index b8e8db0..db35d28 100644 --- a/test/jwk/rsakey-test.js +++ b/test/jwk/rsakey-test.js @@ -142,6 +142,23 @@ describe("jwk/RSA", function() { assert.deepEqual(actual, undefined); }); }); + describe("#thumbprint", function() { + var json = { + public: { + "n": "i8exGrwNz69aIKhpblb7DrouMBJJohuzhGPezA1EWZ8klqNt7kxKhd3fA1MN1Nn9QTIX4NefJxmOwzXhq6qLtfOa7ZnXUS-pWrs3pm9oFOf1qF1DbeEDTaTbkvxlO7AFUs_LR1-wHyztH0O-Rl17WqUUjcoA07QYwHCKm1cP_kE4yCkyT0EPNkreCnwQEs-1xvZkyAo_zLjESN8y_Ck9FTTTAWmuEbUhtE1_QmlCfFaoUsBJ5OJG6eCTmr1MQ47T4flKDq6-PFr4JCFyMrmnungxpsg4lp-s1sUgg5qRUyga6ze854pmAgQKzj61lhs8g7k1J5HR6S0PL7xQl5pW6Q", + "e": "AQAB" + } + }; + it("returns required fields (minus kty)", function() { + var expected = { + "n": "i8exGrwNz69aIKhpblb7DrouMBJJohuzhGPezA1EWZ8klqNt7kxKhd3fA1MN1Nn9QTIX4NefJxmOwzXhq6qLtfOa7ZnXUS-pWrs3pm9oFOf1qF1DbeEDTaTbkvxlO7AFUs_LR1-wHyztH0O-Rl17WqUUjcoA07QYwHCKm1cP_kE4yCkyT0EPNkreCnwQEs-1xvZkyAo_zLjESN8y_Ck9FTTTAWmuEbUhtE1_QmlCfFaoUsBJ5OJG6eCTmr1MQ47T4flKDq6-PFr4JCFyMrmnungxpsg4lp-s1sUgg5qRUyga6ze854pmAgQKzj61lhs8g7k1J5HR6S0PL7xQl5pW6Q", + "kty": "RSA", + "e": "AQAB" + }; + var actual = JWK.RSA.config.thumbprint(json); + assert.deepEqual(actual, expected); + }); + }); describe("#wrapKey", function() { it("returns key value", function() { var keys = clone(keyPair); @@ -339,6 +356,10 @@ describe("jwk/RSA", function() { kid: "someid" }); assert.deepEqual(key.toJSON(true), json); + return key.thumbprint(); + }); + promise = promise.then(function(print) { + assert.equal(print.toString("hex"), "5696ddb7881bfafc92c02a70e8dcafc38ade1f9508f643d293ae282d59848eb8"); }); return promise; @@ -359,6 +380,11 @@ describe("jwk/RSA", function() { kid: "someid" }); assert.deepEqual(key.toJSON(true), json); + assert.deepEqual(key.toJSON(true), json); + return key.thumbprint(); + }); + promise = promise.then(function(print) { + assert.equal(print.toString("hex"), "5696ddb7881bfafc92c02a70e8dcafc38ade1f9508f643d293ae282d59848eb8"); }); return promise;