From 8e8196eb11c4b0eda4170e6130873f44fa687148 Mon Sep 17 00:00:00 2001 From: Dave Longley Date: Sun, 17 Mar 2024 20:55:27 -0400 Subject: [PATCH] Add JWK conversion features. --- CHANGELOG.md | 5 ++ lib/constants.js | 8 ++- lib/helpers.js | 21 +++---- lib/index.js | 61 +++++++++++++++++-- lib/keyPairTranslationMap.js | 4 +- lib/serialize.js | 112 ++++++++++++++++++++++++++++++++--- package.json | 9 +-- 7 files changed, 191 insertions(+), 29 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index fad673c..e4499c4 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -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 diff --git a/lib/constants.js b/lib/constants.js index 0ae5ab5..ad68400 100644 --- a/lib/constants.js +++ b/lib/constants.js @@ -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 @@ -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; diff --git a/lib/helpers.js b/lib/helpers.js index ea5a4ea..f6450fc 100644 --- a/lib/helpers.js +++ b/lib/helpers.js @@ -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 { @@ -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}) { diff --git a/lib/index.js b/lib/index.js index c8a38c7..0910c46 100644 --- a/lib/index.js +++ b/lib/index.js @@ -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} = {}) { @@ -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'; } @@ -50,6 +59,33 @@ 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); @@ -57,9 +93,26 @@ async function _createKeyPairInterface({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; diff --git a/lib/keyPairTranslationMap.js b/lib/keyPairTranslationMap.js index df42158..39e2515 100644 --- a/lib/keyPairTranslationMap.js +++ b/lib/keyPairTranslationMap.js @@ -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', { diff --git a/lib/serialize.js b/lib/serialize.js index ec1364a..01a5cb7 100644 --- a/lib/serialize.js +++ b/lib/serialize.js @@ -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) { @@ -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) { @@ -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; +} diff --git a/package.json b/package.json index b574efb..937e38a 100644 --- a/package.json +++ b/package.json @@ -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", @@ -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",