702 lines
19 KiB
JavaScript
702 lines
19 KiB
JavaScript
/*!
|
|
* jwk/keystore.js - JWK KeyStore Implementation
|
|
*
|
|
* Copyright (c) 2015 Cisco Systems, Inc. See LICENSE file.
|
|
*/
|
|
"use strict";
|
|
|
|
var clone = require("lodash/clone"),
|
|
merge = require("../util/merge"),
|
|
forge = require("../deps/forge"),
|
|
util = require("../util");
|
|
|
|
var JWK = {
|
|
BaseKey: require("./basekey.js"),
|
|
helpers: require("./helpers.js")
|
|
};
|
|
|
|
/**
|
|
* @class JWK.KeyStoreRegistry
|
|
* @classdesc
|
|
* A registry of JWK.Key types that can be used.
|
|
*
|
|
* @description
|
|
* **NOTE:** This constructor cannot be called directly. Instead use the
|
|
* global {JWK.registry}
|
|
*/
|
|
var JWKRegistry = function() {
|
|
var types = {};
|
|
|
|
Object.defineProperty(this, "register", {
|
|
value: function(factory) {
|
|
if (!factory || "string" !== typeof factory.kty || !factory.kty) {
|
|
throw new Error("invalid Key factory");
|
|
}
|
|
|
|
var kty = factory.kty;
|
|
types[kty] = factory;
|
|
return this;
|
|
}
|
|
});
|
|
Object.defineProperty(this, "unregister", {
|
|
value: function(factory) {
|
|
if (!factory || "string" !== typeof factory.kty || !factory.kty) {
|
|
throw new Error("invalid Key factory");
|
|
}
|
|
|
|
var kty = factory.kty;
|
|
if (factory === types[kty]) {
|
|
delete types[kty];
|
|
}
|
|
return this;
|
|
}
|
|
});
|
|
|
|
Object.defineProperty(this, "get", {
|
|
value: function(kty) {
|
|
return types[kty || ""] || undefined;
|
|
}
|
|
});
|
|
Object.defineProperty(this, "all", {
|
|
value: function() {
|
|
return Object.keys(types).map(function(t) { return types[t]; });
|
|
}
|
|
});
|
|
};
|
|
|
|
// Globals
|
|
var GLOBAL_REGISTRY = new JWKRegistry();
|
|
|
|
// importer
|
|
function processCert(input) {
|
|
// convert certIssuer to readable attributes
|
|
["certIssuer", "certSubject"].forEach(function(field) {
|
|
/* eslint new-cap: [0] */
|
|
var attrs = forge.pki.RDNAttributesAsArray(input[field]);
|
|
var result = input[field] = {};
|
|
attrs.forEach(function(a) {
|
|
result[a.name || a.type] = a.value;
|
|
});
|
|
});
|
|
|
|
return input;
|
|
}
|
|
|
|
function fromPEM(input) {
|
|
var result = {};
|
|
var pems = forge.pem.decode(input);
|
|
var found = pems.some(function(p) {
|
|
switch (p.type) {
|
|
case "CERTIFICATE":
|
|
result.form = "pkix";
|
|
break;
|
|
case "PUBLIC KEY":
|
|
result.form = "spki";
|
|
break;
|
|
case "PRIVATE KEY":
|
|
result.form = "pkcs8";
|
|
break;
|
|
case "EC PRIVATE KEY":
|
|
/* eslint no-fallthrough: [0] */
|
|
case "RSA PRIVATE KEY":
|
|
result.form = "private";
|
|
break;
|
|
default:
|
|
return false;
|
|
}
|
|
|
|
result.body = p.body;
|
|
return true;
|
|
});
|
|
if (!found) {
|
|
throw new Error("supported PEM type not found");
|
|
}
|
|
return result;
|
|
}
|
|
function importFrom(registry, input) {
|
|
// form can be one of:
|
|
// 'private' | 'pkcs8' | 'public' | 'spki' | 'pkix' | 'x509'
|
|
var capture = {},
|
|
errors = [],
|
|
result;
|
|
|
|
// conver from DER to ASN1
|
|
var form = input.form,
|
|
der = input.body,
|
|
thumbprint = null;
|
|
input = forge.asn1.fromDer(der);
|
|
switch(form) {
|
|
case "private":
|
|
registry.all().some(function(factory) {
|
|
if (result) {
|
|
return false;
|
|
}
|
|
if (!factory.validators) {
|
|
return false;
|
|
}
|
|
|
|
var oid = factory.validators.oid,
|
|
validator = factory.validators.privateKey;
|
|
if (!validator) {
|
|
return false;
|
|
}
|
|
capture = {};
|
|
errors = [];
|
|
result = forge.asn1.validate(input, validator, capture, errors);
|
|
if (result) {
|
|
capture.keyOid = forge.asn1.oidToDer(oid);
|
|
capture.parsed = true;
|
|
}
|
|
return result;
|
|
});
|
|
capture.type = "private";
|
|
break;
|
|
case "pkcs8":
|
|
result = forge.asn1.validate(input, JWK.helpers.validators.privateKey, capture, errors);
|
|
capture.type = "private";
|
|
break;
|
|
case "public":
|
|
// eslint no-fallthrough: [0] */
|
|
case "spki":
|
|
result = forge.asn1.validate(input, JWK.helpers.validators.publicKey, capture, errors);
|
|
capture.type = "public";
|
|
break;
|
|
case "pkix":
|
|
/* eslint no-fallthrough: [0] */
|
|
case "x509":
|
|
result = forge.asn1.validate(input, JWK.helpers.validators.certificate, capture, errors);
|
|
if (result) {
|
|
capture = processCert(capture);
|
|
var md = forge.md.sha1.create();
|
|
md.update(der);
|
|
thumbprint = util.base64url.encode(Buffer.from(md.digest().toHex(), "hex"));
|
|
}
|
|
capture.type = "public";
|
|
break;
|
|
}
|
|
if (!result) {
|
|
return null;
|
|
}
|
|
|
|
// convert oids
|
|
if (capture.keyOid) {
|
|
capture.keyOid = forge.asn1.derToOid(capture.keyOid);
|
|
}
|
|
|
|
// find and invoke the importer
|
|
result = null;
|
|
GLOBAL_REGISTRY.all().forEach(function(factory) {
|
|
if (result) {
|
|
return;
|
|
}
|
|
if (!factory) {
|
|
return;
|
|
}
|
|
if ("function" !== typeof factory.import) {
|
|
return;
|
|
}
|
|
result = factory.import(capture);
|
|
});
|
|
if (result && capture.certSubject && capture.certSubject.commonName) {
|
|
result.kid = capture.certSubject.commonName;
|
|
}
|
|
if (result && thumbprint) {
|
|
result.x5t = thumbprint;
|
|
}
|
|
return result;
|
|
}
|
|
|
|
/**
|
|
* @class JWK.KeyStore
|
|
* @classdesc
|
|
* Represents a collection of Keys.
|
|
*
|
|
* @description
|
|
* **NOTE:** This constructor cannot be called directly. Instead call {@link
|
|
* JWK.createKeyStore}.
|
|
*/
|
|
var JWKStore = function(registry, parent) {
|
|
var keysets = {};
|
|
|
|
/**
|
|
* @method JWK.KeyStore#generate
|
|
* @description
|
|
* Generates a new random Key into this KeyStore.
|
|
*
|
|
* The type of {size} depends on the value of {kty}:
|
|
*
|
|
* + **`EC`**: String naming the curve to use, which can be one of:
|
|
* `"P-256"`, `"P-384"`, or `"P-521"` (default is **`"P-256"`**).
|
|
* + **`RSA`**: Number describing the size of the key, in bits (default is
|
|
* **`2048`**).
|
|
* + **`oct`**: Number describing the size of the key, in bits (default is
|
|
* **`256`**).
|
|
*
|
|
* Any properties in {props} are applied before the key is generated,
|
|
* and are expected to be data types acceptable in JSON. This allows the
|
|
* generated key to have a specific key identifier, or to specify its
|
|
* acceptable usage.
|
|
*
|
|
* The returned Promise, when fulfilled, returns the generated Key.
|
|
*
|
|
* @param {String} kty The type of generated key
|
|
* @param {String|Number} [size] The size of the generated key
|
|
* @param {Object} [props] Additional properties to apply to the generated
|
|
* key.
|
|
* @returns {Promise} The promise for the generated Key
|
|
* @throws {Error} If {kty} is not supported
|
|
*/
|
|
Object.defineProperty(this, "generate", {
|
|
value: function(kty, size, props) {
|
|
var keytype = registry.get(kty);
|
|
if (!keytype) {
|
|
return Promise.reject(new Error("unsupported key type"));
|
|
}
|
|
|
|
props = clone(props || {});
|
|
props.kty = kty;
|
|
|
|
var self = this,
|
|
promise = keytype.generate(size);
|
|
return promise.then(function(jwk) {
|
|
jwk = merge(props, jwk, {
|
|
kty: kty
|
|
});
|
|
return self.add(jwk);
|
|
});
|
|
}
|
|
});
|
|
/**
|
|
* @method JWK.KeyStore#add
|
|
* @description
|
|
* Adds a Key to this KeyStore. If {jwk} is a string, it is first
|
|
* parsed into a plain JSON object. If {jwk} is already an instance
|
|
* of JWK.Key, its (public) JSON representation is first obtained
|
|
* then applied to a new JWK.Key object within this KeyStore.
|
|
*
|
|
* @param {String|Object} jwk The JSON Web Key (JWK)
|
|
* @param {String} [form] The format of a String key to expect
|
|
* @param {Object} [extras] extra jwk fields inserted when importing from a non json string (eg "pem")
|
|
* @returns {Promise} The promise for the added key
|
|
*/
|
|
Object.defineProperty(this, "add", {
|
|
value: function(jwk, form, extras) {
|
|
extras = extras || {};
|
|
|
|
var factors;
|
|
if (Buffer.isBuffer(jwk) || typeof jwk === "string") {
|
|
// form can be 'json', 'pkcs8', 'spki', 'pkix', 'x509', 'pem'
|
|
form = (form || "json").toLowerCase();
|
|
if ("json" === form) {
|
|
jwk = JSON.parse(jwk.toString("utf8"));
|
|
} else {
|
|
try {
|
|
if ("pem" === form) {
|
|
// convert *first* PEM -> DER
|
|
factors = fromPEM(jwk);
|
|
} else {
|
|
factors = {
|
|
body: jwk.toString("binary"),
|
|
form: form
|
|
};
|
|
}
|
|
jwk = importFrom(registry, factors);
|
|
if (!jwk) {
|
|
throw new Error("no importer for key");
|
|
}
|
|
Object.keys(extras).forEach(function(field){
|
|
jwk[field] = extras[field];
|
|
});
|
|
} catch (err) {
|
|
return Promise.reject(err);
|
|
}
|
|
}
|
|
} else if (JWKStore.isKey(jwk)) {
|
|
// assume a complete duplicate is desired
|
|
jwk = jwk.toJSON(true);
|
|
} else {
|
|
jwk = clone(jwk);
|
|
}
|
|
|
|
var keytype = registry.get(jwk.kty);
|
|
if (!keytype) {
|
|
return Promise.reject(new Error("unsupported key type"));
|
|
}
|
|
|
|
var self = this,
|
|
promise = keytype.prepare(jwk);
|
|
return promise.then(function(cfg) {
|
|
return new JWK.BaseKey(jwk.kty, self, jwk, cfg);
|
|
}).then(function(jwk) {
|
|
var kid = jwk.kid || "";
|
|
var keys = keysets[kid] = keysets[kid] || [];
|
|
keys.push(jwk);
|
|
|
|
return jwk;
|
|
});
|
|
}
|
|
});
|
|
/**
|
|
* @method JWK.KeyStore#remove
|
|
* @description
|
|
* Removes a Key from this KeyStore.
|
|
*
|
|
* **NOTE:** The removed Key's {keystore} property is not changed.
|
|
*
|
|
* @param {JWK.Key} jwk The key to remove.
|
|
*/
|
|
Object.defineProperty(this, "remove", {
|
|
value: function(jwk) {
|
|
if (!jwk) {
|
|
return;
|
|
}
|
|
|
|
var keys = keysets[jwk.kid];
|
|
if (!keys) {
|
|
return;
|
|
}
|
|
|
|
var pos = keys.indexOf(jwk);
|
|
if (pos === -1) {
|
|
return;
|
|
}
|
|
|
|
keys.splice(pos, 1);
|
|
if (!keys.length) {
|
|
delete keysets[jwk.kid];
|
|
}
|
|
}
|
|
});
|
|
|
|
/**
|
|
* @method JWK.KeyStore#all
|
|
* @description
|
|
* Retrieves all of the contained Keys that optinally match all of the
|
|
* given properties.
|
|
*
|
|
* If {props} are specified, this method only returns Keys which exactly
|
|
* match the given properties. The properties can be any of the
|
|
* following:
|
|
*
|
|
* + **alg**: The algorithm for the Key.
|
|
* + **use**: The usage for the Key.
|
|
* + **kid**: The identifier for the Key.
|
|
*
|
|
* If no properties are given, this method returns all of the Keys for this
|
|
* KeyStore.
|
|
*
|
|
* @param {Object} [props] The properties to match against
|
|
* @param {Boolean} [local = false] `true` if only the Keys
|
|
* directly contained by this KeyStore should be returned, or
|
|
* `false` if it should return all Keys of this KeyStore and
|
|
* its ancestors.
|
|
* @returns {JWK.Key[]} The list of matching Keys, or an empty array if no
|
|
* matches are found.
|
|
*/
|
|
Object.defineProperty(this, "all", {
|
|
value: function(props, local) {
|
|
props = props || {};
|
|
|
|
// workaround for issues/109
|
|
if (props.kid !== undefined && props.kid !== null && typeof props.kid !== "string") {
|
|
props.kid = String(props.kid);
|
|
}
|
|
|
|
var candidates = [];
|
|
var matches = function(key) {
|
|
// match on 'kty'
|
|
if (props.kty &&
|
|
key.kty &&
|
|
props.kty !== key.kty) {
|
|
return false;
|
|
}
|
|
// match on 'use'
|
|
if (props.use &&
|
|
key.use &&
|
|
props.use !== key.use) {
|
|
return false;
|
|
}
|
|
// match on 'alg'
|
|
if (props.alg) {
|
|
if (props.alg !== "dir" &&
|
|
key.alg &&
|
|
props.alg !== key.alg) {
|
|
return false;
|
|
}
|
|
return key.supports(props.alg);
|
|
}
|
|
//TODO: match on 'key_ops'
|
|
|
|
return true;
|
|
};
|
|
Object.keys(keysets).forEach(function(id) {
|
|
if (props.kid && props.kid !== id) {
|
|
return;
|
|
}
|
|
|
|
var keys = keysets[id].filter(matches);
|
|
if (keys.length) {
|
|
candidates = candidates.concat(keys);
|
|
}
|
|
});
|
|
|
|
if (!local && parent) {
|
|
candidates = candidates.concat(parent.all(props));
|
|
}
|
|
|
|
return candidates;
|
|
}
|
|
});
|
|
/**
|
|
* @method JWK.KeyStore#get
|
|
* @description
|
|
* Retrieves the contained Key matching the given {kid}, and optionally
|
|
* all of the given properties. This method equivalent to calling
|
|
* {@link JWK.Store#all}, then returning the first Key whose
|
|
* "kid" is {kid}. If {kid} is undefined, then the first Key that
|
|
* is returned from `all()` is returned.
|
|
*
|
|
* @param {String} [kid] The key identifier to match against.
|
|
* @param {Object} [props] The properties to match against.
|
|
* @param {Boolean} [local = false] `true` if only the Keys
|
|
* directly contained by this KeyStore should be returned, or
|
|
* `false` if it should return all Keys of this KeyStore and
|
|
* its ancestors.
|
|
* @returns {JWK.Key} The Key matching {kid} and {props}, or `null`
|
|
* if no match is found.
|
|
*/
|
|
Object.defineProperty(this, "get", {
|
|
value: function(kid, props, local) {
|
|
// reconcile arguments
|
|
if (typeof kid === "boolean") {
|
|
local = kid;
|
|
props = kid = null;
|
|
} else if (typeof kid === "object") {
|
|
local = props;
|
|
props = kid;
|
|
kid = null;
|
|
}
|
|
|
|
// fixup props
|
|
props = props || {};
|
|
if (kid) {
|
|
props.kid = kid;
|
|
}
|
|
|
|
// workaround for issues/109
|
|
if (props.kid !== undefined && props.kid !== null && typeof props.kid !== "string") {
|
|
props.kid = String(props.kid);
|
|
}
|
|
|
|
var candidates = this.all(props, true);
|
|
if (!candidates.length && parent && !local) {
|
|
candidates = parent.get(props, local);
|
|
}
|
|
return candidates[0] || null;
|
|
}
|
|
});
|
|
|
|
/**
|
|
* @method JWK.KeyStore#temp
|
|
* @description
|
|
* Creates a temporary KeyStore based on this KeyStore.
|
|
*
|
|
* @returns {JWK.KeyStore} The temporary KeyStore.
|
|
*/
|
|
Object.defineProperty(this, "temp", {
|
|
value: function() {
|
|
return new JWKStore(registry, this);
|
|
}
|
|
});
|
|
|
|
/**
|
|
* @method JWK.KeyStore#toJSON
|
|
* @description
|
|
* Generates a JSON representation of this KeyStore, which conforms
|
|
* to a JWK Set from {I-D.ietf-jose-json-web-key}.
|
|
*
|
|
* @param {Boolean} [isPrivate = false] `true` if the private fields
|
|
* of stored keys are to be included.
|
|
* @returns {Object} The JSON representation of this KeyStore.
|
|
*/
|
|
Object.defineProperty(this, "toJSON", {
|
|
value: function(isPrivate) {
|
|
var keys = [];
|
|
|
|
Object.keys(keysets).forEach(function(kid) {
|
|
var items = keysets[kid].map(function(k) {
|
|
return k.toJSON(isPrivate);
|
|
});
|
|
keys = keys.concat(items);
|
|
});
|
|
|
|
return {
|
|
keys: keys
|
|
};
|
|
}
|
|
});
|
|
};
|
|
|
|
/**
|
|
* Determines if the given object is an instance of JWK.KeyStore.
|
|
*
|
|
* @param {Object} obj The object to test
|
|
* @returns {Boolean} `true` if {obj} is an instance of JWK.KeyStore,
|
|
* and `false` otherwise.
|
|
*/
|
|
JWKStore.isKeyStore = function(obj) {
|
|
if (!obj) {
|
|
return false;
|
|
}
|
|
|
|
if ("object" !== typeof obj) {
|
|
return false;
|
|
}
|
|
|
|
if ("function" !== typeof obj.get ||
|
|
"function" !== typeof obj.all ||
|
|
"function" !== typeof obj.generate ||
|
|
"function" !== typeof obj.add ||
|
|
"function" !== typeof obj.remove) {
|
|
return false;
|
|
}
|
|
|
|
return true;
|
|
};
|
|
|
|
/**
|
|
* Creates a new empty KeyStore.
|
|
*
|
|
* @returns {JWK.KeyStore} The empty KeyStore.
|
|
*/
|
|
JWKStore.createKeyStore = function() {
|
|
return new JWKStore(GLOBAL_REGISTRY);
|
|
};
|
|
|
|
/**
|
|
* Coerces the given object into a KeyStore. This method uses the following
|
|
* algorithm to coerce {ks}:
|
|
*
|
|
* 1. if {ks} is an instance of JWK.KeyStore, it is returned directly
|
|
* 2. if {ks} is a string, it is parsed into a JSON value
|
|
* 3. if {ks} is an array, it creates a new JWK.KeyStore and calls {@link
|
|
* JWK.KeyStore#add} for each element in the {ks} array.
|
|
* 4. if {ks} is a JSON object, it creates a new JWK.KeyStore and calls {@link
|
|
* JWK.KeyStore#add} for each element in the "keys" property.
|
|
*
|
|
* @param {Object|String} ks The value to coerce into a
|
|
* KeyStore
|
|
* @returns {Promise(JWK.KeyStore)} A promise for the coerced KeyStore.
|
|
*/
|
|
JWKStore.asKeyStore = function(ks) {
|
|
if (JWKStore.isKeyStore(ks)) {
|
|
return Promise.resolve(ks);
|
|
}
|
|
|
|
var store = JWKStore.createKeyStore(),
|
|
keys;
|
|
|
|
if (typeof ks === "string") {
|
|
ks = JSON.parse(ks);
|
|
}
|
|
|
|
if (Array.isArray(ks)) {
|
|
keys = ks;
|
|
} else if ("keys" in ks) {
|
|
keys = ks.keys;
|
|
} else {
|
|
return Promise.reject(new Error("invalid keystore"));
|
|
}
|
|
|
|
keys = keys.map(function(k) {
|
|
return store.add(k);
|
|
});
|
|
|
|
var promise = Promise.all(keys);
|
|
promise = promise.then(function() {
|
|
return store;
|
|
});
|
|
|
|
return promise;
|
|
};
|
|
|
|
|
|
/**
|
|
* Determines if the given object is a JWK.Key instance.
|
|
*
|
|
* @param {Object} obj The object to test
|
|
* @returns `true` if {obj} is a JWK.Key
|
|
*/
|
|
JWKStore.isKey = function(obj) {
|
|
if (!obj) {
|
|
return false;
|
|
}
|
|
|
|
if ("object" !== typeof obj) {
|
|
return false;
|
|
}
|
|
|
|
if (!JWKStore.isKeyStore(obj.keystore)) {
|
|
return false;
|
|
}
|
|
|
|
if ("string" !== typeof obj.kty ||
|
|
"number" !== typeof obj.length ||
|
|
"function" !== typeof obj.algorithms ||
|
|
"function" !== typeof obj.supports ||
|
|
"function" !== typeof obj.encrypt ||
|
|
"function" !== typeof obj.decrypt ||
|
|
"function" !== typeof obj.wrap ||
|
|
"function" !== typeof obj.unwrap ||
|
|
"function" !== typeof obj.sign ||
|
|
"function" !== typeof obj.verify) {
|
|
return false;
|
|
}
|
|
|
|
return true;
|
|
};
|
|
|
|
/**
|
|
* Creates a new key with the given properties. This method is a convenience
|
|
* to calling `JWK.createKeyStore()` then `generate()` on the returned keystore.
|
|
*
|
|
* @param {String} kty The type of generated key
|
|
* @param {String|Number} [size] The size of the generated key
|
|
* @param {Object} [props] Additional properties to apply to the generated
|
|
* key.
|
|
* @returns {Promise} The promise for the generated Key
|
|
* @throws {Error} If {kty} is not supported
|
|
* @see JWKStore#generate
|
|
*/
|
|
JWKStore.createKey = function(kty, size, props) {
|
|
var ks = JWKStore.createKeyStore();
|
|
return ks.generate(kty, size, props);
|
|
}
|
|
|
|
/**
|
|
* Coerces the given object into a Key. If {key} is an instance of JWK.Key,
|
|
* it is returned directly. Otherwise, this method first creates a new
|
|
* JWK.KeyStore and calls {@link JWK.KeyStore#add} on this new KeyStore.
|
|
*
|
|
* @param {Object|String} key The value to coerce into a Key
|
|
* @param {String} [form] The format of a String Key to expect
|
|
* @param {Object} [extras] extra jwk fields inserted when importing from a non json string (eg "pem")
|
|
* @returns {Promise(JWK.Key)} A promise for the coerced Key.
|
|
*/
|
|
JWKStore.asKey = function(key, form, extras) {
|
|
if (JWKStore.isKey(key)) {
|
|
return Promise.resolve(key);
|
|
}
|
|
|
|
var ks = JWKStore.createKeyStore();
|
|
key = ks.add(key, form, extras);
|
|
|
|
return key;
|
|
};
|
|
|
|
module.exports = {
|
|
KeyRegistry: JWKRegistry,
|
|
KeyStore: JWKStore,
|
|
registry: GLOBAL_REGISTRY
|
|
};
|