diff --git a/package-lock.json b/package-lock.json index ba68f67..dfd06ea 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,15 +1,15 @@ { "name": "@solid/keychain", - "version": "0.3.5", + "version": "0.4.0", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "@solid/keychain", - "version": "0.3.5", + "version": "0.4.0", "license": "MIT", "dependencies": { - "@solid/jose": "^0.6.9", + "@solid/jose": "^0.7.0", "base64url": "^3.0.1" }, "devDependencies": { @@ -3605,9 +3605,9 @@ "license": "(Unlicense OR Apache-2.0)" }, "node_modules/@solid/jose": { - "version": "0.6.9", - "resolved": "https://registry.npmjs.org/@solid/jose/-/jose-0.6.9.tgz", - "integrity": "sha512-Q54wCYn6rSOcW3gmMVWDhbbmMMyDinmWW6MtKTuk7688iNHMNRzKQbllfr0fVJxKA/GD4UIUQGTKapGW2/ntRA==", + "version": "0.7.0", + "resolved": "https://registry.npmjs.org/@solid/jose/-/jose-0.7.0.tgz", + "integrity": "sha512-VOqzJ+6M/47GYigQdng15ZNHDbyCGED5zG/p0Bhi+1lIfVhwBmgRzpydd1NHslA6hMxe18XfWfSvffSriZiT2w==", "license": "MIT", "dependencies": { "@sinonjs/text-encoding": "^0.7.2", diff --git a/package.json b/package.json index b9ee9e7..80b06a4 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "@solid/keychain", - "version": "0.3.5", + "version": "0.4.0", "description": "KeyChain for use with Web Cryptography API in Node.js", "main": "src/index.js", "directories": { @@ -38,7 +38,7 @@ }, "homepage": "https://github.com/solid/keychain#README", "dependencies": { - "@solid/jose": "^0.6.9", + "@solid/jose": "^0.7.0", "base64url": "^3.0.1" }, "devDependencies": { diff --git a/src/KeyChain.js b/src/KeyChain.js index 6a9c918..c7d3656 100644 --- a/src/KeyChain.js +++ b/src/KeyChain.js @@ -2,6 +2,7 @@ * Dependencies */ const supportedAlgorithms = require('./algorithms') +const InvalidDescriptorError = require('./errors/InvalidDescriptorError') /** * KeyChain @@ -10,14 +11,21 @@ class KeyChain { /** * constructor + * + * @param data {Object} Keychain data + * @param options {Object} Optional configuration + * @param options.crypto {Object} Optional crypto instance for cross-package compatibility */ - constructor (data) { + constructor (data, options = {}) { // use data as the descriptor if descriptor property is missing if (!data.descriptor) { data = { descriptor: data } } Object.assign(this, data) + + // Store crypto instance if provided + this._crypto = options.crypto } /** @@ -34,14 +42,14 @@ class KeyChain { * @param {Object} params * @return {Promise} */ - static generateKey (params) { + static generateKey (params, crypto) { let normalizedAlgorithm = supportedAlgorithms.normalize('generateKey', params.alg) if (normalizedAlgorithm instanceof Error) { return Promise.reject(normalizedAlgorithm) } - let algorithm = new normalizedAlgorithm(params) + let algorithm = new normalizedAlgorithm({...params, crypto}) return algorithm.generateKey() } @@ -53,7 +61,7 @@ class KeyChain { * @param {Object} jwk * @return {Promise} */ - static importKey (jwk) { + static importKey (jwk, crypto) { let {alg} = jwk let normalizedAlgorithm = supportedAlgorithms.normalize('importKey', alg) @@ -61,16 +69,20 @@ class KeyChain { return Promise.reject(normalizedAlgorithm) } - let algorithm = new normalizedAlgorithm({alg}) + let algorithm = new normalizedAlgorithm({alg, crypto}) return algorithm.importKey(jwk) } /** * restore + * + * @param data {Object} Keychain data + * @param options {Object} Optional configuration + * @param options.crypto {Object} Optional crypto instance for cross-package compatibility */ - static restore (data) { - let keys = new KeyChain(data) + static restore (data, options) { + let keys = new KeyChain(data, options) return keys.importKeys().then(() => keys) } @@ -84,15 +96,15 @@ class KeyChain { // import key if (props.includes('alg')) { - return KeyChain.importKey(object).then(cryptoKey => { - if (cryptoKey.type === 'private') { + return KeyChain.importKey(object, this._crypto).then(cryptoKey => { + if (cryptoKey.type === 'private' && !container.privateKey) { Object.defineProperty(container, 'privateKey', { enumerable: false, value: cryptoKey }) } - if (cryptoKey.type === 'public') { + if (cryptoKey.type === 'public' && !container.publicKey) { Object.defineProperty(container, 'publicKey', { enumerable: false, value: cryptoKey @@ -100,6 +112,28 @@ class KeyChain { } }) + // import key pair structure (has privateJwk and publicJwk) + } else if (props.includes('privateJwk') && props.includes('publicJwk')) { + // Import both private and public keys + return Promise.all([ + KeyChain.importKey(object.privateJwk, this._crypto), + KeyChain.importKey(object.publicJwk, this._crypto) + ]).then(([privateKey, publicKey]) => { + if (!object.privateKey) { + Object.defineProperty(object, 'privateKey', { + enumerable: false, + value: privateKey + }) + } + + if (!object.publicKey) { + Object.defineProperty(object, 'publicKey', { + enumerable: false, + value: publicKey + }) + } + }) + // recurse } else { return Promise.all( @@ -109,7 +143,7 @@ class KeyChain { let subProps = Object.keys(subObject) //console.log('RECURSE WITH', name, subDescriptor, subObject, subProps) - this.importKeys({ + return this.importKeys({ descriptor: subDescriptor, object: subObject, container: object, @@ -141,7 +175,7 @@ class KeyChain { // generate key(pair), assign resulting object to keychain, // and add JWK for public key to JWK Set if (params.alg) { - return KeyChain.generateKey(params).then(result => { + return KeyChain.generateKey(params, this._crypto).then(result => { container[key] = result if (result.publicJwk) { @@ -164,7 +198,7 @@ class KeyChain { // invalid descriptor } else { - throw new InvalidDescriptorError(key, value) + throw new InvalidDescriptorError(key, params) } }) ).then(() => { diff --git a/src/algorithms/EcKeyPair.js b/src/algorithms/EcKeyPair.js new file mode 100644 index 0000000..235593b --- /dev/null +++ b/src/algorithms/EcKeyPair.js @@ -0,0 +1,131 @@ +/** + * Dependencies + */ +const { crypto } = require('@solid/jose') +const base64url = require('base64url') + +/** + * EcKeyPair + */ +class EcKeyPair { + + /** + * constructor + * + * @param params {Object} Options hashmap + * @param params.alg {string} For example, 'ES256', 'ES384', 'ES512' + * @param params.namedCurve {string} For example, 'P-256', 'P-384', 'P-521' + * @param params.usages {Array} + * @param params.crypto {Object} Optional crypto instance to use (for cross-package compatibility) + */ + constructor (params) { + let name = 'ECDSA' + let {alg, namedCurve, usages} = params + + // Allow overriding crypto instance for cross-package compatibility + this.crypto = params.crypto || crypto + + // Map algorithm to curve and hash + let algorithmMap = { + 'ES256': { curve: 'P-256', hash: 'SHA-256' }, + 'ES384': { curve: 'P-384', hash: 'SHA-384' }, + 'ES512': { curve: 'P-521', hash: 'SHA-512' } // Note: P-521, not P-512 + } + + let algConfig = algorithmMap[alg] + + if (!algConfig) { + throw new Error(`Unsupported EC algorithm: ${alg}`) + } + + // Use provided namedCurve or default from algorithm + if (!namedCurve) { + namedCurve = algConfig.curve + } + + let hash = { name: algConfig.hash } + + if (!usages) { + usages = ['sign', 'verify'] + } + + this.alg = alg + this.algorithm = {name, namedCurve, hash} + this.extractable = true + this.usages = usages + } + + /** + * generateKey + */ + generateKey () { + let {algorithm, extractable, usages} = this + + return this.crypto.subtle + .generateKey(algorithm, extractable, usages) + .then(this.setCryptoKeyPair) + .then(result => this.setJwkKeyPair(result)) + } + + /** + * importKey + */ + importKey (jwk) { + let {name, namedCurve, hash} = this.algorithm + let algorithm = {name, namedCurve, hash} + let extractable = true + let usages = jwk.key_ops + + return this.crypto.subtle + .importKey('jwk', jwk, algorithm, extractable, usages) + } + + /** + * setCryptoKeyPair + */ + setCryptoKeyPair (cryptoKeyPair) { + let result = {} + + Object.defineProperty(result, 'privateKey', { + enumerable: false, + value: cryptoKeyPair.privateKey + }) + + Object.defineProperty(result, 'publicKey', { + enumerable: false, + value: cryptoKeyPair.publicKey + }) + + return result + } + + /** + * setJwkKeyPair + */ + setJwkKeyPair (result) { + return Promise.all([ + this.crypto.subtle.exportKey('jwk', result.privateKey), + this.crypto.subtle.exportKey('jwk', result.publicKey) + ]) + .then(jwks => { + let [privateJwk, publicJwk] = jwks + + result.privateJwk = Object.assign({ + kid: base64url(Buffer.from(this.crypto.getRandomValues(new Uint8Array(8)))), + alg: this.alg + }, privateJwk) + + result.publicJwk = Object.assign({ + kid: base64url(Buffer.from(this.crypto.getRandomValues(new Uint8Array(8)))), + alg: this.alg + }, publicJwk) + + return result + }) + } +} + +/** + * Export + */ +module.exports = EcKeyPair diff --git a/src/algorithms/RsaKeyPair.js b/src/algorithms/RsaKeyPair.js index e3c8188..ddd578b 100644 --- a/src/algorithms/RsaKeyPair.js +++ b/src/algorithms/RsaKeyPair.js @@ -17,6 +17,7 @@ class RsaKeyPair { * @param params.modulusLength {number} * @param params.publicExponent {BufferSource} For example, a Uint8Array * @param params.usages {Array} + * @param params.crypto {Object} Optional crypto instance to use (for cross-package compatibility) */ constructor (params) { let name = 'RSASSA-PKCS1-v1_5' @@ -24,6 +25,9 @@ class RsaKeyPair { let hashLengthValid = alg.match(/(256|384|512)$/) let hashLength = hashLengthValid && hashLengthValid.shift() let hash = { name: `SHA-${hashLength}` } + + // Allow overriding crypto instance for cross-package compatibility + this.crypto = params.crypto || crypto if (!hashLength) { throw new Error('Invalid hash length') @@ -52,10 +56,10 @@ class RsaKeyPair { generateKey () { let {algorithm, extractable, usages} = this - return crypto.subtle + return this.crypto.subtle .generateKey(algorithm, extractable, usages) .then(this.setCryptoKeyPair) - .then(this.setJwkKeyPair) + .then((result) => this.setJwkKeyPair(result)) } /** @@ -67,7 +71,7 @@ class RsaKeyPair { let extractable = true let usages = jwk.key_ops - return crypto.subtle + return this.crypto.subtle .importKey('jwk', jwk, algorithm, extractable, usages) } @@ -95,18 +99,18 @@ class RsaKeyPair { */ setJwkKeyPair (result) { return Promise.all([ - crypto.subtle.exportKey('jwk', result.privateKey), - crypto.subtle.exportKey('jwk', result.publicKey) + this.crypto.subtle.exportKey('jwk', result.privateKey), + this.crypto.subtle.exportKey('jwk', result.publicKey) ]) .then(jwks => { let [privateJwk, publicJwk] = jwks result.privateJwk = Object.assign({ - kid: base64url(Buffer.from(crypto.getRandomValues(new Uint8Array(8)))) + kid: base64url(Buffer.from(this.crypto.getRandomValues(new Uint8Array(8)))) }, privateJwk) result.publicJwk = Object.assign({ - kid: base64url(Buffer.from(crypto.getRandomValues(new Uint8Array(8)))) + kid: base64url(Buffer.from(this.crypto.getRandomValues(new Uint8Array(8)))) }, publicJwk) return result diff --git a/src/algorithms/index.js b/src/algorithms/index.js index e1426ec..c102aa1 100644 --- a/src/algorithms/index.js +++ b/src/algorithms/index.js @@ -3,6 +3,7 @@ */ const SupportedAlgorithms = require('./SupportedAlgorithms') const RsaKeyPair = require('./RsaKeyPair') +const EcKeyPair = require('./EcKeyPair') /** * Supported Algorithms @@ -19,6 +20,16 @@ supportedAlgorithms.define('RS256', 'importKey', RsaKeyPair) supportedAlgorithms.define('RS384', 'importKey', RsaKeyPair) supportedAlgorithms.define('RS512', 'importKey', RsaKeyPair) +/** + * ECDSA + */ +supportedAlgorithms.define('ES256', 'generateKey', EcKeyPair) +supportedAlgorithms.define('ES384', 'generateKey', EcKeyPair) +supportedAlgorithms.define('ES512', 'generateKey', EcKeyPair) +supportedAlgorithms.define('ES256', 'importKey', EcKeyPair) +supportedAlgorithms.define('ES384', 'importKey', EcKeyPair) +supportedAlgorithms.define('ES512', 'importKey', EcKeyPair) + /** * Export diff --git a/src/errors/InvalidDescriptorError.js b/src/errors/InvalidDescriptorError.js new file mode 100644 index 0000000..1319ba3 --- /dev/null +++ b/src/errors/InvalidDescriptorError.js @@ -0,0 +1,14 @@ +/** + * InvalidDescriptorError + */ +class InvalidDescriptorError extends Error { + constructor (key, value) { + super() + this.message = `Invalid descriptor for key "${key}": ${value}` + } +} + +/** + * Export + */ +module.exports = InvalidDescriptorError diff --git a/test/EcKeyPairSpec.js b/test/EcKeyPairSpec.js new file mode 100644 index 0000000..3881f0c --- /dev/null +++ b/test/EcKeyPairSpec.js @@ -0,0 +1,269 @@ +'use strict' + +/** + * Test dependencies + */ +const chai = require('chai') + +/** + * Assertions + */ +chai.use(require('dirty-chai')) +chai.should() +let expect = chai.expect + +/** + * Code under test + */ +const EcKeyPair = require('../src/algorithms/EcKeyPair') +const KeyChain = require('../src/KeyChain') + +describe('EcKeyPair', () => { + describe('constructor', () => { + it('should set algorithm properties for ES256', () => { + let params = { alg: 'ES256' } + let ecKeyPair = new EcKeyPair(params) + + ecKeyPair.alg.should.equal('ES256') + ecKeyPair.algorithm.name.should.equal('ECDSA') + ecKeyPair.algorithm.namedCurve.should.equal('P-256') + ecKeyPair.algorithm.hash.name.should.equal('SHA-256') + ecKeyPair.extractable.should.equal(true) + ecKeyPair.usages.should.eql(['sign', 'verify']) + }) + + it('should set algorithm properties for ES384', () => { + let params = { alg: 'ES384' } + let ecKeyPair = new EcKeyPair(params) + + ecKeyPair.alg.should.equal('ES384') + ecKeyPair.algorithm.namedCurve.should.equal('P-384') + ecKeyPair.algorithm.hash.name.should.equal('SHA-384') + }) + + it('should set algorithm properties for ES512', () => { + let params = { alg: 'ES512' } + let ecKeyPair = new EcKeyPair(params) + + ecKeyPair.alg.should.equal('ES512') + ecKeyPair.algorithm.namedCurve.should.equal('P-521') + ecKeyPair.algorithm.hash.name.should.equal('SHA-512') + }) + + it('should use provided namedCurve parameter', () => { + let params = { alg: 'ES256', namedCurve: 'P-256' } + let ecKeyPair = new EcKeyPair(params) + + ecKeyPair.algorithm.namedCurve.should.equal('P-256') + }) + + it('should use provided usages parameter', () => { + let params = { alg: 'ES256', usages: ['sign'] } + let ecKeyPair = new EcKeyPair(params) + + ecKeyPair.usages.should.eql(['sign']) + }) + + it('should throw an error for unsupported algorithm', () => { + let params = { alg: 'ES128' } + + expect(() => new EcKeyPair(params)) + .to.throw(/Unsupported EC algorithm/) + }) + }) + + describe('generateKey', () => { + it('should generate ES256 key pair', function () { + this.timeout(5000) + + let params = { alg: 'ES256', namedCurve: 'P-256' } + let ecKeyPair = new EcKeyPair(params) + + return ecKeyPair.generateKey().then(result => { + expect(result).to.be.an('object') + expect(result.privateKey).to.exist() + expect(result.publicKey).to.exist() + expect(result.privateJwk).to.exist() + expect(result.publicJwk).to.exist() + + // Verify JWK structure + result.privateJwk.kty.should.equal('EC') + result.privateJwk.crv.should.equal('P-256') + result.privateJwk.alg.should.equal('ES256') + expect(result.privateJwk.kid).to.exist() + expect(result.privateJwk.d).to.exist() + expect(result.privateJwk.x).to.exist() + expect(result.privateJwk.y).to.exist() + + result.publicJwk.kty.should.equal('EC') + result.publicJwk.crv.should.equal('P-256') + result.publicJwk.alg.should.equal('ES256') + expect(result.publicJwk.kid).to.exist() + expect(result.publicJwk.x).to.exist() + expect(result.publicJwk.y).to.exist() + expect(result.publicJwk.d).to.not.exist() + + // Verify CryptoKey properties + result.privateKey.type.should.equal('private') + result.privateKey.algorithm.name.should.equal('ECDSA') + result.publicKey.type.should.equal('public') + result.publicKey.algorithm.name.should.equal('ECDSA') + }) + }) + + it('should generate ES384 key pair', function () { + this.timeout(5000) + + let params = { alg: 'ES384', namedCurve: 'P-384' } + let ecKeyPair = new EcKeyPair(params) + + return ecKeyPair.generateKey().then(result => { + result.privateJwk.crv.should.equal('P-384') + result.privateJwk.alg.should.equal('ES384') + result.publicJwk.crv.should.equal('P-384') + result.publicJwk.alg.should.equal('ES384') + }) + }) + + it('should generate ES512 key pair', function () { + this.timeout(5000) + + let params = { alg: 'ES512', namedCurve: 'P-521' } + let ecKeyPair = new EcKeyPair(params) + + return ecKeyPair.generateKey().then(result => { + result.privateJwk.crv.should.equal('P-521') + result.privateJwk.alg.should.equal('ES512') + result.publicJwk.crv.should.equal('P-521') + result.publicJwk.alg.should.equal('ES512') + }) + }) + }) + + describe('importKey', () => { + let publicJwk + + before(function () { + this.timeout(5000) + + let params = { alg: 'ES256', namedCurve: 'P-256' } + let ecKeyPair = new EcKeyPair(params) + + return ecKeyPair.generateKey().then(result => { + publicJwk = result.publicJwk + }) + }) + + it('should import a public JWK', () => { + let params = { alg: 'ES256' } + let ecKeyPair = new EcKeyPair(params) + + return ecKeyPair.importKey(publicJwk).then(cryptoKey => { + cryptoKey.type.should.equal('public') + cryptoKey.algorithm.name.should.equal('ECDSA') + cryptoKey.algorithm.namedCurve.should.equal('P-256') + }) + }) + }) + + describe('KeyChain integration', () => { + const testKeysDescriptor = require('./resources/ES256-keys.json') + + it('should generate ES256 keys through KeyChain', function () { + this.timeout(10000) + + return KeyChain.generate(testKeysDescriptor.keys.descriptor).then(keychain => { + expect(keychain.id_token).to.exist() + expect(keychain.id_token.signing).to.exist() + expect(keychain.id_token.signing.ES256).to.exist() + + const es256Keys = keychain.id_token.signing.ES256 + + // Check JWKs + expect(es256Keys.privateJwk).to.exist() + es256Keys.privateJwk.alg.should.equal('ES256') + es256Keys.privateJwk.kty.should.equal('EC') + es256Keys.privateJwk.crv.should.equal('P-256') + + expect(es256Keys.publicJwk).to.exist() + es256Keys.publicJwk.alg.should.equal('ES256') + es256Keys.publicJwk.kty.should.equal('EC') + es256Keys.publicJwk.crv.should.equal('P-256') + + // Check CryptoKeys (non-enumerable) + expect(es256Keys.privateKey).to.exist() + expect(es256Keys.publicKey).to.exist() + + // Check JWKS + expect(keychain.jwks).to.exist() + expect(keychain.jwks.keys).to.be.an('array') + const es256PublicKey = keychain.jwks.keys.find(k => k.alg === 'ES256') + expect(es256PublicKey).to.exist() + es256PublicKey.kty.should.equal('EC') + }) + }) + + it('should generate ES384 keys through KeyChain', function () { + this.timeout(10000) + + return KeyChain.generate(testKeysDescriptor.keys.descriptor).then(keychain => { + const es384Keys = keychain.id_token.signing.ES384 + + expect(es384Keys).to.exist() + es384Keys.privateJwk.alg.should.equal('ES384') + es384Keys.privateJwk.crv.should.equal('P-384') + es384Keys.publicJwk.alg.should.equal('ES384') + es384Keys.publicJwk.crv.should.equal('P-384') + }) + }) + + it('should generate ES512 keys through KeyChain', function () { + this.timeout(10000) + + return KeyChain.generate(testKeysDescriptor.keys.descriptor).then(keychain => { + const es512Keys = keychain.id_token.signing.ES512 + + expect(es512Keys).to.exist() + es512Keys.privateJwk.alg.should.equal('ES512') + es512Keys.privateJwk.crv.should.equal('P-521') + es512Keys.publicJwk.alg.should.equal('ES512') + es512Keys.publicJwk.crv.should.equal('P-521') + }) + }) + + it('should restore ES256 keychain from data', function () { + this.timeout(10000) + + let originalKeychain + + return KeyChain.generate({ + signing: { alg: 'ES256', namedCurve: 'P-256' } + }) + .then(keychain => { + originalKeychain = keychain + // Serialize the keychain data - include the full structure + const data = { + descriptor: keychain.descriptor, + signing: keychain.signing // This contains privateJwk, publicJwk, and the CryptoKeys + } + return KeyChain.restore(data) + }) + .then(restoredKeychain => { + // After restore, the CryptoKeys should be re-imported + expect(restoredKeychain).to.exist() + expect(restoredKeychain.signing).to.exist() + + // Check that privateKey and publicKey CryptoKeys were restored + expect(restoredKeychain.signing.privateKey).to.exist() + expect(restoredKeychain.signing.publicKey).to.exist() + + restoredKeychain.signing.privateKey.type.should.equal('private') + restoredKeychain.signing.publicKey.type.should.equal('public') + + // Verify JWKs are preserved + restoredKeychain.signing.privateJwk.alg.should.equal('ES256') + restoredKeychain.signing.publicJwk.alg.should.equal('ES256') + }) + }) + }) +}) diff --git a/test/KeyChainSpec.js b/test/KeyChainSpec.js index d390428..9aa4173 100644 --- a/test/KeyChainSpec.js +++ b/test/KeyChainSpec.js @@ -17,7 +17,7 @@ let expect = chai.expect */ const KeyChain = require('../src/index') -const testKeys = require('./resources/keys.json') +const testKeys = require('./resources/RS256-keys.json') describe('KeyChain', () => { describe('constructor', () => { diff --git a/test/resources/ES256-keys.json b/test/resources/ES256-keys.json new file mode 100644 index 0000000..c76f83c --- /dev/null +++ b/test/resources/ES256-keys.json @@ -0,0 +1,38 @@ +{ + "keys": { + "descriptor": { + "id_token": { + "signing": { + "ES256": { + "alg": "ES256", + "namedCurve": "P-256" + }, + "ES384": { + "alg": "ES384", + "namedCurve": "P-384" + }, + "ES512": { + "alg": "ES512", + "namedCurve": "P-521" + } + } + }, + "token": { + "signing": { + "ES256": { + "alg": "ES256", + "namedCurve": "P-256" + } + } + }, + "register": { + "signing": { + "ES256": { + "alg": "ES256", + "namedCurve": "P-256" + } + } + } + } + } +} diff --git a/test/resources/keys.json b/test/resources/RS256-keys.json similarity index 100% rename from test/resources/keys.json rename to test/resources/RS256-keys.json