Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Add key agreement support. #21

Merged
merged 1 commit into from
Oct 31, 2023
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
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