Skip to content

Commit d53ef17

Browse files
authored
feat: store x509 certs in the keychain (#3062)
Adds `importX509`/`exportX509`/etc methods to the keychain to enable storing x509 certificates securely.
1 parent 0b9090a commit d53ef17

File tree

6 files changed

+295
-22
lines changed

6 files changed

+295
-22
lines changed

packages/keychain/package.json

+1
Original file line numberDiff line numberDiff line change
@@ -71,6 +71,7 @@
7171
},
7272
"devDependencies": {
7373
"@libp2p/logger": "^5.1.13",
74+
"@peculiar/x509": "^1.12.3",
7475
"aegir": "^45.1.1",
7576
"datastore-core": "^10.0.2"
7677
},

packages/keychain/src/index.ts

+33
Original file line numberDiff line numberDiff line change
@@ -105,6 +105,13 @@ export interface KeyInfo {
105105
name: string
106106
}
107107

108+
export interface X509Info {
109+
/**
110+
* The local key name
111+
*/
112+
name: string
113+
}
114+
108115
export interface Keychain {
109116
/**
110117
* Find a key by name
@@ -201,6 +208,32 @@ export interface Keychain {
201208
*/
202209
listKeys(): Promise<KeyInfo[]>
203210

211+
/**
212+
* Import an X509 certificate in PEM format
213+
*/
214+
importX509 (name: string, pem: string): Promise<void>
215+
216+
/**
217+
* Export an X509 certificate in PEM format
218+
*/
219+
exportX509 (name: string): Promise<string>
220+
221+
/**
222+
* Removes an X509 certificate from the keychain
223+
*/
224+
removeX509 (name: string): Promise<void>
225+
226+
/**
227+
* List all certificates.
228+
*
229+
* @example
230+
*
231+
* ```TypeScript
232+
* const certs = await libp2p.keychain.listX509()
233+
* ```
234+
*/
235+
listX509(): Promise<X509Info[]>
236+
204237
/**
205238
* Rotate keychain password and re-encrypt all associated keys
206239
*

packages/keychain/src/keychain.ts

+125-22
Original file line numberDiff line numberDiff line change
@@ -10,12 +10,13 @@ import { sha256 } from 'multiformats/hashes/sha2'
1010
import sanitize from 'sanitize-filename'
1111
import { fromString as uint8ArrayFromString } from 'uint8arrays/from-string'
1212
import { toString as uint8ArrayToString } from 'uint8arrays/to-string'
13-
import { exportPrivateKey } from './utils/export.js'
14-
import { importPrivateKey } from './utils/import.js'
15-
import type { KeychainComponents, KeychainInit, Keychain as KeychainInterface, KeyInfo } from './index.js'
13+
import { exportPrivateKey, exporter } from './utils/export.js'
14+
import { importPrivateKey, importer } from './utils/import.js'
15+
import type { KeychainComponents, KeychainInit, Keychain as KeychainInterface, KeyInfo, X509Info } from './index.js'
1616
import type { Logger, PrivateKey } from '@libp2p/interface'
1717

1818
const keyPrefix = '/pkcs8/'
19+
const certPrefix = '/x509/'
1920
const infoPrefix = '/info/'
2021
const privates = new WeakMap<object, { dek: string }>()
2122

@@ -63,8 +64,8 @@ async function randomDelay (): Promise<void> {
6364
/**
6465
* Converts a key name into a datastore name
6566
*/
66-
function DsName (name: string): Key {
67-
return new Key(keyPrefix + name)
67+
function DsName (prefix: string, name: string): Key {
68+
return new Key(prefix + name)
6869
}
6970

7071
/**
@@ -206,7 +207,7 @@ export class Keychain implements KeychainInterface {
206207
await randomDelay()
207208
throw new InvalidParametersError('Key is required')
208209
}
209-
const datastoreName = DsName(name)
210+
const datastoreName = DsName(keyPrefix, name)
210211
const exists = await this.components.datastore.has(datastoreName)
211212
if (exists) {
212213
await randomDelay()
@@ -248,7 +249,7 @@ export class Keychain implements KeychainInterface {
248249
throw new InvalidParametersError(`Invalid key name '${name}'`)
249250
}
250251

251-
const datastoreName = DsName(name)
252+
const datastoreName = DsName(keyPrefix, name)
252253
try {
253254
const res = await this.components.datastore.get(datastoreName)
254255
const pem = uint8ArrayToString(res)
@@ -273,7 +274,7 @@ export class Keychain implements KeychainInterface {
273274
throw new InvalidParametersError(`Invalid key name '${name}'`)
274275
}
275276

276-
const datastoreName = DsName(name)
277+
const datastoreName = DsName(keyPrefix, name)
277278
const keyInfo = await this.findKeyByName(name)
278279
const batch = this.components.datastore.batch()
279280
batch.delete(datastoreName)
@@ -317,8 +318,8 @@ export class Keychain implements KeychainInterface {
317318
await randomDelay()
318319
throw new InvalidParametersError(`Invalid new key name '${newName}'`)
319320
}
320-
const oldDatastoreName = DsName(oldName)
321-
const newDatastoreName = DsName(newName)
321+
const oldDatastoreName = DsName(keyPrefix, oldName)
322+
const newDatastoreName = DsName(keyPrefix, newName)
322323
const oldInfoName = DsInfoName(oldName)
323324
const newInfoName = DsInfoName(newName)
324325

@@ -347,6 +348,99 @@ export class Keychain implements KeychainInterface {
347348
}
348349
}
349350

351+
/**
352+
* List all the certificates
353+
*/
354+
async listX509 (): Promise<X509Info[]> {
355+
const query = {
356+
prefix: certPrefix
357+
}
358+
359+
const info = []
360+
for await (const value of this.components.datastore.query(query)) {
361+
info.push({
362+
name: value.key.toString().replace(certPrefix, '')
363+
})
364+
}
365+
366+
return info
367+
}
368+
369+
async importX509 (name: string, pem: string): Promise<void> {
370+
try {
371+
if (!validateKeyName(name)) {
372+
throw new InvalidParametersError(`Invalid certificate name '${name}'`)
373+
}
374+
375+
if (pem == null) {
376+
throw new InvalidParametersError('PEM is required')
377+
}
378+
379+
if (!pem.includes('-----BEGIN CERTIFICATE-----') && !pem.includes('-----END CERTIFICATE-----')) {
380+
throw new InvalidParametersError('PEM was invalid')
381+
}
382+
383+
const datastoreName = DsName(certPrefix, name)
384+
385+
const exists = await this.components.datastore.has(datastoreName)
386+
if (exists) {
387+
throw new InvalidParametersError(`Certificate '${name}' already exists`)
388+
}
389+
390+
const cached = privates.get(this)
391+
392+
if (cached == null) {
393+
throw new InvalidParametersError('dek missing')
394+
}
395+
396+
const dek = cached.dek
397+
const dsPem = await exporter(uint8ArrayFromString(pem), dek)
398+
await this.components.datastore.put(datastoreName, uint8ArrayFromString(dsPem))
399+
} catch (err) {
400+
await randomDelay()
401+
throw err
402+
}
403+
}
404+
405+
async exportX509 (name: string): Promise<string> {
406+
try {
407+
if (!validateKeyName(name)) {
408+
throw new InvalidParametersError(`Invalid key name '${name}'`)
409+
}
410+
411+
const datastoreName = DsName(certPrefix, name)
412+
const res = await this.components.datastore.get(datastoreName)
413+
const encryptedPem = uint8ArrayToString(res)
414+
const cached = privates.get(this)
415+
416+
if (cached == null) {
417+
throw new InvalidParametersError('dek missing')
418+
}
419+
420+
const dek = cached.dek
421+
const buf = await importer(encryptedPem, dek)
422+
423+
return uint8ArrayToString(buf)
424+
} catch (err: any) {
425+
await randomDelay()
426+
throw err
427+
}
428+
}
429+
430+
async removeX509 (name: string): Promise<void> {
431+
try {
432+
if (!validateKeyName(name) || name === this.self) {
433+
throw new InvalidParametersError(`Invalid key name '${name}'`)
434+
}
435+
436+
const datastoreName = DsName(certPrefix, name)
437+
await this.components.datastore.delete(datastoreName)
438+
} catch (err) {
439+
await randomDelay()
440+
throw err
441+
}
442+
}
443+
350444
/**
351445
* Rotate keychain password and re-encrypt all associated keys
352446
*/
@@ -381,24 +475,33 @@ export class Keychain implements KeychainInterface {
381475
this.init.dek?.hash)
382476
: ''
383477
privates.set(this, { dek: newDek })
384-
const keys = await this.listKeys()
385-
for (const key of keys) {
386-
const res = await this.components.datastore.get(DsName(key.name))
478+
479+
const batch = this.components.datastore.batch()
480+
481+
for (const key of await this.listKeys()) {
482+
const res = await this.components.datastore.get(DsName(keyPrefix, key.name))
387483
const pem = uint8ArrayToString(res)
388484
const privateKey = await importPrivateKey(pem, oldDek)
389485
const password = newDek.toString()
390486
const keyAsPEM = await exportPrivateKey(privateKey, password, privateKey.type === 'RSA' ? 'pkcs-8' : 'libp2p-key')
391487

392-
// Update stored key
393-
const batch = this.components.datastore.batch()
394-
const keyInfo = {
395-
name: key.name,
396-
id: key.id
397-
}
398-
batch.put(DsName(key.name), uint8ArrayFromString(keyAsPEM))
399-
batch.put(DsInfoName(key.name), uint8ArrayFromString(JSON.stringify(keyInfo)))
400-
await batch.commit()
488+
// add to batch
489+
batch.put(DsName(keyPrefix, key.name), uint8ArrayFromString(keyAsPEM))
401490
}
491+
492+
for (const key of await this.listX509()) {
493+
// decrypt using old password and encrypt using new
494+
const res = await this.components.datastore.get(DsName(certPrefix, key.name))
495+
const pem = uint8ArrayToString(res)
496+
const decrypted = await importer(pem, oldDek)
497+
const encrypted = await exporter(decrypted, newDek)
498+
499+
// add to batch
500+
batch.put(DsName(certPrefix, key.name), uint8ArrayFromString(encrypted))
501+
}
502+
503+
await batch.commit()
504+
402505
this.log('keychain reconstructed')
403506
}
404507
}
+102
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,102 @@
1+
import { generateKeyPair } from '@libp2p/crypto/keys'
2+
import { defaultLogger } from '@libp2p/logger'
3+
import { expect } from 'aegir/chai'
4+
import { MemoryDatastore } from 'datastore-core/memory'
5+
import { Keychain as KeychainClass } from '../src/keychain.js'
6+
import { createSelfSigned } from './fixtures/create-certificate.js'
7+
import type { Keychain } from '../src/index.js'
8+
import type { Datastore } from 'interface-datastore'
9+
10+
describe('certificates', () => {
11+
const passPhrase = 'this is not a secure phrase'
12+
const logger = defaultLogger()
13+
let kc: Keychain
14+
let datastore: Datastore
15+
16+
beforeEach(async () => {
17+
datastore = new MemoryDatastore()
18+
19+
kc = new KeychainClass({
20+
datastore,
21+
logger
22+
}, { pass: passPhrase })
23+
})
24+
25+
it('can store a ECDSA certificate', async () => {
26+
const keyName = `key-${Math.random()}`
27+
const certName = `cert-${Math.random()}`
28+
29+
const key = await generateKeyPair('ECDSA')
30+
await kc.importKey(keyName, key)
31+
32+
const cert = await createSelfSigned(key)
33+
34+
const pem = cert.toString('pem')
35+
await kc.importX509(certName, pem)
36+
37+
const stored = await kc.exportX509(certName)
38+
expect(stored).to.equal(pem)
39+
})
40+
41+
it('can store a RSA certificate', async () => {
42+
const keyName = `key-${Math.random()}`
43+
const certName = `cert-${Math.random()}`
44+
45+
const key = await generateKeyPair('RSA')
46+
await kc.importKey(keyName, key)
47+
48+
const cert = await createSelfSigned(key)
49+
50+
const pem = cert.toString('pem')
51+
await kc.importX509(certName, pem)
52+
53+
const stored = await kc.exportX509(certName)
54+
expect(stored).to.equal(pem)
55+
})
56+
57+
it('can remove a certificate', async () => {
58+
const keyName = `key-${Math.random()}`
59+
const certName = `cert-${Math.random()}`
60+
61+
const key = await generateKeyPair('RSA')
62+
await kc.importKey(keyName, key)
63+
64+
const cert = await createSelfSigned(key)
65+
66+
const pem = cert.toString('pem')
67+
await kc.importX509(certName, pem)
68+
69+
await kc.removeX509(certName)
70+
71+
await expect(kc.exportX509(certName)).to.eventually.be.rejected
72+
.with.property('name', 'NotFoundError')
73+
})
74+
75+
it('can list all certificates', async () => {
76+
const keyName = `key-${Math.random()}`
77+
const certName1 = `cert-${Math.random()}-1`
78+
const certName2 = `cert-${Math.random()}-2`
79+
const certName3 = `cert-${Math.random()}-3`
80+
81+
const key = await generateKeyPair('RSA')
82+
await kc.importKey(keyName, key)
83+
84+
const cert1 = await createSelfSigned(key)
85+
const cert2 = await createSelfSigned(key)
86+
const cert3 = await createSelfSigned(key)
87+
88+
await kc.importX509(certName1, cert1.toString('pem'))
89+
await kc.importX509(certName2, cert2.toString('pem'))
90+
await kc.importX509(certName3, cert3.toString('pem'))
91+
92+
const certs = await kc.listX509()
93+
94+
expect(certs).to.deep.equal([{
95+
name: certName1
96+
}, {
97+
name: certName2
98+
}, {
99+
name: certName3
100+
}])
101+
})
102+
})
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,25 @@
1+
import { privateKeyToCryptoKeyPair } from '@libp2p/crypto/keys'
2+
import * as x509 from '@peculiar/x509'
3+
import type { PrivateKey } from '@libp2p/interface'
4+
5+
export async function createSelfSigned (privateKey: PrivateKey): Promise<x509.X509Certificate> {
6+
const keys = await privateKeyToCryptoKeyPair(privateKey)
7+
8+
return x509.X509CertificateGenerator.createSelfSigned({
9+
serialNumber: '01',
10+
name: 'CN=Test',
11+
notBefore: new Date('2020/01/01'),
12+
notAfter: new Date('2020/01/02'),
13+
signingAlgorithm: {
14+
name: 'RSASSA-PKCS1-v1_5',
15+
hash: 'SHA-256'
16+
},
17+
keys,
18+
extensions: [
19+
new x509.BasicConstraintsExtension(true, 2, true),
20+
new x509.ExtendedKeyUsageExtension(['1.2.3.4.5.6.7', '2.3.4.5.6.7.8'], true),
21+
new x509.KeyUsagesExtension(x509.KeyUsageFlags.keyCertSign | x509.KeyUsageFlags.cRLSign, true),
22+
await x509.SubjectKeyIdentifierExtension.create(keys.publicKey)
23+
]
24+
})
25+
}

0 commit comments

Comments
 (0)