Skip to content

Commit

Permalink
Add JWK conversion features.
Browse files Browse the repository at this point in the history
  • Loading branch information
dlongley committed Mar 18, 2024
1 parent f144e72 commit 8e8196e
Show file tree
Hide file tree
Showing 7 changed files with 191 additions and 29 deletions.
5 changes: 5 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
@@ -1,5 +1,10 @@
# @digitalbazaar/ed25519-multikey ChangeLog

## 1.1.0 - 2024-mm-dd

### Added
- Enable loading keys from `publicKeyJwk` and converting to/from JWK.

## 1.0.2 - 2024-01-25

### Fixed
Expand Down
8 changes: 6 additions & 2 deletions lib/constants.js
Original file line number Diff line number Diff line change
@@ -1,8 +1,8 @@
/*!
* Copyright (c) 2022 Digital Bazaar, Inc. All rights reserved.
* Copyright (c) 2022-2024 Digital Bazaar, Inc. All rights reserved.
*/

// Ed25519 Signature 20218 Context v1 URL
// Ed25519 Signature 2018 Context v1 URL
export const ED25519_SIGNATURE_2018_V1_URL =
'https://w3id.org/security/suites/ed25519-2018/v1';
// Ed25519 Signature 2020 Context v1 URL
Expand All @@ -16,3 +16,7 @@ export const MULTICODEC_PUB_HEADER = new Uint8Array([0xed, 0x01]);
export const MULTICODEC_PRIV_HEADER = new Uint8Array([0x80, 0x26]);
// multikey context v1 url
export const MULTIKEY_CONTEXT_V1_URL = 'https://w3id.org/security/multikey/v1';
// Ed25519 public key size in bytes
export const PUBLIC_KEY_SIZE = 32;
// Ed25519 secret key size in bytes
export const SECRET_KEY_SIZE = 32;
21 changes: 11 additions & 10 deletions lib/helpers.js
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
/*!
* Copyright (c) 2020-2022 Digital Bazaar, Inc. All rights reserved.
* Copyright (c) 2020-2024 Digital Bazaar, Inc. All rights reserved.
*/
import * as base58btc from 'base58-universal';
import {
Expand All @@ -9,15 +9,16 @@ import {
} from './constants.js';

export function mbEncodeKeyPair({keyPair}) {
const publicKeyMultibase =
_encodeMbKey(MULTICODEC_PUB_HEADER, keyPair.publicKey);
const secretKeyMultibase =
_encodeMbKey(MULTICODEC_PRIV_HEADER, keyPair.secretKey);

return {
publicKeyMultibase,
secretKeyMultibase
};
const result = {};
if(keyPair.publicKey) {
result.publicKeyMultibase = _encodeMbKey(
MULTICODEC_PUB_HEADER, keyPair.publicKey);
}
if(keyPair.secretKey) {
result.secretKeyMultibase = _encodeMbKey(
MULTICODEC_PRIV_HEADER, keyPair.secretKey);
}
return result;
}

export function mbDecodeKeyPair({publicKeyMultibase, secretKeyMultibase}) {
Expand Down
61 changes: 57 additions & 4 deletions lib/index.js
Original file line number Diff line number Diff line change
@@ -1,11 +1,16 @@
/*!
* Copyright (c) 2020-2023 Digital Bazaar, Inc. All rights reserved.
*/
import * as base64url from 'base64url-universal';
import * as ed25519 from './ed25519.js';
import {createSigner, createVerifier} from './factory.js';
import {exportKeyPair, importKeyPair} from './serialize.js';
import {
exportKeyPair, importKeyPair,
jwkToPublicKeyMultibase,
jwkToSecretKeyMultibase
} from './serialize.js';
import {MULTIKEY_CONTEXT_V1_URL, SECRET_KEY_SIZE} from './constants.js';
import {mbEncodeKeyPair} from './helpers.js';
import {MULTIKEY_CONTEXT_V1_URL} from './constants.js';
import {toMultikey} from './keyPairTranslator.js';

export async function generate({id, controller, seed} = {}) {
Expand Down Expand Up @@ -39,6 +44,10 @@ export async function from(key) {
multikey = await toMultikey({keyPair: multikey});
return _createKeyPairInterface({keyPair: multikey});
}
// attempt loading from JWK if `publicKeyJwk` is present
if(multikey.publicKeyJwk) {
return fromJwk({jwk: multikey.publicKeyJwk, secretKey: true});
}
if(!multikey.type) {
multikey.type = 'Multikey';
}
Expand All @@ -50,16 +59,60 @@ export async function from(key) {
return _createKeyPairInterface({keyPair: multikey});
}

// imports key pair from JWK
export async function fromJwk({jwk, secretKey = false} = {}) {
const multikey = {
'@context': MULTIKEY_CONTEXT_V1_URL,
type: 'Multikey',
publicKeyMultibase: jwkToPublicKeyMultibase({jwk})
};
if(secretKey && jwk.d) {
multikey.secretKeyMultibase = jwkToSecretKeyMultibase({jwk});
}
return from(multikey);
}

// converts key pair to JWK
export async function toJwk({keyPair, secretKey = false} = {}) {
const jwk = {
kty: 'OKP',
crv: 'Ed25519',
x: base64url.encode(keyPair.publicKey)
};
const useSecretKey = secretKey && !!keyPair.secretKey;
if(useSecretKey) {
jwk.d = base64url.encode(keyPair.secretKey);
}
return jwk;
}

async function _createKeyPairInterface({keyPair}) {
if(!keyPair.publicKey) {
keyPair = await importKeyPair(keyPair);
}
keyPair = {
...keyPair,
async export({
publicKey = true, secretKey = false, includeContext = true
publicKey = true, secretKey = false, includeContext = true, raw = false,
canonicalize = false
} = {}) {
return exportKeyPair({keyPair, publicKey, secretKey, includeContext});
if(raw) {
const {publicKey, secretKey} = keyPair;
const result = {};
if(publicKey) {
result.publicKey = publicKey.slice();
}
if(secretKey) {
if(canonicalize && secretKey.length > SECRET_KEY_SIZE) {
result.secretKey = secretKey.subarray(0, SECRET_KEY_SIZE).slice();
}
result.secretKey = secretKey;
}
return result;
}
return exportKeyPair({
keyPair, publicKey, secretKey, includeContext, canonicalize
});
},
signer() {
const {id, secretKey} = keyPair;
Expand Down
4 changes: 2 additions & 2 deletions lib/keyPairTranslationMap.js
Original file line number Diff line number Diff line change
@@ -1,13 +1,13 @@
/*!
* Copyright (c) 2022 Digital Bazaar, Inc. All rights reserved.
* Copyright (c) 2022-2024 Digital Bazaar, Inc. All rights reserved.
*/
import * as base58btc from 'base58-universal';
import {mbEncodeKeyPair} from './helpers.js';
import {
ED25519_SIGNATURE_2018_V1_URL,
ED25519_SIGNATURE_2020_V1_URL,
MULTIKEY_CONTEXT_V1_URL
} from './constants.js';
import {mbEncodeKeyPair} from './helpers.js';

const keyPairTranslationMap = new Map([
['Ed25519VerificationKey2020', {
Expand Down
112 changes: 105 additions & 7 deletions lib/serialize.js
Original file line number Diff line number Diff line change
@@ -1,17 +1,26 @@
/*!
* Copyright (c) 2022-2024 Digital Bazaar, Inc. All rights reserved.
*/
import {mbDecodeKeyPair} from './helpers.js';
import {MULTIKEY_CONTEXT_V1_URL} from './constants.js';
import * as base64url from 'base64url-universal';
import {mbDecodeKeyPair, mbEncodeKeyPair} from './helpers.js';
import {
MULTIKEY_CONTEXT_V1_URL,
PUBLIC_KEY_SIZE,
SECRET_KEY_SIZE
} from './constants.js';

const LEGACY_SECRET_KEY_SIZE = SECRET_KEY_SIZE + PUBLIC_KEY_SIZE;

export async function exportKeyPair({
keyPair, secretKey, publicKey, includeContext
keyPair, secretKey, publicKey, includeContext, canonicalize = false
} = {}) {
if(!(publicKey || secretKey)) {
throw new TypeError(
'Export requires specifying either "publicKey" or "secretKey".');
}

const useSecretKey = secretKey && !!keyPair.secretKey;

// export as Multikey
const exported = {};
if(includeContext) {
Expand All @@ -22,11 +31,12 @@ export async function exportKeyPair({
exported.controller = keyPair.controller;

if(publicKey) {
exported.publicKeyMultibase = keyPair.publicKeyMultibase;
exported.publicKeyMultibase = rawToPublicKeyMultibase(keyPair);
}

if(secretKey) {
exported.secretKeyMultibase = keyPair.secretKeyMultibase;
if(useSecretKey) {
exported.secretKeyMultibase = rawToSecretKeyMultibase({
...keyPair, canonicalize
});
}

if(keyPair.revoked) {
Expand Down Expand Up @@ -61,3 +71,91 @@ export async function importKeyPair({
revoked,
};
}

export function jwkToPublicKeyBytes({jwk} = {}) {
const {kty, crv, x} = jwk;
if(kty !== 'OKP') {
throw new TypeError('"jwk.kty" must be "OKP".');
}
if(crv !== 'Ed25519') {
throw new TypeError('"jwk.crv" must be "Ed25519".');
}
if(typeof x !== 'string') {
throw new TypeError('"jwk.x" must be a string.');
}
const publicKey = base64url.decode(jwk.x);
if(publicKey.length !== PUBLIC_KEY_SIZE) {
throw new Error(
`Invalid public key size (${publicKey.length}); ` +
`expected ${PUBLIC_KEY_SIZE}.`);
}
return publicKey;
}

export function jwkToPublicKeyMultibase({jwk} = {}) {
const publicKey = jwkToPublicKeyBytes({jwk});
const {publicKeyMultibase} = mbEncodeKeyPair({
keyPair: {publicKey}
});
return publicKeyMultibase;
}

export function jwkToSecretKeyBytes({jwk} = {}) {
const {kty, crv, d} = jwk;
if(kty !== 'OKP') {
throw new TypeError('"jwk.kty" must be "OKP".');
}
if(crv !== 'Ed25519') {
throw new TypeError('"jwk.crv" must be "Ed25519".');
}
if(typeof d !== 'string') {
throw new TypeError('"jwk.d" must be a string.');
}
const secretKey = Uint8Array.from(base64url.decode(jwk.d));
if(secretKey.length !== SECRET_KEY_SIZE) {
throw new Error(
`Invalid secret key size (${secretKey.length}); ` +
`expected ${SECRET_KEY_SIZE}.`);
}
return secretKey;
}

export function jwkToSecretKeyMultibase({jwk} = {}) {
const secretKey = jwkToSecretKeyBytes({jwk});
const {secretKeyMultibase} = mbEncodeKeyPair({
keyPair: {secretKey}
});
return secretKeyMultibase;
}

export function rawToPublicKeyMultibase({publicKey} = {}) {
if(publicKey.length !== PUBLIC_KEY_SIZE) {
throw new Error(
`Invalid public key size (${publicKey.length}); ` +
`expected ${PUBLIC_KEY_SIZE}.`);
}
const {publicKeyMultibase} = mbEncodeKeyPair({
keyPair: {publicKey}
});
return publicKeyMultibase;
}

export function rawToSecretKeyMultibase({
secretKey, canonicalize = false
} = {}) {
if(secretKey.length !== SECRET_KEY_SIZE) {
if(secretKey.length !== LEGACY_SECRET_KEY_SIZE) {
throw new Error(
`Invalid secret key size (${secretKey.length}); ` +
`expected ${SECRET_KEY_SIZE}.`);
}
// handle legacy concatenated (secret key + public key)
if(canonicalize) {
secretKey = secretKey.subarray(0, SECRET_KEY_SIZE);
}
}
const {secretKeyMultibase} = mbEncodeKeyPair({
keyPair: {secretKey}
});
return secretKeyMultibase;
}
9 changes: 5 additions & 4 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -18,7 +18,8 @@
],
"dependencies": {
"@noble/ed25519": "^1.6.0",
"base58-universal": "^2.0.0"
"base58-universal": "^2.0.0",
"base64url-universal": "^2.0.0"
},
"devDependencies": {
"@digitalbazaar/ed25519-verification-key-2018": "^4.0.0",
Expand All @@ -28,9 +29,9 @@
"chai": "^4.3.6",
"cross-env": "^7.0.3",
"eslint": "^8.16.0",
"eslint-config-digitalbazaar": "^3.0.0",
"eslint-plugin-jsdoc": "^39.3.2",
"eslint-plugin-unicorn": "^42.0.0",
"eslint-config-digitalbazaar": "^5.0.1",
"eslint-plugin-jsdoc": "^48.2.1",
"eslint-plugin-unicorn": "^51.0.1",
"karma": "^6.3.20",
"karma-chai": "^0.1.0",
"karma-chrome-launcher": "^3.1.1",
Expand Down

0 comments on commit 8e8196e

Please sign in to comment.