Skip to content

Commit 1159b0d

Browse files
committed
feat: add key.toPEM() export function with optional encryption
1 parent 2dbd3ed commit 1159b0d

File tree

6 files changed

+117
-6
lines changed

6 files changed

+117
-6
lines changed

docs/README.md

Lines changed: 39 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -36,6 +36,7 @@ I can continue maintaining it and adding new features carefree. You may also don
3636
- [key.secret](#keysecret)
3737
- [key.algorithms([operation])](#keyalgorithmsoperation)
3838
- [key.toJWK([private])](#keytojwkprivate)
39+
- [key.toPEM([private[, encoding]])](#keytopemprivate-encoding)
3940
- JWK.importKey
4041
- [JWK.importKey(key[, options]) asymmetric key import](#jwkimportkeykey-options-asymmetric-key-import)
4142
- [JWK.importKey(secret[, options]) secret key import](#jwkimportkeysecret-options-secret-key-import)
@@ -245,6 +246,44 @@ key.toJWK(true)
245246

246247
---
247248

249+
#### `key.toPEM([private[, encoding]])`
250+
251+
Exports an asymmetric key as a PEM string with specified encoding and optional encryption for private keys.
252+
253+
- `private`: `<boolean>` When true exports keys as private. **Default:** 'false'
254+
- `encoding`: `<Object>` See below
255+
- Returns: `<string>`
256+
257+
For public key export, the following encoding options can be used:
258+
259+
- `type`: `<string>` Must be one of 'pkcs1' (RSA only) or 'spki'. **Default:** 'spki'
260+
261+
262+
For private key export, the following encoding options can be used:
263+
264+
- `type`: `<string>` Must be one of 'pkcs1' (RSA only), 'pkcs8' or 'sec1' (EC only). **Default:** 'pkcs8'
265+
- `cipher`: `<string>` If specified, the private key will be encrypted with the given cipher and
266+
passphrase using PKCS#5 v2.0 password based encryption. **Default**: 'undefined' (no encryption)
267+
- `passphrase`: `<string>` &vert; `<Buffer>` The passphrase to use for encryption. **Default**: 'undefined' (no encryption)
268+
269+
<details>
270+
<summary><em><strong>Example</strong></em> (Click to expand)</summary>
271+
272+
```js
273+
const { JWK: { generateSync } } = require('@panva/jose')
274+
275+
const key = generateSync('RSA', 2048)
276+
key.toPEM()
277+
// -----BEGIN PUBLIC KEY-----\nMFkwEwYHKoZIzj0CAQYIKoZIzj0DAQcDQgAEATPpxgDY7XU8cYX9Rb44xxXDO6zP\nzELVOHTcutCiXS9HZvUrZsnG7U/SPj0AT1hsH6lTUK4uFr7GG7KWgsf1Aw==\n-----END PUBLIC KEY-----\n
278+
key.toPEM(true)
279+
// -----BEGIN PRIVATE KEY-----\nMIGHAgEAMBMGByqGSM49AgEGCCqGSM49AwEHBG0wawIBAQQgUdAzlvX4i+RJS2BL\nQrqRj/ndTbpqugX61Ih9X+rvAcShRANCAAQBM+nGANjtdTxxhf1FvjjHFcM7rM/M\nQtU4dNy60KJdL0dm9StmycbtT9I+PQBPWGwfqVNQri4WvsYbspaCx/UD\n-----END PRIVATE KEY-----\n
280+
key.toPEM(true, { passphrase: 'super-strong', cipher: 'aes-256-cbc' })
281+
// -----BEGIN ENCRYPTED PRIVATE KEY-----\nMIHsMFcGCSqGSIb3DQEFDTBKMCkGCSqGSIb3DQEFDDAcBAjjeqsgorjSqwICCAAw\nDAYIKoZIhvcNAgkFADAdBglghkgBZQMEASoEEJFcyG1ZBe2FZuvXIqiRFUcEgZD5\nWzt2XIUGIEZQIUUpJ1naaIFKiZvBcFAXhqG5KJ6PgaohgcmRUK8OZTA9Ome+uXB+\n9PLLfKscOsyr0gkd45gYYNRDLYwbQSqDQ4g8pHrCVjR+R3mh1nk8jIkOxSppwzmF\n7aoCmnQo7oXRy1+kRZL7OfwAD5gAXnsIA42D9RgOG1XIiBYTvAITcFVX0UPh0zM=\n-----END ENCRYPTED PRIVATE KEY-----\n
282+
```
283+
</details>
284+
285+
---
286+
248287
#### `JWK.importKey(key[, options])` asymmetric key import
249288

250289
Imports an asymmetric private or public key. Supports importing JWK formatted keys (private, public,

lib/help/key_utils.js

Lines changed: 4 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -198,7 +198,7 @@ const jwkToPem = {
198198
dp: base64url.decodeToBuffer(jwk.dp),
199199
dq: base64url.decodeToBuffer(jwk.dq),
200200
qi: base64url.decodeToBuffer(jwk.qi)
201-
}, 'pem', { label: 'RSA PRIVATE KEY' }).toString('base64')
201+
}, 'pem', { label: 'RSA PRIVATE KEY' })
202202
},
203203
public (jwk) {
204204
const RSAPublicKey = asn1.get('RSAPublicKey')
@@ -207,7 +207,7 @@ const jwkToPem = {
207207
version: 0,
208208
n: base64url.decodeToBuffer(jwk.n),
209209
e: base64url.decodeToBuffer(jwk.e)
210-
}, 'pem', { label: 'RSA PUBLIC KEY' }).toString('base64')
210+
}, 'pem', { label: 'RSA PUBLIC KEY' })
211211
}
212212
},
213213
EC: {
@@ -222,7 +222,7 @@ const jwkToPem = {
222222
value: crvToOid.get(jwk.crv)
223223
},
224224
publicKey: concatEcPublicKey(jwk.x, jwk.y)
225-
}, 'pem', { label: 'EC PRIVATE KEY' }).toString('base64')
225+
}, 'pem', { label: 'EC PRIVATE KEY' })
226226
},
227227
public (jwk) {
228228
const PublicKeyInfo = asn1.get('PublicKeyInfo')
@@ -233,7 +233,7 @@ const jwkToPem = {
233233
parameters: crvToOidBuf.get(jwk.crv)
234234
},
235235
publicKey: concatEcPublicKey(jwk.x, jwk.y)
236-
}, 'pem', { label: 'PUBLIC KEY' }).toString('base64')
236+
}, 'pem', { label: 'PUBLIC KEY' })
237237
}
238238
},
239239
OKP: {

lib/index.d.ts

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -17,6 +17,12 @@ type keyObjectTypes = asymmetricKeyObjectTypes | 'secret'
1717

1818
export namespace JWK {
1919

20+
interface pemEncodingOptions {
21+
type?: string
22+
cipher?: string
23+
passphrase?: string
24+
}
25+
2026
class Key {
2127
kty: keyType
2228
type: keyObjectTypes
@@ -28,6 +34,7 @@ export namespace JWK {
2834
kid: string
2935
thumbprint: string
3036

37+
toPEM(private?: boolean, encoding?: pemEncodingOptions): string
3138

3239
algorithms(operation?: keyOperation): Set<string>
3340
}

lib/jwa/ecdh/derive.js

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -24,8 +24,8 @@ const computeSecret = ({ crv, d }, { x, y = '' }) => {
2424
const exchange = createECDH(curve)
2525

2626
exchange.setPrivateKey(base64url.decodeToBuffer(d))
27-
let secret = exchange.computeSecret(pubToBuffer(x, y))
28-
return secret
27+
28+
return exchange.computeSecret(pubToBuffer(x, y))
2929
}
3030

3131
const concat = (key, length, value) => {

lib/jwk/key/base.js

Lines changed: 31 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,5 @@
1+
const { createPublicKey } = require('crypto')
2+
13
const { keyObjectToJWK } = require('../../help/key_utils')
24
const { THUMBPRINT_MATERIAL, PUBLIC_MEMBERS, PRIVATE_MEMBERS, JWK_MEMBERS } = require('../../help/symbols')
35
const { KEYOBJECT } = require('../../help/symbols')
@@ -54,6 +56,35 @@ class Key {
5456
})
5557
}
5658

59+
toPEM (priv = false, encoding = {}) {
60+
if (this.secret) {
61+
throw new TypeError('symmetric keys cannot be exported as PEM')
62+
}
63+
64+
if (priv && this.public === true) {
65+
throw new TypeError('public key cannot be exported as private')
66+
}
67+
68+
const { type = priv ? 'pkcs8' : 'spki', cipher, passphrase } = encoding
69+
70+
let keyObject = this[KEYOBJECT]
71+
72+
if (!priv) {
73+
if (this.private) {
74+
keyObject = createPublicKey(keyObject)
75+
}
76+
if (cipher || passphrase) {
77+
throw new TypeError('cipher and passphrase can only be applied when exporting private keys')
78+
}
79+
}
80+
81+
if (priv) {
82+
return keyObject.export({ format: 'pem', type, cipher, passphrase })
83+
}
84+
85+
return keyObject.export({ format: 'pem', type })
86+
}
87+
5788
toJWK (priv = false) {
5889
if (priv && this.public === true) {
5990
throw new TypeError('public key cannot be exported as private')

test/cookbook/jwk.test.js

Lines changed: 34 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -7,35 +7,66 @@ const { JWK: { importKey }, JWKS: { KeyStore } } = require('../..')
77
test('public EC', t => {
88
const jwk = recipes.get('3.1')
99
const key = importKey(jwk)
10+
t.true(key.toPEM().includes('BEGIN PUBLIC KEY'))
1011
t.deepEqual(key.toJWK(), jwk)
1112
t.deepEqual(key.toJWK(false), jwk)
1213
t.throws(() => {
1314
key.toJWK(true)
1415
}, { instanceOf: TypeError, message: 'public key cannot be exported as private' })
16+
t.throws(() => {
17+
key.toPEM(false, { cipher: 'aes-256-cbc' })
18+
}, { instanceOf: TypeError, message: 'cipher and passphrase can only be applied when exporting private keys' })
19+
t.throws(() => {
20+
key.toPEM(false, { passphrase: 'top secret' })
21+
}, { instanceOf: TypeError, message: 'cipher and passphrase can only be applied when exporting private keys' })
22+
t.throws(() => {
23+
key.toPEM(true)
24+
}, { instanceOf: TypeError, message: 'public key cannot be exported as private' })
1525
})
1626

1727
test('private EC', t => {
1828
const jwk = recipes.get('3.2')
1929
const key = importKey(jwk)
30+
t.true(key.toPEM(true, { cipher: 'aes-256-cbc', passphrase: 'top secret' }).includes('BEGIN ENCRYPTED PRIVATE KEY'))
31+
t.true(key.toPEM(true, { type: 'sec1' }).includes('BEGIN EC PRIVATE KEY'))
32+
t.true(key.toPEM(true, { type: 'sec1', cipher: 'aes-256-cbc', passphrase: 'top secret' }).includes('ENCRYPTED'))
33+
t.true(key.toPEM(true).includes('BEGIN PRIVATE KEY'))
34+
t.true(key.toPEM().includes('BEGIN PUBLIC KEY'))
2035
t.deepEqual(key.toJWK(true), jwk)
2136
const { d, ...pub } = jwk
2237
t.deepEqual(key.toJWK(), pub)
2338
t.deepEqual(key.toJWK(false), pub)
39+
t.throws(() => {
40+
key.toPEM(false, { cipher: 'aes-256-cbc' })
41+
}, { instanceOf: TypeError, message: 'cipher and passphrase can only be applied when exporting private keys' })
42+
t.throws(() => {
43+
key.toPEM(false, { passphrase: 'top secret' })
44+
}, { instanceOf: TypeError, message: 'cipher and passphrase can only be applied when exporting private keys' })
2445
})
2546

2647
test('public RSA', t => {
2748
const jwk = recipes.get('3.3')
2849
const key = importKey(jwk)
50+
t.true(key.toPEM().includes('BEGIN PUBLIC KEY'))
2951
t.deepEqual(key.toJWK(), jwk)
3052
t.deepEqual(key.toJWK(false), jwk)
3153
t.throws(() => {
3254
key.toJWK(true)
3355
}, { instanceOf: TypeError, message: 'public key cannot be exported as private' })
56+
t.throws(() => {
57+
key.toPEM(true)
58+
}, { instanceOf: TypeError, message: 'public key cannot be exported as private' })
3459
})
3560

3661
test('private RSA', t => {
3762
const jwk = recipes.get('3.4')
3863
const key = importKey(jwk)
64+
t.true(key.toPEM(true, { type: 'pkcs1' }).includes('BEGIN RSA PRIVATE KEY'))
65+
t.true(key.toPEM(true, { cipher: 'aes-256-cbc', passphrase: 'top secret', type: 'pkcs1' }).includes('ENCRYPTED'))
66+
t.true(key.toPEM(true, { type: 'pkcs1', cipher: 'aes-256-cbc', passphrase: 'top secret' }).includes('BEGIN RSA PRIVATE KEY'))
67+
t.true(key.toPEM(true, { cipher: 'aes-256-cbc', passphrase: 'top secret' }).includes('BEGIN ENCRYPTED PRIVATE KEY'))
68+
t.true(key.toPEM(true).includes('BEGIN PRIVATE KEY'))
69+
t.true(key.toPEM().includes('BEGIN PUBLIC KEY'))
3970
t.deepEqual(key.toJWK(true), jwk)
4071
const { d, dp, dq, p, q, qi, ...pub } = jwk
4172
t.deepEqual(key.toJWK(), pub)
@@ -45,6 +76,9 @@ test('private RSA', t => {
4576
test('oct (1/2)', t => {
4677
const jwk = recipes.get('3.5')
4778
const key = importKey(jwk)
79+
t.throws(() => {
80+
key.toPEM()
81+
}, { instanceOf: TypeError, message: 'symmetric keys cannot be exported as PEM' })
4882
t.deepEqual(key.toJWK(true), jwk)
4983
const { k, ...pub } = jwk
5084
t.deepEqual(key.toJWK(), pub)

0 commit comments

Comments
 (0)