Skip to content

Commit

Permalink
Add key agreement support.
Browse files Browse the repository at this point in the history
  • Loading branch information
dlongley committed Oct 31, 2023
1 parent 45160ab commit c3c07bb
Show file tree
Hide file tree
Showing 4 changed files with 95 additions and 18 deletions.
12 changes: 12 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
@@ -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
Expand Down
61 changes: 49 additions & 12 deletions lib/index.js
Original file line number Diff line number Diff line change
Expand Up @@ -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) {
Expand All @@ -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';
Expand All @@ -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
Expand All @@ -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
Expand All @@ -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);
}
Expand All @@ -104,6 +108,7 @@ async function _createKeyPairInterface({keyPair}) {
...keyPair,
publicKeyMultibase,
secretKeyMultibase,
keyAgreement,
export: exportFn,
signer() {
const {id, secretKey} = keyPair;
Expand All @@ -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});
}
};

Expand All @@ -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);
}
11 changes: 7 additions & 4 deletions lib/serialize.js
Original file line number Diff line number Diff line change
Expand Up @@ -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.');
Expand All @@ -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) {
Expand All @@ -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;
Expand Down
29 changes: 27 additions & 2 deletions test/EcdsaMultikey.spec.js
Original file line number Diff line number Diff line change
Expand Up @@ -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', () => {
Expand Down

0 comments on commit c3c07bb

Please sign in to comment.