From 1159b0df4e415deeb44a856a55413732f9fc2552 Mon Sep 17 00:00:00 2001 From: Filip Skokan Date: Mon, 1 Apr 2019 20:24:22 +0200 Subject: [PATCH] feat: add key.toPEM() export function with optional encryption --- docs/README.md | 39 +++++++++++++++++++++++++++++++++++++++ lib/help/key_utils.js | 8 ++++---- lib/index.d.ts | 7 +++++++ lib/jwa/ecdh/derive.js | 4 ++-- lib/jwk/key/base.js | 31 +++++++++++++++++++++++++++++++ test/cookbook/jwk.test.js | 34 ++++++++++++++++++++++++++++++++++ 6 files changed, 117 insertions(+), 6 deletions(-) diff --git a/docs/README.md b/docs/README.md index d00374ab90..ee4003485d 100644 --- a/docs/README.md +++ b/docs/README.md @@ -36,6 +36,7 @@ I can continue maintaining it and adding new features carefree. You may also don - [key.secret](#keysecret) - [key.algorithms([operation])](#keyalgorithmsoperation) - [key.toJWK([private])](#keytojwkprivate) + - [key.toPEM([private[, encoding]])](#keytopemprivate-encoding) - JWK.importKey - [JWK.importKey(key[, options]) asymmetric key import](#jwkimportkeykey-options-asymmetric-key-import) - [JWK.importKey(secret[, options]) secret key import](#jwkimportkeysecret-options-secret-key-import) @@ -245,6 +246,44 @@ key.toJWK(true) --- +#### `key.toPEM([private[, encoding]])` + +Exports an asymmetric key as a PEM string with specified encoding and optional encryption for private keys. + +- `private`: `` When true exports keys as private. **Default:** 'false' +- `encoding`: `` See below +- Returns: `` + +For public key export, the following encoding options can be used: + +- `type`: `` Must be one of 'pkcs1' (RSA only) or 'spki'. **Default:** 'spki' + + +For private key export, the following encoding options can be used: + +- `type`: `` Must be one of 'pkcs1' (RSA only), 'pkcs8' or 'sec1' (EC only). **Default:** 'pkcs8' +- `cipher`: `` If specified, the private key will be encrypted with the given cipher and + passphrase using PKCS#5 v2.0 password based encryption. **Default**: 'undefined' (no encryption) +- `passphrase`: `` | `` The passphrase to use for encryption. **Default**: 'undefined' (no encryption) + +
+ Example (Click to expand) + +```js +const { JWK: { generateSync } } = require('@panva/jose') + +const key = generateSync('RSA', 2048) +key.toPEM() +// -----BEGIN PUBLIC KEY-----\nMFkwEwYHKoZIzj0CAQYIKoZIzj0DAQcDQgAEATPpxgDY7XU8cYX9Rb44xxXDO6zP\nzELVOHTcutCiXS9HZvUrZsnG7U/SPj0AT1hsH6lTUK4uFr7GG7KWgsf1Aw==\n-----END PUBLIC KEY-----\n +key.toPEM(true) +// -----BEGIN PRIVATE KEY-----\nMIGHAgEAMBMGByqGSM49AgEGCCqGSM49AwEHBG0wawIBAQQgUdAzlvX4i+RJS2BL\nQrqRj/ndTbpqugX61Ih9X+rvAcShRANCAAQBM+nGANjtdTxxhf1FvjjHFcM7rM/M\nQtU4dNy60KJdL0dm9StmycbtT9I+PQBPWGwfqVNQri4WvsYbspaCx/UD\n-----END PRIVATE KEY-----\n +key.toPEM(true, { passphrase: 'super-strong', cipher: 'aes-256-cbc' }) +// -----BEGIN ENCRYPTED PRIVATE KEY-----\nMIHsMFcGCSqGSIb3DQEFDTBKMCkGCSqGSIb3DQEFDDAcBAjjeqsgorjSqwICCAAw\nDAYIKoZIhvcNAgkFADAdBglghkgBZQMEASoEEJFcyG1ZBe2FZuvXIqiRFUcEgZD5\nWzt2XIUGIEZQIUUpJ1naaIFKiZvBcFAXhqG5KJ6PgaohgcmRUK8OZTA9Ome+uXB+\n9PLLfKscOsyr0gkd45gYYNRDLYwbQSqDQ4g8pHrCVjR+R3mh1nk8jIkOxSppwzmF\n7aoCmnQo7oXRy1+kRZL7OfwAD5gAXnsIA42D9RgOG1XIiBYTvAITcFVX0UPh0zM=\n-----END ENCRYPTED PRIVATE KEY-----\n +``` +
+ +--- + #### `JWK.importKey(key[, options])` asymmetric key import Imports an asymmetric private or public key. Supports importing JWK formatted keys (private, public, diff --git a/lib/help/key_utils.js b/lib/help/key_utils.js index 869f3c38d2..7da8adf5c9 100644 --- a/lib/help/key_utils.js +++ b/lib/help/key_utils.js @@ -198,7 +198,7 @@ const jwkToPem = { dp: base64url.decodeToBuffer(jwk.dp), dq: base64url.decodeToBuffer(jwk.dq), qi: base64url.decodeToBuffer(jwk.qi) - }, 'pem', { label: 'RSA PRIVATE KEY' }).toString('base64') + }, 'pem', { label: 'RSA PRIVATE KEY' }) }, public (jwk) { const RSAPublicKey = asn1.get('RSAPublicKey') @@ -207,7 +207,7 @@ const jwkToPem = { version: 0, n: base64url.decodeToBuffer(jwk.n), e: base64url.decodeToBuffer(jwk.e) - }, 'pem', { label: 'RSA PUBLIC KEY' }).toString('base64') + }, 'pem', { label: 'RSA PUBLIC KEY' }) } }, EC: { @@ -222,7 +222,7 @@ const jwkToPem = { value: crvToOid.get(jwk.crv) }, publicKey: concatEcPublicKey(jwk.x, jwk.y) - }, 'pem', { label: 'EC PRIVATE KEY' }).toString('base64') + }, 'pem', { label: 'EC PRIVATE KEY' }) }, public (jwk) { const PublicKeyInfo = asn1.get('PublicKeyInfo') @@ -233,7 +233,7 @@ const jwkToPem = { parameters: crvToOidBuf.get(jwk.crv) }, publicKey: concatEcPublicKey(jwk.x, jwk.y) - }, 'pem', { label: 'PUBLIC KEY' }).toString('base64') + }, 'pem', { label: 'PUBLIC KEY' }) } }, OKP: { diff --git a/lib/index.d.ts b/lib/index.d.ts index 93a1514e14..2d69896b26 100644 --- a/lib/index.d.ts +++ b/lib/index.d.ts @@ -17,6 +17,12 @@ type keyObjectTypes = asymmetricKeyObjectTypes | 'secret' export namespace JWK { + interface pemEncodingOptions { + type?: string + cipher?: string + passphrase?: string + } + class Key { kty: keyType type: keyObjectTypes @@ -28,6 +34,7 @@ export namespace JWK { kid: string thumbprint: string + toPEM(private?: boolean, encoding?: pemEncodingOptions): string algorithms(operation?: keyOperation): Set } diff --git a/lib/jwa/ecdh/derive.js b/lib/jwa/ecdh/derive.js index c69eccb8d9..a9debd8d1b 100644 --- a/lib/jwa/ecdh/derive.js +++ b/lib/jwa/ecdh/derive.js @@ -24,8 +24,8 @@ const computeSecret = ({ crv, d }, { x, y = '' }) => { const exchange = createECDH(curve) exchange.setPrivateKey(base64url.decodeToBuffer(d)) - let secret = exchange.computeSecret(pubToBuffer(x, y)) - return secret + + return exchange.computeSecret(pubToBuffer(x, y)) } const concat = (key, length, value) => { diff --git a/lib/jwk/key/base.js b/lib/jwk/key/base.js index 8ce098cbf4..785d61cf1f 100644 --- a/lib/jwk/key/base.js +++ b/lib/jwk/key/base.js @@ -1,3 +1,5 @@ +const { createPublicKey } = require('crypto') + const { keyObjectToJWK } = require('../../help/key_utils') const { THUMBPRINT_MATERIAL, PUBLIC_MEMBERS, PRIVATE_MEMBERS, JWK_MEMBERS } = require('../../help/symbols') const { KEYOBJECT } = require('../../help/symbols') @@ -54,6 +56,35 @@ class Key { }) } + toPEM (priv = false, encoding = {}) { + if (this.secret) { + throw new TypeError('symmetric keys cannot be exported as PEM') + } + + if (priv && this.public === true) { + throw new TypeError('public key cannot be exported as private') + } + + const { type = priv ? 'pkcs8' : 'spki', cipher, passphrase } = encoding + + let keyObject = this[KEYOBJECT] + + if (!priv) { + if (this.private) { + keyObject = createPublicKey(keyObject) + } + if (cipher || passphrase) { + throw new TypeError('cipher and passphrase can only be applied when exporting private keys') + } + } + + if (priv) { + return keyObject.export({ format: 'pem', type, cipher, passphrase }) + } + + return keyObject.export({ format: 'pem', type }) + } + toJWK (priv = false) { if (priv && this.public === true) { throw new TypeError('public key cannot be exported as private') diff --git a/test/cookbook/jwk.test.js b/test/cookbook/jwk.test.js index ee97e2f762..23aac3c72b 100644 --- a/test/cookbook/jwk.test.js +++ b/test/cookbook/jwk.test.js @@ -7,35 +7,66 @@ const { JWK: { importKey }, JWKS: { KeyStore } } = require('../..') test('public EC', t => { const jwk = recipes.get('3.1') const key = importKey(jwk) + t.true(key.toPEM().includes('BEGIN PUBLIC KEY')) t.deepEqual(key.toJWK(), jwk) t.deepEqual(key.toJWK(false), jwk) t.throws(() => { key.toJWK(true) }, { instanceOf: TypeError, message: 'public key cannot be exported as private' }) + t.throws(() => { + key.toPEM(false, { cipher: 'aes-256-cbc' }) + }, { instanceOf: TypeError, message: 'cipher and passphrase can only be applied when exporting private keys' }) + t.throws(() => { + key.toPEM(false, { passphrase: 'top secret' }) + }, { instanceOf: TypeError, message: 'cipher and passphrase can only be applied when exporting private keys' }) + t.throws(() => { + key.toPEM(true) + }, { instanceOf: TypeError, message: 'public key cannot be exported as private' }) }) test('private EC', t => { const jwk = recipes.get('3.2') const key = importKey(jwk) + t.true(key.toPEM(true, { cipher: 'aes-256-cbc', passphrase: 'top secret' }).includes('BEGIN ENCRYPTED PRIVATE KEY')) + t.true(key.toPEM(true, { type: 'sec1' }).includes('BEGIN EC PRIVATE KEY')) + t.true(key.toPEM(true, { type: 'sec1', cipher: 'aes-256-cbc', passphrase: 'top secret' }).includes('ENCRYPTED')) + t.true(key.toPEM(true).includes('BEGIN PRIVATE KEY')) + t.true(key.toPEM().includes('BEGIN PUBLIC KEY')) t.deepEqual(key.toJWK(true), jwk) const { d, ...pub } = jwk t.deepEqual(key.toJWK(), pub) t.deepEqual(key.toJWK(false), pub) + t.throws(() => { + key.toPEM(false, { cipher: 'aes-256-cbc' }) + }, { instanceOf: TypeError, message: 'cipher and passphrase can only be applied when exporting private keys' }) + t.throws(() => { + key.toPEM(false, { passphrase: 'top secret' }) + }, { instanceOf: TypeError, message: 'cipher and passphrase can only be applied when exporting private keys' }) }) test('public RSA', t => { const jwk = recipes.get('3.3') const key = importKey(jwk) + t.true(key.toPEM().includes('BEGIN PUBLIC KEY')) t.deepEqual(key.toJWK(), jwk) t.deepEqual(key.toJWK(false), jwk) t.throws(() => { key.toJWK(true) }, { instanceOf: TypeError, message: 'public key cannot be exported as private' }) + t.throws(() => { + key.toPEM(true) + }, { instanceOf: TypeError, message: 'public key cannot be exported as private' }) }) test('private RSA', t => { const jwk = recipes.get('3.4') const key = importKey(jwk) + t.true(key.toPEM(true, { type: 'pkcs1' }).includes('BEGIN RSA PRIVATE KEY')) + t.true(key.toPEM(true, { cipher: 'aes-256-cbc', passphrase: 'top secret', type: 'pkcs1' }).includes('ENCRYPTED')) + t.true(key.toPEM(true, { type: 'pkcs1', cipher: 'aes-256-cbc', passphrase: 'top secret' }).includes('BEGIN RSA PRIVATE KEY')) + t.true(key.toPEM(true, { cipher: 'aes-256-cbc', passphrase: 'top secret' }).includes('BEGIN ENCRYPTED PRIVATE KEY')) + t.true(key.toPEM(true).includes('BEGIN PRIVATE KEY')) + t.true(key.toPEM().includes('BEGIN PUBLIC KEY')) t.deepEqual(key.toJWK(true), jwk) const { d, dp, dq, p, q, qi, ...pub } = jwk t.deepEqual(key.toJWK(), pub) @@ -45,6 +76,9 @@ test('private RSA', t => { test('oct (1/2)', t => { const jwk = recipes.get('3.5') const key = importKey(jwk) + t.throws(() => { + key.toPEM() + }, { instanceOf: TypeError, message: 'symmetric keys cannot be exported as PEM' }) t.deepEqual(key.toJWK(true), jwk) const { k, ...pub } = jwk t.deepEqual(key.toJWK(), pub)