diff --git a/CHANGELOG.md b/CHANGELOG.md index bc5a728..bffe59a 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,17 @@ # @digitalbazaar/ecdsa-multikey ChangeLog +## 1.3.0 - 2023-10-dd + +### Added +- Add `keyAgreement` option to `generate()` to generate ECDH keys instead of + ECDSA keys. This module needs a better name than `ecdsa-multikey` as it also + supports key agreement keys, but only for keys based on curves that are also + compatible with ECDSA. Note that a key should only be used for ECDSA or ECDH + (key agreement), not both, so calling this module `ecdsa-multikey` is a bit + misleading as you can also generate a key that is to only be used for key + agreement. +- Add `deriveSecret()` API for `keyAgreement` enabled keys. + ## 1.2.1 - 2023-10-30 ### Fixed diff --git a/lib/index.js b/lib/index.js index 0ba34d8..e3ec81e 100644 --- a/lib/index.js +++ b/lib/index.js @@ -12,24 +12,29 @@ import {createSigner, createVerifier} from './factory.js'; import { exportKeyPair, importKeyPair, toPublicKeyMultibase, toSecretKeyMultibase } from './serialize.js'; +import {getSecretKeySize} from './helpers.js'; import {toMultikey} from './translators.js'; // FIXME: support `P-256K` via `@noble/secp256k1` // generates ECDSA key pair -export async function generate({id, controller, curve} = {}) { +export async function generate({ + id, controller, curve, keyAgreement = false +} = {}) { if(!curve) { throw new TypeError( '"curve" must be one of the following values: ' + `${Object.values(ECDSA_CURVE).map(v => `'${v}'`).join(', ')}.` ); } - const algorithm = {name: ALGORITHM, namedCurve: curve}; + const algorithm = keyAgreement ? + {name: 'ECDH', namedCurve: curve} : {name: ALGORITHM, namedCurve: curve}; + const usage = keyAgreement ? ['deriveBits'] : ['sign', 'verify']; const keyPair = await webcrypto.subtle.generateKey( - algorithm, EXTRACTABLE, ['sign', 'verify'] - ); + algorithm, EXTRACTABLE, usage); keyPair.secretKey = keyPair.privateKey; delete keyPair.privateKey; - const keyPairInterface = await _createKeyPairInterface({keyPair}); + const keyPairInterface = await _createKeyPairInterface( + {keyPair, keyAgreement}); const exportedKeyPair = await keyPairInterface.export({publicKey: true}); const {publicKeyMultibase} = exportedKeyPair; if(controller && !id) { @@ -41,11 +46,11 @@ export async function generate({id, controller, curve} = {}) { } // imports P-256 key pair from JSON Multikey -export async function from(key) { +export async function from(key, keyAgreement = false) { let multikey = {...key}; if(multikey.type && multikey.type !== 'Multikey') { multikey = await toMultikey({keyPair: multikey}); - return _createKeyPairInterface({keyPair: multikey}); + return _createKeyPairInterface({keyPair: multikey, keyAgreement}); } if(!multikey.type) { multikey.type = 'Multikey'; @@ -58,7 +63,7 @@ export async function from(key) { } _assertMultikey(multikey); - return _createKeyPairInterface({keyPair: multikey}); + return _createKeyPairInterface({keyPair: multikey, keyAgreement}); } // imports key pair from JWK @@ -71,7 +76,8 @@ export async function fromJwk({jwk, secretKey = false} = {}) { if(secretKey && jwk.d) { multikey.secretKeyMultibase = toSecretKeyMultibase({jwk}); } - return from(multikey); + const keyAgreement = !jwk.key_ops || jwk.key_ops.includes('deriveBits'); + return from(multikey, keyAgreement); } // converts key pair to JWK @@ -82,13 +88,11 @@ export async function toJwk({keyPair, secretKey = false} = {}) { const useSecretKey = secretKey && !!keyPair.secretKey; const cryptoKey = useSecretKey ? keyPair.secretKey : keyPair.publicKey; const jwk = await webcrypto.subtle.exportKey('jwk', cryptoKey); - delete jwk.ext; - delete jwk.key_ops; return jwk; } // augments key pair with useful metadata and utilities -async function _createKeyPairInterface({keyPair}) { +async function _createKeyPairInterface({keyPair, keyAgreement = false}) { if(!(keyPair?.publicKey instanceof CryptoKey)) { keyPair = await importKeyPair(keyPair); } @@ -104,6 +108,7 @@ async function _createKeyPairInterface({keyPair}) { ...keyPair, publicKeyMultibase, secretKeyMultibase, + keyAgreement, export: exportFn, signer() { const {id, secretKey} = keyPair; @@ -112,6 +117,15 @@ async function _createKeyPairInterface({keyPair}) { verifier() { const {id, publicKey} = keyPair; return createVerifier({id, publicKey}); + }, + async deriveSecret({remotePublicKey} = {}) { + if(!keyPair.keyAgreement) { + const error = Error('"keyAgreement" is not supported by this keypair.'); + error.name = 'NotSupportedError'; + throw error; + } + return _deriveSecret( + {localKeyPair: this, remoteKeyPair: remotePublicKey}); } }; @@ -133,3 +147,26 @@ function _assertMultikey(key) { ); } } + +async function _deriveSecret({localKeyPair, remoteKeyPair}) { + if(!localKeyPair.secretKey) { + const error = Error('"secretKey" required to derive secret.'); + error.name = 'NotSupportedError'; + throw error; + } + + // import keys with `keyAgreement` key usage + localKeyPair = await importKeyPair({...localKeyPair, keyAgreement: true}); + remoteKeyPair = await importKeyPair({...remoteKeyPair, keyAgreement: true}); + + // produce shared secret that is the same size as a secret key, the + // shared secret should be used as just one input to a KDF + const {namedCurve: curve} = localKeyPair.secretKey.algorithm; + const secretSize = getSecretKeySize({curve}); + const arrayBuffer = await webcrypto.subtle.deriveBits({ + name: 'ECDH', + namedCurve: curve, + public: remoteKeyPair.publicKey, + }, localKeyPair.secretKey, secretSize * 8); + return new Uint8Array(arrayBuffer, 0, secretSize); +} diff --git a/lib/serialize.js b/lib/serialize.js index 8014fa8..ee81114 100644 --- a/lib/serialize.js +++ b/lib/serialize.js @@ -101,7 +101,7 @@ export async function exportKeyPair({ // imports key pair export async function importKeyPair({ - id, controller, secretKeyMultibase, publicKeyMultibase + id, controller, secretKeyMultibase, publicKeyMultibase, keyAgreement = false }) { if(!publicKeyMultibase) { throw new TypeError('The "publicKeyMultibase" property is required.'); @@ -120,15 +120,17 @@ export async function importKeyPair({ // set named curved based on multikey header const algorithm = { - name: ALGORITHM, + name: keyAgreement ? 'ECDH' : ALGORITHM, namedCurve: getNamedCurveFromPublicMultikey({publicMultikey}) }; // import public key; convert to `spki` format because `jwk` doesn't handle // compressed public keys const spki = _toSpki({publicMultikey}); + // must be empty usage for importing a public key + const publicUsage = keyAgreement ? [] : ['verify']; keyPair.publicKey = await webcrypto.subtle.importKey( - 'spki', spki, algorithm, EXTRACTABLE, ['verify']); + 'spki', spki, algorithm, EXTRACTABLE, publicUsage); // import secret key if given if(secretKeyMultibase) { @@ -146,8 +148,9 @@ export async function importKeyPair({ // convert to `pkcs8` format for import because `jwk` doesn't support // compressed keys const pkcs8 = _toPkcs8({secretMultikey, publicMultikey}); + const secretUsage = keyAgreement ? ['deriveBits'] : ['sign']; keyPair.secretKey = await webcrypto.subtle.importKey( - 'pkcs8', pkcs8, algorithm, EXTRACTABLE, ['sign']); + 'pkcs8', pkcs8, algorithm, EXTRACTABLE, secretUsage); } return keyPair; diff --git a/test/EcdsaMultikey.spec.js b/test/EcdsaMultikey.spec.js index dcc44b1..d41f5e5 100644 --- a/test/EcdsaMultikey.spec.js +++ b/test/EcdsaMultikey.spec.js @@ -27,17 +27,42 @@ describe('EcdsaMultikey', () => { }); describe('algorithm', () => { - it('createSigner should export proper algorithm', async () => { + it('signer() instance should export proper algorithm', async () => { const keyPair = await EcdsaMultikey.from(mockKey); const signer = keyPair.signer(); signer.algorithm.should.equal('P-256'); }); - it('createVerifier should export proper algorithm', async () => { + it('verifier() instance should export proper algorithm', async () => { const keyPair = await EcdsaMultikey.from(mockKey); const verifier = keyPair.verifier(); verifier.algorithm.should.equal('P-256'); }); + + it('deriveSecret() should not be supported by default', async () => { + const keyPair = await EcdsaMultikey.generate({curve: 'P-256'}); + + let err; + try { + await keyPair.deriveSecret({remotePublicKey: keyPair}); + } catch(e) { + err = e; + } + should.exist(err); + err.name.should.equal('NotSupportedError'); + }); + + it('deriveSecret() should produce a shared secret', async () => { + const keyPair1 = await EcdsaMultikey.generate( + {curve: 'P-256', keyAgreement: true}); + const keyPair2 = await EcdsaMultikey.generate( + {curve: 'P-256', keyAgreement: true}); + + const secret1 = await keyPair1.deriveSecret({remotePublicKey: keyPair2}); + const secret2 = await keyPair2.deriveSecret({remotePublicKey: keyPair1}); + + expect(secret1).to.deep.eql(secret2); + }); }); describe('generate', () => {